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

This commit is contained in:
Richard
2026-08-10 10:48:34 +02:00
parent a0e18d2a57
commit b5bf97ae74
187 changed files with 20054 additions and 882 deletions
+485
View File
@@ -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>&lt;Version&gt;</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>
@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.App.Models;
/// <summary>
/// Eintrag in der AgentList.json Basisinformationen zu einem Agenten.
/// Liegt im Agents/-Ordner einer Instanz.
/// </summary>
public sealed class AgentListItem
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("description")]
public string Description { get; set; } = "";
[JsonPropertyName("folderName")]
public string FolderName { get; set; } = "";
}
/// <summary>
/// Root-Objekt der AgentList.json
/// </summary>
public sealed class AgentListFile
{
[JsonPropertyName("agents")]
public List<AgentListItem> Agents { get; set; } = new();
}
@@ -0,0 +1,13 @@
namespace ClawdDotNet.App.Models;
/// <summary>
/// Zusammenfassung einer Instanz für die Anzeige im InstanceManager.
/// </summary>
public sealed class InstanceInfo
{
public string InstanceName { get; set; } = "";
public string FolderName { get; set; } = "";
public string FolderPath { get; set; } = "";
public int AgentCount { get; set; }
public string ApiKeyStatus { get; set; } = "—";
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.App.Models;
public sealed class JobHistoryEntry
{
[JsonPropertyName("jobName")]
public string JobName { get; set; } = "";
[JsonPropertyName("agent")]
public string Agent { get; set; } = "";
[JsonPropertyName("time")]
public DateTime Time { get; set; } = DateTime.Now;
[JsonPropertyName("jobDescription")]
public string JobDescription { get; set; } = "";
[JsonPropertyName("info")]
public string Info { get; set; } = "";
[JsonPropertyName("status")]
public string Status { get; set; } = "Success"; // Success, Error, Manual
}
@@ -0,0 +1,51 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.App.Models;
public sealed class TokenUsageRecord
{
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.Now;
[JsonPropertyName("agentId")]
public string AgentId { get; set; } = "";
[JsonPropertyName("agentName")]
public string AgentName { get; set; } = "";
[JsonPropertyName("model")]
public string Model { get; set; } = "";
[JsonPropertyName("promptTokens")]
public int PromptTokens { get; set; }
[JsonPropertyName("completionTokens")]
public int CompletionTokens { get; set; }
[JsonPropertyName("totalTokens")]
public int TotalTokens { get; set; }
[JsonPropertyName("costUsd")]
public double CostUsd { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "";
[JsonPropertyName("stepCount")]
public int StepCount { get; set; }
[JsonPropertyName("durationMs")]
public long DurationMs { get; set; }
}
public sealed class TokenUsageFile
{
[JsonPropertyName("instanceId")]
public string InstanceId { get; set; } = "";
[JsonPropertyName("instanceName")]
public string InstanceName { get; set; } = "";
[JsonPropertyName("records")]
public List<TokenUsageRecord> Records { get; set; } = new();
}
@@ -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;
}
}
@@ -0,0 +1,454 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.App.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Verwaltet die gesamte Verzeichnisstruktur für Instanzen und Agenten.
///
/// Layout:
/// {InstancesDir}/
/// ├── Instance-{Name}/
/// │ ├── InstanceSettings.json
/// │ ├── TokenUsage.json
/// │ └── Agents/
/// │ ├── AgentList.json
/// │ └── Agent-{Name}/
/// │ ├── AgentSettings.json
/// │ ├── Soul.md
/// │ ├── Identity.md
/// │ ├── Logs/
/// │ └── Workspace/
/// </summary>
public sealed class InstanceDirectoryManager
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly string _instancesDir;
private readonly Lock _tokenUsageLock = new();
public InstanceDirectoryManager(string instancesDirectory)
{
_instancesDir = Path.GetFullPath(instancesDirectory);
Directory.CreateDirectory(_instancesDir);
}
public string InstancesDirectory => _instancesDir;
// ═══════════════════════════════════════════════════
// INSTANZ-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildInstanceFolderName(string instanceName)
=> $"Instance-{SanitizeName(instanceName)}";
public string GetInstancePath(string instanceName)
=> Path.Combine(_instancesDir, BuildInstanceFolderName(instanceName));
public List<InstanceInfo> ListInstances()
{
var result = new List<InstanceInfo>();
if (!Directory.Exists(_instancesDir))
return result;
foreach (var dir in Directory.GetDirectories(_instancesDir, "Instance-*"))
{
var folderName = Path.GetFileName(dir);
var settingsPath = Path.Combine(dir, "InstanceSettings.json");
var info = new InstanceInfo
{
FolderName = folderName,
FolderPath = dir,
InstanceName = folderName.Replace("Instance-", "")
};
if (File.Exists(settingsPath))
{
try
{
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts);
if (config is not null)
{
info.InstanceName = config.InstanceName;
info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey)
? "Fehlt" : "Konfiguriert";
}
}
catch { /* defekte Config → Standardwerte */ }
}
// Agenten zählen
var agentsDir = Path.Combine(dir, "Agents");
if (Directory.Exists(agentsDir))
info.AgentCount = Directory.GetDirectories(agentsDir, "Agent-*").Length;
result.Add(info);
}
return result.OrderBy(i => i.InstanceName).ToList();
}
public string CreateInstance(string instanceName)
{
var instanceDir = GetInstancePath(instanceName);
if (Directory.Exists(instanceDir))
throw new InvalidOperationException($"Instanz '{instanceName}' existiert bereits.");
// Hauptverzeichnis
Directory.CreateDirectory(instanceDir);
// Agents-Unterverzeichnis
var agentsDir = Path.Combine(instanceDir, "Agents");
Directory.CreateDirectory(agentsDir);
// SharedWorkspace-Verzeichnis
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// InstanceSettings.json
var config = new InstanceConfig
{
InstanceId = Guid.NewGuid().ToString("N")[..8],
InstanceName = instanceName,
LogDirectory = "./Logs",
WorkingDirectory = instanceDir
};
SaveJson(Path.Combine(instanceDir, "InstanceSettings.json"), config);
// TokenUsage.json (leer)
var tokenUsage = new TokenUsageFile
{
InstanceId = config.InstanceId,
InstanceName = instanceName
};
SaveJson(Path.Combine(instanceDir, "TokenUsage.json"), tokenUsage);
// AgentList.json (leer)
SaveJson(Path.Combine(agentsDir, "AgentList.json"), new AgentListFile());
return instanceDir;
}
public InstanceConfig LoadInstanceConfig(string instanceDir)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
if (!File.Exists(settingsPath))
throw new FileNotFoundException($"InstanceSettings.json nicht gefunden in: {instanceDir}");
var json = File.ReadAllText(settingsPath);
var config = JsonSerializer.Deserialize<InstanceConfig>(json, JsonOpts)
?? throw new InvalidOperationException("InstanceSettings.json ist leer oder ungültig.");
// Agenten aus Verzeichnisstruktur laden
config.Agents.Clear();
var agentsDir = Path.Combine(instanceDir, "Agents");
// Sicherstellen, dass SharedWorkspace existiert (Migration)
Directory.CreateDirectory(Path.Combine(agentsDir, "SharedWorkspace"));
// AgentList.json für Descriptions laden
var agentListPath = Path.Combine(agentsDir, "AgentList.json");
var agentList = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
if (Directory.Exists(agentsDir))
{
foreach (var agentDir in Directory.GetDirectories(agentsDir, "Agent-*").OrderBy(d => d))
{
var agent = LoadAgentConfig(agentDir);
var folderName = Path.GetFileName(agentDir);
var listEntry = agentList.Agents.FirstOrDefault(a => a.FolderName == folderName);
if (listEntry is not null && !string.IsNullOrWhiteSpace(listEntry.Description))
agent.Description = listEntry.Description;
config.Agents.Add(agent);
}
}
return config;
}
public void SaveInstanceConfig(string instanceDir, InstanceConfig config)
{
var settingsPath = Path.Combine(instanceDir, "InstanceSettings.json");
// Zugangsdaten nur für das Schreiben verschlüsseln — die laufende Instanz
// braucht sie danach wieder im Klartext.
ConfigSecrets.Protect(config);
try
{
SaveJson(settingsPath, config);
}
finally
{
ConfigSecrets.Unprotect(config);
}
}
// ═══════════════════════════════════════════════════
// AGENTEN-OPERATIONEN
// ═══════════════════════════════════════════════════
public static string BuildAgentFolderName(string agentName)
=> $"Agent-{SanitizeName(agentName)}";
public string GetAgentPath(string instanceDir, string agentName)
=> Path.Combine(instanceDir, "Agents", BuildAgentFolderName(agentName));
public string CreateAgent(string instanceDir, string agentName, string description = "")
{
var agentDir = GetAgentPath(instanceDir, agentName);
if (Directory.Exists(agentDir))
throw new InvalidOperationException($"Agent '{agentName}' existiert bereits in dieser Instanz.");
// Verzeichnisse anlegen
Directory.CreateDirectory(agentDir);
Directory.CreateDirectory(Path.Combine(agentDir, "Logs"));
Directory.CreateDirectory(Path.Combine(agentDir, "Workspace"));
// AgentSettings.json
var agentConfig = new AgentConfig
{
AgentId = SanitizeName(agentName).ToLowerInvariant(),
DisplayName = agentName,
Model = "anthropic/claude-sonnet-4-5"
};
SaveAgentSettings(agentDir, agentConfig);
// Identity.md
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"),
$"""
# Identity: {agentName}
Du bist **{agentName}**, ein spezialisierter KI-Agent im ClawdDotNet-System.
## Rolle
[Beschreibe hier die Rolle und Verantwortlichkeiten des Agenten]
## Expertise
[Beschreibe hier die Fachgebiete und Fähigkeiten]
## Kontext
[Beschreibe hier den Arbeitskontext und die Teamzugehörigkeit]
""");
// Soul.md
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"),
$"""
# Soul: {agentName}
## Persönlichkeit
- Gründlich und zuverlässig
- Klar und präzise in der Kommunikation
- Proaktiv bei der Problemerkennung
## Arbeitsweise
- Analysiere Aufgaben sorgfältig bevor du handelst
- Dokumentiere deine Entscheidungen und Ergebnisse
- Nutze die dir zugewiesenen Tools effizient
## Werte
- Genauigkeit vor Geschwindigkeit
- Transparenz in der Entscheidungsfindung
- Sicherheit und Datenschutz haben Priorität
""");
// AgentList.json aktualisieren
UpdateAgentList(instanceDir, agentName, description, BuildAgentFolderName(agentName));
return agentDir;
}
public AgentConfig LoadAgentConfig(string agentDir)
{
// AgentSettings.json laden
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
AgentConfig config;
if (File.Exists(settingsPath))
{
var json = File.ReadAllText(settingsPath);
config = JsonSerializer.Deserialize<AgentConfig>(json, JsonOpts) ?? new AgentConfig();
ConfigLoader.Migrate(config);
ConfigSecrets.Unprotect(config);
}
else
{
config = new AgentConfig
{
AgentId = Path.GetFileName(agentDir).Replace("Agent-", "").ToLowerInvariant(),
DisplayName = Path.GetFileName(agentDir).Replace("Agent-", "")
};
}
// Identity.md laden
var identityPath = Path.Combine(agentDir, "Identity.md");
if (File.Exists(identityPath))
config.Identity = File.ReadAllText(identityPath);
// Soul.md laden
var soulPath = Path.Combine(agentDir, "Soul.md");
if (File.Exists(soulPath))
config.Soul = File.ReadAllText(soulPath);
// Agent-Verzeichnis merken (stabil auch bei DisplayName-Änderungen)
config.AgentDir = Path.GetFullPath(agentDir);
// Workspace-Pfad setzen
config.WorkspacePath = Path.GetFullPath(Path.Combine(agentDir, "Workspace"));
// SharedWorkspace-Pfad setzen
var agentsDir = Path.GetDirectoryName(agentDir); // Dies ist der /Agents Ordner
if (agentsDir != null)
{
config.SharedWorkspacePath = Path.GetFullPath(Path.Combine(agentsDir, "SharedWorkspace"));
}
return config;
}
public void SaveAgentSettings(string agentDir, AgentConfig config)
{
var settingsPath = Path.Combine(agentDir, "AgentSettings.json");
ConfigSecrets.Protect(config);
try
{
SaveJson(settingsPath, config);
}
finally
{
ConfigSecrets.Unprotect(config);
}
}
public void SaveAgentIdentity(string agentDir, string identity)
{
AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity);
}
public void SaveAgentSoul(string agentDir, string soul)
{
AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul);
}
public void RemoveAgent(string instanceDir, string agentFolderName)
{
var agentDir = Path.Combine(instanceDir, "Agents", agentFolderName);
if (Directory.Exists(agentDir))
Directory.Delete(agentDir, recursive: true);
// AgentList.json aktualisieren
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
if (File.Exists(agentListPath))
{
var list = LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile();
list.Agents.RemoveAll(a => a.FolderName == agentFolderName);
SaveJson(agentListPath, list);
}
}
// ═══════════════════════════════════════════════════
// TOKEN USAGE
// ═══════════════════════════════════════════════════
public void AppendTokenUsage(string instanceDir, TokenUsageRecord record)
{
lock (_tokenUsageLock)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
TokenUsageFile file;
if (File.Exists(path))
{
try
{
file = LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile();
}
catch (JsonException)
{
// Korrupte Datei: Backup erstellen, neu anfangen
var backupPath = path + $".corrupt_{DateTime.Now:yyyyMMdd_HHmmss}";
try { File.Copy(path, backupPath, overwrite: true); } catch { /* best effort */ }
file = new TokenUsageFile();
}
}
else
{
file = new TokenUsageFile();
}
file.Records.Add(record);
SaveJson(path, file);
}
}
public TokenUsageFile LoadTokenUsage(string instanceDir)
{
var path = Path.Combine(instanceDir, "TokenUsage.json");
return File.Exists(path)
? LoadJson<TokenUsageFile>(path) ?? new TokenUsageFile()
: new TokenUsageFile();
}
// ═══════════════════════════════════════════════════
// HILFSMETHODEN
// ═══════════════════════════════════════════════════
private void UpdateAgentList(string instanceDir, string agentName, string description, string folderName)
{
var agentListPath = Path.Combine(instanceDir, "Agents", "AgentList.json");
var list = File.Exists(agentListPath)
? LoadJson<AgentListFile>(agentListPath) ?? new AgentListFile()
: new AgentListFile();
// Duplikat-Check
if (list.Agents.All(a => a.FolderName != folderName))
{
list.Agents.Add(new AgentListItem
{
Name = agentName,
Description = description,
FolderName = folderName
});
}
SaveJson(agentListPath, list);
}
private static void SaveJson<T>(string path, T obj)
{
// Atomar: Ein Absturz mitten im Schreiben soll keine halbe Datei hinterlassen.
// Genau das ist bereits passiert (TokenUsage.json.corrupt_…).
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(obj, JsonOpts));
}
private static T? LoadJson<T>(string path)
{
// Lesen ohne den Schreiber zu blockieren — siehe AtomicFile.ReadAllText.
var json = AtomicFile.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
private static string SanitizeName(string name)
{
var sanitized = name.Trim();
foreach (var c in Path.GetInvalidFileNameChars())
sanitized = sanitized.Replace(c, '_');
return sanitized.Replace(' ', '_');
}
}
@@ -0,0 +1,95 @@
using System.Text.Json;
using ClawdDotNet.App.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Thread-safe JSON persistence for job execution history.
/// Uses file locking to handle concurrent access from multiple schedulers.
/// </summary>
public sealed class JobHistoryService
{
private readonly string _filePath;
private readonly Lock _lock = new();
private readonly int _maxEntries;
private List<JobHistoryEntry> _entries = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public JobHistoryService(string instancePath, int maxEntries = 500)
{
_filePath = Path.Combine(instancePath, "job_history.json");
_maxEntries = maxEntries;
Load();
}
/// <summary>
/// Adds a new entry to the history (thread-safe, persists immediately).
/// </summary>
public void Add(JobHistoryEntry entry)
{
lock (_lock)
{
_entries.Insert(0, entry); // newest first
// Trim old entries
if (_entries.Count > _maxEntries)
_entries = _entries.Take(_maxEntries).ToList();
Save();
}
}
/// <summary>
/// Returns a snapshot of all entries (newest first).
/// </summary>
public List<JobHistoryEntry> GetAll()
{
lock (_lock)
return new List<JobHistoryEntry>(_entries);
}
private void Load()
{
lock (_lock)
{
try
{
if (!File.Exists(_filePath))
{
_entries = new List<JobHistoryEntry>();
return;
}
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
_entries = JsonSerializer.Deserialize<List<JobHistoryEntry>>(stream, JsonOptions)
?? new List<JobHistoryEntry>();
}
catch
{
_entries = new List<JobHistoryEntry>();
}
}
}
private void Save()
{
try
{
var tmpPath = _filePath + ".tmp";
using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
JsonSerializer.Serialize(stream, _entries, JsonOptions);
}
File.Move(tmpPath, _filePath, overwrite: true);
}
catch
{
// Silently ignore write failures — next save will retry
}
}
}
+237
View File
@@ -0,0 +1,237 @@
using ClawdDotNet.App.Settings;
using Deploymentcenter.Client;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
///
/// <para><b>Nur ein Urteil sperrt.</b> Das SDK trennt seit 2.1 zwei Dinge, die vorher
/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
/// (<c>revoked</c>, <c>expired</c>, <c>not_found</c>, <c>activation_limit</c>,
/// <c>suspended</c>, <c>clock_rollback</c>) und ein gescheiterter Versuch, überhaupt
/// eine zu bekommen (<see cref="LicenseValidationResult.IsTransient"/>). Nur das Urteil
/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
/// aussperren.</para>
///
/// <para>Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung
/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und
/// trägt damit über Ausfälle hinweg — begrenzt durch <c>cache_ttl_hours</c> des Projekts
/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.</para>
///
/// <para><b>Was diese Fassung nicht kann.</b> Deaktivieren (Aktivierungsplatz freigeben)
/// verlangt den <c>shared_key</c> des Servers. Der gehört nicht in eine ausgelieferte
/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben").
/// Eine Signaturprüfung findet nicht statt — siehe <see cref="LicenseInfo"/>.</para>
/// </summary>
public sealed class LicenseGate
{
private readonly SettingsManager _settings;
private readonly ILogger _logger;
private readonly ILicensePrompt _prompt;
private readonly LicenseClient _client;
private readonly string _serverUrl;
private string? _hardwareId;
public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
{
_settings = settings;
_logger = logger;
_prompt = prompt;
_serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
? LicenseInfo.DefaultServerUrl
: settings.AppSettings.DeploymentcenterUrl.Trim();
// Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
// trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
// Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
// von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
// Server nicht antwortete.
_client = new LicenseClient();
}
/// <summary>
/// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung
/// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas
/// eintragen kann.
/// </summary>
public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
/// <summary>
/// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
/// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
/// </summary>
public string HardwareId =>
// Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
_hardwareId ??= global::Deploymentcenter.Client.HardwareId
.GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
/// <summary>
/// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein
/// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
/// </summary>
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
/// <summary>
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> nach, bis eine nutzbare
/// Lizenz vorliegt, oder gibt <c>false</c> zurück — dann beendet der Aufrufer die
/// Anwendung.
/// </summary>
public async Task<bool> RunStartupCheckAsync(CancellationToken ct = default)
{
if (!IsEnforcementConfigured)
{
_logger.LogWarning(
"Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) übersprungen.");
return true;
}
var key = _settings.AppSettings.LicenseKey;
if (string.IsNullOrWhiteSpace(key))
{
key = await _prompt.RequestKeyAsync(HardwareId, null, null);
if (key is null) return false;
}
var result = await ValidateAsync(key, ct);
while (!result.IsValid)
{
// Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer
// Schlüssel würde daran nichts ändern, und danach zu fragen ließe den
// Benutzer raten. Der Notausschalter greift, sobald der Server wieder
// antwortet (LicenseWatch).
if (result.IsTransient)
{
_logger.LogWarning(
"Lizenz nicht prüfbar ({Status}): {Message} Start wird fortgesetzt.",
result.Status, result.Message);
await _prompt.ShowInfoAsync("ClawdDotNet Lizenz",
"Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder "
+ "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später "
+ "erneut.");
return true;
}
if (result.Status == "clock_rollback")
{
// Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung.
await _prompt.ShowErrorAsync("ClawdDotNet Lizenz",
"Die Lizenzprüfung konnte nicht abgeschlossen werden. "
+ "Bitte den Support kontaktieren.");
return false;
}
_logger.LogWarning(
"Lizenz abgelehnt: {Status} {Message} (Produkt {Slug}, Server {Server})",
result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl);
var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key);
if (retry is null) return false;
key = retry;
result = await ValidateAsync(key, ct);
}
_settings.AppSettings.LicenseKey = key;
try
{
_settings.Save();
}
catch (SettingsPersistenceException ex)
{
// Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
// beim nächsten Start wird eben erneut gefragt.
_logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden");
}
if (result.IsCached)
{
var until = Describe(result.CacheExpiresAt);
_logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until);
await _prompt.ShowInfoAsync("ClawdDotNet Lizenz",
$"Lizenzserver nicht erreichbar. Offline gültig bis {until}.");
}
return true;
}
private async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(key))
{
return new LicenseValidationResult
{
IsValid = false,
Status = "not_found",
Message = "Kein Lizenzschlüssel hinterlegt."
};
}
try
{
return await _client.ValidateAsync(
LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als
// IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem
// nicht als Urteil über die Lizenz gelten.
_logger.LogError(ex, "Lizenzprüfung fehlgeschlagen.");
return new LicenseValidationResult
{
IsValid = false,
Status = "server_unavailable",
Message = "Lizenz konnte nicht geprüft werden.",
IsTransient = true
};
}
}
private static string Describe(long? unixSeconds) =>
unixSeconds is { } seconds and > 0
? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g")
: "auf Weiteres";
private static string DescribeProblem(LicenseValidationResult result) => result.Status switch
{
"revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.",
"suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.",
"expired" => "Diese Lizenz ist abgelaufen.",
"activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. "
+ "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste "
+ "über \"Freigeben\" räumen.",
// Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes
// Projekt und unbekannter Schlüssel. Welches davon, steht nur in message —
// deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch
// eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man
// sucht am falschen Ende.
"not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss "
+ $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt "
+ "sein und der Schlüssel dort hinterlegt."
+ (string.IsNullOrWhiteSpace(result.Message)
? ""
: $" (Server: {result.Message})"),
_ => string.IsNullOrWhiteSpace(result.Message)
? "Die Lizenz ist nicht gültig."
: result.Message
};
}
@@ -0,0 +1,38 @@
namespace ClawdDotNet.App.Services;
/// <summary>
/// Fest hinterlegte Lizenz-Eckdaten.
///
/// <para><b>Kein Public-Key mehr.</b> Die frühere Fassung führte einen
/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine
/// Gegenseite: Der Client liest ausschließlich das Feld <c>status</c> aus der Antwort,
/// eine Signaturprüfung findet nicht statt (siehe
/// <c>docs/Deploymentcenter-Anbindung-Review.md</c>, Abschnitt 2.1). Ein Schlüssel, der
/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist.
/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück.</para>
///
/// <para>Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann
/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz.</para>
/// </summary>
public static class LicenseInfo
{
/// <summary>
/// Projekt-Slug, wie er in <c>dc_projects</c> angelegt ist. Gilt für alle Module:
/// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe
/// Projekttabelle zu.
///
/// <para>Hier stand bis zur Umstellung <c>clawd</c> — der Name aus dem
/// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt
/// <c>clawddotnet</c>. Der Server beantwortet einen unbekannten Slug mit demselben
/// <c>not_found</c> wie einen unbekannten Schlüssel, weshalb das wie ein falsch
/// eingegebener Lizenzschlüssel aussah.</para>
/// </summary>
public const string ProductSlug = "clawddotnet";
/// <summary>
/// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine
/// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung
/// und teilen sich diese Adresse.
/// </summary>
public const string DefaultServerUrl = "https://dc.mhdf.de";
}
@@ -0,0 +1,110 @@
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Prüft die Lizenz im laufenden Betrieb nach.
///
/// <para>Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die
/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle
/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro
/// Klick.</para>
///
/// <para><b>Nur ein Urteil zählt.</b> Ein Netzproblem, eine Drosselung oder eine
/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als
/// <c>IsTransient</c>, und ein Serverausfall darf nicht alle laufenden Instanzen
/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg.</para>
/// </summary>
public sealed class LicenseWatch : IAsyncDisposable
{
private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12);
private readonly LicenseGate _gate;
private readonly ILogger _logger;
private readonly TimeSpan _interval;
private CancellationTokenSource? _cts;
private Task? _loop;
/// <summary>
/// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber
/// ohne Rückfrage; der übergebene Text erklärt den Grund.
/// </summary>
public event Func<string, Task>? Revoked;
public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null)
{
_gate = gate;
_logger = logger;
_interval = interval ?? DefaultInterval;
}
public void Start()
{
if (_loop is { IsCompleted: false })
return;
_cts = new CancellationTokenSource();
_loop = RunAsync(_cts.Token);
}
private async Task RunAsync(CancellationToken ct)
{
try
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false);
if (result.IsValid)
continue;
if (result.IsTransient)
{
_logger.LogInformation(
"Lizenz-Nachprüfung ohne Ergebnis ({Status}) Betrieb läuft weiter.",
result.Status);
continue;
}
_logger.LogWarning("Lizenz gilt nicht mehr ({Status}) Instanz wird beendet.",
result.Status);
if (Revoked is { } handler)
{
await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). "
+ "ClawdDotNet wird beendet.").ConfigureAwait(false);
}
return;
}
}
catch (OperationCanceledException)
{
// Regulärer Stopp.
}
catch (Exception ex)
{
// Die Nachprüfung darf den Betrieb nicht mitnehmen.
_logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen.");
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null)
return;
await _cts.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
}
}
+154
View File
@@ -0,0 +1,154 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace ClawdDotNet.App.Services;
/// <summary>Eine gelesene Logzeile.</summary>
/// <param name="Module">Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei.</param>
/// <param name="Level">INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat.</param>
public readonly record struct LogLine(string Module, string Level, string Text);
/// <summary>Ab welcher Stufe angezeigt wird.</summary>
public enum LogLevelFilter { All, Info, Warn, Error }
/// <summary>
/// Liest neu hinzugekommene Zeilen aus den Logdateien.
///
/// <para>Die vorige Fassung (<c>LiveLogViewerService</c>) schrieb unmittelbar in eine
/// <c>RichTextBox</c> und taktete über einen <c>System.Windows.Forms.Timer</c> — Lesen
/// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur
/// das Lesen; was damit geschieht, entscheidet der Aufrufer.</para>
///
/// <para>Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das
/// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen.</para>
/// </summary>
public sealed partial class LogTail(string logDirectory)
{
private readonly ConcurrentDictionary<string, long> _positions = new();
[GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)]
private static partial Regex LevelPattern();
/// <summary>Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.</summary>
public IReadOnlyList<string> AvailableModules()
{
var directory = TodayDirectory();
if (directory is null) return [];
return Directory.GetFiles(directory, "*.log")
.Select(Path.GetFileNameWithoutExtension)
.Where(name => !string.IsNullOrEmpty(name))
.Select(name => name!)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// Alles, was seit dem letzten Aufruf dazugekommen ist.
///
/// <paramref name="module"/> leer heißt: alle Module.
/// </summary>
public IReadOnlyList<LogLine> ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All)
{
var directory = TodayDirectory();
if (directory is null) return [];
var result = new List<LogLine>();
foreach (var path in Directory.GetFiles(directory, "*.log"))
{
var moduleName = Path.GetFileNameWithoutExtension(path);
if (!string.IsNullOrEmpty(module)
&& !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase))
{
// Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters
// die gesamte bisherige Datei auf einmal herein.
TrackWithoutReading(path);
continue;
}
ReadFile(path, moduleName, level, result);
}
return result;
}
private void ReadFile(string path, string module, LogLevelFilter level, List<LogLine> into)
{
var lastPosition = _positions.GetOrAdd(path, 0L);
try
{
// FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die
// Datei auch ersetzen können, während wir lesen.
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete);
if (stream.Length < lastPosition)
lastPosition = 0; // rotiert oder gekürzt
if (stream.Length == lastPosition)
return;
stream.Seek(lastPosition, SeekOrigin.Begin);
using var reader = new StreamReader(stream);
while (reader.ReadLine() is { } line)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var lineLevel = ExtractLevel(line);
if (!Passes(lineLevel, level)) continue;
into.Add(new LogLine(module, lineLevel, line));
}
_positions[path] = stream.Position;
}
catch (IOException)
{
// Wird gerade geschrieben — beim nächsten Takt erneut versuchen.
}
}
private void TrackWithoutReading(string path)
{
try { _positions[path] = new FileInfo(path).Length; }
catch (IOException) { }
}
private static string ExtractLevel(string line)
{
var match = LevelPattern().Match(line);
return match.Success ? match.Groups[1].Value.ToUpperInvariant() : "";
}
private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch
{
LogLevelFilter.All => true,
// Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine
// Fehlermeldung, die der Filter verschluckt.
_ when lineLevel.Length == 0 => true,
LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL",
LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL",
LogLevelFilter.Error => lineLevel is "ERR" or "FTL",
_ => true
};
/// <summary>
/// Das Verzeichnis des heutigen Tages, oder <c>null</c>.
///
/// <c>DateTime.Now</c> und nicht UTC: Die Schreibseite legt die Verzeichnisse nach
/// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit
/// <c>TZ=UTC</c> wechselt der Ordner damit um Mitternacht UTC — richtig, aber
/// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht.
/// </summary>
private string? TodayDirectory()
{
if (!Directory.Exists(logDirectory)) return null;
var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
return Directory.Exists(directory) ? directory : null;
}
}
@@ -0,0 +1,284 @@
using System.Collections.Concurrent;
using System.Net.Http.Headers;
using System.Text.Json;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Api.Models;
namespace ClawdDotNet.App.Services;
/// <summary>
/// Fragt regelmäßig Erreichbarkeit und Guthaben der OpenRouter-API ab.
///
/// Der Takt lief früher über einen <c>System.Windows.Forms.Timer</c> — der braucht eine
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/>: läuft
/// auch ohne Oberfläche, was der kopflose Betrieb voraussetzt.
///
/// <see cref="OnStatusUpdated"/> wird auf einem Hintergrundfaden ausgelöst. Wer daran
/// eine Oberfläche hängt, muss selbst auf den Oberflächenfaden wechseln — in Avalonia
/// über <c>Dispatcher.UIThread</c>.
/// </summary>
public sealed class OpenRouterStatusService : IAsyncDisposable
{
private readonly HttpClient _http;
private readonly TimeSpan _interval;
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
/// <summary>
/// Preise kommen vom /models-Endpunkt statt aus einer fest verdrahteten Tabelle.
/// Die alte Tabelle war veraltet und enthielt ausgerechnet das Standardmodell der
/// Agenten nicht — die Anzeige meldete dafür stillschweigend 0 €.
/// </summary>
private readonly ModelPricingCatalog _pricing = new();
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
private const double UsdToEur = 0.92;
public bool IsApiReachable { get; private set; }
public string StatusText { get; private set; } = "Prüfe...";
public string CreditsText { get; private set; } = "—";
public string CreditsTooltip { get; private set; } = "";
public double? CreditBalance { get; private set; }
public double? CreditRemaining { get; private set; }
public event Action? OnStatusUpdated;
public OpenRouterStatusService(string apiKey, string baseUrl = "https://openrouter.ai/api/v1/", int checkIntervalSeconds = 60)
{
_http = new HttpClient { BaseAddress = new Uri(baseUrl) };
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
_http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet");
_interval = TimeSpan.FromSeconds(checkIntervalSeconds);
}
public void Start()
{
_loop ??= RunLoopAsync(_cts.Token);
_ = CheckStatusAsync();
}
private async Task RunLoopAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(ct))
{
// CheckStatusAsync fängt bereits alles ab und setzt IsApiReachable — hier
// muss nichts mehr behandelt werden.
await CheckStatusAsync();
}
}
public void RecordUsage(string model, int promptTokens, int completionTokens)
{
var estimate = _pricing.Estimate(model, promptTokens, completionTokens);
if (!estimate.IsKnown)
_modelsWithoutPricing.TryAdd(model, 0);
_usageRecords.Add(new UsageRecord(
DateTime.Now, model, promptTokens, completionTokens, (double)estimate.Usd, estimate.IsKnown));
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
/// <summary>
/// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und
/// alle Kosten werden als unbekannt ausgewiesen.
/// </summary>
public async Task LoadPricingAsync(OpenRouterClient client, CancellationToken ct = default)
{
try
{
var models = await client.GetAvailableModelsAsync(ct);
_pricing.Load(models);
// Modelle, die bisher als unbekannt galten, sind jetzt vielleicht bekannt.
foreach (var model in _modelsWithoutPricing.Keys)
{
if (_pricing.IsKnown(model))
_modelsWithoutPricing.TryRemove(model, out _);
}
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
catch (Exception)
{
// Ohne Preise bleibt die Anzeige ehrlich unbekannt statt falsch null.
}
}
private async Task CheckStatusAsync()
{
try
{
using var response = await _http.GetAsync("auth/key");
if (response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(body);
IsApiReachable = true;
if (doc.RootElement.TryGetProperty("data", out var data))
{
if (data.TryGetProperty("limit", out var limit))
CreditBalance = limit.GetDouble();
if (data.TryGetProperty("usage", out var usage))
{
var used = usage.GetDouble();
var remaining = (CreditBalance ?? 0) - used;
CreditRemaining = remaining;
StatusText = $"✓ API OK | Credits: ${remaining:F4} von ${CreditBalance:F2}";
}
else
{
StatusText = "✓ API erreichbar";
}
}
else
{
StatusText = "✓ API erreichbar";
}
}
else
{
IsApiReachable = false;
StatusText = $"✗ API Fehler: {(int)response.StatusCode}";
}
}
catch (HttpRequestException ex)
{
IsApiReachable = false;
StatusText = $"✗ Nicht erreichbar: {ex.Message}";
}
catch (TaskCanceledException)
{
IsApiReachable = false;
StatusText = "✗ Timeout";
}
catch (Exception ex)
{
IsApiReachable = false;
StatusText = $"✗ Fehler: {ex.Message}";
}
UpdateCreditsText();
OnStatusUpdated?.Invoke();
}
private void UpdateCreditsText()
{
var now = DateTime.Now;
var oneHourAgo = now.AddHours(-1);
var oneDayAgo = now.AddHours(-24);
var records = _usageRecords.ToArray();
var lastHour = records.Where(r => r.Timestamp >= oneHourAgo).ToArray();
var last24h = records.Where(r => r.Timestamp >= oneDayAgo).ToArray();
var tokensLastHour = lastHour.Sum(r => r.PromptTokens + r.CompletionTokens);
var tokensLast24h = last24h.Sum(r => r.PromptTokens + r.CompletionTokens);
var costLastHour = lastHour.Sum(r => r.CostUsd);
var costLast24h = last24h.Sum(r => r.CostUsd);
// Ein Hinweis, sobald Läufe dabei sind, deren Kosten nicht bezifferbar sind —
// sonst liest sich eine zu niedrige Summe wie eine vollständige.
var unpriced = last24h.Count(r => !r.CostIsKnown);
var warning = unpriced > 0 ? " ⚠" : "";
CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " +
$"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€){warning}";
// Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h)
var modelGroups = last24h
.GroupBy(r => r.Model)
.OrderByDescending(g => g.Sum(r => r.CostUsd))
.ToList();
if (modelGroups.Count == 0)
{
CreditsTooltip = "Keine Token-Nutzung in den letzten 24h";
return;
}
var sb = new System.Text.StringBuilder();
sb.AppendLine("═══ Token-Verbrauch (24h) ═══");
sb.AppendLine();
foreach (var group in modelGroups)
{
var modelName = group.Key;
var shortName = modelName.Contains('/') ? modelName[(modelName.IndexOf('/') + 1)..] : modelName;
var prompt = group.Sum(r => r.PromptTokens);
var completion = group.Sum(r => r.CompletionTokens);
var total = prompt + completion;
var cost = group.Sum(r => r.CostUsd);
var runs = group.Count();
sb.AppendLine($"▸ {shortName}");
sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)");
if (_pricing.Get(modelName) is { } pricing)
{
sb.AppendLine($" Preis: ${pricing.InputPer1M:0.####}/1M in, ${pricing.OutputPer1M:0.####}/1M out");
sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)");
}
else
{
sb.AppendLine(" Kosten: unbekannt — für dieses Modell liegen keine Preise vor");
}
sb.AppendLine();
}
var totalCost = last24h.Sum(r => r.CostUsd);
sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══");
if (unpriced > 0)
{
sb.AppendLine();
sb.AppendLine($"⚠ {unpriced} Lauf/Läufe ohne Preisangabe — die Summe ist unvollständig.");
sb.AppendLine($" Betroffene Modelle: {string.Join(", ", _modelsWithoutPricing.Keys.Order())}");
}
if (_pricing.LastUpdated is { } updated)
sb.AppendLine($"\nPreise abgerufen: {updated:g} ({_pricing.Count} Modelle)");
else
sb.AppendLine("\n⚠ Preise noch nicht geladen.");
CreditsTooltip = sb.ToString().TrimEnd();
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
if (_loop is not null)
{
try { await _loop; }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
_http.Dispose();
}
private sealed record UsageRecord(
DateTime Timestamp,
string Model,
int PromptTokens,
int CompletionTokens,
double CostUsd,
bool CostIsKnown);
}
+153
View File
@@ -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);
+110
View File
@@ -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;
}
+4 -5
View File
@@ -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();
}
}
+234 -31
View File
@@ -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
{
+1
View File
@@ -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);
}
+114 -33
View File
@@ -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;
}
+119
View File
@@ -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] + "…";
}
+123
View File
@@ -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);
}
+5 -1
View File
@@ -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();
+101
View File
@@ -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:&lt;agent&gt;</c> → <see cref="AgentEngine.RunAsync"/>: frischer
/// Lauf ohne Historie.
/// - <c>@&lt;agent&gt;</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:&lt;agent&gt;</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();
}
+233
View File
@@ -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>@&lt;agentId&gt;</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>@&lt;agentId&gt;</c> (bestehender Kontext), <c>@new</c> bzw.
/// <c>@new:&lt;agentId&gt;</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>@&lt;agent&gt;</c> der Teil hinter dem <c>@</c>, für
/// <c>@new:&lt;agent&gt;</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;
}
+262
View File
@@ -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();
}
}
+205
View File
@@ -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;
}
+70
View File
@@ -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
);
+17
View File
@@ -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>
+213
View File
@@ -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.");
}
+33
View File
@@ -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>
+40
View File
@@ -0,0 +1,40 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop;
/// <summary>
/// Findet zu einem Ansichtsmodell die passende Ansicht über die Namenskonvention
/// <c>…ViewModels.FooViewModel</c> → <c>…Views.FooView</c>.
///
/// Das erspart es, jede Zuordnung von Hand als <c>DataTemplate</c> einzutragen — bei
/// den sieben Bereichen und ihren Unteransichten wären das schnell dreißig Einträge,
/// die man beim Umbenennen übersieht.
///
/// Passt nichts, erscheint der gesuchte Typname im Fenster statt einer leeren Fläche.
/// Beim schrittweisen Umbau ist eine sichtbare Fehlstelle mehr wert als ein stiller
/// weißer Bereich.
/// </summary>
public sealed class ViewLocator : IDataTemplate
{
public Control Build(object? data)
{
if (data is null)
return new TextBlock { Text = "(kein Inhalt)" };
var viewModelName = data.GetType().FullName!;
var viewName = viewModelName
.Replace(".ViewModels.", ".Views.", StringComparison.Ordinal)
.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(viewName);
if (type is null)
return new TextBlock { Text = $"Ansicht nicht gefunden: {viewName}" };
return (Control)Activator.CreateInstance(type)!;
}
public bool Match(object? data) => data is ViewModelBase;
}
@@ -0,0 +1,76 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AddAgentResultData(
string AgentId,
string DisplayName,
string Model,
string Description
);
public sealed partial class AddAgentViewModel : ViewModelBase
{
[ObservableProperty]
private string _agentId = "";
[ObservableProperty]
private string _displayName = "";
[ObservableProperty]
private string _selectedModel = "anthropic/claude-3-5-sonnet";
[ObservableProperty]
private string _description = "";
[ObservableProperty]
private string _statusMessage = "";
public List<string> AvailableModels { get; } =
[
"anthropic/claude-3-5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro",
"deepseek/deepseek-r1"
];
public AddAgentResultData? Result { get; private set; }
public event Action? CloseRequested;
[RelayCommand]
private void Confirm()
{
if (string.IsNullOrWhiteSpace(AgentId))
{
StatusMessage = "Bitte eine Agenten-ID eingeben.";
return;
}
if (string.IsNullOrWhiteSpace(DisplayName))
{
StatusMessage = "Bitte einen Anzeigenamen eingeben.";
return;
}
Result = new AddAgentResultData(
AgentId.Trim(),
DisplayName.Trim(),
SelectedModel,
Description.Trim()
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,111 @@
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AddJobResultData(
string AgentId,
bool IsToolJob,
string ToolName,
string JobTypeId,
string CronExpression,
string TaskMessage,
bool RunOnStart
);
public sealed partial class AddJobViewModel : ViewModelBase
{
public List<AgentConfig> AvailableAgents { get; }
[ObservableProperty]
private AgentConfig? _selectedAgent;
[ObservableProperty]
private bool _isToolJob;
[ObservableProperty]
private bool _isAgentWakeup = true;
[ObservableProperty]
private string _selectedToolName = "FileRW";
[ObservableProperty]
private string _jobTypeId = "PollJob";
[ObservableProperty]
private string _cronExpression = "0 * * * *";
[ObservableProperty]
private string _taskMessage = "Führe deine zugewiesenen Aufgaben aus.";
[ObservableProperty]
private bool _runOnStart;
[ObservableProperty]
private string _statusMessage = "";
public List<string> AvailableTools { get; } =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public AddJobResultData? Result { get; private set; }
public event Action? CloseRequested;
public AddJobViewModel() : this([]) { }
public AddJobViewModel(List<AgentConfig> agents)
{
AvailableAgents = agents;
SelectedAgent = AvailableAgents.FirstOrDefault();
}
partial void OnIsToolJobChanged(bool value)
{
IsAgentWakeup = !value;
}
partial void OnIsAgentWakeupChanged(bool value)
{
IsToolJob = !value;
}
[RelayCommand]
private void Confirm()
{
if (SelectedAgent is null)
{
StatusMessage = "Bitte einen Agenten auswählen.";
return;
}
if (string.IsNullOrWhiteSpace(CronExpression))
{
StatusMessage = "Bitte einen Cron-Ausdruck angeben.";
return;
}
Result = new AddJobResultData(
SelectedAgent.AgentId,
IsToolJob,
SelectedToolName,
JobTypeId,
CronExpression.Trim(),
TaskMessage.Trim(),
RunOnStart
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,67 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AddServiceResultData(
string ServiceName,
string ServiceType,
int ServicePort,
string ServiceDescription
);
public sealed partial class AddServiceViewModel : ViewModelBase
{
[ObservableProperty]
private string _serviceName = "";
[ObservableProperty]
private string _serviceType = "HTTP";
[ObservableProperty]
private int _servicePort = 8080;
[ObservableProperty]
private string _serviceDescription = "";
[ObservableProperty]
private string _statusMessage = "";
public List<string> AvailableServiceTypes { get; } = ["HTTP", "TCP", "gRPC", "WebSocket", "Custom"];
public AddServiceResultData? Result { get; private set; }
public event Action? CloseRequested;
[RelayCommand]
private void Confirm()
{
if (string.IsNullOrWhiteSpace(ServiceName))
{
StatusMessage = "Bitte einen Dienstnamen eingeben.";
return;
}
if (ServicePort <= 0 || ServicePort > 65535)
{
StatusMessage = "Gültigen Port zwischen 1 und 65535 eingeben.";
return;
}
Result = new AddServiceResultData(
ServiceName.Trim(),
ServiceType,
ServicePort,
ServiceDescription.Trim()
);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,280 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed partial class ToolItemViewModel : ObservableObject
{
public string Name { get; }
[ObservableProperty]
private bool _isEnabled;
public ToolItemViewModel(string name, bool isEnabled)
{
Name = name;
_isEnabled = isEnabled;
}
}
public sealed partial class AgentItemViewModel : ObservableObject
{
public AgentConfig Config { get; }
public string AgentId => Config.AgentId;
[ObservableProperty]
private string _displayName;
[ObservableProperty]
private string _model;
[ObservableProperty]
private string _systemPrompt;
[ObservableProperty]
private string _identity;
[ObservableProperty]
private string _soul;
[ObservableProperty]
private string _description;
[ObservableProperty]
private string _promptCaching;
[ObservableProperty]
private int _maxToolResultChars;
[ObservableProperty]
private int _maxSteps;
[ObservableProperty]
private int _maxContextTokens;
[ObservableProperty]
private int _timeoutSeconds;
[ObservableProperty]
private decimal _dailyCostUsd;
[ObservableProperty]
private long _dailyTokens;
public ObservableCollection<ToolItemViewModel> AvailableTools { get; } = [];
public static readonly List<string> KnownTools =
[
"FileRW", "Telegram", "Mail", "Database", "FTP",
"DirectAPI", "WebFetch", "WebMonitor", "AgentComm",
"SocialMediaManager", "AgentSpawn", "AgentEditor", "Memory", "Taskboard"
];
public AgentItemViewModel(AgentConfig config)
{
Config = config;
_displayName = config.DisplayName;
_model = config.Model;
_systemPrompt = config.SystemPrompt;
_identity = config.Identity;
_soul = config.Soul;
_description = config.Description;
_promptCaching = config.PromptCaching;
_maxToolResultChars = config.MaxToolResultChars;
_maxSteps = config.LoopGuard.MaxSteps;
_maxContextTokens = config.LoopGuard.MaxContextTokens;
_timeoutSeconds = config.LoopGuard.TimeoutSeconds;
_dailyCostUsd = config.Budget.DailyCostUsd;
_dailyTokens = config.Budget.DailyTokens;
foreach (var tool in KnownTools)
{
var isEnabled = config.Tools.ContainsKey(tool);
var item = new ToolItemViewModel(tool, isEnabled);
item.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(ToolItemViewModel.IsEnabled))
{
if (item.IsEnabled && !Config.Tools.ContainsKey(tool))
Config.Tools[tool] = new Dictionary<string, object?>();
else if (!item.IsEnabled)
Config.Tools.Remove(tool);
}
};
AvailableTools.Add(item);
}
}
public void ApplyChanges()
{
Config.DisplayName = DisplayName;
Config.Model = Model;
Config.SystemPrompt = SystemPrompt;
Config.Identity = Identity;
Config.Soul = Soul;
Config.Description = Description;
Config.PromptCaching = PromptCaching;
Config.MaxToolResultChars = MaxToolResultChars;
Config.LoopGuard.MaxSteps = MaxSteps;
Config.LoopGuard.MaxContextTokens = MaxContextTokens;
Config.LoopGuard.TimeoutSeconds = TimeoutSeconds;
Config.Budget.DailyCostUsd = DailyCostUsd;
Config.Budget.DailyTokens = DailyTokens;
}
}
/// <summary>
/// Ansichtsmodell für die Agenten-Verwaltungsseite.
/// </summary>
public sealed partial class AgentsPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
public ObservableCollection<AgentItemViewModel> Agents { get; } = [];
[ObservableProperty]
private AgentItemViewModel? _selectedAgent;
[ObservableProperty]
private string _statusText = "Bereit";
public List<string> KnownModels { get; } =
[
"anthropic/claude-3-5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro",
"deepseek/deepseek-r1"
];
public List<string> CachingOptions { get; } = ["auto", "on", "off"];
public event Func<AddAgentViewModel, Task<AddAgentResultData?>>? RequestAddAgentDialog;
public event Func<ToolSettingsViewModel, Task<Dictionary<string, object?>?>>? RequestToolSettingsDialog;
public AgentsPageViewModel() : this(null) { }
public AgentsPageViewModel(AppHost? host) : base("Agenten")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Agents");
ReloadAgents();
}
else
{
StatusText = "Entwurfsmodus";
}
}
[RelayCommand]
private void ReloadAgents()
{
Agents.Clear();
if (_host is null) return;
foreach (var agent in _host.Instance.Agents)
{
Agents.Add(new AgentItemViewModel(agent));
}
SelectedAgent = Agents.FirstOrDefault();
}
[RelayCommand]
private async Task AddAgentAsync()
{
if (_host is null || RequestAddAgentDialog is null) return;
var vm = new AddAgentViewModel();
var result = await RequestAddAgentDialog(vm);
if (result is null) return;
if (_host.Instance.Agents.Any(a => a.AgentId.Equals(result.AgentId, StringComparison.OrdinalIgnoreCase)))
{
StatusText = $"Ein Agent mit der ID '{result.AgentId}' existiert bereits.";
return;
}
var newConfig = new AgentConfig
{
AgentId = result.AgentId,
DisplayName = result.DisplayName,
Model = result.Model,
Description = result.Description
};
_host.Instance.Agents.Add(newConfig);
SaveAgents();
var item = new AgentItemViewModel(newConfig);
Agents.Add(item);
SelectedAgent = item;
StatusText = $"Agent '{result.DisplayName}' angelegt.";
}
[RelayCommand]
private void RemoveAgent()
{
if (_host is null || SelectedAgent is null) return;
var agentId = SelectedAgent.AgentId;
_host.Instance.Agents.RemoveAll(a => a.AgentId == agentId);
SaveAgents();
Agents.Remove(SelectedAgent);
SelectedAgent = Agents.FirstOrDefault();
StatusText = $"Agent '{agentId}' entfernt.";
}
[RelayCommand]
private async Task ConfigureToolAsync(ToolItemViewModel? toolItem)
{
if (SelectedAgent is null || toolItem is null || RequestToolSettingsDialog is null) return;
var toolName = toolItem.Name;
if (!SelectedAgent.Config.Tools.TryGetValue(toolName, out var config))
{
config = new Dictionary<string, object?>();
SelectedAgent.Config.Tools[toolName] = config;
toolItem.IsEnabled = true;
}
var vm = new ToolSettingsViewModel(toolName, config);
var newConfig = await RequestToolSettingsDialog(vm);
if (newConfig is not null)
{
SelectedAgent.Config.Tools[toolName] = newConfig;
SaveAgents();
StatusText = $"Einstellungen für Werkzeug '{toolName}' gespeichert.";
}
}
[RelayCommand]
private void SaveAgents()
{
if (_host is null) return;
foreach (var agentVm in Agents)
{
agentVm.ApplyChanges();
}
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
_logger?.LogInformation("Agenten-Konfiguration gespeichert.");
StatusText = "Agenten-Konfiguration gespeichert.";
}
}
@@ -0,0 +1,397 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Backup;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record BackupItemViewModel(
string FileName,
DateTime LastWriteTime,
string InstanceName,
long SizeBytes,
string SizeFormatted,
bool HasSecrets,
string SecretsInfo,
string FullPath,
bool IsReadable
);
/// <summary>
/// Ansichtsmodell für die Sicherungs- und Wiederherstellungsseite.
/// </summary>
public sealed partial class BackupPageViewModel : PageViewModel
{
private readonly BackupService _service = new();
private readonly AppHost? _host;
private readonly SettingsManager? _settingsManager;
private readonly ILogger? _logger;
private readonly string _instanceDir;
private readonly string _instanceName;
private bool _loading;
[ObservableProperty]
private string _targetFolder = "";
[ObservableProperty]
private bool _secretsExclude = true;
[ObservableProperty]
private bool _secretsPassphrase;
[ObservableProperty]
private string _passphrase = "";
[ObservableProperty]
private string _passphraseRepeat = "";
[ObservableProperty]
private bool _includeLogs;
[ObservableProperty]
private bool _includeChatHistory = true;
[ObservableProperty]
private bool _autoBackupEnabled;
[ObservableProperty]
private string _autoBackupTime = "03:00";
[ObservableProperty]
private int _keepCount = 10;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isBusy;
[ObservableProperty]
private BackupItemViewModel? _selectedBackup;
public ObservableCollection<BackupItemViewModel> Backups { get; } = [];
public event Func<RestoreBackupViewModel, Task<RestoreResultData?>>? RequestRestoreDialog;
public BackupPageViewModel() : this(null) { }
public BackupPageViewModel(AppHost? host) : base("Sicherung")
{
_host = host;
if (host is not null)
{
_instanceDir = host.InstancePath;
_instanceName = host.Instance.InstanceName;
_settingsManager = host.Settings;
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Backup");
LoadSettings();
RefreshBackupList();
}
else
{
_instanceDir = "";
_instanceName = "Entwurf";
TargetFolder = @"C:\ClawdDotNet\Backups";
}
}
partial void OnAutoBackupEnabledChanged(bool value) => SaveSettings();
partial void OnAutoBackupTimeChanged(string value) => SaveSettings();
partial void OnKeepCountChanged(int value) => SaveSettings();
private void LoadSettings()
{
if (_settingsManager is null) return;
_loading = true;
try
{
var settings = _settingsManager.AppSettings;
TargetFolder = Path.GetFullPath(settings.BackupDirectory);
AutoBackupEnabled = settings.AutoBackupEnabled;
KeepCount = Math.Clamp(settings.BackupKeepCount, 0, 999);
AutoBackupTime = TimeSpan.TryParse(settings.AutoBackupTime, out var time)
? time.ToString(@"hh\:mm")
: "03:00";
}
finally
{
_loading = false;
}
}
public void SaveSettings()
{
if (_loading || _settingsManager is null) return;
var settings = _settingsManager.AppSettings;
settings.BackupDirectory = TargetFolder.Trim();
settings.AutoBackupEnabled = AutoBackupEnabled;
settings.AutoBackupTime = AutoBackupTime.Trim();
settings.BackupKeepCount = KeepCount;
_settingsManager.Save();
}
[RelayCommand]
private void RefreshBackupList()
{
Backups.Clear();
var folder = TargetFolder.Trim();
if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder))
return;
Task.Run(async () =>
{
var files = new DirectoryInfo(folder)
.GetFiles("*.zip")
.OrderByDescending(f => f.LastWriteTime)
.ToList();
var list = new List<BackupItemViewModel>();
foreach (var file in files)
{
try
{
var manifest = await _service.InspectAsync(file.FullName);
var secretsInfo = manifest.HasSecrets
? $"ja ({manifest.SecretCount}, Passphrase)"
: "nein";
list.Add(new BackupItemViewModel(
file.Name, file.LastWriteTime, manifest.InstanceName,
file.Length, FormatSize(file.Length), manifest.HasSecrets, secretsInfo,
file.FullName, true));
}
catch
{
list.Add(new BackupItemViewModel(
file.Name, file.LastWriteTime, "—",
file.Length, FormatSize(file.Length), false, "unlesbar",
file.FullName, false));
}
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
Backups.Clear();
foreach (var item in list)
Backups.Add(item);
});
});
}
[RelayCommand]
private async Task CreateBackupAsync()
{
if (string.IsNullOrWhiteSpace(_instanceDir))
{
StatusText = "Keine gültige Instanz geladen.";
return;
}
var folder = TargetFolder.Trim();
if (string.IsNullOrWhiteSpace(folder))
{
StatusText = "Bitte einen Zielordner angeben.";
return;
}
BackupOptions options;
if (SecretsPassphrase)
{
if (string.IsNullOrEmpty(Passphrase))
{
StatusText = "Bitte eine Passphrase eingeben.";
return;
}
if (Passphrase != PassphraseRepeat)
{
StatusText = "Die Passphrasen stimmen nicht überein.";
return;
}
options = new BackupOptions
{
Secrets = SecretMode.Passphrase,
Passphrase = Passphrase,
IncludeLogs = IncludeLogs,
IncludeChatHistory = IncludeChatHistory
};
}
else
{
options = new BackupOptions
{
Secrets = SecretMode.Exclude,
IncludeLogs = IncludeLogs,
IncludeChatHistory = IncludeChatHistory
};
}
Directory.CreateDirectory(folder);
var fileName = BuildFileName();
var zipPath = Path.Combine(folder, fileName);
IsBusy = true;
StatusText = "Sicherung läuft…";
try
{
var result = await Task.Run(() => _service.CreateAsync(_instanceDir, zipPath, options));
StatusText = $"Fertig: {Path.GetFileName(result.ZipPath)} ({FormatSize(result.SizeBytes)})";
_logger?.LogInformation("Sicherung erstellt: {Path} ({Size} Bytes)", result.ZipPath, result.SizeBytes);
Passphrase = "";
PassphraseRepeat = "";
ApplyRotation(folder);
RefreshBackupList();
}
catch (Exception ex)
{
StatusText = $"Fehler: {ex.Message}";
_logger?.LogError(ex, "Sicherung fehlgeschlagen");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private void ShowSelectedInFolder()
{
if (SelectedBackup is null) return;
SystemShell.RevealFile(SelectedBackup.FullPath);
}
[RelayCommand]
private void DeleteSelected()
{
if (SelectedBackup is null) return;
try
{
File.Delete(SelectedBackup.FullPath);
RefreshBackupList();
StatusText = $"Gelöscht: {SelectedBackup.FileName}";
}
catch (Exception ex)
{
StatusText = $"Konnte nicht gelöscht werden: {ex.Message}";
}
}
[RelayCommand]
private async Task RestoreSelectedAsync()
{
if (SelectedBackup is null || RequestRestoreDialog is null) return;
BackupManifest manifest;
try
{
manifest = await _service.InspectAsync(SelectedBackup.FullPath);
}
catch (Exception ex)
{
StatusText = $"Sicherung unlesbar: {ex.Message}";
return;
}
var suggestedTarget = SuggestRestoreTarget(manifest);
var vm = new RestoreBackupViewModel(manifest, suggestedTarget);
var resultData = await RequestRestoreDialog(vm);
if (resultData is null) return;
IsBusy = true;
StatusText = "Wiederherstellung läuft…";
try
{
var restoreOptions = new RestoreOptions
{
Passphrase = resultData.Passphrase,
Overwrite = resultData.Overwrite
};
var result = await Task.Run(() => _service.RestoreAsync(
SelectedBackup.FullPath, resultData.TargetDirectory, restoreOptions));
StatusText = $"{result.Written.Count} Datei(en) wiederhergestellt nach {resultData.TargetDirectory}";
_logger?.LogInformation("Wiederherstellung: {Written} geschrieben nach {Target}",
result.Written.Count, resultData.TargetDirectory);
}
catch (Exception ex)
{
StatusText = $"Wiederherstellung fehlgeschlagen: {ex.Message}";
_logger?.LogError(ex, "Wiederherstellung fehlgeschlagen");
}
finally
{
IsBusy = false;
}
}
private string BuildFileName()
{
var name = string.IsNullOrWhiteSpace(_instanceName) ? "Instanz" : _instanceName;
var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
return $"backup_{safeName}_{DateTime.Now:yyyy-MM-dd_HHmm}.zip";
}
private string SuggestRestoreTarget(BackupManifest manifest)
{
var parent = Directory.GetParent(_instanceDir)?.FullName ?? _instanceDir;
var name = string.IsNullOrWhiteSpace(manifest.InstanceName) ? "Instanz" : manifest.InstanceName;
var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
return Path.Combine(parent, $"Instance-{safeName}_wiederhergestellt_{DateTime.Now:yyyyMMdd_HHmm}");
}
private void ApplyRotation(string folder)
{
if (KeepCount <= 0 || !Directory.Exists(folder)) return;
try
{
var prefix = $"backup_{_instanceName}_";
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 rotiert: {Name}", file.Name);
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Rotation der Sicherungen fehlgeschlagen");
}
}
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:F1} KB",
< 1024L * 1024 * 1024 => $"{bytes / 1024.0 / 1024:F1} MB",
_ => $"{bytes / 1024.0 / 1024 / 1024:F2} GB"
};
}
@@ -0,0 +1,158 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record ChatEntryItemViewModel(
string Role,
string Sender,
string Text,
DateTime Timestamp,
bool IsUser
);
/// <summary>
/// Ansichtsmodell für die native Chat-Seite (Avalonia UI statt WebView2).
/// </summary>
public sealed partial class ChatPageViewModel : PageViewModel
{
private readonly AppHost? _host;
public ObservableCollection<AgentConfig> AvailableAgents { get; } = [];
public ObservableCollection<ChatEntryItemViewModel> ChatEntries { get; } = [];
[ObservableProperty]
private AgentConfig? _selectedAgent;
[ObservableProperty]
private string _inputPrompt = "";
[ObservableProperty]
private bool _isSending;
[ObservableProperty]
private string _statusText = "Bereit";
public ChatPageViewModel() : this(null) { }
public ChatPageViewModel(AppHost? host) : base("Chat")
{
_host = host;
if (host is not null)
{
foreach (var agent in host.Instance.Agents)
AvailableAgents.Add(agent);
SelectedAgent = AvailableAgents.FirstOrDefault();
if (host.Engine is not null)
{
host.Engine.OnChatEntryAdded += OnEngineChatEntryAdded;
}
}
else
{
StatusText = "Entwurfsmodus";
}
}
partial void OnSelectedAgentChanged(AgentConfig? value)
{
LoadChatHistory();
}
private void LoadChatHistory()
{
ChatEntries.Clear();
if (_host?.Engine is null || SelectedAgent is null) return;
var history = _host.Engine.GetChatHistory(SelectedAgent.AgentId);
foreach (var entry in history)
{
var isUser = entry.Role.Equals("user", StringComparison.OrdinalIgnoreCase);
ChatEntries.Add(new ChatEntryItemViewModel(
entry.Role,
isUser ? "Du" : SelectedAgent.DisplayName,
entry.Content,
entry.Timestamp,
isUser
));
}
StatusText = $"Chat-Verlauf für '{SelectedAgent.DisplayName}' geladen ({ChatEntries.Count} Nachrichten).";
}
private void OnEngineChatEntryAdded(string agentId, string role, string text, string? toolName)
{
if (SelectedAgent is null || SelectedAgent.AgentId != agentId) return;
Dispatcher.UIThread.Post(() =>
{
var isUser = role.Equals("user", StringComparison.OrdinalIgnoreCase);
var sender = isUser ? "Du" : (string.IsNullOrWhiteSpace(toolName) ? SelectedAgent.DisplayName : $"Tool: {toolName}");
ChatEntries.Add(new ChatEntryItemViewModel(
role, sender, text, DateTime.Now, isUser
));
});
}
[RelayCommand]
private async Task SendMessageAsync()
{
if (_host?.Engine is null || SelectedAgent is null)
{
StatusText = "Engine nicht aktiv oder kein Agent ausgewählt.";
return;
}
var text = InputPrompt.Trim();
if (string.IsNullOrWhiteSpace(text)) return;
InputPrompt = "";
IsSending = true;
StatusText = $"{SelectedAgent.DisplayName} denkt nach…";
try
{
var agent = SelectedAgent;
var instanceId = _host.Instance.InstanceId;
var result = await Task.Run(() =>
_host.Engine.ChatAsync(agent, text, instanceId, CancellationToken.None));
StatusText = $"{agent.DisplayName} fertig ({result.Status}, {result.TokensUsed:N0} Tokens).";
}
catch (Exception ex)
{
StatusText = $"Fehler: {ex.Message}";
}
finally
{
IsSending = false;
}
}
[RelayCommand]
private void Abort()
{
if (_host?.Engine is null || SelectedAgent is null) return;
_host.Engine.AbortChat(SelectedAgent.AgentId);
StatusText = "Chat abgebrochen.";
IsSending = false;
}
[RelayCommand]
private void ClearHistory()
{
if (_host?.Engine is null || SelectedAgent is null) return;
_host.Engine.ClearChatHistory(SelectedAgent.AgentId);
ChatEntries.Clear();
StatusText = "Verlauf geleert.";
}
}
@@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using System.Reflection;
using ClawdDotNet.App;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record AssemblyInfoItem(string ShortName, int BuildNumber, string BuildDate, string Changes);
/// <summary>
/// Ansichtsmodell für die Info-Seite (Version, Build-Informationen, Instanz-Details).
/// </summary>
public sealed partial class InfoPageViewModel : PageViewModel
{
[ObservableProperty]
private string _appVersion = "—";
[ObservableProperty]
private string _buildSummary = "—";
[ObservableProperty]
private string _instanceName = "—";
[ObservableProperty]
private string _instanceId = "—";
[ObservableProperty]
private string _instancePath = "—";
[ObservableProperty]
private string _logDirectory = "—";
[ObservableProperty]
private string _openRouterStatus = "—";
public ObservableCollection<AssemblyInfoItem> Assemblies { get; } = [];
public InfoPageViewModel() : this(null) { }
public InfoPageViewModel(AppHost? host) : base("Info")
{
AppVersion = AppHost.AppVersion;
BuildSummary = AppHost.BuildSummary;
if (host is not null)
{
InstanceName = host.Instance.InstanceName;
InstanceId = host.Instance.InstanceId;
InstancePath = host.InstancePath;
LogDirectory = host.LogDirectory;
OpenRouterStatus = host.Engine is null
? "Deaktiviert (kein API-Key hinterlegt)"
: "Aktiv (AgentEngine bereit)";
}
else
{
InstanceName = "Entwurfsmodus";
InstanceId = "00000000-0000-0000-0000-000000000000";
InstancePath = @"C:\ClawdDotNet\Instances\Default";
LogDirectory = @"C:\ClawdDotNet\Logs";
OpenRouterStatus = "Entwurf";
}
LoadAssemblyBuildInfos();
}
private void LoadAssemblyBuildInfos()
{
Assemblies.Clear();
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name?.StartsWith("ClawdDotNet") == true)
.OrderBy(a => a.GetName().Name))
{
var name = asm.GetName().Name!;
var shortName = name.Replace("ClawdDotNet.", "").Replace("Tools.", "");
var buildDate = asm.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false)
.OfType<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == "BuildDate")?.Value ?? "?";
var buildInfoType = asm.GetType(asm.GetName().Name + ".BuildInfo");
var buildNum = buildInfoType?.GetField("Build")?.GetValue(null) as int? ?? 0;
var changes = buildInfoType?.GetField("Changes")?.GetValue(null) as string ?? "—";
Assemblies.Add(new AssemblyInfoItem(shortName, buildNum, buildDate, changes));
}
}
}
@@ -0,0 +1,103 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Auswahl der Instanz beim Start — der erste Rückruf, den <c>AppHost</c> erfragt.
///
/// Die WinForms-Fassung mischte Datenzugriff, Spaltenformatierung und Meldungsfenster in
/// einem Formular. Hier steht nur, <em>was</em> geschieht; das <em>wie</em> es aussieht
/// liegt in der Ansicht, und Rückfragen laufen über <see cref="ConfirmError"/> statt über
/// einen direkten Dialogaufruf — so bleibt das Ansichtsmodell ohne Fensterbezug prüfbar.
/// </summary>
public sealed partial class InstancePickerViewModel : ViewModelBase
{
private readonly InstanceDirectoryManager _directories;
private readonly TaskCompletionSource<string?> _completion = new();
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartCommand))]
private InstanceInfo? _selected;
[ObservableProperty]
private string _newInstanceName = "";
/// <summary>Fehlertext für die Ansicht; leer heißt: alles in Ordnung.</summary>
[ObservableProperty]
private string _errorText = "";
public ObservableCollection<InstanceInfo> Instances { get; } = [];
/// <summary>Der gewählte Pfad, oder <c>null</c> bei Abbruch.</summary>
public Task<string?> Result => _completion.Task;
public string InstancesDirectory => _directories.InstancesDirectory;
public InstancePickerViewModel(InstanceDirectoryManager directories)
{
_directories = directories;
Refresh();
}
[RelayCommand]
private void Refresh()
{
Instances.Clear();
foreach (var instance in _directories.ListInstances())
Instances.Add(instance);
// Eine einzelne Instanz gleich vorwählen — der häufigste Fall, und ein Klick
// weniger bei jedem Start.
Selected ??= Instances.FirstOrDefault();
}
private bool CanStart => Selected is not null;
[RelayCommand(CanExecute = nameof(CanStart))]
private void Start() => _completion.TrySetResult(Selected!.FolderPath);
/// <summary>Abbruch — der Aufrufer beendet die Anwendung.</summary>
[RelayCommand]
private void Cancel() => _completion.TrySetResult(null);
[RelayCommand]
private void Create()
{
var name = NewInstanceName.Trim();
if (string.IsNullOrWhiteSpace(name))
{
ErrorText = "Bitte einen Instanznamen eingeben.";
return;
}
try
{
_directories.CreateInstance(name);
NewInstanceName = "";
ErrorText = "";
Refresh();
// Die frisch angelegte Instanz vorwählen — wer sie erstellt, will sie starten.
Selected = Instances.FirstOrDefault(i =>
string.Equals(i.InstanceName, name, StringComparison.Ordinal));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
or ArgumentException)
{
ErrorText = $"Instanz konnte nicht angelegt werden: {ex.Message}";
}
}
/// <summary>
/// Wird gesetzt, wenn ein Fehler von außen kommt (etwa eine unlesbare
/// Instanzkonfiguration), damit das Fenster offen bleiben und eine andere Wahl
/// zulassen kann.
/// </summary>
public void ConfirmError(string message) => ErrorText = message;
}
@@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>Eingabe eines Lizenzschlüssels. Siehe <c>AvaloniaLicensePrompt</c>.</summary>
public sealed partial class LicenseViewModel : ViewModelBase
{
private readonly TaskCompletionSource<string?> _completion = new();
[ObservableProperty]
private string _key;
public string HardwareId { get; }
/// <summary>Warum der bisherige Schlüssel nicht taugt; leer beim ersten Fragen.</summary>
public string Problem { get; }
/// <summary>Der eingegebene Schlüssel, oder <c>null</c> bei Abbruch.</summary>
public Task<string?> Result => _completion.Task;
public LicenseViewModel(string hardwareId, string? problem, string? currentKey)
{
HardwareId = hardwareId;
Problem = problem ?? "";
_key = currentKey ?? "";
}
[RelayCommand]
private void Confirm()
{
var value = Key.Trim();
// Leere Eingabe ist kein Abbruch — sonst schließt sich das Fenster, wenn jemand
// versehentlich die Eingabetaste drückt.
if (value.Length == 0) return;
_completion.TrySetResult(value);
}
[RelayCommand]
private void Cancel() => _completion.TrySetResult(null);
/// <summary>Schließt der Benutzer das Fenster, gilt das als Abbruch.</summary>
public void CancelIfPending() => _completion.TrySetResult(null);
}
@@ -0,0 +1,112 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Live-Ansicht der Logdateien.
///
/// Der Takt läuft über <see cref="DispatcherTimer"/> — das Avalonia-Gegenstück zum
/// <c>System.Windows.Forms.Timer</c>, also auf dem Oberflächenfaden. Für eine Ansicht
/// ist das genau richtig: Das Lesen selbst dauert Millisekunden, und so entfällt jedes
/// Marshalling. Alles, was ohne Fenster laufen muss, liegt dagegen in
/// <see cref="LogTail"/> und taktet dort über <c>PeriodicTimer</c>.
/// </summary>
public sealed partial class LogPageViewModel : PageViewModel
{
/// <summary>
/// Obergrenze der angezeigten Zeilen.
///
/// Ohne Deckel wächst die Liste unbegrenzt — ein Agent, der eine Nacht durchläuft,
/// hinterlässt sonst Hunderttausende Einträge im Speicher, und das Scrollen wird zäh.
/// </summary>
private const int MaxLines = 2000;
private readonly LogTail? _tail;
private readonly DispatcherTimer? _timer;
private readonly string _logDirectory;
public ObservableCollection<LogLine> Lines { get; } = [];
public ObservableCollection<string> Modules { get; } = ["Alle"];
public IReadOnlyList<string> Levels { get; } = ["Alle", "Info", "Warn", "Error"];
[ObservableProperty]
private string _selectedModule = "Alle";
[ObservableProperty]
private string _selectedLevel = "Alle";
/// <summary>
/// Hängt die Ansicht am unteren Ende? Sobald jemand nach oben scrollt, soll nicht
/// weitergesprungen werden — sonst kann man nichts lesen, während etwas läuft.
/// </summary>
[ObservableProperty]
private bool _followTail = true;
public LogPageViewModel(AppHost? host) : base("Logs")
{
_logDirectory = host?.LogDirectory ?? "";
if (host is null) return; // Entwurfsmodus
_tail = new LogTail(_logDirectory);
RefreshModules();
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_timer.Tick += (_, _) => Poll();
_timer.Start();
}
private void Poll()
{
if (_tail is null) return;
var module = SelectedModule == "Alle" ? null : SelectedModule;
var level = SelectedLevel switch
{
"Info" => LogLevelFilter.Info,
"Warn" => LogLevelFilter.Warn,
"Error" => LogLevelFilter.Error,
_ => LogLevelFilter.All
};
foreach (var line in _tail.ReadNew(module, level))
{
Lines.Add(line);
// Von vorn kürzen statt am Ende zu deckeln: Das Neueste soll bleiben.
while (Lines.Count > MaxLines)
Lines.RemoveAt(0);
}
}
[RelayCommand]
private void Clear() => Lines.Clear();
[RelayCommand]
private void RefreshModules()
{
if (_tail is null) return;
var current = SelectedModule;
Modules.Clear();
Modules.Add("Alle");
foreach (var module in _tail.AvailableModules())
Modules.Add(module);
// Auswahl halten, wenn es das Modul noch gibt.
SelectedModule = Modules.Contains(current) ? current : "Alle";
}
[RelayCommand]
private void OpenFolder() => SystemShell.OpenFolder(_logDirectory);
}
@@ -0,0 +1,102 @@
using System.Collections.ObjectModel;
using ClawdDotNet.App;
using ClawdDotNet.Core.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Das Gerüst des Hauptfensters: welche Bereiche es gibt und welcher gerade sichtbar ist.
/// </summary>
public sealed partial class MainWindowViewModel : ViewModelBase
{
private readonly AppHost? _host;
[ObservableProperty]
private PageViewModel? _selectedPage;
public ObservableCollection<PageViewModel> Pages { get; }
/// <summary>Text der Statusleiste am unteren Rand.</summary>
[ObservableProperty]
private string _statusText = "Bereit";
public event Action? RequestExit;
public event Action? RequestInstanceManagerDialog;
/// <summary>Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.</summary>
public MainWindowViewModel() : this(null) { }
public MainWindowViewModel(AppHost? host)
{
_host = host;
Pages =
[
new ChatPageViewModel(host),
new LogPageViewModel(host),
new AgentsPageViewModel(host),
new SettingsPageViewModel(host),
new TasksPageViewModel(host),
new BackupPageViewModel(host),
new InfoPageViewModel(host)
];
SelectedPage = Pages[0];
StatusText = host is null
? "Entwurfsmodus"
: $"Instanz: {host.Instance.InstanceName} · "
+ (host.Engine is null ? "Agenten deaktiviert (kein API-Key)" : "bereit");
}
[RelayCommand]
private void OpenInstanceFolder()
{
if (_host is null) return;
SystemShell.OpenFolder(_host.InstancePath);
}
[RelayCommand]
private void OpenLogFolder()
{
if (_host is null) return;
SystemShell.OpenFolder(_host.LogDirectory);
}
[RelayCommand]
private void OpenInstanceManager()
{
RequestInstanceManagerDialog?.Invoke();
}
[RelayCommand]
private void SelectPageByTitle(string title)
{
var target = Pages.FirstOrDefault(p => p.Title.Equals(title, StringComparison.OrdinalIgnoreCase));
if (target is not null)
SelectedPage = target;
}
[RelayCommand]
private void Exit()
{
RequestExit?.Invoke();
}
}
/// <summary>Ein Bereich des Hauptfensters.</summary>
public abstract partial class PageViewModel(string title) : ViewModelBase
{
public string Title { get; } = title;
}
/// <summary>
/// Platzhalter, solange der Bereich noch nicht portiert ist.
/// </summary>
public sealed partial class PlaceholderPageViewModel(string title, string note)
: PageViewModel(title)
{
public string Note { get; } = note;
}
@@ -0,0 +1,77 @@
using ClawdDotNet.Core.Backup;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record RestoreResultData(string TargetDirectory, string? Passphrase, bool Overwrite);
public sealed partial class RestoreBackupViewModel : ViewModelBase
{
private readonly BackupManifest _manifest;
public string InstanceName => _manifest.InstanceName;
public DateTime CreatedAt => _manifest.CreatedAt;
public int FileCount => _manifest.Files.Count;
public bool NeedsPassphrase => _manifest.HasSecrets;
public string SecretsDescription => _manifest.HasSecrets
? $"{_manifest.SecretCount}, mit Passphrase geschützt"
: _manifest.SecretCount > 0
? $"{_manifest.SecretCount} entfernt"
: "keine";
[ObservableProperty]
private string _targetDirectory = "";
[ObservableProperty]
private string _passphrase = "";
[ObservableProperty]
private bool _overwrite;
[ObservableProperty]
private string _statusMessage = "";
public RestoreResultData? Result { get; private set; }
public event Action? CloseRequested;
public RestoreBackupViewModel() : this(new BackupManifest(), @"C:\Instance_Restored") { }
public RestoreBackupViewModel(BackupManifest manifest, string suggestedTarget)
{
_manifest = manifest;
TargetDirectory = suggestedTarget;
}
[RelayCommand]
private void Confirm()
{
if (string.IsNullOrWhiteSpace(TargetDirectory))
{
StatusMessage = "Bitte einen Zielordner angeben.";
return;
}
if (NeedsPassphrase && string.IsNullOrEmpty(Passphrase))
{
StatusMessage = "Bitte Passphrase für geschützte Zugangsdaten eingeben.";
return;
}
Result = new RestoreResultData(
TargetDirectory.Trim(),
NeedsPassphrase ? Passphrase : null,
Overwrite);
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,183 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Ansichtsmodell für die Einstellungen-Seite (Anwendungs- und Instanz-Einstellungen).
/// </summary>
public sealed partial class SettingsPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly ILogger? _logger;
// ─── Allgemein & Pfade ───
[ObservableProperty]
private string _logDirectory = "";
[ObservableProperty]
private string _instancesDirectory = "";
[ObservableProperty]
private string _minimumLogLevel = "Info";
[ObservableProperty]
private int _maxLogLinesInUi = 2000;
[ObservableProperty]
private int _logRefreshIntervalMs = 500;
// ─── API & OpenRouter ───
[ObservableProperty]
private string _openRouterBaseUrl = "https://openrouter.ai/api/v1/";
[ObservableProperty]
private int _statusCheckIntervalSeconds = 60;
// ─── Backup ───
[ObservableProperty]
private string _backupDirectory = "";
[ObservableProperty]
private bool _autoBackupEnabled;
[ObservableProperty]
private string _autoBackupTime = "03:00";
[ObservableProperty]
private int _backupKeepCount = 14;
// ─── Deploymentcenter ───
[ObservableProperty]
private string _deploymentcenterUrl = "https://dc.mhdf.de";
[ObservableProperty]
private string _deploymentcenterToken = "";
[ObservableProperty]
private string _deploymentcenterEnvironment = "production";
[ObservableProperty]
private bool _errorReportingEnabled = true;
[ObservableProperty]
private bool _updateCheckEnabled = true;
[ObservableProperty]
private string _updateChannel = "prod";
// ─── Lizenz ───
[ObservableProperty]
private string _licenseKey = "";
// ─── Instanz-Einstellungen ───
[ObservableProperty]
private string _instanceName = "";
[ObservableProperty]
private string _openRouterApiKey = "";
[ObservableProperty]
private decimal _instanceDailyCostUsd;
[ObservableProperty]
private long _instanceDailyTokens;
[ObservableProperty]
private string _statusText = "Bereit";
public List<string> LogLevels { get; } = ["Debug", "Info", "Warn", "Error"];
public List<string> UpdateChannels { get; } = ["prod", "beta", "dev"];
public List<string> Environments { get; } = ["production", "development", "staging"];
public SettingsPageViewModel() : this(null) { }
public SettingsPageViewModel(AppHost? host) : base("Einstellungen")
{
_host = host;
if (host is not null)
{
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Settings");
LoadFromHost();
}
else
{
StatusText = "Entwurfsmodus";
}
}
[RelayCommand]
private void LoadFromHost()
{
if (_host is null) return;
var s = _host.Settings.AppSettings;
LogDirectory = s.LogDirectory;
InstancesDirectory = s.InstancesDirectory;
MinimumLogLevel = s.MinimumLogLevel;
MaxLogLinesInUi = s.MaxLogLinesInUi;
LogRefreshIntervalMs = s.LogRefreshIntervalMs;
OpenRouterBaseUrl = s.OpenRouterBaseUrl;
StatusCheckIntervalSeconds = s.StatusCheckIntervalSeconds;
BackupDirectory = s.BackupDirectory;
AutoBackupEnabled = s.AutoBackupEnabled;
AutoBackupTime = s.AutoBackupTime;
BackupKeepCount = s.BackupKeepCount;
DeploymentcenterUrl = s.DeploymentcenterUrl;
DeploymentcenterToken = s.DeploymentcenterToken;
DeploymentcenterEnvironment = s.DeploymentcenterEnvironment;
ErrorReportingEnabled = s.ErrorReportingEnabled;
UpdateCheckEnabled = s.UpdateCheckEnabled;
UpdateChannel = s.UpdateChannel;
LicenseKey = s.LicenseKey;
var inst = _host.Instance;
InstanceName = inst.InstanceName;
OpenRouterApiKey = inst.OpenRouterApiKey;
InstanceDailyCostUsd = inst.Budget.DailyCostUsd;
InstanceDailyTokens = inst.Budget.DailyTokens;
}
[RelayCommand]
private void SaveSettings()
{
if (_host is null) return;
var s = _host.Settings.AppSettings;
s.LogDirectory = LogDirectory.Trim();
s.InstancesDirectory = InstancesDirectory.Trim();
s.MinimumLogLevel = MinimumLogLevel;
s.MaxLogLinesInUi = MaxLogLinesInUi;
s.LogRefreshIntervalMs = LogRefreshIntervalMs;
s.OpenRouterBaseUrl = OpenRouterBaseUrl.Trim();
s.StatusCheckIntervalSeconds = StatusCheckIntervalSeconds;
s.BackupDirectory = BackupDirectory.Trim();
s.AutoBackupEnabled = AutoBackupEnabled;
s.AutoBackupTime = AutoBackupTime.Trim();
s.BackupKeepCount = BackupKeepCount;
s.DeploymentcenterUrl = DeploymentcenterUrl.Trim();
s.DeploymentcenterToken = DeploymentcenterToken.Trim();
s.DeploymentcenterEnvironment = DeploymentcenterEnvironment.Trim();
s.ErrorReportingEnabled = ErrorReportingEnabled;
s.UpdateCheckEnabled = UpdateCheckEnabled;
s.UpdateChannel = UpdateChannel;
s.LicenseKey = LicenseKey.Trim();
_host.Settings.Save();
var inst = _host.Instance;
inst.InstanceName = InstanceName.Trim();
inst.OpenRouterApiKey = OpenRouterApiKey.Trim();
inst.Budget.DailyCostUsd = InstanceDailyCostUsd;
inst.Budget.DailyTokens = InstanceDailyTokens;
_host.Directories.SaveInstanceConfig(_host.InstancePath, inst);
_logger?.LogInformation("Einstellungen und Instanz-Konfiguration gespeichert.");
StatusText = "Einstellungen erfolgreich gespeichert.";
}
}
@@ -0,0 +1,446 @@
using System.Collections.ObjectModel;
using Avalonia.Threading;
using ClawdDotNet.App;
using ClawdDotNet.App.Models;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClawdDotNet.Core.Scheduling;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed record JobDisplayItemViewModel(
string JobType,
string JobId,
string AgentId,
string AgentName,
string ToolName,
string CronExpression,
string TaskMessage,
bool RunOnStart,
string NextRun,
string LastRun,
string LastStatus,
string Status
);
public sealed record ServiceDisplayItemViewModel(
string ServiceId,
string Name,
string Type,
int Port,
string Status,
string Description
);
/// <summary>
/// Ansichtsmodell für die Aufgaben-Seite (Jobs, Dienste, Verlauf).
/// </summary>
public sealed partial class TasksPageViewModel : PageViewModel
{
private readonly AppHost? _host;
private readonly JobHistoryService? _jobHistoryService;
private readonly ILogger? _logger;
private readonly Dictionary<string, DateTime> _jobLastRunTimes = new();
public ObservableCollection<JobDisplayItemViewModel> JobEntries { get; } = [];
public ObservableCollection<ServiceDisplayItemViewModel> ServiceEntries { get; } = [];
public ObservableCollection<JobHistoryEntry> JobHistoryEntries { get; } = [];
[ObservableProperty]
private JobDisplayItemViewModel? _selectedJob;
[ObservableProperty]
private ServiceDisplayItemViewModel? _selectedService;
[ObservableProperty]
private string _statusText = "Bereit";
[ObservableProperty]
private bool _isRunningJob;
public event Func<AddJobViewModel, Task<AddJobResultData?>>? RequestAddJobDialog;
public event Func<AddServiceViewModel, Task<AddServiceResultData?>>? RequestAddServiceDialog;
public TasksPageViewModel() : this(null) { }
public TasksPageViewModel(AppHost? host) : base("Aufgaben")
{
_host = host;
if (host is not null)
{
_jobHistoryService = new JobHistoryService(host.InstancePath);
_logger = host.LoggerFactory.CreateLogger("ClawdDotNet.Desktop.Tasks");
EnsureBuiltInServices();
RefreshJobList();
RefreshServiceList();
RefreshJobHistory();
}
else
{
StatusText = "Entwurfsmodus";
}
}
private void SaveInstanceConfig()
{
if (_host is null) return;
_host.Directories.SaveInstanceConfig(_host.InstancePath, _host.Instance);
}
private void EnsureBuiltInServices()
{
if (_host is null) return;
var defaults = BuiltInServices.CreateDefaults();
var changed = false;
foreach (var def in defaults)
{
if (_host.Instance.Services.Any(s => s.ServiceId == def.ServiceId))
continue;
_host.Instance.Services.Insert(0, def);
changed = true;
}
if (changed)
SaveInstanceConfig();
}
[RelayCommand]
private void RefreshJobList()
{
JobEntries.Clear();
if (_host is null) return;
foreach (var agent in _host.Instance.Agents)
{
// Agent Wakeup Jobs
if (agent.Scheduler is not null)
{
var nextRun = CalculateNextRun(agent.Scheduler.Cron);
var agentLastRun = _jobLastRunTimes.TryGetValue($"agent_{agent.AgentId}", out var agentLastTime)
? agentLastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
JobEntries.Add(new JobDisplayItemViewModel(
"Agent Wakeup",
$"agent_{agent.AgentId}",
agent.AgentId,
agent.DisplayName,
"—",
agent.Scheduler.Cron,
agent.Scheduler.TaskMessage,
agent.Scheduler.RunOnStart,
nextRun,
agentLastRun,
"—",
_host.Scanner is not null ? "Aktiv" : "Inaktiv"
));
}
// Tool Jobs
foreach (var toolJob in agent.ToolJobs)
{
var nextRun = CalculateNextRun(toolJob.Cron);
var toolLastRun = _jobLastRunTimes.TryGetValue(toolJob.JobId, out var lastTime)
? lastTime.ToString("yyyy-MM-dd HH:mm:ss")
: "—";
JobEntries.Add(new JobDisplayItemViewModel(
"Tool Job",
toolJob.JobId,
agent.AgentId,
agent.DisplayName,
toolJob.ToolName,
toolJob.Cron,
toolJob.JobTypeId,
toolJob.RunOnStart,
nextRun,
toolLastRun,
"—",
toolJob.Enabled ? "Aktiv" : "Deaktiviert"
));
}
}
}
[RelayCommand]
private void RefreshServiceList()
{
ServiceEntries.Clear();
if (_host is null) return;
foreach (var svc in _host.Instance.Services)
{
ServiceEntries.Add(new ServiceDisplayItemViewModel(
svc.ServiceId,
svc.Name,
svc.Type,
svc.Port,
svc.Enabled ? "Bereit" : "Deaktiviert",
svc.Description
));
}
}
[RelayCommand]
private void RefreshJobHistory()
{
JobHistoryEntries.Clear();
if (_jobHistoryService is null) return;
foreach (var entry in _jobHistoryService.GetAll().Take(200))
JobHistoryEntries.Add(entry);
}
[RelayCommand]
private async Task AddJobAsync()
{
if (_host is null || RequestAddJobDialog is null) return;
var vm = new AddJobViewModel(_host.Instance.Agents);
var result = await RequestAddJobDialog(vm);
if (result is null) return;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == result.AgentId);
if (agent is null) return;
if (result.IsToolJob)
{
agent.ToolJobs.Add(new ToolJobConfig
{
ToolName = result.ToolName,
JobTypeId = result.JobTypeId,
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
Enabled = true
});
_logger?.LogInformation("Tool-Job hinzugefügt: Agent={Agent}, Tool={Tool}, Cron={Cron}",
agent.DisplayName, result.ToolName, result.CronExpression);
}
else
{
agent.Scheduler = new SchedulerConfig
{
Cron = result.CronExpression,
RunOnStart = result.RunOnStart,
TaskMessage = result.TaskMessage
};
_logger?.LogInformation("Agent-Wakeup-Job hinzugefügt: Agent={Agent}, Cron={Cron}",
agent.DisplayName, result.CronExpression);
}
SaveInstanceConfig();
RefreshJobList();
}
[RelayCommand]
private async Task EditJobAsync()
{
if (_host is null || SelectedJob is null || RequestAddJobDialog is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
var isToolJob = entry.JobType == "Tool Job";
var vm = new AddJobViewModel(_host.Instance.Agents)
{
SelectedAgent = agent,
IsToolJob = isToolJob,
SelectedToolName = isToolJob ? entry.ToolName : "FileRW",
JobTypeId = isToolJob ? entry.TaskMessage : "PollJob",
CronExpression = entry.CronExpression,
TaskMessage = isToolJob ? "" : entry.TaskMessage,
RunOnStart = entry.RunOnStart
};
var result = await RequestAddJobDialog(vm);
if (result is null) return;
if (isToolJob)
{
var toolJob = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (toolJob is not null)
{
toolJob.Cron = result.CronExpression;
toolJob.RunOnStart = result.RunOnStart;
toolJob.JobTypeId = result.JobTypeId;
toolJob.ToolName = result.ToolName;
}
}
else
{
if (agent.Scheduler is not null)
{
agent.Scheduler.Cron = result.CronExpression;
agent.Scheduler.TaskMessage = result.TaskMessage;
agent.Scheduler.RunOnStart = result.RunOnStart;
}
}
SaveInstanceConfig();
RefreshJobList();
}
[RelayCommand]
private void RemoveJob()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
if (entry.JobType == "Tool Job")
{
agent.ToolJobs.RemoveAll(j => j.JobId == entry.JobId);
}
else
{
agent.Scheduler = null;
}
SaveInstanceConfig();
RefreshJobList();
}
[RelayCommand]
private async Task RunJobNowAsync()
{
if (_host is null || SelectedJob is null) return;
var entry = SelectedJob;
var agent = _host.Instance.Agents.FirstOrDefault(a => a.AgentId == entry.AgentId);
if (agent is null) return;
IsRunningJob = true;
StatusText = $"Führe Job für '{agent.DisplayName}' aus…";
try
{
if (entry.JobType == "Tool Job")
{
if (_host.Scanner is null)
{
StatusText = "Scanner ist nicht aktiv.";
return;
}
var jobConfig = agent.ToolJobs.FirstOrDefault(j => j.JobId == entry.JobId);
if (jobConfig is null) return;
var ran = await _host.Scanner.RunTaskNowAsync(
$"tj-{agent.AgentId}-{jobConfig.JobId}", CancellationToken.None);
_jobLastRunTimes[jobConfig.JobId] = DateTime.Now;
var info = ran ? "Ausgeführt" : "Nicht bereit";
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = jobConfig.JobTypeId,
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = $"Tool Job: {jobConfig.ToolName}",
Info = info,
Status = "Manual"
});
StatusText = $"Tool Job '{jobConfig.JobTypeId}' abgeschlossen ({info}).";
}
else
{
if (_host.Engine is null)
{
StatusText = "Agent-Engine ist nicht aktiv (kein API-Key).";
return;
}
var taskMessage = agent.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.";
var result = await _host.Engine.RunAsync(agent, taskMessage, _host.Instance.InstanceId, CancellationToken.None);
_jobLastRunTimes[$"agent_{agent.AgentId}"] = DateTime.Now;
_jobHistoryService?.Add(new JobHistoryEntry
{
JobName = "Agent Wakeup",
Agent = agent.DisplayName,
Time = DateTime.Now,
JobDescription = taskMessage,
Info = $"{result.Status} ({result.StepCount} Steps, {result.TokensUsed:N0} Tokens)",
Status = "Manual"
});
StatusText = $"Agent '{agent.DisplayName}' abgeschlossen: {result.Status} ({result.TokensUsed:N0} Tokens).";
}
RefreshJobList();
RefreshJobHistory();
}
catch (Exception ex)
{
StatusText = $"Fehler beim Ausführen: {ex.Message}";
_logger?.LogError(ex, "Job-Ausführung fehlgeschlagen");
}
finally
{
IsRunningJob = false;
}
}
[RelayCommand]
private async Task AddServiceAsync()
{
if (_host is null || RequestAddServiceDialog is null) return;
var vm = new AddServiceViewModel();
var result = await RequestAddServiceDialog(vm);
if (result is null) return;
var svcConfig = new ServiceConfig
{
Name = result.ServiceName,
Type = result.ServiceType,
Port = result.ServicePort,
Enabled = true,
Description = result.ServiceDescription
};
_host.Instance.Services.Add(svcConfig);
SaveInstanceConfig();
RefreshServiceList();
}
[RelayCommand]
private void RemoveService()
{
if (_host is null || SelectedService is null) return;
_host.Instance.Services.RemoveAll(s => s.ServiceId == SelectedService.ServiceId);
SaveInstanceConfig();
RefreshServiceList();
}
private static string CalculateNextRun(string? cronExpr)
{
if (string.IsNullOrWhiteSpace(cronExpr)) return "—";
try
{
var cron = CronExpression.Parse(cronExpr);
var next = cron.GetNextOccurrence(DateTime.UtcNow);
return next?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "—";
}
catch
{
return "Ungültig";
}
}
}
@@ -0,0 +1,221 @@
using ClawdDotNet.Models;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClawdDotNet.Desktop.ViewModels;
public sealed partial class ToolSettingsViewModel : ViewModelBase
{
public string ToolName { get; }
public object? SettingsObject { get; }
// FTP
[ObservableProperty] private string _ftpHost = "";
[ObservableProperty] private int _ftpPort = 21;
[ObservableProperty] private string _ftpUsername = "";
[ObservableProperty] private string _ftpPassword = "";
[ObservableProperty] private string _ftpRootPath = "./data/";
// Mail
[ObservableProperty] private string _mailUsername = "";
[ObservableProperty] private string _mailPassword = "";
[ObservableProperty] private string _mailImapHost = "";
[ObservableProperty] private int _mailImapPort = 993;
[ObservableProperty] private string _mailSmtpHost = "";
[ObservableProperty] private int _mailSmtpPort = 587;
[ObservableProperty] private string _mailAllowedRecipients = "";
// Database
[ObservableProperty] private DatabaseType _dbType = DatabaseType.MySql;
[ObservableProperty] private string _dbConnectionString = "";
[ObservableProperty] private DatabaseAccessLevel _dbAccessLevel = DatabaseAccessLevel.ReadOnly;
[ObservableProperty] private string _dbAllowedTables = "";
// Telegram
[ObservableProperty] private string _telegramBotToken = "";
[ObservableProperty] private string _telegramDefaultChatId = "";
[ObservableProperty] private string _telegramAllowedChatIds = "";
// DirectAPI
[ObservableProperty] private string _directApiDefaultProvider = "twelvedata";
[ObservableProperty] private int _directApiCacheTtlSeconds = 60;
[ObservableProperty] private string _directApiTwelveDataKey = "";
[ObservableProperty] private string _directApiAlphaVantageKey = "";
// WebFetch
[ObservableProperty] private string _webFetchAllowedDomains = "";
[ObservableProperty] private int _webFetchMaxResponseKb = 512;
[ObservableProperty] private string _webFetchUserAgent = "ClawdDotNet-Agent/1.0";
// SocialMediaManager
[ObservableProperty] private string _smmXApiKey = "";
[ObservableProperty] private string _smmXWatchAccounts = "";
[ObservableProperty] private string _smmRedditWatchSubreddits = "";
[ObservableProperty] private int _smmRedditPostLimit = 15;
[ObservableProperty] private string _smmOpenRouterApiKey = "";
[ObservableProperty] private string _smmSttModel = "openai/whisper-1";
[ObservableProperty] private string _smmYoutubeChannels = "";
// FileRW
[ObservableProperty] private string _fileRwPersonalAllowedExtensions = ".txt,.json,.md,.html,.js,.css";
[ObservableProperty] private FileRWAccessLevel _fileRwSharedAccessLevel = FileRWAccessLevel.Denied;
[ObservableProperty] private string _fileRwSharedAllowedExtensions = ".txt,.json,.md";
[ObservableProperty] private string _fileRwProtectedPaths = "stocks/";
public List<DatabaseType> DbTypes { get; } = [DatabaseType.MySql, DatabaseType.Postgres, DatabaseType.MsSql, DatabaseType.MongoDb];
public List<DatabaseAccessLevel> DbAccessLevels { get; } = [DatabaseAccessLevel.ReadOnly, DatabaseAccessLevel.ReadWrite, DatabaseAccessLevel.Admin];
public List<FileRWAccessLevel> FileRwAccessLevels { get; } = [FileRWAccessLevel.Denied, FileRWAccessLevel.Read, FileRWAccessLevel.ReadWrite, FileRWAccessLevel.Admin];
public Dictionary<string, object?>? Result { get; private set; }
public event Action? CloseRequested;
public ToolSettingsViewModel() : this("FTP", new()) { }
public ToolSettingsViewModel(string toolName, Dictionary<string, object?> config)
{
ToolName = toolName;
SettingsObject = ToolSettingsFactory.CreateViewModel(toolName, config);
switch (SettingsObject)
{
case FTPToolSettings ftp:
FtpHost = ftp.Host;
FtpPort = ftp.Port;
FtpUsername = ftp.Username;
FtpPassword = ftp.Password;
FtpRootPath = ftp.RootPath;
break;
case MailToolSettings mail:
MailUsername = mail.Username;
MailPassword = mail.Password;
MailImapHost = mail.ImapHost;
MailImapPort = mail.ImapPort;
MailSmtpHost = mail.SmtpHost;
MailSmtpPort = mail.SmtpPort;
MailAllowedRecipients = mail.AllowedRecipients;
break;
case DatabaseToolSettings db:
DbType = db.Type;
DbConnectionString = db.ConnectionString;
DbAccessLevel = db.AccessLevel;
DbAllowedTables = db.AllowedTables;
break;
case TelegramToolSettings tg:
TelegramBotToken = tg.BotToken;
TelegramDefaultChatId = tg.DefaultChatId;
TelegramAllowedChatIds = tg.AllowedChatIds;
break;
case DirectAPIToolSettings dapi:
DirectApiDefaultProvider = dapi.DefaultProvider;
DirectApiCacheTtlSeconds = dapi.CacheTtlSeconds;
DirectApiTwelveDataKey = dapi.TwelveDataKey;
DirectApiAlphaVantageKey = dapi.AlphaVantageKey;
break;
case WebFetchToolSettings wf:
WebFetchAllowedDomains = wf.AllowedDomains;
WebFetchMaxResponseKb = wf.MaxResponseKb;
WebFetchUserAgent = wf.UserAgent;
break;
case SocialMediaManagerToolSettings smm:
SmmXApiKey = smm.XApiKey;
SmmXWatchAccounts = smm.XWatchAccounts;
SmmRedditWatchSubreddits = smm.RedditWatchSubreddits;
SmmRedditPostLimit = smm.RedditPostLimit;
SmmOpenRouterApiKey = smm.OpenRouterApiKey;
SmmSttModel = smm.STTModel;
SmmYoutubeChannels = smm.YoutubeChannels;
break;
case FileRWToolSettings frw:
FileRwPersonalAllowedExtensions = frw.PersonalAllowedExtensions;
FileRwSharedAccessLevel = frw.SharedAccessLevel;
FileRwSharedAllowedExtensions = frw.SharedAllowedExtensions;
FileRwProtectedPaths = frw.ProtectedPaths;
break;
}
}
[RelayCommand]
private void Confirm()
{
switch (SettingsObject)
{
case FTPToolSettings ftp:
ftp.Host = FtpHost;
ftp.Port = FtpPort;
ftp.Username = FtpUsername;
ftp.Password = FtpPassword;
ftp.RootPath = FtpRootPath;
break;
case MailToolSettings mail:
mail.Username = MailUsername;
mail.Password = MailPassword;
mail.ImapHost = MailImapHost;
mail.ImapPort = MailImapPort;
mail.SmtpHost = MailSmtpHost;
mail.SmtpPort = MailSmtpPort;
mail.AllowedRecipients = MailAllowedRecipients;
break;
case DatabaseToolSettings db:
db.Type = DbType;
db.ConnectionString = DbConnectionString;
db.AccessLevel = DbAccessLevel;
db.AllowedTables = DbAllowedTables;
break;
case TelegramToolSettings tg:
tg.BotToken = TelegramBotToken;
tg.DefaultChatId = TelegramDefaultChatId;
tg.AllowedChatIds = TelegramAllowedChatIds;
break;
case DirectAPIToolSettings dapi:
dapi.DefaultProvider = DirectApiDefaultProvider;
dapi.CacheTtlSeconds = DirectApiCacheTtlSeconds;
dapi.TwelveDataKey = DirectApiTwelveDataKey;
dapi.AlphaVantageKey = DirectApiAlphaVantageKey;
break;
case WebFetchToolSettings wf:
wf.AllowedDomains = WebFetchAllowedDomains;
wf.MaxResponseKb = WebFetchMaxResponseKb;
wf.UserAgent = WebFetchUserAgent;
break;
case SocialMediaManagerToolSettings smm:
smm.XApiKey = SmmXApiKey;
smm.XWatchAccounts = SmmXWatchAccounts;
smm.RedditWatchSubreddits = SmmRedditWatchSubreddits;
smm.RedditPostLimit = SmmRedditPostLimit;
smm.OpenRouterApiKey = SmmOpenRouterApiKey;
smm.STTModel = SmmSttModel;
smm.YoutubeChannels = SmmYoutubeChannels;
break;
case FileRWToolSettings frw:
frw.PersonalAllowedExtensions = FileRwPersonalAllowedExtensions;
frw.SharedAccessLevel = FileRwSharedAccessLevel;
frw.SharedAllowedExtensions = FileRwSharedAllowedExtensions;
frw.ProtectedPaths = FileRwProtectedPaths;
break;
}
Result = ToolSettingsFactory.ToConfig(ToolName, SettingsObject) ?? new();
CloseRequested?.Invoke();
}
[RelayCommand]
private void Cancel()
{
Result = null;
CloseRequested?.Invoke();
}
}
@@ -0,0 +1,15 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClawdDotNet.Desktop.ViewModels;
/// <summary>
/// Grundlage aller Ansichtsmodelle.
///
/// <see cref="ObservableObject"/> aus dem CommunityToolkit bringt
/// <c>INotifyPropertyChanged</c> samt Quelltextgenerator mit — aus
/// <c>[ObservableProperty] private string _name;</c> wird die vollständige Eigenschaft
/// mit Benachrichtigung. Das spart gegenüber der WinForms-Fassung, in der die
/// Oberfläche bei jeder Änderung von Hand nachgezogen wurde
/// (38 Stellen mit <c>Invoke</c>/<c>BeginInvoke</c>).
/// </summary>
public abstract class ViewModelBase : ObservableObject;
@@ -0,0 +1,51 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="380"
Width="480" Height="400"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Neuen Agenten anlegen"
x:Class="ClawdDotNet.Desktop.Views.AddAgentWindow"
x:DataType="vm:AddAgentViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Anlegen" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" />
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Agent-ID (eindeutiger Schlüssel):" FontWeight="Bold" />
<TextBox Text="{Binding AgentId}" PlaceholderText="z.B. dev-assistant" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Anzeigename:" FontWeight="Bold" />
<TextBox Text="{Binding DisplayName}" PlaceholderText="z.B. Dev Assistant" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Standard-Modell:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableModels}" SelectedItem="{Binding SelectedModel}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung / Rolle:" FontWeight="Bold" />
<TextBox Text="{Binding Description}" PlaceholderText="Kurze Beschreibung der Rolle..." AcceptsReturn="True" Height="60" />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AddAgentWindow : Window
{
public AddAgentWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AddAgentViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
@@ -0,0 +1,80 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450"
Width="520" Height="460"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Job hinzufügen / bearbeiten"
x:Class="ClawdDotNet.Desktop.Views.AddJobWindow"
x:DataType="vm:AddJobViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Speichern" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" />
<ScrollViewer>
<StackPanel Spacing="12">
<!-- Agent Selection -->
<StackPanel Spacing="4">
<TextBlock Text="Agent:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableAgents}" SelectedItem="{Binding SelectedAgent}" HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<!-- Job Type RadioButtons -->
<StackPanel Spacing="4" Margin="0,4,0,0">
<TextBlock Text="Job-Typ:" FontWeight="Bold" />
<RadioButton Content="Agent Wakeup (Periodische Ausführung)" IsChecked="{Binding IsAgentWakeup}" />
<RadioButton Content="Tool Job (Werkzeug-spezifischer Job)" IsChecked="{Binding IsToolJob}" />
</StackPanel>
<!-- Tool Job Details -->
<StackPanel Spacing="8" IsVisible="{Binding IsToolJob}" Margin="12,4,0,0">
<StackPanel Spacing="4">
<TextBlock Text="Tool:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableTools}" SelectedItem="{Binding SelectedToolName}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Job-Typ-ID / Name:" FontWeight="Bold" />
<TextBox Text="{Binding JobTypeId}" PlaceholderText="z.B. PollMail" />
</StackPanel>
</StackPanel>
<!-- Agent Wakeup Details -->
<StackPanel Spacing="4" IsVisible="{Binding IsAgentWakeup}">
<TextBlock Text="Aufgabenbeschreibung / Nachricht:" FontWeight="Bold" />
<TextBox Text="{Binding TaskMessage}" PlaceholderText="Nachricht an den Agenten..." AcceptsReturn="True" Height="60" />
</StackPanel>
<!-- Cron & Options -->
<StackPanel Spacing="4">
<TextBlock Text="Cron-Ausdruck:" FontWeight="Bold" />
<TextBox Text="{Binding CronExpression}" PlaceholderText="0 * * * *" />
<TextBlock Text="Syntax: Min Std Tag Mon Wochentag (z.B. '*/15 * * * *' alle 15 Minuten)" Classes="caption" />
</StackPanel>
<CheckBox Content="Beim Anwendungsstart einmalig sofort ausführen" IsChecked="{Binding RunOnStart}" Margin="0,4,0,0" />
</StackPanel>
</ScrollViewer>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AddJobWindow : Window
{
public AddJobWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AddJobViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
@@ -0,0 +1,53 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="350"
Width="480" Height="360"
Padding="16"
WindowStartupLocation="CenterOwner"
CanResize="False"
Title="Dienst (Service) hinzufügen"
x:Class="ClawdDotNet.Desktop.Views.AddServiceWindow"
x:DataType="vm:AddServiceViewModel">
<DockPanel>
<!-- Bottom Buttons -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,16,0,0">
<Button Content="Abbrechen" Command="{Binding CancelCommand}" />
<Button Content="Hinzufügen" Command="{Binding ConfirmCommand}" Classes="accent" />
</StackPanel>
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Foreground="Red" Margin="0,8,0,0" />
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="Name des Dienstes:" FontWeight="Bold" />
<TextBox Text="{Binding ServiceName}" PlaceholderText="z.B. WebHookService" />
</StackPanel>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
<StackPanel Grid.Column="0" Spacing="4" Margin="0,0,4,0">
<TextBlock Text="Typ:" FontWeight="Bold" />
<ComboBox ItemsSource="{Binding AvailableServiceTypes}" SelectedItem="{Binding ServiceType}" HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="4" Margin="4,0,0,0">
<TextBlock Text="Port:" FontWeight="Bold" />
<NumericUpDown Value="{Binding ServicePort}" Minimum="1" Maximum="65535" FormatString="0" />
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Beschreibung:" FontWeight="Bold" />
<TextBox Text="{Binding ServiceDescription}" PlaceholderText="Optionale Beschreibung..." AcceptsReturn="True" Height="60" />
</StackPanel>
</StackPanel>
</DockPanel>
</Window>
@@ -0,0 +1,21 @@
using Avalonia.Controls;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class AddServiceWindow : Window
{
public AddServiceWindow()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is AddServiceViewModel vm)
{
vm.CloseRequested += () => Close();
}
}
}
@@ -0,0 +1,155 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:ClawdDotNet.Desktop.ViewModels"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
Padding="16"
x:Class="ClawdDotNet.Desktop.Views.AgentsPageView"
x:DataType="vm:AgentsPageViewModel">
<DockPanel>
<!-- Statusbar am unteren Rand -->
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock Text="{Binding StatusText}" VerticalAlignment="Center" />
</Border>
<!-- Master-Detail Split -->
<Grid ColumnDefinitions="260,*">
<!-- Linke Spalte: Agenten-Liste -->
<DockPanel Grid.Column="0" Margin="0,0,12,0">
<!-- Toolbar -->
<StackPanel Classes="toolbar" DockPanel.Dock="Top">
<Button Content="Neu" Command="{Binding AddAgentCommand}" Classes="accent" />
<Button Content="Entfernen" Command="{Binding RemoveAgentCommand}" IsEnabled="{Binding SelectedAgent, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Content="Speichern" Command="{Binding SaveAgentsCommand}" />
</StackPanel>
<!-- Liste -->
<ListBox ItemsSource="{Binding Agents}" SelectedItem="{Binding SelectedAgent}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<StackPanel Margin="4">
<TextBlock Text="{Binding DisplayName}" FontWeight="Bold" />
<TextBlock Text="{Binding Model}" Classes="caption" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<!-- Rechte Spalte: Agenten-Details -->
<ContentControl Grid.Column="1" Content="{Binding SelectedAgent}">
<ContentControl.ContentTemplate>
<DataTemplate DataType="vm:AgentItemViewModel">
<TabControl>
<!-- TAB 1: Allgemein & Modell -->
<TabItem Header="Allgemein &amp; Modell">
<ScrollViewer Padding="8">
<StackPanel Spacing="12">
<Grid ColumnDefinitions="140,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Agent ID:" FontWeight="Bold" VerticalAlignment="Center" />
<TextBox Grid.Row="0" Grid.Column="1" Text="{Binding AgentId}" IsReadOnly="True" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Anzeigename:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding DisplayName}" Margin="0,8,0,0" />
<TextBlock Grid.Row="2" Grid.Column="0" Text="Modell:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).KnownModels}" SelectedItem="{Binding Model}" HorizontalAlignment="Stretch" Margin="0,8,0,0" />
<TextBlock Grid.Row="3" Grid.Column="0" Text="Rolle / Beschreibung:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding Description}" AcceptsReturn="True" Height="60" Margin="0,8,0,0" />
<TextBlock Grid.Row="4" Grid.Column="0" Text="Prompt Caching:" FontWeight="Bold" VerticalAlignment="Center" Margin="0,8,0,0" />
<ComboBox Grid.Row="4" Grid.Column="1" ItemsSource="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).CachingOptions}" SelectedItem="{Binding PromptCaching}" Width="150" HorizontalAlignment="Left" Margin="0,8,0,0" />
</Grid>
<!-- Limits Card -->
<Border Classes="card" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="Schutzgrenzen (LoopGuard &amp; Budget)" Classes="heading" />
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto">
<StackPanel Grid.Row="0" Grid.Column="0" Spacing="4">
<TextBlock Text="Max. Schritte pro Run:" />
<NumericUpDown Value="{Binding MaxSteps}" Minimum="1" Maximum="100" />
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1" Spacing="4" Margin="8,0,0,0">
<TextBlock Text="Timeout (Sekunden):" />
<NumericUpDown Value="{Binding TimeoutSeconds}" Minimum="10" Maximum="3600" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="0" Spacing="4" Margin="0,8,0,0">
<TextBlock Text="Max. Kontext-Tokens:" />
<NumericUpDown Value="{Binding MaxContextTokens}" Minimum="1000" Maximum="2000000" />
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="1" Spacing="4" Margin="8,8,0,0">
<TextBlock Text="Max. Tool-Ergebnis (Zeichen):" />
<NumericUpDown Value="{Binding MaxToolResultChars}" Minimum="1000" Maximum="500000" />
</StackPanel>
</Grid>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB 2: System Prompt & Seele -->
<TabItem Header="System-Prompt &amp; Soul">
<ScrollViewer Padding="8">
<StackPanel Spacing="12">
<StackPanel Spacing="4">
<TextBlock Text="System-Prompt (Zusätzliche Anweisungen):" FontWeight="Bold" />
<TextBox Text="{Binding SystemPrompt}" AcceptsReturn="True" Height="120" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Identität (Identity):" FontWeight="Bold" />
<TextBox Text="{Binding Identity}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Seele &amp; Werte (Soul):" FontWeight="Bold" />
<TextBox Text="{Binding Soul}" AcceptsReturn="True" Height="100" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB 3: Werkzeuge (Tools) -->
<TabItem Header="Werkzeuge (Tools)">
<ScrollViewer Padding="8">
<StackPanel Spacing="8">
<TextBlock Text="Zugelassene Werkzeuge für diesen Agenten:" Classes="heading" />
<ItemsControl ItemsSource="{Binding AvailableTools}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:ToolItemViewModel">
<Grid ColumnDefinitions="200,Auto" Margin="0,4">
<CheckBox Content="{Binding Name}" IsChecked="{Binding IsEnabled}" Grid.Column="0" VerticalAlignment="Center" />
<Button Content="⚙ Konfigurieren" Command="{Binding $parent[UserControl].((vm:AgentsPageViewModel)DataContext).ConfigureToolCommand}" CommandParameter="{Binding}" IsEnabled="{Binding IsEnabled}" Grid.Column="1" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
</Grid>
</DockPanel>
</UserControl>

Some files were not shown because too many files have changed in this diff Show More