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.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))
.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))
: 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)).ToResponse());
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Remove(agentId).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");
// #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) =>
await host.TaskAdmin.ListAsync(limit, ct) is { } list
? Results.Ok(list.Select(t => t.ToDto()).ToList())
: Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."));
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());
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);
var liste = new List(pending.Count);
foreach (var call in pending)
liste.Add(call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben));
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));
});
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());
}
}