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; /// /// Die HTTP-Fläche der Weboberfläche. /// /// Jeder Endpunkt hier ist dünn: Er nimmt entgegen, ruft einen Dienst aus /// ClawdDotNet.App und formt das Ergebnis um. Eine Regel, die nur hier steht, /// ist eine an der falschen Stelle — 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. /// 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(); } /// /// Übersetzt ein in eine Antwort. /// /// 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. /// public static IResult ToResponse(this AdminResult result) => result.Ok ? Results.Ok(new OutcomeDto(true, result.Message)) : Results.BadRequest(new OutcomeDto(false, result.Message)); /// /// Für Bereiche, die es ohne Zutun nicht gibt: keine Engine ohne API-Schlüssel, kein /// Scanner ohne gemeinsamen Arbeitsbereich, kein Staging ohne beides. /// /// 503 mit einer Begründung statt einer NullReferenceException. Die /// Oberfläche kann daraus einen Satz machen; aus einem Absturz nicht. /// 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)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(); 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())); // #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()); var list = new List(); 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(); var liste = new List(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()); 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()); 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()); } }