diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index 51678ec..6507084 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -22,6 +22,8 @@
+
+
diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md
index 4ecff0e..dd0db23 100644
--- a/docs/Instanzmanager-Konzept.md
+++ b/docs/Instanzmanager-Konzept.md
@@ -322,7 +322,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis.
| 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll |
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
| 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
-| 4 | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
+| 4 ◑ | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
@@ -549,3 +549,65 @@ Sockets hinterlegt, *bevor* der Manager hochfuhr — damit sagt man „diese Ins
bereits", der Manager übernimmt sie (E-M6), der Starter wird nie angefasst, und der Test
prüft etwas anderes, als er behauptet. Es gibt jetzt eine Hilfsmethode, die genau das
ausschließt.
+
+### 9.5 Was Stufe 4 gebracht hat
+
+**Den Managerprozess.** `ClawdDotNet.Instanzmanager` ist eine Konsolenanwendung: Sie prüft
+die Lizenz, übernimmt, was läuft, hängt die Weboberfläche auf und wartet. Sie beendet beim
+Herunterfahren keine Instanz.
+
+**Zwei neue Projekte.** `ClawdDotNet.Manager.Web` trägt die Weboberfläche — ohne jeden
+Verweis auf `ClawdDotNet.Web`, denn dort hängt `App` und daran sechzehn Werkzeugprojekte.
+`ClawdDotNet.ManagerApp` ist der Einstiegspunkt. `wwwroot` ist aus der Instanz in den
+Manager gezogen (Abschnitt 8).
+
+**Der Schnitt der HTTP-Fläche.** Unter `/api/verwaltung` steht, was den Manager angeht:
+Instanzen auflisten, anlegen, starten, anhalten. **Alles andere unter `/api` geht
+unverändert an eine Instanz** — er leitet weiter, er implementiert nicht. Welche Instanz
+gemeint ist, sagt die Kopfzeile `X-Instanz`; läuft genau eine, ist sie gemeint, und laufen
+mehrere, wird nachgefragt statt geraten. Eine Anfrage an die falsche Instanz ist schlimmer
+als eine abgelehnte.
+
+**Der zusammengeführte Ereignisstrom.** Der Manager hält je laufender Instanz eine
+Zuleitung und schreibt an jedes Ereignis, von wem es kommt (`instanz`, `instanzName`).
+Ergänzt statt verpackt: Ein Umschlag wäre einfacher gewesen, hätte aber jede Ansicht der
+Weboberfläche zum Umschreiben gezwungen.
+
+**Abnahme, gegen laufende Prozesse gemessen:**
+
+| Geprüft | Ergebnis |
+|---|---|
+| `/api/verwaltung/instanzen` ohne Token | 401 |
+| Oberfläche ohne Token | 200 — die leere Hülle, wie vorgesehen |
+| Instanz über den Manager starten | `Running`, PID 18104 |
+| `/api/info` durchgereicht | Antwort **der Instanz** (`instanceId d68af6fd`) |
+| `/api/status` durchgereicht | 503 der Instanz, unverfälscht |
+| `/events/logs` | Zeilen der Instanz, mit `"instanz":"Instance-Test"` daran |
+| Anhalten über den Manager | `Stopped` |
+| **Manager abgeschossen** | Instanz läuft weiter (E-M6) |
+| **Manager neu gestartet** | „1 Instanzen bekannt, 1 laufen bereits" — übernommen |
+
+**Drei Defekte, die erst der Lauf gezeigt hat:**
+
+1. **Der Manager hing an einer Konsolenabfrage.** `ConsoleLicensePrompt` wartete auf eine
+ Eingabe, die niemand geben konnte; die Weboberfläche kam nie hoch, und im Protokoll
+ stand nichts davon, weil die Frage auf der Standardausgabe steht. Ein Test auf
+ `Console.IsInputRedirected` reicht dafür **nicht**: Er beantwortet „ist die Eingabe
+ umgeleitet", nicht „kann jemand antworten" — ein Prozess ganz ohne Konsole, der
+ gefährlichste Fall, ist nicht umgeleitet. Der Manager fragt jetzt gar nicht; ein
+ Schlüssel gehört in `manager.json`.
+2. **Eine unlesbare `manager.json` wurde stillschweigend durch Vorgaben ersetzt.** Der
+ Manager suchte dann im falschen Verzeichnis, hörte auf einem anderen Port und fragte
+ nach einer Lizenz, die woanders stand — und nichts sagte, woran es lag. Es kostete eine
+ halbe Stunde und einen fehlenden Backslash. `LoadProblem` meldet den Grund jetzt.
+3. **Der Instanzprozess erbte `CLAWD_CONFIG_DIR` vom Manager** und legte sich im
+ Konfigurationsverzeichnis des Managers eine eigene `Settings.json` an. Eine
+ Umgebungsvariable vererbt sich, ein Argument nicht — deshalb nimmt der Manager sein
+ Konfigurationsverzeichnis jetzt über `--config`. Für eine systemd-Unit ist das ohnehin
+ der geradere Weg.
+
+**Was in Stufe 4 noch fehlt:** Die Weboberfläche selbst kennt noch keine Instanzen — sie
+spricht `/api/…` an und bekommt die eine laufende. Instanzauswahl, `X-Instanz` und die
+Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und
+`webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die
+Oberfläche im Manager vollständig ist.
diff --git a/src/ClawdDotNet.Web/SharedLoggerProvider.cs b/src/ClawdDotNet.Core/Logging/SharedLoggerProvider.cs
similarity index 69%
rename from src/ClawdDotNet.Web/SharedLoggerProvider.cs
rename to src/ClawdDotNet.Core/Logging/SharedLoggerProvider.cs
index 1b74159..66c67eb 100644
--- a/src/ClawdDotNet.Web/SharedLoggerProvider.cs
+++ b/src/ClawdDotNet.Core/Logging/SharedLoggerProvider.cs
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Logging;
-namespace ClawdDotNet.Web;
+namespace ClawdDotNet.Core.Logging;
///
/// Reicht die Protokollierung des Webhosts an die Protokollierung der Anwendung durch.
@@ -12,9 +12,12 @@ namespace ClawdDotNet.Web;
/// Anwendung protokolliert ab da ins Leere.
///
/// Deshalb: durchreichen, und beim Abräumen nichts tun. Die Fabrik gehört
-/// dem AppHost, und der räumt sie selbst ab.
+/// dem, der sie angelegt hat, und der räumt sie selbst ab.
+///
+/// In Core, weil es inzwischen zwei Webhosts gibt, die dasselbe brauchen: den der
+/// Instanz und den des Instanzmanagers (#528). Die beiden dürfen einander nicht kennen.
///
-internal sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
+public sealed class SharedLoggerProvider(ILoggerFactory shared) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => shared.CreateLogger(categoryName);
diff --git a/src/ClawdDotNet.Manager.Web/AccessToken.cs b/src/ClawdDotNet.Manager.Web/AccessToken.cs
new file mode 100644
index 0000000..dd7729b
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/AccessToken.cs
@@ -0,0 +1,81 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+
+namespace ClawdDotNet.Manager.Web;
+
+///
+/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
+///
+/// Warum der Manager einen eigenen hat und nicht den der Instanz. Die Instanz
+/// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem —
+/// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die
+/// nicht: Alles unter /api und /events kommt über TCP herein, und alles davon
+/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
+/// werden kann.
+///
+/// Was ohne Token ausgeliefert wird, ist die leere Hülle — HTML, CSS und
+/// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert:
+/// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die
+/// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte.
+/// In den Dateien steht nichts, was jemand nicht wissen dürfte.
+///
+/// Warum der Vergleich in fester Zeit läuft: Ein gewöhnlicher
+/// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst,
+/// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der
+/// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand.
+///
+public sealed class AccessToken(byte[] expected)
+{
+ /// Kürzer als das hier darf ein Token nicht sein.
+ public const int MinimumLength = 20;
+
+ public static AccessToken For(string token)
+ => new(Encoding.UTF8.GetBytes(token));
+
+ /// Ob dieser Pfad ein Token braucht: alles unter /api und /events.
+ public static bool NeedsToken(PathString path)
+ => path.StartsWithSegments("/api") || path.StartsWithSegments("/events");
+
+ ///
+ /// Holt das Token aus der Anfrage.
+ ///
+ /// Authorization: Bearer … ist der Weg. Ein Cookie wäre bequemer und
+ /// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit,
+ /// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die
+ /// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier
+ /// keine eigene Baustelle.
+ ///
+ /// Die Ausnahme ist der Ereignisstrom: EventSource im Browser kann keine
+ /// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der
+ /// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit
+ /// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in
+ /// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy
+ /// davor und entscheidet dort, was protokolliert wird.
+ ///
+ public static string? Read(HttpRequest request)
+ {
+ var header = request.Headers.Authorization.ToString();
+
+ if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
+ return header["Bearer ".Length..].Trim();
+
+ if (request.Path.StartsWithSegments("/events")
+ && request.Query.TryGetValue("token", out var fromQuery))
+ return fromQuery.ToString();
+
+ return null;
+ }
+
+ public bool Matches(string? candidate)
+ {
+ if (string.IsNullOrEmpty(candidate)) return false;
+
+ // FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass
+ // dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis,
+ // sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit
+ // verglichen.
+ return CryptographicOperations.FixedTimeEquals(
+ Encoding.UTF8.GetBytes(candidate), expected);
+ }
+}
diff --git a/src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj b/src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj
new file mode 100644
index 0000000..99083ad
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/ClawdDotNet.Manager.Web.csproj
@@ -0,0 +1,39 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Manager.Web
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs
new file mode 100644
index 0000000..2e8e0eb
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs
@@ -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;
+
+/// Eine Instanz, wie die Weboberfläche sie sieht.
+public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess);
+
+/// Was beim Anlegen mitkommt.
+public sealed record NewInstanceDto(string Name);
+
+/// Antwort auf etwas, das gelingen oder scheitern kann.
+public sealed record OutcomeDto(bool Ok, string Meldung);
+
+///
+/// Die HTTP-Fläche des Managers.
+///
+/// Zwei Sorten, und der Unterschied ist die ganze Architektur: Unter
+/// /api/verwaltung steht, was den Manager selbst angeht — welche Instanzen es gibt,
+/// anlegen, starten, anhalten. Alles andere unter /api wird unverändert an eine
+/// Instanz weitergereicht.
+///
+/// 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.
+///
+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()
+ .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));
+}
diff --git a/src/ClawdDotNet.Manager.Web/InstanceForwarding.cs b/src/ClawdDotNet.Manager.Web/InstanceForwarding.cs
new file mode 100644
index 0000000..e9b4428
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/InstanceForwarding.cs
@@ -0,0 +1,153 @@
+using ClawdDotNet.Core.Instances;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Manager.Web;
+
+///
+/// Reicht eine Anfrage an den Socket der gemeinten Instanz durch.
+///
+/// Er leitet weiter, er implementiert nicht (E-M5). Diese Klasse kennt
+/// Kennungen und Pfade, sonst nichts — keinen Agenten, keine Aufgabe, kein Taskboard. Wer
+/// hier anfängt, eine Antwort umzuformen oder eine Regel anzuwenden, hat Fachlogik in den
+/// Manager gelegt, und die gehört in die Instanz.
+///
+/// Deshalb bleibt das Protokoll HTTP (E-M1). Weil auf beiden Seiten HTTP
+/// steht, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Die Endpunkte, die
+/// Übertragungstypen und der Ereignisstrom aus #494 gelten unverändert weiter, es ändert
+/// sich nur die Bindung.
+///
+internal sealed class InstanceForwarding(ILogger logger) : IDisposable
+{
+ ///
+ /// Je Instanz ein Client, und der bleibt.
+ ///
+ /// Ein HttpClient je Anfrage wäre hier besonders teuer: Jeder brächte
+ /// einen eigenen Verbindungsaufbau zum Socket mit, und die Weboberfläche fragt viel
+ /// und oft.
+ ///
+ private readonly Dictionary _clients =
+ new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
+
+ private readonly Lock _lock = new();
+
+ ///
+ /// Kopfzeilen, die nicht weitergereicht werden.
+ ///
+ /// Authorization, weil der Socket kein Token kennt und keines bekommen
+ /// soll — die Grenze steht dort am Dateisystem. Host, weil die Gegenstelle
+ /// keinen Hostnamen hat. Die Übertragungskopfzeilen, weil sie die Verbindung
+ /// beschreiben und nicht den Inhalt: Sie einfach mitzunehmen ergibt eine Antwort, die
+ /// doppelt gestückelt ist.
+ ///
+ private static readonly HashSet NotForwarded = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Authorization", "Host", "Connection", "Transfer-Encoding",
+ "Keep-Alive", "Upgrade", "Proxy-Authorization", "TE", "Trailer",
+ InstanceRouting.HeaderName
+ };
+
+ public async Task ForwardAsync(
+ HttpContext context, ManagedInstance ziel, string pfad, CancellationToken ct)
+ {
+ var client = ClientFor(ziel.Directory);
+
+ using var anfrage = new HttpRequestMessage(
+ new HttpMethod(context.Request.Method),
+ pfad + context.Request.QueryString);
+
+ if (context.Request.ContentLength is > 0 || context.Request.Headers.ContentLength is null)
+ {
+ // Den Rumpf stroemen lassen statt ihn zu puffern: Eine hochgeladene Datei
+ // muss nicht erst vollstaendig im Manager liegen, bevor die Instanz sie sieht.
+ anfrage.Content = new StreamContent(context.Request.Body);
+
+ foreach (var (name, werte) in context.Request.Headers)
+ {
+ if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase))
+ anfrage.Content.Headers.TryAddWithoutValidation(name, werte.ToArray());
+ }
+ }
+
+ foreach (var (name, werte) in context.Request.Headers)
+ {
+ if (NotForwarded.Contains(name)) continue;
+ if (name.StartsWith("Content-", StringComparison.OrdinalIgnoreCase)) continue;
+
+ anfrage.Headers.TryAddWithoutValidation(name, werte.ToArray());
+ }
+
+ HttpResponseMessage antwort;
+
+ try
+ {
+ antwort = await client.SendAsync(
+ anfrage, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Die Instanz antwortet nicht. 502 und nicht 500: Der Fehler liegt nicht hier,
+ // und das gehoert in die Antwort - sonst sucht jemand im Manager nach einem
+ // Problem, das in der Instanz steckt oder darin, dass sie gar nicht laeuft.
+ logger.LogWarning(ex,
+ "Instanz {Name} war für {Methode} {Pfad} nicht erreichbar.",
+ ziel.Name, context.Request.Method, pfad);
+
+ context.Response.StatusCode = StatusCodes.Status502BadGateway;
+ await context.Response.WriteAsJsonAsync(
+ new { fehler = $"Die Instanz '{ziel.Name}' antwortet nicht." }, ct);
+
+ return;
+ }
+
+ using (antwort)
+ {
+ context.Response.StatusCode = (int)antwort.StatusCode;
+
+ foreach (var (name, werte) in antwort.Headers)
+ {
+ if (!NotForwarded.Contains(name))
+ context.Response.Headers[name] = werte.ToArray();
+ }
+
+ foreach (var (name, werte) in antwort.Content.Headers)
+ {
+ if (!NotForwarded.Contains(name))
+ context.Response.Headers[name] = werte.ToArray();
+ }
+
+ // Kestrel bestimmt selbst, wie er stueckelt.
+ context.Response.Headers.Remove("transfer-encoding");
+
+ await antwort.Content.CopyToAsync(context.Response.Body, ct).ConfigureAwait(false);
+ }
+ }
+
+ private HttpClient ClientFor(string instanceDirectory)
+ {
+ lock (_lock)
+ {
+ if (_clients.TryGetValue(instanceDirectory, out var vorhanden))
+ return vorhanden;
+
+ var client = InstanceEndpoint.CreateClient(instanceDirectory);
+
+ // Grosszuegig: Hinter einer weitergeleiteten Anfrage kann ein Agentenlauf
+ // stecken, und der dauert, so lange er dauert. Der Ereignisstrom laeuft
+ // ohnehin nicht hierueber.
+ client.Timeout = TimeSpan.FromMinutes(10);
+
+ _clients[instanceDirectory] = client;
+ return client;
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_lock)
+ {
+ foreach (var client in _clients.Values) client.Dispose();
+ _clients.Clear();
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Manager.Web/InstanceRouting.cs b/src/ClawdDotNet.Manager.Web/InstanceRouting.cs
new file mode 100644
index 0000000..6b7a844
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/InstanceRouting.cs
@@ -0,0 +1,55 @@
+using Microsoft.AspNetCore.Http;
+
+namespace ClawdDotNet.Manager.Web;
+
+///
+/// Welche Instanz eine Anfrage meint.
+///
+/// Der Ordnername ist die Kennung. Er ist eindeutig — zwei Instanzen können
+/// nicht im selben Verzeichnis liegen —, er ist stabil, und er ist lesbar. Die
+/// Instanzkennung aus der Konfiguration wäre ebenfalls eindeutig, aber man müsste jede
+/// Instanzdatei lesen, um von ihr auf ein Verzeichnis zu kommen; der Manager hat den Pfad
+/// ohnehin in der Hand.
+///
+/// Und wenn nichts dabeisteht? Läuft genau eine Instanz, ist sie gemeint —
+/// alles andere wäre Förmlichkeit gegenüber dem Normalfall. Laufen mehrere, wird
+/// nachgefragt statt geraten: Eine Anfrage an die falsche Instanz ist schlimmer als eine
+/// abgelehnte.
+///
+internal static class InstanceRouting
+{
+ /// Die Kopfzeile, mit der die Weboberfläche die Instanz benennt.
+ public const string HeaderName = "X-Instanz";
+
+ public static string IdOf(ManagedInstance instance) => Path.GetFileName(instance.Directory);
+
+ /// Die gemeinte Instanz, oder eine Begründung, warum keine bestimmbar ist.
+ public static (ManagedInstance? Instance, string? Problem) Resolve(
+ InstanceRegistry registry, HttpContext context)
+ {
+ var alle = registry.Instances;
+
+ if (context.Request.Headers.TryGetValue(HeaderName, out var genannt)
+ && !string.IsNullOrWhiteSpace(genannt))
+ {
+ var id = genannt.ToString().Trim();
+
+ var treffer = alle.FirstOrDefault(
+ i => string.Equals(IdOf(i), id, StringComparison.OrdinalIgnoreCase));
+
+ return treffer is null
+ ? (null, $"Es gibt keine Instanz '{id}'.")
+ : (treffer, null);
+ }
+
+ var laufend = alle.Where(i => i.Condition == InstanceCondition.Running).ToList();
+
+ return laufend.Count switch
+ {
+ 1 => (laufend[0], null),
+ 0 => (null, "Es läuft keine Instanz."),
+ _ => (null, $"Es laufen {laufend.Count} Instanzen. "
+ + $"Welche gemeint ist, gehört in die Kopfzeile {HeaderName}.")
+ };
+ }
+}
diff --git a/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs
new file mode 100644
index 0000000..5212aef
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/ManagerWebHost.cs
@@ -0,0 +1,228 @@
+using ClawdDotNet.Core.Logging;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Manager.Web;
+
+///
+/// Die eine Weboberfläche für alle Instanzen (#528, E-M5).
+///
+/// Eine Adresse, ein Zugang, alle Instanzen. Der Manager besitzt Port, Zugang
+/// und Rahmen; Anfragen zu Chat, Agenten, Aufgaben und Freigaben reicht er über den Socket
+/// an die betroffene Instanz weiter. Vorher hatte jede Instanz ihren eigenen Port, und
+/// zwei Instanzen stritten sich stillschweigend um die Vorbelegung 7001.
+///
+/// Er leitet weiter, er implementiert nicht. Unter /api/verwaltung
+/// steht, was den Manager selbst angeht: welche Instanzen es gibt, anlegen, starten,
+/// anhalten. Alles andere unter /api geht unverändert an eine Instanz. Wer hier
+/// einen Endpunkt ergänzt, der etwas über Agenten oder Aufgaben weiß, hat Fachlogik in
+/// den Manager gelegt.
+///
+/// Zwei Dinge, die er nicht kann und nicht können soll: Er fasst keine
+/// state.db an — die gehört genau einem Prozess, und prozessübergreifend gilt die
+/// Schreibsperre von SqliteStorage nicht. Und er führt keine Werkzeuge aus;
+/// Telegram, Rocket.Chat, Mail und FTP laufen ausschließlich in den Instanzen.
+///
+public sealed class ManagerWebHost : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+ private readonly ILogger _logger;
+ private readonly InstanceForwarding _forwarding;
+
+ private ManagerWebHost(
+ WebApplication app, ILogger logger, InstanceForwarding forwarding, string url)
+ {
+ _app = app;
+ _logger = logger;
+ _forwarding = forwarding;
+ Url = url;
+ }
+
+ /// Die Adresse, unter der die Oberfläche hört.
+ public string Url { get; }
+
+ ///
+ /// Fährt die Weboberfläche hoch.
+ ///
+ /// Gibt in jedem Fehlerfall null zurück und wirft nicht. Ein
+ /// Manager, der nicht anläuft, weil ein Port belegt ist, wäre genau dann im Weg, wenn
+ /// man ihn braucht: Er beaufsichtigt dann auch keine Instanzen mehr. Was schiefging,
+ /// steht im Protokoll.
+ ///
+ public static ManagerWebHost? TryStart(ManagerCore manager, ILoggerFactory loggerFactory)
+ {
+ var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
+ var settings = manager.Settings;
+
+ var token = settings.WebUiToken?.Trim() ?? "";
+
+ if (token.Length < AccessToken.MinimumLength)
+ {
+ // Keine halben Sachen: Ein Netzwerkdienst, der Agenten mit Datei-, Mail- und
+ // FTP-Zugriff steuert, ist nichts, was mit einem schwachen oder gar keinem
+ // Token offen stehen darf.
+ logger.LogWarning(
+ "Weboberfläche nicht gestartet: Es fehlt ein Zugangstoken von mindestens "
+ + "{Mindestlänge} Zeichen (webUiToken in manager.json).",
+ AccessToken.MinimumLength);
+
+ return null;
+ }
+
+ var loopback = !string.Equals(settings.WebUiBinding, "any", StringComparison.OrdinalIgnoreCase);
+ var host = loopback ? "127.0.0.1" : "0.0.0.0";
+ var url = $"http://{host}:{settings.WebUiPort}";
+
+ var forwarding = new InstanceForwarding(logger);
+
+ try
+ {
+ var app = Build(manager, loggerFactory, url, token, forwarding);
+
+ app.StartAsync().GetAwaiter().GetResult();
+
+ var anzeige = loopback ? url : url.Replace("0.0.0.0", Environment.MachineName);
+
+ logger.LogInformation(
+ "Weboberfläche hört auf {Url} ({Bindung})",
+ anzeige, loopback ? "nur lokal" : "alle Adressen");
+
+ return new ManagerWebHost(app, logger, forwarding, anzeige);
+ }
+ catch (Exception ex)
+ {
+ forwarding.Dispose();
+ logger.LogWarning(ex, "Weboberfläche konnte nicht gestartet werden ({Url})", url);
+ return null;
+ }
+ }
+
+ private static WebApplication Build(
+ ManagerCore manager,
+ ILoggerFactory loggerFactory,
+ string url,
+ string token,
+ InstanceForwarding forwarding)
+ {
+ var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions
+ {
+ ContentRootPath = AppContext.BaseDirectory,
+ EnvironmentName = Environments.Production,
+ ApplicationName = typeof(ManagerWebHost).Assembly.GetName().Name
+ });
+
+ builder.Logging.ClearProviders();
+ builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
+
+ // ASP.NET protokolliert auf Information jede einzelne Anfrage - auch jede
+ // Schriftart und jedes Stylesheet. Ab Warnung ist es das, was man wissen will.
+ builder.Logging.AddFilter("Microsoft", Microsoft.Extensions.Logging.LogLevel.Warning);
+ builder.Logging.AddFilter("System", Microsoft.Extensions.Logging.LogLevel.Warning);
+
+ builder.WebHost.UseUrls(url);
+
+ builder.Services.AddSingleton(manager);
+ builder.Services.AddSingleton(manager.Instances);
+ builder.Services.AddSingleton(AccessToken.For(token));
+ builder.Services.AddSingleton(forwarding);
+
+ var app = builder.Build();
+
+ app.Use(ReportUnhandled(loggerFactory));
+ app.Use(CheckToken);
+
+ MapStaticFiles(app);
+
+ app.MapVerwaltung();
+ app.MapEvents();
+ app.MapForwarding();
+
+ return app;
+ }
+
+ private static Func ReportUnhandled(
+ ILoggerFactory loggerFactory)
+ {
+ var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager.Web");
+
+ return async (context, next) =>
+ {
+ try
+ {
+ await next(context);
+ }
+ catch (OperationCanceledException)
+ {
+ // Der Browser hat aufgelegt, oder der Manager faehrt herunter. Bewusst
+ // ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
+ // ApplicationStopping ab, und RequestAborted ist dabei nicht gesetzt.
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Unbehandelter Fehler in {Pfad}", context.Request.Path);
+
+ if (context.Response.HasStarted) return;
+
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ await context.Response.WriteAsJsonAsync(new { fehler = "Interner Fehler." });
+ }
+ };
+ }
+
+ private static async Task CheckToken(HttpContext context, RequestDelegate next)
+ {
+ if (!AccessToken.NeedsToken(context.Request.Path))
+ {
+ await next(context);
+ return;
+ }
+
+ var gate = context.RequestServices.GetRequiredService();
+
+ if (!gate.Matches(AccessToken.Read(context.Request)))
+ {
+ context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ await context.Response.WriteAsJsonAsync(new { fehler = "Kein gültiges Token." });
+ return;
+ }
+
+ await next(context);
+ }
+
+ private static void MapStaticFiles(WebApplication app)
+ {
+ var webRoot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
+
+ if (!Directory.Exists(webRoot))
+ {
+ app.Logger.LogWarning(
+ "Kein wwwroot unter {Pfad} — es läuft nur die API.", webRoot);
+ return;
+ }
+
+ var files = new PhysicalFileProvider(webRoot);
+
+ app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
+ app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ await _app.StopAsync(TimeSpan.FromSeconds(5));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Weboberfläche ließ sich nicht sauber beenden");
+ }
+
+ _forwarding.Dispose();
+ await _app.DisposeAsync();
+ }
+}
diff --git a/src/ClawdDotNet.Manager.Web/MergedEventStream.cs b/src/ClawdDotNet.Manager.Web/MergedEventStream.cs
new file mode 100644
index 0000000..5e77e96
--- /dev/null
+++ b/src/ClawdDotNet.Manager.Web/MergedEventStream.cs
@@ -0,0 +1,217 @@
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading.Channels;
+using ClawdDotNet.Core.Instances;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Manager.Web;
+
+///
+/// N Instanzen, ein Ereignisstrom zum Browser.
+///
+/// Die eine Stelle, die wirklich neu gedacht werden musste (Konzept,
+/// Abschnitt 8). Alles andere an der Weboberfläche ließ sich weiterleiten; ein
+/// Ereignisstrom nicht. Der Browser hält eine Verbindung, die Instanzen halten je eine,
+/// und dazwischen muss jemand zusammenführen — und an jedes Ereignis schreiben, von wem
+/// es kommt. Ohne das wäre in einem Fenster mit drei Instanzen nicht zu erkennen, welcher
+/// Agent gerade spricht.
+///
+/// Ergänzt statt verpackt. Es wäre einfacher, jedes Ereignis in einen
+/// Umschlag zu stecken ({"instanz":…,"daten":…}) — dann müsste aber jede Ansicht
+/// der Weboberfläche umgeschrieben werden. Stattdessen bekommt das vorhandene Objekt ein
+/// Feld instanz dazu; alles, was heute Felder liest, liest sie weiter.
+///
+/// Eine tote Instanz reißt den Strom nicht ab. Fällt eine weg, endet ihre
+/// Zuleitung, und die anderen laufen weiter. Der Browser merkt davon nichts — was er
+/// merken soll, sagt ihm die Aufsicht als eigenes Ereignis.
+///
+internal static class MergedEventStream
+{
+ ///
+ /// 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 Managers füllen. Ein verlorener
+ /// Zwischenstand ist der bessere Verlust — die Seite lädt den Verlauf beim Verbinden
+ /// ohnehin neu.
+ ///
+ private const int QueueDepth = 512;
+
+ public static async Task StreamAsync(
+ HttpContext context,
+ InstanceRegistry registry,
+ string instancePath,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ var queue = Channel.CreateBounded(new BoundedChannelOptions(QueueDepth)
+ {
+ FullMode = BoundedChannelFullMode.DropOldest,
+ SingleReader = true
+ });
+
+ var laufend = registry.Instances
+ .Where(i => i.Condition == InstanceCondition.Running)
+ .ToList();
+
+ using var leben = CancellationTokenSource.CreateLinkedTokenSource(ct);
+
+ var zuleitungen = laufend
+ .Select(instanz => PumpAsync(instanz, instancePath, queue.Writer, logger, leben.Token))
+ .ToList();
+
+ 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 ({laufend.Count} Instanzen)\n\n", ct).ConfigureAwait(false);
+
+ await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
+
+ using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
+
+ try
+ {
+ var naechstes = queue.Reader.ReadAsync(ct).AsTask();
+ var schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
+
+ while (true)
+ {
+ var fertig = await Task.WhenAny(naechstes, schlag).ConfigureAwait(false);
+
+ if (fertig == naechstes)
+ {
+ await context.Response.WriteAsync(await naechstes, ct).ConfigureAwait(false);
+ naechstes = queue.Reader.ReadAsync(ct).AsTask();
+ }
+ else
+ {
+ if (!await schlag) break;
+
+ // 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).ConfigureAwait(false);
+ schlag = heartbeat.WaitForNextTickAsync(ct).AsTask();
+ }
+
+ await context.Response.Body.FlushAsync(ct).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Der Browser hat aufgelegt, oder der Manager faehrt herunter.
+ }
+ catch (ChannelClosedException)
+ {
+ // Der Strom wird abgeraeumt.
+ }
+ finally
+ {
+ await leben.CancelAsync().ConfigureAwait(false);
+ queue.Writer.TryComplete();
+
+ try { await Task.WhenAll(zuleitungen).ConfigureAwait(false); }
+ catch { /* Abgebrochene Zuleitungen sind der Normalfall beim Aufraeumen. */ }
+ }
+ }
+
+ ///
+ /// Eine Zuleitung: hört am Socket einer Instanz und schiebt weiter, was kommt.
+ ///
+ private static async Task PumpAsync(
+ ManagedInstance instanz,
+ string instancePath,
+ ChannelWriter ziel,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ var id = InstanceRouting.IdOf(instanz);
+
+ try
+ {
+ using var client = InstanceEndpoint.CreateClient(instanz.Directory);
+
+ // Kein Zeitlimit: Ein Ereignisstrom endet nie von selbst - das ist sein Zweck.
+ client.Timeout = Timeout.InfiniteTimeSpan;
+
+ using var antwort = await client.GetAsync(
+ instancePath, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
+
+ antwort.EnsureSuccessStatusCode();
+
+ await using var strom = await antwort.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ using var leser = new StreamReader(strom, Encoding.UTF8);
+
+ string? art = null;
+
+ while (await leser.ReadLineAsync(ct).ConfigureAwait(false) is { } zeile)
+ {
+ if (zeile.StartsWith(": ", StringComparison.Ordinal))
+ continue; // Herzschlag der Instanz. Der Manager hat einen eigenen.
+
+ if (zeile.StartsWith("event: ", StringComparison.Ordinal))
+ {
+ art = zeile["event: ".Length..];
+ continue;
+ }
+
+ if (!zeile.StartsWith("data: ", StringComparison.Ordinal))
+ continue;
+
+ ziel.TryWrite(Format(art ?? "message", zeile["data: ".Length..], id, instanz.Name));
+ art = null;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Der Strom wird abgeraeumt oder der Browser hat aufgelegt.
+ }
+ catch (Exception ex)
+ {
+ // Eine Instanz, die waehrend des Zuhoerens weggeht, ist kein Fehler des
+ // Managers - und sie darf die anderen Zuleitungen nicht mitnehmen.
+ logger.LogInformation(
+ "Ereigniszuleitung von Instanz {Name} beendet: {Grund}", instanz.Name, ex.Message);
+ }
+ }
+
+ ///
+ /// Hängt instanz und instanzName an das Ereignis.
+ ///
+ /// Ist der Inhalt kein Objekt — oder gar kein JSON —, wird er unverändert
+ /// weitergereicht. Ein Ereignis zu verschlucken, weil sein Inhalt unerwartet aussieht,
+ /// wäre die schlechtere Antwort: Der Manager leitet weiter, er urteilt nicht.
+ ///
+ private static string Format(string art, string daten, string id, string name)
+ {
+ try
+ {
+ if (JsonNode.Parse(daten) is JsonObject objekt)
+ {
+ objekt["instanz"] = id;
+ objekt["instanzName"] = name;
+ return $"event: {art}\ndata: {objekt.ToJsonString()}\n\n";
+ }
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ // Siehe oben.
+ }
+
+ return $"event: {art}\ndata: {daten}\n\n";
+ }
+
+ 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";
+ }
+}
diff --git a/src/ClawdDotNet.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/app.css
rename to src/ClawdDotNet.Manager.Web/wwwroot/app.css
diff --git a/src/ClawdDotNet.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/app.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/app.js
diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlow-regular.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-regular.ttf
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/fonts/barlow-regular.ttf
rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-regular.ttf
diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlow-semibold.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-semibold.ttf
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/fonts/barlow-semibold.ttf
rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlow-semibold.ttf
diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-regular.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-regular.ttf
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-regular.ttf
rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-regular.ttf
diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-semibold.ttf b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-semibold.ttf
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/fonts/barlowcondensed-semibold.ttf
rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/barlowcondensed-semibold.ttf
diff --git a/src/ClawdDotNet.Web/wwwroot/fonts/ofl.txt b/src/ClawdDotNet.Manager.Web/wwwroot/fonts/ofl.txt
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/fonts/ofl.txt
rename to src/ClawdDotNet.Manager.Web/wwwroot/fonts/ofl.txt
diff --git a/src/ClawdDotNet.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/index.html
rename to src/ClawdDotNet.Manager.Web/wwwroot/index.html
diff --git a/src/ClawdDotNet.Web/wwwroot/modules/api.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/modules/api.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js
diff --git a/src/ClawdDotNet.Web/wwwroot/modules/dom.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/modules/dom.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js
diff --git a/src/ClawdDotNet.Web/wwwroot/modules/events.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/modules/events.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/events.js
diff --git a/src/ClawdDotNet.Web/wwwroot/modules/router.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/router.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/modules/router.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/router.js
diff --git a/src/ClawdDotNet.Web/wwwroot/modules/theme.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/theme.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/modules/theme.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/modules/theme.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/agents.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/agents.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/chat.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/chat.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/chat.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/info.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/info.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/info.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/info.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/login.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/login.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/login.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/login.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/logs.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/logs.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/logs.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/placeholders.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/placeholders.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/placeholders.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/services.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/services.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/services.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/services.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/staging.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/tasks.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js
diff --git a/src/ClawdDotNet.Web/wwwroot/views/usage.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js
similarity index 100%
rename from src/ClawdDotNet.Web/wwwroot/views/usage.js
rename to src/ClawdDotNet.Manager.Web/wwwroot/views/usage.js
diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs
index 3c02349..744088d 100644
--- a/src/ClawdDotNet.Manager/ManagerSettings.cs
+++ b/src/ClawdDotNet.Manager/ManagerSettings.cs
@@ -143,8 +143,23 @@ public sealed class ManagerSettingsStore
public ManagerSettings Settings { get; private set; } = new();
+ ///
+ /// Warum die Datei nicht gelesen werden konnte — oder null, wenn alles ging.
+ ///
+ /// Warum das nach aussen muss. Eine unlesbare Datei verhindert den Start
+ /// nicht; das wäre der schlechtere Zustand, denn dann liefe gar keine Instanz mehr.
+ /// Aber „läuft weiter" heisst nicht „niemand erfährt es": Mit Vorgaben statt der
+ /// eigenen Einstellungen sucht der Manager im falschen Verzeichnis nach Instanzen,
+ /// hört auf einem anderen Port und fragt nach einer Lizenz, die woanders steht — und
+ /// nichts davon sagt, woran es liegt. Genau das ist beim ersten Abnahmelauf passiert:
+ /// ein einzelner Backslash zu wenig, und eine Viertelstunde Ratelei.
+ ///
+ public string? LoadProblem { get; private set; }
+
public ManagerSettings Load()
{
+ LoadProblem = null;
+
if (!File.Exists(SettingsPath))
{
Settings = new ManagerSettings();
@@ -160,7 +175,9 @@ public sealed class ManagerSettingsStore
catch (Exception ex) when (ex is JsonException or IOException)
{
// Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe
- // gar keine Instanz mehr, und das ist der schlechtere Zustand.
+ // gar keine Instanz mehr, und das ist der schlechtere Zustand. Aber der Grund
+ // gehoert nach draussen, sonst arbeitet er stillschweigend mit Vorgaben.
+ LoadProblem = ex.Message;
Settings = new ManagerSettings();
return Settings;
}
diff --git a/src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj b/src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj
new file mode 100644
index 0000000..dacc8af
--- /dev/null
+++ b/src/ClawdDotNet.ManagerApp/ClawdDotNet.ManagerApp.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ ClawdDotNet.ManagerApp
+
+
+ ClawdDotNet.Instanzmanager
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs
new file mode 100644
index 0000000..61a0f38
--- /dev/null
+++ b/src/ClawdDotNet.ManagerApp/Program.cs
@@ -0,0 +1,272 @@
+using System.Runtime.InteropServices;
+using ClawdDotNet.Core.Deploymentcenter;
+using ClawdDotNet.Core.Instances;
+using ClawdDotNet.Core.Logging;
+using ClawdDotNet.Licensing;
+using ClawdDotNet.Manager;
+using ClawdDotNet.Manager.Web;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.ManagerApp;
+
+///
+/// Der Instanzmanager als Prozess (#528, Stufe 4).
+///
+/// Ein Prozess je Maschine. Er startet Instanzen, beaufsichtigt sie, meldet
+/// stellvertretend, was sie nicht mehr melden können, und trägt die eine Weboberfläche für
+/// alle. Er hält keine Datenbank, führt keine Werkzeuge aus und kennt keine Fachlogik —
+/// das alles läuft in den Instanzprozessen.
+///
+/// Und er beendet beim Herunterfahren keine Instanz (E-M6). Wer den Manager
+/// schließt, will den Manager schließen. Läuft er wieder an, findet er die laufenden über
+/// ihren Socket wieder. Das ist der Grund, warum er nicht der Einzelpunkt ist, gegen den
+/// dieses Vorhaben gebaut wurde.
+///
+internal static class Program
+{
+ private const int ExitOk = 0;
+ private const int ExitNoWebUi = 2;
+
+ /// Wo manager.json liegt, wenn nicht am üblichen Ort.
+ private const string ConfigArgument = "--config";
+
+ public static async Task Main(string[] args)
+ {
+ var settings = new ManagerSettingsStore(ReadConfigDirectory(args));
+ settings.Load();
+
+ var logDirectory = Path.Combine(
+ ClawdDotNet.Core.Storage.AppPaths.DataDirectory, "Logs", "Manager");
+
+ using var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(
+ logDirectory, ClawdDotNet.Core.Logging.LogLevel.Info);
+
+ var logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
+
+ logger.LogInformation("Instanzmanager startet.");
+ logger.LogInformation("Einstellungen: {Pfad}", settings.SettingsPath);
+
+ if (settings.LoadProblem is { } problem)
+ {
+ logger.LogError(
+ "Die Einstellungen unter {Pfad} liessen sich nicht lesen ({Grund}). "
+ + "Der Manager arbeitet mit Vorgaben — Instanzverzeichnis, Port und Lizenz "
+ + "stehen damit anders, als in der Datei steht.",
+ settings.SettingsPath, problem);
+ }
+
+ logger.LogInformation("Protokoll: {Pfad}", logDirectory);
+ logger.LogInformation("Instanzen: {Pfad}", settings.Settings.InstancesDirectory);
+
+ // Was tatsaechlich gilt, nicht was in der Datei steht: Beim Abnahmelauf hat eine
+ // halbe Stunde gefehlt, weil beides auseinanderlief und nichts es sagte.
+ logger.LogInformation(
+ "Weboberfläche: Port {Port}, Bindung {Bindung}, Token {Token}. "
+ + "Instanz-Programmdatei: {Exe}",
+ settings.Settings.WebUiPort,
+ settings.Settings.WebUiBinding,
+ string.IsNullOrWhiteSpace(settings.Settings.WebUiToken)
+ ? "fehlt"
+ : $"{settings.Settings.WebUiToken.Length} Zeichen",
+ string.IsNullOrWhiteSpace(settings.Settings.InstanceExecutable)
+ ? "(neben dem Manager)"
+ : settings.Settings.InstanceExecutable);
+
+ await using var manager = Build(settings, loggerFactory, logger);
+
+ if (!await manager.StartAsync())
+ logger.LogWarning("Der Manager läuft ohne gültige Lizenz — er startet keine Instanz.");
+
+ await using var web = ManagerWebHost.TryStart(manager, loggerFactory);
+
+ if (web is null)
+ {
+ // Ohne Weboberflaeche hat dieser Prozess keinen Zugang: Ein Fenster hat er
+ // noch nicht (Stufe 5), und ohne Zugang laesst sich nichts starten und nichts
+ // ansehen. Dann ist Beenden ehrlicher als Weiterlaufen.
+ logger.LogError(
+ "Kein Zugang zum Manager: Die Weboberfläche ist nicht hochgekommen. "
+ + "Ohne sie lässt sich nichts bedienen — der Manager beendet sich.");
+
+ return ExitNoWebUi;
+ }
+
+ Console.WriteLine(web.Url);
+ logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url);
+
+ await WaitForShutdownAsync(logger);
+
+ logger.LogInformation(
+ "Instanzmanager wird beendet. Laufende Instanzen laufen weiter.");
+
+ return ExitOk;
+ }
+
+ private static ManagerCore Build(
+ ManagerSettingsStore settings, ILoggerFactory loggerFactory, ILogger logger)
+ {
+ var s = settings.Settings;
+
+ var directories = new InstanceDirectoryManager(
+ Path.GetFullPath(s.InstancesDirectory));
+
+ var launcher = new ProcessInstanceLauncher(ResolveInstanceExecutable(s, logger));
+ var channel = new SocketInstanceChannel();
+
+ var registry = new InstanceRegistry(
+ directories, launcher, channel, loggerFactory, s.ToSupervisionOptions());
+
+ // ─── Deploymentcenter: Sub-Token und stellvertretende Meldung ───
+ IInstanceTokenSource tokens = new NoInstanceTokenSource();
+ SupervisionReporter? reporter = null;
+
+ if (!string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken))
+ {
+ var api = new DeploymentcenterApi(s.DeploymentcenterUrl, s.DeploymentcenterMasterToken);
+
+ tokens = new DeploymentcenterTokenSource(
+ directories, api, loggerFactory.CreateLogger("ClawdDotNet.Manager.Token"));
+
+ reporter = new SupervisionReporter(
+ new BugtrackerClient(api, LicenseInfo.ProductSlug, "production", Version()),
+ loggerFactory.CreateLogger("ClawdDotNet.Manager.Meldung"));
+ }
+ else
+ {
+ logger.LogInformation(
+ "Kein Deploymentcenter-Token hinterlegt: keine Sub-Token, keine "
+ + "stellvertretenden Meldungen. Befunde stehen im Protokoll.");
+ }
+
+ // ─── Lizenz (E-M3) ───
+ LicenseGate? gate = null;
+ LicenseWatch? watch = null;
+
+ if (!string.IsNullOrWhiteSpace(s.LicenseKey) || !string.IsNullOrWhiteSpace(s.DeploymentcenterUrl))
+ {
+ gate = new LicenseGate(
+ new ManagerLicenseStore(settings),
+ loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"),
+ LicensePrompt(logger),
+ Version());
+
+ watch = new LicenseWatch(gate, loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz"));
+ }
+
+ return new ManagerCore(settings, registry, tokens, loggerFactory, reporter, gate, watch);
+ }
+
+ ///
+ /// Das Verzeichnis hinter --config, oder null für den üblichen Ort.
+ ///
+ /// Wozu, wenn es CLAWD_CONFIG_DIR gibt. Weil eine Umgebungsvariable
+ /// an Kindprozesse vererbt wird und ein Argument nicht. Der Manager startet Instanzen;
+ /// mit der Variablen läse jede von ihnen ihre Anwendungseinstellungen aus dem
+ /// Verzeichnis des Managers — nachgemessen, sie legt sich dort eine eigene
+ /// Settings.json an. Ein Argument sagt nur diesem einen Prozess, wo seine Datei
+ /// liegt, und genau das ist gemeint.
+ ///
+ /// Für eine systemd-Unit ist es ohnehin der geradere Weg als eine
+ /// Environment=-Zeile.
+ ///
+ private static string? ReadConfigDirectory(string[] args)
+ {
+ for (var i = 0; i < args.Length - 1; i++)
+ {
+ if (string.Equals(args[i], ConfigArgument, StringComparison.OrdinalIgnoreCase))
+ return args[i + 1];
+ }
+
+ return null;
+ }
+
+ ///
+ /// Wie der Manager nach einem Lizenzschlüssel fragt: gar nicht.
+ ///
+ /// Er ist ein Hintergrundprozess. Er wird von einem Dienst gestartet, von einem
+ /// Skript oder von einem Fenster, das es noch nicht gibt (Stufe 5) — in keinem dieser
+ /// Fälle sitzt jemand an seiner Konsole. Console.ReadLine steht dort für immer,
+ /// ohne dass jemand die Frage je zu sehen bekommt, und weil der Manager die Instanzen
+ /// startet, steht damit die ganze Maschine, nicht bloss eine Anwendung.
+ ///
+ /// Genau so ist der erste Abnahmelauf ausgegangen: Der Prozess hing an
+ /// „Lizenzschlüssel: ", die Weboberfläche kam nie hoch, und im Protokoll stand nichts
+ /// davon — die Frage steht auf der Standardausgabe.
+ ///
+ /// Ein Test auf Console.IsInputRedirected reicht nicht. Er
+ /// beantwortet „ist die Eingabe umgeleitet", nicht „kann jemand antworten": Ein
+ /// Prozess ganz ohne Konsole — der gefährlichste Fall — ist nicht umgeleitet. Wo die
+ /// falsche Antwort einen Aufpasser für immer anhält, ist die Frage die falsche Frage.
+ /// Ein Schlüssel gehört in manager.json, hineingeschrieben von der Einrichtung
+ /// oder vom Fenster.
+ ///
+ private static ILicensePrompt LicensePrompt(ILogger logger)
+ => new NonInteractiveLicensePrompt(text => logger.LogWarning("{Meldung}", text));
+
+ ///
+ /// Wo die Programmdatei der Instanz liegt.
+ ///
+ /// Ohne Eintrag: neben der des Managers. Das ist der Normalfall — beide kommen
+ /// aus demselben Paket —, und ein Eintrag ist die Ausnahme für eine Entwicklungsablage,
+ /// in der die beiden auseinanderliegen.
+ ///
+ private static string ResolveInstanceExecutable(ManagerSettings settings, ILogger logger)
+ {
+ if (!string.IsNullOrWhiteSpace(settings.InstanceExecutable))
+ return Path.GetFullPath(settings.InstanceExecutable);
+
+ var name = OperatingSystem.IsWindows() ? "ClawdDotNet.exe" : "ClawdDotNet";
+ var daneben = Path.Combine(AppContext.BaseDirectory, name);
+
+ if (!File.Exists(daneben))
+ {
+ // Nicht abbrechen: Der Manager kann trotzdem beaufsichtigen, was schon laeuft,
+ // und die Weboberflaeche zeigt es an. Nur starten kann er dann nichts - und
+ // das soll man wissen, bevor man es versucht.
+ logger.LogWarning(
+ "Die Programmdatei der Instanzen liegt nicht unter {Pfad}. Ohne sie lässt "
+ + "sich keine Instanz starten (instanceExecutable in manager.json).", daneben);
+ }
+
+ return daneben;
+ }
+
+ private static string Version()
+ => typeof(Program).Assembly.GetName().Version?.ToString() ?? "0.0.0";
+
+ ///
+ /// Wartet, bis jemand Schluss sagt.
+ ///
+ /// Das Signal wird abgefangen, nicht durchgelassen
+ /// (context.Cancel = true). Sonst räumte die Laufzeit den Prozess ab, während
+ /// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer des
+ /// Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529, nur diesmal
+ /// beim Aufpasser.
+ ///
+ private static async Task WaitForShutdownAsync(ILogger logger)
+ {
+ var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ void Beenden(string grund)
+ {
+ if (schluss.TrySetResult())
+ logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
+ }
+
+ using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
+ {
+ context.Cancel = true;
+ Beenden("SIGTERM");
+ });
+
+ using var sigint = PosixSignalRegistration.Create(PosixSignal.SIGINT, context =>
+ {
+ context.Cancel = true;
+ Beenden("SIGINT");
+ });
+
+ AppDomain.CurrentDomain.ProcessExit += (_, _) => Beenden("ProcessExit");
+
+ await schluss.Task;
+ }
+}
diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs
index 265689b..7e2e25f 100644
--- a/src/ClawdDotNet.Web/InstanceHttpHost.cs
+++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs
@@ -161,7 +161,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
// Die Protokollierung der Anwendung uebernehmen, statt eine zweite aufzumachen.
builder.Logging.ClearProviders();
- builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
+ builder.Logging.AddProvider(new Core.Logging.SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener
// Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes
diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs
index 0f10580..5174ce4 100644
--- a/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/ManagerSettingsTests.cs
@@ -53,6 +53,40 @@ public sealed class ManagerSettingsTests : IDisposable
erneut.LicenseKey.ShouldBe("LIZ-1234-5678");
}
+ ///
+ /// Eine von Hand oder von PowerShell geschriebene Datei hat oft ein Byte-Order-Mark,
+ /// und sie darf trotzdem gelten.
+ ///
+ /// Beim Abnahmelauf sah es aus, als würde sie ignoriert — der Manager arbeitete
+ /// mit Vorgaben. Dieser Test klärt, ob das an der Datei lag.
+ ///
+ [Fact]
+ public void Eine_Datei_mit_Byte_Order_Mark_gilt_auch()
+ {
+ var pfad = Path.Combine(_dir, "manager.json");
+
+ // Genau so, wie PowerShell es schreibt: mit BOM, mit doppelten Leerzeichen nach
+ // dem Doppelpunkt, und mit verdoppelten Backslashes im Pfad.
+ var inhalt = """
+ {
+ "instanceExecutable": "J:\\Programme\\ClawdDotNet.exe",
+ "webUiPort": 7009,
+ "deploymentcenterUrl": ""
+ }
+ """;
+
+ File.WriteAllText(pfad, inhalt, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
+ File.ReadAllBytes(pfad)[0].ShouldBe((byte)0xEF, "Der Test braucht das BOM.");
+
+ var store = new ManagerSettingsStore(_dir);
+ var settings = store.Load();
+
+ store.LoadProblem.ShouldBeNull();
+ settings.InstanceExecutable.ShouldBe(@"J:\Programme\ClawdDotNet.exe");
+ settings.WebUiPort.ShouldBe(7009);
+ settings.DeploymentcenterUrl.ShouldBe("");
+ }
+
///
/// Eine unlesbare Datei darf den Manager nicht am Start hindern — dann liefe gar
/// keine Instanz mehr, und das ist der schlechtere Zustand.
@@ -62,7 +96,13 @@ public sealed class ManagerSettingsTests : IDisposable
{
File.WriteAllText(Path.Combine(_dir, "manager.json"), "{ das ist kein JSON");
- new ManagerSettingsStore(_dir).Load().WebUiPort.ShouldBe(7000);
+ var store = new ManagerSettingsStore(_dir);
+
+ store.Load().WebUiPort.ShouldBe(7000);
+
+ // Und der Grund steht fest - sonst arbeitet der Manager stillschweigend mit
+ // Vorgaben, und niemand kann sagen, warum sein Instanzverzeichnis falsch ist.
+ store.LoadProblem.ShouldNotBeNullOrWhiteSpace();
}
///