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 @@