Trigger: Auftrag, Adressname, Prioritaet, Bearbeiten und neues Token (#725)

Rueckmeldung aus der Testinstanz: Nach dem Anlegen war nicht zu erkennen,
wofuer ein Trigger da ist, der Agent erfuhr nur 'ein Ereignis kam', und
bearbeiten ging nicht.

- Slug aus dem Namen in der Adresse: /hooks/<instanz>/trigger/<slug>;
  ein Token an fremder Adresse oeffnet nichts. Die alte Adresse ohne
  Slug bleibt gueltig.
- Auftrag vom Menschen steht vor den gelieferten Daten (die weiter als
  Fremdinhalt gerahmt sind); ohne Auftrag wird der Agent darauf hingewiesen.
- Prioritaet der erzeugten Aufgaben.
- PUT /api/triggers/{id} (Token bleibt), POST /api/triggers/{id}/token.
- WebUI: Formular fuer Anlegen und Bearbeiten, Adresse und Auftrag je Zeile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 10:03:06 +02:00
co-authored by Claude Opus 5.5
parent 44613c2b77
commit e2344f4d33
11 changed files with 518 additions and 93 deletions
+3 -2
View File
@@ -3,9 +3,10 @@ namespace ClawdDotNet.Web.Contracts;
/// <summary>#725: An inbound trigger — never with its token.</summary>
public sealed record TriggerDto(
string Id, string Label, string AgentId, int PerHour, DateTime CreatedAt, DateTime? RevokedAt,
DateTime? LastUsedAt, int Received, bool Active);
DateTime? LastUsedAt, int Received, bool Active, string Slug, string Instruction, int Priority);
public sealed record TriggerInputDto(string AgentId, string? Label, int? PerHour);
/// <summary>What a human sets on a trigger; <c>Slug</c> empty means "from the label" (new) or "unchanged" (edit).</summary>
public sealed record TriggerInputDto(string AgentId, string? Label, int? PerHour, string? Instruction = null, int? Priority = null, string? Slug = null);
/// <summary>The token, once — the instance keeps only its hash.</summary>
public sealed record TriggerIssuedDto(TriggerDto Info, string Token);
+79 -34
View File
@@ -63,51 +63,47 @@ internal static class McpEndpoints
// #725: An inbound trigger — n8n or any system that sends a request. Outside /api: its
// own token is the key, and it can do one thing — hand its agent a task.
app.MapPost(Security.McpTransport.TriggerPath, async (HttpContext context, AppHost host, CancellationToken ct) =>
{
if (context.Request.Headers.Origin.Count > 0)
return Results.StatusCode(StatusCodes.Status403Forbidden);
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (context.Request.ContentLength is > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
string payload;
using (var reader = new StreamReader(context.Request.Body))
{
var buffer = new char[Core.Triggers.InboundTriggers.MaxPayloadBytes + 1];
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
if (read > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
payload = new string(buffer, 0, read);
}
var result = await triggers.ReceiveAsync(BearerToken(context.Request), payload, ct);
return result.Outcome switch
{
Core.Triggers.TriggerOutcome.Accepted => Results.Json(new { ok = true, taskId = result.TaskId }, statusCode: StatusCodes.Status202Accepted),
Core.Triggers.TriggerOutcome.Unauthorized => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status401Unauthorized),
Core.Triggers.TriggerOutcome.Limited => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status429TooManyRequests),
Core.Triggers.TriggerOutcome.TooLarge => Results.StatusCode(StatusCodes.Status413PayloadTooLarge),
_ => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status503ServiceUnavailable)
};
});
app.MapPost(Security.McpTransport.TriggerPath, (HttpContext context, AppHost host, CancellationToken ct) => ReceiveTriggerAsync(context, host, null, ct));
app.MapPost(Security.McpTransport.TriggerPath + "/{name}", (HttpContext context, AppHost host, string name, CancellationToken ct) => ReceiveTriggerAsync(context, host, name, ct));
var triggerAdmin = app.MapGroup("/api/triggers");
triggerAdmin.MapGet("/", async (AppHost host, CancellationToken ct) =>
host.Triggers is { } triggers
? Results.Ok((await triggers.ListAsync(ct)).Select(t => new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, t.RevokedAtUtc, t.LastUsedAtUtc, t.Received, t.Active)).ToList())
? Results.Ok((await triggers.ListAsync(ct)).Select(TriggerDtoOf).ToList())
: Results.Ok(Array.Empty<TriggerDto>()));
triggerAdmin.MapPost("/", async (AppHost host, TriggerInputDto input, CancellationToken ct) =>
{
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
return Results.BadRequest(new OutcomeDto(false, $"There is no agent '{input.AgentId}'."));
var (t, token) = await triggers.IssueAsync(input.Label ?? "", input.AgentId, input.PerHour ?? 30, ct);
return Results.Ok(new TriggerIssuedDto(new TriggerDto(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, null, null, 0, true), token));
if (TriggerProblem(host, input) is { } problem)
return Results.BadRequest(new OutcomeDto(false, problem));
var (t, token) = await triggers.IssueAsync(SpecOf(input), ct);
return Results.Ok(new TriggerIssuedDto(TriggerDtoOf(t), token));
});
// Change what a human set — the token stays, so the sender needs nothing new unless the slug changed.
triggerAdmin.MapPut("/{id}", async (AppHost host, string id, TriggerInputDto input, CancellationToken ct) =>
{
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (TriggerProblem(host, input) is { } problem)
return Results.BadRequest(new OutcomeDto(false, problem));
return await triggers.UpdateAsync(id, SpecOf(input), ct) is { } t
? Results.Ok(TriggerDtoOf(t))
: Results.NotFound(new OutcomeDto(false, $"No active trigger '{id}'."));
});
// A new token, shown once; the old one stops working at once.
triggerAdmin.MapPost("/{id}/token", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (await triggers.RotateTokenAsync(id, ct) is not { } token
|| (await triggers.ListAsync(ct)).FirstOrDefault(t => t.Id == id) is not { } trigger)
return Results.NotFound(new OutcomeDto(false, $"No active trigger '{id}'."));
return Results.Ok(new TriggerIssuedDto(TriggerDtoOf(trigger), token));
});
triggerAdmin.MapDelete("/{id}", async (AppHost host, string id, CancellationToken ct) =>
@@ -246,6 +242,55 @@ internal static class McpEndpoints
return Results.Ok(new OutcomeDto(true, message));
}
/// <summary>#725: A delivery — the token is the key; a name in the address must be the token's own trigger.</summary>
private static async Task<IResult> ReceiveTriggerAsync(HttpContext context, AppHost host, string? name, CancellationToken ct)
{
if (context.Request.Headers.Origin.Count > 0)
return Results.StatusCode(StatusCodes.Status403Forbidden);
if (host.Triggers is not { } triggers)
return ApiEndpoints.Unavailable("No inbound triggers in this instance.");
if (context.Request.ContentLength is > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
string payload;
using (var reader = new StreamReader(context.Request.Body))
{
var buffer = new char[Core.Triggers.InboundTriggers.MaxPayloadBytes + 1];
var read = await reader.ReadBlockAsync(buffer.AsMemory(), ct);
if (read > Core.Triggers.InboundTriggers.MaxPayloadBytes)
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
payload = new string(buffer, 0, read);
}
var result = await triggers.ReceiveAsync(BearerToken(context.Request), payload, ct, name);
return result.Outcome switch
{
Core.Triggers.TriggerOutcome.Accepted => Results.Json(new { ok = true, taskId = result.TaskId }, statusCode: StatusCodes.Status202Accepted),
Core.Triggers.TriggerOutcome.Unauthorized => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status401Unauthorized),
Core.Triggers.TriggerOutcome.Limited => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status429TooManyRequests),
Core.Triggers.TriggerOutcome.TooLarge => Results.StatusCode(StatusCodes.Status413PayloadTooLarge),
_ => Results.Json(new { ok = false, error = result.Message }, statusCode: StatusCodes.Status503ServiceUnavailable)
};
}
private static TriggerDto TriggerDtoOf(Core.Triggers.InboundTrigger t)
=> new(t.Id, t.Label, t.AgentId, t.PerHour, t.CreatedAtUtc, t.RevokedAtUtc, t.LastUsedAtUtc, t.Received, t.Active,
t.AddressName, t.Instruction, t.Priority);
private static Core.Triggers.TriggerSpec SpecOf(TriggerInputDto input)
=> new(input.Label ?? "", input.AgentId, input.PerHour ?? 30, input.Instruction, input.Priority ?? 3, input.Slug);
private static string? TriggerProblem(AppHost host, TriggerInputDto input)
{
if (!host.Instance.Agents.Any(a => string.Equals(a.AgentId, input.AgentId, StringComparison.OrdinalIgnoreCase)))
return $"There is no agent '{input.AgentId}'.";
if ((input.Instruction ?? "").Length > Core.Triggers.InboundTriggers.MaxInstructionChars)
return $"The instruction is longer than {Core.Triggers.InboundTriggers.MaxInstructionChars} characters.";
if (!string.IsNullOrWhiteSpace(input.Slug) && Core.Triggers.InboundTriggers.Slugify(input.Slug).Length == 0)
return "An address name needs letters or digits.";
return null;
}
/// <summary>Only the header — never the query string: an address ends up in logs.</summary>
private static string? BearerToken(HttpRequest request)
{
+7 -1
View File
@@ -32,11 +32,17 @@ internal sealed class McpTransport
/// <summary>#725: Where an inbound trigger delivers.</summary>
public const string TriggerPath = "/hooks/trigger";
/// <summary><c>/hooks/trigger</c>, or <c>/hooks/trigger/{name}</c> with the trigger's slug — one segment, nothing deeper.</summary>
public static bool IsTriggerPath(PathString path)
=> path.Equals(TriggerPath, StringComparison.Ordinal)
|| (path.StartsWithSegments(TriggerPath, StringComparison.Ordinal, out var rest)
&& rest.Value is { Length: > 1 } r && r.IndexOf('/', 1) < 0);
public static async Task OnlyMcp(HttpContext context, RequestDelegate next)
{
// #725: inbound triggers come the same way — from outside, with a token of their own.
if (Applies(context) && !context.Request.Path.Equals("/mcp", StringComparison.Ordinal)
&& !context.Request.Path.Equals(TriggerPath, StringComparison.Ordinal))
&& !IsTriggerPath(context.Request.Path))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;