Files
ClawdDotNet/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
T
RichardandClaude Opus 5.5 e42ec100b0 Taskboard fasst nach: stockende, entblockte und wartende Aufgaben erkennen und nachfassen (#646)
Alle 30 Minuten, als Abfrage statt Modelllauf: Agentenaufgabe in Arbeit ohne
Aenderung (Vorgabe 24 h) -> einmal neu gestartet, stockt sie binnen einer
Woche wieder, geht es an die Leitung bzw. den Menschen; blockiert mit
erledigten Blockern -> wieder offen; @human-Aufgabe ueber 48 h -> in einer
Sammelaufgabe fuer den Menschen; in_review ueber 48 h und wiederholtes
LoopLimitExceeded (3 in 7 Tagen) -> Aufgabe an die Leitung. Einmal je Zustand,
hoechstens 10 Nachfassungen je Durchgang, jede im Audit. Befunde liegen
abfragbar in TaskFindings (fuer #680); WebUI-Karte 'Liegengeblieben'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:49:04 +02:00

491 lines
23 KiB
C#

using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// Die HTTP-Fläche der Weboberfläche.
///
/// <para>Jeder Endpunkt hier ist dünn: Er nimmt entgegen, ruft einen Dienst aus
/// <c>ClawdDotNet.App</c> und formt das Ergebnis um. <b>Eine Regel, die nur hier steht,
/// ist eine an der falschen Stelle</b> — sie gehört in die Anwendungsschicht, wo die
/// Avalonia-Oberfläche sie auch findet. Wer beim Bauen eines Endpunkts merkt, dass er
/// etwas entscheiden muss, hat einen Befund und keine Aufgabe.</para>
/// </summary>
internal static class ApiEndpoints
{
public static void MapApi(this WebApplication app)
{
var api = app.MapGroup("/api");
api.MapInfo();
api.MapAgents();
api.MapChat();
api.MapServices();
api.MapTasks();
api.MapStaging();
api.MapUsage();
api.MapAnalytics();
api.MapLogs();
api.MapOrganisation();
api.MapCases();
api.MapFileHistory();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
app.MapControl();
}
/// <summary>
/// Übersetzt ein <see cref="AdminResult"/> in eine Antwort.
///
/// <para>400 und nicht 500, wenn es nicht geklappt hat: Eine doppelte Agenten-ID ist
/// keine Störung des Servers, sondern eine Antwort auf eine Frage. Die Begründung
/// steht im Rumpf und ist dieselbe, die im Fenster stünde.</para>
/// </summary>
public static IResult ToResponse(this AdminResult result)
=> result.Ok
? Results.Ok(new OutcomeDto(true, result.Message))
: Results.BadRequest(new OutcomeDto(false, result.Message));
/// <summary>
/// Für Bereiche, die es ohne Zutun nicht gibt: keine Engine ohne API-Schlüssel, kein
/// Scanner ohne gemeinsamen Arbeitsbereich, kein Staging ohne beides.
///
/// <para>503 mit einer Begründung statt einer <c>NullReferenceException</c>. Die
/// Oberfläche kann daraus einen Satz machen; aus einem Absturz nicht.</para>
/// </summary>
public static IResult Unavailable(string reason)
=> Results.Json(new OutcomeDto(false, reason), statusCode: StatusCodes.Status503ServiceUnavailable);
// ─── Info und Zustand ───
private static void MapInfo(this RouteGroupBuilder api)
{
api.MapGet("/info", (AppHost host) => new InfoDto(
AppHost.AppVersion,
AppHost.BuildSummary,
host.Instance.InstanceId,
host.Instance.InstanceName,
EngineActive: host.Engine is not null,
SchedulerActive: host.Scanner is not null,
StagingActive: host.Staging is not null,
DeploymentcenterActive: host.Deploymentcenter is not null));
api.MapGet("/status", (AppHost host) =>
{
if (host.Status is not { } status)
return Unavailable("Kein OpenRouter-Schlüssel hinterlegt.");
return Results.Ok(new StatusDto(
status.IsApiReachable,
status.StatusText,
status.CreditsText,
status.CreditBalance,
status.CreditRemaining));
});
}
// ─── Agenten ───
private static void MapAgents(this RouteGroupBuilder api)
{
var agents = api.MapGroup("/agents");
agents.MapGet("/", (AppHost host) =>
{
var descriptions = host.AgentAdmin.ToolDescriptions();
return host.AgentAdmin.All
.Select(a => a.ToDto(descriptions, host.Engine?.IsRunning(a.AgentId) ?? false, host.Tools.Get, host.AgentAdmin.ToolCatalog()))
.ToList();
});
// ─── Interne Agenten-Kommunikation (A2A, #208) ───
agents.MapGet("/communications", (AppHost host) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
var list = AgentCommunicationBuilder.Build(
host.AgentAdmin.All.Select(agent => (
agent.AgentId,
agent.DisplayName,
(IEnumerable<Core.Engine.ChatEntry>)engine.GetChatHistory(agent.AgentId))));
return Results.Ok(list);
});
agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Find(agentId) is { } agent
? Results.Ok(agent.ToDto(
host.AgentAdmin.ToolDescriptions(),
host.Engine?.IsRunning(agent.AgentId) ?? false,
host.Tools.Get,
host.AgentAdmin.ToolCatalog()))
: Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'.")));
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
host.AgentAdmin.Add(new NewAgent(
input.AgentId, input.DisplayName, input.Model, input.Description, input.UnitId)).ToResponse());
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Remove(agentId).ToResponse());
// #712: the runtime — native, or foreign in the sandbox.
agents.MapPut("/{agentId}/runtime", (AppHost host, string agentId, AgentRuntimeDto input) =>
host.AgentAdmin.SetRuntime(agentId, input.ToConfig()).ToResponse());
var runtimes = api.MapGroup("/runtimes");
runtimes.MapGet("/", (AppHost host) =>
{
var sandbox = host.Engine?.Sandbox;
var claude = host.Instance.ClaudeCode;
return new RuntimesDto(
sandbox?.IsAvailable ?? false,
sandbox?.UnavailableReason ?? (sandbox is null ? "no engine" : null),
Core.Runtimes.RuntimeKinds.All,
Core.Runtimes.ClaudeCodeRuntime.DefaultAllowedTools,
new ClaudeCodeDto(
claude.Executable,
Core.Runtimes.ClaudeCodeRuntime.Locate(claude),
claude.Auth,
!string.IsNullOrWhiteSpace(claude.ApiKey),
!string.IsNullOrWhiteSpace(claude.OAuthToken)));
});
runtimes.MapPut("/claude-code", (AppHost host, ClaudeCodeInputDto input) =>
host.AgentAdmin.SetClaudeCode(input.Executable, input.Auth, input.ApiKey, input.OAuthToken).ToResponse());
// #715: Can only tighten — the tools decide the tier, this adds to it.
agents.MapPut("/{agentId}/foreign-reader", (AppHost host, string agentId, bool value) =>
host.AgentAdmin.SetTreatAsForeignReader(agentId, value).ToResponse());
agents.MapPut("/{agentId}/tools/{toolName}", (
AppHost host, string agentId, string toolName, bool enabled) =>
host.AgentAdmin.SetToolEnabled(agentId, toolName, enabled).ToResponse());
agents.MapGet("/{agentId}/tools/{toolName}/settings", (
AppHost host, string agentId, string toolName) =>
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
var settings = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>();
return Results.Ok(Mapping.ToDto(toolName, settings));
});
// #641: Rechte je Werkzeug - was laeuft, was vorgelegt wird, was verboten ist.
agents.MapGet("/{agentId}/tools/{toolName}/rights", async (
AppHost host, string agentId, string toolName, CancellationToken ct) =>
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
if (!agent.Tools.TryGetValue(toolName, out var settings))
return Results.NotFound(new OutcomeDto(false, $"Werkzeug '{toolName}' ist nicht zugewiesen."));
if (host.Tools.Get(toolName) is not { } tool)
return Results.NotFound(new OutcomeDto(false, $"Werkzeug '{toolName}' ist nicht installiert."));
var dauer = host.Staging is { } service
? (await service.ListDauerfreigabenAsync(false, ct)).Where(d => d.AgentId == agentId && d.Tool == toolName).ToList()
: [];
return Results.Ok(WerkzeugrechteAnsicht.Erstelle(tool, settings.AsReadOnly(), dauer));
});
agents.MapPut("/{agentId}/tools/{toolName}/rights", (
AppHost host, string agentId, string toolName, ToolRightsInputDto input) =>
host.AgentAdmin.SetToolRights(agentId, toolName, input.Allowed, input.Denied ?? []).ToResponse());
agents.MapPost("/{agentId}/run", async (
AppHost host, string agentId, CancellationToken ct) =>
(await host.Runs.RunAgentAsync(agentId, message: null, ct)).ToResponse());
}
// ─── Chat ───
private static void MapChat(this RouteGroupBuilder api)
{
var chat = api.MapGroup("/agents/{agentId}/chat");
chat.MapGet("/", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
return Results.Ok(engine.GetChatHistory(agentId).Select(e => e.ToDto()).ToList());
});
chat.MapPost("/", async (
AppHost host, string agentId, SendMessageDto input, CancellationToken ct) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
var text = input.Message?.Trim() ?? "";
if (text.Length == 0)
return Results.BadRequest(new OutcomeDto(false, "Leere Nachricht."));
// Quelle mitgeben: OnChatEntryAdded reicht sie weiter, und das Fenster kann
// anzeigen, dass die Nachricht aus dem Browser kam. Die Engine reiht Laeufe
// desselben Agenten ohnehin hintereinander ein — zwei Oberflaechen
// gleichzeitig sind deshalb kein Sonderfall.
var result = await engine.ChatAsync(
agent, text, host.Instance.InstanceId, ct, source: Core.Engine.ChatSource.WebUi);
return Results.Ok(new OutcomeDto(
true, $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens)."));
});
chat.MapPost("/abort", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
engine.AbortChat(agentId);
return Results.Ok(new OutcomeDto(true, "Chat abgebrochen."));
});
chat.MapDelete("/", (AppHost host, string agentId) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
engine.ClearChatHistory(agentId);
return Results.Ok(new OutcomeDto(true, "Verlauf geleert."));
});
}
// ─── Dienste ───
private static void MapServices(this RouteGroupBuilder api)
{
var services = api.MapGroup("/services");
services.MapGet("/", (AppHost host) =>
host.ServiceAdmin.All.Select(s => s.ToDto()).ToList());
services.MapPost("/", (AppHost host, ServiceInputDto input) =>
host.ServiceAdmin.Add(new NewService(
input.Name, input.Type, input.Port, input.Description)).ToResponse());
services.MapDelete("/{serviceId}", (AppHost host, string serviceId) =>
host.ServiceAdmin.Remove(serviceId).ToResponse());
services.MapPut("/{serviceId}/enabled", (
AppHost host, string serviceId, bool enabled) =>
host.ServiceAdmin.SetEnabled(serviceId, enabled).ToResponse());
}
// ─── Taskboard ───
private static void MapTasks(this RouteGroupBuilder api)
{
var tasks = api.MapGroup("/tasks");
// #646: what the board found left lying, and what it did about it.
tasks.MapGet("/findings", async (AppHost host, CancellationToken ct, bool all = false, int limit = 100) =>
host.FollowUp is { } followUp
? Results.Ok((await followUp.ListAsync(!all, limit, ct)).Select(f => new TaskFindingDto(
f.Id, f.Kind, f.TaskId, f.Assignee, f.Title, f.Detail, f.FirstSeenUtc, f.LastSeenUtc, f.ActedAtUtc, f.Action, f.ResolvedAtUtc)).ToList())
: Results.Ok(Array.Empty<TaskFindingDto>()));
// #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren
// /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte.
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
{
if (await host.TaskAdmin.ListAsync(limit, ct) is not { } list)
return Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
// #684: A human sees every board; each task says which one it is on.
var org = host.Organisation.Current();
return Results.Ok(list.Select(t => t.ToDto().WithBoard(t.Task, org)).ToList());
});
tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) =>
(await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse());
tasks.MapPut("/{id}", async (AppHost host, string id, TaskInputDto input, CancellationToken ct) =>
(await host.TaskAdmin.UpdateAsync(id, input.ToInput(), ct)).ToResponse());
tasks.MapPut("/{id}/enabled", async (AppHost host, string id, bool enabled, CancellationToken ct) =>
(await host.TaskAdmin.SetEnabledAsync(id, enabled, ct)).ToResponse());
tasks.MapPost("/{id}/run", async (AppHost host, string id, CancellationToken ct) =>
(await host.TaskAdmin.RunNowAsync(id, ct)).ToResponse());
// #717: Special budgets with what they have used — shown on the tasks they are for.
api.MapGet("/special-budgets", async (AppHost host, CancellationToken ct) =>
{
if (host.SpecialBudgets is not { } pots)
return Results.Ok(Array.Empty<SpecialBudgetDto>());
var list = new List<SpecialBudgetDto>();
foreach (var pot in await pots.ListAsync(ct))
list.Add(new SpecialBudgetDto(pot.TaskId, pot.AmountUsd, await pots.SpentAsync(pot.TaskId, ct),
pot.Reason, pot.RequestedBy, pot.ApprovedAt));
return Results.Ok(list);
});
tasks.MapGet("/{id}", async (AppHost host, string id, CancellationToken ct) =>
{
if (host.Board is not { } board)
return Unavailable("Kein Taskboard in dieser Instanz.");
return await board.GetAsync(id, ct) is { } task
? Results.Ok(task.ToDto())
: Results.NotFound(new OutcomeDto(false, $"Keine Aufgabe '{id}'."));
});
}
// ─── Freigaben ───
private static void MapStaging(this RouteGroupBuilder api)
{
var staging = api.MapGroup("/staging");
staging.MapGet("/", async (AppHost host, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var pending = await service.ListPendingAsync(ct);
// #702: the vote beside each call it was held on — the human decides with every ballot in view.
var votes = host.Engine?.Votes is Core.Staging.Votes v
? await v.ForCallsAsync(pending.Select(c => c.Id), ct)
: new Dictionary<long, Core.Staging.VoteRecord>();
var liste = new List<StagedCallDto>(pending.Count);
foreach (var call in pending)
{
var dto = call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben);
if (votes.TryGetValue(call.Id, out var vote))
dto = dto with { Vote = await VoteMapping.ToDtoAsync(vote, (Core.Staging.Votes)host.Engine!.Votes!, ct) };
liste.Add(dto);
}
return Results.Ok(liste);
});
// #641: similar=true - "Gleichartiges kuenftig erlauben".
staging.MapPost("/{id:long}/approve", async (
AppHost host, long id, CancellationToken ct, bool similar = false) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.ApproveAsync(id, decidedBy: "webui", gleichartigErlauben: similar, ct);
return Results.Ok(new OutcomeDto(
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());
});
// #726: whether this instance works with n8n — only then do agents propose workflows.
staging.MapGet("/automations/settings", (AppHost host) => Results.Ok(new AutomationSettingsDto(host.Instance.Automation.Enabled)));
staging.MapPut("/automations/settings", (AppHost host, AutomationSettingsDto input) =>
{
host.Instance.Automation.Enabled = input.Enabled;
host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
return Results.Ok(new OutcomeDto(true, input.Enabled
? "n8n connection on: agents may propose workflows, and recurring routine is put to them."
: "n8n connection off: no workflow proposals, no candidates."));
});
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) =>
{
if (host.Engine?.LeadApprovals is not { } leads)
return Results.Ok(Array.Empty<LeadApprovalStatsDto>());
var stats = await leads.StatsAsync(days, ct);
return Results.Ok(stats.Select(x => new LeadApprovalStatsDto(x.LeadId, x.Routed, x.Approved, x.Rejected, x.Lapsed, x.ApprovalRate)).ToList());
});
staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var liste = await service.ListDauerfreigabenAsync(revoked, ct);
return Results.Ok(liste.Select(d => d.ToDto()).ToList());
});
staging.MapPost("/standing/{id:long}/revoke", async (AppHost host, long id, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var ok = await service.WiderrufenAsync(id, "webui", ct);
return Results.Ok(new OutcomeDto(ok, ok
? $"Dauerfreigabe #{id} widerrufen. Die Operation wird wieder einzeln vorgelegt."
: $"Dauerfreigabe #{id} gibt es nicht oder sie ist schon widerrufen."));
});
staging.MapPost("/{id:long}/reject", async (
AppHost host, long id, RejectDto input, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.RejectAsync(id, "webui", input.Reason ?? "", ct);
return Results.Ok(new OutcomeDto(
result.Kind is Core.Staging.StagingResultKind.Rejected, result.Message));
});
}
// ─── Verbrauch ───
private static void MapUsage(this RouteGroupBuilder api)
{
api.MapGet("/usage", async (AppHost host, CancellationToken ct, int limit = 100) =>
{
if (host.Usage is not { } usage)
return Unavailable("Keine Verbrauchsdaten: Agenten sind nicht aktiv.");
var recent = await usage.GetRecentAsync(Math.Clamp(limit, 1, 1000), ct);
return Results.Ok(recent.Select(r => r.ToDto()).ToList());
});
// #595: Hochrechnung aus Terminen und bisherigen Laeufen.
api.MapGet("/usage/forecast", async (AppHost host, CancellationToken ct) =>
await host.TaskAdmin.ForecastAsync(ct) is { } prognose
? Results.Ok(prognose.ToDto())
: Unavailable("Keine Prognose: In dieser Instanz gibt es kein Taskboard."));
}
// ─── Protokoll ───
private static void MapLogs(this RouteGroupBuilder api)
{
api.MapGet("/logs/modules", (AppHost host) =>
new LogTail(host.LogDirectory).AvailableModules());
}
}