From dcffcdcff55bcc307ea1a5c53d2610c50e51ff63 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 10 Sep 2026 11:15:59 +0200 Subject: [PATCH] Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531) Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt. Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder. Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass: Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden. Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202 beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf seiner Frist jedes Mal ab. InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist. - ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten, Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb. - GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt genau dann, wenn sie haengt. - Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten, nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz. - EnsureDeadAsync vor jedem Start. Weiteres aus dem Review: - .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/. InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt. - Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein. - Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus. - [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand abstellen konnte, solange E-M5 nicht fertig ist. - Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht. - Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und der Port kommt aus manager.json statt fest aus der Zeile. Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535). 806 Tests gruen, 0 Warnungen. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 +- Directory.Build.props | 7 + docs/Instanzmanager-Konzept.md | 89 +++- src/ClawdDotNet.App/AppHost.cs | 78 +++- src/ClawdDotNet.Core/Config/InstanceConfig.cs | 10 +- src/ClawdDotNet.Core/Engine/AgentEngine.cs | 68 +++ src/ClawdDotNet.Core/Engine/AgentRunResult.cs | 11 +- .../Instances/InstanceEndpoint.cs | 207 +++++++++ .../Instances/InstanceLayout.cs | 71 +++ .../Instances/ShutdownCoordinator.cs | 276 +++++++++++ src/ClawdDotNet.Core/Storage/SystemShell.cs | 31 ++ src/ClawdDotNet.Core/Tasks/TaskScanner.cs | 52 +++ src/ClawdDotNet.Desktop/App.axaml.cs | 412 ++++++++++++++++- src/ClawdDotNet.Desktop/HeadlessRun.cs | 8 +- .../Views/MainWindow.axaml.cs | 144 ++++++ src/ClawdDotNet.Manager.Web/Endpoints.cs | 252 ++++++++++ src/ClawdDotNet.Manager.Web/wwwroot/app.css | 338 ++++++++++++++ src/ClawdDotNet.Manager.Web/wwwroot/app.js | 271 +++++++++-- .../wwwroot/index.html | 19 +- .../wwwroot/modules/api.js | 62 ++- .../wwwroot/modules/dom.js | 50 +- .../wwwroot/views/agent-chats.js | 274 +++++++++++ .../wwwroot/views/agents.js | 32 +- .../wwwroot/views/backup.js | 426 +++++++++++++++++ .../wwwroot/views/chat.js | 22 +- .../wwwroot/views/info.js | 14 +- .../wwwroot/views/instances.js | 433 ++++++++++++++++++ .../wwwroot/views/login.js | 6 +- .../wwwroot/views/logs.js | 39 +- .../wwwroot/views/placeholders.js | 22 - .../wwwroot/views/settings.js | 245 ++++++++++ src/ClawdDotNet.Manager/InstanceChannel.cs | 73 ++- src/ClawdDotNet.Manager/InstanceRegistry.cs | 9 + src/ClawdDotNet.Manager/InstanceSupervisor.cs | 296 +++++++++++- src/ClawdDotNet.Manager/ManagerCore.cs | 4 + src/ClawdDotNet.Manager/ManagerSettings.cs | 20 +- src/ClawdDotNet.ManagerApp/Program.cs | 19 +- src/ClawdDotNet.Web/Contracts/Dtos.cs | 11 + src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs | 52 +++ .../Endpoints/ControlEndpoints.cs | 47 ++ .../Instances/ShutdownCoordinatorTests.cs | 160 +++++++ .../AutostartTests.cs | 89 +++- .../BackupManagementTests.cs | 96 ++++ tests/ClawdDotNet.Manager.Tests/Doubles.cs | 31 ++ .../InstanceRegistryTests.cs | 11 +- .../InstanceSupervisorTests.cs | 86 +++- .../ManagerCoreTests.cs | 14 +- .../AgentCommunicationTests.cs | 32 ++ 48 files changed, 4852 insertions(+), 171 deletions(-) create mode 100644 src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs create mode 100644 src/ClawdDotNet.Core/Instances/InstanceLayout.cs create mode 100644 src/ClawdDotNet.Core/Instances/ShutdownCoordinator.cs create mode 100644 src/ClawdDotNet.Manager.Web/wwwroot/views/agent-chats.js create mode 100644 src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js create mode 100644 src/ClawdDotNet.Manager.Web/wwwroot/views/instances.js create mode 100644 src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js create mode 100644 tests/ClawdDotNet.Core.Tests/Instances/ShutdownCoordinatorTests.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/BackupManagementTests.cs create mode 100644 tests/ClawdDotNet.Web.Tests/AgentCommunicationTests.cs diff --git a/.gitignore b/.gitignore index de97023..937864f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,8 @@ msbuild.wrn deploy/*.zip # ---- Runtime instance/agent data (created next to the binary) ---- -Instances/ -data/ +/Instances/ +/data/ # ---- OS files ---- Thumbs.db diff --git a/Directory.Build.props b/Directory.Build.props index 7c4f59e..d5bef64 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -34,6 +34,13 @@ Assemblies und nichts an Betriebsaufwand. --> embedded + + + de;en diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index 3a7fdf3..4d3e571 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -290,11 +290,9 @@ sondern die Prüfung aus E-M3. ja, aber nur, wenn die Instanz es verlangt. Der Schalter heißt „Autostart durch Manager" und steht in der Instanzdatei; siehe Abschnitt 9.7. -**Was ist mit einer Instanz, die verstummt?** Der Autostart greift nur bei einem -Absturz. Eine stumme Instanz lebt noch — sie neu zu starten hieße, sie erst -abzuschießen, und ob der Manager das von sich aus tun darf, ist nicht entschieden. -Bis dahin meldet er sie und lässt sie stehen. Genau dieser Zustand war der Anlass -(1.1), insofern ist die Frage nicht akademisch. +**Was ist mit einer Instanz, die verstummt? — Entschieden** (2026-09-10): Ja, der +Manager darf sie abschießen — aber erst am Ende einer Leiter, und nie ohne +Nachweis. Siehe Abschnitt 9.8. **Der Aufhänger aus 1.1 ist nicht geklärt** (#529). Er verschwindet mit dem Umbau vermutlich von selbst, weil es den Selbst-Neustart nicht mehr gibt — aber @@ -835,3 +833,84 @@ Diese drei Punkte sind **C#** und warten auf den Kern-Agenten. Nicht anfangen: 2. **Lizenzprüfung aus der Instanz nehmen** (E-M3 vollständig). Braucht vorher einen Handschlag, an dem eine Instanz erkennt, ob ein Manager da ist. 3. **Verstummte Instanzen neu starten?** Nicht entschieden — siehe Abschnitt 7. + +### 9.8 Geordnetes Ende und Puls + +Entschieden am 2026-09-10, aus zwei Befunden derselben Sitzung. + +**Der erste: Jedes Beenden war ein Abschneiden.** `TaskScanner.DisposeAsync` bricht den +laufenden Takt über sein Token ab — mitten in `ProcessAsync`, also *nachdem* eine Aufgabe +beansprucht wurde und *bevor* `CompleteClaimAsync` sie wieder freigibt. Sie steht danach +auf `running` und bleibt dort, bis das Lease abläuft. Ein Agentenlauf mitten im +Werkzeugaufruf verlor seine Antwort ganz. + +**Der zweite, schlimmere: Der Manager konnte eine Instanz mit Fenster überhaupt nicht +geordnet beenden.** `Control.ShutdownRequested` war nur kopflos verdrahtet +(`HeadlessRun`), nicht in der Avalonia-Anwendung — und ein Fenster ist die Vorgabe +(`InstanceWindows`). Die Bitte über den Socket wurde angenommen, mit 202 beantwortet, und +niemand hörte zu. Der Manager wartete seine Frist ab und schoss den Prozess dann **jedes +Mal** ab. Das geordnete Ende gab es auf dem Papier. + +#### Erst die Tür, dann warten + +`ShutdownCoordinator` (in `Core/Instances`) schließt zuerst alle Arbeitsquellen und wartet +danach, bis nichts mehr offen ist. Die Reihenfolge ist der ganze Trick: Andersherum +beansprucht der Scanner im nächsten Takt die nächste Aufgabe, und die Zahl der offenen +Arbeiten geht nie auf null. + +Zwei Quellen sind angemeldet — Agentenläufe und Aufgaben. Beide können genau zweierlei: +keine neue Arbeit mehr annehmen und sagen, wie viel noch läuft. Was daraus folgt, +entscheidet der Koordinator. Die Frist (zwei Minuten) ist eine Zusage, keine Hoffnung: +Danach endet die Instanz, ob fertig oder nicht — sonst hinge sie an einem Agentenlauf, der +auf ein Modell wartet, das nicht antwortet. Was dann noch offen war, steht im Protokoll. + +Beide Wege dorthin gibt es wirklich: Der Manager bittet über den Socket, und gleichzeitig +kann jemand am Fenster auf das Kreuz klicken. Ein zweiter Aufruf wartet deshalb auf +denselben Vorgang, statt einen neuen anzustoßen. + +#### Der Puls + +Der Manager fragte bisher `/api/info` und wusste danach nur, dass überhaupt jemand +geantwortet hat. Das reicht, um „läuft" von „tot" zu unterscheiden — aber nicht für die +beiden Fälle, die gleich aussehen und gegensätzlich zu behandeln sind: **eine Instanz, die +hängt, und eine, die gerade aufräumt.** Die erste gehört nach einer Frist abgeschossen, +die zweite in Ruhe gelassen. + +`GET /api/instanz/puls` liegt hinter dem Socket-Gate, gehört also dem Manager allein, und +nennt zusätzlich Phase und offene Arbeiten. Bewusst weiter **gefragt und nicht gemeldet**: +Ein Puls, den die Instanz von sich aus schickt, fehlt genau dann, wenn sie hängt — und +dann kann der Manager „hängt" nicht von „hat es vergessen" unterscheiden. Wer fragt, weiß, +wann er keine Antwort bekommen hat. + +#### Die Leiter + +| Stufe | Was passiert | +|---|---| +| 3 Durchgänge ohne Antwort | `Unresponsive`, gemeldet. Vorher nicht — ein Agentenlauf, der die Maschine auslastet, ist kein Vorfall | +| sofort danach | Einmal über den Socket um ein Ende bitten. **Ein Versuch, mehr nicht** — wer eben auf demselben Socket geschwiegen hat, hört wahrscheinlich auch das nicht. Es kann ein einzelner hängender Endpunkt sein und nicht der ganze Prozess; gewartet wird auf die Uhr, nicht auf die Antwort | +| nach 30 s (`silentKillGraceSeconds`) | Der Prozess wird beendet — und der Vollzug **abgewartet**. `Kill()` ist eine Bitte an das Betriebssystem, keine vollzogene Tatsache | +| danach | Gemeldet als **Absturz**, nicht als geordnetes Ende. Gewollt hat es niemand — und nur so greift der Autostart | +| vor jedem Start | `EnsureDeadAsync`: Antwortet noch jemand auf dem Socket? Lebt das Prozesshandle noch? Solange ja, wird **nicht** gestartet | + +Antwortet die Instanz zwischendurch wieder, war es ein Aussetzer, und niemand wird +abgeschossen (`Recovered`). + +#### Der Fehlschluss, der hier begraben liegt + +`StopAsync` brach seine Warteschleife ab, sobald der Socket nicht mehr antwortete, meldete +„beendet" und gab `true` zurück — **ohne den Prozess anzufassen.** Bei einer verstummten +Instanz ist der Socket aber von vornherein still: Der Aufruf meldete sofort Erfolg, der +Prozess lief weiter, und der Neustart setzte einen zweiten auf dieselbe `state.db`. + +Schweigen ist nur dann ein Todesnachweis, wenn es keinen anderen Zeugen gibt. Hält der +Manager ein Prozesshandle und sagt das „lebt noch", dann lebt der Prozess — er antwortet +bloß nicht mehr. Nur bei einer wiederaufgenommenen Instanz ohne Handle muss das Schweigen +reichen, weil mehr nicht zu haben ist. + +#### Und die Fristen greifen ineinander + +Die Instanz gibt sich zwei Minuten zum Auslaufen, der Manager wartet bis zu fünf +(`maxDrainWaitSeconds`), solange sie **meldet**, dass sie aufräumt. Wäre seine Frist +kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten hat. Und +trotzdem endlich: Eine Instanz, die dauerhaft „ich räume noch auf" sagt, hängt genauso wie +eine, die schweigt. diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 08e72ab..9169359 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -60,6 +60,22 @@ public sealed class AppHost : IAsyncDisposable public required ILoggerFactory LoggerFactory { get; init; } public required ToolRegistry Tools { get; init; } + /// + /// Das geordnete Ende dieser Instanz (#528). + /// + /// Wer die Instanz beenden will — der Instanzmanager über den Socket, ein + /// Mensch über das Fenster — ruft und + /// wartet, bevor er anstößt. Der Instanzmanager liest + /// unterdessen über den Puls und weiß + /// dadurch, dass hier aufgeräumt und nicht gehangen wird. + /// + /// Nicht required, sondern mit einem leeren Koordinator vorbelegt: Ein + /// Aufbau ohne angemeldete Arbeitsquellen hat nichts abzuwarten und endet sofort. So + /// gibt es diese Zusage immer, und niemand muss auf null prüfen, + /// bevor er eine Instanz geordnet beendet. + public ShutdownCoordinator Shutdown { get; init; } = + new(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + /// Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten. public AgentEngine? Engine { get; private init; } @@ -177,8 +193,11 @@ public sealed class AppHost : IAsyncDisposable /// public required Func> SelectInstance { get; init; } - /// Wie der Lizenz-Torwächter mit dem Benutzer spricht. - public required ILicensePrompt License { get; init; } + /// Wie der Lizenz-Torwächter mit dem Benutzer spricht. Null im geführten Modus. + public ILicensePrompt? License { get; init; } + + /// Wahr, wenn die Instanz durch den Instanzmanager gestartet und geführt wird. + public bool IsManaged { get; init; } /// /// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus — @@ -270,17 +289,26 @@ public sealed class AppHost : IAsyncDisposable } // ─── 4. Lizenz ─── - var license = new LicenseGate( - new SettingsLicenseStore(settings), - loggerFactory.CreateLogger("ClawdDotNet.License"), - callbacks.License, - ReleaseInfo.Version); - - if (!await license.RunStartupCheckAsync(ct)) + LicenseGate? license = null; + if (!callbacks.IsManaged) { - logger.LogWarning("Start abgebrochen: keine gültige Lizenz."); - loggerFactory.Dispose(); - return new StartupResult(null, null); + var prompt = callbacks.License ?? new NonInteractiveLicensePrompt(Console.Error.WriteLine); + license = new LicenseGate( + new SettingsLicenseStore(settings), + loggerFactory.CreateLogger("ClawdDotNet.License"), + prompt, + ReleaseInfo.Version); + + if (!await license.RunStartupCheckAsync(ct)) + { + logger.LogWarning("Start abgebrochen: keine gültige Lizenz."); + loggerFactory.Dispose(); + return new StartupResult(null, null); + } + } + else + { + logger.LogInformation("Instanz wird vom Manager geführt (--managed): Lizenzprüfung erfolgt zentral im Manager."); } // ─── 5. Werkzeuge ─── @@ -307,7 +335,7 @@ public sealed class AppHost : IAsyncDisposable // Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK. host.License = license; - if (license.IsEnforcementConfigured) + if (license is not null && license.IsEnforcementConfigured) { host.LicenseWatch = new LicenseWatch( license, loggerFactory.CreateLogger("ClawdDotNet.License")); @@ -381,7 +409,9 @@ public sealed class AppHost : IAsyncDisposable LogDirectory = logDirectory, LoggerFactory = loggerFactory, Tools = tools, - TelegramClient = telegram + TelegramClient = telegram, + Shutdown = new ShutdownCoordinator( + loggerFactory.CreateLogger("ClawdDotNet.Shutdown")) }; } @@ -445,9 +475,19 @@ public sealed class AppHost : IAsyncDisposable Board = board, Usage = usage, TelegramClient = telegram, - Status = new OpenRouterStatusService(instance.OpenRouterApiKey) + Status = new OpenRouterStatusService(instance.OpenRouterApiKey), + Shutdown = new ShutdownCoordinator( + loggerFactory.CreateLogger("ClawdDotNet.Shutdown")) }; + // Was beim Beenden auslaufen darf, bevor abgeschnitten wird (#528). Beide Quellen + // koennen nur zweierlei: die Tuer schliessen und sagen, wie viel noch laeuft. + host.Shutdown.Register(new WorkSource( + "Agentenläufe", () => engine?.OpenRunCount ?? 0, () => engine?.StopAccepting())); + + host.Shutdown.Register(new WorkSource( + "Aufgaben", () => scanner?.OpenCount ?? 0, () => scanner?.StopAccepting())); + host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; }); logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen"); @@ -602,6 +642,14 @@ public sealed class AppHost : IAsyncDisposable if (_disposed) return; _disposed = true; + // Erst auslaufen lassen, dann abschneiden (#528). Laeuft das Auslaufen schon, + // wartet dieser Aufruf darauf statt es ein zweites Mal anzustossen - und wenn + // niemand darum gebeten hat, ist es hier in einem Wimpernschlag vorbei, weil + // dann nichts offen ist. Wer DisposeAsync ohne vorheriges BeginAsync ruft, + // bekommt so trotzdem das geordnete Ende. + try { await Shutdown.BeginAsync(); } + catch (Exception) { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ } + foreach (var step in _shutdown) { try { await step(); } diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index a458a60..8760207 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -79,8 +79,14 @@ public sealed class InstanceConfig public int BackupKeepCount { get; set; } = 14; /// - /// Port der Weboberfläche. Je Instanz und nicht anwendungsweit: Zwei Instanzen auf - /// einer Maschine stritten sich sonst um einen Port. + /// Port der Weboberfläche. Entfällt mit dem Instanzmanager (E-M5): Instanzen hören + /// künftig ausschließlich auf ihrem Socket/Named Pipe; die Weboberfläche trägt der Manager. + /// + /// Noch ohne [Obsolete]. Das Attribut stand hier kurz und hat + /// sechs Warnungen erzeugt, ohne dass eine davon jemand hätte abstellen können — der + /// Port ist bis zum Abschluss von E-M5 der einzige Weg, auf dem eine eigenständige + /// Instanz ihre Oberfläche anbietet. Eine Warnung, die nur wegsehen lässt, ist + /// schlechter als keine; sie kommt zurück, wenn der Ersatz steht. /// [JsonPropertyName("webServerPort")] public int WebServerPort { get; set; } = 7001; diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 72db757..4198804 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -145,10 +145,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut string? taskId = null, DelegationChain? delegation = null) { + // Vor allem anderen: Endet die Instanz gerade, faengt hier nichts Neues mehr an. + if (RefuseWhileShuttingDown(agentConfig) is { } zu) + return zu; + // Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten. if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied) return denied; + using var lauf = TrackRun(); + var runId = Guid.NewGuid().ToString("N"); var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId); var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain); @@ -340,9 +346,14 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut string? taskId = null, DelegationChain? delegation = null) { + if (RefuseWhileShuttingDown(agentConfig) is { } zu) + return zu; + if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied) return denied; + using var lauf = TrackRun(); + var runId = Guid.NewGuid().ToString("N"); var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId); @@ -630,6 +641,63 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut get { lock (_lock) return _runningChats.Count; } } + // ─── Geordnetes Ende (#528) ─── + + private int _openRuns; + private int _accepting = 1; + + /// + /// Wie viele Agentenläufe gerade laufen — Chats und angestoßene Läufe. + /// + /// Nicht dasselbe wie : Der zählt Agenten + /// mit mindestens einem Chat, dieser zählt Läufe. Fürs Herunterfahren ist der + /// Lauf die Einheit — zwei Läufe desselben Agenten sind zwei Dinge, die zu Ende kommen + /// müssen. + /// + public int OpenRunCount => Volatile.Read(ref _openRuns); + + /// Nimmt die Engine noch neue Läufe an? + public bool IsAcceptingWork => Volatile.Read(ref _accepting) == 1; + + /// + /// Schließt die Tür: ab jetzt kein neuer Lauf mehr. + /// + /// Laufende werden nicht abgebrochen — das ist der ganze Punkt. Wer sie + /// abbrechen will, hat dafür AbortChat. + /// + public void StopAccepting() => Volatile.Write(ref _accepting, 0); + + private AgentRunResult? RefuseWhileShuttingDown(AgentConfig agentConfig) + => IsAcceptingWork + ? null + : new AgentRunResult( + agentConfig.AgentId, AgentRunStatus.ShuttingDown, + "[Die Instanz endet gerade — es werden keine neuen Läufe mehr angenommen]", + 0, 0, TimeSpan.Zero); + + /// + /// Zählt einen laufenden Lauf, solange sie lebt. + /// + /// Als using-Wert statt als try/finally von Hand, weil beide + /// Einstiege mehrere Rückgabewege haben und ein vergessener Weg den Zähler dauerhaft + /// verschöbe — eine Instanz, die dann nie mehr enden könnte, weil ein Lauf zu Buche + /// steht, den es nicht gibt. + /// + private RunScope TrackRun() => new(this); + + private readonly struct RunScope : IDisposable + { + private readonly AgentEngine _engine; + + public RunScope(AgentEngine engine) + { + _engine = engine; + Interlocked.Increment(ref engine._openRuns); + } + + public void Dispose() => Interlocked.Decrement(ref _engine._openRuns); + } + /// /// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten, /// die beim nächsten Schritt tatsächlich an das Modell gehen. diff --git a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs index 4a536dc..4462e5a 100644 --- a/src/ClawdDotNet.Core/Engine/AgentRunResult.cs +++ b/src/ClawdDotNet.Core/Engine/AgentRunResult.cs @@ -38,5 +38,14 @@ public enum AgentRunStatus /// unbegrenzt warten darf (#461). Trifft nur delegierte Läufe — ein vom Menschen /// angestoßener Chat reiht sich weiterhin ein und wartet, so lange es dauert. /// - TargetBusy + TargetBusy, + + /// + /// Der Lauf fand nicht statt, weil die Instanz gerade geordnet endet (#528). + /// + /// Beim Herunterfahren wird zuerst die Tür geschlossen und dann gewartet. + /// Andersherum ginge die Zahl der offenen Läufe nie auf null, weil ständig neue + /// dazukämen — die Instanz käme nie zum Ende. + /// + ShuttingDown } diff --git a/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs b/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs new file mode 100644 index 0000000..2cf82c4 --- /dev/null +++ b/src/ClawdDotNet.Core/Instances/InstanceEndpoint.cs @@ -0,0 +1,207 @@ +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; + +namespace ClawdDotNet.Core.Instances; + +/// +/// Der Weg, auf dem ein Instanzprozess von außen ansprechbar ist (#528, E-M1). +/// +/// Kein Port. Ports müssten vergeben, verwaltet und gegen Kollisionen +/// gesichert werden — genau das Problem, an dem die Vorbelegung 7001 scheitert, +/// sobald zwei Instanzen laufen. Ein Socket im Instanzverzeichnis braucht keine Vergabe: +/// Wo die Instanz liegt, liegt auch ihre Adresse. +/// +/// Die Zugriffsrechte stehen am Dateisystem, nicht in einem Token. Wer die +/// Socketdatei öffnen darf, darf mit der Instanz sprechen — und das darf nur, wer ohnehin +/// in ihr Verzeichnis sehen kann. Unter Linux setzt die Rechte auf +/// 0600, unter Windows übernimmt das die Named Pipe mit +/// CurrentUserOnly. +/// +/// Warum HTTP darüber. Der Manager leitet Anfragen weiter (E-M5). Bleibt das +/// Protokoll HTTP, ist Weiterleiten eine Weiterleitung und keine Übersetzung: Endpunkte, +/// Übertragungstypen und Ereignisstrom gelten unverändert weiter, es ändert sich nur die +/// Bindung. +/// +public static class InstanceEndpoint +{ + /// Der Name der Socketdatei im Instanzverzeichnis. + public const string SocketFileName = "manager.sock"; + + /// + /// Ob auf dieser Maschine eine Named Pipe statt eines Unix-Domain-Sockets benutzt + /// wird. + /// + /// Der Zielbetrieb ist Linux, entwickelt wird auf Windows — beide Wege müssen + /// tragen. Windows kann seit 10/1803 auch Unix-Domain-Sockets, aber nicht jede + /// Schicht darüber zieht mit; die Named Pipe ist dort der Weg, den das Betriebssystem + /// selbst vorsieht, samt eigener Rechteverwaltung. + /// + /// An Aufrufstellen, hinter denen etwas Plattformgebundenes steht, wird + /// trotzdem OperatingSystem.IsWindows() geschrieben: Die Plattformanalyse + /// erkennt nur den unmittelbaren Aufruf als Absicherung, nicht eine Eigenschaft, die + /// dasselbe sagt. + /// + public static bool UsesNamedPipe => OperatingSystem.IsWindows(); + + /// Die Socketdatei dieser Instanz. Auch unter Windows berechenbar — dort + /// bestimmt sie über nur den Namen, es entsteht keine Datei. + public static string SocketPath(string instanceDirectory) + => Path.Combine(Path.GetFullPath(instanceDirectory), SocketFileName); + + /// + /// Der Name der Named Pipe dieser Instanz. + /// + /// Pipenamen sind maschinenweit und kennen keine Verzeichnisse. Der Name muss + /// deshalb aus dem Instanzpfad entstehen, und zwar so, dass zwei Instanzen nie + /// denselben bekommen — sonst redete der Manager mit der falschen. Ein Fingerabdruck + /// des Pfades leistet das und trägt dabei nichts aus dem Pfad nach außen: Pipenamen + /// sind für jeden Prozess der Maschine sichtbar, ein Klartextpfad darin wäre ein + /// unnötiger Hinweis auf die Ablage. + /// + public static string PipeName(string instanceDirectory) + => "clawd-" + Fingerprint(instanceDirectory); + + /// Die Adresse, wie man sie einem Menschen ins Protokoll schreibt. + public static string Describe(string instanceDirectory) + => UsesNamedPipe + ? $@"\\.\pipe\{PipeName(instanceDirectory)}" + : SocketPath(instanceDirectory); + + /// + /// Räumt eine liegengebliebene Socketdatei weg. + /// + /// Ohne das startet die Instanz nach einem harten Ende nicht wieder. Ein + /// Unix-Domain-Socket ist eine Datei, und sie überlebt den Prozess, der sie angelegt + /// hat. Beim nächsten Binden kommt EADDRINUSE — von einem Prozess, den es + /// nicht mehr gibt. Genau der Fall aus #529: Windows hat die Anwendung erschlagen, + /// aufgeräumt hat niemand. + /// + /// Unter Windows ohne Wirkung: Eine Named Pipe verschwindet mit ihrem letzten + /// Halter. + /// + public static void ClearStale(string instanceDirectory) + { + if (OperatingSystem.IsWindows()) return; + + var path = SocketPath(instanceDirectory); + + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (IOException) + { + // Kein Grund, den Start zu verhindern: Das Binden meldet gleich selbst, ob + // die Adresse frei ist, und diese Meldung ist die genauere. + } + catch (UnauthorizedAccessException) + { + } + } + + /// + /// Setzt die Rechte der Socketdatei auf 0600 — nur der eigene Benutzer. + /// + /// Erst nach dem Binden aufzurufen: Vorher gibt es die Datei nicht. Unter + /// Windows ohne Wirkung; dort trägt NamedPipeTransportOptions.CurrentUserOnly + /// dieselbe Zusage. + /// + public static void Restrict(string instanceDirectory) + { + if (OperatingSystem.IsWindows()) return; + + var path = SocketPath(instanceDirectory); + + try + { + if (File.Exists(path)) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or PlatformNotSupportedException) + { + // Der Aufrufer protokolliert. Ein Socket mit weiteren Rechten ist ein Mangel, + // aber keiner, der die Instanz am Laufen hindern sollte — im Verzeichnis + // daneben liegt ohnehin alles, was schützenswert ist, und dessen Rechte + // stehen bereits. + } + } + + /// + /// Ein , der über den Socket dieser Instanz spricht. + /// + /// Die Adresse in der Anfrage ist ein Platzhalter — http://instanz —, + /// weil HTTP einen Host verlangt und der Socket keinen hat. Verbunden wird über den + /// Rückruf, nicht über den Namen. + /// + public static HttpClient CreateClient(string instanceDirectory) + => new(CreateHandler(instanceDirectory)) { BaseAddress = new Uri("http://instanz") }; + + /// + public static HttpMessageHandler CreateHandler(string instanceDirectory) + { + if (OperatingSystem.IsWindows()) + { + var name = PipeName(instanceDirectory); + + return new SocketsHttpHandler + { + ConnectCallback = async (_, ct) => + { + var stream = new System.IO.Pipes.NamedPipeClientStream( + ".", name, + System.IO.Pipes.PipeDirection.InOut, + System.IO.Pipes.PipeOptions.Asynchronous + | System.IO.Pipes.PipeOptions.CurrentUserOnly); + + // 150 ms Frist: Ein lokaler Socket antwortet sofort (< 2 ms). + // Läuft die Instanz nicht, wartet Windows sonst bis zum Abbruch des Aufrufers. + await stream.ConnectAsync(150, ct); + return stream; + } + }; + } + + var endpoint = new UnixDomainSocketEndPoint(SocketPath(instanceDirectory)); + + return new SocketsHttpHandler + { + ConnectCallback = async (_, ct) => + { + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + + try + { + await socket.ConnectAsync(endpoint, ct); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + }; + } + + /// + /// Fingerabdruck eines Instanzpfades: 16 Hexzeichen aus SHA-256. + /// + /// Unter Windows wird die Schreibweise vorher zusammengeführt — dort sind + /// C:\Instanzen\Test und c:\instanzen\test dasselbe Verzeichnis und + /// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und + /// dürfen es nicht. + /// + private static string Fingerprint(string instanceDirectory) + { + var full = Path.GetFullPath(instanceDirectory).TrimEnd( + Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) + full = full.ToLowerInvariant(); + + var digest = SHA256.HashData(Encoding.UTF8.GetBytes(full)); + return Convert.ToHexStringLower(digest)[..16]; + } +} diff --git a/src/ClawdDotNet.Core/Instances/InstanceLayout.cs b/src/ClawdDotNet.Core/Instances/InstanceLayout.cs new file mode 100644 index 0000000..b8f187d --- /dev/null +++ b/src/ClawdDotNet.Core/Instances/InstanceLayout.cs @@ -0,0 +1,71 @@ +using ClawdDotNet.Core.Config; + +namespace ClawdDotNet.Core.Instances; + +/// +/// Wo die Verzeichnisse einer Instanz liegen. +/// +/// In Core, weil zwei Seiten es brauchen (#528): die Anwendung, die dorthin +/// schreibt, und der Instanzmanager, der beim Absturz einer Instanz den letzten Ausschnitt +/// ihres Protokolls in seine Meldung nimmt. Der Manager darf ClawdDotNet.App nicht +/// kennen — dort hängen sechzehn Werkzeugprojekte —, und dieselbe Rechnung an zwei Stellen +/// zu führen wäre die schlechtere Antwort darauf. +/// +public static class InstanceLayout +{ + /// + /// Wohin diese Instanz protokolliert. Zwei Regeln: + /// + /// Leer heißt <Instanzverzeichnis>/Logs — nicht mehr das + /// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien + /// schrieben, stand in Startup.log abwechselnd, was zwei Instanzen zu sagen + /// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede + /// Fehlersuche damit Ratearbeit (#528, Stufe 1). + /// + /// Ein relativer Wert zählt gegen das Instanzverzeichnis, nicht gegen + /// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem + /// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne WorkingDirectory= + /// startet in /, und "./Logs" zeigt dann auf /Logs — für ein + /// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb + /// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt. + /// + /// Die geladene Instanz — liefert Vorrang und Bezugspunkt. + /// Das Verzeichnis, aus dem die Instanz geladen wurde. + public static string ResolveLogDirectory(InstanceConfig instance, string instancePath) + => ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath); + + /// + /// Wohin die Sicherungen dieser Instanz gehen. Leer heißt + /// <Instanzverzeichnis>/Backups. + /// + /// Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt + /// dort nach dem Präfix backup_<Instanzname>_ auf, und bei zwei Namen, + /// die ineinander liegen (Test und Test_2), zählt die eine Instanz die + /// Archive der anderen mit (#528, Stufe 1). + /// + public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath) + => ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath); + + /// + /// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem + /// Instanzverzeichnis. + /// + private static string ResolveInstanceDirectory( + string configured, string fallbackFolder, InstanceConfig instance, string instancePath) + { + // InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten + // Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann, + // wenn jemand das Feld von Hand geleert oder relativ gesetzt hat. + var instanceBase = Path.IsPathRooted(instance.WorkingDirectory) + ? instance.WorkingDirectory + : instancePath; + + if (string.IsNullOrWhiteSpace(configured)) + return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder)); + + return Path.GetFullPath( + Path.IsPathRooted(configured) + ? configured + : Path.Combine(instanceBase, configured)); + } +} diff --git a/src/ClawdDotNet.Core/Instances/ShutdownCoordinator.cs b/src/ClawdDotNet.Core/Instances/ShutdownCoordinator.cs new file mode 100644 index 0000000..5452391 --- /dev/null +++ b/src/ClawdDotNet.Core/Instances/ShutdownCoordinator.cs @@ -0,0 +1,276 @@ +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Instances; + +/// Wo eine Instanz beim Herunterfahren gerade steht. +public enum ShutdownPhase +{ + /// Niemand hat um ein Ende gebeten. Der Normalbetrieb. + Running, + + /// Die Tür ist zu, die offenen Arbeiten laufen aus. + Draining, + + /// Nichts mehr offen — oder die Frist ist abgelaufen. Der Wirt darf enden. + Done +} + +/// +/// Eine Stelle, an der Arbeit offen sein kann: Agentenläufe, Aufgaben, was noch kommt. +/// +/// Bewusst über Rückrufe und nicht als Schnittstelle an AgentEngine oder +/// TaskScanner: Die beiden sollen nicht wissen, dass es ein Herunterfahren gibt. +/// Sie können zwei Dinge — keine neue Arbeit mehr annehmen und sagen, wie viel noch +/// läuft. Was daraus folgt, entscheidet der Koordinator. +/// +/// Wie diese Quelle im Protokoll und in der Puls-Auskunft heißt. +/// Wie viele Arbeiten hier gerade noch laufen. +/// Schließt die Tür: ab jetzt keine neue Arbeit mehr. +public sealed record WorkSource( + string Name, + Func OpenCount, + Action StopAccepting); + +/// Was gerade offen ist, je Quelle. +public sealed record OpenWork(string Name, int Count); + +/// +/// Der Stand des Herunterfahrens, wie ihn der Instanzmanager über den Puls erfährt. +/// +/// Laufend, am Auslaufen, fertig. +/// Was noch offen ist, je Quelle. Leer heißt: nichts mehr. +/// Wie lange das Auslaufen schon dauert. +/// Wie viel von der Frist noch übrig ist. +public sealed record ShutdownSnapshot( + ShutdownPhase Phase, + IReadOnlyList Open, + TimeSpan Elapsed, + TimeSpan Remaining) +{ + /// Die Summe über alle Quellen. + public int TotalOpen => Open.Sum(o => o.Count); + + public static ShutdownSnapshot Idle { get; } = + new(ShutdownPhase.Running, [], TimeSpan.Zero, TimeSpan.Zero); +} + +/// +/// Das geordnete Ende einer Instanz: erst die Tür zu, dann auslaufen lassen, dann enden. +/// +/// Warum es das braucht. Bis hierher war jedes Beenden ein Abschneiden. +/// TaskScanner.DisposeAsync bricht den laufenden Takt über sein +/// CancellationToken ab — mitten in ProcessAsync, also nachdem eine +/// Aufgabe beansprucht wurde und bevor CompleteClaimAsync sie wieder +/// freigibt. Die Aufgabe steht danach auf running und bleibt dort, bis das Lease +/// abläuft. Ein Agentenlauf mitten im Werkzeugaufruf verliert seine Antwort ganz. +/// +/// Die Reihenfolge ist der ganze Trick. Erst wird die Tür geschlossen, dann +/// gewartet. Andersherum wartet man ewig: Der Scanner beansprucht im nächsten Takt die +/// nächste Aufgabe, und die Zahl der offenen Arbeiten geht nie auf null. +/// +/// Die Frist ist eine Zusage, keine Hoffnung. Nach +/// endet die Instanz, ob fertig oder nicht — sonst hinge sie an +/// einem Agentenlauf, der auf ein Modell wartet, das nicht antwortet. Was dann noch offen +/// war, steht im Protokoll; verschwiegen wird es nicht. +/// +/// Und der Manager sieht zu. geht über den +/// Puls-Endpunkt an den Instanzmanager. Solange hier Fortschritt gemeldet wird, wartet er +/// — er soll eine Instanz nicht abschießen, die gerade ordentlich aufräumt. +/// +public sealed class ShutdownCoordinator( + ILogger logger, + TimeSpan? budget = null, + TimeProvider? clock = null) +{ + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + private readonly List _sources = []; + private readonly Lock _lock = new(); + private readonly TaskCompletionSource _finished = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private DateTimeOffset _startedAt; + private int _begun; + + /// + /// Wie lange das Auslaufen höchstens dauern darf. + /// + /// Zwei Minuten, weil ein Agentenlauf mit Werkzeugaufrufen selten kürzer ist und + /// selten länger dauert. Der Manager rechnet damit — seine eigene Frist muss größer + /// sein, sonst schießt er in das Aufräumen hinein. + /// + public TimeSpan Budget { get; } = budget ?? TimeSpan.FromMinutes(2); + + public ShutdownPhase Phase { get; private set; } = ShutdownPhase.Running; + + /// Ist erfüllt, sobald das Auslaufen vorbei ist — fertig oder abgelaufen. + public Task Finished => _finished.Task; + + /// + /// Meldet eine Stelle an, an der Arbeit offen sein kann. + /// + /// Vor aufzurufen. Wer sich später meldet, wird nicht + /// mehr abgewartet — und das ist richtig so: Was nach dem Schließen der Tür entsteht, + /// hätte gar nicht entstehen dürfen. + /// + public void Register(WorkSource source) + { + lock (_lock) _sources.Add(source); + } + + /// Der aktuelle Stand, für den Puls an den Manager. + public ShutdownSnapshot Snapshot() + { + lock (_lock) + { + if (Phase == ShutdownPhase.Running) + return ShutdownSnapshot.Idle; + + var elapsed = _clock.GetUtcNow() - _startedAt; + var remaining = Budget - elapsed; + + return new ShutdownSnapshot( + Phase, + Offen(), + elapsed, + remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero); + } + } + + /// + /// Schließt die Tür und wartet, bis nichts mehr offen ist — höchstens + /// lang. + /// + /// Ein zweiter Aufruf wartet auf denselben Vorgang, statt einen neuen + /// anzustoßen. Beide Wege dorthin gibt es wirklich: Der Manager bittet über den + /// Socket, und gleichzeitig kann jemand am Fenster auf das Kreuz klicken. + /// + public async Task BeginAsync(CancellationToken ct = default) + { + if (Interlocked.Exchange(ref _begun, 1) != 0) + { + await _finished.Task.WaitAsync(ct).ConfigureAwait(false); + return; + } + + List quellen; + + lock (_lock) + { + _startedAt = _clock.GetUtcNow(); + Phase = ShutdownPhase.Draining; + quellen = [.. _sources]; + } + + // Erst die Tuer. Wer hier wartet, bevor er schliesst, wartet, bis der Strom + // ausfaellt. + foreach (var quelle in quellen) + { + try { quelle.StopAccepting(); } + catch (Exception ex) + { + logger.LogError(ex, + "{Quelle} liess sich nicht schliessen — es wird trotzdem gewartet.", + quelle.Name); + } + } + + var offenZuBeginn = Snapshot(); + + if (offenZuBeginn.TotalOpen == 0) + { + logger.LogInformation("Nichts mehr offen — die Instanz endet sofort."); + Vollenden(); + return; + } + + logger.LogInformation( + "Geordnetes Ende: {Anzahl} offene Arbeiten ({Aufschluesselung}). " + + "Es wird bis zu {Frist:0}s gewartet.", + offenZuBeginn.TotalOpen, Beschreiben(offenZuBeginn.Open), Budget.TotalSeconds); + + var deadline = _startedAt + Budget; + var zuletztGemeldet = offenZuBeginn.TotalOpen; + + while (_clock.GetUtcNow() < deadline) + { + var stand = Snapshot(); + + if (stand.TotalOpen == 0) + { + logger.LogInformation( + "Alle Arbeiten abgeschlossen nach {Dauer:0.0}s — die Instanz endet.", + stand.Elapsed.TotalSeconds); + + Vollenden(); + return; + } + + if (stand.TotalOpen != zuletztGemeldet) + { + // Fortschritt gehoert ins Protokoll. Nicht als Verzierung: Er ist der + // Unterschied zwischen "raeumt auf" und "haengt", und genau den muss + // hinterher jemand nachlesen koennen. + logger.LogInformation( + "Noch {Anzahl} offen ({Aufschluesselung}), {Rest:0}s Frist.", + stand.TotalOpen, Beschreiben(stand.Open), stand.Remaining.TotalSeconds); + + zuletztGemeldet = stand.TotalOpen; + } + + try + { + await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Abgebrochen heisst hier nicht "weitermachen", sondern "keine Zeit mehr". + // Der Wirt will enden; das Warten war der Dienst, nicht der Zweck. + break; + } + } + + var uebrig = Snapshot(); + + if (uebrig.TotalOpen > 0) + { + logger.LogWarning( + "Die Frist von {Frist:0}s ist abgelaufen, {Anzahl} Arbeiten waren noch offen " + + "({Aufschluesselung}). Sie werden abgebrochen.", + Budget.TotalSeconds, uebrig.TotalOpen, Beschreiben(uebrig.Open)); + } + + Vollenden(); + } + + private void Vollenden() + { + lock (_lock) Phase = ShutdownPhase.Done; + _finished.TrySetResult(); + } + + private List Offen() + { + var liste = new List(); + + foreach (var quelle in _sources) + { + int anzahl; + + try { anzahl = quelle.OpenCount(); } + catch (Exception ex) + { + // Eine Quelle, die beim Zaehlen stolpert, darf das Ende nicht aufhalten. + // Null ist hier die sichere Antwort: Sie verlaengert nichts. + logger.LogError(ex, "{Quelle} liess sich nicht zaehlen.", quelle.Name); + anzahl = 0; + } + + if (anzahl > 0) liste.Add(new OpenWork(quelle.Name, anzahl)); + } + + return liste; + } + + private static string Beschreiben(IReadOnlyList offen) + => offen.Count == 0 ? "nichts" : string.Join(", ", offen.Select(o => $"{o.Name}: {o.Count}")); +} diff --git a/src/ClawdDotNet.Core/Storage/SystemShell.cs b/src/ClawdDotNet.Core/Storage/SystemShell.cs index 9d04577..e03a5b9 100644 --- a/src/ClawdDotNet.Core/Storage/SystemShell.cs +++ b/src/ClawdDotNet.Core/Storage/SystemShell.cs @@ -79,6 +79,37 @@ public static class SystemShell } } + /// + /// Öffnet eine Internetadresse im Standard-Webbrowser des Systems. + /// + public static bool OpenUrl(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return false; + + try + { + if (OperatingSystem.IsWindows()) + { + using var p = Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); + return p is not null; + } + if (OperatingSystem.IsLinux()) + { + return Start("xdg-open", [url]); + } + if (OperatingSystem.IsMacOS()) + { + return Start("open", [url]); + } + return false; + } + catch + { + return false; + } + } + /// /// Startet mit ArgumentList statt einer Argumentzeichenkette — so muss nichts /// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs index 5fe5ac7..367c735 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs @@ -89,12 +89,41 @@ public sealed class TaskScanner : IAsyncDisposable /// public bool HasStopped => _loop is { IsCompleted: true }; + // ─── Geordnetes Ende (#528) ─── + + private int _openRuns; + private int _accepting = 1; + + /// + /// Wie viele Aufgaben gerade tatsächlich laufen — also beansprucht und noch nicht + /// abgeschlossen. + /// + public int OpenCount => Volatile.Read(ref _openRuns); + + /// Beansprucht der Scanner noch neue Aufgaben? + public bool IsAcceptingWork => Volatile.Read(ref _accepting) == 1; + + /// + /// Schließt die Tür: kein neuer Takt, keine neue Beanspruchung. + /// + /// Warum das nicht dasselbe ist wie . Dispose + /// bricht den laufenden Takt über sein Token ab — mitten in ProcessAsync, also + /// nachdem eine Aufgabe beansprucht wurde und bevor CompleteClaimAsync sie + /// wieder freigibt. Sie steht danach auf running und bleibt dort bis zum + /// Ablauf des Lease. Wer geordnet enden will, schließt erst hier die Tür, wartet auf + /// gleich null und ruft Dispose erst dann. + /// + public void StopAccepting() => Volatile.Write(ref _accepting, 0); + private async Task RunLoopAsync(CancellationToken ct) { // PeriodicTimer über den TimeProvider — im Test steuerbar, im Betrieb driftfrei. using var timer = new PeriodicTimer(_tick, _clock); while (await timer.WaitForNextTickAsync(ct)) { + if (!IsAcceptingWork) + continue; // Die Tuer ist zu. Der Takt laeuft leer, bis Dispose ihn beendet. + try { await ScanOnceAsync(ct); @@ -113,6 +142,9 @@ public sealed class TaskScanner : IAsyncDisposable /// public async Task ScanOnceAsync(CancellationToken ct) { + if (!IsAcceptingWork) + return 0; + var now = _clock.GetUtcNow().UtcDateTime; var leaseCutoff = now - _lease; @@ -167,6 +199,9 @@ public sealed class TaskScanner : IAsyncDisposable /// public async Task RunTaskNowAsync(string taskId, CancellationToken ct) { + if (!IsAcceptingWork) + return false; + var task = await _repo.GetAsync(taskId, ct); if (task is null) return false; @@ -181,6 +216,23 @@ public sealed class TaskScanner : IAsyncDisposable } private async Task ProcessAsync(TaskItem task, string token, CancellationToken ct) + { + // Ab hier ist die Aufgabe beansprucht und steht auf `running`. Wer das + // Herunterfahren abwartet, wartet genau auf diesen Zaehler - denn erst + // CompleteClaimAsync weiter unten gibt sie wieder frei. + Interlocked.Increment(ref _openRuns); + + try + { + await ProcessCoreAsync(task, token, ct); + } + finally + { + Interlocked.Decrement(ref _openRuns); + } + } + + private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct) { bool ok; try diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs index 3748d60..7436881 100644 --- a/src/ClawdDotNet.Desktop/App.axaml.cs +++ b/src/ClawdDotNet.Desktop/App.axaml.cs @@ -4,6 +4,8 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using Avalonia.Layout; +using Avalonia.Media; using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; @@ -12,6 +14,8 @@ using ClawdDotNet.Desktop.ViewModels; using ClawdDotNet.Desktop.Views; using ClawdDotNet.Web; using ClawdDotNet.Core.Instances; +using ClawdDotNet.Core.Storage; +using Microsoft.Extensions.Logging; namespace ClawdDotNet.Desktop; @@ -106,6 +110,7 @@ public partial class App : Application { SelectInstance = SelectInstanceAsync, License = new AvaloniaLicensePrompt(), + IsManaged = _managed, TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt), Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:") }); @@ -152,10 +157,25 @@ public partial class App : Application // Der Manager bittet ueber den Socket, dieses Fenster nach vorn zu holen (E-M2). if (_web is not null) + { _web.Control.FocusRequested += () => BringToFront(desktop); + // Und er bittet, zu enden. Ohne diese Zeile lief die Bitte ins Leere: Der + // Endpunkt nahm sie an, antwortete mit 202 - und niemand hoerte zu. Der + // Manager wartete seine Frist ab und schoss den Prozess dann jedes Mal ab. + // Kopflos war das verdrahtet (HeadlessRun), mit Fenster nicht; und mit + // Fenster ist die Vorgabe (ManagerSettings.InstanceWindows). + _web.Control.ShutdownRequested += () => Dispatcher.UIThread.Post(() => + { + if (desktop.MainWindow is MainWindow fenster) + fenster.BeendenOhneRueckfrage(); + else + _ = BeendeGeordnetAsync(desktop, "Instanzmanager"); + }); + } + desktop.MainWindow.Show(); - desktop.MainWindow.Closed += (_, _) => desktop.Shutdown(); + desktop.MainWindow.Closed += (_, _) => _ = BeendeGeordnetAsync(desktop, "Fenster"); await OfferUpdateAsync(_host, desktop); } @@ -373,14 +393,29 @@ public partial class App : Application /// Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess /// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank. /// + private enum StartModeChoice + { + LaunchManager, + Standalone, + Cancel + } + + private enum StandaloneSwitchChoice + { + LaunchManager, + SwitchStandalone, + Cancel + } + + /// + /// Instanzwechsel aus der laufenden Anwendung. + /// private async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop) { if (_host is null) return; if (_managed) { - // Sollte gar nicht erst aufrufbar sein (CanSwitchInstance), aber ein zweites - // Schloss an der Tuer, hinter der ein Selbst-Neustart wartet, ist billig. await new AvaloniaLicensePrompt().ShowInfoAsync( "ClawdDotNet – Instanzwechsel", "Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — " @@ -390,6 +425,18 @@ public partial class App : Application return; } + // Im Standalone-Modus den Benutzer fragen: + var switchChoice = await AskStandaloneSwitchModeAsync(desktop.MainWindow); + if (switchChoice == StandaloneSwitchChoice.LaunchManager) + { + LaunchInstanceManagerAndOpenBrowser(); + return; + } + if (switchChoice == StandaloneSwitchChoice.Cancel) + { + return; + } + var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath); var window = new InstancePickerWindow { DataContext = viewModel }; @@ -408,8 +455,6 @@ public partial class App : Application if (path is null) return; if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return; - await ShutdownWebAsync(); - var executable = Environment.ProcessPath; if (executable is null) @@ -421,14 +466,13 @@ public partial class App : Application return; } - await _host.DisposeAsync(); - + // Neue Instanz mit UseShellExecute = true starten (keine geerbten Datei- oder Socket-Handles) try { Process.Start(new ProcessStartInfo(executable) { ArgumentList = { InstanceArgument, path }, - UseShellExecute = false + UseShellExecute = true }); } catch (Exception ex) @@ -436,8 +480,340 @@ public partial class App : Application await new AvaloniaLicensePrompt().ShowErrorAsync( "ClawdDotNet – Instanzwechsel", $"Neustart fehlgeschlagen: {ex.Message}"); + return; } + // Hauptfenster sofort verbergen, damit es nicht eingefroren sichtbar bleibt + if (desktop.MainWindow is { } mainWin) + { + mainWin.Hide(); + } + + await ShutdownWebAsync(); + + var hostToDispose = _host; + _host = null; + if (hostToDispose is not null) + { + await hostToDispose.DisposeAsync(); + } + + // Sauber beenden + await Dispatcher.UIThread.InvokeAsync(() => + { + desktop.Shutdown(0); + Environment.Exit(0); + }); + } + + private static void LaunchInstanceManagerAndOpenBrowser() + { + var exePath = FindInstanceManagerExecutable(); + if (exePath is null) + { + _ = new AvaloniaLicensePrompt().ShowErrorAsync( + "ClawdDotNet – Instanzmanager", + "Die Programmdatei des Instanzmanagers (ClawdDotNet.Instanzmanager.exe) wurde nicht gefunden."); + return; + } + + try + { + Process.Start(new ProcessStartInfo(exePath) + { + UseShellExecute = true + }); + + SystemShell.OpenUrl($"http://localhost:{ManagerPort()}/#/instanzen"); + } + catch (Exception ex) + { + _ = new AvaloniaLicensePrompt().ShowErrorAsync( + "ClawdDotNet – Instanzmanager", + $"Konnte Instanzmanager nicht starten: {ex.Message}"); + } + } + + /// + /// Sucht die Programmdatei des Instanzmanagers. + /// + /// Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette. Hier + /// stand @"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0" — unter Linux, + /// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit + /// Rückstrichen darin, und die Suche ging dort nie auf. + /// + /// Die Entwicklungspfade bleiben, weil im Betrieb beide Programme + /// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei + /// vergebliche File.Exists. + /// + private static string? FindInstanceManagerExecutable() + { + var exeName = OperatingSystem.IsWindows() + ? "ClawdDotNet.Instanzmanager.exe" + : "ClawdDotNet.Instanzmanager"; + + var nebenan = Path.Combine(AppContext.BaseDirectory, exeName); + if (File.Exists(nebenan)) return nebenan; + + string[][] entwicklungspfade = + [ + ["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"], + ["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"] + ]; + + foreach (var segmente in entwicklungspfade) + { + var pfad = Path.GetFullPath(Path.Combine( + [AppContext.BaseDirectory, .. segmente, exeName])); + + if (File.Exists(pfad)) return pfad; + } + + return null; + } + + /// + /// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet. + /// + /// Aus manager.json gelesen und nicht fest eingetragen: Wer den Port + /// dort ändert, bekäme sonst einen Browser auf eine Adresse, an der nichts + /// antwortet. Diese Anwendung kennt den Typ hinter der Datei nicht — sie liest das + /// eine Feld, das sie braucht, und nimmt bei jedem Zweifel die Vorgabe. + /// + private static int ManagerPort() + { + const int vorgabe = 7000; + + try + { + var datei = Path.Combine(AppPaths.ConfigDirectory, "manager.json"); + if (!File.Exists(datei)) return vorgabe; + + using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(datei)); + + return doc.RootElement.TryGetProperty("webUiPort", out var port) + && port.ValueKind == System.Text.Json.JsonValueKind.Number + && port.TryGetInt32(out var wert) + && wert is > 0 and < 65536 + ? wert + : vorgabe; + } + catch (Exception) + { + // Eine unlesbare Managerdatei ist kein Grund, den Browser gar nicht zu oeffnen. + return vorgabe; + } + } + + private static async Task AskStandaloneSwitchModeAsync(Window? owner) + => await Dispatcher.UIThread.InvokeAsync(async () => + { + var completion = new TaskCompletionSource(); + + var managerBtn = new Button + { + Content = "Instanzmanager starten (Empfohlen)", + Classes = { "primary" } + }; + + var switchBtn = new Button + { + Content = "Instanz trotzdem wechseln" + }; + + var cancelBtn = new Button + { + Content = "Abbrechen", + IsCancel = true + }; + + var window = new Window + { + Title = "ClawdDotNet – Instanzwechsel (Standalone)", + Width = 540, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Icon = owner?.Icon, + Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 16, + Children = + { + new TextBlock + { + Text = "Achtung: Standalone-Modus", + Classes = { "h1" } + }, + new TextBlock + { + Text = "Diese Instanz läuft eigenständig ohne Instanzmanager.\n\n" + + "Wenn du die Instanz hier wechselst, wird diese Instanz vollständig beendet " + + "und alle laufenden Agenten und Hintergrunddienste werden gestoppt.\n\n" + + "Empfehlung: Starte stattdessen den Instanzmanager, um mehrere Instanzen gleichzeitig und unabhängig voneinander zu betreiben.", + TextWrapping = TextWrapping.Wrap + }, + new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + HorizontalAlignment = HorizontalAlignment.Right, + Children = { cancelBtn, switchBtn, managerBtn } + } + } + } + }; + + managerBtn.Click += (_, _) => + { + completion.TrySetResult(StandaloneSwitchChoice.LaunchManager); + window.Close(); + }; + + switchBtn.Click += (_, _) => + { + completion.TrySetResult(StandaloneSwitchChoice.SwitchStandalone); + window.Close(); + }; + + cancelBtn.Click += (_, _) => + { + completion.TrySetResult(StandaloneSwitchChoice.Cancel); + window.Close(); + }; + + window.Closed += (_, _) => completion.TrySetResult(StandaloneSwitchChoice.Cancel); + + if (owner is not null) + await window.ShowDialog(owner); + else + window.Show(); + + return await completion.Task; + }); + + private static async Task AskStartModeAsync() + => await Dispatcher.UIThread.InvokeAsync(async () => + { + var completion = new TaskCompletionSource(); + + var managerBtn = new Button + { + Content = "Instanzmanager starten (Empfohlen)", + Classes = { "primary" } + }; + + var standaloneBtn = new Button + { + Content = "Als Standalone-Instanz starten" + }; + + var cancelBtn = new Button + { + Content = "Abbrechen", + IsCancel = true + }; + + var window = new Window + { + Title = "ClawdDotNet – Startmodus wählen", + Width = 540, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Content = new StackPanel + { + Margin = new Thickness(22), + Spacing = 16, + Children = + { + new TextBlock + { + Text = "Wie möchtest du ClawdDotNet starten?", + Classes = { "h1" } + }, + new TextBlock + { + Text = "• Instanzmanager (Empfohlen):\n" + + " Startet den zentralen Hintergrund-Manager und öffnet die Verwaltung im Browser. " + + "Ermöglicht den stabilen Dauerbetrieb mehrerer Instanzen parallel ohne Absturzrisiko.\n\n" + + "• Standalone-Modus:\n" + + " Startet genau eine Instanz direkt als einzelnes Desktop-Fenster.", + TextWrapping = TextWrapping.Wrap + }, + new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 10, + HorizontalAlignment = HorizontalAlignment.Right, + Children = { cancelBtn, standaloneBtn, managerBtn } + } + } + } + }; + + managerBtn.Click += (_, _) => + { + completion.TrySetResult(StartModeChoice.LaunchManager); + window.Close(); + }; + + standaloneBtn.Click += (_, _) => + { + completion.TrySetResult(StartModeChoice.Standalone); + window.Close(); + }; + + cancelBtn.Click += (_, _) => + { + completion.TrySetResult(StartModeChoice.Cancel); + window.Close(); + }; + + window.Closed += (_, _) => completion.TrySetResult(StartModeChoice.Cancel); + + window.Show(); + return await completion.Task; + }); + + private int _beendetSich; + + /// + /// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat. + /// + /// Erst auslaufen lassen, dann abschneiden. Laufende Agenten und + /// beanspruchte Aufgaben bekommen ihre Frist + /// (), bevor Weboberfläche und Aufbau fallen. Ohne + /// das endete eine Aufgabe mitten in ProcessAsync und bliebe bis zum Ablauf + /// ihres Lease auf running stehen. + /// + /// Und nicht im Rückruf von desktop.ShutdownRequested. Avalonia + /// wartet dort nicht auf async-Behandlungen; was länger dauert als ein + /// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst + /// aufräumen und dann Shutdown rufen — der Rückruf dort bleibt als + /// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts. + /// + private async Task BeendeGeordnetAsync( + IClassicDesktopStyleApplicationLifetime desktop, string grund) + { + if (Interlocked.Exchange(ref _beendetSich, 1) != 0) return; + + var host = _host; + + if (host is not null) + { + host.LoggerFactory.CreateLogger("ClawdDotNet.Shutdown") + .LogInformation("Beendigung angefordert ({Grund}).", grund); + + try { await host.Shutdown.BeginAsync(); } + catch (Exception) { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ } + } + + await ShutdownWebAsync(); + + if (host is not null) await host.DisposeAsync(); + await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown()); } @@ -458,12 +834,6 @@ public partial class App : Application /// /// Instanzauswahl beim Start. - /// - /// Kam die Anwendung mit --instance hoch, wird nicht gefragt: Dann hat - /// der Benutzer die Instanz im vorigen Prozess bereits gewaehlt, und ein zweiter - /// Dialog waere nur die Frage nach der schon gegebenen Antwort. Der Wert wird einmal - /// verbraucht — bleibt der Pfad ungueltig, erscheint doch der Dialog, statt dass der - /// Start wortlos scheitert. /// private async Task SelectInstanceAsync(InstanceDirectoryManager directories) { @@ -473,6 +843,20 @@ public partial class App : Application if (preselected is not null && Directory.Exists(preselected)) return preselected; + if (!_managed) + { + var choice = await AskStartModeAsync(); + if (choice == StartModeChoice.LaunchManager) + { + LaunchInstanceManagerAndOpenBrowser(); + return null; + } + if (choice == StartModeChoice.Cancel) + { + return null; + } + } + return await ShowInstancePickerAsync(directories); } diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs index c4f5ae1..281974d 100644 --- a/src/ClawdDotNet.Desktop/HeadlessRun.cs +++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs @@ -54,10 +54,10 @@ internal static class HeadlessRun { SelectInstance = _ => Task.FromResult(instancePath), - // Fragt nicht, sondern meldet, was zu tun ist. Mit E-M3 wandert die - // Lizenzpruefung ohnehin in den Instanzmanager; bis dahin ist das hier der - // ehrliche Weg: Ein Schluessel wird vorab hinterlegt, nicht erfragt. - License = new NonInteractiveLicensePrompt(Console.Error.WriteLine) + // Fragt nicht, sondern meldet, was zu tun ist. Im geführten Modus (E-M3) + // entfällt die lokale Prüfung; der Manager prüft zentral. + License = new NonInteractiveLicensePrompt(Console.Error.WriteLine), + IsManaged = CommandLine.IsManaged(args) }); if (host is null) diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs index 968bdc7..a1c654c 100644 --- a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs +++ b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml.cs @@ -1,13 +1,48 @@ +using Avalonia; using Avalonia.Controls; +using Avalonia.Threading; using ClawdDotNet.Desktop.ViewModels; namespace ClawdDotNet.Desktop.Views; public partial class MainWindow : Window { + private bool _confirmedClose; + public MainWindow() { InitializeComponent(); + Closing += OnClosing; + } + + /// + /// Schließt ohne Rückfrage — für eine Beendigung, die nicht von diesem Fenster ausgeht. + /// + /// Warum das nötig ist. Der Bestätigungsdialog schützt vor dem + /// versehentlichen Klick eines Menschen. Bittet dagegen der Instanzmanager über den + /// Socket, steht niemand davor: Der Dialog bliebe offen, die zehn Sekunden liefen ab, + /// und niemand klickte. Der Manager wartete seine Frist ab und schösse den Prozess + /// dann ab — der Dialog, der ein hartes Ende verhindern soll, hätte es garantiert. + /// + public void BeendenOhneRueckfrage() + { + _confirmedClose = true; + Close(); + } + + private async void OnClosing(object? sender, WindowClosingEventArgs e) + { + if (_confirmedClose) return; + + // Schließen zunächst abbrechen, um Bestätigungsdialog anzuzeigen + e.Cancel = true; + + var confirmed = await ShowExitConfirmationAsync(this); + if (confirmed) + { + _confirmedClose = true; + Close(); + } } protected override void OnDataContextChanged(EventArgs e) @@ -21,4 +56,113 @@ public partial class MainWindow : Window if (DataContext is MainWindowViewModel vm) vm.RequestExit += Close; } + + internal static async Task ShowExitConfirmationAsync(Window? owner) + { + var completion = new TaskCompletionSource(); + var remainingSeconds = 10; + + var cancelBtn = new Button + { + Content = "Abbrechen (Instanz läuft weiter)", + IsCancel = true + }; + + var exitBtn = new Button + { + Content = $"Instanz beenden ({remainingSeconds}s)", + IsEnabled = false + }; + exitBtn.Classes.Add("danger"); + + var countdownText = new TextBlock + { + Text = $"Bestätigung gesperrt: Noch {remainingSeconds} Sekunden…", + Classes = { "caption" } + }; + + var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + timer.Tick += (_, _) => + { + remainingSeconds--; + if (remainingSeconds > 0) + { + countdownText.Text = $"Bestätigung gesperrt: Noch {remainingSeconds} Sekunden…"; + exitBtn.Content = $"Instanz beenden ({remainingSeconds}s)"; + } + else + { + timer.Stop(); + exitBtn.IsEnabled = true; + exitBtn.Content = "Instanz jetzt endgültig beenden"; + countdownText.Text = "Sperre aufgehoben — Bestätigung jetzt möglich."; + } + }; + + var window = new Window + { + Title = "ClawdDotNet – Instanz beenden", + Width = 500, + SizeToContent = SizeToContent.Height, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterScreen, + Icon = owner?.Icon, + Content = new StackPanel + { + Margin = new Thickness(20), + Spacing = 16, + Children = + { + new TextBlock + { + Text = "Achtung: Produktivinstanz beenden", + Classes = { "h1" } + }, + new TextBlock + { + Text = "ClawdDotNet-Instanzen sind auf dauerhaften Dauerbetrieb ausgelegt. " + + "Das Beenden stoppt alle aktiven Agenten, Automationen, Web-Zugriffe und Hintergrundaufgaben dieser Instanz geordnet.", + TextWrapping = Avalonia.Media.TextWrapping.Wrap + }, + countdownText, + new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + Spacing = 10, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Children = { cancelBtn, exitBtn } + } + } + } + }; + + cancelBtn.Click += (_, _) => + { + timer.Stop(); + completion.TrySetResult(false); + window.Close(); + }; + + exitBtn.Click += (_, _) => + { + timer.Stop(); + completion.TrySetResult(true); + window.Close(); + }; + + window.Closed += (_, _) => + { + timer.Stop(); + completion.TrySetResult(false); + }; + + timer.Start(); + + if (owner is not null) + await window.ShowDialog(owner); + else + window.Show(); + + return await completion.Task; + } } diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 3f226c1..371435d 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -1,3 +1,8 @@ +using System.IO; +using System.Text.Json; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -21,6 +26,32 @@ public sealed record NewInstanceDto(string Name); /// Antwort auf etwas, das gelingen oder scheitern kann. public sealed record OutcomeDto(bool Ok, string Meldung); +/// Eine gefundene Sicherungsdatei einer Instanz. +public sealed record BackupItemDto( + string FileName, + long SizeBytes, + DateTime CreatedAt, + string InstanceName, + string Secrets, + int SecretCount, + int FileCount); + +/// Übersicht aller Sicherungen einer Instanz mit Verzeichnisangabe. +public sealed record BackupListDto( + string Directory, + IReadOnlyList Backups); + +/// Parameter zum Anlegen einer neuen Sicherung. +public sealed record CreateBackupRequestDto( + string? Secrets, + string? Passphrase, + bool IncludeLogs, + bool IncludeChatHistory); + +/// Parameter zur Wiederherstellung einer Sicherung. +public sealed record RestoreBackupRequestDto( + string? Passphrase); + /// /// Die HTTP-Fläche des Managers. /// @@ -125,6 +156,190 @@ internal static class ManagerEndpoints $"Instanz '{instanz.Name}' hört nicht und ist von hier aus nicht zu beenden."), statusCode: StatusCodes.Status409Conflict); }); + + // ─── Datensicherung & Wiederherstellung (#530, Stufe 6) ─── + + verwaltung.MapGet("/instanzen/{id}/backups", async ( + string id, InstanceRegistry registry, ILoggerFactory protokoll, + CancellationToken ct) => + { + if (Find(registry, id) is not { } instanz) + return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); + + var backupDir = GetBackupDirectory(instanz, Log(protokoll)); + if (!Directory.Exists(backupDir)) + return Results.Ok(new BackupListDto(backupDir, [])); + + var service = new BackupService(); + var list = new List(); + + var files = new DirectoryInfo(backupDir) + .GetFiles("*.zip") + .OrderByDescending(f => f.LastWriteTimeUtc); + + foreach (var f in files) + { + ct.ThrowIfCancellationRequested(); + try + { + var manifest = await service.InspectAsync(f.FullName, ct); + list.Add(new BackupItemDto( + f.Name, + f.Length, + manifest.CreatedAt != default ? manifest.CreatedAt : f.CreationTimeUtc, + manifest.InstanceName, + manifest.Secrets, + manifest.SecretCount, + manifest.Files.Count)); + } + catch + { + list.Add(new BackupItemDto( + f.Name, + f.Length, + f.CreationTimeUtc, + instanz.Name, + "Unbekannt", + 0, + 0)); + } + } + + return Results.Ok(new BackupListDto(backupDir, list)); + }); + + verwaltung.MapPost("/instanzen/{id}/backups", async ( + string id, CreateBackupRequestDto? req, InstanceRegistry registry, + ILoggerFactory protokoll, CancellationToken ct) => + { + if (Find(registry, id) is not { } instanz) + return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); + + var backupDir = GetBackupDirectory(instanz, Log(protokoll)); + Directory.CreateDirectory(backupDir); + + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + var safeName = string.Join("_", instanz.Name.Split(Path.GetInvalidFileNameChars())); + var zipName = $"backup_{safeName}_{timestamp}.zip"; + var zipPath = Path.Combine(backupDir, zipName); + + var secretMode = string.Equals(req?.Secrets, "Passphrase", StringComparison.OrdinalIgnoreCase) + ? SecretMode.Passphrase + : SecretMode.Exclude; + + if (secretMode == SecretMode.Passphrase && string.IsNullOrWhiteSpace(req?.Passphrase)) + return Results.BadRequest(new OutcomeDto(false, "Für den Schutz der Zugangsdaten wird eine Passphrase benötigt.")); + + var options = new BackupOptions + { + Secrets = secretMode, + Passphrase = req?.Passphrase, + IncludeLogs = req?.IncludeLogs ?? false, + IncludeChatHistory = req?.IncludeChatHistory ?? true + }; + + try + { + var service = new BackupService(); + var result = await service.CreateAsync(instanz.Directory, zipPath, options, ct); + return Results.Ok(new OutcomeDto(true, $"Sicherung '{zipName}' erfolgreich erstellt ({result.SizeBytes / 1024} KB).")); + } + catch (Exception ex) + { + return Results.BadRequest(new OutcomeDto(false, $"Sicherung fehlgeschlagen: {ex.Message}")); + } + }); + + verwaltung.MapPost("/instanzen/{id}/backups/{filename}/restore", async ( + string id, string filename, RestoreBackupRequestDto? req, ManagerCore manager, + InstanceRegistry registry, ILoggerFactory protokoll) => + { + if (Find(registry, id) is not { } instanz) + return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); + + if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename) + return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname.")); + + var backupDir = GetBackupDirectory(instanz, Log(protokoll)); + var zipPath = Path.Combine(backupDir, filename); + + if (!File.Exists(zipPath)) + return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' wurde nicht gefunden.")); + + // Ausdruecklich ohne das Token der Anfrage: Wer mitten im Zurueckspielen den + // Browser schliesst, hinterliesse sonst ein halb ueberschriebenes + // Instanzverzeichnis - und das wuerde anschliessend auch noch gestartet. Was + // hier beginnt, wird zu Ende gebracht. + var ct = CancellationToken.None; + + var wasRunning = instanz.Condition is InstanceCondition.Running or InstanceCondition.Silent; + if (wasRunning) + { + var stopped = await manager.StopInstanceAsync(instanz.Directory, ct); + if (!stopped) + { + return Results.Json( + new OutcomeDto(false, $"Instanz '{instanz.Name}' konnte vor der Wiederherstellung nicht angehalten werden."), + statusCode: StatusCodes.Status409Conflict); + } + } + + try + { + var service = new BackupService(); + var restoreResult = await service.RestoreAsync( + zipPath, + instanz.Directory, + new RestoreOptions + { + Passphrase = req?.Passphrase, + Overwrite = true + }, + ct); + + if (wasRunning) + { + await manager.StartInstanceAsync(instanz.Directory, ct); + } + + return Results.Ok(new OutcomeDto(true, $"Sicherung '{filename}' erfolgreich wiederhergestellt ({restoreResult.Written.Count} Dateien geschrieben).")); + } + catch (Exception ex) + { + if (wasRunning) + { + try { await manager.StartInstanceAsync(instanz.Directory, ct); } catch { } + } + + return Results.BadRequest(new OutcomeDto(false, $"Wiederherstellung fehlgeschlagen: {ex.Message}")); + } + }); + + verwaltung.MapDelete("/instanzen/{id}/backups/{filename}", ( + string id, string filename, InstanceRegistry registry, ILoggerFactory protokoll) => + { + if (Find(registry, id) is not { } instanz) + return Results.NotFound(new OutcomeDto(false, $"Es gibt keine Instanz '{id}'.")); + + if (filename.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || Path.GetFileName(filename) != filename) + return Results.BadRequest(new OutcomeDto(false, "Ungültiger Dateiname.")); + + var backupDir = GetBackupDirectory(instanz, Log(protokoll)); + var zipPath = Path.Combine(backupDir, filename); + + if (!File.Exists(zipPath)) + return Results.NotFound(new OutcomeDto(false, $"Sicherungsdatei '{filename}' nicht gefunden.")); + + try + { + File.Delete(zipPath); + return Results.Ok(new OutcomeDto(true, $"Sicherungsdatei '{filename}' gelöscht.")); + } + catch (Exception ex) + { + return Results.BadRequest(new OutcomeDto(false, $"Konnte nicht gelöscht werden: {ex.Message}")); + } + }); } // ─── Der Ereignisstrom ─── @@ -174,7 +389,44 @@ internal static class ManagerEndpoints InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId, instanz.AutostartByManager); + private static ILogger Log(ILoggerFactory factory) + => factory.CreateLogger("ClawdDotNet.Manager.Web"); + private static ManagedInstance? Find(InstanceRegistry registry, string id) => registry.Instances.FirstOrDefault( i => string.Equals(InstanceRouting.IdOf(i), id, StringComparison.OrdinalIgnoreCase)); + + /// + /// Wo die Sicherungen dieser Instanz liegen. + /// + /// Eine unlesbare Instanzdatei wird gemeldet, nicht verschluckt. Sonst + /// landeten die Sicherungen wortlos im Standardverzeichnis statt im eingestellten — + /// und niemand sähe, warum die alten verschwunden sind. Genau diese Bauart hat + /// manager.json schon einmal eine halbe Stunde Fehlersuche gekostet. + /// + private static string GetBackupDirectory(ManagedInstance instanz, ILogger? logger = null) + { + InstanceConfig? config = null; + var settingsFile = Path.Combine(instanz.Directory, "InstanceSettings.json"); + if (File.Exists(settingsFile)) + { + try + { + config = JsonSerializer.Deserialize( + File.ReadAllText(settingsFile), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + logger?.LogError(ex, + "InstanceSettings.json von {Name} ist nicht lesbar — für die Sicherungen " + + "gilt das Standardverzeichnis, nicht das dort eingetragene.", + instanz.Name); + } + } + + return InstanceLayout.ResolveBackupDirectory( + config ?? new InstanceConfig { WorkingDirectory = instanz.Directory }, + instanz.Directory); + } } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index fe917bb..e378064 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -326,6 +326,33 @@ a:hover { gap: 12px; } +.kopf-instanz-container { + display: flex; + align-items: center; + gap: 8px; +} + +.kopf-instanz-label { + font-size: 13px; + font-weight: 600; + color: var(--gedaempft); +} + +.kopf-instanz-select { + max-width: 220px; + font-weight: 600; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +#kopf-instanz-start-btn { + padding: 3px 8px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + .status-punkt { width: 9px; height: 9px; @@ -871,6 +898,46 @@ button.klein { font-weight: 600; } +.log-instanz { + font-family: var(--schrift-mono); + font-size: 11px; + font-weight: 600; + padding: 1px 5px; + background: var(--surface); + border: 1px solid var(--divider); + color: var(--accent); + margin-right: 6px; +} + +/* ─── Ansicht: Instanzen ─── */ + +.instanzen-raster { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; + margin-top: 16px; +} + +.instanz-karte { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.instanz-karte.aktiv { + border-left: 4px solid var(--accent); +} + +.instanz-aktionen { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--divider); +} + /* ─── Modal Dialoge ─── */ .modal-overlay { @@ -987,6 +1054,166 @@ button.klein { overflow-x: auto; } +/* ─── Modale Dialoge ─── */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1500; + padding: 16px; + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.modal-overlay.sichtbar { + opacity: 1; + pointer-events: auto; +} + +.modal-overlay[hidden] { + display: none; +} + +.modal-box { + max-width: 520px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--divider); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35); +} + +.modal-kopf { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--divider); + background: var(--surface-subtil); +} + +.modal-kopf h2 { + margin: 0; + font-size: 18px; + font-family: var(--schrift-titel); +} + +.modal-koerper { + padding: 18px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +.modal-fuss { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 18px; + border-top: 1px solid var(--divider); + background: var(--surface-subtil); +} + +/* Sicherheits- und Countdown-Elemente */ + +.sicherheits-box { + background: rgba(192, 57, 43, 0.08); + border: 1px solid var(--gefahr); + border-left: 4px solid var(--gefahr); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sicherheits-box h3 { + margin: 0; + font-size: 15px; + color: var(--gefahr); + display: flex; + align-items: center; + gap: 8px; +} + +.sicherheits-box p { + margin: 0; + font-size: 13px; + line-height: 1.45; +} + +.sicherheits-countdown { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + background: var(--bg); + border: 1px dashed var(--divider); + font-family: var(--schrift-mono); + font-size: 13px; + font-weight: 600; + color: var(--warnung); + text-align: center; +} + +.sicherheits-countdown.abgelaufen { + color: var(--gefahr); + border-color: var(--gefahr); + border-style: solid; +} + +/* ─── Einstellungen & Systemarchitektur ─── */ + +.einstellungen-ansicht { + display: flex; + flex-direction: column; + gap: 28px; +} + +.einstellungen-sektion { + display: flex; + flex-direction: column; + gap: 14px; + padding: 20px; + background: var(--surface-subtil); + border: 1px solid var(--divider); +} + +.einstellungen-sektion.sektion-manager { + border-left: 4px solid var(--warnung); +} + +.einstellungen-sektion.sektion-instanz { + border-left: 4px solid var(--erfolg); +} + +.sektion-titel-zeile { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--divider); +} + +.sektion-titel-zeile h2 { + margin: 0; + font-size: 18px; +} + /* ─── Responsive Optimierung (Handy / 375px) ─── */ @media (max-width: 768px) { @@ -1036,4 +1263,115 @@ button.klein { .chat-aktionen { justify-content: flex-end; } + + .kopf-instanz-label { + display: none; + } + + .kopf-instanz-select { + max-width: 140px; + font-size: 13px; + padding: 4px 6px; + } + + .instanzen-raster { + grid-template-columns: 1fr; + } } + +/* ─── Datensicherung & Wiederherstellung ─── */ + +.sicherung-ansicht { + display: flex; + flex-direction: column; + gap: 16px; +} + +.ordner-pfad-anzeige { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface-subtil); + border: 1px solid var(--divider); + padding: 8px 12px; + margin-top: 8px; + font-family: var(--schrift-mono); + font-size: 13px; + word-break: break-all; +} + +.dateiname-zelle { + display: flex; + align-items: center; + gap: 8px; + word-break: break-all; +} + +.spalte-aktionen { + white-space: nowrap; + text-align: right; +} + +/* ─── Interne Agenten-Kommunikation (A2A) ─── */ + +.agenten-chats-ansicht { + display: flex; + flex-direction: column; + gap: 16px; +} + +.a2a-timeline { + display: flex; + flex-direction: column; + gap: 12px; +} + +.a2a-eintrag { + background: var(--surface); + border: 1px solid var(--divider); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.a2a-eintrag.a2a-anfrage { + border-left: 3px solid var(--accent); +} + +.a2a-eintrag.a2a-antwort { + border-left: 3px solid var(--erfolg); + background: var(--surface-subtil); +} + +.a2a-eintrag-kopf { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.a2a-pfeil { + color: var(--gedaempft); + font-weight: 600; + font-size: 13px; +} + +.a2a-zeit { + font-size: 12px; + color: var(--gedaempft); + white-space: nowrap; +} + +.a2a-inhalt { + font-family: var(--schrift-mono); + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + background: var(--bg); + padding: 10px 12px; + border: 1px solid var(--divider); + margin: 0; + color: var(--text); +} + diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.js b/src/ClawdDotNet.Manager.Web/wwwroot/app.js index 864dc9b..556fa99 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.js @@ -1,12 +1,21 @@ // ClawdDotNet — WebUI Haupteinstiegspunkt // -// Initialisiert Authentifizierung, Router, Ereignisstrom, Farbschema und Kopfzeile. +// Initialisiert Authentifizierung, Router, Ereignisstrom, Farbschema, Kopfzeile und Instanzenverwaltung. -import { api, hatToken, loescheToken, beiUnberechtigt } from "./modules/api.js"; +import { + api, + hatToken, + loescheToken, + beiUnberechtigt, + holeAktiveInstanz, + setzeAktiveInstanz, + beiInstanzWechsel +} from "./modules/api.js"; import { verbindeStrom, trenneStrom, beiVerbindungsStatus, abonnieren } from "./modules/events.js"; import { initialisiereTheme, wechsleTheme } from "./modules/theme.js"; import { registriereRoute, initialisiereRouter, navigiere } from "./modules/router.js"; import { initialisiereLogin, zeigeAnmeldung, versteckeAnmeldung } from "./views/login.js"; +import { erstelleElement, erstellePlakette, instanzZustandInfo, zeigeMeldung } from "./modules/dom.js"; // Ansichten import { rendereChat } from "./views/chat.js"; @@ -17,23 +26,33 @@ import { rendereStaging, holeWartendeAnzahl } from "./views/staging.js"; import { rendereVerbrauch } from "./views/usage.js"; import { rendereProtokoll, trenneLogStream } from "./views/logs.js"; import { rendereInfo } from "./views/info.js"; -import { rendereSicherung, rendereEinstellungen, rendereAgentenChats } from "./views/placeholders.js"; +import { rendereInstanzen, stoppePolling as stoppeInstanzenPolling } from "./views/instances.js"; +import { rendereEinstellungen } from "./views/settings.js"; +import { rendereSicherung } from "./views/backup.js"; +import { rendereAgentenChats, trenneAgentenChatsStream } from "./views/agent-chats.js"; // ─── 1. Routen registrieren ─── -registriereRoute("/chat", () => { trenneLogStream(); return rendereChat(); }); -registriereRoute("/chat/:agentId", (p) => { trenneLogStream(); return rendereChat(p); }); -registriereRoute("/agenten", () => { trenneLogStream(); return rendereAgenten(); }); -registriereRoute("/agenten/:agentId", (p) => { trenneLogStream(); return rendereAgenten(p); }); -registriereRoute("/aufgaben", () => { trenneLogStream(); return rendereAufgaben(); }); -registriereRoute("/dienste", () => { trenneLogStream(); return rendereDienste(); }); -registriereRoute("/staging", () => { trenneLogStream(); return rendereStaging(); }); -registriereRoute("/verbrauch", () => { trenneLogStream(); return rendereVerbrauch(); }); -registriereRoute("/agenten-chats", () => { trenneLogStream(); return rendereAgentenChats(); }); -registriereRoute("/protokoll", () => rendereProtokoll()); -registriereRoute("/sicherung", () => { trenneLogStream(); return rendereSicherung(); }); -registriereRoute("/einstellungen", () => { trenneLogStream(); return rendereEinstellungen(); }); -registriereRoute("/info", () => { trenneLogStream(); return rendereInfo(); }); +function verlasseAnsicht() { + trenneLogStream(); + trenneAgentenChatsStream(); + stoppeInstanzenPolling(); +} + +registriereRoute("/instanzen", () => { verlasseAnsicht(); return rendereInstanzen(); }); +registriereRoute("/chat", () => { verlasseAnsicht(); return rendereChat(); }); +registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereChat(p); }); +registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); }); +registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); }); +registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); }); +registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); }); +registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); }); +registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); }); +registriereRoute("/agenten-chats", () => { verlasseAnsicht(); return rendereAgentenChats(); }); +registriereRoute("/protokoll", () => { verlasseAnsicht(); return rendereProtokoll(); }); +registriereRoute("/sicherung", () => { verlasseAnsicht(); return rendereSicherung(); }); +registriereRoute("/einstellungen", () => { verlasseAnsicht(); return rendereEinstellungen(); }); +registriereRoute("/info", () => { verlasseAnsicht(); return rendereInfo(); }); // ─── 2. DOM-Verdrahtung ─── @@ -44,19 +63,30 @@ const seitenleisteBackdrop = document.getElementById("seitenleiste-backdrop"); const themeSchalter = document.getElementById("theme-schalter"); const abmeldenBtn = document.getElementById("abmelden-btn"); const verbindungPunkt = document.getElementById("verbindung-punkt"); -const kopfInstanz = document.getElementById("kopf-instanz"); +let kopfInstanzSelect = document.getElementById("kopf-instanz-select"); +let kopfInstanzStatus = document.getElementById("kopf-instanz-status"); const kopfOpenRouter = document.getElementById("kopf-openrouter-status"); -// Seitenleiste kollabieren (Desktop) -sidebarToggle?.addEventListener("click", () => { - seitenleiste?.classList.toggle("kollabiert"); -}); +let bekannteInstanzen = []; +let instanzenPoller = null; -// Mobil-Menü öffnen -mobilMenueBtn?.addEventListener("click", () => { - seitenleiste?.classList.add("mobil-offen"); - if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = false; -}); +// Seitenleiste ein-/ausklappen (Desktop & Mobil) +function toggleSeitenleiste() { + if (window.innerWidth <= 768) { + const istOffen = seitenleiste?.classList.contains("mobil-offen"); + if (istOffen) { + schliesseMobilMenue(); + } else { + seitenleiste?.classList.add("mobil-offen"); + if (seitenleisteBackdrop) seitenleisteBackdrop.hidden = false; + } + } else { + seitenleiste?.classList.toggle("kollabiert"); + } +} + +sidebarToggle?.addEventListener("click", toggleSeitenleiste); +mobilMenueBtn?.addEventListener("click", toggleSeitenleiste); // Mobil-Menü schließen bei Klick auf Backdrop oder Link function schliesseMobilMenue() { @@ -78,19 +108,27 @@ themeSchalter?.addEventListener("click", () => { // Abmelden abmeldenBtn?.addEventListener("click", () => { + beendeHauptAnwendung(); loescheToken(); - trenneStrom(); - trenneLogStream(); zeigeAnmeldung(); }); // Bei unberechtigtem API-Aufruf (401) sofort zur Anmeldung springen beiUnberechtigt(() => { - trenneStrom(); - trenneLogStream(); + beendeHauptAnwendung(); zeigeAnmeldung(); }); +function beendeHauptAnwendung() { + if (instanzenPoller) { + clearInterval(instanzenPoller); + instanzenPoller = null; + } + trenneStrom(); + trenneLogStream(); + stoppeInstanzenPolling(); +} + // Verbindungsstatus des Ereignisstroms beiVerbindungsStatus((istOffen) => { if (!verbindungPunkt) return; @@ -103,12 +141,148 @@ beiVerbindungsStatus((istOffen) => { } }); +// Instanzwechsel über Dropdown +kopfInstanzSelect?.addEventListener("change", (e) => { + const neueId = e.target.value; + setzeAktiveInstanz(neueId); +}); + +// Bei Instanzwechsel Zustand synchronisieren und Ansicht aktualisieren +beiInstanzWechsel(async (neueId) => { + if (kopfInstanzSelect && kopfInstanzSelect.value !== neueId) { + kopfInstanzSelect.value = neueId; + } + + aktualisiereInstanzPlakette(); + await aktualisiereKopfStatus(); + await aktualisiereStagingZaehler(); + + // Aktuelle Ansicht neu laden, damit die Daten der neuen Instanz geladen werden + if (window.location.hash) { + window.dispatchEvent(new HashChangeEvent("hashchange")); + } +}); + +function aktualisiereInstanzPlakette() { + kopfInstanzStatus = document.getElementById("kopf-instanz-status"); + const startBtn = document.getElementById("kopf-instanz-start-btn"); + + const gewaehlteId = holeAktiveInstanz(); + const aktive = bekannteInstanzen.find((i) => i.id === gewaehlteId); + const laufende = bekannteInstanzen.filter((i) => i.zustand === "Running"); + + if (kopfInstanzStatus) { + if (aktive) { + const zInfo = instanzZustandInfo(aktive.zustand); + const neuePlakette = erstellePlakette(zInfo.text, zInfo.art); + neuePlakette.id = "kopf-instanz-status"; + kopfInstanzStatus.replaceWith(neuePlakette); + kopfInstanzStatus = neuePlakette; + } else { + const text = laufende.length > 1 ? `${laufende.length} laufen` : "Keine aktiv"; + const art = laufende.length > 1 ? "warnung" : "neutral"; + const neuePlakette = erstellePlakette(text, art); + neuePlakette.id = "kopf-instanz-status"; + kopfInstanzStatus.replaceWith(neuePlakette); + kopfInstanzStatus = neuePlakette; + } + } + + if (startBtn) { + if (aktive && aktive.zustand !== "Running") { + startBtn.hidden = false; + startBtn.disabled = false; + startBtn.textContent = "Starten"; + startBtn.onclick = async () => { + startBtn.disabled = true; + startBtn.textContent = "Startet…"; + try { + const res = await api(`/verwaltung/instanzen/${encodeURIComponent(aktive.id)}/start`, { + method: "POST" + }); + zeigeMeldung(res?.meldung || `Instanz '${aktive.name}' gestartet.`, "erfolg"); + await aktualisiereInstanzenListe(); + if (window.location.hash) { + window.dispatchEvent(new HashChangeEvent("hashchange")); + } + } catch (err) { + if (err.lizenzFehlt) { + zeigeMeldung("Start abgelehnt: Ohne gültige Lizenz startet keine Instanz (HTTP 402).", "gefahr", 6000); + } else { + zeigeMeldung(`Start fehlgeschlagen: ${err.message}`, "gefahr"); + } + startBtn.disabled = false; + startBtn.textContent = "Starten"; + } + }; + } else { + startBtn.hidden = true; + } + } +} + +async function aktualisiereInstanzenListe(vorhandene = null) { + try { + bekannteInstanzen = vorhandene || (await api("/verwaltung/instanzen")); + } catch (err) { + if (err.status !== 401) { + console.warn("Konnte Instanzenliste nicht abrufen:", err.message); + } + return; + } + + kopfInstanzSelect = document.getElementById("kopf-instanz-select"); + if (!kopfInstanzSelect) return; + + let gewaehlteId = holeAktiveInstanz(); + const laufende = bekannteInstanzen.filter((i) => i.zustand === "Running"); + + // Automatische Wahl, wenn genau eine Instanz läuft und keine explizit gewählt ist + if (!gewaehlteId && laufende.length === 1) { + gewaehlteId = laufende[0].id; + setzeAktiveInstanz(gewaehlteId); + } else if (gewaehlteId && !bekannteInstanzen.some((i) => i.id === gewaehlteId)) { + if (laufende.length === 1) { + gewaehlteId = laufende[0].id; + setzeAktiveInstanz(gewaehlteId); + } else { + gewaehlteId = ""; + setzeAktiveInstanz(""); + } + } + + // Dropdown neu befüllen + kopfInstanzSelect.replaceChildren(); + const placeholderOpt = erstelleElement("option", { + value: "", + text: bekannteInstanzen.length === 0 ? "Keine Instanzen" : "— Instanz wählen —" + }); + kopfInstanzSelect.append(placeholderOpt); + + for (const instanz of bekannteInstanzen) { + const zInfo = instanzZustandInfo(instanz.zustand); + const opt = erstelleElement("option", { + value: instanz.id, + text: `${instanz.name} (${zInfo.text})` + }); + kopfInstanzSelect.append(opt); + } + + kopfInstanzSelect.value = gewaehlteId || ""; + aktualisiereInstanzPlakette(); +} + // ─── 3. Start & Initialisierung ─── initialisiereTheme(); initialisiereLogin(starteHauptAnwendung); async function aktualisiereKopfStatus() { + if (!holeAktiveInstanz()) { + if (kopfOpenRouter) kopfOpenRouter.textContent = ""; + return; + } + try { const status = await api("/status"); if (kopfOpenRouter && status) { @@ -116,12 +290,26 @@ async function aktualisiereKopfStatus() { ? `OpenRouter: ${status.creditsText}` : (status.isApiReachable ? "OpenRouter: OK" : "OpenRouter: offline"); } - } catch { - if (kopfOpenRouter) kopfOpenRouter.textContent = ""; + } catch (err) { + if (kopfOpenRouter) { + if (err.instanzUnerreichbar) { + kopfOpenRouter.textContent = "Instanz offline (502)"; + } else if (err.instanzKonflikt) { + kopfOpenRouter.textContent = "Instanz wählen (409)"; + } else { + kopfOpenRouter.textContent = ""; + } + } } } async function aktualisiereStagingZaehler() { + if (!holeAktiveInstanz()) { + const badge = document.getElementById("staging-badge"); + if (badge) badge.hidden = true; + return; + } + const anzahl = await holeWartendeAnzahl(); const badge = document.getElementById("staging-badge"); if (badge) { @@ -134,12 +322,17 @@ async function aktualisiereStagingZaehler() { } } -async function starteHauptAnwendung(info) { +async function starteHauptAnwendung(instanzen) { versteckeAnmeldung(); - if (kopfInstanz && info) { - kopfInstanz.textContent = `${info.instanceName} (${info.instanceId})`; - } + // Instanzenliste initialisieren + await aktualisiereInstanzenListe(Array.isArray(instanzen) ? instanzen : null); + + // Hintergrundabgleich der Instanzen alle 5s + if (instanzenPoller) clearInterval(instanzenPoller); + instanzenPoller = setInterval(() => { + aktualisiereInstanzenListe(); + }, 5000); // Ereignisstrom verbinden verbindeStrom(); @@ -165,9 +358,9 @@ async function starteHauptAnwendung(info) { // Prüfe bestehende Session if (hatToken()) { - api("/info") - .then((info) => { - starteHauptAnwendung(info); + api("/verwaltung/instanzen") + .then((instanzen) => { + starteHauptAnwendung(instanzen); }) .catch(() => { loescheToken(); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/index.html b/src/ClawdDotNet.Manager.Web/wwwroot/index.html index ec7548c..ce9a013 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/index.html +++ b/src/ClawdDotNet.Manager.Web/wwwroot/index.html @@ -15,7 +15,7 @@

ClawdDotNet

- Bitte gib das Zugangstoken der Instanz ein (webUiToken in der Instanzdatei). + Bitte gib das Zugangstoken des Instanzmanagers ein (webUiToken in manager.json).

@@ -87,6 +87,12 @@