feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -34,6 +34,14 @@
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Uebergang: SettingsManager und AppSettings sind nach ClawdDotNet.App gewandert.
|
||||
Das globale Using haelt die WinForms-Fassung baubar, solange sie waehrend der
|
||||
Avalonia-Portierung noch als Vorlage und Vergleich dient. Faellt mit dem Projekt
|
||||
weg (Aufgabe „WinForms-Oberflaeche entfernen"). -->
|
||||
<ItemGroup>
|
||||
<Using Include="ClawdDotNet.App.Settings" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="EmbeddedUI\**\*" />
|
||||
</ItemGroup>
|
||||
@@ -43,6 +51,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="src\ClawdDotNet.App\ClawdDotNet.App.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.Telegram\ClawdDotNet.Tools.Telegram.csproj" />
|
||||
@@ -57,7 +66,11 @@
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.AgentEditor\ClawdDotNet.Tools.AgentEditor.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<ProjectReference Include="src\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
|
||||
<!-- Der Verweis auf das LicenseLabrador-SDK ist entfallen: Lizenz, Watchdog, Updates,
|
||||
Fehler-Stream und Bugtracker laufen jetzt ueber das Deploymentcenter. Das SDK
|
||||
dafuer haengt an src\ClawdDotNet.App und kommt von dort transitiv mit. -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+14
-1
@@ -1,5 +1,7 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ClawdDotNet.App/ClawdDotNet.App.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Desktop/ClawdDotNet.Desktop.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Core/ClawdDotNet.Core.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.DirectAPI/ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.FileRW/ClawdDotNet.Tools.FileRW.csproj" />
|
||||
@@ -15,10 +17,21 @@
|
||||
<Project Path="src/ClawdDotNet.Tools.AgentEditor/ClawdDotNet.Tools.AgentEditor.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.SocialMediaManager/ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.Memory/ClawdDotNet.Tools.Memory.csproj" />
|
||||
<Project Path="src/ClawdDotNet.Tools.Taskboard/ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj" />
|
||||
<Project Path="tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj" />
|
||||
</Folder>
|
||||
<Project Path="ClawdDotNet.csproj" />
|
||||
<!--
|
||||
Die WinForms-Fassung (ClawdDotNet.csproj, frm_*.cs, UI/, Models/, EmbeddedUI/) ist
|
||||
seit dem Herausloesen der Anwendungsschicht nicht mehr Teil des Builds.
|
||||
|
||||
Die Dateien bleiben bis zum Abschluss der Portierung liegen: Sie sind die Vorlage
|
||||
fuer Aufbau und Verdrahtung der Avalonia-Ansichten — dafuer muessen sie lesbar sein,
|
||||
nicht uebersetzbar. Entfernt werden sie mit der Aufgabe
|
||||
„WinForms-Oberflaeche entfernen".
|
||||
|
||||
Oberflaeche ist jetzt src/ClawdDotNet.Desktop.
|
||||
-->
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
Produktversion, eine Stelle fuer alle Projekte.
|
||||
|
||||
Sie ist ab jetzt die Wahrheit fuer alles, was nach draussen geht: die
|
||||
Aktivierungsliste des Deploymentcenters (app_version), das Feld version am
|
||||
Watchdog-Heartbeat, die Build-Angabe an Fehlermeldungen und den
|
||||
Versionsvergleich der Update-Pruefung. Vorher wurde dafuer
|
||||
BuildInfo.Build behelfsweise als "0.0.<Zahl>" gemeldet - eine fortlaufende
|
||||
Zahl ist aber keine semantische Version, und der Update-Dienst vergleicht
|
||||
semantisch.
|
||||
|
||||
Beim Veroeffentlichen eines Releases erhoehen und denselben Wert an
|
||||
pack-and-deploy uebergeben.
|
||||
|
||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
||||
-->
|
||||
<Version>0.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,82 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Models;
|
||||
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public sealed class AppSettings
|
||||
{
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Log-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
|
||||
[JsonPropertyName("logDirectory")]
|
||||
public string LogDirectory { get; set; } = "./Logs";
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Instanzen-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
|
||||
[JsonPropertyName("instancesDirectory")]
|
||||
public string InstancesDirectory { get; set; } = "./Instances";
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Standard-Konfigurations-Datei")]
|
||||
[Description("Pfad zur Standard-Instanz-Konfiguration (Legacy). Neue Instanzen nutzen das Instanzen-Verzeichnis.")]
|
||||
[JsonPropertyName("defaultConfigPath")]
|
||||
public string DefaultConfigPath { get; set; } = "./configs/config.json";
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Minimaler Log-Level")]
|
||||
[Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
|
||||
[JsonPropertyName("minimumLogLevel")]
|
||||
public string MinimumLogLevel { get; set; } = "Info";
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Max. Log-Zeilen in UI")]
|
||||
[Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
|
||||
[JsonPropertyName("maxLogLinesInUi")]
|
||||
public int MaxLogLinesInUi { get; set; } = 2000;
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Log-Aktualisierungsintervall (ms)")]
|
||||
[Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
|
||||
[JsonPropertyName("logRefreshIntervalMs")]
|
||||
public int LogRefreshIntervalMs { get; set; } = 500;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("Status-Check-Intervall (Sek)")]
|
||||
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
|
||||
[JsonPropertyName("statusCheckIntervalSeconds")]
|
||||
public int StatusCheckIntervalSeconds { get; set; } = 60;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("OpenRouter Base-URL")]
|
||||
[Description("Basis-URL der OpenRouter-API.")]
|
||||
[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; } = "./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;
|
||||
|
||||
public override string ToString() => "Anwendungseinstellungen";
|
||||
}
|
||||
@@ -78,6 +78,45 @@ public sealed class InstanceSettingsViewModel
|
||||
[ReadOnly(true)]
|
||||
public int AgentCount => _config.Agents.Count;
|
||||
|
||||
// ─── WatchDog (Instanz-Heartbeat) ───
|
||||
// Server-URL und Master-Token stehen app-weit in den Anwendungseinstellungen. Die
|
||||
// Instanz registriert sich damit selbst; hier stehen nur Source/Gruppe/Intervall.
|
||||
// Ein/Aus läuft über den Service „Instanz-Watchdog" im Worker-Tab. Änderungen greifen
|
||||
// beim nächsten Start der Instanz.
|
||||
|
||||
[Category("WatchDog")]
|
||||
[DisplayName("Source")]
|
||||
[Description("Dienst-Kennung im WatchDog. Alle Instanzen teilen sich dieselbe Source.")]
|
||||
public string WatchdogSource
|
||||
{
|
||||
get => _config.Watchdog.Source;
|
||||
set => _config.Watchdog.Source = value;
|
||||
}
|
||||
|
||||
[Category("WatchDog")]
|
||||
[DisplayName("Gruppe")]
|
||||
[Description("Gruppierung im WatchDog-Dashboard.")]
|
||||
public string WatchdogGroup
|
||||
{
|
||||
get => _config.Watchdog.Group;
|
||||
set => _config.Watchdog.Group = value;
|
||||
}
|
||||
|
||||
[Category("WatchDog")]
|
||||
[DisplayName("Intervall (Sek)")]
|
||||
[Description("Sende-Takt der Heartbeats in Sekunden (mindestens 5).")]
|
||||
public int WatchdogIntervalSeconds
|
||||
{
|
||||
get => _config.Watchdog.IntervalSeconds;
|
||||
set => _config.Watchdog.IntervalSeconds = value;
|
||||
}
|
||||
|
||||
[Category("WatchDog")]
|
||||
[DisplayName("Token registriert")]
|
||||
[Description("Ob diese Instanz bereits einen eigenen Agent-Token vom WatchDog erhalten hat.")]
|
||||
[ReadOnly(true)]
|
||||
public bool WatchdogTokenRegistered => _config.Watchdog.HasToken;
|
||||
|
||||
[Browsable(false)]
|
||||
public InstanceConfig UnderlyingConfig => _config;
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@
|
||||
<package pattern="FluentFTP" />
|
||||
<package pattern="SQLitePCLRaw.*" />
|
||||
<package pattern="WTelegramClient" />
|
||||
<!-- Oberflaeche (Avalonia-Portierung) -->
|
||||
<package pattern="Avalonia" />
|
||||
<package pattern="Avalonia.*" />
|
||||
<package pattern="CommunityToolkit.*" />
|
||||
<package pattern="SkiaSharp" />
|
||||
<package pattern="SkiaSharp.*" />
|
||||
<package pattern="HarfBuzzSharp" />
|
||||
<package pattern="HarfBuzzSharp.*" />
|
||||
<package pattern="Tmds.DBus.*" />
|
||||
<package pattern="MicroCom.*" />
|
||||
<!-- Diagramme, sobald die Handelsansichten kommen -->
|
||||
<package pattern="LiveChartsCore.*" />
|
||||
<!-- Test-Pakete -->
|
||||
<package pattern="xunit" />
|
||||
<package pattern="xunit.*" />
|
||||
|
||||
+84
-17
@@ -3,7 +3,6 @@ using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
@@ -106,6 +105,13 @@ internal static class Program
|
||||
instanceConfig.InstanceName, instanceConfig.InstanceId);
|
||||
coreLogger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
|
||||
|
||||
// ─── 5b. Lizenzprüfung ───
|
||||
// Entfällt in dieser Fassung. Startprüfung, laufende Nachprüfung und die
|
||||
// Deploymentcenter-Anbindung (Watchdog, Updates, Fehler-Stream, Bugtracker)
|
||||
// liegen in ClawdDotNet.App (AppHost); Einstiegspunkt ist
|
||||
// src/ClawdDotNet.Desktop. Diese Datei ist nur noch Vorlage — siehe
|
||||
// ClawdDotNet.slnx.
|
||||
|
||||
// ─── 6. Core-Komponenten erzeugen ───
|
||||
var toolRegistry = new ToolRegistry();
|
||||
toolRegistry.Register(new FileRWTool());
|
||||
@@ -121,6 +127,7 @@ internal static class Program
|
||||
toolRegistry.Register(new AgentSpawnTool());
|
||||
toolRegistry.Register(new AgentEditorTool());
|
||||
toolRegistry.Register(new ClawdDotNet.Tools.Memory.MemoryTool());
|
||||
toolRegistry.Register(new ClawdDotNet.Tools.Taskboard.TaskboardTool());
|
||||
|
||||
// ─── 6a. TelegramClient (MTProto User-API) ───
|
||||
TelegramClientManager? tgClientManager = null;
|
||||
@@ -142,8 +149,9 @@ internal static class Program
|
||||
|
||||
OpenRouterClient? openRouterClient = null;
|
||||
AgentEngine? agentEngine = null;
|
||||
AgentScheduler? agentScheduler = null;
|
||||
ToolJobScheduler? toolJobScheduler = null;
|
||||
ClawdDotNet.Core.Tasks.TaskScanner? taskScanner = null;
|
||||
ClawdDotNet.Core.Staging.StagingService? stagingService = null;
|
||||
SqliteUsageRepository? usageRepository = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(instanceConfig.OpenRouterApiKey))
|
||||
{
|
||||
@@ -159,7 +167,12 @@ internal static class Program
|
||||
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
|
||||
var stateStore = new SqliteStateStore(storage);
|
||||
var memoryRepository = new SqliteMemoryRepository(storage);
|
||||
var usageRepository = new SqliteUsageRepository(storage);
|
||||
var taskRepository = new ClawdDotNet.Core.Tasks.SqliteTaskRepository(storage);
|
||||
var auditRepository = new ClawdDotNet.Core.Audit.SqliteAuditRepository(storage);
|
||||
var stagingRepository = new ClawdDotNet.Core.Staging.SqliteStagingRepository(storage);
|
||||
var stagingGate = new ClawdDotNet.Core.Staging.StagingGate(
|
||||
new ClawdDotNet.Core.Staging.StagingPolicy(), stagingRepository);
|
||||
usageRepository = new SqliteUsageRepository(storage);
|
||||
|
||||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||||
var pricingCatalog = new ModelPricingCatalog();
|
||||
@@ -171,7 +184,7 @@ internal static class Program
|
||||
|
||||
agentEngine = new AgentEngine(
|
||||
openRouterClient, toolRegistry, permissionGate, stateStore, loggerFactory,
|
||||
memoryRepository, usageRepository, pricingCatalog)
|
||||
memoryRepository, usageRepository, pricingCatalog, taskRepository, auditRepository, stagingGate)
|
||||
{
|
||||
InstanceBudget = instanceConfig.Budget
|
||||
};
|
||||
@@ -185,13 +198,65 @@ internal static class Program
|
||||
return string.IsNullOrWhiteSpace(agent.AgentDir) ? null : agent.AgentDir;
|
||||
});
|
||||
agentEngine.LoadPersistedChats();
|
||||
agentScheduler = new AgentScheduler(agentEngine, instanceConfig.InstanceId, loggerFactory);
|
||||
agentScheduler.RegisterAll(instanceConfig.Agents);
|
||||
|
||||
toolJobScheduler = new ToolJobScheduler(agentEngine, toolRegistry, stateStore, loggerFactory, instanceConfig.InstanceId);
|
||||
toolJobScheduler.RegisterAll(instanceConfig.Agents);
|
||||
// ─── Taskboard: Dateien in die DB spiegeln (Reconciliation beim Start) ───
|
||||
// Verwaiste Claims eines abgestürzten Laufs lösen, dann alle Task-Dateien
|
||||
// importieren. Nicht blockierend — der Start soll nicht auf das Dateisystem warten.
|
||||
var sharedWorkspace = instanceConfig.Agents
|
||||
.Select(a => a.SharedWorkspacePath)
|
||||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||
if (!string.IsNullOrWhiteSpace(sharedWorkspace))
|
||||
{
|
||||
var board = new ClawdDotNet.Core.Tasks.TaskboardService(
|
||||
taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||||
|
||||
coreLogger.LogInformation("AgentEngine und Scheduler erstellt, Chat-Verläufe geladen");
|
||||
// Staging (A2): Freigabe-/Ablehnungs-Dienst für die Review-Oberfläche.
|
||||
// Weckt den Agenten nach der Entscheidung über einen Folge-Task (A1).
|
||||
stagingService = new ClawdDotNet.Core.Staging.StagingService(
|
||||
stagingRepository, agentEngine, board, loggerFactory, auditRepository);
|
||||
|
||||
// Scanner (A1): der Taktgeber. Startet erst, wenn die Reconciliation durch
|
||||
// ist — sonst könnte er einen noch nicht bereinigten Claim antreffen.
|
||||
// Der Scanner ist der EINZIGE periodische Treiber (A1) — er ersetzt die
|
||||
// früheren AgentScheduler/ToolJobScheduler. Geplante Agent-Läufe wie auch
|
||||
// Tool-Job-Polls sind jetzt Tasks.
|
||||
var dispatcher = new ClawdDotNet.Core.Tasks.EngineTaskDispatcher(
|
||||
agentEngine, () => instanceConfig.Agents, instanceConfig.InstanceId,
|
||||
toolRegistry, stateStore, loggerFactory);
|
||||
taskScanner = new ClawdDotNet.Core.Tasks.TaskScanner(
|
||||
taskRepository, dispatcher, loggerFactory);
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reset = await taskRepository.ReleaseStaleClaimsAsync(
|
||||
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
// Einmalige Übernahme der alten coordination/task_*-Dateien …
|
||||
var coordMigration = new ClawdDotNet.Core.Tasks.CoordinationMigration(
|
||||
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
|
||||
var migratedCoord = await coordMigration.RunAsync(CancellationToken.None);
|
||||
|
||||
// … und der alten scheduler/toolJobs-Konfiguration in Tasks.
|
||||
var schedMigration = new ClawdDotNet.Core.Tasks.SchedulerTaskMigration(
|
||||
board, taskRepository, loggerFactory);
|
||||
var migratedSched = await schedMigration.RunAsync(instanceConfig.Agents, CancellationToken.None);
|
||||
|
||||
var imported = await board.ImportAllAsync(CancellationToken.None);
|
||||
coreLogger.LogInformation(
|
||||
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + {Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
|
||||
imported, migratedCoord, migratedSched, reset);
|
||||
taskScanner.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
coreLogger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
coreLogger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -234,6 +299,11 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6c. Instanz-Watchdog ───
|
||||
// Entfällt hier ebenfalls. Der Heartbeat ans Deploymentcenter (ein Monitor je
|
||||
// Instanz, mit Gesundheitsprüfungen und angekündigtem Ende) steht in
|
||||
// ClawdDotNet.App/Services/DeploymentcenterService.cs.
|
||||
|
||||
// ─── 7. MainForm starten ───
|
||||
var form = new frm_main(
|
||||
settingsManager,
|
||||
@@ -244,19 +314,16 @@ internal static class Program
|
||||
toolRegistry,
|
||||
dirManager,
|
||||
agentEngine,
|
||||
agentScheduler,
|
||||
toolJobScheduler);
|
||||
taskScanner,
|
||||
stagingService);
|
||||
|
||||
Application.Run(form);
|
||||
|
||||
// ─── 8. Aufräumen ───
|
||||
coreLogger.LogInformation("ClawdDotNet wird beendet");
|
||||
|
||||
if (toolJobScheduler is not null)
|
||||
toolJobScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
if (agentScheduler is not null)
|
||||
agentScheduler.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
if (taskScanner is not null)
|
||||
taskScanner.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
if (tgClientManager is not null)
|
||||
tgClientManager.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.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.
|
||||
/// </summary>
|
||||
public sealed class BackupScheduler : IDisposable
|
||||
{
|
||||
private readonly string _instanceDir;
|
||||
private readonly string _instanceName;
|
||||
private readonly SettingsManager _settings;
|
||||
private readonly ILogger _logger;
|
||||
private readonly BackupService _service = new();
|
||||
private readonly System.Windows.Forms.Timer _timer;
|
||||
|
||||
/// <summary>Verhindert mehrere Sicherungen innerhalb derselben Minute.</summary>
|
||||
private DateTime? _lastRun;
|
||||
|
||||
private bool _running;
|
||||
|
||||
public event Action<string>? OnBackupCreated;
|
||||
|
||||
public BackupScheduler(string instanceDir, string instanceName,
|
||||
SettingsManager settings, ILogger logger)
|
||||
{
|
||||
_instanceDir = instanceDir;
|
||||
_instanceName = instanceName;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
|
||||
// Minütlich prüfen reicht — die Uhrzeit ist auf Minuten genau eingestellt.
|
||||
_timer = new System.Windows.Forms.Timer { Interval = 60_000 };
|
||||
_timer.Tick += async (_, _) => await TickAsync();
|
||||
}
|
||||
|
||||
public void Start() => _timer.Start();
|
||||
|
||||
public void Stop() => _timer.Stop();
|
||||
|
||||
private async Task TickAsync()
|
||||
{
|
||||
if (_running)
|
||||
return;
|
||||
|
||||
var settings = _settings.AppSettings;
|
||||
if (!settings.AutoBackupEnabled)
|
||||
return;
|
||||
|
||||
if (!TimeSpan.TryParse(settings.AutoBackupTime, out var scheduled))
|
||||
return;
|
||||
|
||||
var now = DateTime.Now;
|
||||
|
||||
// Fällig, sobald die Uhrzeit erreicht ist und heute noch nichts lief.
|
||||
if (now.TimeOfDay < scheduled)
|
||||
return;
|
||||
|
||||
if (_lastRun?.Date == now.Date)
|
||||
return;
|
||||
|
||||
_running = true;
|
||||
try
|
||||
{
|
||||
await RunAsync(settings.BackupDirectory, settings.BackupKeepCount);
|
||||
_lastRun = now;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein Fehlschlag darf die Anwendung nicht stören; er wird protokolliert
|
||||
// und morgen erneut versucht.
|
||||
_logger.LogError(ex, "Automatische Sicherung fehlgeschlagen");
|
||||
_lastRun = now;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunAsync(string folder, int keepCount)
|
||||
{
|
||||
var safeName = string.Concat(
|
||||
_instanceName.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
|
||||
|
||||
var file = Path.Combine(
|
||||
Path.GetFullPath(folder),
|
||||
$"backup_{safeName}_{DateTime.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, StringComparison.OrdinalIgnoreCase))
|
||||
.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 void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
|
||||
public sealed class SettingsManager
|
||||
{
|
||||
private const string SettingsFileName = "Settings.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public AppSettings AppSettings { get; private set; } = new();
|
||||
|
||||
public SettingsManager(string? basePath = null)
|
||||
{
|
||||
var dir = basePath ?? AppDomain.CurrentDomain.BaseDirectory;
|
||||
_settingsPath = Path.Combine(dir, SettingsFileName);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
Save(); // Defaults schreiben
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
|
||||
?? new AppSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
|
||||
ClawdDotNet.Core.Storage.AtomicFile.WriteAllText(_settingsPath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Logging ist hier ggf. noch nicht verfügbar – Fallback auf MessageBox
|
||||
MessageBox.Show(
|
||||
$"Settings konnten nicht gespeichert werden:\n{ex.Message}",
|
||||
"Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -335,7 +335,7 @@ public sealed partial class BackupPanel : UserControl
|
||||
{
|
||||
if (SelectedBackupPath is not { } path) return;
|
||||
|
||||
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
|
||||
ClawdDotNet.Core.Storage.SystemShell.RevealFile(path);
|
||||
}
|
||||
|
||||
private void DeleteSelected()
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# Agentenkommunikation — Erfassung, Ansicht, Auswertung
|
||||
|
||||
Ziel: Die **gesamte** Kommunikation zwischen Agenten wird erfasst, ist im WhatsApp-Stil
|
||||
paarweise nachlesbar und lässt sich von einem Agenten automatisiert auswerten — um zu
|
||||
finden, wo die Zusammenarbeit klemmt.
|
||||
|
||||
Abgegrenzt davon: Rocket.Chat (siehe [RocketChat-Nextcloud-Konzept](RocketChat-Nextcloud-Konzept.md))
|
||||
trägt **ausschließlich** das, was ein Mensch wissen soll. Interne Absprachen der Agenten
|
||||
gehen dort nie hin.
|
||||
|
||||
Aufbauend auf [Audit-Konzept](Audit-Konzept.md) (A3) und [Taskboard-Konzept](Taskboard-Konzept.md) (A1).
|
||||
|
||||
---
|
||||
|
||||
## 1 — Beschlüsse (August 2026)
|
||||
|
||||
| # | Beschluss |
|
||||
|---|---|
|
||||
| 1 | **Korrelation**: `ParentRunId` + `RootRunId` in Audit-Log und Receipts. Eine Delegationskette wird damit zu einer Abfrage. |
|
||||
| 2 | **Nachrichtenspeicher**: eigene Tabelle `AgentMessages`, die alle Wege gleich behandelt und beide Richtungen festhält. |
|
||||
| 3 | **Rocket.Chat bleibt außen vor** — kein Spiegeln der Agentenkommunikation dorthin. Ein Gruppenchat mit allem drin wäre unlesbar. |
|
||||
| 4 | **Ansicht**: Paar auswählen (Agent A / Agent B), Verlauf im Chat-Stil scrollen. |
|
||||
| 5 | **Auswertung**: ein Analyse-Tool, das ein dafür vorgesehener Agent bekommt. |
|
||||
|
||||
---
|
||||
|
||||
## 2 — AgentComm behalten oder durch das Taskboard ersetzen?
|
||||
|
||||
Das war die offene Frage. Der Befund zuerst, die Empfehlung danach.
|
||||
|
||||
### 2.1 Was heute passiert
|
||||
|
||||
`AgentComm.send_message` ist ein **synchroner Aufruf**: A ruft, `SendMessageAsync` startet
|
||||
`ChatAsync(B)`, wartet auf den vollständigen Lauf von B und gibt dessen Schlussnachricht
|
||||
als Tool-Ergebnis an A zurück. Drei Eigenschaften folgen daraus:
|
||||
|
||||
- **Es gibt keine Tiefenbegrenzung** (B8). A→B→A→B… läuft, bis ein Timeout greift.
|
||||
- **Es kann echt verklemmen.** Seit B2 serialisiert ein Gate je Agent alle Läufe. A hält
|
||||
sein Gate, während es auf B wartet. Ruft B nun `send_message(A)`, wartet B auf As Gate —
|
||||
das A hält, während es auf B wartet. Das löst nur der Timeout auf. Der Selbstaufruf
|
||||
A→A wurde damals abgefangen, der Zweierzyklus nicht.
|
||||
- **Der Fehler ist teuer**: Jeder Hop ist ein vollständiger, bezahlter Lauf.
|
||||
|
||||
### 2.2 Was ein Task nicht kann
|
||||
|
||||
Trotzdem ist „einfach alles über Tasks" nicht ohne Verlust. Zwei Dinge kann der
|
||||
asynchrone Weg strukturell nicht:
|
||||
|
||||
- **Antwort im selben Lauf.** Bei `send_message` kommt die Antwort als Tool-Ergebnis
|
||||
zurück, und A arbeitet damit sofort weiter. Über einen Task endet As Lauf; die Antwort
|
||||
kommt später als neuer Weckvorgang, und A muss seinen Gedankengang neu aufnehmen. Für
|
||||
eine Rückfrage sind das **zwei Läufe statt einem** — der asynchrone Weg ist hier also
|
||||
nicht nur langsamer, sondern *teurer*.
|
||||
- **Antwortzeit.** Der Scanner tickt im Minutentakt. Eine Rückfrage „hast du die Datei
|
||||
schon abgelegt?" braucht damit im Mittel eine halbe Minute plus den Lauf des anderen.
|
||||
|
||||
### 2.3 Empfehlung: nach Zweck trennen, nicht beides parallel führen
|
||||
|
||||
Der Fehler in der jetzigen Lage ist nicht, dass es zwei Mechanismen gibt — es ist, dass
|
||||
**beide dasselbe können**. Ein Agent kann Arbeit sowohl per Task delegieren als auch per
|
||||
`send_message` „mal eben" abschieben, und der zweite Weg ist der gefährliche.
|
||||
|
||||
Vorschlag:
|
||||
|
||||
> **Delegation gehört ausschließlich ins Taskboard.** `AgentComm` verliert diese Rolle
|
||||
> vollständig — kein „mach du mal", kein Auftrag, kein Arbeitspaket.
|
||||
>
|
||||
> **Die kurze Rückfrage bleibt**, aber als eigenes, eng gefasstes Werkzeug: `ask_agent`.
|
||||
|
||||
`ask_agent` mit harten Grenzen:
|
||||
|
||||
| Grenze | Begründung |
|
||||
|---|---|
|
||||
| **Tiefe 1** — wer gerade eine Rückfrage *beantwortet*, darf selbst keine stellen | Beendet die Rekursion an der Wurzel. Prüfbar, sobald `ParentRunId` steht (Punkt 1) — die Synergie ist der Grund, warum das jetzt fast umsonst zu haben ist |
|
||||
| **Zyklusprüfung** — Ziel darf nicht im aktuellen Aufrufpfad liegen | Schließt den Deadlock aus 2.1 aus, statt auf den Timeout zu hoffen |
|
||||
| **Kurzer Timeout + kleines Schrittbudget** (z. B. 60 s, 5 Schritte) | Eine Rückfrage, die fünf Schritte braucht, war keine Rückfrage, sondern ein Auftrag |
|
||||
| **Eigene Beschreibung im Prompt**: „für kurze Fragen an einen Kollegen, nicht um Arbeit abzugeben" | Der häufigste Missbrauch ist der falsche Griff, nicht die böse Absicht |
|
||||
|
||||
Damit gibt es weiterhin zwei Wege, aber sie überschneiden sich nicht mehr: Der eine ist
|
||||
ein Auftrag (dauerhaft, nachvollziehbar, mit Abnahme), der andere eine Frage
|
||||
(flüchtig, sofort, begrenzt).
|
||||
|
||||
**Die Gegenposition, fairerweise:** Man kann `AgentComm` auch ersatzlos streichen und die
|
||||
Rückfrage über einen Task mit hoher Priorität abbilden. Das wäre die konsequentere
|
||||
Umsetzung des „alles ist ein Task"-Prinzips und spart ein Tool. Der Preis sind die zwei
|
||||
Läufe je Rückfrage und die Minute Wartezeit. **Meine Empfehlung ist die Trennung**, weil
|
||||
Rückfragen im Mehr-Agenten-Betrieb häufig sind und der Aufpreis sich dann summiert — aber
|
||||
das ist eine Abwägung, keine technische Notwendigkeit.
|
||||
|
||||
`AgentSpawn` geht in beiden Varianten im Taskboard auf (`assignee: @new:<agent>` ist genau
|
||||
das) und wird zurückgebaut.
|
||||
|
||||
---
|
||||
|
||||
## 3 — Korrelation: `ParentRunId` und `RootRunId`
|
||||
|
||||
Heute erzeugt jeder Lauf eine frische `runId`; der Lauf des Empfängers weiß nichts vom
|
||||
Lauf des Absenders. Eine Kette A→B→C ist deshalb nur über Zeitstempel zu erraten.
|
||||
|
||||
Zwei Felder auf `AuditEntry` und `RunReceipt`:
|
||||
|
||||
- **`ParentRunId`** — der Lauf, aus dem dieser hervorging. `null` bei einem Lauf, den ein
|
||||
Mensch oder der Scanner auslöst.
|
||||
- **`RootRunId`** — die Wurzel der Kette. Das ist faktisch die **Vorgangs-Id**: Alles, was
|
||||
aus einer Anweisung entstand, trägt denselben Wert.
|
||||
|
||||
Regeln:
|
||||
|
||||
- **Die Engine stempelt.** Wie beim Audit gilt: Herkunft wird nie vom Agenten behauptet.
|
||||
- Ein Lauf ohne Vorgänger ist seine eigene Wurzel (`RootRunId = RunId`).
|
||||
- Weitergereicht wird über die Aufrufstellen, an denen ein Lauf einen anderen auslöst:
|
||||
`ask_agent`, Task-Dispatch, Staging-Folgetask.
|
||||
- **Migration**: Bestandszeilen bekommen `RootRunId = RunId` und `ParentRunId = NULL`.
|
||||
Das ist nicht rückwirkend korrekt, aber ehrlich — alte Ketten bleiben unbekannt, statt
|
||||
falsch zusammengesetzt zu werden.
|
||||
|
||||
Der Nutzen reicht über die Analyse hinaus: Was eine Delegationskette insgesamt gekostet
|
||||
hat, ist danach ein `SUM` über `RunReceipts` gruppiert nach `RootRunId`. Damit fällt ein
|
||||
Teil von C7 nebenbei ab.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Der Nachrichtenspeicher
|
||||
|
||||
### 4.1 Tabelle
|
||||
|
||||
Neue Tabelle in der Instanz-DB, neben `AuditLog` und `RunReceipts`:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS AgentMessages (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
PairKey TEXT NOT NULL, -- sortiertes Paar: "agentA|agentB"
|
||||
FromAgentId TEXT NOT NULL,
|
||||
ToAgentId TEXT NOT NULL,
|
||||
Channel TEXT NOT NULL, -- ask | task | task_comment | task_result
|
||||
Direction TEXT NOT NULL, -- request | response
|
||||
Content TEXT NOT NULL, -- vollständig, nach Scrubbing
|
||||
RunId TEXT NOT NULL,
|
||||
ParentRunId TEXT,
|
||||
RootRunId TEXT NOT NULL,
|
||||
TaskId TEXT,
|
||||
Status TEXT NOT NULL, -- delivered | failed | timeout | denied
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IX_AgentMessages_Pair ON AgentMessages(PairKey, OccurredAt);
|
||||
CREATE INDEX IX_AgentMessages_Root ON AgentMessages(RootRunId, OccurredAt);
|
||||
```
|
||||
|
||||
Dazu ein FTS5-Index auf `Content` — dieselbe Technik wie beim geplanten Historien-Umzug
|
||||
(K6), damit die Suche in der Ansicht und im Analyse-Tool nicht über `LIKE` läuft.
|
||||
|
||||
### 4.2 Die Entscheidungen dahinter
|
||||
|
||||
**`PairKey` als sortiertes Paar.** Die geforderte Ansicht („A und B auswählen, scrollen")
|
||||
wird damit zu `WHERE PairKey = ? ORDER BY OccurredAt` — eine Abfrage auf einem Index,
|
||||
unabhängig davon, wer gerade wen anspricht.
|
||||
|
||||
**Beide Richtungen als eigene Zeilen.** Eine Rückfrage erzeugt zwei Zeilen (`request`
|
||||
A→B, `response` B→A) mit derselben `RootRunId`. Nur so entsteht ein Verlauf, der sich wie
|
||||
ein Chat liest. Das Audit-Log kann das nicht leisten: Es speichert nur `Arguments`, die
|
||||
Antwort landet dort nirgends.
|
||||
|
||||
**Inhalt ungekappt.** Das Audit kappt bei 4.000 Zeichen — richtig, denn es dient der
|
||||
Nachvollziehbarkeit. Für die Auswertung braucht es den vollen Text. Gekappt wird erst
|
||||
dort, wo Text in einen LLM-Kontext zurückfließt (Abschnitt 6).
|
||||
|
||||
**Alle Wege in einer Tabelle.** `ask_agent`, Task-Delegation, `task_comment` und das
|
||||
Ergebnis eines Tasks landen im selben Format. Sonst müsste die Auswertung drei Quellen
|
||||
zusammensuchen — und genau daran scheitert sie heute.
|
||||
|
||||
**Fan-out statt Sammelzeile.** Eine Nachricht an mehrere Empfänger wird zu mehreren
|
||||
Zeilen. Etwas redundant, dafür bleibt jede Zeile paarweise auswertbar.
|
||||
|
||||
### 4.3 Wer schreibt
|
||||
|
||||
Ein `IAgentMessageLog` im Core, aufgerufen an genau den Stellen, an denen eine Nachricht
|
||||
eine Agentengrenze überschreitet:
|
||||
|
||||
| Aufrufstelle | Zeilen |
|
||||
|---|---|
|
||||
| `AgentEngine` — `ask_agent` | `request` beim Absenden, `response` beim Rückgabewert (auch bei Fehler/Timeout, mit passendem `Status`) |
|
||||
| Taskboard — `task_create` mit fremdem Assignee | `request` |
|
||||
| Taskboard — `task_comment` | `request` bzw. `response`, je nach Richtung |
|
||||
| Taskboard — Task abgeschlossen/geblockt | `response` mit Ergebnis oder Blocker-Grund |
|
||||
|
||||
Drei bis vier Stellen, alle im Core. Kein Tool schreibt selbst — sonst könnte ein Agent
|
||||
seine eigene Kommunikationsakte färben.
|
||||
|
||||
**Fehlschläge werden mitgeschrieben.** Eine nicht zugestellte Nachricht ist für die
|
||||
Analyse wertvoller als eine erfolgreiche.
|
||||
|
||||
### 4.4 Was hier nicht hineingehört
|
||||
|
||||
- **Mensch↔Agent-Chat.** Das ist die Chat-Historie, ein anderer Gegenstand mit anderem
|
||||
Umzugsplan (K6). *Ausnahme mit gutem Preis-Leistungs-Verhältnis:* Tasks mit
|
||||
`assignee: @human` durchlaufen dieselben Aufrufstellen — man kann sie als Paar
|
||||
(Agent, `@human`) mitschreiben und bekommt die Ansicht dafür geschenkt. Vorschlag: ja,
|
||||
aber als Nachzügler, nicht als Teil der ersten Fassung.
|
||||
- **Tool-Aufrufe.** Die stehen im Audit-Log und gehören nicht in einen Gesprächsverlauf.
|
||||
- **Rocket.Chat-Nachrichten.** Anderer Gegenstand, andere Vertrauensgrenze.
|
||||
|
||||
### 4.5 Zwei Pflichten
|
||||
|
||||
- **Output-Scrubbing vor dem Schreiben.** Nachrichteninhalte enthalten Tool-Ergebnisse.
|
||||
Ohne Maskierung bekannter Geheimnisse wird dieser Speicher zur zweiten Fundstelle für
|
||||
Zugangsdaten — und über den MySQL-Spiegel (A6) verlässt er sogar die Maschine. Der
|
||||
Roadmap-Punkt „Output-Scrubbing" ist damit **Voraussetzung**, nicht Beiwerk.
|
||||
- **Aufbewahrung.** Die Tabelle wächst unbegrenzt. Ein Instanz-Wert
|
||||
(`agentMessageRetentionDays`, 0 = unbegrenzt) plus ein Aufräum-Task gehören von Anfang
|
||||
an dazu, nicht erst, wenn die DB groß ist.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Die Ansicht
|
||||
|
||||
Neuer Reiter im Hauptfenster: **Agentenkommunikation**.
|
||||
|
||||
**Bedienung**: zwei Auswahlfelder (Agent A, Agent B) — dazu „alle" für einen Agenten, um
|
||||
zu sehen, mit wem er überhaupt spricht. Zeitraum, Kanalfilter, Freitextsuche.
|
||||
|
||||
**Darstellung**: Chat-Stil, A rechts, B links, Zeitstempel, Tagestrenner. Jede Blase
|
||||
trägt eine kleine Kennzeichnung des Kanals (`Rückfrage` / `Auftrag` / `Kommentar` /
|
||||
`Ergebnis`) und, wo vorhanden, die anklickbare Task-Id.
|
||||
|
||||
**Technisch**: über den vorhandenen **WebView2**-Unterbau, wie ihn `frm_chat` schon nutzt
|
||||
(inkl. Virtual-Host-Mapping auf einen lokalen Ordner). Der Verlauf wird als HTML
|
||||
gerendert. Handgezeichnete Sprechblasen in WinForms wären ein Vielfaches an Aufwand für
|
||||
ein schlechteres Ergebnis.
|
||||
|
||||
**Paging**: die jüngsten ~200 Nachrichten, „ältere laden" nach oben. Ein Paar mit 50.000
|
||||
Zeilen darf die Oberfläche nicht am Start blockieren.
|
||||
|
||||
**Der eigentliche Mehrwert** liegt über dem flachen Verlauf: Ein Klick auf eine Nachricht
|
||||
zeigt die ganze Kette zu ihrer `RootRunId` — also den Vorgang von der auslösenden
|
||||
Anweisung bis zum letzten Beitrag, über alle beteiligten Agenten hinweg, mit den Kosten
|
||||
aus den Receipts. Das ist die Ansicht, die die Frage „warum hat das drei Stunden und
|
||||
vier Dollar gekostet" tatsächlich beantwortet.
|
||||
|
||||
---
|
||||
|
||||
## 6 — Automatisierte Auswertung
|
||||
|
||||
Ein Tool `AgentCommAnalysis`, das **bewusst nur einem dafür vorgesehenen Agenten**
|
||||
zugewiesen wird (die Zuweisung je Agent gibt es ohnehin).
|
||||
|
||||
| Aktion | Zweck |
|
||||
|---|---|
|
||||
| `list_pairs` | Wer spricht mit wem, wie oft, seit wann — der Einstieg |
|
||||
| `stats` | Kennzahlen je Paar/Kanal/Zeitraum (siehe unten) |
|
||||
| `read_conversation` | Verlauf eines Paares, gekappt und seitenweise |
|
||||
| `chain` | Ein kompletter Vorgang über `RootRunId`, inkl. Kosten |
|
||||
| `search` | Volltext über FTS5 |
|
||||
|
||||
**Fragen, die das beantworten soll** — sie sind der Grund für den Schnitt des Schemas:
|
||||
|
||||
- Wie viele Delegationen führen zu einem Ergebnis, und wie viele versanden?
|
||||
- Wie tief werden Ketten, und ab welcher Tiefe steigt die Fehlerquote?
|
||||
- Welche Paare stellen sich wiederholt dieselbe Rückfrage? (Ein Hinweis auf unklare
|
||||
Zuständigkeit oder einen fehlenden Skill — genau die Art Problem, die man sucht.)
|
||||
- Was kostet ein Vorgang von der Anweisung bis zum Ergebnis?
|
||||
- Wo häufen sich `failed`/`timeout`?
|
||||
|
||||
**Drei Sicherungen**, weil ein Agent hier fremde Kommunikation liest:
|
||||
|
||||
1. Ergebnisse werden als `<untrusted_content>` gerahmt. Der Inhalt stammt aus anderen
|
||||
Läufen und kann Anweisungen enthalten — auch ohne böse Absicht.
|
||||
2. Standardmäßig liefert das Tool **Kennzahlen**, Rohtext nur auf ausdrückliche Anfrage
|
||||
und mit harter Obergrenze. Ein unbedachtes „lies mir alles vor" ist sonst ein
|
||||
Kontext-Überlauf mit Rechnung.
|
||||
3. Das Tool ist **lesend**. Es gibt keine Schreibaktion.
|
||||
|
||||
---
|
||||
|
||||
## 7 — Verhältnis zu Rocket.Chat
|
||||
|
||||
Klargestellt, weil es die vorherige Überlegung ablöst:
|
||||
|
||||
- Agentenkommunikation wird **nicht** nach Rocket.Chat gespiegelt. Ein Raum, in dem jede
|
||||
interne Absprache mitläuft, ist nach einer Woche unlesbar und verdeckt genau das, was
|
||||
man sehen soll.
|
||||
- Nach Rocket.Chat geht nur, was ein Mensch wissen soll oder muss — über den
|
||||
`ChannelRouter` bzw. eine bewusste Handlung des Agenten.
|
||||
- Wer den internen Verlauf sehen will, nimmt die Ansicht aus Abschnitt 5. Die ist dafür
|
||||
gebaut; ein Gruppenchat ist es nicht.
|
||||
|
||||
---
|
||||
|
||||
## 8 — Schnitt
|
||||
|
||||
| Phase | Inhalt | Abhängigkeit |
|
||||
|---|---|---|
|
||||
| **1** | `ParentRunId` + `RootRunId` in `AuditLog`/`RunReceipts`, Migration, Stempelung in der Engine | — |
|
||||
| **2** | `AgentMessages` + FTS5 + `IAgentMessageLog`, Schreiben an den Aufrufstellen | 1 |
|
||||
| **3** | `ask_agent` (Tiefe 1, Zyklusprüfung), Rückbau von `AgentComm`/`AgentSpawn` | 1 — die Tiefe kommt aus der Kette |
|
||||
| **4** | Ansicht (WebView2-Reiter) | 2 |
|
||||
| **5** | `AgentCommAnalysis`-Tool | 2 |
|
||||
| **—** | Output-Scrubbing | **vor** 2 |
|
||||
|
||||
Phase 1 zuerst, weil Phase 3 die Kette braucht und Phase 2 die Felder mitschreibt. Das
|
||||
Scrubbing muss vor Phase 2 stehen — sonst legen wir einen Speicher an, der erst
|
||||
nachträglich bereinigt werden müsste.
|
||||
|
||||
Zur Einstufung: Phasen 1, 2, 4 und 5 sind gut spezifizierbar. Phase 3 fasst das
|
||||
Agent-Gate an, an dem schon einmal ein Deadlock lauerte (B2/B8) — dafür gehören die Tests
|
||||
aus der [Teststrategie](Teststrategie.md) mit dazu, insbesondere der dort vorgesehene,
|
||||
bis heute fehlende Fall **A13** (`A→B→A` wird begrenzt statt zu verklemmen).
|
||||
|
||||
---
|
||||
|
||||
## 9 — Offene Punkte
|
||||
|
||||
1. **`ask_agent` behalten oder ersatzlos streichen?** Meine Empfehlung steht in 2.3
|
||||
(behalten, eng gefasst) — die Gegenposition ist dort ebenfalls notiert.
|
||||
2. **`@human`-Tasks mitschreiben?** Gibt die Paar-Ansicht auch für Mensch↔Agent, fast
|
||||
ohne Zusatzaufwand. Vorschlag: ja, aber nach Phase 4.
|
||||
3. **Aufbewahrungsdauer** — Vorgabewert? (Vorschlag: unbegrenzt, bis der Spiegel aus A6
|
||||
steht; dann 180 Tage lokal.)
|
||||
4. **Wer bekommt `AgentCommAnalysis`?** Ein eigener Analyse-Agent oder ein bestehender?
|
||||
@@ -0,0 +1,91 @@
|
||||
# Audit-Log & Receipts — Nachvollziehbarkeit
|
||||
|
||||
Setzt A3 aus der [Roadmap](Roadmap.md) um (F-A2). Zwei zusammengehörige Dinge:
|
||||
|
||||
- **Audit-Log** — ein Eintrag je Tool-Aufruf: wer, wann, welches Tool, mit welchem
|
||||
Ausgang.
|
||||
- **Receipts** — ein Abschluss-Beleg je Lauf: Ergebnis, Schritte, Tokens, Kosten,
|
||||
verknüpft mit dem Task.
|
||||
|
||||
A3 ist das Fundament für A2 (jede Staging-Entscheidung wird als Datensatz verankert)
|
||||
und für C7 („Kosten pro Ergebnis", fällt aus den Receipts ab). Die Tool-Fehlerquote aus
|
||||
der Leistungsanalyse liest sich direkt aus dem Log.
|
||||
|
||||
## Warum eigene Tabellen, nicht der State-Store
|
||||
|
||||
Dieselbe Überlegung wie bei Gedächtnis und Taskboard: `IStateStore` ist Schlüssel-Wert.
|
||||
Ein Log, das man nach Lauf, Task oder Tool filtern und dessen Fehlerquote man auswerten
|
||||
will, braucht typisierte Spalten. Zwei Tabellen auf dem vorhandenen
|
||||
[`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs): `AuditLog` und
|
||||
`RunReceipts`.
|
||||
|
||||
## Provenienz — von der Engine gestempelt, nie vom Agenten behauptet
|
||||
|
||||
Die entscheidende Regel (aus dem OpenAlice-Provenance-Konzept):
|
||||
|
||||
- **Herkunft stempelt die Engine.** `AgentId`, `Model` und `Source` kommen aus dem
|
||||
Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Ein Tool kann seine
|
||||
Herkunft nicht fälschen, weil es sie gar nicht schreibt.
|
||||
- **Einträge sind unveränderlich.** Das Repository hat kein Update und kein Delete —
|
||||
eine Korrektur ist ein neuer Eintrag. Das ist die eigentliche Zusage, keine fehlende
|
||||
Funktion.
|
||||
- **Unbekanntes wird als unbekannt markiert, nicht geraten.** Fehlt die Quelle, steht
|
||||
`unknown`, nicht ein plausibel geratener Kanal.
|
||||
- **Worker-Typ und Session sind getrennt.** `Model` (das ausführende Modell) und
|
||||
`Source` (die verantwortliche Session: `webview`, `telegram`, `task`, `agentcomm`,
|
||||
`job`, `direct`) sind verschiedene Begriffe und stehen in eigenen Spalten.
|
||||
|
||||
## Audit-Log
|
||||
|
||||
Gestempelt an genau einer Stelle: `AgentEngine.ExecuteToolCallAsync` — dort, wo jeder
|
||||
Tool-Aufruf durchläuft. Je Aufruf ein Eintrag mit Ausgang:
|
||||
|
||||
| Status | Wann |
|
||||
|---|---|
|
||||
| `Ok` | Tool lief und lieferte ein Ergebnis |
|
||||
| `Error` | Tool meldete einen Fehler oder warf |
|
||||
| `Denied` | das `PermissionGate` hat abgelehnt |
|
||||
| `NotFound` | Tool dem Agenten nicht zugewiesen/unbekannt |
|
||||
|
||||
Ein Abbruch (Cancellation) wird **nicht** protokolliert — der Aufruf kam nicht zum
|
||||
Abschluss. Die Argumente werden roh, aber gekappt abgelegt (4 000 Zeichen); die
|
||||
Ausgangsnotiz kurz (500).
|
||||
|
||||
**Best effort:** Ein Fehler beim Schreiben des Audits darf den Lauf nie scheitern
|
||||
lassen — dieselbe Linie wie bei der Verbrauchserfassung. Der Eintrag wird geschrieben,
|
||||
nachdem die eigentliche Arbeit getan ist.
|
||||
|
||||
## Receipts
|
||||
|
||||
Je Lauf ein Beleg, geschrieben beim Abschluss von `RunAsync`/`ChatAsync` (neben der
|
||||
vorhandenen `RunUsage`-Erfassung). Er trägt Status, Schritte, Prompt-/Completion-/
|
||||
Cached-Tokens, geschätzte Kosten (aus dem `ModelPricingCatalog`, mit
|
||||
`CostIsKnown`-Flag) und einen kurzen Ergebnis-Verweis.
|
||||
|
||||
**Verknüpfung `RunUsage` ↔ Task:** Der Receipt trägt die `TaskId`, wenn der Lauf aus dem
|
||||
Taskboard kam — der `EngineTaskDispatcher` reicht sie (samt `source: task`) durch. Damit
|
||||
ist „Kosten pro Ergebnis" (C7) ein Abfallprodukt: `ListReceiptsForTaskAsync` liefert
|
||||
alle Belege zu einem Task.
|
||||
|
||||
## RunId — die Klammer
|
||||
|
||||
Jeder Lauf bekommt zu Beginn eine `RunId` (GUID). Alle Audit-Einträge **und** der
|
||||
Receipt eines Laufs tragen sie. So lässt sich ein Lauf lückenlos rekonstruieren:
|
||||
`ListForRunAsync(runId)` gibt die Aufrufe in Reihenfolge, `GetReceiptForRunAsync(runId)`
|
||||
den Abschluss.
|
||||
|
||||
## Verdrahtung
|
||||
|
||||
`IAuditRepository` ist optional (wie Gedächtnis und Taskboard): ohne Repo läuft die
|
||||
Engine unverändert. In `Program.cs` wird ein `SqliteAuditRepository` auf der Instanz-DB
|
||||
erzeugt und der Engine übergeben.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Output-Scrubbing** — die `Arguments` können Secrets enthalten. Das zentrale
|
||||
Maskieren bekannter Secret-Werte (eigener beschlossener Roadmap-Punkt) greift, sobald
|
||||
es steht; der Andockpunkt (`ExecuteToolCallAsync`) ist derselbe.
|
||||
- **Review-Oberfläche** — die Anzeige/Durchsicht des Logs und der Receipts gehört zu A2
|
||||
(Staging-Review im Hauptfenster); die Abfragemethoden dafür stehen bereit.
|
||||
- **Export** — ein JSONL-Export des Logs wäre für externe Auswertung nützlich (später,
|
||||
passt zum A6-Spiegel).
|
||||
@@ -0,0 +1,186 @@
|
||||
# Avalonia-Portierung — Leitfaden
|
||||
|
||||
Für alle, die weitere Ansichten von WinForms nach Avalonia übertragen.
|
||||
Stand: 2026-08-07.
|
||||
|
||||
---
|
||||
|
||||
## 1. Auftrag
|
||||
|
||||
Drei Bereiche des Hauptfensters sind noch Platzhalter. In dieser Reihenfolge portieren —
|
||||
sie steigen im Umfang, und jede baut auf dem Muster der vorigen auf:
|
||||
|
||||
| # | Bereich | WinForms-Vorlage | Daten aus |
|
||||
|---|---|---|---|
|
||||
| 1 | **Info** | `frm_main.Designer.cs`, Suchwort `tabPage_Info` | `AppHost.AppVersion`, `AppHost.BuildSummary`, `host.Instance` |
|
||||
| 2 | **Sicherung** | `UI/BackupPanel.cs` + `UI/BackupPanel.Designer.cs` | `host.InstancePath`, `host.Settings`, `Core.Backup.BackupService` |
|
||||
| 3 | **Aufgaben** (Jobs/Services/Verlauf) | `frm_main.cs`, Abschnitt `WORKER TAB` ab Zeile 932 | `host.Instance.Agents`, `App.Services.JobHistoryService` |
|
||||
|
||||
**Nicht anfassen:** Chat und Einstellungen. Beide sind Entwurfsarbeit, nicht Übersetzung,
|
||||
und werden gesondert gemacht.
|
||||
|
||||
Die WinForms-Dateien liegen noch im Repository, sind aber **nicht mehr Teil des Builds**
|
||||
(siehe Kommentar in `ClawdDotNet.slnx`). Sie sind Vorlage zum Lesen — nicht zum Kompilieren,
|
||||
nicht zum Reparieren.
|
||||
|
||||
---
|
||||
|
||||
## 2. Drei Regeln, die nicht verletzt werden dürfen
|
||||
|
||||
### 2.1 Der Schichtschnitt
|
||||
|
||||
```
|
||||
src/ClawdDotNet.App ← Fachlogik. KEIN Verweis auf Avalonia. Niemals.
|
||||
src/ClawdDotNet.Desktop ← Oberfläche. Darf App und Core verwenden.
|
||||
```
|
||||
|
||||
`ClawdDotNet.App` muss ohne Fenster laufen — darauf setzt der geplante systemd-Dienst auf.
|
||||
Sobald dort ein `using Avalonia…` steht, ist der Schnitt kaputt und fällt erst Wochen
|
||||
später auf.
|
||||
|
||||
**Faustregel:** Alles, was Dateien liest, rechnet oder mit der Engine spricht, gehört nach
|
||||
`App`. Alles, was etwas anzeigt, nach `Desktop`.
|
||||
|
||||
### 2.2 Fäden
|
||||
|
||||
Ereignisse aus `AgentEngine`, `TaskScanner`, `OpenRouterStatusService` und
|
||||
`BackupScheduler` kommen auf **Hintergrundfäden**. Eine `ObservableCollection` von dort aus
|
||||
zu ändern wirft entweder oder beschädigt still die Anzeige.
|
||||
|
||||
```csharp
|
||||
// Aus einem Ereignis der Fachschicht heraus:
|
||||
Dispatcher.UIThread.Post(() => Lines.Add(neu));
|
||||
|
||||
// Wenn ein Rückgabewert gebraucht wird:
|
||||
await Dispatcher.UIThread.InvokeAsync(() => …);
|
||||
```
|
||||
|
||||
Ein `DispatcherTimer` läuft dagegen bereits auf dem Oberflächenfaden — dort ist kein
|
||||
Wechsel nötig (siehe `LogPageViewModel`).
|
||||
|
||||
### 2.3 Avalonia **12**, nicht 11
|
||||
|
||||
Praktisch alle Anleitungen im Netz sind für Avalonia 11 und lassen sich hier nicht
|
||||
übernehmen. Bekannte Unterschiede:
|
||||
|
||||
- `BindingPlugins` ist nicht mehr öffentlich. Das übliche
|
||||
`DisableAvaloniaDataAnnotationValidation()` aus den 11er-Vorlagen **entfällt ersatzlos** —
|
||||
nicht nachbauen.
|
||||
- `ShutdownMode` voll qualifizieren: `Avalonia.Controls.ShutdownMode`.
|
||||
|
||||
Diese Fehler brechen den Build. Das ist gut — sie fallen sofort auf.
|
||||
|
||||
---
|
||||
|
||||
## 3. Das Muster
|
||||
|
||||
Der Logs-Bereich ist als vollständiges Beispiel gebaut. Drei Dateien, drei Aufgaben:
|
||||
|
||||
**`src/ClawdDotNet.App/Services/LogTail.cs`** — die Fachlogik. Liest Dateien, kennt keine
|
||||
Oberfläche, wäre ohne Fenster lauffähig.
|
||||
|
||||
**`src/ClawdDotNet.Desktop/ViewModels/LogPageViewModel.cs`** — das Ansichtsmodell. Erbt von
|
||||
`PageViewModel`, hält Zustand und Befehle. Kennt keine Steuerelemente.
|
||||
|
||||
**`src/ClawdDotNet.Desktop/Views/LogPageView.axaml`** — die Ansicht. Nur Aufbau und
|
||||
Bindungen.
|
||||
|
||||
### Ein neuer Bereich in vier Schritten
|
||||
|
||||
**1.** Ansichtsmodell anlegen, von `PageViewModel` erbend:
|
||||
|
||||
```csharp
|
||||
public sealed partial class InfoPageViewModel : PageViewModel
|
||||
{
|
||||
public InfoPageViewModel(AppHost? host) : base("Info") { … }
|
||||
}
|
||||
```
|
||||
|
||||
`AppHost?` ist **nullbar** — der Entwurfsmodus des Editors erzeugt das Ansichtsmodell ohne
|
||||
laufenden Aufbau. Bei `null` einfach nichts starten und Beispielwerte zeigen.
|
||||
|
||||
**2.** Ansicht anlegen: `Views/InfoPageView.axaml` + `.axaml.cs`. Der Name muss der
|
||||
Konvention folgen — `ViewLocator` sucht `…ViewModels.FooViewModel` → `…Views.FooView`.
|
||||
Passt der Name nicht, steht der gesuchte Typ im Fenster statt der Ansicht.
|
||||
|
||||
**3.** In `MainWindowViewModel` den Platzhalter ersetzen:
|
||||
|
||||
```csharp
|
||||
new PlaceholderPageViewModel("Info", "…") // vorher
|
||||
new InfoPageViewModel(host) // nachher
|
||||
```
|
||||
|
||||
**4.** `x:DataType` in der AXAML setzen. Ohne das greifen die kompilierten Bindungen nicht
|
||||
und Tippfehler in Bindungspfaden fallen erst zur Laufzeit auf.
|
||||
|
||||
### Werkzeugkasten
|
||||
|
||||
- Zustand: `[ObservableProperty] private string _text = "";` → erzeugt `Text` samt
|
||||
Benachrichtigung.
|
||||
- Befehle: `[RelayCommand] private void Speichern() { … }` → bindbar als
|
||||
`SpeichernCommand`.
|
||||
- Formatierung gehört in `Styles/Shell.axaml`, nicht an einzelne Steuerelemente.
|
||||
Vorhandene Klassen: `heading`, `caption`, `toolbar`, `card`, `statusbar`.
|
||||
- Ordner öffnen: `Core.Storage.SystemShell.OpenFolder(pfad)`. **Kein** `explorer.exe`.
|
||||
- Dateinamen erzeugen: `Core.Storage.PortableFileName.Sanitize(name)`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Prüfliste für die leisen Fehler
|
||||
|
||||
Diese Klasse bricht weder den Build noch die Tests. Vor jeder Abgabe durchgehen:
|
||||
|
||||
- [ ] **Fenster-Schließen behandelt?** Wartet der Code auf eine Antwort aus einem Fenster
|
||||
(`TaskCompletionSource`), muss `window.Closed` als Abbruch gelten. Sonst hängt der
|
||||
Ablauf lautlos für immer.
|
||||
- [ ] **Sammlungen nur vom Oberflächenfaden geändert?** Siehe 2.2.
|
||||
- [ ] **Wächst etwas unbegrenzt?** Listen, die im Betrieb volllaufen, brauchen eine
|
||||
Obergrenze (`LogPageViewModel.MaxLines = 2000` als Vorbild).
|
||||
- [ ] **Timer beendet?** `DispatcherTimer` in einem Ansichtsmodell läuft weiter, auch wenn
|
||||
der Bereich nicht sichtbar ist. Bei teuren Abfragen anhalten.
|
||||
- [ ] **Farben aus dem Thema?** Keine festen Farbwerte — die Anwendung läuft hell und
|
||||
dunkel. `{DynamicResource …}` verwenden.
|
||||
- [ ] **Keine relativen Pfade.** `./Backups` und Ähnliches hängt vom Arbeitsverzeichnis ab
|
||||
und zeigt unter Linux ins Leere. `AppPaths.DataDirectory` verwenden.
|
||||
- [ ] **Kein `MessageBox`, kein `System.Windows.Forms`, kein `System.Drawing`.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Abnahme
|
||||
|
||||
```bash
|
||||
dotnet build ClawdDotNet.slnx
|
||||
```
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClawdDotNet.Core.Tests/ClawdDotNet.Core.Tests.csproj
|
||||
```
|
||||
|
||||
Beide müssen fehlerfrei sein — 567 Tests, keine neuen Fehlschläge.
|
||||
|
||||
**Und dann tatsächlich starten.** Die Oberfläche hat keine Testabdeckung; die Fehler aus
|
||||
Abschnitt 4 fallen ausschließlich beim Laufen auf.
|
||||
|
||||
```bash
|
||||
dotnet run --project src/ClawdDotNet.Desktop
|
||||
```
|
||||
|
||||
Hinweis: Ein Starttest hinterlässt unter Windows einen Prozess, der die `.exe` sperrt und
|
||||
den nächsten Build mit `MSB3021` scheitern lässt. Aufräumen mit:
|
||||
|
||||
```bash
|
||||
powershell -Command "Get-Process ClawdDotNet -EA SilentlyContinue | Stop-Process -Force"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Wenn etwas unklar ist
|
||||
|
||||
Lieber nachfragen als raten. Zwei Dinge sind besonders leicht falsch zu machen:
|
||||
|
||||
- **Was gehört in welche Schicht?** Im Zweifel nach `App` — von dort kann die Oberfläche
|
||||
es holen, umgekehrt nicht.
|
||||
- **Wie kommen Daten aus der Engine in die Ansicht?** `AppHost` gibt `Engine`, `Scanner`,
|
||||
`Staging`, `Status` und `Usage` heraus; alle sind **nullbar**, wenn kein
|
||||
OpenRouter-Schlüssel hinterlegt ist. Diesen Fall mitdenken — die Anwendung läuft dann
|
||||
bewusst ohne Agenten.
|
||||
@@ -523,6 +523,11 @@ Siehe K3.
|
||||
|
||||
## 6. Vorgeschlagene Reihenfolge
|
||||
|
||||
> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte
|
||||
> werden dort weitergeführt; dieser Abschnitt bleibt als Stand der Bestandsaufnahme
|
||||
> eingefroren. F-A1/S4, F-A2, F-A5, T6, T7 sowie B6–B8 sind in den Roadmap-Vorhaben
|
||||
> A1–A4 aufgegangen.
|
||||
|
||||
**Sofort — es blockiert oder gefährdet den Betrieb**
|
||||
1. ~~B1 Compaction-Paarung (bricht produktiv ab)~~ ✅ behoben
|
||||
2. ~~B3 `maxTokens`-Semantik (bricht produktiv ab)~~ ✅ behoben
|
||||
@@ -535,9 +540,9 @@ Siehe K3.
|
||||
6. ~~T1 Prompt-Caching~~ ✅ umgesetzt (inkl. T9 `cached_tokens`)
|
||||
7. ~~T2 Tool-Ergebnisse kappen (= B5)~~ ✅ umgesetzt
|
||||
8. ~~T3 Günstiges Compaction-Modell~~ ✅ umgesetzt
|
||||
9. ~~B4 Kostenerfassung korrigieren~~ ✅ teilweise: Prompt/Completion werden jetzt
|
||||
getrennt erfasst statt 50/50 geschätzt. Offen bleibt die veraltete, hartcodierte
|
||||
Preistabelle (`ModelPricing`) — Preise sollten vom `/models`-Endpoint kommen.
|
||||
9. ~~B4 Kostenerfassung korrigieren~~ ✅ vollständig: Prompt/Completion getrennt
|
||||
erfasst, Preise kommen live vom `/models`-Endpunkt (`ModelPricingCatalog`),
|
||||
Modelle ohne Preisdaten werden sichtbar gemeldet statt still mit 0 gerechnet.
|
||||
10. ~~B12 Retry/Backoff~~ ✅ umgesetzt
|
||||
11. T4 Proaktiv statt reaktiv kompaktieren
|
||||
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
# Deploymentcenter-Anbindung — Durchsicht
|
||||
|
||||
> **Nachtrag 2026-08-08 — die Anbindung ist umgestellt, Server und SDK stehen auf 2.1.**
|
||||
> Abschnitt 4 und 5 sind abgearbeitet; wie es jetzt aussieht, steht in
|
||||
> [Deploymentcenter-Integration](Deploymentcenter-Integration.md).
|
||||
>
|
||||
> Mit **SDK 2.1 erledigt** (waren Befunde aus Abschnitt 3 bzw. aus der Durchsicht der
|
||||
> 2.0-Anbindung):
|
||||
>
|
||||
> - `HttpClient` ohne Zeitgrenze → intern 15 s. Unsere Umgehung (eigener Client mit
|
||||
> 8 s) ist zurückgebaut.
|
||||
> - HTTP 429/5xx entzogen die Lizenz, ohne den Zwischenspeicher zu befragen → jeder
|
||||
> Nicht-Erfolg führt jetzt in denselben Offline-Zweig, `IsTransient` macht den
|
||||
> Unterschied sichtbar. Unsere Behelfsprüfung auf `unknown_error` ist entfernt.
|
||||
> - `cache_ttl_hours` wurde ignoriert, die Gnadenfrist war faktisch unbegrenzt.
|
||||
> - `app_version` fest `"1.0.0"` → kommt jetzt aus `ReleaseInfo.Version`.
|
||||
> - `BuildInfo.targets` war nicht einbindbar (CS0433/CS0103) → erzeugt die Klasse im
|
||||
> eigenen Namensraum, ist eingebunden.
|
||||
> - `UpdateClient`: API-Zweig las snake_case in ein camelCase-Modell → eigenes Modell
|
||||
> `ApiReleaseInfo`, `is_critical` von der obersten Ebene.
|
||||
> - `DeactivateAsync` schickte den Shared Key zusätzlich als `X-Watchdog-Key`.
|
||||
> - Kein `CancellationToken` in der Lizenz-API.
|
||||
>
|
||||
> **Weiterhin offen** — betrifft das Deploymentcenter, nicht ClawdDotNet:
|
||||
>
|
||||
> - **2.1 (keine Signaturprüfung)** — unverändert. `LicenseInfo.PublicKeyBase64` ist
|
||||
> gestrichen, damit nichts Totes stehenbleibt und niemand Schutz vermutet, wo keiner
|
||||
> ist. Kommt die Signatur, kommt das Feld mit ihr zurück.
|
||||
> - **2.2 (v1-Ersatzhash)** und **2.3 (Klartext-Rückfall)** — unverändert, beides im
|
||||
> SDK zu beheben.
|
||||
> - **3 (HW-ID bei jedem Aufruf neu)** — clientseitig umgangen: einmal berechnet und
|
||||
> behalten.
|
||||
> - **`parent_source` ist nur eine `source`, kein Paar** — damit schließen sich „ein
|
||||
> Monitor je Instanz" und instanzweise Alarmunterdrückung gegenseitig aus.
|
||||
|
||||
Stand: 2026-08-06. Geprüft: `J:\Softwareprojekte\Deploymentcenter` (Client, Server,
|
||||
Schema, beide Integrationsleitfäden) gegen den
|
||||
[HW-ID-v2-Vorschlag](Lizenz-HardwareId-v2-Implementierungsvorschlag.md) und die
|
||||
[Linux-Analyse](Linux-Portierung-Analyse.md).
|
||||
|
||||
**Ergebnis vorweg: Die Lizenz blockiert den Linux-Umzug nicht mehr.** Alles, was
|
||||
an Hardware-ID v2 plattformrelevant war, ist da und richtig. Was hier steht, sind
|
||||
Punkte aus derselben Durchsicht — drei davon würden beim Ausrollen wehtun.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was erledigt ist
|
||||
|
||||
| Punkt aus dem Vorschlag | Umsetzung |
|
||||
|---|---|
|
||||
| Format `2:<plattform>:<hex>` | [HardwareId.cs:125](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs) |
|
||||
| **Kein `MachineName` im Hash** | `ComputeV2Hash`, `:138` — der wichtigste Punkt, sauber umgesetzt |
|
||||
| Quellenkette Windows/Linux | `:42–107`, inklusive `dmi-uuid` |
|
||||
| `IsPlausibleMachineId` (Länge, `uninitialized`, nur Nullen) | `:161` |
|
||||
| MAC-Filter über locally-administered-Bit | `:224` |
|
||||
| `/sys/class/net/<name>/device`-Prüfung | `:228` |
|
||||
| Erweiterte Stoppwortliste | `:23` — inkl. `br-`, `virbr`, `cni`, `cali` |
|
||||
| `machine.key` mit `0600` | `:290`, `SetUnixPermissions` mit `#if NET8_0_OR_GREATER` |
|
||||
| Vorgabe per Umgebungsvariable | `LicenseConfig.HardwareIdOverride`, beide Namen |
|
||||
| XDG-Auflösungskette, nie leerer Pfad | [LicenseConfig.cs:28](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseConfig.cs), mit `ValidateNonEmpty` |
|
||||
| Mehrfachziel `netstandard2.0;net8.0` | csproj, BouncyCastle nur im netstandard-Zweig |
|
||||
| `LLS2`-Hülle, AES-GCM, HKDF | [StateStore.cs:169](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) — Schlüssel aus HW-ID abgeleitet, bindet den Cache also echt an die Maschine |
|
||||
| `ILicensePrompt` + Konsolenfassung | vorhanden — genau das, was der kopflose Host braucht |
|
||||
| Servermigration v1→v2 | [LicenseService.php:108](../../Deploymentcenter/src/Modules/License/LicenseService.php), mit Prüfprotokolleintrag `hwid_migrated` |
|
||||
| Schema `hwid_version`/`hwid_source`/`platform` | `sql/migrations/v2_hardware_id.sql`, rückwärtskompatibel |
|
||||
| Verwaltungsansicht zeigt Quelle/Plattform | `public/index.php:1227` |
|
||||
|
||||
`OperatingSystemHelpers` nutzt jetzt `RuntimeInformation`. Der Client hat auf dem
|
||||
Linux-Pfad keine Windows-Laufzeitabhängigkeit — `ProtectedData` wird nur unter
|
||||
`IsWindows()` aufgerufen.
|
||||
|
||||
**Für die Portierung heißt das:** Punkt 4 aus der Entscheidungsliste der
|
||||
Linux-Analyse („Erlaubt LicenseLabrador den Wechsel der Hardware-ID?") ist
|
||||
beantwortet. Der Aufwandsblock „Lizenz" schrumpft von 3–5 PT auf **2–3 PT** —
|
||||
das ist jetzt reine Anschlussarbeit in ClawdDotNet, keine Konzeptarbeit mehr.
|
||||
|
||||
---
|
||||
|
||||
## 2. Drei Befunde, die vor dem Ausrollen geklärt sein sollten
|
||||
|
||||
### 2.1 Es wird nichts signiert — die Lizenzprüfung ist eine Vertrauensfrage an DNS
|
||||
|
||||
[LicenseClient.cs:62](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs):
|
||||
|
||||
```csharp
|
||||
string status = root.TryGetProperty("status", out var sProp) ? sProp.GetString() ?? "unknown" : "unknown";
|
||||
if (status.Equals("valid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// → gültig
|
||||
}
|
||||
```
|
||||
|
||||
Das ist die vollständige Prüfung. Es gibt im neuen Client **kein `Signature.cs`,
|
||||
keinen hinterlegten öffentlichen Schlüssel, keine Hüllenprüfung** — die Dateien
|
||||
`Signature.cs`, `LicenseResult.cs` und `LicenseState.cs` aus dem alten
|
||||
LicenseLabrador-SDK sind beim Umzug nicht mitgekommen.
|
||||
|
||||
Folge: Wer die HTTP-Anfrage umlenken kann, hat eine gültige Lizenz. Ein Eintrag
|
||||
in `/etc/hosts`, ein Proxy, ein eigener DNS — die Antwort `{"status":"valid"}`
|
||||
genügt. Auf einem Linux-Server, den der Betreiber ohnehin vollständig
|
||||
kontrolliert, ist das kein Kunststück.
|
||||
|
||||
Serverseitig sieht es passend dazu aus. `public/index.php:45`:
|
||||
|
||||
```php
|
||||
'signature' => 'ED25519_SIG_' . base64_encode(hash('sha256', $lic['license_key'] . 'DC_OFFLINE_SECRET', true))
|
||||
```
|
||||
|
||||
Das ist ein SHA-256 über den Lizenzschlüssel plus eine fest verdrahtete
|
||||
Zeichenkette — keine Signatur, sondern ein Wert, der jeder erzeugen kann, der den
|
||||
Quelltext kennt. Und `public/index.php:1993` im JavaScript:
|
||||
|
||||
```javascript
|
||||
"ED25519_SIG_" + btoa(key + hwId).substring(0, 32)
|
||||
```
|
||||
|
||||
Base64 der Eingabe, abgeschnitten. Auch kein Hash.
|
||||
|
||||
Das ist erkennbar ein Platzhalter — nur trägt er einen Namen, der nach fertigem
|
||||
Verfahren klingt, und darauf verlässt sich [LicenseGate](Services/LicenseGate.cs)
|
||||
mit seiner harten Startsperre. **Es ist keine Portierungsfrage** (unter Windows
|
||||
gilt heute dasselbe) und auch kein Grund, den Linux-Umzug aufzuhalten — aber es
|
||||
sollte eine bewusste Entscheidung sein und nicht in dem Glauben untergehen, die
|
||||
Signaturprüfung sei bereits da.
|
||||
|
||||
Wenn das Verfahren zurückkommen soll: Ed25519 über die kanonisch serialisierte
|
||||
Antwort, öffentlicher Schlüssel im Client einkompiliert, `nonce` aus der Anfrage
|
||||
in der signierten Nutzlast gegenprüfen (gegen Wiedereinspielung). Der alte
|
||||
`Signer.php` und `Signature.cs` sind im LicenseLabrador-Repo noch vorhanden und
|
||||
lassen sich als Vorlage nehmen.
|
||||
|
||||
### 2.2 Der v1-Ersatzhash trifft die alten Aktivierungen nicht
|
||||
|
||||
Der Migrationsweg ist auf beiden Seiten korrekt gebaut — er wird nur nie
|
||||
auslösen, weil der Client eine andere v1-ID berechnet als die, die in der
|
||||
Datenbank steht.
|
||||
|
||||
Alt ([LicenseLabrador/HardwareId.cs:20](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)):
|
||||
|
||||
```csharp
|
||||
rawBuilder.Append(machineId); // MachineGuid, sonst MAC
|
||||
rawBuilder.Append(Environment.MachineName); // direkt angehängt, kein Trenner
|
||||
→ sha256(machineGuid + machineName)
|
||||
```
|
||||
|
||||
Neu ([HardwareId.cs:149](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/HardwareId.cs)):
|
||||
|
||||
```csharp
|
||||
string raw = $"{Environment.MachineName}:{firstMac}";
|
||||
→ sha256(machineName + ":" + mac)
|
||||
```
|
||||
|
||||
Andere Reihenfolge, anderer Trenner, und **MAC statt MachineGuid**. Auf jedem
|
||||
Windows-Rechner, auf dem `MachineGuid` lesbar war — also praktisch allen —
|
||||
stimmen die Hashes nicht überein. Der Server sucht die Altaktivierung, findet
|
||||
nichts und legt eine neue an: **genau der Platzverbrauch, den die Migration
|
||||
verhindern sollte.** Bei `max_activations = 2` ist danach ein Platz für den
|
||||
Linux-Server weniger da.
|
||||
|
||||
Auch `GetFirstPhysicalMacLegacy` (`:254`) weicht ab: keine Stoppwortfilterung,
|
||||
keine Sortierung, erste Schnittstelle in Aufzählungsreihenfolge. Die alte
|
||||
Fassung nahm die alphabetisch erste *gefilterte* MAC.
|
||||
|
||||
Zu tun: `GetLegacyHardwareId()` muss den v1-Algorithmus zeichengenau
|
||||
nachbilden — inklusive der alten Stichwortliste (`virtual`, `veth`, `docker`,
|
||||
`hyper-v`, `wsl`, `mullvad`, `wireguard`, `tap`, `tun`, `vpn`, `bluetooth`,
|
||||
`vmware`, `box`, `pseudo`, `loopback`, `npcap`, `pcap`), `OrderBy(…, Ordinal)`
|
||||
und `FirstOrDefault()`. Der Code steht im LicenseLabrador-Repo noch da und kann
|
||||
weitgehend übernommen werden.
|
||||
|
||||
Am besten mit einem Test absichern, der einen bekannten Eingabewert gegen den
|
||||
erwarteten v1-Hash prüft — sonst fällt eine Abweichung erst auf, wenn die
|
||||
Aktivierungsplätze schon verbraucht sind.
|
||||
|
||||
### 2.3 Der Klartext-Rückfall ist noch da, nur woanders
|
||||
|
||||
[StateStore.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/StateStore.cs) —
|
||||
„Legacy Migration Check":
|
||||
|
||||
```csharp
|
||||
string legacyJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var legacyData = JsonSerializer.Deserialize<LocalCacheData>(legacyJson);
|
||||
if (legacyData != null)
|
||||
{
|
||||
legacyData.SchemaVersion = 2;
|
||||
Save(productSlug, hardwareId, legacyData);
|
||||
return legacyData;
|
||||
}
|
||||
```
|
||||
|
||||
Der LLS2-Zweig darüber ist genau richtig — Entschlüsselung fehlgeschlagen heißt
|
||||
Cache-Fehltreffer, kein Klartext. Der Zweig darunter hebt das wieder auf: Jede
|
||||
Datei ohne `LLS2`-Kennung wird als JSON gelesen und, wenn sie sich deserialisieren
|
||||
lässt, **übernommen und anschließend verschlüsselt neu geschrieben**.
|
||||
|
||||
Durchgespielt: Eine von Hand angelegte `state.dat` mit
|
||||
|
||||
```json
|
||||
{"SchemaVersion":2,"Status":"valid","ExpiresAt":99999999999,"MaxSeenTime":0}
|
||||
```
|
||||
|
||||
wird angenommen. In `ValidateAsync` greift bei fehlender Verbindung der
|
||||
Cache-Zweig (`:110`): `Status == "valid"` ✓, `now < MaxSeenTime` ✗, `now >
|
||||
ExpiresAt` ✗ → **`IsValid = true`**. Die Bindung an die Hardware, die
|
||||
`DeriveKey(hardwareId, …)` sonst herstellt, ist auf diesem Weg umgangen; die
|
||||
Datei ist zwischen Maschinen übertragbar.
|
||||
|
||||
Der Zweig hilft dabei nicht einmal beim eigentlichen Zweck. Die alte
|
||||
`LocalCacheData` hieß `last_envelope`, `max_seen_time`, `endpoints`,
|
||||
`last_license_key`; die neue `SchemaVersion`, `Status`, `ExpiresAt`, … Kein
|
||||
gemeinsames Feld, und `JsonSerializer` ist ohne
|
||||
`PropertyNameCaseInsensitive`/`JsonPropertyName` bei den Namen streng. Eine echte
|
||||
v1-Datei ergibt also ein Objekt mit lauter Vorgabewerten (`Status = "invalid"`)
|
||||
und ist als Cache wertlos.
|
||||
|
||||
**Empfehlung: den Zweig ersatzlos streichen.** Er kostet Sicherheit und leistet
|
||||
nichts. Alte Cachedateien sollen verworfen werden — eine einmalige
|
||||
Online-Prüfung ist der ganze Preis.
|
||||
|
||||
Nebenbei: `Checksum = hwInfo.HardwareId` ([LicenseClient.cs:79](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs))
|
||||
ist keine Prüfsumme, sondern eine Kopie der HW-ID. Das Feld ist damit ohne
|
||||
Funktion — entweder mit einem HMAC über die übrigen Felder füllen oder entfernen,
|
||||
damit niemand später Schutz vermutet, wo keiner ist.
|
||||
|
||||
---
|
||||
|
||||
## 3. Kleinere Punkte
|
||||
|
||||
| Fundstelle | Sache |
|
||||
|---|---|
|
||||
| [LicenseClient.cs:26](../../Deploymentcenter/client-dotnet/Deploymentcenter.Client/LicenseClient.cs) | Eigener `HttpClient` je Instanz, nie freigegeben, **ohne Zeitgrenze** (Vorgabe 100 s). Der alte `LicenseConfig.HttpTimeout` war 6 s. In `LicenseGate.RunStartupCheck` bedeutet das bis zu 100 s Standbild beim Start, wenn der Server nicht antwortet. |
|
||||
| `:107` | `catch (Exception ex)` um den gesamten Block: Auch ein Fehler beim Auswerten einer *erfolgreichen* Antwort landet im Offline-Zweig. Ein defekter Server gilt dann als „offline". |
|
||||
| `:47` | `app_version = "1.0.0"` fest verdrahtet. ClawdDotNet hat `BuildInfo.Build` — sollte Parameter sein, sonst steht in der Verwaltungsansicht bei jeder Instanz dasselbe. |
|
||||
| `:32`, `:172` | `HardwareId.GetHardwareId()` bei jedem Aufruf neu: liest unter Linux Dateien und zählt Netzwerkschnittstellen auf. Einmal berechnen und halten. |
|
||||
| `HardwareId.cs:205` | MAC-Auswahl überspringt Schnittstellen, die nicht `Up` oder `Unknown` sind. Ein Kabel, das beim Start nicht steckt, ändert damit die Hardware-ID. Für die Ausweichlösung sollte der Betriebszustand keine Rolle spielen — sonst ist sie genau in dem Moment instabil, in dem sie gebraucht wird. |
|
||||
| `HardwareId.cs:231` | `/sys/class/net/<name>/device` ist ein Symlink. `Directory.Exists`/`File.Exists` folgen ihm — funktioniert, ist aber Zufall und sollte kommentiert sein. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Watchdog: die Anbindung passt noch nicht
|
||||
|
||||
Kein Linux-Thema, fällt aber in dieselbe Umbauarbeit.
|
||||
|
||||
[WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) sendet an:
|
||||
|
||||
| ClawdDotNet | Deploymentcenter |
|
||||
|---|---|
|
||||
| `POST /api/heartbeat` | `POST /api/watchdog/v1/ping` (nimmt auch `/heartbeat`) |
|
||||
| `POST /api/event` | `POST /api/watchdog/v1/event` |
|
||||
| `POST /api/register` | **existiert nicht** |
|
||||
|
||||
Die Pfade sind also alle um `/watchdog/v1` zu ergänzen. Der Kopfzeilenname passt:
|
||||
`public/api/watchdog/v1/index.php:30` akzeptiert `X-Watchdog-Key`,
|
||||
`Authorization` und `X-Agent-Token`.
|
||||
|
||||
Der Selbstregistrierungsweg aus [Program.cs:334](Program.cs:334) — mit dem
|
||||
Master-Token einen eigenen Agent-Token holen und in der Instanzkonfiguration
|
||||
zwischenspeichern — hat serverseitig kein Gegenstück mehr. Zu klären: Tokens
|
||||
künftig von Hand in der Verwaltung anlegen und in die Instanzkonfiguration
|
||||
eintragen, oder `/register` im Deploymentcenter nachziehen. Für den ersten Weg
|
||||
spricht, dass er den Master-Token gar nicht erst auf die Instanzen verteilt.
|
||||
|
||||
Die Feldnamen des Ping-Rumpfs (`source`, `instance`, `type`, `status`, `message`,
|
||||
`interval`, `group`, `os`) sind gegen
|
||||
[InstanceHealthProvider](src/ClawdDotNet.Core/Watchdog/InstanceHealthProvider.cs)
|
||||
abzugleichen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Was in ClawdDotNet zu tun ist
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| [ClawdDotNet.csproj](ClawdDotNet.csproj) | Projektverweis von `..\LicenseLabrador\client-dotnet\…` auf `..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\…` umhängen. Langfristig als Submodul unter `external/` — der Kommentar dazu steht schon im csproj. |
|
||||
| [Services/LicenseGate.cs](Services/LicenseGate.cs) | Neu gegen `LicenseValidationResult` schreiben. `LicenseState` gibt es nicht mehr, `Status` ist jetzt eine Zeichenkette — `DescribeProblem` (`:125`) muss auf `revoked`/`expired`/`activation_limit`/`not_found` umgestellt werden. `MessageBox` durch `ILicensePrompt` ersetzen; die Konsolenfassung bringt der Client mit. |
|
||||
| [Services/LicenseInfo.cs](Services/LicenseInfo.cs) | `PublicKeyBase64` hat ohne Signaturprüfung keine Funktion mehr — entweder mit 2.1 zurückholen oder streichen, damit nichts Totes stehenbleibt. |
|
||||
| [Program.cs:112](Program.cs:112) | Lizenzprüfung so verlagern, dass sie ohne Fenster auskommt (kopfloser Host). |
|
||||
| Host (neu) | `--license-status`, `--license-set-key`, `--license-deactivate` — der Client bringt alles Nötige mit. |
|
||||
| [WatchdogClient.cs](src/ClawdDotNet.Core/Watchdog/WatchdogClient.cs) | Pfade auf `/api/watchdog/v1/…`; Registrierungsweg klären (Abschnitt 4). |
|
||||
| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Abgelöst durch [Deploymentcenter-Integration](Deploymentcenter-Integration.md). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Antwort auf die Ausgangsfrage
|
||||
|
||||
**Ja — Avalonia und Linux sind damit machbar.** Die einzige Frage, die ich als
|
||||
möglicher Blocker außerhalb unserer Hand markiert hatte, ist geklärt: Der Client
|
||||
läuft auf beiden Plattformen, zielt auf `net8.0` (von net10.0 problemlos
|
||||
verwendbar), löst seinen Ablageort auch ohne `HOME` auf, und die HW-ID ist
|
||||
container- und umbenennungsfest.
|
||||
|
||||
Der Lizenzblock in der Aufwandsschätzung fällt von 3–5 PT auf **2–3 PT**. Die
|
||||
Gesamtspanne bleibt bei **50–80 PT**, weil die Lizenz nie der große Posten war —
|
||||
das sind PropertyGrid und Chat-Ansicht.
|
||||
|
||||
Zwei Dinge sollten aber vor dem Ausrollen erledigt sein, unabhängig von Linux:
|
||||
|
||||
- **2.2 (v1-Ersatzhash)** — klein, aber wenn es beim Ausrollen falsch ist, sind
|
||||
Aktivierungsplätze verbraucht und man bekommt sie nur einzeln über die
|
||||
Verwaltung zurück. Das ist der Punkt mit dem schlechtesten Verhältnis von
|
||||
Aufwand zu Schaden.
|
||||
- **2.3 (Klartext-Rückfall)** — eine Zeile weniger Code, dafür wieder das
|
||||
Verhalten, das der `LLS2`-Umbau eigentlich herstellen sollte.
|
||||
|
||||
**2.1 (keine Signaturprüfung)** ist eine eigene Entscheidung mit eigenem Umfang
|
||||
und hält den Umzug nicht auf. Sie sollte nur getroffen und nicht übersehen
|
||||
werden — der Name `ED25519_SIG_` im Serverquelltext legt sonst nahe, dass die
|
||||
Sache erledigt sei.
|
||||
@@ -0,0 +1,338 @@
|
||||
# Deploymentcenter-Integration
|
||||
|
||||
Stand: 2026-08-08, Deploymentcenter **2.1**. Ersetzt den früheren
|
||||
`Integrationsplan-WatchDog-LicenseLabrador.md`.
|
||||
|
||||
ClawdDotNet spricht das [Deploymentcenter](../../Deploymentcenter/docs/README.md) als
|
||||
**eine** Gegenstelle an. Vorher waren es zwei Fremdprojekte mit je eigenem Server,
|
||||
eigenem Schlüssel und eigener Anleitung:
|
||||
|
||||
| Vorher | Jetzt |
|
||||
|---|---|
|
||||
| WatchDog (`watchdog.mhdf.de`, `X-Watchdog-Key`) | Deploymentcenter-Modul Watchdog, `Authorization: Bearer` |
|
||||
| LicenseLabrador (`license.mhdf.de`, Ed25519-Public-Key) | Deploymentcenter-Modul Lizenz |
|
||||
| — | Update-Prüfung |
|
||||
| — | Fehler-Stream (ungefangene Ausnahmen) |
|
||||
| — | Bugtracker |
|
||||
|
||||
Eine Adresse, ein Token. Beides steht in den Anwendungseinstellungen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Was der Betreiber einzutragen hat
|
||||
|
||||
| Ort | Wert |
|
||||
|---|---|
|
||||
| Einstellungen → Deploymentcenter → **Server-URL** | `https://dc.mhdf.de` (Vorgabe) |
|
||||
| Einstellungen → Deploymentcenter → **Token** | Master-Token mit `watchdog:ping` + `bugtracker:report` |
|
||||
| Einstellungen → Lizenz → **Lizenzschlüssel** | Der Schlüssel für das Projekt `clawddotnet` |
|
||||
| Worker-Tab → Dienst **Instanz-Watchdog** | einschalten, greift beim nächsten Start der Instanz |
|
||||
|
||||
Das Token entsteht im WebUI unter **Token-Verwaltung → Master-Token erstellen**. Es
|
||||
wird verschlüsselt (DPAPI) in `Settings.json` abgelegt.
|
||||
|
||||
> **Bis die Avalonia-Einstellungsansicht steht**, gibt es für diese Felder noch keine
|
||||
> Oberfläche — die Seite „Einstellungen" ist ein Platzhalter. Die Werte kommen
|
||||
> vorläufig von Hand in `Settings.json` (Ort steht beim Start im Protokoll:
|
||||
> `%APPDATA%\ClawdDotNet\Settings.json`, unter Linux `$XDG_CONFIG_HOME`) bzw. in die
|
||||
> `instance.json` der Instanz. Das Token wird beim ersten Speichern durch die Anwendung
|
||||
> verschlüsselt; im Klartext eingetragen funktioniert es ebenfalls, weil der
|
||||
> `SecretProtector` beide Richtungen verträgt.
|
||||
|
||||
**Serverseitig ist eine Sache Pflicht**, sonst ist die Überwachung wertlos: der
|
||||
Evaluator-Cron. Ohne ihn ändert sich ein Monitor-Zustand nur beim Eintreffen eines
|
||||
Heartbeats — eine abgestürzte Instanz bliebe dauerhaft grün.
|
||||
|
||||
```bash
|
||||
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Watchdog — ein Monitor je Instanz
|
||||
|
||||
Das war die Vorgabe und ist jetzt sauber abgedeckt: Der Server führt Monitore über das
|
||||
Paar `source` + `instance` (`UNIQUE KEY uq_monitor (source, instance)` in
|
||||
`sql/schema.sql`). Alle Instanzen melden unter `source = "clawddotnet"` und tragen ihre
|
||||
eigene `instance`. Fällt eine von dreien aus, fällt genau deren Monitor — und nur der
|
||||
schlägt Alarm.
|
||||
|
||||
`instance` ist standardmäßig die `InstanceId` (stabil, aber im Dashboard nichtssagend).
|
||||
In den Instanz-Einstellungen lässt sich stattdessen ein Name eintragen
|
||||
([`WatchdogConfig.Instance`](../src/ClawdDotNet.Core/Config/WatchdogConfig.cs)); ein
|
||||
späterer Wechsel legt allerdings einen neuen Monitor an.
|
||||
|
||||
**Eine Registrierung vorab gibt es nicht mehr.** Der Monitor entsteht beim ersten
|
||||
Heartbeat von selbst (`INSERT … ON DUPLICATE KEY UPDATE`). Der frühere Weg über
|
||||
`POST /api/register` hatte im Deploymentcenter nie ein Gegenstück — die alte Anbindung
|
||||
lief in dieser Form also gegen einen Endpunkt, den es nicht gibt.
|
||||
|
||||
### Was der Heartbeat trägt
|
||||
|
||||
```
|
||||
POST /api/watchdog/v1/ping
|
||||
Authorization: Bearer <Instanz-Token>
|
||||
```
|
||||
|
||||
| Feld | Inhalt |
|
||||
|---|---|
|
||||
| `source` / `instance` | `clawddotnet` / InstanceId bzw. eingestellter Name |
|
||||
| `status` | `ok`, `warning`, `error` — beim Beenden `stopped` |
|
||||
| `interval` | 60 s (Vorgabe). Daraus leitet der Evaluator ab: 2× → `warning`, 4× → `down` |
|
||||
| `message` | Instanzname + Kurzbegründung |
|
||||
| `os` | Betriebssystem + .NET-Version |
|
||||
| `version` | Produktversion (2.1). Landet in `watchdog_monitors.app_version` — bei mehreren Instanzen der Unterschied zwischen „läuft" und „läuft noch auf der alten Fassung" |
|
||||
| `checks` | `agents`, `scheduler`, `budget` — siehe unten |
|
||||
| `metrics` | `agentCount`, `runningChats`, `todayCostUsd`, `todayTokens` |
|
||||
|
||||
### `checks` — der eigentliche Gewinn
|
||||
|
||||
Ein Heartbeat beweist nur, dass ein Faden läuft. Deshalb geht der selbst ermittelte
|
||||
Zustand je Teilbereich mit; schlägt eine Prüfung fehl, stuft der Server einen als `ok`
|
||||
gemeldeten Beat auf `warning` herab und nennt in der Antwort die betroffene.
|
||||
|
||||
| Prüfung | Fehlschlag bedeutet |
|
||||
|---|---|
|
||||
| `agents` | Kein OpenRouter-Key — die Instanz läuft, arbeitet aber nichts ab |
|
||||
| `scheduler` | Die Taktschleife des Aufgaben-Scanners ist ausgestiegen |
|
||||
| `budget` | Tagesgrenze für Kosten oder Token erreicht |
|
||||
|
||||
„Scanner noch nicht gestartet" gilt **nicht** als Fehlschlag: Er läuft erst nach der
|
||||
Startabgleichung an, der erste Heartbeat geht sofort raus. Sonst gäbe es bei jedem
|
||||
Start ein `warning_raised` und kurz darauf ein `recovered` — zwei Einträge im
|
||||
Ereignisprotokoll für einen Normalvorgang.
|
||||
|
||||
### Metriken sind nur Zahlen
|
||||
|
||||
Der Server legt numerische Werte mit Zeitstempel ab (14 Tage) und vergleicht den
|
||||
aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische Werte
|
||||
verwirft er dabei stillschweigend — `instanceId`, `instanceName` und `buildVersion`
|
||||
standen früher in den Metriken und waren dort wirkungslos. Beschreibendes steht jetzt
|
||||
in `message` und `os`.
|
||||
|
||||
### Angekündigtes Ende
|
||||
|
||||
Beim Herunterfahren geht ein Heartbeat mit `status: "stopped"` raus, danach das Ereignis
|
||||
`stopped_graceful`. Der Evaluator lässt einen so gemeldeten Monitor in Ruhe. Ohne das
|
||||
erzeugte jedes geplante Beenden wenige Minuten später einen Fehlalarm.
|
||||
|
||||
Nebenbei korrigiert: Die alte Anbindung schickte die Ereignisarten `start` und `stop` —
|
||||
beide stehen nicht auf der Liste des Servers und landeten stillschweigend als `started`.
|
||||
Jetzt sind es `started` und `stopped_graceful`.
|
||||
|
||||
### Token je Instanz
|
||||
|
||||
Beim ersten Start tauscht die Instanz das anwendungsweite Token über
|
||||
`POST /api/tokens/v1/provision` gegen ein eigenes, auf `watchdog:ping` und
|
||||
`bugtracker:report` beschränktes Sub-Token und legt es verschlüsselt in der
|
||||
Instanzkonfiguration ab. Danach liegt auf der Instanz nicht mehr das Master-Token, und
|
||||
ein einzelner Zugang lässt sich widerrufen, ohne die anderen mitzunehmen.
|
||||
|
||||
Das ist derselbe Zweck, den die frühere Selbstregistrierung hatte. Scheitert es (etwa
|
||||
weil das hinterlegte Token selbst ein Sub-Token ist und keine weiteren ausstellen darf),
|
||||
wird mit dem hinterlegten Token gemeldet — Monitoring, das nur bei perfekter Rechtelage
|
||||
läuft, ist genau dann still, wenn man es braucht.
|
||||
|
||||
---
|
||||
|
||||
## 3. Lizenz
|
||||
|
||||
Startprüfung in [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs). 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 (`LLS2`,
|
||||
seit 2.1 Schema 3) und trägt damit über Ausfälle hinweg.
|
||||
|
||||
### Urteil und Fehlversuch sind zwei verschiedene Dinge
|
||||
|
||||
Das ist der Kern der 2.1-Anpassung. `LicenseValidationResult.IsTransient` unterscheidet:
|
||||
|
||||
| | Statuswerte | Folge |
|
||||
|---|---|---|
|
||||
| **Urteil des Servers** | `revoked`, `expired`, `not_found`, `activation_limit`, `suspended`, `clock_rollback` | Anwendung startet nicht bzw. beendet sich |
|
||||
| **Kein Urteil erhalten** | `server_unavailable`, `cache_expired` | Warnung, Betrieb läuft weiter |
|
||||
|
||||
Nur das Urteil sperrt. Ein Serverausfall darf nicht jede Installation gleichzeitig
|
||||
aussperren — und eine Drosselung (`429`) oder ein `500` sind Aussagen über den Server,
|
||||
nicht über die Lizenz. Das gilt an beiden Stellen gleich: Startprüfung und laufende
|
||||
Nachprüfung fragen dasselbe Merkmal ab.
|
||||
|
||||
> **Bewusst in Kauf genommen:** Ein Rechner, der die Gegenstelle nie erreicht, läuft
|
||||
> damit auf Dauer mit Warnung weiter — auch nach Ablauf der Gnadenfrist
|
||||
> (`cache_expired` ist laut SDK-Vertrag vorübergehend). Wer das anders will, prüft in
|
||||
> [`LicenseGate`](../src/ClawdDotNet.App/Services/LicenseGate.cs) zusätzlich auf
|
||||
> `cache_expired` und behandelt es als Urteil. Es sollte eine Entscheidung sein, nicht
|
||||
> ein Nebeneffekt.
|
||||
|
||||
### Offline-Gnadenfrist ist echt begrenzt
|
||||
|
||||
Seit 2.1 wertet der Client `cache_ttl_hours` des Projekts aus (Vorgabe 168 h). Vorher
|
||||
galt faktisch das Ablaufdatum der Lizenz — bei einer Lizenz bis 2040 also unbegrenzt.
|
||||
Der verbleibende Rest steht in `CacheExpiresAt` und wird beim Start angezeigt, wenn die
|
||||
Prüfung aus dem Zwischenspeicher kam.
|
||||
|
||||
`state.dat` steigt auf Schema 3; Schema 2 wird weiter gelesen. Ein Rückschritt auf ein
|
||||
älteres SDK verwirft den Zwischenspeicher — dann ist einmal eine Online-Prüfung nötig.
|
||||
|
||||
### Kein Public-Key mehr
|
||||
|
||||
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
|
||||
`status`. Ein Schlüssel, der nichts prüft, ist schlimmer als keiner: Er lässt Schutz
|
||||
vermuten, wo keiner ist. Details in
|
||||
[Deploymentcenter-Anbindung-Review](Deploymentcenter-Anbindung-Review.md), Abschnitt 2.1.
|
||||
|
||||
### Der Projekt-Slug ist `clawddotnet`
|
||||
|
||||
[`LicenseInfo.ProductSlug`](../src/ClawdDotNet.App/Services/LicenseInfo.cs) gilt für
|
||||
**alle** Module — Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf
|
||||
dieselbe Tabelle `dc_projects` zu.
|
||||
|
||||
Bis zur Umstellung stand hier `clawd`, der Name aus dem LicenseLabrador-Backend. Das
|
||||
ist eine Falle mit langer Zündschnur: Der Server beantwortet ein unbekanntes Projekt mit
|
||||
demselben `not_found` wie einen unbekannten Schlüssel — der Unterschied steht
|
||||
ausschließlich in `message` (`"Project not found"` gegen `"Invalid license key"`). Wer
|
||||
den Text nicht durchreicht, sucht den Fehler beim Lizenzschlüssel, während das Projekt
|
||||
gar nicht existiert. Der Torwächter gibt die Serverantwort deshalb mit aus und schreibt
|
||||
sie ins Protokoll.
|
||||
|
||||
### Deaktivieren läuft über das WebUI
|
||||
|
||||
`POST /api/license/v1/deactivate` verlangt den `shared_key` des Servers. Der gehört nicht
|
||||
in eine ausgelieferte Anwendung, deshalb ist der Weg die Hardware-Liste im WebUI
|
||||
(Schaltfläche „Freigeben").
|
||||
|
||||
### Laufende Nachprüfung
|
||||
|
||||
[`LicenseWatch`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) prüft alle zwölf
|
||||
Stunden nach — dieselbe Unterscheidung wie oben. Ohne das wirkt ein Widerruf erst beim
|
||||
nächsten Start, bei einem wochenlang laufenden Dienst also praktisch nie.
|
||||
|
||||
---
|
||||
|
||||
## 4. Version und Updates
|
||||
|
||||
### Eine Stelle für die Version
|
||||
|
||||
`<Version>` in [Directory.Build.props](../Directory.Build.props) ist die Wahrheit.
|
||||
`Deploymentcenter.BuildInfo.targets` (seit 2.1 einbindbar) erzeugt daraus zur
|
||||
Übersetzungszeit `ClawdDotNet.App.ReleaseInfo` mit `Version`, `GitCommit`,
|
||||
`GitCommitShort`, `BuildDateUtc`, `Channel` und `Summary`.
|
||||
|
||||
Der Wert geht an vier Stellen nach draußen, die vorher alle geraten haben:
|
||||
|
||||
| Stelle | Vorher |
|
||||
|---|---|
|
||||
| Aktivierungsliste (`app_version`) | fest `"1.0.0"` im SDK — jede Installation gleich |
|
||||
| Heartbeat (`version`) | gab es nicht |
|
||||
| Fehlermeldungen (`build`) | — |
|
||||
| Versionsvergleich der Update-Prüfung | `0.0.<BuildInfo.Build>`, behelfsweise |
|
||||
|
||||
Die Klasse heißt bewusst `ReleaseInfo`, nicht `BuildInfo`: Diesen Namen trägt in
|
||||
`ClawdDotNet.Core` schon ein von Hand geführter Zähler mit Änderungstext. Zwei
|
||||
gleichnamige Klassen mit verschiedener Bedeutung wären eine Falle. Umgestellt über
|
||||
`DeploymentcenterBuildInfoClass` in der csproj.
|
||||
|
||||
### Prüfung
|
||||
|
||||
Einmalig beim Start gegen `GET /api/updateservice/v1/check`, über
|
||||
`Deploymentcenter.Client.UpdateClient`. Läuft nebenher und blockiert nichts; liegt eine
|
||||
neuere Version vor, erscheint ein Hinweis mit Changelog. Ob und wann aktualisiert wird,
|
||||
entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu
|
||||
erneuern, ist genau dann im Weg, wenn man sie braucht.
|
||||
|
||||
Seit 2.1 liefern beide Wege vollständige Daten: die statische `latest.json` in camelCase,
|
||||
die API in snake_case, jeweils über ein eigenes Modell (`VersionInfo` bzw.
|
||||
`ApiReleaseInfo`). Vorher kam über den API-Zweig außer der Versionsnummer nichts an — und
|
||||
der ist genau der Rückfall, wenn die `latest.json` fehlt. Die Download-Adresse wird
|
||||
mitgeführt (`UpdateAvailability.DownloadUrl`), damit der `update-agent` später ohne
|
||||
weitere Änderung anschließen kann.
|
||||
|
||||
Der `update-agent` ist noch **nicht** eingebunden, und solange kein Release über
|
||||
`pack-and-deploy` veröffentlicht wird, hat die Prüfung nichts zu finden.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fehler-Stream
|
||||
|
||||
Ungefangene Ausnahmen gehen an `POST /api/errors/v1/report`. Verdrahtet in
|
||||
[`App.axaml.cs`](../src/ClawdDotNet.Desktop/App.axaml.cs) an drei Stellen:
|
||||
`AppDomain.UnhandledException`, `TaskScheduler.UnobservedTaskException` und
|
||||
`Dispatcher.UIThread.UnhandledException`.
|
||||
|
||||
Erst nach dem Aufbau verdrahtet, nicht in `Main`: Vorher gibt es weder Einstellungen
|
||||
noch Token. Die Kehrseite ist bewusst in Kauf genommen — ein Absturz *während* des
|
||||
Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.
|
||||
|
||||
**Eigene Drosselung** in
|
||||
[`ErrorReporter`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs): Derselbe
|
||||
Fehler (Typ + oberste Stelle im Stacktrace) geht höchstens einmal alle fünf Minuten
|
||||
raus. Der Server drosselt auch, aber erst, nachdem die Anfragen über die Leitung waren.
|
||||
Die Fehlermeldung selbst gehört nicht zum Kennzeichen — sie enthält oft wechselnde
|
||||
Werte, und dann wäre jeder Aufruf ein neuer Fehler.
|
||||
|
||||
Bekannte, harmlose Fehler lassen sich serverseitig unter **Bugtracker → Ignore-Regeln**
|
||||
stummschalten. Sie werden weiter gezählt; der Zähler ist der Zweck: Dass ein bekannter
|
||||
Fehler auftritt, ist normal — dass er plötzlich hundertmal so oft auftritt, bedeutet,
|
||||
dass sich etwas geändert hat.
|
||||
|
||||
---
|
||||
|
||||
## 6. Bugtracker
|
||||
|
||||
[`BugtrackerClient`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) für
|
||||
bewusst formulierte Einträge (Fehler, Wunsch, Idee) mit Titel und Beschreibung, gegen
|
||||
`POST /api/bugtracker/v1/report`. Der Absender wird serverseitig aus dem Token
|
||||
abgeleitet und lässt sich nicht frei wählen.
|
||||
|
||||
Der Client ist da und über `AppHost.Deploymentcenter.Bugtracker` erreichbar; **eine
|
||||
Oberfläche dafür fehlt noch** („Fehler melden"-Schaltfläche). Ein Agenten-Tool wäre der
|
||||
nächste sinnvolle Schritt — Agenten könnten dann selbst Wünsche und Fehler eintragen,
|
||||
und der Agenten-Workflow des Deploymentcenters (Claim/Lease über
|
||||
`manage?action=next`) würde sie abarbeiten.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wo was liegt
|
||||
|
||||
| Datei | Inhalt |
|
||||
|---|---|
|
||||
| [`Deploymentcenter/DeploymentcenterApi.cs`](../src/ClawdDotNet.Core/Deploymentcenter/DeploymentcenterApi.cs) | Gemeinsamer Unterbau: Bearer-Header, HTTPS-Pflicht, Umschlag auspacken, `DeploymentcenterException` mit stabilem `Code` |
|
||||
| [`Deploymentcenter/Watchdog/`](../src/ClawdDotNet.Core/Deploymentcenter/Watchdog) | Heartbeat-Client, Zustandsermittlung, Takt-Dienst |
|
||||
| [`Deploymentcenter/ErrorReporter.cs`](../src/ClawdDotNet.Core/Deploymentcenter/ErrorReporter.cs) | Fehler-Stream mit Drosselung |
|
||||
| [`Deploymentcenter/BugtrackerClient.cs`](../src/ClawdDotNet.Core/Deploymentcenter/BugtrackerClient.cs) | Bugtracker-Einträge |
|
||||
| [`Deploymentcenter/TokenProvisioner.cs`](../src/ClawdDotNet.Core/Deploymentcenter/TokenProvisioner.cs) | Sub-Token je Instanz |
|
||||
| [`Services/DeploymentcenterService.cs`](../src/ClawdDotNet.App/Services/DeploymentcenterService.cs) | Verdrahtung: Token beschaffen, Heartbeat starten, Update prüfen |
|
||||
| [`Services/LicenseGate.cs`](../src/ClawdDotNet.App/Services/LicenseGate.cs) | Startprüfung |
|
||||
| [`Services/LicenseWatch.cs`](../src/ClawdDotNet.App/Services/LicenseWatch.cs) | Laufende Nachprüfung |
|
||||
|
||||
Die Lizenz läuft bewusst **nicht** über `DeploymentcenterApi`: Sie hat ein eigenes
|
||||
Antwortformat (kein `status`/`error`-Umschlag — `status` trägt dort den Lizenzzustand),
|
||||
einen eigenen Zwischenspeicher und muss vor allem anderen laufen.
|
||||
|
||||
Watchdog, Fehler-Stream und Bugtracker deckt das SDK `Deploymentcenter.Client` nicht ab;
|
||||
dafür ist der eigene Unterbau da. Hardware-ID v2, Lizenz-Zwischenspeicher und
|
||||
Update-Prüfung kommen aus dem SDK — die nachzubauen wäre Verdopplung.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tests
|
||||
|
||||
[`tests/ClawdDotNet.Core.Tests/Deploymentcenter/`](../tests/ClawdDotNet.Core.Tests/Deploymentcenter):
|
||||
Bearer-Header, Fehlerumschlag → Ausnahme mit Code (auch bei HTTP 200), HTTPS-Pflicht mit
|
||||
Localhost-Ausnahme, Heartbeat-Pfad und -Rumpf, zwei Instanzen → zwei Monitore, Checks
|
||||
und Metriken, Antwort-Auswertung, Drosselung des Fehler-Streams, Sub-Token-Bezug.
|
||||
|
||||
---
|
||||
|
||||
## 9. Offen
|
||||
|
||||
1. **Oberfläche für den Bugtracker** — Client vorhanden, Schaltfläche fehlt.
|
||||
2. **Agenten-Tool für den Bugtracker** — würde den Agenten-Workflow des
|
||||
Deploymentcenters nutzbar machen.
|
||||
3. **Release-Strecke** — `pack-and-deploy` aufrufen und `<Version>` dabei mitgeben,
|
||||
danach `update-agent` einbinden. Die Versionsnummer selbst ist mit 2.1 erledigt.
|
||||
4. **SDK als Git-Submodul** unter `external/` statt Cross-Repo-Pfad.
|
||||
5. **Hierarchie** (`parent_source`): Läuft die Instanz auf einem Host, der selbst als
|
||||
Monitor geführt wird, sollte sie ihn als übergeordnete Entität eingetragen bekommen —
|
||||
sonst erzeugt ein Hostausfall eine Meldung je Instanz. Das ist im WebUI zu pflegen,
|
||||
nicht im Client (siehe aber Anmerkung 2 in den Rückmeldungen).
|
||||
@@ -278,10 +278,14 @@ Rohzahlen.
|
||||
|
||||
# Vorgeschlagene Reihenfolge
|
||||
|
||||
> **Abgelöst durch die [Roadmap](Roadmap.md)** (Juli 2026). Die offenen Punkte
|
||||
> laufen dort als C1–C8 weiter; S4 + K2 sind in den Vorhaben A2/A3
|
||||
> (Staging-Freigabe, Audit-Log) aufgegangen.
|
||||
|
||||
| # | Was | Warum zuerst |
|
||||
|---|---|---|
|
||||
| 1 | Atomares Schreiben | Datenverlust ist bereits eingetreten |
|
||||
| 2 | Backup + Restore mit Test | Schützt alles Folgende |
|
||||
| 1 | ~~Atomares Schreiben~~ ✅ | umgesetzt (`File.Replace`-Muster) |
|
||||
| 2 | ~~Backup + Restore mit Test~~ ✅ | umgesetzt inkl. Oberfläche im Settings-Tab |
|
||||
| 3 | Marktkalender | Spart sofort Kosten, verbessert Datenlage |
|
||||
| 4 | `Indicators` | Qualität hoch, Tokens runter |
|
||||
| 5 | Ergebnisregister (Stufe 2) | Grundlage jeder Bewertung |
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
# Linux-Portierung — Analyse
|
||||
|
||||
Stand: 2026-08-06. Reine Bestandsaufnahme und Aufwandsschätzung, **kein** Umbau.
|
||||
|
||||
Frage: Was ist nötig, damit ClawdDotNet unter Linux läuft, und was kostet das?
|
||||
|
||||
---
|
||||
|
||||
## 0. Kurzfassung
|
||||
|
||||
Die gute Nachricht zuerst: **Der Kern ist bereits portabel.** Alle 16 Bibliotheks-
|
||||
und beide Testprojekte zielen auf `net10.0` (nicht `net10.0-windows`), es gibt im
|
||||
gesamten Repository **kein einziges `DllImport`, keinen Registry-Zugriff und keine
|
||||
`System.Drawing`-Nutzung** in `src/`. Windows steckt an genau drei Stellen im Kern:
|
||||
DPAPI-Verschlüsselung, Groß-/Kleinschreibung bei Pfadvergleichen und die
|
||||
Zeitzonen-IDs.
|
||||
|
||||
Die schlechte Nachricht: Die gesamte Bedienoberfläche — rund **8.900 Zeilen** in
|
||||
`frm_*.cs`, `UI/`, `Models/` und `Services/` — hängt an Windows Forms, an WebView2
|
||||
und, am unangenehmsten, an vier `PropertyGrid`-Instanzen, die praktisch die
|
||||
komplette Einstellungsverwaltung ausmachen. Dafür gibt es in Avalonia keine
|
||||
Eins-zu-eins-Entsprechung.
|
||||
|
||||
**Empfehlung: den Umzug in zwei Schnitte teilen.** Ein kopfloser Host (ohne GUI)
|
||||
auf Linux ist in etwa **12–18 Personentagen** erreichbar und liefert den
|
||||
eigentlichen Nutzen — Agenten laufen auf einem Server, nicht auf einem
|
||||
Windows-Desktop. Die Avalonia-Oberfläche ist ein davon unabhängiges Vorhaben
|
||||
von **32–52 Personentagen**, das man danach in Ruhe angehen kann.
|
||||
|
||||
Gesamt für „alles auf Linux, mit GUI": **50–80 Personentage.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Bestandsaufnahme
|
||||
|
||||
### 1.1 Was bereits portabel ist
|
||||
|
||||
| Bereich | Zeilen | Zielframework | Windows-Abhängigkeit |
|
||||
|---|---:|---|---|
|
||||
| `src/ClawdDotNet.Core` | 8.959 | `net10.0` | nur DPAPI (1 Datei) |
|
||||
| 15 Tool-Projekte | 6.415 | `net10.0` | nur `.exe`-Pfade im SocialMediaManager |
|
||||
| `tests/` (348 Tests, 39 Dateien) | 6.888 | `net10.0` | 3 Testfälle mit `C:\`-Pfaden |
|
||||
|
||||
Alle NuGet-Pakete laufen unter Linux: `Microsoft.Data.Sqlite` (bringt
|
||||
`e_sqlite3` nativ für linux-x64/arm64 mit), `MySqlConnector`, `Npgsql`,
|
||||
`Microsoft.Data.SqlClient`, `MongoDB.Driver`, `MailKit`, `FluentFTP`,
|
||||
`Telegram.Bot`, `WTelegramClient`, `SharpCompress`, `Snappier`,
|
||||
`Microsoft.Extensions.Logging`. Kein Paket muss ersetzt werden — mit zwei
|
||||
Ausnahmen (siehe 2.1 und 2.3).
|
||||
|
||||
Auch die Dinge, bei denen man Ärger erwarten würde, sind sauber gelöst:
|
||||
|
||||
- [AtomicFile.cs:167](src/ClawdDotNet.Core/Storage/AtomicFile.cs:167) — `Commit`
|
||||
prüft `File.Exists` und weicht auf `File.Move` aus. `File.Replace` verlangt
|
||||
unter Unix ebenfalls eine vorhandene Zieldatei; der Fall ist also schon
|
||||
abgedeckt. Die Wiederholschleife ist unter Linux überflüssig, aber harmlos.
|
||||
- [TaskFrontmatter.cs:27](src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs:27) —
|
||||
normalisiert `\r\n` und `\r` vor dem Zerlegen. Task-Dateien von einem
|
||||
Windows-Rechner werden unter Linux korrekt gelesen.
|
||||
- Textdateien werden durchgängig als **UTF-8 ohne BOM** geschrieben
|
||||
(`AtomicFile`, `FileLogWriter`, `AgentEditorTool`). Kein `Encoding.Default`,
|
||||
keine Codepage-Fallen.
|
||||
- Zeitstempel gehen als `DateTime.UtcNow` in die Datenbank und werden mit
|
||||
`DateTimeStyles.RoundtripKind` gelesen.
|
||||
|
||||
### 1.2 Was am Windows-Teil hängt
|
||||
|
||||
| Bereich | Zeilen | davon Designer |
|
||||
|---|---:|---:|
|
||||
| `frm_*.cs` (6 Formulare + Dialoge) | 4.946 | 1.865 |
|
||||
| `UI/` (BackupPanel, WebViewBridge, EmbeddedUiManager) | 1.087 | 428 |
|
||||
| `Models/` (PropertyGrid-ViewModels) | 1.257 | — |
|
||||
| `Services/` (4 Dienste, an WinForms-Timer gekoppelt) | 1.501 | — |
|
||||
| `Properties/` | 123 | — |
|
||||
| **Summe** | **8.914** | **2.293** |
|
||||
|
||||
Dazu drei `.resx`-Dateien à ~272 KB (eingebettete Symbole/Bilder) und eine
|
||||
`frm_main.en.resx` für die englische Lokalisierung über den
|
||||
WinForms-Resx-Mechanismus.
|
||||
|
||||
Steuerelement-Inventar aus den Designer-Dateien: 24 `Label`, 19
|
||||
`ToolStripButton`, 12 `TabPage`, 12 `Button`, 9 `TextBox`, 6 `DataGridView`, 6
|
||||
`ToolStrip`, 5 `ComboBox`, **4 `PropertyGrid`**, 4 `TableLayoutPanel`, 4
|
||||
`FlowLayoutPanel`, 3 `TabControl`, 3 `SplitContainer`, 1 `RichTextBox`, 1
|
||||
`ListView`, 1 `NotifyIcon`, 1 `DateTimePicker`, 1 `NumericUpDown`.
|
||||
|
||||
Tabs in `frm_main`: Chat, Logs, Settings (mit Unter-Tabs App-Settings,
|
||||
Instance-Settings), Agent Settings, Jobs/Services (mit Unter-Tabs Jobs,
|
||||
Services, Job History), Info, Backup.
|
||||
|
||||
---
|
||||
|
||||
## 2. Die harten Brocken
|
||||
|
||||
### 2.1 WebView2 → kein Linux (Chat- und Übersichts-Ansicht)
|
||||
|
||||
`Microsoft.Web.WebView2` ist die einzige Windows-only-Paketabhängigkeit des
|
||||
Hauptprojekts und trägt die zwei sichtbarsten Ansichten:
|
||||
[frm_main.cs:235](frm_main.cs:235) und [frm_chat.cs:44](frm_chat.cs:44) laden
|
||||
`overview.html` bzw. `chat.html` aus `EmbeddedUI/` über
|
||||
`SetVirtualHostNameToFolderMapping` unter `https://ui.clwd.internal/`. Die
|
||||
Kommunikation läuft über [WebViewBridge.cs](UI/WebViewBridge.cs) —
|
||||
`WebMessageReceived` in die eine, `ExecuteScriptAsync` in die andere Richtung.
|
||||
|
||||
Drei Wege, jeder mit einem eigenen Preis:
|
||||
|
||||
| Variante | Was passiert | Aufwand | Risiko |
|
||||
|---|---|---:|---|
|
||||
| **A — Avalonia.WebView** | HTML/JS bleiben. Unter Linux rendert WebKitGTK, unter Windows weiterhin WebView2. Die Bridge wird auf die Abstraktion der Bibliothek umgeschrieben. | 4–6 PT | Bibliothek ist deutlich weniger reif als WebView2; WebKitGTK-Abhängigkeit muss auf dem Zielserver vorhanden sein; Verhalten unterscheidet sich je Plattform. |
|
||||
| **B — nativ neu in Avalonia** | Chat als echte Avalonia-Ansicht mit `ItemsControl` und einem Markdown-Renderer. `EmbeddedUI/` entfällt. | 8–12 PT | Kein Fremdrisiko, aber Neuentwicklung. Am Ende deutlich wartbarer als HTML-in-Container. |
|
||||
| **C — lokaler HTTP-Server + Systembrowser** | Die App liefert `EmbeddedUI/` über `http://localhost:port` aus, der Nutzer öffnet den Browser. | 3–4 PT | Bricht die Ein-Fenster-Anmutung. Passt aber ausgezeichnet zum kopflosen Betrieb — dort **ist** der Browser die Oberfläche. |
|
||||
|
||||
**Empfehlung:** C für den kopflosen Host (fällt dort ohnehin an), B für die
|
||||
Desktop-Oberfläche. Variante A koppelt uns an eine Bibliothek, die weniger stabil
|
||||
ist als alles andere im Projekt.
|
||||
|
||||
### 2.2 PropertyGrid → es gibt keinen Ersatz von der Stange
|
||||
|
||||
Vier `PropertyGrid`-Instanzen in [frm_main.Designer.cs](frm_main.Designer.cs)
|
||||
bilden App-Settings, Instance-Settings, Agent-Settings und Tool-Settings ab. Sie
|
||||
werden vollständig durch Attribute gesteuert — **246 `[Category]`,
|
||||
`[DisplayName]`, `[Description]`-Angaben** verteilt auf vier Dateien:
|
||||
|
||||
- [Models/ToolSettingsViewModels.cs](Models/ToolSettingsViewModels.cs) — 108
|
||||
- [Models/AgentSettingsViewModel.cs](Models/AgentSettingsViewModel.cs) — 54
|
||||
- [Models/AppSettings.cs](Models/AppSettings.cs) — 51
|
||||
- [Models/InstanceSettingsViewModel.cs](Models/InstanceSettingsViewModel.cs) — 33
|
||||
|
||||
Dazu kommen `[TypeConverter(typeof(ExpandableObjectConverter))]` für
|
||||
verschachtelte Objekte, `[PasswordPropertyText(true)]` für Geheimnisse und ein
|
||||
eigener [ModelTypeConverter](Models/ModelTypeConverter.cs), der das
|
||||
Modell-Auswahlfeld dynamisch aus der OpenRouter-Modellliste füllt.
|
||||
|
||||
Avalonia hat kein `PropertyGrid`. Zwei Möglichkeiten:
|
||||
|
||||
1. **`Avalonia.PropertyGrid`** (Community, MIT). Versteht `Category`,
|
||||
`DisplayName`, `Description`, `Browsable`, `ReadOnly` und
|
||||
`ExpandableObjectConverter`. Die ViewModels und ihre Attribute könnten
|
||||
weitgehend unverändert bleiben — das spart am meisten. Zu prüfen ist, ob der
|
||||
dynamische `ModelTypeConverter` mit `GetStandardValues` unterstützt wird; das
|
||||
ist der Punkt, an dem so etwas erfahrungsgemäß hakt. **Aufwand 6–8 PT**, plus
|
||||
dauerhafte Abhängigkeit an ein Ein-Personen-Projekt.
|
||||
2. **Von Hand gebaute Einstellungsformulare.** Mehr Arbeit, aber wir bekommen
|
||||
eine Oberfläche, die man Nutzern zumuten kann — das `PropertyGrid` ist
|
||||
ehrlicherweise eine Entwickleransicht. Passwörter, Verzeichnisauswahl,
|
||||
Validierung und die Modell-Auswahl werden dabei richtig statt behelfsmäßig.
|
||||
**Aufwand 10–14 PT.**
|
||||
|
||||
Das ist der größte Einzelposten der GUI-Portierung. Die Entscheidung kann und
|
||||
sollte man verschieben, bis das Grundgerüst steht.
|
||||
|
||||
### 2.3 DPAPI → Geheimnisse liegen unter Linux im Klartext
|
||||
|
||||
[SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42):
|
||||
|
||||
```csharp
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return plainText;
|
||||
```
|
||||
|
||||
Unter Linux verschlüsselt `Protect` **stillschweigend nicht**. OpenRouter-Key,
|
||||
Datenbank-Verbindungszeichenfolgen mit Passwort, Mail-Zugangsdaten und das
|
||||
Telegram-2FA-Passwort lägen im Klartext in `InstanceConfig.json` und
|
||||
`AgentSettings.json` — genau der Zustand, den S7 behoben hat. Auf einem Server,
|
||||
der per SSH erreichbar ist und gesichert wird, ist das schlechter als auf einem
|
||||
Einzelplatz-Windows.
|
||||
|
||||
Dasselbe gilt für den Lizenz-Zustandsspeicher:
|
||||
`LicenseLabrador/client-dotnet/.../StateStore.cs:92` schützt seine Datei ebenfalls
|
||||
nur unter Windows per DPAPI.
|
||||
|
||||
Zu klären ist also ein plattformübergreifendes Verfahren. Realistisch:
|
||||
|
||||
- **AES-GCM mit Schlüssel aus einer Datei mit `0600`** neben der Konfiguration
|
||||
(Linux) bzw. weiterhin DPAPI (Windows). Einfach, wirkt gegen versehentliche
|
||||
Weitergabe und Backups, nicht gegen einen Angreifer mit demselben Benutzer —
|
||||
dieselbe Schutzstufe wie DPAPI heute.
|
||||
- Optional zusätzlich `libsecret`/Schlüsselbund, wenn eine Desktop-Sitzung da
|
||||
ist. Auf einem Server gibt es die nicht, also braucht es den Dateiweg ohnehin.
|
||||
|
||||
Nebenwirkung, die man einplanen muss: **Konfigurationen sind nicht mehr zwischen
|
||||
Betriebssystemen austauschbar.** Ein `enc:v1:`-Wert von Windows ist unter Linux
|
||||
nicht lesbar und umgekehrt. `Unprotect` wirft dann korrekterweise eine
|
||||
`SecretProtectionException` ([SecretProtector.cs:81](src/ClawdDotNet.Core/Security/SecretProtector.cs:81)) —
|
||||
für den Umzug einer Instanz braucht es einen Migrationsweg (Präfix `enc:v2:`,
|
||||
Werte neu eintragen oder ein Export/Import-Kommando).
|
||||
|
||||
**Aufwand 3–5 PT** inklusive Tests und Migration.
|
||||
|
||||
### 2.4 Zeitzonen → das ist die stillste Fehlerquelle
|
||||
|
||||
[TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161):
|
||||
|
||||
```csharp
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(id); }
|
||||
catch { return TimeZoneInfo.Utc; }
|
||||
```
|
||||
|
||||
Und [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32)
|
||||
schreibt `TimeZoneInfo.Local.Id` in die Task-Frontmatter. Auf dem
|
||||
Entwicklungsrechner ergibt das `"W. Europe Standard Time"`, unter Linux
|
||||
`"Europe/Berlin"`.
|
||||
|
||||
Task-Dateien sind Markdown im `SharedWorkspace` und wandern zwischen Rechnern.
|
||||
Trifft eine Windows-ID auf ein System ohne die Umsetzungsdaten, greift das
|
||||
`catch` — und der Task läuft ab sofort nach **UTC statt Ortszeit**, also im
|
||||
Sommer zwei Stunden zu früh. Ohne Fehlermeldung, ohne Logeintrag. Ein Task, der
|
||||
um 08:00 die Marktübersicht holen soll, läuft um 06:00.
|
||||
|
||||
.NET 6+ kann Windows-IDs unter Linux über ICU auflösen, aber nur wenn ICU
|
||||
vorhanden ist. In einem schlanken Container (Alpine ohne `icu-libs`, distroless)
|
||||
oder bei `InvariantGlobalization=true` ist es das nicht — dann schlägt jede
|
||||
Auflösung fehl und alles fällt auf UTC.
|
||||
|
||||
Was zu tun ist:
|
||||
|
||||
- Beim Schreiben auf **IANA normalisieren**
|
||||
(`TimeZoneInfo.TryConvertWindowsIdToIanaId`), beim Lesen beide Formen
|
||||
akzeptieren.
|
||||
- Das `catch` **nicht mehr still schlucken** — eine unbekannte Zeitzone muss
|
||||
protokolliert werden, besser noch den Task als fehlerhaft markieren.
|
||||
- Das Zielsystem muss `tzdata` haben. Für Container explizit installieren.
|
||||
|
||||
Verwandt: **82 Vorkommen von `DateTime.Now`/`UtcNow`**. Die meisten sind
|
||||
unkritisch, zwei fallen auf:
|
||||
[TaskboardService.cs:80](src/ClawdDotNet.Core/Tasks/TaskboardService.cs:80)
|
||||
schreibt `DateTime.Now`-Zeitstempel in Task-Dateien, und
|
||||
[LiveLogViewerService.cs:98](Services/LiveLogViewerService.cs:98) sucht die
|
||||
Logdatei des Tages über `DateTime.Now`. Server laufen üblicherweise mit `TZ=UTC`
|
||||
— dort wechselt die Logdatei dann um 02:00 Ortszeit statt um Mitternacht, und
|
||||
Task-Zeitstempel bekommen eine andere Bedeutung als bisher. Kein Fehler, aber
|
||||
eine Verhaltensänderung, die man kennen sollte.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 2.5 Groß-/Kleinschreibung bei Pfaden → sicherheitsrelevant
|
||||
|
||||
Linux-Dateisysteme unterscheiden Groß- und Kleinschreibung, Windows nicht. An
|
||||
vier Stellen wird das Gegenteil angenommen — und drei davon bewachen eine
|
||||
Sandbox-Grenze:
|
||||
|
||||
- [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72) —
|
||||
`normalizedCandidate.StartsWith(normalizedRoot, OrdinalIgnoreCase)`. Das ist
|
||||
die Prüfung, die Agenten daran hindert, aus ihrem Arbeitsverzeichnis
|
||||
auszubrechen.
|
||||
- [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174) —
|
||||
Abgleich gegen die Liste geschützter Pfade.
|
||||
- [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155) — dieselbe
|
||||
Einschließungsprüfung.
|
||||
- [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384).
|
||||
|
||||
Unter Linux sind `/home/x/Workspace` und `/home/x/workspace` **zwei
|
||||
verschiedene Verzeichnisse**. Der Vergleich mit `OrdinalIgnoreCase` würde einen
|
||||
Pfad im zweiten als „innerhalb" des ersten durchwinken. Genauso liefe die
|
||||
Sperrliste in `FileRWTool` ins Leere, sobald jemand die Schreibweise ändert.
|
||||
|
||||
Nötig ist ein Vergleichsverfahren, das die Plattform berücksichtigt — ein
|
||||
`PathComparer`, der unter Windows `OrdinalIgnoreCase` und unter Unix `Ordinal`
|
||||
verwendet, konsequent an allen vier Stellen.
|
||||
|
||||
Ebenfalls betroffen, aber harmlos:
|
||||
[AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) schlüsselt
|
||||
seine Sperren mit `fullPath.ToLowerInvariant()`. Unter Linux teilen sich damit
|
||||
zwei verschiedene Dateien eine Sperre — das serialisiert zu viel, gefährdet aber
|
||||
nichts.
|
||||
|
||||
**Aufwand 2–3 PT**, davon der größere Teil Tests.
|
||||
|
||||
### 2.6 Prozessaufrufe und `.exe`-Annahmen
|
||||
|
||||
- **`Process.Start("explorer.exe", …)`** — 4 Stellen
|
||||
([frm_main.cs:1552](frm_main.cs:1552), [frm_main.cs:1557](frm_main.cs:1557),
|
||||
[frm_main.cs:1562](frm_main.cs:1562), [BackupPanel.cs:338](UI/BackupPanel.cs:338)).
|
||||
Ersatz: `Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })`
|
||||
bzw. `xdg-open`. Die Variante `explorer.exe /select,"…"` hat unter Linux kein
|
||||
Gegenstück — dort öffnet man nur den Ordner.
|
||||
- **`Microsoft.VisualBasic.Interaction.InputBox`** — 3 Stellen
|
||||
([Program.cs:280](Program.cs:280), [Program.cs:291](Program.cs:291),
|
||||
[frm_main.cs:658](frm_main.cs:658)), zwei davon für den interaktiven
|
||||
Telegram-Login (Code und 2FA-Passwort). Braucht einen eigenen Dialog. Für den
|
||||
kopflosen Betrieb ohnehin problematisch: **ein Login, der ein Eingabefenster
|
||||
öffnet, blockiert einen Dienst.** Dort muss der Telegram-Login anders gelöst
|
||||
werden (vorab per CLI, oder über die Weboberfläche).
|
||||
- **`yt-dlp.exe` / `ffmpeg.exe`** —
|
||||
[SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782)
|
||||
und `:825`. Die PATH-Suche davor funktioniert unter Linux bereits; nur die
|
||||
Ausweichpfade sind fest auf `.exe` verdrahtet und laufen dort ins Leere.
|
||||
Kleine Änderung, aber sie fällt sonst erst zur Laufzeit auf.
|
||||
|
||||
**Aufwand zusammen 1–2 PT.**
|
||||
|
||||
### 2.7 WinForms-Timer in der Dienstschicht
|
||||
|
||||
`Services/` ist logisch kein UI-Code, hängt aber an
|
||||
`System.Windows.Forms.Timer`:
|
||||
|
||||
- [BackupScheduler.cs:41](Services/BackupScheduler.cs:41)
|
||||
- [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38) — schreibt
|
||||
zusätzlich direkt in eine `RichTextBox`
|
||||
- [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43)
|
||||
- [frm_main.License.cs:41](frm_main.License.cs:41)
|
||||
|
||||
Der Backup-Zeitplan und die Lizenzprüfung gehören in den kopflosen Host und
|
||||
müssen dafür auf `System.Threading.PeriodicTimer` umgestellt werden. Der
|
||||
Log-Betrachter ist echte Oberfläche und wird ohnehin neu gebaut.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 2.8 Lizenzierung — erledigt (Stand 2026-08-06)
|
||||
|
||||
> **Nachtrag.** LicenseLabrador und WatchDog sind im **Deploymentcenter**
|
||||
> zusammengefasst, Hardware-ID v2 ist dort umgesetzt. Der Client
|
||||
> (`Deploymentcenter.Client`, `netstandard2.0;net8.0`) läuft auf beiden
|
||||
> Plattformen, die HW-ID ist container- und umbenennungsfest, der Ablageort
|
||||
> löst sich auch ohne `HOME` auf, und der Zustandsspeicher ist mit AES-GCM
|
||||
> plattformübergreifend verschlüsselt.
|
||||
>
|
||||
> **Damit ist die einzige potenziell blockierende Frage dieser Analyse
|
||||
> beantwortet.** Details und offene Punkte der Anbindung:
|
||||
> [Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md).
|
||||
|
||||
Es bleibt reine Anschlussarbeit in ClawdDotNet: Projektverweis umhängen,
|
||||
[LicenseGate](Services/LicenseGate.cs) gegen die neue Ergebnisklasse schreiben
|
||||
(`LicenseState` ist entfallen, `Status` ist jetzt eine Zeichenkette), `MessageBox`
|
||||
durch das mitgelieferte `ILicensePrompt` ersetzen und die Lizenzprüfung aus
|
||||
[Program.cs:112](Program.cs:112) fensterfrei machen.
|
||||
|
||||
**Aufwand 2–3 PT** (vorher 3–5).
|
||||
|
||||
---
|
||||
|
||||
## 3. Kleinere Punkte, die trotzdem beißen
|
||||
|
||||
### 3.1 Kultur- und Zahlenformatierung
|
||||
|
||||
Nur 16 Stellen im gesamten Projekt nennen eine Kultur explizit. Das heißt
|
||||
umgekehrt: fast alles formatiert mit `CurrentCulture`. Auf dem
|
||||
Entwicklungsrechner ist das `de-DE`, auf einem Server mit unbesetztem `LANG`
|
||||
ist es `InvariantCulture`. Aus `1,25` wird `1.25`.
|
||||
|
||||
Wo das folgenlos bleibt:
|
||||
- **JSON** — `System.Text.Json` schreibt Zahlen immer invariant. Alle
|
||||
Konfigurationen, Zustandsdateien und API-Aufrufe sind sicher.
|
||||
- **SQLite** — Werte gehen typisiert über Parameter, nicht als Text.
|
||||
|
||||
Wo hinzuschauen ist:
|
||||
- Zeichenkettenverkettung in Logeinträgen und Prompts (`$"{cost:F4}"`). Wenn
|
||||
eine Zahl mit deutschem Dezimalkomma in einen Prompt gerät, muss das Modell
|
||||
raten.
|
||||
- Anzeigewerte in der Oberfläche — dort ist Ortsformat gewünscht, aber es sollte
|
||||
bewusst gesetzt sein, nicht zufällig.
|
||||
|
||||
**Empfehlung:** einmal alle Formatierungen durchgehen und trennen — invariant
|
||||
für alles Maschinenlesbare, `CurrentCulture` nur für die Anzeige. Am besten mit
|
||||
einem Analyzer (`CA1305`, `CA1304`, `CA1310`) als Warnung im Build, damit es so
|
||||
bleibt.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 3.2 Globalisierungsmodus festlegen
|
||||
|
||||
`InvariantGlobalization=true` macht das Publikat kleiner und ICU überflüssig —
|
||||
kostet aber `TimeZoneInfo.FindSystemTimeZoneById` (siehe 2.4), kulturabhängige
|
||||
Vergleiche und korrektes `ToLower()` für Umlaute. Für dieses Projekt mit
|
||||
zeitzonenabhängiger Planung ist das **keine Option**; die Entscheidung sollte im
|
||||
Projekt dokumentiert und ICU/tzdata als Voraussetzung festgehalten werden.
|
||||
|
||||
Nebenbemerkung: `COLLATE NOCASE` in
|
||||
[SqliteMemoryRepository.cs:143](src/ClawdDotNet.Core/Memory/SqliteMemoryRepository.cs:143)
|
||||
und [SqliteTaskRepository.cs:105](src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs:105)
|
||||
ist ASCII-beschränkt — `Ä` und `ä` gelten SQLite als verschieden. Das ist heute
|
||||
schon so und ändert sich beim Umzug nicht, ist also kein Portierungsthema,
|
||||
sondern eine bestehende Eigenheit.
|
||||
|
||||
### 3.3 Zeilenenden
|
||||
|
||||
1.230 Stellen verwenden `Environment.NewLine` oder `\r\n`. Für Logdateien ist
|
||||
das egal. Bei **Task-Dateien** und Agenten-erzeugten Dateien im geteilten
|
||||
Arbeitsverzeichnis führt es zu Rauschen: Eine Datei, die unter Windows
|
||||
geschrieben und unter Linux angefasst wird, ändert komplett ihre Zeilenenden.
|
||||
Wenn der Arbeitsbereich unter Git liegt oder synchronisiert wird, sieht jede
|
||||
Änderung wie eine Vollumschreibung aus. Der Parser kommt damit klar (siehe 1.1)
|
||||
— es ist eine Frage der Ordnung, kein Fehler. Empfehlung: für Task- und
|
||||
Konfigurationsdateien fest `\n` schreiben.
|
||||
|
||||
### 3.4 Dateinamen
|
||||
|
||||
`Path.GetInvalidFileNameChars()` liefert unter Windows 41 Zeichen, unter Linux
|
||||
genau zwei (`\0` und `/`). [FileLogWriter.cs:95](src/ClawdDotNet.Core/Logging/FileLogWriter.cs:95)
|
||||
säubert Modulnamen damit — unter Linux entstehen also Dateinamen, die auf
|
||||
Windows nicht mehr lesbar sind. Betrifft Sicherungen, die zwischen Systemen
|
||||
wandern. Ebenso die Windows-Sonderfälle `CON`, `PRN`, `AUX` und Namen mit
|
||||
abschließendem Punkt: unter Linux erlaubt, beim Rückspielen auf Windows nicht.
|
||||
Für den Sicherungs-/Wiederherstellungsweg über Systemgrenzen hinweg relevant.
|
||||
|
||||
### 3.5 Ablageorte
|
||||
|
||||
[SettingsManager.cs:24](Services/SettingsManager.cs:24) legt `AppSettings.json`
|
||||
neben die Programmdatei (`AppDomain.CurrentDomain.BaseDirectory`). Unter Windows
|
||||
in einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung typisch
|
||||
in `/opt/…` oder `/usr/lib/…` und ist für den Dienstbenutzer **nicht
|
||||
schreibbar**. Dasselbe gilt für die Zielordner `tools/`, `Logs/` und
|
||||
`Instances/`, die die Build-Ziele in `ClawdDotNet.csproj` neben der
|
||||
Programmdatei anlegen.
|
||||
|
||||
Nötig ist eine Trennung von Programm und Daten nach XDG-Konvention:
|
||||
`$XDG_CONFIG_HOME` bzw. `/etc/clawddotnet` für die Konfiguration,
|
||||
`$XDG_DATA_HOME` bzw. `/var/lib/clawddotnet` für Instanzen und Datenbanken,
|
||||
`/var/log/clawddotnet` für Logs. Dazu Dateirechte: Instanzverzeichnisse mit
|
||||
Geheimnissen gehören auf `0700`, Konfigurationsdateien auf `0600` — unter
|
||||
Windows regelt das die ACL des Benutzerprofils, unter Linux muss man es setzen.
|
||||
|
||||
**Aufwand 2–3 PT.**
|
||||
|
||||
### 3.6 Tests
|
||||
|
||||
Von 348 Tests sind fast alle portabel. Auffällig ist
|
||||
[WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49):
|
||||
|
||||
```csharp
|
||||
[InlineData(@"C:\Windows\System32\config\SAM")]
|
||||
[InlineData(@"\\server\share\evil.txt")]
|
||||
[InlineData(@"C:\temp\datei.txt")]
|
||||
```
|
||||
|
||||
Unter Linux liefert `Path.IsPathRooted(@"C:\temp\datei.txt")` **`false`** — das
|
||||
ist ein gewöhnlicher relativer Dateiname mit Doppelpunkt und Backslashes darin.
|
||||
Der Test prüft dort also etwas anderes als beabsichtigt. Und da er die
|
||||
Sandbox-Grenze absichert, ist das keine Kleinigkeit: Er muss
|
||||
betriebssystemabhängig aufgeteilt werden, mit einer eigenen Linux-Fassung
|
||||
(`/etc/passwd`, `../../etc/passwd`, Symlinks). Symlinks sind überhaupt ein
|
||||
Prüfpunkt, den es unter Windows so nicht gab — `Path.GetFullPath` löst sie
|
||||
**nicht** auf, `File.ResolveLinkTarget` schon. Ein Agent könnte im
|
||||
Arbeitsverzeichnis einen Symlink nach `/etc` anlegen und die Prüfung ginge
|
||||
durch.
|
||||
|
||||
Ebenso in [YouTubeUrlTests.cs:118](tests/ClawdDotNet.Tools.Tests/SocialMedia/YouTubeUrlTests.cs:118)
|
||||
(harmlos, nur Beispieldaten).
|
||||
|
||||
**Aufwand 2–4 PT**, inklusive Symlink-Absicherung in `WorkspacePath` selbst.
|
||||
|
||||
### 3.7 Bau und Auslieferung
|
||||
|
||||
[Deploy-Build.ps1](Deploy-Build.ps1) setzt PowerShell 5.1 voraus, verwendet
|
||||
Backslash-Pfade und den festen Ausgabepfad `bin\Release\net10.0-windows`. Für
|
||||
Linux braucht es entweder eine `pwsh`-taugliche Fassung oder — besser — einen
|
||||
schlichten `dotnet publish -r linux-x64 --self-contained` mit einer
|
||||
systemd-Unit-Datei. Dazu:
|
||||
|
||||
- systemd-Unit mit eigenem Dienstbenutzer, `Restart=on-failure`
|
||||
- Prüfen, ob der bestehende Watchdog-Heartbeat
|
||||
([Program.cs:367](Program.cs:367)) mit `systemd-notify` zusammenspielen soll
|
||||
- optional `.deb` oder AppImage für den Desktop-Fall
|
||||
|
||||
**Aufwand 3–5 PT.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Der empfohlene Schnitt
|
||||
|
||||
Der entscheidende Befund dieser Analyse: **Die Oberfläche ist nicht der Grund,
|
||||
warum wir Linux wollen.** Der Grund ist, dass Agenten auf einem Server laufen
|
||||
sollen. [Program.cs](Program.cs) baut bereits alles — Speicher, Engine,
|
||||
Taskboard-Scanner, Watchdog, Lizenzprüfung — vollständig auf, **bevor**
|
||||
`frm_main` überhaupt entsteht (Zeilen 36–385 gegen 388–401). Diese Trennung
|
||||
existiert faktisch schon; sie muss nur formalisiert werden.
|
||||
|
||||
### Stufe 1 — Kern Linux-fest und kopfloser Host (12–18 PT)
|
||||
|
||||
| Schritt | PT |
|
||||
|---|---:|
|
||||
| Geheimnisse plattformübergreifend (2.3) | 3–5 |
|
||||
| Zeitzonen normalisieren, Fehler nicht mehr schlucken (2.4) | 2–3 |
|
||||
| Pfadvergleiche plattformabhängig + Symlink-Prüfung (2.5, 3.6) | 3–5 |
|
||||
| Prozessaufrufe, `.exe`-Pfade (2.6) | 1–2 |
|
||||
| Ablageorte und Dateirechte nach XDG (3.5) | 2–3 |
|
||||
| `ClawdDotNet.Host` — Startlogik aus `Program.cs` herauslösen, `PeriodicTimer` statt WinForms-Timer, Telegram-Login ohne Dialog | 4–6 |
|
||||
| Tests auf Linux grün, CI-Lauf für linux-x64 | 2–3 |
|
||||
|
||||
**Ergebnis:** Die Anwendung läuft als systemd-Dienst auf einem Linux-Server. Die
|
||||
Windows-GUI bleibt unverändert bestehen und wird weiter benutzt. Das ist der
|
||||
Punkt, an dem der Nutzen anfällt.
|
||||
|
||||
### Stufe 2 — Avalonia-Oberfläche (32–52 PT)
|
||||
|
||||
| Schritt | PT |
|
||||
|---|---:|
|
||||
| Grundgerüst: Avalonia-Projekt, DI, Dispatcher, Shell mit Tabs, MVVM-Schicht | 5–7 |
|
||||
| Logs-Tab (`RichTextBox` → `SelectingItemsControl` mit Filterung) | 2–3 |
|
||||
| Agent-Settings: Liste, Werkzeugauswahl, Aktionsschaltflächen | 5–8 |
|
||||
| Einstellungs-Tabs — PropertyGrid-Ersatz (2.2) | 6–10 |
|
||||
| Jobs / Services / Job History (4 `DataGridView`) | 4–6 |
|
||||
| Backup-Panel | 3–4 |
|
||||
| Instance-Manager und die fünf Dialoge | 4–6 |
|
||||
| Chat-Ansicht (Variante B, siehe 2.1) | 8–12 |
|
||||
| Info, Statusleiste, Werkzeugleisten, Menü, Lokalisierung de/en | 3–4 |
|
||||
|
||||
Die Spanne ist breit, weil zwei Entscheidungen noch offen sind (PropertyGrid-Ersatz
|
||||
und Chat-Variante). Sind die getroffen, lässt sich das auf etwa ±15 % genau
|
||||
angeben.
|
||||
|
||||
### Stufe 3 — Auslieferung und Härtung (6–10 PT)
|
||||
|
||||
Publish-Pipeline, systemd-Unit, Paketierung, Abnahme auf echter Hardware,
|
||||
Dokumentation, Umzugsweg für bestehende Instanzen.
|
||||
|
||||
### Gesamt
|
||||
|
||||
| | PT | bei Vollzeit |
|
||||
|---|---:|---|
|
||||
| Stufe 1 | 12–18 | 2,5–3,5 Wochen |
|
||||
| Stufe 2 | 32–52 | 6,5–10,5 Wochen |
|
||||
| Stufe 3 | 6–10 | 1,5–2 Wochen |
|
||||
| **Summe** | **50–80** | **10–16 Wochen** |
|
||||
|
||||
---
|
||||
|
||||
## 5. LiveCharts2
|
||||
|
||||
Zur Einordnung: **Das Projekt enthält heute keine einzige Diagrammdarstellung.**
|
||||
Die Suche nach `Chart`, `Series` oder `Plot` findet nur JSON-Feldnamen der
|
||||
Yahoo-Finance-Abfrage in
|
||||
[DirectAPITool.cs:126](src/ClawdDotNet.Tools.DirectAPI/DirectAPITool.cs:126).
|
||||
|
||||
LiveCharts2 ist damit **keine Portierung, sondern neue Funktionalität** — sie
|
||||
gehört zum Trading-Teil, nicht zum Linux-Umzug, und ist in den 50–80 PT oben
|
||||
nicht enthalten. Wenn die Kursansichten kommen, sind dafür grob 5–10 PT
|
||||
zusätzlich zu rechnen. Das passt zu dem, was in
|
||||
[docs/Roadmap.md](docs/Roadmap.md) und der Notiz „Basis vor Trading härten"
|
||||
festgehalten ist: Erst die Basis, dann die Handelsansichten.
|
||||
|
||||
Ein Punkt, der jetzt schon zählt: LiveCharts2 setzt auf SkiaSharp, genau wie
|
||||
Avalonia. Das spricht zusätzlich dafür, die Diagramme erst **nach** der
|
||||
Avalonia-Portierung zu bauen — sonst entstehen sie zweimal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Was vor dem ersten Handgriff zu entscheiden ist
|
||||
|
||||
1. **Ist das Ziel Server oder Desktop?** Bei „Server" reicht Stufe 1, und Stufe 2
|
||||
kann entfallen oder durch eine Weboberfläche ersetzt werden. Das ändert die
|
||||
Schätzung um den Faktor drei.
|
||||
2. **Chat-Ansicht: HTML behalten oder nativ neu bauen?** (2.1)
|
||||
3. **PropertyGrid: Fremdbibliothek oder eigene Formulare?** (2.2)
|
||||
4. ~~**Erlaubt LicenseLabrador den Wechsel der Hardware-ID?**~~ — **geklärt**,
|
||||
siehe 2.8 und
|
||||
[Deploymentcenter-Anbindung-Review.md](Deploymentcenter-Anbindung-Review.md).
|
||||
5. **Bleibt Windows als Zielplattform bestehen?** Wenn ja, muss alles doppelt
|
||||
getestet werden, und die Geheimnis-Verschlüsselung braucht beide Wege plus
|
||||
Umzugspfad. Wenn nein, wird 2.3 deutlich einfacher.
|
||||
|
||||
Frage 1 und 5 beantworten sich vermutlich schnell; 2 und 3 kann man bis zum
|
||||
Beginn von Stufe 2 offenlassen, ohne Stufe 1 zu blockieren. Damit liegt nichts
|
||||
mehr außerhalb unserer Hand — **Stufe 1 kann beginnen.**
|
||||
|
||||
---
|
||||
|
||||
## Anhang — Vollständige Fundstellenliste
|
||||
|
||||
| Thema | Datei:Zeile |
|
||||
|---|---|
|
||||
| DPAPI | [SecretProtector.cs:42](src/ClawdDotNet.Core/Security/SecretProtector.cs:42), `:66`, `:90`, `:94` |
|
||||
| DPAPI (Lizenz) | `LicenseLabrador/client-dotnet/.../StateStore.cs:43`, `:92` |
|
||||
| Zeitzone | [TaskSchedule.cs:161](src/ClawdDotNet.Core/Tasks/TaskSchedule.cs:161), [SchedulerTaskMigration.cs:32](src/ClawdDotNet.Core/Tasks/SchedulerTaskMigration.cs:32) |
|
||||
| Pfad-Groß-/Kleinschreibung | [WorkspacePath.cs:72](src/ClawdDotNet.Tools.FileRW/WorkspacePath.cs:72), [FileRWTool.cs:174](src/ClawdDotNet.Tools.FileRW/FileRWTool.cs:174), [FtpTool.cs:155](src/ClawdDotNet.Tools.FTP/FtpTool.cs:155), [BackupService.cs:384](src/ClawdDotNet.Core/Backup/BackupService.cs:384), [AtomicFile.cs:35](src/ClawdDotNet.Core/Storage/AtomicFile.cs:35) |
|
||||
| `explorer.exe` | [frm_main.cs:1552](frm_main.cs:1552), `:1557`, `:1562`, [BackupPanel.cs:338](UI/BackupPanel.cs:338) |
|
||||
| `VisualBasic.InputBox` | [Program.cs:280](Program.cs:280), `:291`, [frm_main.cs:658](frm_main.cs:658) |
|
||||
| `.exe`-Werkzeugpfade | [SocialMediaManagerTool.cs:782](src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs:782), `:825` |
|
||||
| WinForms-Timer | [BackupScheduler.cs:41](Services/BackupScheduler.cs:41), [LiveLogViewerService.cs:38](Services/LiveLogViewerService.cs:38), [OpenRouterStatusService.cs:43](Services/OpenRouterStatusService.cs:43), [frm_main.License.cs:41](frm_main.License.cs:41) |
|
||||
| WebView2 | [frm_main.cs:235](frm_main.cs:235), [frm_chat.cs:44](frm_chat.cs:44), [WebViewBridge.cs](UI/WebViewBridge.cs), [ClawdDotNet.csproj](ClawdDotNet.csproj) |
|
||||
| PropertyGrid | [frm_main.Designer.cs](frm_main.Designer.cs) (4×), [Models/](Models/) (246 Attribute) |
|
||||
| Datenablage | [SettingsManager.cs:24](Services/SettingsManager.cs:24), [ClawdDotNet.csproj](ClawdDotNet.csproj) (Build-Ziele) |
|
||||
| Testdaten mit Windows-Pfaden | [WorkspacePathTests.cs:49](tests/ClawdDotNet.Tools.Tests/FileRW/WorkspacePathTests.cs:49) |
|
||||
| Build-Skript | [Deploy-Build.ps1](Deploy-Build.ps1) |
|
||||
@@ -0,0 +1,520 @@
|
||||
# Hardware-ID v2 — Implementierungsvorschlag
|
||||
|
||||
Stand: 2026-08-06. Betrifft `LicenseLabrador` (Client + Server) und die
|
||||
Aufrufseite in ClawdDotNet ([Services/LicenseGate.cs](Services/LicenseGate.cs)).
|
||||
|
||||
Anlass: Für den [Linux-Umzug](Linux-Portierung-Analyse.md) muss die
|
||||
Hardware-Bindung auf beiden Plattformen funktionieren. Bei der Durchsicht sind
|
||||
dabei zwei Probleme aufgefallen, die **nichts mit Linux zu tun haben**, aber
|
||||
denselben Code betreffen — die sollten in einem Zug mit erledigt werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Befund
|
||||
|
||||
### 1.1 Der Rechnername steckt im Hash — das ist das eigentliche Problem
|
||||
|
||||
[HardwareId.cs:43](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs):
|
||||
|
||||
```csharp
|
||||
rawBuilder.Append(Environment.MachineName); // "for system isolation"
|
||||
```
|
||||
|
||||
Folge: **Ein umbenannter Rechner ist eine neue Maschine.** Er verbraucht einen
|
||||
weiteren Aktivierungsplatz, und der alte bleibt für immer belegt
|
||||
(`max_activations` ist standardmäßig 2 — nach zwei Umbenennungen ist die Lizenz
|
||||
dicht). Das gilt bereits heute unter Windows.
|
||||
|
||||
Unter Linux wird daraus ein Totalausfall: In einem Container ist der Hostname
|
||||
standardmäßig die gekürzte Container-ID, also **bei jedem Start ein anderer**.
|
||||
Die Lizenz wäre nach dem zweiten `docker run` verbraucht.
|
||||
|
||||
Die Absicht („system isolation") ist auch nicht erfüllt: Der Rechnername steht
|
||||
ohnehin im Feld `hostname`, das der Server bei jeder Prüfung mitschreibt
|
||||
([LicenseService.php:112](../../LicenseLabrador/server/src/LicenseService.php)).
|
||||
Diagnostisch verlieren wir nichts, wenn er aus dem Hash verschwindet.
|
||||
|
||||
### 1.2 Die MAC-Ausweichlösung ist unter Linux instabil
|
||||
|
||||
[HardwareId.cs:86](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/HardwareId.cs)
|
||||
nimmt die alphabetisch erste physische MAC. Unter Linux:
|
||||
|
||||
- Die Stoppwortliste kennt `docker` und `veth`, aber **nicht** `br-` (Bridges),
|
||||
`virbr` (libvirt), `cni`, `flannel`, `cali` (Kubernetes), `zt` (ZeroTier).
|
||||
- `NetworkInterfaceType` meldet unter Linux für die meisten virtuellen Geräte
|
||||
schlicht `Ethernet` — die Typprüfung greift also nicht.
|
||||
- Bridge- und veth-MACs werden von systemd **je Boot neu zufällig** vergeben.
|
||||
|
||||
Sortiert man solche Adressen mit, wechselt die Hardware-ID beim Neustart. Die
|
||||
Ausweichlösung ist damit unter Linux schlimmer als keine.
|
||||
|
||||
### 1.3 Der Zustandsspeicher fällt still auf Klartext zurück
|
||||
|
||||
[StateStore.cs:41](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)
|
||||
beim Lesen und `:90` beim Schreiben:
|
||||
|
||||
```csharp
|
||||
try { decryptedData = ProtectedData.Unprotect(rawData, null, ...); }
|
||||
catch { decryptedData = rawData; } // ← Klartext wird akzeptiert
|
||||
```
|
||||
|
||||
Unter Linux wirft DPAPI immer, also läuft alles über den Klartextzweig. Zwei
|
||||
Folgen:
|
||||
|
||||
- `SECURITY.md` behauptet, der Cache sei „strikt an die `hardware_id` gebunden".
|
||||
Das stimmt für die *Hülle* (die Prüfung in
|
||||
[LicenseClient.cs:198](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs)),
|
||||
nicht für die Cache-Datei selbst.
|
||||
- Ernster: `max_seen_time` ist die Uhr-Rückdreh-Sperre
|
||||
([StateStore.cs:107](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/StateStore.cs)).
|
||||
Wer eine `state.dat` von Hand schreiben kann, setzt den Wert auf 0 und stellt
|
||||
die Systemuhr zurück. Der Klartext-Rückfall beim **Lesen** macht das möglich,
|
||||
und zwar auf jeder Plattform, auf der DPAPI nicht greift.
|
||||
|
||||
### 1.4 Ablageort bricht bei einem systemd-Dienst weg
|
||||
|
||||
[LicenseConfig.cs:22](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseConfig.cs)
|
||||
verwendet `Environment.GetFolderPath(SpecialFolder.ApplicationData)`. Läuft der
|
||||
Dienst unter `User=clawd` ohne Heimatverzeichnis, ist `HOME` nicht gesetzt und
|
||||
`GetFolderPath` liefert einen **leeren String**. `Path.Combine("", slug,
|
||||
"license")` ergibt einen relativen Pfad — die Lizenz landet im Arbeitsverzeichnis
|
||||
oder gar nicht.
|
||||
|
||||
### 1.5 Kein Formatkennzeichen, keine Plattformangabe
|
||||
|
||||
Die Hardware-ID ist heute ein nackter SHA-256-Hex-String. Es gibt keine
|
||||
Möglichkeit, im Server zu erkennen, aus welcher Quelle oder von welchem
|
||||
Betriebssystem eine Aktivierung stammt — und keinen Weg, das Format je zu
|
||||
wechseln, ohne alle bestehenden Aktivierungen zu verlieren.
|
||||
|
||||
**Randnotiz:** `OperatingSystemHelpers.IsWindows()` nutzt
|
||||
`Environment.OSVersion.Platform == PlatformID.Win32NT`. Das funktioniert
|
||||
zufällig richtig (Linux liefert `Unix`), ist aber die veraltete API.
|
||||
`RuntimeInformation.IsOSPlatform(OSPlatform.Windows)` ist in netstandard2.0
|
||||
verfügbar und der korrekte Weg.
|
||||
|
||||
---
|
||||
|
||||
## 2. Zielbild: das Format
|
||||
|
||||
```
|
||||
2:<plattform>:<64 Hex-Zeichen>
|
||||
|
||||
Beispiele:
|
||||
2:win:9f3ab7c1… (Windows, MachineGuid)
|
||||
2:lin:41e0d5aa… (Linux, /etc/machine-id)
|
||||
2:lin:7c9182ff… (Linux, Vorgabe per Umgebungsvariable)
|
||||
```
|
||||
|
||||
68 Zeichen — passt in `activations.hardware_id VARCHAR(128)` ohne
|
||||
Schemaänderung. Der Doppelpunkt ist unproblematisch, die Spalte ist
|
||||
`utf8mb4_unicode_ci` und wird nur verglichen.
|
||||
|
||||
Der Hash selbst:
|
||||
|
||||
```
|
||||
sha256( "LicenseLabrador-HWID-v2" ‖ "\n" ‖ plattform ‖ "\n" ‖ quelle ‖ "\n" ‖ rohwert )
|
||||
```
|
||||
|
||||
- **Kein `MachineName`.** (1.1)
|
||||
- Die Domänenzeichenkette verhindert, dass derselbe Rohwert in anderem
|
||||
Zusammenhang wiederverwendbar ist.
|
||||
- `quelle` geht mit in den Hash: Findet der Client später eine bessere Quelle,
|
||||
ändert sich die ID bewusst und nachvollziehbar, statt zufällig.
|
||||
|
||||
Zusätzlich gehen drei neue Felder mit in die Anfrage — **nicht** in den Hash,
|
||||
nur zur Diagnose und für die Migration:
|
||||
|
||||
| Feld | Beispiel | Zweck |
|
||||
|---|---|---|
|
||||
| `hwid_version` | `2` | Formaterkennung serverseitig |
|
||||
| `hwid_source` | `machine-id` | Admin sieht, wie stabil die Bindung ist |
|
||||
| `legacy_hardware_id` | `<v1-Hash>` | Migration ohne Platzverlust (Abschnitt 4) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Quellen je Plattform
|
||||
|
||||
Reihenfolge = Priorität. Die erste Quelle, die einen nichtleeren, plausiblen Wert
|
||||
liefert, gewinnt.
|
||||
|
||||
### 3.1 Vorgabe (alle Plattformen, höchste Priorität)
|
||||
|
||||
```
|
||||
LicenseConfig.HardwareIdOverride (Code)
|
||||
LICENSELABRADOR_HWID (Umgebungsvariable)
|
||||
```
|
||||
|
||||
Quelle: `override`. Der Rohwert wird trotzdem gehasht, damit das Format
|
||||
einheitlich bleibt.
|
||||
|
||||
**Das ist der ehrliche Weg für Container und Serverbetrieb.** Heuristik kann dort
|
||||
nicht gewinnen — in einem Container gibt es keine Hardware, an die man binden
|
||||
könnte. Der Betreiber setzt einen stabilen Wert, hinterlegt ihn im
|
||||
Deployment-Geheimnis, und die Bindung ist so verlässlich wie dieser Wert. Eine
|
||||
Zeile in der systemd-Unit statt eines Ratespiels.
|
||||
|
||||
### 3.2 Windows
|
||||
|
||||
| # | Quelle | `hwid_source` |
|
||||
|---|---|---|
|
||||
| 1 | `HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid` (Registry64) | `machine-guid` |
|
||||
| 2 | Stabile physische MAC (Abschnitt 3.4) | `mac` |
|
||||
| 3 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` |
|
||||
|
||||
Unverändert zu heute — nur ohne `MachineName` im Hash.
|
||||
|
||||
### 3.3 Linux
|
||||
|
||||
| # | Quelle | `hwid_source` | Anmerkung |
|
||||
|---|---|---|---|
|
||||
| 1 | `/etc/machine-id` | `machine-id` | Von systemd bei der Installation erzeugt, überlebt Neustarts und Kernel-Updates. Die richtige Wahl auf einem echten System. |
|
||||
| 2 | `/var/lib/dbus/machine-id` | `dbus-machine-id` | Ältere Systeme ohne systemd. |
|
||||
| 3 | `/sys/class/dmi/id/product_uuid` | `dmi-uuid` | SMBIOS-UUID, echte Hardware-Bindung. **Meist nur für root lesbar** (`0400`) — Versuch in `try` einpacken, kein Fehler wenn nicht lesbar. Bei VMs vom Hypervisor gesetzt und dort stabil. |
|
||||
| 4 | Stabile physische MAC (Abschnitt 3.4) | `mac` | |
|
||||
| 5 | Erzeugte Datei (Abschnitt 3.5) | `keyfile` | |
|
||||
|
||||
Zwei Fallen bei `/etc/machine-id`, die geprüft werden müssen:
|
||||
|
||||
- **Leer oder nur Zeilenumbruch.** Auf Systemen mit `systemd-firstboot` oder in
|
||||
manchen Images existiert die Datei, ist aber leer. Muss als „nicht vorhanden"
|
||||
behandelt werden, nicht als gültiger Wert — sonst haben *alle* diese
|
||||
Installationen dieselbe ID.
|
||||
- **Der Wert `uninitialized`.** Genau diese Zeichenkette schreibt systemd, wenn
|
||||
die ID im laufenden Betrieb noch nicht festgelegt ist. Ebenfalls verwerfen.
|
||||
|
||||
```csharp
|
||||
private static bool IsPlausibleMachineId(string? v)
|
||||
=> !string.IsNullOrWhiteSpace(v)
|
||||
&& v.Trim().Length >= 16
|
||||
&& !v.Trim().Equals("uninitialized", StringComparison.OrdinalIgnoreCase)
|
||||
&& v.Trim().Trim('0').Length > 0; // nicht alles Nullen
|
||||
```
|
||||
|
||||
### 3.4 MAC-Ausweichlösung, überarbeitet
|
||||
|
||||
Die heutige Fassung nimmt `FirstOrDefault()` der sortierten Liste. Wenn eine
|
||||
Schnittstelle dazukommt oder wegfällt, kann sich damit die gewählte MAC ändern.
|
||||
Besser: **alle** gültigen MACs sortiert verketten — dann ändert sich der Wert
|
||||
nur, wenn sich die Netzwerkausstattung wirklich ändert, und nicht schon, weil
|
||||
eine Adresse hinzukommt, die vorher sortiert davor lag.
|
||||
|
||||
Stoppwortliste erweitern um: `br-`, `virbr`, `cni`, `flannel`, `cali`, `weave`,
|
||||
`zt`, `tailscale`, `ipsec`, `sit`, `gre`, `dummy`, `bond`, `macvlan`, `ovs`.
|
||||
|
||||
Zusätzlich hart ausschließen (unabhängig vom Namen):
|
||||
|
||||
- Schnittstellen mit gesetztem **„locally administered"-Bit** (zweites Bit des
|
||||
ersten Oktetts, `mac[0] & 0x02`). Genau das setzt systemd bei zufällig
|
||||
erzeugten MACs für veth und Bridges. Ein sauberer, namensunabhängiger Filter —
|
||||
und der wirksamste von allen.
|
||||
- Unter Linux zusätzlich prüfen: existiert
|
||||
`/sys/class/net/<name>/device`? Fehlt das Verzeichnis, hat die Schnittstelle
|
||||
kein physisches Gerät und ist virtuell. Das ist zuverlässiger als jede
|
||||
Namensliste.
|
||||
|
||||
```csharp
|
||||
// Namensunabhängig: zufällig erzeugte MACs tragen dieses Bit.
|
||||
private static bool IsLocallyAdministered(PhysicalAddress addr)
|
||||
{
|
||||
var b = addr.GetAddressBytes();
|
||||
return b.Length > 0 && (b[0] & 0x02) != 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Erzeugte Datei als letzte Stufe
|
||||
|
||||
`<StorageDirectory>/machine.key` — 32 Zufallsbytes, Base64, Dateirechte `0600`.
|
||||
Wird nur angelegt, wenn keine Quelle davor greift.
|
||||
|
||||
Das ist eine **Installations-** und keine Hardware-Bindung. Für einen Container
|
||||
ohne Vorgabe ist das aber die Wahrheit, und mit einem gemounteten Datenverzeichnis
|
||||
bleibt sie über Container-Neustarts stabil. `hwid_source` = `keyfile` macht dem
|
||||
Admin sichtbar, dass diese Aktivierung schwächer gebunden ist als die anderen.
|
||||
|
||||
Wichtig: Die Datei gehört ins **Datenverzeichnis**, nicht neben die
|
||||
Programmdatei. Sonst ist sie bei jedem Deployment weg.
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration v1 → v2 ohne Platzverlust
|
||||
|
||||
Der Kern: Der Client kennt **beide** IDs und schickt beide mit. Der Server zieht
|
||||
die alte Aktivierung auf die neue ID um, statt eine zweite anzulegen.
|
||||
|
||||
**Client** — `HardwareId` bekommt neben `GetHardwareId()` (v2) ein
|
||||
`GetLegacyHardwareId()`, das die heutige v1-Berechnung *unverändert* beibehält
|
||||
(inklusive `MachineName`, damit sie zu bestehenden Aktivierungen passt). Beides
|
||||
geht in die Anfrage:
|
||||
|
||||
```csharp
|
||||
hardware_id = "2:lin:41e0…",
|
||||
legacy_hardware_id = "8fa2…", // nur solange v1-Aktivierungen existieren
|
||||
hwid_version = 2,
|
||||
hwid_source = "machine-id",
|
||||
```
|
||||
|
||||
**Server** — in `LicenseService::validate`, an der Stelle der heutigen Suche
|
||||
([LicenseService.php:100](../../LicenseLabrador/server/src/LicenseService.php)):
|
||||
|
||||
```
|
||||
1. Aktivierung mit hardware_id = <v2> suchen
|
||||
→ gefunden: normaler Weg (last_seen, hostname, app_version aktualisieren)
|
||||
|
||||
2. nicht gefunden, und legacy_hardware_id ist gesetzt:
|
||||
Aktivierung mit hardware_id = <v1> suchen
|
||||
→ gefunden: UPDATE activations SET hardware_id = <v2>, hwid_version = 2,
|
||||
hwid_source = <quelle> WHERE id = …
|
||||
+ audit_log-Eintrag 'hwid_migrated'
|
||||
→ weiter wie unter 1. KEIN neuer Platz verbraucht.
|
||||
|
||||
3. weder noch: neue Aktivierung anlegen, max_activations prüfen (wie heute)
|
||||
```
|
||||
|
||||
Damit wandern alle bestehenden Windows-Installationen beim ersten Start nach dem
|
||||
Update lautlos auf v2 — niemand merkt etwas, kein Aktivierungsplatz geht
|
||||
verloren. Das `legacy_hardware_id`-Feld kann nach einer Übergangszeit (etwa zwei
|
||||
Veröffentlichungen) aus dem Client fallen.
|
||||
|
||||
### 4.1 Der lokale Cache muss einmal verworfen werden
|
||||
|
||||
Nicht übersehen: Die Hardware-ID geht in zwei weitere Berechnungen ein —
|
||||
`CalculateHmac(state, licenseKey, _hardwareId)` für die Prüfsumme in
|
||||
`LicenseResult`
|
||||
([LicenseClient.cs:272](../../LicenseLabrador/client-dotnet/LicenseLabrador.Client/LicenseClient.cs))
|
||||
und den Seed des Speicherschutzes (`:287`). Nach dem Formatwechsel schlägt
|
||||
`VerifyChecksum` für jede zwischengespeicherte Hülle fehl.
|
||||
|
||||
Das ist kein Fehler, sondern erwartet — muss aber als **Cache-Fehltreffer**
|
||||
behandelt werden (einmal online neu prüfen), nicht als
|
||||
`TamperSuspected`. Sonst sperrt sich jede bestehende Installation beim ersten
|
||||
Start nach dem Update selbst aus. Der Weg dorthin: Cache-Version im
|
||||
`LocalCacheData` mitführen (`schema_version: 2`) und einen Datensatz mit
|
||||
abweichender Version verwerfen, bevor die Prüfsumme überhaupt geprüft wird.
|
||||
|
||||
### 4.2 Schemaerweiterung
|
||||
|
||||
```sql
|
||||
ALTER TABLE activations
|
||||
ADD COLUMN hwid_version TINYINT NOT NULL DEFAULT 1 AFTER hardware_id,
|
||||
ADD COLUMN hwid_source VARCHAR(32) NULL AFTER hwid_version,
|
||||
ADD COLUMN platform VARCHAR(8) NULL AFTER hwid_source;
|
||||
```
|
||||
|
||||
Alles mit Vorgabewerten, also rückwärtskompatibel — ein alter Client, der die
|
||||
Felder nicht schickt, funktioniert unverändert weiter.
|
||||
|
||||
---
|
||||
|
||||
## 5. Umzug Windows → Linux
|
||||
|
||||
Das ist etwas anderes als die Formatmigration: hier wechselt die Maschine
|
||||
wirklich, die ID muss sich also ändern. Drei Wege, alle drei sinnvoll parallel:
|
||||
|
||||
### 5.1 Der Normalfall braucht gar nichts
|
||||
|
||||
`max_activations` ist standardmäßig **2**. Ein Windows-Entwicklungsrechner und
|
||||
ein Linux-Server passen also ohne jeden Eingriff hinein. Für den anstehenden
|
||||
Umzug ist das wahrscheinlich die ganze Antwort — die anderen beiden Punkte sind
|
||||
für den Fall danach.
|
||||
|
||||
### 5.2 Abmelden vor dem Umzug (existiert, aber nicht erreichbar)
|
||||
|
||||
`LicenseService::deactivate` löscht die Aktivierungszeile und gibt den Platz frei
|
||||
([LicenseService.php:153](../../LicenseLabrador/server/src/LicenseService.php)),
|
||||
und `LicenseClient.DeactivateAsync` ruft es auf. In ClawdDotNet ist die Methode
|
||||
aber nur über [LicenseGate.cs:39](Services/LicenseGate.cs) erreichbar und dort
|
||||
an die GUI gebunden.
|
||||
|
||||
Nachzuliefern: ein Kommandozeilenschalter am Host, damit das auch ohne
|
||||
Oberfläche geht.
|
||||
|
||||
```bash
|
||||
clawddotnet --license-deactivate
|
||||
```
|
||||
|
||||
Das braucht der kopflose Betrieb ohnehin (siehe
|
||||
[Linux-Portierung-Analyse.md](Linux-Portierung-Analyse.md), 2.6 — der
|
||||
Lizenzdialog ist ein `MessageBox`, der einen Dienst blockieren würde).
|
||||
|
||||
### 5.3 Umbinden aus der Verwaltung (fehlt noch)
|
||||
|
||||
Für den Fall, dass die alte Maschine schon weg ist: In
|
||||
`public/admin/license_detail.php` je Aktivierungszeile eine Schaltfläche
|
||||
**„Aktivierung freigeben"** (löscht die Zeile, gibt den Platz frei). Ein echtes
|
||||
„Umbinden" auf eine bekannte neue ID ist unnötig — Freigeben plus Neuaktivierung
|
||||
auf dem Zielsystem ist derselbe Vorgang mit weniger Code und einer klareren
|
||||
Spur im Prüfprotokoll.
|
||||
|
||||
Beides sollte in `audit_log` landen, mit altem und neuem Wert.
|
||||
|
||||
---
|
||||
|
||||
## 6. Zustandsspeicher härten
|
||||
|
||||
Zusammen mit dem HW-ID-Umbau, weil dieselbe Datei betroffen ist und die
|
||||
Verschlüsselung den HW-ID als Schlüsselmaterial braucht.
|
||||
|
||||
**Format** — feste Hülle statt „mal so, mal so":
|
||||
|
||||
```
|
||||
Magic "LLS2" (4 Byte) │ Nonce (12) │ Ciphertext │ GCM-Tag (16)
|
||||
```
|
||||
|
||||
- **AES-GCM**, Schlüssel abgeleitet aus HW-ID + `ProductSlug` per HKDF-SHA256.
|
||||
- Auf Windows das Ergebnis **zusätzlich** in DPAPI wickeln (Gürtel und
|
||||
Hosenträger, kostet nichts).
|
||||
- Dateirechte `0600` auf Unix.
|
||||
|
||||
**Der entscheidende Punkt: den Klartext-Rückfall beim Lesen entfernen.** Eine
|
||||
Datei, die sich nicht entschlüsseln oder nicht authentifizieren lässt, ist
|
||||
**kein Cache** — sie wird verworfen und der Client prüft online. Nicht als
|
||||
Klartext akzeptieren. Genau dieser Rückfall macht heute die
|
||||
Uhr-Rückdreh-Sperre umgehbar (1.3).
|
||||
|
||||
Einmalig weiterhin lesbar bleiben muss das alte Format (Datei ohne `LLS2`-Magic):
|
||||
einlesen, in v2 neu schreiben, fertig. Nach einer Veröffentlichung kann der Pfad
|
||||
weg.
|
||||
|
||||
### 6.1 netstandard2.0 hat kein AesGcm — Empfehlung: mehrfach zielen
|
||||
|
||||
`System.Security.Cryptography.AesGcm` gibt es erst ab .NET Core 3.0, `HKDF` erst
|
||||
ab .NET 5, `File.SetUnixFileMode` erst ab .NET 7. Das Projekt zielt heute auf
|
||||
`netstandard2.0`.
|
||||
|
||||
Zwei Wege:
|
||||
|
||||
1. **`<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>`** —
|
||||
*empfohlen*. ClawdDotNet (net10.0) zieht automatisch das net8.0-Ziel und
|
||||
bekommt `AesGcm`, `HKDF` und `File.SetUnixFileMode` ohne Umwege. Der
|
||||
netstandard2.0-Zweig bleibt für andere Abnehmer erhalten und nutzt dort
|
||||
BouncyCastle. Kosten: ein paar `#if NET8_0_OR_GREATER`-Blöcke an genau drei
|
||||
Stellen.
|
||||
2. **Durchgängig BouncyCastle** (`GcmBlockCipher`, `HkdfBytesGenerator`) — die
|
||||
Bibliothek ist mit `BouncyCastle.Cryptography` bereits als Abhängigkeit da,
|
||||
also kein neues Paket. Kein Mehrfachziel nötig, aber die Dateirechte bleiben
|
||||
ein Problem: `chmod` müsste per P/Invoke laufen.
|
||||
|
||||
Weg 1 ist sauberer, weil er nebenbei das Dateirechte-Problem löst.
|
||||
|
||||
---
|
||||
|
||||
## 7. Ablageort (1.4)
|
||||
|
||||
Auflösungskette in `LicenseConfig.StorageDirectory`, erste nutzbare gewinnt:
|
||||
|
||||
1. Explizit gesetzter Wert (ClawdDotNet setzt ihn künftig — der Host hat ohnehin
|
||||
eine eigene XDG-Auflösung).
|
||||
2. `LICENSELABRADOR_STORAGE_DIR`.
|
||||
3. Unix: `$XDG_CONFIG_HOME/<slug>/license`, sonst `$HOME/.config/<slug>/license`.
|
||||
4. Windows: `SpecialFolder.ApplicationData` wie heute.
|
||||
5. Letzter Ausweg: `<AppContext.BaseDirectory>/license`.
|
||||
|
||||
**Und in jedem Fall: nie einen leeren Pfad durchlassen.** Der heutige Code kann
|
||||
`Path.Combine("", …)` erzeugen, ohne dass es auffällt. Ein `if
|
||||
(string.IsNullOrEmpty(...)) throw` an dieser Stelle ist besser als eine
|
||||
Lizenzdatei, die im Arbeitsverzeichnis landet und beim nächsten Start nicht mehr
|
||||
gefunden wird.
|
||||
|
||||
---
|
||||
|
||||
## 8. Änderungsliste
|
||||
|
||||
### LicenseLabrador — Client
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| `HardwareId.cs` | Neuschreiben: v2-Format, Quellenkette je Plattform, `GetLegacyHardwareId()`, `HwidSource`/`Platform` als Eigenschaften, MAC-Filter (locally-administered-Bit, `/sys/class/net/*/device`), Plausibilitätsprüfung für machine-id, `machine.key`-Erzeugung |
|
||||
| `LicenseConfig.cs` | `HardwareIdOverride`, Auflösungskette für `StorageDirectory`, leeren Pfad ausschließen |
|
||||
| `StateStore.cs` | `LLS2`-Hülle, AES-GCM, `schema_version`, **Klartext-Rückfall beim Lesen entfernen**, v1-Einmalmigration, `0600` |
|
||||
| `LicenseClient.cs` | Neue Felder in `validate`/`deactivate` senden; Cache mit abweichender `schema_version` als Fehltreffer behandeln, **nicht** als `TamperSuspected` |
|
||||
| `OperatingSystemHelpers` | `RuntimeInformation.IsOSPlatform`, dazu `IsLinux()`/`IsMacOs()` |
|
||||
| `LicenseLabrador.Client.csproj` | `netstandard2.0;net8.0` |
|
||||
|
||||
### LicenseLabrador — Server
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| `sql/schema.sql` + Migrationsskript | `hwid_version`, `hwid_source`, `platform` |
|
||||
| `src/LicenseService.php` | `legacy_hardware_id` entgegennehmen; Migrationssuche (Abschnitt 4); neue Felder speichern |
|
||||
| `src/Audit.php` | Ereignisart `hwid_migrated`, `activation_released` |
|
||||
| `public/admin/license_detail.php` | Quelle/Plattform je Aktivierung anzeigen, „Aktivierung freigeben" |
|
||||
| `docs/SECURITY.md` | Aussage zur Cache-Bindung korrigieren (1.3) |
|
||||
|
||||
### ClawdDotNet
|
||||
|
||||
| Datei | Was |
|
||||
|---|---|
|
||||
| [Services/LicenseGate.cs](Services/LicenseGate.cs) | `StorageDirectory` explizit setzen; `MessageBox`/`frm_License` hinter eine Schnittstelle (`ILicensePrompt`) legen, damit der kopflose Host eine Konsolenfassung einsetzen kann |
|
||||
| Host (neu) | `--license-deactivate`, `--license-set-key`, `--license-status` |
|
||||
| `docs/Integrationsplan-WatchDog-LicenseLabrador.md` | Format v2 und Migrationsweg nachtragen |
|
||||
|
||||
---
|
||||
|
||||
## 9. Testplan
|
||||
|
||||
Das Wichtigste zuerst — die Fälle, die heute schiefgehen würden:
|
||||
|
||||
| Fall | Erwartung |
|
||||
|---|---|
|
||||
| Rechner umbenennen | **ID unverändert** (Kern von 1.1) |
|
||||
| Container zweimal starten, `/etc/machine-id` im Abbild | beide Male dieselbe ID |
|
||||
| Container ohne `machine-id`, Datenverzeichnis gemountet | ID über Neustarts stabil, `hwid_source = keyfile` |
|
||||
| Container ohne `machine-id`, **ohne** Mount | ID wechselt — muss so sein, und im Protokoll erkennbar |
|
||||
| `LICENSELABRADOR_HWID` gesetzt | gewinnt gegen alles, `hwid_source = override` |
|
||||
| `/etc/machine-id` leer bzw. `uninitialized` | wird verworfen, nächste Quelle greift |
|
||||
| Docker-Bridge und veth vorhanden, keine machine-id | MAC-Wahl ignoriert sie, ID über Neustart stabil |
|
||||
| Bestehende v1-Windows-Aktivierung, Client aktualisiert | Zeile wird auf v2 umgeschrieben, `max_activations` unverändert, Prüfprotokolleintrag |
|
||||
| v1-Cache-Datei nach dem Update | einmal online geprüft, dann v2-Cache — **kein** `TamperSuspected` |
|
||||
| `state.dat` von Hand mit `max_seen_time = 0` | Datei wird verworfen, Uhr-Rückdreh-Sperre bleibt wirksam |
|
||||
| systemd-Dienst ohne `HOME` | Ablageort auflösbar, keine Datei im Arbeitsverzeichnis |
|
||||
| Alter Client gegen neuen Server | funktioniert unverändert (Felder haben Vorgabewerte) |
|
||||
| Neuer Client gegen alten Server | funktioniert, Zusatzfelder werden ignoriert |
|
||||
|
||||
Die letzten beiden Zeilen sind nicht optional: Client und Server werden nicht
|
||||
gleichzeitig ausgerollt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Aufwand
|
||||
|
||||
| Block | PT |
|
||||
|---|---:|
|
||||
| `HardwareId` v2 samt Quellenkette, MAC-Filter, `machine.key` | 2–3 |
|
||||
| Client mehrfach zielen + `StateStore`-Härtung | 2–3 |
|
||||
| Server: Migrationssuche, Schema, Prüfprotokoll, Verwaltungsansicht | 2–3 |
|
||||
| ClawdDotNet: `ILicensePrompt`, Lizenz-Kommandozeile | 1–2 |
|
||||
| Tests (Container-Fälle brauchen echtes Docker) und Abnahme | 1–2 |
|
||||
| **Summe** | **8–13** |
|
||||
|
||||
Das ist mehr als die 3–5 PT, die in der Linux-Analyse für „Lizenz" standen —
|
||||
weil dort nur die Plattformverträglichkeit gerechnet war. Die Punkte 1.1 und 1.3
|
||||
sind bestehende Fehler, die unabhängig vom Umzug behoben werden sollten; sie
|
||||
machen den Unterschied aus.
|
||||
|
||||
Der Block ist **unabhängig vom übrigen Linux-Umzug** und kann sofort beginnen —
|
||||
er hängt an keiner der offenen GUI-Entscheidungen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Was ich anders machen würde als heute — kurz begründet
|
||||
|
||||
Drei Entscheidungen im Vorschlag verdienen eine Begründung, weil sie vom
|
||||
bisherigen Ansatz abweichen:
|
||||
|
||||
**Rechnername raus.** Er ist der Grund, warum die heutige Bindung fragiler ist
|
||||
als nötig, und er trägt nichts bei, was `activations.hostname` nicht schon
|
||||
festhält. Eine Bindung, die bei einer Umbenennung bricht, bindet nicht an
|
||||
Hardware, sondern an eine Konfiguration.
|
||||
|
||||
**Vorgabe per Umgebungsvariable statt besserer Heuristik für Container.** Man
|
||||
kann eine Container-Umgebung nicht sinnvoll erraten — es gibt dort keine
|
||||
Hardware. Jede zusätzliche Heuristik verschiebt nur, wo es falsch wird. Eine
|
||||
explizite Vorgabe ist ein bewusster Betreiberentscheid, in der Unit-Datei
|
||||
sichtbar, im Prüfprotokoll nachvollziehbar.
|
||||
|
||||
**Kein Klartext-Rückfall, auch nicht „zur Sicherheit".** Der heutige Rückfall
|
||||
sollte Robustheit bringen, kostet aber genau die Eigenschaft, für die der Cache
|
||||
existiert. Ein verworfener Cache bedeutet: einmal online prüfen. Das ist der
|
||||
mildere Schaden — und wer keine Verbindung hat, hat immer noch die
|
||||
Offline-Gnadenfrist aus der signierten Hülle, die von dieser Datei nicht abhängt.
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
# Roadmap
|
||||
|
||||
Zentrale Liste aller offenen Vorhaben. Sie löst die beiden „Vorgeschlagene
|
||||
Reihenfolge"-Abschnitte in der [Bestandsaufnahme](Bestandsaufnahme-2026-07.md) und im
|
||||
[Konzepte-Dokument](Konzepte-Backup-Finanz-Analyse.md) ab — die bleiben als Befund bzw.
|
||||
Konzept bestehen, gepflegt wird nur noch hier.
|
||||
|
||||
Kürzel (S4, K2, T4, F-A1, …) verweisen auf die Bestandsaufnahme.
|
||||
|
||||
---
|
||||
|
||||
## A — Beschlossen (aus dem OpenAlice-Vergleich, Juli 2026)
|
||||
|
||||
Hintergrund: Konzeptvergleich mit [OpenAlice](https://github.com/TraderAlice/OpenAlice)
|
||||
(AGPL-3.0 — Konzepte übernehmen ja, Code nein). Übernommen werden Taskboard,
|
||||
Staging-Freigabe, Audit-Log und das Skill-Modell. Die Inbox-Idee entfällt zugunsten
|
||||
der geplanten Matrix-Migration (A5).
|
||||
|
||||
### A1 — Taskboard
|
||||
|
||||
Aufgaben als Markdown-Dateien mit YAML-Frontmatter im `SharedWorkspace`:
|
||||
`title`, `status` (`backlog | todo | in_progress | done | canceled`), `priority`,
|
||||
`assignee`, optional `when` (`at` | `every` | `cron` **mit Zeitzone**).
|
||||
|
||||
- **Scanner statt Delay-Schleifen**: Ein Takt (~60 s) prüft, was fällig ist.
|
||||
Persistiert werden nur Last-Fired-Marker — ein fehlgeschlagener Lauf bleibt der
|
||||
einzige Versuch für diesen Termin, kein automatischer Retry-Sturm.
|
||||
- **Assignee bestimmt die Ausführung**: `@new` = frischer Lauf ohne Historie,
|
||||
`@<agent>` = bestehender Agent mit seinem Kontext, `@human` = wartet auf uns.
|
||||
Das ersetzt das implizite `UseChatContext`-Flag (T7) durch eine explizite Angabe
|
||||
am Auftrag.
|
||||
- **Agenten-Tool**: `task_create`, `task_list`, `task_update`, `task_comment`.
|
||||
Agent-zu-Agent-Delegation läuft künftig über Tasks statt über rekursives
|
||||
`send_message`.
|
||||
- **Migration**: Die improvisierten `coordination/*.md`-Dateien der Agenten
|
||||
(task_*, status_*, broadcast) gehen im Taskboard auf.
|
||||
|
||||
**Detailbauplan** (aus dem Fünf-Repo-Vergleich, Juli 2026 beschlossen):
|
||||
|
||||
- Status zusätzlich mit **`in_review`**; im Frontmatter **`require_approval`**
|
||||
(Task gilt erst nach Review als done) und **`acceptance`** (Abnahmekriterien,
|
||||
gegen die das Ergebnis geprüft wird).
|
||||
- **Task-Typen `approval` und `human_input`** — ein Mensch ist einfach ein
|
||||
Assignee; seine Antwort ist das Task-Ergebnis und Input für Folgetasks.
|
||||
- **Atomares Claiming**: Die DB verhindert, dass zwei Läufe denselben Task
|
||||
ziehen. Der Scanner arbeitet mit **Claim-before-run** (at-most-once — ein
|
||||
doppelter Tick findet den Claim bereits vergeben) und
|
||||
**Startup-Reconciliation**: Beim Start wird Soll (Frontmatter) gegen Ist
|
||||
(Marker/Claims) abgeglichen, verpasste Läufe werden erkannt statt still
|
||||
übersprungen.
|
||||
- **`blocked_by`-Abhängigkeiten** mit Auto-Dispatch: Wird der letzte Blocker
|
||||
fertig, wird der wartende Task automatisch angestoßen. Meldet ein Agent einen
|
||||
Blocker, fällt der Task und der Zuständige (Lead/Benutzer) wird benachrichtigt
|
||||
(**Blocker-Eskalation**).
|
||||
- **Reopen-/Feedback-Semantik**: Ergebnis + Kritik gehen per `task_comment` an
|
||||
denselben Agenten zur Nachbesserung zurück, statt einen neuen Task von vorn
|
||||
zu beginnen.
|
||||
|
||||
Damit erledigt oder aufgegangen:
|
||||
|
||||
| Punkt | Warum |
|
||||
|---|---|
|
||||
| F-A5 Task-Queue | das Taskboard **ist** die Queue |
|
||||
| F-A4 Run-Historie | Läufe werden am Task verknüpft und persistiert |
|
||||
| B8 Rekursion `send_message` | Delegation über Tasks ist strukturell zyklenfrei |
|
||||
| B6 `Task.Delay`-Überlauf | Scanner-Modell kennt keine langen Delays |
|
||||
| B7 Cron in Lokalzeit | Frontmatter-`when` ist zeitzonen-explizit |
|
||||
| T7 `RunAsync` vs. `ChatAsync` | Assignee-Semantik beantwortet die Frage |
|
||||
|
||||
Verzahnung: Das Marktkalender-Flag (`onlyWhenMarketOpen`, siehe C1) gehört ins
|
||||
Frontmatter, nicht in einen eigenen Mechanismus.
|
||||
|
||||
Konzept-Doc: [Taskboard-Konzept](Taskboard-Konzept.md) (Dateiformat,
|
||||
Wahrheitsaufteilung Datei/DB, Scanner-Verhalten, Invarianten, Migration).
|
||||
|
||||
### A2 — Staging-Freigabe für irreversible Aktionen (F-A1 + S4)
|
||||
|
||||
Konzept-Doc: [Staging-Konzept](Staging-Konzept.md).
|
||||
|
||||
Irreversible Aktionen (Mail senden, X posten, DB-Schreibzugriff, Datei löschen,
|
||||
perspektivisch Orders) werden **gestaged statt ausgeführt**: Vorschlag → Review im
|
||||
Hauptfenster → Freigabe/Ablehnung. Pro Tool/Aktion konfigurierbar:
|
||||
`auto | approve | deny`.
|
||||
|
||||
Das bisher wirkungslose `PermissionGate` (S4) wird dabei zum zentralen
|
||||
Durchsetzungspunkt ausgebaut: Policy-Prüfung, Staging-Entscheidung und Audit-Hook
|
||||
(A3) an einer Stelle statt ad-hoc in jedem Tool. S4 wird nicht separat bearbeitet,
|
||||
sondern geht hier auf.
|
||||
|
||||
Ergänzungen (Juli 2026 beschlossen):
|
||||
|
||||
- **Plan-Freeze**: Freigegeben wird ein eingefrorener, konkreter Aufruf — Tool,
|
||||
Aktion und exakte Argumente zum Zeitpunkt des Stagings. Ausgeführt wird genau
|
||||
das Eingefrorene; jede nachträgliche Änderung ist eine neue Freigabe.
|
||||
- **Approval-Records**: Jede Entscheidung (Freigabe wie Ablehnung) wird als
|
||||
Datensatz im Audit-Log (A3) verankert — wer, wann, was, mit welchem Ergebnis.
|
||||
|
||||
Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen Vorschlag
|
||||
erzeugen, keine Ausführung.
|
||||
|
||||
### A3 — Audit-Log (F-A2)
|
||||
|
||||
Konzept-Doc: [Audit-Konzept](Audit-Konzept.md).
|
||||
|
||||
Jeder Tool-Aufruf wird protokolliert: Agent, Lauf/Session, Zeitstempel, Argumente,
|
||||
Ergebnis-Status. Append-only (JSONL oder SQLite-Tabelle auf dem vorhandenen
|
||||
`SqliteStorage`).
|
||||
|
||||
Designregeln (aus dem OpenAlice-Provenance-Konzept):
|
||||
|
||||
- Herkunft wird **von der Engine gestempelt**, nie vom Agenten behauptet.
|
||||
- Einträge sind unveränderlich; Korrekturen sind neue Einträge.
|
||||
- Unbekannte Herkunft wird als unbekannt markiert, nicht geraten.
|
||||
- Worker-Typ (Modell/Engine) und verantwortliche Session sind getrennte Begriffe.
|
||||
|
||||
Ergänzung (Juli 2026 beschlossen) — **Receipts**: Jeder abgeschlossene Task/Lauf
|
||||
erhält einen Abschluss-Beleg mit Ergebnis-Verweis, Schritten, Tokens und Kosten
|
||||
(Verknüpfung `RunUsage` ↔ Task). Damit fällt C7 („Kosten pro Ergebnis")
|
||||
weitgehend als Abfallprodukt ab.
|
||||
|
||||
Das Audit-Log ist zugleich das Fundament für das Ergebnisregister (C7) und die
|
||||
Tool-Fehlerquote aus der Leistungsanalyse.
|
||||
|
||||
### A4 — Skill-/Toolset-Modell (ersetzt T6)
|
||||
|
||||
Dreischichtig statt „alles immer im System-Prompt":
|
||||
|
||||
1. **Dauerhafter Kern** — Identity, Soul, unveränderliche Regeln. Schlank, damit der
|
||||
Prompt-Cache (T1) stabil bleibt.
|
||||
2. **Nachladbare Skills/Toolsets** — fachliche Abläufe und selten genutzte Tools
|
||||
werden erst auf Anforderung geladen (`list_toolsets` → `load_toolset`).
|
||||
3. **Selbstkorrigierende Tool-Fehler** — Fehlermeldungen nennen die gültigen
|
||||
Parameter/Aktionen, statt das Modell raten zu lassen.
|
||||
|
||||
Mechanik (Juli 2026 beschlossen, nach GoClaw-Vorbild): Skills liegen als
|
||||
`SKILL.md` mit Frontmatter (`name`, `description`) im Instanz- bzw.
|
||||
Agenten-Verzeichnis. Bei wenigen Skills werden die Kurzbeschreibungen inline in
|
||||
den Prompt eingebettet, bei vielen gibt es stattdessen ein `skill_search`-Tool.
|
||||
Änderungen an Skill-Dateien werden per Hot-Reload übernommen.
|
||||
|
||||
Dazu Hermes' Selbstverbesserungs-Idee: **Agenten dürfen Skills aus Erfahrung
|
||||
selbst schreiben** (das AgentEditor-Tool ist die Vorstufe). Wichtig: Ein Skill
|
||||
ist Prompt-Input — agentengeschriebene Skills werden erst nach Freigabe (A2)
|
||||
aktiv, sonst wäre das ein Injection-Kanal in künftige Läufe.
|
||||
|
||||
### A5 — Matrix/Element-Migration
|
||||
|
||||
Beschlossene Richtung: Die Kommunikation (Benachrichtigungen, Berichte, Chat mit
|
||||
Agenten) wird auf Element/Matrix umgestellt.
|
||||
|
||||
- Ersetzt die im OpenAlice-Vergleich erwogene Inbox — Berichte landen in
|
||||
Matrix-Räumen.
|
||||
- Der Tool-Kandidat **Notify** entfällt und geht hierin auf.
|
||||
- Betroffen: Telegram-Tool (Rolle klären), WebView-Chat (bleibt er Haupt-UI?),
|
||||
Streaming K4 (Matrix streamt nicht — nach dieser Entscheidung neu bewerten).
|
||||
|
||||
Scope ist noch unbestimmt — braucht ein eigenes Konzept-Doc, bevor es in die
|
||||
Reihenfolge eingeordnet wird.
|
||||
|
||||
### A6 — MySQL-Replikations-Spiegel (optional)
|
||||
|
||||
Beschlossen Juli 2026. **SQLite bleibt die einzige Wahrheit** — gearbeitet wird
|
||||
ausschließlich auf der Instanz-DB. MySQL ist ein reiner, nachgelagerter Spiegel:
|
||||
Er empfängt nur INSERT/UPDATE/DELETE vom Replikator; die App liest im Betrieb
|
||||
**nie** daraus. Einziger Lesezweck: Wiederherstellung, falls die SQLite korrupt
|
||||
ist — daneben steht der Spiegel externen Auswertungen (Dashboards, Ad-hoc-SQL)
|
||||
offen, ohne die Agenten-Maschine zu berühren.
|
||||
|
||||
Leitplanken:
|
||||
|
||||
- **Outbox-Muster** an der vorhandenen Schreib-Warteschlange von `SqliteStorage`:
|
||||
Replikations-Einträge lokal puffern, idempotente Upserts nach MySQL.
|
||||
Nie blockierend — ist MySQL nicht erreichbar, staut die Outbox und holt auf.
|
||||
- Mehrere Instanzen replizieren in denselben Spiegel; Zeilen tragen `instance_id`.
|
||||
- **Restore-Pfad (Spiegel → frische SQLite) muss existieren und getestet sein** —
|
||||
gleiche Regel wie beim Backup: ein ungeprüftes Restore ist eine Vermutung.
|
||||
- Kein Koordinationspunkt: Claiming, Locks, Taskboard-Zustand bleiben lokal.
|
||||
Die Regel „nur der Replikator schreibt, niemand liest im Betrieb" gehört ins
|
||||
Konzept-Doc.
|
||||
- Sicherheit: TLS zur Datenbank, Zugangsdaten über `SecretProtector`;
|
||||
Output-Scrubbing der Tool-Ergebnisse wird wichtiger, weil Kontext-Daten
|
||||
künftig auch im Spiegel liegen.
|
||||
- Ergänzt das ZIP-Backup, ersetzt es nicht — Identity/Soul, Settings, Workspace
|
||||
und Telegram-Session bleiben Sache des Instanz-Backups.
|
||||
|
||||
**Voraussetzung — Historie-Umzug:** `ChatHistory.json` zieht in die Instanz-DB um
|
||||
(aktive Tabelle + Archiv-Tabelle mit FTS5-Volltextindex und `history_search`-Tool,
|
||||
siehe K6 in Abschnitt B). Das löst
|
||||
nebenbei B10 (O(n²)-Schreiblast) und gibt K6 seine Form; erst danach schützt der
|
||||
Spiegel auch die Historie. Zeitlich passt A6 zu dem Server, der ggf. mit A5
|
||||
(Matrix) ohnehin dazukommt.
|
||||
|
||||
---
|
||||
|
||||
## B — Offen aus der Bestandsaufnahme
|
||||
|
||||
| Punkt | Was | Stand |
|
||||
|---|---|---|
|
||||
| T4 | Proaktiv statt reaktiv kompaktieren | offen, unverändert |
|
||||
| K2-Rest | Untrusted Content als Daten rahmen (`<untrusted_content>`) | A2 nimmt die Schärfe; die Rahmung selbst bleibt nötig |
|
||||
| K4 | Streaming | **zurückgestellt** bis A5 entschieden ist |
|
||||
| K6 | Historie **archivieren + durchsuchbar machen** (FTS5-Index, `history_search`-Tool) statt nur rotieren | beschlossen; Teil des Historie-Umzugs (Voraussetzung von A6) |
|
||||
| Memory-Flush vor Compaction | Bevor der ContextCompactor zusammenfasst, bekommt der Agent ein eng begrenztes Fenster, Dauerhaftes per `memory_store` zu sichern — sonst wirft die Compaction Wissen weg | beschlossen (GoClaw-Muster) |
|
||||
| Memory-Auto-Injection | Relevante Memory-Abstracts werden automatisch eingeblendet (Relevanzschwelle, Deckel ~200 Tokens), **in die Nutzernachricht, nie in den System-Prompt** (Prompt-Cache T1) | beschlossen; löst den offenen Punkt „Automatische Einblendung" im [Memory-Konzept](Memory-Konzept.md) |
|
||||
| Output-Scrubbing | Bekannte Secret-Werte (Register des `SecretProtector`) werden zentral aus **allen** Tool-Ergebnissen maskiert, bevor sie in Kontext, Historie oder Spiegel (A6) gelangen | beschlossen; schließt die Lücke, die S3 nur für URLs schloss |
|
||||
| Hygiene-Paket | B9 (`index_Count`-Race), B11/T8 (`max_tokens` setzen), B13 (`instanceId`-Inkonsistenz), F-A6-Rest (UI zum Setzen/Rotieren der Secrets) | Kleinbugs, in einem Aufwasch. B10 geht im Historie-Umzug (A6) auf |
|
||||
|
||||
Erledigt seit der letzten Fortschreibung: B4 vollständig (Preise kommen live vom
|
||||
`/models`-Endpunkt, unbekannte Modelle werden sichtbar gemeldet).
|
||||
|
||||
### B-DC — Deploymentcenter-Anbindung
|
||||
|
||||
WatchDog und LicenseLabrador sind durch das
|
||||
[Deploymentcenter](Deploymentcenter-Integration.md) ersetzt: eine Adresse, ein Token,
|
||||
und dazu Update-Prüfung, Fehler-Stream und Bugtracker. Watchdog läuft wieder pro
|
||||
Instanz (ein Monitor je Instanz, mit Gesundheitsprüfungen und angekündigtem Ende).
|
||||
|
||||
Offen:
|
||||
|
||||
| Punkt | Was | Bemerkung |
|
||||
|---|---|---|
|
||||
| DC1 | Oberfläche „Fehler melden" | Client vorhanden, Schaltfläche fehlt |
|
||||
| DC2 | Agenten-Tool für den Bugtracker | macht den Claim/Lease-Workflow des Deploymentcenters nutzbar |
|
||||
| DC3 | Release-Strecke: `pack-and-deploy` mit `<Version>` aufrufen, dann `update-agent` | ohne Release hat die Update-Prüfung nichts zu finden. Versionsnummer selbst ist erledigt (`Directory.Build.props` → `ReleaseInfo`) |
|
||||
| DC4 | SDK als Git-Submodul unter `external/` statt Cross-Repo-Pfad | betrifft auch die CI |
|
||||
| DC5 | Betreiber: Evaluator-Cron einrichten, Token ausstellen, `parent_source` pflegen | **ohne den Cron ist die Überwachung wertlos** |
|
||||
|
||||
---
|
||||
|
||||
## C — Offen aus dem Finanz-/Analyse-Konzept
|
||||
|
||||
Punkte 1–2 von dort (atomares Schreiben, Backup/Restore inkl. UI) sind umgesetzt.
|
||||
|
||||
| # | Was | Bemerkung |
|
||||
|---|---|---|
|
||||
| C1 | Marktkalender (`onlyWhenMarketOpen` + `MarketCalendar`-Tool) | Scheduler-Teil gehört ins Taskboard-Frontmatter (A1) |
|
||||
| C2 | `Indicators`-Tool — deterministische Berechnung | Qualität hoch, Tokens runter |
|
||||
| C3 | Datenaktualität erzwingen (`maxAgeSeconds`) | |
|
||||
| C4 | Termine & Fundamentaldaten (Earnings, EDGAR, Wirtschaftskalender) | |
|
||||
| C5 | Bestandsregister (Positionen) | Grundlage für C7/C8 |
|
||||
| C6 | Nachrichten-Entdopplung (`SeenItems`) | |
|
||||
| C7 | Ergebnisregister (Stufe 2, „Kosten pro Ergebnis") | fällt weitgehend aus den A3-Receipts ab |
|
||||
| C8 | Falsifizierbare Aussagen + Auflösung, Brier-Score (Stufe 3) | braucht C5, C7 und einen Auflösungs-Task (A1) |
|
||||
|
||||
---
|
||||
|
||||
## D — Toolkandidaten (unbeschlossen)
|
||||
|
||||
WebSearch, Http (generisch mit Allowlist), Shell (sandboxed), Git, Vision.
|
||||
Notify ist gestrichen — geht in A5 auf.
|
||||
|
||||
---
|
||||
|
||||
## Vorgeschlagene Reihenfolge
|
||||
|
||||
| # | Vorhaben | Begründung |
|
||||
|---|---|---|
|
||||
| 1 | A1 Taskboard | Fundament; löst sechs bestehende Punkte auf einmal |
|
||||
| 2 | A3 Audit-Log | klein, sofort nützlich; muss vor A2 da sein, damit Freigaben protokolliert werden |
|
||||
| 3 | A2 Staging-Freigabe | größter Sicherheitsgewinn; Voraussetzung für unbeaufsichtigten Betrieb |
|
||||
| 4 | C1 Marktkalender | spart sofort Kosten; nutzt A1-Frontmatter |
|
||||
| 5 | A4 Skills/Toolsets | Token-Hebel, Cache-stabil |
|
||||
| 6 | C2 Indicators | Qualität + Kosten |
|
||||
| 7 | C7 + C8 Ergebnisregister, Aussagen | das eigentliche Leistungsmaß; braucht A3 |
|
||||
| — | Hygiene-Paket (B) | zwischendurch, unabhängig |
|
||||
| — | Historie-Umzug in die Instanz-DB | löst B10 + K6; Voraussetzung für A6 |
|
||||
| — | A6 MySQL-Spiegel | nach dem Historie-Umzug; natürliches Zuhause auf dem A5-Server |
|
||||
| — | A5 Matrix | eigenes Konzept-Doc zuerst; Scope klären, dann einordnen |
|
||||
|
||||
Leitlinie der Reihung: erst Nachvollziehbarkeit und Kontrolle (Audit, Staging),
|
||||
dann Fähigkeiten — ein Agent, der unbeaufsichtigt läuft, braucht zuerst Bremsen,
|
||||
dann PS.
|
||||
|
||||
---
|
||||
|
||||
## Umsetzung mit Opus 4.6 — Einstufung
|
||||
|
||||
Die Entwicklung erfolgt mit Opus 4.6. Die meisten Vorhaben sind damit gut
|
||||
machbar, sofern die hier notierten Vorgaben mitgegeben werden. Zwei Stellen
|
||||
berühren Nebenläufigkeits-Invarianten bzw. Engine-Querschnitte — sie sind für
|
||||
Opus 5 / Fable markiert oder durch eine Architektur-Vorgabe entschärft.
|
||||
|
||||
| Vorhaben | Einstufung | Vorgabe / Begründung |
|
||||
|---|---|---|
|
||||
| A1: Dateiformat, Frontmatter-Parsing, `task_*`-Tool, Migration | 4.6 | klar spezifizierbar, gut testbar |
|
||||
| A1: **Scanner-Kern** (atomares Claiming, Auto-Dispatch, Reconciliation) | ⚠️ **Opus 5 / Fable** | At-most-once-Semantik, Claim-CAS und das Zusammenspiel mit den seit B2 serialisierten Chat-Läufen sind genau die Fehlerklasse, die hier schon einmal schiefging. Falls doch 4.6: erst Konzept-Doc, Umsetzung strikt dagegen, Property-Tests für die Invarianten („nie zwei Claims auf einen Task", „kein Dispatch bei offenem Blocker", „doppelter Tick = ein Lauf") |
|
||||
| A2: Gate, Policy, Staging-Queue, Review-UI, Approval-Records | 4.6 | mit der folgenden Architektur-Vorgabe |
|
||||
| A2: **Fortsetzung nach Freigabe** | 4.6 nur mit Vorgabe | **Kein pausierter, im Speicher gehaltener Lauf.** Vorgabe: Der Lauf endet beim Staging regulär — das Tool liefert „zur Freigabe vorgelegt" als Ergebnis, der Agent schließt ab. Die Freigabe erzeugt einen Folge-Task (A1), der den Agenten mit dem **eingefrorenen** Aufruf weckt. Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser Vereinfachung unnötig |
|
||||
| A3: Audit-Log + Receipts | 4.6 | append-only, klares Schema, keine Nebenläufigkeitsfallen |
|
||||
| A4: Skills | 4.6 | `FileSystemWatcher` mit Debounce (~500 ms); agentengeschriebene Skills erst nach Freigabe aktiv (siehe A4) |
|
||||
| Memory-Flush vor Compaction | 4.6 mit Anleitung | Harte Grenzen: max. 3–5 Schritte, einziges Tool `memory_store`, Timeout, günstiges Modell (wie T3), höchstens einmal je Compaction-Zyklus. Vorsicht: Der ContextCompactor hatte B1/B14 — die bestehenden Paarungs-Tests müssen unverändert grün bleiben |
|
||||
| Memory-Auto-Injection | 4.6 | in die Nutzernachricht, nie in den System-Prompt (sonst verfällt der Prompt-Cache T1); Deckel ~200 Tokens |
|
||||
| Output-Scrubbing | 4.6 | ein zentraler Filter an der Stelle, wo Tool-Ergebnisse in den Kontext gelangen (`ExecuteToolCallAsync`); Werte aus dem Secret-Register |
|
||||
| Historie-Umzug + FTS5 + `history_search` | 4.6 | Migration nur nach frischem Backup; alte JSON-Dateien erst nach verifiziertem Import löschen |
|
||||
| A6: MySQL-Spiegel | 4.6 mit Anleitung | Outbox mit Wasserzeichen, idempotente Upserts, nie blockieren; der getestete Restore-Pfad ist Teil der Definition of Done |
|
||||
| C1 Marktkalender, C2 Indicators | 4.6 | reine Fachlogik, deterministisch testbar |
|
||||
|
||||
Generell: Neue Subsysteme (Scanner, Staging, Audit, Replikator) kommen mit Tests
|
||||
nach der [Teststrategie](Teststrategie.md) — die Invarianten-Tests sind bei den
|
||||
markierten Punkten kein Nice-to-have, sondern die Absicherung dafür, dass ein
|
||||
schwächeres Modell sie umsetzen darf.
|
||||
@@ -0,0 +1,544 @@
|
||||
# Rocket.Chat und Nextcloud — Konzept
|
||||
|
||||
Zwei neue Tools, ein gemeinsamer Zweck: **Rocket.Chat** wird der Ort, an dem wir mit den
|
||||
Agenten reden; **Nextcloud** wird der Ort, an dem die Agenten uns Ergebnisse hinlegen.
|
||||
Der typische Ablauf ist die Kombination aus beidem — „schreib mir die Auswertung und leg
|
||||
sie in die Cloud" im Chat, Datei in Nextcloud, Link zurück in den Chat.
|
||||
|
||||
Dieses Dokument prüft die Machbarkeit, legt den Schnitt fest und benennt die Punkte, die
|
||||
vor der Umsetzung entschieden werden müssen. **Es ist noch keine Umsetzungsfreigabe.**
|
||||
|
||||
Verwandt: [Taskboard-Konzept](Taskboard-Konzept.md) (Scanner/Wake), [Staging-Konzept](Staging-Konzept.md)
|
||||
(Freigaben), [Audit-Konzept](Audit-Konzept.md), [Roadmap](Roadmap.md) (A5 — siehe Konflikt unten).
|
||||
|
||||
---
|
||||
|
||||
## 0 — Kurzfassung des Befunds
|
||||
|
||||
| Frage | Antwort |
|
||||
|---|---|
|
||||
| Ist es umsetzbar? | Ja, beides. Ohne neue Architektur — die vorhandenen Bausteine tragen. |
|
||||
| Braucht es Änderungen am Core? | Für Phase 1: **nein**, nur zwei neue Tool-Projekte + Staging-Defaults. Für den automatischen Rückweg (Antwort landet ohne Zutun des Modells im Raum) und den Notfallkanal: ja, zwei kleine Core-Ergänzungen. |
|
||||
| Größtes technisches Risiko | Nicht die API — sondern **Antwort-Schleifen zwischen Agenten** und **Kosten durch zu häufiges Wecken**. |
|
||||
| Größte Konzeptkollision | Roadmap **A5** sieht Matrix/Element für genau diesen Zweck vor. Rocket.Chat ersetzt A5, oder wir haben zwei Chat-Wege. Muss entschieden werden. |
|
||||
| „Agent erstellt Dokument direkt über die Nextcloud-API" | So nicht. Nextcloud hat keine API, die Inhalte *erzeugt*. Der Weg ist: Datei lokal im Workspace erzeugen → hochladen. Für PDF/XLSX kann **Collabora als Konverter** dienen — das ist der elegante Teil, siehe 5.4. |
|
||||
|
||||
---
|
||||
|
||||
## 1 — Was schon da ist (und deshalb nicht neu gebaut wird)
|
||||
|
||||
Der Rückkanal von außen nach innen existiert vollständig:
|
||||
|
||||
```
|
||||
TaskScanner (60-s-Takt)
|
||||
└─ Task vom Typ tool_job
|
||||
└─ EngineTaskDispatcher.DispatchToolJobAsync
|
||||
└─ IToolJobProvider.ExecuteJobAsync ← kein LLM, kostenlos
|
||||
└─ ToolJobResult.Wake(text) ← nur wenn wirklich etwas da ist
|
||||
└─ AgentEngine.ChatAsync ← hier erst kostet es Tokens
|
||||
```
|
||||
|
||||
Das Telegram-Tool nutzt genau das (`telegram_poll`). **Rocket.Chat bekommt dieselbe
|
||||
Bauform** — `rocketchat_poll`. Damit gilt automatisch:
|
||||
|
||||
- Zustand (letzter gesehener Zeitpunkt) über `IStateStore`, überlebt Neustarts.
|
||||
- Ein Takt ohne neue Nachricht kostet nichts.
|
||||
- Kein eigener Thread, kein eigener Scheduler, keine Sonderbehandlung beim Start.
|
||||
- Jeder Tool-Aufruf läuft ohnehin durch `StagingGate` (A2) und Audit (A3).
|
||||
|
||||
Ebenso vorhanden und wiederverwendbar:
|
||||
|
||||
- **Pro-Agent-Konfiguration** (`AgentConfig.Tools["RocketChat"]`) — jeder Agent bekommt
|
||||
seine eigenen Zugangsdaten, ohne dass ein Agent die eines anderen sehen kann.
|
||||
- **`ConfigSecrets`** verschlüsselt Felder nach Namen (`token`, `password`, `apikey` …) —
|
||||
ein Feld namens `authToken` bzw. `appPassword` ist automatisch geschützt.
|
||||
- **Workspace-Prefixe** `personal:` / `shared:` samt Path-Traversal-Prüfung — aus dem
|
||||
FTP-Tool wortgleich übernehmbar für Nextcloud-Uploads.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Rocket.Chat: Machbarkeit
|
||||
|
||||
Geprüft gegen die REST- und Realtime-API von Rocket.Chat. Alles Folgende ist
|
||||
Standardfunktion einer selbstgehosteten Instanz, kein Enterprise-Feature.
|
||||
|
||||
### 2.1 Identität — ein echter Benutzer je Agent
|
||||
|
||||
Die Anforderung „jeder Agent mit eigenem Benutzer, in Gruppen und im Direktkontakt" ist
|
||||
der richtige Ansatz und wird von Rocket.Chat direkt unterstützt.
|
||||
|
||||
- Admin legt je Agent einen Benutzer an: `POST /api/v1/users.create`
|
||||
(`{ name, username, email, password, roles: ["bot"] }`).
|
||||
- Die Rolle **`bot`** ist wichtig: Sie markiert den Benutzer als Maschine (relevant für
|
||||
Schleifenschutz, siehe 2.5) und wird in neueren Versionen bei der Sitzplatzzählung
|
||||
nicht als normaler Nutzer gewertet. *Gegen die eigene Version zu prüfen.*
|
||||
- Für jeden Agenten wird ein **Personal Access Token** erzeugt
|
||||
(`POST /api/v1/users.generatePersonalAccessToken`, oder im Konto des Benutzers).
|
||||
Dauerhaft gültig, einzeln widerrufbar — deutlich besser als Login mit Passwort, weil
|
||||
kein Session-Ablauf und keine gespeicherten Passwörter im Spiel sind.
|
||||
- Authentifiziert wird jeder Aufruf über zwei Header: `X-Auth-Token` und `X-User-Id`.
|
||||
|
||||
**Entscheidung, die ich empfehle:** Das Anlegen der Benutzer ist **kein Agenten-Tool**.
|
||||
Es ist eine einmalige Einrichtungsfunktion in der WinForms-Oberfläche
|
||||
(Instanz-Einstellungen → Rocket.Chat → „Agenten-Benutzer anlegen"). Sonst müsste ein
|
||||
Agent ein Admin-Token halten — und ein Admin-Token in Reichweite einer Prompt-Injection
|
||||
ist genau das, was A2 verhindern soll. Der Admin-Token liegt in der **Instanz**-Konfiguration,
|
||||
nicht in einer Agenten-Tool-Konfiguration.
|
||||
|
||||
### 2.2 Ausgang — Nachrichten senden
|
||||
|
||||
| Zweck | Endpunkt |
|
||||
|---|---|
|
||||
| In Kanal/Gruppe/DM schreiben | `POST /api/v1/chat.postMessage` (`roomId` oder `channel`) |
|
||||
| Auf eine Nachricht antworten (Thread) | dasselbe, mit `tmid` |
|
||||
| Datei anhängen | `POST /api/v1/rooms.upload/{roomId}` (multipart) |
|
||||
| Reaktion setzen | `POST /api/v1/chat.react` |
|
||||
|
||||
Gesendet wird als der Agenten-Benutzer — Direktnachrichten funktionieren dadurch echt und
|
||||
nicht als „Bot mit Alias".
|
||||
|
||||
### 2.3 Eingang — der Poll-Weg (Phase 1)
|
||||
|
||||
Der sparsame Weg, ohne jede neue Infrastruktur:
|
||||
|
||||
1. `GET /api/v1/subscriptions.get?updatedSince=<zeitstempel>` — **ein einziger Aufruf**
|
||||
liefert für diesen Agenten alle Räume mit Ungelesen-Zähler, Erwähnungs-Zähler und
|
||||
„zuletzt gesehen"-Marke. Auch bei 50 Räumen bleibt es ein Aufruf.
|
||||
2. Nur für Räume mit relevanten Neuigkeiten wird die Historie geholt:
|
||||
`channels.history` (öffentlich) / `groups.history` (privat) / `im.history` (DM),
|
||||
jeweils mit `oldest=<letzte gesehene Zeit>`.
|
||||
3. `POST /api/v1/subscriptions.read` markiert gelesen — der Zähler geht zurück auf null.
|
||||
|
||||
Zustand im `IStateStore`: `rocketchat:{agentId}:lastCheck` sowie je Raum die zuletzt
|
||||
verarbeitete Nachrichtenzeit.
|
||||
|
||||
**Rate-Limits:** Rocket.Chat begrenzt REST-Aufrufe (Standard in der Größenordnung von
|
||||
10 Aufrufen je Minute und Endpunkt). Bei einem Takt von 30–60 Sekunden und einem
|
||||
Sammelaufruf pro Takt ist das unkritisch — es ist aber der Grund, warum der Entwurf über
|
||||
`subscriptions.get` sammelt statt jeden Raum einzeln zu pollen.
|
||||
|
||||
**Latenz:** Bei 60-Sekunden-Takt antwortet ein Agent im Mittel nach ~30 s plus Laufzeit.
|
||||
Für Gespräche mit Agenten ist das spürbar, aber tragbar. Der Takt lässt sich pro Job
|
||||
setzen (`*/1 * * * *` ist das Minimum des Cron-Modells; feiner ginge nur über die
|
||||
Realtime-API).
|
||||
|
||||
### 2.4 Eingang — die Realtime-Variante (Phase 3, optional)
|
||||
|
||||
Rocket.Chat bietet eine WebSocket-/DDP-Schnittstelle (`wss://host/websocket`): nach
|
||||
`login` mit dem Token abonniert man `stream-notify-user/{userId}/notification` und
|
||||
bekommt DMs und Erwähnungen **sofort** gepusht, ohne Polling.
|
||||
|
||||
Das ist die richtige Endstufe (Antwortzeit ~1 s statt ~30 s), aber es ist eine dauerhafte
|
||||
Verbindung je Agent mit Wiederverbindungs-Logik — also eine echte Komponente, keine
|
||||
Ergänzung eines Tools. Vorschlag: **erst nachrüsten, wenn Phase 1 im Alltag steht** und
|
||||
sich die Verzögerung tatsächlich stört.
|
||||
|
||||
Eine dritte Möglichkeit — Rocket.Chats *Outgoing Webhook* auf unsere vorhandene
|
||||
`ClawdDotNetApi` (Port 5082) — wäre die einfachste Push-Lösung, setzt aber voraus, dass
|
||||
der Rocket.Chat-Server den Windows-Rechner über das Netz erreicht. Das ist eine Frage
|
||||
deiner Netztopologie und keine der Software. Falls erreichbar: der kürzeste Weg zu
|
||||
niedriger Latenz.
|
||||
|
||||
### 2.5 Die zwei echten Fallen
|
||||
|
||||
Diese beiden Punkte sind wichtiger als jede API-Frage.
|
||||
|
||||
**(a) Mehrere Agenten im selben Raum.** Wenn drei Agenten denselben Gruppenchat pollen,
|
||||
antworten drei Agenten auf jede Nachricht. Regel im Entwurf:
|
||||
|
||||
> Ein Agent wird nur geweckt bei (1) Direktnachrichten an ihn oder (2) Nachrichten, die
|
||||
> ihn per `@name` erwähnen. Alles andere liest er nicht einmal.
|
||||
|
||||
Ein Raum kann per Konfiguration auf `respondToAll: true` gestellt werden — das ist die
|
||||
bewusste Ausnahme für einen Raum mit genau einem Agenten.
|
||||
|
||||
**(b) Agenten-Schleifen.** Agent A schreibt, Agent B wird geweckt, antwortet, weckt A —
|
||||
und das läuft, bis das Tagesbudget greift. Der `LoopGuard` schützt nur *innerhalb* eines
|
||||
Laufs, nicht über Agenten hinweg. Regel im Entwurf:
|
||||
|
||||
> Nachrichten von Benutzern mit der Rolle `bot` werden **ignoriert**, außer der Agent ist
|
||||
> namentlich erwähnt. Zusätzlich eine Drossel: höchstens N Weckvorgänge je Raum und
|
||||
> Stunde (Zähler im `IStateStore`), danach schweigt der Agent in diesem Raum bis zur
|
||||
> nächsten Stunde und protokolliert das.
|
||||
|
||||
Das Tagesbudget (K5) ist das letzte Netz, nicht das erste.
|
||||
|
||||
### 2.6 Der Rückweg der Antwort
|
||||
|
||||
Der Wake-Mechanismus liefert die Nachricht *in* den Agenten. Seine Antwort geht heute in
|
||||
den Chat-Verlauf, nicht zurück nach Rocket.Chat. Zwei Wege:
|
||||
|
||||
- **(a) Der Agent antwortet selbst** — die Weck-Nachricht enthält die `roomId` und die
|
||||
Anweisung, mit `RocketChat.send_message` zu antworten. Kein Core-Eingriff, funktioniert
|
||||
sofort. Schwäche: Es hängt daran, dass das Modell es tut. Erfahrungsgemäß klappt das
|
||||
gut, aber nicht in 100 % der Fälle.
|
||||
- **(b) Automatischer Rückweg** — der Tool-Job merkt sich „Antwort gehört nach Raum X",
|
||||
und der Dispatcher schickt die Abschlussnachricht des Laufs dorthin. Zuverlässig, aber
|
||||
es braucht einen kleinen Haken in `ToolJobResult`/`EngineTaskDispatcher`
|
||||
(etwa ein `ReplyTo`-Feld, das der Dispatcher nach dem Lauf an dasselbe Tool zurückgibt).
|
||||
|
||||
**Empfehlung:** (a) in Phase 1, (b) in Phase 2 nachziehen — denn (b) ist der Unterschied
|
||||
zwischen „meistens antwortet er" und „er antwortet". Für die Hauptkommunikationsschiene
|
||||
ist das am Ende nicht optional.
|
||||
|
||||
### 2.7 Sicherheit
|
||||
|
||||
- **Nachrichten aus Rocket.Chat sind fremder Text.** Sie müssen als
|
||||
`<untrusted_content>` gerahmt in den Kontext (Roadmap K2-Rest). Bei Telegram fehlt das
|
||||
bis heute; hier sollte es von Anfang an drin sein, weil Gruppenchats mehrere Absender
|
||||
haben.
|
||||
- **Raum-Allowlist** je Agent (`allowedRooms`), analog `allowedChatIds` beim Telegram-Tool.
|
||||
- **Staging-Vorschlag** (siehe 6): Senden in erlaubte Räume `auto`, alles darüber hinaus
|
||||
`approve`.
|
||||
- Zugangsdaten heißen im Konfigurationsfeld `authToken` → `ConfigSecrets` verschlüsselt sie
|
||||
automatisch. Der Admin-Token der Instanz muss in `ConfigSecrets.Apply(InstanceConfig)`
|
||||
ergänzt werden.
|
||||
- **TLS** ist Pflicht; selbstsignierte Zertifikate ausdrücklich konfigurieren müssen statt
|
||||
Validierung generell abschalten.
|
||||
|
||||
### 2.8 Tool-Zuschnitt
|
||||
|
||||
```
|
||||
Tool: RocketChat
|
||||
Aktionen: send_message | reply | send_file | list_rooms | read_room
|
||||
| mark_read | search
|
||||
Job: rocketchat_poll
|
||||
```
|
||||
|
||||
Konfiguration je Agent:
|
||||
|
||||
```json
|
||||
"RocketChat": {
|
||||
"baseUrl": "https://chat.example.org",
|
||||
"userId": "aBcD…",
|
||||
"authToken": "…", // von ConfigSecrets geschützt
|
||||
"allowedRooms": ["GENERAL", "finanz-team"],
|
||||
"defaultRoom": "finanz-team",
|
||||
"mentionOnly": true,
|
||||
"maxWakesPerRoomPerHour": 12
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3 — Redundanz: was passiert, wenn Rocket.Chat ausfällt
|
||||
|
||||
Das ist die Anforderung, die die Architektur bestimmt — nicht der Chat selbst. Der Kern:
|
||||
**Rocket.Chat darf ein Kanal sein, nicht der Kanal.**
|
||||
|
||||
### 3.1 Was heute schon unabhängig funktioniert
|
||||
|
||||
| Kanal | Unabhängig von Rocket.Chat? | Richtung |
|
||||
|---|---|---|
|
||||
| WinForms-Chat (`frm_chat`) | vollständig — läuft in der App selbst | beide |
|
||||
| Telegram-Bot-Tool | ja — fremde Infrastruktur | beide |
|
||||
| Mail-Tool | ja, sofern der Mailserver anderswo läuft | beide |
|
||||
| Web-Chat / `ClawdDotNetApi` | ja, aber nur im lokalen Netz | beide |
|
||||
|
||||
Wir sind also nicht bei null. Was fehlt, ist die **Umschaltung** — heute muss ein Mensch
|
||||
merken, dass nichts mehr ankommt.
|
||||
|
||||
### 3.2 Vorschlag: `ChannelRouter` im Core
|
||||
|
||||
Eine kleine Komponente im Core (kein neues Tool, keine Tool-zu-Tool-Abhängigkeit —
|
||||
sie löst Tools über die vorhandene `ToolRegistry` nach Namen auf, wie es der Dispatcher
|
||||
schon tut):
|
||||
|
||||
- Je Agent eine **geordnete Kanalliste**, z. B. `["RocketChat", "Telegram", "Mail"]`.
|
||||
- Eine Methode „stelle dem Menschen diese Nachricht zu": versucht der Reihe nach, bis
|
||||
einer erfolgreich ist, und protokolliert im Audit-Log, **über welchen Kanal** zugestellt
|
||||
wurde — inklusive des Hinweises „Primärkanal war nicht erreichbar".
|
||||
- Genutzt von: Agenten (`notify_user`), aber vor allem von **systemseitigen** Meldungen,
|
||||
die heute keinen Weg nach außen haben: Staging-Vorschlag wartet auf Freigabe, Budget
|
||||
überschritten, Watchdog-Alarm, Task blockiert.
|
||||
|
||||
Der zweite Teil ist der wichtigere: Gerade wenn etwas kaputt ist, ist die Meldung darüber
|
||||
diejenige, die ankommen muss.
|
||||
|
||||
### 3.3 Gesundheitsprüfung und Eskalation
|
||||
|
||||
Ein Tool-Job `rocketchat_health` (Takt ~5 Minuten, `GET /api/info`):
|
||||
|
||||
- Nach **drei** aufeinanderfolgenden Fehlschlägen: einmalige Meldung über den nächsten
|
||||
Kanal der Liste — „Rocket.Chat ist seit HH:MM nicht erreichbar, ich melde mich hier."
|
||||
Einmalig, nicht je Takt.
|
||||
- Bei Rückkehr: „Rocket.Chat ist wieder da", und der Zustand wird zurückgesetzt.
|
||||
- Nachrichten, die während des Ausfalls nicht gesendet werden konnten, werden **nicht**
|
||||
in einer eigenen Warteschlange gehalten — sie gehen über den Ersatzkanal raus. Eine
|
||||
zweite Zustellwarteschlange wäre eine zweite Fehlerquelle.
|
||||
|
||||
**Eingehend während des Ausfalls:** Der Telegram-Poll-Job bleibt dauerhaft aktiv, nur mit
|
||||
langsamem Takt (z. B. alle 5 Minuten). Er kostet nichts, wenn nichts kommt — und ist im
|
||||
Ernstfall der Weg, auf dem *du* die Agenten erreichst. Der WinForms-Chat ist ohnehin immer
|
||||
da, solange die App läuft.
|
||||
|
||||
**Entschieden (August 2026): Telegram ist der Notfallkanal.** Die Kanalliste lautet damit
|
||||
`["RocketChat", "Telegram"]`. Konsequenzen:
|
||||
|
||||
- Das Telegram-Tool wird **nicht** abgebaut und geht nicht in Rocket.Chat auf. Es behält
|
||||
seine Rolle, verliert aber die Rolle als Alltagskanal.
|
||||
- `Telegram.send_message` bleibt in der Staging-Policy auf `approve` — mit einer Ausnahme:
|
||||
Meldungen, die der `ChannelRouter` selbst erzeugt (Ausfall, Budget, Watchdog, offene
|
||||
Freigabe), laufen **ohne** Freigabe. Sonst bliebe die Warnung, dass eine Freigabe
|
||||
aussteht, selbst in der Freigabewarteschlange hängen — ein Ringschluss, der genau im
|
||||
Ernstfall zuschlägt.
|
||||
- Der Telegram-Poll bleibt dauerhaft eingerichtet, aber mit langsamem Takt. Ein Kanal, der
|
||||
erst im Notfall eingeschaltet wird, ist im Notfall ungetestet.
|
||||
- Mail bleibt außen vor. Zwei Ersatzkanäle zu pflegen lohnt nicht; das Mail-Tool behält
|
||||
seinen fachlichen Zweck.
|
||||
|
||||
### 3.4 Was das für die Prompts heißt
|
||||
|
||||
Ein Agent soll seinen Kanal nicht selbst wählen. Er sagt „ich möchte dem Nutzer das hier
|
||||
mitteilen", der Router entscheidet. Sonst muss das Modell im Fehlerfall improvisieren —
|
||||
und genau dann ist Improvisation das Letzte, was man will.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Konflikt mit Roadmap A5 (Matrix)
|
||||
|
||||
Roadmap-Punkt **A5** legt fest: „Die Kommunikation (Benachrichtigungen, Berichte, Chat mit
|
||||
Agenten) wird auf Element/Matrix umgestellt", und der gestrichene Tool-Kandidat *Notify*
|
||||
geht darin auf.
|
||||
|
||||
Rocket.Chat besetzt exakt dieselbe Rolle. Drei mögliche Auflösungen:
|
||||
|
||||
1. **Rocket.Chat ersetzt A5.** A5 wird umgeschrieben, Matrix entfällt. Vorteil: eine
|
||||
Schiene, ein Betriebsaufwand, die Instanz läuft bereits.
|
||||
2. **A5 bleibt, Rocket.Chat ist nur ein weiteres Tool.** Dann bauen wir zweimal dasselbe.
|
||||
Schwer zu begründen.
|
||||
3. **Rocket.Chat primär, Matrix als späterer Zweitkanal.** Passt formal zur
|
||||
Redundanz-Anforderung, verdoppelt aber den Wartungsaufwand für einen Fall, den
|
||||
Telegram schon abdeckt.
|
||||
|
||||
**Meine Empfehlung: (1).** Der `ChannelRouter` aus 3.2 ist ohnehin die Verallgemeinerung,
|
||||
die A5 gebraucht hätte — mit ihm ist ein späterer Matrix-Kanal ein zusätzlicher Eintrag in
|
||||
der Liste, keine Migration. Das ist eine Entscheidung für dich, keine technische Sachfrage.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Nextcloud: Machbarkeit
|
||||
|
||||
### 5.1 Der Zugriffsweg
|
||||
|
||||
Nextcloud hat zwei Schnittstellen, beide brauchen wir:
|
||||
|
||||
| Zweck | Schnittstelle |
|
||||
|---|---|
|
||||
| Dateien lesen/schreiben/auflisten/verschieben | **WebDAV**: `/remote.php/dav/files/{benutzer}/{pfad}` |
|
||||
| Öffentlichen Link erzeugen | **OCS**: `/ocs/v2.php/apps/files_sharing/api/v1/shares` |
|
||||
|
||||
Authentifiziert wird mit **App-Passwörtern** (Nextcloud → Einstellungen → Sicherheit →
|
||||
„Neues App-Passwort erstellen") per Basic-Auth. Ein App-Passwort ist einzeln widerrufbar
|
||||
und lässt das eigentliche Kontopasswort unangetastet — dieselbe Logik wie das Personal
|
||||
Access Token bei Rocket.Chat.
|
||||
|
||||
WebDAV braucht keine Bibliothek: `HttpClient` mit den Methoden `PUT`, `GET`, `MKCOL`,
|
||||
`PROPFIND`, `MOVE`, `DELETE`. Nur `PROPFIND` liefert XML (Multistatus), das geparst werden
|
||||
muss — überschaubar, und es erspart uns eine weitere Abhängigkeit.
|
||||
|
||||
### 5.2 Ein Benutzer je Agent — oder ein Sammelkonto?
|
||||
|
||||
Zwei Modelle:
|
||||
|
||||
- **Je Agent ein Nextcloud-Benutzer.** Sauber nachvollziehbar („wer hat das abgelegt"),
|
||||
passt zum Rocket.Chat-Modell, kostet je nach Lizenzmodell Nutzer.
|
||||
- **Ein Dienstkonto `clawd-agents` mit Unterordnern je Agent.** Einfacher zu verwalten,
|
||||
Herkunft steht dann im Pfad statt im Konto.
|
||||
|
||||
**Empfehlung:** Ein Dienstkonto mit Ordnerstruktur `/ClawdDotNet/{Agent}/…`, **plus** einen
|
||||
mit dir geteilten Ordner `/ClawdDotNet/Berichte/`. Begründung: Bei Rocket.Chat ist die
|
||||
eigene Identität funktional zwingend (DMs, Erwähnungen), bei Dateien ist sie es nicht —
|
||||
und ein Ordnerbaum ist leichter aufzuräumen als zehn Konten. Falls du die Trennung dennoch
|
||||
willst, ändert das am Tool nichts, nur an der Konfiguration.
|
||||
|
||||
Die Ordnerdurchsetzung gehört ins Tool: eine konfigurierte `rootPath`, aus der der Agent
|
||||
nicht ausbrechen kann — dieselbe Prüfung wie in `FTPTool.ResolveLocalPath`.
|
||||
|
||||
### 5.3 Die ehrliche Antwort zu „direkt über die API erstellen"
|
||||
|
||||
Nextcloud hat **keine** API, die Dokumenteninhalte erzeugt. Es ist ein Dateiablage- und
|
||||
Freigabesystem; Collabora ist ein *Editor* im Browser (über WOPI angebunden), kein
|
||||
Generator, den man von außen mit „erstelle eine Tabelle mit diesen Zahlen" beauftragen
|
||||
kann.
|
||||
|
||||
Der tatsächliche Weg ist deshalb der, den du selbst schon beschrieben hast:
|
||||
|
||||
```
|
||||
Agent erzeugt die Datei im eigenen Workspace (FileRW-Tool, schon vorhanden)
|
||||
→ Nextcloud.upload (WebDAV PUT)
|
||||
→ Nextcloud.share (optional) (OCS, liefert Link)
|
||||
→ RocketChat.send_message mit dem Link
|
||||
```
|
||||
|
||||
Das ist kein Umweg, sondern die richtige Aufteilung: Der Agent kann seine Datei lokal
|
||||
prüfen und korrigieren, bevor sie irgendwo landet.
|
||||
|
||||
### 5.4 Formate — und wo Collabora doch nützlich wird
|
||||
|
||||
Was ein Agent von sich aus gut schreiben kann: **Markdown** (Nextcloud rendert `.md`
|
||||
direkt in der Weboberfläche — für Berichte oft die beste Wahl), **CSV**, **HTML**, JSON.
|
||||
|
||||
Was er nicht von sich aus schreiben kann: `.xlsx`, `.docx`, `.pdf`.
|
||||
|
||||
Hier gibt es einen eleganten Weg, weil du Collabora ohnehin betreibst: Collabora Online
|
||||
bringt einen **Konvertierungs-Endpunkt** mit (`POST /cool/convert-to/{format}`, multipart).
|
||||
Damit gilt:
|
||||
|
||||
| Ziel | Weg |
|
||||
|---|---|
|
||||
| PDF | Agent schreibt HTML oder ODT → Collabora → PDF |
|
||||
| XLSX | Agent schreibt CSV → Collabora → XLSX |
|
||||
| DOCX | Agent schreibt HTML/ODT → Collabora → DOCX |
|
||||
|
||||
Vorteil: **keine zusätzliche PDF- oder Excel-Bibliothek** im Projekt (und keine
|
||||
Lizenzfrage, die wir uns damit einhandeln — mehrere verbreitete .NET-Bibliotheken für
|
||||
XLSX und PDF sind für kommerzielle Nutzung nicht frei).
|
||||
|
||||
Zu prüfen, bevor wir darauf bauen:
|
||||
- Ist der Endpunkt in deiner Collabora-Installation erreichbar? Er muss in `coolwsd.xml`
|
||||
für die IP des ClawdDotNet-Rechners freigegeben sein (`net`/`post_allow`-Allowlist).
|
||||
Standardmäßig ist das eng gefasst.
|
||||
- Der Pfad heißt je nach Version `/cool/convert-to/…` (neu) oder `/lool/convert-to/…` (alt).
|
||||
|
||||
Falls der Endpunkt nicht freigegeben werden soll: Rückfallebene ist Markdown/CSV — für
|
||||
den Alltag völlig ausreichend, PDF wäre dann ein späterer eigener Punkt.
|
||||
|
||||
### 5.5 Freigabe-Links
|
||||
|
||||
`POST /ocs/v2.php/apps/files_sharing/api/v1/shares` (Header `OCS-APIRequest: true`),
|
||||
`shareType=3` = öffentlicher Link. Optional `password`, `expireDate`, `permissions=1`
|
||||
(nur lesen). Die Antwort enthält die fertige URL.
|
||||
|
||||
Zwei Hinweise:
|
||||
- Manche Instanzen erzwingen Passwortschutz für öffentliche Links — dann muss das Tool ein
|
||||
Passwort mitgeben und zurückliefern.
|
||||
- Ein öffentlicher Link ist **irreversibel im Sinne von A2**: Einmal geteilt, kann er
|
||||
weitergegeben worden sein, auch wenn man ihn danach löscht. Deshalb steht er unten in
|
||||
der Staging-Tabelle auf `approve`.
|
||||
|
||||
Innerhalb der eigenen Instanz ist die freundlichere Variante `shareType=0` (an einen
|
||||
konkreten Nextcloud-Benutzer) — kein öffentlicher Link nötig, wenn du ohnehin ein Konto
|
||||
hast. Das sollte der **Standard** sein, öffentlich die Ausnahme.
|
||||
|
||||
### 5.6 Fallstricke
|
||||
|
||||
- **Dateisperren (HTTP 423).** Wenn du eine Datei gerade in Collabora offen hast, kann ein
|
||||
Upload auf dieselbe Datei scheitern. Das Tool muss 423 sauber melden statt kryptisch zu
|
||||
scheitern — und beim Überschreiben eines Berichts lieber einen neuen Dateinamen mit
|
||||
Zeitstempel vergeben.
|
||||
- **Überschreiben ist nicht destruktiv**, solange die Versionierung aktiv ist (Nextcloud
|
||||
legt automatisch eine Vorversion an). Das ist der Grund, warum `Nextcloud.upload` unten
|
||||
auf `auto` steht, `FTP.upload` aber auf `approve`.
|
||||
- **Größenbegrenzung.** Ein einfaches `PUT` reicht für Berichte problemlos; erst bei sehr
|
||||
großen Dateien bräuchte es den Chunked-Upload (`/remote.php/dav/uploads/…`). Für den
|
||||
angedachten Zweck (Berichte, Tabellen, PDFs) nicht nötig — und wenn doch, meldet der
|
||||
Server einen klaren Fehler.
|
||||
- **Quota.** Ein Agent, der stündlich Berichte ablegt, füllt das Konto. Ein Aufräum-Task
|
||||
(„Berichte älter als 90 Tage") gehört mittelfristig ins Taskboard.
|
||||
|
||||
### 5.7 Tool-Zuschnitt
|
||||
|
||||
```
|
||||
Tool: Nextcloud
|
||||
Aktionen: upload | download | list | mkdir | move | delete
|
||||
| share | unshare | convert (convert nur falls Collabora freigegeben)
|
||||
```
|
||||
|
||||
Konfiguration je Agent:
|
||||
|
||||
```json
|
||||
"Nextcloud": {
|
||||
"baseUrl": "https://cloud.example.org",
|
||||
"username": "clawd-agents",
|
||||
"appPassword": "…", // von ConfigSecrets geschützt
|
||||
"rootPath": "/ClawdDotNet/Hermes",
|
||||
"allowPublicShares": false,
|
||||
"collaboraUrl": "https://collabora.example.org"
|
||||
}
|
||||
```
|
||||
|
||||
`appPassword` muss der Schlüsselliste in `ConfigSecrets` hinzugefügt werden — `password`
|
||||
allein greift nicht, weil dort auf ganze Feldnamen verglichen wird.
|
||||
|
||||
---
|
||||
|
||||
## 6 — Verzahnung mit Staging (A2) und Audit (A3)
|
||||
|
||||
Vorschlag für die `StagingPolicy.DefaultRules`:
|
||||
|
||||
| Aktion | Standard | Begründung |
|
||||
|---|---|---|
|
||||
| `RocketChat.send_message` (erlaubter Raum) | **auto** | Sonst ist Chat unbenutzbar — jede Antwort bräuchte einen Klick |
|
||||
| `RocketChat.send_message` (Raum nicht in `allowedRooms`) | **deny** | Wird vom Tool selbst abgewiesen, gar nicht erst vorgelegt |
|
||||
| `RocketChat.send_file` | **approve** | Dateiabfluss in einen Chatraum |
|
||||
| `Nextcloud.upload`, `mkdir`, `move` | **auto** | Versioniert, im eigenen Ordner, umkehrbar |
|
||||
| `Nextcloud.delete` | **approve** | wie `FileRW.delete` |
|
||||
| `Nextcloud.share` (an Benutzer) | **auto** | bleibt innerhalb der Instanz |
|
||||
| `Nextcloud.share` (öffentlicher Link) | **approve** | nicht zurückholbar |
|
||||
|
||||
Der Unterschied zu `Telegram.send_message` (heute `approve`) ist Absicht: Telegram ist ein
|
||||
Benachrichtigungskanal nach außen, Rocket.Chat ist der Arbeitsraum. Ein Arbeitsraum, in
|
||||
dem jede Antwort eine Freigabe braucht, ist kein Arbeitsraum. Der Schutz sitzt hier an der
|
||||
Raum-Allowlist statt an der Einzelfreigabe.
|
||||
|
||||
Für das Audit-Log entstehen keine Sonderfälle — die Tool-Aufrufe laufen ohnehin durch.
|
||||
|
||||
---
|
||||
|
||||
## 7 — Was dieses Konzept **nicht** vorsieht
|
||||
|
||||
Damit der Zuschnitt klar ist:
|
||||
|
||||
- Keine Rocket.Chat-**App** (Apps-Engine, TypeScript im Server) — wir bleiben Client.
|
||||
- Keine Verwaltung von Rocket.Chat durch Agenten (Benutzer anlegen, Räume erstellen,
|
||||
Rechte vergeben). Das ist Admin-Arbeit in der WinForms-Oberfläche.
|
||||
- Keine Sprach-/Videofunktionen, keine Nextcloud Talk-Anbindung.
|
||||
- Kein Ersatz für den WinForms-Chat — der bleibt und ist die unterste Rückfallebene.
|
||||
- Keine Ende-zu-Ende-Verschlüsselung. Rocket.Chat kann das, aber verschlüsselte Räume sind
|
||||
über die REST-API nicht lesbar. Agenten arbeiten in unverschlüsselten Räumen — das ist
|
||||
eine bewusste Einschränkung, die du kennen solltest.
|
||||
|
||||
---
|
||||
|
||||
## 8 — Vorschlag für den Schnitt
|
||||
|
||||
| Phase | Inhalt | Ergebnis |
|
||||
|---|---|---|
|
||||
| **1** | `Nextcloud`-Tool: upload/download/list/mkdir/move/delete/share | Agent kann Berichte ablegen und einen Link liefern |
|
||||
| **2** | `RocketChat`-Tool: senden, lesen, `rocketchat_poll`-Job, Raum-Allowlist, Erwähnungsfilter, Schleifendrossel | Gespräch mit Agenten über Rocket.Chat, Antwort per Prompt |
|
||||
| **3** | Automatischer Rückweg (`ReplyTo` in `ToolJobResult`) | Antwort landet zuverlässig im richtigen Raum/Thread |
|
||||
| **4** | `ChannelRouter` + `rocketchat_health` + Eskalation | Der Notfallkanal — Ausfall wird erkannt und umschifft |
|
||||
| **5** | Collabora-Konvertierung (PDF/XLSX) | Berichte in Büroformaten |
|
||||
| **6** *(optional)* | Realtime/DDP statt Polling | Antwortzeit ~1 s statt ~30 s |
|
||||
|
||||
Nextcloud zuerst, weil es das kleinere, in sich abgeschlossene Stück ist und sofort Nutzen
|
||||
bringt — und weil es sich unabhängig vom Ausgang der A5-Entscheidung lohnt.
|
||||
|
||||
Phase 4 ist **kein Nice-to-have**: Ohne sie ist Rocket.Chat ein Einzelpunkt, dessen Ausfall
|
||||
niemand meldet. Sie sollte nicht hinter Phase 5 rutschen.
|
||||
|
||||
Zur Modell-Einstufung im Sinne der Roadmap: Phasen 1, 2 und 5 sind klar spezifizierbare
|
||||
Tool-Arbeit (4.6-tauglich). Phase 3 und 4 fassen Engine bzw. Zustellwege an und sollten
|
||||
mit vorheriger Festlegung der Invarianten und mit Tests gebaut werden.
|
||||
|
||||
---
|
||||
|
||||
## 9 — Offene Punkte für die Diskussion
|
||||
|
||||
1. **A5/Matrix** — ersetzt Rocket.Chat den Punkt, oder bleibt Matrix als Ziel bestehen?
|
||||
(Abschnitt 4; das entscheidet, ob der `ChannelRouter` Pflicht oder Kür ist.)
|
||||
2. **Nextcloud-Identität** — ein Dienstkonto mit Ordnern je Agent (mein Vorschlag) oder
|
||||
je Agent ein eigener Nextcloud-Benutzer?
|
||||
3. **Rückweg der Antwort** — reicht Phase 2 (Agent antwortet selbst) für den Anfang, oder
|
||||
soll Phase 3 direkt mitgebaut werden?
|
||||
4. ~~**Notfallkanal** — Telegram oder Mail?~~ **Entschieden: Telegram** (siehe 3.3).
|
||||
Offen bleibt nur die Kleinigkeit, ob die Reihenfolge instanzweit gilt (mein Vorschlag)
|
||||
oder pro Agent einstellbar sein soll.
|
||||
5. **Collabora-Konvertierung** — ist der `convert-to`-Endpunkt für den ClawdDotNet-Rechner
|
||||
freigebbar? Falls nein, bleibt es bei Markdown/CSV.
|
||||
6. **Versionen** — welche Rocket.Chat- und welche Nextcloud-Version läuft bei dir?
|
||||
Einzelne Endpunkte und Rollennamen sind versionsabhängig; das prüfe ich vor der
|
||||
Umsetzung gegen deine Instanz statt gegen die Dokumentation.
|
||||
7. **Öffentliche Links** — grundsätzlich erlauben (mit Freigabe) oder ganz sperren
|
||||
(`allowPublicShares: false` als harte Voreinstellung)?
|
||||
@@ -0,0 +1,108 @@
|
||||
# Staging-Freigabe für irreversible Aktionen
|
||||
|
||||
Setzt A2 aus der [Roadmap](Roadmap.md) um (F-A1 + S4). Irreversible Aktionen werden
|
||||
**gestaged statt ausgeführt**: Vorschlag → Review im Hauptfenster → Freigabe/Ablehnung.
|
||||
Aufbauend auf dem Taskboard (A1, Fortsetzung nach Freigabe) und dem Audit-Log (A3,
|
||||
Approval-Records).
|
||||
|
||||
Sicherheitswirkung: Eine Prompt-Injection (K2) kann dann nur noch einen **Vorschlag**
|
||||
erzeugen, keine Ausführung.
|
||||
|
||||
## Das Gate wird zum Durchsetzungspunkt (S4)
|
||||
|
||||
Das bisher wirkungslose `PermissionGate` (es prüfte nur, ob ein Tool zugewiesen ist)
|
||||
wird zur zentralen Stelle: Policy-Prüfung, Staging-Entscheidung und Audit-Hook an
|
||||
**einer** Stelle (`AgentEngine.ExecuteToolCallAsync`), statt ad-hoc in jedem Tool. S4
|
||||
geht hier auf.
|
||||
|
||||
## Policy: `auto | approve | deny` pro Tool/Aktion
|
||||
|
||||
Je (Tool, Aktion) eine Entscheidung:
|
||||
|
||||
- **`auto`** — läuft wie bisher.
|
||||
- **`approve`** — wird gestaged; die Ausführung wartet auf eine menschliche Freigabe.
|
||||
- **`deny`** — wird gar nicht erst vorgeschlagen, sondern abgelehnt.
|
||||
|
||||
Auflösung vom Speziellen zum Allgemeinen: `Tool.Aktion` → `Tool` → Standard (`auto`).
|
||||
Die „Aktion" ist das `action`-Argument des Aufrufs (die meisten Tools haben es).
|
||||
|
||||
**Eingebaute Standardregeln** (nach Sichtung der Tools, überschreibbar per Konfiguration)
|
||||
— genau die in der Roadmap genannten irreversiblen Aktionen:
|
||||
|
||||
| Tool.Aktion | Standard |
|
||||
|---|---|
|
||||
| `Mail.send` | approve |
|
||||
| `Telegram.send_message` | approve |
|
||||
| `Database.insert`, `Database.upsert` | approve |
|
||||
| `FileRW.delete` | approve |
|
||||
| `FTP.upload`, `FTP.delete` | approve |
|
||||
| alles andere | auto |
|
||||
|
||||
Lesende Aktionen (`Mail.read_inbox`, `FileRW.read`, `Database.query`, …) bleiben `auto` —
|
||||
Staging soll schützen, nicht lähmen.
|
||||
|
||||
## Plan-Freeze
|
||||
|
||||
Freigegeben wird ein **eingefrorener, konkreter Aufruf**: Tool, Aktion und die **exakten
|
||||
Argumente** zum Zeitpunkt des Stagings. Ausgeführt wird genau das Eingefrorene (der
|
||||
gespeicherte Argument-JSON), nie eine nachträglich veränderte Fassung. Jede Änderung
|
||||
wäre eine neue Freigabe.
|
||||
|
||||
Das ist auch der Grund, warum der Agent den Aufruf **nicht** nach der Freigabe erneut
|
||||
formuliert (er könnte etwas anderes bauen) — der eingefrorene JSON wird direkt an das
|
||||
Tool gegeben.
|
||||
|
||||
## Fortsetzung nach Freigabe — kein pausierter Lauf
|
||||
|
||||
Die tragende Architektur-Vorgabe (aus der Roadmap-Einstufung): **Kein pausierter, im
|
||||
Speicher gehaltener Lauf.** Der Ablauf:
|
||||
|
||||
1. **Vorschlag.** Der Agent ruft eine `approve`-Aktion auf. Das Gate führt sie nicht aus,
|
||||
sondern legt einen **Pending**-Datensatz an (eingefrorener Aufruf) und gibt dem Agenten
|
||||
„Zur Freigabe vorgelegt (#id)" als Tool-Ergebnis zurück. Der Lauf endet **regulär** —
|
||||
der Agent schließt ab, nichts hängt im Speicher.
|
||||
2. **Review.** Ein Mensch sieht die offenen Vorschläge im Hauptfenster und entscheidet.
|
||||
3a. **Freigabe.** Der eingefrorene Aufruf wird **direkt** ausgeführt (standalone, nicht
|
||||
über einen Chat-Lauf). Das Ergebnis wird festgehalten, und ein **Folge-Task** (A1)
|
||||
weckt den Agenten: „Deine Aktion X wurde freigegeben und ausgeführt, Ergebnis: Y —
|
||||
mach weiter." Der Scanner stellt ihn zu (`ChatAsync`, bestehender Kontext).
|
||||
3b. **Ablehnung.** Ein Folge-Task weckt den Agenten mit der Ablehnung (samt Grund).
|
||||
|
||||
Echtes Suspend/Resume eines laufenden `ChatAsync` wäre Fable-Terrain — und ist mit dieser
|
||||
Vereinfachung unnötig.
|
||||
|
||||
## Approval-Records (A3)
|
||||
|
||||
Jede Entscheidung — Freigabe wie Ablehnung — wird an zwei Stellen verankert:
|
||||
|
||||
- Im **Staging-Datensatz** selbst: Status, `DecidedBy`, `DecidedAt`, Ergebnis-Verweis
|
||||
bzw. Ablehnungsgrund.
|
||||
- Als **Audit-Eintrag** (A3): Tool, Ausgang, `source: approval`, „Freigegeben von …" bzw.
|
||||
„Abgelehnt von …". Die Herkunft stempelt auch hier das System, nicht der Agent.
|
||||
|
||||
Der Vorschlag selbst wird beim Anlegen als Audit-Eintrag mit Status **`Staged`** notiert —
|
||||
so ist die ganze Kette (Vorschlag → Entscheidung → Ausführung) im Log nachvollziehbar.
|
||||
|
||||
## Nebenläufigkeit
|
||||
|
||||
Zwei Reviewer dürfen nicht denselben Vorschlag doppelt freigeben. Der Übergang
|
||||
`pending → approved/rejected` ist ein **atomares, bedingtes `UPDATE`** (dieselbe
|
||||
Claim-Technik wie beim Taskboard): Genau einer gewinnt, der zweite Klick läuft ins Leere.
|
||||
Erst nach gewonnenem Übergang wird der eingefrorene Aufruf ausgeführt.
|
||||
|
||||
## Verdrahtung
|
||||
|
||||
- `StagingGate` (Policy + Anlegen des Vorschlags) hängt optional an der Engine — ohne es
|
||||
läuft alles wie bisher (`auto`).
|
||||
- Der eingefrorene Aufruf wird über `IFrozenCallExecutor` (von der Engine implementiert)
|
||||
ausgeführt: gültiger Tool-Kontext, aber ohne LLM-Schleife.
|
||||
- `StagingService` (Freigabe/Ablehnung) nutzt Executor, Audit und das Taskboard für den
|
||||
Folge-Task. Es ist die API, die die Review-Oberfläche aufruft.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Review-Oberfläche** im Hauptfenster (Liste der offenen Vorschläge, Freigeben/Ablehnen)
|
||||
— die Dienst-API steht bereit; die WinForms-Ansicht ist die verbleibende Integration.
|
||||
- **Output-Scrubbing** greift auch hier auf den gespeicherten Argument-JSON, sobald es
|
||||
steht (eigener Roadmap-Punkt).
|
||||
- **Orders** (Handelsaufträge) reihen sich später als weitere `approve`-Aktionen ein.
|
||||
@@ -0,0 +1,334 @@
|
||||
# Taskboard — Aufgaben statt Delay-Schleifen
|
||||
|
||||
Setzt A1 aus der [Roadmap](Roadmap.md) um. Das Taskboard ist das Fundament, auf dem
|
||||
Audit (A3), Staging (A2), Marktkalender (C1) und das Ergebnisregister (C7/C8)
|
||||
aufsetzen. Es löst zugleich sechs Altpunkte auf einmal (F-A5 Queue, F-A4 Run-Historie,
|
||||
B8 Rekursion, B6 `Task.Delay`-Überlauf, B7 Cron in Lokalzeit, T7 `RunAsync` vs.
|
||||
`ChatAsync`).
|
||||
|
||||
Aufbau analog zum [Memory-Konzept](Memory-Konzept.md): erst warum die vorhandenen
|
||||
Mechanismen nicht reichen, dann Dateiformat, Wahrheitsaufteilung, Scanner-Verhalten,
|
||||
Invarianten, Migration.
|
||||
|
||||
## Das Problem
|
||||
|
||||
Heute gibt es zwei getrennte, je für sich unzureichende Wege, einen Agenten Arbeit
|
||||
tun zu lassen:
|
||||
|
||||
- **Der Cron-Scheduler** ([`AgentScheduler`](../src/ClawdDotNet.Core/Scheduling/AgentScheduler.cs),
|
||||
[`ToolJobScheduler`](../src/ClawdDotNet.Core/Scheduling/ToolJobScheduler.cs)) hängt starr
|
||||
am Agenten: eine Cron-Zeile je Agent, ausgeführt über ein `Task.Delay` bis zum
|
||||
nächsten Termin. Ein jährlicher Termin bedeutet ein `Task.Delay` über Monate (B6).
|
||||
Cron läuft in Lokalzeit ohne explizite Zone (B7). Ob mit oder ohne Kontext gelaufen
|
||||
wird, entscheidet ein implizites Flag (`UseChatContext`, T7).
|
||||
- **Die `coordination/*.md`-Dateien** im SharedWorkspace sind die improvisierte
|
||||
Antwort der Agenten darauf, dass es kein Aufgabenmodell gibt: `task_*`-, `status_*`-
|
||||
und `broadcast`-Dateien, per Konvention beschrieben, ohne Schema, ohne Claiming,
|
||||
ohne Zustandsübergänge. Zwei Agenten, die dieselbe Datei „übernehmen", tun das ohne
|
||||
jede Absicherung.
|
||||
|
||||
Delegation läuft heute über rekursives `send_message`/`spawn` — ein Agent ruft
|
||||
synchron einen anderen, der wieder einen dritten (B8: Zyklengefahr, deshalb ein
|
||||
`LoopGuard` als Notbremse). Es gibt keine Run-Historie am Auftrag und keine Queue.
|
||||
|
||||
## Warum ein neues Subsystem, nicht der vorhandene State-Store
|
||||
|
||||
Dieselbe Überlegung wie beim Gedächtnis: `IStateStore` ist eine Schlüssel-Wert-Tabelle
|
||||
für kleine Marker. Ein Aufgabenmodell mit Status, Zuweisung, Abhängigkeiten und
|
||||
Terminen darin abzulegen hieße, JSON in eine `Value`-Spalte zu schreiben — nicht
|
||||
filterbar, nicht atomar claimbar, nicht auswertbar.
|
||||
|
||||
Der Kern ist eine **atomare Anspruchsnahme** (Claim). Genau das kann ein Dateisystem
|
||||
nicht verlässlich und der Schlüssel-Wert-Store nicht ausdrücken, eine SQL-Zeile mit
|
||||
einem bedingten `UPDATE` aber sehr wohl. Deshalb eine eigene Tabelle auf dem
|
||||
vorhandenen [`SqliteStorage`](../src/ClawdDotNet.Core/Storage/SqliteStorage.cs) (WAL,
|
||||
`busy_timeout`, prozessweite Schreib-Warteschlange) — dieselbe Grundlage, die schon
|
||||
Gedächtnis, Zustand und Verbrauch teilen.
|
||||
|
||||
## Wahrheitsaufteilung — Datei ist Definition, DB ist Koordination
|
||||
|
||||
Die eine Entscheidung, an der alles hängt:
|
||||
|
||||
| Ebene | Wahrheit über | Wer schreibt |
|
||||
|---|---|---|
|
||||
| **Markdown-Datei** (Frontmatter + Rumpf) | die *Definition* der Aufgabe: Titel, Priorität, Assignee, Termin, Abnahme, Abhängigkeiten. Menschen- und agentenlesbar. | Mensch (Editor), Agent (`task_*`-Tool) |
|
||||
| **SQLite-Tabelle `Tasks`** | den *Ausführungszustand*: Status, Claim, Lease, Last-Fired-Marker je Termin, Blocker-Auflösung. | ausschließlich das Taskboard selbst (Importer, Scanner, Tool) |
|
||||
|
||||
**Regel:** Für die Definition ist die Datei die Wahrheit. Für jede
|
||||
Ausführungsentscheidung ist die DB die Wahrheit. Weichen beide ab (Absturz zwischen
|
||||
DB-Claim und Datei-Schreiben), gewinnt die DB, und die Datei wird bei der
|
||||
Reconciliation nachgezogen.
|
||||
|
||||
Warum nicht alles nur in die DB und die Datei als reine Projektion? Weil die Datei der
|
||||
Bedienpunkt ist: Ein Mensch soll eine Aufgabe im Editor anlegen und ändern können, ein
|
||||
Agent über sein Tool, und beides soll im SharedWorkspace sichtbar und versionierbar
|
||||
bleiben. Warum nicht alles nur in Dateien? Weil das Claiming dort nicht atomar geht —
|
||||
siehe oben. Die Aufteilung nimmt von beidem das Belastbare.
|
||||
|
||||
Der **Importer** ist die Brücke: Er liest die Frontmatter-Definition und spiegelt sie
|
||||
idempotent in die DB-Zeile (`UPSERT` auf `task_id`). Er läuft beim Start (alle Dateien),
|
||||
nach jeder `task_*`-Änderung (die betroffene Datei) und optional per
|
||||
`FileSystemWatcher` mit Debounce (~500 ms, wie bei A4), damit von Hand editierte
|
||||
Dateien zeitnah einfließen.
|
||||
|
||||
## Dateiformat
|
||||
|
||||
Aufgaben liegen als eine Datei je Aufgabe unter `SharedWorkspace/tasks/`. Der
|
||||
Dateiname ist beschreibend (`recherche-nvda-earnings.md`); die stabile Identität ist
|
||||
die `id` im Frontmatter, nicht der Name — so überlebt eine Aufgabe das Umbenennen.
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: t-8f3a2c # stabil, beim Anlegen vergeben; Wahrheit der Identität
|
||||
title: NVDA Earnings recherchieren
|
||||
status: todo # backlog | todo | in_progress | in_review | done | canceled | blocked
|
||||
type: work # work | approval | human_input
|
||||
priority: 3 # 1 (niedrig) .. 5 (hoch)
|
||||
assignee: "@crawler" # @new | @<agentId> | @human
|
||||
when: # optional; fehlt = einmalige Aufgabe, sofort fällig
|
||||
kind: cron # at | every | cron
|
||||
value: "0 7 * * 1-5"
|
||||
tz: Europe/Berlin # PFLICHT, wenn when gesetzt ist — kein Termin ohne Zone
|
||||
require_approval: false # true = gilt erst nach Review (in_review) als done
|
||||
acceptance: | # Abnahmekriterien, gegen die das Ergebnis geprüft wird
|
||||
Aktuelle Zahlen mit Datum und Quelle, in SharedWorkspace/data/nvda.json abgelegt.
|
||||
blocked_by: [t-4b1e] # diese Aufgabe startet erst, wenn alle Blocker done sind
|
||||
onlyWhenMarketOpen: false # C1: Termin nur auslösen, wenn der Markt offen ist
|
||||
---
|
||||
|
||||
Freitext-Rumpf: Auftragsbeschreibung, Kontext, Verweise. Geht als Aufgabenstellung
|
||||
an den Agenten. Kommentare (Ergebnisse, Kritik, Reopen) werden unten angehängt.
|
||||
```
|
||||
|
||||
Feldregeln:
|
||||
|
||||
- **`when.kind`**: `at` (einmaliger Zeitpunkt, ISO 8601), `every` (Intervall, z. B.
|
||||
`30m`), `cron` (5-Felder-Ausdruck wie bisher). `tz` ist bei allen dreien Pflicht —
|
||||
das ist die Antwort auf B7. Die vorhandene
|
||||
[`CronExpression`](../src/ClawdDotNet.Core/Scheduling/CronExpression.cs) rechnet
|
||||
heute zonen-blind in `DateTime.Now`; sie wird in einen zonen-bewussten Aufruf
|
||||
gekapselt (nächsten Termin in `tz` bestimmen, dann in UTC vergleichen). Das
|
||||
verzahnt sich mit R2 (`TimeProvider`) aus der [Teststrategie](Teststrategie.md).
|
||||
- **`assignee`** ersetzt das `UseChatContext`-Flag durch eine explizite Angabe (T7):
|
||||
- `@new` bzw. `@new:<agentId>` → frischer Lauf ohne Historie (`AgentEngine.RunAsync`).
|
||||
Bloßes `@new` ist nur eindeutig, wenn die Instanz genau **einen** Agenten hat; sonst
|
||||
benennt `@new:<agentId>` den Ziel-Agenten. Ist er nicht auflösbar, scheitert der
|
||||
Dispatch mit klarer Meldung, statt einen falschen Agenten zu raten.
|
||||
- `@<agentId>` → bestehender Agent mit seinem Kontext (`AgentEngine.ChatAsync`)
|
||||
- `@human` → wartet auf einen Menschen; kein Modell-Lauf
|
||||
- **`type`**: `work` (Standard), `approval` und `human_input`. Bei den letzten beiden
|
||||
ist ein Mensch der Assignee; seine Antwort **ist** das Ergebnis und Input für
|
||||
Folgeaufgaben. Kein Sonderpfad — nur ein Assignee, der kein Modell ist.
|
||||
|
||||
## Scanner-Verhalten
|
||||
|
||||
Ein einziger Takt (~60 s) statt vieler langer `Task.Delay`. Damit kennt das System
|
||||
keine Monats-Delays mehr (B6), und ein verpasster Takt ist ein verpasster Termin, kein
|
||||
Zeitbombe.
|
||||
|
||||
Je Takt:
|
||||
|
||||
1. **Fällige Termine bestimmen.** Aus jeder aktiven Task-Zeile den nächsten Termin in
|
||||
ihrer `tz` berechnen und gegen „jetzt" prüfen. Ein Termin ist durch einen
|
||||
**Occurrence-Key** eindeutig: `task_id` + geplante Feuerzeit (bei `at`/`cron`/`every`).
|
||||
2. **Claim vor Lauf (at-most-once).** Bevor gelaufen wird, wird der Occurrence-Key
|
||||
atomar beansprucht:
|
||||
|
||||
```sql
|
||||
UPDATE Tasks
|
||||
SET claim_token = @token, claimed_at = @now, last_occurrence = @occ, status = 'in_progress'
|
||||
WHERE id = @id
|
||||
AND status IN ('todo','backlog')
|
||||
AND (last_occurrence IS NULL OR last_occurrence < @occ)
|
||||
AND (claim_token IS NULL OR claimed_at < @leaseCutoff);
|
||||
```
|
||||
|
||||
Genau eine Zeile betroffen = Anspruch gewonnen. Ein zweiter, gleichzeitiger Takt
|
||||
findet die Bedingung nicht mehr erfüllt (Rowcount 0) und läuft **nicht** — so wird
|
||||
ein doppelter Tick zu einem Lauf.
|
||||
3. **Dispatch nach Assignee.** `@new` → `RunAsync`; `@<agent>` → `ChatAsync`; `@human`
|
||||
→ kein Lauf, Status bleibt/wird `in_review` bzw. `todo`, die UI zeigt die Aufgabe
|
||||
als wartend. Der Rumpf (+ Abnahmekriterien) ist die Nachricht an den Agenten.
|
||||
4. **Abschluss verbuchen.** Last-Fired-Marker setzen (`last_occurrence = @occ`), Status
|
||||
fortschreiben, Claim lösen. Bei `require_approval` → `in_review` statt `done`. Der
|
||||
Lauf wird am Task verknüpft (Grundlage für A3-Receipts und die Run-Historie F-A4).
|
||||
|
||||
**Kein Retry-Sturm.** Der Marker wird auch bei einem **fehlgeschlagenen** Lauf gesetzt:
|
||||
ein fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin. Wiederholung
|
||||
ist eine bewusste Entscheidung (neuer Termin oder Reopen), kein Automatismus. Das
|
||||
schützt vor einem Agenten, der bei jedem Takt erneut in denselben Fehler läuft und
|
||||
Budget verbrennt.
|
||||
|
||||
**Serialisierung mit den Chat-Läufen (B2).** Der Scanner ruft die Engine über
|
||||
dieselben Einstiegspunkte wie WebView, ToolJob und AgentComm. `ChatAsync` ist bereits
|
||||
je Agent über ein `SemaphoreSlim`-Gate serialisiert
|
||||
([`AgentEngine`](../src/ClawdDotNet.Core/Engine/AgentEngine.cs)) — der Scanner fügt
|
||||
sich dort ein, statt einen zweiten, konkurrierenden Pfad in den geteilten
|
||||
Konversationskontext aufzumachen. Der Claim ist eine **DB**-Grenze (welcher Termin wird
|
||||
behandelt), das Agent-Gate eine **Kontext**-Grenze (kein verschränkter Nachrichtenstrom).
|
||||
Beide werden gebraucht; keine ersetzt die andere.
|
||||
|
||||
### Startup-Reconciliation
|
||||
|
||||
Beim Start wird Soll (Frontmatter) gegen Ist (DB-Marker/Claims) abgeglichen, statt
|
||||
verpasste Läufe still zu überspringen:
|
||||
|
||||
- Alle Task-Dateien importieren (UPSERT), gelöschte Dateien in der DB als `archived`
|
||||
markieren.
|
||||
- **Stale Claims freigeben:** Ein Claim, dessen `claimed_at` älter ist als die
|
||||
Lease-Dauer (ein während des Laufs abgestürzter Prozess), wird verworfen — die
|
||||
Bedingung `claimed_at < @leaseCutoff` im Claim-`UPDATE` erledigt das ohnehin, die
|
||||
Reconciliation stellt den Status zusätzlich von `in_progress` auf `todo` zurück.
|
||||
- **Verpasste Termine erkennen:** Liegt der letzte planmäßige Termin nach dem
|
||||
Last-Fired-Marker, ist ein Lauf ausgefallen. Er wird als solcher gemeldet (Log, später
|
||||
A5), nicht heimlich verschluckt. Ob nachgeholt wird, ist Politik — Standard: einmal
|
||||
nachholen, sonst würde ein über Nacht ausgeschalteter Rechner beim Start eine Welle
|
||||
auslösen.
|
||||
|
||||
## Die drei Invarianten
|
||||
|
||||
Der Scanner-Kern ist in der Roadmap als der heikle Teil markiert (die Fehlerklasse, die
|
||||
hier schon einmal schiefging). Er wird strikt gegen diese Invarianten gebaut und mit
|
||||
Property-Tests (FsCheck, siehe Teststrategie) abgesichert:
|
||||
|
||||
1. **Nie zwei Claims auf einen Task-Termin.** Garantiert durch das bedingte `UPDATE`:
|
||||
Rowcount ≤ 1 pro Occurrence-Key. Test: N parallele Claims auf denselben Key → genau
|
||||
einer gewinnt.
|
||||
2. **Kein Dispatch bei offenem Blocker.** Eine Aufgabe mit unerfüllten `blocked_by`
|
||||
ist nicht `todo`, sondern `blocked`, und die Claim-Bedingung (`status IN
|
||||
('todo','backlog')`) greift nicht. Test: Blocker offen → kein Lauf; letzter Blocker
|
||||
`done` → genau ein Auto-Dispatch.
|
||||
3. **Doppelter Tick = ein Lauf.** Zwei Takte im selben Fenster konkurrieren um denselben
|
||||
Occurrence-Key; der Claim lässt nur einen durch. Test: zwei gleichzeitige
|
||||
`ScanOnce` → ein Lauf, ein Marker.
|
||||
|
||||
Diese drei sind keine Kür, sondern die Bedingung dafür, dass der Kern mit einem
|
||||
schwächeren Modell umgesetzt werden darf.
|
||||
|
||||
## Abhängigkeiten und Eskalation
|
||||
|
||||
- **`blocked_by` mit Auto-Dispatch:** Wird eine Aufgabe `done`, sucht das Board alle
|
||||
Aufgaben, deren `blocked_by` sie enthält. Sind für eine davon **alle** Blocker `done`,
|
||||
wechselt sie `blocked → todo` und der Scanner nimmt sie beim nächsten Takt auf.
|
||||
- **Blocker-Eskalation:** Meldet ein Agent einen Blocker (`task_update status=blocked`
|
||||
mit Begründung), fällt die Aufgabe und der Zuständige (Lead/Benutzer) wird
|
||||
benachrichtigt. Bis A5 (Matrix) steht, geht das über den vorhandenen Log-/UI-Weg.
|
||||
|
||||
## Zusammenspiel mit Staging (A2) und Reopen
|
||||
|
||||
- **`require_approval` / `in_review`:** Eine Aufgabe mit `require_approval: true` gilt
|
||||
nach dem Lauf nicht als `done`, sondern als `in_review`. Das ist der natürliche
|
||||
Andockpunkt für A2: Die Freigabe erzeugt einen Folge-Task, der den Agenten mit dem
|
||||
eingefrorenen Aufruf weckt (so bleibt der Lauf regulär beendet, kein pausierter
|
||||
In-Memory-Zustand — genau die Architektur-Vorgabe aus der Roadmap-Einstufung für A2).
|
||||
- **Reopen/Feedback:** Ergebnis + Kritik gehen per `task_comment` an **denselben**
|
||||
Agenten zurück (`ChatAsync` in dessen Kontext), statt eine neue Aufgabe von vorn zu
|
||||
beginnen. Die Aufgabe kehrt nach `todo`/`in_progress` zurück, der Verlauf am Task
|
||||
bleibt erhalten.
|
||||
|
||||
## Agenten-Tool
|
||||
|
||||
Ein Tool `Taskboard` im Muster von `MemoryTool` (eine Aktion je Aufruf), das über einen
|
||||
neuen `ITaskRepository` auf dem `AgentToolContext` arbeitet (analog `IMemoryRepository?
|
||||
Memory`):
|
||||
|
||||
| Aktion | Zweck |
|
||||
|---|---|
|
||||
| `task_create` | Aufgabe anlegen — schreibt Datei **und** DB-Zeile (über den Importer). Vergibt die `id`. |
|
||||
| `task_list` | Aufgaben filtern (Status, Assignee, Betreff). Gekappte, kontextschonende Ausgabe wie bei Memory. |
|
||||
| `task_update` | Status/Felder ändern; `blocked` melden; Ergebnis eintragen. |
|
||||
| `task_comment` | Kommentar/Kritik anhängen; mit Reopen den Zuständigen erneut wecken. |
|
||||
|
||||
Agent-zu-Agent-Delegation läuft künftig hierüber: Statt rekursivem `send_message` legt
|
||||
ein Agent eine Aufgabe mit `assignee: @<other>` an. Das ist strukturell zyklenfrei (B8) —
|
||||
eine Aufgabe ist ein Datensatz, kein synchroner Aufruf-Stack.
|
||||
|
||||
**Sicherheit:** Ein Task-Rumpf ist Prompt-Input für den Assignee. Fremdbestimmte Inhalte
|
||||
(Ergebnisse anderer Tools, die in einen Task fließen) werden als Daten gerahmt, nicht als
|
||||
Anweisung — dieselbe Linie wie K2. Irreversibles, das ein Task auslöst, läuft über A2.
|
||||
|
||||
## Migration
|
||||
|
||||
Die `coordination/*.md`-Dateien gehen im Taskboard auf. Die Altdateien haben kein Schema
|
||||
(freies Markdown wie `# Task: …`, `## Status: ASSIGNED`), deshalb bewusst konservativ
|
||||
(`CoordinationMigration`, beim Start ausgeführt):
|
||||
|
||||
- **`task_*`-Dateien** → einmalig als `backlog`-Aufgaben übernommen: Titel aus der
|
||||
`# Task:`-Überschrift (sonst erste Überschrift, sonst Dateiname), das ganze Markdown
|
||||
als Rumpf, `assignee: @human` als sicherer Default, bis ein Mensch sie zuordnet.
|
||||
`backlog` (nicht `todo`), damit der Scanner nichts unbesehen ausführt.
|
||||
- **`status_*`, `broadcast`, Incident-Berichte, `*.json`-Artefakte** → keine Aufgaben;
|
||||
bleiben unangetastet (später nach A5/Matrix bzw. verfallen als Altbestand).
|
||||
|
||||
Idempotent durch **Verschieben statt Löschen**: eine übernommene `task_*`-Datei wandert
|
||||
nach `coordination/migrated/` — die Historie bleibt, ein zweiter Start findet sie nicht
|
||||
mehr. Vor der Migration greift die übliche Regel: frisches Backup.
|
||||
|
||||
## Ein Takt für alles — Ablösung der Alt-Scheduler
|
||||
|
||||
Der Scanner ist der **einzige** periodische Treiber. Die früheren `AgentScheduler` und
|
||||
`ToolJobScheduler` (zwei `Task.Delay`-Schleifen mit B6/B7, ungetestet) sind **gelöscht** —
|
||||
alles Periodische ist jetzt ein Task:
|
||||
|
||||
- **Geplanter Agent-Lauf** (früher `scheduler`-Config) → ein normaler Task mit
|
||||
`when: cron` und Assignee `@new:<agent>`.
|
||||
- **Tool-Job-Poll** (früher `toolJobs`-Config, z. B. `telegram_poll`) → ein Task vom Typ
|
||||
**`tool_job`** mit `tool_name`/`job_type`. Beim fälligen Termin tickt der Dispatcher den
|
||||
`IToolJobProvider` und weckt den Zielagenten (Assignee) nur, wenn der Tick etwas meldet —
|
||||
mit oder ohne Kontext, je nach `ToolJobResult`.
|
||||
|
||||
Zwei Feinheiten, die dabei geradegezogen wurden:
|
||||
|
||||
- **Wiederkehrende Tasks** (`cron`/`every`/`tool_job`) kehren nach dem Feuern auf `todo`
|
||||
zurück statt auf `done` — sonst liefe ein Cron-Task nur ein einziges Mal. Der
|
||||
Last-Fired-Marker verhindert weiterhin, dass **derselbe** Termin doppelt feuert.
|
||||
- **`backlog` ist ein Halte-Status**: Der Scanner claimt nur `todo`. Eine Aufgabe in
|
||||
`backlog` (frisch importiert, migriert, oder ein deaktivierter Poll) ruht, bis ein
|
||||
Mensch sie auf `todo` setzt.
|
||||
|
||||
Die Alt-Konfiguration (`scheduler`, `toolJobs`) wird beim Start einmalig und
|
||||
nicht-destruktiv in Tasks migriert (`SchedulerTaskMigration`, stabile Ids
|
||||
`sched-<agent>` / `tj-<agent>-<job>`). Ein manuelles „Jetzt ausführen" im Host läuft über
|
||||
`TaskScanner.RunTaskNowAsync`.
|
||||
|
||||
## Verzahnung
|
||||
|
||||
- **C1 Marktkalender:** `onlyWhenMarketOpen` gehört ins Frontmatter, nicht in einen
|
||||
eigenen Mechanismus — der Scanner überspringt einen Termin, wenn der Markt zu ist.
|
||||
- **A3 Audit/Receipts:** Jeder Lauf wird am Task verknüpft; der Abschluss-Beleg (Schritte,
|
||||
Tokens, Kosten) fällt daraus ab und macht C7 weitgehend zum Abfallprodukt.
|
||||
- **F-A5/F-A4:** Das Board **ist** die Queue; die verknüpften Läufe **sind** die Historie.
|
||||
|
||||
## Umsetzungsreihenfolge
|
||||
|
||||
Bewusst so geschnitten, dass der heikle Kern zuletzt und gegen grüne Invarianten kommt:
|
||||
|
||||
1. **Modelle + `ITaskRepository` + Schema + Frontmatter-Parser.** Reine, testbare
|
||||
Bausteine. Der Parser wird eng gebaut (die Frontmatter ist ein kleiner, flacher
|
||||
Satz aus Skalaren und kurzen Listen) — keine YAML-Bibliothek, passend zum
|
||||
dependency-armen Stil des Projekts. 4.6-tauglich.
|
||||
2. **`Taskboard`-Tool + Importer.** Datei ↔ DB, `task_*`-Aktionen. 4.6-tauglich.
|
||||
3. **Scanner-Kern** (Claim, Dispatch, Reconciliation, Auto-Dispatch) — strikt gegen die
|
||||
drei Invarianten, mit Property-Tests. Der in der Roadmap für Opus 5/Fable markierte
|
||||
Teil; mit Opus 4.8 nur streng nach diesem Dokument und mit den Invarianten-Tests als
|
||||
Netz.
|
||||
4. **Migration** der `coordination/*.md`.
|
||||
|
||||
Neue Subsysteme kommen mit Tests nach der [Teststrategie](Teststrategie.md); die
|
||||
Invarianten-Tests sind bei Punkt 3 die Absicherung, kein Nice-to-have.
|
||||
|
||||
## Offen
|
||||
|
||||
- **Nachhol-Politik verpasster Termine** — umgesetzt als „höchstens einmal nachholen":
|
||||
der Scanner nimmt den jüngsten verpassten Termin, nicht jeden einzelnen. Eine frische
|
||||
Aufgabe holt zudem keinen Termin von **vor** ihrer Anlage nach. Ob das je Task
|
||||
abschaltbar sein soll (`catchUp: true|false`), ist offen.
|
||||
- **Marktkalender (C1)** — der Scanner fragt eine `IMarketCalendar` (derzeit Platzhalter
|
||||
„immer offen"). C1 liefert später den echten Kalender; die Verzahnung steht.
|
||||
- **DST-Randfall** — eine bei der Zeitumstellung nicht existierende Ortszeit
|
||||
(Frühjahr, „02:30") wird derzeit übersprungen statt verschoben. Für die geplanten
|
||||
Termine unkritisch; die Härtung gehört zur Scheduler-Nacharbeit (Teststrategie R4/R5).
|
||||
- **Priorität als Reihenfolge** — bei mehreren fälligen Aufgaben desselben Agenten
|
||||
bestimmt `priority` die Reihenfolge; ob strikt oder gewichtet, ist noch offen.
|
||||
- **Aufräumen** — `done`/`canceled`-Aufgaben nach einer Frist archivieren, damit
|
||||
`tasks/` nicht zuwächst (dieselbe Überlegung wie „Verfall" beim Gedächtnis).
|
||||
+53
-71
@@ -4,6 +4,7 @@ using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Models;
|
||||
using ClawdDotNet.Services;
|
||||
@@ -25,8 +26,24 @@ public partial class frm_main : Form
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly InstanceDirectoryManager _dirManager;
|
||||
private readonly AgentEngine? _agentEngine;
|
||||
private readonly AgentScheduler? _agentScheduler;
|
||||
private readonly ToolJobScheduler? _toolJobScheduler;
|
||||
|
||||
/// <summary>
|
||||
/// Der einzige periodische Treiber (A1). Ersetzt die früheren AgentScheduler/
|
||||
/// ToolJobScheduler — geplante Läufe und Tool-Job-Polls sind jetzt Tasks.
|
||||
/// </summary>
|
||||
private readonly ClawdDotNet.Core.Tasks.TaskScanner? _taskScanner;
|
||||
|
||||
/// <summary>
|
||||
/// A2: Freigabe-/Ablehnungs-Dienst. Die Review-Oberfläche (Liste offener Vorschläge,
|
||||
/// Freigeben/Ablehnen) ruft <see cref="ClawdDotNet.Core.Staging.StagingService"/> auf —
|
||||
/// die Anbindung ist die verbleibende Integration; die Dienst-API steht bereit.
|
||||
/// </summary>
|
||||
private readonly ClawdDotNet.Core.Staging.StagingService? _stagingService;
|
||||
|
||||
// Der Lizenz-Teil ist mit der Deploymentcenter-Umstellung aus dieser Fassung
|
||||
// verschwunden: Startprüfung, laufende Nachprüfung und Notausschalter sitzen jetzt
|
||||
// in ClawdDotNet.App (LicenseGate, LicenseWatch) und in der Avalonia-Oberfläche.
|
||||
// Diese Datei ist nur noch Vorlage für die Portierung — siehe ClawdDotNet.slnx.
|
||||
|
||||
// ─── Services ───
|
||||
private LiveLogViewerService? _logViewer;
|
||||
@@ -51,6 +68,9 @@ public partial class frm_main : Form
|
||||
private readonly Dictionary<string, DateTime> _jobLastRunTimes = new();
|
||||
private bool _suppressAgentSelectionChanged;
|
||||
|
||||
/// <summary>Setzt die Beenden-Rückfrage außer Kraft (Lizenz-Sperre/-Deaktivierung).</summary>
|
||||
private bool _forceClose;
|
||||
|
||||
public frm_main(
|
||||
SettingsManager settingsManager,
|
||||
InstanceConfig instanceConfig,
|
||||
@@ -60,8 +80,8 @@ public partial class frm_main : Form
|
||||
ToolRegistry toolRegistry,
|
||||
InstanceDirectoryManager dirManager,
|
||||
AgentEngine? agentEngine,
|
||||
AgentScheduler? agentScheduler,
|
||||
ToolJobScheduler? toolJobScheduler = null)
|
||||
ClawdDotNet.Core.Tasks.TaskScanner? taskScanner = null,
|
||||
ClawdDotNet.Core.Staging.StagingService? stagingService = null)
|
||||
{
|
||||
_settingsManager = settingsManager;
|
||||
_instanceConfig = instanceConfig;
|
||||
@@ -72,8 +92,8 @@ public partial class frm_main : Form
|
||||
_toolRegistry = toolRegistry;
|
||||
_dirManager = dirManager;
|
||||
_agentEngine = agentEngine;
|
||||
_agentScheduler = agentScheduler;
|
||||
_toolJobScheduler = toolJobScheduler;
|
||||
_taskScanner = taskScanner;
|
||||
_stagingService = stagingService;
|
||||
_instanceSettingsVm = new InstanceSettingsViewModel(_instanceConfig);
|
||||
|
||||
InitializeComponent();
|
||||
@@ -580,8 +600,6 @@ public partial class frm_main : Form
|
||||
|
||||
foreach (var agent in _instanceConfig.Agents)
|
||||
{
|
||||
var lastResult = _agentScheduler?.GetLastResult(agent.AgentId);
|
||||
|
||||
_agentListEntries.Add(new AgentListDisplayEntry
|
||||
{
|
||||
AgentId = agent.AgentId,
|
||||
@@ -590,7 +608,7 @@ public partial class frm_main : Form
|
||||
ToolCount = agent.Tools.Count,
|
||||
HasIdentity = !string.IsNullOrWhiteSpace(agent.Identity) ? "Ja" : "—",
|
||||
HasSoul = !string.IsNullOrWhiteSpace(agent.Soul) ? "Ja" : "—",
|
||||
Status = lastResult?.Status.ToString() ?? "Bereit"
|
||||
Status = _agentEngine?.IsRunning(agent.AgentId) == true ? "Läuft" : "Bereit"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -752,7 +770,7 @@ public partial class frm_main : Form
|
||||
|
||||
private async void OnRunAgentNowClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_agentScheduler is null || _agentEngine is null)
|
||||
if (_agentEngine is null)
|
||||
{
|
||||
MessageBox.Show("Kein API-Key konfiguriert – Agenten können nicht ausgeführt werden.",
|
||||
"Hinweis", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
@@ -778,8 +796,8 @@ public partial class frm_main : Form
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(agentConfig.LoopGuard.Timeout);
|
||||
var result = await _agentScheduler.RunNowAsync(
|
||||
agentConfig, "Manual execution triggered by user.", cts.Token);
|
||||
var result = await _agentEngine.RunAsync(
|
||||
agentConfig, "Manual execution triggered by user.", _instanceConfig.InstanceId, cts.Token);
|
||||
|
||||
RefreshAgentList();
|
||||
|
||||
@@ -925,52 +943,14 @@ public partial class frm_main : Form
|
||||
dgv_jobhistory.DataSource = _jobHistoryEntries;
|
||||
RefreshJobHistoryGrid();
|
||||
|
||||
// ─── ToolJobScheduler: OnJobTick → History + LastRun ───
|
||||
if (_toolJobScheduler is not null)
|
||||
{
|
||||
_toolJobScheduler.OnJobTick += OnToolJobTick;
|
||||
}
|
||||
// Tool-Job-Ticks laufen jetzt über den Scanner (Tasks); ihre Historie ergibt sich
|
||||
// aus dem Audit-Log/den Receipts (A3), nicht mehr aus einem Scheduler-Event.
|
||||
|
||||
EnsureBuiltInServices();
|
||||
RefreshJobList();
|
||||
RefreshServiceList();
|
||||
}
|
||||
|
||||
private void OnToolJobTick(string agentId, string jobId, ToolJobResult result)
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated) return;
|
||||
|
||||
BeginInvoke(() =>
|
||||
{
|
||||
// Track LastRun timestamp
|
||||
_jobLastRunTimes[jobId] = DateTime.Now;
|
||||
|
||||
// Add to history
|
||||
var agent = _instanceConfig.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
var jobConfig = agent?.ToolJobs.FirstOrDefault(j => j.JobId == jobId);
|
||||
|
||||
var historyEntry = new JobHistoryEntry
|
||||
{
|
||||
JobName = jobConfig?.JobTypeId ?? jobId,
|
||||
Agent = agent?.DisplayName ?? agentId,
|
||||
Time = DateTime.Now,
|
||||
JobDescription = $"Tool Job: {jobConfig?.ToolName ?? "?"}",
|
||||
Info = result.LogSummary ?? (result.ShouldWakeAgent ? "Wake Agent" : "No Action"),
|
||||
Status = result.ShouldWakeAgent ? "Success" : (result.LogSummary?.Contains("Fehler") == true ? "Error" : "Success")
|
||||
};
|
||||
|
||||
_jobHistoryService?.Add(historyEntry);
|
||||
_jobHistoryEntries.Insert(0, historyEntry);
|
||||
|
||||
// Trim UI list
|
||||
while (_jobHistoryEntries.Count > 200)
|
||||
_jobHistoryEntries.RemoveAt(_jobHistoryEntries.Count - 1);
|
||||
|
||||
// Refresh job list to update LastRun column
|
||||
RefreshJobList();
|
||||
});
|
||||
}
|
||||
|
||||
private void RefreshJobHistoryGrid()
|
||||
{
|
||||
_jobHistoryEntries.Clear();
|
||||
@@ -1123,9 +1103,9 @@ public partial class frm_main : Form
|
||||
|
||||
if (entry.JobType == "Tool Job")
|
||||
{
|
||||
if (_toolJobScheduler is null)
|
||||
if (_taskScanner is null)
|
||||
{
|
||||
MessageBox.Show("Tool-Job-Scheduler ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
MessageBox.Show("Scanner ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1136,11 +1116,13 @@ public partial class frm_main : Form
|
||||
btn_runJob.Text = "⏳ Läuft...";
|
||||
try
|
||||
{
|
||||
var result = await _toolJobScheduler.TriggerJobAsync(agent, jobConfig, CancellationToken.None);
|
||||
// Poll-Jobs sind jetzt Tasks (Id: tj-<agent>-<job>); der Scanner führt sie aus.
|
||||
var ran = await _taskScanner.RunTaskNowAsync(
|
||||
$"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
|
||||
_jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
|
||||
RefreshJobList();
|
||||
|
||||
var status = result.ShouldWakeAgent ? "Wake → Agent gestartet" : (result.LogSummary ?? "Keine Aktion");
|
||||
var status = ran ? "Ausgeführt" : "Nicht bereit (läuft evtl. schon oder ist deaktiviert)";
|
||||
|
||||
_jobHistoryService?.Add(new JobHistoryEntry
|
||||
{
|
||||
@@ -1181,9 +1163,9 @@ public partial class frm_main : Form
|
||||
else
|
||||
{
|
||||
// Agent Wakeup Job
|
||||
if (_agentScheduler is null || _agentEngine is null)
|
||||
if (_agentEngine is null)
|
||||
{
|
||||
MessageBox.Show("Agent-Scheduler ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
MessageBox.Show("Agent-Engine ist nicht aktiv.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1193,7 +1175,7 @@ public partial class frm_main : Form
|
||||
btn_runJob.Text = "⏳ Läuft...";
|
||||
try
|
||||
{
|
||||
var result = await _agentScheduler.RunNowAsync(agent, taskMessage, CancellationToken.None);
|
||||
var result = await _agentEngine.RunAsync(agent, taskMessage, _instanceConfig.InstanceId, CancellationToken.None);
|
||||
_jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
|
||||
RefreshJobList();
|
||||
|
||||
@@ -1244,8 +1226,6 @@ public partial class frm_main : Form
|
||||
// Agent Wakeup Jobs
|
||||
if (agent.Scheduler is not null)
|
||||
{
|
||||
var lastResult = _agentScheduler?.GetLastResult(agent.AgentId);
|
||||
|
||||
string nextRun = "—";
|
||||
if (!string.IsNullOrWhiteSpace(agent.Scheduler.Cron))
|
||||
{
|
||||
@@ -1263,7 +1243,7 @@ public partial class frm_main : Form
|
||||
|
||||
var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
|
||||
? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
: (lastResult is not null ? "Letzter Lauf bekannt" : "—");
|
||||
: "—";
|
||||
|
||||
_jobEntries.Add(new JobDisplayEntry
|
||||
{
|
||||
@@ -1275,8 +1255,8 @@ public partial class frm_main : Form
|
||||
RunOnStart = agent.Scheduler.RunOnStart,
|
||||
NextRun = nextRun,
|
||||
LastRun = agentLastRun,
|
||||
LastStatus = lastResult?.Status.ToString() ?? "—",
|
||||
Status = _agentScheduler is not null ? "Aktiv" : "Inaktiv"
|
||||
LastStatus = "—",
|
||||
Status = _taskScanner is not null ? "Aktiv" : "Inaktiv"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1298,8 +1278,6 @@ public partial class frm_main : Form
|
||||
}
|
||||
}
|
||||
|
||||
var lastToolResult = _toolJobScheduler?.GetLastResult(toolJob.JobId);
|
||||
|
||||
var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
|
||||
? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
: "—";
|
||||
@@ -1316,7 +1294,7 @@ public partial class frm_main : Form
|
||||
RunOnStart = toolJob.RunOnStart,
|
||||
NextRun = nextRun,
|
||||
LastRun = toolLastRun,
|
||||
LastStatus = lastToolResult?.LogSummary ?? "—",
|
||||
LastStatus = "—",
|
||||
Status = toolJob.Enabled ? "Aktiv" : "Deaktiviert"
|
||||
});
|
||||
}
|
||||
@@ -1516,7 +1494,8 @@ public partial class frm_main : Form
|
||||
|
||||
private void OnFormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing)
|
||||
// Erzwungenes Schließen (Lizenz-Sperre/-Deaktivierung) überspringt die Rückfrage.
|
||||
if (e.CloseReason == CloseReason.UserClosing && !_forceClose)
|
||||
{
|
||||
var result = MessageBox.Show(
|
||||
"ClawdDotNet ist für den 24/7-Betrieb ausgelegt.\n\n" +
|
||||
@@ -1568,17 +1547,20 @@ public partial class frm_main : Form
|
||||
|
||||
private void btn_openAgentFolder_Click(object sender, EventArgs e)
|
||||
{
|
||||
Process.Start("explorer.exe", _instancePath + "\\Agents");
|
||||
SystemShell.OpenFolder(Path.Combine(_instancePath, "Agents"));
|
||||
}
|
||||
|
||||
private void btn_showInstanceFolder_Click(object sender, EventArgs e)
|
||||
{
|
||||
Process.Start("explorer.exe", _instancePath);
|
||||
SystemShell.OpenFolder(_instancePath);
|
||||
}
|
||||
|
||||
private void btn_ShowLogFolder2_Click(object sender, EventArgs e)
|
||||
{
|
||||
Process.Start("explorer.exe", Environment.CurrentDirectory + "/Logs");
|
||||
// _logDirectory statt CurrentDirectory + "/Logs": Das Arbeitsverzeichnis ist
|
||||
// nicht zwingend das Programmverzeichnis, und die Instanz kann ein eigenes
|
||||
// Log-Ziel haben — geöffnet werden soll der Ordner, in den auch geschrieben wird.
|
||||
SystemShell.OpenFolder(Path.GetFullPath(_logDirectory));
|
||||
}
|
||||
|
||||
private void dgv_Jobs_CellContentClick(object sender, DataGridViewCellEventArgs e)
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.AgentComm;
|
||||
using ClawdDotNet.Tools.AgentEditor;
|
||||
using ClawdDotNet.Tools.AgentSpawn;
|
||||
using ClawdDotNet.Tools.Database;
|
||||
using ClawdDotNet.Tools.DirectAPI;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using ClawdDotNet.Tools.FTP;
|
||||
using ClawdDotNet.Tools.Mail;
|
||||
using ClawdDotNet.Tools.SocialMediaManager;
|
||||
using ClawdDotNet.Tools.Telegram;
|
||||
using ClawdDotNet.Tools.TelegramClient;
|
||||
using ClawdDotNet.Tools.WebFetch;
|
||||
using ClawdDotNet.Tools.WebMonitor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App;
|
||||
|
||||
/// <summary>
|
||||
/// Baut alles auf, was ClawdDotNet zum Laufen braucht — <b>ohne</b> eine einzige Zeile
|
||||
/// Oberflächencode.
|
||||
///
|
||||
/// <para>Vorher lag das in <c>Program.cs</c> der WinForms-Anwendung: 350 Zeilen zwischen
|
||||
/// <c>ApplicationConfiguration.Initialize()</c> und <c>Application.Run(form)</c>. Die
|
||||
/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
|
||||
/// überhaupt entstand. Sie war nur nirgends festgehalten.</para>
|
||||
///
|
||||
/// <para>Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
|
||||
/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
|
||||
/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
|
||||
/// festgeschrieben zu sein.</para>
|
||||
/// </summary>
|
||||
public sealed class AppHost : IAsyncDisposable
|
||||
{
|
||||
private readonly List<Func<ValueTask>> _shutdown = [];
|
||||
|
||||
public required SettingsManager Settings { get; init; }
|
||||
public required InstanceDirectoryManager Directories { get; init; }
|
||||
public required InstanceConfig Instance { get; init; }
|
||||
public required string InstancePath { get; init; }
|
||||
public required string LogDirectory { get; init; }
|
||||
public required ILoggerFactory LoggerFactory { get; init; }
|
||||
public required ToolRegistry Tools { get; init; }
|
||||
|
||||
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
|
||||
public AgentEngine? Engine { get; private init; }
|
||||
|
||||
public TaskScanner? Scanner { get; private init; }
|
||||
public StagingService? Staging { get; private init; }
|
||||
public SqliteUsageRepository? Usage { get; private init; }
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
|
||||
/// Null, wenn Adresse oder Token fehlen.
|
||||
/// </summary>
|
||||
public DeploymentcenterService? Deploymentcenter { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
|
||||
/// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
|
||||
/// </summary>
|
||||
public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
|
||||
/// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
|
||||
/// </summary>
|
||||
public LicenseGate? License { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Die laufende Nachprüfung. Der Aufrufer hängt sich an
|
||||
/// <see cref="LicenseWatch.Revoked"/> und beendet die Anwendung, wenn es feuert.
|
||||
/// </summary>
|
||||
public LicenseWatch? LicenseWatch { get; private set; }
|
||||
|
||||
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
|
||||
public sealed class Callbacks
|
||||
{
|
||||
/// <summary>
|
||||
/// Wählt die Instanz. Gibt <c>null</c> zurück, wenn der Nutzer abbricht.
|
||||
/// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
|
||||
/// </summary>
|
||||
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
|
||||
|
||||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht.</summary>
|
||||
public required ILicensePrompt License { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
|
||||
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
|
||||
/// Dienst dauerhaft blockieren würde.
|
||||
/// </summary>
|
||||
public Func<string, Task<string>>? TelegramLogin { get; init; }
|
||||
|
||||
public Func<Task<string>>? Telegram2FA { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis des Aufbaus. <see cref="Host"/> ist null, wenn der Nutzer abgebrochen hat
|
||||
/// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
|
||||
/// nachzureichen: Die hat der Torwächter schon gezeigt.
|
||||
/// </summary>
|
||||
public readonly record struct StartupResult(AppHost? Host, string? Error);
|
||||
|
||||
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
|
||||
{
|
||||
// ─── 1. Anwendungseinstellungen ───
|
||||
var settings = new SettingsManager();
|
||||
settings.Load();
|
||||
|
||||
// ─── 2. Instanz wählen ───
|
||||
var directories = new InstanceDirectoryManager(
|
||||
Path.GetFullPath(settings.AppSettings.InstancesDirectory));
|
||||
|
||||
var instancePath = await callbacks.SelectInstance(directories);
|
||||
if (string.IsNullOrWhiteSpace(instancePath))
|
||||
return new StartupResult(null, null); // Abbruch, keine Meldung nötig
|
||||
|
||||
InstanceConfig instance;
|
||||
try
|
||||
{
|
||||
instance = directories.LoadInstanceConfig(instancePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new StartupResult(null,
|
||||
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
|
||||
}
|
||||
|
||||
// ─── 3. Protokollierung ───
|
||||
var logDirectory = Path.GetFullPath(
|
||||
!string.IsNullOrWhiteSpace(instance.LogDirectory)
|
||||
? instance.LogDirectory
|
||||
: settings.AppSettings.LogDirectory);
|
||||
|
||||
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
|
||||
settings.AppSettings.MinimumLogLevel, true, out var parsed)
|
||||
? parsed
|
||||
: Core.Logging.LogLevel.Info;
|
||||
|
||||
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
|
||||
|
||||
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
|
||||
instance.InstanceName, instance.InstanceId);
|
||||
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
|
||||
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
|
||||
|
||||
// ─── 4. Lizenz ───
|
||||
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
|
||||
callbacks.License);
|
||||
|
||||
if (!await license.RunStartupCheckAsync(ct))
|
||||
{
|
||||
logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
|
||||
loggerFactory.Dispose();
|
||||
return new StartupResult(null, null);
|
||||
}
|
||||
|
||||
// ─── 5. Werkzeuge ───
|
||||
var tools = RegisterTools();
|
||||
|
||||
TelegramClientManager? telegram = null;
|
||||
if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
|
||||
{
|
||||
telegram = new TelegramClientManager(instance, instancePath,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
|
||||
|
||||
tools.Register(new TelegramClientTool(telegram));
|
||||
logger.LogInformation("TelegramClient-Tool registriert");
|
||||
}
|
||||
else if (instance.TelegramClient is not null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
|
||||
}
|
||||
|
||||
var host = BuildCore(settings, directories, instance, instancePath,
|
||||
logDirectory, loggerFactory, tools, telegram, logger);
|
||||
|
||||
// Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
|
||||
host.License = license;
|
||||
|
||||
if (license.IsEnforcementConfigured)
|
||||
{
|
||||
host.LicenseWatch = new LicenseWatch(
|
||||
license, loggerFactory.CreateLogger("ClawdDotNet.License"));
|
||||
|
||||
host.LicenseWatch.Start();
|
||||
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
|
||||
}
|
||||
|
||||
await host.ConnectTelegramAsync(callbacks, logger);
|
||||
await host.StartDeploymentcenterAsync(loggerFactory, ct);
|
||||
|
||||
return new StartupResult(host, null);
|
||||
}
|
||||
|
||||
// ─── Aufbau ───
|
||||
|
||||
private static ToolRegistry RegisterTools()
|
||||
{
|
||||
var registry = new ToolRegistry();
|
||||
|
||||
registry.Register(new FileRWTool());
|
||||
registry.Register(new TelegramTool());
|
||||
registry.Register(new MailTool());
|
||||
registry.Register(new DatabaseTool());
|
||||
registry.Register(new FTPTool());
|
||||
registry.Register(new DirectApiTool());
|
||||
registry.Register(new WebFetchTool());
|
||||
registry.Register(new WebMonitorTool());
|
||||
registry.Register(new AgentCommTool());
|
||||
registry.Register(new SocialMediaManagerTool());
|
||||
registry.Register(new AgentSpawnTool());
|
||||
registry.Register(new AgentEditorTool());
|
||||
registry.Register(new Tools.Memory.MemoryTool());
|
||||
registry.Register(new Tools.Taskboard.TaskboardTool());
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static AppHost BuildCore(
|
||||
SettingsManager settings,
|
||||
InstanceDirectoryManager directories,
|
||||
InstanceConfig instance,
|
||||
string instancePath,
|
||||
string logDirectory,
|
||||
ILoggerFactory loggerFactory,
|
||||
ToolRegistry tools,
|
||||
TelegramClientManager? telegram,
|
||||
ILogger logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
|
||||
{
|
||||
logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
|
||||
|
||||
return new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
TelegramClient = telegram
|
||||
};
|
||||
}
|
||||
|
||||
var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
|
||||
|
||||
// Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
|
||||
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
|
||||
var stateStore = new SqliteStateStore(storage);
|
||||
var memory = new SqliteMemoryRepository(storage);
|
||||
var taskRepository = new SqliteTaskRepository(storage);
|
||||
var audit = new SqliteAuditRepository(storage);
|
||||
var stagingRepository = new SqliteStagingRepository(storage);
|
||||
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository);
|
||||
var usage = new SqliteUsageRepository(storage);
|
||||
|
||||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||||
var pricing = new ModelPricingCatalog();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
|
||||
catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
|
||||
});
|
||||
|
||||
var engine = new AgentEngine(
|
||||
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
|
||||
memory, usage, pricing, taskRepository, audit, stagingGate)
|
||||
{
|
||||
InstanceBudget = instance.Budget
|
||||
};
|
||||
|
||||
engine.SetAgentConfigProvider(
|
||||
() => instance.Agents,
|
||||
instance.InstanceId,
|
||||
agentId =>
|
||||
{
|
||||
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||||
});
|
||||
|
||||
engine.LoadPersistedChats();
|
||||
|
||||
var (scanner, staging) = BuildTaskboard(
|
||||
instance, taskRepository, engine, tools, stateStore, audit,
|
||||
stagingRepository, loggerFactory, logger);
|
||||
|
||||
var host = new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
Engine = engine,
|
||||
Scanner = scanner,
|
||||
Staging = staging,
|
||||
Usage = usage,
|
||||
TelegramClient = telegram,
|
||||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
|
||||
};
|
||||
|
||||
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
|
||||
|
||||
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
|
||||
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
|
||||
/// </summary>
|
||||
private static (TaskScanner?, StagingService?) BuildTaskboard(
|
||||
InstanceConfig instance,
|
||||
SqliteTaskRepository taskRepository,
|
||||
AgentEngine engine,
|
||||
ToolRegistry tools,
|
||||
SqliteStateStore stateStore,
|
||||
SqliteAuditRepository audit,
|
||||
SqliteStagingRepository stagingRepository,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger logger)
|
||||
{
|
||||
var sharedWorkspace = instance.Agents
|
||||
.Select(a => a.SharedWorkspacePath)
|
||||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sharedWorkspace))
|
||||
return (null, null);
|
||||
|
||||
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||||
|
||||
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit);
|
||||
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||||
|
||||
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory);
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reset = await taskRepository.ReleaseStaleClaimsAsync(
|
||||
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
var coordination = new CoordinationMigration(
|
||||
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
|
||||
var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
|
||||
|
||||
var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
|
||||
var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
|
||||
|
||||
var imported = await board.ImportAllAsync(CancellationToken.None);
|
||||
|
||||
logger.LogInformation(
|
||||
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
|
||||
+ "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
|
||||
imported, migratedCoordination, migratedScheduler, reset);
|
||||
|
||||
scanner.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
|
||||
}
|
||||
});
|
||||
|
||||
return (scanner, staging);
|
||||
}
|
||||
|
||||
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
|
||||
{
|
||||
if (TelegramClient is null || callbacks.TelegramLogin is null) return;
|
||||
|
||||
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
|
||||
|
||||
if (callbacks.Telegram2FA is not null)
|
||||
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
|
||||
|
||||
try
|
||||
{
|
||||
await TelegramClient.ConnectAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
|
||||
/// die einmalige Update-Prüfung.
|
||||
///
|
||||
/// <para>Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
|
||||
/// sie über <c>source</c> + <c>instance</c>. Stürzt eine von mehreren ab, fällt
|
||||
/// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.</para>
|
||||
/// </summary>
|
||||
private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
|
||||
{
|
||||
Deploymentcenter = DeploymentcenterService.TryCreate(
|
||||
Settings.AppSettings, AppVersion, loggerFactory);
|
||||
|
||||
if (Deploymentcenter is null)
|
||||
return;
|
||||
|
||||
_shutdown.Add(Deploymentcenter.DisposeAsync);
|
||||
|
||||
var health = new InstanceHealthProvider(
|
||||
Instance.InstanceName,
|
||||
agentsEnabled: Engine is not null,
|
||||
Instance.Budget,
|
||||
Usage,
|
||||
() => Instance.Agents.Count,
|
||||
() => Engine?.RunningChatCount ?? 0,
|
||||
// „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
|
||||
// Startabgleichung los, der erste Heartbeat geht sofort raus.
|
||||
schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
|
||||
|
||||
await Deploymentcenter.StartWatchdogAsync(
|
||||
Instance, health,
|
||||
saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
|
||||
ct);
|
||||
|
||||
// Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
|
||||
_ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
|
||||
/// Fehlermeldungen, Versionsvergleich. Kommt aus <c><Version></c> in
|
||||
/// <c>Directory.Build.props</c> und wird zur Übersetzungszeit eingebettet
|
||||
/// (<c>Deploymentcenter.BuildInfo.targets</c>) — zusammen mit Commit und Build-Datum.
|
||||
///
|
||||
/// <para>Nicht zu verwechseln mit <c>ClawdDotNet.Core.BuildInfo.Build</c>: das ist
|
||||
/// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.</para>
|
||||
/// </summary>
|
||||
public static string AppVersion => ReleaseInfo.Version;
|
||||
|
||||
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
|
||||
public static string BuildSummary => ReleaseInfo.Summary;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var step in _shutdown)
|
||||
{
|
||||
try { await step(); }
|
||||
catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
|
||||
}
|
||||
|
||||
if (Status is not null) await Status.DisposeAsync();
|
||||
if (Scanner is not null) await Scanner.DisposeAsync();
|
||||
if (TelegramClient is not null) await TelegramClient.DisposeAsync();
|
||||
|
||||
LoggerFactory.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.App</RootNamespace>
|
||||
|
||||
<!-- Erzeugt ClawdDotNet.App.ReleaseInfo (Version, Git-Commit, Build-Datum, Kanal).
|
||||
Bewusst nicht "BuildInfo": Diesen Namen traegt in ClawdDotNet.Core schon ein von
|
||||
Hand gefuehrter Zaehler mit Aenderungstext. Zwei gleichnamige Klassen mit
|
||||
verschiedener Bedeutung waeren eine Falle. -->
|
||||
<DeploymentcenterBuildInfoClass>ReleaseInfo</DeploymentcenterBuildInfoClass>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Version, Commit und Build-Datum zur Uebersetzungszeit einbetten. Vorher wurde die
|
||||
Version an drei Stellen erraten: fest "1.0.0" im SDK, "0.0.<Build>" fuer den
|
||||
Versionsvergleich und nichts am Heartbeat. -->
|
||||
<Import Project="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||
|
||||
<!-- Bewusst ohne Oberflaechen-Abhaengigkeit: Auf dieser Schicht setzen sowohl die
|
||||
Avalonia-Anwendung als auch der spaetere kopflose Host auf. Wer hier einen
|
||||
Verweis auf Avalonia oder WinForms ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Telegram\ClawdDotNet.Tools.Telegram.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Mail\ClawdDotNet.Tools.Mail.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FTP\ClawdDotNet.Tools.FTP.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.WebMonitor\ClawdDotNet.Tools.WebMonitor.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentComm\ClawdDotNet.Tools.AgentComm.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentSpawn\ClawdDotNet.Tools.AgentSpawn.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentEditor\ClawdDotNet.Tools.AgentEditor.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
|
||||
<!-- Deploymentcenter-SDK (Fremdrepo, netstandard2.0;net8.0). Liefert Hardware-ID v2,
|
||||
den verschluesselten Lizenz-Zwischenspeicher und die Update-Pruefung. Watchdog,
|
||||
Fehler-Stream und Bugtracker deckt es nicht ab — die stehen in
|
||||
ClawdDotNet.Core/Deploymentcenter. Cross-Repo-Pfad; langfristig als Git-Submodul
|
||||
unter external/ ablegen. -->
|
||||
<ProjectReference Include="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Models;
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Eintrag in der AgentList.json – Basisinformationen zu einem Agenten.
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ClawdDotNet.Models;
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Zusammenfassung einer Instanz für die Anzeige im InstanceManager.
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Models;
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
public sealed class JobHistoryEntry
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Models;
|
||||
namespace ClawdDotNet.App.Models;
|
||||
|
||||
public sealed class TokenUsageRecord
|
||||
{
|
||||
@@ -0,0 +1,566 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Models;
|
||||
|
||||
static class ConfigHelper
|
||||
{
|
||||
public static string GetString(Dictionary<string, object?> config, string key, string fallback = "")
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString() ?? fallback,
|
||||
JsonElement je => je.ToString(),
|
||||
string s => s,
|
||||
null => fallback,
|
||||
_ => val.ToString() ?? fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static int GetInt(Dictionary<string, object?> config, string key, int fallback = 0)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Number => je.GetInt32(),
|
||||
JsonElement je => int.TryParse(je.ToString(), out var r) ? r : fallback,
|
||||
int i => i,
|
||||
_ => int.TryParse(val?.ToString(), out var r) ? r : fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static bool GetBool(Dictionary<string, object?> config, string key, bool fallback = false)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind is JsonValueKind.True => true,
|
||||
JsonElement je when je.ValueKind is JsonValueKind.False => false,
|
||||
JsonElement je => bool.TryParse(je.ToString(), out var r) ? r : fallback,
|
||||
bool b => b,
|
||||
_ => bool.TryParse(val?.ToString(), out var r) ? r : fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetStringArray(Dictionary<string, object?> config, string key)
|
||||
{
|
||||
var val = config.GetValueOrDefault(key);
|
||||
return val switch
|
||||
{
|
||||
JsonElement je when je.ValueKind == JsonValueKind.Array =>
|
||||
string.Join(",", je.EnumerateArray().Select(e => e.GetString())),
|
||||
object[] arr => string.Join(",", arr),
|
||||
string s => s,
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public enum FileRWAccessLevel
|
||||
{
|
||||
Denied,
|
||||
Read,
|
||||
ReadWrite,
|
||||
Admin
|
||||
}
|
||||
|
||||
public sealed class FileRWToolSettings
|
||||
{
|
||||
[Category("Persönlicher Workspace")]
|
||||
[DisplayName("Erlaubte Endungen")]
|
||||
[Description("Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)")]
|
||||
public string PersonalAllowedExtensions { get; set; } = ".txt,.json,.md,.html,.js,.css";
|
||||
|
||||
[Category("Shared Workspace")]
|
||||
[DisplayName("Zugriffslevel")]
|
||||
[Description("Legt fest, welche Operationen im SharedWorkspace erlaubt sind")]
|
||||
public FileRWAccessLevel SharedAccessLevel { get; set; } = FileRWAccessLevel.Denied;
|
||||
|
||||
[Category("Shared Workspace")]
|
||||
[DisplayName("Erlaubte Endungen")]
|
||||
[Description("Dateiendungen für den geteilten Workspace")]
|
||||
public string SharedAllowedExtensions { get; set; } = ".txt,.json,.md";
|
||||
|
||||
[Category("Shared Workspace – Schutz")]
|
||||
[DisplayName("Geschützte Pfade")]
|
||||
[Description("Komma-getrennte Pfade im SharedWorkspace die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.")]
|
||||
public string ProtectedPaths { get; set; } = "stocks/";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["personalAllowedExtensions"] = PersonalAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
["sharedAccessLevel"] = SharedAccessLevel.ToString(),
|
||||
["sharedAllowedExtensions"] = SharedAllowedExtensions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
["protectedPaths"] = ProtectedPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static FileRWToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
PersonalAllowedExtensions = ConfigHelper.GetStringArray(config, "personalAllowedExtensions") is { Length: > 0 } s1
|
||||
? s1 : (ConfigHelper.GetStringArray(config, "allowedExtensions") is { Length: > 0 } sOld ? sOld : ".txt,.json,.md,.html,.js,.css"),
|
||||
|
||||
SharedAccessLevel = Enum.TryParse<FileRWAccessLevel>(ConfigHelper.GetString(config, "sharedAccessLevel"), true, out var level)
|
||||
? level : FileRWAccessLevel.Denied,
|
||||
|
||||
SharedAllowedExtensions = ConfigHelper.GetStringArray(config, "sharedAllowedExtensions") is { Length: > 0 } s2
|
||||
? s2 : ".txt,.json,.md",
|
||||
|
||||
ProtectedPaths = ConfigHelper.GetStringArray(config, "protectedPaths") is { Length: > 0 } s3
|
||||
? s3 : "stocks/"
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class MailToolSettings
|
||||
{
|
||||
[Category("Mail - Konto")]
|
||||
[DisplayName("Benutzername")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Category("Mail - Konto")]
|
||||
[DisplayName("Passwort")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Category("Mail - IMAP")]
|
||||
[DisplayName("IMAP-Host")]
|
||||
public string ImapHost { get; set; } = "";
|
||||
|
||||
[Category("Mail - IMAP")]
|
||||
[DisplayName("IMAP-Port")]
|
||||
public int ImapPort { get; set; } = 993;
|
||||
|
||||
[Category("Mail - SMTP")]
|
||||
[DisplayName("SMTP-Host")]
|
||||
public string SmtpHost { get; set; } = "";
|
||||
|
||||
[Category("Mail - SMTP")]
|
||||
[DisplayName("SMTP-Port")]
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
|
||||
[Category("Mail - Sicherheit")]
|
||||
[DisplayName("Erlaubte Empfänger")]
|
||||
[Description("Komma-getrennte Liste erlaubter E-Mail-Adressen")]
|
||||
public string AllowedRecipients { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["username"] = Username,
|
||||
["password"] = Password,
|
||||
["imapHost"] = ImapHost,
|
||||
["imapPort"] = ImapPort,
|
||||
["smtpHost"] = SmtpHost,
|
||||
["smtpPort"] = SmtpPort,
|
||||
["allowedRecipients"] = AllowedRecipients.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static MailToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
Username = ConfigHelper.GetString(config, "username"),
|
||||
Password = ConfigHelper.GetString(config, "password"),
|
||||
ImapHost = ConfigHelper.GetString(config, "imapHost"),
|
||||
ImapPort = ConfigHelper.GetInt(config, "imapPort", 993),
|
||||
SmtpHost = ConfigHelper.GetString(config, "smtpHost"),
|
||||
SmtpPort = ConfigHelper.GetInt(config, "smtpPort", 587),
|
||||
AllowedRecipients = ConfigHelper.GetStringArray(config, "allowedRecipients")
|
||||
};
|
||||
}
|
||||
|
||||
public enum DatabaseType
|
||||
{
|
||||
MySql,
|
||||
Postgres,
|
||||
MsSql,
|
||||
MongoDb
|
||||
}
|
||||
|
||||
public enum DatabaseAccessLevel
|
||||
{
|
||||
[Description("Nur Lesen (SELECT/find)")]
|
||||
ReadOnly,
|
||||
[Description("Lesen und Schreiben (INSERT/UPDATE/DELETE)")]
|
||||
ReadWrite,
|
||||
[Description("Vollzugriff (Admin/Schema-Änderungen)")]
|
||||
Admin
|
||||
}
|
||||
|
||||
public sealed class DatabaseToolSettings
|
||||
{
|
||||
[Category("Datenbank")]
|
||||
[DisplayName("Typ")]
|
||||
[Description("Der zu verwendende Datenbanktyp")]
|
||||
public DatabaseType Type { get; set; } = DatabaseType.MySql;
|
||||
|
||||
[Category("Datenbank")]
|
||||
[DisplayName("Connection-String")]
|
||||
public string ConnectionString { get; set; } = "";
|
||||
|
||||
[Category("Datenbank")]
|
||||
[DisplayName("Zugriffsebene")]
|
||||
[Description("Legt fest, welche Operationen der Agent ausführen darf")]
|
||||
public DatabaseAccessLevel AccessLevel { get; set; } = DatabaseAccessLevel.ReadOnly;
|
||||
|
||||
[Category("Datenbank - Sicherheit")]
|
||||
[DisplayName("Erlaubte Tabellen")]
|
||||
[Description("Komma-getrennte Liste erlaubter Tabellen/Collections")]
|
||||
public string AllowedTables { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["type"] = Type.ToString().ToLowerInvariant(),
|
||||
["connectionString"] = ConnectionString,
|
||||
["accessLevel"] = AccessLevel.ToString(),
|
||||
["allowedTables"] = AllowedTables.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static DatabaseToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
Type = Enum.TryParse<DatabaseType>(config.GetValueOrDefault("type")?.ToString(), true, out var result) ? result : DatabaseType.MySql,
|
||||
ConnectionString = config.GetValueOrDefault("connectionString")?.ToString() ?? "",
|
||||
AccessLevel = Enum.TryParse<DatabaseAccessLevel>(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level)
|
||||
? level
|
||||
: (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly),
|
||||
AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr
|
||||
? string.Join(",", arr)
|
||||
: config.GetValueOrDefault("allowedTables")?.ToString() ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class FTPToolSettings
|
||||
{
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Port")]
|
||||
public int Port { get; set; } = 21;
|
||||
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Benutzername")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[Category("FTP Server")]
|
||||
[DisplayName("Passwort")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
[Category("FTP Lokal")]
|
||||
[DisplayName("Root-Pfad")]
|
||||
[Description("Basisverzeichnis für Dateiübertragungen")]
|
||||
public string RootPath { get; set; } = "./data/";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["host"] = Host,
|
||||
["port"] = Port,
|
||||
["username"] = Username,
|
||||
["password"] = Password,
|
||||
["rootPath"] = RootPath
|
||||
};
|
||||
|
||||
public static FTPToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
Host = ConfigHelper.GetString(config, "host"),
|
||||
Port = ConfigHelper.GetInt(config, "port", 21),
|
||||
Username = ConfigHelper.GetString(config, "username"),
|
||||
Password = ConfigHelper.GetString(config, "password"),
|
||||
RootPath = ConfigHelper.GetString(config, "rootPath", "./data/")
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class TelegramToolSettings
|
||||
{
|
||||
[Category("Telegram")]
|
||||
[DisplayName("Bot-Token")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string BotToken { get; set; } = "";
|
||||
|
||||
[Category("Telegram")]
|
||||
[DisplayName("Standard Chat-ID")]
|
||||
[Description("Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.")]
|
||||
public string DefaultChatId { get; set; } = "";
|
||||
|
||||
[Category("Telegram - Sicherheit")]
|
||||
[DisplayName("Erlaubte Chat-IDs")]
|
||||
[Description("Komma-getrennte Liste erlaubter Chat-IDs")]
|
||||
public string AllowedChatIds { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["botToken"] = BotToken,
|
||||
["defaultChatId"] = DefaultChatId,
|
||||
["allowedChatIds"] = AllowedChatIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
};
|
||||
|
||||
public static TelegramToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
BotToken = ConfigHelper.GetString(config, "botToken"),
|
||||
DefaultChatId = ConfigHelper.GetString(config, "defaultChatId"),
|
||||
AllowedChatIds = ConfigHelper.GetStringArray(config, "allowedChatIds")
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class DirectAPIToolSettings
|
||||
{
|
||||
[Category("DirectAPI")]
|
||||
[DisplayName("Standard-Provider")]
|
||||
public string DefaultProvider { get; set; } = "twelvedata";
|
||||
|
||||
[Category("DirectAPI")]
|
||||
[DisplayName("Cache TTL (Sekunden)")]
|
||||
public int CacheTtlSeconds { get; set; } = 60;
|
||||
|
||||
[Category("DirectAPI - API Keys")]
|
||||
[DisplayName("Twelve Data Key")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string TwelveDataKey { get; set; } = "";
|
||||
|
||||
[Category("DirectAPI - API Keys")]
|
||||
[DisplayName("Alpha Vantage Key")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string AlphaVantageKey { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["defaultProvider"] = DefaultProvider,
|
||||
["cacheTtlSeconds"] = CacheTtlSeconds,
|
||||
["providers"] = new Dictionary<string, object?>
|
||||
{
|
||||
["twelvedata"] = new { apiKey = TwelveDataKey },
|
||||
["alphavantage"] = new { apiKey = AlphaVantageKey }
|
||||
}
|
||||
};
|
||||
|
||||
public static DirectAPIToolSettings FromConfig(Dictionary<string, object?> config)
|
||||
{
|
||||
var settings = new DirectAPIToolSettings
|
||||
{
|
||||
DefaultProvider = ConfigHelper.GetString(config, "defaultProvider", "twelvedata"),
|
||||
CacheTtlSeconds = ConfigHelper.GetInt(config, "cacheTtlSeconds", 60)
|
||||
};
|
||||
|
||||
if (config.GetValueOrDefault("providers") is JsonElement providersJe)
|
||||
{
|
||||
var providers = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(providersJe.GetRawText());
|
||||
if (providers != null)
|
||||
{
|
||||
if (providers.TryGetValue("twelvedata", out var td) && td.TryGetProperty("apiKey", out var tdk))
|
||||
settings.TwelveDataKey = tdk.GetString() ?? "";
|
||||
if (providers.TryGetValue("alphavantage", out var av) && av.TryGetProperty("apiKey", out var avk))
|
||||
settings.AlphaVantageKey = avk.GetString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class WebFetchToolSettings
|
||||
{
|
||||
[Category("WebFetch")]
|
||||
[DisplayName("Erlaubte Domains")]
|
||||
[Description("Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)")]
|
||||
public string AllowedDomains { get; set; } = "";
|
||||
|
||||
[Category("WebFetch")]
|
||||
[DisplayName("Max Response KB")]
|
||||
public int MaxResponseKb { get; set; } = 512;
|
||||
|
||||
[Category("WebFetch")]
|
||||
[DisplayName("User Agent")]
|
||||
public string UserAgent { get; set; } = "ClawdDotNet-Agent/1.0";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["allowedDomains"] = AllowedDomains.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
["maxResponseKb"] = MaxResponseKb,
|
||||
["userAgent"] = UserAgent
|
||||
};
|
||||
|
||||
public static WebFetchToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
AllowedDomains = ConfigHelper.GetStringArray(config, "allowedDomains"),
|
||||
MaxResponseKb = ConfigHelper.GetInt(config, "maxResponseKb", 512),
|
||||
UserAgent = ConfigHelper.GetString(config, "userAgent", "ClawdDotNet-Agent/1.0")
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class WebMonitorToolSettings
|
||||
{
|
||||
[Category("WebMonitor")]
|
||||
[DisplayName("Monitore (JSON)")]
|
||||
[Description("JSON-Konfiguration der Monitore")]
|
||||
public string MonitorsJson { get; set; } = "{}";
|
||||
|
||||
public Dictionary<string, object?> ToConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["monitors"] = JsonSerializer.Deserialize<Dictionary<string, object?>>(MonitorsJson) ?? new()
|
||||
};
|
||||
}
|
||||
catch { return new Dictionary<string, object?> { ["monitors"] = new Dictionary<string, object?>() }; }
|
||||
}
|
||||
|
||||
public static WebMonitorToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
MonitorsJson = config.GetValueOrDefault("monitors") is JsonElement je ? je.GetRawText() : "{}"
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class AgentCommToolSettings
|
||||
{
|
||||
[Category("AgentComm")]
|
||||
[DisplayName("Info")]
|
||||
[Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.")]
|
||||
[ReadOnly(true)]
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new();
|
||||
|
||||
public static AgentCommToolSettings FromConfig(Dictionary<string, object?> config) => new();
|
||||
}
|
||||
|
||||
public sealed class SocialMediaManagerToolSettings
|
||||
{
|
||||
// ─── X (Twitter) ───
|
||||
|
||||
[Category("1. X (Twitter) - API")]
|
||||
[DisplayName("Bearer Token")]
|
||||
[Description("X API v2 Bearer Token für die Authentifizierung")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string XApiKey { get; set; } = "";
|
||||
|
||||
[Category("1. X (Twitter) - Monitoring")]
|
||||
[DisplayName("Überwachte Accounts")]
|
||||
[Description("Komma-getrennte Liste von X-Accounts die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone")]
|
||||
public string XWatchAccounts { get; set; } = "";
|
||||
|
||||
// ─── Reddit ───
|
||||
|
||||
[Category("2. Reddit - Monitoring")]
|
||||
[DisplayName("Überwachte Subreddits")]
|
||||
[Description("Komma-getrennte Liste von Subreddits die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options")]
|
||||
public string RedditWatchSubreddits { get; set; } = "";
|
||||
|
||||
[Category("2. Reddit - Monitoring")]
|
||||
[DisplayName("Posts pro Subreddit")]
|
||||
[Description("Maximale Anzahl Posts die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)")]
|
||||
public int RedditPostLimit { get; set; } = 15;
|
||||
|
||||
// ─── YouTube / STT ───
|
||||
|
||||
[Category("3. YouTube / STT")]
|
||||
[DisplayName("OpenRouter API Key")]
|
||||
[Description("API Key für Speech-to-Text Transkription über OpenRouter")]
|
||||
[PasswordPropertyText(true)]
|
||||
public string OpenRouterApiKey { get; set; } = "";
|
||||
|
||||
[Category("3. YouTube / STT")]
|
||||
[DisplayName("STT Modell")]
|
||||
[Description("OpenRouter Modell-ID für die Transkription")]
|
||||
public string STTModel { get; set; } = "openai/whisper-1";
|
||||
|
||||
[Category("3. YouTube / STT")]
|
||||
[DisplayName("YouTube Kanäle")]
|
||||
[Description("Komma-getrennte Liste von YouTube Kanal-URLs für automatische Überwachung")]
|
||||
public string YoutubeChannels { get; set; } = "";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new()
|
||||
{
|
||||
["xApiKey"] = XApiKey,
|
||||
["xWatchAccounts"] = SplitToArray(XWatchAccounts),
|
||||
["redditWatchSubreddits"] = SplitToArray(RedditWatchSubreddits),
|
||||
["redditPostLimit"] = RedditPostLimit,
|
||||
["openRouterApiKey"] = OpenRouterApiKey,
|
||||
["sttModel"] = STTModel,
|
||||
["youtubeChannels"] = SplitToArray(YoutubeChannels)
|
||||
};
|
||||
|
||||
public static SocialMediaManagerToolSettings FromConfig(Dictionary<string, object?> config) => new()
|
||||
{
|
||||
XApiKey = ConfigHelper.GetString(config, "xApiKey"),
|
||||
XWatchAccounts = ConfigHelper.GetStringArray(config, "xWatchAccounts"),
|
||||
RedditWatchSubreddits = ConfigHelper.GetStringArray(config, "redditWatchSubreddits"),
|
||||
RedditPostLimit = ConfigHelper.GetInt(config, "redditPostLimit", 15),
|
||||
OpenRouterApiKey = ConfigHelper.GetString(config, "openRouterApiKey"),
|
||||
STTModel = ConfigHelper.GetString(config, "sttModel", "openai/whisper-1"),
|
||||
YoutubeChannels = ConfigHelper.GetStringArray(config, "youtubeChannels")
|
||||
};
|
||||
|
||||
private static string[] SplitToArray(string csv)
|
||||
=> csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
public sealed class AgentEditorToolSettings
|
||||
{
|
||||
[Category("AgentEditor")]
|
||||
[DisplayName("Info")]
|
||||
[Description("Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen, zu bearbeiten und neue Agenten zu erstellen. Keine weitere Konfiguration nötig.")]
|
||||
[ReadOnly(true)]
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new();
|
||||
|
||||
public static AgentEditorToolSettings FromConfig(Dictionary<string, object?> config) => new();
|
||||
}
|
||||
|
||||
public sealed class AgentSpawnToolSettings
|
||||
{
|
||||
[Category("AgentSpawn")]
|
||||
[DisplayName("Info")]
|
||||
[Description("Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.")]
|
||||
[ReadOnly(true)]
|
||||
public string Status { get; set; } = "Aktiv";
|
||||
|
||||
public Dictionary<string, object?> ToConfig() => new();
|
||||
|
||||
public static AgentSpawnToolSettings FromConfig(Dictionary<string, object?> config) => new();
|
||||
}
|
||||
|
||||
public static class ToolSettingsFactory
|
||||
{
|
||||
public static object? CreateViewModel(string toolName, Dictionary<string, object?>? config)
|
||||
{
|
||||
config ??= new();
|
||||
return toolName switch
|
||||
{
|
||||
"FileRW" => FileRWToolSettings.FromConfig(config),
|
||||
"Mail" => MailToolSettings.FromConfig(config),
|
||||
"Database" => DatabaseToolSettings.FromConfig(config),
|
||||
"Telegram" => TelegramToolSettings.FromConfig(config),
|
||||
"FTP" => FTPToolSettings.FromConfig(config),
|
||||
"DirectAPI" => DirectAPIToolSettings.FromConfig(config),
|
||||
"WebFetch" => WebFetchToolSettings.FromConfig(config),
|
||||
"WebMonitor" => WebMonitorToolSettings.FromConfig(config),
|
||||
"AgentComm" => AgentCommToolSettings.FromConfig(config),
|
||||
"SocialMediaManager" => SocialMediaManagerToolSettings.FromConfig(config),
|
||||
"AgentSpawn" => AgentSpawnToolSettings.FromConfig(config),
|
||||
"AgentEditor" => AgentEditorToolSettings.FromConfig(config),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?>? ToConfig(string toolName, object? viewModel)
|
||||
{
|
||||
return viewModel switch
|
||||
{
|
||||
FileRWToolSettings f => f.ToConfig(),
|
||||
MailToolSettings m => m.ToConfig(),
|
||||
DatabaseToolSettings d => d.ToConfig(),
|
||||
TelegramToolSettings t => t.ToConfig(),
|
||||
FTPToolSettings ftp => ftp.ToConfig(),
|
||||
DirectAPIToolSettings dapi => dapi.ToConfig(),
|
||||
WebFetchToolSettings wf => wf.ToConfig(),
|
||||
WebMonitorToolSettings wm => wm.ToConfig(),
|
||||
AgentCommToolSettings ac => ac.ToConfig(),
|
||||
SocialMediaManagerToolSettings smm => smm.ToConfig(),
|
||||
AgentSpawnToolSettings asp => asp.ToConfig(),
|
||||
AgentEditorToolSettings ae => ae.ToConfig(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,9 +2,9 @@ using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Models;
|
||||
using ClawdDotNet.App.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Models;
|
||||
using ClawdDotNet.App.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe JSON persistence for job execution history.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
-10
@@ -4,12 +4,25 @@ using System.Text.Json;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
|
||||
namespace ClawdDotNet.Services;
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
public sealed class OpenRouterStatusService : IDisposable
|
||||
/// <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 System.Windows.Forms.Timer _timer;
|
||||
private readonly TimeSpan _interval;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
|
||||
|
||||
@@ -40,17 +53,26 @@ public sealed class OpenRouterStatusService : IDisposable
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
|
||||
|
||||
_timer = new System.Windows.Forms.Timer { Interval = checkIntervalSeconds * 1000 };
|
||||
_timer.Tick += async (_, _) => await CheckStatusAsync();
|
||||
_interval = TimeSpan.FromSeconds(checkIntervalSeconds);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_timer.Start();
|
||||
_loop ??= RunLoopAsync(_cts.Token);
|
||||
_ = CheckStatusAsync();
|
||||
}
|
||||
|
||||
public void Stop() => _timer.Stop();
|
||||
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)
|
||||
{
|
||||
@@ -238,10 +260,17 @@ public sealed class OpenRouterStatusService : IDisposable
|
||||
CreditsTooltip = sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Dispose();
|
||||
await _cts.CancelAsync();
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
_http.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Anwendungsweite Einstellungen (nicht instanzgebunden).
|
||||
///
|
||||
/// Die <see cref="CategoryAttribute"/>-, <see cref="DisplayNameAttribute"/>- und
|
||||
/// <see cref="DescriptionAttribute"/>-Angaben stammen aus der PropertyGrid-Zeit. Sie
|
||||
/// bleiben stehen: Sie sind die Beschriftungen und Hilfetexte, aus denen die
|
||||
/// Avalonia-Einstellungsansicht gebaut wird — nur eben von Hand statt automatisch.
|
||||
/// </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");
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Instanzen-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
|
||||
[JsonPropertyName("instancesDirectory")]
|
||||
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
|
||||
|
||||
// DefaultConfigPath ist ersatzlos entfallen. Die Eigenschaft war als „(Legacy)"
|
||||
// markiert und wurde von keiner Stelle mehr gelesen — sie stand nur noch als
|
||||
// relativer Pfad in der Datei und hätte unter Linux ohnehin ins Leere gezeigt.
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Minimaler Log-Level")]
|
||||
[Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
|
||||
[JsonPropertyName("minimumLogLevel")]
|
||||
public string MinimumLogLevel { get; set; } = "Info";
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Max. Log-Zeilen in UI")]
|
||||
[Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
|
||||
[JsonPropertyName("maxLogLinesInUi")]
|
||||
public int MaxLogLinesInUi { get; set; } = 2000;
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Log-Aktualisierungsintervall (ms)")]
|
||||
[Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
|
||||
[JsonPropertyName("logRefreshIntervalMs")]
|
||||
public int LogRefreshIntervalMs { get; set; } = 500;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("Status-Check-Intervall (Sek)")]
|
||||
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
|
||||
[JsonPropertyName("statusCheckIntervalSeconds")]
|
||||
public int StatusCheckIntervalSeconds { get; set; } = 60;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("OpenRouter Base-URL")]
|
||||
[Description("Basis-URL der OpenRouter-API.")]
|
||||
[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;
|
||||
|
||||
// ─── Deploymentcenter ───
|
||||
//
|
||||
// Ein Server, ein Token. Lizenz, Watchdog, Updates, Fehler-Stream und Bugtracker
|
||||
// sind Module derselben Anwendung — die frühere Aufteilung auf zwei Adressen
|
||||
// (watchdog.mhdf.de, license.mhdf.de) mit je eigenem Schlüssel gibt es nicht mehr.
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Server-URL")]
|
||||
[Description("Basis-URL des Deploymentcenters (nur HTTPS, Ausnahme localhost).")]
|
||||
[JsonPropertyName("deploymentcenterUrl")]
|
||||
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Token")]
|
||||
[Description("Master-Token mit den Rechten 'watchdog:ping' und 'bugtracker:report'. " +
|
||||
"Jede Instanz tauscht es beim ersten Start gegen ein eigenes, " +
|
||||
"eingeschränktes Sub-Token. Wird verschlüsselt gespeichert.")]
|
||||
[PasswordPropertyText(true)]
|
||||
[JsonPropertyName("deploymentcenterToken")]
|
||||
public string DeploymentcenterToken { get; set; } = "";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Umgebung")]
|
||||
[Description("Wird an Fehler- und Bugtracker-Meldungen gehängt: production oder development.")]
|
||||
[JsonPropertyName("deploymentcenterEnvironment")]
|
||||
public string DeploymentcenterEnvironment { get; set; } = "production";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Fehler automatisch melden")]
|
||||
[Description("Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. " +
|
||||
"Derselbe Fehler geht höchstens alle fünf Minuten einmal raus.")]
|
||||
[JsonPropertyName("errorReportingEnabled")]
|
||||
public bool ErrorReportingEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Beim Start auf Updates prüfen")]
|
||||
[Description("Fragt einmalig beim Start, ob ein neueres Release vorliegt. Blockiert nicht.")]
|
||||
[JsonPropertyName("updateCheckEnabled")]
|
||||
public bool UpdateCheckEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Update-Kanal")]
|
||||
[Description("prod, beta oder dev.")]
|
||||
[JsonPropertyName("updateChannel")]
|
||||
public string UpdateChannel { get; set; } = "prod";
|
||||
|
||||
// ─── Lizenz ───
|
||||
|
||||
[Category("Lizenz")]
|
||||
[DisplayName("Lizenzschlüssel")]
|
||||
[Description("Der Lizenzschlüssel für ClawdDotNet. Wird verschlüsselt gespeichert.")]
|
||||
[PasswordPropertyText(true)]
|
||||
[JsonPropertyName("licenseKey")]
|
||||
public string LicenseKey { get; set; } = "";
|
||||
|
||||
// LicensePublicKeyBase64 und LicenseEndpoints sind entfallen. Das Deploymentcenter
|
||||
// signiert seine Antworten nicht (siehe LicenseInfo), ein Public-Key hätte also
|
||||
// nichts zu prüfen; und der Lizenzserver ist dasselbe Deploymentcenter, dessen
|
||||
// Adresse oben steht — zwei Felder für eine Adresse waren nur eine Gelegenheit,
|
||||
// sie widersprüchlich zu füllen.
|
||||
|
||||
public override string ToString() => "Anwendungseinstellungen";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt und speichert die anwendungsweiten Einstellungen.
|
||||
///
|
||||
/// <para><b>Ablageort.</b> Bis zur Linux-Portierung lag <c>Settings.json</c> neben der
|
||||
/// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt
|
||||
/// die Anwendung in <c>/opt</c> oder <c>/usr/lib</c> und ist für den Dienstbenutzer nicht
|
||||
/// beschreibbar. Jetzt entscheidet <see cref="AppPaths.ConfigDirectory"/> — XDG unter
|
||||
/// Linux, <c>%APPDATA%</c> unter Windows, per <c>CLAWD_CONFIG_DIR</c> überschreibbar.</para>
|
||||
///
|
||||
/// <para><b>Kein Migrationspfad.</b> Bewusst: Zum Zeitpunkt der Umstellung lief noch
|
||||
/// keine Installation produktiv. Eine bestehende <c>Settings.json</c> neben der
|
||||
/// Programmdatei wird also <em>nicht</em> übernommen — der Ort wechselt einmal sauber,
|
||||
/// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst.</para>
|
||||
///
|
||||
/// <para><b>Keine Meldungsfenster.</b> Diese Schicht kennt keine Oberfläche. Ein
|
||||
/// Speicherfehler kommt als <see cref="SettingsPersistenceException"/> heraus; ob daraus
|
||||
/// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer.</para>
|
||||
/// </summary>
|
||||
public sealed class SettingsManager
|
||||
{
|
||||
private const string SettingsFileName = "Settings.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public AppSettings AppSettings { get; private set; } = new();
|
||||
|
||||
/// <summary>Der Ort der Einstellungsdatei — für Meldungen und Diagnose.</summary>
|
||||
public string SettingsPath => _settingsPath;
|
||||
|
||||
public SettingsManager(string? basePath = null)
|
||||
{
|
||||
var dir = basePath ?? AppPaths.ConfigDirectory;
|
||||
_settingsPath = Path.Combine(dir, SettingsFileName);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = AtomicFile.ReadAllText(_settingsPath);
|
||||
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
|
||||
?? new AppSettings();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
// Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben
|
||||
// weiterzumachen ist besser, als gar nicht zu starten.
|
||||
AppSettings = new AppSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im
|
||||
// Klartext gehalten.
|
||||
AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey);
|
||||
AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// unbrauchbaren Wert an eine Gegenstelle zu schicken.
|
||||
/// </summary>
|
||||
private static string TryUnprotect(string value)
|
||||
{
|
||||
try { return SecretProtector.Unprotect(value) ?? ""; }
|
||||
catch (SecretProtectionException) { return ""; }
|
||||
}
|
||||
|
||||
/// <exception cref="SettingsPersistenceException">Wenn die Datei nicht geschrieben werden kann.</exception>
|
||||
public void Save()
|
||||
{
|
||||
// Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext.
|
||||
var plainLicenseKey = AppSettings.LicenseKey;
|
||||
var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken;
|
||||
|
||||
try
|
||||
{
|
||||
AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? "";
|
||||
AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? "";
|
||||
|
||||
AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!);
|
||||
|
||||
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
|
||||
AtomicFile.WriteAllText(_settingsPath, json);
|
||||
AppPaths.RestrictToOwner(_settingsPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or SecretProtectionException)
|
||||
{
|
||||
throw new SettingsPersistenceException(
|
||||
$"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AppSettings.LicenseKey = plainLicenseKey;
|
||||
AppSettings.DeploymentcenterToken = plainDeploymentcenterToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SettingsPersistenceException(string message, Exception inner)
|
||||
: Exception(message, inner);
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>Ausgang eines Tool-Aufrufs, von der Engine festgestellt.</summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
/// <summary>Tool lief und lieferte ein Ergebnis.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>Tool meldete einen Fehler oder warf eine Ausnahme.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Das <see cref="Security.PermissionGate"/> hat den Aufruf abgelehnt.</summary>
|
||||
Denied,
|
||||
|
||||
/// <summary>Das angeforderte Tool ist dem Agenten nicht zugewiesen/unbekannt.</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.</summary>
|
||||
Staged
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag im Audit-Log: ein einzelner Tool-Aufruf, wie die Engine ihn gesehen hat.
|
||||
///
|
||||
/// Die Herkunft wird von der <b>Engine gestempelt</b>, nie vom Agenten behauptet:
|
||||
/// <see cref="AgentId"/>, <see cref="Model"/> und <see cref="Source"/> stammen aus dem
|
||||
/// Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Einträge sind
|
||||
/// unveränderlich — eine Korrektur ist ein neuer Eintrag, kein Überschreiben.
|
||||
/// </summary>
|
||||
public sealed record AuditEntry
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.</summary>
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
public string AgentId { get; init; } = "";
|
||||
|
||||
/// <summary>Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).</summary>
|
||||
public string Model { get; init; } = "";
|
||||
|
||||
/// <summary>Verantwortliche Session/Kanal (webview, telegram, task …). <c>unknown</c>,
|
||||
/// wenn nicht bekannt — geraten wird nichts.</summary>
|
||||
public string Source { get; init; } = AuditSource.Unknown;
|
||||
|
||||
public string Tool { get; init; } = "";
|
||||
|
||||
/// <summary>Übergebene Argumente (gekappt). Rohdaten, wie das Modell sie schickte.</summary>
|
||||
public string Arguments { get; init; } = "";
|
||||
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>Kurze Notiz zum Ausgang (Fehlermeldung, knapper Hinweis).</summary>
|
||||
public string Summary { get; init; } = "";
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abschluss-Beleg eines Laufs (Receipt): das Ergebnis mit Schritten, Tokens und Kosten.
|
||||
/// Verknüpft <c>RunUsage</c> mit einem Task und macht so C7 („Kosten pro Ergebnis")
|
||||
/// weitgehend zum Abfallprodukt.
|
||||
/// </summary>
|
||||
public sealed record RunReceipt
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string RunId { get; init; } = "";
|
||||
public string AgentId { get; init; } = "";
|
||||
public string Model { get; init; } = "";
|
||||
public string Source { get; init; } = AuditSource.Unknown;
|
||||
|
||||
/// <summary>Verknüpfter Task, falls der Lauf aus dem Taskboard kam — sonst <c>null</c>.</summary>
|
||||
public string? TaskId { get; init; }
|
||||
|
||||
/// <summary>Endzustand (aus <c>AgentRunStatus</c>).</summary>
|
||||
public string Status { get; init; } = "";
|
||||
|
||||
public int StepCount { get; init; }
|
||||
public int PromptTokens { get; init; }
|
||||
public int CompletionTokens { get; init; }
|
||||
public int CachedTokens { get; init; }
|
||||
|
||||
public decimal CostUsd { get; init; }
|
||||
public bool CostIsKnown { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>Kurzer Verweis auf das Ergebnis (gekappte Schlussnachricht).</summary>
|
||||
public string ResultRef { get; init; } = "";
|
||||
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit
|
||||
/// <c>ChatSource</c>; <see cref="Unknown"/> steht für „nicht bekannt", nicht für geraten.</summary>
|
||||
public static class AuditSource
|
||||
{
|
||||
public const string Unknown = "unknown";
|
||||
|
||||
/// <summary>Ein direkter, quellenloser Lauf (z. B. RunAsync ohne Kanal).</summary>
|
||||
public const string Direct = "direct";
|
||||
|
||||
/// <summary>Ausführung eines freigegebenen, eingefrorenen Aufrufs (A2).</summary>
|
||||
public const string Approval = "approval";
|
||||
|
||||
public static string Normalize(string? source)
|
||||
=> string.IsNullOrWhiteSpace(source) ? Unknown : source.Trim();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Das Audit-Log (A3): append-only. Es gibt kein Ändern und kein Löschen — Korrekturen
|
||||
/// sind neue Einträge. Das ist die bewusste Designregel, nicht eine fehlende Funktion.
|
||||
/// </summary>
|
||||
public interface IAuditRepository
|
||||
{
|
||||
/// <summary>Schreibt einen Tool-Aufruf ins Log.</summary>
|
||||
Task AppendAsync(AuditEntry entry, CancellationToken ct);
|
||||
|
||||
/// <summary>Hält den Abschluss-Beleg eines Laufs fest.</summary>
|
||||
Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct);
|
||||
|
||||
/// <summary>Die jüngsten Log-Einträge (für eine Übersicht/Diagnose).</summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListRecentAsync(int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>Alle Aufrufe eines Laufs, in zeitlicher Reihenfolge.</summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct);
|
||||
|
||||
/// <summary>Der Abschluss-Beleg eines Laufs, falls vorhanden.</summary>
|
||||
Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct);
|
||||
|
||||
/// <summary>Alle Belege zu einem Task — die Kosten pro Ergebnis (C7).</summary>
|
||||
Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct);
|
||||
|
||||
/// <summary>Zahl der Log-Einträge insgesamt.</summary>
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Das Audit-Log in der Instanz-Datenbank. Bewusst nur Einfügen und Lesen — es gibt keine
|
||||
/// Update-/Delete-Methoden, weil die Unveränderlichkeit die eigentliche Zusage ist.
|
||||
/// </summary>
|
||||
public sealed class SqliteAuditRepository : IAuditRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteAuditRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task AppendAsync(AuditEntry entry, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO AuditLog
|
||||
(RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", entry.RunId);
|
||||
cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
|
||||
cmd.Parameters.AddWithValue("@model", entry.Model);
|
||||
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
|
||||
cmd.Parameters.AddWithValue("@tool", entry.Tool);
|
||||
cmd.Parameters.AddWithValue("@arguments", entry.Arguments);
|
||||
cmd.Parameters.AddWithValue("@status", entry.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("@summary", entry.Summary);
|
||||
cmd.Parameters.AddWithValue("@durationMs", entry.DurationMs);
|
||||
cmd.Parameters.AddWithValue("@occurredAt", Format(entry.OccurredAt));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO RunReceipts
|
||||
(RunId, AgentId, Model, Source, TaskId, Status, StepCount,
|
||||
PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown,
|
||||
DurationMs, ResultRef, OccurredAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @model, @source, @taskId, @status, @stepCount,
|
||||
@prompt, @completion, @cached, @cost, @costKnown,
|
||||
@durationMs, @resultRef, @occurredAt)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", receipt.RunId);
|
||||
cmd.Parameters.AddWithValue("@agentId", receipt.AgentId);
|
||||
cmd.Parameters.AddWithValue("@model", receipt.Model);
|
||||
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(receipt.Source));
|
||||
cmd.Parameters.AddWithValue("@taskId", (object?)receipt.TaskId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@status", receipt.Status);
|
||||
cmd.Parameters.AddWithValue("@stepCount", receipt.StepCount);
|
||||
cmd.Parameters.AddWithValue("@prompt", receipt.PromptTokens);
|
||||
cmd.Parameters.AddWithValue("@completion", receipt.CompletionTokens);
|
||||
cmd.Parameters.AddWithValue("@cached", receipt.CachedTokens);
|
||||
cmd.Parameters.AddWithValue("@cost", receipt.CostUsd.ToString(CultureInfo.InvariantCulture));
|
||||
cmd.Parameters.AddWithValue("@costKnown", receipt.CostIsKnown ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@durationMs", receipt.DurationMs);
|
||||
cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef);
|
||||
cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog ORDER BY Id DESC LIMIT @limit";
|
||||
cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id";
|
||||
cmd.Parameters.AddWithValue("@runId", runId);
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
public async Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@runId", runId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE TaskId = @taskId ORDER BY Id";
|
||||
cmd.Parameters.AddWithValue("@taskId", taskId);
|
||||
|
||||
var results = new List<RunReceipt>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(ReadReceipt(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string AuditColumns =
|
||||
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt";
|
||||
|
||||
private const string ReceiptColumns =
|
||||
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
|
||||
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt";
|
||||
|
||||
private static async Task<IReadOnlyList<AuditEntry>> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct)
|
||||
{
|
||||
var results = new List<AuditEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(ReadEntry(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
private static AuditEntry ReadEntry(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetInt64(0),
|
||||
RunId = r.GetString(1),
|
||||
AgentId = r.GetString(2),
|
||||
Model = r.GetString(3),
|
||||
Source = r.GetString(4),
|
||||
Tool = r.GetString(5),
|
||||
Arguments = r.GetString(6),
|
||||
Status = Enum.TryParse<AuditStatus>(r.GetString(7), out var s) ? s : AuditStatus.Ok,
|
||||
Summary = r.GetString(8),
|
||||
DurationMs = r.GetInt64(9),
|
||||
OccurredAt = Parse(r.GetString(10))
|
||||
};
|
||||
|
||||
private static RunReceipt ReadReceipt(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetInt64(0),
|
||||
RunId = r.GetString(1),
|
||||
AgentId = r.GetString(2),
|
||||
Model = r.GetString(3),
|
||||
Source = r.GetString(4),
|
||||
TaskId = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
Status = r.GetString(6),
|
||||
StepCount = r.GetInt32(7),
|
||||
PromptTokens = r.GetInt32(8),
|
||||
CompletionTokens = r.GetInt32(9),
|
||||
CachedTokens = r.GetInt32(10),
|
||||
CostUsd = decimal.TryParse(r.GetString(11), NumberStyles.Any, CultureInfo.InvariantCulture, out var c) ? c : 0m,
|
||||
CostIsKnown = r.GetInt32(12) != 0,
|
||||
DurationMs = r.GetInt64(13),
|
||||
ResultRef = r.GetString(14),
|
||||
OccurredAt = Parse(r.GetString(15))
|
||||
};
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -377,11 +377,10 @@ public sealed class BackupService
|
||||
var root = Path.GetFullPath(targetDir);
|
||||
var full = Path.GetFullPath(Path.Combine(root, relative));
|
||||
|
||||
var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
|
||||
? root
|
||||
: root + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||
// Der Vergleich muss dem Dateisystem folgen: Unter Linux sind "Ziel" und "ziel"
|
||||
// zwei Verzeichnisse, und ein Eintrag darf auch nicht über eine symbolische
|
||||
// Verknüpfung hinauszeigen. Beides steckt in PathBoundary.
|
||||
if (!PathBoundary.IsInside(full, root))
|
||||
throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus.");
|
||||
|
||||
return full;
|
||||
|
||||
@@ -188,6 +188,14 @@ public sealed class LoopGuardConfig
|
||||
[JsonPropertyName("maxContextTokens")]
|
||||
public int MaxContextTokens { get; set; } = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Obergrenze für die Ausgabe eines einzelnen Schritts (<c>max_tokens</c> im Request).
|
||||
/// Deckelt die teuerste Token-Art gegen Ausreißer (B11/T8). 0 = keine Angabe, dann gilt
|
||||
/// der Standard des Anbieters.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxResponseTokens")]
|
||||
public int MaxResponseTokens { get; set; } = 8_192;
|
||||
|
||||
[JsonPropertyName("compactionThreshold")]
|
||||
public double CompactionThreshold { get; set; } = 0.80;
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("telegramClient")]
|
||||
public TelegramClientConfig? TelegramClient { get; set; }
|
||||
|
||||
/// <summary>Anbindung an das Watchdog-Modul des Deploymentcenters (Instanz-Heartbeat).</summary>
|
||||
[JsonPropertyName("watchdog")]
|
||||
public WatchdogConfig Watchdog { get; set; } = new();
|
||||
|
||||
/// <summary>Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze.</summary>
|
||||
[JsonPropertyName("budget")]
|
||||
public InstanceBudget Budget { get; set; } = new();
|
||||
|
||||
@@ -34,6 +34,7 @@ public static class BuiltInServices
|
||||
public const string AgentChatWebUI = "AgentChatWebUI";
|
||||
public const string AgentWebsite = "AgentWebsite";
|
||||
public const string ClawdDotNetApi = "ClawdDotNetApi";
|
||||
public const string InstanceWatchdog = "InstanceWatchdog";
|
||||
|
||||
public static List<ServiceConfig> CreateDefaults() =>
|
||||
[
|
||||
@@ -69,6 +70,17 @@ public static class BuiltInServices
|
||||
AutoStart = true,
|
||||
BuiltIn = true,
|
||||
Description = "REST-API für die Kommunikation mit der WebApp"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ServiceId = "svc_watchdog",
|
||||
Name = "Instanz-Watchdog",
|
||||
Type = InstanceWatchdog,
|
||||
Port = 0,
|
||||
Enabled = false,
|
||||
AutoStart = true,
|
||||
BuiltIn = true,
|
||||
Description = "Sendet Heartbeats ans Deploymentcenter (URL/Token in den Anwendungseinstellungen)"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Core.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Pro-Instanz-Teil der Watchdog-Anbindung ans Deploymentcenter.
|
||||
///
|
||||
/// <para><b>Ein Monitor je Instanz.</b> Der Monitor wird serverseitig über das Paar
|
||||
/// <c>source</c> + <c>instance</c> geführt. Alle Instanzen melden unter derselben
|
||||
/// <see cref="Source"/> und tragen ihre eigene <see cref="Instance"/> — damit hat jede
|
||||
/// laufende Instanz einen eigenen Zustand, ein eigenes Intervall und einen eigenen
|
||||
/// Metrik-Verlauf. Fällt eine von dreien aus, fällt genau deren Monitor.</para>
|
||||
///
|
||||
/// <para>Server-URL und das anwendungsweite Token liegen in den Anwendungseinstellungen.
|
||||
/// Beim ersten Start tauscht die Instanz das Token gegen ein eigenes, eingeschränktes
|
||||
/// Sub-Token (<c>/api/tokens/v1/provision</c>) und legt es hier verschlüsselt ab —
|
||||
/// danach liegt auf der Instanz nicht mehr das Master-Token.</para>
|
||||
///
|
||||
/// <para>Ein/Aus läuft über den eingebauten Dienst <c>InstanceWatchdog</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class WatchdogConfig
|
||||
{
|
||||
/// <summary>Dienst-Kennung im Deploymentcenter. Alle Instanzen teilen sich dieselbe Source.</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "clawddotnet";
|
||||
|
||||
/// <summary>
|
||||
/// Name dieser Instanz im Monitor. Leer bedeutet: die <c>InstanceId</c> wird
|
||||
/// verwendet — stabil, aber im Dashboard nichtssagend. Wer lesbare Namen möchte,
|
||||
/// trägt hier einen ein; ein späterer Wechsel legt allerdings einen neuen Monitor an.
|
||||
/// </summary>
|
||||
[JsonPropertyName("instance")]
|
||||
public string Instance { get; set; } = "";
|
||||
|
||||
/// <summary>Gruppierung im Dashboard (reine Anzeige, keine Hierarchie).</summary>
|
||||
[JsonPropertyName("group")]
|
||||
public string Group { get; set; } = "ClawdDotNet";
|
||||
|
||||
/// <summary>
|
||||
/// Sende-Takt in Sekunden. Daraus leitet der Evaluator die Schwellen ab:
|
||||
/// nach dem Doppelten <c>warning</c>, nach dem Vierfachen <c>down</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("intervalSeconds")]
|
||||
public int IntervalSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Das für diese Instanz ausgestellte Sub-Token. Wird automatisch gesetzt und
|
||||
/// verschlüsselt gespeichert.
|
||||
/// </summary>
|
||||
[JsonPropertyName("agentToken")]
|
||||
public string AgentToken { get; set; } = "";
|
||||
|
||||
/// <summary>True, sobald ein eigenes Token vorliegt.</summary>
|
||||
[JsonIgnore]
|
||||
public bool HasToken => !string.IsNullOrWhiteSpace(AgentToken);
|
||||
|
||||
/// <summary>Der Wert, der als <c>instance</c> gemeldet wird.</summary>
|
||||
public string ResolveInstance(string instanceId) =>
|
||||
string.IsNullOrWhiteSpace(Instance) ? instanceId : Instance.Trim();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Was aus einem Bugtracker-Eintrag geworden ist.</summary>
|
||||
/// <param name="ItemId">Nummer des Eintrags im Deploymentcenter.</param>
|
||||
/// <param name="IsNew">False, wenn ein bestehender Eintrag hochgezählt wurde.</param>
|
||||
/// <param name="OccurrenceCount">Wie oft dieses Vorkommnis bisher gezählt wurde.</param>
|
||||
/// <param name="Url">Adresse der Übersicht, für einen Hinweis an den Benutzer.</param>
|
||||
public sealed record BugtrackerReport(long ItemId, bool IsNew, int OccurrenceCount, string? Url);
|
||||
|
||||
/// <summary>Art eines Eintrags. Der Server kennt darüber hinaus noch <c>idea</c>.</summary>
|
||||
public static class BugtrackerItemType
|
||||
{
|
||||
public const string Bug = "bug";
|
||||
public const string Feature = "feature";
|
||||
public const string Idea = "idea";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an <c>POST /api/bugtracker/v1/report</c> — der Weg, auf dem ClawdDotNet
|
||||
/// selbst (oder ein Benutzer über die Oberfläche) einen Fehler oder Wunsch einträgt.
|
||||
///
|
||||
/// <para>Abgegrenzt vom <see cref="ErrorReporter"/>: Der meldet <em>ungefangene</em>
|
||||
/// Ausnahmen automatisch; hier geht es um bewusst formulierte Einträge mit Titel und
|
||||
/// Beschreibung. Serverseitig landen beide in derselben Tabelle — was richtig ist,
|
||||
/// denn ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste.</para>
|
||||
///
|
||||
/// <para><b>Der Absender kommt aus dem Token</b> und lässt sich nicht frei wählen —
|
||||
/// sonst könnte sich ein Agent als ein anderer ausgeben.</para>
|
||||
/// </summary>
|
||||
public sealed class BugtrackerClient(
|
||||
DeploymentcenterApi api, string projectSlug, string environment, string build)
|
||||
{
|
||||
/// <param name="type">Siehe <see cref="BugtrackerItemType"/>.</param>
|
||||
/// <param name="clientRef">
|
||||
/// Freier Idempotenz-Schlüssel. Zweimal derselbe Wert erzeugt keinen zweiten
|
||||
/// Eintrag — nützlich, wenn eine Meldung nach einem Verbindungsabbruch wiederholt
|
||||
/// wird.
|
||||
/// </param>
|
||||
public async Task<BugtrackerReport> ReportAsync(
|
||||
string type,
|
||||
string title,
|
||||
string? description = null,
|
||||
string severity = "medium",
|
||||
string? clientRef = null,
|
||||
IReadOnlyDictionary<string, object?>? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project_slug"] = projectSlug,
|
||||
["type"] = type,
|
||||
["title"] = title,
|
||||
["description"] = description,
|
||||
["severity"] = severity,
|
||||
["environment"] = environment,
|
||||
["build_version"] = build
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clientRef))
|
||||
payload["client_ref"] = clientRef;
|
||||
|
||||
if (context is { Count: > 0 })
|
||||
payload["context"] = context;
|
||||
|
||||
var response = await api.PostAsync("/api/bugtracker/v1/report", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new BugtrackerReport(
|
||||
ReadLong(response, "item_id"),
|
||||
ReadBool(response, "is_new"),
|
||||
(int)ReadLong(response, "occurrence_count"),
|
||||
ReadString(response, "url"));
|
||||
}
|
||||
|
||||
private static long ReadLong(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt64()
|
||||
: 0;
|
||||
|
||||
private static bool ReadBool(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream,
|
||||
/// Bugtracker, Token-Provisionierung).
|
||||
///
|
||||
/// <para>Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag —
|
||||
/// <c>{"status":"success",…}</c> bzw. <c>{"status":"error","error":{"code":…}}</c>. Der
|
||||
/// <c>code</c> ist stabil und für Programme gedacht, die <c>message</c> für Menschen.
|
||||
/// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine
|
||||
/// <see cref="DeploymentcenterException"/> mit dem Code daran.</para>
|
||||
///
|
||||
/// <para><b>Ausnahme:</b> Die Lizenz-Endpunkte tragen diesen Umschlag bewusst
|
||||
/// <em>nicht</em> — dort steht im Feld <c>status</c> der Lizenzzustand. Sie werden
|
||||
/// deshalb nicht hierüber, sondern über <c>Deploymentcenter.Client</c> angesprochen.</para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterApi : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
private readonly string _token;
|
||||
|
||||
/// <summary>Die Basis-URL ohne abschließenden Schrägstrich.</summary>
|
||||
public string BaseUrl { get; }
|
||||
|
||||
/// <param name="baseUrl">Basis-URL des Deploymentcenters, etwa <c>https://dc.mhdf.de</c>.</param>
|
||||
/// <param name="token">Token mit den nötigen Rechten. Geht als <c>Authorization: Bearer</c> mit.</param>
|
||||
/// <param name="httpClient">Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt.</param>
|
||||
public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl));
|
||||
|
||||
// Über eine ungesicherte Verbindung ginge das Token im Klartext. Ausnahme ist
|
||||
// nur der eigene Rechner — dort gibt es keine Strecke, auf der jemand mithören
|
||||
// könnte, und eine lokale Testinstallation hat selten ein Zertifikat.
|
||||
if (!IsAcceptableUrl(baseUrl))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Deploymentcenter-URL muss mit https:// beginnen (Ausnahme: localhost).",
|
||||
nameof(baseUrl));
|
||||
}
|
||||
|
||||
BaseUrl = baseUrl.TrimEnd('/');
|
||||
_token = token ?? throw new ArgumentNullException(nameof(token));
|
||||
|
||||
_ownsHttp = httpClient is null;
|
||||
_http = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
private static bool IsAcceptableUrl(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
return true;
|
||||
|
||||
return uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback;
|
||||
}
|
||||
|
||||
public async Task<JsonElement> PostAsync(string path, object payload, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + path)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return await SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<JsonElement> GetAsync(string path, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path);
|
||||
return await SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (_token.Length > 0)
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _token);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
|
||||
JsonElement root;
|
||||
try
|
||||
{
|
||||
// Geklont, weil das JsonDocument am Ende dieses Blocks freigegeben wird —
|
||||
// ein JsonElement daraus wäre danach nicht mehr lesbar.
|
||||
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
||||
root = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"invalid_response",
|
||||
$"Antwort war kein JSON (HTTP {(int)response.StatusCode}).",
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
if (IsErrorEnvelope(root, out var code, out var message))
|
||||
throw new DeploymentcenterException(code, message, response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"http_error",
|
||||
$"Deploymentcenter antwortete HTTP {(int)response.StatusCode}.",
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool IsErrorEnvelope(JsonElement root, out string code, out string message)
|
||||
{
|
||||
code = "error";
|
||||
message = "Unbekannter Fehler.";
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
if (!root.TryGetProperty("status", out var status)
|
||||
|| status.ValueKind != JsonValueKind.String
|
||||
|| !string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String)
|
||||
code = c.GetString() ?? code;
|
||||
|
||||
if (error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
|
||||
message = m.GetString() ?? message;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp)
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein vom Deploymentcenter abgelehnter Aufruf. <see cref="Code"/> ist der stabile
|
||||
/// Fehlercode aus dem Umschlag (<c>unauthorized</c>, <c>rate_limited</c>, …) — er ist
|
||||
/// zum Auswerten gedacht, der Text nicht.
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode)
|
||||
: Exception($"{message} [{code}]")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
|
||||
public HttpStatusCode StatusCode { get; } = statusCode;
|
||||
|
||||
/// <summary>Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab.</summary>
|
||||
public bool IsAuthorizationProblem =>
|
||||
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Meldet Laufzeitfehler an das Deploymentcenter.</summary>
|
||||
public interface IErrorReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Meldet eine Ausnahme. <paramref name="fatal"/> heißt: Der Prozess endet daran.
|
||||
/// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen.
|
||||
/// </summary>
|
||||
Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Tut nichts. Für abgeschaltete Meldung und für Tests.</summary>
|
||||
public sealed class NullErrorReporter : IErrorReporter
|
||||
{
|
||||
public static readonly NullErrorReporter Instance = new();
|
||||
|
||||
public Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an <c>POST /api/errors/v1/report</c> — den Eingang für den globalen
|
||||
/// Ausnahmebehandler.
|
||||
///
|
||||
/// <para>Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die
|
||||
/// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter,
|
||||
/// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt
|
||||
/// Alarm, wenn er plötzlich hundertmal so oft auftritt.</para>
|
||||
///
|
||||
/// <para><b>Eigener Schutz gegen Fehlerschleifen.</b> Der Server drosselt auf 300
|
||||
/// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler
|
||||
/// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das
|
||||
/// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server
|
||||
/// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung.</para>
|
||||
/// </summary>
|
||||
public sealed class ErrorReporter : IErrorReporter, IDisposable
|
||||
{
|
||||
/// <summary>Wie lange derselbe Fehler nach einer Meldung stumm bleibt.</summary>
|
||||
private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab.</summary>
|
||||
private const int MaxStackTraceLength = 8000;
|
||||
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly bool _ownsApi;
|
||||
private readonly string _projectSlug;
|
||||
private readonly string _environment;
|
||||
private readonly string _build;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = [];
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public ErrorReporter(
|
||||
DeploymentcenterApi api,
|
||||
string projectSlug,
|
||||
string environment,
|
||||
string build,
|
||||
ILogger logger,
|
||||
bool ownsApi = false,
|
||||
Func<DateTimeOffset>? now = null)
|
||||
{
|
||||
_api = api;
|
||||
_projectSlug = projectSlug;
|
||||
_environment = environment;
|
||||
_build = build;
|
||||
_logger = logger;
|
||||
_ownsApi = ownsApi;
|
||||
_now = now ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!ShouldSend(exception))
|
||||
return false;
|
||||
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project_slug"] = _projectSlug,
|
||||
["exception"] = exception.GetType().FullName,
|
||||
["message"] = exception.Message,
|
||||
["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength),
|
||||
["level"] = fatal ? "fatal" : "error",
|
||||
["build"] = _build,
|
||||
["environment"] = _environment,
|
||||
|
||||
// Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der
|
||||
// Server den Zähler, statt einen zweiten Eintrag anzulegen.
|
||||
["client_ref"] = Fingerprint(exception)
|
||||
};
|
||||
|
||||
if (context is { Count: > 0 })
|
||||
payload["context"] = context;
|
||||
|
||||
if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType)
|
||||
payload["file"] = declaringType;
|
||||
|
||||
try
|
||||
{
|
||||
await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten:
|
||||
// Der ursprüngliche Fehler ginge dabei verloren.
|
||||
_logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert).");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet.</summary>
|
||||
private bool ShouldSend(Exception exception)
|
||||
{
|
||||
var key = Fingerprint(exception);
|
||||
var now = _now();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow)
|
||||
return false;
|
||||
|
||||
// Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst.
|
||||
if (_lastSent.Count > 200)
|
||||
{
|
||||
foreach (var stale in _lastSent
|
||||
.Where(e => now - e.Value > RepeatWindow)
|
||||
.Select(e => e.Key)
|
||||
.ToList())
|
||||
{
|
||||
_lastSent.Remove(stale);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSent[key] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung
|
||||
/// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und
|
||||
/// dann wäre jeder Aufruf ein neuer Fehler.
|
||||
/// </summary>
|
||||
private static string Fingerprint(Exception exception)
|
||||
{
|
||||
var frame = exception.StackTrace?
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault()?
|
||||
.Trim() ?? "";
|
||||
|
||||
return $"{exception.GetType().FullName}|{frame}";
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int max) =>
|
||||
value.Length <= max ? value : value[..max] + "\n… (gekürzt)";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsApi)
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Ein für diese Instanz ausgestelltes Sub-Token.</summary>
|
||||
/// <param name="Token">Der Klartext — wird nur einmal ausgeliefert.</param>
|
||||
/// <param name="TokenId">Kennung zum Widerrufen in der Verwaltung.</param>
|
||||
/// <param name="Scopes">Welche Rechte tatsächlich durchgereicht wurden.</param>
|
||||
public sealed record ProvisionedToken(string Token, string TokenId, IReadOnlyList<string> Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// Tauscht das anwendungsweite Master-Token gegen ein eigenes Sub-Token je Instanz
|
||||
/// (<c>POST /api/tokens/v1/provision</c>).
|
||||
///
|
||||
/// <para>Das ersetzt die frühere Selbstregistrierung über <c>POST /api/register</c> —
|
||||
/// diesen Endpunkt gibt es im Deploymentcenter nicht (und im alten WatchDog-Server war
|
||||
/// er der einzige Weg, überhaupt an einen Token zu kommen). Der Zweck bleibt derselbe
|
||||
/// und ist es wert, erhalten zu bleiben: Auf den Instanzen liegt danach nicht das
|
||||
/// Master-Token, sondern ein eingeschränktes, einzeln widerrufbares.</para>
|
||||
///
|
||||
/// <para>Rechte lassen sich dabei nur einschränken, nie erweitern — was das
|
||||
/// Master-Token nicht hat, bekommt auch das Sub-Token nicht.</para>
|
||||
/// </summary>
|
||||
public sealed class TokenProvisioner(DeploymentcenterApi api)
|
||||
{
|
||||
/// <summary>Was eine Instanz braucht: Heartbeats senden und Fehler melden.</summary>
|
||||
public static readonly string[] InstanceScopes = ["watchdog:ping", "bugtracker:report"];
|
||||
|
||||
public async Task<ProvisionedToken> ProvisionAsync(
|
||||
string clientName,
|
||||
string instanceId,
|
||||
IReadOnlyList<string> scopes,
|
||||
string environment = "production",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
client_name = clientName,
|
||||
instance_id = instanceId,
|
||||
scopes,
|
||||
environment
|
||||
};
|
||||
|
||||
var response = await api.PostAsync("/api/tokens/v1/provision", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var token = response.TryGetProperty("sub_token", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"no_token",
|
||||
"Die Provisionierung lieferte kein Token.",
|
||||
System.Net.HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
var tokenId = response.TryGetProperty("token_id", out var i) && i.ValueKind == JsonValueKind.String
|
||||
? i.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var granted = new List<string>();
|
||||
if (response.TryGetProperty("scopes", out var s) && s.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
granted.AddRange(s.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!));
|
||||
}
|
||||
|
||||
return new ProvisionedToken(token, tokenId, granted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>
|
||||
/// Die Statuswerte, die der Watchdog kennt. <c>stopped</c> und <c>maintenance</c> sind
|
||||
/// angekündigte Zustände — der Evaluator lässt solche Monitore in Ruhe, statt wenige
|
||||
/// Minuten nach einem geplanten Herunterfahren einen Fehlalarm zu erzeugen.
|
||||
/// </summary>
|
||||
public static class WatchdogStatus
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
public const string Warning = "warning";
|
||||
public const string Error = "error";
|
||||
public const string Stopped = "stopped";
|
||||
public const string Maintenance = "maintenance";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine selbst ermittelte Teilprüfung. Das Deploymentcenter interpretiert den Namen
|
||||
/// nicht — es liest nur <see cref="Ok"/> und <see cref="Message"/>. Was „gesund"
|
||||
/// bedeutet, entscheidet damit jede Anwendung selbst.
|
||||
///
|
||||
/// <para>Schlägt eine Prüfung fehl, stuft der Server einen als <c>ok</c> gemeldeten
|
||||
/// Heartbeat auf <c>warning</c> herab. Das ist der Unterschied zwischen „ein Faden
|
||||
/// läuft" und „die Anwendung tut, was sie soll".</para>
|
||||
/// </summary>
|
||||
public sealed record HealthCheck(bool Ok, string? Message = null);
|
||||
|
||||
/// <summary>Momentaufnahme des Instanz-Zustands für einen Heartbeat.</summary>
|
||||
/// <param name="Status">Einer der Werte aus <see cref="WatchdogStatus"/>.</param>
|
||||
/// <param name="Message">Kurzbegründung, erscheint im Dashboard.</param>
|
||||
/// <param name="Metrics">
|
||||
/// Nur Zahlen: Das Deploymentcenter legt sie mit Zeitstempel ab (14 Tage) und vergleicht
|
||||
/// den aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische
|
||||
/// Werte würden dabei stillschweigend verworfen — beschreibende Angaben gehören
|
||||
/// deshalb in <paramref name="Message"/> oder in die Checks.
|
||||
/// </param>
|
||||
/// <param name="Checks">Selbst ermittelter Gesundheitszustand je Teilbereich.</param>
|
||||
public sealed record InstanceHealth(
|
||||
string Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, double> Metrics,
|
||||
IReadOnlyDictionary<string, HealthCheck> Checks)
|
||||
{
|
||||
public static InstanceHealth Ok(string? message = null) => new(
|
||||
WatchdogStatus.Ok, message,
|
||||
new Dictionary<string, double>(),
|
||||
new Dictionary<string, HealthCheck>());
|
||||
}
|
||||
|
||||
/// <summary>Liefert vor jedem Heartbeat den aktuellen Instanz-Zustand.</summary>
|
||||
public interface IInstanceHealthProvider
|
||||
{
|
||||
Task<InstanceHealth> GetAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>
|
||||
/// Leitet den Instanz-Zustand für den Heartbeat ab.
|
||||
///
|
||||
/// <para>Ein Heartbeat allein beweist nur, dass ein Faden läuft. Deshalb geht der
|
||||
/// selbst ermittelte Gesundheitszustand als <c>checks</c> mit — der Server stuft einen
|
||||
/// als <c>ok</c> gemeldeten Beat herab, sobald eine Prüfung fehlschlägt, und nennt in
|
||||
/// der Antwort die betroffene. Der klassische Fall, den das abfängt: Der Takt meldet
|
||||
/// brav <c>ok</c>, während der Aufgaben-Scanner seit einer Stunde tot ist.</para>
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>error</c> — kein OpenRouter-Key konfiguriert (Agenten deaktiviert).</item>
|
||||
/// <item><c>warning</c> — Tagesbudget der Instanz erschöpft oder Scanner steht.</item>
|
||||
/// <item><c>ok</c> — sonst.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Die Metriken sind bewusst schlank und ausschließlich numerisch: keine
|
||||
/// sensiblen Nutzdaten, und nur Zahlen landen im Verlauf.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceHealthProvider : IInstanceHealthProvider
|
||||
{
|
||||
private readonly string _instanceName;
|
||||
private readonly bool _agentsEnabled;
|
||||
private readonly InstanceBudget _budget;
|
||||
private readonly IUsageRepository? _usage;
|
||||
private readonly Func<int> _agentCount;
|
||||
private readonly Func<int> _runningChats;
|
||||
private readonly Func<bool>? _schedulerRunning;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public InstanceHealthProvider(
|
||||
string instanceName,
|
||||
bool agentsEnabled,
|
||||
InstanceBudget budget,
|
||||
IUsageRepository? usage,
|
||||
Func<int> agentCount,
|
||||
Func<int> runningChats,
|
||||
Func<bool>? schedulerRunning = null,
|
||||
Func<DateTime>? now = null)
|
||||
{
|
||||
_instanceName = instanceName;
|
||||
_agentsEnabled = agentsEnabled;
|
||||
_budget = budget;
|
||||
_usage = usage;
|
||||
_agentCount = agentCount;
|
||||
_runningChats = runningChats;
|
||||
_schedulerRunning = schedulerRunning;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
public async Task<InstanceHealth> GetAsync(CancellationToken ct)
|
||||
{
|
||||
var metrics = new Dictionary<string, double>
|
||||
{
|
||||
["agentCount"] = _agentCount(),
|
||||
["runningChats"] = _runningChats()
|
||||
};
|
||||
|
||||
var checks = new Dictionary<string, HealthCheck>
|
||||
{
|
||||
["agents"] = new(_agentsEnabled,
|
||||
_agentsEnabled ? null : "Kein OpenRouter-API-Key konfiguriert.")
|
||||
};
|
||||
|
||||
if (_schedulerRunning is not null)
|
||||
{
|
||||
var running = _schedulerRunning();
|
||||
checks["scheduler"] = new(running,
|
||||
running ? null : "Aufgaben-Scanner läuft nicht.");
|
||||
}
|
||||
|
||||
string? budgetProblem = null;
|
||||
|
||||
if (_usage is not null)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(_now());
|
||||
var used = await _usage.GetDailyAsync(today, agentId: "", ct).ConfigureAwait(false);
|
||||
|
||||
metrics["todayCostUsd"] = (double)decimal.Round(used.CostUsd, 4);
|
||||
metrics["todayTokens"] = used.TotalTokens;
|
||||
|
||||
if (Exceeds(_budget.DailyCostUsd, used.CostUsd))
|
||||
{
|
||||
budgetProblem =
|
||||
$"Tagesbudget erschöpft: {used.CostUsd:F2} von {_budget.DailyCostUsd:F2} USD.";
|
||||
}
|
||||
else if (Exceeds(_budget.DailyTokens, used.TotalTokens))
|
||||
{
|
||||
budgetProblem =
|
||||
$"Token-Tageslimit erschöpft: {used.TotalTokens:N0} von {_budget.DailyTokens:N0}.";
|
||||
}
|
||||
|
||||
checks["budget"] = new(budgetProblem is null, budgetProblem);
|
||||
}
|
||||
|
||||
// Der Instanzname steht in der Meldung, nicht in den Metriken: Metriken sind
|
||||
// Zahlen, alles andere würde der Server beim Verdichten ohnehin verwerfen.
|
||||
if (!_agentsEnabled)
|
||||
{
|
||||
return new InstanceHealth(
|
||||
WatchdogStatus.Error,
|
||||
$"{_instanceName}: Kein OpenRouter-API-Key konfiguriert – Agenten deaktiviert.",
|
||||
metrics, checks);
|
||||
}
|
||||
|
||||
if (budgetProblem is not null)
|
||||
return new InstanceHealth(WatchdogStatus.Warning, $"{_instanceName}: {budgetProblem}", metrics, checks);
|
||||
|
||||
if (_schedulerRunning is not null && !_schedulerRunning())
|
||||
{
|
||||
return new InstanceHealth(
|
||||
WatchdogStatus.Warning,
|
||||
$"{_instanceName}: Aufgaben-Scanner läuft nicht.",
|
||||
metrics, checks);
|
||||
}
|
||||
|
||||
return new InstanceHealth(WatchdogStatus.Ok, $"{_instanceName}: Betrieb normal.", metrics, checks);
|
||||
}
|
||||
|
||||
/// <summary>0 oder kleiner bedeutet: keine Grenze gesetzt. Deckungsgleich mit BudgetGuard.</summary>
|
||||
private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
|
||||
|
||||
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>Was der Server zu einem Heartbeat zurückmeldet.</summary>
|
||||
/// <param name="State">Der daraus abgeleitete Monitor-Zustand (<c>up</c>, <c>warning</c>, …).</param>
|
||||
/// <param name="FailingChecks">Welche der mitgeschickten Prüfungen fehlgeschlagen sind.</param>
|
||||
public sealed record WatchdogPingResult(string State, IReadOnlyList<string> FailingChecks);
|
||||
|
||||
/// <summary>
|
||||
/// Sendet Heartbeats und Ereignisse an das Watchdog-Modul des Deploymentcenters.
|
||||
/// </summary>
|
||||
public interface IWatchdogClient
|
||||
{
|
||||
Task<WatchdogPingResult> SendHeartbeatAsync(
|
||||
InstanceHealth health, int intervalSeconds, CancellationToken ct);
|
||||
|
||||
Task SendEventAsync(
|
||||
string kind, string severity, string? message, object? meta, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watchdog-Anbindung: <c>POST /api/watchdog/v1/ping</c> und
|
||||
/// <c>POST /api/watchdog/v1/event</c>.
|
||||
///
|
||||
/// <para><b>Ein Monitor je Instanz.</b> Der Schlüssel des Monitors ist das Paar
|
||||
/// <c>source</c> + <c>instance</c> (so das Datenbankschema:
|
||||
/// <c>UNIQUE KEY uq_monitor (source, instance)</c>). Alle ClawdDotNet-Instanzen melden
|
||||
/// unter derselben <c>source</c> und tragen ihre eigene <c>instance</c> — damit ist jede
|
||||
/// laufende Instanz ein eigener Monitor mit eigenem Zustand, eigenem Intervall und
|
||||
/// eigenem Metrik-Verlauf. Stürzt eine von dreien ab, fällt genau deren Monitor.</para>
|
||||
///
|
||||
/// <para>Der Monitor entsteht beim ersten Heartbeat von selbst (<c>INSERT … ON DUPLICATE
|
||||
/// KEY UPDATE</c>) — eine Registrierung vorab gibt es nicht mehr und ist auch nicht
|
||||
/// nötig.</para>
|
||||
/// </summary>
|
||||
public sealed class WatchdogClient : IWatchdogClient, IDisposable
|
||||
{
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly bool _ownsApi;
|
||||
private readonly string _source;
|
||||
private readonly string _instance;
|
||||
private readonly string _group;
|
||||
private readonly string _os;
|
||||
private readonly string _version;
|
||||
|
||||
public WatchdogClient(
|
||||
DeploymentcenterApi api, string source, string instance, string group, string os,
|
||||
string version, bool ownsApi = false)
|
||||
{
|
||||
_api = api;
|
||||
_ownsApi = ownsApi;
|
||||
_source = source;
|
||||
_instance = instance;
|
||||
_group = group;
|
||||
_os = os;
|
||||
_version = version;
|
||||
}
|
||||
|
||||
public static WatchdogClient Create(
|
||||
string baseUrl, string token, string source, string instance, string group, string os,
|
||||
string version, HttpClient? httpClient = null)
|
||||
=> new(new DeploymentcenterApi(baseUrl, token, httpClient),
|
||||
source, instance, group, os, version, ownsApi: true);
|
||||
|
||||
public async Task<WatchdogPingResult> SendHeartbeatAsync(
|
||||
InstanceHealth health, int intervalSeconds, CancellationToken ct)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = _source,
|
||||
["instance"] = _instance,
|
||||
["type"] = "heartbeat",
|
||||
["status"] = health.Status,
|
||||
["interval"] = intervalSeconds,
|
||||
["message"] = health.Message,
|
||||
["group"] = _group,
|
||||
["os"] = _os,
|
||||
|
||||
// Landet in watchdog_monitors.app_version. Damit steht im Dashboard, welche
|
||||
// Fassung eine Instanz gerade fährt — bei mehreren Instanzen der
|
||||
// Unterschied zwischen „läuft" und „läuft noch auf der alten Version".
|
||||
["version"] = _version
|
||||
};
|
||||
|
||||
// Leere Objekte weglassen: Der Server übernimmt health_json nur, wenn etwas
|
||||
// mitkommt — ein leeres würde den letzten bekannten Zustand nicht ersetzen,
|
||||
// aber unnötig Platz im Protokoll kosten.
|
||||
if (health.Checks.Count > 0)
|
||||
{
|
||||
payload["checks"] = health.Checks.ToDictionary(
|
||||
c => c.Key,
|
||||
c => (object)new { ok = c.Value.Ok, message = c.Value.Message });
|
||||
}
|
||||
|
||||
if (health.Metrics.Count > 0)
|
||||
payload["metrics"] = health.Metrics;
|
||||
|
||||
var response = await _api.PostAsync("/api/watchdog/v1/ping", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ReadPingResult(response);
|
||||
}
|
||||
|
||||
private static WatchdogPingResult ReadPingResult(JsonElement response)
|
||||
{
|
||||
if (!response.TryGetProperty("monitor", out var monitor)
|
||||
|| monitor.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new WatchdogPingResult("unknown", []);
|
||||
}
|
||||
|
||||
var state = monitor.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var failing = new List<string>();
|
||||
if (monitor.TryGetProperty("failing_checks", out var checks)
|
||||
&& checks.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
failing.AddRange(checks.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!));
|
||||
}
|
||||
|
||||
return new WatchdogPingResult(state, failing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein einmaliges Vorkommnis statt einer zyklischen Meldung. Zulässige
|
||||
/// <paramref name="kind"/>-Werte siehe <see cref="WatchdogEventKind"/> — der Server
|
||||
/// weist andere ab.
|
||||
/// </summary>
|
||||
public async Task SendEventAsync(
|
||||
string kind, string severity, string? message, object? meta, CancellationToken ct)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
source = _source,
|
||||
instance = _instance,
|
||||
kind,
|
||||
severity,
|
||||
message,
|
||||
meta
|
||||
};
|
||||
|
||||
await _api.PostAsync("/api/watchdog/v1/event", payload, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsApi)
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die vom Server akzeptierten Ereignisarten. Die frühere Anbindung schickte
|
||||
/// <c>start</c> und <c>stop</c> — beide stehen nicht auf dieser Liste und wurden
|
||||
/// stillschweigend als <c>started</c> abgelegt.
|
||||
/// </summary>
|
||||
public static class WatchdogEventKind
|
||||
{
|
||||
public const string Started = "started";
|
||||
public const string StoppedGraceful = "stopped_graceful";
|
||||
public const string CrashSuspected = "crash_suspected";
|
||||
public const string HardError = "hard_error";
|
||||
public const string Recovered = "recovered";
|
||||
public const string WarningRaised = "warning_raised";
|
||||
public const string WarningCleared = "warning_cleared";
|
||||
public const string MaintenanceStart = "maintenance_start";
|
||||
public const string MaintenanceEnd = "maintenance_end";
|
||||
public const string WatchdogStarted = "watchdog_started";
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>
|
||||
/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende.
|
||||
/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen —
|
||||
/// alle Sendefehler werden geloggt und verschluckt.
|
||||
///
|
||||
/// <para><b>Sauberes Beenden.</b> Beim Herunterfahren geht ein Heartbeat mit
|
||||
/// <c>status: "stopped"</c> raus. Der Evaluator lässt einen so gemeldeten Monitor in
|
||||
/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen
|
||||
/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den
|
||||
/// Monitor-Zustand.</para>
|
||||
/// </summary>
|
||||
public sealed class WatchdogHeartbeatService : IAsyncDisposable
|
||||
{
|
||||
private readonly IWatchdogClient _client;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly IInstanceHealthProvider _health;
|
||||
private readonly int _intervalSeconds;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
private string _lastState = "unknown";
|
||||
|
||||
public WatchdogHeartbeatService(
|
||||
IWatchdogClient client,
|
||||
IInstanceHealthProvider health,
|
||||
int intervalSeconds,
|
||||
ILogger logger,
|
||||
bool ownsClient = false)
|
||||
{
|
||||
_client = client;
|
||||
_health = health;
|
||||
_intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400);
|
||||
_logger = logger;
|
||||
_ownsClient = ownsClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration
|
||||
/// (fehlende oder nicht-HTTPS-URL).
|
||||
/// </summary>
|
||||
public static WatchdogHeartbeatService Create(
|
||||
string baseUrl, string token, string source, string instance, string group, string os,
|
||||
string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger)
|
||||
{
|
||||
var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version);
|
||||
return new WatchdogHeartbeatService(
|
||||
client, health, intervalSeconds, logger, ownsClient: true);
|
||||
}
|
||||
|
||||
public bool IsRunning => _loop is { IsCompleted: false };
|
||||
|
||||
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
|
||||
public string LastState => _lastState;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsRunning)
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = RunAsync(_cts.Token);
|
||||
_logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds);
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
await TrySendAsync(
|
||||
() => _client.SendEventAsync(
|
||||
WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct),
|
||||
"Start-Ereignis").ConfigureAwait(false);
|
||||
|
||||
// Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen
|
||||
// Intervall im Dashboard auftaucht.
|
||||
await BeatAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
await BeatAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Regulärer Stopp.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BeatAsync(CancellationToken ct)
|
||||
{
|
||||
InstanceHealth health;
|
||||
try
|
||||
{
|
||||
health = await _health.GetAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen
|
||||
// rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall.
|
||||
_logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen – melde warning.");
|
||||
health = new InstanceHealth(
|
||||
WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.",
|
||||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>());
|
||||
}
|
||||
|
||||
await TrySendAsync(async () =>
|
||||
{
|
||||
var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.State != _lastState)
|
||||
{
|
||||
_logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}",
|
||||
_lastState, result.State,
|
||||
result.FailingChecks.Count > 0
|
||||
? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})"
|
||||
: "");
|
||||
|
||||
_lastState = result.State;
|
||||
}
|
||||
}, "Heartbeat").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task TrySendAsync(Func<Task> send, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
await send().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem)
|
||||
{
|
||||
// Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor
|
||||
// für immer stumm, und niemand merkt es, weil ja nichts abstürzt.
|
||||
_logger.LogWarning(
|
||||
"Watchdog: {What} abgelehnt ({Code}) – Token prüfen (Recht watchdog:ping).",
|
||||
what, ex.Code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ausfall des Monitorings darf den Betrieb nie stören.
|
||||
_logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what);
|
||||
}
|
||||
}
|
||||
|
||||
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 */ }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); }
|
||||
}
|
||||
|
||||
// Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein
|
||||
// Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft.
|
||||
try
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
await _client.SendHeartbeatAsync(
|
||||
new InstanceHealth(
|
||||
WatchdogStatus.Stopped, "Instanz planmäßig beendet.",
|
||||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
|
||||
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
|
||||
|
||||
await _client.SendEventAsync(
|
||||
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert).");
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
|
||||
if (_ownsClient && _client is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
@@ -14,13 +15,16 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Engine;
|
||||
|
||||
public sealed class AgentEngine : IAgentMessageRouter
|
||||
public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecutor
|
||||
{
|
||||
private readonly IChatCompletionClient _client;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly PermissionGate _permissionGate;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly IMemoryRepository? _memoryRepository;
|
||||
private readonly Tasks.ITaskRepository? _taskRepository;
|
||||
private readonly IAuditRepository? _auditRepository;
|
||||
private readonly Staging.StagingGate? _stagingGate;
|
||||
private readonly IUsageRepository? _usageRepository;
|
||||
private readonly BudgetGuard? _budgetGuard;
|
||||
private readonly ModelPricingCatalog? _pricing;
|
||||
@@ -67,7 +71,10 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
ILoggerFactory loggerFactory,
|
||||
IMemoryRepository? memoryRepository = null,
|
||||
IUsageRepository? usageRepository = null,
|
||||
ModelPricingCatalog? pricing = null)
|
||||
ModelPricingCatalog? pricing = null,
|
||||
Tasks.ITaskRepository? taskRepository = null,
|
||||
IAuditRepository? auditRepository = null,
|
||||
Staging.StagingGate? stagingGate = null)
|
||||
{
|
||||
_client = client;
|
||||
_toolRegistry = toolRegistry;
|
||||
@@ -75,6 +82,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
_stateStore = stateStore;
|
||||
_loggerFactory = loggerFactory;
|
||||
_memoryRepository = memoryRepository;
|
||||
_taskRepository = taskRepository;
|
||||
_auditRepository = auditRepository;
|
||||
_stagingGate = stagingGate;
|
||||
_usageRepository = usageRepository;
|
||||
_pricing = pricing;
|
||||
_budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository);
|
||||
@@ -100,14 +110,18 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
AgentConfig agentConfig,
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt)
|
||||
CancellationToken externalCt,
|
||||
string? source = null,
|
||||
string? taskId = null)
|
||||
{
|
||||
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
|
||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
||||
return denied;
|
||||
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt);
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -115,7 +129,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
AgentConfig agentConfig,
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt)
|
||||
CancellationToken externalCt,
|
||||
string runId,
|
||||
string? source)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -157,7 +173,11 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
|
||||
var response = await _client.CompleteAsync(request, ct);
|
||||
@@ -194,7 +214,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct);
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||||
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
}
|
||||
@@ -282,11 +302,14 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt,
|
||||
string? source = null)
|
||||
string? source = null,
|
||||
string? taskId = null)
|
||||
{
|
||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
||||
return denied;
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
|
||||
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
|
||||
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
|
||||
@@ -307,8 +330,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
@@ -323,7 +347,8 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken runCt,
|
||||
string? source)
|
||||
string? source,
|
||||
string runId)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -380,7 +405,11 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
|
||||
var response = await _client.CompleteAsync(request, ct);
|
||||
@@ -418,7 +447,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct);
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
}
|
||||
|
||||
@@ -538,6 +567,12 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
return _runningChats.ContainsKey(agentId);
|
||||
}
|
||||
|
||||
/// <summary>Anzahl Agenten mit mindestens einem aktiven Chat-Lauf — für Diagnose/Heartbeat.</summary>
|
||||
public int RunningChatCount
|
||||
{
|
||||
get { lock (_lock) return _runningChats.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
|
||||
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
|
||||
@@ -805,9 +840,19 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string instanceId,
|
||||
IReadOnlyList<IAgentTool> availableTools,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
string runId,
|
||||
string? source)
|
||||
{
|
||||
var toolName = toolCall.Function.Name;
|
||||
var arguments = toolCall.Function.Arguments ?? "";
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Das Audit wird von der Engine gestempelt (A3) — Herkunft aus dem Wissen der
|
||||
// Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf
|
||||
// nicht scheitern lassen.
|
||||
Task Audit(AuditStatus status, string summary)
|
||||
=> RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary, sw.ElapsedMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -815,29 +860,36 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
|
||||
var tool = availableTools.FirstOrDefault(t => t.Name == toolName);
|
||||
if (tool is null)
|
||||
{
|
||||
await Audit(AuditStatus.NotFound, $"Tool '{toolName}' nicht zugewiesen/unbekannt");
|
||||
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
|
||||
}
|
||||
|
||||
// Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
|
||||
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
|
||||
if (_stagingGate is not null)
|
||||
{
|
||||
var intercept = await _stagingGate.InterceptAsync(
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct);
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Denied)
|
||||
{
|
||||
await Audit(AuditStatus.Denied, intercept.Message);
|
||||
return JsonSerializer.Serialize(new { error = intercept.Message });
|
||||
}
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Staged)
|
||||
{
|
||||
await Audit(AuditStatus.Staged, intercept.Message);
|
||||
return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis
|
||||
}
|
||||
}
|
||||
|
||||
var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments)
|
||||
? default
|
||||
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
|
||||
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
|
||||
|
||||
var context = new AgentToolContext(
|
||||
agentConfig.AgentId,
|
||||
instanceId,
|
||||
toolConfig,
|
||||
_stateStore,
|
||||
toolLogger,
|
||||
ct,
|
||||
agentConfig.WorkspacePath,
|
||||
agentConfig.SharedWorkspacePath,
|
||||
this,
|
||||
_memoryRepository);
|
||||
var context = BuildToolContext(agentConfig, instanceId, toolName, ct);
|
||||
|
||||
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
|
||||
|
||||
@@ -846,7 +898,10 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await Audit(AuditStatus.Error, result.ErrorMessage ?? "");
|
||||
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||
}
|
||||
|
||||
var content = TruncateToolResult(result.Content, agentConfig.MaxToolResultChars);
|
||||
if (content.Length != result.Content.Length)
|
||||
@@ -856,22 +911,92 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
toolName, result.Content.Length, agentConfig.MaxToolResultChars);
|
||||
}
|
||||
|
||||
await Audit(AuditStatus.Ok, "");
|
||||
return content;
|
||||
}
|
||||
catch (ToolAccessDeniedException ex)
|
||||
{
|
||||
logger.LogWarning("Tool access denied: {Message}", ex.Message);
|
||||
await Audit(AuditStatus.Denied, ex.Message);
|
||||
return JsonSerializer.Serialize(new { error = ex.Message });
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
|
||||
// Schritt weiter und der Abbruch greift erst verzögert.
|
||||
// Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit —
|
||||
// der Aufruf kam nicht zum Abschluss.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
|
||||
await Audit(AuditStatus.Error, ex.Message);
|
||||
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private AgentToolContext BuildToolContext(
|
||||
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct)
|
||||
{
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
|
||||
|
||||
return new AgentToolContext(
|
||||
agentConfig.AgentId,
|
||||
instanceId,
|
||||
toolConfig,
|
||||
_stateStore,
|
||||
toolLogger,
|
||||
ct,
|
||||
agentConfig.WorkspacePath,
|
||||
agentConfig.SharedWorkspacePath,
|
||||
this,
|
||||
_memoryRepository,
|
||||
_taskRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen freigegebenen, eingefrorenen Aufruf aus (A2) — mit gültigem Tool-Kontext,
|
||||
/// aber ohne LLM-Schleife und ohne erneute Staging-Prüfung. Genau der übergebene
|
||||
/// Argument-JSON wird ausgeführt (Plan-Freeze).
|
||||
/// </summary>
|
||||
public async Task<string> ExecuteApprovedCallAsync(
|
||||
string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
|
||||
{
|
||||
var config = _agentConfigProvider?.Invoke().FirstOrDefault(a => a.AgentId == agentId);
|
||||
if (config is null)
|
||||
return JsonSerializer.Serialize(new { error = $"Agent '{agentId}' nicht gefunden." });
|
||||
|
||||
var agentTool = _toolRegistry.GetForAgent(config).FirstOrDefault(t => t.Name == tool)
|
||||
?? _toolRegistry.Get(tool);
|
||||
if (agentTool is null)
|
||||
return JsonSerializer.Serialize(new { error = $"Tool '{tool}' nicht gefunden." });
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var input = string.IsNullOrWhiteSpace(argumentsJson)
|
||||
? default
|
||||
: JsonDocument.Parse(argumentsJson).RootElement;
|
||||
|
||||
var context = BuildToolContext(config, _instanceId, tool, ct);
|
||||
var result = await agentTool.ExecuteAsync(input, context, ct);
|
||||
|
||||
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
|
||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||
status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
|
||||
|
||||
return result.Success
|
||||
? TruncateToolResult(result.Content, config.MaxToolResultChars)
|
||||
: JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||
AuditStatus.Error, ex.Message, sw.ElapsedMilliseconds);
|
||||
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
@@ -938,6 +1063,84 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audit-Log und Receipts (A3) ───
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Tool-Aufruf ins Audit-Log. Best effort — ein Fehler hierbei darf den
|
||||
/// Lauf nicht scheitern lassen; die eigentliche Arbeit ist bereits getan.
|
||||
/// </summary>
|
||||
private async Task RecordAuditAsync(
|
||||
string runId, AgentConfig agentConfig, string? source, string tool,
|
||||
string arguments, AuditStatus status, string summary, long durationMs)
|
||||
{
|
||||
if (_auditRepository is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _auditRepository.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
Tool = tool,
|
||||
Arguments = Cap(arguments, 4_000),
|
||||
Status = status,
|
||||
Summary = Cap(summary, 500),
|
||||
DurationMs = durationMs,
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Audit")
|
||||
.LogWarning(ex, "Audit-Eintrag konnte nicht geschrieben werden ({Tool})", tool);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
|
||||
/// </summary>
|
||||
private async Task RecordReceiptAsync(
|
||||
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
|
||||
{
|
||||
if (_auditRepository is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
|
||||
|
||||
await _auditRepository.RecordReceiptAsync(new RunReceipt
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
TaskId = taskId,
|
||||
Status = result.Status.ToString(),
|
||||
StepCount = result.StepCount,
|
||||
PromptTokens = result.PromptTokens,
|
||||
CompletionTokens = result.CompletionTokens,
|
||||
CachedTokens = result.CachedTokens,
|
||||
CostUsd = estimate?.Usd ?? 0m,
|
||||
CostIsKnown = estimate?.IsKnown ?? false,
|
||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||
ResultRef = Cap(result.FinalMessage ?? "", 500),
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Receipt")
|
||||
.LogWarning(ex, "Receipt konnte nicht geschrieben werden für {AgentId}", agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Cap(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
|
||||
/// <summary>Sammelt die Token-Zahlen über alle Schritte eines Runs.</summary>
|
||||
private sealed class TokenTally
|
||||
{
|
||||
|
||||
@@ -18,4 +18,5 @@ public static class ChatSource
|
||||
public const string Telegram = "telegram";
|
||||
public const string AgentComm = "agentcomm";
|
||||
public const string Job = "job";
|
||||
public const string Task = "task";
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
public sealed class AgentScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly string _instanceId;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _schedulerTasks = new();
|
||||
private readonly Dictionary<string, AgentRunResult?> _lastResults = new();
|
||||
private readonly Lock _resultsLock = new();
|
||||
|
||||
public event Action<string, AgentRunResult>? OnRunCompleted;
|
||||
|
||||
public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_engine = engine;
|
||||
_instanceId = instanceId;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
|
||||
}
|
||||
|
||||
public void RegisterAgent(AgentConfig agentConfig)
|
||||
{
|
||||
if (agentConfig.Scheduler is null)
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
|
||||
agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
|
||||
|
||||
var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
|
||||
_schedulerTasks.Add(task);
|
||||
}
|
||||
|
||||
public void RegisterAll(IEnumerable<AgentConfig> agents)
|
||||
{
|
||||
foreach (var agent in agents)
|
||||
RegisterAgent(agent);
|
||||
}
|
||||
|
||||
public async Task<AgentRunResult> RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
|
||||
var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
|
||||
StoreResult(agentConfig.AgentId, result);
|
||||
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public AgentRunResult? GetLastResult(string agentId)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(agentId);
|
||||
}
|
||||
|
||||
private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
|
||||
{
|
||||
var scheduler = agentConfig.Scheduler!;
|
||||
|
||||
if (scheduler.RunOnStart)
|
||||
{
|
||||
await ExecuteScheduledRunAsync(agentConfig, ct);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scheduler.Cron))
|
||||
return;
|
||||
|
||||
var cron = CronExpression.Parse(scheduler.Cron);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = cron.GetNextOccurrence(now);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
_logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = next.Value - now;
|
||||
_logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExecuteScheduledRunAsync(agentConfig, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _engine.RunAsync(
|
||||
agentConfig,
|
||||
agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
|
||||
_instanceId,
|
||||
ct);
|
||||
|
||||
StoreResult(agentConfig.AgentId, result);
|
||||
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
|
||||
agentConfig.AgentId, result.Status, result.TokensUsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreResult(string agentId, AgentRunResult result)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
_lastResults[agentId] = result;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_schedulerTasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,9 @@ public sealed class CronExpression
|
||||
|
||||
foreach (var part in field.Split(','))
|
||||
{
|
||||
if (part.Length == 0)
|
||||
throw Bad(field, "leeres Teilfeld");
|
||||
|
||||
if (part == "*")
|
||||
{
|
||||
for (var i = min; i <= max; i++) result.Add(i);
|
||||
@@ -97,23 +100,44 @@ public sealed class CronExpression
|
||||
else if (part.Contains('/'))
|
||||
{
|
||||
var split = part.Split('/');
|
||||
var start = split[0] == "*" ? min : int.Parse(split[0]);
|
||||
var step = int.Parse(split[1]);
|
||||
if (split.Length != 2)
|
||||
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
|
||||
|
||||
var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max);
|
||||
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
|
||||
for (var i = start; i <= max; i += step) result.Add(i);
|
||||
}
|
||||
else if (part.Contains('-'))
|
||||
{
|
||||
var split = part.Split('-');
|
||||
var from = int.Parse(split[0]);
|
||||
var to = int.Parse(split[1]);
|
||||
if (split.Length != 2)
|
||||
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
|
||||
|
||||
var from = ParseNumber(field, split[0], min, max);
|
||||
var to = ParseNumber(field, split[1], min, max);
|
||||
if (from > to)
|
||||
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
|
||||
|
||||
for (var i = from; i <= to; i++) result.Add(i);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(int.Parse(part));
|
||||
result.Add(ParseNumber(field, part, min, max));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int ParseNumber(string field, string value, int min, int max)
|
||||
{
|
||||
if (!int.TryParse(value, out var n))
|
||||
throw Bad(field, $"'{value}' ist keine Zahl");
|
||||
if (n < min || n > max)
|
||||
throw Bad(field, $"{n} liegt außerhalb von {min}-{max}");
|
||||
return n;
|
||||
}
|
||||
|
||||
private static FormatException Bad(string field, string reason)
|
||||
=> new($"Ungültiges Cron-Feld '{field}': {reason}.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Deutet Zeitzonen-Kennungen unabhängig davon, auf welchem Betriebssystem sie
|
||||
/// geschrieben wurden.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Zeitzonen werden auf Windows und Linux
|
||||
/// unterschiedlich benannt — <c>"W. Europe Standard Time"</c> gegen
|
||||
/// <c>"Europe/Berlin"</c>. Task-Dateien sind Markdown im geteilten Arbeitsverzeichnis
|
||||
/// und wandern zwischen Rechnern. Eine Kennung, die auf dem einen System entstanden ist,
|
||||
/// muss auf dem anderen lesbar bleiben.
|
||||
///
|
||||
/// Zuvor fing <c>TaskSchedule</c> die unbekannte Kennung ab und rechnete <b>still</b>
|
||||
/// in UTC weiter. Ein Task, der um 08:00 Ortszeit laufen sollte, lief damit im Sommer
|
||||
/// um 06:00 — ohne Meldung, ohne Logeintrag. Deshalb hier: beide Schreibweisen deuten,
|
||||
/// und was sich nicht deuten lässt, meldet <see cref="TryResolve"/> als <c>null</c>
|
||||
/// zurück, statt es zu erraten.
|
||||
///
|
||||
/// <para><b>Voraussetzung auf dem Zielsystem:</b> Die Umsetzung zwischen beiden
|
||||
/// Schreibweisen kommt aus den ICU-Daten, die Zeitzonen selbst aus <c>tzdata</c>. In
|
||||
/// einem schlanken Abbild (Alpine ohne <c>icu-libs</c>, distroless) oder bei
|
||||
/// <c>InvariantGlobalization=true</c> fehlen sie — dann schlägt jede Auflösung außer
|
||||
/// UTC fehl. Beides gehört ins Abbild.</para>
|
||||
/// </summary>
|
||||
public static class TimeZones
|
||||
{
|
||||
/// <summary>
|
||||
/// Die lokale Zeitzone in IANA-Schreibweise (<c>Europe/Berlin</c>).
|
||||
///
|
||||
/// Das ist die Form, die in Task-Dateien geschrieben werden soll: Sie gilt auf
|
||||
/// Linux, macOS und — seit .NET 8 — auch auf Windows.
|
||||
/// </summary>
|
||||
public static string LocalIanaId => ToIana(TimeZoneInfo.Local.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Löst eine Kennung auf, gleich ob IANA- oder Windows-Schreibweise.
|
||||
/// Gibt <c>null</c> zurück, wenn sie auf diesem System nicht auflösbar ist —
|
||||
/// der Aufrufer muss dann entscheiden, und zwar sichtbar.
|
||||
/// </summary>
|
||||
public static TimeZoneInfo? TryResolve(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
|
||||
var value = id.Trim();
|
||||
|
||||
if (string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase))
|
||||
return TimeZoneInfo.Utc;
|
||||
|
||||
// Direkt versuchen: .NET nimmt je nach Version und Plattform bereits beide
|
||||
// Formen an. Wenn das reicht, sind wir fertig.
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(value); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
|
||||
// Sonst die jeweils andere Schreibweise versuchen.
|
||||
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana))
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(iana); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
|
||||
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(value, out var windows))
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(windows); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ob die Kennung auf diesem System auflösbar ist. Leer gilt als gültig — das
|
||||
/// bedeutet „keine Angabe" und wird vom Aufrufer als UTC gedeutet.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string? id)
|
||||
=> string.IsNullOrWhiteSpace(id) || TryResolve(id) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Bringt eine Kennung auf IANA-Schreibweise. Lässt sie sich nicht umsetzen, kommt
|
||||
/// sie unverändert zurück — eine unbekannte Kennung zu verfälschen wäre schlimmer,
|
||||
/// als sie durchzureichen.
|
||||
/// </summary>
|
||||
public static string ToIana(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return "";
|
||||
|
||||
var value = id.Trim();
|
||||
|
||||
// Enthält einen Schrägstrich → bereits IANA (Windows-Kennungen haben keinen).
|
||||
if (value.Contains('/')) return value;
|
||||
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana) ? iana : value;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
public sealed class ToolJobScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly string _instanceId;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _schedulerTasks = new();
|
||||
private readonly Dictionary<string, ToolJobResult?> _lastResults = new();
|
||||
private readonly Lock _resultsLock = new();
|
||||
|
||||
public event Action<string, string, ToolJobResult>? OnJobTick;
|
||||
|
||||
public ToolJobScheduler(
|
||||
AgentEngine engine,
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
string instanceId)
|
||||
{
|
||||
_engine = engine;
|
||||
_toolRegistry = toolRegistry;
|
||||
_stateStore = stateStore;
|
||||
_instanceId = instanceId;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob");
|
||||
}
|
||||
|
||||
public void RegisterAll(IEnumerable<AgentConfig> agents)
|
||||
{
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
foreach (var jobConfig in agent.ToolJobs)
|
||||
{
|
||||
if (!jobConfig.Enabled)
|
||||
continue;
|
||||
|
||||
var tool = _toolRegistry.Get(jobConfig.ToolName);
|
||||
if (tool is not IToolJobProvider provider)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found",
|
||||
jobConfig.ToolName, jobConfig.JobId, agent.AgentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}",
|
||||
agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron);
|
||||
|
||||
var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token);
|
||||
_schedulerTasks.Add(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ToolJobResult? GetLastResult(string jobId)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(jobId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans).
|
||||
/// </summary>
|
||||
public async Task<ToolJobResult> TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct)
|
||||
{
|
||||
var tool = _toolRegistry.Get(jobConfig.ToolName);
|
||||
if (tool is not IToolJobProvider provider)
|
||||
return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert.");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}",
|
||||
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId);
|
||||
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(jobConfig.JobId)
|
||||
?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden.");
|
||||
}
|
||||
|
||||
private async Task RunToolJobAsync(
|
||||
AgentConfig agentConfig,
|
||||
ToolJobConfig jobConfig,
|
||||
IToolJobProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (jobConfig.RunOnStart)
|
||||
{
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobConfig.Cron))
|
||||
return;
|
||||
|
||||
var cron = CronExpression.Parse(jobConfig.Cron);
|
||||
|
||||
while (!ct.IsCancellationRequested && jobConfig.Enabled)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = cron.GetNextOccurrence(now);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
_logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = next.Value - now;
|
||||
_logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}",
|
||||
jobConfig.JobId, jobConfig.JobTypeId, next.Value);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteTickAsync(
|
||||
AgentConfig agentConfig,
|
||||
ToolJobConfig jobConfig,
|
||||
IToolJobProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job");
|
||||
|
||||
if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'",
|
||||
jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId);
|
||||
|
||||
jobConfig.Enabled = false;
|
||||
|
||||
var disabledResult = new ToolJobResult(false, null,
|
||||
$"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'");
|
||||
lock (_resultsLock)
|
||||
_lastResults[jobConfig.JobId] = disabledResult;
|
||||
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg)
|
||||
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var result = await provider.ExecuteJobAsync(
|
||||
jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct,
|
||||
agentConfig.AgentId, agentConfig.WorkspacePath);
|
||||
|
||||
lock (_resultsLock)
|
||||
_lastResults[jobConfig.JobId] = result;
|
||||
|
||||
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}",
|
||||
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary);
|
||||
|
||||
if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}",
|
||||
agentConfig.AgentId, jobConfig.JobId, result.UseChatContext);
|
||||
|
||||
if (result.UseChatContext)
|
||||
await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job);
|
||||
else
|
||||
await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}",
|
||||
agentConfig.AgentId, jobConfig.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_schedulerTasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ public static class ConfigSecrets
|
||||
telegram.Password2FA = transform(telegram.Password2FA);
|
||||
}
|
||||
|
||||
if (config.Watchdog is { } watchdog)
|
||||
watchdog.AgentToken = transform(watchdog.AgentToken) ?? "";
|
||||
|
||||
foreach (var agent in config.Agents)
|
||||
Apply(agent, transform);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Security.Cryptography;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet den lokalen Schlüssel, mit dem <see cref="SecretProtector"/> die
|
||||
/// <c>enc:v2</c>-Werte sichert.
|
||||
///
|
||||
/// <para><b>Ein Schlüssel je Benutzer und Rechner.</b> Er liegt als
|
||||
/// <c>secret.key</c> in <see cref="AppPaths.ConfigDirectory"/> — bewusst außerhalb
|
||||
/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
|
||||
/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
|
||||
/// nützt auf einem anderen Rechner nichts.</para>
|
||||
///
|
||||
/// <para><b>Rechte.</b> Unter Unix <c>0600</c>. Unter Windows erbt die Datei die
|
||||
/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
|
||||
/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
|
||||
/// plattformübergreifend ist.</para>
|
||||
/// </summary>
|
||||
public static class SecretKeyStore
|
||||
{
|
||||
private const string KeyFileName = "secret.key";
|
||||
private const int KeySize = 32; // AES-256
|
||||
|
||||
/// <summary>DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows).</summary>
|
||||
private static readonly byte[] KeyEntropy =
|
||||
System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
|
||||
|
||||
private static readonly Lock Gate = new();
|
||||
private static byte[]? _cached;
|
||||
private static string? _overrideDirectory;
|
||||
|
||||
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Verlegt den Schlüssel — für Tests, damit sie den echten Benutzerschlüssel weder
|
||||
/// lesen noch überschreiben.
|
||||
/// </summary>
|
||||
public static void UseDirectory(string? directory)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_overrideDirectory = directory;
|
||||
_cached = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den Schlüssel oder legt ihn beim ersten Aufruf an.
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
|
||||
public static byte[] GetOrCreateKey()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_cached is not null) return _cached;
|
||||
|
||||
var directory = AppPaths.EnsureDirectory(Directory());
|
||||
var path = Path.Combine(directory, KeyFileName);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var stored = Unwrap(File.ReadAllBytes(path));
|
||||
if (stored.Length == KeySize)
|
||||
{
|
||||
_cached = stored;
|
||||
return _cached;
|
||||
}
|
||||
|
||||
// Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen
|
||||
// würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt,
|
||||
// warum — deshalb hier abbrechen und den Pfad nennen.
|
||||
throw new CryptographicException(
|
||||
$"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). "
|
||||
+ "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen.");
|
||||
}
|
||||
|
||||
var key = RandomNumberGenerator.GetBytes(KeySize);
|
||||
|
||||
// Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der
|
||||
// würde alle Geheimnisse dieser Installation unlesbar machen.
|
||||
AtomicFile.WriteAllBytes(path, Wrap(key));
|
||||
AppPaths.RestrictToOwner(path);
|
||||
|
||||
_cached = key;
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
|
||||
|
||||
/// <summary>Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden.</summary>
|
||||
private static byte[] Wrap(byte[] key)
|
||||
=> OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key;
|
||||
|
||||
private static byte[] Unwrap(byte[] stored)
|
||||
=> OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored;
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static byte[] ProtectWithDpapi(byte[] key)
|
||||
=> ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser);
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static byte[] UnprotectWithDpapi(byte[] stored)
|
||||
=> ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Security;
|
||||
|
||||
@@ -12,23 +13,46 @@ namespace ClawdDotNet.Core.Security;
|
||||
/// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein
|
||||
/// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge.
|
||||
///
|
||||
/// Verwendet wird DPAPI im Benutzerkontext: Die Daten lassen sich nur von demselben
|
||||
/// Windows-Benutzer auf demselben Rechner entschlüsseln. Das schützt gegen Weitergabe
|
||||
/// der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer läuft —
|
||||
/// für einen lokal laufenden Dienst ist das die angemessene Stufe.
|
||||
/// <para><b>Zwei Formate.</b></para>
|
||||
///
|
||||
/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen
|
||||
/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>enc:v1:</c> — DPAPI im Benutzerkontext. Nur unter Windows lesbar. Wird
|
||||
/// nicht mehr geschrieben, aber weiterhin gelesen: bestehende Installationen sollen
|
||||
/// ohne Zutun weiterlaufen und wandern beim nächsten Speichern von selbst auf v2.</item>
|
||||
/// <item><c>enc:v2:</c> — AES-256-GCM mit einem Schlüssel aus
|
||||
/// <see cref="AppPaths.ConfigDirectory"/>. Läuft auf jeder Plattform.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Warum v2 überhaupt nötig wurde.</b> Die vorige Fassung gab unter Linux
|
||||
/// stillschweigend den Klartext zurück — <c>Protect</c> verschlüsselte dort schlicht
|
||||
/// nicht. Auf einem Server, der per SSH erreichbar ist und gesichert wird, wäre das
|
||||
/// schlechter gewesen als auf einem Einzelplatz-Windows.</para>
|
||||
///
|
||||
/// <para><b>Schutzstufe.</b> Dieselbe wie DPAPI zuvor: gegen Weitergabe der
|
||||
/// Konfigurationsdatei, gegen ein Backup, gegen einen versehentlichen Commit — nicht
|
||||
/// gegen einen Angreifer, der bereits als dieser Benutzer läuft. Der Schlüssel liegt
|
||||
/// deshalb bewusst <b>außerhalb</b> des Instanzverzeichnisses: Eine Sicherung der
|
||||
/// Instanz enthält ihn nicht, und ob Geheimnisse mitreisen, entscheidet weiterhin
|
||||
/// allein die Sicherungsrichtlinie in <c>BackupService</c>.</para>
|
||||
/// </summary>
|
||||
public static class SecretProtector
|
||||
{
|
||||
private const string Prefix = "enc:v1:";
|
||||
private const string PrefixV1 = "enc:v1:";
|
||||
private const string PrefixV2 = "enc:v2:";
|
||||
|
||||
/// <summary>Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist.</summary>
|
||||
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1");
|
||||
|
||||
/// <summary>Wird v2 als Zusatzangabe mitgeschrieben und beim Entschlüsseln geprüft.</summary>
|
||||
private static readonly byte[] AssociatedData = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v2");
|
||||
|
||||
private const int NonceSize = 12; // AES-GCM: vorgeschriebene Länge
|
||||
private const int TagSize = 16;
|
||||
|
||||
public static bool IsProtected(string? value)
|
||||
=> value?.StartsWith(Prefix, StringComparison.Ordinal) == true;
|
||||
=> value is not null
|
||||
&& (value.StartsWith(PrefixV2, StringComparison.Ordinal)
|
||||
|| value.StartsWith(PrefixV1, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert,
|
||||
@@ -39,18 +63,35 @@ public static class SecretProtector
|
||||
if (string.IsNullOrEmpty(plainText) || IsProtected(plainText))
|
||||
return plainText;
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return plainText;
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = ProtectWindows(Encoding.UTF8.GetBytes(plainText));
|
||||
return Prefix + Convert.ToBase64String(encrypted);
|
||||
var key = SecretKeyStore.GetOrCreateKey();
|
||||
|
||||
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
||||
var plain = Encoding.UTF8.GetBytes(plainText);
|
||||
var cipher = new byte[plain.Length];
|
||||
var tag = new byte[TagSize];
|
||||
|
||||
using (var aes = new AesGcm(key, TagSize))
|
||||
aes.Encrypt(nonce, plain, cipher, tag, AssociatedData);
|
||||
|
||||
// nonce ‖ tag ‖ ciphertext — feste Längen vorn, damit das Zerlegen eindeutig ist.
|
||||
var payload = new byte[NonceSize + TagSize + cipher.Length];
|
||||
nonce.CopyTo(payload, 0);
|
||||
tag.CopyTo(payload, NonceSize);
|
||||
cipher.CopyTo(payload, NonceSize + TagSize);
|
||||
|
||||
return PrefixV2 + Convert.ToBase64String(payload);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren.
|
||||
return plainText;
|
||||
// Kein Schlüssel anlegbar (etwa ein schreibgeschütztes Konfigurationsverzeichnis).
|
||||
// Den Wert unverschlüsselt zu speichern wäre die stille Rückkehr zu genau dem
|
||||
// Zustand, den S7 behoben hat — deshalb hier abbrechen statt weiterreichen.
|
||||
throw new SecretProtectionException(
|
||||
"Ein Wert konnte nicht verschlüsselt werden, weil der lokale Schlüssel nicht "
|
||||
+ $"lesbar oder anlegbar ist ({SecretKeyStore.KeyFilePath}). Ohne ihn würden "
|
||||
+ "Zugangsdaten im Klartext gespeichert — der Vorgang wurde abgebrochen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,34 +101,74 @@ public static class SecretProtector
|
||||
/// </summary>
|
||||
public static string? Unprotect(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || !IsProtected(value))
|
||||
return value;
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return value;
|
||||
if (value.StartsWith(PrefixV2, StringComparison.Ordinal))
|
||||
return UnprotectV2(value[PrefixV2.Length..]);
|
||||
|
||||
var payload = value[Prefix.Length..];
|
||||
if (value.StartsWith(PrefixV1, StringComparison.Ordinal))
|
||||
return UnprotectV1(value[PrefixV1.Length..]);
|
||||
|
||||
return value; // Klartext aus älteren Konfigurationen
|
||||
}
|
||||
|
||||
private static string UnprotectV2(string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var decrypted = UnprotectWindows(Convert.FromBase64String(payload));
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
var raw = Convert.FromBase64String(payload);
|
||||
if (raw.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Der verschlüsselte Block ist unvollständig.");
|
||||
|
||||
var key = SecretKeyStore.GetOrCreateKey();
|
||||
|
||||
var nonce = raw.AsSpan(0, NonceSize);
|
||||
var tag = raw.AsSpan(NonceSize, TagSize);
|
||||
var cipher = raw.AsSpan(NonceSize + TagSize);
|
||||
var plain = new byte[cipher.Length];
|
||||
|
||||
using (var aes = new AesGcm(key, TagSize))
|
||||
aes.Decrypt(nonce, cipher, tag, plain, AssociatedData);
|
||||
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException)
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException
|
||||
or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Etwa nach Benutzerwechsel oder Rechnerwechsel: Der Wert ist hier nicht
|
||||
// lesbar. Ihn als Klartext auszugeben wäre falsch — dann würde ein
|
||||
// unbrauchbarer Schlüssel an die API gehen.
|
||||
throw new SecretProtectionException(
|
||||
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " +
|
||||
"Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " +
|
||||
"Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
|
||||
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
|
||||
+ "Konfiguration von einem anderen Rechner oder Benutzer stammt — der Schlüssel "
|
||||
+ $"dazu liegt in {SecretKeyStore.KeyFilePath} und reist nicht mit. "
|
||||
+ "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static byte[] ProtectWindows(byte[] data)
|
||||
=> ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser);
|
||||
private static string UnprotectV1(string payload)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
// Der Fall beim Umzug einer Windows-Instanz auf Linux. Ihn als Klartext
|
||||
// durchzureichen wäre falsch — dann ginge ein unbrauchbarer Schlüssel an die API.
|
||||
throw new SecretProtectionException(
|
||||
"Dieser Wert wurde mit der Windows-Verschlüsselung (DPAPI) gesichert und lässt "
|
||||
+ "sich hier nicht lesen. Beim Umzug einer Instanz von Windows müssen die "
|
||||
+ "betroffenen Werte einmal neu eingetragen werden; danach liegen sie im "
|
||||
+ "plattformübergreifenden Format vor.",
|
||||
new PlatformNotSupportedException("DPAPI ist nur unter Windows verfügbar."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(UnprotectWindows(Convert.FromBase64String(payload)));
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException)
|
||||
{
|
||||
throw new SecretProtectionException(
|
||||
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
|
||||
+ "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. "
|
||||
+ "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static byte[] UnprotectWindows(byte[] data)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Die Staging-Warteschlange in der Instanz-Datenbank. Der bedingte Statuswechsel
|
||||
/// (<see cref="TryTransitionAsync"/>) ist dieselbe atomare Claim-Technik wie beim
|
||||
/// Taskboard: Er verhindert, dass zwei Reviewer denselben Vorschlag doppelt entscheiden.
|
||||
/// </summary>
|
||||
public sealed class SqliteStagingRepository : IStagingRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteStagingRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task<long> AppendAsync(StagedCall call, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO StagedCalls
|
||||
(RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, CreatedAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @instanceId, @tool, @action, @args, @proposal, 'Pending', @createdAt);
|
||||
SELECT last_insert_rowid();
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", call.RunId);
|
||||
cmd.Parameters.AddWithValue("@agentId", call.AgentId);
|
||||
cmd.Parameters.AddWithValue("@instanceId", call.InstanceId);
|
||||
cmd.Parameters.AddWithValue("@tool", call.Tool);
|
||||
cmd.Parameters.AddWithValue("@action", (object?)call.Action ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@args", call.ArgumentsJson);
|
||||
cmd.Parameters.AddWithValue("@proposal", call.Proposal);
|
||||
cmd.Parameters.AddWithValue("@createdAt", Format(DateTime.UtcNow));
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct));
|
||||
}, ct);
|
||||
|
||||
public async Task<StagedCall?> GetAsync(long id, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Id = @id LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? Read(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Status = 'Pending' ORDER BY Id";
|
||||
|
||||
var results = new List<StagedCall>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<bool> TryTransitionAsync(
|
||||
long id, StagingStatus from, StagingStatus to,
|
||||
string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE StagedCalls
|
||||
SET Status = @to, DecidedBy = @decidedBy, DecidedAt = @now,
|
||||
RejectionReason = @reason
|
||||
WHERE Id = @id AND Status = @from
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@to", to.ToString());
|
||||
cmd.Parameters.AddWithValue("@decidedBy", (object?)decidedBy ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@reason", (object?)rejectionReason ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@from", from.ToString());
|
||||
return await cmd.ExecuteNonQueryAsync(ct) == 1;
|
||||
}, ct);
|
||||
|
||||
public Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE StagedCalls
|
||||
SET Status = @status, ResultRef = @resultRef, DecidedAt = @now
|
||||
WHERE Id = @id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@status", status.ToString());
|
||||
cmd.Parameters.AddWithValue("@resultRef", (object?)resultRef ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<int> CountPendingAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM StagedCalls WHERE Status = 'Pending'";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
"Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " +
|
||||
"CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason";
|
||||
|
||||
private static StagedCall Read(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetInt64(0),
|
||||
RunId = r.GetString(1),
|
||||
AgentId = r.GetString(2),
|
||||
InstanceId = r.GetString(3),
|
||||
Tool = r.GetString(4),
|
||||
Action = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
ArgumentsJson = r.GetString(6),
|
||||
Proposal = r.GetString(7),
|
||||
Status = Enum.TryParse<StagingStatus>(r.GetString(8), out var s) ? s : StagingStatus.Pending,
|
||||
CreatedAt = Parse(r.GetString(9)),
|
||||
DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
|
||||
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
|
||||
ResultRef = r.IsDBNull(12) ? null : r.GetString(12),
|
||||
RejectionReason = r.IsDBNull(13) ? null : r.GetString(13)
|
||||
};
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>Was die Engine mit einem Aufruf tun soll, nachdem das Gate ihn geprüft hat.</summary>
|
||||
public enum StagingOutcome
|
||||
{
|
||||
/// <summary>Normal ausführen.</summary>
|
||||
Proceed,
|
||||
|
||||
/// <summary>Als Vorschlag angelegt — die Ausführung wartet auf Freigabe.</summary>
|
||||
Staged,
|
||||
|
||||
/// <summary>Abgelehnt (Policy <c>deny</c>).</summary>
|
||||
Denied
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis der Gate-Prüfung samt der Nachricht, die der Agent als Tool-Ergebnis sieht.</summary>
|
||||
public readonly record struct StagingInterception(StagingOutcome Outcome, string Message, long StagedId)
|
||||
{
|
||||
public static StagingInterception Proceed { get; } = new(StagingOutcome.Proceed, "", 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Durchsetzungspunkt (A2): prüft die Policy für einen konkreten Aufruf und legt bei
|
||||
/// <c>approve</c> einen eingefrorenen Vorschlag an, statt auszuführen. Optional an der
|
||||
/// Engine — ohne Gate läuft alles wie bisher.
|
||||
/// </summary>
|
||||
public sealed class StagingGate
|
||||
{
|
||||
private readonly StagingPolicy _policy;
|
||||
private readonly IStagingRepository _repo;
|
||||
|
||||
public StagingGate(StagingPolicy policy, IStagingRepository repo)
|
||||
{
|
||||
_policy = policy;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<StagingInterception> InterceptAsync(
|
||||
string agentId, string instanceId, string runId,
|
||||
string tool, string argumentsJson, CancellationToken ct)
|
||||
{
|
||||
var action = ExtractAction(argumentsJson);
|
||||
|
||||
switch (_policy.Decide(tool, action))
|
||||
{
|
||||
case StagingDecision.Auto:
|
||||
return StagingInterception.Proceed;
|
||||
|
||||
case StagingDecision.Deny:
|
||||
return new StagingInterception(
|
||||
StagingOutcome.Denied,
|
||||
$"Aktion '{Label(tool, action)}' ist gesperrt (Policy: deny) und wurde nicht ausgeführt.",
|
||||
0);
|
||||
|
||||
case StagingDecision.Approve:
|
||||
var id = await _repo.AppendAsync(new StagedCall
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentId,
|
||||
InstanceId = instanceId,
|
||||
Tool = tool,
|
||||
Action = action,
|
||||
ArgumentsJson = argumentsJson,
|
||||
Proposal = BuildProposal(tool, action, argumentsJson)
|
||||
}, ct);
|
||||
|
||||
return new StagingInterception(
|
||||
StagingOutcome.Staged,
|
||||
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. " +
|
||||
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt; du wirst danach " +
|
||||
"mit dem Ergebnis geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
|
||||
id);
|
||||
|
||||
default:
|
||||
return StagingInterception.Proceed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Liest das <c>action</c>-Argument, wenn vorhanden — der Aktionsschlüssel der Policy.</summary>
|
||||
public static string? ExtractAction(string argumentsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argumentsJson))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argumentsJson);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("action", out var a)
|
||||
&& a.ValueKind == JsonValueKind.String
|
||||
? a.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Label(string tool, string? action)
|
||||
=> string.IsNullOrWhiteSpace(action) ? tool : $"{tool}.{action}";
|
||||
|
||||
private static string BuildProposal(string tool, string? action, string argumentsJson)
|
||||
{
|
||||
var args = argumentsJson.Length > 500 ? argumentsJson[..500] + "…" : argumentsJson;
|
||||
return $"{Label(tool, action)} {args}".Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen freigegebenen, eingefrorenen Aufruf aus — mit gültigem Tool-Kontext, aber
|
||||
/// ohne LLM-Schleife. Von der Engine implementiert.
|
||||
/// </summary>
|
||||
public interface IFrozenCallExecutor
|
||||
{
|
||||
Task<string> ExecuteApprovedCallAsync(
|
||||
string agentId, string tool, string argumentsJson, string runId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>Was mit einem Tool-Aufruf geschehen soll — die Policy-Entscheidung.</summary>
|
||||
public enum StagingDecision
|
||||
{
|
||||
/// <summary>Ausführen wie bisher.</summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>Stagen und auf menschliche Freigabe warten.</summary>
|
||||
Approve,
|
||||
|
||||
/// <summary>Gar nicht erst vorschlagen — ablehnen.</summary>
|
||||
Deny
|
||||
}
|
||||
|
||||
/// <summary>Lebenszyklus eines eingefrorenen Aufrufs.</summary>
|
||||
public enum StagingStatus
|
||||
{
|
||||
/// <summary>Vorgeschlagen, wartet auf Entscheidung.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Freigegeben und beansprucht (wird ausgeführt).</summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>Freigegeben und ausgeführt.</summary>
|
||||
Executed,
|
||||
|
||||
/// <summary>Freigegeben, aber die Ausführung schlug fehl.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Abgelehnt.</summary>
|
||||
Rejected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein eingefrorener, konkreter Tool-Aufruf, der auf eine Freigabe wartet. „Eingefroren"
|
||||
/// heißt: Tool, Aktion und die <b>exakten</b> Argumente zum Zeitpunkt des Vorschlags.
|
||||
/// Ausgeführt wird genau das (Plan-Freeze) — nie eine nachträglich veränderte Fassung.
|
||||
/// </summary>
|
||||
public sealed record StagedCall
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>Lauf, aus dem der Vorschlag stammt — verbindet ihn mit dem Audit-Log.</summary>
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
public string AgentId { get; init; } = "";
|
||||
public string InstanceId { get; init; } = "";
|
||||
|
||||
public string Tool { get; init; } = "";
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>Die eingefrorenen Argumente (roher JSON, exakt wie vom Modell geschickt).</summary>
|
||||
public string ArgumentsJson { get; init; } = "";
|
||||
|
||||
/// <summary>Kurze, menschenlesbare Zusammenfassung des Vorschlags.</summary>
|
||||
public string Proposal { get; init; } = "";
|
||||
|
||||
public StagingStatus Status { get; init; } = StagingStatus.Pending;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? DecidedAt { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
|
||||
/// <summary>Nach der Ausführung: kurzer Verweis auf das Ergebnis.</summary>
|
||||
public string? ResultRef { get; init; }
|
||||
|
||||
/// <summary>Bei Ablehnung: der Grund.</summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
}
|
||||
|
||||
public interface IStagingRepository
|
||||
{
|
||||
/// <summary>Legt einen Vorschlag an und gibt seine Id zurück.</summary>
|
||||
Task<long> AppendAsync(StagedCall call, CancellationToken ct);
|
||||
|
||||
Task<StagedCall?> GetAsync(long id, CancellationToken ct);
|
||||
|
||||
/// <summary>Die offenen Vorschläge (Pending), älteste zuerst — für die Review-Ansicht.</summary>
|
||||
Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Atomarer, bedingter Statuswechsel: nur wirksam, wenn der Vorschlag noch im Status
|
||||
/// <paramref name="from"/> steht. Verhindert, dass zwei Reviewer denselben Vorschlag
|
||||
/// doppelt entscheiden. Gibt zurück, ob der Wechsel gelang.
|
||||
/// </summary>
|
||||
Task<bool> TryTransitionAsync(
|
||||
long id, StagingStatus from, StagingStatus to,
|
||||
string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>Setzt Endstatus und Ergebnis-Verweis nach der Ausführung (zweite Phase).</summary>
|
||||
Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct);
|
||||
|
||||
Task<int> CountPendingAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet je (Tool, Aktion), ob ein Aufruf läuft, gestaged oder abgelehnt wird.
|
||||
///
|
||||
/// Auflösung vom Speziellen zum Allgemeinen: <c>Tool.Aktion</c> → <c>Tool</c> → Standard.
|
||||
/// Rein und ohne Zustand, damit die Entscheidung testbar bleibt.
|
||||
/// </summary>
|
||||
public sealed class StagingPolicy
|
||||
{
|
||||
private readonly Dictionary<string, StagingDecision> _rules;
|
||||
private readonly StagingDecision _default;
|
||||
|
||||
public StagingPolicy(
|
||||
IReadOnlyDictionary<string, StagingDecision>? rules = null,
|
||||
StagingDecision defaultDecision = StagingDecision.Auto)
|
||||
{
|
||||
_rules = new Dictionary<string, StagingDecision>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in rules ?? DefaultRules)
|
||||
_rules[key] = value;
|
||||
_default = defaultDecision;
|
||||
}
|
||||
|
||||
public StagingDecision Decide(string tool, string? action)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(action)
|
||||
&& _rules.TryGetValue($"{tool}.{action}", out var specific))
|
||||
return specific;
|
||||
|
||||
if (_rules.TryGetValue(tool, out var byTool))
|
||||
return byTool;
|
||||
|
||||
return _default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eingebaute Standardregeln — genau die irreversiblen Aktionen aus der Roadmap, nach
|
||||
/// Sichtung der Tools. Lesende Aktionen bleiben bewusst außen vor (Staging soll
|
||||
/// schützen, nicht lähmen). Überschreibbar per Konfiguration.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, StagingDecision> DefaultRules { get; } =
|
||||
new Dictionary<string, StagingDecision>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Mail.send"] = StagingDecision.Approve,
|
||||
["Telegram.send_message"] = StagingDecision.Approve,
|
||||
["Database.insert"] = StagingDecision.Approve,
|
||||
["Database.upsert"] = StagingDecision.Approve,
|
||||
["FileRW.delete"] = StagingDecision.Approve,
|
||||
["FTP.upload"] = StagingDecision.Approve,
|
||||
["FTP.delete"] = StagingDecision.Approve
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
public enum StagingResultKind { NotFound, AlreadyDecided, Executed, Failed, Rejected }
|
||||
|
||||
public readonly record struct StagingResult(StagingResultKind Kind, string Message)
|
||||
{
|
||||
public static StagingResult NotFound { get; } = new(StagingResultKind.NotFound, "Vorschlag nicht gefunden.");
|
||||
public static StagingResult AlreadyDecided { get; } =
|
||||
new(StagingResultKind.AlreadyDecided, "Der Vorschlag wurde bereits entschieden.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Freigabe-/Ablehnungs-Seite von A2 — die API, die die Review-Oberfläche aufruft.
|
||||
///
|
||||
/// Kein pausierter Lauf: Bei Freigabe wird der <b>eingefrorene</b> Aufruf direkt
|
||||
/// ausgeführt (Plan-Freeze), das Ergebnis festgehalten und der Agent über einen
|
||||
/// Folge-Task (A1) mit dem Ergebnis geweckt. Jede Entscheidung wird als Approval-Record
|
||||
/// ins Audit-Log (A3) geschrieben.
|
||||
/// </summary>
|
||||
public sealed class StagingService
|
||||
{
|
||||
private readonly IStagingRepository _repo;
|
||||
private readonly IFrozenCallExecutor _executor;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly IAuditRepository? _audit;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public StagingService(
|
||||
IStagingRepository repo,
|
||||
IFrozenCallExecutor executor,
|
||||
TaskboardService board,
|
||||
ILoggerFactory loggerFactory,
|
||||
IAuditRepository? audit = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_executor = executor;
|
||||
_board = board;
|
||||
_audit = audit;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct) => _repo.ListPendingAsync(ct);
|
||||
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
|
||||
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
|
||||
|
||||
public async Task<StagingResult> ApproveAsync(long id, string decidedBy, CancellationToken ct)
|
||||
{
|
||||
var call = await _repo.GetAsync(id, ct);
|
||||
if (call is null) return StagingResult.NotFound;
|
||||
|
||||
// Atomar beanspruchen — ein zweiter Reviewer läuft ins Leere, bevor irgendetwas
|
||||
// ausgeführt wird.
|
||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
|
||||
return StagingResult.AlreadyDecided;
|
||||
|
||||
string result;
|
||||
StagingStatus final;
|
||||
try
|
||||
{
|
||||
// Genau der eingefrorene Aufruf — nie eine neu formulierte Fassung.
|
||||
result = await _executor.ExecuteApprovedCallAsync(
|
||||
call.AgentId, call.Tool, call.ArgumentsJson, Guid.NewGuid().ToString("N"), ct);
|
||||
final = StagingStatus.Executed;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
result = $"Ausführung fehlgeschlagen: {ex.Message}";
|
||||
final = StagingStatus.Failed;
|
||||
_logger.LogError(ex, "Freigegebener Aufruf #{Id} ({Tool}) schlug fehl", id, call.Tool);
|
||||
}
|
||||
|
||||
await _repo.FinalizeAsync(id, final, Cap(result, 500), DateTime.UtcNow, ct);
|
||||
await RecordAuditAsync(call, decidedBy, final == StagingStatus.Executed ? AuditStatus.Ok : AuditStatus.Error,
|
||||
$"Freigegeben von {decidedBy}", ct);
|
||||
await WakeAgentAsync(call,
|
||||
$"Freigabe-Ergebnis: {call.Tool}",
|
||||
$"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben und ausgeführt.\n\n" +
|
||||
$"Ergebnis:\n{result}\n\nSetze deine Arbeit fort.", ct);
|
||||
|
||||
return new StagingResult(
|
||||
final == StagingStatus.Executed ? StagingResultKind.Executed : StagingResultKind.Failed, result);
|
||||
}
|
||||
|
||||
public async Task<StagingResult> RejectAsync(long id, string decidedBy, string reason, CancellationToken ct)
|
||||
{
|
||||
var call = await _repo.GetAsync(id, ct);
|
||||
if (call is null) return StagingResult.NotFound;
|
||||
|
||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
|
||||
return StagingResult.AlreadyDecided;
|
||||
|
||||
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
|
||||
await WakeAgentAsync(call,
|
||||
$"Freigabe abgelehnt: {call.Tool}",
|
||||
$"[Ablehnung] Deine vorgelegte Aktion '{Label(call)}' wurde abgelehnt.\n\n" +
|
||||
$"Grund: {reason}\n\nFühre sie nicht erneut ohne Rücksprache aus.", ct);
|
||||
|
||||
return new StagingResult(StagingResultKind.Rejected, reason);
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private async Task WakeAgentAsync(StagedCall call, string title, string body, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Title = title,
|
||||
Body = body,
|
||||
Status = TaskItemStatus.Todo,
|
||||
Assignee = "@" + call.AgentId,
|
||||
Type = TaskItemType.Work
|
||||
}, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Folge-Task für Vorschlag #{Id} konnte nicht angelegt werden", call.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordAuditAsync(
|
||||
StagedCall call, string decidedBy, AuditStatus status, string summary, CancellationToken ct)
|
||||
{
|
||||
if (_audit is null) return;
|
||||
try
|
||||
{
|
||||
await _audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = call.RunId,
|
||||
AgentId = call.AgentId,
|
||||
Model = "",
|
||||
Source = "approval",
|
||||
Tool = call.Tool,
|
||||
Arguments = Cap(call.ArgumentsJson, 4_000),
|
||||
Status = status,
|
||||
Summary = Cap(summary, 500),
|
||||
OccurredAt = DateTime.Now
|
||||
}, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Approval-Record für #{Id} konnte nicht geschrieben werden", call.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Label(StagedCall call)
|
||||
=> string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
|
||||
|
||||
private static string Cap(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Anwendung ihre eigenen Dateien ablegt — getrennt nach Programm und Daten.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Bisher lagen Einstellungen und Arbeitsordner neben
|
||||
/// der Programmdatei (<c>AppDomain.CurrentDomain.BaseDirectory</c>). Unter Windows in
|
||||
/// einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung in <c>/opt</c>
|
||||
/// oder <c>/usr/lib</c> und ist für den Dienstbenutzer <b>nicht schreibbar</b>.
|
||||
///
|
||||
/// Deshalb hier die übliche Trennung: Programm bleibt, wo es installiert ist, Daten
|
||||
/// wandern in das Verzeichnis des Benutzers (XDG unter Linux, <c>%APPDATA%</c> unter
|
||||
/// Windows). Beides lässt sich per Umgebungsvariable überschreiben — für Dienste, die
|
||||
/// nach <c>/var/lib</c> schreiben sollen, und für Tests.
|
||||
/// </summary>
|
||||
public static class AppPaths
|
||||
{
|
||||
private const string AppFolder = "ClawdDotNet";
|
||||
|
||||
/// <summary>Einstellungen und Schlüssel. Klein, selten geschrieben, gehört gesichert.</summary>
|
||||
public static string ConfigDirectory { get; } = ResolveConfig();
|
||||
|
||||
/// <summary>Instanzen, Datenbanken, Logs. Groß, oft geschrieben.</summary>
|
||||
public static string DataDirectory { get; } = ResolveData();
|
||||
|
||||
/// <summary>
|
||||
/// Das Verzeichnis der Programmdatei.
|
||||
///
|
||||
/// Weiterhin nötig, um bestehende Windows-Installationen zu finden: Dort liegt die
|
||||
/// <c>AppSettings.json</c> noch am alten Ort, und ein Update darf sie nicht
|
||||
/// verwaisen lassen.
|
||||
/// </summary>
|
||||
public static string ProgramDirectory => AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>Legt das Verzeichnis an und schränkt unter Unix die Rechte auf den Benutzer ein.</summary>
|
||||
public static string EnsureDirectory(string path)
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
|
||||
// 0700: In den Konfigurationsverzeichnissen liegen Schlüssel und Zugangsdaten.
|
||||
// Unter Windows regelt das die Vererbung aus dem Benutzerprofil, unter Linux
|
||||
// wären es sonst je nach umask 0755 — für alle lesbar.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
catch (IOException) { /* etwa auf Netzlaufwerken ohne Rechteverwaltung */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Schränkt eine Datei unter Unix auf <c>0600</c> ein. Unter Windows wirkungslos.</summary>
|
||||
public static void RestrictToOwner(string filePath)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
// ─── Auflösung ───
|
||||
|
||||
private static string ResolveConfig()
|
||||
{
|
||||
if (FromEnvironment("CLAWD_CONFIG_DIR") is { } explicitDir)
|
||||
return explicitDir;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppFolder);
|
||||
|
||||
if (FromEnvironment("XDG_CONFIG_HOME") is { } xdg)
|
||||
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
|
||||
|
||||
if (FromEnvironment("HOME") is { } home)
|
||||
return Path.Combine(home, ".config", AppFolder.ToLowerInvariant());
|
||||
|
||||
// Ein Dienst ohne HOME. Lieber ein fester, dokumentierter Ort als ein relativer
|
||||
// Pfad, der vom Arbeitsverzeichnis abhängt und beim nächsten Start woanders liegt.
|
||||
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string ResolveData()
|
||||
{
|
||||
if (FromEnvironment("CLAWD_DATA_DIR") is { } explicitDir)
|
||||
return explicitDir;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolder);
|
||||
|
||||
if (FromEnvironment("XDG_DATA_HOME") is { } xdg)
|
||||
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
|
||||
|
||||
if (FromEnvironment("HOME") is { } home)
|
||||
return Path.Combine(home, ".local", "share", AppFolder.ToLowerInvariant());
|
||||
|
||||
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string? FromEnvironment(string name)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>GetFolderPath</c> kann eine leere Zeichenkette liefern (Dienstkonto ohne
|
||||
/// geladenes Profil). <c>Path.Combine("", …)</c> ergäbe dann einen relativen Pfad —
|
||||
/// die Datei landete im Arbeitsverzeichnis und wäre beim nächsten Start verschwunden.
|
||||
/// </summary>
|
||||
private static string Combine(string root, string folder)
|
||||
=> string.IsNullOrWhiteSpace(root)
|
||||
? Path.Combine(AppContext.BaseDirectory, folder)
|
||||
: Path.Combine(root, folder);
|
||||
}
|
||||
@@ -29,11 +29,15 @@ public static class AtomicFile
|
||||
/// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
|
||||
/// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
|
||||
/// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
|
||||
///
|
||||
/// Der Schlüssel kommt aus <see cref="PathBoundary.CanonicalKey"/>: Unter Windows
|
||||
/// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe
|
||||
/// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();
|
||||
|
||||
private static SemaphoreSlim LockFor(string fullPath)
|
||||
=> PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
|
||||
=> PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut.
|
||||
///
|
||||
/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit
|
||||
/// <c>OrdinalIgnoreCase</c> — die Annahme von Windows, dass Groß- und Kleinschreibung
|
||||
/// keine Rolle spielt. Unter Linux ist das falsch: <c>/home/x/Workspace</c> und
|
||||
/// <c>/home/x/workspace</c> sind zwei verschiedene Verzeichnisse. Ein Kandidat im
|
||||
/// zweiten würde als „innerhalb" des ersten durchgehen.
|
||||
///
|
||||
/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung.
|
||||
///
|
||||
/// Zweiter Punkt, den es unter Windows so nicht gab: <b>symbolische Verknüpfungen</b>.
|
||||
/// <c>Path.GetFullPath</c> löst sie nicht auf — es rechnet nur <c>..</c> heraus. Legt
|
||||
/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach <c>/etc</c> an, liegt
|
||||
/// <c>workspace/etc/passwd</c> nach reiner Zeichenkettenrechnung innerhalb, zeigt aber
|
||||
/// hinaus. <see cref="Canonicalize"/> löst deshalb jeden Pfadabschnitt auf.
|
||||
/// </summary>
|
||||
public static class PathBoundary
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie das Dateisystem Pfade vergleicht.
|
||||
///
|
||||
/// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist
|
||||
/// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber
|
||||
/// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt,
|
||||
/// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab,
|
||||
/// statt zu wenig.
|
||||
/// </summary>
|
||||
public static StringComparison Comparison { get; } =
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
/// <inheritdoc cref="Comparison"/>
|
||||
public static StringComparer Comparer { get; } =
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
|
||||
/// <summary>Absoluter Pfad mit genau einem abschließenden Trenner.</summary>
|
||||
public static string NormalizeDirectory(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.EndsWith(Path.DirectorySeparatorChar)
|
||||
? full
|
||||
: full + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob <paramref name="candidate"/> im Verzeichnis <paramref name="root"/> liegt.
|
||||
///
|
||||
/// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne
|
||||
/// den abschließenden Trenner gälte <c>…/Workspace-Backup</c> als Teil von
|
||||
/// <c>…/Workspace</c>.
|
||||
///
|
||||
/// Symbolische Verknüpfungen werden aufgelöst (<see cref="Canonicalize"/>) — ein
|
||||
/// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
|
||||
/// </summary>
|
||||
public static bool IsInside(string candidate, string root)
|
||||
{
|
||||
var normalizedRoot = Canonicalize(NormalizeDirectory(root));
|
||||
var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));
|
||||
|
||||
// Der Root selbst gilt als innerhalb.
|
||||
if (string.Equals(
|
||||
normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
|
||||
normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
|
||||
Comparison))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizedCandidate.StartsWith(
|
||||
NormalizeDirectory(normalizedRoot), Comparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen.
|
||||
///
|
||||
/// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort
|
||||
/// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen.
|
||||
/// </summary>
|
||||
public static string CanonicalKey(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return Comparison == StringComparison.OrdinalIgnoreCase
|
||||
? full.ToLowerInvariant()
|
||||
: full;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
|
||||
///
|
||||
/// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt
|
||||
/// <c>workspace/daten</c> nach <c>/etc</c>, dann liegt <c>workspace/daten/passwd</c>
|
||||
/// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung
|
||||
/// ist. <c>ResolveLinkTarget(returnFinalTarget: true)</c> folgt dabei ganzen Ketten,
|
||||
/// ein Aufruf je Abschnitt genügt also.
|
||||
///
|
||||
/// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
|
||||
/// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
|
||||
/// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
|
||||
/// </summary>
|
||||
public static string Canonicalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
|
||||
var root = Path.GetPathRoot(full);
|
||||
if (string.IsNullOrEmpty(root))
|
||||
return full;
|
||||
|
||||
var rest = full[root.Length..]
|
||||
.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in rest)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
|
||||
string? target;
|
||||
try
|
||||
{
|
||||
// Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
|
||||
// nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
|
||||
target = Directory.Exists(current)
|
||||
? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
|
||||
: File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
|
||||
// wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
|
||||
// dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
|
||||
return full;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(target))
|
||||
{
|
||||
current = Path.IsPathRooted(target)
|
||||
? target
|
||||
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target));
|
||||
}
|
||||
}
|
||||
|
||||
return Path.GetFullPath(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt Dateinamen, die auf Windows <b>und</b> Linux gültig sind.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): <see cref="Path.GetInvalidFileNameChars"/> liefert
|
||||
/// unter Windows 41 Zeichen, unter Unix genau zwei (<c>\0</c> und <c>/</c>). Wer sich
|
||||
/// darauf verlässt, erzeugt unter Linux Namen wie <c>bericht:2026.zip</c> — dort
|
||||
/// zulässig, unter Windows nicht anlegbar.
|
||||
///
|
||||
/// Das trifft alles, was zwischen Systemen wandert: Sicherungsarchive, Logdateien,
|
||||
/// Task-Dateien im geteilten Arbeitsverzeichnis. Deshalb hier bewusst der strengere
|
||||
/// Maßstab auf beiden Plattformen — ein paar Unterstriche mehr sind billiger als eine
|
||||
/// Sicherung, die sich auf dem Zielsystem nicht auspacken lässt.
|
||||
/// </summary>
|
||||
public static class PortableFileName
|
||||
{
|
||||
/// <summary>Unter Windows unzulässig, unter Unix erlaubt — hier immer ersetzt.</summary>
|
||||
private static readonly char[] WindowsReserved =
|
||||
['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
|
||||
|
||||
/// <summary>
|
||||
/// Gerätenamen, die Windows unabhängig von der Endung nicht als Datei zulässt.
|
||||
/// Unter Linux völlig gewöhnliche Namen — deshalb fällt es dort erst beim
|
||||
/// Zurückspielen auf.
|
||||
/// </summary>
|
||||
private static readonly string[] ReservedNames =
|
||||
[
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
|
||||
];
|
||||
|
||||
public static string Sanitize(string? name, string fallback = "unbenannt")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return fallback;
|
||||
|
||||
var chars = name.Select(c =>
|
||||
c < 32 || WindowsReserved.Contains(c) ? '_' : c).ToArray();
|
||||
|
||||
// Windows schneidet abschließende Punkte und Leerzeichen stillschweigend ab —
|
||||
// aus "bericht." würde "bericht", und zwei Dateien fielen zusammen.
|
||||
var result = new string(chars).TrimEnd('.', ' ');
|
||||
|
||||
if (result.Length == 0)
|
||||
return fallback;
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(result);
|
||||
if (ReservedNames.Contains(stem, StringComparer.OrdinalIgnoreCase))
|
||||
result = "_" + result;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,112 @@ public sealed class SqliteStorage
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
|
||||
ON RunUsage (OccurredAt DESC);
|
||||
|
||||
-- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
|
||||
-- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
|
||||
CREATE TABLE IF NOT EXISTS Tasks (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Title TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT 'todo',
|
||||
Type TEXT NOT NULL DEFAULT 'work',
|
||||
Priority INTEGER NOT NULL DEFAULT 3,
|
||||
Assignee TEXT NOT NULL DEFAULT '@human',
|
||||
WhenKind TEXT NULL,
|
||||
WhenValue TEXT NULL,
|
||||
WhenTz TEXT NULL,
|
||||
RequireApproval INTEGER NOT NULL DEFAULT 0,
|
||||
Acceptance TEXT NOT NULL DEFAULT '',
|
||||
-- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
|
||||
BlockedBy TEXT NOT NULL DEFAULT '',
|
||||
OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
|
||||
Body TEXT NOT NULL DEFAULT '',
|
||||
FileName TEXT NOT NULL DEFAULT '',
|
||||
-- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
|
||||
ToolName TEXT NOT NULL DEFAULT '',
|
||||
JobTypeId TEXT NOT NULL DEFAULT '',
|
||||
-- Ausführungszustand: nur das Board schreibt hier.
|
||||
LastOccurrence TEXT NULL,
|
||||
ClaimToken TEXT NULL,
|
||||
ClaimedAt TEXT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Tasks_Status
|
||||
ON Tasks (Status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
|
||||
ON Tasks (Assignee);
|
||||
|
||||
-- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
|
||||
-- stempelt die Engine, Korrekturen sind neue Zeilen.
|
||||
CREATE TABLE IF NOT EXISTS AuditLog (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT 'unknown',
|
||||
Tool TEXT NOT NULL,
|
||||
Arguments TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL,
|
||||
Summary TEXT NOT NULL DEFAULT '',
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
|
||||
ON AuditLog (RunId, Id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
|
||||
ON AuditLog (Id DESC);
|
||||
|
||||
-- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
|
||||
CREATE TABLE IF NOT EXISTS RunReceipts (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT 'unknown',
|
||||
TaskId TEXT NULL,
|
||||
Status TEXT NOT NULL DEFAULT '',
|
||||
StepCount INTEGER NOT NULL DEFAULT 0,
|
||||
PromptTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CompletionTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CostUsd TEXT NOT NULL DEFAULT '0',
|
||||
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
ResultRef TEXT NOT NULL DEFAULT '',
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
|
||||
ON RunReceipts (TaskId);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
|
||||
ON RunReceipts (RunId);
|
||||
|
||||
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
|
||||
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
|
||||
CREATE TABLE IF NOT EXISTS StagedCalls (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
InstanceId TEXT NOT NULL DEFAULT '',
|
||||
Tool TEXT NOT NULL,
|
||||
Action TEXT NULL,
|
||||
ArgumentsJson TEXT NOT NULL DEFAULT '',
|
||||
Proposal TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT 'Pending',
|
||||
CreatedAt TEXT NOT NULL,
|
||||
DecidedAt TEXT NULL,
|
||||
DecidedBy TEXT NULL,
|
||||
ResultRef TEXT NULL,
|
||||
RejectionReason TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
|
||||
ON StagedCalls (Status, Id);
|
||||
""";
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet Ordner und Dateien im Dateimanager des Systems.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Die Oberfläche rief an vier Stellen direkt
|
||||
/// <c>explorer.exe</c> auf. Hier liegt das plattformneutral — und in Core statt in der
|
||||
/// Oberfläche, weil die künftige Avalonia-Fassung dieselben Aufrufe braucht.
|
||||
///
|
||||
/// Bewusst ohne Rückmeldung im Fehlerfall: Einen Ordner zu öffnen ist eine
|
||||
/// Bequemlichkeit. Schlägt es fehl (kein Dateimanager installiert, Dienst ohne
|
||||
/// Sitzung), soll das den Aufrufer nicht beschäftigen.
|
||||
/// </summary>
|
||||
public static class SystemShell
|
||||
{
|
||||
/// <summary>Öffnet ein Verzeichnis im Dateimanager.</summary>
|
||||
public static bool OpenFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
return false;
|
||||
|
||||
return Open(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet den Ordner einer Datei und hebt sie nach Möglichkeit hervor.
|
||||
///
|
||||
/// Das Hervorheben können nur Windows und macOS. Unter Linux gibt es keinen
|
||||
/// Aufruf, der über alle Dateimanager hinweg funktioniert — dort wird nur der
|
||||
/// Ordner geöffnet. Das ist der kleinere Verlust gegenüber einer Liste von
|
||||
/// Sonderfällen je Desktop-Umgebung.
|
||||
/// </summary>
|
||||
public static bool RevealFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return false;
|
||||
|
||||
var folder = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (string.IsNullOrEmpty(folder)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Start("explorer.exe", ["/select,", Path.GetFullPath(path)]);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Start("open", ["-R", Path.GetFullPath(path)]);
|
||||
}
|
||||
catch { /* siehe Klassenkommentar */ }
|
||||
|
||||
return OpenFolder(folder);
|
||||
}
|
||||
|
||||
private static bool Open(string target)
|
||||
{
|
||||
var full = Path.GetFullPath(target);
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
return Start("xdg-open", [full]);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Start("open", [full]);
|
||||
|
||||
// Windows: UseShellExecute lässt die Shell entscheiden, statt explorer.exe
|
||||
// festzuschreiben.
|
||||
using var process = Process.Start(new ProcessStartInfo(full)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
return process is not null;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet mit <c>ArgumentList</c> statt einer Argumentzeichenkette — so muss nichts
|
||||
/// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen
|
||||
/// zusätzlichen Aufruf einschleusen.
|
||||
/// </summary>
|
||||
private static bool Start(string fileName, string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(fileName)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
info.ArgumentList.Add(argument);
|
||||
|
||||
using var process = Process.Start(info);
|
||||
return process is not null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Einmalige Überführung der improvisierten <c>coordination/*.md</c>-Dateien ins Taskboard
|
||||
/// (A1, Schritt 4).
|
||||
///
|
||||
/// Bewusst konservativ, weil die Altdateien kein Schema haben: Nur <c>task_*.md</c> gelten
|
||||
/// als Aufgaben und werden als <b>Backlog</b>-Aufgaben mit Assignee <c>@human</c>
|
||||
/// übernommen — ein Mensch ordnet sie zu, bevor irgendetwas läuft. <c>status_*</c>,
|
||||
/// <c>broadcast</c>, Incident-Berichte und <c>*.json</c>-Artefakte sind keine Aufgaben und
|
||||
/// bleiben unangetastet. Was sich nicht sicher deuten lässt, wird nicht verfälscht.
|
||||
///
|
||||
/// Idempotent: Eine übernommene Datei wandert nach <c>coordination/migrated/</c> — sie
|
||||
/// wird nicht gelöscht (die Historie bleibt), aber ein zweiter Start findet sie nicht mehr.
|
||||
/// </summary>
|
||||
public sealed class CoordinationMigration
|
||||
{
|
||||
private readonly TaskboardService _board;
|
||||
private readonly string _coordinationDir;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private static readonly Regex TaskHeading =
|
||||
new(@"^#\s*Task\b[^:]*:\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex AnyHeading =
|
||||
new(@"^#\s+(.+)$", RegexOptions.Compiled);
|
||||
|
||||
public CoordinationMigration(
|
||||
TaskboardService board, string coordinationDirectory, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_board = board;
|
||||
_coordinationDir = coordinationDirectory;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Migration");
|
||||
}
|
||||
|
||||
/// <summary>Führt die Migration aus. Gibt die Zahl der übernommenen Aufgaben zurück.</summary>
|
||||
public async Task<int> RunAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(_coordinationDir))
|
||||
return 0;
|
||||
|
||||
var migratedDir = Path.Combine(_coordinationDir, "migrated");
|
||||
var count = 0;
|
||||
|
||||
// Nur die oberste Ebene — der migrated/-Unterordner wird so nie erneut gelesen.
|
||||
foreach (var path in Directory.EnumerateFiles(_coordinationDir, "task_*.md"))
|
||||
{
|
||||
string text;
|
||||
try { text = AtomicFile.ReadAllText(path); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Migration: {File} nicht lesbar — übersprungen", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
var title = ExtractTitle(text, fileName);
|
||||
|
||||
var body =
|
||||
$"_Übernommen aus coordination/{fileName} bei der Taskboard-Migration (A1). " +
|
||||
"Assignee und Status bitte prüfen._\n\n" + text.Trim();
|
||||
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Title = title,
|
||||
Body = body,
|
||||
Status = TaskItemStatus.Backlog, // erst nach menschlicher Sichtung bereit
|
||||
Assignee = TaskAssignee.Human, // sicherer Standard, bis jemand zuordnet
|
||||
Type = TaskItemType.Work
|
||||
}, ct);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(migratedDir);
|
||||
File.Move(path, Path.Combine(migratedDir, fileName), overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Migration: {File} übernommen, konnte aber nicht verschoben werden", fileName);
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
_logger.LogInformation("Taskboard-Migration: {Count} coordination/task_*-Datei(en) übernommen", count);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string ExtractTitle(string text, string fileName)
|
||||
{
|
||||
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var match = TaskHeading.Match(line.Trim());
|
||||
if (match.Success)
|
||||
return Trim(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var match = AnyHeading.Match(line.Trim());
|
||||
if (match.Success)
|
||||
return Trim(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// Kein Titel im Text: aus dem Dateinamen ableiten ("task_video_x" → "video x").
|
||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||
if (stem.StartsWith("task_", StringComparison.OrdinalIgnoreCase))
|
||||
stem = stem[5..];
|
||||
return Trim(stem.Replace('_', ' '));
|
||||
}
|
||||
|
||||
private static string Trim(string value)
|
||||
{
|
||||
var v = value.Trim();
|
||||
return v.Length > 120 ? v[..120].TrimEnd() : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Verbindet den Scanner mit der Engine: übersetzt den Assignee einer fälligen Aufgabe in
|
||||
/// einen konkreten Lauf.
|
||||
///
|
||||
/// - <c>@new</c> / <c>@new:<agent></c> → <see cref="AgentEngine.RunAsync"/>: frischer
|
||||
/// Lauf ohne Historie.
|
||||
/// - <c>@<agent></c> → <see cref="AgentEngine.ChatAsync"/>: der Agent mit seinem
|
||||
/// bestehenden Kontext. Das Agent-Gate der Engine serialisiert solche Läufe (B2).
|
||||
///
|
||||
/// Ist der Ziel-Agent nicht auflösbar (unbekannt, oder bloßes <c>@new</c> bei mehreren
|
||||
/// Agenten), scheitert der Dispatch mit einer klaren Meldung, statt einen falschen Agenten
|
||||
/// zu raten.
|
||||
/// </summary>
|
||||
public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
|
||||
private readonly string _instanceId;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public EngineTaskDispatcher(
|
||||
AgentEngine engine,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
string instanceId,
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_engine = engine;
|
||||
_agents = agents;
|
||||
_instanceId = instanceId;
|
||||
_toolRegistry = toolRegistry;
|
||||
_stateStore = stateStore;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher");
|
||||
}
|
||||
|
||||
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
|
||||
if (task.Type == TaskItemType.ToolJob)
|
||||
return await DispatchToolJobAsync(task, ct);
|
||||
|
||||
var agents = _agents();
|
||||
var message = BuildMessage(task);
|
||||
var kind = TaskAssignee.KindOf(task.Assignee);
|
||||
|
||||
AgentRunResult result;
|
||||
switch (kind)
|
||||
{
|
||||
case TaskAssigneeKind.New:
|
||||
{
|
||||
var config = ResolveFreshAgent(agents, TaskAssignee.AgentId(task.Assignee));
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)",
|
||||
task.Id, task.Assignee);
|
||||
return false;
|
||||
}
|
||||
result = await _engine.RunAsync(
|
||||
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
|
||||
break;
|
||||
}
|
||||
case TaskAssigneeKind.Agent:
|
||||
{
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
var config = agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId);
|
||||
return false;
|
||||
}
|
||||
result = await _engine.ChatAsync(
|
||||
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// @human wird vom Scanner gar nicht erst angestoßen.
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.Status == AgentRunStatus.Completed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tickt einen <see cref="IToolJobProvider"/> (Poll) und weckt den Ziel-Agenten nur,
|
||||
/// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts
|
||||
/// zu tun war — ein Poll ohne Fund ist kein Fehler.
|
||||
/// </summary>
|
||||
private async Task<bool> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert",
|
||||
task.Id, task.ToolName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
var config = _agents().FirstOrDefault(a => a.AgentId == agentId);
|
||||
var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg)
|
||||
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var result = await provider.ExecuteJobAsync(
|
||||
task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);
|
||||
|
||||
if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage))
|
||||
return true; // Poll lief, nichts zu wecken
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde",
|
||||
task.Id, agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos.
|
||||
if (result.UseChatContext)
|
||||
await _engine.ChatAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
|
||||
else
|
||||
await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AgentConfig? ResolveFreshAgent(IReadOnlyList<AgentConfig> agents, string agentId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(agentId))
|
||||
return agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
|
||||
// Bloßes @new ohne Agent: nur eindeutig, wenn die Instanz genau einen Agenten hat.
|
||||
return agents.Count == 1 ? agents[0] : null;
|
||||
}
|
||||
|
||||
private static string BuildMessage(TaskItem task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(task.Body.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Acceptance))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Abnahmekriterien (das Ergebnis wird daran gemessen):");
|
||||
sb.AppendLine(task.Acceptance.Trim());
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Ausführungszustand des Taskboards. Die Definition der Aufgaben lebt in
|
||||
/// Markdown-Dateien; dieses Repository ist die DB-Seite, die das Claiming atomar macht
|
||||
/// (was ein Dateisystem nicht verlässlich kann) und dem Scanner erlaubt, ohne
|
||||
/// Dateizugriff zu entscheiden.
|
||||
/// </summary>
|
||||
public interface ITaskRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Legt eine Aufgabe an oder aktualisiert ihre Definition (Importer). Idempotent über
|
||||
/// die <see cref="TaskItem.Id"/> — ein zweiter Import erzeugt keine Dublette. Der
|
||||
/// Ausführungszustand (Marker, Claim) bleibt dabei unangetastet.
|
||||
/// </summary>
|
||||
Task<TaskItem> UpsertAsync(TaskItem task, CancellationToken ct);
|
||||
|
||||
Task<TaskItem?> GetAsync(string id, CancellationToken ct);
|
||||
|
||||
Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
|
||||
/// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>)
|
||||
/// sofort — ein Termin ist damit auch dann verbraucht, wenn der Lauf später scheitert
|
||||
/// oder der Prozess abstürzt (at-most-once, kein Retry-Sturm).
|
||||
/// </summary>
|
||||
/// <param name="occurrenceKey">Sortierbarer ISO-UTC-Zeitstempel des Termins.</param>
|
||||
/// <param name="leaseCutoff">
|
||||
/// Claims, die älter sind, gelten als verwaist (abgestürzter Lauf) und dürfen
|
||||
/// überschrieben werden.
|
||||
/// </param>
|
||||
Task<bool> TryClaimAsync(
|
||||
string id, string occurrenceKey, string claimToken,
|
||||
DateTime now, DateTime leaseCutoff, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Schließt einen beanspruchten Lauf ab und setzt den Endstatus. Nur wirksam, solange
|
||||
/// der Aufrufer den Claim noch hält (<paramref name="claimToken"/> passt) — ein Lauf,
|
||||
/// der seine Lease verloren hat, überschreibt nichts mehr. Gibt zurück, ob der Claim
|
||||
/// noch gehörte.
|
||||
/// </summary>
|
||||
Task<bool> CompleteClaimAsync(
|
||||
string id, string claimToken, TaskItemStatus finalStatus,
|
||||
DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
|
||||
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
|
||||
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
|
||||
/// auf <see cref="TaskItemStatus.Todo"/> stellen. Gibt die Zahl der zurückgesetzten
|
||||
/// Aufgaben zurück.
|
||||
/// </summary>
|
||||
Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Aufgaben, die auf <paramref name="blockerId"/> warten — Grundlage für den
|
||||
/// Auto-Dispatch, wenn der letzte Blocker fertig wird.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct);
|
||||
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Überführt die alten Scheduler-Konfigurationen ins Taskboard — der letzte Schritt, um
|
||||
/// die Alt-Scheduler abzulösen: alles Periodische ist danach ein Task.
|
||||
///
|
||||
/// - Ein <c>scheduler</c> (Agent nach Cron) → ein Task mit <c>when: cron</c> und Assignee
|
||||
/// <c>@new:<agent></c> (frischer Lauf, wie der alte AgentScheduler).
|
||||
/// - Jeder <c>toolJobs</c>-Eintrag (Poll) → ein Task vom Typ <c>tool_job</c>.
|
||||
///
|
||||
/// Einmalig und nicht-destruktiv: Existiert der Task (stabile Id) schon, wird er
|
||||
/// übersprungen — spätere Änderungen an der Task-Datei bleiben erhalten.
|
||||
/// </summary>
|
||||
public sealed class SchedulerTaskMigration
|
||||
{
|
||||
private readonly TaskboardService _board;
|
||||
private readonly ITaskRepository _repo;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public SchedulerTaskMigration(TaskboardService board, ITaskRepository repo, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_board = board;
|
||||
_repo = repo;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.SchedulerMigration");
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(IEnumerable<AgentConfig> agents, CancellationToken ct)
|
||||
{
|
||||
// IANA-Schreibweise, nicht TimeZoneInfo.Local.Id: Unter Windows lieferte das
|
||||
// "W. Europe Standard Time", unter Linux "Europe/Berlin". Die erzeugten
|
||||
// Task-Dateien wandern zwischen Rechnern — sie brauchen die Form, die überall
|
||||
// gilt.
|
||||
var localTz = TimeZones.LocalIanaId;
|
||||
var created = 0;
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
if (agent.Scheduler is { } scheduler && !string.IsNullOrWhiteSpace(scheduler.Cron))
|
||||
created += await MigrateSchedulerAsync(agent, scheduler, localTz, ct);
|
||||
|
||||
foreach (var job in agent.ToolJobs)
|
||||
created += await MigrateToolJobAsync(agent, job, localTz, ct);
|
||||
}
|
||||
|
||||
if (created > 0)
|
||||
_logger.LogInformation("Scheduler-Migration: {Count} Task(s) aus Alt-Konfiguration angelegt", created);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> MigrateSchedulerAsync(
|
||||
AgentConfig agent, SchedulerConfig scheduler, string tz, CancellationToken ct)
|
||||
{
|
||||
var id = $"sched-{agent.AgentId}";
|
||||
if (await _repo.GetAsync(id, ct) is not null)
|
||||
return 0;
|
||||
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = $"Geplanter Lauf: {Display(agent)}",
|
||||
Body = scheduler.TaskMessage,
|
||||
Status = TaskItemStatus.Todo,
|
||||
Type = TaskItemType.Work,
|
||||
Assignee = $"@new:{agent.AgentId}", // frischer Lauf, wie der alte AgentScheduler
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = scheduler.Cron, TimeZone = tz }
|
||||
}, ct);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private async Task<int> MigrateToolJobAsync(
|
||||
AgentConfig agent, ToolJobConfig job, string tz, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Cron) || string.IsNullOrWhiteSpace(job.ToolName))
|
||||
return 0;
|
||||
|
||||
var id = $"tj-{agent.AgentId}-{job.JobId}";
|
||||
if (await _repo.GetAsync(id, ct) is not null)
|
||||
return 0;
|
||||
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = $"Poll: {job.ToolName}/{job.JobTypeId} → {Display(agent)}",
|
||||
Body = $"Wiederkehrender {job.ToolName}-Poll ({job.JobTypeId}). Weckt {Display(agent)} bei einem Ereignis.",
|
||||
// Deaktivierte Jobs kommen als backlog — der Scanner nimmt nur todo/backlog...
|
||||
// backlog wird aber nicht geclaimt, also ruht der Poll, bis jemand ihn aktiviert.
|
||||
Status = job.Enabled ? TaskItemStatus.Todo : TaskItemStatus.Backlog,
|
||||
Type = TaskItemType.ToolJob,
|
||||
ToolName = job.ToolName,
|
||||
JobTypeId = job.JobTypeId,
|
||||
Assignee = $"@{agent.AgentId}", // Zielagent zum Wecken
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = job.Cron, TimeZone = tz }
|
||||
}, ct);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static string Display(AgentConfig agent)
|
||||
=> string.IsNullOrWhiteSpace(agent.DisplayName) ? agent.AgentId : agent.DisplayName;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Ausführungszustand des Taskboards in der Instanz-Datenbank.
|
||||
///
|
||||
/// Der Kern ist <see cref="TryClaimAsync"/>: ein bedingtes <c>UPDATE</c>, das genau eine
|
||||
/// Zeile trifft und damit garantiert, dass nie zwei Läufe denselben Termin ziehen. Genau
|
||||
/// das kann ein Dateisystem nicht verlässlich — deshalb liegt der Zustand hier und nicht
|
||||
/// bei den Task-Dateien.
|
||||
///
|
||||
/// Zeitangaben werden als sortierbares ISO-UTC (<c>"O"</c>) abgelegt, damit
|
||||
/// Zeichenketten-Vergleiche in SQL derselben Ordnung folgen wie die Zeit selbst.
|
||||
/// </summary>
|
||||
public sealed class SqliteTaskRepository : ITaskRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteTaskRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task<TaskItem> UpsertAsync(TaskItem task, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = await ReadAsync(conn, task.Id, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
using var insert = conn.CreateCommand();
|
||||
insert.CommandText = """
|
||||
INSERT INTO Tasks
|
||||
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
|
||||
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt)
|
||||
VALUES
|
||||
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
|
||||
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
|
||||
""";
|
||||
BindDefinition(insert, task);
|
||||
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
|
||||
insert.Parameters.AddWithValue("@createdAt", Format(now));
|
||||
insert.Parameters.AddWithValue("@updatedAt", Format(now));
|
||||
await insert.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nur die Definition aktualisieren. Status und Ausführungszustand
|
||||
// (Marker, Claim) gehören der DB — ein Re-Import darf einen laufenden
|
||||
// oder abgeschlossenen Zustand nicht zurücksetzen. Status-Änderungen
|
||||
// laufen über SetStatusAsync bzw. den Scanner.
|
||||
using var update = conn.CreateCommand();
|
||||
update.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Title = @title, Type = @type, Priority = @priority, Assignee = @assignee,
|
||||
WhenKind = @whenKind, WhenValue = @whenValue, WhenTz = @whenTz,
|
||||
RequireApproval = @requireApproval, Acceptance = @acceptance,
|
||||
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
|
||||
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
|
||||
UpdatedAt = @updatedAt
|
||||
WHERE Id = @id
|
||||
""";
|
||||
BindDefinition(update, task);
|
||||
update.Parameters.AddWithValue("@updatedAt", Format(now));
|
||||
await update.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
return (await ReadAsync(conn, task.Id, ct))!;
|
||||
}, ct);
|
||||
|
||||
public async Task<TaskItem?> GetAsync(string id, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
return await ReadAsync(conn, id, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
var sql = new StringBuilder("SELECT " + Columns + " FROM Tasks WHERE 1 = 1");
|
||||
|
||||
if (query.Status is { } status)
|
||||
{
|
||||
sql.Append(" AND Status = @status");
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
|
||||
}
|
||||
else if (!query.IncludeArchived)
|
||||
{
|
||||
sql.Append(" AND Status <> 'archived'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Assignee))
|
||||
{
|
||||
sql.Append(" AND Assignee = @assignee COLLATE NOCASE");
|
||||
cmd.Parameters.AddWithValue("@assignee", query.Assignee.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
sql.Append(" AND (Title LIKE @search ESCAPE '\\' COLLATE NOCASE"
|
||||
+ " OR Body LIKE @search ESCAPE '\\' COLLATE NOCASE)");
|
||||
cmd.Parameters.AddWithValue("@search", "%" + Escape(query.Search.Trim()) + "%");
|
||||
}
|
||||
|
||||
// Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält.
|
||||
sql.Append(" ORDER BY Priority DESC, UpdatedAt DESC LIMIT @limit");
|
||||
cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 500));
|
||||
cmd.CommandText = sql.ToString();
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<bool> TryClaimAsync(
|
||||
string id, string occurrenceKey, string claimToken,
|
||||
DateTime now, DateTime leaseCutoff, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET ClaimToken = @token, ClaimedAt = @now, Status = 'in_progress',
|
||||
LastOccurrence = @occ, UpdatedAt = @now
|
||||
WHERE Id = @id
|
||||
AND Status = 'todo'
|
||||
AND (LastOccurrence IS NULL OR LastOccurrence < @occ)
|
||||
AND (ClaimToken IS NULL OR ClaimedAt < @leaseCutoff)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@occ", occurrenceKey);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) == 1;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> CompleteClaimAsync(
|
||||
string id, string claimToken, TaskItemStatus finalStatus, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = @status, ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
|
||||
WHERE Id = @id AND ClaimToken = @token
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(finalStatus));
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Tasks SET Status = @status, UpdatedAt = @now WHERE Id = @id";
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
|
||||
WHERE Status = 'in_progress' AND ClaimToken IS NOT NULL AND ClaimedAt < @leaseCutoff
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns
|
||||
+ " FROM Tasks WHERE BlockedBy LIKE @needle ESCAPE '\\'";
|
||||
cmd.Parameters.AddWithValue("@needle", "%|" + Escape(blockerId.Trim()) + "|%");
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Tasks";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@id", task.Id);
|
||||
cmd.Parameters.AddWithValue("@title", task.Title);
|
||||
cmd.Parameters.AddWithValue("@type", TaskText.Of(task.Type));
|
||||
cmd.Parameters.AddWithValue("@priority", Math.Clamp(task.Priority, 1, 5));
|
||||
cmd.Parameters.AddWithValue("@assignee", task.Assignee);
|
||||
cmd.Parameters.AddWithValue("@whenKind", (object?)(task.When is null ? null : TaskText.Of(task.When.Kind)) ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@whenValue", (object?)task.When?.Value ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@whenTz", (object?)task.When?.TimeZone ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@requireApproval", task.RequireApproval ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@acceptance", task.Acceptance);
|
||||
cmd.Parameters.AddWithValue("@blockedBy", SerializeBlockedBy(task.BlockedBy));
|
||||
cmd.Parameters.AddWithValue("@market", task.OnlyWhenMarketOpen ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@body", task.Body);
|
||||
cmd.Parameters.AddWithValue("@fileName", task.FileName);
|
||||
cmd.Parameters.AddWithValue("@toolName", task.ToolName);
|
||||
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
|
||||
}
|
||||
|
||||
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Id = @id LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? Read(reader) : null;
|
||||
}
|
||||
|
||||
private static TaskItem Read(SqliteDataReader r)
|
||||
{
|
||||
TaskWhen? when = null;
|
||||
if (!r.IsDBNull(6) && TaskText.WhenKind(r.GetString(6)) is { } kind)
|
||||
{
|
||||
when = new TaskWhen
|
||||
{
|
||||
Kind = kind,
|
||||
Value = r.IsDBNull(7) ? "" : r.GetString(7),
|
||||
TimeZone = r.IsDBNull(8) ? "" : r.GetString(8)
|
||||
};
|
||||
}
|
||||
|
||||
return new TaskItem
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
Title = r.GetString(1),
|
||||
Status = TaskText.Status(r.GetString(2)),
|
||||
Type = TaskText.Type(r.GetString(3)),
|
||||
Priority = r.GetInt32(4),
|
||||
Assignee = r.GetString(5),
|
||||
When = when,
|
||||
RequireApproval = r.GetInt32(9) != 0,
|
||||
Acceptance = r.GetString(10),
|
||||
BlockedBy = DeserializeBlockedBy(r.GetString(11)),
|
||||
OnlyWhenMarketOpen = r.GetInt32(12) != 0,
|
||||
Body = r.GetString(13),
|
||||
FileName = r.GetString(14),
|
||||
ToolName = r.GetString(15),
|
||||
JobTypeId = r.GetString(16),
|
||||
LastOccurrence = r.IsDBNull(17) ? null : r.GetString(17),
|
||||
ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
|
||||
ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
|
||||
CreatedAt = Parse(r.GetString(20)),
|
||||
UpdatedAt = Parse(r.GetString(21))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Blocker als "|a|b|" — die Begrenzer erlauben eine Suche nach ganzen Ids,
|
||||
/// ohne dass <c>t-1</c> auch <c>t-10</c> trifft.</summary>
|
||||
private static string SerializeBlockedBy(IReadOnlyList<string> ids)
|
||||
{
|
||||
var cleaned = ids
|
||||
.Select(x => x.Trim().Replace("|", ""))
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DeserializeBlockedBy(string raw)
|
||||
=> raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static string Escape(string value) => value
|
||||
.Replace(@"\", @"\\")
|
||||
.Replace("%", @"\%")
|
||||
.Replace("_", @"\_");
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen
|
||||
/// <c>---</c>-Zeilen, gefolgt vom Rumpf.
|
||||
///
|
||||
/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist
|
||||
/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten <c>when</c>, einer
|
||||
/// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt
|
||||
/// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die
|
||||
/// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten.
|
||||
/// </summary>
|
||||
public static class TaskFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Zerlegt eine Task-Datei in ihre Definition. Gibt <c>false</c> mit einer Meldung
|
||||
/// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand
|
||||
/// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB.
|
||||
/// </summary>
|
||||
public static bool TryParse(string text, out TaskItem task, out string? error)
|
||||
{
|
||||
task = new TaskItem();
|
||||
error = null;
|
||||
|
||||
var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
|
||||
// Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren).
|
||||
var lines = normalized.Split('\n');
|
||||
var open = -1;
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var t = lines[i].TrimStart('').Trim();
|
||||
if (t.Length == 0) continue;
|
||||
if (t == "---") { open = i; break; }
|
||||
break; // erste nicht-leere Zeile ist kein Fence
|
||||
}
|
||||
|
||||
if (open < 0)
|
||||
{
|
||||
error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var close = -1;
|
||||
for (var i = open + 1; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i].Trim() == "---") { close = i; break; }
|
||||
}
|
||||
|
||||
if (close < 0)
|
||||
{
|
||||
error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var block = lines[(open + 1)..close];
|
||||
var body = string.Join('\n', lines[(close + 1)..]).Trim('\n');
|
||||
|
||||
var scalars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var when = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var blockedBy = new List<string>();
|
||||
string? acceptance = null;
|
||||
|
||||
var j = 0;
|
||||
while (j < block.Length)
|
||||
{
|
||||
var raw = block[j];
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; }
|
||||
|
||||
// Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen
|
||||
// gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen.
|
||||
if (Indent(raw) > 0) { j++; continue; }
|
||||
|
||||
var colon = trimmed.IndexOf(':');
|
||||
if (colon < 0) { j++; continue; }
|
||||
|
||||
var key = trimmed[..colon].Trim();
|
||||
var rest = trimmed[(colon + 1)..].Trim();
|
||||
|
||||
if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0)
|
||||
{
|
||||
j = CollectChildren(block, j + 1, child =>
|
||||
{
|
||||
var c = child.IndexOf(':');
|
||||
if (c < 0) return;
|
||||
when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim());
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest))
|
||||
{
|
||||
acceptance = ReadBlockScalar(block, ref j);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (rest.StartsWith('['))
|
||||
{
|
||||
blockedBy.AddRange(ParseInlineList(rest));
|
||||
j++;
|
||||
}
|
||||
else if (rest.Length == 0)
|
||||
{
|
||||
// Block-Liste: nachfolgende "- item"-Zeilen.
|
||||
j = CollectChildren(block, j + 1, child =>
|
||||
{
|
||||
var item = child.TrimStart();
|
||||
if (item.StartsWith('-'))
|
||||
blockedBy.Add(Unquote(item[1..].Trim()));
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
blockedBy.AddRange(ParseInlineList(rest));
|
||||
j++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
scalars[key] = Unquote(rest);
|
||||
j++;
|
||||
}
|
||||
|
||||
TaskWhen? whenValue = null;
|
||||
if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind)
|
||||
{
|
||||
whenValue = new TaskWhen
|
||||
{
|
||||
Kind = kind,
|
||||
Value = when.GetValueOrDefault("value", ""),
|
||||
TimeZone = when.GetValueOrDefault("tz", "")
|
||||
};
|
||||
}
|
||||
|
||||
task = new TaskItem
|
||||
{
|
||||
Id = scalars.GetValueOrDefault("id", ""),
|
||||
Title = scalars.GetValueOrDefault("title", ""),
|
||||
Status = TaskText.Status(scalars.GetValueOrDefault("status")),
|
||||
Type = TaskText.Type(scalars.GetValueOrDefault("type")),
|
||||
Priority = ParsePriority(scalars.GetValueOrDefault("priority")),
|
||||
Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human),
|
||||
When = whenValue,
|
||||
RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")),
|
||||
Acceptance = acceptance?.Trim() ?? "",
|
||||
BlockedBy = blockedBy,
|
||||
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
|
||||
ToolName = scalars.GetValueOrDefault("tool_name", ""),
|
||||
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
|
||||
Body = body
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand
|
||||
/// bleibt außen vor — er gehört in die DB, nicht in die Datei.</summary>
|
||||
public static string Serialize(TaskItem task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("---\n");
|
||||
sb.Append($"id: {Scalar(task.Id)}\n");
|
||||
sb.Append($"title: {Scalar(task.Title)}\n");
|
||||
sb.Append($"status: {TaskText.Of(task.Status)}\n");
|
||||
sb.Append($"type: {TaskText.Of(task.Type)}\n");
|
||||
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
|
||||
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
|
||||
|
||||
if (task.When is { } w)
|
||||
{
|
||||
sb.Append("when:\n");
|
||||
sb.Append($" kind: {TaskText.Of(w.Kind)}\n");
|
||||
sb.Append($" value: {Scalar(w.Value)}\n");
|
||||
sb.Append($" tz: {Scalar(w.TimeZone)}\n");
|
||||
}
|
||||
|
||||
// Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird.
|
||||
if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName))
|
||||
{
|
||||
sb.Append($"tool_name: {Scalar(task.ToolName)}\n");
|
||||
sb.Append($"job_type: {Scalar(task.JobTypeId)}\n");
|
||||
}
|
||||
|
||||
sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Acceptance))
|
||||
{
|
||||
sb.Append("acceptance: |\n");
|
||||
foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n'))
|
||||
sb.Append(" ").Append(line).Append('\n');
|
||||
}
|
||||
|
||||
if (task.BlockedBy.Count > 0)
|
||||
sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n");
|
||||
|
||||
sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n");
|
||||
sb.Append("---\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private static int Indent(string line)
|
||||
{
|
||||
var n = 0;
|
||||
while (n < line.Length && line[n] == ' ') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Ruft <paramref name="onChild"/> für jede eingerückte Folgezeile auf und
|
||||
/// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück.</summary>
|
||||
private static int CollectChildren(string[] block, int start, Action<string> onChild)
|
||||
{
|
||||
var k = start;
|
||||
while (k < block.Length)
|
||||
{
|
||||
var line = block[k];
|
||||
if (line.Trim().Length == 0) { k++; continue; }
|
||||
if (Indent(line) == 0) break;
|
||||
onChild(line.Trim());
|
||||
k++;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-";
|
||||
|
||||
/// <summary>Liest einen eingerückten Block-Skalar und entfernt die gemeinsame
|
||||
/// Einrückung. Setzt <paramref name="j"/> hinter den Block.</summary>
|
||||
private static string ReadBlockScalar(string[] block, ref int j)
|
||||
{
|
||||
var collected = new List<string>();
|
||||
var k = j + 1;
|
||||
var commonIndent = int.MaxValue;
|
||||
|
||||
while (k < block.Length)
|
||||
{
|
||||
var line = block[k];
|
||||
if (line.Trim().Length == 0) { collected.Add(""); k++; continue; }
|
||||
if (Indent(line) == 0) break;
|
||||
commonIndent = Math.Min(commonIndent, Indent(line));
|
||||
collected.Add(line);
|
||||
k++;
|
||||
}
|
||||
|
||||
j = k;
|
||||
if (commonIndent == int.MaxValue) commonIndent = 0;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in collected)
|
||||
sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n');
|
||||
|
||||
return sb.ToString().TrimEnd('\n');
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseInlineList(string rest)
|
||||
{
|
||||
var inner = rest.Trim();
|
||||
if (inner.StartsWith('[')) inner = inner[1..];
|
||||
if (inner.EndsWith(']')) inner = inner[..^1];
|
||||
|
||||
return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(Unquote)
|
||||
.Where(s => s.Length > 0);
|
||||
}
|
||||
|
||||
private static string Unquote(string value)
|
||||
{
|
||||
var v = value.Trim();
|
||||
if (v.Length >= 2 && v[0] == '"' && v[^1] == '"')
|
||||
return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\");
|
||||
if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'')
|
||||
return v[1..^1].Replace("''", "'");
|
||||
return v;
|
||||
}
|
||||
|
||||
/// <summary>Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes
|
||||
/// <c>@</c> (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert
|
||||
/// werden.</summary>
|
||||
private static string Scalar(string? value)
|
||||
{
|
||||
var v = value ?? "";
|
||||
if (v.Length == 0) return "\"\"";
|
||||
|
||||
var needsQuote =
|
||||
v != v.Trim()
|
||||
|| "@-[]{}>|*&!%#`,\"'".Contains(v[0])
|
||||
|| v.Contains(':')
|
||||
|| v.Contains('#')
|
||||
|| v.Contains('\n');
|
||||
|
||||
if (!needsQuote) return v;
|
||||
|
||||
return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private static bool ParseBool(string? value)
|
||||
=> value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on";
|
||||
|
||||
private static int ParsePriority(string? value)
|
||||
=> int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
|
||||
|
||||
private static string EmptyToDefault(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Lebenszyklus einer Aufgabe. Als Zeichenkette in der DB und im Frontmatter abgelegt,
|
||||
/// damit beide Seiten dieselbe, menschenlesbare Form teilen.
|
||||
/// </summary>
|
||||
public enum TaskItemStatus
|
||||
{
|
||||
/// <summary>Angelegt, aber noch nicht zur Ausführung freigegeben.</summary>
|
||||
Backlog,
|
||||
|
||||
/// <summary>Bereit; der Scanner darf sie beim nächsten fälligen Termin nehmen.</summary>
|
||||
Todo,
|
||||
|
||||
/// <summary>Ein Lauf hat sie beansprucht und arbeitet daran.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Erledigt, wartet aber auf Review (<c>require_approval</c>).</summary>
|
||||
InReview,
|
||||
|
||||
/// <summary>Abgeschlossen.</summary>
|
||||
Done,
|
||||
|
||||
/// <summary>Verworfen, ohne erledigt zu sein.</summary>
|
||||
Canceled,
|
||||
|
||||
/// <summary>Wartet auf einen offenen Blocker (<c>blocked_by</c>) oder auf eine Meldung.</summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>Aus dem Board genommen (Datei gelöscht, aufgeräumt) — bleibt für die Historie.</summary>
|
||||
Archived
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Art der Aufgabe. Ein Mensch ist einfach ein Assignee, kein Sonderpfad — <c>approval</c>
|
||||
/// und <c>human_input</c> unterscheiden sich nur darin, dass ihr Ergebnis von einem
|
||||
/// Menschen statt von einem Modell kommt.
|
||||
/// </summary>
|
||||
public enum TaskItemType
|
||||
{
|
||||
Work,
|
||||
Approval,
|
||||
HumanInput,
|
||||
|
||||
/// <summary>
|
||||
/// Ein wiederkehrender Poll: Der Scanner tickt beim fälligen Termin einen
|
||||
/// <c>IToolJobProvider</c> (siehe <see cref="TaskItem.ToolName"/>/<see cref="TaskItem.JobTypeId"/>)
|
||||
/// und weckt den Agenten nur, wenn der Tick etwas meldet. Ersetzt den früheren
|
||||
/// ToolJobScheduler — alles Periodische ist jetzt ein Task.
|
||||
/// </summary>
|
||||
ToolJob
|
||||
}
|
||||
|
||||
/// <summary>Wie ein Termin zu verstehen ist.</summary>
|
||||
public enum TaskWhenKind
|
||||
{
|
||||
/// <summary>Einmaliger Zeitpunkt (ISO 8601).</summary>
|
||||
At,
|
||||
|
||||
/// <summary>Wiederkehrendes Intervall (z. B. <c>30m</c>, <c>2h</c>).</summary>
|
||||
Every,
|
||||
|
||||
/// <summary>5-Felder-Cron-Ausdruck.</summary>
|
||||
Cron
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Termin. Die Zeitzone ist Pflicht — das ist die Antwort auf B7 (Cron lief bisher
|
||||
/// zonen-blind in Lokalzeit). Ohne Zone lässt sich <c>0 7 * * *</c> nicht eindeutig
|
||||
/// deuten.
|
||||
/// </summary>
|
||||
public sealed record TaskWhen
|
||||
{
|
||||
public TaskWhenKind Kind { get; init; }
|
||||
public string Value { get; init; } = "";
|
||||
|
||||
/// <summary>IANA-Zeitzone, etwa <c>Europe/Berlin</c>.</summary>
|
||||
public string TimeZone { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer eine Aufgabe ausführt. Löst das implizite <c>UseChatContext</c>-Flag (T7) durch
|
||||
/// eine explizite Angabe am Auftrag ab.
|
||||
/// </summary>
|
||||
public enum TaskAssigneeKind
|
||||
{
|
||||
/// <summary><c>@new</c> — frischer Lauf ohne Historie (<c>RunAsync</c>).</summary>
|
||||
New,
|
||||
|
||||
/// <summary><c>@<agentId></c> — bestehender Agent mit seinem Kontext (<c>ChatAsync</c>).</summary>
|
||||
Agent,
|
||||
|
||||
/// <summary><c>@human</c> — wartet auf einen Menschen; kein Modell-Lauf.</summary>
|
||||
Human
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deutet den <c>assignee</c>-String, ohne ihn selbst zu speichern. Formen:
|
||||
/// <c>@human</c>, <c>@<agentId></c> (bestehender Kontext), <c>@new</c> bzw.
|
||||
/// <c>@new:<agentId></c> (frischer Lauf ohne Historie). Bei bloßem <c>@new</c> ohne
|
||||
/// Agent bleibt die Ziel-Id offen — dann muss die Instanz genau einen Agenten haben.
|
||||
/// </summary>
|
||||
public static class TaskAssignee
|
||||
{
|
||||
public const string New = "@new";
|
||||
public const string Human = "@human";
|
||||
|
||||
public static TaskAssigneeKind KindOf(string? assignee)
|
||||
{
|
||||
var value = assignee?.Trim();
|
||||
if (string.Equals(value, Human, StringComparison.OrdinalIgnoreCase))
|
||||
return TaskAssigneeKind.Human;
|
||||
if (string.Equals(value, New, StringComparison.OrdinalIgnoreCase)
|
||||
|| value?.StartsWith("@new:", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return TaskAssigneeKind.New;
|
||||
return TaskAssigneeKind.Agent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die reine Agent-Id. Für <c>@<agent></c> der Teil hinter dem <c>@</c>, für
|
||||
/// <c>@new:<agent></c> der Teil hinter dem Doppelpunkt. Leer bei <c>@human</c>
|
||||
/// und bloßem <c>@new</c>.
|
||||
/// </summary>
|
||||
public static string AgentId(string? assignee)
|
||||
{
|
||||
var value = assignee?.Trim() ?? "";
|
||||
switch (KindOf(value))
|
||||
{
|
||||
case TaskAssigneeKind.Agent:
|
||||
return value.StartsWith('@') ? value[1..] : value;
|
||||
case TaskAssigneeKind.New:
|
||||
var colon = value.IndexOf(':');
|
||||
return colon >= 0 ? value[(colon + 1)..].Trim() : "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Aufgabe — die Spiegelung der Frontmatter-Definition zusammen mit dem
|
||||
/// Ausführungszustand. Die Definition ist in der Datei die Wahrheit, der
|
||||
/// Ausführungszustand in der DB (siehe Taskboard-Konzept). Diese Zeile hält beides,
|
||||
/// damit der Scanner ohne Dateizugriff entscheiden kann.
|
||||
/// </summary>
|
||||
public sealed record TaskItem
|
||||
{
|
||||
// ─── Definition (aus dem Frontmatter gespiegelt) ───
|
||||
|
||||
/// <summary>Stabile Identität. Überlebt das Umbenennen der Datei.</summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
public string Title { get; init; } = "";
|
||||
public TaskItemStatus Status { get; init; } = TaskItemStatus.Todo;
|
||||
public TaskItemType Type { get; init; } = TaskItemType.Work;
|
||||
|
||||
/// <summary>1 (niedrig) .. 5 (hoch).</summary>
|
||||
public int Priority { get; init; } = 3;
|
||||
|
||||
public string Assignee { get; init; } = TaskAssignee.Human;
|
||||
|
||||
/// <summary>Termin; <c>null</c> = einmalige, sofort fällige Aufgabe.</summary>
|
||||
public TaskWhen? When { get; init; }
|
||||
|
||||
/// <summary>Gilt erst nach Review als <see cref="TaskItemStatus.Done"/>.</summary>
|
||||
public bool RequireApproval { get; init; }
|
||||
|
||||
/// <summary>Abnahmekriterien, gegen die das Ergebnis geprüft wird.</summary>
|
||||
public string Acceptance { get; init; } = "";
|
||||
|
||||
/// <summary>Ids der Aufgaben, die erst erledigt sein müssen.</summary>
|
||||
public IReadOnlyList<string> BlockedBy { get; init; } = [];
|
||||
|
||||
/// <summary>C1: Termin nur auslösen, wenn der Markt offen ist.</summary>
|
||||
public bool OnlyWhenMarketOpen { get; init; }
|
||||
|
||||
/// <summary>Auftragsbeschreibung — geht als Aufgabenstellung an den Agenten.</summary>
|
||||
public string Body { get; init; } = "";
|
||||
|
||||
/// <summary>Dateiname relativ zu <c>tasks/</c>. Dient dem Rückschreiben.</summary>
|
||||
public string FileName { get; init; } = "";
|
||||
|
||||
// ─── Nur für Typ tool_job ───
|
||||
|
||||
/// <summary>Das zu tickende Tool (z. B. <c>Telegram</c>) — nur bei <c>tool_job</c>.</summary>
|
||||
public string ToolName { get; init; } = "";
|
||||
|
||||
/// <summary>Die Job-Art des Tools (z. B. <c>telegram_poll</c>) — nur bei <c>tool_job</c>.</summary>
|
||||
public string JobTypeId { get; init; } = "";
|
||||
|
||||
// ─── Ausführungszustand (nur das Board schreibt) ───
|
||||
|
||||
/// <summary>
|
||||
/// Occurrence-Key des zuletzt behandelten Termins (sortierbares ISO-UTC). Ein neuer
|
||||
/// Termin gilt nur als fällig, wenn er hierüber liegt — so wird ein Termin höchstens
|
||||
/// einmal ausgelöst.
|
||||
/// </summary>
|
||||
public string? LastOccurrence { get; init; }
|
||||
|
||||
/// <summary>Gesetzt, solange ein Lauf die Aufgabe beansprucht.</summary>
|
||||
public string? ClaimToken { get; init; }
|
||||
|
||||
public DateTime? ClaimedAt { get; init; }
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf <c>todo</c> zurück statt auf
|
||||
/// <c>done</c> — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
|
||||
/// <c>tool_job</c> ist immer ein Poll und damit wiederkehrend.
|
||||
/// </summary>
|
||||
public bool IsRecurring
|
||||
=> Type == TaskItemType.ToolJob
|
||||
|| When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
|
||||
}
|
||||
|
||||
/// <summary>Suchkriterien für <see cref="ITaskRepository.ListAsync"/>.</summary>
|
||||
public sealed record TaskQuery
|
||||
{
|
||||
public TaskItemStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Filtert auf einen Assignee (roher String, z. B. <c>@crawler</c>).</summary>
|
||||
public string? Assignee { get; init; }
|
||||
|
||||
/// <summary>Freitext über Titel und Beschreibung.</summary>
|
||||
public string? Search { get; init; }
|
||||
|
||||
/// <summary>Archivierte werden standardmäßig ausgeblendet.</summary>
|
||||
public bool IncludeArchived { get; init; }
|
||||
|
||||
public int Limit { get; init; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der
|
||||
/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar.
|
||||
/// </summary>
|
||||
public interface ITaskDispatcher
|
||||
{
|
||||
/// <summary>Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss.</summary>
|
||||
Task<bool> DispatchAsync(TaskItem task, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob ein marktabhängiger Termin (<see cref="TaskItem.OnlyWhenMarketOpen"/>)
|
||||
/// jetzt laufen darf. Platzhalter, bis C1 (Marktkalender) den echten Kalender liefert.
|
||||
/// </summary>
|
||||
public interface IMarketCalendar
|
||||
{
|
||||
bool IsOpen(DateTime nowUtc);
|
||||
}
|
||||
|
||||
/// <summary>Bis C1 kommt: der Markt gilt als immer offen.</summary>
|
||||
public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
|
||||
{
|
||||
public bool IsOpen(DateTime nowUtc) => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Taktgeber des Taskboards (A1). Ein einziger Takt prüft, was fällig ist —
|
||||
/// keine langen Delays (B6). Persistiert wird nur der Last-Fired-Marker; ein
|
||||
/// fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin (kein Retry-Sturm).
|
||||
///
|
||||
/// Der heikle Kern ist streng gegen drei Invarianten gebaut, die die Repository-Tests
|
||||
/// und die Scanner-Tests absichern:
|
||||
/// 1. Nie zwei Läufe auf denselben Termin — das atomare <see cref="ITaskRepository.TryClaimAsync"/>.
|
||||
/// 2. Kein Dispatch bei offenem Blocker — blockierte Aufgaben sind nicht claimbar.
|
||||
/// 3. Doppelter Takt = ein Lauf — konkurrierende Claims um denselben Occurrence-Key.
|
||||
/// </summary>
|
||||
public sealed class TaskScanner : IAsyncDisposable
|
||||
{
|
||||
private readonly ITaskRepository _repo;
|
||||
private readonly ITaskDispatcher _dispatcher;
|
||||
private readonly IMarketCalendar _market;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _tick;
|
||||
private readonly TimeSpan _lease;
|
||||
|
||||
/// <summary>Aufgaben, deren unauflösbare Zeitzone bereits gemeldet wurde — der Takt
|
||||
/// läuft jede Minute, die Meldung soll nicht mitlaufen.</summary>
|
||||
private readonly HashSet<string> _warnedTimeZones = [];
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
public TaskScanner(
|
||||
ITaskRepository repo,
|
||||
ITaskDispatcher dispatcher,
|
||||
ILoggerFactory loggerFactory,
|
||||
IMarketCalendar? market = null,
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null,
|
||||
TimeSpan? lease = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
_market = market ?? new AlwaysOpenMarketCalendar();
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner");
|
||||
_tick = tick ?? TimeSpan.FromSeconds(60);
|
||||
_lease = lease ?? TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_loop ??= RunLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>Läuft die Taktschleife gerade?</summary>
|
||||
public bool IsRunning => _loop is { IsCompleted: false };
|
||||
|
||||
/// <summary>
|
||||
/// Die Schleife lief und ist beendet — anders als „noch nicht gestartet". Der
|
||||
/// Unterschied zählt für die Zustandsmeldung an den Watchdog: Ein Scanner, der noch
|
||||
/// auf die Startabgleichung wartet, ist in Ordnung; einer, dessen Schleife
|
||||
/// ausgestiegen ist, bedeutet, dass keine Aufgabe mehr läuft.
|
||||
/// </summary>
|
||||
public bool HasStopped => _loop is { IsCompleted: true };
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
// PeriodicTimer über den TimeProvider — im Test steuerbar, im Betrieb driftfrei.
|
||||
using var timer = new PeriodicTimer(_tick, _clock);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanOnceAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Ein Scanner-Takt ist gescheitert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der
|
||||
/// tatsächlich angestoßenen Läufe zurück. Öffentlich, damit Tests einen Takt
|
||||
/// deterministisch auslösen können.
|
||||
/// </summary>
|
||||
public async Task<int> ScanOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var leaseCutoff = now - _lease;
|
||||
|
||||
var candidates = await _repo.ListAsync(new TaskQuery { Limit = 500 }, ct);
|
||||
|
||||
var claimed = new List<(TaskItem Task, string Token)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
if (task.Status != TaskItemStatus.Todo)
|
||||
continue; // backlog (Halte-Status), blockiert, laufend, erledigt bleiben außen vor
|
||||
|
||||
// Ein Mensch ist kein Modell-Lauf — solche Aufgaben rührt der Scanner nicht an.
|
||||
if (TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human)
|
||||
continue;
|
||||
|
||||
// Eine Aufgabe mit unauflösbarer Zeitzone feuert nie. Das einmal melden,
|
||||
// sonst sucht man den Fehler bei der Aufgabe statt beim System.
|
||||
if (TaskSchedule.UnresolvableTimeZone(task) is { } badZone
|
||||
&& _warnedTimeZones.Add(task.Id))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Aufgabe {TaskId} ({Title}) hat die Zeitzone '{TimeZone}', die auf diesem "
|
||||
+ "System nicht auflösbar ist — sie wird nicht ausgeführt. Zeitzone in "
|
||||
+ "IANA-Schreibweise eintragen (z.B. 'Europe/Berlin') und sicherstellen, "
|
||||
+ "dass tzdata und ICU vorhanden sind.",
|
||||
task.Id, task.Title, badZone);
|
||||
}
|
||||
|
||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||
if (occ is null)
|
||||
continue;
|
||||
|
||||
if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
|
||||
continue;
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
|
||||
claimed.Add((task, token));
|
||||
}
|
||||
|
||||
// Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das
|
||||
// Agent-Gate der Engine; verschiedene Agenten laufen echt parallel.
|
||||
await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct)));
|
||||
|
||||
return claimed.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen Task sofort aus (manueller „Jetzt ausführen"-Knopf), unabhängig vom
|
||||
/// Termin. Beansprucht ihn atomar wie ein Takt — läuft er schon oder ist er kein
|
||||
/// <c>todo</c>, gibt die Methode <c>false</c> zurück, statt ihn doppelt anzustoßen.
|
||||
/// </summary>
|
||||
public async Task<bool> RunTaskNowAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var task = await _repo.GetAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return false;
|
||||
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (!await _repo.TryClaimAsync(task.Id, now.ToString("O"), token, now, now - _lease, ct))
|
||||
return false;
|
||||
|
||||
await ProcessAsync(task, token, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(TaskItem task, string token, CancellationToken ct)
|
||||
{
|
||||
bool ok;
|
||||
try
|
||||
{
|
||||
ok = await _dispatcher.DispatchAsync(task, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
|
||||
// Endstatus:
|
||||
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
|
||||
// wird nicht wiederholt).
|
||||
// - Wiederkehrend (Cron/every/tool_job) → zurück auf todo, damit der nächste Termin
|
||||
// feuern kann. Sonst liefe ein Cron-Task nur ein einziges Mal.
|
||||
// - Einmalig → done bzw. in_review (A2).
|
||||
var final = !ok || task.IsRecurring
|
||||
? TaskItemStatus.Todo
|
||||
: task.RequireApproval
|
||||
? TaskItemStatus.InReview
|
||||
: TaskItemStatus.Done;
|
||||
|
||||
await _repo.CompleteClaimAsync(task.Id, token, final, now, ct);
|
||||
|
||||
if (ok && final == TaskItemStatus.Done)
|
||||
await UnblockDependentsAsync(task.Id, now, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
|
||||
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.
|
||||
/// </summary>
|
||||
private async Task UnblockDependentsAsync(string completedId, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var dependents = await _repo.ListBlockedByAsync(completedId, ct);
|
||||
foreach (var dependent in dependents)
|
||||
{
|
||||
if (dependent.Status != TaskItemStatus.Blocked)
|
||||
continue;
|
||||
|
||||
var allDone = true;
|
||||
foreach (var blockerId in dependent.BlockedBy)
|
||||
{
|
||||
var blocker = await _repo.GetAsync(blockerId, ct);
|
||||
if (blocker is null || blocker.Status != TaskItemStatus.Done)
|
||||
{
|
||||
allDone = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allDone)
|
||||
{
|
||||
await _repo.SetStatusAsync(dependent.Id, TaskItemStatus.Todo, now, ct);
|
||||
_logger.LogInformation(
|
||||
"Aufgabe {TaskId} freigegeben — alle Blocker erledigt", dependent.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
|
||||
/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
|
||||
///
|
||||
/// Das ist die Antwort auf B6 und B7: Statt eines langen <c>Task.Delay</c> bis zum
|
||||
/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
|
||||
/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
|
||||
/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
|
||||
///
|
||||
/// Höchstens <b>ein</b> Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
|
||||
/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
|
||||
/// eine Welle aus.
|
||||
/// </summary>
|
||||
public static class TaskSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Der Occurrence-Key des fälligen Termins, oder <c>null</c>, wenn nichts ansteht.
|
||||
/// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
|
||||
/// (<see cref="TaskItem.LastOccurrence"/>) liegt.
|
||||
/// </summary>
|
||||
public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
|
||||
{
|
||||
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
||||
|
||||
if (task.When is null)
|
||||
{
|
||||
// Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
|
||||
if (task.LastOccurrence is not null) return null;
|
||||
var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
|
||||
return Iso(basis);
|
||||
}
|
||||
|
||||
return task.When.Kind switch
|
||||
{
|
||||
TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
|
||||
TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
|
||||
TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
|
||||
/// <c>null</c>.
|
||||
///
|
||||
/// Ein solcher Termin feuert nie (siehe <c>ResolveTimeZone</c>). Damit das nicht
|
||||
/// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
|
||||
/// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
|
||||
/// (<c>W. Europe Standard Time</c>) auf einem System ohne ICU-Daten.
|
||||
/// </summary>
|
||||
public static string? UnresolvableTimeZone(TaskItem task)
|
||||
=> task.When is { } when
|
||||
&& !string.IsNullOrWhiteSpace(when.TimeZone)
|
||||
&& !TimeZones.IsKnown(when.TimeZone)
|
||||
? when.TimeZone
|
||||
: null;
|
||||
|
||||
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
||||
if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
|
||||
if (nowUtc < targetUtc) return null;
|
||||
return Iso(targetUtc);
|
||||
}
|
||||
|
||||
private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (!TryParseInterval(task.When!.Value, out var interval)) return null;
|
||||
|
||||
var anchor = marker is not null
|
||||
? ParseIso(marker)
|
||||
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
|
||||
|
||||
return nowUtc < anchor + interval ? null : Iso(nowUtc);
|
||||
}
|
||||
|
||||
private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
|
||||
{
|
||||
var when = task.When!;
|
||||
CronExpression cron;
|
||||
try { cron = CronExpression.Parse(when.Value); }
|
||||
catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen
|
||||
|
||||
var tz = ResolveTimeZone(when.TimeZone);
|
||||
if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit
|
||||
|
||||
var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));
|
||||
|
||||
// Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
|
||||
// ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
|
||||
// nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
|
||||
var lowerUtc = marker is not null ? ParseIso(marker)
|
||||
: task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
|
||||
: nowUtc;
|
||||
var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));
|
||||
|
||||
var limit = lowerLocal;
|
||||
DateTime? matchLocal = null;
|
||||
for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
|
||||
{
|
||||
if (cron.Matches(candidate)) { matchLocal = candidate; break; }
|
||||
}
|
||||
|
||||
if (matchLocal is null) return null;
|
||||
|
||||
// Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen.
|
||||
var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified);
|
||||
if (tz.IsInvalidTime(unspecified)) return null;
|
||||
|
||||
var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
||||
var occ = Iso(occUtc);
|
||||
|
||||
// Nur fällig, wenn der Termin echt über dem Marker liegt.
|
||||
return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
/// <summary>Deutet einen <c>at</c>-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
|
||||
/// angegebenen Zeitzone.</summary>
|
||||
private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
var v = value.Trim();
|
||||
if (v.Length == 0) return false;
|
||||
|
||||
if (v.EndsWith('Z') || v.EndsWith('z'))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal, out var dto))
|
||||
{
|
||||
utc = dto.UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
|
||||
{
|
||||
var tz = ResolveTimeZone(timeZone);
|
||||
if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
|
||||
|
||||
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
|
||||
if (tz.IsInvalidTime(unspecified)) return false;
|
||||
utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Ein Intervall wie <c>30s</c>, <c>15m</c>, <c>2h</c>, <c>1d</c>.</summary>
|
||||
private static bool TryParseInterval(string value, out TimeSpan interval)
|
||||
{
|
||||
interval = default;
|
||||
var v = value.Trim().ToLowerInvariant();
|
||||
if (v.Length < 2) return false;
|
||||
|
||||
var unit = v[^1];
|
||||
if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
|
||||
return false;
|
||||
|
||||
interval = unit switch
|
||||
{
|
||||
's' => TimeSpan.FromSeconds(n),
|
||||
'm' => TimeSpan.FromMinutes(n),
|
||||
'h' => TimeSpan.FromHours(n),
|
||||
'd' => TimeSpan.FromDays(n),
|
||||
_ => TimeSpan.Zero
|
||||
};
|
||||
return interval > TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
|
||||
///
|
||||
/// Eine <b>unbekannte</b> Zone gibt <c>null</c> zurück — und der Aufrufer behandelt
|
||||
/// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
|
||||
/// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
|
||||
/// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
|
||||
///
|
||||
/// Sichtbar wird er beim Import — <see cref="TaskboardService"/> weist eine Aufgabe
|
||||
/// mit unbekannter Zone mit Meldung ab.
|
||||
/// </summary>
|
||||
private static TimeZoneInfo? ResolveTimeZone(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
|
||||
return TimeZones.TryResolve(id);
|
||||
}
|
||||
|
||||
private static DateTime Truncate(DateTime value)
|
||||
=> new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);
|
||||
|
||||
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime ParseIso(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt.ToUniversalTime()
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Übersetzt die Aufzählungen in ihre menschenlesbare Form und zurück. Dieselbe
|
||||
/// Schreibweise wird im Frontmatter wie in der DB verwendet, damit beide Seiten sich
|
||||
/// nicht auseinanderentwickeln. Unbekannte Eingaben fallen auf einen sicheren Standard
|
||||
/// zurück, statt zu werfen — eine von Hand editierte Datei soll das Board nicht kippen.
|
||||
/// </summary>
|
||||
public static class TaskText
|
||||
{
|
||||
public static string Of(TaskItemStatus status) => status switch
|
||||
{
|
||||
TaskItemStatus.Backlog => "backlog",
|
||||
TaskItemStatus.Todo => "todo",
|
||||
TaskItemStatus.InProgress => "in_progress",
|
||||
TaskItemStatus.InReview => "in_review",
|
||||
TaskItemStatus.Done => "done",
|
||||
TaskItemStatus.Canceled => "canceled",
|
||||
TaskItemStatus.Blocked => "blocked",
|
||||
TaskItemStatus.Archived => "archived",
|
||||
_ => "todo"
|
||||
};
|
||||
|
||||
public static TaskItemStatus Status(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"backlog" => TaskItemStatus.Backlog,
|
||||
"todo" => TaskItemStatus.Todo,
|
||||
"in_progress" => TaskItemStatus.InProgress,
|
||||
"in_review" => TaskItemStatus.InReview,
|
||||
"done" => TaskItemStatus.Done,
|
||||
"canceled" or "cancelled" => TaskItemStatus.Canceled,
|
||||
"blocked" => TaskItemStatus.Blocked,
|
||||
"archived" => TaskItemStatus.Archived,
|
||||
_ => TaskItemStatus.Todo
|
||||
};
|
||||
|
||||
public static string Of(TaskItemType type) => type switch
|
||||
{
|
||||
TaskItemType.Work => "work",
|
||||
TaskItemType.Approval => "approval",
|
||||
TaskItemType.HumanInput => "human_input",
|
||||
TaskItemType.ToolJob => "tool_job",
|
||||
_ => "work"
|
||||
};
|
||||
|
||||
public static TaskItemType Type(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"approval" => TaskItemType.Approval,
|
||||
"human_input" => TaskItemType.HumanInput,
|
||||
"tool_job" => TaskItemType.ToolJob,
|
||||
_ => TaskItemType.Work
|
||||
};
|
||||
|
||||
public static string Of(TaskWhenKind kind) => kind switch
|
||||
{
|
||||
TaskWhenKind.At => "at",
|
||||
TaskWhenKind.Every => "every",
|
||||
TaskWhenKind.Cron => "cron",
|
||||
_ => "cron"
|
||||
};
|
||||
|
||||
/// <summary>Gibt <c>null</c> zurück, wenn die Angabe keine bekannte Terminart ist.</summary>
|
||||
public static TaskWhenKind? WhenKind(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"at" => TaskWhenKind.At,
|
||||
"every" => TaskWhenKind.Every,
|
||||
"cron" => TaskWhenKind.Cron,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Die Brücke der Wahrheitsaufteilung: Markdown-Dateien unter <c>SharedWorkspace/tasks/</c>
|
||||
/// halten die Definition, die DB (<see cref="ITaskRepository"/>) den Ausführungszustand.
|
||||
/// Dieser Dienst hält beide im Gleichschritt und wird von drei Seiten genutzt — dem
|
||||
/// Agenten-Tool, dem Scanner und dem Start (Reconciliation).
|
||||
///
|
||||
/// Bewusst zustandslos über den Aufrufen: nur Repository und Verzeichnis, keine
|
||||
/// gepufferten Aufgaben. So kann jede Seite ihn nach Bedarf erzeugen.
|
||||
/// </summary>
|
||||
public sealed class TaskboardService
|
||||
{
|
||||
private readonly ITaskRepository _repo;
|
||||
private readonly string _tasksDir;
|
||||
|
||||
public TaskboardService(ITaskRepository repo, string tasksDirectory)
|
||||
{
|
||||
_repo = repo;
|
||||
_tasksDir = tasksDirectory;
|
||||
}
|
||||
|
||||
public string TasksDirectory => _tasksDir;
|
||||
|
||||
// ─── Lesen (direkt aus der DB) ───
|
||||
|
||||
public Task<TaskItem?> GetAsync(string id, CancellationToken ct) => _repo.GetAsync(id, ct);
|
||||
|
||||
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
=> _repo.ListAsync(query, ct);
|
||||
|
||||
// ─── Anlegen ───
|
||||
|
||||
/// <summary>
|
||||
/// Legt eine Aufgabe an: vergibt (falls nötig) eine stabile Id, schreibt die Datei und
|
||||
/// spiegelt sie in die DB. Datei und DB entstehen in einem Zug.
|
||||
/// </summary>
|
||||
public async Task<TaskItem> CreateAsync(TaskItem definition, CancellationToken ct)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(definition.Id) ? await NewIdAsync(ct) : definition.Id.Trim();
|
||||
var fileName = string.IsNullOrWhiteSpace(definition.FileName)
|
||||
? BuildFileName(definition.Title, id)
|
||||
: definition.FileName;
|
||||
|
||||
var toWrite = definition with { Id = id, FileName = fileName };
|
||||
WriteFile(toWrite);
|
||||
return await _repo.UpsertAsync(toWrite, ct);
|
||||
}
|
||||
|
||||
// ─── Ändern ───
|
||||
|
||||
/// <summary>
|
||||
/// Ändert die Definition einer Aufgabe (Tool-Aktion). Schreibt Datei und DB. Eine
|
||||
/// Status-Änderung wird zusätzlich explizit gesetzt — der Import spiegelt nur die
|
||||
/// Definition und lässt den Ausführungszustand absichtlich unangetastet, damit ein
|
||||
/// blinder Re-Import einen laufenden Zustand nicht zurücksetzt.
|
||||
/// </summary>
|
||||
public async Task<TaskItem?> UpdateAsync(
|
||||
string id, Func<TaskItem, TaskItem> mutate, CancellationToken ct)
|
||||
{
|
||||
var current = await _repo.GetAsync(id, ct);
|
||||
if (current is null) return null;
|
||||
|
||||
var updated = mutate(current) with { Id = id, FileName = current.FileName };
|
||||
WriteFile(updated);
|
||||
await _repo.UpsertAsync(updated, ct);
|
||||
|
||||
if (updated.Status != current.Status)
|
||||
await _repo.SetStatusAsync(id, updated.Status, DateTime.UtcNow, ct);
|
||||
|
||||
return await _repo.GetAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an.</summary>
|
||||
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct)
|
||||
=> UpdateAsync(id, current =>
|
||||
{
|
||||
var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
|
||||
var block = $"---\n**{author}** ({stamp}):\n\n{text.Trim()}";
|
||||
var body = string.IsNullOrWhiteSpace(current.Body) ? block : $"{current.Body.TrimEnd()}\n\n{block}";
|
||||
return current with { Body = body };
|
||||
}, ct);
|
||||
|
||||
// ─── Import (Datei → DB) ───
|
||||
|
||||
/// <summary>
|
||||
/// Liest alle Task-Dateien und spiegelt sie in die DB. DB-Zeilen, deren Datei
|
||||
/// verschwunden ist, werden archiviert (nicht gelöscht — die Historie bleibt). Gibt
|
||||
/// die Zahl der importierten Dateien zurück.
|
||||
/// </summary>
|
||||
public async Task<int> ImportAllAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(_tasksDir))
|
||||
return 0;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var imported = 0;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(_tasksDir, "*.md"))
|
||||
{
|
||||
var task = await ImportFileAsync(path, ct);
|
||||
if (task is not null) { seen.Add(task.Id); imported++; }
|
||||
}
|
||||
|
||||
// Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch;
|
||||
// wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her.
|
||||
var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct);
|
||||
foreach (var task in known)
|
||||
if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
|
||||
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
/// <summary>Importiert eine einzelne Datei. Eine von Hand angelegte Datei ohne
|
||||
/// <c>id</c> bekommt eine zugewiesen und wird einmalig kanonisch zurückgeschrieben.</summary>
|
||||
public async Task<TaskItem?> ImportFileAsync(string path, CancellationToken ct)
|
||||
{
|
||||
string text;
|
||||
try { text = AtomicFile.ReadAllText(path); }
|
||||
catch { return null; }
|
||||
|
||||
if (!TaskFrontmatter.TryParse(text, out var definition, out _))
|
||||
return null;
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(definition.Id))
|
||||
{
|
||||
definition = definition with { Id = await NewIdAsync(ct), FileName = fileName };
|
||||
WriteFile(definition); // Id festschreiben, damit sie den nächsten Start überlebt
|
||||
}
|
||||
else
|
||||
{
|
||||
definition = definition with { FileName = fileName };
|
||||
}
|
||||
|
||||
return await _repo.UpsertAsync(definition, ct);
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private void WriteFile(TaskItem definition)
|
||||
{
|
||||
Directory.CreateDirectory(_tasksDir);
|
||||
var path = Path.Combine(_tasksDir, definition.FileName);
|
||||
AtomicFile.WriteAllText(path, TaskFrontmatter.Serialize(definition));
|
||||
}
|
||||
|
||||
private async Task<string> NewIdAsync(CancellationToken ct)
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var id = "t-" + Guid.NewGuid().ToString("N")[..6];
|
||||
if (await _repo.GetAsync(id, ct) is null)
|
||||
return id;
|
||||
}
|
||||
return "t-" + Guid.NewGuid().ToString("N")[..12];
|
||||
}
|
||||
|
||||
private static string BuildFileName(string title, string id)
|
||||
{
|
||||
var slug = Slug(title);
|
||||
var suffix = id.StartsWith("t-", StringComparison.Ordinal) ? id[2..] : id;
|
||||
return (slug.Length == 0 ? "task" : slug) + "-" + suffix + ".md";
|
||||
}
|
||||
|
||||
private static string Slug(string title)
|
||||
{
|
||||
var chars = title.Trim().ToLowerInvariant()
|
||||
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
|
||||
.ToArray();
|
||||
var slug = new string(chars);
|
||||
|
||||
while (slug.Contains("--"))
|
||||
slug = slug.Replace("--", "-");
|
||||
slug = slug.Trim('-');
|
||||
|
||||
if (slug.Length > 40)
|
||||
slug = slug[..40].Trim('-');
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tools;
|
||||
@@ -14,5 +15,6 @@ public sealed record AgentToolContext(
|
||||
string? WorkspacePath = null,
|
||||
string? SharedWorkspacePath = null,
|
||||
IAgentMessageRouter? MessageRouter = null,
|
||||
IMemoryRepository? Memory = null
|
||||
IMemoryRepository? Memory = null,
|
||||
ITaskRepository? Tasks = null
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:ClawdDotNet.Desktop"
|
||||
x:Class="ClawdDotNet.Desktop.App"
|
||||
RequestedThemeVariant="Default">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator />
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
<StyleInclude Source="avares://ClawdDotNet/Styles/Shell.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@@ -0,0 +1,213 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Desktop.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private AppHost? _host;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Erst beenden, wenn wir es sagen: Zwischen Instanzauswahl und Hauptfenster
|
||||
// ist kurz gar kein Fenster offen. Mit OnLastWindowClose würde die Anwendung
|
||||
// in genau dieser Lücke aussteigen.
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
};
|
||||
|
||||
// Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren,
|
||||
// damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf
|
||||
// dem die Fenster des Startvorgangs überhaupt erscheinen könnten.
|
||||
_ = StartAsync(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var result = await AppHost.StartAsync(new AppHost.Callbacks
|
||||
{
|
||||
SelectInstance = SelectInstanceAsync,
|
||||
License = new AvaloniaLicensePrompt(),
|
||||
TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt),
|
||||
Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:")
|
||||
});
|
||||
|
||||
if (result.Error is { } error)
|
||||
{
|
||||
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Fehler", error);
|
||||
desktop.Shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Host is null)
|
||||
{
|
||||
// Abbruch durch den Benutzer oder fehlende Lizenz — beides ist bereits
|
||||
// erklärt worden, hier kommt keine weitere Meldung hinterher.
|
||||
desktop.Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
_host = result.Host;
|
||||
|
||||
HookErrorReporting(_host);
|
||||
HookLicenseWatch(_host, desktop);
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(_host)
|
||||
};
|
||||
|
||||
desktop.MainWindow.Show();
|
||||
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
|
||||
|
||||
await ShowUpdateNoticeAsync(_host);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters.
|
||||
///
|
||||
/// <para>Erst hier verdrahtet, nicht in <c>Main</c>: Vor dem Aufbau gibt es weder
|
||||
/// Einstellungen noch Token, und ohne die wäre der Meldeweg ohnehin der Leerlauf.
|
||||
/// Die Kehrseite ist bewusst in Kauf genommen — ein Absturz <em>während</em> des
|
||||
/// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.</para>
|
||||
/// </summary>
|
||||
private static void HookErrorReporting(AppHost host)
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
{
|
||||
if (args.ExceptionObject is Exception ex)
|
||||
{
|
||||
// Der Prozess endet gleich: kurze Frist, dann weiterlaufen lassen.
|
||||
host.Errors.ReportAsync(ex, fatal: args.IsTerminating)
|
||||
.Wait(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
_ = host.Errors.ReportAsync(args.Exception, fatal: false);
|
||||
|
||||
// Ohne Observe reißt eine unbeobachtete Ausnahme in manchen Konfigurationen
|
||||
// den Prozess mit — und das wäre eine Nebenwirkung des Meldens.
|
||||
args.SetObserved();
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += (_, args) =>
|
||||
{
|
||||
_ = host.Errors.ReportAsync(args.Exception, fatal: false);
|
||||
|
||||
// Ein Fehler in einem Ereignisbehandler soll die Oberfläche nicht beenden.
|
||||
args.Handled = true;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage.</summary>
|
||||
private static void HookLicenseWatch(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (host.LicenseWatch is null) return;
|
||||
|
||||
host.LicenseWatch.Revoked += async message =>
|
||||
{
|
||||
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Lizenz", message);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(2));
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: Wann aktualisiert
|
||||
/// wird, entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst
|
||||
/// beendet, um sich zu erneuern, ist genau dann im Weg, wenn man sie braucht.
|
||||
/// </summary>
|
||||
private static async Task ShowUpdateNoticeAsync(AppHost host)
|
||||
{
|
||||
if (host.Deploymentcenter is null) return;
|
||||
|
||||
// Die Prüfung läuft nebenher; kurz Zeit geben, dann aufgeben.
|
||||
for (var waited = 0; host.Deploymentcenter.Update is null && waited < 10; waited++)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
|
||||
if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
|
||||
|
||||
await new AvaloniaLicensePrompt().ShowInfoAsync(
|
||||
update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update",
|
||||
$"Version {update.LatestVersion} ist verfügbar."
|
||||
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
|
||||
}
|
||||
|
||||
private static async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var viewModel = new InstancePickerViewModel(directories);
|
||||
var window = new InstancePickerWindow { DataContext = viewModel };
|
||||
|
||||
// Schließt der Benutzer das Fenster, gilt das als Abbruch — sonst wartete
|
||||
// der Start für immer auf eine Auswahl, die nie kommt.
|
||||
window.Closed += (_, _) => viewModel.CancelCommand.Execute(null);
|
||||
|
||||
window.Show();
|
||||
|
||||
var path = await viewModel.Result;
|
||||
window.Close();
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Einzeilige Abfrage — ersetzt <c>Microsoft.VisualBasic.Interaction.InputBox</c>,
|
||||
/// das die Telegram-Anmeldung an Windows band.
|
||||
/// </summary>
|
||||
private static async Task<string> AskAsync(string title, string prompt)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource<string>();
|
||||
|
||||
var input = new TextBox();
|
||||
var ok = new Button { Content = "OK", IsDefault = true };
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 420,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = prompt, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
input,
|
||||
ok
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ok.Click += (_, _) => window.Close();
|
||||
window.Closed += (_, _) => completion.TrySetResult(input.Text?.Trim() ?? "");
|
||||
|
||||
window.Show();
|
||||
input.Focus();
|
||||
|
||||
return await completion.Task;
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Desktop</RootNamespace>
|
||||
<AssemblyName>ClawdDotNet</AssemblyName>
|
||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||
|
||||
<!-- Avalonia legt AXAML-Dateien selbst als AvaloniaResource an; die Standard-Globs
|
||||
wuerden sie zusaetzlich als None einsammeln. -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
|
||||
<!-- Kein Konsolenfenster unter Windows, aber unter Linux ein normaler Prozess.
|
||||
WinExe verhaelt sich dort ohnehin wie Exe. -->
|
||||
<BuiltInComInteropSupport>false</BuiltInComInteropSupport>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten.
|
||||
/// </summary>
|
||||
public sealed class ChatBubbleBrushConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatBubbleBrushConverter Instance = new();
|
||||
|
||||
private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isUser && isUser)
|
||||
return UserBubble;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten.
|
||||
/// </summary>
|
||||
public sealed class ChatAlignmentConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatAlignmentConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isUser && isUser)
|
||||
return HorizontalAlignment.Right;
|
||||
|
||||
return HorizontalAlignment.Left;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt.
|
||||
/// </summary>
|
||||
public sealed class ToolMatchConverter : IValueConverter
|
||||
{
|
||||
public static readonly ToolMatchConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is string toolName && parameter is string targetTool)
|
||||
return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Färbt eine Logzeile nach ihrer Stufe.
|
||||
///
|
||||
/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht
|
||||
/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die
|
||||
/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel.
|
||||
/// </summary>
|
||||
public sealed class LogLevelBrushConverter : IValueConverter
|
||||
{
|
||||
public static readonly LogLevelBrushConverter Instance = new();
|
||||
|
||||
private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B));
|
||||
private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value as string switch
|
||||
{
|
||||
"ERR" or "FTL" => Error,
|
||||
"WRN" => Warn,
|
||||
// null heißt: Bindung greift nicht, das Steuerelement behält seine eigene
|
||||
// Farbe aus dem Thema.
|
||||
_ => null
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstiegspunkt der Oberfläche.
|
||||
///
|
||||
/// Bewusst schlank: Hier wird nur Avalonia hochgefahren. Alles Fachliche —
|
||||
/// Einstellungen, Instanzauswahl, Engine, Scanner, Watchdog — baut
|
||||
/// <see cref="App"/> über die Anwendungsschicht auf, damit derselbe Aufbau später
|
||||
/// auch ohne Fenster laufen kann.
|
||||
///
|
||||
/// Kein <c>[STAThread]</c> mehr: Das war eine COM-Anforderung von WinForms. Avalonia
|
||||
/// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung.
|
||||
/// </summary>
|
||||
public static int Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
/// <summary>
|
||||
/// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und
|
||||
/// getrennt von <see cref="Main"/>.
|
||||
/// </summary>
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
// Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen:
|
||||
// Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die
|
||||
// Oberfläche leer.
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lizenzabfrage über Fenster.
|
||||
///
|
||||
/// Der Aufbau läuft auf einem Hintergrundfaden — Avalonia besteht aber darauf, dass
|
||||
/// Fenster auf dem Oberflächenfaden entstehen. Deshalb geht hier alles über
|
||||
/// <see cref="Dispatcher.UIThread"/>. Das ist der Grund, warum
|
||||
/// <see cref="ILicensePrompt"/> durchgehend asynchron ist: Die WinForms-Fassung konnte
|
||||
/// <c>ShowDialog</c> einfach blockierend aufrufen, weil sie ohnehin auf dem
|
||||
/// Oberflächenfaden lief.
|
||||
/// </summary>
|
||||
public sealed class AvaloniaLicensePrompt : ILicensePrompt
|
||||
{
|
||||
public async Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var viewModel = new LicenseViewModel(hardwareId, problem, currentKey);
|
||||
var window = new LicenseWindow { DataContext = viewModel };
|
||||
|
||||
window.Show();
|
||||
|
||||
var key = await viewModel.Result;
|
||||
window.Close();
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
public Task ShowInfoAsync(string title, string message)
|
||||
=> ShowMessageAsync(title, message);
|
||||
|
||||
public Task ShowErrorAsync(string title, string message)
|
||||
=> ShowMessageAsync(title, message);
|
||||
|
||||
/// <summary>
|
||||
/// Ein Meldungsfenster.
|
||||
///
|
||||
/// Avalonia bringt kein <c>MessageBox</c> mit — bewusst, weil es auf allen
|
||||
/// Plattformen anders aussähe. Ein schlichtes Fenster ist hier ausreichend und
|
||||
/// erspart uns eine weitere Abhängigkeit für drei Aufrufstellen.
|
||||
/// </summary>
|
||||
private static async Task ShowMessageAsync(string title, string message)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource();
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Content = "OK",
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
IsDefault = true
|
||||
};
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 460,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Avalonia.Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
button
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
button.Click += (_, _) => window.Close();
|
||||
window.Closed += (_, _) => completion.TrySetResult();
|
||||
|
||||
window.Show();
|
||||
await completion.Task;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Gemeinsame Anmutung der Oberflaeche.
|
||||
|
||||
Bewusst wenige, benannte Regeln statt Formatierung an jedem Steuerelement: Die
|
||||
WinForms-Fassung hatte Groessen, Abstaende und Schriften ueber 130 Stellen in den
|
||||
Designer-Dateien verteilt: Jede Aenderung war eine Suche.
|
||||
-->
|
||||
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- Werkzeugleiste ueber einer Ansicht -->
|
||||
<Style Selector="StackPanel.toolbar">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="Spacing" Value="6" />
|
||||
<Setter Property="Margin" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- Statusleiste am unteren Rand -->
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user