using System.Text.Json;
using System.Threading.Channels;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
namespace ClawdDotNet.Web.Endpoints;
///
/// Der Ereignisstrom: was im Kern passiert, während niemand danach fragt.
///
/// Server-Sent Events und nicht WebSocket. Die Richtung ist einseitig —
/// Befehle gehen über gewöhnliche POSTs —, SSE übersteht Zwischenschichten, bringt das
/// Wiederverbinden mit und braucht auf keiner Seite eine zusätzliche Bibliothek.
///
/// Die Ereignisse kommen aus Hintergrundfäden. AgentEngine löst sie
/// dort aus, wo der Lauf gerade ist. Deshalb geht hier nichts direkt in die Antwort,
/// sondern erst in einen Kanal, den die Anfrage abarbeitet: Ein Ereignisbehandler, der in
/// den Antwortstrom schreibt, träfe früher oder später auf einen zweiten, der dasselbe
/// tut, und ein HttpResponse verträgt keine zwei Schreiber.
///
internal static class EventEndpoints
{
///
/// Wie viele Ereignisse ein langsamer Browser hinter sich herziehen darf, bevor die
/// ältesten wegfallen.
///
/// Begrenzt und verwerfend, nicht unbegrenzt: Ein Tab, der im Hintergrund
/// eingefroren ist, würde sonst den Speicher des Prozesses füllen, in dem die
/// Agenten laufen. Ein verlorener Zwischenstand ist der bessere Verlust — die Seite
/// lädt den Verlauf beim Verbinden ohnehin neu.
///
private const int QueueDepth = 256;
public static void MapEvents(this WebApplication app)
{
var events = app.MapGroup("/events");
events.MapGet("/", StreamCoreEvents);
events.MapGet("/logs", StreamLogs);
}
private static async Task StreamCoreEvents(
HttpContext context, AppHost host, IHostApplicationLifetime lifetime, CancellationToken ct)
{
using var leben = Verbinde(lifetime, ct);
ct = leben.Token;
var queue = Channel.CreateBounded(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true
});
void Publish(string type, object payload)
=> queue.Writer.TryWrite(Format(type, payload));
// Die Behandler stehen als benannte Delegaten da, weil sie im finally wieder
// abgemeldet werden muessen. Ein Lambda direkt am += liesse sich nicht mehr
// entfernen, und nach dreissig Neuladen der Seite liefe jede Nachricht
// dreissigmal durch.
void OnChat(string agentId, string role, string content, string? source)
=> Publish("chat", new { agentId, role, content, source, timestamp = DateTime.UtcNow });
void OnStep(string agentId, string text)
=> Publish("step", new { agentId, text });
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
agentId = result.AgentId,
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
// #711: the team overview — who starts, who stops, who reached whom.
void OnRunStarted(Core.Activity.ActiveRun run)
=> Publish("activity", new { kind = "started", run = run.ToDto() });
void OnRunFinished(Core.Activity.ActiveRun run, string status)
=> Publish("activity", new { kind = "finished", run = run.ToDto(), status });
void OnLink(Core.Activity.AgentLink link)
=> Publish("activity", new { kind = "link", link = link.ToDto() });
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
void OnStatus()
=> Publish("status", new { });
if (host.Engine is { } engine)
{
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
engine.Activity.RunStarted += OnRunStarted;
engine.Activity.RunFinished += OnRunFinished;
engine.Activity.LinkObserved += OnLink;
}
host.InstanceChanged += OnInstanceChanged;
if (host.Status is { } status)
status.OnStatusUpdated += OnStatus;
try
{
await WriteStream(context, queue.Reader, ct);
}
finally
{
if (host.Engine is { } e)
{
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
e.Activity.RunStarted -= OnRunStarted;
e.Activity.RunFinished -= OnRunFinished;
e.Activity.LinkObserved -= OnLink;
}
host.InstanceChanged -= OnInstanceChanged;
if (host.Status is { } s)
s.OnStatusUpdated -= OnStatus;
queue.Writer.TryComplete();
}
}
///
/// Das Protokoll, fortlaufend.
///
/// Eine eigene je Verbindung, und das ist keine
/// Sparsamkeitsfrage: ReadNew liefert die Zeilen seit dem letzten Aufruf und
/// merkt sich die Stelle. Eine geteilte Ausprägung hieße, dass der erste Browser die
/// Zeilen bekommt und alle anderen nichts.
///
private static async Task StreamLogs(
HttpContext context, AppHost host, IHostApplicationLifetime lifetime,
CancellationToken ct, string? module = null, string? level = null)
{
using var leben = Verbinde(lifetime, ct);
ct = leben.Token;
var tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse(level, ignoreCase: true, out var parsed)
? parsed
: LogLevelFilter.All;
var interval = TimeSpan.FromMilliseconds(
Math.Clamp(host.Settings.AppSettings.LogRefreshIntervalMs, 200, 5000));
PrepareResponse(context);
using var timer = new PeriodicTimer(interval);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
var lines = tail.ReadNew(module, filter);
if (lines.Count == 0)
{
// Ein Kommentar haelt die Verbindung offen, ohne ein Ereignis
// vorzutaeuschen. Ohne ihn kappt so mancher Proxy nach einer Minute.
await context.Response.WriteAsync(": still\n\n", ct);
}
else
{
foreach (var line in lines)
await context.Response.WriteAsync(
Format("log", new LogLineDto(line.Module, line.Level, line.Text)), ct);
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt. Der einzige normale Weg, auf dem dieser Strom endet.
}
}
private static async Task WriteStream(
HttpContext context, ChannelReader reader, CancellationToken ct)
{
PrepareResponse(context);
// Sofort etwas schicken: Erst damit steht die Verbindung fuer den Browser, und
// onopen feuert. Ohne das haengt die Seite im Ungewissen, bis das erste Ereignis
// von selbst kommt — was Stunden dauern kann.
await context.Response.WriteAsync(": bereit\n\n", ct);
await context.Response.Body.FlushAsync(ct);
var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
try
{
var next = reader.ReadAsync(ct).AsTask();
var beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
while (true)
{
var done = await Task.WhenAny(next, beat);
if (done == next)
{
await context.Response.WriteAsync(await next, ct);
next = reader.ReadAsync(ct).AsTask();
}
else
{
if (!await beat) break;
await context.Response.WriteAsync(": still\n\n", ct);
beat = heartbeat.WaitForNextTickAsync(ct).AsTask();
}
await context.Response.Body.FlushAsync(ct);
}
}
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt.
}
catch (ChannelClosedException)
{
// Der Strom wird abgeraeumt.
}
finally
{
heartbeat.Dispose();
}
}
///
/// Verbindet den Abbruch der Anfrage mit dem Herunterfahren der Anwendung.
///
/// Ohne das hängt das Beenden an jedem offenen Ereignisstrom.
/// RequestAborted feuert, wenn der Browser auflegt — beim geordneten
/// Herunterfahren tut er das aber nicht. Kestrel wartet dann darauf, dass die
/// Anfrage von selbst endet; ein Ereignisstrom endet nie von selbst. Erst nach der
/// Frist von StopAsync wird abgeschnitten, und so lange steht die Anwendung.
///
/// Sichtbar wurde es beim Instanzwechsel: Zwischen „Wechseln" und dem Neustart
/// lagen fünf Sekunden, in denen nichts geschah. Mit einem offenen Browserfenster
/// jedes Mal.
///
private static CancellationTokenSource Verbinde(
IHostApplicationLifetime lifetime, CancellationToken requestAborted)
=> CancellationTokenSource.CreateLinkedTokenSource(
requestAborted, lifetime.ApplicationStopping);
private static void PrepareResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
// Ohne das puffert ein vorgelagerter nginx den Strom und liefert ihn erst, wenn
// er voll ist — aus einem Ereignisstrom wird dann eine Ereignissammlung.
context.Response.Headers["X-Accel-Buffering"] = "no";
}
///
/// Ein Ereignis in SSE-Form.
///
/// Mit den Web-Vorgaben (camelCase) wie jede REST-Antwort (#591). Ohne sie kamen
/// Protokollzeilen als Module/Level/Text an, die Ansicht las module/level/text
/// — und zeigte von jeder Zeile nur den Instanznamen. Den anonymen Objekten der übrigen
/// Ereignisse sah man das nicht an, weil ihre Namen ohnehin klein geschrieben sind.
///
internal static string Format(string type, object payload)
=> $"event: {type}\ndata: {JsonSerializer.Serialize(payload, JsonSerializerOptions.Web)}\n\n";
}