diff --git a/docs/InstanceSetupGuide.md b/docs/InstanceSetupGuide.md index a44e901..440d000 100644 --- a/docs/InstanceSetupGuide.md +++ b/docs/InstanceSetupGuide.md @@ -68,7 +68,11 @@ Oder manuell die Ordnerstruktur erzeugen. Die `InstanceSettings.json` hat dieses "instanceName": "MeinProjekt", "openRouterApiKey": "", "workingDirectory": "", - "logDirectory": "./Logs", + "logDirectory": "", + "backupDirectory": "", + "autoBackupEnabled": false, + "autoBackupTime": "03:00", + "backupKeepCount": 14, "webServerPort": 7001, "agents": [], "services": [] @@ -77,6 +81,30 @@ Oder manuell die Ordnerstruktur erzeugen. Die `InstanceSettings.json` hat dieses **Wichtig:** Der `openRouterApiKey` muss gesetzt werden, bevor Agenten funktionieren. +### Protokoll und Sicherungen gehören der Instanz + +`logDirectory` und `backupDirectory` leer zu lassen ist der Normalfall und nicht die +Ausnahme: Leer heißt `/Logs` beziehungsweise +`/Backups`. + +Beides stand bis #528 (Stufe 1) in den anwendungsweiten Einstellungen und galt damit für +jede Instanz — in `Startup.log` standen abwechselnd Zeilen zweier Instanzen, ohne +Kennzeichnung, welche gerade spricht. + +Ein eingetragener Wert geht vor und zählt, wenn er relativ ist, **gegen das +Instanzverzeichnis** — nicht gegen das Arbeitsverzeichnis des Prozesses. Ein +systemd-Dienst ohne `WorkingDirectory=` startet in `/`, und `"./Logs"` zeigte dort auf +`/Logs`. + +Zwei Instanzen sollten sich kein Sicherungsverzeichnis teilen. Die Aufbewahrung +(`backupKeepCount`) räumt dort nach dem Präfix `backup__` auf, und ein +Präfix ist keine Grenze: Das Archiv der Instanz `Test_2` fängt mit `backup_Test_` an und +wird von `Test` mitgezählt. + +Wer die fünf Schlüssel noch in seiner alten `Settings.json` stehen hat, findet beim +nächsten Start eine Warnung im Protokoll; übernommen werden sie **nicht** — ein Pfad für +alle Instanzen wäre genau der Zustand, den die Trennung beseitigt. + ### 3.2 Agenten anlegen Pro Agent wird ein Ordner `Agent-{Name}` unter `Agents/` erstellt. diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md new file mode 100644 index 0000000..322cb55 --- /dev/null +++ b/docs/Instanzmanager-Konzept.md @@ -0,0 +1,359 @@ +# Instanzmanager — Befund und Konzept + +Warum mehrere Instanzen heute nicht unabhängig voneinander laufen, und wie es +stattdessen aussehen soll. + +Stand: 2026-09-08. Anlass: Beim Instanzwechsel hat sich die Anwendung aufgehängt +und wurde von Windows beendet. Vorhaben: **#528**, zugehöriger Fehler: **#529**. + +Die Entscheidungen in Abschnitt 2 sind getroffen. Was danach kommt, folgt aus +ihnen. + +--- + +## 1 — Der Befund + +### 1.1 Was passiert ist + +Am 2026-09-08 wurde aus der laufenden Anwendung heraus eine zweite Instanz +angelegt und auf sie gewechselt: + +| Zeit | Quelle | Eintrag | +|---|---|---| +| 17:26:48 | Dateisystem | `Instance-Test2` angelegt | +| 17:26:50 | `Lifetime.log` | `Application is shutting down...` | +| 17:26:55 | `Diagnostics.log` | Zwei Ereignisströme beendet, 33 s und 776 s Laufzeit | +| 17:27:45 | Windows-Ereignisprotokoll | `AppHangB1` — „hat aufgehört mit Windows zu interagieren und wurde geschlossen" | + +**Die Anwendung wurde nicht beendet, sie wurde erschlagen.** Bemerkenswert ist, +was dabei *nicht* geschah: Zu diesem Prozess steht **keine einzige +Protokollzeile** in den Instanzprotokollen — der Schreibpuffer wurde nie geleert. +Keine Fehlermeldung, kein Bugtracker-Eintrag, kein Watchdog-Alarm. Der Vorfall +ist ausschließlich rekonstruierbar, weil das Betriebssystem ihn festgehalten hat. + +**Das ist der Kern:** Meldeweg, Watchdog und Fehler-Stream leben *in* dem +Prozess, über dessen Zustand sie berichten sollen. Fällt er hart, fällt der +Meldeweg mit — genau in dem Fall, der gemeldet werden müsste. + +### 1.2 Was heute geteilt ist, obwohl es getrennt sein müsste + +| Geteilt | Wirkung | Beleg | +|---|---|---| +| **Ein Protokollverzeichnis** | Alle Instanzen schreiben in dieselben Dateien, ohne Kennzeichnung, welche spricht | `Startup.log` enthält abwechselnd Zeilen von `Test` und `Test2` | +| **Eine `Settings.json`** | Vier Stellen schreiben sie im laufenden Betrieb. Zwei Instanzen: die letzte gewinnt, stillschweigend | `LicenseGate`, `MainWindowViewModel`, `AgentsPageViewModel`, `SettingsPageViewModel` | +| **Ein Sicherungsverzeichnis** | Ein Ordner für alle Archive. Die Aufbewahrung räumt dort nach dem Präfix `backup__` auf — und ein Präfix ist keine Grenze: `backup_Test_2_….zip` fängt mit `backup_Test_` an, `Test` wirft es also weg | `AppSettings.BackupDirectory` | +| **Ein Sicherungs-Schalter** | Wer die tägliche Sicherung für eine Instanz einschaltet, schaltet sie für alle ein | `AppSettings.AutoBackupEnabled` | +| **Ein Web-Port als Vorgabe** | Jede Instanz bringt `webServerPort: 7001` mit; die zweite kann nicht binden | `InstanceConfig.WebServerPort` | + +### 1.3 Der Wechsel kann gar nicht anders enden + +Der Instanzmanager ist heute ein Fenster *innerhalb* einer laufenden Instanz. +Wechseln heißt deshalb: Der Prozess fährt sich selbst herunter, startet sich mit +`--instance` neu und beendet sich. Wer die Instanz wechselt, beendet zwangsläufig +die, aus der heraus er es tut. + +--- + +## 2 — Die Entscheidungen + +Getroffen am 2026-09-08. + +### E-M1 — Der Weg zwischen Manager und Instanz + +**Entschieden: HTTP über einen Unix-Domain-Socket je Instanz, unter Windows über +eine Named Pipe.** Der Socket liegt im Instanzverzeichnis +(`/manager.sock`), Rechte 0600. + +Warum nicht TCP: Ports müssten verwaltet, vergeben und gegen Kollisionen +gesichert werden — genau das Problem, an dem die heutige Vorbelegung `7001` +scheitert. Ein Socket im Instanzverzeichnis braucht keine Vergabe, und die +Zugriffsrechte stehen schon am Dateisystem. + +Warum trotzdem HTTP darüber: Weil der Manager Anfragen an die Instanzen +**weiterleitet** (E-M5). Bleibt das Protokoll HTTP, ist das Weiterleiten eine +Weiterleitung und keine Übersetzung — und die bestehenden Endpunkte, DTOs und +der Ereignisstrom aus #494 gelten unverändert weiter. Kestrel kann auf beidem +hören, `SocketsHttpHandler` kann auf beidem verbinden. + +### E-M2 — Die Oberflächen + +**Entschieden: Jede Instanz behält ihr eigenes Desktop-Fenster, der Manager +bekommt ein eigenes.** Die Instanzfenster laufen im Hintergrund und werden beim +Wechsel nach vorn geholt. Ein Wechsel ist damit kein Neustart mehr, sondern ein +Fensterwechsel. + +### E-M3 — Lizenz + +**Entschieden: Die Lizenz liegt im Manager und wird dort geprüft.** +Instanzprozesse starten nicht ohne aktiven Manager. Eine Lizenz erlaubt beliebig +viele Instanzen. + +Folge: `LicenseGate` und `LicenseWatch` wandern aus dem Instanzprozess in den +Manager. `AppHost.Callbacks.License` entfällt für Instanzen. + +### E-M4 — Einstellungen + +**Entschieden: Der Manager kennt nur das Nötigste, alles andere gehört der +Instanz.** Die Aufteilung steht in Abschnitt 6. + +### E-M5 — Die Weboberfläche + +**Entschieden: Eine Adresse, ein Zugang, alle Instanzen.** Der Manager besitzt +Port, Zugang und Rahmen. Anfragen zu Chat, Agenten, Aufgaben und Freigaben +reicht er über E-M1 an die betroffene Instanz weiter. + +**Er leitet weiter, er implementiert nicht.** Keine Fachlogik im Manager — die +Weiterleitung kennt Kennungen und Pfade, sonst nichts. + +### E-M6 — Wenn der Manager ausfällt + +**Entschieden: Laufende Instanzen laufen weiter.** Der neu gestartete Manager +findet sie über eine Datei im Instanzverzeichnis wieder und übernimmt die +Aufsicht. + +Damit wird der Manager nicht zu dem Einzelpunkt, gegen den dieses ganze Vorhaben +gebaut wird. Die Prüfung „gibt es einen Manager?" findet **beim Start** einer +Instanz statt, nicht fortlaufend. + +Ausnahme: Ein **Lizenzwiderruf** ist kein Ausfall, sondern ein Urteil. Erfährt +der Manager davon, hält er die Instanzen an. + +--- + +## 3 — Der Schnitt + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Instanzmanager (ein Prozess je Maschine) │ +│ · eigenes Fenster + die eine Weboberfläche │ +│ · Instanzen anlegen, starten, anhalten, entfernen │ +│ · Aufsicht: lebt sie? stürzt sie wiederholt ab? │ +│ · Lizenz · Update · DC-Master-Token → Sub-Token je Instanz │ +│ · KEINE Datenbank. KEINE Fachlogik. KEINE Werkzeuge. │ +└──────┬───────────────────────────────────────────────────────┘ + │ manager.sock (HTTP über UDS / Named Pipe) + ┌───┴────────┬────────────────┬───────────────┐ + ▼ ▼ ▼ ▼ +┌────────┐ ┌────────┐ ┌────────┐ Fenster je Instanz +│Instanz │ │Instanz │ │Instanz │ (laufen im Hinter- +│ A │ │ B │ │ C │ grund, kommen beim +│ │ │ │ │ │ Wechsel nach vorn) +│ Engine · Werkzeuge · Taskboard · Staging · state.db │ +│ eigenes Protokoll · eigenes Sub-Token · eigener Meldeweg │ +│ Telegram · Rocket.Chat · Mail · FTP — ausschliesslich hier │ +└────────┘ └────────┘ └────────┘ +``` + +### 3.1 Der Manager hat keine Datenbank + +Alles, was er braucht, liegt als JSON im Projektverzeichnis: die Instanzliste, +seine drei Einstellungen, je Instanz eine kleine Zustandsdatei (läuft sie? seit +wann? mit welcher Prozesskennung? wie oft zuletzt abgestürzt?). + +`state.db` einer Instanz fasst er **nicht** an — die Datei gehört genau einem +Prozess, und prozessübergreifend gilt die Schreibsperre von `SqliteStorage` +nicht. + +### 3.2 Werkzeuge laufen ausschließlich in der Instanz + +Telegram, Rocket.Chat, Mail, FTP, Datenbank — alle Werkzeuge gehören der Instanz +und nur ihr. Der Manager kennt sie nicht und spricht nie mit ihnen. + +**Das gilt auch für die Telegram-Anmeldung.** Sie ist ein Rückruf in +`AppHost.Callbacks`; mit E-M2 beantwortet ihn das **eigene Fenster der Instanz**. +Über den Manager läuft davon nichts. (Eine frühere Fassung dieses Papiers hat den +Rückruf als Beleg für Kopflosigkeit angeführt — das war missverständlich: Der +Rückruf zeigt nur, dass die Anwendungsschicht ihre Umgebung nicht festschreibt.) + +--- + +## 4 — Was der Manager protokolliert und meldet + +**Nur, was aus seiner Sicht relevant ist:** + +| Ereignis | Protokoll | Ans Deploymentcenter | +|---|---|---| +| Instanz gestartet | ja | Zustandswechsel | +| Instanz geordnet beendet | ja | Zustandswechsel | +| **Instanz abgestürzt** | ja | **Fehlermeldung stellvertretend** | +| Instanz startet wiederholt neu | ja | Fehlermeldung | +| Instanz antwortet nicht mehr auf dem Socket | ja | Fehlermeldung | +| Lizenz ungültig, Update fehlgeschlagen | ja | Fehlermeldung | + +**Was die Instanz betrifft, bleibt in der Instanz.** Ein fehlgeschlagener +Werkzeugaufruf, ein abgebrochener Agentenlauf, eine überschrittene Budgetgrenze — +das protokolliert und meldet die Instanz selbst, mit ihrem eigenen Sub-Token. +Der Manager sieht davon nichts und soll davon nichts sehen. + +Der Fall aus 1.1 ist genau der, für den es den Manager gibt: Die Instanz kann +nichts mehr sagen, also sagt er es an ihrer Stelle — mit Prozesskennung, +Beendigungsgrund und dem Ausschnitt aus ihrem Protokoll, der noch auf der Platte +liegt. + +### 4.1 Jede Instanz meldet weiterhin selbst + +Zwei Wege, mit Absicht: + +- Die **Instanz** meldet, solange sie lebt — eigener Fehler-Stream, eigener + Heartbeat, eigener Watchdog-Monitor. Das Deploymentcenter führt Instanzen + bereits über `source` + `instance` als eigene Monitore. +- Der **Manager** meldet, was die Instanz nicht mehr melden kann. + +Jeder Weg deckt den Ausfall des anderen ab. Ein Manager, der jede Meldung +durchreicht, wäre der neue Einzelpunkt. + +--- + +## 5 — Lizenz und Token + +**Der Manager hält das Master-Token, die Instanzen halten Sub-Token.** + +Das Verfahren ist gebaut: `TokenProvisioner` tauscht über +`POST /api/tokens/v1/provision` das Master-Token gegen ein Sub-Token je Instanz, +mit den Rechten `watchdog:ping` und `bugtracker:report`. Rechte lassen sich dabei +nur einschränken, nie erweitern; jedes Sub-Token ist einzeln widerrufbar. + +Zu tun bleibt der Umzug: Heute wird bei der Einrichtung provisioniert +(`setup.json`, `"source": "provision"`), künftig beim Anlegen einer Instanz durch +den Manager. + +**Achtung bei der Projektbindung.** Seit SDK 2.8.1 erben Watchdog-Monitore das +Projekt ihres Ping-Tokens; ein Monitor mit `project_slug = NULL` erzeugt Befunde +im Scheinprojekt „default", und `dc_findings(project_slug=…)` liefert für eine +laufende, meldende Instanz dann **null Befunde**. Die Sub-Token müssen also +projektgebunden ausgestellt werden — sonst sieht die Aufsicht aus wie „nichts +auffällig". + +Wir stehen auf **SDK 2.6.0**, aktuell ist **2.8.2**. **#218 ist damit +Voraussetzung, nicht Nacharbeit.** + +--- + +## 6 — Einstellungen: die Aufteilung + +### Der Manager (`manager.json`) + +| Feld | Warum es nicht in die Instanz kann | +|---|---| +| `webUiPort`, `webUiBinding`, `webUiToken` | Eine Weboberfläche für alle | +| `licenseKey` | E-M3: die Lizenz gilt der Installation | +| `deploymentcenterUrl`, `deploymentcenterMasterToken` | Aus ihm entstehen die Sub-Token | +| `instancesDirectory` | Wo die Instanzen liegen — das muss er wissen, bevor es eine gibt | +| `updateChannel` | Aktualisiert wird das Programm, nicht die Instanz | + +Mehr nicht. Farbschema, Teilerstellungen und Protokollstufe sind Werte einer +Oberfläche und gehören zu der, die sie anzeigt. + +### Die Instanz (`InstanceSettings.json`) + +Alles Übrige, und drei Felder ziehen aus den Anwendungseinstellungen dorthin +um — sie waren nie anwendungsweit, sie sahen nur so aus: + +| Feld | Heute | Künftig | +|---|---|---| +| `logDirectory` | geteilt, alle Instanzen in denselben Dateien | `/Logs`, Vorbelegung | +| `backupDirectory` + `backupKeepCount` | geteilt, Präfixkollision bei ineinanderliegenden Namen | je Instanz | +| `autoBackupEnabled` + `autoBackupTime` | geteilt, ein Schalter für alle | je Instanz | +| `webServerPort` | je Instanz, Vorbelegung 7001, Kollision | **entfällt** — es gibt nur noch den Socket | + +`autoBackupEnabled` und `autoBackupTime` standen in der ersten Fassung dieser Tabelle +nicht. Sie sind mitgegangen, weil die Hälfte eines Blocks zu verschieben eine +Einstellungsseite hinterlässt, auf der zwei von vier Sicherungsfeldern für alle Instanzen +gelten und zwei nur für eine — und weil ein Schalter, der die Sicherung für alle +einschaltet, derselbe Fehler ist wie ein Ordner für alle. + +`deploymentcenterEnvironment` bleibt bei der Instanz: Eine Maschine kann eine +Produktions- und eine Testinstanz nebeneinander betreiben. + +--- + +## 7 — Noch zu klären + +**Sicherungen im Manager?** Dafür spricht viel: Die gemeinsame Rotation aus 1.2 +verschwände, und eine Sicherung ließe sich auch von einer Instanz ziehen, die +gerade nicht läuft. Dagegen spricht eine Falle — **eine Sicherung +zurückzuspielen heißt, in ein Instanzverzeichnis zu schreiben, während die +Instanz womöglich läuft.** Das muss den Prozess erst anhalten, sonst schreibt man +unter einer offenen `state.db` das Dateisystem um. Wenn es kommt, dann mit dieser +Reihenfolge: anhalten, zurückspielen, starten — und nie ohne Rückfrage. + +**Wie stellt eine Instanz fest, dass ein Manager da ist?** Vorschlag: Der Manager +startet sie und gibt ihr den Socketpfad mit; die Instanz verbindet sich einmal +zurück und beendet sich, wenn das nicht gelingt. Kein Selbstzweck-Handschlag, +sondern die Prüfung aus E-M3. + +**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 +„vermutlich" ist kein Befund. Zwei unbelegte Verdachtsmomente: `BackupScheduler` +hat sechs `await` und kein einziges `ConfigureAwait(false)` und fängt sich damit +den Oberflächenfaden ein; `ShutdownRequested` ist ein `async void`-Behandler. + +--- + +## 8 — Was aus #494 wird + +Die Weboberfläche ist nicht umsonst gebaut. Sie zerfällt in zwei Teile, die +beide bleiben: + +| Teil | Wohin | +|---|---| +| Endpunkte, DTOs, Maskierung, Ereignisstrom | **In die Instanz.** Statt auf einem TCP-Port hört Kestrel auf dem Socket. Der Code bleibt, die Bindung ändert sich | +| `wwwroot` (Gemini), Anmeldung, Token, Rahmen | **In den Manager.** Dazu die Instanzauswahl und die Verwaltungsansichten | +| Weiterleitung `/api/instanzen/{id}/…` → Instanz | **Neu im Manager** | + +Der Ereignisstrom ist die eine Stelle, die wirklich neu gedacht werden muss: N +Instanzen, ein Strom zum Browser. Der Manager hält je laufender Instanz eine +Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis. + +--- + +## 9 — Reihenfolge + +| Stufe | Inhalt | Woran man es misst | +|---|---|---| +| 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll | +| 2 | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet | +| 3 | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es | +| 4 | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart | +| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln | +| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder | + +**Stufe 1 ist die, die man überspringen möchte und nicht überspringen darf.** +Solange alle Instanzen in dieselben Protokolldateien schreiben, ist jede +Fehlersuche im Mehrinstanzbetrieb Ratearbeit — und genau in dieser Lage ist der +Vorfall aus 1.1 entstanden. + +### 9.1 Was Stufe 1 gebracht hat + +Fünf Schlüssel sind aus `AppSettings` in `InstanceSettings.json` gewandert: +`logDirectory`, `backupDirectory`, `backupKeepCount`, `autoBackupEnabled`, +`autoBackupTime`. Leer heißt jetzt `/Logs` und `/Backups`; ein +relativer Wert zählt weiterhin gegen das Instanzverzeichnis (#375). + +**Keine Übernahme der Altwerte, aber eine Ansage.** Ein anwendungsweiter Pfad, in alle +Instanzen kopiert, wäre wieder derselbe Ordner für alle. Stehen die Schlüssel noch in +einer alten `Settings.json`, meldet `SettingsManager.AbandonedKeys` sie, und der Start +protokolliert einmal, was nicht mehr gilt und wohin diese Instanz stattdessen schreibt. +Beim nächsten Speichern verschwinden sie aus der Datei. + +**`webServerPort` bleibt vorerst stehen.** Es entfällt erst, wenn Kestrel auf dem Socket +hört — also mit Stufe 2. Vorher wegzunehmen hieße, die Weboberfläche ohne Ersatz +abzuschalten. Der Portstreit zweier Instanzen ist bis dahin gedeckelt, aber nicht gelöst: +`WebUiHost.TryStart` gibt bei belegtem Port `null` zurück und protokolliert es, statt den +Start zu verhindern. + +--- + +## 10 — Was das für Vorhandenes bedeutet + +**„Kern als Dienst, Oberfläche als Client"** aus dem Roadmap-Rückstau wird +überholt: Dort ging es um *einen* Kern und *eine* Oberfläche. Der dort notierte +Auslöser — „lohnt sich, sobald der erste UI-Absturz eine Nacht Agentenarbeit +kostet" — ist mit 1.1 eingetreten. + +**#214 (24/7-Betriebsform)** sagt heute „Kein kopfloser Betrieb: Die Oberfläche +läuft laut Zielbetrieb immer mit". Das gilt nach E-M2 in anderer Form: Der +Manager läuft immer mit, die Instanzfenster laufen mit, aber eine Instanz *kann* +ohne Fenster laufen. + +**#218 (SDK 2.6.0 → aktuell)** wird Voraussetzung — siehe Abschnitt 5. diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 1cd16f1..7815d83 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -196,13 +196,13 @@ public sealed class AppHost : IAsyncDisposable public readonly record struct StartupResult(AppHost? Host, string? Error); /// - /// Wohin protokolliert wird. Zwei Regeln, die vorher beide nicht galten: + /// Wohin diese Instanz protokolliert. Zwei Regeln: /// - /// Leer heißt „nimm die Anwendungseinstellung". Die steht absolut unter - /// AppPaths.DataDirectory und ist der Wert, den die Einstellungsseite zeigt. - /// Vorher war InstanceConfig.LogDirectory mit "./Logs" vorbelegt und - /// damit nie leer — der Zweig auf die Anwendungseinstellung war unerreichbar, und - /// das Feld auf der Einstellungsseite ließ sich ändern, ohne etwas zu ändern. + /// Leer heißt <Instanzverzeichnis>/Logs — nicht mehr das + /// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien + /// schrieben, stand in Startup.log abwechselnd, was zwei Instanzen zu sagen + /// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede + /// Fehlersuche damit Ratearbeit (#528, Stufe 1). /// /// Ein relativer Wert zählt gegen das Instanzverzeichnis, nicht gegen /// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem @@ -212,15 +212,29 @@ public sealed class AppHost : IAsyncDisposable /// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt. /// /// Die geladene Instanz — liefert Vorrang und Bezugspunkt. - /// Der Wert aus den Anwendungseinstellungen. /// Das Verzeichnis, aus dem die Instanz geladen wurde. - public static string ResolveLogDirectory( - InstanceConfig instance, string applicationLogDirectory, string instancePath) - { - var configured = !string.IsNullOrWhiteSpace(instance.LogDirectory) - ? instance.LogDirectory - : applicationLogDirectory; + public static string ResolveLogDirectory(InstanceConfig instance, string instancePath) + => ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath); + /// + /// Wohin die Sicherungen dieser Instanz gehen. Leer heißt + /// <Instanzverzeichnis>/Backups. + /// + /// Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt + /// dort nach dem Präfix backup_<Instanzname>_ auf, und bei zwei Namen, + /// die ineinander liegen (Test und Test_2), zählt die eine Instanz die + /// Archive der anderen mit (#528, Stufe 1). + /// + public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath) + => ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath); + + /// + /// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem + /// Instanzverzeichnis. + /// + private static string ResolveInstanceDirectory( + string configured, string fallbackFolder, InstanceConfig instance, string instancePath) + { // InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten // Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann, // wenn jemand das Feld von Hand geleert oder relativ gesetzt hat. @@ -228,6 +242,9 @@ public sealed class AppHost : IAsyncDisposable ? instance.WorkingDirectory : instancePath; + if (string.IsNullOrWhiteSpace(configured)) + return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder)); + return Path.GetFullPath( Path.IsPathRooted(configured) ? configured @@ -260,8 +277,7 @@ public sealed class AppHost : IAsyncDisposable } // ─── 3. Protokollierung ─── - var logDirectory = ResolveLogDirectory( - instance, settings.AppSettings.LogDirectory, instancePath); + var logDirectory = ResolveLogDirectory(instance, instancePath); var minLevel = Enum.TryParse( settings.AppSettings.MinimumLogLevel, true, out var parsed) @@ -274,8 +290,24 @@ public sealed class AppHost : IAsyncDisposable logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})", instance.InstanceName, instance.InstanceId); logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath); + logger.LogInformation("Protokoll-Verzeichnis: {Path}", logDirectory); logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath); + // Ein Wechsel ohne Ausweichlogik braucht wenigstens eine Ansage: Wer ein eigenes + // Log- oder Sicherungsverzeichnis in den Anwendungseinstellungen stehen hatte, + // soll nicht raten muessen, warum dort nichts mehr ankommt. + if (settings.AbandonedKeys.Count > 0) + { + logger.LogWarning( + "Diese Einstellungen gelten nicht mehr anwendungsweit und wurden aus " + + "{Path} entfernt: {Keys}. Sie stehen jetzt je Instanz in " + + "InstanceSettings.json; die Vorgaben dieser Instanz sind {Logs} und {Backups}.", + settings.SettingsPath, + string.Join(", ", settings.AbandonedKeys), + logDirectory, + ResolveBackupDirectory(instance, instancePath)); + } + // ─── 4. Lizenz ─── var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"), callbacks.License); @@ -324,10 +356,10 @@ public sealed class AppHost : IAsyncDisposable await host.StartDeploymentcenterAsync(loggerFactory, ct); // Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und - // Zielordner in die Einstellungen — gestartet wurde der Takt dazu bisher nicht, - // die Einstellung lief also ins Leere. + // Zielordner in die Instanzkonfiguration — gestartet wurde der Takt dazu bisher + // nicht, die Einstellung lief also ins Leere. host.Backups = new BackupScheduler( - instancePath, instance.InstanceName, settings, + instancePath, instance, loggerFactory.CreateLogger("ClawdDotNet.Backup")); host.Backups.Start(); diff --git a/src/ClawdDotNet.App/Services/BackupScheduler.cs b/src/ClawdDotNet.App/Services/BackupScheduler.cs index 73faa8c..6faa055 100644 --- a/src/ClawdDotNet.App/Services/BackupScheduler.cs +++ b/src/ClawdDotNet.App/Services/BackupScheduler.cs @@ -1,5 +1,5 @@ -using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Storage; using Microsoft.Extensions.Logging; @@ -12,8 +12,16 @@ namespace ClawdDotNet.App.Services; /// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten /// mitsichern will, macht das von Hand. /// -/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit -/// eine Änderung ohne Neustart greift. +/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft, +/// damit eine Änderung ohne Neustart greift. +/// +/// Schalter, Uhrzeit, Zielordner und Aufbewahrung gehören der Instanz +/// (#528, Stufe 1). Alle vier standen in den Anwendungseinstellungen und galten damit +/// für jede Instanz: Wer die tägliche Sicherung für eine einschaltete, schaltete sie für +/// alle ein, und alle Archive landeten in einem Ordner. Dort räumt +/// nach dem Präfix backup_<Instanzname>_ auf — +/// bei zwei Namen, die ineinander liegen (Test und Test_2), zählt die eine +/// Instanz die Archive der anderen mit und wirft die überzähligen weg. /// /// Takt. Früher ein System.Windows.Forms.Timer — der braucht eine /// Nachrichtenschleife und damit ein Fenster. Jetzt über @@ -23,8 +31,7 @@ namespace ClawdDotNet.App.Services; public sealed class BackupScheduler : IAsyncDisposable { private readonly string _instanceDir; - private readonly string _instanceName; - private readonly SettingsManager _settings; + private readonly InstanceConfig _instance; private readonly ILogger _logger; private readonly TimeProvider _clock; private readonly TimeSpan _tick; @@ -38,17 +45,24 @@ public sealed class BackupScheduler : IAsyncDisposable public event Action? OnBackupCreated; + /// + /// Das Instanzverzeichnis. Zugleich der Bezugspunkt, gegen den ein relativ + /// eingetragener Zielordner zählt. + /// + /// + /// Die laufende Instanzkonfiguration — als Verweis, nicht als Abzug: Wer Zielordner + /// oder Uhrzeit in der Oberfläche ändert, ändert dieses Objekt, und der nächste + /// Durchlauf sieht es. + /// public BackupScheduler( string instanceDir, - string instanceName, - SettingsManager settings, + InstanceConfig instance, ILogger logger, TimeProvider? clock = null, TimeSpan? tick = null) { _instanceDir = instanceDir; - _instanceName = instanceName; - _settings = settings; + _instance = instance; _logger = logger; _clock = clock ?? TimeProvider.System; @@ -79,11 +93,10 @@ public sealed class BackupScheduler : IAsyncDisposable /// Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können. public async Task TickAsync() { - var settings = _settings.AppSettings; - if (!settings.AutoBackupEnabled) + if (!_instance.AutoBackupEnabled) return; - if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled)) + if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled)) return; var now = _clock.GetLocalNow().DateTime; @@ -96,7 +109,10 @@ public sealed class BackupScheduler : IAsyncDisposable // sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem. _lastRun = now; - await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now); + await RunAsync( + AppHost.ResolveBackupDirectory(_instance, _instanceDir), + _instance.BackupKeepCount, + now); } private async Task RunAsync(string folder, int keepCount, DateTime now) @@ -104,11 +120,13 @@ public sealed class BackupScheduler : IAsyncDisposable // PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das // nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter // Windows nicht mehr anlegen lässt. - var safeName = PortableFileName.Sanitize(_instanceName, "Instanz"); + var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz"); - var file = Path.Combine( - Path.GetFullPath(folder), - $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip"); + // Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten + // Lauf erst entstehen. + Directory.CreateDirectory(folder); + + var file = Path.Combine(folder, $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip"); var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions { @@ -120,7 +138,7 @@ public sealed class BackupScheduler : IAsyncDisposable _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes); - ApplyRotation(Path.GetFullPath(folder), safeName, keepCount); + ApplyRotation(folder, safeName, keepCount); OnBackupCreated?.Invoke(result.ZipPath); } diff --git a/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs index 86e5f99..eeb5f72 100644 --- a/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs +++ b/src/ClawdDotNet.App/Services/InstanceDirectoryManager.cs @@ -120,10 +120,9 @@ public sealed class InstanceDirectoryManager { InstanceId = Guid.NewGuid().ToString("N")[..8], InstanceName = instanceName, - // LogDirectory bleibt leer: dann gilt die Anwendungseinstellung, und die - // steht absolut. Hier stand vorher "./Logs" — ein Wert, der jede neue - // Instanz an das Arbeitsverzeichnis des Prozesses band und die - // Einstellungsseite wirkungslos machte. + // LogDirectory und BackupDirectory bleiben leer: Dann gelten + // /Logs und /Backups. Hier stand vorher "./Logs" — ein + // Wert, der jede neue Instanz an das Arbeitsverzeichnis des Prozesses band. WorkingDirectory = instanceDir }; SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config); @@ -153,11 +152,24 @@ public sealed class InstanceDirectoryManager var config = JsonSerializer.Deserialize(json, JsonOpts) ?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig."); - // Migration: Die alte Vorbelegung "./Logs" wird zu „leer" — also zur - // Anwendungseinstellung. Der Wert hatte nie die Bedeutung, die er vorgab: Er - // wurde gegen das Arbeitsverzeichnis des Prozesses aufgelöst, nicht gegen das - // Instanzverzeichnis. Wer bewusst einen anderen Pfad eingetragen hat, behält - // ihn; nur genau die Vorbelegung fällt weg. + // Zugangsdaten der Instanz lesbar machen — OpenRouter-Schlüssel, Watchdog-Token, + // Telegram-Zugang, Token der Weboberfläche. + // + // Das fehlte. SaveInstanceConfig verschlüsselt beim Schreiben, hier wurde + // aber nie entschlüsselt: Was einmal über die Oberfläche gespeichert war, kam + // als "enc:v2:…" zurück und wurde als Wert benutzt. Der OpenRouter-Schlüssel + // ging so verschlüsselt an die API. Aufgefallen ist es nie, weil + // ConfigLoader.LoadAsync — das es richtig macht — im laufenden Programm gar + // nicht aufgerufen wird; nur seine Migrate-Methode wird gebraucht. + // + // Gefahrlos für Klartext aus älteren Dateien: SecretProtector.Unprotect + // gibt zurück, was es nicht als geschützt erkennt. + ConfigSecrets.Unprotect(config); + + // Die alte Vorbelegung "./Logs" wird zu „leer". Beide Schreibweisen zeigen + // seit #528 (Stufe 1) auf denselben Ort — /Logs —, der leere Wert sagt + // das aber, ohne es festzuschreiben: Zieht die Instanz um, zieht das Protokoll + // mit. // // Nur im Speicher: Die Datei bleibt unangetastet, damit ein Ladevorgang nichts // zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst. diff --git a/src/ClawdDotNet.App/Settings/AppSettings.cs b/src/ClawdDotNet.App/Settings/AppSettings.cs index bf52a27..7926b07 100644 --- a/src/ClawdDotNet.App/Settings/AppSettings.cs +++ b/src/ClawdDotNet.App/Settings/AppSettings.cs @@ -14,16 +14,16 @@ namespace ClawdDotNet.App.Settings; /// public sealed class AppSettings { - // Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis. - // Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht - // schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde. - // Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths). - [Category("Allgemein")] - [DisplayName("Log-Verzeichnis")] - [Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")] - [JsonPropertyName("logDirectory")] - public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs"); - + // logDirectory ist hier entfallen (#528, Stufe 1). Es war anwendungsweit und damit + // fuer alle Instanzen derselbe Ordner: In Startup.log standen abwechselnd Zeilen + // zweier Instanzen ohne Kennzeichnung, welche gerade spricht. Wohin eine Instanz + // protokolliert, steht jetzt in ihrer InstanceSettings.json; leer heisst + // /Logs (siehe AppHost.ResolveLogDirectory). + // + // Die Vorgabe war "./Instances" — relativ zum Arbeitsverzeichnis. Unter Linux liegt + // die Anwendung in /opt oder /usr/lib und darf dort nicht schreiben; zudem hing der + // Ort davon ab, aus welchem Verzeichnis gestartet wurde. Jetzt ein absoluter Pfad im + // Datenverzeichnis des Benutzers (siehe AppPaths). [Category("Allgemein")] [DisplayName("Instanzen-Verzeichnis")] [Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")] @@ -104,29 +104,12 @@ public sealed class AppSettings [JsonPropertyName("openRouterBaseUrl")] public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/"; - [Category("Backup")] - [DisplayName("Backup-Verzeichnis")] - [Description("Ordner, in dem Sicherungen abgelegt werden.")] - [JsonPropertyName("backupDirectory")] - public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups"); - - [Category("Backup")] - [DisplayName("Automatisch sichern")] - [Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")] - [JsonPropertyName("autoBackupEnabled")] - public bool AutoBackupEnabled { get; set; } - - [Category("Backup")] - [DisplayName("Uhrzeit der automatischen Sicherung")] - [Description("Tageszeit im Format HH:mm.")] - [JsonPropertyName("autoBackupTime")] - public string AutoBackupTime { get; set; } = "03:00"; - - [Category("Backup")] - [DisplayName("Aufbewahrte Sicherungen")] - [Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")] - [JsonPropertyName("backupKeepCount")] - public int BackupKeepCount { get; set; } = 14; + // Die vier Sicherungs-Einstellungen sind hier entfallen (#528, Stufe 1) und stehen + // jetzt in InstanceSettings.json. Anwendungsweit galten sie fuer jede Instanz: Wer + // die taegliche Sicherung fuer eine einschaltete, schaltete sie fuer alle ein, und + // alle Archive lagen in einem Ordner. Dort raeumt die Aufbewahrung nach dem Praefix + // backup__ auf — bei zwei Namen, die ineinander liegen (Test und + // Test_2), zaehlt die eine Instanz die Archive der anderen mit. // ─── Deploymentcenter ─── // diff --git a/src/ClawdDotNet.App/Settings/SettingsManager.cs b/src/ClawdDotNet.App/Settings/SettingsManager.cs index 0eb0116..b41de9c 100644 --- a/src/ClawdDotNet.App/Settings/SettingsManager.cs +++ b/src/ClawdDotNet.App/Settings/SettingsManager.cs @@ -34,10 +34,40 @@ public sealed class SettingsManager PropertyNameCaseInsensitive = true }; + /// + /// Schlüssel, die es in einmal gab und die dorthin nicht + /// zurückkommen. Steht eine alte Datei noch voll davon, sagt der Start das einmal an + /// (siehe ). + /// + private static readonly string[] RetiredKeys = + [ + // Alle fünf sind mit #528 (Stufe 1) in die Instanz gewandert: Sie galten + // anwendungsweit und waren damit fuer alle Instanzen derselbe Ordner + // beziehungsweise derselbe Zeitplan. + "logDirectory", "backupDirectory", "backupKeepCount", + "autoBackupEnabled", "autoBackupTime" + ]; + private readonly string _settingsPath; public AppSettings AppSettings { get; private set; } = new(); + /// + /// Welche der aufgegebenen Schlüssel beim letzten noch in der + /// Datei standen. + /// + /// System.Text.Json überliest unbekannte Schlüssel wortlos, und + /// schreibt sie danach nicht wieder — der Wert verschwindet also + /// still. Wer ein eigenes Log- oder Sicherungsverzeichnis eingetragen hatte, soll + /// nicht raten müssen, warum dort nichts mehr ankommt; deshalb wird das hier + /// festgehalten und beim Start einmal protokolliert. + /// + /// Bewusst nur eine Ansage und keine Übernahme: Ein anwendungsweiter Pfad, in + /// alle Instanzen kopiert, wäre wieder derselbe Ordner für alle — also genau der + /// Zustand, den Stufe 1 beseitigt. + /// + public IReadOnlyList AbandonedKeys { get; private set; } = []; + /// Der Ort der Einstellungsdatei — für Meldungen und Diagnose. public string SettingsPath => _settingsPath; @@ -49,6 +79,8 @@ public sealed class SettingsManager public void Load() { + AbandonedKeys = []; + if (!File.Exists(_settingsPath)) { AppSettings = new AppSettings(); @@ -61,6 +93,8 @@ public sealed class SettingsManager var json = AtomicFile.ReadAllText(_settingsPath); AppSettings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + + AbandonedKeys = FindRetiredKeys(json); } catch (Exception ex) when (ex is JsonException or IOException) { @@ -76,6 +110,36 @@ public sealed class SettingsManager AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken); } + /// + /// Welche aufgegebenen Schlüssel die Datei noch enthält. Ein zweiter Lesevorgang + /// über denselben Text — die Deserialisierung kann es nicht sagen, weil sie + /// Unbekanntes überliest. + /// + private static IReadOnlyList FindRetiredKeys(string json) + { + try + { + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return []; + + return RetiredKeys + .Where(key => doc.RootElement.TryGetProperty(key, out _)) + .ToList(); + } + catch (JsonException) + { + // Der Deserialisierer kam durch, dieser Weg nicht — dann ist die Ansage + // verzichtbar, der Start nicht. + return []; + } + } + /// /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner, /// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index d66ac5f..032367f 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -17,17 +17,47 @@ public sealed class InstanceConfig public string WorkingDirectory { get; set; } = "./data/"; /// - /// Wohin diese Instanz protokolliert. Leer heißt: das Log-Verzeichnis aus den - /// Anwendungseinstellungen, also der absolute Pfad unter AppPaths.DataDirectory. + /// Wohin diese Instanz protokolliert. Leer heißt <Instanzverzeichnis>/Logs. /// - /// Die frühere Vorbelegung "./Logs" war nie leer und verdeckte damit die - /// Anwendungseinstellung vollständig; aufgelöst wurde sie zudem gegen das - /// Arbeitsverzeichnis des Prozesses. Ein relativer Wert zählt heute gegen das - /// Instanzverzeichnis (siehe AppHost.StartAsync). + /// Je Instanz und nicht anwendungsweit (#528, Stufe 1). Vorher galt bei + /// „leer" das Log-Verzeichnis aus den Anwendungseinstellungen — ein Ort für alle + /// Instanzen. In Startup.log standen dann abwechselnd Zeilen zweier Instanzen + /// ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede + /// Fehlersuche damit Ratearbeit. + /// + /// Ein relativer Wert zählt gegen das Instanzverzeichnis, nicht gegen das + /// Arbeitsverzeichnis des Prozesses (siehe AppHost.ResolveLogDirectory). /// [JsonPropertyName("logDirectory")] public string LogDirectory { get; set; } = ""; + /// + /// Wohin die Sicherungen dieser Instanz gehen. Leer heißt + /// <Instanzverzeichnis>/Backups. + /// + /// Je Instanz (#528, Stufe 1). Vorher lagen die Archive aller Instanzen + /// in einem Ordner, und die Aufbewahrung () räumt dort + /// nach dem Präfix backup_<Instanzname>_ auf. Liegen zwei Namen + /// ineinander — Test und Test_2 —, zählt die eine Instanz die Archive + /// der anderen mit und wirft die überzähligen weg. + /// + [JsonPropertyName("backupDirectory")] + public string BackupDirectory { get; set; } = ""; + + /// Erstellt täglich zur eingestellten Uhrzeit eine Sicherung dieser Instanz. + [JsonPropertyName("autoBackupEnabled")] + public bool AutoBackupEnabled { get; set; } + + /// Tageszeit der automatischen Sicherung, HH:mm. + [JsonPropertyName("autoBackupTime")] + public string AutoBackupTime { get; set; } = "03:00"; + + /// + /// Wie viele Sicherungen dieser Instanz aufbewahrt werden. 0 = alle behalten. + /// + [JsonPropertyName("backupKeepCount")] + public int BackupKeepCount { get; set; } = 14; + /// /// Port der Weboberfläche. Je Instanz und nicht anwendungsweit: Zwei Instanzen auf /// einer Maschine stritten sich sonst um einen Port. diff --git a/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs index c7383a9..7ca6943 100644 --- a/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs +++ b/src/ClawdDotNet.Desktop/ViewModels/BackupPageViewModel.cs @@ -1,8 +1,9 @@ using System.Collections.ObjectModel; using Avalonia.Threading; using ClawdDotNet.App; -using ClawdDotNet.App.Settings; +using ClawdDotNet.App.Services; using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Storage; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -29,7 +30,14 @@ public sealed partial class BackupPageViewModel : PageViewModel { private readonly BackupService _service = new(); private readonly AppHost? _host; - private readonly SettingsManager? _settingsManager; + + /// + /// Schalter, Uhrzeit, Zielordner und Aufbewahrung stehen seit #528 (Stufe 1) hier + /// und nicht mehr in den Anwendungseinstellungen. Dort galten sie fuer jede Instanz: + /// Wer die taegliche Sicherung fuer eine einschaltete, schaltete sie fuer alle ein. + /// + private readonly InstanceConfig? _instance; + private readonly ILogger? _logger; private readonly string _instanceDir; private readonly string _instanceName; @@ -88,7 +96,7 @@ public sealed partial class BackupPageViewModel : PageViewModel { _instanceDir = host.InstancePath; _instanceName = host.Instance.InstanceName; - _settingsManager = host.Settings; + _instance = host.Instance; _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup"); LoadSettings(); @@ -108,17 +116,19 @@ public sealed partial class BackupPageViewModel : PageViewModel private void LoadSettings() { - if (_settingsManager is null) return; + if (_host is null || _instance is null) return; _loading = true; try { - var settings = _settingsManager.AppSettings; - TargetFolder = Path.GetFullPath(settings.BackupDirectory); - AutoBackupEnabled = settings.AutoBackupEnabled; - KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999); + // Aufgeloest und nicht roh angezeigt: In der Datei steht in aller Regel + // nichts, und ein leeres Feld beantwortet die Frage nicht, wohin gesichert + // wird. + TargetFolder = AppHost.ResolveBackupDirectory(_instance, _instanceDir); + AutoBackupEnabled = _instance.AutoBackupEnabled; + KeepCount = Math.Clamp(_instance.BackupKeepCount, 0, 999); - AutoBackupTime = TimeSpan.TryParse(settings.AutoBackupTime, out var time) + AutoBackupTime = TimeSpan.TryParse(_instance.AutoBackupTime, out var time) ? time.ToString(@"hh\:mm") : "03:00"; } @@ -130,15 +140,23 @@ public sealed partial class BackupPageViewModel : PageViewModel public void SaveSettings() { - if (_loading || _settingsManager is null) return; + if (_loading || _host is null || _instance is null) return; - var settings = _settingsManager.AppSettings; - settings.BackupDirectory = TargetFolder.Trim(); - settings.AutoBackupEnabled = AutoBackupEnabled; - settings.AutoBackupTime = AutoBackupTime.Trim(); - settings.BackupKeepCount = KeepCount; + var folder = TargetFolder.Trim(); - _settingsManager.Save(); + // Der Vorgabeort bleibt als Leereintrag stehen. Sonst schriebe die Ansicht den + // aufgeloesten Pfad fest, und eine umgezogene Instanz sicherte weiter dorthin, + // wo sie einmal lag. + var vorgabe = AppHost.ResolveBackupDirectory(new InstanceConfig(), _instanceDir); + + _instance.BackupDirectory = + string.Equals(folder, vorgabe, PathBoundary.Comparison) ? "" : folder; + + _instance.AutoBackupEnabled = AutoBackupEnabled; + _instance.AutoBackupTime = AutoBackupTime.Trim(); + _instance.BackupKeepCount = KeepCount; + + _host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); } [RelayCommand] diff --git a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs index 85eab90..7f50b7c 100644 --- a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs +++ b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs @@ -1,5 +1,5 @@ using ClawdDotNet.App; -using ClawdDotNet.App.Settings; +using ClawdDotNet.App.Services; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; @@ -14,10 +14,10 @@ public sealed partial class SettingsPageViewModel : PageViewModel private readonly AppHost? _host; private readonly ILogger? _logger; - // ─── Allgemein & Pfade ─── - [ObservableProperty] - private string _logDirectory = ""; + /// Womit die Seite geladen wurde — nur, um eine Änderung zu erkennen. + private string _logDirectoryAtLoad = ""; + // ─── Allgemein & Pfade ─── [ObservableProperty] private string _instancesDirectory = ""; @@ -70,6 +70,20 @@ public sealed partial class SettingsPageViewModel : PageViewModel [ObservableProperty] private string _instanceName = ""; + /// + /// Wohin diese Instanz protokolliert. Steht seit #528 (Stufe 1) hier und nicht mehr + /// bei den Anwendungseinstellungen: Ein Verzeichnis für alle hiess, dass in + /// Startup.log abwechselnd zwei Instanzen schrieben, ohne Kennzeichnung, welche + /// gerade spricht. + /// + /// Leer angezeigt heisst leer gemeint — die Vorgabe steht als Hinweis unter + /// dem Feld. Anders als bei der Sicherungsseite wird hier bewusst nicht aufgeloest: + /// Der geltende Ort steht auf der Info-Seite, und ein aufgeloester Pfad im Feld + /// wuerde beim naechsten Speichern festgeschrieben. + /// + [ObservableProperty] + private string _logDirectory = ""; + [ObservableProperty] private string _openRouterApiKey = ""; @@ -109,7 +123,6 @@ public sealed partial class SettingsPageViewModel : PageViewModel if (_host is null) return; var s = _host.Settings.AppSettings; - LogDirectory = s.LogDirectory; InstancesDirectory = s.InstancesDirectory; MinimumLogLevel = s.MinimumLogLevel; MaxLogLinesInUi = s.MaxLogLinesInUi; @@ -126,6 +139,8 @@ public sealed partial class SettingsPageViewModel : PageViewModel var inst = _host.Instance; InstanceName = inst.InstanceName; + LogDirectory = inst.LogDirectory; + _logDirectoryAtLoad = inst.LogDirectory; OpenRouterApiKey = inst.OpenRouterApiKey; InstanceDailyCostUsd = inst.Budget.DailyCostUsd; InstanceDailyTokens = inst.Budget.DailyTokens; @@ -137,7 +152,6 @@ public sealed partial class SettingsPageViewModel : PageViewModel if (_host is null) return; var s = _host.Settings.AppSettings; - s.LogDirectory = LogDirectory.Trim(); s.InstancesDirectory = InstancesDirectory.Trim(); s.MinimumLogLevel = MinimumLogLevel; s.MaxLogLinesInUi = MaxLogLinesInUi; @@ -156,13 +170,21 @@ public sealed partial class SettingsPageViewModel : PageViewModel var inst = _host.Instance; inst.InstanceName = InstanceName.Trim(); + inst.LogDirectory = LogDirectory.Trim(); inst.OpenRouterApiKey = OpenRouterApiKey.Trim(); inst.Budget.DailyCostUsd = InstanceDailyCostUsd; inst.Budget.DailyTokens = InstanceDailyTokens; - _host.Directories.SaveInstanceConfig(_host.InstancePath, inst); + _host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); _logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert."); - StatusText = "Einstellungen erfolgreich gespeichert."; + + // Das Protokoll-Verzeichnis greift erst beim naechsten Start: Die + // Protokollierung steht seit dem Aufbau des AppHost und schreibt bereits. + StatusText = string.Equals(inst.LogDirectory, _logDirectoryAtLoad, StringComparison.Ordinal) + ? "Einstellungen erfolgreich gespeichert." + : "Gespeichert. Das Protokoll-Verzeichnis gilt ab dem nächsten Start."; + + _logDirectoryAtLoad = inst.LogDirectory; } } diff --git a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml index 8fa96be..2fe4af9 100644 --- a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml +++ b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml @@ -42,6 +42,13 @@ + + + + + + @@ -60,11 +67,6 @@ - - - - - diff --git a/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs index d1b89e2..55ee93c 100644 --- a/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/EventEndpoints.cs @@ -5,6 +5,7 @@ using ClawdDotNet.App.Services; using ClawdDotNet.Web.Contracts; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; namespace ClawdDotNet.Web.Endpoints; @@ -43,8 +44,11 @@ internal static class EventEndpoints } private static async Task StreamCoreEvents( - HttpContext context, AppHost host, CancellationToken ct) + HttpContext context, AppHost host, IHostApplicationLifetime lifetime, CancellationToken ct) { + using var leben = Verbinde(lifetime, ct); + ct = leben.Token; + var queue = Channel.CreateBounded(new BoundedChannelOptions(QueueDepth) { FullMode = BoundedChannelFullMode.DropOldest, @@ -122,8 +126,12 @@ internal static class EventEndpoints /// Zeilen bekommt und alle anderen nichts. /// private static async Task StreamLogs( - HttpContext context, AppHost host, CancellationToken ct, string? module = null, string? level = null) + HttpContext context, AppHost host, IHostApplicationLifetime lifetime, + CancellationToken ct, string? module = null, string? level = null) { + using var leben = Verbinde(lifetime, ct); + ct = leben.Token; + var tail = new LogTail(host.LogDirectory); var filter = Enum.TryParse(level, ignoreCase: true, out var parsed) @@ -216,6 +224,24 @@ internal static class EventEndpoints } } + /// + /// Verbindet den Abbruch der Anfrage mit dem Herunterfahren der Anwendung. + /// + /// Ohne das hängt das Beenden an jedem offenen Ereignisstrom. + /// RequestAborted feuert, wenn der Browser auflegt — beim geordneten + /// Herunterfahren tut er das aber nicht. Kestrel wartet dann darauf, dass die + /// Anfrage von selbst endet; ein Ereignisstrom endet nie von selbst. Erst nach der + /// Frist von StopAsync wird abgeschnitten, und so lange steht die Anwendung. + /// + /// Sichtbar wurde es beim Instanzwechsel: Zwischen „Wechseln" und dem Neustart + /// lagen fünf Sekunden, in denen nichts geschah. Mit einem offenen Browserfenster + /// jedes Mal. + /// + private static CancellationTokenSource Verbinde( + IHostApplicationLifetime lifetime, CancellationToken requestAborted) + => CancellationTokenSource.CreateLinkedTokenSource( + requestAborted, lifetime.ApplicationStopping); + private static void PrepareResponse(HttpContext context) { context.Response.Headers.ContentType = "text/event-stream"; diff --git a/src/ClawdDotNet.Web/WebUiHost.cs b/src/ClawdDotNet.Web/WebUiHost.cs index 7e8cd5c..ff54779 100644 --- a/src/ClawdDotNet.Web/WebUiHost.cs +++ b/src/ClawdDotNet.Web/WebUiHost.cs @@ -103,6 +103,14 @@ public sealed class WebUiHost : IAsyncDisposable builder.Logging.ClearProviders(); builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory)); + // ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener + // Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes + // Stylesheet. In einer Viertelstunde Herumklicken standen so mehrere tausend + // Zeilen im Instanzprotokoll, zwischen denen die Meldungen der Agenten + // untergingen. Ab Warnung ist es das, was man wissen will. + builder.Logging.AddFilter("Microsoft", LogLevel.Warning); + builder.Logging.AddFilter("System", LogLevel.Warning); + builder.WebHost.UseUrls(options.Url); builder.Services.AddSingleton(host); @@ -144,10 +152,15 @@ public sealed class WebUiHost : IAsyncDisposable { await next(context); } - catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + catch (OperationCanceledException) { - // Der Browser hat aufgelegt. Das ist kein Fehler und schon gar keiner, - // der ins Deploymentcenter gehoert. + // Der Browser hat aufgelegt, oder die Anwendung faehrt herunter. Beides + // ist kein Fehler und schon gar keiner, der ins Deploymentcenter gehoert. + // + // Bewusst ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht + // ApplicationStopping ab, und RequestAborted ist dabei gar nicht gesetzt. + // Mit der Bedingung landete jeder offene Ereignisstrom beim Beenden als + // gemeldeter Fehler im Bugtracker. } catch (Exception ex) { diff --git a/src/ClawdDotNet.Web/wwwroot/app.css b/src/ClawdDotNet.Web/wwwroot/app.css index d252dff..fe917bb 100644 --- a/src/ClawdDotNet.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Web/wwwroot/app.css @@ -142,6 +142,22 @@ a:hover { /* ─── Layout Shell ─── */ +/* + Das hidden-Attribut muss gewinnen. + + Der Browser setzt fuer [hidden] display:none, aber nur mit der Staerke einer + Attributregel. #anmeldung und #anwendung setzen display:flex ueber einen + ID-Selektor - und der ist staerker. Ohne diese Zeile blieb die Anmeldemaske + nach dem Anmelden stehen und lag ueber der Anwendung: Beide Bereiche waren + gleichzeitig sichtbar, die Anmeldung nur weiter vorn. + + Gilt fuer den ganzen Baum, nicht nur fuer diese zwei: Ueberall, wo JavaScript + el.hidden setzt, soll das auch etwas bewirken. +*/ +[hidden] { + display: none !important; +} + #anmeldung { display: flex; align-items: center; diff --git a/tests/ClawdDotNet.App.Tests/LogDirectoryResolutionTests.cs b/tests/ClawdDotNet.App.Tests/LogDirectoryResolutionTests.cs index c4a4276..f8ba681 100644 --- a/tests/ClawdDotNet.App.Tests/LogDirectoryResolutionTests.cs +++ b/tests/ClawdDotNet.App.Tests/LogDirectoryResolutionTests.cs @@ -1,80 +1,139 @@ using ClawdDotNet.Core.Config; -using ClawdDotNet.Core.Storage; using Shouldly; namespace ClawdDotNet.App.Tests; /// -/// #375: Die Einstellungsseite zeigte ein Log-Verzeichnis, das nie galt. +/// Wohin eine Instanz protokolliert und sichert — und dass zwei Instanzen dabei nicht +/// im selben Ordner landen (#528, Stufe 1). /// -/// InstanceConfig.LogDirectory war mit "./Logs" vorbelegt und damit nie -/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der -/// Wert zudem mit Path.GetFullPath ohne Basis, also gegen das Arbeitsverzeichnis -/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand -/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in /) war das -/// /Logs — für ein Dienstkonto nicht anlegbar. +/// Der Anlass. Beide Vorgaben waren anwendungsweit. In Startup.log +/// standen deshalb abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche +/// gerade spricht; und weil die Aufbewahrung nach Präfix in genau einem Ordner +/// aufräumt, warf die laufende Instanz die Archive der anderen mit weg. Solange das so +/// ist, ist jede Fehlersuche im Mehrinstanzbetrieb Ratearbeit. +/// +/// Was aus #375 bleibt. Ein relativer Wert zählt gegen das +/// Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein +/// systemd-Dienst ohne WorkingDirectory= startet in /, und "./Logs" +/// zeigte dann auf /Logs — für ein Dienstkonto nicht anlegbar. /// public sealed class LogDirectoryResolutionTests { private static readonly string InstancePath = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test")); - private static readonly string ApplicationLogDirectory = - Path.Combine(AppPaths.DataDirectory, "Logs"); - - private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null) + private static InstanceConfig Instance( + string logDirectory = "", string backupDirectory = "", string? workingDirectory = null) => new() { LogDirectory = logDirectory, + BackupDirectory = backupDirectory, WorkingDirectory = workingDirectory ?? InstancePath }; + // ─── Vorgaben ─── + [Fact] - public void Die_Vorbelegung_ist_leer() + public void Die_Vorbelegungen_sind_leer() { - // Der Kern des Befunds: Solange hier "./Logs" stand, konnte die - // Anwendungseinstellung nicht greifen. + // Der Kern: Leer heisst „nimm den Ordner dieser Instanz". Steht hier ein Wert, + // traegt ihn jede neue Instanz mit sich herum. new InstanceConfig().LogDirectory.ShouldBe(""); + new InstanceConfig().BackupDirectory.ShouldBe(""); } [Fact] - public void Ohne_Angabe_in_der_Instanz_gilt_die_Anwendungseinstellung() + public void Ohne_Angabe_protokolliert_die_Instanz_in_ihr_eigenes_Verzeichnis() { - AppHost.ResolveLogDirectory(Instance(""), ApplicationLogDirectory, InstancePath) - .ShouldBe(Path.GetFullPath(ApplicationLogDirectory)); + AppHost.ResolveLogDirectory(Instance(), InstancePath) + .ShouldBe(Path.Combine(InstancePath, "Logs")); } [Fact] - public void Ein_absoluter_Wert_in_der_Instanz_geht_vor() + public void Ohne_Angabe_sichert_die_Instanz_in_ihr_eigenes_Verzeichnis() { - var eigen = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs")); + AppHost.ResolveBackupDirectory(Instance(), InstancePath) + .ShouldBe(Path.Combine(InstancePath, "Backups")); + } - AppHost.ResolveLogDirectory(Instance(eigen), ApplicationLogDirectory, InstancePath) - .ShouldBe(eigen); + /// + /// Das eigentliche Versprechen von Stufe 1. Alles andere in dieser Datei prüft die + /// Mechanik; dieser Test prüft die Zusage. + /// + [Fact] + public void Zwei_Instanzen_ohne_Angabe_teilen_sich_nichts() + { + var a = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-A")); + var b = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-B")); + + var logA = AppHost.ResolveLogDirectory(Instance(workingDirectory: a), a); + var logB = AppHost.ResolveLogDirectory(Instance(workingDirectory: b), b); + + var backupA = AppHost.ResolveBackupDirectory(Instance(workingDirectory: a), a); + var backupB = AppHost.ResolveBackupDirectory(Instance(workingDirectory: b), b); + + logA.ShouldNotBe(logB); + backupA.ShouldNotBe(backupB); + } + + // ─── Eingetragene Werte ─── + + [Fact] + public void Ein_absoluter_Wert_geht_vor() + { + var eigenesLog = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-logs")); + var eigenesBackup = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-eigene-sicherungen")); + + AppHost.ResolveLogDirectory(Instance(logDirectory: eigenesLog), InstancePath) + .ShouldBe(eigenesLog); + + AppHost.ResolveBackupDirectory(Instance(backupDirectory: eigenesBackup), InstancePath) + .ShouldBe(eigenesBackup); } [Fact] public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis() { - // Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler. - AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath) - .ShouldBe(Path.Combine(InstancePath, "Logs")); + // Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler aus #375. + AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath) + .ShouldBe(Path.Combine(InstancePath, "Protokolle")); } [Fact] public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses() { - AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath) - .ShouldNotBe(Path.GetFullPath("./Logs")); + AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath) + .ShouldNotBe(Path.GetFullPath("./Protokolle")); } + [Theory] + [InlineData("./Logs")] + [InlineData("Logs")] + public void Die_alte_Vorbelegung_zeigt_auf_dieselbe_Stelle_wie_keine_Angabe(string alt) + { + // Deshalb darf LoadInstanceConfig sie zu „leer" machen, ohne dass sich etwas + // verschiebt. + AppHost.ResolveLogDirectory(Instance(logDirectory: alt), InstancePath) + .ShouldBe(AppHost.ResolveLogDirectory(Instance(), InstancePath)); + } + + [Fact] + public void Nur_Leerzeichen_gelten_als_keine_Angabe() + { + AppHost.ResolveLogDirectory(Instance(logDirectory: " "), InstancePath) + .ShouldBe(Path.Combine(InstancePath, "Logs")); + } + + // ─── Bezugspunkt ─── + [Fact] public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck() { // Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand // geleert worden sein. Dann trägt der Ort, aus dem geladen wurde. AppHost.ResolveLogDirectory( - Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath) + Instance(logDirectory: "Logs", workingDirectory: ""), InstancePath) .ShouldBe(Path.Combine(InstancePath, "Logs")); } @@ -84,14 +143,7 @@ public sealed class LogDirectoryResolutionTests // "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das // Arbeitsverzeichnis des Prozesses aufgelöst worden. AppHost.ResolveLogDirectory( - Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath) + Instance(logDirectory: "Logs", workingDirectory: "./data/"), InstancePath) .ShouldBe(Path.Combine(InstancePath, "Logs")); } - - [Fact] - public void Nur_Leerzeichen_gelten_als_keine_Angabe() - { - AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath) - .ShouldBe(Path.GetFullPath(ApplicationLogDirectory)); - } } diff --git a/tests/ClawdDotNet.App.Tests/Services/BackupSchedulerTests.cs b/tests/ClawdDotNet.App.Tests/Services/BackupSchedulerTests.cs new file mode 100644 index 0000000..3731c96 --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/BackupSchedulerTests.cs @@ -0,0 +1,201 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Config; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// Die tägliche Sicherung, seit sie der Instanz gehört (#528, Stufe 1). +/// +/// Schalter, Uhrzeit, Zielordner und Aufbewahrung standen in den +/// Anwendungseinstellungen und galten damit für jede Instanz. Zwei Folgen: Wer die +/// Sicherung für eine Instanz einschaltete, schaltete sie für alle ein — und alle +/// Archive lagen in einem Ordner, in dem die Aufbewahrung nach dem Präfix +/// backup_<Instanzname>_ aufräumt. Dass ein Präfix keine Grenze ist, zeigt +/// . +/// +public sealed class BackupSchedulerTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + /// Ein Instanzverzeichnis mit genug Inhalt, dass sich eine Sicherung lohnt. + private string NeueInstanz(string name) + { + var dir = Path.Combine(_root, "Instance-" + name); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "InstanceSettings.json"), $$"""{"instanceName":"{{name}}"}"""); + return dir; + } + + private static InstanceConfig Config(string name, string? backupDirectory = null, int keep = 14) + => new() + { + InstanceName = name, + WorkingDirectory = "", + AutoBackupEnabled = true, + AutoBackupTime = "03:00", + BackupDirectory = backupDirectory ?? "", + BackupKeepCount = keep + }; + + /// + /// Eine Uhr, die auf einer Zeit steht — der Zeitpunkt entscheidet, ob + /// TickAsync etwas tut. + /// + private sealed class FesteUhr(DateTime jetzt) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(jetzt, TimeSpan.Zero); + public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc; + } + + private static BackupScheduler Scheduler(string dir, InstanceConfig config, DateTime jetzt) + => new(dir, config, NullLogger.Instance, new FesteUhr(jetzt)); + + private static DateTime Nachts(int tag) => new(2026, 9, tag, 4, 0, 0, DateTimeKind.Utc); + + // ─── Der Zielordner ─── + + [Fact] + public async Task Ohne_Angabe_landet_die_Sicherung_im_Instanzverzeichnis() + { + var dir = NeueInstanz("Allein"); + + await Scheduler(dir, Config("Allein"), Nachts(1)).TickAsync(); + + var ziel = Path.Combine(dir, "Backups"); + Directory.Exists(ziel).ShouldBeTrue(); + Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(1); + } + + [Fact] + public async Task Zwei_Instanzen_sichern_ohne_Angabe_getrennt() + { + var a = NeueInstanz("Eins"); + var b = NeueInstanz("Zwei"); + + await Scheduler(a, Config("Eins"), Nachts(1)).TickAsync(); + await Scheduler(b, Config("Zwei"), Nachts(1)).TickAsync(); + + Directory.GetFiles(Path.Combine(a, "Backups"), "*.zip").Length.ShouldBe(1); + Directory.GetFiles(Path.Combine(b, "Backups"), "*.zip").Length.ShouldBe(1); + } + + // ─── Der Schalter ─── + + [Fact] + public async Task Ausgeschaltet_passiert_nichts() + { + var dir = NeueInstanz("Ruhig"); + var config = Config("Ruhig"); + config.AutoBackupEnabled = false; + + await Scheduler(dir, config, Nachts(1)).TickAsync(); + + Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse(); + } + + [Fact] + public async Task Vor_der_eingestellten_Uhrzeit_passiert_nichts() + { + var dir = NeueInstanz("Frueh"); + + await Scheduler(dir, Config("Frueh"), new DateTime(2026, 9, 1, 2, 0, 0, DateTimeKind.Utc)) + .TickAsync(); + + Directory.Exists(Path.Combine(dir, "Backups")).ShouldBeFalse(); + } + + [Fact] + public async Task Zweimal_am_selben_Tag_sichert_nur_einmal() + { + var dir = NeueInstanz("Einmal"); + var scheduler = Scheduler(dir, Config("Einmal"), Nachts(1)); + + await scheduler.TickAsync(); + await scheduler.TickAsync(); + + Directory.GetFiles(Path.Combine(dir, "Backups"), "*.zip").Length.ShouldBe(1); + } + + // ─── Die Aufbewahrung ─── + + [Fact] + public async Task Die_Aufbewahrung_raeumt_die_eigenen_Archive_auf() + { + var dir = NeueInstanz("Alt"); + var ziel = Path.Combine(dir, "Backups"); + Directory.CreateDirectory(ziel); + + // Drei Archive aus der Vergangenheit, die aelter sind als das gleich entstehende. + foreach (var tag in new[] { 1, 2, 3 }) + { + var datei = Path.Combine(ziel, $"backup_Alt_2026-08-0{tag}_0300.zip"); + File.WriteAllText(datei, "alt"); + File.SetLastWriteTime(datei, new DateTime(2026, 8, tag, 3, 0, 0)); + } + + await Scheduler(dir, Config("Alt", keep: 2), Nachts(1)).TickAsync(); + + Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2); + } + + [Fact] + public async Task Aufbewahrung_null_behaelt_alles() + { + var dir = NeueInstanz("Sammler"); + var ziel = Path.Combine(dir, "Backups"); + Directory.CreateDirectory(ziel); + File.WriteAllText(Path.Combine(ziel, "backup_Sammler_2026-08-01_0300.zip"), "alt"); + + await Scheduler(dir, Config("Sammler", keep: 0), Nachts(1)).TickAsync(); + + Directory.GetFiles(ziel, "*.zip").Length.ShouldBe(2); + } + + /// + /// Warum der Zielordner der Instanz gehören muss. + /// + /// Die Aufbewahrung sucht nach dem Präfix backup_<Instanzname>_, + /// und ein Präfix ist keine Grenze: backup_Test_2_….zip — das Archiv der + /// Instanz Test_2 — fängt mit backup_Test_ an. Im gemeinsamen Ordner + /// zählt Test es also zu den eigenen und wirft es weg. Getrennte Ordner + /// nehmen der Namensfrage ihre Wirkung: Der Test hier stellt den alten Zustand + /// eigens wieder her, indem er beiden Instanzen denselben Ordner einträgt. + /// + [Fact] + public async Task Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus() + { + var gemeinsam = Path.Combine(_root, "Backups"); + Directory.CreateDirectory(gemeinsam); + + var fremd = Path.Combine(gemeinsam, "backup_Test_2_2026-08-01_0300.zip"); + File.WriteAllText(fremd, "gehoert Test_2"); + File.SetLastWriteTime(fremd, new DateTime(2026, 8, 1, 3, 0, 0)); + + var dir = NeueInstanz("Test"); + await Scheduler(dir, Config("Test", gemeinsam, keep: 1), Nachts(1)).TickAsync(); + + File.Exists(fremd).ShouldBeFalse( + "Der alte Zustand ist damit belegt — im gemeinsamen Ordner räumt eine " + + "Instanz über ihre eigenen Archive hinaus auf."); + + // Und die Gegenprobe: mit getrennten Ordnern kommen sie sich nicht in die Quere. + var eigen = NeueInstanz("Test2"); + var eigenesArchiv = Path.Combine(eigen, "Backups", "backup_Test2_2026-08-01_0300.zip"); + Directory.CreateDirectory(Path.GetDirectoryName(eigenesArchiv)!); + File.WriteAllText(eigenesArchiv, "gehoert Test2"); + + var dir2 = NeueInstanz("Test3"); + await Scheduler(dir2, Config("Test3", keep: 1), Nachts(2)).TickAsync(); + + File.Exists(eigenesArchiv).ShouldBeTrue(); + } +} diff --git a/tests/ClawdDotNet.App.Tests/Services/InstanceLogDirectoryMigrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/InstanceLogDirectoryMigrationTests.cs index 6f590c2..8fdf4ab 100644 --- a/tests/ClawdDotNet.App.Tests/Services/InstanceLogDirectoryMigrationTests.cs +++ b/tests/ClawdDotNet.App.Tests/Services/InstanceLogDirectoryMigrationTests.cs @@ -8,8 +8,11 @@ namespace ClawdDotNet.App.Tests.Services; /// /// Die andere Hälfte von #375: Bestehende Instanzen tragen "logDirectory": "./Logs" /// in ihrer InstanceSettings.json — -/// hat es dort hineingeschrieben. Der Wert hatte nie die Bedeutung, die er vorgab, und -/// wird beim Laden zu „leer", also zur Anwendungseinstellung. +/// hat es dort hineingeschrieben. Beim Laden wird daraus „leer". +/// +/// Seit #528 (Stufe 1) zeigen beide Schreibweisen ohnehin auf denselben Ort: +/// <Instanz>/Logs. Der leere Wert sagt das, ohne es festzuschreiben — zieht +/// die Instanz um, zieht das Protokoll mit. /// /// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn. /// @@ -81,8 +84,31 @@ public sealed class InstanceLogDirectoryMigrationTests : IDisposable public void Eine_neue_Instanz_bekommt_gar_keinen_Wert() { var dir = _manager.CreateInstance("Frisch"); + var config = _manager.LoadInstanceConfig(dir); + + config.LogDirectory.ShouldBe(""); + config.BackupDirectory.ShouldBe(""); - _manager.LoadInstanceConfig(dir).LogDirectory.ShouldBe(""); File.ReadAllText(Path.Combine(dir, "InstanceSettings.json")).ShouldNotContain("./Logs"); } + + /// + /// Der eigentliche Zweck von Stufe 1: Zwei frisch angelegte Instanzen protokollieren + /// und sichern nicht in denselben Ordner. + /// + [Fact] + public void Zwei_frische_Instanzen_landen_nicht_im_selben_Ordner() + { + var a = _manager.CreateInstance("Eins"); + var b = _manager.CreateInstance("Zwei"); + + var configA = _manager.LoadInstanceConfig(a); + var configB = _manager.LoadInstanceConfig(b); + + AppHost.ResolveLogDirectory(configA, a) + .ShouldNotBe(AppHost.ResolveLogDirectory(configB, b)); + + AppHost.ResolveBackupDirectory(configA, a) + .ShouldNotBe(AppHost.ResolveBackupDirectory(configB, b)); + } } diff --git a/tests/ClawdDotNet.App.Tests/Services/InstanceSecretRoundtripTests.cs b/tests/ClawdDotNet.App.Tests/Services/InstanceSecretRoundtripTests.cs new file mode 100644 index 0000000..2260c1e --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/InstanceSecretRoundtripTests.cs @@ -0,0 +1,92 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Config; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// Zugangsdaten der Instanz müssen den Weg über die Platte und zurück überstehen. +/// +/// SaveInstanceConfig verschlüsselte beim Schreiben, LoadInstanceConfig +/// entschlüsselte beim Lesen aber nicht. Was einmal über die Oberfläche gespeichert war, +/// kam als enc:v2:… zurück und wurde als Wert benutzt — der OpenRouter-Schlüssel +/// ging so verschlüsselt an die API. +/// +/// Unentdeckt geblieben ist es, weil ConfigLoader.LoadAsync es richtig macht +/// und im laufenden Programm gar nicht aufgerufen wird: Gebraucht wird von dieser Klasse +/// nur Migrate. +/// +public sealed class InstanceSecretRoundtripTests +{ + [Fact] + public void Zugangsdaten_kommen_im_Klartext_zurueck() + { + using var host = new TestHost(); + + host.Host.Instance.OpenRouterApiKey = "sk-or-v1-beispielschluessel"; + host.Host.Instance.WebUiToken = "ein-hinreichend-langes-token"; + host.Host.Instance.Watchdog.AgentToken = "dc-token-beispiel"; + + host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + + var geladen = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath); + + geladen.OpenRouterApiKey.ShouldBe("sk-or-v1-beispielschluessel"); + geladen.WebUiToken.ShouldBe("ein-hinreichend-langes-token"); + geladen.Watchdog.AgentToken.ShouldBe("dc-token-beispiel"); + } + + [Fact] + public void In_der_Datei_stehen_sie_trotzdem_geschuetzt() + { + // Die Gegenprobe: Entschluesseln beim Laden darf nicht heissen, dass gar nicht + // mehr verschluesselt wird. + using var host = new TestHost(); + + host.Host.Instance.OpenRouterApiKey = "sk-or-v1-beispielschluessel"; + host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + + var rohtext = File.ReadAllText( + Path.Combine(host.Host.InstancePath, "InstanceSettings.json")); + + rohtext.ShouldNotContain("sk-or-v1-beispielschluessel"); + } + + [Fact] + public void Klartext_aus_aelteren_Dateien_bleibt_lesbar() + { + // Entschluesseln muss gefahrlos auf etwas anwendbar sein, das nie verschluesselt + // war — sonst braeche der erste Start nach dem Update jede bestehende Instanz. + using var host = new TestHost(); + + var pfad = Path.Combine(host.Host.InstancePath, "InstanceSettings.json"); + + File.WriteAllText(pfad, """ + { + "instanceId": "alt", + "instanceName": "Alt", + "openRouterApiKey": "klartext-von-hand-eingetragen", + "webUiToken": "auch-klartext" + } + """); + + var geladen = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath); + + geladen.OpenRouterApiKey.ShouldBe("klartext-von-hand-eingetragen"); + geladen.WebUiToken.ShouldBe("auch-klartext"); + } + + [Fact] + public void Zweimal_speichern_verschluesselt_nicht_zweimal() + { + using var host = new TestHost(); + + host.Host.Instance.WebUiToken = "ein-hinreichend-langes-token"; + + host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + host.Host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + + host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath) + .WebUiToken.ShouldBe("ein-hinreichend-langes-token"); + } +} diff --git a/tests/ClawdDotNet.App.Tests/Settings/SettingsManagerTests.cs b/tests/ClawdDotNet.App.Tests/Settings/SettingsManagerTests.cs new file mode 100644 index 0000000..24a01bc --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Settings/SettingsManagerTests.cs @@ -0,0 +1,106 @@ +using ClawdDotNet.App.Settings; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Settings; + +/// +/// Was mit den Einstellungen geschieht, die es hier nicht mehr gibt (#528, Stufe 1). +/// +/// Der Anlass. Fünf Schlüssel sind aus in die +/// Instanz gewandert. System.Text.Json überliest unbekannte Schlüssel wortlos, und +/// Save schreibt sie danach nicht wieder — wer ein eigenes Log- oder +/// Sicherungsverzeichnis eingetragen hatte, verlöre den Wert also, ohne es zu erfahren. +/// Ein Umzug ohne Ausweichlogik ist in Ordnung; einer ohne Ansage nicht. +/// +/// Bewusst keine Übernahme in die Instanzen: Ein anwendungsweiter Pfad, in alle +/// Instanzen kopiert, wäre wieder derselbe Ordner für alle — also genau der Zustand, den +/// Stufe 1 beseitigt. +/// +public sealed class SettingsManagerTests : IDisposable +{ + private readonly string _dir = Path.Combine( + Path.GetTempPath(), "clawd-tests", "settings-" + Guid.NewGuid().ToString("N")[..8]); + + private string Datei => Path.Combine(_dir, "Settings.json"); + + public SettingsManagerTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + private SettingsManager Geladen(string json) + { + File.WriteAllText(Datei, json); + var manager = new SettingsManager(_dir); + manager.Load(); + return manager; + } + + [Fact] + public void Eine_alte_Datei_meldet_ihre_aufgegebenen_Schluessel() + { + var manager = Geladen(""" + { + "logDirectory": "/var/log/clawd", + "backupDirectory": "/srv/sicherungen", + "backupKeepCount": 30, + "autoBackupEnabled": true, + "autoBackupTime": "02:30", + "minimumLogLevel": "Debug" + } + """); + + manager.AbandonedKeys.ShouldBe( + ["logDirectory", "backupDirectory", "backupKeepCount", + "autoBackupEnabled", "autoBackupTime"], + ignoreOrder: true); + } + + [Fact] + public void Was_die_Datei_sonst_enthaelt_wird_weiter_gelesen() + { + // Die Ansage darf nichts daran ändern, dass der Rest ankommt. + var manager = Geladen(""" + { + "logDirectory": "/var/log/clawd", + "minimumLogLevel": "Debug", + "maxLogLinesInUi": 4000 + } + """); + + manager.AppSettings.MinimumLogLevel.ShouldBe("Debug"); + manager.AppSettings.MaxLogLinesInUi.ShouldBe(4000); + } + + [Fact] + public void Eine_frische_Datei_meldet_nichts() + { + Geladen("""{ "minimumLogLevel": "Info" }""").AbandonedKeys.ShouldBeEmpty(); + } + + [Fact] + public void Speichern_wird_die_aufgegebenen_Schluessel_los() + { + var manager = Geladen("""{ "logDirectory": "/var/log/clawd" }"""); + manager.Save(); + + File.ReadAllText(Datei).ShouldNotContain("logDirectory"); + + // Und der zweite Ladevorgang hat nichts mehr zu melden. + var erneut = new SettingsManager(_dir); + erneut.Load(); + erneut.AbandonedKeys.ShouldBeEmpty(); + } + + [Fact] + public void Eine_unlesbare_Datei_verhindert_den_Start_nicht() + { + var manager = Geladen("{ das ist kein JSON"); + + manager.AppSettings.MinimumLogLevel.ShouldBe("Info"); + manager.AbandonedKeys.ShouldBeEmpty(); + } +} diff --git a/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs b/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs new file mode 100644 index 0000000..385cab1 --- /dev/null +++ b/tests/ClawdDotNet.Web.Tests/ShutdownTests.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using ClawdDotNet.App; +using ClawdDotNet.App.Services; +using ClawdDotNet.App.Settings; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Tools; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Web.Tests; + +/// +/// Die Weboberfläche muss sich zügig beenden lassen — auch mit offenen Ereignisströmen. +/// +/// Der Anlass. Ein Ereignisstrom endet nie von selbst. Beim geordneten +/// Herunterfahren legt der Browser nicht auf, also feuert RequestAborted nicht, +/// und Kestrel wartet geduldig darauf, dass die Anfrage fertig wird. Erst nach der Frist +/// von StopAsync wurde abgeschnitten: fünf Sekunden, in denen die Anwendung stand. +/// Sichtbar wurde es beim Instanzwechsel — zwischen „Wechseln" und dem Neustart lag jedes +/// Mal diese Pause, sobald irgendwo ein Browserfenster offen war. +/// +/// Die Ströme hängen jetzt zusätzlich an ApplicationStopping. Dieser Test +/// hält die Zusage fest, damit sie nicht beim nächsten Umbau still verlorengeht. +/// +public sealed class ShutdownTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "web-" + Guid.NewGuid().ToString("N")[..8]); + + private const string Token = "ein-hinreichend-langes-testtoken"; + + /// + /// Grosszuegig bemessen: Gemessen wird gegen die Frist von StopAsync (fuenf + /// Sekunden). Bleibt der Test unter zwei, ist die Zusage eingehalten, ohne dass ein + /// langsamer Bauserver ihn rot faerbt. + /// + private static readonly TimeSpan Grenze = TimeSpan.FromSeconds(2); + + private AppHost NeuerHost(int port) + { + var instancesDir = Path.Combine(_root, "Instances"); + var instancePath = Path.Combine(instancesDir, "Test"); + Directory.CreateDirectory(instancePath); + + return new AppHost + { + Settings = new SettingsManager(_root), + Directories = new InstanceDirectoryManager(instancesDir), + Instance = new InstanceConfig + { + InstanceName = "Test", + WebUiEnabled = true, + WebUiBinding = "loopback", + WebUiToken = Token, + WebServerPort = port + }, + InstancePath = instancePath, + LogDirectory = Path.Combine(_root, "Logs"), + LoggerFactory = NullLoggerFactory.Instance, + Tools = new ToolRegistry() + }; + } + + /// + /// Ein freier Port vom Betriebssystem. Eine feste Zahl liesse zwei Testlaeufe + /// nebeneinander scheitern — und genau solche Fehler sucht man am laengsten. + /// + private static int FreierPort() + { + using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + probe.Start(); + var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + [Fact] + public async Task Beenden_wartet_nicht_auf_einen_offenen_Ereignisstrom() + { + var port = FreierPort(); + await using var host = NeuerHost(port); + + var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}"); + + // Den Strom wirklich oeffnen und die erste Zeile abwarten: Erst dann haelt + // Kestrel die Anfrage, und erst dann prueft der Test das, was er pruefen will. + using var antwort = await client.GetAsync( + $"http://127.0.0.1:{port}/events?token={Token}", + HttpCompletionOption.ResponseHeadersRead); + + antwort.EnsureSuccessStatusCode(); + + await using var strom = await antwort.Content.ReadAsStreamAsync(); + using var leser = new StreamReader(strom); + (await leser.ReadLineAsync()).ShouldBe(": bereit"); + + var uhr = Stopwatch.StartNew(); + await web!.DisposeAsync(); + uhr.Stop(); + + uhr.Elapsed.ShouldBeLessThan(Grenze, + $"Das Beenden dauerte {uhr.Elapsed.TotalSeconds:0.0}s — der Ereignisstrom " + + "hat das Herunterfahren nicht bemerkt."); + } + + [Fact] + public async Task Beenden_geht_auch_ohne_offene_Verbindung_zuegig() + { + var port = FreierPort(); + await using var host = NeuerHost(port); + + var web = WebUiHost.TryStart(host, NullLoggerFactory.Instance); + web.ShouldNotBeNull(); + + var uhr = Stopwatch.StartNew(); + await web!.DisposeAsync(); + uhr.Stop(); + + uhr.Elapsed.ShouldBeLessThan(Grenze); + } + + [Fact] + public void Ein_belegter_Port_haelt_die_Anwendung_nicht_auf() + { + var port = FreierPort(); + + using var blockierer = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, port); + blockierer.Start(); + + var host = NeuerHost(port); + + // Kein Wurf, sondern null: Ein Fenster, das nicht aufgeht, weil ein Webserver + // keine Adresse bekommt, waere genau dann im Weg, wenn man es braucht. + WebUiHost.TryStart(host, NullLoggerFactory.Instance).ShouldBeNull(); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } +}