2 Commits
Author SHA1 Message Date
RichardandClaude Opus 5.5 184a507e3d Merge feat/726-automation-proposals: Automatisierungsvorschlaege (#726)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 19:06:30 +02:00
RichardandClaude Opus 5.5 067ff62317 Automatisierungsvorschlaege: Agenten schlagen n8n-Workflows vor, der Mensch importiert von Hand (#726)
- Core/Automation/WorkflowReview: free check of an n8n export - every node
  with its effect and target, code/command/SSH nodes only with a stated
  reason, embedded secrets refused (by name or by look), a short summary of
  what the workflow does.
- AutomationProposals: measured saving from the replaced tasks' receipts
  (28 days), at most 3 open per agent, the same purpose not twice, a
  rejected proposal must be answered; the human gets a task and decides in
  the approvals view. Accepting records it - importing stays a human's step.
- Candidates: recurring tasks whose runs repeat the same calls (>= 75 % of
  >= 8 runs in 14 days) are put to their agent once a month.
- Escalation tool: automation_find, automation_propose.
- Approvals view: proposals with nodes, targets, warnings, saving, JSON
  download, accept/reject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:58:36 +02:00
13 changed files with 992 additions and 4 deletions
+11
View File
@@ -144,6 +144,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>#197: The external MCP servers, each registered as a tool.</summary>
public Core.Mcp.McpConnections? McpServers { get; private set; }
/// <summary>#726: Workflows agents propose; a human decides and imports by hand.</summary>
public Core.Automation.AutomationProposals? Automations { get; private set; }
/// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary>
public ModelPricingCatalog? Pricing { get; private init; }
@@ -745,6 +748,14 @@ public sealed class AppHost : IAsyncDisposable
mcpServers.Start();
host._shutdown.Add(mcpServers.DisposeAsync);
// #726: agents propose workflows; recurring tasks that do the same each time are put to them.
var automations = new Core.Automation.AutomationProposals(storage, audit, taskRepository, board,
loggerFactory.CreateLogger("ClawdDotNet.Core.Automation"));
engine.Automations = automations;
host.Automations = automations;
automations.Start();
host._shutdown.Add(automations.DisposeAsync);
// #725: what comes in from outside through a trigger becomes a task.
host.Triggers = new Core.Triggers.InboundTriggers(storage, board, audit, () => instance.Agents);
@@ -0,0 +1,393 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Automation;
/// <summary>What the replaced tasks cost over the measured days — taken from the receipts, not estimated.</summary>
public sealed record MeasuredSavings(int Days, int Runs, decimal CostUsd, long Tokens)
{
public decimal CostPerMonthUsd => Days == 0 ? 0 : Math.Round(CostUsd * 30m / Days, 2);
public int RunsPerMonth => Days == 0 ? 0 : (int)Math.Round(Runs * 30.0 / Days);
}
/// <summary>#726: An agent's proposal, as the human decides it.</summary>
public sealed record AutomationProposal(
long Id, string AgentId, string Purpose, string Trigger, IReadOnlyList<string> TaskIds, IReadOnlyList<string> Services,
string Risks, string TestGuide, string WorkflowJson, string? Justification, string Summary,
IReadOnlyList<WorkflowNode> Nodes, IReadOnlyList<string> Warnings, MeasuredSavings Savings,
string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, string? TaskId, long? Answers);
/// <summary>What an agent hands in.</summary>
public sealed record AutomationRequest(
string AgentId, string Purpose, string Trigger, IReadOnlyList<string> TaskIds, IReadOnlyList<string> Services,
string Risks, string TestGuide, string WorkflowJson, string? Justification, long? Answers, string? Argument);
public sealed record AutomationOutcome(bool Ok, string Message, long? Id = null);
public interface IAutomationProposals
{
Task<IReadOnlyList<AutomationProposal>> FindAsync(string subject, CancellationToken ct);
Task<AutomationOutcome> ProposeAsync(AutomationRequest request, CancellationToken ct);
}
/// <summary>
/// #726: Agents propose n8n workflows with reasons; a human decides and imports by hand.
/// Agents never create workflows themselves — a workflow is a program that runs outside every
/// check here, with all of n8n's credentials. Importing by hand is the point: it makes the
/// human look at it instead of clicking "looks fine".
///
/// <para><b>Measured, not guessed.</b> The saving is what the replaced tasks' runs cost over the
/// last <see cref="MeasureDays"/> days. <b>No flood:</b> at most <see cref="MaxOpenPerAgent"/>
/// open proposals per agent, and a proposal for the same purpose as an open one is refused. A
/// rejected proposal keeps its reason — a new one for the same purpose must answer it.</para>
///
/// <para><b>Looked for, not only waited for.</b> <see cref="FindCandidatesAsync"/> spots recurring
/// tasks whose runs keep doing the same thing and puts them to their agent.</para>
/// </summary>
public sealed class AutomationProposals(
SqliteStorage storage,
IAuditRepository audit,
ITaskRepository tasks,
TaskboardService? board,
ILogger logger,
TimeProvider? clock = null) : IAutomationProposals, IAsyncDisposable
{
public const int MaxOpenPerAgent = 3;
public const int MeasureDays = 28;
/// <summary>Runs in <see cref="CandidateDays"/> days from which a recurring task is looked at.</summary>
public const int CandidateMinRuns = 8;
public const int CandidateDays = 14;
/// <summary>Share of runs with the same sequence of calls that makes a task a candidate.</summary>
public const double CandidateSameShare = 0.75;
private static readonly JsonSerializerOptions Json = new();
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
public async Task<IReadOnlyList<AutomationProposal>> FindAsync(string subject, CancellationToken ct)
=> (await ListAsync(null, 500, ct))
.Where(p => TaskSimilarity.Compare(p.Purpose, subject) != TaskTitleMatch.None
|| p.Purpose.Contains(subject.Trim(), StringComparison.OrdinalIgnoreCase))
.Take(10)
.ToList();
public async Task<AutomationOutcome> ProposeAsync(AutomationRequest r, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(r.Purpose) || string.IsNullOrWhiteSpace(r.Trigger))
return new(false, "'purpose' and 'trigger' are required — what it is for, and what starts it.");
if (r.TaskIds.Count == 0)
return new(false, "'tasks' is required — the tasks it would replace. The saving is measured from their runs.");
if (string.IsNullOrWhiteSpace(r.Risks))
return new(false, "'risks' is required — what can go wrong, and what the workflow sends outside.");
if (string.IsNullOrWhiteSpace(r.TestGuide))
return new(false, "'test_guide' is required — how the human checks it after importing.");
foreach (var id in r.TaskIds)
if (await tasks.GetAsync(id, ct) is null)
return new(false, $"There is no task '{id}'.");
var all = await ListAsync(null, 1000, ct);
var mine = all.Where(p => p.Status == "proposed" && string.Equals(p.AgentId, r.AgentId, StringComparison.OrdinalIgnoreCase)).ToList();
if (mine.Count >= MaxOpenPerAgent)
return new(false, $"You have {mine.Count} proposals waiting for a human ({string.Join(", ", mine.Select(m => "#" + m.Id))}). Wait for a decision first.");
if (all.FirstOrDefault(p => p.Status is "proposed" or "accepted" && Same(p, r)) is { } open)
return new(false, $"Proposal #{open.Id} ({open.Status}) is about the same: \"{open.Purpose}\". Do not propose it again.");
if (all.FirstOrDefault(p => p.Status == "rejected" && Same(p, r)) is { } rejected
&& (r.Answers != rejected.Id || string.IsNullOrWhiteSpace(r.Argument)))
return new(false, $"Proposal #{rejected.Id} for the same purpose was rejected: \"{rejected.Reason}\". " +
$"A new one must answer that: give 'answers' {rejected.Id} and an 'argument' that is not already there.");
var review = WorkflowReview.Review(r.WorkflowJson, r.Justification);
if (!review.Ok)
return new(false, "The workflow does not pass the check:\n- " + string.Join("\n- ", review.Problems));
var savings = await MeasureAsync(r.TaskIds, ct);
var id2 = await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO AutomationProposals (AgentId, Purpose, Trigger, TaskIds, Services, Risks, TestGuide, WorkflowJson,
Justification, Summary, Nodes, Warnings, Savings, Status, CreatedAt, Answers, Argument)
VALUES (@agent, @purpose, @trigger, @tasks, @services, @risks, @guide, @workflow,
@justification, @summary, @nodes, @warnings, @savings, 'proposed', @at, @answers, @argument)
RETURNING Id
""";
cmd.Parameters.AddWithValue("@agent", r.AgentId);
cmd.Parameters.AddWithValue("@purpose", r.Purpose.Trim());
cmd.Parameters.AddWithValue("@trigger", r.Trigger.Trim());
cmd.Parameters.AddWithValue("@tasks", JsonSerializer.Serialize(r.TaskIds));
cmd.Parameters.AddWithValue("@services", JsonSerializer.Serialize(r.Services));
cmd.Parameters.AddWithValue("@risks", r.Risks.Trim());
cmd.Parameters.AddWithValue("@guide", r.TestGuide.Trim());
cmd.Parameters.AddWithValue("@workflow", r.WorkflowJson);
cmd.Parameters.AddWithValue("@justification", (object?)r.Justification?.Trim() ?? DBNull.Value);
cmd.Parameters.AddWithValue("@summary", review.Summary);
cmd.Parameters.AddWithValue("@nodes", JsonSerializer.Serialize(review.Nodes, Json));
cmd.Parameters.AddWithValue("@warnings", JsonSerializer.Serialize(review.Warnings, Json));
cmd.Parameters.AddWithValue("@savings", JsonSerializer.Serialize(savings, Json));
cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
cmd.Parameters.AddWithValue("@answers", (object?)r.Answers ?? DBNull.Value);
cmd.Parameters.AddWithValue("@argument", (object?)r.Argument?.Trim() ?? DBNull.Value);
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
}, ct);
if (board is not null)
{
var task = await board.CreateAsync(new TaskItem
{
Title = $"Automation proposal #{id2}: {r.Purpose.Trim()}",
Body = $"{r.AgentId} proposes an n8n workflow. Decide it in the approvals view; if you accept, import the JSON " +
$"into n8n by hand, check it with the test guide, and release it as an MCP tool.\n\n" +
$"Measured over {savings.Days} days: {savings.Runs} run(s), {savings.CostUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD " +
$"(about {savings.CostPerMonthUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD a month).\n" +
$"What it does: {review.Summary}",
Assignee = TaskAssignee.Human,
Priority = 2
}, ct);
await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE AutomationProposals SET TaskId = @task WHERE Id = @id";
cmd.Parameters.AddWithValue("@task", task.Id);
cmd.Parameters.AddWithValue("@id", id2);
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
}
logger.LogInformation("Automation proposal #{Id} by {Agent}: {Purpose}", id2, r.AgentId, r.Purpose);
return new(true, $"Proposal #{id2} is with a human. Measured: {savings.RunsPerMonth} run(s) and about " +
$"{savings.CostPerMonthUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD a month. " +
(review.Warnings.Count > 0 ? "Noted for the human: " + string.Join(" ", review.Warnings) : ""), id2);
}
/// <summary>The human's decision. Accepting records it; importing stays with the human, in n8n.</summary>
public async Task<AutomationOutcome> DecideAsync(long id, bool accept, string reason, CancellationToken ct)
{
if ((await ListAsync("Id = @p", 1, ct, id.ToString(CultureInfo.InvariantCulture))).FirstOrDefault() is not { } proposal)
return new(false, $"There is no proposal #{id}.");
if (proposal.Status != "proposed")
return new(false, $"Proposal #{id} is decided already ({proposal.Status}).");
if (!accept && string.IsNullOrWhiteSpace(reason))
return new(false, "A rejection needs its reason — it stays with the proposal, so the same one does not come back.");
await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE AutomationProposals SET Status = @s, Reason = @r, DecidedAt = @at WHERE Id = @id AND Status = 'proposed'";
cmd.Parameters.AddWithValue("@s", accept ? "accepted" : "rejected");
cmd.Parameters.AddWithValue("@r", (reason ?? "").Trim());
cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
if (proposal.TaskId is { } taskId && board is not null
&& await board.GetAsync(taskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled) })
{
await board.AddCommentAsync(taskId, "human", accept ? "Accepted." + (string.IsNullOrWhiteSpace(reason) ? "" : " " + reason.Trim()) : "Rejected: " + reason.Trim(), ct);
await board.UpdateAsync(taskId, t => t with { Status = accept ? TaskItemStatus.Done : TaskItemStatus.Canceled }, ct);
}
return new(true, accept
? $"Proposal #{id} accepted. Import the workflow into n8n by hand, test it, release it as an MCP tool — then switch the replaced tasks off."
: $"Proposal #{id} rejected; the reason stays with it.");
}
public Task<IReadOnlyList<AutomationProposal>> ListAsync(string? status, int limit, CancellationToken ct)
=> ListAsync(status is null ? null : "Status = @p", limit, ct, status);
// ─── measuring ───
public async Task<MeasuredSavings> MeasureAsync(IReadOnlyList<string> taskIds, CancellationToken ct)
{
var since = Now.AddDays(-MeasureDays);
var runs = 0;
decimal cost = 0;
long tokens = 0;
foreach (var id in taskIds.Distinct(StringComparer.OrdinalIgnoreCase))
foreach (var receipt in await audit.ListReceiptsForTaskAsync(id, ct))
if (receipt.OccurredAt >= since)
{
runs++;
cost += receipt.CostIsKnown ? receipt.CostUsd : 0;
tokens += receipt.PromptTokens + receipt.CompletionTokens;
}
return new MeasuredSavings(MeasureDays, runs, cost, tokens);
}
// ─── candidates ───
/// <summary>
/// Recurring tasks whose runs keep doing the same thing — the same sequence of calls in most
/// of them — are put to their agent once a month: maybe a workflow does it without a model.
/// Returns the tasks noted in this pass.
/// </summary>
public async Task<IReadOnlyList<string>> FindCandidatesAsync(CancellationToken ct)
{
if (board is null)
return [];
var since = Now.AddDays(-CandidateDays);
var proposals = await ListAsync(null, 1000, ct);
var noted = new List<string>();
var perAgent = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var task in await tasks.ListAsync(new TaskQuery(), ct))
{
if (task.When is not { Kind: TaskWhenKind.Every or TaskWhenKind.Cron } || task.Status is TaskItemStatus.Canceled or TaskItemStatus.Archived)
continue;
if (TaskAssignee.AgentId(task.Assignee) is not { Length: > 0 } agent)
continue;
if (proposals.Any(p => p.Status is "proposed" or "accepted" && p.TaskIds.Contains(task.Id, StringComparer.OrdinalIgnoreCase)))
continue;
if (await NotedSinceAsync(task.Id, Now.AddDays(-30), ct) || perAgent.GetValueOrDefault(agent) >= 2)
continue;
var receipts = (await audit.ListReceiptsForTaskAsync(task.Id, ct)).Where(r => r.OccurredAt >= since).ToList();
if (receipts.Count < CandidateMinRuns)
continue;
var signatures = new List<string>();
foreach (var receipt in receipts)
signatures.Add(Signature(await audit.ListForRunAsync(receipt.RunId, ct)));
var common = signatures.GroupBy(s => s).OrderByDescending(g => g.Count()).First();
if (common.Key.Length == 0 || (double)common.Count() / signatures.Count < CandidateSameShare)
continue;
var cost = receipts.Where(r => r.CostIsKnown).Sum(r => r.CostUsd);
await board.CreateAsync(new TaskItem
{
Title = $"Automation candidate: {task.Title}",
Body = $"Task {task.Id} ran {receipts.Count} times in {CandidateDays} days for {cost.ToString("0.00", CultureInfo.InvariantCulture)} USD, " +
$"and {common.Count()} of the runs made the same calls: {common.Key}.\n\n" +
"Consider whether an n8n workflow could do it without a model. If yes: Escalation automation_find (is there a proposal already?), " +
"then automation_propose with the workflow JSON, the tasks it replaces, the risks and a test guide. " +
"If no: close this task and say why — judgement, changing inputs, or decisions a workflow cannot make.",
Assignee = "@" + agent,
Priority = 2
}, ct);
await NoteAsync(task.Id, ct);
perAgent[agent] = perAgent.GetValueOrDefault(agent) + 1;
noted.Add(task.Id);
}
if (noted.Count > 0)
logger.LogInformation("Automation candidates put to their agents: {Tasks}", string.Join(", ", noted));
return noted;
}
/// <summary>The calls of a run in order, repeats folded — "FileRW.read > DirectAPI.get > FileRW.write".</summary>
internal static string Signature(IReadOnlyList<AuditEntry> entries)
{
var steps = new List<string>();
foreach (var e in entries.Where(e => e.Status != AuditStatus.Started && e.Tool is not ("Taskboard" or "Escalation")))
{
var step = Inspection.RunInspector.OperationOf(e.Arguments) is { } op ? $"{e.Tool}.{op}" : e.Tool;
if (steps.Count == 0 || steps[^1] != step)
steps.Add(step);
}
return string.Join(" > ", steps);
}
private static bool Same(AutomationProposal existing, AutomationRequest request)
=> TaskSimilarity.Compare(existing.Purpose, request.Purpose) == TaskTitleMatch.Duplicate
|| (existing.TaskIds.Count > 0 && existing.TaskIds.OrderBy(t => t).SequenceEqual(request.TaskIds.OrderBy(t => t), StringComparer.OrdinalIgnoreCase));
// ─── storage ───
private async Task<bool> NotedSinceAsync(string taskId, DateTime since, CancellationToken ct)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM AutomationCandidates WHERE TaskId = @t AND NotedAt >= @since";
cmd.Parameters.AddWithValue("@t", taskId);
cmd.Parameters.AddWithValue("@since", since.ToString("O"));
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture) > 0;
}
private Task NoteAsync(string taskId, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO AutomationCandidates (TaskId, NotedAt) VALUES (@t, @at)";
cmd.Parameters.AddWithValue("@t", taskId);
cmd.Parameters.AddWithValue("@at", Now.ToString("O"));
return await cmd.ExecuteNonQueryAsync(ct);
}, ct);
private async Task<IReadOnlyList<AutomationProposal>> ListAsync(string? where, int limit, CancellationToken ct, string? value = null)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT Id, AgentId, Purpose, Trigger, TaskIds, Services, Risks, TestGuide, WorkflowJson, Justification, Summary,
Nodes, Warnings, Savings, Status, Reason, CreatedAt, DecidedAt, TaskId, Answers
FROM AutomationProposals
""" + (where is null ? "" : " WHERE " + where) + " ORDER BY Id DESC LIMIT @limit";
if (value is not null)
cmd.Parameters.AddWithValue("@p", value);
cmd.Parameters.AddWithValue("@limit", limit);
var result = new List<AutomationProposal>();
await using var r = await cmd.ExecuteReaderAsync(ct);
while (await r.ReadAsync(ct))
result.Add(new AutomationProposal(
r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3),
JsonSerializer.Deserialize<List<string>>(r.GetString(4)) ?? [],
JsonSerializer.Deserialize<List<string>>(r.GetString(5)) ?? [],
r.GetString(6), r.GetString(7), r.GetString(8), Text(r, 9), r.GetString(10),
JsonSerializer.Deserialize<List<WorkflowNode>>(r.GetString(11), Json) ?? [],
JsonSerializer.Deserialize<List<string>>(r.GetString(12)) ?? [],
JsonSerializer.Deserialize<MeasuredSavings>(r.GetString(13), Json) ?? new MeasuredSavings(0, 0, 0, 0),
r.GetString(14), Text(r, 15), Time(r, 16)!.Value, Time(r, 17), Text(r, 18), r.IsDBNull(19) ? null : r.GetInt64(19)));
return result;
}
private static string? Text(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetString(i);
private static DateTime? Time(SqliteDataReader r, int i)
=> r.IsDBNull(i) ? null : DateTime.Parse(r.GetString(i), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
// ─── loop ───
public void Start() => _loop ??= RunAsync(_cts.Token);
private async Task RunAsync(CancellationToken ct)
{
try { await Task.Delay(TimeSpan.FromMinutes(10), ct); }
catch (OperationCanceledException) { return; }
while (!ct.IsCancellationRequested)
{
try { await FindCandidatesAsync(ct); }
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Looking for automation candidates failed");
}
try { await Task.Delay(TimeSpan.FromHours(24), ct); }
catch (OperationCanceledException) { return; }
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_loop is not null)
{
try { await _loop; }
catch (OperationCanceledException) { }
}
_cts.Dispose();
}
}
@@ -0,0 +1,181 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using ClawdDotNet.Core.Organisation;
namespace ClawdDotNet.Core.Automation;
/// <summary>One node of a workflow as the review reads it: what it does, and where to.</summary>
public sealed record WorkflowNode(string Name, string Type, string Effect, string? Target, bool Dangerous);
public sealed record WorkflowReviewResult(
bool Ok,
IReadOnlyList<string> Problems,
IReadOnlyList<string> Warnings,
IReadOnlyList<WorkflowNode> Nodes,
IReadOnlyList<string> Targets,
string Summary);
/// <summary>
/// #726: The free check of a proposed n8n workflow before a human sees it — not whether it is
/// valid, but what it DOES: every node with its effect, every address outside, everything that
/// runs code. A workflow is a program that runs outside all our checks, with all of n8n's
/// credentials; the human must see that before importing it by hand.
///
/// <para>Refused: no JSON, no nodes, an embedded secret (credentials are references in n8n,
/// never values), and nodes that run code or commands without a stated reason.</para>
/// </summary>
public static partial class WorkflowReview
{
public const int MaxBytes = 200 * 1024;
public const int MaxNodes = 200;
[GeneratedRegex(@"\b(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|xox[bap]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b")]
private static partial Regex KeyLike();
public static WorkflowReviewResult Review(string json, string? justification)
{
var problems = new List<string>();
var warnings = new List<string>();
var nodes = new List<WorkflowNode>();
if (string.IsNullOrWhiteSpace(json))
return Fail("No workflow was given.");
if (System.Text.Encoding.UTF8.GetByteCount(json) > MaxBytes)
return Fail($"The workflow is larger than {MaxBytes / 1024} KB.");
JsonDocument doc;
try { doc = JsonDocument.Parse(json); }
catch (JsonException ex) { return Fail($"The workflow is no JSON: {ex.Message}"); }
using (doc)
{
if (doc.RootElement.ValueKind != JsonValueKind.Object
|| !doc.RootElement.TryGetProperty("nodes", out var list) || list.ValueKind != JsonValueKind.Array || list.GetArrayLength() == 0)
return Fail("The workflow has no nodes — n8n exports it as { \"nodes\": [...], \"connections\": {...} }.");
if (list.GetArrayLength() > MaxNodes)
return Fail($"The workflow has more than {MaxNodes} nodes.");
foreach (var node in list.EnumerateArray())
{
var name = Text(node, "name") ?? "(unnamed)";
var type = Text(node, "type") ?? "(no type)";
var parameters = node.TryGetProperty("parameters", out var p) && p.ValueKind == JsonValueKind.Object ? p : default;
var (effect, target, dangerous) = Effect(type, parameters);
nodes.Add(new WorkflowNode(name, type, effect, target, dangerous));
if (parameters.ValueKind == JsonValueKind.Object)
foreach (var path in Secrets(parameters, ""))
problems.Add($"Node '{name}' carries a secret in {path} — credentials belong into n8n and are only referenced.");
}
}
var risky = nodes.Where(n => n.Dangerous).ToList();
if (risky.Count > 0)
{
var list = string.Join(", ", risky.Select(n => $"'{n.Name}' ({n.Effect})"));
if (string.IsNullOrWhiteSpace(justification))
problems.Add($"{list}: these run code or commands outside every check. Without a reason ('justification') they are refused.");
else
warnings.Add($"{list} — the proposer's reason: {justification.Trim()}");
}
if (nodes.All(n => n.Effect != "starts the workflow"))
warnings.Add("No trigger node — the workflow would only run by hand.");
var targets = nodes.Select(n => n.Target).OfType<string>().Distinct(StringComparer.OrdinalIgnoreCase).ToList();
return new WorkflowReviewResult(problems.Count == 0, problems, warnings, nodes, targets, Summarize(nodes, targets));
WorkflowReviewResult Fail(string problem) => new(false, [problem], [], [], [], "");
}
/// <summary>What a node does, from its type — the last part of e.g. <c>n8n-nodes-base.httpRequest</c>.</summary>
internal static (string Effect, string? Target, bool Dangerous) Effect(string type, JsonElement parameters)
{
var kind = type.Contains('.') ? type[(type.LastIndexOf('.') + 1)..] : type;
var k = kind.ToLowerInvariant();
if (k.EndsWith("trigger", StringComparison.Ordinal) || k is "webhook" or "cron" or "interval" or "start" or "manualtrigger")
return ("starts the workflow", k == "webhook" ? "(webhook in n8n)" : null, false);
if (k is "code" or "function" or "functionitem" or "pythonfunction" or "aitransform")
return ("runs code", null, true);
if (k == "executecommand")
return ("runs a command on the n8n host", null, true);
if (k == "ssh")
return ("runs commands on another machine (SSH)", Host(Param(parameters, "host")), true);
if (k == "httprequest")
return ("calls an address", Host(Param(parameters, "url")), false);
if (k is "emailsend" or "gmail" or "microsoftoutlook" or "sendgrid" or "mailjet" or "mailgun")
return ("sends mail", Param(parameters, "toEmail") ?? Param(parameters, "sendTo") ?? "(recipients in the node)", false);
if (k is "readwritefile" or "writebinaryfile" or "readbinaryfile" or "readbinaryfiles")
return ("reads or writes files on the n8n host", null, false);
if (k is "postgres" or "mysql" or "microsoftsql" or "mongodb" or "redis")
return ("talks to a database", null, false);
if (k is "set" or "if" or "switch" or "merge" or "splitinbatches" or "noop" or "itemlists" or "filter" or "sort" or "limit" or "aggregate" or "datetime" or "stickynote")
return ("shapes the data", null, false);
return ($"uses {kind}", null, false);
}
private static string? Param(JsonElement parameters, string name)
=> parameters.ValueKind == JsonValueKind.Object && parameters.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
/// <summary>The host of an address; an n8n expression is computed at run time and named as such.</summary>
private static string? Host(string? address)
{
if (string.IsNullOrWhiteSpace(address))
return null;
if (address.TrimStart().StartsWith('=') || address.Contains("{{"))
return "(address computed at run time)";
return Uri.TryCreate(address.Trim(), UriKind.Absolute, out var uri) ? uri.Host : address.Trim();
}
/// <summary>Paths of values that are secrets: by name, or by look (a key or a bearer token).</summary>
private static IEnumerable<string> Secrets(JsonElement element, string path)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var property in element.EnumerateObject())
{
var here = path.Length == 0 ? property.Name : $"{path}.{property.Name}";
if (property.Value.ValueKind == JsonValueKind.String)
{
var value = property.Value.GetString() ?? "";
var expression = value.TrimStart().StartsWith('=');
if (!expression && value.Length > 0
&& (OrgTemplates.IsSecretName(property.Name) || value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) || KeyLike().IsMatch(value)))
yield return here;
}
else
foreach (var inner in Secrets(property.Value, here))
yield return inner;
}
break;
case JsonValueKind.Array:
var i = 0;
foreach (var item in element.EnumerateArray())
foreach (var inner in Secrets(item, $"{path}[{i++}]"))
yield return inner;
break;
}
}
private static string Summarize(IReadOnlyList<WorkflowNode> nodes, IReadOnlyList<string> targets)
{
var parts = new List<string> { $"{nodes.Count} node(s)." };
var starts = nodes.Where(n => n.Effect == "starts the workflow").Select(n => n.Name).ToList();
if (starts.Count > 0)
parts.Add($"Starts with: {string.Join(", ", starts)}.");
if (targets.Count > 0)
parts.Add($"Reaches outside: {string.Join(", ", targets)}.");
var mail = nodes.Where(n => n.Effect == "sends mail").ToList();
if (mail.Count > 0)
parts.Add($"Sends mail ({string.Join(", ", mail.Select(m => m.Name))}).");
var code = nodes.Where(n => n.Dangerous).ToList();
if (code.Count > 0)
parts.Add($"Runs code or commands: {string.Join(", ", code.Select(c => c.Name))}.");
return string.Join(" ", parts);
}
private static string? Text(JsonElement node, string name)
=> node.ValueKind == JsonValueKind.Object && node.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}
@@ -66,6 +66,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// <summary>#702: Blind votes on staged calls; set by the host.</summary>
public Staging.IVotes? Votes { get; set; }
/// <summary>#726: Automation proposals; set by the host.</summary>
public Automation.IAutomationProposals? Automations { get; set; }
/// <summary>
/// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the
/// daily limits; without this, every run counts against the day.
@@ -1885,6 +1888,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Doctrines = Doctrines,
SpotChecks = SpotChecks,
Votes = Votes,
Automations = Automations,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
@@ -395,6 +395,36 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_InboundTriggerEvents_Trigger ON InboundTriggerEvents (TriggerId, ReceivedAt);
-- #726: workflows agents propose, the human decides; and the tasks already put to their agent.
CREATE TABLE IF NOT EXISTS AutomationProposals (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
AgentId TEXT NOT NULL,
Purpose TEXT NOT NULL,
Trigger TEXT NOT NULL,
TaskIds TEXT NOT NULL,
Services TEXT NOT NULL,
Risks TEXT NOT NULL,
TestGuide TEXT NOT NULL,
WorkflowJson TEXT NOT NULL,
Justification TEXT NULL,
Summary TEXT NOT NULL,
Nodes TEXT NOT NULL,
Warnings TEXT NOT NULL,
Savings TEXT NOT NULL,
Status TEXT NOT NULL,
Reason TEXT NULL,
CreatedAt TEXT NOT NULL,
DecidedAt TEXT NULL,
TaskId TEXT NULL,
Answers INTEGER NULL,
Argument TEXT NULL
);
CREATE TABLE IF NOT EXISTS AutomationCandidates (
TaskId TEXT NOT NULL,
NotedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS StagedCalls (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId TEXT NOT NULL,
@@ -61,6 +61,9 @@ public sealed record AgentToolContext(
/// <summary>#702: The ballots of a voter. <c>null</c> outside the engine.</summary>
public Staging.IVotes? Votes { get; init; }
/// <summary>#726: Automation proposals of agents. <c>null</c> outside the engine.</summary>
public Automation.IAutomationProposals? Automations { get; init; }
/// <summary>#654: Where code runs — never in the main process. Unavailable under Windows.</summary>
public Sandbox.ISandboxRunner? Sandbox { get; init; }
@@ -698,6 +698,20 @@
"staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
"automation.title": "Automatisierungsvorschläge",
"automation.hint": "Agenten schlagen n8n-Workflows für Routine vor, die sie immer wieder gleich erledigen. Lies, was der Workflow tut — jeden Knoten und jede Adresse, die er erreicht. Nimmst du an, importierst du das JSON von Hand in n8n, testest es und gibst es als MCP-Werkzeug frei; einen Knopf, der das für dich tut, gibt es mit Absicht nicht. Eine Ablehnung behält ihren Grund.",
"automation.none": "Kein Vorschlag wartet auf dich.",
"automation.trigger": "Auslöser",
"automation.savings": "Gemessen über {days} Tage: {runs} Lauf/Läufe, {cost} USD — etwa {runsMonth} Läufe und {month} USD im Monat.",
"automation.does": "Was er tut",
"automation.replaces": "Ersetzt Aufgaben",
"automation.services": "Braucht",
"automation.risks": "Risiken",
"automation.testGuide": "Testanleitung",
"automation.download": "JSON herunterladen",
"automation.reason": "Grund (Pflicht beim Ablehnen)",
"automation.accept": "Annehmen",
"automation.reject": "Ablehnen",
"staging.voteRunning": "Abstimmung #{id} läuft bis {until}: {yes} ja, {no} nein, {missing} ausstehend. Du kannst vorher entscheiden.",
"staging.voteDone": "Abstimmung #{id}: {outcome} — {yes} ja, {no} nein, {missing} fehlen.",
"staging.voteRule": "Angenommen ab {approve} Ja, abgelehnt bei höchstens {reject}, mindestens {quorum} Stimmen",
@@ -698,6 +698,20 @@
"staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}",
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
"automation.title": "Automation proposals",
"automation.hint": "Agents propose n8n workflows for routine they do the same way again and again. Read what the workflow does — every node and every address it reaches. If you accept, import the JSON into n8n by hand, test it, and release it as an MCP tool; there is deliberately no button that does it for you. A rejection keeps its reason.",
"automation.none": "No proposal waits for you.",
"automation.trigger": "trigger",
"automation.savings": "Measured over {days} days: {runs} run(s), {cost} USD — about {runsMonth} runs and {month} USD a month.",
"automation.does": "What it does",
"automation.replaces": "Replaces tasks",
"automation.services": "Needs",
"automation.risks": "Risks",
"automation.testGuide": "Test guide",
"automation.download": "Download JSON",
"automation.reason": "Reason (required to reject)",
"automation.accept": "Accept",
"automation.reject": "Reject",
"staging.voteRunning": "Vote #{id} runs until {until}: {yes} yes, {no} no, {missing} outstanding. You can decide it first.",
"staging.voteDone": "Vote #{id}: {outcome} — {yes} yes, {no} no, {missing} missing.",
"staging.voteRule": "Approved from {approve} yes, rejected at {reject} or fewer, at least {quorum} votes",
@@ -77,7 +77,8 @@ export async function rendereStaging() {
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich);
// #726: workflows agents propose — decided here, imported by hand.
layout.append(kopfZeile, listeBereich, erstelleAutomationsKarte(), dauerBereich, leitungBereich);
container.append(layout);
await ladeFreigaben();
@@ -420,3 +421,76 @@ function oeffneAblehnenDialog(id) {
modal.oeffnen();
}
/**
* #726: Workflows agents propose. The human reads what the workflow does (the review lists every
* node and every address outside), decides, and on acceptance imports the JSON into n8n by hand —
* deliberately no "apply" button: a workflow runs outside every check here.
*/
export function erstelleAutomationsKarte() {
const box = erstelleElement("div", { klasse: "automationen" });
async function lade() {
let liste = [];
try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; }
if (!liste.length) {
box.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.none") }));
return;
}
box.replaceChildren(...liste.map(vorschlag));
}
function vorschlag(p) {
const grund = erstelleElement("input", { type: "text", placeholder: t("automation.reason") });
const entscheiden = async (annehmen) => {
try {
const r = await api(`/staging/automations/${p.id}/decide`, { method: "POST", body: { accept: annehmen, reason: grund.value } });
zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 8000);
await lade();
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
}
};
const herunterladen = async () => {
try {
const json = await api(`/staging/automations/${p.id}/workflow`);
const blob = new Blob([typeof json === "string" ? json : JSON.stringify(json, null, 2)], { type: "application/json" });
const link = erstelleElement("a", { href: URL.createObjectURL(blob) });
link.download = `automation-${p.id}.n8n.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
}
};
return erstelleElement("div", { klasse: "automation" },
erstelleElement("strong", { text: `#${p.id} ${p.purpose}` }),
erstelleElement("div", { klasse: "gedaempft text-klein", text: `${p.agentId} · ${formatiereDatumZeit(p.createdAt)} · ${t("automation.trigger")}: ${p.trigger}` }),
erstelleElement("p", { klasse: "text-klein",
text: t("automation.savings", { days: p.days, runs: p.runs, cost: p.costUsd.toFixed(2), month: p.costPerMonthUsd.toFixed(2), runsMonth: p.runsPerMonth }) }),
erstelleElement("p", { klasse: "text-klein", text: `${t("automation.does")}: ${p.summary}` }),
erstelleElement("table", { klasse: "daten-tabelle" },
erstelleElement("tbody", {}, ...p.nodes.map((n) => erstelleElement("tr", { klasse: n.dangerous ? "org-ausreisser" : "" },
erstelleElement("td", { text: n.name }),
erstelleElement("td", { text: n.effect }),
erstelleElement("td", { klasse: "gedaempft", text: n.target ?? "" }))))),
...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })),
erstelleElement("dl", { klasse: "datenliste" },
erstelleElement("dt", { text: t("automation.replaces") }), erstelleElement("dd", { text: p.taskIds.join(", ") }),
erstelleElement("dt", { text: t("automation.services") }), erstelleElement("dd", { text: p.services.join(", ") || "—" }),
erstelleElement("dt", { text: t("automation.risks") }), erstelleElement("dd", { text: p.risks }),
erstelleElement("dt", { text: t("automation.testGuide") }), erstelleElement("dd", { text: p.testGuide })),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.download"), onclick: herunterladen }),
grund,
erstelleElement("button", { type: "button", klasse: "primaer klein", text: t("automation.accept"), onclick: () => entscheiden(true) }),
erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.reject"), onclick: () => entscheiden(false) })));
}
lade();
return erstelleKarte(t("automation.title"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.hint") }),
box
]);
}
@@ -41,6 +41,7 @@ public sealed class EscalationTool : IAgentTool
spot_checks lists the runs picked for you, with what the audit log shows; inspect_run (run) shows one run in full;
check_done (check, verdict ok|finding, note) closes a check. propose_measure (agent, measure, reasoning, check) puts a measure to a human:
sharpen_task, change_model, adjust_rights, suspend — or security, if you suspect the agent is steered from outside.
Routine you do the same way again and again may not need a model: automation_find (is there a proposal for it, and why was one rejected?), then automation_propose (purpose, trigger, tasks it replaces, services, risks, workflow as n8n JSON, test_guide; justification if it runs code). A human decides and imports it — you never create workflows yourself.
As a voter: list_ballots shows the calls you are asked to vote on; cast_vote (vote, choice yes|no, reasoning, evidence) — check against your own data, with source and time. You never see the other votes.
""";
@@ -50,7 +51,7 @@ public sealed class EscalationTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote"]
"enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote", "automation_find", "automation_propose"]
},
"amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." },
"subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." },
@@ -76,7 +77,17 @@ public sealed class EscalationTool : IAgentTool
"measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." },
"vote": { "type": "integer", "description": "cast_vote: the vote number (#…)." },
"choice": { "type": "string", "enum": ["yes", "no"], "description": "cast_vote: your vote." },
"evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." }
"evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." },
"purpose": { "type": "string", "description": "automation_propose: what the workflow is for. automation_find: what to look for." },
"trigger": { "type": "string", "description": "automation_propose: what starts it (mail, schedule, webhook …) and how often it happens." },
"tasks": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the task ids it replaces — the saving is measured from their runs." },
"services": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the services and credentials it needs — names only, never values." },
"risks": { "type": "string", "description": "automation_propose: what can go wrong, and what it sends outside." },
"workflow": { "description": "automation_propose: the n8n workflow as exported JSON ({ nodes, connections }) — object or string. Credentials only as references." },
"test_guide": { "type": "string", "description": "automation_propose: how the human checks it after importing." },
"justification": { "type": "string", "description": "automation_propose: why a Code, Execute Command or SSH node is needed. Without it such nodes are refused." },
"answers": { "type": "integer", "description": "automation_propose: the rejected proposal (#) this one answers." },
"argument": { "type": "string", "description": "automation_propose: what is new against that rejection's reason." }
},
"required": ["action"]
}
@@ -108,6 +119,10 @@ public sealed class EscalationTool : IAgentTool
if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure")
return await SpotCheckAsync(input, context, ct);
// #726: Proposing a workflow instead of doing routine by model.
if (Get(input, "action") is "automation_find" or "automation_propose")
return await AutomationAsync(input, context, ct);
// #702: A voter's ballot.
if (Get(input, "action") is "list_ballots" or "cast_vote")
return await BallotAsync(input, context, ct);
@@ -184,7 +199,7 @@ public sealed class EscalationTool : IAgentTool
break;
default:
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote.");
return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote, automation_find, automation_propose.");
}
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
@@ -336,6 +351,41 @@ public sealed class EscalationTool : IAgentTool
}
}
private static async Task<ToolResult> AutomationAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Automations is not { } automations)
return ToolResult.Fail("Automation proposals are not available in this instance.");
if (Get(input, "action") == "automation_find")
{
var subject = Get(input, "purpose") ?? Get(input, "subject") ?? "";
if (string.IsNullOrWhiteSpace(subject))
return ToolResult.Fail("'purpose' is required — what to look for.");
var found = await automations.FindAsync(subject, ct);
if (found.Count == 0)
return ToolResult.Ok($"No proposal is about \"{subject.Trim()}\".");
var sb = new StringBuilder("Proposals about it:\n");
foreach (var p in found)
sb.AppendLine($"- #{p.Id} by {p.AgentId}: {p.Purpose} — {p.Status}" + (string.IsNullOrWhiteSpace(p.Reason) ? "" : $" (reason: {p.Reason})"));
return ToolResult.Ok(sb.ToString().TrimEnd());
}
var workflow = input.TryGetProperty("workflow", out var w)
? w.ValueKind == JsonValueKind.String ? w.GetString() ?? "" : w.GetRawText()
: "";
long? answers = input.TryGetProperty("answers", out var a) && a.ValueKind == JsonValueKind.Number && a.TryGetInt64(out var n) ? n : null;
var outcome = await automations.ProposeAsync(new Core.Automation.AutomationRequest(
context.AgentId, Get(input, "purpose") ?? "", Get(input, "trigger") ?? "",
List(input, "tasks"), List(input, "services"), Get(input, "risks") ?? "", Get(input, "test_guide") ?? "",
workflow, Get(input, "justification"), answers, Get(input, "argument")), ct);
return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message);
}
private static IReadOnlyList<string> List(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array
? v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!.Trim()).Where(s => s.Length > 0).ToList()
: [];
private static async Task<ToolResult> BallotAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
if (context.Votes is not { } votes)
@@ -0,0 +1,13 @@
namespace ClawdDotNet.Web.Contracts;
/// <summary>#726: A node of a proposed workflow, as the review read it.</summary>
public sealed record WorkflowNodeDto(string Name, string Type, string Effect, string? Target, bool Dangerous);
/// <summary>#726: An agent's workflow proposal with its measured saving and the review.</summary>
public sealed record AutomationProposalDto(
long Id, string AgentId, string Purpose, string Trigger, IReadOnlyList<string> TaskIds, IReadOnlyList<string> Services,
string Risks, string TestGuide, string? Justification, string Summary, IReadOnlyList<WorkflowNodeDto> Nodes,
IReadOnlyList<string> Warnings, int Runs, decimal CostUsd, decimal CostPerMonthUsd, int RunsPerMonth, int Days,
string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers);
public sealed record AutomationDecisionDto(bool Accept, string? Reason);
@@ -375,6 +375,31 @@ internal static class ApiEndpoints
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
});
// #726: Workflows agents propose — the human decides here and imports by hand in n8n.
staging.MapGet("/automations", async (AppHost host, CancellationToken ct, string? status) =>
{
if (host.Automations is not { } automations)
return Results.Ok(Array.Empty<AutomationProposalDto>());
return Results.Ok((await automations.ListAsync(status, 50, ct)).Select(p => new AutomationProposalDto(
p.Id, p.AgentId, p.Purpose, p.Trigger, p.TaskIds, p.Services, p.Risks, p.TestGuide, p.Justification, p.Summary,
p.Nodes.Select(x => new WorkflowNodeDto(x.Name, x.Type, x.Effect, x.Target, x.Dangerous)).ToList(),
p.Warnings, p.Savings.Runs, p.Savings.CostUsd, p.Savings.CostPerMonthUsd, p.Savings.RunsPerMonth, p.Savings.Days,
p.Status, p.Reason, p.CreatedAt, p.DecidedAt, p.Answers)).ToList());
});
staging.MapGet("/automations/{id:long}/workflow", async (AppHost host, long id, CancellationToken ct) =>
host.Automations is { } automations && (await automations.ListAsync(null, 1000, ct)).FirstOrDefault(p => p.Id == id) is { } proposal
? Results.Text(proposal.WorkflowJson, "application/json")
: Results.NotFound(new OutcomeDto(false, $"No proposal #{id}.")));
staging.MapPost("/automations/{id:long}/decide", async (AppHost host, long id, AutomationDecisionDto input, CancellationToken ct) =>
{
if (host.Automations is not { } automations)
return Results.BadRequest(new OutcomeDto(false, "Automation proposals are not available in this instance."));
var outcome = await automations.DecideAsync(id, input.Accept, input.Reason ?? "", ct);
return outcome.Ok ? Results.Ok(new OutcomeDto(true, outcome.Message)) : Results.BadRequest(new OutcomeDto(false, outcome.Message));
});
// #687: How often each lead approved what it was given — a lead that approves everything does nothing.
staging.MapGet("/leads", async (AppHost host, CancellationToken ct, int days = 30) =>
{
@@ -0,0 +1,176 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Automation;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Automation;
/// <summary>
/// #726: Agents propose workflows, measured and checked for free; the human decides and imports
/// by hand. No flood, and a rejected proposal does not come back without a new argument.
/// </summary>
public sealed class AutomationProposalsTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteAuditRepository _audit;
private readonly SqliteTaskRepository _tasks;
private readonly TaskboardService _board;
private readonly AutomationProposals _proposals;
private const string Workflow = """
{"nodes":[
{"name":"Every morning","type":"n8n-nodes-base.scheduleTrigger","parameters":{}},
{"name":"Get rates","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://api.rates.example/eur","headers":{"x":"={{ $env.TOKEN }}"}}},
{"name":"Mail","type":"n8n-nodes-base.emailSend","parameters":{"toEmail":"desk@example.org"},"credentials":{"smtp":{"id":"1","name":"Office SMTP"}}}
],"connections":{}}
""";
public AutomationProposalsTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_audit = new SqliteAuditRepository(storage);
_tasks = new SqliteTaskRepository(storage);
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
_proposals = new AutomationProposals(storage, _audit, _tasks, _board, NullLogger.Instance);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
// ─── the review ───
[Fact]
public void The_review_says_what_the_workflow_does_and_where_it_reaches()
{
var review = WorkflowReview.Review(Workflow, null);
review.Ok.ShouldBeTrue(string.Join(" ", review.Problems));
review.Nodes.Select(n => n.Effect).ShouldBe(["starts the workflow", "calls an address", "sends mail"]);
review.Targets.ShouldBe(["api.rates.example", "desk@example.org"]);
review.Summary.ShouldContain("Reaches outside: api.rates.example");
}
[Fact]
public void Code_needs_a_reason_and_a_secret_is_never_carried()
{
const string code = """{"nodes":[{"name":"Calc","type":"n8n-nodes-base.code","parameters":{"jsCode":"return 1"}}]}""";
WorkflowReview.Review(code, null).Problems.ShouldContain(p => p.Contains("Without a reason"));
var reasoned = WorkflowReview.Review(code, "Rounds prices the same way the shop does.");
reasoned.Ok.ShouldBeTrue();
reasoned.Warnings.ShouldContain(w => w.Contains("Rounds prices"));
WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://x.example","apiKey":"k-123"}}]}""", null)
.Problems.ShouldContain(p => p.Contains("parameters") || p.Contains("apiKey"));
WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"headerValue":"Bearer abc"}}]}""", null)
.Ok.ShouldBeFalse("a bearer token by its look");
WorkflowReview.Review("not json", null).Ok.ShouldBeFalse();
WorkflowReview.Review("""{"nodes":[]}""", null).Ok.ShouldBeFalse();
}
// ─── proposing ───
private async Task<string> RecurringTask(int runs, string agent = "desk", bool same = true)
{
var task = await _board.CreateAsync(new TaskItem
{
Title = "Morning exchange rates",
Assignee = "@" + agent,
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 7 * * *", TimeZone = "Europe/Berlin" }
}, default);
for (var i = 0; i < runs; i++)
{
var run = $"run-{task.Id}-{i}";
await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = "WebFetch", Arguments = "{\"action\":\"fetch\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default);
await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = same || i % 2 == 0 ? "Mail" : "FileRW", Arguments = "{\"action\":\"send\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default);
await _audit.RecordReceiptAsync(new RunReceipt { RunId = run, AgentId = agent, TaskId = task.Id, Status = "Completed", CostUsd = 0.05m, CostIsKnown = true, PromptTokens = 1000, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default);
}
return task.Id;
}
private AutomationRequest Request(string taskId, string purpose = "Send the morning exchange rates", long? answers = null, string? argument = null)
=> new("desk", purpose, "every morning at 7", [taskId], ["Office SMTP"], "Sends rates to the desk only.", "Run once by hand, check the mail.", Workflow, null, answers, argument);
[Fact]
public async Task A_proposal_carries_its_measured_saving_and_goes_to_a_human()
{
var taskId = await RecurringTask(10);
var outcome = await _proposals.ProposeAsync(Request(taskId), default);
outcome.Ok.ShouldBeTrue(outcome.Message);
var proposal = (await _proposals.ListAsync("proposed", 10, default)).Single();
proposal.Savings.Runs.ShouldBe(10);
proposal.Savings.CostUsd.ShouldBe(0.50m);
proposal.Summary.ShouldContain("api.rates.example");
(await _board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human }, default)).ShouldContain(t => t.Title.Contains($"#{proposal.Id}"));
}
[Fact]
public async Task The_same_purpose_is_not_proposed_twice_and_a_rejection_must_be_answered()
{
var taskId = await RecurringTask(3);
var first = await _proposals.ProposeAsync(Request(taskId), default);
(await _proposals.ProposeAsync(Request(taskId, "Send the exchange rates every morning"), default)).Message.ShouldContain($"#{first.Id}");
(await _proposals.DecideAsync(first.Id!.Value, false, "", default)).Ok.ShouldBeFalse("a rejection needs its reason");
(await _proposals.DecideAsync(first.Id!.Value, false, "Rates change format often — the agent copes, a workflow breaks.", default)).Ok.ShouldBeTrue();
(await _proposals.ProposeAsync(Request(taskId), default)).Message.ShouldContain("must answer");
(await _proposals.ProposeAsync(Request(taskId, answers: first.Id, argument: "The rates API now has a versioned, stable format."), default))
.Ok.ShouldBeTrue();
(await _proposals.FindAsync("exchange rates", default)).Count.ShouldBe(2);
}
[Fact]
public async Task An_agent_has_at_most_three_open_proposals()
{
for (var i = 0; i < 3; i++)
(await _proposals.ProposeAsync(Request(await RecurringTask(1), $"Purpose number {i} quite different {Guid.NewGuid():N}"), default)).Ok.ShouldBeTrue();
(await _proposals.ProposeAsync(Request(await RecurringTask(1), "A fourth unrelated thing entirely"), default)).Message.ShouldContain("Wait for a decision");
}
[Fact]
public async Task A_proposal_without_the_required_parts_or_a_broken_workflow_is_refused()
{
var taskId = await RecurringTask(1);
(await _proposals.ProposeAsync(Request(taskId) with { TaskIds = [] }, default)).Message.ShouldContain("'tasks'");
(await _proposals.ProposeAsync(Request("t-nope"), default)).Message.ShouldContain("no task");
(await _proposals.ProposeAsync(Request(taskId) with { WorkflowJson = """{"nodes":[{"name":"x","type":"n8n-nodes-base.executeCommand"}]}""" }, default))
.Message.ShouldContain("does not pass the check");
}
// ─── candidates ───
[Fact]
public async Task A_recurring_task_that_does_the_same_each_time_is_put_to_its_agent_once()
{
var same = await RecurringTask(9, "desk");
var varied = await RecurringTask(9, "other", same: false);
var noted = await _proposals.FindCandidatesAsync(default);
noted.ShouldBe([same], "half the runs of the other task differ");
(await _board.ListAsync(new TaskQuery { Assignee = "@desk" }, default)).ShouldContain(t => t.Title.StartsWith("Automation candidate"));
(await _proposals.FindCandidatesAsync(default)).ShouldBeEmpty("once a month, not every day");
varied.ShouldNotBe(same);
}
[Fact]
public void The_signature_folds_repeats_and_leaves_out_bookkeeping()
{
AuditEntry E(string tool, string op) => new() { Tool = tool, Arguments = $"{{\"action\":\"{op}\"}}", Status = AuditStatus.Ok };
AutomationProposals.Signature([E("FileRW", "read"), E("FileRW", "read"), E("Taskboard", "complete"), E("Mail", "send")])
.ShouldBe("FileRW.read > Mail.send");
}
}