Compare commits
2
Commits
64e09b82d5
...
184a507e3d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
184a507e3d | ||
|
|
067ff62317 |
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user