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 <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
Assemblies und nichts an Betriebsaufwand.
|
||||
-->
|
||||
<DebugType>embedded</DebugType>
|
||||
|
||||
<!--
|
||||
Nur Deutsch und optional Englisch als Ressourcensprachen mitfuehren.
|
||||
Verhindert, dass Drittbibliotheken Dutzende Sprachordner (cs, es, fr, it, ja,
|
||||
ko, pl, pt-BR, ru, tr, zh-Hans, zh-Hant) in das Ausgabeverzeichnis kopieren.
|
||||
-->
|
||||
<SatelliteResourceLanguages>de;en</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -60,6 +60,22 @@ public sealed class AppHost : IAsyncDisposable
|
||||
public required ILoggerFactory LoggerFactory { get; init; }
|
||||
public required ToolRegistry Tools { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Das geordnete Ende dieser Instanz (#528).
|
||||
///
|
||||
/// <para>Wer die Instanz beenden will — der Instanzmanager über den Socket, ein
|
||||
/// Mensch über das Fenster — ruft <see cref="ShutdownCoordinator.BeginAsync"/> und
|
||||
/// wartet, bevor er <see cref="DisposeAsync"/> anstößt. Der Instanzmanager liest
|
||||
/// unterdessen <see cref="ShutdownCoordinator.Snapshot"/> über den Puls und weiß
|
||||
/// dadurch, dass hier aufgeräumt und nicht gehangen wird.</para>
|
||||
/// </summary>
|
||||
/// <para>Nicht <c>required</c>, sondern mit einem leeren Koordinator vorbelegt: Ein
|
||||
/// Aufbau ohne angemeldete Arbeitsquellen hat nichts abzuwarten und endet sofort. So
|
||||
/// gibt es diese Zusage <em>immer</em>, und niemand muss auf <c>null</c> prüfen,
|
||||
/// bevor er eine Instanz geordnet beendet.</para>
|
||||
public ShutdownCoordinator Shutdown { get; init; } =
|
||||
new(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
|
||||
|
||||
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
|
||||
public AgentEngine? Engine { get; private init; }
|
||||
|
||||
@@ -177,8 +193,11 @@ public sealed class AppHost : IAsyncDisposable
|
||||
/// </summary>
|
||||
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
|
||||
|
||||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht.</summary>
|
||||
public required ILicensePrompt License { get; init; }
|
||||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht. Null im geführten Modus.</summary>
|
||||
public ILicensePrompt? License { get; init; }
|
||||
|
||||
/// <summary>Wahr, wenn die Instanz durch den Instanzmanager gestartet und geführt wird.</summary>
|
||||
public bool IsManaged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 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(); }
|
||||
|
||||
@@ -79,8 +79,14 @@ public sealed class InstanceConfig
|
||||
public int BackupKeepCount { get; set; } = 14;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para><b>Noch ohne <c>[Obsolete]</c>.</b> 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.</para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("webServerPort")]
|
||||
public int WebServerPort { get; set; } = 7001;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Wie viele Agentenläufe gerade laufen — Chats <em>und</em> angestoßene Läufe.
|
||||
///
|
||||
/// <para>Nicht dasselbe wie <see cref="RunningChatCount"/>: Der zählt <em>Agenten</em>
|
||||
/// mit mindestens einem Chat, dieser zählt <em>Läufe</em>. Fürs Herunterfahren ist der
|
||||
/// Lauf die Einheit — zwei Läufe desselben Agenten sind zwei Dinge, die zu Ende kommen
|
||||
/// müssen.</para>
|
||||
/// </summary>
|
||||
public int OpenRunCount => Volatile.Read(ref _openRuns);
|
||||
|
||||
/// <summary>Nimmt die Engine noch neue Läufe an?</summary>
|
||||
public bool IsAcceptingWork => Volatile.Read(ref _accepting) == 1;
|
||||
|
||||
/// <summary>
|
||||
/// Schließt die Tür: ab jetzt kein neuer Lauf mehr.
|
||||
///
|
||||
/// <para>Laufende werden <b>nicht</b> abgebrochen — das ist der ganze Punkt. Wer sie
|
||||
/// abbrechen will, hat dafür <c>AbortChat</c>.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Zählt einen laufenden Lauf, solange sie lebt.
|
||||
///
|
||||
/// <para>Als <c>using</c>-Wert statt als <c>try/finally</c> 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.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
|
||||
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
TargetBusy
|
||||
TargetBusy,
|
||||
|
||||
/// <summary>
|
||||
/// Der Lauf fand nicht statt, weil die Instanz gerade geordnet endet (#528).
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
ShuttingDown
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ClawdDotNet.Core.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Der Weg, auf dem ein Instanzprozess von außen ansprechbar ist (#528, E-M1).
|
||||
///
|
||||
/// <para><b>Kein Port.</b> Ports müssten vergeben, verwaltet und gegen Kollisionen
|
||||
/// gesichert werden — genau das Problem, an dem die Vorbelegung <c>7001</c> scheitert,
|
||||
/// sobald zwei Instanzen laufen. Ein Socket im Instanzverzeichnis braucht keine Vergabe:
|
||||
/// Wo die Instanz liegt, liegt auch ihre Adresse.</para>
|
||||
///
|
||||
/// <para><b>Die Zugriffsrechte stehen am Dateisystem</b>, 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 <see cref="Restrict"/> die Rechte auf
|
||||
/// 0600, unter Windows übernimmt das die Named Pipe mit
|
||||
/// <c>CurrentUserOnly</c>.</para>
|
||||
///
|
||||
/// <para><b>Warum HTTP darüber.</b> 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.</para>
|
||||
/// </summary>
|
||||
public static class InstanceEndpoint
|
||||
{
|
||||
/// <summary>Der Name der Socketdatei im Instanzverzeichnis.</summary>
|
||||
public const string SocketFileName = "manager.sock";
|
||||
|
||||
/// <summary>
|
||||
/// Ob auf dieser Maschine eine Named Pipe statt eines Unix-Domain-Sockets benutzt
|
||||
/// wird.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para>An Aufrufstellen, hinter denen etwas Plattformgebundenes steht, wird
|
||||
/// trotzdem <c>OperatingSystem.IsWindows()</c> geschrieben: Die Plattformanalyse
|
||||
/// erkennt nur den unmittelbaren Aufruf als Absicherung, nicht eine Eigenschaft, die
|
||||
/// dasselbe sagt.</para>
|
||||
/// </summary>
|
||||
public static bool UsesNamedPipe => OperatingSystem.IsWindows();
|
||||
|
||||
/// <summary>Die Socketdatei dieser Instanz. Auch unter Windows berechenbar — dort
|
||||
/// bestimmt sie über <see cref="PipeName"/> nur den Namen, es entsteht keine Datei.</summary>
|
||||
public static string SocketPath(string instanceDirectory)
|
||||
=> Path.Combine(Path.GetFullPath(instanceDirectory), SocketFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Der Name der Named Pipe dieser Instanz.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public static string PipeName(string instanceDirectory)
|
||||
=> "clawd-" + Fingerprint(instanceDirectory);
|
||||
|
||||
/// <summary>Die Adresse, wie man sie einem Menschen ins Protokoll schreibt.</summary>
|
||||
public static string Describe(string instanceDirectory)
|
||||
=> UsesNamedPipe
|
||||
? $@"\\.\pipe\{PipeName(instanceDirectory)}"
|
||||
: SocketPath(instanceDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Räumt eine liegengebliebene Socketdatei weg.
|
||||
///
|
||||
/// <para><b>Ohne das startet die Instanz nach einem harten Ende nicht wieder.</b> Ein
|
||||
/// Unix-Domain-Socket ist eine Datei, und sie überlebt den Prozess, der sie angelegt
|
||||
/// hat. Beim nächsten Binden kommt <c>EADDRINUSE</c> — von einem Prozess, den es
|
||||
/// nicht mehr gibt. Genau der Fall aus #529: Windows hat die Anwendung erschlagen,
|
||||
/// aufgeräumt hat niemand.</para>
|
||||
///
|
||||
/// <para>Unter Windows ohne Wirkung: Eine Named Pipe verschwindet mit ihrem letzten
|
||||
/// Halter.</para>
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt die Rechte der Socketdatei auf 0600 — nur der eigene Benutzer.
|
||||
///
|
||||
/// <para>Erst nach dem Binden aufzurufen: Vorher gibt es die Datei nicht. Unter
|
||||
/// Windows ohne Wirkung; dort trägt <c>NamedPipeTransportOptions.CurrentUserOnly</c>
|
||||
/// dieselbe Zusage.</para>
|
||||
/// </summary>
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein <see cref="HttpClient"/>, der über den Socket dieser Instanz spricht.
|
||||
///
|
||||
/// <para>Die Adresse in der Anfrage ist ein Platzhalter — <c>http://instanz</c> —,
|
||||
/// weil HTTP einen Host verlangt und der Socket keinen hat. Verbunden wird über den
|
||||
/// Rückruf, nicht über den Namen.</para>
|
||||
/// </summary>
|
||||
public static HttpClient CreateClient(string instanceDirectory)
|
||||
=> new(CreateHandler(instanceDirectory)) { BaseAddress = new Uri("http://instanz") };
|
||||
|
||||
/// <inheritdoc cref="CreateClient"/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fingerabdruck eines Instanzpfades: 16 Hexzeichen aus SHA-256.
|
||||
///
|
||||
/// <para>Unter Windows wird die Schreibweise vorher zusammengeführt — dort sind
|
||||
/// <c>C:\Instanzen\Test</c> und <c>c:\instanzen\test</c> dasselbe Verzeichnis und
|
||||
/// müssen denselben Pipenamen ergeben. Unter Linux sind es zwei Verzeichnisse und
|
||||
/// dürfen es nicht.</para>
|
||||
/// </summary>
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.Core.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Verzeichnisse einer Instanz liegen.
|
||||
///
|
||||
/// <para><b>In Core, weil zwei Seiten es brauchen</b> (#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 <c>ClawdDotNet.App</c> nicht
|
||||
/// kennen — dort hängen sechzehn Werkzeugprojekte —, und dieselbe Rechnung an zwei Stellen
|
||||
/// zu führen wäre die schlechtere Antwort darauf.</para>
|
||||
/// </summary>
|
||||
public static class InstanceLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// Wohin diese Instanz protokolliert. Zwei Regeln:
|
||||
///
|
||||
/// <para><b>Leer heißt <c><Instanzverzeichnis>/Logs</c></b> — nicht mehr das
|
||||
/// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien
|
||||
/// schrieben, stand in <c>Startup.log</c> abwechselnd, was zwei Instanzen zu sagen
|
||||
/// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
|
||||
/// Fehlersuche damit Ratearbeit (#528, Stufe 1).</para>
|
||||
///
|
||||
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen
|
||||
/// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
|
||||
/// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne <c>WorkingDirectory=</c>
|
||||
/// startet in <c>/</c>, und <c>"./Logs"</c> zeigt dann auf <c>/Logs</c> — für ein
|
||||
/// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb
|
||||
/// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
|
||||
/// </summary>
|
||||
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
|
||||
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
|
||||
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
|
||||
=> ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath);
|
||||
|
||||
/// <summary>
|
||||
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
|
||||
/// <c><Instanzverzeichnis>/Backups</c>.
|
||||
///
|
||||
/// <para>Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt
|
||||
/// dort nach dem Präfix <c>backup_<Instanzname>_</c> auf, und bei zwei Namen,
|
||||
/// die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine Instanz die
|
||||
/// Archive der anderen mit (#528, Stufe 1).</para>
|
||||
/// </summary>
|
||||
public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath)
|
||||
=> ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath);
|
||||
|
||||
/// <summary>
|
||||
/// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem
|
||||
/// Instanzverzeichnis.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Instances;
|
||||
|
||||
/// <summary>Wo eine Instanz beim Herunterfahren gerade steht.</summary>
|
||||
public enum ShutdownPhase
|
||||
{
|
||||
/// <summary>Niemand hat um ein Ende gebeten. Der Normalbetrieb.</summary>
|
||||
Running,
|
||||
|
||||
/// <summary>Die Tür ist zu, die offenen Arbeiten laufen aus.</summary>
|
||||
Draining,
|
||||
|
||||
/// <summary>Nichts mehr offen — oder die Frist ist abgelaufen. Der Wirt darf enden.</summary>
|
||||
Done
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Stelle, an der Arbeit offen sein kann: Agentenläufe, Aufgaben, was noch kommt.
|
||||
///
|
||||
/// <para>Bewusst über Rückrufe und nicht als Schnittstelle an <c>AgentEngine</c> oder
|
||||
/// <c>TaskScanner</c>: 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.</para>
|
||||
/// </summary>
|
||||
/// <param name="Name">Wie diese Quelle im Protokoll und in der Puls-Auskunft heißt.</param>
|
||||
/// <param name="OpenCount">Wie viele Arbeiten hier gerade noch laufen.</param>
|
||||
/// <param name="StopAccepting">Schließt die Tür: ab jetzt keine neue Arbeit mehr.</param>
|
||||
public sealed record WorkSource(
|
||||
string Name,
|
||||
Func<int> OpenCount,
|
||||
Action StopAccepting);
|
||||
|
||||
/// <summary>Was gerade offen ist, je Quelle.</summary>
|
||||
public sealed record OpenWork(string Name, int Count);
|
||||
|
||||
/// <summary>
|
||||
/// Der Stand des Herunterfahrens, wie ihn der Instanzmanager über den Puls erfährt.
|
||||
/// </summary>
|
||||
/// <param name="Phase">Laufend, am Auslaufen, fertig.</param>
|
||||
/// <param name="Open">Was noch offen ist, je Quelle. Leer heißt: nichts mehr.</param>
|
||||
/// <param name="Elapsed">Wie lange das Auslaufen schon dauert.</param>
|
||||
/// <param name="Remaining">Wie viel von der Frist noch übrig ist.</param>
|
||||
public sealed record ShutdownSnapshot(
|
||||
ShutdownPhase Phase,
|
||||
IReadOnlyList<OpenWork> Open,
|
||||
TimeSpan Elapsed,
|
||||
TimeSpan Remaining)
|
||||
{
|
||||
/// <summary>Die Summe über alle Quellen.</summary>
|
||||
public int TotalOpen => Open.Sum(o => o.Count);
|
||||
|
||||
public static ShutdownSnapshot Idle { get; } =
|
||||
new(ShutdownPhase.Running, [], TimeSpan.Zero, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das geordnete Ende einer Instanz: erst die Tür zu, dann auslaufen lassen, dann enden.
|
||||
///
|
||||
/// <para><b>Warum es das braucht.</b> Bis hierher war jedes Beenden ein Abschneiden.
|
||||
/// <c>TaskScanner.DisposeAsync</c> bricht den laufenden Takt über sein
|
||||
/// <c>CancellationToken</c> ab — mitten in <c>ProcessAsync</c>, also <em>nachdem</em> eine
|
||||
/// Aufgabe beansprucht wurde und <em>bevor</em> <c>CompleteClaimAsync</c> sie wieder
|
||||
/// freigibt. Die Aufgabe steht danach auf <c>running</c> und bleibt dort, bis das Lease
|
||||
/// abläuft. Ein Agentenlauf mitten im Werkzeugaufruf verliert seine Antwort ganz.</para>
|
||||
///
|
||||
/// <para><b>Die Reihenfolge ist der ganze Trick.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Die Frist ist eine Zusage, keine Hoffnung.</b> Nach
|
||||
/// <see cref="Budget"/> 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.</para>
|
||||
///
|
||||
/// <para><b>Und der Manager sieht zu.</b> <see cref="Snapshot"/> 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.</para>
|
||||
/// </summary>
|
||||
public sealed class ShutdownCoordinator(
|
||||
ILogger logger,
|
||||
TimeSpan? budget = null,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||
private readonly List<WorkSource> _sources = [];
|
||||
private readonly Lock _lock = new();
|
||||
private readonly TaskCompletionSource _finished =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private DateTimeOffset _startedAt;
|
||||
private int _begun;
|
||||
|
||||
/// <summary>
|
||||
/// Wie lange das Auslaufen höchstens dauern darf.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public TimeSpan Budget { get; } = budget ?? TimeSpan.FromMinutes(2);
|
||||
|
||||
public ShutdownPhase Phase { get; private set; } = ShutdownPhase.Running;
|
||||
|
||||
/// <summary>Ist erfüllt, sobald das Auslaufen vorbei ist — fertig oder abgelaufen.</summary>
|
||||
public Task Finished => _finished.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Meldet eine Stelle an, an der Arbeit offen sein kann.
|
||||
///
|
||||
/// <para>Vor <see cref="BeginAsync"/> 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.</para>
|
||||
/// </summary>
|
||||
public void Register(WorkSource source)
|
||||
{
|
||||
lock (_lock) _sources.Add(source);
|
||||
}
|
||||
|
||||
/// <summary>Der aktuelle Stand, für den Puls an den Manager.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schließt die Tür und wartet, bis nichts mehr offen ist — höchstens
|
||||
/// <see cref="Budget"/> lang.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public async Task BeginAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _begun, 1) != 0)
|
||||
{
|
||||
await _finished.Task.WaitAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
List<WorkSource> 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<OpenWork> Offen()
|
||||
{
|
||||
var liste = new List<OpenWork>();
|
||||
|
||||
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<OpenWork> offen)
|
||||
=> offen.Count == 0 ? "nichts" : string.Join(", ", offen.Select(o => $"{o.Name}: {o.Count}"));
|
||||
}
|
||||
@@ -79,6 +79,37 @@ public static class SystemShell
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet eine Internetadresse im Standard-Webbrowser des Systems.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet mit <c>ArgumentList</c> statt einer Argumentzeichenkette — so muss nichts
|
||||
/// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen
|
||||
|
||||
@@ -89,12 +89,41 @@ public sealed class TaskScanner : IAsyncDisposable
|
||||
/// </summary>
|
||||
public bool HasStopped => _loop is { IsCompleted: true };
|
||||
|
||||
// ─── Geordnetes Ende (#528) ───
|
||||
|
||||
private int _openRuns;
|
||||
private int _accepting = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Wie viele Aufgaben gerade tatsächlich laufen — also beansprucht und noch nicht
|
||||
/// abgeschlossen.
|
||||
/// </summary>
|
||||
public int OpenCount => Volatile.Read(ref _openRuns);
|
||||
|
||||
/// <summary>Beansprucht der Scanner noch neue Aufgaben?</summary>
|
||||
public bool IsAcceptingWork => Volatile.Read(ref _accepting) == 1;
|
||||
|
||||
/// <summary>
|
||||
/// Schließt die Tür: kein neuer Takt, keine neue Beanspruchung.
|
||||
///
|
||||
/// <para><b>Warum das nicht dasselbe ist wie <see cref="DisposeAsync"/>.</b> Dispose
|
||||
/// bricht den laufenden Takt über sein Token ab — mitten in <c>ProcessAsync</c>, also
|
||||
/// nachdem eine Aufgabe beansprucht wurde und bevor <c>CompleteClaimAsync</c> sie
|
||||
/// wieder freigibt. Sie steht danach auf <c>running</c> und bleibt dort bis zum
|
||||
/// Ablauf des Lease. Wer geordnet enden will, schließt erst hier die Tür, wartet auf
|
||||
/// <see cref="OpenCount"/> gleich null und ruft Dispose erst dann.</para>
|
||||
/// </summary>
|
||||
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
|
||||
/// </summary>
|
||||
public async Task<int> 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
|
||||
/// </summary>
|
||||
public async Task<bool> 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
|
||||
|
||||
@@ -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
|
||||
/// <para>Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess
|
||||
/// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank.</para>
|
||||
/// </summary>
|
||||
private enum StartModeChoice
|
||||
{
|
||||
LaunchManager,
|
||||
Standalone,
|
||||
Cancel
|
||||
}
|
||||
|
||||
private enum StandaloneSwitchChoice
|
||||
{
|
||||
LaunchManager,
|
||||
SwitchStandalone,
|
||||
Cancel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Instanzwechsel aus der laufenden Anwendung.
|
||||
/// </summary>
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht die Programmdatei des Instanzmanagers.
|
||||
///
|
||||
/// <para><b>Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette.</b> Hier
|
||||
/// stand <c>@"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0"</c> — unter Linux,
|
||||
/// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit
|
||||
/// Rückstrichen darin, und die Suche ging dort nie auf.</para>
|
||||
///
|
||||
/// <para>Die Entwicklungspfade bleiben, weil im Betrieb beide Programme
|
||||
/// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei
|
||||
/// vergebliche <c>File.Exists</c>.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet.
|
||||
///
|
||||
/// <para>Aus <c>manager.json</c> 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.</para>
|
||||
/// </summary>
|
||||
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<StandaloneSwitchChoice> AskStandaloneSwitchModeAsync(Window? owner)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource<StandaloneSwitchChoice>();
|
||||
|
||||
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<StartModeChoice> AskStartModeAsync()
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource<StartModeChoice>();
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat.
|
||||
///
|
||||
/// <para><b>Erst auslaufen lassen, dann abschneiden.</b> Laufende Agenten und
|
||||
/// beanspruchte Aufgaben bekommen ihre Frist
|
||||
/// (<see cref="ShutdownCoordinator"/>), bevor Weboberfläche und Aufbau fallen. Ohne
|
||||
/// das endete eine Aufgabe mitten in <c>ProcessAsync</c> und bliebe bis zum Ablauf
|
||||
/// ihres Lease auf <c>running</c> stehen.</para>
|
||||
///
|
||||
/// <para><b>Und nicht im Rückruf von <c>desktop.ShutdownRequested</c>.</b> Avalonia
|
||||
/// wartet dort nicht auf <c>async</c>-Behandlungen; was länger dauert als ein
|
||||
/// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst
|
||||
/// aufräumen und <em>dann</em> <c>Shutdown</c> rufen — der Rückruf dort bleibt als
|
||||
/// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts.</para>
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Instanzauswahl beim Start.
|
||||
///
|
||||
/// <para>Kam die Anwendung mit <c>--instance</c> 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.</para>
|
||||
/// </summary>
|
||||
private async Task<string?> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,10 +54,10 @@ internal static class HeadlessRun
|
||||
{
|
||||
SelectInstance = _ => Task.FromResult<string?>(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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schließt ohne Rückfrage — für eine Beendigung, die nicht von diesem Fenster ausgeht.
|
||||
///
|
||||
/// <para><b>Warum das nötig ist.</b> 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.</para>
|
||||
/// </summary>
|
||||
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<bool> ShowExitConfirmationAsync(Window? owner)
|
||||
{
|
||||
var completion = new TaskCompletionSource<bool>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
/// <summary>Antwort auf etwas, das gelingen oder scheitern kann.</summary>
|
||||
public sealed record OutcomeDto(bool Ok, string Meldung);
|
||||
|
||||
/// <summary>Eine gefundene Sicherungsdatei einer Instanz.</summary>
|
||||
public sealed record BackupItemDto(
|
||||
string FileName,
|
||||
long SizeBytes,
|
||||
DateTime CreatedAt,
|
||||
string InstanceName,
|
||||
string Secrets,
|
||||
int SecretCount,
|
||||
int FileCount);
|
||||
|
||||
/// <summary>Übersicht aller Sicherungen einer Instanz mit Verzeichnisangabe.</summary>
|
||||
public sealed record BackupListDto(
|
||||
string Directory,
|
||||
IReadOnlyList<BackupItemDto> Backups);
|
||||
|
||||
/// <summary>Parameter zum Anlegen einer neuen Sicherung.</summary>
|
||||
public sealed record CreateBackupRequestDto(
|
||||
string? Secrets,
|
||||
string? Passphrase,
|
||||
bool IncludeLogs,
|
||||
bool IncludeChatHistory);
|
||||
|
||||
/// <summary>Parameter zur Wiederherstellung einer Sicherung.</summary>
|
||||
public sealed record RestoreBackupRequestDto(
|
||||
string? Passphrase);
|
||||
|
||||
/// <summary>
|
||||
/// 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<BackupItemDto>();
|
||||
|
||||
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));
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Sicherungen dieser Instanz liegen.
|
||||
///
|
||||
/// <para><b>Eine unlesbare Instanzdatei wird gemeldet, nicht verschluckt.</b> Sonst
|
||||
/// landeten die Sicherungen wortlos im Standardverzeichnis statt im eingestellten —
|
||||
/// und niemand sähe, warum die alten verschwunden sind. Genau diese Bauart hat
|
||||
/// <c>manager.json</c> schon einmal eine halbe Stunde Fehlersuche gekostet.</para>
|
||||
/// </summary>
|
||||
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<InstanceConfig>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<h2>ClawdDotNet</h2>
|
||||
</div>
|
||||
<p class="hinweis">
|
||||
Bitte gib das Zugangstoken der Instanz ein (<code>webUiToken</code> in der Instanzdatei).
|
||||
Bitte gib das Zugangstoken des Instanzmanagers ein (<code>webUiToken</code> in <code>manager.json</code>).
|
||||
</p>
|
||||
<form id="anmeldeformular" class="formular-raster">
|
||||
<input type="password" id="token-eingabe" placeholder="Zugangstoken (mind. 20 Zeichen)" autocomplete="current-password" required>
|
||||
@@ -87,6 +87,12 @@
|
||||
|
||||
<div class="nav-gruppe">
|
||||
<div class="nav-gruppe-titel">System</div>
|
||||
<a href="#/instanzen" class="nav-link" title="Instanzen">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M2 20h20 M2 14h20 M2 8h20 M2 4h20 M6 6h.01 M6 12h.01 M6 18h.01"/>
|
||||
</svg>
|
||||
<span class="nav-label">Instanzen</span>
|
||||
</a>
|
||||
<a href="#/staging" class="nav-link" title="Freigaben">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z M9 12l2 2 4-4"/>
|
||||
@@ -129,14 +135,21 @@
|
||||
<div class="hauptbereich">
|
||||
<header class="kopfzeile">
|
||||
<div class="kopfzeile-links">
|
||||
<button id="mobil-menue-btn" class="schaltflaeche-icon flach" title="Menü öffnen" type="button">
|
||||
<button id="mobil-menue-btn" class="schaltflaeche-icon flach" title="Seitenleiste ein-/ausklappen" type="button">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="kopf-instanz" class="gedaempft"></span>
|
||||
<div class="kopf-instanz-container" id="kopf-instanz-container">
|
||||
<label for="kopf-instanz-select" class="kopf-instanz-label">Instanz:</label>
|
||||
<select id="kopf-instanz-select" class="eingabe-select klein kopf-instanz-select" title="Aktive Instanz wechseln">
|
||||
<option value="">(Keine Instanz)</option>
|
||||
</select>
|
||||
<span id="kopf-instanz-status" class="plakette plakette-neutral">Lädt…</span>
|
||||
<button id="kopf-instanz-start-btn" class="schaltflaeche klein primaer" title="Ausgewählte Instanz jetzt starten" type="button" hidden>Starten</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kopfzeile-rechts">
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
// Verwaltet das Zugangstoken im sessionStorage und kapselt fetch-Aufrufe.
|
||||
|
||||
export const TOKEN_SCHLUESSEL = "clawd.token";
|
||||
export const INSTANZ_SCHLUESSEL = "clawd.instanz";
|
||||
|
||||
let token = sessionStorage.getItem(TOKEN_SCHLUESSEL) ?? "";
|
||||
let aktiveInstanz = sessionStorage.getItem(INSTANZ_SCHLUESSEL) ?? "";
|
||||
const unberechtigtCallbacks = new Set();
|
||||
const instanzWechselCallbacks = new Set();
|
||||
|
||||
/**
|
||||
* Gibt das aktuell gespeicherte Token zurück.
|
||||
@@ -29,11 +32,58 @@ export function setzeToken(neuesToken) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht das Token (Abmeldung).
|
||||
* Löscht das Token und die aktive Instanz (Abmeldung).
|
||||
*/
|
||||
export function loescheToken() {
|
||||
token = "";
|
||||
sessionStorage.removeItem(TOKEN_SCHLUESSEL);
|
||||
loescheAktiveInstanz();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die aktuell ausgewählte Instanz-ID zurück.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function holeAktiveInstanz() {
|
||||
return aktiveInstanz;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die aktive Instanz im Speicher und sessionStorage.
|
||||
* @param {string} neueInstanzId
|
||||
*/
|
||||
export function setzeAktiveInstanz(neueInstanzId) {
|
||||
const id = (neueInstanzId || "").trim();
|
||||
if (aktiveInstanz === id) return;
|
||||
aktiveInstanz = id;
|
||||
if (aktiveInstanz) {
|
||||
sessionStorage.setItem(INSTANZ_SCHLUESSEL, aktiveInstanz);
|
||||
} else {
|
||||
sessionStorage.removeItem(INSTANZ_SCHLUESSEL);
|
||||
}
|
||||
for (const cb of instanzWechselCallbacks) {
|
||||
try {
|
||||
cb(aktiveInstanz);
|
||||
} catch (err) {
|
||||
console.error("Fehler im InstanzWechsel-Callback:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt die aktive Instanz.
|
||||
*/
|
||||
export function loescheAktiveInstanz() {
|
||||
setzeAktiveInstanz("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert einen Rückruf für Instanzwechsel.
|
||||
* @param {(instanzId: string) => void} callback
|
||||
*/
|
||||
export function beiInstanzWechsel(callback) {
|
||||
instanzWechselCallbacks.add(callback);
|
||||
return () => instanzWechselCallbacks.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +116,7 @@ function meldeUnberechtigt() {
|
||||
|
||||
/**
|
||||
* Führt einen Aufruf gegen die API aus.
|
||||
* Sendet bei gesetzter aktiver Instanz automatisch die X-Instanz-Kopfzeile mit.
|
||||
* @param {string} pfad Pfad relativ zu /api, z.B. "/info" oder "/agents"
|
||||
* @param {RequestInit} [optionen={}]
|
||||
* @returns {Promise<any>}
|
||||
@@ -76,6 +127,10 @@ export async function api(pfad, optionen = {}) {
|
||||
Authorization: `Bearer ${token}`
|
||||
};
|
||||
|
||||
if (aktiveInstanz && !kopfzeilen["X-Instanz"] && !pfad.startsWith("/verwaltung")) {
|
||||
kopfzeilen["X-Instanz"] = aktiveInstanz;
|
||||
}
|
||||
|
||||
if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) {
|
||||
kopfzeilen["Content-Type"] = "application/json";
|
||||
optionen.body = JSON.stringify(optionen.body);
|
||||
@@ -98,12 +153,15 @@ export async function api(pfad, optionen = {}) {
|
||||
let meldung = `HTTP ${antwort.status}`;
|
||||
try {
|
||||
const rumpf = await antwort.json();
|
||||
meldung = rumpf?.message ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
|
||||
meldung = rumpf?.message ?? rumpf?.meldung ?? rumpf?.fehler ?? rumpf?.detail ?? meldung;
|
||||
} catch {
|
||||
// Wenn kein JSON geliefert wird, bleibt es beim HTTP-Code
|
||||
}
|
||||
const fehler = new Error(meldung);
|
||||
fehler.status = antwort.status;
|
||||
if (antwort.status === 402) fehler.lizenzFehlt = true;
|
||||
if (antwort.status === 409) fehler.instanzKonflikt = true;
|
||||
if (antwort.status === 502) fehler.instanzUnerreichbar = true;
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
|
||||
@@ -109,9 +109,33 @@ export const PFADE = {
|
||||
pfeilUnten: "M6 9l6 6 6-6",
|
||||
haken: "M20 6L9 17l-5-5",
|
||||
ausrufezeichen: "M12 8v4 M12 16h.01 M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z",
|
||||
ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9"
|
||||
ausloggen: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4 M16 17l5-5-5-5 M21 12H9",
|
||||
server: "M2 20h20 M2 14h20 M2 8h20 M2 4h20 M6 6h.01 M6 12h.01 M6 18h.01",
|
||||
fenster: "M3 3h18v18H3z M3 9h18 M9 21V9",
|
||||
stop: "M6 6h12v12H6z"
|
||||
};
|
||||
|
||||
/**
|
||||
* Übersetzt den technischen Instanzzustand in deutschen Text und Plakettenstil.
|
||||
* @param {string} zustand "Running"|"Stopped"|"Silent"|"Crashed"|"Unknown"
|
||||
* @returns {{ text: string, art: "erfolg"|"warnung"|"gefahr"|"neutral" }}
|
||||
*/
|
||||
export function instanzZustandInfo(zustand) {
|
||||
const z = (zustand || "").toLowerCase();
|
||||
switch (z) {
|
||||
case "running":
|
||||
return { text: "Läuft", art: "erfolg" };
|
||||
case "stopped":
|
||||
return { text: "Beendet", art: "neutral" };
|
||||
case "silent":
|
||||
return { text: "Verstummt", art: "warnung" };
|
||||
case "crashed":
|
||||
return { text: "Abgestürzt", art: "gefahr" };
|
||||
default:
|
||||
return { text: zustand || "Unbekannt", art: "neutral" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erzeugt eine Statusplakette (Badge).
|
||||
* @param {string} text
|
||||
@@ -154,11 +178,12 @@ export function erstelleKarte(titel, elemente = [], optionen = {}) {
|
||||
* @param {string} titel
|
||||
* @param {HTMLElement} inhalt
|
||||
* @param {HTMLElement[]} [aktionen]
|
||||
* @param {object} [optionen]
|
||||
* @returns {{ dialog: HTMLElement, oeffnen: () => void, schliessen: () => void }}
|
||||
*/
|
||||
export function erstelleModal(titel, inhalt, aktionen = []) {
|
||||
export function erstelleModal(titel, inhalt, aktionen = [], optionen = {}) {
|
||||
const overlay = erstelleElement("div", { klasse: "modal-overlay", hidden: true });
|
||||
const box = erstelleElement("div", { klasse: "modal-box karte" });
|
||||
const box = erstelleElement("div", { klasse: `modal-box karte ${optionen.klasse ?? ""}`.trim() });
|
||||
|
||||
const kopf = erstelleElement("div", { klasse: "modal-kopf" });
|
||||
const h = erstelleElement("h2", { text: titel });
|
||||
@@ -172,16 +197,33 @@ export function erstelleModal(titel, inhalt, aktionen = []) {
|
||||
const koerper = erstelleElement("div", { klasse: "modal-koerper" }, inhalt);
|
||||
|
||||
const fuss = erstelleElement("div", { klasse: "modal-fuss" });
|
||||
for (const aktion of aktionen) fuss.append(aktion);
|
||||
|
||||
// Wenn der Inhalt ein Formular ist oder enthält, verknüpfe Submit-Buttons im Fuß
|
||||
const formElement = (inhalt && inhalt.tagName === "FORM")
|
||||
? inhalt
|
||||
: (inhalt?.querySelector ? inhalt.querySelector("form") : null);
|
||||
|
||||
for (const aktion of aktionen) {
|
||||
if (formElement && (aktion.type === "submit" || aktion.getAttribute?.("type") === "submit")) {
|
||||
aktion.addEventListener("click", () => {
|
||||
formElement.requestSubmit();
|
||||
});
|
||||
}
|
||||
fuss.append(aktion);
|
||||
}
|
||||
|
||||
box.append(kopf, koerper, fuss);
|
||||
overlay.append(box);
|
||||
document.body.append(overlay);
|
||||
|
||||
const schliessen = () => {
|
||||
if (overlay.hidden) return;
|
||||
overlay.hidden = true;
|
||||
overlay.classList.remove("sichtbar");
|
||||
document.removeEventListener("keydown", tasteHandler);
|
||||
if (typeof optionen.onSchliessen === "function") {
|
||||
optionen.onSchliessen();
|
||||
}
|
||||
};
|
||||
|
||||
const oeffnen = () => {
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// ClawdDotNet — Interne Agenten-Kommunikation A2A (Vorhaben #208)
|
||||
//
|
||||
// Analyse und Protokollierung des Nachrichtenaustauschs zwischen Agenten.
|
||||
// - Filterbar nach beteiligten Agenten
|
||||
// - Dialog-Timeline zwischen Quelle und Ziel
|
||||
// - Live-Aktualisierung über Server-Sent Events (SSE "chat" mit source === "agentcomm")
|
||||
// Strikt DOM API, kein innerHTML.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
erstellePlakette,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
formatiereDatumZeit
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let sseAbmeldung = null;
|
||||
|
||||
export async function rendereAgentenChats() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
if (sseAbmeldung) {
|
||||
sseAbmeldung();
|
||||
sseAbmeldung = null;
|
||||
}
|
||||
|
||||
const instanzId = holeAktiveInstanz();
|
||||
const layout = erstelleElement("div", { klasse: "agenten-chats-ansicht" });
|
||||
|
||||
// Kopfzeile
|
||||
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
titelGruppe.append(
|
||||
erstelleElement("h1", { text: "Agenten-Kommunikation (A2A)" })
|
||||
);
|
||||
|
||||
const filterGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const agentSelect = erstelleElement("select", {
|
||||
klasse: "eingabe",
|
||||
stil: { minWidth: "180px" },
|
||||
title: "Nach Agent filtern"
|
||||
});
|
||||
const optAlle = erstelleElement("option", { value: "", text: "Alle Agenten" });
|
||||
agentSelect.append(optAlle);
|
||||
|
||||
const aktualisierenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Kommunikation neu laden",
|
||||
onclick: () => ladeVerlauf()
|
||||
},
|
||||
erstelleIcon(PFADE.aktualisieren, 16),
|
||||
erstelleElement("span", { text: " Aktualisieren" })
|
||||
);
|
||||
|
||||
filterGruppe.append(agentSelect, aktualisierenBtn);
|
||||
kopf.append(titelGruppe, filterGruppe);
|
||||
layout.append(kopf);
|
||||
|
||||
if (!instanzId) {
|
||||
layout.append(
|
||||
erstelleKarte("Keine Instanz ausgewählt", [
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Agenten-Kommunikation anzuzeigen."
|
||||
})
|
||||
])
|
||||
);
|
||||
container.append(layout);
|
||||
return;
|
||||
}
|
||||
|
||||
// Infokarte
|
||||
const infoKarte = erstelleKarte("Interne Kommunikation", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Hier wird der direkte Nachrichtenaustausch zwischen Agenten (A2A) protokolliert, wenn Agenten über das Werkzeug 'SendMessage' miteinander interagieren."
|
||||
})
|
||||
]);
|
||||
layout.append(infoKarte);
|
||||
|
||||
// Verlauf-Container
|
||||
const verlaufContainer = erstelleElement("div", { klasse: "a2a-timeline" });
|
||||
layout.append(verlaufContainer);
|
||||
container.append(layout);
|
||||
|
||||
let alleNachrichten = [];
|
||||
let agentenListe = [];
|
||||
|
||||
// Agenten laden für Filter
|
||||
try {
|
||||
const agenten = await api("/agents");
|
||||
agentenListe = Array.isArray(agenten) ? agenten : [];
|
||||
for (const a of agentenListe) {
|
||||
const opt = erstelleElement("option", {
|
||||
value: a.agentId,
|
||||
text: a.displayName || a.agentId
|
||||
});
|
||||
agentSelect.append(opt);
|
||||
}
|
||||
} catch {
|
||||
// Wenn Agenten nicht ladbar sind, Filter leer lassen
|
||||
}
|
||||
|
||||
agentSelect.addEventListener("change", () => {
|
||||
rendereNachrichten();
|
||||
});
|
||||
|
||||
async function ladeVerlauf() {
|
||||
verlaufContainer.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Kommunikationsverlauf…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const daten = await api("/agents/communications");
|
||||
alleNachrichten = Array.isArray(daten) ? daten : [];
|
||||
rendereNachrichten();
|
||||
} catch (err) {
|
||||
verlaufContainer.replaceChildren(
|
||||
erstelleKarte("Fehler", [
|
||||
erstelleElement("p", {
|
||||
klasse: "fehler-meldung",
|
||||
text: `Kommunikation konnte nicht geladen werden: ${err.message}`
|
||||
})
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rendereNachrichten() {
|
||||
const gewaehlterAgent = agentSelect.value;
|
||||
const gefiltert = gewaehlterAgent
|
||||
? alleNachrichten.filter(
|
||||
(m) =>
|
||||
m.targetAgentId === gewaehlterAgent ||
|
||||
m.sourceAgentId === gewaehlterAgent
|
||||
)
|
||||
: alleNachrichten;
|
||||
|
||||
if (gefiltert.length === 0) {
|
||||
verlaufContainer.replaceChildren(
|
||||
erstelleKarte("Keine Nachrichten", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: gewaehlterAgent
|
||||
? "Für den ausgewählten Agenten liegt noch keine interne Kommunikation vor."
|
||||
: "In dieser Instanz wurde bisher keine interne Agent-zu-Agent-Kommunikation protokolliert."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
verlaufContainer.replaceChildren();
|
||||
|
||||
for (const m of gefiltert) {
|
||||
const eintrag = erstelleNachrichtElement(m);
|
||||
verlaufContainer.append(eintrag);
|
||||
}
|
||||
}
|
||||
|
||||
function erstelleNachrichtElement(m) {
|
||||
const istAntwort = m.role === "assistant";
|
||||
const kasten = erstelleElement("div", {
|
||||
klasse: `a2a-eintrag ${istAntwort ? "a2a-antwort" : "a2a-anfrage"}`
|
||||
});
|
||||
|
||||
const kopf = erstelleElement("div", { klasse: "a2a-eintrag-kopf" });
|
||||
const absenderGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
if (istAntwort) {
|
||||
// Antwort vom Zielagenten
|
||||
absenderGruppe.append(
|
||||
erstellePlakette(m.targetAgentName || m.targetAgentId, "erfolg"),
|
||||
erstelleElement("span", { klasse: "a2a-pfeil", text: "➔ Antwort" })
|
||||
);
|
||||
} else {
|
||||
// Anfrage/Nachricht von Quelle an Ziel
|
||||
const quelleName = m.sourceAgentName || m.sourceAgentId || "Agent";
|
||||
const zielName = m.targetAgentName || m.targetAgentId;
|
||||
absenderGruppe.append(
|
||||
erstellePlakette(quelleName, "neutral"),
|
||||
erstelleElement("span", { klasse: "a2a-pfeil", text: "➔" }),
|
||||
erstellePlakette(zielName, "primaer")
|
||||
);
|
||||
}
|
||||
|
||||
const zeit = erstelleElement("span", {
|
||||
klasse: "a2a-zeit",
|
||||
text: formatiereDatumZeit(m.timestamp)
|
||||
});
|
||||
|
||||
kopf.append(absenderGruppe, zeit);
|
||||
|
||||
const inhalt = erstelleElement("pre", {
|
||||
klasse: "a2a-inhalt",
|
||||
text: m.content
|
||||
});
|
||||
|
||||
kasten.append(kopf, inhalt);
|
||||
return kasten;
|
||||
}
|
||||
|
||||
// SSE Live-Aktualisierung
|
||||
sseAbmeldung = abonnieren("chat", (daten) => {
|
||||
if (daten?.source === "agentcomm") {
|
||||
// Neue Nachricht kam live herein: an die Liste anhängen
|
||||
const zielAgent = agentenListe.find((a) => a.agentId === daten.agentId);
|
||||
const zielName = zielAgent ? zielAgent.displayName : daten.agentId;
|
||||
|
||||
let senderId = null;
|
||||
let senderName = null;
|
||||
let content = daten.content || "";
|
||||
|
||||
if (daten.role === "user" && content.startsWith("[Nachricht von Agent \"")) {
|
||||
const endQuote = content.indexOf('"', 22);
|
||||
if (endQuote > 22) {
|
||||
senderName = content.slice(22, endQuote);
|
||||
const openParen = content.indexOf("(", endQuote);
|
||||
const closeParen = content.indexOf(")", openParen);
|
||||
if (openParen > 0 && closeParen > openParen) {
|
||||
senderId = content.slice(openParen + 1, closeParen);
|
||||
}
|
||||
}
|
||||
const doubleNl = content.indexOf("\n\n");
|
||||
if (doubleNl > 0) {
|
||||
content = content.slice(doubleNl + 2);
|
||||
}
|
||||
}
|
||||
|
||||
const neueNachricht = {
|
||||
targetAgentId: daten.agentId,
|
||||
targetAgentName: zielName,
|
||||
sourceAgentId: senderId,
|
||||
sourceAgentName: senderName,
|
||||
role: daten.role,
|
||||
content: content,
|
||||
timestamp: daten.timestamp || new Date().toISOString()
|
||||
};
|
||||
|
||||
alleNachrichten.push(neueNachricht);
|
||||
|
||||
const gewaehlterAgent = agentSelect.value;
|
||||
if (
|
||||
!gewaehlterAgent ||
|
||||
neueNachricht.targetAgentId === gewaehlterAgent ||
|
||||
neueNachricht.sourceAgentId === gewaehlterAgent
|
||||
) {
|
||||
// Falls vorher "Keine Nachrichten" da stand:
|
||||
const leerKarte = verlaufContainer.querySelector(".karte");
|
||||
if (leerKarte) verlaufContainer.replaceChildren();
|
||||
|
||||
const el = erstelleNachrichtElement(neueNachricht);
|
||||
verlaufContainer.append(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ladeVerlauf();
|
||||
}
|
||||
|
||||
export function trenneAgentenChatsStream() {
|
||||
if (sseAbmeldung) {
|
||||
sseAbmeldung();
|
||||
sseAbmeldung = null;
|
||||
}
|
||||
}
|
||||
@@ -74,12 +74,16 @@ async function ladeUndRendereListe(zielId = null) {
|
||||
listeContainer.replaceChildren();
|
||||
|
||||
if (!agenten || agenten.length === 0) {
|
||||
listeContainer.append(
|
||||
erstelleElement("div", {
|
||||
klasse: "hinweis-zentriert",
|
||||
text: "Keine Agenten eingerichtet."
|
||||
})
|
||||
const leerKasten = erstelleElement("div", { klasse: "hinweis-zentriert" });
|
||||
leerKasten.append(
|
||||
erstelleElement("p", { klasse: "gedaempft", text: "In dieser Instanz sind noch keine Agenten eingerichtet." }),
|
||||
erstelleElement("button", {
|
||||
klasse: "schaltflaeche primaer klein",
|
||||
onclick: oeffneNeuerAgentDialog,
|
||||
style: "margin-top: 10px;"
|
||||
}, erstelleIcon(PFADE.plus, 14), erstelleElement("span", { text: " Ersten Agenten anlegen" }))
|
||||
);
|
||||
listeContainer.append(leerKasten);
|
||||
rendereDetails(null);
|
||||
return;
|
||||
}
|
||||
@@ -123,8 +127,13 @@ async function ladeUndRendereListe(zielId = null) {
|
||||
|
||||
rendereDetails(auswahlId);
|
||||
} catch (err) {
|
||||
const text = err.instanzUnerreichbar
|
||||
? "Die ausgewählte Instanz läuft derzeit nicht. Bitte starte sie zuerst im Kopfbereich oder unter 'Instanzen'."
|
||||
: (err.instanzKonflikt
|
||||
? "Keine aktive Instanz gewählt. Bitte wähle oben eine Instanz aus."
|
||||
: err.message);
|
||||
listeContainer.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: err.message })
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -260,7 +269,10 @@ async function rendereDetails(agentId) {
|
||||
}
|
||||
|
||||
function oeffneNeuerAgentDialog() {
|
||||
const formular = erstelleElement("form", { klasse: "formular-raster" });
|
||||
const formular = erstelleElement("form", {
|
||||
klasse: "formular-raster",
|
||||
id: "neuer-agent-formular"
|
||||
});
|
||||
|
||||
const idFeld = erstelleElement("input", {
|
||||
type: "text",
|
||||
@@ -315,7 +327,11 @@ function oeffneNeuerAgentDialog() {
|
||||
"button",
|
||||
{
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer"
|
||||
klasse: "schaltflaeche primaer",
|
||||
form: "neuer-agent-formular",
|
||||
onclick: () => {
|
||||
formular.requestSubmit();
|
||||
}
|
||||
},
|
||||
erstelleElement("span", { text: "Agent anlegen" })
|
||||
);
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
// ClawdDotNet — Datensicherung & Wiederherstellung (Vorhaben #530, Stufe 6)
|
||||
//
|
||||
// Verwaltet Sicherungen (.zip) der aktiven Instanz:
|
||||
// - Auflistung aller Archive im Sicherungsverzeichnis
|
||||
// - Neue Sicherung anlegen (Passphrase-Schutz, Logs, Chatverlauf)
|
||||
// - Wiederherstellung (mit geordnetem Stop/Start der Instanz)
|
||||
// - Löschen von Archiven
|
||||
// Strikt DOM API, kein innerHTML.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
erstellePlakette,
|
||||
erstelleIcon,
|
||||
erstelleModal,
|
||||
PFADE,
|
||||
formatiereDatumZeit,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
function formatiereBytes(bytes) {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (!bytes) return "—";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export async function rendereSicherung() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const instanzId = holeAktiveInstanz();
|
||||
const layout = erstelleElement("div", { klasse: "sicherung-ansicht" });
|
||||
|
||||
// Kopfzeile
|
||||
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
titelGruppe.append(erstelleElement("h1", { text: "Datensicherung & Wiederherstellung" }));
|
||||
|
||||
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const aktualisierenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Sicherungen neu laden",
|
||||
onclick: () => ladeSicherungen()
|
||||
},
|
||||
erstelleIcon(PFADE.aktualisieren, 16),
|
||||
erstelleElement("span", { text: " Aktualisieren" })
|
||||
);
|
||||
|
||||
const neuBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
title: "Neue Sicherung erstellen",
|
||||
disabled: !instanzId,
|
||||
onclick: () => oeffneSicherungsDialog()
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neue Sicherung" })
|
||||
);
|
||||
|
||||
aktionenGruppe.append(aktualisierenBtn, neuBtn);
|
||||
kopf.append(titelGruppe, aktionenGruppe);
|
||||
layout.append(kopf);
|
||||
|
||||
if (!instanzId) {
|
||||
layout.append(
|
||||
erstelleKarte("Keine Instanz ausgewählt", [
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Bitte wähle oben im Kopfbereich eine Instanz aus, um deren Sicherungen zu verwalten."
|
||||
})
|
||||
])
|
||||
);
|
||||
container.append(layout);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verzeichnis-Info & Inhalt
|
||||
const infoBereich = erstelleElement("div", { klasse: "sicherung-info-bereich" });
|
||||
const inhaltBereich = erstelleElement("div", { klasse: "sicherung-inhalt-bereich" });
|
||||
|
||||
layout.append(infoBereich, inhaltBereich);
|
||||
container.append(layout);
|
||||
|
||||
async function ladeSicherungen() {
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Sicherungen…" })
|
||||
);
|
||||
|
||||
try {
|
||||
const daten = await api(`/verwaltung/instanzen/${instanzId}/backups`);
|
||||
const verzeichnis = daten?.directory ?? "—";
|
||||
const backups = daten?.backups ?? [];
|
||||
|
||||
infoBereich.replaceChildren(
|
||||
erstelleKarte("Sicherungsverzeichnis", [
|
||||
erstelleElement("p", { klasse: "gedaempft", text: "Sicherungen werden komprimiert als ZIP-Archiv abgelegt. Die Datenbank (state.db) wird konsistent per VACUUM INTO kopiert." }),
|
||||
erstelleElement("div", { klasse: "ordner-pfad-anzeige" },
|
||||
erstelleIcon(PFADE.server, 14),
|
||||
erstelleElement("code", { text: verzeichnis })
|
||||
)
|
||||
])
|
||||
);
|
||||
|
||||
if (backups.length === 0) {
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte("Vorhandene Sicherungen", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Für diese Instanz sind noch keine Sicherungsarchive vorhanden. Erstelle über die Schaltfläche oben eine neue Sicherung."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tabelle = erstelleElement("table", { klasse: "tabelle sicherungen-tabelle" });
|
||||
const thead = erstelleElement("thead", {},
|
||||
erstelleElement("tr", {},
|
||||
erstelleElement("th", { text: "Archivdatei" }),
|
||||
erstelleElement("th", { text: "Erstellt am" }),
|
||||
erstelleElement("th", { text: "Größe" }),
|
||||
erstelleElement("th", { text: "Zugangsdaten" }),
|
||||
erstelleElement("th", { text: "Dateien" }),
|
||||
erstelleElement("th", { klasse: "spalte-aktionen", text: "Aktionen" })
|
||||
)
|
||||
);
|
||||
|
||||
const tbody = erstelleElement("tbody");
|
||||
|
||||
for (const b of backups) {
|
||||
const tr = erstelleElement("tr");
|
||||
|
||||
// Dateiname & Icon
|
||||
const dateiTd = erstelleElement("td", { klasse: "dateiname-zelle" },
|
||||
erstelleIcon(PFADE.sicherung, 16),
|
||||
erstelleElement("strong", { text: b.fileName })
|
||||
);
|
||||
|
||||
// Datum
|
||||
const datumTd = erstelleElement("td", { text: formatiereDatumZeit(b.createdAt) });
|
||||
|
||||
// Größe
|
||||
const groesseTd = erstelleElement("td", { text: formatiereBytes(b.sizeBytes) });
|
||||
|
||||
// Zugangsdaten Modus
|
||||
const hatPassphrase = b.secrets === "Passphrase";
|
||||
const secretsPlakette = erstellePlakette(
|
||||
hatPassphrase ? `Passphrase (${b.secretCount})` : "Ausgeschlossen",
|
||||
hatPassphrase ? "erfolg" : "neutral"
|
||||
);
|
||||
const secretsTd = erstelleElement("td", {}, secretsPlakette);
|
||||
|
||||
// Dateienanzahl
|
||||
const dateienTd = erstelleElement("td", { text: b.fileCount > 0 ? `${b.fileCount} Dateien` : "—" });
|
||||
|
||||
// Aktionen
|
||||
const aktionenTd = erstelleElement("td", { klasse: "spalte-aktionen" });
|
||||
|
||||
const restoreBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche klein sekundär",
|
||||
title: "Diese Sicherung wiederherstellen",
|
||||
onclick: () => oeffneRestoreDialog(b)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Wiederherstellen" })
|
||||
);
|
||||
|
||||
const deleteBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche klein gefahr flach",
|
||||
title: "Sicherungsdatei unwiderruflich löschen",
|
||||
onclick: () => oeffneLoeschenDialog(b.fileName)
|
||||
},
|
||||
erstelleIcon(PFADE.muelleimer, 14)
|
||||
);
|
||||
|
||||
aktionenTd.append(restoreBtn, deleteBtn);
|
||||
tr.append(dateiTd, datumTd, groesseTd, secretsTd, dateienTd, aktionenTd);
|
||||
tbody.append(tr);
|
||||
}
|
||||
|
||||
tabelle.append(thead, tbody);
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte(`Vorhandene Sicherungen (${backups.length})`, [tabelle])
|
||||
);
|
||||
} catch (err) {
|
||||
inhaltBereich.replaceChildren(
|
||||
erstelleKarte("Fehler", [
|
||||
erstelleElement("p", { klasse: "fehler-meldung", text: `Sicherungen konnten nicht geladen werden: ${err.message}` })
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal: Neue Sicherung erstellen ───
|
||||
function oeffneSicherungsDialog() {
|
||||
const form = erstelleElement("form", { klasse: "formular" });
|
||||
|
||||
// Modus Zugangsdaten
|
||||
const secretsGruppe = erstelleElement("div", { klasse: "form-gruppe" });
|
||||
secretsGruppe.append(
|
||||
erstelleElement("label", { text: "Zugangsdaten & Schlüssel" }),
|
||||
erstelleElement("p", { klasse: "form-hilfe", text: "DPAPI-geschützte Daten können mit einer Passphrase geschützt oder sicherheitshalber entfernt werden." })
|
||||
);
|
||||
|
||||
const secretsSelect = erstelleElement("select", { klasse: "eingabe" });
|
||||
const optExclude = erstelleElement("option", { value: "Exclude", text: "Entfernen (gefahrlos ablegbar, Schlüssel müssen neu eingetragen werden)" });
|
||||
const optPassphrase = erstelleElement("option", { value: "Passphrase", text: "Mit Passphrase schützen (vollständig übertragbar)" });
|
||||
secretsSelect.append(optExclude, optPassphrase);
|
||||
secretsGruppe.append(secretsSelect);
|
||||
|
||||
// Passphrase Feld
|
||||
const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { display: "none" } });
|
||||
const passInput = erstelleElement("input", {
|
||||
type: "password",
|
||||
klasse: "eingabe",
|
||||
placeholder: "Passphrase für Verschlüsselung eingeben…"
|
||||
});
|
||||
passGruppe.append(
|
||||
erstelleElement("label", { text: "Passphrase" }),
|
||||
passInput
|
||||
);
|
||||
|
||||
secretsSelect.addEventListener("change", () => {
|
||||
passGruppe.style.display = secretsSelect.value === "Passphrase" ? "block" : "none";
|
||||
if (secretsSelect.value === "Passphrase") passInput.focus();
|
||||
});
|
||||
|
||||
// Checkboxen
|
||||
const chatsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
|
||||
const chatsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-chats", checked: true });
|
||||
const chatsLabel = erstelleElement("label", { for: "backup-chk-chats", text: "Chatverläufe sichern" });
|
||||
chatsGruppe.append(chatsCheckbox, chatsLabel);
|
||||
|
||||
const logsGruppe = erstelleElement("div", { klasse: "form-gruppe-checkbox" });
|
||||
const logsCheckbox = erstelleElement("input", { type: "checkbox", id: "backup-chk-logs", checked: false });
|
||||
const logsLabel = erstelleElement("label", { for: "backup-chk-logs", text: "Protokolldateien (Logs) einschließen" });
|
||||
logsGruppe.append(logsCheckbox, logsLabel);
|
||||
|
||||
form.append(secretsGruppe, passGruppe, chatsGruppe, logsGruppe);
|
||||
|
||||
let modal;
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche",
|
||||
text: "Abbrechen",
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
|
||||
const speichernBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: "Sicherung erstellen"
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const secrets = secretsSelect.value;
|
||||
const passphrase = passInput.value.trim();
|
||||
|
||||
if (secrets === "Passphrase" && !passphrase) {
|
||||
zeigeMeldung("Bitte gib eine Passphrase ein.", "warnung");
|
||||
passInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
speichernBtn.disabled = true;
|
||||
speichernBtn.textContent = "Erstelle Sicherung…";
|
||||
|
||||
try {
|
||||
const res = await api(`/verwaltung/instanzen/${instanzId}/backups`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
secrets,
|
||||
passphrase: passphrase || null,
|
||||
includeLogs: logsCheckbox.checked,
|
||||
includeChatHistory: chatsCheckbox.checked
|
||||
}
|
||||
});
|
||||
zeigeMeldung(res.meldung || "Sicherung erfolgreich erstellt.", "erfolg");
|
||||
modal.schliessen();
|
||||
ladeSicherungen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Sichern: ${err.message}`, "gefahr");
|
||||
speichernBtn.disabled = false;
|
||||
speichernBtn.textContent = "Sicherung erstellen";
|
||||
}
|
||||
});
|
||||
|
||||
modal = erstelleModal("Neue Sicherung erstellen", form, [abbrechenBtn, speichernBtn]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
// ─── Modal: Wiederherstellen ───
|
||||
function oeffneRestoreDialog(backup) {
|
||||
const form = erstelleElement("form", { klasse: "formular" });
|
||||
|
||||
const warnung = erstelleElement("div", { klasse: "hinweis-box warnung" },
|
||||
erstelleIcon(PFADE.ausrufezeichen, 18),
|
||||
erstelleElement("div", {},
|
||||
erstelleElement("strong", { text: "Achtung: Überschreiben bestehender Daten" }),
|
||||
erstelleElement("p", {
|
||||
text: `Die Wiederherstellung überschreibt die aktuellen Konfigurationen und Daten der Instanz mit dem Stand vom ${formatiereDatumZeit(backup.createdAt)}.`
|
||||
}),
|
||||
erstelleElement("p", {
|
||||
text: "Die Instanz wird dafür kurz geordnet gestoppt und nach erfolgreicher Rücksicherung wieder hochgefahren."
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
form.append(warnung);
|
||||
|
||||
let passInput = null;
|
||||
if (backup.secrets === "Passphrase") {
|
||||
const passGruppe = erstelleElement("div", { klasse: "form-gruppe", stil: { marginTop: "16px" } });
|
||||
passInput = erstelleElement("input", {
|
||||
type: "password",
|
||||
klasse: "eingabe",
|
||||
placeholder: "Passphrase der Sicherung eingeben…",
|
||||
required: true
|
||||
});
|
||||
passGruppe.append(
|
||||
erstelleElement("label", { text: "Passphrase für Zugangsdaten" }),
|
||||
passInput
|
||||
);
|
||||
form.append(passGruppe);
|
||||
}
|
||||
|
||||
let modal;
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche",
|
||||
text: "Abbrechen",
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
|
||||
const restoreBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche warnung",
|
||||
text: "Jetzt Wiederherstellen"
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const passphrase = passInput?.value?.trim() || null;
|
||||
|
||||
if (backup.secrets === "Passphrase" && !passphrase) {
|
||||
zeigeMeldung("Bitte gib die Passphrase an.", "warnung");
|
||||
passInput?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
restoreBtn.disabled = true;
|
||||
restoreBtn.textContent = "Stelle wieder her…";
|
||||
|
||||
try {
|
||||
const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(backup.fileName)}/restore`, {
|
||||
method: "POST",
|
||||
body: { passphrase }
|
||||
});
|
||||
zeigeMeldung(res.meldung || "Wiederherstellung erfolgreich abgeschlossen.", "erfolg");
|
||||
modal.schliessen();
|
||||
ladeSicherungen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Wiederherstellung fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
restoreBtn.disabled = false;
|
||||
restoreBtn.textContent = "Jetzt Wiederherstellen";
|
||||
}
|
||||
});
|
||||
|
||||
modal = erstelleModal(`Sicherung wiederherstellen: ${backup.fileName}`, form, [abbrechenBtn, restoreBtn]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
// ─── Modal: Löschen ───
|
||||
function oeffneLoeschenDialog(fileName) {
|
||||
const text = erstelleElement("p", {
|
||||
text: `Soll die Sicherungsdatei '${fileName}' wirklich unwiderruflich gelöscht werden?`
|
||||
});
|
||||
|
||||
let modal;
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche",
|
||||
text: "Abbrechen",
|
||||
onclick: () => modal.schliessen()
|
||||
});
|
||||
|
||||
const loeschenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche gefahr",
|
||||
text: "Löschen",
|
||||
onclick: async () => {
|
||||
loeschenBtn.disabled = true;
|
||||
try {
|
||||
const res = await api(`/verwaltung/instanzen/${instanzId}/backups/${encodeURIComponent(fileName)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
zeigeMeldung(res.meldung || "Sicherung gelöscht.", "erfolg");
|
||||
modal.schliessen();
|
||||
ladeSicherungen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Löschen fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
loeschenBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
modal = erstelleModal("Sicherung löschen", text, [abbrechenBtn, loeschenBtn]);
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
ladeSicherungen();
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Leitfaden: Sicherer Chatverlauf (strikt textContent), Echtzeit-Ereignisse über SSE.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import { abonnieren } from "../modules/events.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
@@ -131,7 +131,8 @@ export async function rendereChat(parameter = {}) {
|
||||
// SSE-Abonnements
|
||||
entladeEreignisse.push(
|
||||
abonnieren("chat", (daten) => {
|
||||
if (daten.agentId === aktiverAgentId) {
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
if (daten.agentId === aktiverAgentId && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) {
|
||||
fuegeNachrichtEin(daten);
|
||||
}
|
||||
})
|
||||
@@ -139,7 +140,8 @@ export async function rendereChat(parameter = {}) {
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("step", (daten) => {
|
||||
if (daten.agentId === aktiverAgentId) {
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
if (daten.agentId === aktiverAgentId && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) {
|
||||
zeigeSchritt(daten.text);
|
||||
}
|
||||
})
|
||||
@@ -147,14 +149,18 @@ export async function rendereChat(parameter = {}) {
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("run", (daten) => {
|
||||
versteckeSchritt();
|
||||
aktualisiereLaufStatus(false);
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
if (!daten.instanz || !aktiveId || daten.instanz === aktiveId) {
|
||||
versteckeSchritt();
|
||||
aktualisiereLaufStatus(false);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
entladeEreignisse.push(
|
||||
abonnieren("instance", (daten) => {
|
||||
if (daten.kind === "Agents") {
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
if (daten.kind === "Agents" && (!daten.instanz || !aktiveId || daten.instanz === aktiveId)) {
|
||||
ladeAgenten(aktiverAgentId);
|
||||
}
|
||||
})
|
||||
@@ -286,6 +292,10 @@ function fuegeNachrichtEin(eintrag) {
|
||||
});
|
||||
|
||||
kopf.append(autor);
|
||||
if (eintrag.instanzName) {
|
||||
const instanzBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.instanzName });
|
||||
kopf.append(instanzBadge);
|
||||
}
|
||||
if (eintrag.source) {
|
||||
const quelleBadge = erstelleElement("span", { klasse: "quelle-tag", text: eintrag.source });
|
||||
kopf.append(quelleBadge);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Zeigt Anwendungsversion, Instanzkennung, Teilsysteme und OpenRouter-Guthaben an.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
@@ -21,6 +21,18 @@ export async function rendereInfo() {
|
||||
layout.append(gitter);
|
||||
container.append(layout);
|
||||
|
||||
if (!holeAktiveInstanz()) {
|
||||
gitter.append(
|
||||
erstelleKarte("Keine Instanz ausgewählt", [
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
gitter.append(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Systemdaten…" })
|
||||
);
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
// ClawdDotNet — Instanzverwaltung
|
||||
//
|
||||
// Leitfaden: Übersicht, Anlegen, Starten, Anhalten, Desktop-Fenster nach vorn holen,
|
||||
// Auswahl der aktiven Instanz für das WebUI. Kein innerHTML, strikt textContent.
|
||||
|
||||
import { api, holeAktiveInstanz, setzeAktiveInstanz } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleIcon,
|
||||
PFADE,
|
||||
erstellePlakette,
|
||||
erstelleKarte,
|
||||
erstelleModal,
|
||||
instanzZustandInfo,
|
||||
zeigeMeldung
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let aktualisierungsIntervall = null;
|
||||
|
||||
export async function rendereInstanzen() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
stoppePolling();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "instanzen-ansicht" });
|
||||
|
||||
const kopfZeile = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const titelGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
titelGruppe.append(erstelleElement("h1", { text: "Instanzverwaltung" }));
|
||||
|
||||
const aktionenGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
const aktualisierenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach",
|
||||
title: "Instanzliste manuell aktualisieren",
|
||||
onclick: () => ladeInstanzen()
|
||||
},
|
||||
erstelleIcon(PFADE.aktualisieren, 16),
|
||||
erstelleElement("span", { text: " Aktualisieren" })
|
||||
);
|
||||
|
||||
const neuBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche primaer",
|
||||
title: "Neue Instanz anlegen",
|
||||
onclick: oeffneNeueInstanzDialog
|
||||
},
|
||||
erstelleIcon(PFADE.plus, 16),
|
||||
erstelleElement("span", { text: " Neue Instanz" })
|
||||
);
|
||||
|
||||
aktionenGruppe.append(aktualisierenBtn, neuBtn);
|
||||
kopfZeile.append(titelGruppe, aktionenGruppe);
|
||||
|
||||
const raster = erstelleElement("div", { klasse: "instanzen-raster", id: "instanzen-raster" });
|
||||
|
||||
layout.append(kopfZeile, raster);
|
||||
container.append(layout);
|
||||
|
||||
await ladeInstanzen();
|
||||
|
||||
// Hintergrund-Polling alle 4 Sekunden, da sich Zustände ohne Klick ändern können
|
||||
aktualisierungsIntervall = setInterval(() => {
|
||||
ladeInstanzen(true);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
export function stoppePolling() {
|
||||
if (aktualisierungsIntervall) {
|
||||
clearInterval(aktualisierungsIntervall);
|
||||
aktualisierungsIntervall = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ladeInstanzen(istHintergrund = false) {
|
||||
const raster = document.getElementById("instanzen-raster");
|
||||
if (!raster) {
|
||||
stoppePolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!istHintergrund && raster.children.length === 0) {
|
||||
raster.replaceChildren(
|
||||
erstelleElement("div", { klasse: "hinweis-zentriert", text: "Lade Instanzen…" })
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const instanzen = await api("/verwaltung/instanzen");
|
||||
if (!raster) return;
|
||||
raster.replaceChildren();
|
||||
|
||||
if (!Array.isArray(instanzen) || instanzen.length === 0) {
|
||||
raster.append(
|
||||
erstelleKarte("Keine Instanzen gefunden", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Es sind noch keine Instanzen eingerichtet. Lege mit 'Neue Instanz' eine an."
|
||||
})
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
|
||||
for (const instanz of instanzen) {
|
||||
const istAktiv = instanz.id === aktiveId;
|
||||
const zustandInfo = instanzZustandInfo(instanz.zustand);
|
||||
const istLaufend = instanz.zustand === "Running";
|
||||
const istStumm = instanz.zustand === "Silent";
|
||||
const istAbgestuerzt = instanz.zustand === "Crashed";
|
||||
|
||||
// Instanzkarte
|
||||
const karteKopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
if (istAktiv) {
|
||||
karteKopfAktionen.append(erstellePlakette("Aktiv im WebUI", "erfolg"));
|
||||
}
|
||||
karteKopfAktionen.append(erstellePlakette(zustandInfo.text, zustandInfo.art));
|
||||
|
||||
// Details
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dl.append(
|
||||
erstelleElement("dt", { text: "Instanz-Kennung" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: instanz.id })),
|
||||
erstelleElement("dt", { text: "Zustand" }),
|
||||
erstelleElement("dd", { text: zustandInfo.text }),
|
||||
erstelleElement("dt", { text: "Prozess (PID)" }),
|
||||
erstelleElement("dd", { text: instanz.prozess ? String(instanz.prozess) : "—" }),
|
||||
erstelleElement("dt", { text: "Autostart durch Manager" }),
|
||||
erstelleElement("dd", {
|
||||
text: instanz.autostart ? "Aktiviert" : "Aus",
|
||||
klasse: instanz.autostart ? "" : "gedaempft"
|
||||
})
|
||||
);
|
||||
|
||||
// Aktionsleiste
|
||||
const fussAktionen = erstelleElement("div", { klasse: "instanz-aktionen" });
|
||||
|
||||
// Auswählen-Knopf
|
||||
const waehlenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: istAktiv ? "schaltflaeche klein" : "schaltflaeche primaer klein",
|
||||
disabled: istAktiv,
|
||||
title: istAktiv ? "Diese Instanz wird aktuell im WebUI bedient" : "Diese Instanz für das WebUI auswählen",
|
||||
onclick: () => {
|
||||
setzeAktiveInstanz(instanz.id);
|
||||
zeigeMeldung(`Instanz '${instanz.name}' im WebUI ausgewählt.`, "info");
|
||||
ladeInstanzen();
|
||||
}
|
||||
},
|
||||
erstelleElement("span", { text: istAktiv ? "Ausgewählt" : "Auswählen" })
|
||||
);
|
||||
|
||||
// Starten-Knopf
|
||||
const startenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach klein",
|
||||
disabled: istLaufend,
|
||||
title: istLaufend ? "Instanz läuft bereits" : "Instanz starten",
|
||||
onclick: () => starteInstanz(instanz)
|
||||
},
|
||||
erstelleIcon(PFADE.abspielen, 14),
|
||||
erstelleElement("span", { text: " Starten" })
|
||||
);
|
||||
|
||||
// Anhalten-Knopf
|
||||
const anhaltenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche gefahr flach klein",
|
||||
disabled: !istLaufend && !istStumm,
|
||||
title: (!istLaufend && !istStumm) ? "Instanz läuft nicht" : "Instanz geordnet anhalten",
|
||||
onclick: () => stoppeInstanz(instanz)
|
||||
},
|
||||
erstelleIcon(PFADE.stop, 14),
|
||||
erstelleElement("span", { text: " Anhalten" })
|
||||
);
|
||||
|
||||
// Fenster nach vorn
|
||||
const fensterBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
klasse: "schaltflaeche flach klein",
|
||||
disabled: !istLaufend,
|
||||
title: !istLaufend ? "Instanz läuft nicht" : "Desktop-Fenster dieser Instanz in den Vordergrund holen",
|
||||
onclick: () => holeFensterNachVorn(instanz)
|
||||
},
|
||||
erstelleIcon(PFADE.fenster, 14),
|
||||
erstelleElement("span", { text: " Fenster" })
|
||||
);
|
||||
|
||||
fussAktionen.append(waehlenBtn, startenBtn, anhaltenBtn, fensterBtn);
|
||||
|
||||
const karte = erstelleKarte(instanz.name, [dl, fussAktionen], {
|
||||
aktionen: karteKopfAktionen,
|
||||
klasse: istAktiv ? "instanz-karte aktiv" : "instanz-karte"
|
||||
});
|
||||
|
||||
raster.append(karte);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!istHintergrund) {
|
||||
raster.replaceChildren(
|
||||
erstelleElement("div", { klasse: "fehler-zentriert", text: `Fehler beim Laden: ${err.message}` })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function starteInstanz(instanz) {
|
||||
try {
|
||||
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/start`, {
|
||||
method: "POST"
|
||||
});
|
||||
zeigeMeldung(antwort?.meldung || `Instanz '${instanz.name}' wird gestartet…`, "erfolg");
|
||||
// Wenn bisher keine Instanz im WebUI gewählt war, diese direkt wählen
|
||||
if (!holeAktiveInstanz()) {
|
||||
setzeAktiveInstanz(instanz.id);
|
||||
}
|
||||
await ladeInstanzen();
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stoppeInstanz(instanz) {
|
||||
oeffneSicherheitsStoppDialog(instanz);
|
||||
}
|
||||
|
||||
function oeffneSicherheitsStoppDialog(instanz) {
|
||||
let verbleibend = 10;
|
||||
let timerId = null;
|
||||
|
||||
const inhalt = erstelleElement("div", { klasse: "formular-raster" });
|
||||
|
||||
// Sicherheits-Warnbox
|
||||
const sicherheitsBox = erstelleElement("div", { klasse: "sicherheits-box" });
|
||||
const boxTitel = erstelleElement(
|
||||
"h3",
|
||||
{},
|
||||
erstelleIcon(PFADE.ausrufezeichen, 18),
|
||||
erstelleElement("span", { text: "Achtung: Produktivinstanz beenden" })
|
||||
);
|
||||
const boxText1 = erstelleElement("p", {
|
||||
text: "ClawdDotNet-Instanzen sind auf Dauerbetrieb ausgelegt. Das Beenden stoppt alle aktiven Agenten, Automationen, Web-Zugriffe und Hintergrundaufgaben dieser Instanz geordnet."
|
||||
});
|
||||
const boxText2 = erstelleElement("p", {
|
||||
text: "Um ein versehentliches Beenden durch unbedachtes Klicken zu verhindern, ist die Bestätigung für 10 Sekunden gesperrt."
|
||||
});
|
||||
sicherheitsBox.append(boxTitel, boxText1, boxText2);
|
||||
|
||||
// Instanzdetails
|
||||
const dl = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dl.append(
|
||||
erstelleElement("dt", { text: "Instanz" }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: instanz.name })),
|
||||
erstelleElement("dt", { text: "Kennung" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: instanz.id })),
|
||||
erstelleElement("dt", { text: "Prozess (PID)" }),
|
||||
erstelleElement("dd", { text: instanz.prozess ? String(instanz.prozess) : "—" })
|
||||
);
|
||||
|
||||
// Countdown-Anzeige
|
||||
const countdownBox = erstelleElement("div", {
|
||||
klasse: "sicherheits-countdown",
|
||||
text: `Bestätigung gesperrt: Noch ${verbleibend} Sekunden…`
|
||||
});
|
||||
|
||||
inhalt.append(sicherheitsBox, dl, countdownBox);
|
||||
|
||||
// Aktionen
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen (Instanz läuft weiter)"
|
||||
});
|
||||
|
||||
const anhaltenBtn = erstelleElement(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
klasse: "schaltflaeche gefahr",
|
||||
disabled: true
|
||||
},
|
||||
erstelleIcon(PFADE.stop, 14),
|
||||
erstelleElement("span", { text: ` Instanz anhalten (${verbleibend}s)` })
|
||||
);
|
||||
|
||||
const modal = erstelleModal(
|
||||
`Instanz anhalten — ${instanz.name}`,
|
||||
inhalt,
|
||||
[abbrechenBtn, anhaltenBtn],
|
||||
{
|
||||
onSchliessen: () => {
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
timerId = setInterval(() => {
|
||||
verbleibend--;
|
||||
if (verbleibend > 0) {
|
||||
countdownBox.textContent = `Bestätigung gesperrt: Noch ${verbleibend} Sekunden…`;
|
||||
const textSpan = anhaltenBtn.querySelector("span");
|
||||
if (textSpan) textSpan.textContent = ` Instanz anhalten (${verbleibend}s)`;
|
||||
} else {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
anhaltenBtn.disabled = false;
|
||||
const textSpan = anhaltenBtn.querySelector("span");
|
||||
if (textSpan) textSpan.textContent = " Instanz jetzt endgültig anhalten";
|
||||
countdownBox.className = "sicherheits-countdown abgelaufen";
|
||||
countdownBox.textContent = "Sperre aufgehoben — Bestätigung jetzt möglich.";
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
anhaltenBtn.addEventListener("click", async () => {
|
||||
if (verbleibend > 0) return;
|
||||
anhaltenBtn.disabled = true;
|
||||
const textSpan = anhaltenBtn.querySelector("span");
|
||||
if (textSpan) textSpan.textContent = " Wird angehalten…";
|
||||
|
||||
try {
|
||||
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/stop`, {
|
||||
method: "POST"
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung(antwort?.meldung || `Instanz '${instanz.name}' beendet.`, "info");
|
||||
await ladeInstanzen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Anhalten fehlgeschlagen: ${err.message}`, "gefahr");
|
||||
anhaltenBtn.disabled = false;
|
||||
if (textSpan) textSpan.textContent = " Instanz jetzt endgültig anhalten";
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
async function holeFensterNachVorn(instanz) {
|
||||
try {
|
||||
const antwort = await api(`/verwaltung/instanzen/${encodeURIComponent(instanz.id)}/fenster`, {
|
||||
method: "POST"
|
||||
});
|
||||
zeigeMeldung(antwort?.meldung || `Fenster von '${instanz.name}' nach vorn geholt.`, "info");
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fensteraufruf fehlgeschlagen: ${err.message}`, "warnung");
|
||||
}
|
||||
}
|
||||
|
||||
function oeffneNeueInstanzDialog() {
|
||||
const formular = erstelleElement("form", {
|
||||
klasse: "formular-raster",
|
||||
id: "neue-instanz-formular"
|
||||
});
|
||||
|
||||
const nameInput = erstelleElement("input", {
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "z.B. Produktion oder Agenten-Labor",
|
||||
autocomplete: "off"
|
||||
});
|
||||
|
||||
const hinweis = erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Der Instanzmanager legt dafür ein eigenes Verzeichnis mit getrennten Protokollen und Einstellungen an."
|
||||
});
|
||||
|
||||
formular.append(
|
||||
erstelleElement("label", { text: "Name der neuen Instanz:" }),
|
||||
nameInput,
|
||||
hinweis
|
||||
);
|
||||
|
||||
const abbrechenBtn = erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: "schaltflaeche flach",
|
||||
onclick: () => modal.schliessen(),
|
||||
text: "Abbrechen"
|
||||
});
|
||||
|
||||
// Kein eigenes onclick und kein form-Attribut: erstelleModal verknüpft Submit-Knöpfe
|
||||
// im Fuß selbst mit dem Formular. Beides zusammen löste requestSubmit() zweimal aus —
|
||||
// ein Klick, zwei POST, zwei Instanzen. `disabled` hilft dagegen nicht, denn
|
||||
// form.requestSubmit() schert sich nicht um den Zustand eines Knopfes.
|
||||
const erstellenBtn = erstelleElement("button", {
|
||||
type: "submit",
|
||||
klasse: "schaltflaeche primaer",
|
||||
text: "Instanz anlegen"
|
||||
});
|
||||
|
||||
const modal = erstelleModal("Neue Instanz anlegen", formular, [abbrechenBtn, erstellenBtn]);
|
||||
|
||||
formular.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
erstellenBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const antwort = await api("/verwaltung/instanzen", {
|
||||
method: "POST",
|
||||
body: { name }
|
||||
});
|
||||
modal.schliessen();
|
||||
zeigeMeldung(antwort?.meldung || `Instanz '${name}' erfolgreich angelegt.`, "erfolg");
|
||||
await ladeInstanzen();
|
||||
} catch (err) {
|
||||
zeigeMeldung(`Fehler beim Anlegen: ${err.message}`, "gefahr");
|
||||
} finally {
|
||||
erstellenBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
modal.oeffnen();
|
||||
}
|
||||
@@ -29,10 +29,10 @@ export function initialisiereLogin(onErfolg) {
|
||||
setzeToken(token);
|
||||
|
||||
try {
|
||||
// Teste das Token gegen /api/info
|
||||
const info = await api("/info");
|
||||
// Teste das Token gegen /api/verwaltung/instanzen (gehört dem Manager direkt und scheitert nie an Instanzanzahl)
|
||||
const instanzen = await api("/verwaltung/instanzen");
|
||||
versteckeAnmeldung();
|
||||
if (loginCallback) loginCallback(info);
|
||||
if (loginCallback) loginCallback(instanzen);
|
||||
} catch (err) {
|
||||
loescheToken();
|
||||
if (fehlerBox) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../modules/dom.js";
|
||||
|
||||
let logStream = null;
|
||||
let instanzFilter = "";
|
||||
let modulFilter = "";
|
||||
let levelFilter = "All";
|
||||
let autoScroll = true;
|
||||
@@ -38,6 +39,16 @@ export async function rendereProtokoll() {
|
||||
|
||||
const steuerungGruppe = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
|
||||
// Instanz-Auswahl
|
||||
const instanzSelect = erstelleElement("select", {
|
||||
klasse: "eingabe-select",
|
||||
title: "Nach Instanz filtern",
|
||||
onchange: (e) => {
|
||||
instanzFilter = e.target.value;
|
||||
}
|
||||
});
|
||||
instanzSelect.append(erstelleElement("option", { value: "", text: "Alle Instanzen" }));
|
||||
|
||||
// Modul-Auswahl
|
||||
const modulSelect = erstelleElement("select", {
|
||||
klasse: "eingabe-select",
|
||||
@@ -102,13 +113,25 @@ export async function rendereProtokoll() {
|
||||
erstelleElement("span", { text: " Leeren" })
|
||||
);
|
||||
|
||||
steuerungGruppe.append(modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
|
||||
steuerungGruppe.append(instanzSelect, modulSelect, levelSelect, autoScrollLabel, pauseButton, leerenBtn);
|
||||
kopfZeile.append(titelGruppe, steuerungGruppe);
|
||||
|
||||
logContainer = erstelleElement("div", { klasse: "protokoll-strom code-block" });
|
||||
layout.append(kopfZeile, logContainer);
|
||||
container.append(layout);
|
||||
|
||||
// Lade verfügbare Instanzen für Filter
|
||||
try {
|
||||
const instanzen = await api("/verwaltung/instanzen");
|
||||
if (Array.isArray(instanzen)) {
|
||||
for (const inst of instanzen) {
|
||||
instanzSelect.append(erstelleElement("option", { value: inst.id, text: inst.name }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Nicht kritisch
|
||||
}
|
||||
|
||||
// Lade verfügbare Module
|
||||
try {
|
||||
const module = await api("/logs/modules");
|
||||
@@ -159,8 +182,22 @@ function starteLogStream() {
|
||||
function fuegeLogZeileEin(eintrag) {
|
||||
if (!logContainer) return;
|
||||
|
||||
// Filter nach Instanz falls gewählt
|
||||
if (instanzFilter && eintrag.instanz && eintrag.instanz !== instanzFilter && eintrag.instanzName !== instanzFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zeile = erstelleElement("div", { klasse: "log-zeile" });
|
||||
|
||||
if (eintrag.instanzName || eintrag.instanz) {
|
||||
zeile.append(
|
||||
erstelleElement("span", {
|
||||
klasse: "log-instanz",
|
||||
text: `[${eintrag.instanzName || eintrag.instanz}]`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (eintrag.level) {
|
||||
const lvl = String(eintrag.level).toLowerCase();
|
||||
const badgeArt =
|
||||
|
||||
@@ -27,28 +27,6 @@ export function rendereSicherung() {
|
||||
container.append(layout);
|
||||
}
|
||||
|
||||
export function rendereEinstellungen() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "platzhalter-ansicht" });
|
||||
layout.append(erstelleElement("h1", { text: "Instanz-Einstellungen" }));
|
||||
|
||||
const karte = erstelleKarte("In Vorbereitung", [
|
||||
erstelleElement("p", {
|
||||
klasse: "gedaempft",
|
||||
text: "Die Konfiguration der Instanz (Port, Token, Logging, Budget) erfolgt über die Datei 'InstanceSettings.json' bzw. über den Einstellungsdialog im Desktop-Fenster."
|
||||
}),
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Ein dedizierter Web-Endpunkt zum Schreiben sensibler Zugangsdaten ist für einen späteren Meilenstein vorgesehen."
|
||||
})
|
||||
]);
|
||||
|
||||
layout.append(karte);
|
||||
container.append(layout);
|
||||
}
|
||||
|
||||
export function rendereAgentenChats() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
// ClawdDotNet — Einstellungen & Systemarchitektur
|
||||
//
|
||||
// Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene.
|
||||
|
||||
import { api, holeAktiveInstanz } from "../modules/api.js";
|
||||
import {
|
||||
erstelleElement,
|
||||
erstelleKarte,
|
||||
erstellePlakette,
|
||||
instanzZustandInfo,
|
||||
erstelleIcon,
|
||||
PFADE
|
||||
} from "../modules/dom.js";
|
||||
|
||||
export async function rendereEinstellungen() {
|
||||
const container = document.getElementById("ansicht-inhalt");
|
||||
if (!container) return;
|
||||
container.replaceChildren();
|
||||
|
||||
const layout = erstelleElement("div", { klasse: "einstellungen-ansicht" });
|
||||
|
||||
// Kopfbereich
|
||||
const kopf = erstelleElement("div", { klasse: "ansicht-kopf" });
|
||||
const kopfText = erstelleElement("div");
|
||||
kopfText.append(
|
||||
erstelleElement("h1", { text: "Einstellungen & Systemarchitektur" }),
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Übersicht der Konfiguration nach dem Instanzmanager-Konzept: Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene."
|
||||
})
|
||||
);
|
||||
kopf.append(kopfText);
|
||||
layout.append(kopf);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SEKTION 1: Instanzmanager (Globale Ebene / Installation)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const managerSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-manager" });
|
||||
|
||||
const managerKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" });
|
||||
const managerTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
managerTitelLinks.append(
|
||||
erstelleIcon(PFADE.server, 20),
|
||||
erstelleElement("h2", { text: "Instanzmanager" }),
|
||||
erstellePlakette("Manager-Ebene · Global", "warnung")
|
||||
);
|
||||
managerKopf.append(managerTitelLinks);
|
||||
|
||||
const managerIntro = erstelleElement("p", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: "Gilt für alle Instanzen und die Weboberfläche. Gespeichert in manager.json im Anwendungs-Hauptverzeichnis."
|
||||
});
|
||||
|
||||
const managerGitter = erstelleElement("div", { klasse: "info-gitter" });
|
||||
|
||||
// Karte 1: Weboberfläche & Netzwerk
|
||||
const dlNetz = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlNetz.append(
|
||||
erstelleElement("dt", { text: "Konfigurationsdatei" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: "manager.json" })),
|
||||
erstelleElement("dt", { text: "WebUI-Port" }),
|
||||
erstelleElement("dd", { text: "7000 (Vorgabe, webUiPort)" }),
|
||||
erstelleElement("dt", { text: "Netzwerk-Bindung" }),
|
||||
erstelleElement("dd", { text: "loopback (127.0.0.1, webUiBinding)" }),
|
||||
erstelleElement("dt", { text: "Zugangsschutz" }),
|
||||
erstelleElement("dd", {}, erstellePlakette("Token aktiv (≥ 20 Zeichen)", "erfolg"))
|
||||
);
|
||||
managerGitter.append(erstelleKarte("Weboberfläche & Netzwerk", [dlNetz]));
|
||||
|
||||
// Karte 2: Lizenz & Programm-Updates
|
||||
const dlLizenz = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlLizenz.append(
|
||||
erstelleElement("dt", { text: "Lizenz-Geltung" }),
|
||||
erstelleElement("dd", { text: "Installationsebene (E-M3: Beliebig viele Instanzen)" }),
|
||||
erstelleElement("dt", { text: "Lizenzschlüssel" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: "●●●●-●●●●-●●●●-●●●● (verschlüsselt)" })),
|
||||
erstelleElement("dt", { text: "Update-Kanal" }),
|
||||
erstelleElement("dd", { text: "prod (Offizielle Release-Versionen)" }),
|
||||
erstelleElement("dt", { text: "Update-Prüfung" }),
|
||||
erstelleElement("dd", { text: "Zentral auf Manager-Ebene" })
|
||||
);
|
||||
managerGitter.append(erstelleKarte("Lizenz & Updates", [dlLizenz]));
|
||||
|
||||
// Karte 3: Deploymentcenter & Aufsicht
|
||||
const dlAufsicht = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlAufsicht.append(
|
||||
erstelleElement("dt", { text: "Deploymentcenter" }),
|
||||
erstelleElement("dd", { text: "https://dc.mhdf.de" }),
|
||||
erstelleElement("dt", { text: "Master-Token" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: "●●●●●●●● (erzeugt Sub-Token je Instanz)" })),
|
||||
erstelleElement("dt", { text: "Desktop-Fenster" }),
|
||||
erstelleElement("dd", { text: "Aktiv (jede Instanz hat ein eigenes Fenster)" }),
|
||||
erstelleElement("dt", { text: "Absturz-Schutz" }),
|
||||
erstelleElement("dd", { text: "Max. 5 Neustarts / 10 Minuten" })
|
||||
);
|
||||
managerGitter.append(erstelleKarte("Deploymentcenter & Aufsicht", [dlAufsicht]));
|
||||
|
||||
managerSektion.append(managerKopf, managerIntro, managerGitter);
|
||||
layout.append(managerSektion);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SEKTION 2: Aktive Instanz (Instanz-Ebene)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const instanzSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-instanz" });
|
||||
|
||||
const aktiveId = holeAktiveInstanz();
|
||||
let instanzenListe = [];
|
||||
try {
|
||||
instanzenListe = await api("/verwaltung/instanzen");
|
||||
} catch {
|
||||
// Wenn Liste nicht abrufbar, weiter
|
||||
}
|
||||
|
||||
const aktiveInstanz = instanzenListe.find((i) => i.id === aktiveId);
|
||||
const zInfo = aktiveInstanz ? instanzZustandInfo(aktiveInstanz.zustand) : { text: "Keine gewählt", art: "neutral" };
|
||||
|
||||
const instanzKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" });
|
||||
const instanzTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" });
|
||||
instanzTitelLinks.append(
|
||||
erstelleIcon(PFADE.fenster, 20),
|
||||
erstelleElement("h2", { text: aktiveInstanz ? `Aktive Instanz: ${aktiveInstanz.name}` : "Instanzbezogene Einstellungen" }),
|
||||
erstellePlakette("Instanz-Ebene · Isoliert", "erfolg"),
|
||||
aktiveInstanz ? erstellePlakette(zInfo.text, zInfo.art) : erstellePlakette("Keine Instanz aktiv", "neutral")
|
||||
);
|
||||
instanzKopf.append(instanzTitelLinks);
|
||||
|
||||
const instanzIntro = erstelleElement("p", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: "Gilt ausschließlich für die aktuell ausgewählte Instanz. Alle Pfade, Protokolle, Agenten und Budgets sind strikt von anderen Instanzen isoliert."
|
||||
});
|
||||
|
||||
instanzSektion.append(instanzKopf, instanzIntro);
|
||||
|
||||
if (!aktiveInstanz) {
|
||||
const leerKarte = erstelleKarte("Keine Instanz ausgewählt", [
|
||||
erstelleElement("p", {
|
||||
klasse: "hinweis",
|
||||
text: "Bitte wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten oder auszuwählen."
|
||||
}),
|
||||
erstelleElement("a", {
|
||||
href: "#/instanzen",
|
||||
klasse: "schaltflaeche primaer klein",
|
||||
text: "Zur Instanzen-Übersicht"
|
||||
})
|
||||
]);
|
||||
instanzSektion.append(leerKarte);
|
||||
} else {
|
||||
// Daten von der laufenden Instanz abfragen (sofern sie läuft)
|
||||
const [instanzInfo, instanzStatus] = await Promise.all([
|
||||
api("/info").catch(() => null),
|
||||
api("/status").catch(() => null)
|
||||
]);
|
||||
|
||||
const instanzGitter = erstelleElement("div", { klasse: "info-gitter" });
|
||||
|
||||
// Karte 1: Instanz-Identität & Autostart
|
||||
const dlIdent = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlIdent.append(
|
||||
erstelleElement("dt", { text: "Instanzname" }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: aktiveInstanz.name })),
|
||||
erstelleElement("dt", { text: "Instanz-ID" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: aktiveInstanz.id })),
|
||||
erstelleElement("dt", { text: "Konfigurationsdatei" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/InstanceSettings.json` })),
|
||||
erstelleElement("dt", { text: "Autostart durch Manager" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(aktiveInstanz.autostart ? "Aktiv (Am Laufen halten)" : "Inaktiv (Manueller Start)", aktiveInstanz.autostart ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Kommunikation" }),
|
||||
erstelleElement("dd", { text: "HTTP über Named Pipe (Windows) / Socket (Linux)" })
|
||||
);
|
||||
instanzGitter.append(erstelleKarte("Identität & Prozess", [dlIdent]));
|
||||
|
||||
// Karte 2: Isolierte Verzeichnisse & Speicher
|
||||
const dlPfade = erstelleElement("dl", { klasse: "datenliste" });
|
||||
dlPfade.append(
|
||||
erstelleElement("dt", { text: "Protokolle (Logs)" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Logs/` })),
|
||||
erstelleElement("dt", { text: "Datensicherungen" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Backups/` })),
|
||||
erstelleElement("dt", { text: "Zustandsdatenbank" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/state.db (exklusiv)` })),
|
||||
erstelleElement("dt", { text: "Arbeitsbereich (Shared)" }),
|
||||
erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Agents/SharedWorkspace/` }))
|
||||
);
|
||||
instanzGitter.append(erstelleKarte("Isolierte Verzeichnisse", [dlPfade]));
|
||||
|
||||
// Karte 3: Modellanbindung & Budget (OpenRouter)
|
||||
const dlModell = erstelleElement("dl", { klasse: "datenliste" });
|
||||
if (instanzStatus) {
|
||||
dlModell.append(
|
||||
erstelleElement("dt", { text: "OpenRouter-Status" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzStatus.isApiReachable ? "Verbunden" : "Offline", instanzStatus.isApiReachable ? "erfolg" : "gefahr")),
|
||||
erstelleElement("dt", { text: "Statusmeldung" }),
|
||||
erstelleElement("dd", { text: instanzStatus.statusText || "—" }),
|
||||
erstelleElement("dt", { text: "Guthaben / Credits" }),
|
||||
erstelleElement("dd", {}, erstelleElement("strong", { text: instanzStatus.creditsText || "—" })),
|
||||
erstelleElement("dt", { text: "API-Schlüssel" }),
|
||||
erstelleElement("dd", { text: "In InstanceSettings.json dieser Instanz maskiert hinterlegt" })
|
||||
);
|
||||
} else {
|
||||
dlModell.append(
|
||||
erstelleElement("dt", { text: "OpenRouter" }),
|
||||
erstelleElement("dd", {
|
||||
klasse: "gedaempft",
|
||||
text: aktiveInstanz.zustand === "Running"
|
||||
? "Kein Schlüssel hinterlegt oder Dienst nicht erreichbar."
|
||||
: "Instanz läuft nicht. Daten werden nach dem Start geladen."
|
||||
})
|
||||
);
|
||||
}
|
||||
instanzGitter.append(erstelleKarte("Modellanbindung (OpenRouter)", [dlModell]));
|
||||
|
||||
// Karte 4: Teilsysteme der Instanz
|
||||
const dlSysteme = erstelleElement("dl", { klasse: "datenliste" });
|
||||
if (instanzInfo) {
|
||||
dlSysteme.append(
|
||||
erstelleElement("dt", { text: "Agent Engine" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.engineActive ? "Aktiv" : "Aus", instanzInfo.engineActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Scanner / Scheduler" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.schedulerActive ? "Aktiv" : "Aus", instanzInfo.schedulerActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Freigabestelle (Staging)" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.stagingActive ? "Aktiv" : "Aus", instanzInfo.stagingActive ? "erfolg" : "neutral")),
|
||||
erstelleElement("dt", { text: "Deploymentcenter Sub-Token" }),
|
||||
erstelleElement("dd", {}, erstellePlakette(instanzInfo.deploymentcenterActive ? "Aktiv" : "Aus", instanzInfo.deploymentcenterActive ? "erfolg" : "neutral"))
|
||||
);
|
||||
} else {
|
||||
dlSysteme.append(
|
||||
erstelleElement("dt", { text: "Subsysteme" }),
|
||||
erstelleElement("dd", {
|
||||
klasse: "gedaempft",
|
||||
text: aktiveInstanz.zustand === "Running"
|
||||
? "Laufzeitdaten werden abgefragt…"
|
||||
: "Instanz ist beendet — Teilsysteme ruhen."
|
||||
})
|
||||
);
|
||||
}
|
||||
instanzGitter.append(erstelleKarte("Laufzeit & Teilsysteme", [dlSysteme]));
|
||||
|
||||
instanzSektion.append(instanzGitter);
|
||||
}
|
||||
|
||||
layout.append(instanzSektion);
|
||||
container.append(layout);
|
||||
}
|
||||
@@ -3,8 +3,34 @@ using ClawdDotNet.Core.Instances;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt.</summary>
|
||||
public sealed record InstanceIdentity(string InstanceId, string InstanceName, string AppVersion);
|
||||
/// <summary>
|
||||
/// Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt — ihr Puls.
|
||||
///
|
||||
/// <para><b>Die Identität ist der Beweis, der Rest ist die Auskunft.</b> Dass überhaupt
|
||||
/// jemand antwortet, unterscheidet „läuft" von „tot". Erst
|
||||
/// <see cref="Phase"/> und <see cref="OpenWork"/> unterscheiden die beiden Fälle, die
|
||||
/// gleich aussehen und gegensätzlich zu behandeln sind: eine Instanz, die hängt, und
|
||||
/// eine, die gerade geordnet aufräumt.</para>
|
||||
/// </summary>
|
||||
/// <param name="Phase">Läuft, fährt herunter, ist fertig.</param>
|
||||
/// <param name="OpenWork">Wie viele Arbeiten beim Herunterfahren noch auslaufen.</param>
|
||||
/// <param name="OpenSummary">Was das im Klartext ist, fürs Protokoll.</param>
|
||||
public sealed record InstanceIdentity(
|
||||
string InstanceId,
|
||||
string InstanceName,
|
||||
string AppVersion,
|
||||
ShutdownPhase Phase = ShutdownPhase.Running,
|
||||
int OpenWork = 0,
|
||||
string OpenSummary = "")
|
||||
{
|
||||
/// <summary>
|
||||
/// Fährt diese Instanz gerade geordnet herunter und ist dabei noch beschäftigt?
|
||||
///
|
||||
/// <para>Die Aufsicht darf so eine Instanz nicht abschießen — sie tut genau das,
|
||||
/// worum gebeten wurde.</para>
|
||||
/// </summary>
|
||||
public bool IsWindingDown => Phase == ShutdownPhase.Draining;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Das Gespräch mit einer Instanz über ihren Socket: Wer bist du — und bitte beende dich.
|
||||
@@ -74,18 +100,26 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
|
||||
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||
client.Timeout = _timeout;
|
||||
|
||||
using var antwort = await client.GetAsync("/api/info", ct).ConfigureAwait(false);
|
||||
// Der Puls und nicht /api/info: Er liegt hinter dem Socket-Gate, gehoert also
|
||||
// dem Manager allein, und er sagt zusaetzlich, ob die Instanz gerade
|
||||
// aufraeumt. Von dieser Unterscheidung haengt ab, ob abgeschossen wird.
|
||||
using var antwort = await client
|
||||
.GetAsync("/api/instanz/puls", ct).ConfigureAwait(false);
|
||||
|
||||
if (!antwort.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var json = await antwort.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
return new InstanceIdentity(
|
||||
Text(doc.RootElement, "instanceId"),
|
||||
Text(doc.RootElement, "instanceName"),
|
||||
Text(doc.RootElement, "appVersion"));
|
||||
Text(root, "instanceId"),
|
||||
Text(root, "instanceName"),
|
||||
Text(root, "appVersion"),
|
||||
Phase(root),
|
||||
Zahl(root, "offenGesamt"),
|
||||
Beschreibung(root));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -139,4 +173,31 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
|
||||
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
private static int Zahl(JsonElement root, string name)
|
||||
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32()
|
||||
: 0;
|
||||
|
||||
/// <summary>
|
||||
/// Die Phase aus der Antwort — im Zweifel <c>Running</c>.
|
||||
///
|
||||
/// <para>Ein unbekannter Wert darf hier nicht als „fährt herunter" durchgehen: Das
|
||||
/// wäre der Freibrief, mit dem eine hängende Instanz beliebig lange stehen bliebe.
|
||||
/// <c>Running</c> ist die Auslegung, bei der die Aufsicht wachsam bleibt.</para>
|
||||
/// </summary>
|
||||
private static ShutdownPhase Phase(JsonElement root)
|
||||
=> Enum.TryParse<ShutdownPhase>(Text(root, "phase"), ignoreCase: true, out var phase)
|
||||
? phase
|
||||
: ShutdownPhase.Running;
|
||||
|
||||
private static string Beschreibung(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("offeneArbeiten", out var liste)
|
||||
|| liste.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
return string.Join(", ", liste.EnumerateArray()
|
||||
.Select(e => $"{Text(e, "name")}: {Zahl(e, "anzahl")}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,15 @@ public sealed class InstanceRegistry : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Aufsicht über eine bestimmte Instanz, oder <c>null</c>.
|
||||
///
|
||||
/// <para>Damit sich ein Aufsichtsdurchgang von außen auslösen lässt, statt auf die Uhr
|
||||
/// zu warten — Tests mit Wartezeiten sind Tests, die man irgendwann abschaltet.</para>
|
||||
/// </summary>
|
||||
public InstanceSupervisor? SupervisorFor(string instanceDirectory)
|
||||
=> _supervisors.GetValueOrDefault(Path.GetFullPath(instanceDirectory));
|
||||
|
||||
private InstanceSupervisor Find(string instanceDirectory)
|
||||
{
|
||||
var pfad = Path.GetFullPath(instanceDirectory);
|
||||
|
||||
@@ -34,17 +34,37 @@ public enum InstanceCondition
|
||||
/// die Aufsicht ihr Geld nicht mehr wert.</para>
|
||||
/// </param>
|
||||
/// <param name="StopGrace">Wie lange eine Instanz nach der Bitte zu enden noch bekommt.</param>
|
||||
/// <param name="SilentKillGrace">
|
||||
/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt.
|
||||
///
|
||||
/// <para>Der Bitte über den Socket ist bei einer Instanz, die eben auf demselben Socket
|
||||
/// nicht geantwortet hat, wenig zuzutrauen — sie kostet einen Versuch und wird deshalb
|
||||
/// unternommen, aber gewartet wird nicht auf sie, sondern auf die Uhr. Antwortet die
|
||||
/// Instanz in dieser Zeit wieder, war es ein Aussetzer und niemand wird abgeschossen.</para>
|
||||
/// </param>
|
||||
/// <param name="MaxDrainWait">
|
||||
/// Die absolute Obergrenze beim Anhalten, wenn die Instanz meldet, dass sie aufräumt.
|
||||
///
|
||||
/// <para>Muss größer sein als die Frist, die sich die Instanz selbst gibt
|
||||
/// (<c>ShutdownCoordinator.Budget</c>) — sonst schießt der Manager 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.</para>
|
||||
/// </param>
|
||||
public sealed record SupervisionOptions(
|
||||
TimeSpan ProbeInterval,
|
||||
TimeSpan StartTimeout,
|
||||
int SilentChecksBeforeAlarm,
|
||||
TimeSpan StopGrace)
|
||||
TimeSpan StopGrace,
|
||||
TimeSpan SilentKillGrace,
|
||||
TimeSpan MaxDrainWait)
|
||||
{
|
||||
public static SupervisionOptions Default { get; } = new(
|
||||
ProbeInterval: TimeSpan.FromSeconds(15),
|
||||
StartTimeout: TimeSpan.FromSeconds(60),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromSeconds(20));
|
||||
StopGrace: TimeSpan.FromSeconds(20),
|
||||
SilentKillGrace: TimeSpan.FromSeconds(30),
|
||||
MaxDrainWait: TimeSpan.FromMinutes(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,6 +111,9 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
private int _silentChecks;
|
||||
private bool _stopRequested;
|
||||
|
||||
/// <summary>Seit wann das Verstummen andauert — der Beginn der Frist bis zum harten Ende.</summary>
|
||||
private DateTimeOffset? _silentSince;
|
||||
|
||||
public InstanceSupervisor(
|
||||
string instanceDirectory,
|
||||
IInstanceLauncher launcher,
|
||||
@@ -136,23 +159,58 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
/// </summary>
|
||||
public async Task<bool> StartAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (Condition == InstanceCondition.Running)
|
||||
{
|
||||
// Nicht dem eigenen Merkzettel glauben, sondern nachfragen. Zwischen zwei
|
||||
// Aufsichtsdurchgaengen liegen Sekunden, und in denen kann die Instanz weg
|
||||
// sein: Wer hier "laeuft schon" antwortet, meldet einen Erfolg, hinter dem
|
||||
// nichts steht - und der naechste Durchgang meldet dann den Absturz, den
|
||||
// niemand mehr erwartet.
|
||||
if (await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is not null)
|
||||
return true;
|
||||
// Nicht dem eigenen Merkzettel glauben, sondern nachfragen - und zwar immer,
|
||||
// nicht nur wenn die Aufsicht die Instanz fuer laufend haelt. Zwischen zwei
|
||||
// Durchgaengen liegen Sekunden, und in denen kann sie ebenso wegbrechen wie
|
||||
// wiederkommen. Wer hier ungefragt startet, setzt im zweiten Fall einen zweiten
|
||||
// Prozess auf dieselbe state.db.
|
||||
var vorhanden = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Instanz {Name} galt als laufend, antwortet aber nicht mehr — sie wird gestartet.",
|
||||
InstanceName);
|
||||
if (vorhanden is not null)
|
||||
{
|
||||
if (vorhanden.IsWindingDown)
|
||||
{
|
||||
// Sie faehrt gerade herunter. Jetzt zu starten hiesse, den neuen Prozess
|
||||
// gegen den alten laufen zu lassen, waehrend der noch aufraeumt.
|
||||
_logger.LogWarning(
|
||||
"Instanz {Name} fährt gerade herunter ({Offen} offen) — es wird nicht "
|
||||
+ "gestartet, solange sie das tut.",
|
||||
InstanceName, vorhanden.OpenSummary);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Condition != InstanceCondition.Running)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Instanz {Name} galt als {Zustand}, antwortet aber — sie wird übernommen "
|
||||
+ "statt gestartet.", InstanceName, Condition);
|
||||
}
|
||||
|
||||
InstanceName = vorhanden.InstanceName;
|
||||
Condition = InstanceCondition.Running;
|
||||
_silentChecks = 0;
|
||||
_silentSince = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ab hier antwortet niemand. Bevor gestartet wird, muss ein etwaiger alter
|
||||
// Prozess nachweislich weg sein - genau hier entsteht sonst der Schaden, gegen
|
||||
// den dieses ganze Vorhaben gebaut ist: zwei Prozesse auf einer Datenbank.
|
||||
if (!await EnsureDeadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
Raise(InstanceEventKind.StartFailed, _process?.Id, null,
|
||||
$"Instanz {InstanceName} wird nicht gestartet: Der bisherige Prozess "
|
||||
+ $"{_process?.Id} lebt noch und war nicht zu beenden. Zwei Prozesse auf "
|
||||
+ "derselben Datenbank wären schlimmer als eine Instanz, die aus bleibt.");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_stopRequested = false;
|
||||
_silentChecks = 0;
|
||||
_silentSince = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -207,6 +265,57 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass der bisherige Prozess wirklich weg ist — notfalls mit Gewalt.
|
||||
///
|
||||
/// <para><b>Warum das eine eigene Prüfung ist.</b> <c>Kill()</c> ist eine Bitte an das
|
||||
/// Betriebssystem, keine vollzogene Tatsache: Der Aufruf kehrt zurück, bevor der
|
||||
/// Prozess verschwunden ist, und bis dahin hält er die Datenbank und den Socket. Wer
|
||||
/// unmittelbar danach startet, hat für einen Moment zwei Prozesse auf einer
|
||||
/// <c>state.db</c> — und das ist kein Moment, sondern eine kaputte Datei.</para>
|
||||
///
|
||||
/// <para><b>Ohne Prozesshandle gilt der Socket.</b> Bei einer wiederaufgenommenen
|
||||
/// Instanz gibt es nichts zu greifen (siehe <see cref="ReattachAsync"/>). Dann ist
|
||||
/// die einzige verfügbare Auskunft, dass niemand mehr antwortet — und die muss
|
||||
/// reichen, denn mehr ist nicht zu haben.</para>
|
||||
/// </summary>
|
||||
/// <returns><c>true</c>, wenn nachweislich nichts mehr läuft.</returns>
|
||||
public async Task<bool> EnsureDeadAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_process is null)
|
||||
{
|
||||
// Kein Handle: Der Socket ist der einzige Zeuge. Wer hier gerufen wird, hat
|
||||
// ihn gerade gefragt - aber ein zweites Mal fragen kostet nichts und deckt
|
||||
// den Fall ab, dass zwischendurch jemand hochgekommen ist.
|
||||
return await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null;
|
||||
}
|
||||
|
||||
if (_process.HasExited)
|
||||
return true;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Prozess {Pid} von Instanz {Name} lebt noch — er wird beendet, bevor "
|
||||
+ "irgendetwas Neues startet.", _process.Id, InstanceName);
|
||||
|
||||
try { _process.Kill(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Prozess {Pid} liess sich nicht beenden.", _process.Id);
|
||||
}
|
||||
|
||||
// Kill ist eine Bitte. Auf den Vollzug wird gewartet.
|
||||
var deadline = _clock.GetUtcNow() + _options.SilentKillGrace;
|
||||
|
||||
while (_clock.GetUtcNow() < deadline)
|
||||
{
|
||||
if (_process.HasExited) return true;
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _process.HasExited;
|
||||
}
|
||||
|
||||
private async Task<InstanceIdentity?> WaitForAnswerAsync(CancellationToken ct)
|
||||
{
|
||||
var deadline = _clock.GetUtcNow() + _options.StartTimeout;
|
||||
@@ -324,6 +433,7 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
if (identity is not null)
|
||||
{
|
||||
_silentChecks = 0;
|
||||
_silentSince = null;
|
||||
|
||||
if (Condition == InstanceCondition.Silent)
|
||||
{
|
||||
@@ -336,6 +446,17 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
Condition = InstanceCondition.Running;
|
||||
}
|
||||
|
||||
if (identity.IsWindingDown)
|
||||
{
|
||||
// Kein Vorkommnis, sondern eine Auskunft: Sie tut, worum gebeten wurde.
|
||||
// Ins Protokoll gehoert es trotzdem - hinterher will jemand nachlesen
|
||||
// koennen, wie lange das Aufraeumen gedauert hat und woran.
|
||||
_logger.LogInformation(
|
||||
"Instanz {Name} fährt herunter, noch offen: {Offen}.",
|
||||
InstanceName, string.IsNullOrEmpty(identity.OpenSummary)
|
||||
? "nichts" : identity.OpenSummary);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,16 +485,101 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
_silentChecks++;
|
||||
|
||||
if (Condition == InstanceCondition.Silent)
|
||||
return; // Schon gemeldet. Ein Alarm je Vorfall, nicht je Durchgang.
|
||||
{
|
||||
// Der Vorfall laeuft schon. Kein zweiter Alarm - aber die Uhr laeuft.
|
||||
await EskalierenAsync(ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_silentChecks < _options.SilentChecksBeforeAlarm)
|
||||
return;
|
||||
|
||||
Condition = InstanceCondition.Silent;
|
||||
_silentSince = _clock.GetUtcNow();
|
||||
|
||||
Raise(InstanceEventKind.Unresponsive, _process?.Id, null,
|
||||
$"Instanz {InstanceName} läuft, antwortet aber seit {_silentChecks} Durchgängen "
|
||||
+ $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}.");
|
||||
+ $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}. Sie wird "
|
||||
+ $"gebeten zu enden; nach {_options.SilentKillGrace.TotalSeconds:0}s ohne "
|
||||
+ "Antwort wird der Prozess beendet.");
|
||||
|
||||
// Ein Versuch, mehr nicht. Wer eben auf demselben Socket nicht geantwortet hat,
|
||||
// wird die Bitte wahrscheinlich auch nicht hoeren - aber "wahrscheinlich nicht"
|
||||
// ist kein Grund, es zu lassen: Es kann ein einzelner haengender Endpunkt sein
|
||||
// und nicht der ganze Prozess. Gewartet wird auf die Uhr, nicht auf die Antwort.
|
||||
try
|
||||
{
|
||||
if (await _channel.RequestStopAsync(InstanceDirectory, ct).ConfigureAwait(false))
|
||||
{
|
||||
// Ausdruecklich ohne _stopRequested: Niemand hat gewollt, dass diese
|
||||
// Instanz endet. Sie antwortet nicht, und was daraufhin passiert, ist ein
|
||||
// Absturz und keine geordnete Beendigung - auch dann, wenn der letzte
|
||||
// Anstoss von hier kam. Nur so greift der Autostart, und nur so steht
|
||||
// hinterher im Protokoll, was wirklich vorgefallen ist.
|
||||
_logger.LogInformation(
|
||||
"Die Bitte zu enden ist bei der verstummten Instanz {Name} angekommen.",
|
||||
InstanceName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation(ex,
|
||||
"Die Bitte zu enden erreichte die verstummte Instanz {Name} nicht.", InstanceName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Frist für eine verstummte Instanz läuft ab: Der Prozess wird beendet.
|
||||
///
|
||||
/// <para>Erst hier, nicht beim ersten Schweigen. Zwischen „antwortet gerade nicht" und
|
||||
/// „ist hin" liegt ein Agentenlauf, der die Maschine auslastet — und ein Manager, der
|
||||
/// dabei abschießt, ist schlimmer als gar keiner.</para>
|
||||
/// </summary>
|
||||
private async Task EskalierenAsync(CancellationToken ct)
|
||||
{
|
||||
if (_silentSince is not { } seit) return;
|
||||
|
||||
var verstrichen = _clock.GetUtcNow() - seit;
|
||||
if (verstrichen < _options.SilentKillGrace) return;
|
||||
|
||||
if (_process is null)
|
||||
{
|
||||
// Nicht zu greifen (siehe ReattachAsync). Einmal melden, dann Ruhe - sonst
|
||||
// steht diese Zeile in jedem Durchgang bis in alle Ewigkeit.
|
||||
_silentSince = null;
|
||||
|
||||
_logger.LogError(
|
||||
"Instanz {Name} antwortet seit {Dauer:0}s nicht und ist von diesem Manager "
|
||||
+ "nicht zu beenden — es gibt keinen Prozess, den er greifen kann.",
|
||||
InstanceName, verstrichen.TotalSeconds);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instanz {Name} antwortet seit {Dauer:0}s nicht — der Prozess wird beendet.",
|
||||
InstanceName, verstrichen.TotalSeconds);
|
||||
|
||||
var pid = _process.Id;
|
||||
var tot = await EnsureDeadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
_silentSince = null;
|
||||
|
||||
if (!tot)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Prozess {Pid} von Instanz {Name} liess sich nicht beenden. Sie bleibt "
|
||||
+ "unter Aufsicht; gestartet wird nichts, solange er lebt.", pid, InstanceName);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Condition = InstanceCondition.Crashed;
|
||||
InstanceStateStore.Clear(InstanceDirectory);
|
||||
|
||||
Raise(InstanceEventKind.Crashed, pid, _process.ExitCode,
|
||||
$"Instanz {InstanceName} hat {verstrichen.TotalSeconds:0}s nicht geantwortet "
|
||||
+ "und wurde beendet.");
|
||||
}
|
||||
|
||||
/// <summary>Sieht von selbst nach, im eingestellten Takt.</summary>
|
||||
@@ -424,17 +630,53 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
+ "es gilt trotzdem die Frist.", InstanceName);
|
||||
}
|
||||
|
||||
var deadline = _clock.GetUtcNow() + _options.StopGrace;
|
||||
var begonnen = _clock.GetUtcNow();
|
||||
var hartesEnde = begonnen + _options.MaxDrainWait;
|
||||
var deadline = begonnen + _options.StopGrace;
|
||||
var aufraeumenGemeldet = false;
|
||||
|
||||
while (_clock.GetUtcNow() < deadline)
|
||||
{
|
||||
if (_process is { HasExited: true }
|
||||
|| await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null)
|
||||
if (_process is { HasExited: true })
|
||||
{
|
||||
Finish(_process?.ExitCode, gewaltsam: false);
|
||||
Finish(_process.ExitCode, gewaltsam: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
var puls = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
|
||||
|
||||
if (puls is null)
|
||||
{
|
||||
// Schweigen ist nur dann ein Beweis, wenn es keinen anderen Zeugen gibt.
|
||||
// Haelt dieser Manager ein Prozesshandle und sagt es "lebt noch", dann
|
||||
// lebt der Prozess - er antwortet bloss nicht mehr. Wer hier "beendet"
|
||||
// meldet und anschliessend neu startet, setzt einen zweiten Prozess auf
|
||||
// dieselbe state.db. Genau das war der Anlass dieses Vorhabens.
|
||||
if (_process is null)
|
||||
{
|
||||
Finish(null, gewaltsam: false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (puls.IsWindingDown)
|
||||
{
|
||||
// Sie raeumt auf - also tut sie genau das, worum gebeten wurde. Solange
|
||||
// das laeuft, wird die Frist nachgezogen, aber nicht ins Unendliche:
|
||||
// MaxDrainWait ist die Grenze, hinter der auch "ich raeume noch auf"
|
||||
// nur noch eine andere Art zu haengen ist.
|
||||
var neu = _clock.GetUtcNow() + _options.StopGrace;
|
||||
deadline = neu < hartesEnde ? neu : hartesEnde;
|
||||
|
||||
if (!aufraeumenGemeldet)
|
||||
{
|
||||
aufraeumenGemeldet = true;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Instanz {Name} räumt auf ({Offen}) — es wird bis zu {Grenze:0}s gewartet.",
|
||||
InstanceName, puls.OpenSummary, _options.MaxDrainWait.TotalSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -450,10 +692,18 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Instanz {Name} hat binnen {Frist}s nicht geendet — sie wird beendet.",
|
||||
InstanceName, _options.StopGrace.TotalSeconds);
|
||||
"Instanz {Name} hat binnen {Frist:0}s nicht geendet — sie wird beendet.",
|
||||
InstanceName, (_clock.GetUtcNow() - begonnen).TotalSeconds);
|
||||
|
||||
if (!await EnsureDeadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Prozess {Pid} von Instanz {Name} liess sich nicht beenden.",
|
||||
_process.Id, InstanceName);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_process.Kill();
|
||||
Finish(_process.ExitCode, gewaltsam: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,10 @@ public sealed class ManagerCore : IAsyncDisposable
|
||||
if (_reporter is not null)
|
||||
_ = _reporter.ReportAsync(ereignis);
|
||||
|
||||
// Nur beim Absturz, und das schliesst die verstummte Instanz ein: Die
|
||||
// Aufsicht beendet sie nach ihrer Frist und meldet das als Absturz - denn
|
||||
// gewollt hat es niemand. Ein zweiter Einstieg bei "Unresponsive" waere ein
|
||||
// zweiter Neustart fuer denselben Vorfall.
|
||||
if (ereignis.Kind == InstanceEventKind.Crashed)
|
||||
_ = RestartAfterCrashAsync(ereignis);
|
||||
};
|
||||
|
||||
@@ -130,6 +130,22 @@ public sealed class ManagerSettings
|
||||
[JsonPropertyName("restartWindowMinutes")]
|
||||
public int RestartWindowMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Wie lange eine verstummte Instanz nach der angeordneten Beendigung noch bekommt,
|
||||
/// bevor der Prozess hart beendet wird.
|
||||
/// </summary>
|
||||
[JsonPropertyName("silentKillGraceSeconds")]
|
||||
public int SilentKillGraceSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Die Obergrenze beim Anhalten, solange die Instanz meldet, dass sie aufräumt.
|
||||
///
|
||||
/// <para>Muss über der Frist liegen, die sich die Instanz selbst gibt — sonst schießt
|
||||
/// der Manager in das Aufräumen hinein, um das er gebeten hat.</para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxDrainWaitSeconds")]
|
||||
public int MaxDrainWaitSeconds { get; set; } = 300;
|
||||
|
||||
/// <summary>Die geprüfte Neustartregel.</summary>
|
||||
public RestartPolicy ToRestartPolicy() => new(
|
||||
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
|
||||
@@ -146,7 +162,9 @@ public sealed class ManagerSettings
|
||||
ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)),
|
||||
StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)),
|
||||
SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100),
|
||||
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)));
|
||||
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)),
|
||||
SilentKillGrace: TimeSpan.FromSeconds(Math.Clamp(SilentKillGraceSeconds, 1, 600)),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(Math.Clamp(MaxDrainWaitSeconds, 1, 3600)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -97,7 +97,24 @@ internal static class Program
|
||||
return ExitNoWebUi;
|
||||
}
|
||||
|
||||
Console.WriteLine(web.Url);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=================================================");
|
||||
Console.WriteLine(" ClawdDotNet Instanzmanager bereit");
|
||||
Console.WriteLine($" Weboberfläche: {web.Url}");
|
||||
if (!string.IsNullOrWhiteSpace(settings.Settings.WebUiToken))
|
||||
{
|
||||
Console.WriteLine($" Token: {settings.Settings.WebUiToken}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(" Token: (kein Token erforderlich)");
|
||||
}
|
||||
Console.WriteLine("=================================================");
|
||||
Console.WriteLine();
|
||||
// Das Token steht auf der Konsole, aber ausdruecklich NICHT im Protokoll: Es ist
|
||||
// der Generalschluessel zu allen Instanzen, und die Protokolldatei ist genau die,
|
||||
// die man an einen Fehlerbericht haengt. Vor der Konsole steht, wer den Manager
|
||||
// gestartet hat; die Datei liest hinterher irgendwer.
|
||||
logger.LogInformation("Instanzmanager bereit. Weboberfläche: {Url}", web.Url);
|
||||
|
||||
await WaitForShutdownAsync(logger);
|
||||
|
||||
@@ -175,3 +175,14 @@ public sealed record UsageDto(
|
||||
|
||||
/// <summary>Eine Protokollzeile.</summary>
|
||||
public sealed record LogLineDto(string Module, string Level, string Text);
|
||||
|
||||
/// <summary>Eine interne Kommunikation zwischen Agenten (A2A, #208).</summary>
|
||||
public sealed record AgentCommunicationDto(
|
||||
string TargetAgentId,
|
||||
string TargetAgentName,
|
||||
string? SourceAgentId,
|
||||
string? SourceAgentName,
|
||||
string Role,
|
||||
string Content,
|
||||
DateTime Timestamp);
|
||||
|
||||
|
||||
@@ -101,6 +101,58 @@ internal static class ApiEndpoints
|
||||
.ToList();
|
||||
});
|
||||
|
||||
// ─── Interne Agenten-Kommunikation (A2A, #208) ───
|
||||
agents.MapGet("/communications", (AppHost host) =>
|
||||
{
|
||||
if (host.Engine is not { } engine)
|
||||
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
|
||||
|
||||
var allAgents = host.AgentAdmin.All;
|
||||
var list = new List<AgentCommunicationDto>();
|
||||
|
||||
foreach (var agent in allAgents)
|
||||
{
|
||||
var history = engine.GetChatHistory(agent.AgentId);
|
||||
foreach (var entry in history.Where(e => string.Equals(e.Source, Core.Engine.ChatSource.AgentComm, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string? senderId = null;
|
||||
string? senderName = null;
|
||||
var content = entry.Content;
|
||||
|
||||
if (entry.Role == "user" && content.StartsWith("[Nachricht von Agent \""))
|
||||
{
|
||||
var endQuote = content.IndexOf('"', 22);
|
||||
if (endQuote > 22)
|
||||
{
|
||||
senderName = content[22..endQuote];
|
||||
var openParen = content.IndexOf('(', endQuote);
|
||||
var closeParen = content.IndexOf(')', openParen);
|
||||
if (openParen > 0 && closeParen > openParen)
|
||||
{
|
||||
senderId = content[(openParen + 1)..closeParen];
|
||||
}
|
||||
}
|
||||
var doubleNl = content.IndexOf("\n\n", StringComparison.Ordinal);
|
||||
if (doubleNl > 0)
|
||||
{
|
||||
content = content[(doubleNl + 2)..];
|
||||
}
|
||||
}
|
||||
|
||||
list.Add(new AgentCommunicationDto(
|
||||
TargetAgentId: agent.AgentId,
|
||||
TargetAgentName: agent.DisplayName,
|
||||
SourceAgentId: senderId,
|
||||
SourceAgentName: senderName,
|
||||
Role: entry.Role,
|
||||
Content: content,
|
||||
Timestamp: entry.Timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(list.OrderBy(c => c.Timestamp).ToList());
|
||||
});
|
||||
|
||||
agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
|
||||
host.AgentAdmin.Find(agentId) is { } agent
|
||||
? Results.Ok(agent.ToDto(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using ClawdDotNet.Web.Security;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -5,6 +7,32 @@ using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace ClawdDotNet.Web.Endpoints;
|
||||
|
||||
/// <summary>Was noch offen ist, für die Puls-Auskunft.</summary>
|
||||
public sealed record OffeneArbeitDto(string Name, int Anzahl);
|
||||
|
||||
/// <summary>
|
||||
/// Der Puls einer Instanz: Wer bin ich, lebe ich, und arbeite ich noch an etwas?
|
||||
///
|
||||
/// <para><b>Warum mehr als die Identität.</b> Bis hierher fragte der Manager
|
||||
/// <c>/api/info</c> und wusste danach nur, dass überhaupt jemand geantwortet hat. Das
|
||||
/// reicht, um „läuft" von „tot" zu unterscheiden, aber nicht, um zwei Fälle
|
||||
/// auseinanderzuhalten, die völlig verschieden zu behandeln sind: eine Instanz, die
|
||||
/// hängt, und eine, die gerade geordnet aufräumt. Die erste gehört nach einer Frist
|
||||
/// abgeschossen, die zweite in Ruhe gelassen.</para>
|
||||
/// </summary>
|
||||
/// <param name="InstanceId">Die Kennung — der eigentliche Beweis, wer da antwortet.</param>
|
||||
/// <param name="Phase">"Running", "Draining" oder "Done".</param>
|
||||
/// <param name="OffeneArbeiten">Was gerade noch läuft. Leer heißt: nichts.</param>
|
||||
/// <param name="RestsekundenFrist">Wie lange das Auslaufen noch dauern darf.</param>
|
||||
public sealed record PulsDto(
|
||||
string InstanceId,
|
||||
string InstanceName,
|
||||
string AppVersion,
|
||||
string Phase,
|
||||
IReadOnlyList<OffeneArbeitDto> OffeneArbeiten,
|
||||
int OffenGesamt,
|
||||
double RestsekundenFrist);
|
||||
|
||||
/// <summary>
|
||||
/// Was der Instanzmanager diesem Prozess sagen kann — und was der Prozess damit macht.
|
||||
///
|
||||
@@ -64,6 +92,25 @@ internal static class ControlEndpoints
|
||||
{
|
||||
var control = app.MapGroup("/api/instanz");
|
||||
|
||||
// Der Puls. Nur ueber den Socket - was hier steht, geht ausschliesslich den
|
||||
// Instanzmanager etwas an, und er ist der Einzige, der diesen Weg hat.
|
||||
control.MapGet("/puls", (HttpContext context, AppHost host) =>
|
||||
{
|
||||
if (!LocalTransport.Applies(context))
|
||||
return Results.NotFound();
|
||||
|
||||
var stand = host.Shutdown.Snapshot();
|
||||
|
||||
return Results.Ok(new PulsDto(
|
||||
host.Instance.InstanceId,
|
||||
host.Instance.InstanceName,
|
||||
AppHost.AppVersion,
|
||||
stand.Phase.ToString(),
|
||||
[.. stand.Open.Select(o => new OffeneArbeitDto(o.Name, o.Count))],
|
||||
stand.TotalOpen,
|
||||
Math.Round(stand.Remaining.TotalSeconds, 1)));
|
||||
});
|
||||
|
||||
control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) =>
|
||||
{
|
||||
if (!LocalTransport.Applies(context))
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Das geordnete Ende einer Instanz (#528).
|
||||
///
|
||||
/// <para><b>Worum es geht.</b> Bis hierher war jedes Beenden ein Abschneiden. Eine
|
||||
/// Aufgabe, die mitten in der Ausführung abgebrochen wird, ist bereits beansprucht und
|
||||
/// noch nicht abgeschlossen — sie steht danach auf <c>running</c> und bleibt dort bis zum
|
||||
/// Ablauf ihres Lease. Ein Agentenlauf verliert seine Antwort ganz.</para>
|
||||
/// </summary>
|
||||
public sealed class ShutdownCoordinatorTests
|
||||
{
|
||||
private static ShutdownCoordinator Koordinator(TimeSpan? frist = null)
|
||||
=> new(NullLogger.Instance, frist ?? TimeSpan.FromSeconds(5));
|
||||
|
||||
/// <summary>
|
||||
/// Die Reihenfolge ist der ganze Trick: erst schließen, dann warten.
|
||||
///
|
||||
/// <para>Andersherum wartet man ewig — die Quelle nähme im nächsten Takt die nächste
|
||||
/// Arbeit an, und die Zahl der offenen ginge nie auf null.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Erst_wird_die_Tuer_geschlossen_dann_gewartet()
|
||||
{
|
||||
var geschlossen = false;
|
||||
var offenBeimSchliessen = -1;
|
||||
var offen = 3;
|
||||
|
||||
var koordinator = Koordinator();
|
||||
|
||||
koordinator.Register(new WorkSource(
|
||||
"Aufgaben",
|
||||
() => offen,
|
||||
() =>
|
||||
{
|
||||
geschlossen = true;
|
||||
offenBeimSchliessen = offen;
|
||||
}));
|
||||
|
||||
// Die Arbeiten laufen im Hintergrund aus.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (offen > 0)
|
||||
{
|
||||
await Task.Delay(30);
|
||||
offen--;
|
||||
}
|
||||
});
|
||||
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
geschlossen.ShouldBeTrue();
|
||||
offenBeimSchliessen.ShouldBe(3, "Geschlossen wird, bevor gewartet wird.");
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
koordinator.Snapshot().TotalOpen.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ohne offene Arbeit endet es sofort — kein Warten aus Prinzip.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ohne_offene_Arbeit_endet_es_sofort()
|
||||
{
|
||||
var koordinator = Koordinator(TimeSpan.FromMinutes(10));
|
||||
koordinator.Register(new WorkSource("Aufgaben", () => 0, () => { }));
|
||||
|
||||
var begonnen = DateTimeOffset.UtcNow;
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
(DateTimeOffset.UtcNow - begonnen).ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Frist ist eine Zusage, keine Hoffnung.
|
||||
///
|
||||
/// <para>Sonst hinge die Instanz an einem Agentenlauf, der auf ein Modell wartet, das
|
||||
/// nicht antwortet — und niemand käme je zum Ende.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Nach_der_Frist_endet_es_auch_mit_offener_Arbeit()
|
||||
{
|
||||
var koordinator = Koordinator(TimeSpan.FromMilliseconds(300));
|
||||
koordinator.Register(new WorkSource("Agentenläufe", () => 2, () => { }));
|
||||
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Während des Auslaufens sagt der Puls, was noch offen ist — daran erkennt der
|
||||
/// Instanzmanager, dass hier aufgeräumt und nicht gehangen wird.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Waehrend_des_Auslaufens_meldet_der_Stand_was_offen_ist()
|
||||
{
|
||||
var offen = 2;
|
||||
var koordinator = Koordinator(TimeSpan.FromSeconds(5));
|
||||
koordinator.Register(new WorkSource("Agentenläufe", () => offen, () => { }));
|
||||
|
||||
koordinator.Snapshot().Phase.ShouldBe(ShutdownPhase.Running);
|
||||
|
||||
var lauf = koordinator.BeginAsync();
|
||||
|
||||
// Solange etwas offen ist, steht der Stand auf Draining und nennt die Zahl.
|
||||
var stand = koordinator.Snapshot();
|
||||
stand.Phase.ShouldBe(ShutdownPhase.Draining);
|
||||
stand.TotalOpen.ShouldBe(2);
|
||||
stand.Open.Single().Name.ShouldBe("Agentenläufe");
|
||||
|
||||
offen = 0;
|
||||
await lauf;
|
||||
|
||||
koordinator.Snapshot().Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zwei Aufrufer, ein Vorgang.
|
||||
///
|
||||
/// <para>Beide Wege gibt es wirklich: Der Manager bittet über den Socket, und
|
||||
/// gleichzeitig kann jemand am Fenster auf das Kreuz klicken. Zweimal auszulaufen
|
||||
/// hieße, die Tür zweimal zu schließen und zweimal zu warten.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_zweiter_Aufruf_wartet_auf_denselben_Vorgang()
|
||||
{
|
||||
var schliessungen = 0;
|
||||
var koordinator = Koordinator(TimeSpan.FromSeconds(5));
|
||||
koordinator.Register(new WorkSource("Aufgaben", () => 0, () => schliessungen++));
|
||||
|
||||
await Task.WhenAll(koordinator.BeginAsync(), koordinator.BeginAsync());
|
||||
|
||||
schliessungen.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Quelle, die beim Zählen stolpert, darf das Ende nicht aufhalten.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_stolpernde_Quelle_haelt_das_Ende_nicht_auf()
|
||||
{
|
||||
var koordinator = Koordinator(TimeSpan.FromSeconds(30));
|
||||
|
||||
koordinator.Register(new WorkSource(
|
||||
"Kaputt",
|
||||
() => throw new InvalidOperationException("zählt nicht"),
|
||||
() => throw new InvalidOperationException("schließt nicht")));
|
||||
|
||||
var begonnen = DateTimeOffset.UtcNow;
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
(DateTimeOffset.UtcNow - begonnen).ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,9 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public AutostartTests()
|
||||
{
|
||||
@@ -175,6 +177,87 @@ public sealed class AutostartTests : IAsyncDisposable
|
||||
_launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine verstummte Instanz wird beendet und neu gestartet — aber erst, nachdem der
|
||||
/// alte Prozess nachweislich weg ist.
|
||||
///
|
||||
/// <para><b>Der Fall wird hier wirklich hergestellt und nicht behauptet.</b> Der
|
||||
/// Prozess lebt weiter, nur der Socket schweigt — genau die Lage aus #529. Ein Test,
|
||||
/// der stattdessen das Ereignis von Hand baut und die Neustartmethode aufruft, prüft
|
||||
/// die eine Hälfte, die ohnehin funktioniert, und lässt die gefährliche aus: dass vor
|
||||
/// dem Start niemand mehr auf der Datenbank sitzt.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_verstummte_Instanz_wird_erst_beendet_und_dann_neu_gestartet()
|
||||
{
|
||||
var pfad = Instanz("Alpha", autostart: true);
|
||||
|
||||
await using var manager = Manager();
|
||||
await manager.StartAsync();
|
||||
|
||||
var alt = _launcher.Last.ShouldNotBeNull();
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
|
||||
// Ab jetzt haengt sie: Der Prozess lebt, der Socket antwortet nicht mehr.
|
||||
_kanal.Verstummen(pfad);
|
||||
|
||||
var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
|
||||
|
||||
// Drei Durchgaenge bis zum Alarm - vorher wird nichts unternommen.
|
||||
for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
|
||||
alt.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen.");
|
||||
|
||||
// Die Frist laeuft ab (SilentKillGrace: 300ms).
|
||||
await Task.Delay(400);
|
||||
await aufsicht.CheckAsync();
|
||||
|
||||
alt.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet.");
|
||||
|
||||
// Der Absturzweg startet neu - und der neue Prozess ist ein anderer.
|
||||
await Warte(() => _launcher.Starts >= 2);
|
||||
|
||||
_launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(alt.Id);
|
||||
alt.HasExited.ShouldBeTrue("Der alte Prozess darf beim Start nicht mehr leben.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Instanz, die aufräumt, wird nicht abgeschossen — auch wenn es dauert.
|
||||
///
|
||||
/// <para>Sie antwortet ja; sie tut genau das, worum gebeten wurde. Der Unterschied
|
||||
/// zwischen „hängt" und „räumt auf" ist der ganze Grund, warum der Puls mehr sagt als
|
||||
/// „jemand hat geantwortet".</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wer_aufraeumt_gilt_nicht_als_verstummt()
|
||||
{
|
||||
var pfad = Instanz("Alpha", autostart: true);
|
||||
|
||||
await using var manager = Manager();
|
||||
await manager.StartAsync();
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity(
|
||||
"i-Alpha", "Alpha", "0.1.2",
|
||||
ShutdownPhase.Draining, OpenWork: 2, OpenSummary: "Agentenläufe: 2");
|
||||
|
||||
var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull();
|
||||
|
||||
for (var i = 0; i < 5; i++) await aufsicht.CheckAsync();
|
||||
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
prozess.WasKilled.ShouldBeFalse();
|
||||
_launcher.Starts.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static async Task Warte(Func<bool> bedingung)
|
||||
{
|
||||
for (var i = 0; i < 100 && !bedingung(); i++)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nach der Grenze setzt der Manager aus.
|
||||
///
|
||||
@@ -341,7 +424,9 @@ public sealed class StaleStateTests : IAsyncDisposable
|
||||
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
|
||||
TimeSpan.FromMilliseconds(600),
|
||||
3,
|
||||
TimeSpan.FromMilliseconds(300)));
|
||||
TimeSpan.FromMilliseconds(300),
|
||||
TimeSpan.FromMilliseconds(300),
|
||||
TimeSpan.FromSeconds(2)));
|
||||
|
||||
(await aufsicht.StartAsync()).ShouldBeTrue();
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Running);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests für Sicherung und Wiederherstellung im Manager (#530, Stufe 6).
|
||||
/// </summary>
|
||||
public sealed class BackupManagementTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
public BackupManagementTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackupDirectory_wird_standardmaessig_unter_Backups_aufgeloest()
|
||||
{
|
||||
var instancePath = Path.Combine(_root, "TestInstance");
|
||||
Directory.CreateDirectory(instancePath);
|
||||
|
||||
var config = new InstanceConfig { WorkingDirectory = instancePath };
|
||||
var backupDir = InstanceLayout.ResolveBackupDirectory(config, instancePath);
|
||||
|
||||
backupDir.ShouldBe(Path.Combine(instancePath, "Backups"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backup_kann_erstellt_geprueft_und_wiederhergestellt_werden()
|
||||
{
|
||||
var instancePath = Path.Combine(_root, "InstanceAlpha");
|
||||
Directory.CreateDirectory(instancePath);
|
||||
|
||||
var settingsPath = Path.Combine(instancePath, "InstanceSettings.json");
|
||||
await File.WriteAllTextAsync(settingsPath, """
|
||||
{
|
||||
"instanceId": "alpha-123",
|
||||
"instanceName": "Alpha",
|
||||
"workingDirectory": ""
|
||||
}
|
||||
""");
|
||||
|
||||
var testDataPath = Path.Combine(instancePath, "data.txt");
|
||||
await File.WriteAllTextAsync(testDataPath, "Wichtige Testdaten");
|
||||
|
||||
var backupDir = InstanceLayout.ResolveBackupDirectory(
|
||||
new InstanceConfig { WorkingDirectory = instancePath }, instancePath);
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
var zipPath = Path.Combine(backupDir, "backup_Alpha_test.zip");
|
||||
|
||||
var service = new BackupService();
|
||||
var backupResult = await service.CreateAsync(instancePath, zipPath, new BackupOptions
|
||||
{
|
||||
Secrets = SecretMode.Exclude,
|
||||
IncludeChatHistory = true,
|
||||
IncludeLogs = false
|
||||
});
|
||||
|
||||
File.Exists(zipPath).ShouldBeTrue();
|
||||
backupResult.SizeBytes.ShouldBeGreaterThan(0);
|
||||
|
||||
// Prüfen
|
||||
var manifest = await service.InspectAsync(zipPath);
|
||||
manifest.InstanceId.ShouldBe("alpha-123");
|
||||
manifest.InstanceName.ShouldBe("Alpha");
|
||||
manifest.Files.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
// Datei im Ziel modifizieren und Restore prüfen
|
||||
await File.WriteAllTextAsync(testDataPath, "Überschriebene Testdaten");
|
||||
|
||||
var restoreResult = await service.RestoreAsync(
|
||||
zipPath,
|
||||
instancePath,
|
||||
new RestoreOptions { Overwrite = true });
|
||||
|
||||
restoreResult.Written.ShouldContain("data.txt");
|
||||
var restoredContent = await File.ReadAllTextAsync(testDataPath);
|
||||
restoredContent.ShouldBe("Wichtige Testdaten");
|
||||
|
||||
// Löschen
|
||||
File.Delete(zipPath);
|
||||
File.Exists(zipPath).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,20 @@ internal sealed class FakeLauncher : IInstanceLauncher
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
|
||||
///
|
||||
/// <para>Zählt nicht als Start — genau darin liegt der Unterschied, den mehrere Tests
|
||||
/// prüfen: Was läuft, wird übernommen und nicht ein zweites Mal hochgefahren.</para>
|
||||
/// </summary>
|
||||
public FakeProcess Fremdstart(string instanceDirectory, int processId)
|
||||
{
|
||||
var prozess = new FakeProcess(processId, Now);
|
||||
Running[processId] = prozess;
|
||||
ByDirectory[Path.GetFullPath(instanceDirectory)] = prozess;
|
||||
return prozess;
|
||||
}
|
||||
|
||||
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
|
||||
{
|
||||
if (!Running.TryGetValue(processId, out var process))
|
||||
@@ -120,6 +134,20 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
/// </summary>
|
||||
public FakeLauncher? Launcher { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
|
||||
///
|
||||
/// <para>Der Fall aus #529 — und der einzige, in dem die Aufsicht von sich aus zur
|
||||
/// Gewalt greift. Ohne diesen Schalter lässt er sich nicht nachstellen: Die Kopplung
|
||||
/// an <see cref="Launcher"/> bindet die Antwort sonst genau an das, was hier
|
||||
/// auseinanderfallen soll.</para>
|
||||
/// </summary>
|
||||
public HashSet<string> Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.</summary>
|
||||
public void Verstummen(string instanceDirectory)
|
||||
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
|
||||
@@ -132,6 +160,9 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
{
|
||||
Calls++;
|
||||
|
||||
if (Verstummt.Contains(Path.GetFullPath(instanceDirectory)))
|
||||
return Task.FromResult<InstanceIdentity?>(null);
|
||||
|
||||
if (Launcher is not null && !Launcher.IsRunning(instanceDirectory))
|
||||
return Task.FromResult<InstanceIdentity?>(null);
|
||||
|
||||
|
||||
@@ -27,10 +27,17 @@ public sealed class InstanceRegistryTests : IAsyncDisposable
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public InstanceRegistryTests()
|
||||
=> _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
{
|
||||
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb (siehe FakeChannel.Launcher).
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
private InstanceRegistry Register()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
@@ -38,9 +39,19 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public InstanceSupervisorTests() => Directory.CreateDirectory(_dir);
|
||||
public InstanceSupervisorTests()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb. Eine vorab hinterlegte Antwort hiesse sonst "laeuft schon", und die
|
||||
// Aufsicht wuerde uebernehmen statt zu starten.
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -252,6 +263,72 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt.
|
||||
///
|
||||
/// <para><b>Hier lag der gefährlichste Fehlschluss.</b> Die Warteschleife brach ab,
|
||||
/// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab <c>true</c>
|
||||
/// 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 <c>state.db</c>. Genau
|
||||
/// der Schaden, gegen den dieses Vorhaben gebaut ist.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
// Sie haengt: Der Socket schweigt, der Prozess lebt.
|
||||
_kanal.Verstummen(_dir);
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
|
||||
prozess.WasKilled.ShouldBeTrue(
|
||||
"Wer schweigt und lebt, muss beendet werden - nicht fuer beendet erklaert.");
|
||||
|
||||
prozess.HasExited.ShouldBeTrue();
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen.
|
||||
///
|
||||
/// <para>Die Instanz gibt sich selbst eine Frist zum Auslaufen. Wäre die des Managers
|
||||
/// kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten
|
||||
/// hat.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
// Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (300ms), aber
|
||||
// kuerzer als MaxDrainWait (2s).
|
||||
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(700);
|
||||
_kanal.Answer = null;
|
||||
prozess.Exit(0);
|
||||
});
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
|
||||
prozess.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen.");
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann
|
||||
/// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden.
|
||||
@@ -265,6 +342,10 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
_kanal.Answer = Test;
|
||||
_kanal.StopRequestArrives = false;
|
||||
|
||||
// Sie lief schon, bevor dieser Manager kam - und ohne Zustandsdatei ist ihr
|
||||
// Prozess fuer ihn nicht zu greifen.
|
||||
_launcher.Fremdstart(_dir, 4711);
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
|
||||
aufsicht.ProcessId.ShouldBeNull();
|
||||
@@ -356,6 +437,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
// Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder
|
||||
// nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie.
|
||||
_kanal.Answer = Test;
|
||||
_launcher.Fremdstart(_dir, 4711);
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.ReattachAsync()).ShouldBeTrue();
|
||||
|
||||
@@ -41,12 +41,17 @@ public sealed class ManagerCoreTests : IAsyncDisposable
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public ManagerCoreTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb (siehe FakeChannel.Launcher).
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
private ManagerCore Manager()
|
||||
@@ -239,12 +244,17 @@ public sealed class InstanceFocusTests : IAsyncDisposable
|
||||
ProbeInterval: TimeSpan.FromMilliseconds(50),
|
||||
StartTimeout: TimeSpan.FromMilliseconds(600),
|
||||
SilentChecksBeforeAlarm: 3,
|
||||
StopGrace: TimeSpan.FromMilliseconds(300));
|
||||
StopGrace: TimeSpan.FromMilliseconds(300),
|
||||
SilentKillGrace: TimeSpan.FromMilliseconds(300),
|
||||
MaxDrainWait: TimeSpan.FromSeconds(2));
|
||||
|
||||
public InstanceFocusTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb (siehe FakeChannel.Launcher).
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
private InstanceRegistry Register() => new(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ClawdDotNet.Web.Contracts;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests für die interne Agenten-Kommunikation A2A (#208).
|
||||
/// </summary>
|
||||
public sealed class AgentCommunicationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AgentCommunicationDto_speichert_Felder_korrekt()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var dto = new AgentCommunicationDto(
|
||||
TargetAgentId: "editor-1",
|
||||
TargetAgentName: "Editor",
|
||||
SourceAgentId: "scout-1",
|
||||
SourceAgentName: "Scout",
|
||||
Role: "user",
|
||||
Content: "Bitte bearbeite Datei X",
|
||||
Timestamp: now);
|
||||
|
||||
dto.TargetAgentId.ShouldBe("editor-1");
|
||||
dto.TargetAgentName.ShouldBe("Editor");
|
||||
dto.SourceAgentId.ShouldBe("scout-1");
|
||||
dto.SourceAgentName.ShouldBe("Scout");
|
||||
dto.Role.ShouldBe("user");
|
||||
dto.Content.ShouldBe("Bitte bearbeite Datei X");
|
||||
dto.Timestamp.ShouldBe(now);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user