Instanzmanager Stufe 4: eine Adresse, ein Zugang, alle Instanzen (#528, E-M5)

Der Managerprozess. ClawdDotNet.Instanzmanager ist eine Konsolenanwendung:
Lizenz pruefen, uebernehmen was laeuft, Weboberflaeche aufhaengen, warten.
Beim Herunterfahren beendet er keine Instanz.

Zwei neue Projekte. ClawdDotNet.Manager.Web traegt die Weboberflaeche -
ohne jeden Verweis auf ClawdDotNet.Web, denn dort haengt App und daran
sechzehn Werkzeugprojekte. ClawdDotNet.ManagerApp ist der Einstiegspunkt.
wwwroot ist aus der Instanz in den Manager gezogen.

DER SCHNITT DER HTTP-FLAECHE: Unter /api/verwaltung steht, was den Manager
angeht - auflisten, anlegen, starten, anhalten. Alles andere unter /api
geht unveraendert an eine Instanz. Er leitet weiter, er implementiert
nicht. Welche Instanz gemeint ist, sagt die Kopfzeile X-Instanz; laeuft
genau eine, ist sie gemeint, und laufen mehrere, wird nachgefragt statt
geraten - eine Anfrage an die falsche Instanz ist schlimmer als eine
abgelehnte.

Der zusammengefuehrte Ereignisstrom haelt je laufender Instanz eine
Zuleitung und schreibt an jedes Ereignis, von wem es kommt. Ergaenzt statt
verpackt: Ein Umschlag waere einfacher gewesen, haette aber jede Ansicht
der Weboberflaeche zum Umschreiben gezwungen.

ABNAHME gegen laufende Prozesse: 401 ohne Token, Oberflaeche ohne Token
200, Instanz ueber den Manager gestartet und angehalten, /api/info und
/api/status unverfaelscht durchgereicht, /events/logs mit
"instanz":"Instance-Test" an jeder Zeile. Manager abgeschossen: Instanz
laeuft weiter. Manager neu gestartet: "1 Instanzen bekannt, 1 laufen
bereits" - uebernommen. E-M6 damit in beide Richtungen belegt.

DREI DEFEKTE, die erst der Lauf gezeigt hat:

1. Der Manager hing an einer Konsolenabfrage nach dem Lizenzschluessel. Ein
   Test auf Console.IsInputRedirected reicht dafuer NICHT: Er beantwortet
   "ist die Eingabe umgeleitet", nicht "kann jemand antworten" - ein
   Prozess ganz ohne Konsole, der gefaehrlichste Fall, ist nicht
   umgeleitet. Der Manager fragt jetzt gar nicht.
2. Eine unlesbare manager.json wurde stillschweigend durch Vorgaben
   ersetzt: falsches Instanzverzeichnis, anderer Port, Lizenzfrage - und
   nichts sagte, woran es lag. LoadProblem meldet den Grund jetzt.
3. Der Instanzprozess erbte CLAWD_CONFIG_DIR vom Manager und legte sich in
   dessen Konfigurationsverzeichnis eine eigene Settings.json an. Eine
   Umgebungsvariable vererbt sich, ein Argument nicht - der Manager nimmt
   sein Verzeichnis jetzt ueber --config.

SharedLoggerProvider ist nach Core gezogen: Es gibt jetzt zwei Webhosts,
die dasselbe brauchen, und sie duerfen einander nicht kennen.

NOCH OFFEN in dieser Stufe: Die Weboberflaeche kennt noch keine Instanzen -
Instanzauswahl, X-Instanz und die Verwaltungsansicht sind Arbeit an
wwwroot. Und webServerPort steht noch in der Instanz.

Build ohne Warnungen, 775 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 23:19:49 +02:00
co-authored by Claude Opus 5
parent cb56175d20
commit a511480b77
38 changed files with 1352 additions and 7 deletions
+154
View File
@@ -0,0 +1,154 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager.Web;
/// <summary>Eine Instanz, wie die Weboberfläche sie sieht.</summary>
public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess);
/// <summary>Was beim Anlegen mitkommt.</summary>
public sealed record NewInstanceDto(string Name);
/// <summary>Antwort auf etwas, das gelingen oder scheitern kann.</summary>
public sealed record OutcomeDto(bool Ok, string Meldung);
/// <summary>
/// Die HTTP-Fläche des Managers.
///
/// <para><b>Zwei Sorten, und der Unterschied ist die ganze Architektur:</b> Unter
/// <c>/api/verwaltung</c> steht, was den Manager selbst angeht — welche Instanzen es gibt,
/// anlegen, starten, anhalten. Alles andere unter <c>/api</c> wird unverändert an eine
/// Instanz weitergereicht.</para>
///
/// <para>Wer hier einen Endpunkt ergänzt, der etwas über Agenten, Aufgaben oder
/// Freigaben weiß, hat Fachlogik in den Manager gelegt — die gehört in die Instanz, wo
/// die Avalonia-Oberfläche sie auch findet.</para>
/// </summary>
internal static class ManagerEndpoints
{
// ─── Was den Manager selbst angeht ───
public static void MapVerwaltung(this WebApplication app)
{
var verwaltung = app.MapGroup("/api/verwaltung");
verwaltung.MapGet("/instanzen", (InstanceRegistry registry) =>
registry.Instances.Select(Map).ToList());
verwaltung.MapPost("/instanzen", async (
NewInstanceDto neu, ManagerCore manager, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(neu.Name))
return Results.BadRequest(new OutcomeDto(false, "Eine Instanz braucht einen Namen."));
try
{
var pfad = await manager.CreateInstanceAsync(neu.Name.Trim(), ct);
return Results.Ok(new OutcomeDto(
true, $"Instanz '{neu.Name.Trim()}' angelegt: {pfad}"));
}
catch (InvalidOperationException ex)
{
// Gibt es schon. Das ist eine Antwort auf eine Frage und keine Stoerung
// des Servers - deshalb 400 und nicht 500.
return Results.BadRequest(new OutcomeDto(false, ex.Message));
}
});
verwaltung.MapPost("/instanzen/{id}/start", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
var ergebnis = await manager.StartInstanceAsync(instanz.Directory, ct);
return ergebnis switch
{
StartRefusal.None =>
Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' läuft.")),
// 402: Der Grund liegt nicht an der Anfrage, sondern an der Lizenz.
StartRefusal.NoLicense => Results.Json(
new OutcomeDto(false, "Ohne gültige Lizenz startet keine Instanz."),
statusCode: StatusCodes.Status402PaymentRequired),
_ => Results.Json(
new OutcomeDto(false,
$"Instanz '{instanz.Name}' ist nicht hochgekommen. "
+ "Der Grund steht im Protokoll des Managers."),
statusCode: StatusCodes.Status500InternalServerError)
};
});
verwaltung.MapPost("/instanzen/{id}/stop", async (
string id, ManagerCore manager, InstanceRegistry registry, CancellationToken ct) =>
{
if (Find(registry, id) is not { } instanz)
return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'."));
var beendet = await manager.StopInstanceAsync(instanz.Directory, ct);
return beendet
? Results.Ok(new OutcomeDto(true, $"Instanz '{instanz.Name}' beendet."))
: Results.Json(
new OutcomeDto(false,
$"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."),
statusCode: StatusCodes.Status409Conflict);
});
}
// ─── Der Ereignisstrom ───
public static void MapEvents(this WebApplication app)
{
var logger = app.Services
.GetRequiredService<ILoggerFactory>()
.CreateLogger("ClawdDotNet.Manager.Web");
// Zwei Stroeme, dieselbe Zusammenfuehrung: die Kernereignisse und das Protokoll.
// Beide gewinnen dadurch, dass an jeder Zeile steht, aus welcher Instanz sie kommt.
app.MapGet("/events", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
=> MergedEventStream.StreamAsync(context, registry, "/events", logger, ct));
app.MapGet("/events/logs", (HttpContext context, InstanceRegistry registry, CancellationToken ct)
=> MergedEventStream.StreamAsync(
context, registry, "/events/logs" + context.Request.QueryString, logger, ct));
}
// ─── Alles andere: durchreichen ───
public static void MapForwarding(this WebApplication app)
{
app.Map("/api/{**pfad}", async (
HttpContext context, InstanceRegistry registry, InstanceForwarding forwarding,
CancellationToken ct) =>
{
var (instanz, problem) = InstanceRouting.Resolve(registry, context);
if (instanz is null)
{
// 409 und nicht 404: Der Pfad ist in Ordnung, nur ist unklar oder
// unmoeglich, an wen er gehen soll.
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new OutcomeDto(false, problem!), ct);
return;
}
await forwarding.ForwardAsync(context, instanz, context.Request.Path, ct);
});
}
// ─── Kleinkram ───
private static InstanceDto Map(ManagedInstance instanz) => new(
InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId);
private static ManagedInstance? Find(InstanceRegistry registry, string id)
=> registry.Instances.FirstOrDefault(
i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase));
}