feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
@@ -0,0 +1,169 @@
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Erstellt einmal täglich zur eingestellten Uhrzeit eine Sicherung.
///
/// Bewusst ohne Zugangsdaten: Die Passphrase müsste dafür gespeichert werden, und
/// 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.
///
/// <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
/// einen <see cref="TimeProvider"/>: läuft ohne Oberfläche, driftet nicht, und Tests
/// können die Zeit steuern statt zu warten.</para>
/// </summary>
public sealed class BackupScheduler : IAsyncDisposable
{
private readonly string _instanceDir;
private readonly string _instanceName;
private readonly SettingsManager _settings;
private readonly ILogger _logger;
private readonly TimeProvider _clock;
private readonly TimeSpan _tick;
private readonly BackupService _service = new();
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
/// <summary>Verhindert mehrere Sicherungen am selben Tag.</summary>
private DateTime? _lastRun;
public event Action<string>? OnBackupCreated;
public BackupScheduler(
string instanceDir,
string instanceName,
SettingsManager settings,
ILogger logger,
TimeProvider? clock = null,
TimeSpan? tick = null)
{
_instanceDir = instanceDir;
_instanceName = instanceName;
_settings = settings;
_logger = logger;
_clock = clock ?? TimeProvider.System;
// Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
_tick = tick ?? TimeSpan.FromMinutes(1);
}
public void Start() => _loop ??= RunLoopAsync(_cts.Token);
private async Task RunLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(_tick, _clock);
while (await timer.WaitForNextTickAsync(ct))
{
try
{
await TickAsync();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Ein Fehlschlag darf die Anwendung nicht stören.
_logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
}
}
}
/// <summary>Ein Durchlauf. Öffentlich, damit Tests ihn deterministisch auslösen können.</summary>
public async Task TickAsync()
{
var settings = _settings.AppSettings;
if (!settings.AutoBackupEnabled)
return;
if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
return;
var now = _clock.GetLocalNow().DateTime;
// Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
if (now.TimeOfDay < scheduled) return;
if (_lastRun?.Date == now.Date) return;
// Vor dem Lauf setzen: Scheitert er, wird nicht jede Minute erneut versucht,
// sondern morgen wieder. Ein Retry-Sturm über Nacht hilft niemandem.
_lastRun = now;
await RunAsync(settings.BackupDirectory, settings.BackupKeepCount, now);
}
private async Task RunAsync(string folder, int keepCount, DateTime now)
{
// 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 file = Path.Combine(
Path.GetFullPath(folder),
$"backup_{safeName}_{now:yyyy-MM-dd_HHmm}.zip");
var result = await _service.CreateAsync(_instanceDir, file, new BackupOptions
{
Secrets = SecretMode.Exclude,
IncludeChatHistory = true,
IncludeLogs = false
});
_logger.LogInformation("Automatische Sicherung erstellt: {Path} ({Size} Bytes)",
result.ZipPath, result.SizeBytes);
ApplyRotation(Path.GetFullPath(folder), safeName, keepCount);
OnBackupCreated?.Invoke(result.ZipPath);
}
/// <summary>Behält die neuesten Sicherungen dieser Instanz und entfernt den Rest.</summary>
private void ApplyRotation(string folder, string safeName, int keepCount)
{
if (keepCount <= 0 || !Directory.Exists(folder))
return;
try
{
var prefix = $"backup_{safeName}_";
var obsolete = new DirectoryInfo(folder)
.GetFiles("*.zip")
.Where(f => f.Name.StartsWith(prefix, PathBoundary.Comparison))
.OrderByDescending(f => f.LastWriteTime)
.Skip(keepCount)
.ToList();
foreach (var file in obsolete)
{
file.Delete();
_logger.LogInformation("Alte Sicherung entfernt: {Name}", file.Name);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_loop is not null)
{
try { await _loop; }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
}
}
@@ -0,0 +1,269 @@
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Die Anbindung ans Deploymentcenter, an einer Stelle gebündelt: Watchdog-Heartbeat,
/// Fehler-Stream, Bugtracker und Update-Prüfung.
///
/// <para>Zuvor lagen Watchdog (eigener Server, eigener Schlüssel) und Lizenz
/// (LicenseLabrador, eigener Server, eigener Public-Key) getrennt nebeneinander. Beides
/// sind jetzt Module derselben Anwendung mit einer Adresse und einem Token — und dazu
/// kommen Updates, Fehler-Stream und Bugtracker, die es vorher gar nicht gab.</para>
///
/// <para>Die Lizenz bleibt bewusst außen vor: Sie hat ein eigenes Antwortformat (kein
/// <c>status</c>/<c>error</c>-Umschlag), einen eigenen Zwischenspeicher und muss vor
/// allem anderen laufen. Dafür ist <see cref="LicenseGate"/> zuständig.</para>
/// </summary>
public sealed class DeploymentcenterService : IAsyncDisposable
{
private readonly DeploymentcenterApi _api;
private readonly string _appToken;
private readonly string _build;
private readonly ILogger _logger;
private WatchdogHeartbeatService? _heartbeat;
public IErrorReporter Errors { get; private set; } = NullErrorReporter.Instance;
/// <summary>Null, wenn kein Token hinterlegt ist — dann lässt sich nichts melden.</summary>
public BugtrackerClient? Bugtracker { get; private set; }
/// <summary>Ergebnis der Update-Prüfung beim Start; null, solange sie nicht durch ist.</summary>
public UpdateAvailability? Update { get; private set; }
private DeploymentcenterService(
DeploymentcenterApi api, string appToken, string build, ILogger logger)
{
_api = api;
_appToken = appToken;
_build = build;
_logger = logger;
}
/// <summary>
/// Baut die Anbindung auf, soweit sie konfiguriert ist. Gibt <c>null</c> zurück,
/// wenn Adresse oder Token fehlen — der Aufrufer läuft dann ohne weiter, denn keines
/// dieser Module darf ein Startgrund oder ein Hindernis sein.
/// </summary>
public static DeploymentcenterService? TryCreate(
AppSettings settings, string build, ILoggerFactory loggerFactory)
{
var logger = loggerFactory.CreateLogger("ClawdDotNet.Deploymentcenter");
if (string.IsNullOrWhiteSpace(settings.DeploymentcenterUrl))
{
logger.LogInformation("Deploymentcenter: keine Server-URL hinterlegt Anbindung aus.");
return null;
}
if (string.IsNullOrWhiteSpace(settings.DeploymentcenterToken))
{
logger.LogInformation(
"Deploymentcenter: kein Token hinterlegt Heartbeat, Fehler-Stream und "
+ "Bugtracker bleiben aus.");
return null;
}
DeploymentcenterApi api;
try
{
api = new DeploymentcenterApi(settings.DeploymentcenterUrl, settings.DeploymentcenterToken);
}
catch (ArgumentException ex)
{
logger.LogWarning(ex, "Deploymentcenter: Konfiguration unbrauchbar Anbindung aus.");
return null;
}
var service = new DeploymentcenterService(
api, settings.DeploymentcenterToken, build, logger);
if (settings.ErrorReportingEnabled)
{
service.Errors = new ErrorReporter(
api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build, logger);
}
service.Bugtracker = new BugtrackerClient(
api, LicenseInfo.ProductSlug, settings.DeploymentcenterEnvironment, build);
return service;
}
// ─── Watchdog ───
/// <summary>
/// Startet den Instanz-Heartbeat, wenn der eingebaute Dienst eingeschaltet ist.
///
/// <para>Jede laufende Instanz ist ein eigener Monitor: Der Server führt sie über
/// das Paar <c>source</c> + <c>instance</c>. Fällt eine von mehreren aus, fällt
/// genau deren Monitor — und nur der schlägt Alarm.</para>
/// </summary>
/// <param name="saveInstanceConfig">
/// Wird aufgerufen, wenn ein neu bezogenes Sub-Token in die Instanzkonfiguration
/// geschrieben werden soll (dort verschlüsselt).
/// </param>
public async Task StartWatchdogAsync(
InstanceConfig instance,
IInstanceHealthProvider health,
Action saveInstanceConfig,
CancellationToken ct = default)
{
var service = instance.Services.FirstOrDefault(s => s.Type == BuiltInServices.InstanceWatchdog);
if (service is not { Enabled: true, AutoStart: true })
return;
var watchdog = instance.Watchdog;
var token = await ResolveInstanceTokenAsync(instance, saveInstanceConfig, ct);
try
{
_heartbeat = WatchdogHeartbeatService.Create(
_api.BaseUrl,
token,
watchdog.Source,
watchdog.ResolveInstance(instance.InstanceId),
watchdog.Group,
DescribePlatform(),
_build,
watchdog.IntervalSeconds,
health,
_logger);
_heartbeat.Start();
_logger.LogInformation("Instanz-Watchdog aktiv: {Source}/{Instance}, alle {Interval}s",
watchdog.Source, watchdog.ResolveInstance(instance.InstanceId), watchdog.IntervalSeconds);
}
catch (Exception ex)
{
_logger.LogError(ex, "Instanz-Watchdog konnte nicht gestartet werden");
}
}
/// <summary>
/// Liefert das Token, mit dem diese Instanz meldet: das zwischengespeicherte
/// Sub-Token, sonst ein frisch gezogenes, sonst das anwendungsweite.
///
/// <para>Der Umweg lohnt sich, weil danach auf der Instanz nicht mehr das
/// Master-Token liegt, sondern ein auf <c>watchdog:ping</c> und
/// <c>bugtracker:report</c> beschränktes, das sich einzeln widerrufen lässt.
/// Scheitert das, wird trotzdem gemeldet — Monitoring, das nur bei perfekter
/// Rechtelage läuft, ist genau dann still, wenn man es braucht.</para>
/// </summary>
private async Task<string> ResolveInstanceTokenAsync(
InstanceConfig instance, Action saveInstanceConfig, CancellationToken ct)
{
if (instance.Watchdog.HasToken)
return instance.Watchdog.AgentToken;
try
{
var provisioned = await new TokenProvisioner(_api).ProvisionAsync(
clientName: $"ClawdDotNet {instance.InstanceName}",
instanceId: instance.InstanceId,
scopes: TokenProvisioner.InstanceScopes,
ct: ct);
instance.Watchdog.AgentToken = provisioned.Token;
saveInstanceConfig();
_logger.LogInformation(
"Deploymentcenter: eigenes Token für diese Instanz bezogen ({TokenId}, Rechte: {Scopes})",
provisioned.TokenId, string.Join(", ", provisioned.Scopes));
return provisioned.Token;
}
catch (Exception ex)
{
// Häufigster Fall: Das hinterlegte Token ist selbst ein Sub-Token und darf
// keine weiteren ausstellen. Kein Grund, das Monitoring aufzugeben.
_logger.LogInformation(
"Deploymentcenter: kein eigenes Instanz-Token beziehbar ({Reason}) "
+ "es wird mit dem hinterlegten Token gemeldet.", ex.Message);
return _appToken;
}
}
private static string DescribePlatform() =>
$"{System.Runtime.InteropServices.RuntimeInformation.OSDescription} / "
+ $".NET {System.Environment.Version}";
// ─── Updates ───
/// <summary>
/// Fragt einmalig, ob ein neueres Release vorliegt. Bewusst ohne Folgen: Das
/// Ergebnis wird protokolliert und über <see cref="Update"/> bereitgestellt; ob und
/// wann aktualisiert wird, entscheidet der Benutzer.
/// </summary>
public async Task CheckForUpdateAsync(AppSettings settings, string currentVersion, CancellationToken ct = default)
{
if (!settings.UpdateCheckEnabled)
return;
try
{
var client = new global::Deploymentcenter.Client.UpdateClient();
var result = await client.CheckForUpdateAsync(
settings.DeploymentcenterUrl, LicenseInfo.ProductSlug, currentVersion,
settings.UpdateChannel, cancellationToken: ct);
if (result.Error is not null)
{
_logger.LogDebug(result.Error, "Update-Prüfung fehlgeschlagen (ignoriert).");
return;
}
// Seit SDK 2.1 liefern beide Wege vollständige Daten: die statische
// latest.json in camelCase, die API in snake_case, jeweils über ein eigenes
// Modell. Vorher kam über den API-Zweig außer der Versionsnummer nichts an —
// und der ist genau der Rückfall, wenn die latest.json fehlt.
Update = new UpdateAvailability(
result.UpdateAvailable,
result.LatestRelease?.Version ?? currentVersion,
result.IsCritical,
result.LatestRelease?.Changelog,
result.LatestRelease?.PackageUrl);
if (result.UpdateAvailable)
{
_logger.LogInformation("Update verfügbar: {Version}{Critical}",
Update.LatestVersion, Update.IsCritical ? " (kritisch)" : "");
}
else
{
_logger.LogInformation("Kein Update verfügbar (installiert: {Version}).", currentVersion);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Update-Prüfung fehlgeschlagen (ignoriert).");
}
}
public async ValueTask DisposeAsync()
{
if (_heartbeat is not null)
await _heartbeat.DisposeAsync();
if (Errors is IDisposable disposableReporter)
disposableReporter.Dispose();
_api.Dispose();
}
}
/// <summary>Ergebnis der Update-Prüfung.</summary>
/// <param name="DownloadUrl">
/// Adresse des Pakets — für einen späteren Anschluss des <c>update-agent</c>. Solange
/// der nicht eingebunden ist, dient sie nur der Anzeige.
/// </param>
public sealed record UpdateAvailability(
bool IsAvailable, string LatestVersion, bool IsCritical, string? ReleaseNotes,
string? DownloadUrl = null);
@@ -0,0 +1,93 @@
namespace ClawdDotNet.App.Services;
/// <summary>
/// Wie der <see cref="LicenseGate"/> mit dem Benutzer spricht.
///
/// Vorher rief er unmittelbar <c>MessageBox.Show</c> und einen WinForms-Dialog.
/// Das band die Lizenzprüfung an WinForms — und wäre im kopflosen Betrieb fatal
/// gewesen: Ein Dienst, der beim Start ein Fenster öffnet und auf eine Eingabe wartet,
/// hängt für immer, ohne dass jemand die Meldung je zu sehen bekäme.
///
/// Es gibt drei Umsetzungen:
/// <list type="bullet">
/// <item><c>AvaloniaLicensePrompt</c> — Dialoge im Fenster (Desktop).</item>
/// <item><see cref="ConsoleLicensePrompt"/> — Eingabe über die Konsole (Kommandozeile).</item>
/// <item><see cref="NonInteractiveLicensePrompt"/> — antwortet nie (systemd-Dienst).</item>
/// </list>
/// </summary>
public interface ILicensePrompt
{
/// <summary>
/// Fragt einen Lizenzschlüssel ab. <c>null</c> heißt Abbruch — der Aufrufer beendet
/// die Anwendung.
/// </summary>
/// <param name="hardwareId">Wird angezeigt, damit der Nutzer ihn an den Support geben kann.</param>
/// <param name="problem">Warum der bisherige Schlüssel nicht taugt; <c>null</c> beim ersten Fragen.</param>
/// <param name="currentKey">Vorbelegung des Eingabefelds.</param>
Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey);
/// <summary>Eine Meldung, die keine Antwort braucht (etwa „offline gültig bis …").</summary>
Task ShowInfoAsync(string title, string message);
/// <summary>Ein Fehler, nach dem die Anwendung nicht weiterläuft.</summary>
Task ShowErrorAsync(string title, string message);
}
/// <summary>
/// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab.
///
/// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren
/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile
/// hinterlegt (<c>--license-set-key</c>).
/// </summary>
public sealed class NonInteractiveLicensePrompt(Action<string> log) : ILicensePrompt
{
public Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
{
log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. "
+ (problem is null ? "" : $"Grund: {problem}. ")
+ "Schlüssel mit '--license-set-key <SCHLÜSSEL>' hinterlegen.");
return Task.FromResult<string?>(null);
}
public Task ShowInfoAsync(string title, string message)
{
log($"{title}: {message}");
return Task.CompletedTask;
}
public Task ShowErrorAsync(string title, string message)
{
log($"{title}: {message}");
return Task.CompletedTask;
}
}
/// <summary>Eingabe über die Konsole — für Kommandozeilenaufrufe.</summary>
public sealed class ConsoleLicensePrompt : ILicensePrompt
{
public Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
{
if (problem is not null)
Console.Error.WriteLine($"Lizenz: {problem}");
Console.WriteLine($"Hardware-ID: {hardwareId}");
Console.Write("Lizenzschlüssel: ");
var input = Console.ReadLine()?.Trim();
return Task.FromResult(string.IsNullOrWhiteSpace(input) ? null : input);
}
public Task ShowInfoAsync(string title, string message)
{
Console.WriteLine($"{title}: {message}");
return Task.CompletedTask;
}
public Task ShowErrorAsync(string title, string message)
{
Console.Error.WriteLine($"{title}: {message}");
return Task.CompletedTask;
}
}
@@ -0,0 +1,454 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.App.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
///
/// Layout:
/// {InstancesDir}/
/// ├── Instance-{Name}/
/// │ ├── InstanceSettings.json
/// │ ├── TokenUsage.json
/// │ └── Agents/
/// │ ├── AgentList.json
/// │ └── Agent-{Name}/
/// │ ├── AgentSettings.json
/// │ ├── Soul.md
/// │ ├── Identity.md
/// │ ├── Logs/
/// │ └── Workspace/
/// </summary>
public sealed class InstanceDirectoryManager
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly string _instancesDir;
private readonly Lock _tokenUsageLock = new();
public InstanceDirectoryManager(string instancesDirectory)
{
_instancesDir = Path.GetFullPath(instancesDirectory);
Directory.CreateDirectory(_instancesDir);
}
public string InstancesDirectory => _instancesDir;
// ═══════════════════════════════════════════════════
// INSTANZ-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildInstanceFolderName(string instanceName)
=> $"Instance-{SanitizeName(instanceName)}";
public string GetInstancePath(string instanceName)
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
public List<InstanceInfo> ListInstances()
{
var result = new List<InstanceInfo>();
if (!Directory.Exists(_instancesDir))
return result;
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
{
var folderName = Path.GetFileName(dir);
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
var info = new InstanceInfo
{
FolderName = folderName,
FolderPath = dir,
InstanceName = folderName.Replace("Instance-", "")
};
if (File.Exists(settingsPath))
{
try
{
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
if (config is not null)
{
info.InstanceName = config.InstanceName;
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
? "Fehlt" : "Konfiguriert";
}
}
catch { /* defekte Config → Standardwerte */ }
}
// Agenten zählen
var agentsDir = Path.Combine(dir, "Agents");
if (Directory.Exists(agentsDir))
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
result.Add(info);
}
return result.OrderBy(i => i.InstanceName).ToList();
}
public string CreateInstance(string instanceName)
{
var instanceDir = GetInstancePath(instanceName);
if (Directory.Exists(instanceDir))
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
// Hauptverzeichnis
Directory.CreateDirectory(instanceDir);
// Agents-Unterverzeichnis
var agentsDir = Path.Combine(instanceDir, "Agents");
Directory.CreateDirectory(agentsDir);
// SharedWorkspace-Verzeichnis
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// InstanceSettings.json
var config = new InstanceConfig
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
LogDirectory = "./Logs",
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
// TokenUsage.json (leer)
var tokenUsage = new TokenUsageFile
{
InstanceId = config.InstanceId,
InstanceName = instanceName
};
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
// AgentList.json (leer)
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
return instanceDir;
}
public InstanceConfig LoadInstanceConfig(string instanceDir)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
if (!File.Exists(settingsPath))
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Agenten aus Verzeichnisstruktur laden
config.Agents.Clear();
var agentsDir = Path.Combine(instanceDir, "Agents");
// Sicherstellen, dass SharedWorkspace existiert (Migration)
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// AgentList.json für Descriptions laden
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
var agentList = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
if (Directory.Exists(agentsDir))
{
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
{
var agent = LoadAgentConfig(agentDir);
var folderName = Path.GetFileName(agentDir);
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
agent.Description = listEntry.Description;
config.Agents.Add(agent);
}
}
return config;
}
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
// Zugangsdaten nur für das Schreiben verschlüsseln — die laufende Instanz
// braucht sie danach wieder im Klartext.
ConfigSecrets.Protect(config);
try
{
SaveJson(settingsPath, config);
}
finally
{
ConfigSecrets.Unprotect(config);
}
}
// ═══════════════════════════════════════════════════
// AGENTEN-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildAgentFolderName(string agentName)
=> $"Agent-{SanitizeName(agentName)}";
public string GetAgentPath(string instanceDir, string agentName)
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
public string CreateAgent(string instanceDir, string agentName, string description = "")
{
var agentDir = GetAgentPath(instanceDir, agentName);
if (Directory.Exists(agentDir))
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
// Verzeichnisse anlegen
Directory.CreateDirectory(agentDir);
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
// AgentSettings.json
var agentConfig = new AgentConfig
{
AgentId = SanitizeName(agentName).ToLowerInvariant(),
DisplayName = agentName,
Model = "anthropic/claude-sonnet-4-5"
};
SaveAgentSettings(agentDir, agentConfig);
// Identity.md
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"),
$"""
# Identity: {agentName}
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
## Rolle
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
## Expertise
[Beschreibe hier die Fachgebiete und Fähigkeiten]
## Kontext
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
""");
// Soul.md
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"),
$"""
# Soul: {agentName}
## Persönlichkeit
- Gründlich und zuverlässig
- Klar und präzise in der Kommunikation
- Proaktiv bei der Problemerkennung
## Arbeitsweise
- Analysiere Aufgaben sorgfältig bevor du handelst
- Dokumentiere deine Entscheidungen und Ergebnisse
- Nutze die dir zugewiesenen Tools effizient
## Werte
- Genauigkeit vor Geschwindigkeit
- Transparenz in der Entscheidungsfindung
- Sicherheit und Datenschutz haben Priorität
""");
// AgentList.json aktualisieren
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
return agentDir;
}
public AgentConfig LoadAgentConfig(string agentDir)
{
// AgentSettings.json laden
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
AgentConfig config;
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
ConfigLoader.Migrate(config);
ConfigSecrets.Unprotect(config);
}
else
{
config = new AgentConfig
{
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
};
}
// Identity.md laden
var identityPath = Path.Combine(agentDir, "Identity.md");
if (File.Exists(identityPath))
config.Identity = File.ReadAllText(identityPath);
// Soul.md laden
var soulPath = Path.Combine(agentDir, "Soul.md");
if (File.Exists(soulPath))
config.Soul = File.ReadAllText(soulPath);
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
config.AgentDir = Path.GetFullPath(agentDir);
// Workspace-Pfad setzen
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
// SharedWorkspace-Pfad setzen
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
if (agentsDir != null)
{
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
}
return config;
}
public void SaveAgentSettings(string agentDir, AgentConfig config)
{
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
ConfigSecrets.Protect(config);
try
{
SaveJson(settingsPath, config);
}
finally
{
ConfigSecrets.Unprotect(config);
}
}
public void SaveAgentIdentity(string agentDir, string identity)
{
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
}
public void SaveAgentSoul(string agentDir, string soul)
{
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
}
public void RemoveAgent(string instanceDir, string agentFolderName)
{
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
if (Directory.Exists(agentDir))
Directory.Delete(agentDir, recursive: true);
// AgentList.json aktualisieren
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
if (File.Exists(agentListPath))
{
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
SaveJson(agentListPath, list);
}
}
// ═══════════════════════════════════════════════════
// TOKEN USAGE
// ═══════════════════════════════════════════════════
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
{
lock (_tokenUsageLock)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
TokenUsageFile file;
if (File.Exists(path))
{
try
{
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
}
catch (JsonException)
{
// Korrupte Datei: Backup erstellen, neu anfangen
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
file = new TokenUsageFile();
}
}
else
{
file = new TokenUsageFile();
}
file.Records.Add(record);
SaveJson(path, file);
}
}
public TokenUsageFile LoadTokenUsage(string instanceDir)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
return File.Exists(path)
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
: new TokenUsageFile();
}
// ═══════════════════════════════════════════════════
// HILFSMETHODEN
// ═══════════════════════════════════════════════════
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
{
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
var list = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
// Duplikat-Check
if (list.Agents.All(a => a.FolderName != folderName))
{
list.Agents.Add(new AgentListItem
{
Name = agentName,
Description = description,
FolderName = folderName
});
}
SaveJson(agentListPath, list);
}
private static void SaveJson<T>(string path, T obj)
{
// Atomar: Ein Absturz mitten im Schreiben soll keine halbe Datei hinterlassen.
// Genau das ist bereits passiert (TokenUsage.json.corrupt_…).
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(obj, JsonOpts));
}
private static T? LoadJson<T>(string path)
{
// Lesen ohne den Schreiber zu blockieren — siehe AtomicFile.ReadAllText.
var json = AtomicFile.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
private static string SanitizeName(string name)
{
var sanitized = name.Trim();
foreach (var c in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(c, '_');
return sanitized.Replace(' ', '_');
}
}
@@ -0,0 +1,95 @@
using System.Text.Json;
using ClawdDotNet.App.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Thread-safe JSON persistence for job execution history.
/// Uses file locking to handle concurrent access from multiple schedulers.
/// </summary>
public sealed class JobHistoryService
{
private readonly string _filePath;
private readonly Lock _lock = new();
private readonly int _maxEntries;
private List<JobHistoryEntry> _entries = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public JobHistoryService(string instancePath, int maxEntries = 500)
{
_filePath = Path.Combine(instancePath, "job_history.json");
_maxEntries = maxEntries;
Load();
}
/// <summary>
/// Adds a new entry to the history (thread-safe, persists immediately).
/// </summary>
public void Add(JobHistoryEntry entry)
{
lock (_lock)
{
_entries.Insert(0, entry); // newest first
// Trim old entries
if (_entries.Count > _maxEntries)
_entries = _entries.Take(_maxEntries).ToList();
Save();
}
}
/// <summary>
/// Returns a snapshot of all entries (newest first).
/// </summary>
public List<JobHistoryEntry> GetAll()
{
lock (_lock)
return new List<JobHistoryEntry>(_entries);
}
private void Load()
{
lock (_lock)
{
try
{
if (!File.Exists(_filePath))
{
_entries = new List<JobHistoryEntry>();
return;
}
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
_entries = JsonSerializer.Deserialize<List<JobHistoryEntry>>(stream, JsonOptions)
?? new List<JobHistoryEntry>();
}
catch
{
_entries = new List<JobHistoryEntry>();
}
}
}
private void Save()
{
try
{
var tmpPath = _filePath + ".tmp";
using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
JsonSerializer.Serialize(stream, _entries, JsonOptions);
}
File.Move(tmpPath, _filePath, overwrite: true);
}
catch
{
// Silently ignore write failures — next save will retry
}
}
}
+237
View File
@@ -0,0 +1,237 @@
using ClawdDotNet.App.Settings;
using Deploymentcenter.Client;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
///
/// <para><b>Nur ein Urteil sperrt.</b> Das SDK trennt seit 2.1 zwei Dinge, die vorher
/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
/// (<c>revoked</c>, <c>expired</c>, <c>not_found</c>, <c>activation_limit</c>,
/// <c>suspended</c>, <c>clock_rollback</c>) und ein gescheiterter Versuch, überhaupt
/// eine zu bekommen (<see cref="LicenseValidationResult.IsTransient"/>). Nur das Urteil
/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
/// aussperren.</para>
///
/// <para>Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung
/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und
/// trägt damit über Ausfälle hinweg — begrenzt durch <c>cache_ttl_hours</c> des Projekts
/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.</para>
///
/// <para><b>Was diese Fassung nicht kann.</b> Deaktivieren (Aktivierungsplatz freigeben)
/// verlangt den <c>shared_key</c> des Servers. Der gehört nicht in eine ausgelieferte
/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben").
/// Eine Signaturprüfung findet nicht statt — siehe <see cref="LicenseInfo"/>.</para>
/// </summary>
public sealed class LicenseGate
{
private readonly SettingsManager _settings;
private readonly ILogger _logger;
private readonly ILicensePrompt _prompt;
private readonly LicenseClient _client;
private readonly string _serverUrl;
private string? _hardwareId;
public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
{
_settings = settings;
_logger = logger;
_prompt = prompt;
_serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
? LicenseInfo.DefaultServerUrl
: settings.AppSettings.DeploymentcenterUrl.Trim();
// Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
// trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
// Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
// von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
// Server nicht antwortete.
_client = new LicenseClient();
}
/// <summary>
/// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung
/// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas
/// eintragen kann.
/// </summary>
public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
/// <summary>
/// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
/// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
/// </summary>
public string HardwareId =>
// Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
_hardwareId ??= global::Deploymentcenter.Client.HardwareId
.GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
/// <summary>
/// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein
/// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
/// </summary>
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
/// <summary>
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> nach, bis eine nutzbare
/// Lizenz vorliegt, oder gibt <c>false</c> zurück — dann beendet der Aufrufer die
/// Anwendung.
/// </summary>
public async Task<bool> RunStartupCheckAsync(CancellationToken ct = default)
{
if (!IsEnforcementConfigured)
{
_logger.LogWarning(
"Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) übersprungen.");
return true;
}
var key = _settings.AppSettings.LicenseKey;
if (string.IsNullOrWhiteSpace(key))
{
key = await _prompt.RequestKeyAsync(HardwareId, null, null);
if (key is null) return false;
}
var result = await ValidateAsync(key, ct);
while (!result.IsValid)
{
// Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer
// Schlüssel würde daran nichts ändern, und danach zu fragen ließe den
// Benutzer raten. Der Notausschalter greift, sobald der Server wieder
// antwortet (LicenseWatch).
if (result.IsTransient)
{
_logger.LogWarning(
"Lizenz nicht prüfbar ({Status}): {Message} Start wird fortgesetzt.",
result.Status, result.Message);
await _prompt.ShowInfoAsync("ClawdDotNet Lizenz",
"Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder "
+ "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später "
+ "erneut.");
return true;
}
if (result.Status == "clock_rollback")
{
// Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung.
await _prompt.ShowErrorAsync("ClawdDotNet Lizenz",
"Die Lizenzprüfung konnte nicht abgeschlossen werden. "
+ "Bitte den Support kontaktieren.");
return false;
}
_logger.LogWarning(
"Lizenz abgelehnt: {Status} {Message} (Produkt {Slug}, Server {Server})",
result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl);
var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key);
if (retry is null) return false;
key = retry;
result = await ValidateAsync(key, ct);
}
_settings.AppSettings.LicenseKey = key;
try
{
_settings.Save();
}
catch (SettingsPersistenceException ex)
{
// Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
// beim nächsten Start wird eben erneut gefragt.
_logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden");
}
if (result.IsCached)
{
var until = Describe(result.CacheExpiresAt);
_logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until);
await _prompt.ShowInfoAsync("ClawdDotNet Lizenz",
$"Lizenzserver nicht erreichbar. Offline gültig bis {until}.");
}
return true;
}
private async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(key))
{
return new LicenseValidationResult
{
IsValid = false,
Status = "not_found",
Message = "Kein Lizenzschlüssel hinterlegt."
};
}
try
{
return await _client.ValidateAsync(
LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als
// IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem
// nicht als Urteil über die Lizenz gelten.
_logger.LogError(ex, "Lizenzprüfung fehlgeschlagen.");
return new LicenseValidationResult
{
IsValid = false,
Status = "server_unavailable",
Message = "Lizenz konnte nicht geprüft werden.",
IsTransient = true
};
}
}
private static string Describe(long? unixSeconds) =>
unixSeconds is { } seconds and > 0
? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g")
: "auf Weiteres";
private static string DescribeProblem(LicenseValidationResult result) => result.Status switch
{
"revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.",
"suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.",
"expired" => "Diese Lizenz ist abgelaufen.",
"activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. "
+ "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste "
+ "über \"Freigeben\" räumen.",
// Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes
// Projekt und unbekannter Schlüssel. Welches davon, steht nur in message —
// deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch
// eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man
// sucht am falschen Ende.
"not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss "
+ $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt "
+ "sein und der Schlüssel dort hinterlegt."
+ (string.IsNullOrWhiteSpace(result.Message)
? ""
: $" (Server: {result.Message})"),
_ => string.IsNullOrWhiteSpace(result.Message)
? "Die Lizenz ist nicht gültig."
: result.Message
};
}
@@ -0,0 +1,38 @@
namespace ClawdDotNet.App.Services;
/// <summary>
/// Fest hinterlegte Lizenz-Eckdaten.
///
/// <para><b>Kein Public-Key mehr.</b> Die frühere Fassung führte einen
/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine
/// Gegenseite: Der Client liest ausschließlich das Feld <c>status</c> aus der Antwort,
/// eine Signaturprüfung findet nicht statt (siehe
/// <c>docs/Deploymentcenter-Anbindung-Review.md</c>, Abschnitt 2.1). Ein Schlüssel, der
/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist.
/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück.</para>
///
/// <para>Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann
/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz.</para>
/// </summary>
public static class LicenseInfo
{
/// <summary>
/// Projekt-Slug, wie er in <c>dc_projects</c> angelegt ist. Gilt für alle Module:
/// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe
/// Projekttabelle zu.
///
/// <para>Hier stand bis zur Umstellung <c>clawd</c> — der Name aus dem
/// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt
/// <c>clawddotnet</c>. Der Server beantwortet einen unbekannten Slug mit demselben
/// <c>not_found</c> wie einen unbekannten Schlüssel, weshalb das wie ein falsch
/// eingegebener Lizenzschlüssel aussah.</para>
/// </summary>
public const string ProductSlug = "clawddotnet";
/// <summary>
/// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine
/// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung
/// und teilen sich diese Adresse.
/// </summary>
public const string DefaultServerUrl = "https://dc.mhdf.de";
}
@@ -0,0 +1,110 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Prüft die Lizenz im laufenden Betrieb nach.
///
/// <para>Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die
/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle
/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro
/// Klick.</para>
///
/// <para><b>Nur ein Urteil zählt.</b> Ein Netzproblem, eine Drosselung oder eine
/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als
/// <c>IsTransient</c>, und ein Serverausfall darf nicht alle laufenden Instanzen
/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg.</para>
/// </summary>
public sealed class LicenseWatch : IAsyncDisposable
{
private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12);
private readonly LicenseGate _gate;
private readonly ILogger _logger;
private readonly TimeSpan _interval;
private CancellationTokenSource? _cts;
private Task? _loop;
/// <summary>
/// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber
/// ohne Rückfrage; der übergebene Text erklärt den Grund.
/// </summary>
public event Func<string, Task>? Revoked;
public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null)
{
_gate = gate;
_logger = logger;
_interval = interval ?? DefaultInterval;
}
public void Start()
{
if (_loop is { IsCompleted: false })
return;
_cts = new CancellationTokenSource();
_loop = RunAsync(_cts.Token);
}
private async Task RunAsync(CancellationToken ct)
{
try
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false);
if (result.IsValid)
continue;
if (result.IsTransient)
{
_logger.LogInformation(
"Lizenz-Nachprüfung ohne Ergebnis ({Status}) Betrieb läuft weiter.",
result.Status);
continue;
}
_logger.LogWarning("Lizenz gilt nicht mehr ({Status}) Instanz wird beendet.",
result.Status);
if (Revoked is { } handler)
{
await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). "
+ "ClawdDotNet wird beendet.").ConfigureAwait(false);
}
return;
}
}
catch (OperationCanceledException)
{
// Regulärer Stopp.
}
catch (Exception ex)
{
// Die Nachprüfung darf den Betrieb nicht mitnehmen.
_logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen.");
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null)
return;
await _cts.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
}
}
+154
View File
@@ -0,0 +1,154 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace ClawdDotNet.App.Services;
/// <summary>Eine gelesene Logzeile.</summary>
/// <param name="Module">Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei.</param>
/// <param name="Level">INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat.</param>
public readonly record struct LogLine(string Module, string Level, string Text);
/// <summary>Ab welcher Stufe angezeigt wird.</summary>
public enum LogLevelFilter { All, Info, Warn, Error }
/// <summary>
/// Liest neu hinzugekommene Zeilen aus den Logdateien.
///
/// <para>Die vorige Fassung (<c>LiveLogViewerService</c>) schrieb unmittelbar in eine
/// <c>RichTextBox</c> und taktete über einen <c>System.Windows.Forms.Timer</c> — Lesen
/// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur
/// das Lesen; was damit geschieht, entscheidet der Aufrufer.</para>
///
/// <para>Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das
/// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen.</para>
/// </summary>
public sealed partial class LogTail(string logDirectory)
{
private readonly ConcurrentDictionary<string, long> _positions = new();
[GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)]
private static partial Regex LevelPattern();
/// <summary>Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.</summary>
public IReadOnlyList<string> AvailableModules()
{
var directory = TodayDirectory();
if (directory is null) return [];
return Directory.GetFiles(directory, "*.log")
.Select(Path.GetFileNameWithoutExtension)
.Where(name => !string.IsNullOrEmpty(name))
.Select(name => name!)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// Alles, was seit dem letzten Aufruf dazugekommen ist.
///
/// <paramref name="module"/> leer heißt: alle Module.
/// </summary>
public IReadOnlyList<LogLine> ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All)
{
var directory = TodayDirectory();
if (directory is null) return [];
var result = new List<LogLine>();
foreach (var path in Directory.GetFiles(directory, "*.log"))
{
var moduleName = Path.GetFileNameWithoutExtension(path);
if (!string.IsNullOrEmpty(module)
&& !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase))
{
// Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters
// die gesamte bisherige Datei auf einmal herein.
TrackWithoutReading(path);
continue;
}
ReadFile(path, moduleName, level, result);
}
return result;
}
private void ReadFile(string path, string module, LogLevelFilter level, List<LogLine> into)
{
var lastPosition = _positions.GetOrAdd(path, 0L);
try
{
// FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die
// Datei auch ersetzen können, während wir lesen.
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
if (stream.Length < lastPosition)
lastPosition = 0; // rotiert oder gekürzt
if (stream.Length == lastPosition)
return;
stream.Seek(lastPosition, SeekOrigin.Begin);
using var reader = new StreamReader(stream);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var lineLevel = ExtractLevel(line);
if (!Passes(lineLevel, level)) continue;
into.Add(new LogLine(module, lineLevel, line));
}
_positions[path] = stream.Position;
}
catch (IOException)
{
// Wird gerade geschrieben — beim nächsten Takt erneut versuchen.
}
}
private void TrackWithoutReading(string path)
{
try { _positions[path] = new FileInfo(path).Length; }
catch (IOException) { }
}
private static string ExtractLevel(string line)
{
var match = LevelPattern().Match(line);
return match.Success ? match.Groups[1].Value.ToUpperInvariant() : "";
}
private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch
{
LogLevelFilter.All => true,
// Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine
// Fehlermeldung, die der Filter verschluckt.
_ when lineLevel.Length == 0 => true,
LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL",
LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL",
LogLevelFilter.Error => lineLevel is "ERR" or "FTL",
_ => true
};
/// <summary>
/// Das Verzeichnis des heutigen Tages, oder <c>null</c>.
///
/// <c>DateTime.Now</c> und nicht UTC: Die Schreibseite legt die Verzeichnisse nach
/// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit
/// <c>TZ=UTC</c> wechselt der Ordner damit um Mitternacht UTC — richtig, aber
/// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht.
/// </summary>
private string? TodayDirectory()
{
if (!Directory.Exists(logDirectory)) return null;
var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
return Directory.Exists(directory) ? directory : null;
}
}
@@ -0,0 +1,284 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Fragt regelmäßig Erreichbarkeit und Guthaben der OpenRouter-API ab.
///
/// Der Takt lief früher über einen <c>System.Windows.Forms.Timer</c> — der braucht eine
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/>: läuft
/// auch ohne Oberfläche, was der kopflose Betrieb voraussetzt.
///
/// <see cref="OnStatusUpdated"/> wird auf einem Hintergrundfaden ausgelöst. Wer daran
/// eine Oberfläche hängt, muss selbst auf den Oberflächenfaden wechseln — in Avalonia
/// über <c>Dispatcher.UIThread</c>.
/// </summary>
public sealed class OpenRouterStatusService : IAsyncDisposable
{
private readonly HttpClient _http;
private readonly TimeSpan _interval;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
/// <summary>
/// Preise kommen vom /models-Endpunkt statt aus einer fest verdrahteten Tabelle.
/// Die alte Tabelle war veraltet und enthielt ausgerechnet das Standardmodell der
/// Agenten nicht — die Anzeige meldete dafür stillschweigend 0 €.
/// </summary>
private readonly ModelPricingCatalog _pricing = new();
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
private const double UsdToEur = 0.92;
public bool IsApiReachable { get; private set; }
public string StatusText { get; private set; } = "Prüfe...";
public string CreditsText { get; private set; } = "—";
public string CreditsTooltip { get; private set; } = "";
public double? CreditBalance { get; private set; }
public double? CreditRemaining { get; private set; }
public event Action? OnStatusUpdated;
public OpenRouterStatusService(string apiKey, string baseUrl = "https://openrouter.ai/api/v1/", int checkIntervalSeconds = 60)
{
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
_interval = TimeSpan.FromSeconds(checkIntervalSeconds);
}
public void Start()
{
_loop ??= RunLoopAsync(_cts.Token);
_ = CheckStatusAsync();
}
private async Task RunLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(ct))
{
// CheckStatusAsync fängt bereits alles ab und setzt IsApiReachable — hier
// muss nichts mehr behandelt werden.
await CheckStatusAsync();
}
}
public void RecordUsage(string model, int promptTokens, int completionTokens)
{
var estimate = _pricing.Estimate(model, promptTokens, completionTokens);
if (!estimate.IsKnown)
_modelsWithoutPricing.TryAdd(model, 0);
_usageRecords.Add(new UsageRecord(
DateTime.Now, model, promptTokens, completionTokens, (double)estimate.Usd, estimate.IsKnown));
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
/// <summary>
/// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und
/// alle Kosten werden als unbekannt ausgewiesen.
/// </summary>
public async Task LoadPricingAsync(OpenRouterClient client, CancellationToken ct = default)
{
try
{
var models = await client.GetAvailableModelsAsync(ct);
_pricing.Load(models);
// Modelle, die bisher als unbekannt galten, sind jetzt vielleicht bekannt.
foreach (var model in _modelsWithoutPricing.Keys)
{
if (_pricing.IsKnown(model))
_modelsWithoutPricing.TryRemove(model, out _);
}
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
catch (Exception)
{
// Ohne Preise bleibt die Anzeige ehrlich unbekannt statt falsch null.
}
}
private async Task CheckStatusAsync()
{
try
{
using var response = await _http.GetAsync("auth/key");
if (response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
IsApiReachable = true;
if (doc.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("limit", out var limit))
CreditBalance = limit.GetDouble();
if (data.TryGetProperty("usage", out var usage))
{
var used = usage.GetDouble();
var remaining = (CreditBalance ?? 0) - used;
CreditRemaining = remaining;
StatusText = $"✓ API OK | Credits: ${remaining:F4} von ${CreditBalance:F2}";
}
else
{
StatusText = "✓ API erreichbar";
}
}
else
{
StatusText = "✓ API erreichbar";
}
}
else
{
IsApiReachable = false;
StatusText = $"✗ API Fehler: {(int)response.StatusCode}";
}
}
catch (HttpRequestException ex)
{
IsApiReachable = false;
StatusText = $"✗ Nicht erreichbar: {ex.Message}";
}
catch (TaskCanceledException)
{
IsApiReachable = false;
StatusText = "✗ Timeout";
}
catch (Exception ex)
{
IsApiReachable = false;
StatusText = $"✗ Fehler: {ex.Message}";
}
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
private void UpdateCreditsText()
{
var now = DateTime.Now;
var oneHourAgo = now.AddHours(-1);
var oneDayAgo = now.AddHours(-24);
var records = _usageRecords.ToArray();
var lastHour = records.Where(r => r.Timestamp >= oneHourAgo).ToArray();
var last24h = records.Where(r => r.Timestamp >= oneDayAgo).ToArray();
var tokensLastHour = lastHour.Sum(r => r.PromptTokens + r.CompletionTokens);
var tokensLast24h = last24h.Sum(r => r.PromptTokens + r.CompletionTokens);
var costLastHour = lastHour.Sum(r => r.CostUsd);
var costLast24h = last24h.Sum(r => r.CostUsd);
// Ein Hinweis, sobald Läufe dabei sind, deren Kosten nicht bezifferbar sind —
// sonst liest sich eine zu niedrige Summe wie eine vollständige.
var unpriced = last24h.Count(r => !r.CostIsKnown);
var warning = unpriced > 0 ? " ⚠" : "";
CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " +
$"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€){warning}";
// Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h)
var modelGroups = last24h
.GroupBy(r => r.Model)
.OrderByDescending(g => g.Sum(r => r.CostUsd))
.ToList();
if (modelGroups.Count == 0)
{
CreditsTooltip = "Keine Token-Nutzung in den letzten 24h";
return;
}
var sb = new System.Text.StringBuilder();
sb.AppendLine("═══ Token-Verbrauch (24h) ═══");
sb.AppendLine();
foreach (var group in modelGroups)
{
var modelName = group.Key;
var shortName = modelName.Contains('/') ? modelName[(modelName.IndexOf('/') + 1)..] : modelName;
var prompt = group.Sum(r => r.PromptTokens);
var completion = group.Sum(r => r.CompletionTokens);
var total = prompt + completion;
var cost = group.Sum(r => r.CostUsd);
var runs = group.Count();
sb.AppendLine($"▸ {shortName}");
sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)");
if (_pricing.Get(modelName) is { } pricing)
{
sb.AppendLine($" Preis: ${pricing.InputPer1M:0.####}/1M in, ${pricing.OutputPer1M:0.####}/1M out");
sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)");
}
else
{
sb.AppendLine(" Kosten: unbekannt — für dieses Modell liegen keine Preise vor");
}
sb.AppendLine();
}
var totalCost = last24h.Sum(r => r.CostUsd);
sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══");
if (unpriced > 0)
{
sb.AppendLine();
sb.AppendLine($"⚠ {unpriced} Lauf/Läufe ohne Preisangabe — die Summe ist unvollständig.");
sb.AppendLine($" Betroffene Modelle: {string.Join(", ", _modelsWithoutPricing.Keys.Order())}");
}
if (_pricing.LastUpdated is { } updated)
sb.AppendLine($"\nPreise abgerufen: {updated:g} ({_pricing.Count} Modelle)");
else
sb.AppendLine("\n⚠ Preise noch nicht geladen.");
CreditsTooltip = sb.ToString().TrimEnd();
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_loop is not null)
{
try { await _loop; }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
_http.Dispose();
}
private sealed record UsageRecord(
DateTime Timestamp,
string Model,
int PromptTokens,
int CompletionTokens,
double CostUsd,
bool CostIsKnown);
}