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
+50 -18
View File
@@ -196,13 +196,13 @@ public sealed class AppHost : IAsyncDisposable
public readonly record struct StartupResult(AppHost? Host, string? Error);
/// <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
/// <c>AppPaths.DataDirectory</c> und ist der Wert, den die Einstellungsseite zeigt.
/// Vorher war <c>InstanceConfig.LogDirectory</c> mit <c>"./Logs"</c> vorbelegt und
/// damit nie leer — der Zweig auf die Anwendungseinstellung war unerreichbar, und
/// das Feld auf der Einstellungsseite ließ sich ändern, ohne etwas zu ändern.</para>
/// <para><b>Leer heißt <c>&lt;Instanzverzeichnis&gt;/Logs</c></b> — nicht mehr das
/// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien
/// schrieben, stand in <c>Startup.log</c> abwechselnd, was zwei Instanzen zu sagen
/// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
/// Fehlersuche damit Ratearbeit (#528, Stufe 1).</para>
///
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen
/// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
@@ -212,15 +212,29 @@ public sealed class AppHost : IAsyncDisposable
/// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
/// </summary>
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
/// <param name="applicationLogDirectory">Der Wert aus den Anwendungseinstellungen.</param>
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
public static string ResolveLogDirectory(
InstanceConfig instance, string applicationLogDirectory, string instancePath)
{
var configured = !string.IsNullOrWhiteSpace(instance.LogDirectory)
? instance.LogDirectory
: applicationLogDirectory;
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
=> ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath);
/// <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
// Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann,
// wenn jemand das Feld von Hand geleert oder relativ gesetzt hat.
@@ -228,6 +242,9 @@ public sealed class AppHost : IAsyncDisposable
? instance.WorkingDirectory
: instancePath;
if (string.IsNullOrWhiteSpace(configured))
return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder));
return Path.GetFullPath(
Path.IsPathRooted(configured)
? configured
@@ -260,8 +277,7 @@ public sealed class AppHost : IAsyncDisposable
}
// ─── 3. Protokollierung ───
var logDirectory = ResolveLogDirectory(
instance, settings.AppSettings.LogDirectory, instancePath);
var logDirectory = ResolveLogDirectory(instance, instancePath);
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
settings.AppSettings.MinimumLogLevel, true, out var parsed)
@@ -274,8 +290,24 @@ public sealed class AppHost : IAsyncDisposable
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
instance.InstanceName, instance.InstanceId);
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
logger.LogInformation("Protokoll-Verzeichnis: {Path}", logDirectory);
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
// Ein Wechsel ohne Ausweichlogik braucht wenigstens eine Ansage: Wer ein eigenes
// Log- oder Sicherungsverzeichnis in den Anwendungseinstellungen stehen hatte,
// soll nicht raten muessen, warum dort nichts mehr ankommt.
if (settings.AbandonedKeys.Count > 0)
{
logger.LogWarning(
"Diese Einstellungen gelten nicht mehr anwendungsweit und wurden aus "
+ "{Path} entfernt: {Keys}. Sie stehen jetzt je Instanz in "
+ "InstanceSettings.json; die Vorgaben dieser Instanz sind {Logs} und {Backups}.",
settings.SettingsPath,
string.Join(", ", settings.AbandonedKeys),
logDirectory,
ResolveBackupDirectory(instance, instancePath));
}
// ─── 4. Lizenz ───
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
callbacks.License);
@@ -324,10 +356,10 @@ public sealed class AppHost : IAsyncDisposable
await host.StartDeploymentcenterAsync(loggerFactory, ct);
// Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und
// Zielordner in die Einstellungen — gestartet wurde der Takt dazu bisher nicht,
// die Einstellung lief also ins Leere.
// Zielordner in die Instanzkonfiguration — gestartet wurde der Takt dazu bisher
// nicht, die Einstellung lief also ins Leere.
host.Backups = new BackupScheduler(
instancePath, instance.InstanceName, settings,
instancePath, instance,
loggerFactory.CreateLogger("ClawdDotNet.Backup"));
host.Backups.Start();
+36 -18
View File
@@ -1,5 +1,5 @@
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging;
@@ -12,8 +12,16 @@ namespace ClawdDotNet.App.Services;
/// neben den Sicherungen abgelegt wäre sie wirkungslos. Wer die Zugangsdaten
/// mitsichern will, macht das von Hand.
///
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Einstellungen geprüft, damit
/// eine Änderung ohne Neustart greift.
/// Der Zeitpunkt wird bei jedem Durchlauf neu gegen die Instanzkonfiguration geprüft,
/// damit eine Änderung ohne Neustart greift.
///
/// <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
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/> über
@@ -23,8 +31,7 @@ namespace ClawdDotNet.App.Services;
public sealed class BackupScheduler : IAsyncDisposable
{
private readonly string _instanceDir;
private readonly string _instanceName;
private readonly SettingsManager _settings;
private readonly InstanceConfig _instance;
private readonly ILogger _logger;
private readonly TimeProvider _clock;
private readonly TimeSpan _tick;
@@ -38,17 +45,24 @@ public sealed class BackupScheduler : IAsyncDisposable
public event Action<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(
string instanceDir,
string instanceName,
SettingsManager settings,
InstanceConfig instance,
ILogger logger,
TimeProvider? clock = null,
TimeSpan? tick = null)
{
_instanceDir = instanceDir;
_instanceName = instanceName;
_settings = settings;
_instance = instance;
_logger = logger;
_clock = clock ?? TimeProvider.System;
@@ -79,11 +93,10 @@ public sealed class BackupScheduler : IAsyncDisposable
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
public async Task TickAsync()
{
var settings = _settings.AppSettings;
if (!settings.AutoBackupEnabled)
if (!_instance.AutoBackupEnabled)
return;
if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
if (!TimeSpan.TryParse(_instance.AutoBackupTime, out var scheduled))
return;
var now = _clock.GetLocalNow().DateTime;
@@ -96,7 +109,10 @@ public sealed class BackupScheduler : IAsyncDisposable
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
_lastRun = now;
await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now);
await RunAsync(
AppHost.ResolveBackupDirectory(_instance, _instanceDir),
_instance.BackupKeepCount,
now);
}
private async Task RunAsync(string folder, int keepCount, DateTime now)
@@ -104,11 +120,13 @@ public sealed class BackupScheduler : IAsyncDisposable
// PortableFileName statt Path.GetInvalidFileNameChars: Unter Linux liefert das
// nur '\0' und '/', ein Instanzname mit ':' ergäbe ein Archiv, das sich unter
// Windows nicht mehr anlegen lässt.
var safeName = PortableFileName.Sanitize(_instanceName, "Instanz");
var safeName = PortableFileName.Sanitize(_instance.InstanceName, "Instanz");
var file = Path.Combine(
Path.GetFullPath(folder),
$"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
// Der Ordner liegt jetzt in der Regel unterhalb der Instanz und muss beim ersten
// Lauf erst entstehen.
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, $"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
{
@@ -120,7 +138,7 @@ public sealed class BackupScheduler : IAsyncDisposable
_logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
result.ZipPath, result.SizeBytes);
ApplyRotation(Path.GetFullPath(folder), safeName, keepCount);
ApplyRotation(folder, safeName, keepCount);
OnBackupCreated?.Invoke(result.ZipPath);
}
@@ -120,10 +120,9 @@ public sealed class InstanceDirectoryManager
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
// LogDirectory bleibt leer: dann gilt die Anwendungseinstellung, und die
// steht absolut. Hier stand vorher "./Logs" — ein Wert, der jede neue
// Instanz an das Arbeitsverzeichnis des Prozesses band und die
// Einstellungsseite wirkungslos machte.
// LogDirectory und BackupDirectory bleiben leer: Dann gelten
// <Instanz>/Logs und <Instanz>/Backups. Hier stand vorher "./Logs" — ein
// Wert, der jede neue Instanz an das Arbeitsverzeichnis des Prozesses band.
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
@@ -153,11 +152,24 @@ public sealed class InstanceDirectoryManager
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Migration: Die alte Vorbelegung "./Logs" wird zu „leer" — also zur
// Anwendungseinstellung. Der Wert hatte nie die Bedeutung, die er vorgab: Er
// wurde gegen das Arbeitsverzeichnis des Prozesses aufgelöst, nicht gegen das
// Instanzverzeichnis. Wer bewusst einen anderen Pfad eingetragen hat, behält
// ihn; nur genau die Vorbelegung fällt weg.
// Zugangsdaten der Instanz lesbar machen — OpenRouter-Schlüssel, Watchdog-Token,
// Telegram-Zugang, Token der Weboberfläche.
//
// <para>Das fehlte. SaveInstanceConfig verschlüsselt beim Schreiben, hier wurde
// aber nie entschlüsselt: Was einmal über die Oberfläche gespeichert war, kam
// als "enc:v2:…" zurück und wurde als Wert benutzt. Der OpenRouter-Schlüssel
// ging so verschlüsselt an die API. Aufgefallen ist es nie, weil
// ConfigLoader.LoadAsync — das es richtig macht — im laufenden Programm gar
// nicht aufgerufen wird; nur seine Migrate-Methode wird gebraucht.</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
// zurückschreibt. Beim nächsten Speichern verschwindet der Wert von selbst.
+16 -33
View File
@@ -14,16 +14,16 @@ namespace ClawdDotNet.App.Settings;
/// </summary>
public sealed class AppSettings
{
// Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis.
// Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht
// schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde.
// Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths).
[Category("Allgemein")]
[DisplayName("Log-Verzeichnis")]
[Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
[JsonPropertyName("logDirectory")]
public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs");
// logDirectory ist hier entfallen (#528, Stufe 1). Es war anwendungsweit und damit
// fuer alle Instanzen derselbe Ordner: In Startup.log standen abwechselnd Zeilen
// zweier Instanzen ohne Kennzeichnung, welche gerade spricht. Wohin eine Instanz
// protokolliert, steht jetzt in ihrer InstanceSettings.json; leer heisst
// <Instanzverzeichnis>/Logs (siehe AppHost.ResolveLogDirectory).
//
// Die Vorgabe war "./Instances" — relativ zum Arbeitsverzeichnis. Unter Linux liegt
// die Anwendung in /opt oder /usr/lib und darf dort nicht schreiben; zudem hing der
// Ort davon ab, aus welchem Verzeichnis gestartet wurde. Jetzt ein absoluter Pfad im
// Datenverzeichnis des Benutzers (siehe AppPaths).
[Category("Allgemein")]
[DisplayName("Instanzen-Verzeichnis")]
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
@@ -104,29 +104,12 @@ public sealed class AppSettings
[JsonPropertyName("openRouterBaseUrl")]
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
[Category("Backup")]
[DisplayName("Backup-Verzeichnis")]
[Description("Ordner, in dem Sicherungen abgelegt werden.")]
[JsonPropertyName("backupDirectory")]
public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups");
[Category("Backup")]
[DisplayName("Automatisch sichern")]
[Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")]
[JsonPropertyName("autoBackupEnabled")]
public bool AutoBackupEnabled { get; set; }
[Category("Backup")]
[DisplayName("Uhrzeit der automatischen Sicherung")]
[Description("Tageszeit im Format HH:mm.")]
[JsonPropertyName("autoBackupTime")]
public string AutoBackupTime { get; set; } = "03:00";
[Category("Backup")]
[DisplayName("Aufbewahrte Sicherungen")]
[Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")]
[JsonPropertyName("backupKeepCount")]
public int BackupKeepCount { get; set; } = 14;
// Die vier Sicherungs-Einstellungen sind hier entfallen (#528, Stufe 1) und stehen
// jetzt in InstanceSettings.json. Anwendungsweit galten sie fuer jede Instanz: Wer
// die taegliche Sicherung fuer eine einschaltete, schaltete sie fuer alle ein, und
// alle Archive lagen in einem Ordner. Dort raeumt die Aufbewahrung nach dem Praefix
// backup_<Instanzname>_ auf — bei zwei Namen, die ineinander liegen (Test und
// Test_2), zaehlt die eine Instanz die Archive der anderen mit.
// ─── Deploymentcenter ───
//
@@ -34,10 +34,40 @@ public sealed class SettingsManager
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;
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>
public string SettingsPath => _settingsPath;
@@ -49,6 +79,8 @@ public sealed class SettingsManager
public void Load()
{
AbandonedKeys = [];
if (!File.Exists(_settingsPath))
{
AppSettings = new AppSettings();
@@ -61,6 +93,8 @@ public sealed class SettingsManager
var json = AtomicFile.ReadAllText(_settingsPath);
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? new AppSettings();
AbandonedKeys = FindRetiredKeys(json);
}
catch (Exception ex) when (ex is JsonException or IOException)
{
@@ -76,6 +110,36 @@ public sealed class SettingsManager
AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
}
/// <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>
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner,
/// 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/";
/// <summary>
/// Wohin diese Instanz protokolliert. Leer heißt: das Log-Verzeichnis aus den
/// Anwendungseinstellungen, also der absolute Pfad unter <c>AppPaths.DataDirectory</c>.
/// Wohin diese Instanz protokolliert. Leer heißt <c>&lt;Instanzverzeichnis&gt;/Logs</c>.
///
/// Die frühere Vorbelegung <c>"./Logs"</c> war nie leer und verdeckte damit die
/// Anwendungseinstellung vollständig; aufgelöst wurde sie zudem gegen das
/// Arbeitsverzeichnis des Prozesses. Ein relativer Wert zählt heute gegen das
/// Instanzverzeichnis (siehe <c>AppHost.StartAsync</c>).
/// <para><b>Je Instanz und nicht anwendungsweit</b> (#528, Stufe 1). Vorher galt bei
/// „leer" das Log-Verzeichnis aus den Anwendungseinstellungen — ein Ort für alle
/// Instanzen. In <c>Startup.log</c> standen dann abwechselnd Zeilen zweier Instanzen
/// 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>
[JsonPropertyName("logDirectory")]
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>
/// Port der Weboberfläche. Je Instanz und nicht anwendungsweit: Zwei Instanzen auf
/// einer Maschine stritten sich sonst um einen Port.
@@ -1,8 +1,9 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Settings;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@@ -29,7 +30,14 @@ public sealed partial class BackupPageViewModel : PageViewModel
{
private readonly BackupService _service = new();
private readonly AppHost? _host;
private readonly SettingsManager? _settingsManager;
/// <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 string _instanceDir;
private readonly string _instanceName;
@@ -88,7 +96,7 @@ public sealed partial class BackupPageViewModel : PageViewModel
{
_instanceDir = host.InstancePath;
_instanceName = host.Instance.InstanceName;
_settingsManager = host.Settings;
_instance = host.Instance;
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup");
LoadSettings();
@@ -108,17 +116,19 @@ public sealed partial class BackupPageViewModel : PageViewModel
private void LoadSettings()
{
if (_settingsManager is null) return;
if (_host is null || _instance is null) return;
_loading = true;
try
{
var settings = _settingsManager.AppSettings;
TargetFolder = Path.GetFullPath(settings.BackupDirectory);
AutoBackupEnabled = settings.AutoBackupEnabled;
KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999);
// Aufgeloest und nicht roh angezeigt: In der Datei steht in aller Regel
// nichts, und ein leeres Feld beantwortet die Frage nicht, wohin gesichert
// wird.
TargetFolder = AppHost.ResolveBackupDirectory(_instance, _instanceDir);
AutoBackupEnabled = _instance.AutoBackupEnabled;
KeepCount = Math.Clamp(_instance.BackupKeepCount, 0, 999);
AutoBackupTime = TimeSpan.TryParse(settings.AutoBackupTime, out var time)
AutoBackupTime = TimeSpan.TryParse(_instance.AutoBackupTime, out var time)
? time.ToString(@"hh\:mm")
: "03:00";
}
@@ -130,15 +140,23 @@ public sealed partial class BackupPageViewModel : PageViewModel
public void SaveSettings()
{
if (_loading || _settingsManager is null) return;
if (_loading || _host is null || _instance is null) return;
var settings = _settingsManager.AppSettings;
settings.BackupDirectory = TargetFolder.Trim();
settings.AutoBackupEnabled = AutoBackupEnabled;
settings.AutoBackupTime = AutoBackupTime.Trim();
settings.BackupKeepCount = KeepCount;
var folder = TargetFolder.Trim();
_settingsManager.Save();
// Der Vorgabeort bleibt als Leereintrag stehen. Sonst schriebe die Ansicht den
// aufgeloesten Pfad fest, und eine umgezogene Instanz sicherte weiter dorthin,
// wo sie einmal lag.
var vorgabe = AppHost.ResolveBackupDirectory(new InstanceConfig(), _instanceDir);
_instance.BackupDirectory =
string.Equals(folder, vorgabe, PathBoundary.Comparison) ? "" : folder;
_instance.AutoBackupEnabled = AutoBackupEnabled;
_instance.AutoBackupTime = AutoBackupTime.Trim();
_instance.BackupKeepCount = KeepCount;
_host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
}
[RelayCommand]
@@ -1,5 +1,5 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Settings;
using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
@@ -14,10 +14,10 @@ public sealed partial class SettingsPageViewModel : PageViewModel
private readonly AppHost? _host;
private readonly ILogger? _logger;
// ─── Allgemein & Pfade ───
[ObservableProperty]
private string _logDirectory = "";
/// <summary>Womit die Seite geladen wurde — nur, um eine Änderung zu erkennen.</summary>
private string _logDirectoryAtLoad = "";
// ─── Allgemein & Pfade ───
[ObservableProperty]
private string _instancesDirectory = "";
@@ -70,6 +70,20 @@ public sealed partial class SettingsPageViewModel : PageViewModel
[ObservableProperty]
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]
private string _openRouterApiKey = "";
@@ -109,7 +123,6 @@ public sealed partial class SettingsPageViewModel : PageViewModel
if (_host is null) return;
var s = _host.Settings.AppSettings;
LogDirectory = s.LogDirectory;
InstancesDirectory = s.InstancesDirectory;
MinimumLogLevel = s.MinimumLogLevel;
MaxLogLinesInUi = s.MaxLogLinesInUi;
@@ -126,6 +139,8 @@ public sealed partial class SettingsPageViewModel : PageViewModel
var inst = _host.Instance;
InstanceName = inst.InstanceName;
LogDirectory = inst.LogDirectory;
_logDirectoryAtLoad = inst.LogDirectory;
OpenRouterApiKey = inst.OpenRouterApiKey;
InstanceDailyCostUsd = inst.Budget.DailyCostUsd;
InstanceDailyTokens = inst.Budget.DailyTokens;
@@ -137,7 +152,6 @@ public sealed partial class SettingsPageViewModel : PageViewModel
if (_host is null) return;
var s = _host.Settings.AppSettings;
s.LogDirectory = LogDirectory.Trim();
s.InstancesDirectory = InstancesDirectory.Trim();
s.MinimumLogLevel = MinimumLogLevel;
s.MaxLogLinesInUi = MaxLogLinesInUi;
@@ -156,13 +170,21 @@ public sealed partial class SettingsPageViewModel : PageViewModel
var inst = _host.Instance;
inst.InstanceName = InstanceName.Trim();
inst.LogDirectory = LogDirectory.Trim();
inst.OpenRouterApiKey = OpenRouterApiKey.Trim();
inst.Budget.DailyCostUsd = InstanceDailyCostUsd;
inst.Budget.DailyTokens = InstanceDailyTokens;
_host.Directories.SaveInstanceConfig(_host.InstancePath, inst);
_host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings));
_logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert.");
StatusText = "Einstellungen erfolgreich gespeichert.";
// Das Protokoll-Verzeichnis greift erst beim naechsten Start: Die
// Protokollierung steht seit dem Aufbau des AppHost und schreibt bereits.
StatusText = string.Equals(inst.LogDirectory, _logDirectoryAtLoad, StringComparison.Ordinal)
? "Einstellungen erfolgreich gespeichert."
: "Gespeichert. Das Protokoll-Verzeichnis gilt ab dem nächsten Start.";
_logDirectoryAtLoad = inst.LogDirectory;
}
}
@@ -42,6 +42,13 @@
<TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" />
</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,*">
<StackPanel Grid.Column="0">
<TextBlock Classes="label" Text="Tagesbudget ($ USD)" />
@@ -60,11 +67,6 @@
<StackPanel Spacing="14">
<TextBlock Classes="kicker" Text="ALLGEMEIN" />
<StackPanel>
<TextBlock Classes="label" Text="Log-Verzeichnis" />
<TextBox Text="{Binding LogDirectory}" />
</StackPanel>
<StackPanel>
<TextBlock Classes="label" Text="Instanzen-Verzeichnis" />
<TextBox Text="{Binding InstancesDirectory}" />
@@ -5,6 +5,7 @@ using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
namespace ClawdDotNet.Web.Endpoints;
@@ -43,8 +44,11 @@ internal static class EventEndpoints
}
private static async Task StreamCoreEvents(
HttpContext context, AppHost host, CancellationToken ct)
HttpContext context, AppHost host, IHostApplicationLifetime lifetime, CancellationToken ct)
{
using var leben = Verbinde(lifetime, ct);
ct = leben.Token;
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
@@ -122,8 +126,12 @@ internal static class EventEndpoints
/// Zeilen bekommt und alle anderen nichts.</para>
/// </summary>
private static async Task StreamLogs(
HttpContext context, AppHost host, CancellationToken ct, string? module = null, string? level = null)
HttpContext context, AppHost host, IHostApplicationLifetime lifetime,
CancellationToken ct, string? module = null, string? level = null)
{
using var leben = Verbinde(lifetime, ct);
ct = leben.Token;
var tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse<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)
{
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.AddProvider(new SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener
// Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes
// Stylesheet. In einer Viertelstunde Herumklicken standen so mehrere tausend
// Zeilen im Instanzprotokoll, zwischen denen die Meldungen der Agenten
// untergingen. Ab Warnung ist es das, was man wissen will.
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);
builder.Logging.AddFilter("System", LogLevel.Warning);
builder.WebHost.UseUrls(options.Url);
builder.Services.AddSingleton(host);
@@ -144,10 +152,15 @@ public sealed class WebUiHost : IAsyncDisposable
{
await next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt. Das ist kein Fehler und schon gar keiner,
// der ins Deploymentcenter gehoert.
// Der Browser hat aufgelegt, oder die Anwendung faehrt herunter. Beides
// ist kein Fehler und schon gar keiner, der ins Deploymentcenter gehoert.
//
// Bewusst ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
// ApplicationStopping ab, und RequestAborted ist dabei gar nicht gesetzt.
// Mit der Bedingung landete jeder offene Ereignisstrom beim Beenden als
// gemeldeter Fehler im Bugtracker.
}
catch (Exception ex)
{
+16
View File
@@ -142,6 +142,22 @@ a:hover {
/* ─── Layout Shell ─── */
/*
Das hidden-Attribut muss gewinnen.
Der Browser setzt fuer [hidden] display:none, aber nur mit der Staerke einer
Attributregel. #anmeldung und #anwendung setzen display:flex ueber einen
ID-Selektor - und der ist staerker. Ohne diese Zeile blieb die Anmeldemaske
nach dem Anmelden stehen und lag ueber der Anwendung: Beide Bereiche waren
gleichzeitig sichtbar, die Anmeldung nur weiter vorn.
Gilt fuer den ganzen Baum, nicht nur fuer diese zwei: Ueberall, wo JavaScript
el.hidden setzt, soll das auch etwas bewirken.
*/
[hidden] {
display: none !important;
}
#anmeldung {
display: flex;
align-items: center;