Instanzmanager Stufe 1: Protokolle und Sicherungen je Instanz (#528)

Mehrere Instanzen sollen unabhaengig voneinander laufen. Heute tun sie das
nicht: Sie teilen sich Protokollverzeichnis, Sicherungsordner und
Sicherungszeitplan. In einer gemeinsamen Startup.log standen abwechselnd
Zeilen zweier Instanzen, ohne Kennzeichnung, welche gerade spricht -- im
Mehrinstanzbetrieb ist damit jede Fehlersuche Ratearbeit. Genau in dieser
Lage ist der Aufhaenger aus #529 entstanden.

Fuenf Schluessel wandern aus AppSettings nach InstanceSettings.json:
logDirectory, backupDirectory, backupKeepCount, autoBackupEnabled,
autoBackupTime. Leer heisst jetzt <Instanz>/Logs beziehungsweise
<Instanz>/Backups; ein relativer Wert zaehlt weiterhin gegen das
Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses
(#375 bleibt gewahrt).

autoBackupEnabled und autoBackupTime standen in der Planung nicht, sind
aber mitgegangen: Ein Schalter, der die taegliche Sicherung fuer eine
Instanz einschaltet und sie damit fuer alle einschaltet, ist derselbe
Fehler wie ein Ordner fuer alle -- und zwei von vier Sicherungsfeldern zu
verschieben hinterliesse eine Einstellungsseite, auf der die Haelfte
anwendungsweit gilt.

Keine Uebernahme der Altwerte, aber eine Ansage: SettingsManager.
AbandonedKeys meldet die aufgegebenen Schluessel, solange sie noch in
Settings.json stehen, und der Start protokolliert einmal, was nicht mehr
gilt und wohin diese Instanz stattdessen schreibt. Ein anwendungsweiter
Pfad, in alle Instanzen kopiert, waere wieder ein Ordner fuer alle.

webServerPort bleibt vorerst stehen. Es entfaellt mit Stufe 2, sobald
Kestrel auf dem Socket hoert; vorher wegzunehmen hiesse, die
Weboberflaeche ohne Ersatz abzuschalten.

Korrektur an der eigenen Befundlage: "Instanz A wirft die Sicherungen von
Instanz B weg" war zu breit. Beide Rotationen filtern bereits nach dem
Praefix backup_<Instanzname>_. Der reale Defekt ist enger, und ein Praefix
ist keine Grenze: backup_Test_2_*.zip faengt mit backup_Test_ an, also
zaehlt Test im gemeinsamen Ordner die Archive von Test_2 mit. Belegt in
BackupSchedulerTests, das den alten Zustand eigens wiederherstellt.

Nachgeprueft an der laufenden Installation, nicht nur im Test: Nach dem
Umbau schreibt Instance-Test nach Instances/Instance-Test/Logs, das alte
gemeinsame Verzeichnis bekommt keine Zeile mehr, und die Warnung zu den
aufgegebenen Schluesseln erscheint genau einmal.

Enthaelt ausserdem, was seit der Weboberflaeche liegengeblieben war:

- #526: Instanzgeheimnisse wurden beim Speichern verschluesselt und beim
  Laden nie entschluesselt. LoadInstanceConfig hatte kein
  ConfigSecrets.Unprotect; ConfigLoader.LoadAsync macht es richtig, wird
  zur Laufzeit aber gar nicht aufgerufen. Der OpenRouter-Schluessel ging
  so verschluesselt an die API.
- Ein offener Ereignisstrom hielt das Beenden fuenf Sekunden auf. Die
  Stroeme hingen nur an RequestAborted, das beim geordneten Herunterfahren
  nicht feuert; jetzt zusaetzlich an ApplicationStopping. Der Fang in
  WebUiHost gilt dazu ohne Bedingung auf RequestAborted -- mit ihr waere
  jeder offene Strom beim Beenden als Fehler im Bugtracker gelandet.
- Nach der Anmeldung blieb die Weboberflaeche leer: #anmeldung
  { display:flex } schlaegt als ID-Selektor das hidden-Attribut.
- docs/Instanzmanager-Konzept.md: Befund, die Entscheidungen E-M1 bis
  E-M6, der Schnitt zwischen Manager und Instanz und die sechs Stufen.

Build ohne Warnungen, 709 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 21:06:40 +02:00
co-authored by Claude Opus 5
parent 928da7e6af
commit facd21459c
20 changed files with 1404 additions and 158 deletions
+29 -1
View File
@@ -68,7 +68,11 @@ Oder manuell die Ordnerstruktur erzeugen. Die `InstanceSettings.json` hat dieses
"instanceName": "MeinProjekt", "instanceName": "MeinProjekt",
"openRouterApiKey": "", "openRouterApiKey": "",
"workingDirectory": "", "workingDirectory": "",
"logDirectory": "./Logs", "logDirectory": "",
"backupDirectory": "",
"autoBackupEnabled": false,
"autoBackupTime": "03:00",
"backupKeepCount": 14,
"webServerPort": 7001, "webServerPort": 7001,
"agents": [], "agents": [],
"services": [] "services": []
@@ -77,6 +81,30 @@ Oder manuell die Ordnerstruktur erzeugen. Die `InstanceSettings.json` hat dieses
**Wichtig:** Der `openRouterApiKey` muss gesetzt werden, bevor Agenten funktionieren. **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 `<Instanzverzeichnis>/Logs` beziehungsweise
`<Instanzverzeichnis>/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_<Instanzname>_` 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 ### 3.2 Agenten anlegen
Pro Agent wird ein Ordner `Agent-{Name}` unter `Agents/` erstellt. Pro Agent wird ein Ordner `Agent-{Name}` unter `Agents/` erstellt.
+359
View File
@@ -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_<Instanzname>_` 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
(`<Instanz>/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 | `<Instanz>/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 `<Instanz>/Logs` und `<Instanz>/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.
+50 -18
View File
@@ -196,13 +196,13 @@ public sealed class AppHost : IAsyncDisposable
public readonly record struct StartupResult(AppHost? Host, string? Error); public readonly record struct StartupResult(AppHost? Host, string? Error);
/// <summary> /// <summary>
/// Wohin protokolliert wird. Zwei Regeln, die vorher beide nicht galten: /// Wohin diese Instanz protokolliert. Zwei Regeln:
/// ///
/// <para><b>Leer heißt „nimm die Anwendungseinstellung".</b> Die steht absolut unter /// <para><b>Leer heißt <c>&lt;Instanzverzeichnis&gt;/Logs</c></b> — nicht mehr das
/// <c>AppPaths.DataDirectory</c> und ist der Wert, den die Einstellungsseite zeigt. /// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien
/// Vorher war <c>InstanceConfig.LogDirectory</c> mit <c>"./Logs"</c> vorbelegt und /// schrieben, stand in <c>Startup.log</c> abwechselnd, was zwei Instanzen zu sagen
/// damit nie leer — der Zweig auf die Anwendungseinstellung war unerreichbar, und /// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
/// das Feld auf der Einstellungsseite ließ sich ändern, ohne etwas zu ändern.</para> /// Fehlersuche damit Ratearbeit (#528, Stufe 1).</para>
/// ///
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen /// <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 /// 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.</para> /// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
/// </summary> /// </summary>
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param> /// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
/// <param name="applicationLogDirectory">Der Wert aus den Anwendungseinstellungen.</param>
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param> /// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
public static string ResolveLogDirectory( public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
InstanceConfig instance, string applicationLogDirectory, string instancePath) => ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath);
{
var configured = !string.IsNullOrWhiteSpace(instance.LogDirectory)
? instance.LogDirectory
: applicationLogDirectory;
/// <summary>
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
/// <c>&lt;Instanzverzeichnis&gt;/Backups</c>.
///
/// <para>Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt
/// dort nach dem Präfix <c>backup_&lt;Instanzname&gt;_</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 // InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten
// Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann, // Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann,
// wenn jemand das Feld von Hand geleert oder relativ gesetzt hat. // wenn jemand das Feld von Hand geleert oder relativ gesetzt hat.
@@ -228,6 +242,9 @@ public sealed class AppHost : IAsyncDisposable
? instance.WorkingDirectory ? instance.WorkingDirectory
: instancePath; : instancePath;
if (string.IsNullOrWhiteSpace(configured))
return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder));
return Path.GetFullPath( return Path.GetFullPath(
Path.IsPathRooted(configured) Path.IsPathRooted(configured)
? configured ? configured
@@ -260,8 +277,7 @@ public sealed class AppHost : IAsyncDisposable
} }
// ─── 3. Protokollierung ─── // ─── 3. Protokollierung ───
var logDirectory = ResolveLogDirectory( var logDirectory = ResolveLogDirectory(instance, instancePath);
instance, settings.AppSettings.LogDirectory, instancePath);
var minLevel = Enum.TryParse<Core.Logging.LogLevel>( var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
settings.AppSettings.MinimumLogLevel, true, out var parsed) settings.AppSettings.MinimumLogLevel, true, out var parsed)
@@ -274,8 +290,24 @@ public sealed class AppHost : IAsyncDisposable
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})", logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
instance.InstanceName, instance.InstanceId); instance.InstanceName, instance.InstanceId);
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath); logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
logger.LogInformation("Protokoll-Verzeichnis: {Path}", logDirectory);
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath); 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 ─── // ─── 4. Lizenz ───
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"), var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
callbacks.License); callbacks.License);
@@ -324,10 +356,10 @@ public sealed class AppHost : IAsyncDisposable
await host.StartDeploymentcenterAsync(loggerFactory, ct); await host.StartDeploymentcenterAsync(loggerFactory, ct);
// Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und // Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und
// Zielordner in die Einstellungen — gestartet wurde der Takt dazu bisher nicht, // Zielordner in die Instanzkonfiguration — gestartet wurde der Takt dazu bisher
// die Einstellung lief also ins Leere. // nicht, die Einstellung lief also ins Leere.
host.Backups = new BackupScheduler( host.Backups = new BackupScheduler(
instancePath, instance.InstanceName, settings, instancePath, instance,
loggerFactory.CreateLogger("ClawdDotNet.Backup")); loggerFactory.CreateLogger("ClawdDotNet.Backup"));
host.Backups.Start(); host.Backups.Start();
+36 -18
View File
@@ -1,5 +1,5 @@
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Backup; using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -12,8 +12,16 @@ namespace ClawdDotNet.App.Services;
/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten /// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
/// mitsichern will, macht das von Hand. /// mitsichern will, macht das von Hand.
/// ///
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit /// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft,
/// eine Änderung ohne Neustart greift. /// damit eine Änderung ohne Neustart greift.
///
/// <para><b>Schalter, Uhrzeit, Zielordner und Aufbewahrung gehören der Instanz</b>
/// (#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
/// <see cref="ApplyRotation"/> nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c> auf —
/// bei zwei Namen, die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine
/// Instanz die Archive der anderen mit und wirft die überzähligen weg.</para>
/// ///
/// <para><b>Takt.</b> Früher ein <c>System.Windows.Forms.Timer</c> — der braucht eine /// <para><b>Takt.</b> Früher ein <c>System.Windows.Forms.Timer</c> — der braucht eine
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über /// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über
@@ -23,8 +31,7 @@ namespace ClawdDotNet.App.Services;
public sealed class BackupScheduler : IAsyncDisposable public sealed class BackupScheduler : IAsyncDisposable
{ {
private readonly string _instanceDir; private readonly string _instanceDir;
private readonly string _instanceName; private readonly InstanceConfig _instance;
private readonly SettingsManager _settings;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly TimeProvider _clock; private readonly TimeProvider _clock;
private readonly TimeSpan _tick; private readonly TimeSpan _tick;
@@ -38,17 +45,24 @@ public sealed class BackupScheduler : IAsyncDisposable
public event Action<string>? OnBackupCreated; public event Action<string>? OnBackupCreated;
/// <param name="instanceDir">
/// Das Instanzverzeichnis. Zugleich der Bezugspunkt, gegen den ein relativ
/// eingetragener Zielordner zählt.
/// </param>
/// <param name="instance">
/// 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.
/// </param>
public BackupScheduler( public BackupScheduler(
string instanceDir, string instanceDir,
string instanceName, InstanceConfig instance,
SettingsManager settings,
ILogger logger, ILogger logger,
TimeProvider? clock = null, TimeProvider? clock = null,
TimeSpan? tick = null) TimeSpan? tick = null)
{ {
_instanceDir = instanceDir; _instanceDir = instanceDir;
_instanceName = instanceName; _instance = instance;
_settings = settings;
_logger = logger; _logger = logger;
_clock = clock ?? TimeProvider.System; _clock = clock ?? TimeProvider.System;
@@ -79,11 +93,10 @@ public sealed class BackupScheduler : IAsyncDisposable
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary> /// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
public async Task TickAsync() public async Task TickAsync()
{ {
var settings = _settings.AppSettings; if (!_instance.AutoBackupEnabled)
if (!settings.AutoBackupEnabled)
return; return;
if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled)) if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled))
return; return;
var now = _clock.GetLocalNow().DateTime; var now = _clock.GetLocalNow().DateTime;
@@ -96,7 +109,10 @@ public sealed class BackupScheduler : IAsyncDisposable
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem. // sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
_lastRun = now; _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) 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 // PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
// nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter // nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
// Windows nicht mehr anlegen lässt. // Windows nicht mehr anlegen lässt.
var safeName = PortableFileName.Sanitize(_instanceName, "Instanz"); var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz");
var file = Path.Combine( // Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten
Path.GetFullPath(folder), // Lauf erst entstehen.
$"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip"); 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 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)", _logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
result.ZipPath, result.SizeBytes); result.ZipPath, result.SizeBytes);
ApplyRotation(Path.GetFullPath(folder), safeName, keepCount); ApplyRotation(folder, safeName, keepCount);
OnBackupCreated?.Invoke(result.ZipPath); OnBackupCreated?.Invoke(result.ZipPath);
} }
@@ -120,10 +120,9 @@ public sealed class InstanceDirectoryManager
{ {
InstanceId = Guid.NewGuid().ToString("N")[..8], InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName, InstanceName = instanceName,
// LogDirectory bleibt leer: dann gilt die Anwendungseinstellung, und die // LogDirectory und BackupDirectory bleiben leer: Dann gelten
// steht absolut. Hier stand vorher "./Logs" — ein Wert, der jede neue // <Instanz>/Logs und <Instanz>/Backups. Hier stand vorher "./Logs" — ein
// Instanz an das Arbeitsverzeichnis des Prozesses band und die // Wert, der jede neue Instanz an das Arbeitsverzeichnis des Prozesses band.
// Einstellungsseite wirkungslos machte.
WorkingDirectory = instanceDir WorkingDirectory = instanceDir
}; };
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config); SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
@@ -153,11 +152,24 @@ public sealed class InstanceDirectoryManager
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts) var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig."); ?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Migration: Die alte Vorbelegung "./Logs" wird zu „leer" — also zur // Zugangsdaten der Instanz lesbar machen — OpenRouter-Schlüssel, Watchdog-Token,
// Anwendungseinstellung. Der Wert hatte nie die Bedeutung, die er vorgab: Er // Telegram-Zugang, Token der Weboberfläche.
// wurde gegen das Arbeitsverzeichnis des Prozesses aufgelöst, nicht gegen das //
// Instanzverzeichnis. Wer bewusst einen anderen Pfad eingetragen hat, behält // <para>Das fehlte. SaveInstanceConfig verschlüsselt beim Schreiben, hier wurde
// ihn; nur genau die Vorbelegung fällt weg. // 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.</para>
//
// <para>Gefahrlos für Klartext aus älteren Dateien: SecretProtector.Unprotect
// gibt zurück, was es nicht als geschützt erkennt.</para>
ConfigSecrets.Unprotect(config);
// Die alte Vorbelegung "./Logs" wird zu „leer". Beide Schreibweisen zeigen
// seit #528 (Stufe 1) auf denselben Ort — <Instanz>/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 // Nur im Speicher: Die Datei bleibt unangetastet, damit ein Ladevorgang nichts
// zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst. // zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst.
+16 -33
View File
@@ -14,16 +14,16 @@ namespace ClawdDotNet.App.Settings;
/// </summary> /// </summary>
public sealed class AppSettings public sealed class AppSettings
{ {
// Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis. // logDirectory ist hier entfallen (#528, Stufe 1). Es war anwendungsweit und damit
// Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht // fuer alle Instanzen derselbe Ordner: In Startup.log standen abwechselnd Zeilen
// schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde. // zweier Instanzen ohne Kennzeichnung, welche gerade spricht. Wohin eine Instanz
// Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths). // protokolliert, steht jetzt in ihrer InstanceSettings.json; leer heisst
[Category("Allgemein")] // <Instanzverzeichnis>/Logs (siehe AppHost.ResolveLogDirectory).
[DisplayName("Log-Verzeichnis")] //
[Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")] // Die Vorgabe war "./Instances" — relativ zum Arbeitsverzeichnis. Unter Linux liegt
[JsonPropertyName("logDirectory")] // die Anwendung in /opt oder /usr/lib und darf dort nicht schreiben; zudem hing der
public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs"); // Ort davon ab, aus welchem Verzeichnis gestartet wurde. Jetzt ein absoluter Pfad im
// Datenverzeichnis des Benutzers (siehe AppPaths).
[Category("Allgemein")] [Category("Allgemein")]
[DisplayName("Instanzen-Verzeichnis")] [DisplayName("Instanzen-Verzeichnis")]
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")] [Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
@@ -104,29 +104,12 @@ public sealed class AppSettings
[JsonPropertyName("openRouterBaseUrl")] [JsonPropertyName("openRouterBaseUrl")]
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/"; public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
[Category("Backup")] // Die vier Sicherungs-Einstellungen sind hier entfallen (#528, Stufe 1) und stehen
[DisplayName("Backup-Verzeichnis")] // jetzt in InstanceSettings.json. Anwendungsweit galten sie fuer jede Instanz: Wer
[Description("Ordner, in dem Sicherungen abgelegt werden.")] // die taegliche Sicherung fuer eine einschaltete, schaltete sie fuer alle ein, und
[JsonPropertyName("backupDirectory")] // alle Archive lagen in einem Ordner. Dort raeumt die Aufbewahrung nach dem Praefix
public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups"); // backup_<Instanzname>_ auf — bei zwei Namen, die ineinander liegen (Test und
// Test_2), zaehlt die eine Instanz die Archive der anderen mit.
[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;
// ─── Deploymentcenter ─── // ─── Deploymentcenter ───
// //
@@ -34,10 +34,40 @@ public sealed class SettingsManager
PropertyNameCaseInsensitive = true PropertyNameCaseInsensitive = true
}; };
/// <summary>
/// Schlüssel, die es in <see cref="AppSettings"/> einmal gab und die dorthin nicht
/// zurückkommen. Steht eine alte Datei noch voll davon, sagt der Start das einmal an
/// (siehe <see cref="AbandonedKeys"/>).
/// </summary>
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; private readonly string _settingsPath;
public AppSettings AppSettings { get; private set; } = new(); public AppSettings AppSettings { get; private set; } = new();
/// <summary>
/// Welche der aufgegebenen Schlüssel beim letzten <see cref="Load"/> noch in der
/// Datei standen.
///
/// <para>System.Text.Json überliest unbekannte Schlüssel wortlos, und
/// <see cref="Save"/> 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.</para>
///
/// <para>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.</para>
/// </summary>
public IReadOnlyList<string> AbandonedKeys { get; private set; } = [];
/// <summary>Der Ort der Einstellungsdatei — für Meldungen und Diagnose.</summary> /// <summary>Der Ort der Einstellungsdatei — für Meldungen und Diagnose.</summary>
public string SettingsPath => _settingsPath; public string SettingsPath => _settingsPath;
@@ -49,6 +79,8 @@ public sealed class SettingsManager
public void Load() public void Load()
{ {
AbandonedKeys = [];
if (!File.Exists(_settingsPath)) if (!File.Exists(_settingsPath))
{ {
AppSettings = new AppSettings(); AppSettings = new AppSettings();
@@ -61,6 +93,8 @@ public sealed class SettingsManager
var json = AtomicFile.ReadAllText(_settingsPath); var json = AtomicFile.ReadAllText(_settingsPath);
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? new AppSettings(); ?? new AppSettings();
AbandonedKeys = FindRetiredKeys(json);
} }
catch (Exception ex) when (ex is JsonException or IOException) catch (Exception ex) when (ex is JsonException or IOException)
{ {
@@ -76,6 +110,36 @@ public sealed class SettingsManager
AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken); AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
} }
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<string> 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 [];
}
}
/// <summary> /// <summary>
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner, /// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner,
/// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen /// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen
+36 -6
View File
@@ -17,17 +17,47 @@ public sealed class InstanceConfig
public string WorkingDirectory { get; set; } = "./data/"; public string WorkingDirectory { get; set; } = "./data/";
/// <summary> /// <summary>
/// Wohin diese Instanz protokolliert. Leer heißt: das Log-Verzeichnis aus den /// Wohin diese Instanz protokolliert. Leer heißt <c>&lt;Instanzverzeichnis&gt;/Logs</c>.
/// Anwendungseinstellungen, also der absolute Pfad unter <c>AppPaths.DataDirectory</c>.
/// ///
/// Die frühere Vorbelegung <c>"./Logs"</c> war nie leer und verdeckte damit die /// <para><b>Je Instanz und nicht anwendungsweit</b> (#528, Stufe 1). Vorher galt bei
/// Anwendungseinstellung vollständig; aufgelöst wurde sie zudem gegen das /// „leer" das Log-Verzeichnis aus den Anwendungseinstellungen — ein Ort für alle
/// Arbeitsverzeichnis des Prozesses. Ein relativer Wert zählt heute gegen das /// Instanzen. In <c>Startup.log</c> standen dann abwechselnd Zeilen zweier Instanzen
/// Instanzverzeichnis (siehe <c>AppHost.StartAsync</c>). /// ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
/// Fehlersuche damit Ratearbeit.</para>
///
/// <para>Ein relativer Wert zählt gegen das Instanzverzeichnis, nicht gegen das
/// Arbeitsverzeichnis des Prozesses (siehe <c>AppHost.ResolveLogDirectory</c>).</para>
/// </summary> /// </summary>
[JsonPropertyName("logDirectory")] [JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = ""; public string LogDirectory { get; set; } = "";
/// <summary>
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
/// <c>&lt;Instanzverzeichnis&gt;/Backups</c>.
///
/// <para><b>Je Instanz</b> (#528, Stufe 1). Vorher lagen die Archive aller Instanzen
/// in einem Ordner, und die Aufbewahrung (<see cref="BackupKeepCount"/>) räumt dort
/// nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c> auf. Liegen zwei Namen
/// ineinander — <c>Test</c> und <c>Test_2</c> —, zählt die eine Instanz die Archive
/// der anderen mit und wirft die überzähligen weg.</para>
/// </summary>
[JsonPropertyName("backupDirectory")]
public string BackupDirectory { get; set; } = "";
/// <summary>Erstellt täglich zur eingestellten Uhrzeit eine Sicherung dieser Instanz.</summary>
[JsonPropertyName("autoBackupEnabled")]
public bool AutoBackupEnabled { get; set; }
/// <summary>Tageszeit der automatischen Sicherung, <c>HH:mm</c>.</summary>
[JsonPropertyName("autoBackupTime")]
public string AutoBackupTime { get; set; } = "03:00";
/// <summary>
/// Wie viele Sicherungen dieser Instanz aufbewahrt werden. 0 = alle behalten.
/// </summary>
[JsonPropertyName("backupKeepCount")]
public int BackupKeepCount { get; set; } = 14;
/// <summary> /// <summary>
/// Port der Weboberfläche. Je Instanz und nicht anwendungsweit: Zwei Instanzen auf /// Port der Weboberfläche. Je Instanz und nicht anwendungsweit: Zwei Instanzen auf
/// einer Maschine stritten sich sonst um einen Port. /// einer Maschine stritten sich sonst um einen Port.
@@ -1,8 +1,9 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using Avalonia.Threading; using Avalonia.Threading;
using ClawdDotNet.App; using ClawdDotNet.App;
using ClawdDotNet.App.Settings; using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Backup; using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -29,7 +30,14 @@ public sealed partial class BackupPageViewModel : PageViewModel
{ {
private readonly BackupService _service = new(); private readonly BackupService _service = new();
private readonly AppHost? _host; private readonly AppHost? _host;
private readonly SettingsManager? _settingsManager;
/// <summary>
/// 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.
/// </summary>
private readonly InstanceConfig? _instance;
private readonly ILogger? _logger; private readonly ILogger? _logger;
private readonly string _instanceDir; private readonly string _instanceDir;
private readonly string _instanceName; private readonly string _instanceName;
@@ -88,7 +96,7 @@ public sealed partial class BackupPageViewModel : PageViewModel
{ {
_instanceDir = host.InstancePath; _instanceDir = host.InstancePath;
_instanceName = host.Instance.InstanceName; _instanceName = host.Instance.InstanceName;
_settingsManager = host.Settings; _instance = host.Instance;
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup"); _logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup");
LoadSettings(); LoadSettings();
@@ -108,17 +116,19 @@ public sealed partial class BackupPageViewModel : PageViewModel
private void LoadSettings() private void LoadSettings()
{ {
if (_settingsManager is null) return; if (_host is null || _instance is null) return;
_loading = true; _loading = true;
try try
{ {
var settings = _settingsManager.AppSettings; // Aufgeloest und nicht roh angezeigt: In der Datei steht in aller Regel
TargetFolder = Path.GetFullPath(settings.BackupDirectory); // nichts, und ein leeres Feld beantwortet die Frage nicht, wohin gesichert
AutoBackupEnabled = settings.AutoBackupEnabled; // wird.
KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999); 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") ? time.ToString(@"hh\:mm")
: "03:00"; : "03:00";
} }
@@ -130,15 +140,23 @@ public sealed partial class BackupPageViewModel : PageViewModel
public void SaveSettings() public void SaveSettings()
{ {
if (_loading || _settingsManager is null) return; if (_loading || _host is null || _instance is null) return;
var settings = _settingsManager.AppSettings; var folder = TargetFolder.Trim();
settings.BackupDirectory = TargetFolder.Trim();
settings.AutoBackupEnabled = AutoBackupEnabled;
settings.AutoBackupTime = AutoBackupTime.Trim();
settings.BackupKeepCount = KeepCount;
_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] [RelayCommand]
@@ -1,5 +1,5 @@
using ClawdDotNet.App; using ClawdDotNet.App;
using ClawdDotNet.App.Settings; using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -14,10 +14,10 @@ public sealed partial class SettingsPageViewModel : PageViewModel
private readonly AppHost? _host; private readonly AppHost? _host;
private readonly ILogger? _logger; private readonly ILogger? _logger;
// ─── Allgemein & Pfade ─── /// <summary>Womit die Seite geladen wurde — nur, um eine Änderung zu erkennen.</summary>
[ObservableProperty] private string _logDirectoryAtLoad = "";
private string _logDirectory = "";
// ─── Allgemein & Pfade ───
[ObservableProperty] [ObservableProperty]
private string _instancesDirectory = ""; private string _instancesDirectory = "";
@@ -70,6 +70,20 @@ public sealed partial class SettingsPageViewModel : PageViewModel
[ObservableProperty] [ObservableProperty]
private string _instanceName = ""; private string _instanceName = "";
/// <summary>
/// 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.
///
/// <para>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.</para>
/// </summary>
[ObservableProperty]
private string _logDirectory = "";
[ObservableProperty] [ObservableProperty]
private string _openRouterApiKey = ""; private string _openRouterApiKey = "";
@@ -109,7 +123,6 @@ public sealed partial class SettingsPageViewModel : PageViewModel
if (_host is null) return; if (_host is null) return;
var s = _host.Settings.AppSettings; var s = _host.Settings.AppSettings;
LogDirectory = s.LogDirectory;
InstancesDirectory = s.InstancesDirectory; InstancesDirectory = s.InstancesDirectory;
MinimumLogLevel = s.MinimumLogLevel; MinimumLogLevel = s.MinimumLogLevel;
MaxLogLinesInUi = s.MaxLogLinesInUi; MaxLogLinesInUi = s.MaxLogLinesInUi;
@@ -126,6 +139,8 @@ public sealed partial class SettingsPageViewModel : PageViewModel
var inst = _host.Instance; var inst = _host.Instance;
InstanceName = inst.InstanceName; InstanceName = inst.InstanceName;
LogDirectory = inst.LogDirectory;
_logDirectoryAtLoad = inst.LogDirectory;
OpenRouterApiKey = inst.OpenRouterApiKey; OpenRouterApiKey = inst.OpenRouterApiKey;
InstanceDailyCostUsd = inst.Budget.DailyCostUsd; InstanceDailyCostUsd = inst.Budget.DailyCostUsd;
InstanceDailyTokens = inst.Budget.DailyTokens; InstanceDailyTokens = inst.Budget.DailyTokens;
@@ -137,7 +152,6 @@ public sealed partial class SettingsPageViewModel : PageViewModel
if (_host is null) return; if (_host is null) return;
var s = _host.Settings.AppSettings; var s = _host.Settings.AppSettings;
s.LogDirectory = LogDirectory.Trim();
s.InstancesDirectory = InstancesDirectory.Trim(); s.InstancesDirectory = InstancesDirectory.Trim();
s.MinimumLogLevel = MinimumLogLevel; s.MinimumLogLevel = MinimumLogLevel;
s.MaxLogLinesInUi = MaxLogLinesInUi; s.MaxLogLinesInUi = MaxLogLinesInUi;
@@ -156,13 +170,21 @@ public sealed partial class SettingsPageViewModel : PageViewModel
var inst = _host.Instance; var inst = _host.Instance;
inst.InstanceName = InstanceName.Trim(); inst.InstanceName = InstanceName.Trim();
inst.LogDirectory = LogDirectory.Trim();
inst.OpenRouterApiKey = OpenRouterApiKey.Trim(); inst.OpenRouterApiKey = OpenRouterApiKey.Trim();
inst.Budget.DailyCostUsd = InstanceDailyCostUsd; inst.Budget.DailyCostUsd = InstanceDailyCostUsd;
inst.Budget.DailyTokens = InstanceDailyTokens; inst.Budget.DailyTokens = InstanceDailyTokens;
_host.Directories.SaveInstanceConfig(_host.InstancePath, inst); _host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
_logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert."); _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;
} }
} }
@@ -42,6 +42,13 @@
<TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" /> <TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" />
</StackPanel> </StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Protokoll-Verzeichnis" />
<TextBox Text="{Binding LogDirectory}" />
<TextBlock Classes="caption" TextWrapping="Wrap" Margin="0,4,0,0"
Text="Gilt nur für diese Instanz. Leer heißt: Unterordner Logs im Instanzverzeichnis." />
</StackPanel>
<Grid ColumnDefinitions="*,16,*"> <Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0"> <StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Tagesbudget ($ USD)" /> <TextBlock Classes="label" Text="Tagesbudget ($ USD)" />
@@ -60,11 +67,6 @@
<StackPanel Spacing="14"> <StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="ALLGEMEIN" /> <TextBlock Classes="kicker" Text="ALLGEMEIN" />
<StackPanel>
<TextBlock Classes="label" Text="Log-Verzeichnis" />
<TextBox Text="{Binding LogDirectory}" />
</StackPanel>
<StackPanel> <StackPanel>
<TextBlock Classes="label" Text="Instanzen-Verzeichnis" /> <TextBlock Classes="label" Text="Instanzen-Verzeichnis" />
<TextBox Text="{Binding InstancesDirectory}" /> <TextBox Text="{Binding InstancesDirectory}" />
@@ -5,6 +5,7 @@ using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts; using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
namespace ClawdDotNet.Web.Endpoints; namespace ClawdDotNet.Web.Endpoints;
@@ -43,8 +44,11 @@ internal static class EventEndpoints
} }
private static async Task StreamCoreEvents( 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<string>(new BoundedChannelOptions(QueueDepth) var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{ {
FullMode = BoundedChannelFullMode.DropOldest, FullMode = BoundedChannelFullMode.DropOldest,
@@ -122,8 +126,12 @@ internal static class EventEndpoints
/// Zeilen bekommt und alle anderen nichts.</para> /// Zeilen bekommt und alle anderen nichts.</para>
/// </summary> /// </summary>
private static async Task StreamLogs( 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 tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse<LogLevelFilter>(level, ignoreCase: true, out var parsed) var filter = Enum.TryParse<LogLevelFilter>(level, ignoreCase: true, out var parsed)
@@ -216,6 +224,24 @@ internal static class EventEndpoints
} }
} }
/// <summary>
/// Verbindet den Abbruch der Anfrage mit dem Herunterfahren der Anwendung.
///
/// <para><b>Ohne das hängt das Beenden an jedem offenen Ereignisstrom.</b>
/// <c>RequestAborted</c> 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 <c>StopAsync</c> wird abgeschnitten, und so lange steht die Anwendung.</para>
///
/// <para>Sichtbar wurde es beim Instanzwechsel: Zwischen „Wechseln" und dem Neustart
/// lagen fünf Sekunden, in denen nichts geschah. Mit einem offenen Browserfenster
/// jedes Mal.</para>
/// </summary>
private static CancellationTokenSource Verbinde(
IHostApplicationLifetime lifetime, CancellationToken requestAborted)
=> CancellationTokenSource.CreateLinkedTokenSource(
requestAborted, lifetime.ApplicationStopping);
private static void PrepareResponse(HttpContext context) private static void PrepareResponse(HttpContext context)
{ {
context.Response.Headers.ContentType = "text/event-stream"; context.Response.Headers.ContentType = "text/event-stream";
+16 -3
View File
@@ -103,6 +103,14 @@ public sealed class WebUiHost : IAsyncDisposable
builder.Logging.ClearProviders(); builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory)); 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.WebHost.UseUrls(options.Url);
builder.Services.AddSingleton(host); builder.Services.AddSingleton(host);
@@ -144,10 +152,15 @@ public sealed class WebUiHost : IAsyncDisposable
{ {
await next(context); await next(context);
} }
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) catch (OperationCanceledException)
{ {
// Der Browser hat aufgelegt. Das ist kein Fehler und schon gar keiner, // Der Browser hat aufgelegt, oder die Anwendung faehrt herunter. Beides
// der ins Deploymentcenter gehoert. // 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) catch (Exception ex)
{ {
+16
View File
@@ -142,6 +142,22 @@ a:hover {
/* ─── Layout Shell ─── */ /* ─── 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 { #anmeldung {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1,80 +1,139 @@
using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Shouldly; using Shouldly;
namespace ClawdDotNet.App.Tests; namespace ClawdDotNet.App.Tests;
/// <summary> /// <summary>
/// #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).
/// ///
/// <c>InstanceConfig.LogDirectory</c> war mit <c>"./Logs"</c> vorbelegt und damit nie /// <para><b>Der Anlass.</b> Beide Vorgaben waren anwendungsweit. In <c>Startup.log</c>
/// leer — der Zweig auf die Anwendungseinstellung war unerreichbar. Aufgelöst wurde der /// standen deshalb abwechselnd Zeilen zweier Instanzen, ohne Kennzeichnung, welche
/// Wert zudem mit <c>Path.GetFullPath</c> ohne Basis, also gegen das Arbeitsverzeichnis /// gerade spricht; und weil die Aufbewahrung nach Präfix in genau einem Ordner
/// des Prozesses: Wo die Logs landeten, hing davon ab, aus welchem Verzeichnis jemand /// aufräumt, warf die laufende Instanz die Archive der anderen mit weg. Solange das so
/// gestartet hatte. Für den Zielbetrieb (systemd-Dienst, startet in <c>/</c>) war das /// ist, ist jede Fehlersuche im Mehrinstanzbetrieb Ratearbeit.</para>
/// <c>/Logs</c> — für ein Dienstkonto nicht anlegbar. ///
/// <para><b>Was aus #375 bleibt.</b> Ein relativer Wert zählt gegen das
/// Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses. Ein
/// systemd-Dienst ohne <c>WorkingDirectory=</c> startet in <c>/</c>, und <c>"./Logs"</c>
/// zeigte dann auf <c>/Logs</c> — für ein Dienstkonto nicht anlegbar.</para>
/// </summary> /// </summary>
public sealed class LogDirectoryResolutionTests public sealed class LogDirectoryResolutionTests
{ {
private static readonly string InstancePath = private static readonly string InstancePath =
Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test")); Path.GetFullPath(Path.Combine(Path.GetTempPath(), "clawd-tests", "Instance-Test"));
private static readonly string ApplicationLogDirectory = private static InstanceConfig Instance(
Path.Combine(AppPaths.DataDirectory, "Logs"); string logDirectory = "", string backupDirectory = "", string? workingDirectory = null)
private static InstanceConfig Instance(string logDirectory, string? workingDirectory = null)
=> new() => new()
{ {
LogDirectory = logDirectory, LogDirectory = logDirectory,
BackupDirectory = backupDirectory,
WorkingDirectory = workingDirectory ?? InstancePath WorkingDirectory = workingDirectory ?? InstancePath
}; };
// ─── Vorgaben ───
[Fact] [Fact]
public void Die_Vorbelegung_ist_leer() public void Die_Vorbelegungen_sind_leer()
{ {
// Der Kern des Befunds: Solange hier "./Logs" stand, konnte die // Der Kern: Leer heisst „nimm den Ordner dieser Instanz". Steht hier ein Wert,
// Anwendungseinstellung nicht greifen. // traegt ihn jede neue Instanz mit sich herum.
new InstanceConfig().LogDirectory.ShouldBe(""); new InstanceConfig().LogDirectory.ShouldBe("");
new InstanceConfig().BackupDirectory.ShouldBe("");
} }
[Fact] [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) AppHost.ResolveLogDirectory(Instance(), InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory)); .ShouldBe(Path.Combine(InstancePath, "Logs"));
} }
[Fact] [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) /// <summary>
.ShouldBe(eigen); /// Das eigentliche Versprechen von Stufe 1. Alles andere in dieser Datei prüft die
/// Mechanik; dieser Test prüft die Zusage.
/// </summary>
[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] [Fact]
public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis() public void Ein_relativer_Wert_zaehlt_gegen_das_Instanzverzeichnis()
{ {
// Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler. // Nicht gegen das Arbeitsverzeichnis des Prozesses — das war der Fehler aus #375.
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath) AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs")); .ShouldBe(Path.Combine(InstancePath, "Protokolle"));
} }
[Fact] [Fact]
public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses() public void Ein_relativer_Wert_haengt_nicht_am_Arbeitsverzeichnis_des_Prozesses()
{ {
AppHost.ResolveLogDirectory(Instance("./Logs"), ApplicationLogDirectory, InstancePath) AppHost.ResolveLogDirectory(Instance(logDirectory: "./Protokolle"), InstancePath)
.ShouldNotBe(Path.GetFullPath("./Logs")); .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] [Fact]
public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck() public void Ein_leeres_WorkingDirectory_faellt_auf_den_Instanzpfad_zurueck()
{ {
// Das Feld steht seit CreateInstance absolut in der Datei, kann aber von Hand // 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. // geleert worden sein. Dann trägt der Ort, aus dem geladen wurde.
AppHost.ResolveLogDirectory( AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: ""), ApplicationLogDirectory, InstancePath) Instance(logDirectory: "Logs", workingDirectory: ""), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs")); .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 // "./data/" ist die alte Vorbelegung des Feldes und wäre wieder gegen das
// Arbeitsverzeichnis des Prozesses aufgelöst worden. // Arbeitsverzeichnis des Prozesses aufgelöst worden.
AppHost.ResolveLogDirectory( AppHost.ResolveLogDirectory(
Instance("Logs", workingDirectory: "./data/"), ApplicationLogDirectory, InstancePath) Instance(logDirectory: "Logs", workingDirectory: "./data/"), InstancePath)
.ShouldBe(Path.Combine(InstancePath, "Logs")); .ShouldBe(Path.Combine(InstancePath, "Logs"));
} }
[Fact]
public void Nur_Leerzeichen_gelten_als_keine_Angabe()
{
AppHost.ResolveLogDirectory(Instance(" "), ApplicationLogDirectory, InstancePath)
.ShouldBe(Path.GetFullPath(ApplicationLogDirectory));
}
} }
@@ -0,0 +1,201 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Die tägliche Sicherung, seit sie der Instanz gehört (#528, Stufe 1).
///
/// <para>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
/// <c>backup_&lt;Instanzname&gt;_</c> aufräumt. Dass ein Präfix keine Grenze ist, zeigt
/// <see cref="Im_gemeinsamen_Ordner_greift_die_Aufbewahrung_ueber_die_Instanz_hinaus"/>.</para>
/// </summary>
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. */ }
}
/// <summary>Ein Instanzverzeichnis mit genug Inhalt, dass sich eine Sicherung lohnt.</summary>
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
};
/// <summary>
/// Eine Uhr, die auf einer Zeit steht — der Zeitpunkt entscheidet, ob
/// <c>TickAsync</c> etwas tut.
/// </summary>
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);
}
/// <summary>
/// Warum der Zielordner der Instanz gehören muss.
///
/// <para>Die Aufbewahrung sucht nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c>,
/// und ein Präfix ist keine Grenze: <c>backup_Test_2_….zip</c> — das Archiv der
/// Instanz <c>Test_2</c> — fängt mit <c>backup_Test_</c> an. Im gemeinsamen Ordner
/// zählt <c>Test</c> 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.</para>
/// </summary>
[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();
}
}
@@ -8,8 +8,11 @@ namespace ClawdDotNet.App.Tests.Services;
/// <summary> /// <summary>
/// Die andere Hälfte von #375: Bestehende Instanzen tragen <c>"logDirectory": "./Logs"</c> /// Die andere Hälfte von #375: Bestehende Instanzen tragen <c>"logDirectory": "./Logs"</c>
/// in ihrer <c>InstanceSettings.json</c> — <see cref="InstanceDirectoryManager.CreateInstance"/> /// in ihrer <c>InstanceSettings.json</c> — <see cref="InstanceDirectoryManager.CreateInstance"/>
/// hat es dort hineingeschrieben. Der Wert hatte nie die Bedeutung, die er vorgab, und /// hat es dort hineingeschrieben. Beim Laden wird daraus „leer".
/// wird beim Laden zu „leer", also zur Anwendungseinstellung. ///
/// <para>Seit #528 (Stufe 1) zeigen beide Schreibweisen ohnehin auf denselben Ort:
/// <c>&lt;Instanz&gt;/Logs</c>. Der leere Wert sagt das, ohne es festzuschreiben — zieht
/// die Instanz um, zieht das Protokoll mit.</para>
/// ///
/// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn. /// Wer bewusst einen anderen Pfad eingetragen hat, behält ihn.
/// </summary> /// </summary>
@@ -81,8 +84,31 @@ public sealed class InstanceLogDirectoryMigrationTests : IDisposable
public void Eine_neue_Instanz_bekommt_gar_keinen_Wert() public void Eine_neue_Instanz_bekommt_gar_keinen_Wert()
{ {
var dir = _manager.CreateInstance("Frisch"); 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"); File.ReadAllText(Path.Combine(dir, "InstanceSettings.json")).ShouldNotContain("./Logs");
} }
/// <summary>
/// Der eigentliche Zweck von Stufe 1: Zwei frisch angelegte Instanzen protokollieren
/// und sichern nicht in denselben Ordner.
/// </summary>
[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));
}
} }
@@ -0,0 +1,92 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// Zugangsdaten der Instanz müssen den Weg über die Platte und zurück überstehen.
///
/// <para><c>SaveInstanceConfig</c> verschlüsselte beim Schreiben, <c>LoadInstanceConfig</c>
/// entschlüsselte beim Lesen aber nicht. Was einmal über die Oberfläche gespeichert war,
/// kam als <c>enc:v2:…</c> zurück und wurde als Wert benutzt — der OpenRouter-Schlüssel
/// ging so verschlüsselt an die API.</para>
///
/// <para>Unentdeckt geblieben ist es, weil <c>ConfigLoader.LoadAsync</c> es richtig macht
/// und im laufenden Programm gar nicht aufgerufen wird: Gebraucht wird von dieser Klasse
/// nur <c>Migrate</c>.</para>
/// </summary>
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");
}
}
@@ -0,0 +1,106 @@
using ClawdDotNet.App.Settings;
using Shouldly;
namespace ClawdDotNet.App.Tests.Settings;
/// <summary>
/// Was mit den Einstellungen geschieht, die es hier nicht mehr gibt (#528, Stufe 1).
///
/// <para><b>Der Anlass.</b> Fünf Schlüssel sind aus <see cref="AppSettings"/> in die
/// Instanz gewandert. System.Text.Json überliest unbekannte Schlüssel wortlos, und
/// <c>Save</c> 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.</para>
///
/// <para>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.</para>
/// </summary>
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();
}
}
@@ -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;
/// <summary>
/// Die Weboberfläche muss sich zügig beenden lassen — auch mit offenen Ereignisströmen.
///
/// <para><b>Der Anlass.</b> Ein Ereignisstrom endet nie von selbst. Beim geordneten
/// Herunterfahren legt der Browser nicht auf, also feuert <c>RequestAborted</c> nicht,
/// und Kestrel wartet geduldig darauf, dass die Anfrage fertig wird. Erst nach der Frist
/// von <c>StopAsync</c> 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.</para>
///
/// <para>Die Ströme hängen jetzt zusätzlich an <c>ApplicationStopping</c>. Dieser Test
/// hält die Zusage fest, damit sie nicht beim nächsten Umbau still verlorengeht.</para>
/// </summary>
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";
/// <summary>
/// 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.
/// </summary>
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()
};
}
/// <summary>
/// Ein freier Port vom Betriebssystem. Eine feste Zahl liesse zwei Testlaeufe
/// nebeneinander scheitern — und genau solche Fehler sucht man am laengsten.
/// </summary>
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. */ }
}
}