feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.AgentComm;
|
||||
using ClawdDotNet.Tools.AgentEditor;
|
||||
using ClawdDotNet.Tools.AgentSpawn;
|
||||
using ClawdDotNet.Tools.Database;
|
||||
using ClawdDotNet.Tools.DirectAPI;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using ClawdDotNet.Tools.FTP;
|
||||
using ClawdDotNet.Tools.Mail;
|
||||
using ClawdDotNet.Tools.SocialMediaManager;
|
||||
using ClawdDotNet.Tools.Telegram;
|
||||
using ClawdDotNet.Tools.TelegramClient;
|
||||
using ClawdDotNet.Tools.WebFetch;
|
||||
using ClawdDotNet.Tools.WebMonitor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App;
|
||||
|
||||
/// <summary>
|
||||
/// Baut alles auf, was ClawdDotNet zum Laufen braucht — <b>ohne</b> eine einzige Zeile
|
||||
/// Oberflächencode.
|
||||
///
|
||||
/// <para>Vorher lag das in <c>Program.cs</c> der WinForms-Anwendung: 350 Zeilen zwischen
|
||||
/// <c>ApplicationConfiguration.Initialize()</c> und <c>Application.Run(form)</c>. Die
|
||||
/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
|
||||
/// überhaupt entstand. Sie war nur nirgends festgehalten.</para>
|
||||
///
|
||||
/// <para>Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
|
||||
/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
|
||||
/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
|
||||
/// festgeschrieben zu sein.</para>
|
||||
/// </summary>
|
||||
public sealed class AppHost : IAsyncDisposable
|
||||
{
|
||||
private readonly List<Func<ValueTask>> _shutdown = [];
|
||||
|
||||
public required SettingsManager Settings { get; init; }
|
||||
public required InstanceDirectoryManager Directories { get; init; }
|
||||
public required InstanceConfig Instance { get; init; }
|
||||
public required string InstancePath { get; init; }
|
||||
public required string LogDirectory { get; init; }
|
||||
public required ILoggerFactory LoggerFactory { get; init; }
|
||||
public required ToolRegistry Tools { get; init; }
|
||||
|
||||
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
|
||||
public AgentEngine? Engine { get; private init; }
|
||||
|
||||
public TaskScanner? Scanner { get; private init; }
|
||||
public StagingService? Staging { get; private init; }
|
||||
public SqliteUsageRepository? Usage { get; private init; }
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
|
||||
/// Null, wenn Adresse oder Token fehlen.
|
||||
/// </summary>
|
||||
public DeploymentcenterService? Deploymentcenter { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
|
||||
/// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
|
||||
/// </summary>
|
||||
public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
|
||||
/// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
|
||||
/// </summary>
|
||||
public LicenseGate? License { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Die laufende Nachprüfung. Der Aufrufer hängt sich an
|
||||
/// <see cref="LicenseWatch.Revoked"/> und beendet die Anwendung, wenn es feuert.
|
||||
/// </summary>
|
||||
public LicenseWatch? LicenseWatch { get; private set; }
|
||||
|
||||
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
|
||||
public sealed class Callbacks
|
||||
{
|
||||
/// <summary>
|
||||
/// Wählt die Instanz. Gibt <c>null</c> zurück, wenn der Nutzer abbricht.
|
||||
/// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
|
||||
/// </summary>
|
||||
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
|
||||
|
||||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht.</summary>
|
||||
public required ILicensePrompt License { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
|
||||
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
|
||||
/// Dienst dauerhaft blockieren würde.
|
||||
/// </summary>
|
||||
public Func<string, Task<string>>? TelegramLogin { get; init; }
|
||||
|
||||
public Func<Task<string>>? Telegram2FA { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis des Aufbaus. <see cref="Host"/> ist null, wenn der Nutzer abgebrochen hat
|
||||
/// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
|
||||
/// nachzureichen: Die hat der Torwächter schon gezeigt.
|
||||
/// </summary>
|
||||
public readonly record struct StartupResult(AppHost? Host, string? Error);
|
||||
|
||||
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
|
||||
{
|
||||
// ─── 1. Anwendungseinstellungen ───
|
||||
var settings = new SettingsManager();
|
||||
settings.Load();
|
||||
|
||||
// ─── 2. Instanz wählen ───
|
||||
var directories = new InstanceDirectoryManager(
|
||||
Path.GetFullPath(settings.AppSettings.InstancesDirectory));
|
||||
|
||||
var instancePath = await callbacks.SelectInstance(directories);
|
||||
if (string.IsNullOrWhiteSpace(instancePath))
|
||||
return new StartupResult(null, null); // Abbruch, keine Meldung nötig
|
||||
|
||||
InstanceConfig instance;
|
||||
try
|
||||
{
|
||||
instance = directories.LoadInstanceConfig(instancePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new StartupResult(null,
|
||||
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
|
||||
}
|
||||
|
||||
// ─── 3. Protokollierung ───
|
||||
var logDirectory = Path.GetFullPath(
|
||||
!string.IsNullOrWhiteSpace(instance.LogDirectory)
|
||||
? instance.LogDirectory
|
||||
: settings.AppSettings.LogDirectory);
|
||||
|
||||
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
|
||||
settings.AppSettings.MinimumLogLevel, true, out var parsed)
|
||||
? parsed
|
||||
: Core.Logging.LogLevel.Info;
|
||||
|
||||
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
|
||||
|
||||
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
|
||||
instance.InstanceName, instance.InstanceId);
|
||||
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
|
||||
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
|
||||
|
||||
// ─── 4. Lizenz ───
|
||||
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
|
||||
callbacks.License);
|
||||
|
||||
if (!await license.RunStartupCheckAsync(ct))
|
||||
{
|
||||
logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
|
||||
loggerFactory.Dispose();
|
||||
return new StartupResult(null, null);
|
||||
}
|
||||
|
||||
// ─── 5. Werkzeuge ───
|
||||
var tools = RegisterTools();
|
||||
|
||||
TelegramClientManager? telegram = null;
|
||||
if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
|
||||
{
|
||||
telegram = new TelegramClientManager(instance, instancePath,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
|
||||
|
||||
tools.Register(new TelegramClientTool(telegram));
|
||||
logger.LogInformation("TelegramClient-Tool registriert");
|
||||
}
|
||||
else if (instance.TelegramClient is not null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
|
||||
}
|
||||
|
||||
var host = BuildCore(settings, directories, instance, instancePath,
|
||||
logDirectory, loggerFactory, tools, telegram, logger);
|
||||
|
||||
// Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
|
||||
host.License = license;
|
||||
|
||||
if (license.IsEnforcementConfigured)
|
||||
{
|
||||
host.LicenseWatch = new LicenseWatch(
|
||||
license, loggerFactory.CreateLogger("ClawdDotNet.License"));
|
||||
|
||||
host.LicenseWatch.Start();
|
||||
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
|
||||
}
|
||||
|
||||
await host.ConnectTelegramAsync(callbacks, logger);
|
||||
await host.StartDeploymentcenterAsync(loggerFactory, ct);
|
||||
|
||||
return new StartupResult(host, null);
|
||||
}
|
||||
|
||||
// ─── Aufbau ───
|
||||
|
||||
private static ToolRegistry RegisterTools()
|
||||
{
|
||||
var registry = new ToolRegistry();
|
||||
|
||||
registry.Register(new FileRWTool());
|
||||
registry.Register(new TelegramTool());
|
||||
registry.Register(new MailTool());
|
||||
registry.Register(new DatabaseTool());
|
||||
registry.Register(new FTPTool());
|
||||
registry.Register(new DirectApiTool());
|
||||
registry.Register(new WebFetchTool());
|
||||
registry.Register(new WebMonitorTool());
|
||||
registry.Register(new AgentCommTool());
|
||||
registry.Register(new SocialMediaManagerTool());
|
||||
registry.Register(new AgentSpawnTool());
|
||||
registry.Register(new AgentEditorTool());
|
||||
registry.Register(new Tools.Memory.MemoryTool());
|
||||
registry.Register(new Tools.Taskboard.TaskboardTool());
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static AppHost BuildCore(
|
||||
SettingsManager settings,
|
||||
InstanceDirectoryManager directories,
|
||||
InstanceConfig instance,
|
||||
string instancePath,
|
||||
string logDirectory,
|
||||
ILoggerFactory loggerFactory,
|
||||
ToolRegistry tools,
|
||||
TelegramClientManager? telegram,
|
||||
ILogger logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
|
||||
{
|
||||
logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
|
||||
|
||||
return new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
TelegramClient = telegram
|
||||
};
|
||||
}
|
||||
|
||||
var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
|
||||
|
||||
// Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
|
||||
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
|
||||
var stateStore = new SqliteStateStore(storage);
|
||||
var memory = new SqliteMemoryRepository(storage);
|
||||
var taskRepository = new SqliteTaskRepository(storage);
|
||||
var audit = new SqliteAuditRepository(storage);
|
||||
var stagingRepository = new SqliteStagingRepository(storage);
|
||||
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository);
|
||||
var usage = new SqliteUsageRepository(storage);
|
||||
|
||||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||||
var pricing = new ModelPricingCatalog();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
|
||||
catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
|
||||
});
|
||||
|
||||
var engine = new AgentEngine(
|
||||
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
|
||||
memory, usage, pricing, taskRepository, audit, stagingGate)
|
||||
{
|
||||
InstanceBudget = instance.Budget
|
||||
};
|
||||
|
||||
engine.SetAgentConfigProvider(
|
||||
() => instance.Agents,
|
||||
instance.InstanceId,
|
||||
agentId =>
|
||||
{
|
||||
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||||
});
|
||||
|
||||
engine.LoadPersistedChats();
|
||||
|
||||
var (scanner, staging) = BuildTaskboard(
|
||||
instance, taskRepository, engine, tools, stateStore, audit,
|
||||
stagingRepository, loggerFactory, logger);
|
||||
|
||||
var host = new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
Engine = engine,
|
||||
Scanner = scanner,
|
||||
Staging = staging,
|
||||
Usage = usage,
|
||||
TelegramClient = telegram,
|
||||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
|
||||
};
|
||||
|
||||
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
|
||||
|
||||
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
|
||||
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
|
||||
/// </summary>
|
||||
private static (TaskScanner?, StagingService?) BuildTaskboard(
|
||||
InstanceConfig instance,
|
||||
SqliteTaskRepository taskRepository,
|
||||
AgentEngine engine,
|
||||
ToolRegistry tools,
|
||||
SqliteStateStore stateStore,
|
||||
SqliteAuditRepository audit,
|
||||
SqliteStagingRepository stagingRepository,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger logger)
|
||||
{
|
||||
var sharedWorkspace = instance.Agents
|
||||
.Select(a => a.SharedWorkspacePath)
|
||||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sharedWorkspace))
|
||||
return (null, null);
|
||||
|
||||
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||||
|
||||
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit);
|
||||
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||||
|
||||
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory);
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reset = await taskRepository.ReleaseStaleClaimsAsync(
|
||||
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
var coordination = new CoordinationMigration(
|
||||
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
|
||||
var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
|
||||
|
||||
var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
|
||||
var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
|
||||
|
||||
var imported = await board.ImportAllAsync(CancellationToken.None);
|
||||
|
||||
logger.LogInformation(
|
||||
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
|
||||
+ "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
|
||||
imported, migratedCoordination, migratedScheduler, reset);
|
||||
|
||||
scanner.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
|
||||
}
|
||||
});
|
||||
|
||||
return (scanner, staging);
|
||||
}
|
||||
|
||||
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
|
||||
{
|
||||
if (TelegramClient is null || callbacks.TelegramLogin is null) return;
|
||||
|
||||
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
|
||||
|
||||
if (callbacks.Telegram2FA is not null)
|
||||
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
|
||||
|
||||
try
|
||||
{
|
||||
await TelegramClient.ConnectAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
|
||||
/// die einmalige Update-Prüfung.
|
||||
///
|
||||
/// <para>Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
|
||||
/// sie über <c>source</c> + <c>instance</c>. Stürzt eine von mehreren ab, fällt
|
||||
/// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.</para>
|
||||
/// </summary>
|
||||
private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
|
||||
{
|
||||
Deploymentcenter = DeploymentcenterService.TryCreate(
|
||||
Settings.AppSettings, AppVersion, loggerFactory);
|
||||
|
||||
if (Deploymentcenter is null)
|
||||
return;
|
||||
|
||||
_shutdown.Add(Deploymentcenter.DisposeAsync);
|
||||
|
||||
var health = new InstanceHealthProvider(
|
||||
Instance.InstanceName,
|
||||
agentsEnabled: Engine is not null,
|
||||
Instance.Budget,
|
||||
Usage,
|
||||
() => Instance.Agents.Count,
|
||||
() => Engine?.RunningChatCount ?? 0,
|
||||
// „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
|
||||
// Startabgleichung los, der erste Heartbeat geht sofort raus.
|
||||
schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
|
||||
|
||||
await Deploymentcenter.StartWatchdogAsync(
|
||||
Instance, health,
|
||||
saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
|
||||
ct);
|
||||
|
||||
// Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
|
||||
_ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
|
||||
/// Fehlermeldungen, Versionsvergleich. Kommt aus <c><Version></c> in
|
||||
/// <c>Directory.Build.props</c> und wird zur Übersetzungszeit eingebettet
|
||||
/// (<c>Deploymentcenter.BuildInfo.targets</c>) — zusammen mit Commit und Build-Datum.
|
||||
///
|
||||
/// <para>Nicht zu verwechseln mit <c>ClawdDotNet.Core.BuildInfo.Build</c>: das ist
|
||||
/// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.</para>
|
||||
/// </summary>
|
||||
public static string AppVersion => ReleaseInfo.Version;
|
||||
|
||||
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
|
||||
public static string BuildSummary => ReleaseInfo.Summary;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var step in _shutdown)
|
||||
{
|
||||
try { await step(); }
|
||||
catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
|
||||
}
|
||||
|
||||
if (Status is not null) await Status.DisposeAsync();
|
||||
if (Scanner is not null) await Scanner.DisposeAsync();
|
||||
if (TelegramClient is not null) await TelegramClient.DisposeAsync();
|
||||
|
||||
LoggerFactory.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.App</RootNamespace>
|
||||
|
||||
<!-- Erzeugt ClawdDotNet.App.ReleaseInfo (Version, Git-Commit, Build-Datum, Kanal).
|
||||
Bewusst nicht "BuildInfo": Diesen Namen traegt in ClawdDotNet.Core schon ein von
|
||||
Hand gefuehrter Zaehler mit Aenderungstext. Zwei gleichnamige Klassen mit
|
||||
verschiedener Bedeutung waeren eine Falle. -->
|
||||
<DeploymentcenterBuildInfoClass>ReleaseInfo</DeploymentcenterBuildInfoClass>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Version, Commit und Build-Datum zur Uebersetzungszeit einbetten. Vorher wurde die
|
||||
Version an drei Stellen erraten: fest "1.0.0" im SDK, "0.0.<Build>" fuer den
|
||||
Versionsvergleich und nichts am Heartbeat. -->
|
||||
<Import Project="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.BuildInfo.targets" />
|
||||
|
||||
<!-- Bewusst ohne Oberflaechen-Abhaengigkeit: Auf dieser Schicht setzen sowohl die
|
||||
Avalonia-Anwendung als auch der spaetere kopflose Host auf. Wer hier einen
|
||||
Verweis auf Avalonia oder WinForms ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Telegram\ClawdDotNet.Tools.Telegram.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Mail\ClawdDotNet.Tools.Mail.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.FTP\ClawdDotNet.Tools.FTP.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.WebFetch\ClawdDotNet.Tools.WebFetch.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.WebMonitor\ClawdDotNet.Tools.WebMonitor.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentComm\ClawdDotNet.Tools.AgentComm.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentSpawn\ClawdDotNet.Tools.AgentSpawn.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.AgentEditor\ClawdDotNet.Tools.AgentEditor.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
<ProjectReference Include="..\ClawdDotNet.Tools.TelegramClient\ClawdDotNet.Tools.TelegramClient.csproj" />
|
||||
<!-- Deploymentcenter-SDK (Fremdrepo, netstandard2.0;net8.0). Liefert Hardware-ID v2,
|
||||
den verschluesselten Lizenz-Zwischenspeicher und die Update-Pruefung. Watchdog,
|
||||
Fehler-Stream und Bugtracker deckt es nicht ab — die stehen in
|
||||
ClawdDotNet.Core/Deploymentcenter. Cross-Repo-Pfad; langfristig als Git-Submodul
|
||||
unter external/ ablegen. -->
|
||||
<ProjectReference Include="..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
using ClawdDotNet.App.Settings;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Durchsetzung der Lizenz für ClawdDotNet gegen das Lizenzmodul des Deploymentcenters.
|
||||
///
|
||||
/// <para><b>Nur ein Urteil sperrt.</b> Das SDK trennt seit 2.1 zwei Dinge, die vorher
|
||||
/// beide als „Lizenz ungültig" ankamen: eine Aussage des Servers über die Lizenz
|
||||
/// (<c>revoked</c>, <c>expired</c>, <c>not_found</c>, <c>activation_limit</c>,
|
||||
/// <c>suspended</c>, <c>clock_rollback</c>) und ein gescheiterter Versuch, überhaupt
|
||||
/// eine zu bekommen (<see cref="LicenseValidationResult.IsTransient"/>). Nur das Urteil
|
||||
/// beendet die Anwendung. Ein Serverausfall darf nicht jede Installation gleichzeitig
|
||||
/// aussperren.</para>
|
||||
///
|
||||
/// <para>Die Offline-Gnadenfrist steckt im SDK: Es legt nach jeder erfolgreichen Prüfung
|
||||
/// einen mit AES-GCM verschlüsselten, an die Hardware gebundenen Zwischenspeicher an und
|
||||
/// trägt damit über Ausfälle hinweg — begrenzt durch <c>cache_ttl_hours</c> des Projekts
|
||||
/// (Vorgabe 168 h), nicht mehr durch das Ablaufdatum der Lizenz.</para>
|
||||
///
|
||||
/// <para><b>Was diese Fassung nicht kann.</b> Deaktivieren (Aktivierungsplatz freigeben)
|
||||
/// verlangt den <c>shared_key</c> des Servers. Der gehört nicht in eine ausgelieferte
|
||||
/// Anwendung, deshalb läuft der Weg über die Hardware-Liste im WebUI („Freigeben").
|
||||
/// Eine Signaturprüfung findet nicht statt — siehe <see cref="LicenseInfo"/>.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseGate
|
||||
{
|
||||
private readonly SettingsManager _settings;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILicensePrompt _prompt;
|
||||
private readonly LicenseClient _client;
|
||||
private readonly string _serverUrl;
|
||||
|
||||
private string? _hardwareId;
|
||||
|
||||
public LicenseGate(SettingsManager settings, ILogger logger, ILicensePrompt prompt)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_prompt = prompt;
|
||||
|
||||
_serverUrl = string.IsNullOrWhiteSpace(settings.AppSettings.DeploymentcenterUrl)
|
||||
? LicenseInfo.DefaultServerUrl
|
||||
: settings.AppSettings.DeploymentcenterUrl.Trim();
|
||||
|
||||
// Die Version landet in der Aktivierungsliste des Deploymentcenters. Ohne das
|
||||
// trug dort jede Installation dieselbe "1.0.0", obwohl die Spalte dafür da ist.
|
||||
LicenseClient.DefaultAppVersion = ReleaseInfo.Version;
|
||||
|
||||
// Kein eigener HttpClient mehr: Der interne des SDK hat seit 2.1 eine Zeitgrenze
|
||||
// von 15 s. Vorher waren es 100 s — und damit ein Standbild beim Start, wenn der
|
||||
// Server nicht antwortete.
|
||||
_client = new LicenseClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True, wenn eine Serveradresse hinterlegt ist. Ohne sie wird die Prüfung
|
||||
/// übersprungen — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand etwas
|
||||
/// eintragen kann.
|
||||
/// </summary>
|
||||
public bool IsEnforcementConfigured => !string.IsNullOrWhiteSpace(_serverUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Die Hardware-ID v2 dieses Rechners. Einmal berechnet und behalten: Die Ermittlung
|
||||
/// liest unter Linux Dateien und zählt Netzwerkschnittstellen auf.
|
||||
/// </summary>
|
||||
public string HardwareId =>
|
||||
// Voll qualifiziert: Sonst zeigte der Name auf diese Eigenschaft selbst.
|
||||
_hardwareId ??= global::Deploymentcenter.Client.HardwareId
|
||||
.GetHardwareId(LicenseInfo.ProductSlug).HardwareId;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft den hinterlegten Schlüssel erneut — für die laufende Nachprüfung. Ein
|
||||
/// widerrufener Schlüssel schlägt damit auch durch, ohne dass jemand neu startet.
|
||||
/// </summary>
|
||||
public Task<LicenseValidationResult> RevalidateAsync(CancellationToken ct = default)
|
||||
=> ValidateAsync(_settings.AppSettings.LicenseKey, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Startprüfung. Fragt über <see cref="ILicensePrompt"/> nach, bis eine nutzbare
|
||||
/// Lizenz vorliegt, oder gibt <c>false</c> zurück — dann beendet der Aufrufer die
|
||||
/// Anwendung.
|
||||
/// </summary>
|
||||
public async Task<bool> RunStartupCheckAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnforcementConfigured)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Lizenzprüfung nicht konfiguriert (keine Deploymentcenter-URL) – übersprungen.");
|
||||
return true;
|
||||
}
|
||||
|
||||
var key = _settings.AppSettings.LicenseKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
key = await _prompt.RequestKeyAsync(HardwareId, null, null);
|
||||
if (key is null) return false;
|
||||
}
|
||||
|
||||
var result = await ValidateAsync(key, ct);
|
||||
|
||||
while (!result.IsValid)
|
||||
{
|
||||
// Kein Urteil, sondern ein gescheiterter Versuch: weiterlaufen. Ein anderer
|
||||
// Schlüssel würde daran nichts ändern, und danach zu fragen ließe den
|
||||
// Benutzer raten. Der Notausschalter greift, sobald der Server wieder
|
||||
// antwortet (LicenseWatch).
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Lizenz nicht prüfbar ({Status}): {Message} – Start wird fortgesetzt.",
|
||||
result.Status, result.Message);
|
||||
|
||||
await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
|
||||
"Die Lizenz konnte nicht geprüft werden (Server nicht erreichbar oder "
|
||||
+ "Offline-Frist abgelaufen). ClawdDotNet läuft weiter und prüft später "
|
||||
+ "erneut.");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (result.Status == "clock_rollback")
|
||||
{
|
||||
// Bewusst ohne Details: Ein genauer Text wäre eine Bauanleitung.
|
||||
await _prompt.ShowErrorAsync("ClawdDotNet – Lizenz",
|
||||
"Die Lizenzprüfung konnte nicht abgeschlossen werden. "
|
||||
+ "Bitte den Support kontaktieren.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Lizenz abgelehnt: {Status} – {Message} (Produkt {Slug}, Server {Server})",
|
||||
result.Status, result.Message, LicenseInfo.ProductSlug, _serverUrl);
|
||||
|
||||
var retry = await _prompt.RequestKeyAsync(HardwareId, DescribeProblem(result), key);
|
||||
if (retry is null) return false;
|
||||
|
||||
key = retry;
|
||||
result = await ValidateAsync(key, ct);
|
||||
}
|
||||
|
||||
_settings.AppSettings.LicenseKey = key;
|
||||
|
||||
try
|
||||
{
|
||||
_settings.Save();
|
||||
}
|
||||
catch (SettingsPersistenceException ex)
|
||||
{
|
||||
// Der Schlüssel ist gültig, ließ sich aber nicht sichern. Weiterlaufen ja —
|
||||
// beim nächsten Start wird eben erneut gefragt.
|
||||
_logger.LogWarning(ex, "Lizenzschlüssel konnte nicht gespeichert werden");
|
||||
}
|
||||
|
||||
if (result.IsCached)
|
||||
{
|
||||
var until = Describe(result.CacheExpiresAt);
|
||||
|
||||
_logger.LogInformation("Lizenz offline gültig, Gnadenfrist bis {Until}.", until);
|
||||
|
||||
await _prompt.ShowInfoAsync("ClawdDotNet – Lizenz",
|
||||
$"Lizenzserver nicht erreichbar. Offline gültig bis {until}.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "not_found",
|
||||
Message = "Kein Lizenzschlüssel hinterlegt."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _client.ValidateAsync(
|
||||
LicenseInfo.ProductSlug, key, _serverUrl, ReleaseInfo.Version, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Das SDK fängt Netz- und HTTP-Fehler selbst ab und liefert sie als
|
||||
// IsTransient. Was hier noch ankommt, ist unerwartet — und darf trotzdem
|
||||
// nicht als Urteil über die Lizenz gelten.
|
||||
_logger.LogError(ex, "Lizenzprüfung fehlgeschlagen.");
|
||||
|
||||
return new LicenseValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
Status = "server_unavailable",
|
||||
Message = "Lizenz konnte nicht geprüft werden.",
|
||||
IsTransient = true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(long? unixSeconds) =>
|
||||
unixSeconds is { } seconds and > 0
|
||||
? DateTimeOffset.FromUnixTimeSeconds(seconds).LocalDateTime.ToString("g")
|
||||
: "auf Weiteres";
|
||||
|
||||
private static string DescribeProblem(LicenseValidationResult result) => result.Status switch
|
||||
{
|
||||
"revoked" => "Diese Lizenz wurde widerrufen oder diese Hardware ist gesperrt.",
|
||||
"suspended" => "Diese Lizenz ist vorübergehend ausgesetzt.",
|
||||
"expired" => "Diese Lizenz ist abgelaufen.",
|
||||
"activation_limit" => "Das Aktivierungslimit dieser Lizenz ist erreicht. "
|
||||
+ "Ein Platz lässt sich im Deploymentcenter in der Hardware-Liste "
|
||||
+ "über \"Freigeben\" räumen.",
|
||||
// Der Server verwendet not_found für zwei verschiedene Dinge: unbekanntes
|
||||
// Projekt und unbekannter Schlüssel. Welches davon, steht nur in message —
|
||||
// deshalb wird die Serverantwort hier mitgegeben. Ohne sie sieht ein falsch
|
||||
// eingetragener Produkt-Slug wie ein vertippter Lizenzschlüssel aus, und man
|
||||
// sucht am falschen Ende.
|
||||
"not_found" => "Lizenzschlüssel oder Produkt unbekannt. Im Deploymentcenter muss "
|
||||
+ $"ein Projekt mit dem Slug \"{LicenseInfo.ProductSlug}\" angelegt "
|
||||
+ "sein und der Schlüssel dort hinterlegt."
|
||||
+ (string.IsNullOrWhiteSpace(result.Message)
|
||||
? ""
|
||||
: $" (Server: {result.Message})"),
|
||||
_ => string.IsNullOrWhiteSpace(result.Message)
|
||||
? "Die Lizenz ist nicht gültig."
|
||||
: result.Message
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fest hinterlegte Lizenz-Eckdaten.
|
||||
///
|
||||
/// <para><b>Kein Public-Key mehr.</b> Die frühere Fassung führte einen
|
||||
/// Ed25519-Public-Key als „Vertrauensanker". Im Deploymentcenter gibt es dazu keine
|
||||
/// Gegenseite: Der Client liest ausschließlich das Feld <c>status</c> aus der Antwort,
|
||||
/// eine Signaturprüfung findet nicht statt (siehe
|
||||
/// <c>docs/Deploymentcenter-Anbindung-Review.md</c>, Abschnitt 2.1). Ein Schlüssel, der
|
||||
/// nichts prüft, ist schlimmer als keiner — er lässt Schutz vermuten, wo keiner ist.
|
||||
/// Wenn die Signatur zurückkommt, kommt das Feld mit ihr zurück.</para>
|
||||
///
|
||||
/// <para>Praktische Folge, die man kennen sollte: Wer die HTTP-Anfrage umlenken kann
|
||||
/// (hosts-Datei, Proxy, eigener DNS), hat eine gültige Lizenz.</para>
|
||||
/// </summary>
|
||||
public static class LicenseInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Projekt-Slug, wie er in <c>dc_projects</c> angelegt ist. Gilt für alle Module:
|
||||
/// Lizenz, Bugtracker, Fehler-Stream und Update-Prüfung greifen auf dieselbe
|
||||
/// Projekttabelle zu.
|
||||
///
|
||||
/// <para>Hier stand bis zur Umstellung <c>clawd</c> — der Name aus dem
|
||||
/// LicenseLabrador-Backend. Im Deploymentcenter heißt das Projekt
|
||||
/// <c>clawddotnet</c>. Der Server beantwortet einen unbekannten Slug mit demselben
|
||||
/// <c>not_found</c> wie einen unbekannten Schlüssel, weshalb das wie ein falsch
|
||||
/// eingegebener Lizenzschlüssel aussah.</para>
|
||||
/// </summary>
|
||||
public const string ProductSlug = "clawddotnet";
|
||||
|
||||
/// <summary>
|
||||
/// Rückfallwert für die Server-Adresse, falls in den Anwendungseinstellungen keine
|
||||
/// steht. Lizenz, Watchdog, Updates und Bugtracker sind Module derselben Anwendung
|
||||
/// und teilen sich diese Adresse.
|
||||
/// </summary>
|
||||
public const string DefaultServerUrl = "https://dc.mhdf.de";
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft die Lizenz im laufenden Betrieb nach.
|
||||
///
|
||||
/// <para>Ohne das wirkt ein Widerruf erst beim nächsten Start — bei einer Anwendung, die
|
||||
/// als Dienst wochenlang läuft, ist das praktisch nie. Der Takt ist bewusst grob (alle
|
||||
/// zwölf Stunden): Es geht um einen Notausschalter, nicht um eine Zugangskontrolle pro
|
||||
/// Klick.</para>
|
||||
///
|
||||
/// <para><b>Nur ein Urteil zählt.</b> Ein Netzproblem, eine Drosselung oder eine
|
||||
/// abgelaufene Offline-Frist beenden nichts — das SDK meldet solche Fälle als
|
||||
/// <c>IsTransient</c>, und ein Serverausfall darf nicht alle laufenden Instanzen
|
||||
/// mitnehmen. Der verschlüsselte Zwischenspeicher trägt über solche Lücken hinweg.</para>
|
||||
/// </summary>
|
||||
public sealed class LicenseWatch : IAsyncDisposable
|
||||
{
|
||||
private static readonly TimeSpan DefaultInterval = TimeSpan.FromHours(12);
|
||||
|
||||
private readonly LicenseGate _gate;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _interval;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
|
||||
/// <summary>
|
||||
/// Die Lizenz gilt nicht mehr. Der Aufrufer beendet die Anwendung — geordnet, aber
|
||||
/// ohne Rückfrage; der übergebene Text erklärt den Grund.
|
||||
/// </summary>
|
||||
public event Func<string, Task>? Revoked;
|
||||
|
||||
public LicenseWatch(LicenseGate gate, ILogger logger, TimeSpan? interval = null)
|
||||
{
|
||||
_gate = gate;
|
||||
_logger = logger;
|
||||
_interval = interval ?? DefaultInterval;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_loop is { IsCompleted: false })
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = RunAsync(_cts.Token);
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(_interval);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
var result = await _gate.RevalidateAsync(ct).ConfigureAwait(false);
|
||||
|
||||
if (result.IsValid)
|
||||
continue;
|
||||
|
||||
if (result.IsTransient)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Lizenz-Nachprüfung ohne Ergebnis ({Status}) – Betrieb läuft weiter.",
|
||||
result.Status);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Lizenz gilt nicht mehr ({Status}) – Instanz wird beendet.",
|
||||
result.Status);
|
||||
|
||||
if (Revoked is { } handler)
|
||||
{
|
||||
await handler($"Die Lizenz ist nicht mehr gültig ({result.Status}). "
|
||||
+ "ClawdDotNet wird beendet.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Regulärer Stopp.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Die Nachprüfung darf den Betrieb nicht mitnehmen.
|
||||
_logger.LogWarning(ex, "Lizenz-Nachprüfung abgebrochen.");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts is null)
|
||||
return;
|
||||
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClawdDotNet.App.Services;
|
||||
|
||||
/// <summary>Eine gelesene Logzeile.</summary>
|
||||
/// <param name="Module">Der Dateiname ohne Endung — jedes Modul schreibt in seine eigene Datei.</param>
|
||||
/// <param name="Level">INF, WRN, ERR … oder leer, wenn die Zeile kein bekanntes Format hat.</param>
|
||||
public readonly record struct LogLine(string Module, string Level, string Text);
|
||||
|
||||
/// <summary>Ab welcher Stufe angezeigt wird.</summary>
|
||||
public enum LogLevelFilter { All, Info, Warn, Error }
|
||||
|
||||
/// <summary>
|
||||
/// Liest neu hinzugekommene Zeilen aus den Logdateien.
|
||||
///
|
||||
/// <para>Die vorige Fassung (<c>LiveLogViewerService</c>) schrieb unmittelbar in eine
|
||||
/// <c>RichTextBox</c> und taktete über einen <c>System.Windows.Forms.Timer</c> — Lesen
|
||||
/// und Darstellen waren dasselbe Ding und ohne Fenster nicht zu haben. Hier bleibt nur
|
||||
/// das Lesen; was damit geschieht, entscheidet der Aufrufer.</para>
|
||||
///
|
||||
/// <para>Merkt sich je Datei die Leseposition, gibt also bei jedem Aufruf nur das
|
||||
/// Neue zurück. Wird eine Datei kürzer, gilt sie als rotiert und wird von vorn gelesen.</para>
|
||||
/// </summary>
|
||||
public sealed partial class LogTail(string logDirectory)
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, long> _positions = new();
|
||||
|
||||
[GeneratedRegex(@"\[(TRC|DBG|INF|WRN|ERR|FTL)\]", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex LevelPattern();
|
||||
|
||||
/// <summary>Die Module, für die heute Dateien vorliegen — füllt das Auswahlfeld.</summary>
|
||||
public IReadOnlyList<string> AvailableModules()
|
||||
{
|
||||
var directory = TodayDirectory();
|
||||
if (directory is null) return [];
|
||||
|
||||
return Directory.GetFiles(directory, "*.log")
|
||||
.Select(Path.GetFileNameWithoutExtension)
|
||||
.Where(name => !string.IsNullOrEmpty(name))
|
||||
.Select(name => name!)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alles, was seit dem letzten Aufruf dazugekommen ist.
|
||||
///
|
||||
/// <paramref name="module"/> leer heißt: alle Module.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogLine> ReadNew(string? module = null, LogLevelFilter level = LogLevelFilter.All)
|
||||
{
|
||||
var directory = TodayDirectory();
|
||||
if (directory is null) return [];
|
||||
|
||||
var result = new List<LogLine>();
|
||||
|
||||
foreach (var path in Directory.GetFiles(directory, "*.log"))
|
||||
{
|
||||
var moduleName = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
if (!string.IsNullOrEmpty(module)
|
||||
&& !string.Equals(moduleName, module, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Position trotzdem nachziehen: Sonst käme beim Wechsel des Filters
|
||||
// die gesamte bisherige Datei auf einmal herein.
|
||||
TrackWithoutReading(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
ReadFile(path, moduleName, level, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReadFile(string path, string module, LogLevelFilter level, List<LogLine> into)
|
||||
{
|
||||
var lastPosition = _positions.GetOrAdd(path, 0L);
|
||||
|
||||
try
|
||||
{
|
||||
// FileShare.ReadWrite | Delete: Der Schreiber muss weiterarbeiten und die
|
||||
// Datei auch ersetzen können, während wir lesen.
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
|
||||
if (stream.Length < lastPosition)
|
||||
lastPosition = 0; // rotiert oder gekürzt
|
||||
|
||||
if (stream.Length == lastPosition)
|
||||
return;
|
||||
|
||||
stream.Seek(lastPosition, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
|
||||
var lineLevel = ExtractLevel(line);
|
||||
if (!Passes(lineLevel, level)) continue;
|
||||
|
||||
into.Add(new LogLine(module, lineLevel, line));
|
||||
}
|
||||
|
||||
_positions[path] = stream.Position;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Wird gerade geschrieben — beim nächsten Takt erneut versuchen.
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackWithoutReading(string path)
|
||||
{
|
||||
try { _positions[path] = new FileInfo(path).Length; }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
private static string ExtractLevel(string line)
|
||||
{
|
||||
var match = LevelPattern().Match(line);
|
||||
return match.Success ? match.Groups[1].Value.ToUpperInvariant() : "";
|
||||
}
|
||||
|
||||
private static bool Passes(string lineLevel, LogLevelFilter filter) => filter switch
|
||||
{
|
||||
LogLevelFilter.All => true,
|
||||
// Unbekanntes Format durchlassen: Lieber eine Zeile zu viel als eine
|
||||
// Fehlermeldung, die der Filter verschluckt.
|
||||
_ when lineLevel.Length == 0 => true,
|
||||
LogLevelFilter.Info => lineLevel is "INF" or "WRN" or "ERR" or "FTL",
|
||||
LogLevelFilter.Warn => lineLevel is "WRN" or "ERR" or "FTL",
|
||||
LogLevelFilter.Error => lineLevel is "ERR" or "FTL",
|
||||
_ => true
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Das Verzeichnis des heutigen Tages, oder <c>null</c>.
|
||||
///
|
||||
/// <c>DateTime.Now</c> und nicht UTC: Die Schreibseite legt die Verzeichnisse nach
|
||||
/// Ortszeit an, also muss hier dieselbe Rechnung gelten. Auf einem Server mit
|
||||
/// <c>TZ=UTC</c> wechselt der Ordner damit um Mitternacht UTC — richtig, aber
|
||||
/// erwähnenswert, wenn jemand die Umstellung um 02:00 Ortszeit sucht.
|
||||
/// </summary>
|
||||
private string? TodayDirectory()
|
||||
{
|
||||
if (!Directory.Exists(logDirectory)) return null;
|
||||
|
||||
var directory = Path.Combine(logDirectory, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
return Directory.Exists(directory) ? directory : null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Anwendungsweite Einstellungen (nicht instanzgebunden).
|
||||
///
|
||||
/// Die <see cref="CategoryAttribute"/>-, <see cref="DisplayNameAttribute"/>- und
|
||||
/// <see cref="DescriptionAttribute"/>-Angaben stammen aus der PropertyGrid-Zeit. Sie
|
||||
/// bleiben stehen: Sie sind die Beschriftungen und Hilfetexte, aus denen die
|
||||
/// Avalonia-Einstellungsansicht gebaut wird — nur eben von Hand statt automatisch.
|
||||
/// </summary>
|
||||
public sealed class AppSettings
|
||||
{
|
||||
// Die Vorgaben waren "./Logs" und "./Instances" — relativ zum Arbeitsverzeichnis.
|
||||
// Unter Linux liegt die Anwendung in /opt oder /usr/lib und darf dort nicht
|
||||
// schreiben; zudem hing der Ort davon ab, aus welchem Verzeichnis gestartet wurde.
|
||||
// Jetzt absolute Pfade im Datenverzeichnis des Benutzers (siehe AppPaths).
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Log-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem Log-Dateien gespeichert werden.")]
|
||||
[JsonPropertyName("logDirectory")]
|
||||
public string LogDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Logs");
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Instanzen-Verzeichnis")]
|
||||
[Description("Pfad zum Verzeichnis, in dem alle Instanz-Ordner liegen.")]
|
||||
[JsonPropertyName("instancesDirectory")]
|
||||
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
|
||||
|
||||
// DefaultConfigPath ist ersatzlos entfallen. Die Eigenschaft war als „(Legacy)"
|
||||
// markiert und wurde von keiner Stelle mehr gelesen — sie stand nur noch als
|
||||
// relativer Pfad in der Datei und hätte unter Linux ohnehin ins Leere gezeigt.
|
||||
|
||||
[Category("Allgemein")]
|
||||
[DisplayName("Minimaler Log-Level")]
|
||||
[Description("Minimaler Log-Level für die Datei-Logs (Debug, Info, Warn, Error).")]
|
||||
[JsonPropertyName("minimumLogLevel")]
|
||||
public string MinimumLogLevel { get; set; } = "Info";
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Max. Log-Zeilen in UI")]
|
||||
[Description("Maximale Anzahl Zeilen in der Log-RichTextBox bevor bereinigt wird.")]
|
||||
[JsonPropertyName("maxLogLinesInUi")]
|
||||
public int MaxLogLinesInUi { get; set; } = 2000;
|
||||
|
||||
[Category("UI")]
|
||||
[DisplayName("Log-Aktualisierungsintervall (ms)")]
|
||||
[Description("Intervall in Millisekunden, in dem die Log-Anzeige aktualisiert wird.")]
|
||||
[JsonPropertyName("logRefreshIntervalMs")]
|
||||
public int LogRefreshIntervalMs { get; set; } = 500;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("Status-Check-Intervall (Sek)")]
|
||||
[Description("Intervall in Sekunden für den OpenRouter-API-Status-Check.")]
|
||||
[JsonPropertyName("statusCheckIntervalSeconds")]
|
||||
public int StatusCheckIntervalSeconds { get; set; } = 60;
|
||||
|
||||
[Category("API")]
|
||||
[DisplayName("OpenRouter Base-URL")]
|
||||
[Description("Basis-URL der OpenRouter-API.")]
|
||||
[JsonPropertyName("openRouterBaseUrl")]
|
||||
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1/";
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Backup-Verzeichnis")]
|
||||
[Description("Ordner, in dem Sicherungen abgelegt werden.")]
|
||||
[JsonPropertyName("backupDirectory")]
|
||||
public string BackupDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Backups");
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Automatisch sichern")]
|
||||
[Description("Erstellt täglich zur angegebenen Uhrzeit eine Sicherung der laufenden Instanz.")]
|
||||
[JsonPropertyName("autoBackupEnabled")]
|
||||
public bool AutoBackupEnabled { get; set; }
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Uhrzeit der automatischen Sicherung")]
|
||||
[Description("Tageszeit im Format HH:mm.")]
|
||||
[JsonPropertyName("autoBackupTime")]
|
||||
public string AutoBackupTime { get; set; } = "03:00";
|
||||
|
||||
[Category("Backup")]
|
||||
[DisplayName("Aufbewahrte Sicherungen")]
|
||||
[Description("Wie viele Sicherungen je Instanz behalten werden. Ältere werden entfernt. 0 = alle behalten.")]
|
||||
[JsonPropertyName("backupKeepCount")]
|
||||
public int BackupKeepCount { get; set; } = 14;
|
||||
|
||||
// ─── Deploymentcenter ───
|
||||
//
|
||||
// Ein Server, ein Token. Lizenz, Watchdog, Updates, Fehler-Stream und Bugtracker
|
||||
// sind Module derselben Anwendung — die frühere Aufteilung auf zwei Adressen
|
||||
// (watchdog.mhdf.de, license.mhdf.de) mit je eigenem Schlüssel gibt es nicht mehr.
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Server-URL")]
|
||||
[Description("Basis-URL des Deploymentcenters (nur HTTPS, Ausnahme localhost).")]
|
||||
[JsonPropertyName("deploymentcenterUrl")]
|
||||
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Token")]
|
||||
[Description("Master-Token mit den Rechten 'watchdog:ping' und 'bugtracker:report'. " +
|
||||
"Jede Instanz tauscht es beim ersten Start gegen ein eigenes, " +
|
||||
"eingeschränktes Sub-Token. Wird verschlüsselt gespeichert.")]
|
||||
[PasswordPropertyText(true)]
|
||||
[JsonPropertyName("deploymentcenterToken")]
|
||||
public string DeploymentcenterToken { get; set; } = "";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Umgebung")]
|
||||
[Description("Wird an Fehler- und Bugtracker-Meldungen gehängt: production oder development.")]
|
||||
[JsonPropertyName("deploymentcenterEnvironment")]
|
||||
public string DeploymentcenterEnvironment { get; set; } = "production";
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Fehler automatisch melden")]
|
||||
[Description("Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. " +
|
||||
"Derselbe Fehler geht höchstens alle fünf Minuten einmal raus.")]
|
||||
[JsonPropertyName("errorReportingEnabled")]
|
||||
public bool ErrorReportingEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Beim Start auf Updates prüfen")]
|
||||
[Description("Fragt einmalig beim Start, ob ein neueres Release vorliegt. Blockiert nicht.")]
|
||||
[JsonPropertyName("updateCheckEnabled")]
|
||||
public bool UpdateCheckEnabled { get; set; } = true;
|
||||
|
||||
[Category("Deploymentcenter")]
|
||||
[DisplayName("Update-Kanal")]
|
||||
[Description("prod, beta oder dev.")]
|
||||
[JsonPropertyName("updateChannel")]
|
||||
public string UpdateChannel { get; set; } = "prod";
|
||||
|
||||
// ─── Lizenz ───
|
||||
|
||||
[Category("Lizenz")]
|
||||
[DisplayName("Lizenzschlüssel")]
|
||||
[Description("Der Lizenzschlüssel für ClawdDotNet. Wird verschlüsselt gespeichert.")]
|
||||
[PasswordPropertyText(true)]
|
||||
[JsonPropertyName("licenseKey")]
|
||||
public string LicenseKey { get; set; } = "";
|
||||
|
||||
// LicensePublicKeyBase64 und LicenseEndpoints sind entfallen. Das Deploymentcenter
|
||||
// signiert seine Antworten nicht (siehe LicenseInfo), ein Public-Key hätte also
|
||||
// nichts zu prüfen; und der Lizenzserver ist dasselbe Deploymentcenter, dessen
|
||||
// Adresse oben steht — zwei Felder für eine Adresse waren nur eine Gelegenheit,
|
||||
// sie widersprüchlich zu füllen.
|
||||
|
||||
public override string ToString() => "Anwendungseinstellungen";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.App.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt und speichert die anwendungsweiten Einstellungen.
|
||||
///
|
||||
/// <para><b>Ablageort.</b> Bis zur Linux-Portierung lag <c>Settings.json</c> neben der
|
||||
/// Programmdatei. Unter Windows in einem Benutzerverzeichnis ging das; unter Linux liegt
|
||||
/// die Anwendung in <c>/opt</c> oder <c>/usr/lib</c> und ist für den Dienstbenutzer nicht
|
||||
/// beschreibbar. Jetzt entscheidet <see cref="AppPaths.ConfigDirectory"/> — XDG unter
|
||||
/// Linux, <c>%APPDATA%</c> unter Windows, per <c>CLAWD_CONFIG_DIR</c> überschreibbar.</para>
|
||||
///
|
||||
/// <para><b>Kein Migrationspfad.</b> Bewusst: Zum Zeitpunkt der Umstellung lief noch
|
||||
/// keine Installation produktiv. Eine bestehende <c>Settings.json</c> neben der
|
||||
/// Programmdatei wird also <em>nicht</em> übernommen — der Ort wechselt einmal sauber,
|
||||
/// statt eine Ausweichlogik zu hinterlassen, die niemand mehr anfasst.</para>
|
||||
///
|
||||
/// <para><b>Keine Meldungsfenster.</b> Diese Schicht kennt keine Oberfläche. Ein
|
||||
/// Speicherfehler kommt als <see cref="SettingsPersistenceException"/> heraus; ob daraus
|
||||
/// ein Dialog, ein Logeintrag oder ein Rückgabewert wird, entscheidet der Aufrufer.</para>
|
||||
/// </summary>
|
||||
public sealed class SettingsManager
|
||||
{
|
||||
private const string SettingsFileName = "Settings.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public AppSettings AppSettings { get; private set; } = new();
|
||||
|
||||
/// <summary>Der Ort der Einstellungsdatei — für Meldungen und Diagnose.</summary>
|
||||
public string SettingsPath => _settingsPath;
|
||||
|
||||
public SettingsManager(string? basePath = null)
|
||||
{
|
||||
var dir = basePath ?? AppPaths.ConfigDirectory;
|
||||
_settingsPath = Path.Combine(dir, SettingsFileName);
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
if (!File.Exists(_settingsPath))
|
||||
{
|
||||
AppSettings = new AppSettings();
|
||||
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = AtomicFile.ReadAllText(_settingsPath);
|
||||
AppSettings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
|
||||
?? new AppSettings();
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException)
|
||||
{
|
||||
// Eine unlesbare Datei darf den Start nicht verhindern — mit Vorgaben
|
||||
// weiterzumachen ist besser, als gar nicht zu starten.
|
||||
AppSettings = new AppSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
// Geheimnisse liegen in der Datei verschlüsselt und werden zur Laufzeit im
|
||||
// Klartext gehalten.
|
||||
AppSettings.LicenseKey = TryUnprotect(AppSettings.LicenseKey);
|
||||
AppSettings.DeploymentcenterToken = TryUnprotect(AppSettings.DeploymentcenterToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner,
|
||||
/// Umzug von Windows) leer, damit der Nutzer ihn neu eintragen kann statt einen
|
||||
/// unbrauchbaren Wert an eine Gegenstelle zu schicken.
|
||||
/// </summary>
|
||||
private static string TryUnprotect(string value)
|
||||
{
|
||||
try { return SecretProtector.Unprotect(value) ?? ""; }
|
||||
catch (SecretProtectionException) { return ""; }
|
||||
}
|
||||
|
||||
/// <exception cref="SettingsPersistenceException">Wenn die Datei nicht geschrieben werden kann.</exception>
|
||||
public void Save()
|
||||
{
|
||||
// Nur zum Schreiben verschlüsseln; die laufende Instanz braucht Klartext.
|
||||
var plainLicenseKey = AppSettings.LicenseKey;
|
||||
var plainDeploymentcenterToken = AppSettings.DeploymentcenterToken;
|
||||
|
||||
try
|
||||
{
|
||||
AppSettings.LicenseKey = SecretProtector.Protect(plainLicenseKey) ?? "";
|
||||
AppSettings.DeploymentcenterToken = SecretProtector.Protect(plainDeploymentcenterToken) ?? "";
|
||||
|
||||
AppPaths.EnsureDirectory(Path.GetDirectoryName(_settingsPath)!);
|
||||
|
||||
var json = JsonSerializer.Serialize(AppSettings, JsonOptions);
|
||||
AtomicFile.WriteAllText(_settingsPath, json);
|
||||
AppPaths.RestrictToOwner(_settingsPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException
|
||||
or SecretProtectionException)
|
||||
{
|
||||
throw new SettingsPersistenceException(
|
||||
$"Die Einstellungen konnten nicht nach {_settingsPath} geschrieben werden: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AppSettings.LicenseKey = plainLicenseKey;
|
||||
AppSettings.DeploymentcenterToken = plainDeploymentcenterToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SettingsPersistenceException(string message, Exception inner)
|
||||
: Exception(message, inner);
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>Ausgang eines Tool-Aufrufs, von der Engine festgestellt.</summary>
|
||||
public enum AuditStatus
|
||||
{
|
||||
/// <summary>Tool lief und lieferte ein Ergebnis.</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>Tool meldete einen Fehler oder warf eine Ausnahme.</summary>
|
||||
Error,
|
||||
|
||||
/// <summary>Das <see cref="Security.PermissionGate"/> hat den Aufruf abgelehnt.</summary>
|
||||
Denied,
|
||||
|
||||
/// <summary>Das angeforderte Tool ist dem Agenten nicht zugewiesen/unbekannt.</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.</summary>
|
||||
Staged
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag im Audit-Log: ein einzelner Tool-Aufruf, wie die Engine ihn gesehen hat.
|
||||
///
|
||||
/// Die Herkunft wird von der <b>Engine gestempelt</b>, nie vom Agenten behauptet:
|
||||
/// <see cref="AgentId"/>, <see cref="Model"/> und <see cref="Source"/> stammen aus dem
|
||||
/// Wissen der Engine über den Lauf, nicht aus dem Tool-Ergebnis. Einträge sind
|
||||
/// unveränderlich — eine Korrektur ist ein neuer Eintrag, kein Überschreiben.
|
||||
/// </summary>
|
||||
public sealed record AuditEntry
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.</summary>
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
public string AgentId { get; init; } = "";
|
||||
|
||||
/// <summary>Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).</summary>
|
||||
public string Model { get; init; } = "";
|
||||
|
||||
/// <summary>Verantwortliche Session/Kanal (webview, telegram, task …). <c>unknown</c>,
|
||||
/// wenn nicht bekannt — geraten wird nichts.</summary>
|
||||
public string Source { get; init; } = AuditSource.Unknown;
|
||||
|
||||
public string Tool { get; init; } = "";
|
||||
|
||||
/// <summary>Übergebene Argumente (gekappt). Rohdaten, wie das Modell sie schickte.</summary>
|
||||
public string Arguments { get; init; } = "";
|
||||
|
||||
public AuditStatus Status { get; init; }
|
||||
|
||||
/// <summary>Kurze Notiz zum Ausgang (Fehlermeldung, knapper Hinweis).</summary>
|
||||
public string Summary { get; init; } = "";
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Abschluss-Beleg eines Laufs (Receipt): das Ergebnis mit Schritten, Tokens und Kosten.
|
||||
/// Verknüpft <c>RunUsage</c> mit einem Task und macht so C7 („Kosten pro Ergebnis")
|
||||
/// weitgehend zum Abfallprodukt.
|
||||
/// </summary>
|
||||
public sealed record RunReceipt
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string RunId { get; init; } = "";
|
||||
public string AgentId { get; init; } = "";
|
||||
public string Model { get; init; } = "";
|
||||
public string Source { get; init; } = AuditSource.Unknown;
|
||||
|
||||
/// <summary>Verknüpfter Task, falls der Lauf aus dem Taskboard kam — sonst <c>null</c>.</summary>
|
||||
public string? TaskId { get; init; }
|
||||
|
||||
/// <summary>Endzustand (aus <c>AgentRunStatus</c>).</summary>
|
||||
public string Status { get; init; } = "";
|
||||
|
||||
public int StepCount { get; init; }
|
||||
public int PromptTokens { get; init; }
|
||||
public int CompletionTokens { get; init; }
|
||||
public int CachedTokens { get; init; }
|
||||
|
||||
public decimal CostUsd { get; init; }
|
||||
public bool CostIsKnown { get; init; }
|
||||
|
||||
public long DurationMs { get; init; }
|
||||
|
||||
/// <summary>Kurzer Verweis auf das Ergebnis (gekappte Schlussnachricht).</summary>
|
||||
public string ResultRef { get; init; } = "";
|
||||
|
||||
public DateTime OccurredAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit
|
||||
/// <c>ChatSource</c>; <see cref="Unknown"/> steht für „nicht bekannt", nicht für geraten.</summary>
|
||||
public static class AuditSource
|
||||
{
|
||||
public const string Unknown = "unknown";
|
||||
|
||||
/// <summary>Ein direkter, quellenloser Lauf (z. B. RunAsync ohne Kanal).</summary>
|
||||
public const string Direct = "direct";
|
||||
|
||||
/// <summary>Ausführung eines freigegebenen, eingefrorenen Aufrufs (A2).</summary>
|
||||
public const string Approval = "approval";
|
||||
|
||||
public static string Normalize(string? source)
|
||||
=> string.IsNullOrWhiteSpace(source) ? Unknown : source.Trim();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Das Audit-Log (A3): append-only. Es gibt kein Ändern und kein Löschen — Korrekturen
|
||||
/// sind neue Einträge. Das ist die bewusste Designregel, nicht eine fehlende Funktion.
|
||||
/// </summary>
|
||||
public interface IAuditRepository
|
||||
{
|
||||
/// <summary>Schreibt einen Tool-Aufruf ins Log.</summary>
|
||||
Task AppendAsync(AuditEntry entry, CancellationToken ct);
|
||||
|
||||
/// <summary>Hält den Abschluss-Beleg eines Laufs fest.</summary>
|
||||
Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct);
|
||||
|
||||
/// <summary>Die jüngsten Log-Einträge (für eine Übersicht/Diagnose).</summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListRecentAsync(int limit, CancellationToken ct);
|
||||
|
||||
/// <summary>Alle Aufrufe eines Laufs, in zeitlicher Reihenfolge.</summary>
|
||||
Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct);
|
||||
|
||||
/// <summary>Der Abschluss-Beleg eines Laufs, falls vorhanden.</summary>
|
||||
Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct);
|
||||
|
||||
/// <summary>Alle Belege zu einem Task — die Kosten pro Ergebnis (C7).</summary>
|
||||
Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct);
|
||||
|
||||
/// <summary>Zahl der Log-Einträge insgesamt.</summary>
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Das Audit-Log in der Instanz-Datenbank. Bewusst nur Einfügen und Lesen — es gibt keine
|
||||
/// Update-/Delete-Methoden, weil die Unveränderlichkeit die eigentliche Zusage ist.
|
||||
/// </summary>
|
||||
public sealed class SqliteAuditRepository : IAuditRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteAuditRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task AppendAsync(AuditEntry entry, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO AuditLog
|
||||
(RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", entry.RunId);
|
||||
cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
|
||||
cmd.Parameters.AddWithValue("@model", entry.Model);
|
||||
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
|
||||
cmd.Parameters.AddWithValue("@tool", entry.Tool);
|
||||
cmd.Parameters.AddWithValue("@arguments", entry.Arguments);
|
||||
cmd.Parameters.AddWithValue("@status", entry.Status.ToString());
|
||||
cmd.Parameters.AddWithValue("@summary", entry.Summary);
|
||||
cmd.Parameters.AddWithValue("@durationMs", entry.DurationMs);
|
||||
cmd.Parameters.AddWithValue("@occurredAt", Format(entry.OccurredAt));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public Task RecordReceiptAsync(RunReceipt receipt, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO RunReceipts
|
||||
(RunId, AgentId, Model, Source, TaskId, Status, StepCount,
|
||||
PromptTokens, CompletionTokens, CachedTokens, CostUsd, CostIsKnown,
|
||||
DurationMs, ResultRef, OccurredAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @model, @source, @taskId, @status, @stepCount,
|
||||
@prompt, @completion, @cached, @cost, @costKnown,
|
||||
@durationMs, @resultRef, @occurredAt)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", receipt.RunId);
|
||||
cmd.Parameters.AddWithValue("@agentId", receipt.AgentId);
|
||||
cmd.Parameters.AddWithValue("@model", receipt.Model);
|
||||
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(receipt.Source));
|
||||
cmd.Parameters.AddWithValue("@taskId", (object?)receipt.TaskId ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@status", receipt.Status);
|
||||
cmd.Parameters.AddWithValue("@stepCount", receipt.StepCount);
|
||||
cmd.Parameters.AddWithValue("@prompt", receipt.PromptTokens);
|
||||
cmd.Parameters.AddWithValue("@completion", receipt.CompletionTokens);
|
||||
cmd.Parameters.AddWithValue("@cached", receipt.CachedTokens);
|
||||
cmd.Parameters.AddWithValue("@cost", receipt.CostUsd.ToString(CultureInfo.InvariantCulture));
|
||||
cmd.Parameters.AddWithValue("@costKnown", receipt.CostIsKnown ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@durationMs", receipt.DurationMs);
|
||||
cmd.Parameters.AddWithValue("@resultRef", receipt.ResultRef);
|
||||
cmd.Parameters.AddWithValue("@occurredAt", Format(receipt.OccurredAt));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog ORDER BY Id DESC LIMIT @limit";
|
||||
cmd.Parameters.AddWithValue("@limit", Math.Clamp(limit, 1, 1000));
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AuditEntry>> ListForRunAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + AuditColumns + " FROM AuditLog WHERE RunId = @runId ORDER BY Id";
|
||||
cmd.Parameters.AddWithValue("@runId", runId);
|
||||
return await ReadEntriesAsync(cmd, ct);
|
||||
}
|
||||
|
||||
public async Task<RunReceipt?> GetReceiptForRunAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE RunId = @runId ORDER BY Id DESC LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@runId", runId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? ReadReceipt(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RunReceipt>> ListReceiptsForTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + ReceiptColumns + " FROM RunReceipts WHERE TaskId = @taskId ORDER BY Id";
|
||||
cmd.Parameters.AddWithValue("@taskId", taskId);
|
||||
|
||||
var results = new List<RunReceipt>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(ReadReceipt(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM AuditLog";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string AuditColumns =
|
||||
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt";
|
||||
|
||||
private const string ReceiptColumns =
|
||||
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
|
||||
"CompletionTokens, CachedTokens, CostUsd, CostIsKnown, DurationMs, ResultRef, OccurredAt";
|
||||
|
||||
private static async Task<IReadOnlyList<AuditEntry>> ReadEntriesAsync(SqliteCommand cmd, CancellationToken ct)
|
||||
{
|
||||
var results = new List<AuditEntry>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(ReadEntry(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
private static AuditEntry ReadEntry(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetInt64(0),
|
||||
RunId = r.GetString(1),
|
||||
AgentId = r.GetString(2),
|
||||
Model = r.GetString(3),
|
||||
Source = r.GetString(4),
|
||||
Tool = r.GetString(5),
|
||||
Arguments = r.GetString(6),
|
||||
Status = Enum.TryParse<AuditStatus>(r.GetString(7), out var s) ? s : AuditStatus.Ok,
|
||||
Summary = r.GetString(8),
|
||||
DurationMs = r.GetInt64(9),
|
||||
OccurredAt = Parse(r.GetString(10))
|
||||
};
|
||||
|
||||
private static RunReceipt ReadReceipt(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetInt64(0),
|
||||
RunId = r.GetString(1),
|
||||
AgentId = r.GetString(2),
|
||||
Model = r.GetString(3),
|
||||
Source = r.GetString(4),
|
||||
TaskId = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
Status = r.GetString(6),
|
||||
StepCount = r.GetInt32(7),
|
||||
PromptTokens = r.GetInt32(8),
|
||||
CompletionTokens = r.GetInt32(9),
|
||||
CachedTokens = r.GetInt32(10),
|
||||
CostUsd = decimal.TryParse(r.GetString(11), NumberStyles.Any, CultureInfo.InvariantCulture, out var c) ? c : 0m,
|
||||
CostIsKnown = r.GetInt32(12) != 0,
|
||||
DurationMs = r.GetInt64(13),
|
||||
ResultRef = r.GetString(14),
|
||||
OccurredAt = Parse(r.GetString(15))
|
||||
};
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -377,11 +377,10 @@ public sealed class BackupService
|
||||
var root = Path.GetFullPath(targetDir);
|
||||
var full = Path.GetFullPath(Path.Combine(root, relative));
|
||||
|
||||
var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
|
||||
? root
|
||||
: root + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!full.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||
// Der Vergleich muss dem Dateisystem folgen: Unter Linux sind "Ziel" und "ziel"
|
||||
// zwei Verzeichnisse, und ein Eintrag darf auch nicht über eine symbolische
|
||||
// Verknüpfung hinauszeigen. Beides steckt in PathBoundary.
|
||||
if (!PathBoundary.IsInside(full, root))
|
||||
throw new BackupException($"Eintrag '{relative}' zeigt aus dem Zielverzeichnis heraus.");
|
||||
|
||||
return full;
|
||||
|
||||
@@ -188,6 +188,14 @@ public sealed class LoopGuardConfig
|
||||
[JsonPropertyName("maxContextTokens")]
|
||||
public int MaxContextTokens { get; set; } = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Obergrenze für die Ausgabe eines einzelnen Schritts (<c>max_tokens</c> im Request).
|
||||
/// Deckelt die teuerste Token-Art gegen Ausreißer (B11/T8). 0 = keine Angabe, dann gilt
|
||||
/// der Standard des Anbieters.
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxResponseTokens")]
|
||||
public int MaxResponseTokens { get; set; } = 8_192;
|
||||
|
||||
[JsonPropertyName("compactionThreshold")]
|
||||
public double CompactionThreshold { get; set; } = 0.80;
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("telegramClient")]
|
||||
public TelegramClientConfig? TelegramClient { get; set; }
|
||||
|
||||
/// <summary>Anbindung an das Watchdog-Modul des Deploymentcenters (Instanz-Heartbeat).</summary>
|
||||
[JsonPropertyName("watchdog")]
|
||||
public WatchdogConfig Watchdog { get; set; } = new();
|
||||
|
||||
/// <summary>Tagesgrenzen über alle Agenten der Instanz hinweg. 0 = keine Grenze.</summary>
|
||||
[JsonPropertyName("budget")]
|
||||
public InstanceBudget Budget { get; set; } = new();
|
||||
|
||||
@@ -34,6 +34,7 @@ public static class BuiltInServices
|
||||
public const string AgentChatWebUI = "AgentChatWebUI";
|
||||
public const string AgentWebsite = "AgentWebsite";
|
||||
public const string ClawdDotNetApi = "ClawdDotNetApi";
|
||||
public const string InstanceWatchdog = "InstanceWatchdog";
|
||||
|
||||
public static List<ServiceConfig> CreateDefaults() =>
|
||||
[
|
||||
@@ -69,6 +70,17 @@ public static class BuiltInServices
|
||||
AutoStart = true,
|
||||
BuiltIn = true,
|
||||
Description = "REST-API für die Kommunikation mit der WebApp"
|
||||
},
|
||||
new()
|
||||
{
|
||||
ServiceId = "svc_watchdog",
|
||||
Name = "Instanz-Watchdog",
|
||||
Type = InstanceWatchdog,
|
||||
Port = 0,
|
||||
Enabled = false,
|
||||
AutoStart = true,
|
||||
BuiltIn = true,
|
||||
Description = "Sendet Heartbeats ans Deploymentcenter (URL/Token in den Anwendungseinstellungen)"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Core.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Pro-Instanz-Teil der Watchdog-Anbindung ans Deploymentcenter.
|
||||
///
|
||||
/// <para><b>Ein Monitor je Instanz.</b> Der Monitor wird serverseitig über das Paar
|
||||
/// <c>source</c> + <c>instance</c> geführt. Alle Instanzen melden unter derselben
|
||||
/// <see cref="Source"/> und tragen ihre eigene <see cref="Instance"/> — damit hat jede
|
||||
/// laufende Instanz einen eigenen Zustand, ein eigenes Intervall und einen eigenen
|
||||
/// Metrik-Verlauf. Fällt eine von dreien aus, fällt genau deren Monitor.</para>
|
||||
///
|
||||
/// <para>Server-URL und das anwendungsweite Token liegen in den Anwendungseinstellungen.
|
||||
/// Beim ersten Start tauscht die Instanz das Token gegen ein eigenes, eingeschränktes
|
||||
/// Sub-Token (<c>/api/tokens/v1/provision</c>) und legt es hier verschlüsselt ab —
|
||||
/// danach liegt auf der Instanz nicht mehr das Master-Token.</para>
|
||||
///
|
||||
/// <para>Ein/Aus läuft über den eingebauten Dienst <c>InstanceWatchdog</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class WatchdogConfig
|
||||
{
|
||||
/// <summary>Dienst-Kennung im Deploymentcenter. Alle Instanzen teilen sich dieselbe Source.</summary>
|
||||
[JsonPropertyName("source")]
|
||||
public string Source { get; set; } = "clawddotnet";
|
||||
|
||||
/// <summary>
|
||||
/// Name dieser Instanz im Monitor. Leer bedeutet: die <c>InstanceId</c> wird
|
||||
/// verwendet — stabil, aber im Dashboard nichtssagend. Wer lesbare Namen möchte,
|
||||
/// trägt hier einen ein; ein späterer Wechsel legt allerdings einen neuen Monitor an.
|
||||
/// </summary>
|
||||
[JsonPropertyName("instance")]
|
||||
public string Instance { get; set; } = "";
|
||||
|
||||
/// <summary>Gruppierung im Dashboard (reine Anzeige, keine Hierarchie).</summary>
|
||||
[JsonPropertyName("group")]
|
||||
public string Group { get; set; } = "ClawdDotNet";
|
||||
|
||||
/// <summary>
|
||||
/// Sende-Takt in Sekunden. Daraus leitet der Evaluator die Schwellen ab:
|
||||
/// nach dem Doppelten <c>warning</c>, nach dem Vierfachen <c>down</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("intervalSeconds")]
|
||||
public int IntervalSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Das für diese Instanz ausgestellte Sub-Token. Wird automatisch gesetzt und
|
||||
/// verschlüsselt gespeichert.
|
||||
/// </summary>
|
||||
[JsonPropertyName("agentToken")]
|
||||
public string AgentToken { get; set; } = "";
|
||||
|
||||
/// <summary>True, sobald ein eigenes Token vorliegt.</summary>
|
||||
[JsonIgnore]
|
||||
public bool HasToken => !string.IsNullOrWhiteSpace(AgentToken);
|
||||
|
||||
/// <summary>Der Wert, der als <c>instance</c> gemeldet wird.</summary>
|
||||
public string ResolveInstance(string instanceId) =>
|
||||
string.IsNullOrWhiteSpace(Instance) ? instanceId : Instance.Trim();
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Was aus einem Bugtracker-Eintrag geworden ist.</summary>
|
||||
/// <param name="ItemId">Nummer des Eintrags im Deploymentcenter.</param>
|
||||
/// <param name="IsNew">False, wenn ein bestehender Eintrag hochgezählt wurde.</param>
|
||||
/// <param name="OccurrenceCount">Wie oft dieses Vorkommnis bisher gezählt wurde.</param>
|
||||
/// <param name="Url">Adresse der Übersicht, für einen Hinweis an den Benutzer.</param>
|
||||
public sealed record BugtrackerReport(long ItemId, bool IsNew, int OccurrenceCount, string? Url);
|
||||
|
||||
/// <summary>Art eines Eintrags. Der Server kennt darüber hinaus noch <c>idea</c>.</summary>
|
||||
public static class BugtrackerItemType
|
||||
{
|
||||
public const string Bug = "bug";
|
||||
public const string Feature = "feature";
|
||||
public const string Idea = "idea";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an <c>POST /api/bugtracker/v1/report</c> — der Weg, auf dem ClawdDotNet
|
||||
/// selbst (oder ein Benutzer über die Oberfläche) einen Fehler oder Wunsch einträgt.
|
||||
///
|
||||
/// <para>Abgegrenzt vom <see cref="ErrorReporter"/>: Der meldet <em>ungefangene</em>
|
||||
/// Ausnahmen automatisch; hier geht es um bewusst formulierte Einträge mit Titel und
|
||||
/// Beschreibung. Serverseitig landen beide in derselben Tabelle — was richtig ist,
|
||||
/// denn ein zweiter Speicher wäre nur ein zweiter Ort, an dem man suchen müsste.</para>
|
||||
///
|
||||
/// <para><b>Der Absender kommt aus dem Token</b> und lässt sich nicht frei wählen —
|
||||
/// sonst könnte sich ein Agent als ein anderer ausgeben.</para>
|
||||
/// </summary>
|
||||
public sealed class BugtrackerClient(
|
||||
DeploymentcenterApi api, string projectSlug, string environment, string build)
|
||||
{
|
||||
/// <param name="type">Siehe <see cref="BugtrackerItemType"/>.</param>
|
||||
/// <param name="clientRef">
|
||||
/// Freier Idempotenz-Schlüssel. Zweimal derselbe Wert erzeugt keinen zweiten
|
||||
/// Eintrag — nützlich, wenn eine Meldung nach einem Verbindungsabbruch wiederholt
|
||||
/// wird.
|
||||
/// </param>
|
||||
public async Task<BugtrackerReport> ReportAsync(
|
||||
string type,
|
||||
string title,
|
||||
string? description = null,
|
||||
string severity = "medium",
|
||||
string? clientRef = null,
|
||||
IReadOnlyDictionary<string, object?>? context = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project_slug"] = projectSlug,
|
||||
["type"] = type,
|
||||
["title"] = title,
|
||||
["description"] = description,
|
||||
["severity"] = severity,
|
||||
["environment"] = environment,
|
||||
["build_version"] = build
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(clientRef))
|
||||
payload["client_ref"] = clientRef;
|
||||
|
||||
if (context is { Count: > 0 })
|
||||
payload["context"] = context;
|
||||
|
||||
var response = await api.PostAsync("/api/bugtracker/v1/report", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new BugtrackerReport(
|
||||
ReadLong(response, "item_id"),
|
||||
ReadBool(response, "is_new"),
|
||||
(int)ReadLong(response, "occurrence_count"),
|
||||
ReadString(response, "url"));
|
||||
}
|
||||
|
||||
private static long ReadLong(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt64()
|
||||
: 0;
|
||||
|
||||
private static bool ReadBool(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
|
||||
|
||||
private static string? ReadString(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>
|
||||
/// Der gemeinsame Unterbau für alle Deploymentcenter-Module (Watchdog, Fehler-Stream,
|
||||
/// Bugtracker, Token-Provisionierung).
|
||||
///
|
||||
/// <para>Alle JSON-Endpunkte antworten einheitlich mit einem Umschlag —
|
||||
/// <c>{"status":"success",…}</c> bzw. <c>{"status":"error","error":{"code":…}}</c>. Der
|
||||
/// <c>code</c> ist stabil und für Programme gedacht, die <c>message</c> für Menschen.
|
||||
/// Diese Klasse packt den Umschlag aus und macht aus einem Fehler eine
|
||||
/// <see cref="DeploymentcenterException"/> mit dem Code daran.</para>
|
||||
///
|
||||
/// <para><b>Ausnahme:</b> Die Lizenz-Endpunkte tragen diesen Umschlag bewusst
|
||||
/// <em>nicht</em> — dort steht im Feld <c>status</c> der Lizenzzustand. Sie werden
|
||||
/// deshalb nicht hierüber, sondern über <c>Deploymentcenter.Client</c> angesprochen.</para>
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterApi : IDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly bool _ownsHttp;
|
||||
private readonly string _token;
|
||||
|
||||
/// <summary>Die Basis-URL ohne abschließenden Schrägstrich.</summary>
|
||||
public string BaseUrl { get; }
|
||||
|
||||
/// <param name="baseUrl">Basis-URL des Deploymentcenters, etwa <c>https://dc.mhdf.de</c>.</param>
|
||||
/// <param name="token">Token mit den nötigen Rechten. Geht als <c>Authorization: Bearer</c> mit.</param>
|
||||
/// <param name="httpClient">Nur für Tests — sonst wird ein eigener mit Zeitgrenze erstellt.</param>
|
||||
public DeploymentcenterApi(string baseUrl, string token, HttpClient? httpClient = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
throw new ArgumentException("Deploymentcenter-URL fehlt.", nameof(baseUrl));
|
||||
|
||||
// Über eine ungesicherte Verbindung ginge das Token im Klartext. Ausnahme ist
|
||||
// nur der eigene Rechner — dort gibt es keine Strecke, auf der jemand mithören
|
||||
// könnte, und eine lokale Testinstallation hat selten ein Zertifikat.
|
||||
if (!IsAcceptableUrl(baseUrl))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Deploymentcenter-URL muss mit https:// beginnen (Ausnahme: localhost).",
|
||||
nameof(baseUrl));
|
||||
}
|
||||
|
||||
BaseUrl = baseUrl.TrimEnd('/');
|
||||
_token = token ?? throw new ArgumentNullException(nameof(token));
|
||||
|
||||
_ownsHttp = httpClient is null;
|
||||
_http = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
}
|
||||
|
||||
private static bool IsAcceptableUrl(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
return true;
|
||||
|
||||
return uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback;
|
||||
}
|
||||
|
||||
public async Task<JsonElement> PostAsync(string path, object payload, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + path)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return await SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<JsonElement> GetAsync(string path, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + path);
|
||||
return await SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<JsonElement> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
if (_token.Length > 0)
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + _token);
|
||||
|
||||
using var response = await _http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
|
||||
JsonElement root;
|
||||
try
|
||||
{
|
||||
// Geklont, weil das JsonDocument am Ende dieses Blocks freigegeben wird —
|
||||
// ein JsonElement daraus wäre danach nicht mehr lesbar.
|
||||
using var document = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
||||
root = document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"invalid_response",
|
||||
$"Antwort war kein JSON (HTTP {(int)response.StatusCode}).",
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
if (IsErrorEnvelope(root, out var code, out var message))
|
||||
throw new DeploymentcenterException(code, message, response.StatusCode);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"http_error",
|
||||
$"Deploymentcenter antwortete HTTP {(int)response.StatusCode}.",
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool IsErrorEnvelope(JsonElement root, out string code, out string message)
|
||||
{
|
||||
code = "error";
|
||||
message = "Unbekannter Fehler.";
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
if (!root.TryGetProperty("status", out var status)
|
||||
|| status.ValueKind != JsonValueKind.String
|
||||
|| !string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (error.TryGetProperty("code", out var c) && c.ValueKind == JsonValueKind.String)
|
||||
code = c.GetString() ?? code;
|
||||
|
||||
if (error.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String)
|
||||
message = m.GetString() ?? message;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttp)
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein vom Deploymentcenter abgelehnter Aufruf. <see cref="Code"/> ist der stabile
|
||||
/// Fehlercode aus dem Umschlag (<c>unauthorized</c>, <c>rate_limited</c>, …) — er ist
|
||||
/// zum Auswerten gedacht, der Text nicht.
|
||||
/// </summary>
|
||||
public sealed class DeploymentcenterException(string code, string message, HttpStatusCode statusCode)
|
||||
: Exception($"{message} [{code}]")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
|
||||
public HttpStatusCode StatusCode { get; } = statusCode;
|
||||
|
||||
/// <summary>Token fehlt, ist abgelaufen oder deckt das nötige Recht nicht ab.</summary>
|
||||
public bool IsAuthorizationProblem =>
|
||||
StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Meldet Laufzeitfehler an das Deploymentcenter.</summary>
|
||||
public interface IErrorReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Meldet eine Ausnahme. <paramref name="fatal"/> heißt: Der Prozess endet daran.
|
||||
/// Gibt zurück, ob die Meldung angekommen ist — der Aufrufer muss das nicht prüfen.
|
||||
/// </summary>
|
||||
Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Tut nichts. Für abgeschaltete Meldung und für Tests.</summary>
|
||||
public sealed class NullErrorReporter : IErrorReporter
|
||||
{
|
||||
public static readonly NullErrorReporter Instance = new();
|
||||
|
||||
public Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung an <c>POST /api/errors/v1/report</c> — den Eingang für den globalen
|
||||
/// Ausnahmebehandler.
|
||||
///
|
||||
/// <para>Gespeichert wird serverseitig in derselben Tabelle wie der Bugtracker. Die
|
||||
/// Trennung von Rauschen und Signal leisten dort die Ignore-Regeln: Ein bekannter,
|
||||
/// harmloser Fehler wird weiterhin gezählt, bleibt aber aus der Übersicht — und schlägt
|
||||
/// Alarm, wenn er plötzlich hundertmal so oft auftritt.</para>
|
||||
///
|
||||
/// <para><b>Eigener Schutz gegen Fehlerschleifen.</b> Der Server drosselt auf 300
|
||||
/// Meldungen pro Minute und IP. Diese Klasse drosselt schon vorher: Derselbe Fehler
|
||||
/// (gleicher Typ, gleiche Stelle) geht höchstens einmal je Zeitfenster raus. Ohne das
|
||||
/// erzeugt eine Schleife in einem Timer tausende identische Anfragen, die der Server
|
||||
/// dann verwerfen muss — und der einzige, der davon etwas hat, ist die Leitung.</para>
|
||||
/// </summary>
|
||||
public sealed class ErrorReporter : IErrorReporter, IDisposable
|
||||
{
|
||||
/// <summary>Wie lange derselbe Fehler nach einer Meldung stumm bleibt.</summary>
|
||||
private static readonly TimeSpan RepeatWindow = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>Obergrenze für den Stacktrace — der Server schneidet sonst mitten im Wort ab.</summary>
|
||||
private const int MaxStackTraceLength = 8000;
|
||||
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly bool _ownsApi;
|
||||
private readonly string _projectSlug;
|
||||
private readonly string _environment;
|
||||
private readonly string _build;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Func<DateTimeOffset> _now;
|
||||
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = [];
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public ErrorReporter(
|
||||
DeploymentcenterApi api,
|
||||
string projectSlug,
|
||||
string environment,
|
||||
string build,
|
||||
ILogger logger,
|
||||
bool ownsApi = false,
|
||||
Func<DateTimeOffset>? now = null)
|
||||
{
|
||||
_api = api;
|
||||
_projectSlug = projectSlug;
|
||||
_environment = environment;
|
||||
_build = build;
|
||||
_logger = logger;
|
||||
_ownsApi = ownsApi;
|
||||
_now = now ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public async Task<bool> ReportAsync(Exception exception, bool fatal = false,
|
||||
IReadOnlyDictionary<string, object?>? context = null, CancellationToken ct = default)
|
||||
{
|
||||
if (!ShouldSend(exception))
|
||||
return false;
|
||||
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["project_slug"] = _projectSlug,
|
||||
["exception"] = exception.GetType().FullName,
|
||||
["message"] = exception.Message,
|
||||
["stack_trace"] = Truncate(exception.ToString(), MaxStackTraceLength),
|
||||
["level"] = fatal ? "fatal" : "error",
|
||||
["build"] = _build,
|
||||
["environment"] = _environment,
|
||||
|
||||
// Idempotenz: Kommt derselbe Fehler nach einem Neustart erneut, erhöht der
|
||||
// Server den Zähler, statt einen zweiten Eintrag anzulegen.
|
||||
["client_ref"] = Fingerprint(exception)
|
||||
};
|
||||
|
||||
if (context is { Count: > 0 })
|
||||
payload["context"] = context;
|
||||
|
||||
if (exception.TargetSite?.DeclaringType?.FullName is { } declaringType)
|
||||
payload["file"] = declaringType;
|
||||
|
||||
try
|
||||
{
|
||||
await _api.PostAsync("/api/errors/v1/report", payload, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ein Meldeweg, der selbst wirft, wäre die schlechteste aller Welten:
|
||||
// Der ursprüngliche Fehler ginge dabei verloren.
|
||||
_logger.LogDebug(ex, "Fehlermeldung an das Deploymentcenter fehlgeschlagen (ignoriert).");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drosselung je Fehlerart, damit eine Schleife nicht die Leitung flutet.</summary>
|
||||
private bool ShouldSend(Exception exception)
|
||||
{
|
||||
var key = Fingerprint(exception);
|
||||
var now = _now();
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastSent.TryGetValue(key, out var last) && now - last < RepeatWindow)
|
||||
return false;
|
||||
|
||||
// Alte Einträge räumen, damit das Wörterbuch bei wechselnden Fehlern nicht wächst.
|
||||
if (_lastSent.Count > 200)
|
||||
{
|
||||
foreach (var stale in _lastSent
|
||||
.Where(e => now - e.Value > RepeatWindow)
|
||||
.Select(e => e.Key)
|
||||
.ToList())
|
||||
{
|
||||
_lastSent.Remove(stale);
|
||||
}
|
||||
}
|
||||
|
||||
_lastSent[key] = now;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kennzeichen eines Fehlers: Typ plus oberste Stelle im Stacktrace. Die Meldung
|
||||
/// bleibt bewusst außen vor — sie enthält oft wechselnde Werte (IDs, Pfade), und
|
||||
/// dann wäre jeder Aufruf ein neuer Fehler.
|
||||
/// </summary>
|
||||
private static string Fingerprint(Exception exception)
|
||||
{
|
||||
var frame = exception.StackTrace?
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.FirstOrDefault()?
|
||||
.Trim() ?? "";
|
||||
|
||||
return $"{exception.GetType().FullName}|{frame}";
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int max) =>
|
||||
value.Length <= max ? value : value[..max] + "\n… (gekürzt)";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsApi)
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter;
|
||||
|
||||
/// <summary>Ein für diese Instanz ausgestelltes Sub-Token.</summary>
|
||||
/// <param name="Token">Der Klartext — wird nur einmal ausgeliefert.</param>
|
||||
/// <param name="TokenId">Kennung zum Widerrufen in der Verwaltung.</param>
|
||||
/// <param name="Scopes">Welche Rechte tatsächlich durchgereicht wurden.</param>
|
||||
public sealed record ProvisionedToken(string Token, string TokenId, IReadOnlyList<string> Scopes);
|
||||
|
||||
/// <summary>
|
||||
/// Tauscht das anwendungsweite Master-Token gegen ein eigenes Sub-Token je Instanz
|
||||
/// (<c>POST /api/tokens/v1/provision</c>).
|
||||
///
|
||||
/// <para>Das ersetzt die frühere Selbstregistrierung über <c>POST /api/register</c> —
|
||||
/// diesen Endpunkt gibt es im Deploymentcenter nicht (und im alten WatchDog-Server war
|
||||
/// er der einzige Weg, überhaupt an einen Token zu kommen). Der Zweck bleibt derselbe
|
||||
/// und ist es wert, erhalten zu bleiben: Auf den Instanzen liegt danach nicht das
|
||||
/// Master-Token, sondern ein eingeschränktes, einzeln widerrufbares.</para>
|
||||
///
|
||||
/// <para>Rechte lassen sich dabei nur einschränken, nie erweitern — was das
|
||||
/// Master-Token nicht hat, bekommt auch das Sub-Token nicht.</para>
|
||||
/// </summary>
|
||||
public sealed class TokenProvisioner(DeploymentcenterApi api)
|
||||
{
|
||||
/// <summary>Was eine Instanz braucht: Heartbeats senden und Fehler melden.</summary>
|
||||
public static readonly string[] InstanceScopes = ["watchdog:ping", "bugtracker:report"];
|
||||
|
||||
public async Task<ProvisionedToken> ProvisionAsync(
|
||||
string clientName,
|
||||
string instanceId,
|
||||
IReadOnlyList<string> scopes,
|
||||
string environment = "production",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
client_name = clientName,
|
||||
instance_id = instanceId,
|
||||
scopes,
|
||||
environment
|
||||
};
|
||||
|
||||
var response = await api.PostAsync("/api/tokens/v1/provision", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var token = response.TryGetProperty("sub_token", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
throw new DeploymentcenterException(
|
||||
"no_token",
|
||||
"Die Provisionierung lieferte kein Token.",
|
||||
System.Net.HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
var tokenId = response.TryGetProperty("token_id", out var i) && i.ValueKind == JsonValueKind.String
|
||||
? i.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
var granted = new List<string>();
|
||||
if (response.TryGetProperty("scopes", out var s) && s.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
granted.AddRange(s.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!));
|
||||
}
|
||||
|
||||
return new ProvisionedToken(token, tokenId, granted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>
|
||||
/// Die Statuswerte, die der Watchdog kennt. <c>stopped</c> und <c>maintenance</c> sind
|
||||
/// angekündigte Zustände — der Evaluator lässt solche Monitore in Ruhe, statt wenige
|
||||
/// Minuten nach einem geplanten Herunterfahren einen Fehlalarm zu erzeugen.
|
||||
/// </summary>
|
||||
public static class WatchdogStatus
|
||||
{
|
||||
public const string Ok = "ok";
|
||||
public const string Warning = "warning";
|
||||
public const string Error = "error";
|
||||
public const string Stopped = "stopped";
|
||||
public const string Maintenance = "maintenance";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine selbst ermittelte Teilprüfung. Das Deploymentcenter interpretiert den Namen
|
||||
/// nicht — es liest nur <see cref="Ok"/> und <see cref="Message"/>. Was „gesund"
|
||||
/// bedeutet, entscheidet damit jede Anwendung selbst.
|
||||
///
|
||||
/// <para>Schlägt eine Prüfung fehl, stuft der Server einen als <c>ok</c> gemeldeten
|
||||
/// Heartbeat auf <c>warning</c> herab. Das ist der Unterschied zwischen „ein Faden
|
||||
/// läuft" und „die Anwendung tut, was sie soll".</para>
|
||||
/// </summary>
|
||||
public sealed record HealthCheck(bool Ok, string? Message = null);
|
||||
|
||||
/// <summary>Momentaufnahme des Instanz-Zustands für einen Heartbeat.</summary>
|
||||
/// <param name="Status">Einer der Werte aus <see cref="WatchdogStatus"/>.</param>
|
||||
/// <param name="Message">Kurzbegründung, erscheint im Dashboard.</param>
|
||||
/// <param name="Metrics">
|
||||
/// Nur Zahlen: Das Deploymentcenter legt sie mit Zeitstempel ab (14 Tage) und vergleicht
|
||||
/// den aktuellen Wert mit dem Sieben-Tage-Schnitt desselben Monitors. Nicht-numerische
|
||||
/// Werte würden dabei stillschweigend verworfen — beschreibende Angaben gehören
|
||||
/// deshalb in <paramref name="Message"/> oder in die Checks.
|
||||
/// </param>
|
||||
/// <param name="Checks">Selbst ermittelter Gesundheitszustand je Teilbereich.</param>
|
||||
public sealed record InstanceHealth(
|
||||
string Status,
|
||||
string? Message,
|
||||
IReadOnlyDictionary<string, double> Metrics,
|
||||
IReadOnlyDictionary<string, HealthCheck> Checks)
|
||||
{
|
||||
public static InstanceHealth Ok(string? message = null) => new(
|
||||
WatchdogStatus.Ok, message,
|
||||
new Dictionary<string, double>(),
|
||||
new Dictionary<string, HealthCheck>());
|
||||
}
|
||||
|
||||
/// <summary>Liefert vor jedem Heartbeat den aktuellen Instanz-Zustand.</summary>
|
||||
public interface IInstanceHealthProvider
|
||||
{
|
||||
Task<InstanceHealth> GetAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>
|
||||
/// Leitet den Instanz-Zustand für den Heartbeat ab.
|
||||
///
|
||||
/// <para>Ein Heartbeat allein beweist nur, dass ein Faden läuft. Deshalb geht der
|
||||
/// selbst ermittelte Gesundheitszustand als <c>checks</c> mit — der Server stuft einen
|
||||
/// als <c>ok</c> gemeldeten Beat herab, sobald eine Prüfung fehlschlägt, und nennt in
|
||||
/// der Antwort die betroffene. Der klassische Fall, den das abfängt: Der Takt meldet
|
||||
/// brav <c>ok</c>, während der Aufgaben-Scanner seit einer Stunde tot ist.</para>
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>error</c> — kein OpenRouter-Key konfiguriert (Agenten deaktiviert).</item>
|
||||
/// <item><c>warning</c> — Tagesbudget der Instanz erschöpft oder Scanner steht.</item>
|
||||
/// <item><c>ok</c> — sonst.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Die Metriken sind bewusst schlank und ausschließlich numerisch: keine
|
||||
/// sensiblen Nutzdaten, und nur Zahlen landen im Verlauf.</para>
|
||||
/// </summary>
|
||||
public sealed class InstanceHealthProvider : IInstanceHealthProvider
|
||||
{
|
||||
private readonly string _instanceName;
|
||||
private readonly bool _agentsEnabled;
|
||||
private readonly InstanceBudget _budget;
|
||||
private readonly IUsageRepository? _usage;
|
||||
private readonly Func<int> _agentCount;
|
||||
private readonly Func<int> _runningChats;
|
||||
private readonly Func<bool>? _schedulerRunning;
|
||||
private readonly Func<DateTime> _now;
|
||||
|
||||
public InstanceHealthProvider(
|
||||
string instanceName,
|
||||
bool agentsEnabled,
|
||||
InstanceBudget budget,
|
||||
IUsageRepository? usage,
|
||||
Func<int> agentCount,
|
||||
Func<int> runningChats,
|
||||
Func<bool>? schedulerRunning = null,
|
||||
Func<DateTime>? now = null)
|
||||
{
|
||||
_instanceName = instanceName;
|
||||
_agentsEnabled = agentsEnabled;
|
||||
_budget = budget;
|
||||
_usage = usage;
|
||||
_agentCount = agentCount;
|
||||
_runningChats = runningChats;
|
||||
_schedulerRunning = schedulerRunning;
|
||||
_now = now ?? (() => DateTime.Now);
|
||||
}
|
||||
|
||||
public async Task<InstanceHealth> GetAsync(CancellationToken ct)
|
||||
{
|
||||
var metrics = new Dictionary<string, double>
|
||||
{
|
||||
["agentCount"] = _agentCount(),
|
||||
["runningChats"] = _runningChats()
|
||||
};
|
||||
|
||||
var checks = new Dictionary<string, HealthCheck>
|
||||
{
|
||||
["agents"] = new(_agentsEnabled,
|
||||
_agentsEnabled ? null : "Kein OpenRouter-API-Key konfiguriert.")
|
||||
};
|
||||
|
||||
if (_schedulerRunning is not null)
|
||||
{
|
||||
var running = _schedulerRunning();
|
||||
checks["scheduler"] = new(running,
|
||||
running ? null : "Aufgaben-Scanner läuft nicht.");
|
||||
}
|
||||
|
||||
string? budgetProblem = null;
|
||||
|
||||
if (_usage is not null)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(_now());
|
||||
var used = await _usage.GetDailyAsync(today, agentId: "", ct).ConfigureAwait(false);
|
||||
|
||||
metrics["todayCostUsd"] = (double)decimal.Round(used.CostUsd, 4);
|
||||
metrics["todayTokens"] = used.TotalTokens;
|
||||
|
||||
if (Exceeds(_budget.DailyCostUsd, used.CostUsd))
|
||||
{
|
||||
budgetProblem =
|
||||
$"Tagesbudget erschöpft: {used.CostUsd:F2} von {_budget.DailyCostUsd:F2} USD.";
|
||||
}
|
||||
else if (Exceeds(_budget.DailyTokens, used.TotalTokens))
|
||||
{
|
||||
budgetProblem =
|
||||
$"Token-Tageslimit erschöpft: {used.TotalTokens:N0} von {_budget.DailyTokens:N0}.";
|
||||
}
|
||||
|
||||
checks["budget"] = new(budgetProblem is null, budgetProblem);
|
||||
}
|
||||
|
||||
// Der Instanzname steht in der Meldung, nicht in den Metriken: Metriken sind
|
||||
// Zahlen, alles andere würde der Server beim Verdichten ohnehin verwerfen.
|
||||
if (!_agentsEnabled)
|
||||
{
|
||||
return new InstanceHealth(
|
||||
WatchdogStatus.Error,
|
||||
$"{_instanceName}: Kein OpenRouter-API-Key konfiguriert – Agenten deaktiviert.",
|
||||
metrics, checks);
|
||||
}
|
||||
|
||||
if (budgetProblem is not null)
|
||||
return new InstanceHealth(WatchdogStatus.Warning, $"{_instanceName}: {budgetProblem}", metrics, checks);
|
||||
|
||||
if (_schedulerRunning is not null && !_schedulerRunning())
|
||||
{
|
||||
return new InstanceHealth(
|
||||
WatchdogStatus.Warning,
|
||||
$"{_instanceName}: Aufgaben-Scanner läuft nicht.",
|
||||
metrics, checks);
|
||||
}
|
||||
|
||||
return new InstanceHealth(WatchdogStatus.Ok, $"{_instanceName}: Betrieb normal.", metrics, checks);
|
||||
}
|
||||
|
||||
/// <summary>0 oder kleiner bedeutet: keine Grenze gesetzt. Deckungsgleich mit BudgetGuard.</summary>
|
||||
private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
|
||||
|
||||
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>Was der Server zu einem Heartbeat zurückmeldet.</summary>
|
||||
/// <param name="State">Der daraus abgeleitete Monitor-Zustand (<c>up</c>, <c>warning</c>, …).</param>
|
||||
/// <param name="FailingChecks">Welche der mitgeschickten Prüfungen fehlgeschlagen sind.</param>
|
||||
public sealed record WatchdogPingResult(string State, IReadOnlyList<string> FailingChecks);
|
||||
|
||||
/// <summary>
|
||||
/// Sendet Heartbeats und Ereignisse an das Watchdog-Modul des Deploymentcenters.
|
||||
/// </summary>
|
||||
public interface IWatchdogClient
|
||||
{
|
||||
Task<WatchdogPingResult> SendHeartbeatAsync(
|
||||
InstanceHealth health, int intervalSeconds, CancellationToken ct);
|
||||
|
||||
Task SendEventAsync(
|
||||
string kind, string severity, string? message, object? meta, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watchdog-Anbindung: <c>POST /api/watchdog/v1/ping</c> und
|
||||
/// <c>POST /api/watchdog/v1/event</c>.
|
||||
///
|
||||
/// <para><b>Ein Monitor je Instanz.</b> Der Schlüssel des Monitors ist das Paar
|
||||
/// <c>source</c> + <c>instance</c> (so das Datenbankschema:
|
||||
/// <c>UNIQUE KEY uq_monitor (source, instance)</c>). Alle ClawdDotNet-Instanzen melden
|
||||
/// unter derselben <c>source</c> und tragen ihre eigene <c>instance</c> — damit ist jede
|
||||
/// laufende Instanz ein eigener Monitor mit eigenem Zustand, eigenem Intervall und
|
||||
/// eigenem Metrik-Verlauf. Stürzt eine von dreien ab, fällt genau deren Monitor.</para>
|
||||
///
|
||||
/// <para>Der Monitor entsteht beim ersten Heartbeat von selbst (<c>INSERT … ON DUPLICATE
|
||||
/// KEY UPDATE</c>) — eine Registrierung vorab gibt es nicht mehr und ist auch nicht
|
||||
/// nötig.</para>
|
||||
/// </summary>
|
||||
public sealed class WatchdogClient : IWatchdogClient, IDisposable
|
||||
{
|
||||
private readonly DeploymentcenterApi _api;
|
||||
private readonly bool _ownsApi;
|
||||
private readonly string _source;
|
||||
private readonly string _instance;
|
||||
private readonly string _group;
|
||||
private readonly string _os;
|
||||
private readonly string _version;
|
||||
|
||||
public WatchdogClient(
|
||||
DeploymentcenterApi api, string source, string instance, string group, string os,
|
||||
string version, bool ownsApi = false)
|
||||
{
|
||||
_api = api;
|
||||
_ownsApi = ownsApi;
|
||||
_source = source;
|
||||
_instance = instance;
|
||||
_group = group;
|
||||
_os = os;
|
||||
_version = version;
|
||||
}
|
||||
|
||||
public static WatchdogClient Create(
|
||||
string baseUrl, string token, string source, string instance, string group, string os,
|
||||
string version, HttpClient? httpClient = null)
|
||||
=> new(new DeploymentcenterApi(baseUrl, token, httpClient),
|
||||
source, instance, group, os, version, ownsApi: true);
|
||||
|
||||
public async Task<WatchdogPingResult> SendHeartbeatAsync(
|
||||
InstanceHealth health, int intervalSeconds, CancellationToken ct)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = _source,
|
||||
["instance"] = _instance,
|
||||
["type"] = "heartbeat",
|
||||
["status"] = health.Status,
|
||||
["interval"] = intervalSeconds,
|
||||
["message"] = health.Message,
|
||||
["group"] = _group,
|
||||
["os"] = _os,
|
||||
|
||||
// Landet in watchdog_monitors.app_version. Damit steht im Dashboard, welche
|
||||
// Fassung eine Instanz gerade fährt — bei mehreren Instanzen der
|
||||
// Unterschied zwischen „läuft" und „läuft noch auf der alten Version".
|
||||
["version"] = _version
|
||||
};
|
||||
|
||||
// Leere Objekte weglassen: Der Server übernimmt health_json nur, wenn etwas
|
||||
// mitkommt — ein leeres würde den letzten bekannten Zustand nicht ersetzen,
|
||||
// aber unnötig Platz im Protokoll kosten.
|
||||
if (health.Checks.Count > 0)
|
||||
{
|
||||
payload["checks"] = health.Checks.ToDictionary(
|
||||
c => c.Key,
|
||||
c => (object)new { ok = c.Value.Ok, message = c.Value.Message });
|
||||
}
|
||||
|
||||
if (health.Metrics.Count > 0)
|
||||
payload["metrics"] = health.Metrics;
|
||||
|
||||
var response = await _api.PostAsync("/api/watchdog/v1/ping", payload, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ReadPingResult(response);
|
||||
}
|
||||
|
||||
private static WatchdogPingResult ReadPingResult(JsonElement response)
|
||||
{
|
||||
if (!response.TryGetProperty("monitor", out var monitor)
|
||||
|| monitor.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return new WatchdogPingResult("unknown", []);
|
||||
}
|
||||
|
||||
var state = monitor.TryGetProperty("state", out var s) && s.ValueKind == JsonValueKind.String
|
||||
? s.GetString() ?? "unknown"
|
||||
: "unknown";
|
||||
|
||||
var failing = new List<string>();
|
||||
if (monitor.TryGetProperty("failing_checks", out var checks)
|
||||
&& checks.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
failing.AddRange(checks.EnumerateArray()
|
||||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||||
.Select(e => e.GetString()!));
|
||||
}
|
||||
|
||||
return new WatchdogPingResult(state, failing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein einmaliges Vorkommnis statt einer zyklischen Meldung. Zulässige
|
||||
/// <paramref name="kind"/>-Werte siehe <see cref="WatchdogEventKind"/> — der Server
|
||||
/// weist andere ab.
|
||||
/// </summary>
|
||||
public async Task SendEventAsync(
|
||||
string kind, string severity, string? message, object? meta, CancellationToken ct)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
source = _source,
|
||||
instance = _instance,
|
||||
kind,
|
||||
severity,
|
||||
message,
|
||||
meta
|
||||
};
|
||||
|
||||
await _api.PostAsync("/api/watchdog/v1/event", payload, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsApi)
|
||||
_api.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die vom Server akzeptierten Ereignisarten. Die frühere Anbindung schickte
|
||||
/// <c>start</c> und <c>stop</c> — beide stehen nicht auf dieser Liste und wurden
|
||||
/// stillschweigend als <c>started</c> abgelegt.
|
||||
/// </summary>
|
||||
public static class WatchdogEventKind
|
||||
{
|
||||
public const string Started = "started";
|
||||
public const string StoppedGraceful = "stopped_graceful";
|
||||
public const string CrashSuspected = "crash_suspected";
|
||||
public const string HardError = "hard_error";
|
||||
public const string Recovered = "recovered";
|
||||
public const string WarningRaised = "warning_raised";
|
||||
public const string WarningCleared = "warning_cleared";
|
||||
public const string MaintenanceStart = "maintenance_start";
|
||||
public const string MaintenanceEnd = "maintenance_end";
|
||||
public const string WatchdogStarted = "watchdog_started";
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
|
||||
/// <summary>
|
||||
/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende.
|
||||
/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen —
|
||||
/// alle Sendefehler werden geloggt und verschluckt.
|
||||
///
|
||||
/// <para><b>Sauberes Beenden.</b> Beim Herunterfahren geht ein Heartbeat mit
|
||||
/// <c>status: "stopped"</c> raus. Der Evaluator lässt einen so gemeldeten Monitor in
|
||||
/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen
|
||||
/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den
|
||||
/// Monitor-Zustand.</para>
|
||||
/// </summary>
|
||||
public sealed class WatchdogHeartbeatService : IAsyncDisposable
|
||||
{
|
||||
private readonly IWatchdogClient _client;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly IInstanceHealthProvider _health;
|
||||
private readonly int _intervalSeconds;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _loop;
|
||||
private string _lastState = "unknown";
|
||||
|
||||
public WatchdogHeartbeatService(
|
||||
IWatchdogClient client,
|
||||
IInstanceHealthProvider health,
|
||||
int intervalSeconds,
|
||||
ILogger logger,
|
||||
bool ownsClient = false)
|
||||
{
|
||||
_client = client;
|
||||
_health = health;
|
||||
_intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400);
|
||||
_logger = logger;
|
||||
_ownsClient = ownsClient;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration
|
||||
/// (fehlende oder nicht-HTTPS-URL).
|
||||
/// </summary>
|
||||
public static WatchdogHeartbeatService Create(
|
||||
string baseUrl, string token, string source, string instance, string group, string os,
|
||||
string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger)
|
||||
{
|
||||
var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version);
|
||||
return new WatchdogHeartbeatService(
|
||||
client, health, intervalSeconds, logger, ownsClient: true);
|
||||
}
|
||||
|
||||
public bool IsRunning => _loop is { IsCompleted: false };
|
||||
|
||||
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
|
||||
public string LastState => _lastState;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsRunning)
|
||||
return;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_loop = RunAsync(_cts.Token);
|
||||
_logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds);
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
await TrySendAsync(
|
||||
() => _client.SendEventAsync(
|
||||
WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct),
|
||||
"Start-Ereignis").ConfigureAwait(false);
|
||||
|
||||
// Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen
|
||||
// Intervall im Dashboard auftaucht.
|
||||
await BeatAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds));
|
||||
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
|
||||
await BeatAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Regulärer Stopp.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BeatAsync(CancellationToken ct)
|
||||
{
|
||||
InstanceHealth health;
|
||||
try
|
||||
{
|
||||
health = await _health.GetAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen
|
||||
// rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall.
|
||||
_logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen – melde warning.");
|
||||
health = new InstanceHealth(
|
||||
WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.",
|
||||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>());
|
||||
}
|
||||
|
||||
await TrySendAsync(async () =>
|
||||
{
|
||||
var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.State != _lastState)
|
||||
{
|
||||
_logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}",
|
||||
_lastState, result.State,
|
||||
result.FailingChecks.Count > 0
|
||||
? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})"
|
||||
: "");
|
||||
|
||||
_lastState = result.State;
|
||||
}
|
||||
}, "Heartbeat").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task TrySendAsync(Func<Task> send, string what)
|
||||
{
|
||||
try
|
||||
{
|
||||
await send().ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem)
|
||||
{
|
||||
// Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor
|
||||
// für immer stumm, und niemand merkt es, weil ja nichts abstürzt.
|
||||
_logger.LogWarning(
|
||||
"Watchdog: {What} abgelehnt ({Code}) – Token prüfen (Recht watchdog:ping).",
|
||||
what, ex.Code);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Ausfall des Monitorings darf den Betrieb nie stören.
|
||||
_logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cts is null)
|
||||
return;
|
||||
|
||||
await _cts.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop.ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { /* erwartet */ }
|
||||
catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); }
|
||||
}
|
||||
|
||||
// Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein
|
||||
// Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft.
|
||||
try
|
||||
{
|
||||
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
|
||||
await _client.SendHeartbeatAsync(
|
||||
new InstanceHealth(
|
||||
WatchdogStatus.Stopped, "Instanz planmäßig beendet.",
|
||||
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
|
||||
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
|
||||
|
||||
await _client.SendEventAsync(
|
||||
WatchdogEventKind.StoppedGraceful, "info", "Instanz beendet.", null, stopCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert).");
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
|
||||
if (_ownsClient && _client is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Api.Models;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Budget;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
@@ -14,13 +15,16 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Engine;
|
||||
|
||||
public sealed class AgentEngine : IAgentMessageRouter
|
||||
public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecutor
|
||||
{
|
||||
private readonly IChatCompletionClient _client;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly PermissionGate _permissionGate;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly IMemoryRepository? _memoryRepository;
|
||||
private readonly Tasks.ITaskRepository? _taskRepository;
|
||||
private readonly IAuditRepository? _auditRepository;
|
||||
private readonly Staging.StagingGate? _stagingGate;
|
||||
private readonly IUsageRepository? _usageRepository;
|
||||
private readonly BudgetGuard? _budgetGuard;
|
||||
private readonly ModelPricingCatalog? _pricing;
|
||||
@@ -67,7 +71,10 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
ILoggerFactory loggerFactory,
|
||||
IMemoryRepository? memoryRepository = null,
|
||||
IUsageRepository? usageRepository = null,
|
||||
ModelPricingCatalog? pricing = null)
|
||||
ModelPricingCatalog? pricing = null,
|
||||
Tasks.ITaskRepository? taskRepository = null,
|
||||
IAuditRepository? auditRepository = null,
|
||||
Staging.StagingGate? stagingGate = null)
|
||||
{
|
||||
_client = client;
|
||||
_toolRegistry = toolRegistry;
|
||||
@@ -75,6 +82,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
_stateStore = stateStore;
|
||||
_loggerFactory = loggerFactory;
|
||||
_memoryRepository = memoryRepository;
|
||||
_taskRepository = taskRepository;
|
||||
_auditRepository = auditRepository;
|
||||
_stagingGate = stagingGate;
|
||||
_usageRepository = usageRepository;
|
||||
_pricing = pricing;
|
||||
_budgetGuard = usageRepository is null ? null : new BudgetGuard(usageRepository);
|
||||
@@ -100,14 +110,18 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
AgentConfig agentConfig,
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt)
|
||||
CancellationToken externalCt,
|
||||
string? source = null,
|
||||
string? taskId = null)
|
||||
{
|
||||
// Vor der ersten Anfrage prüfen — ein erschöpftes Budget soll gar nichts kosten.
|
||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
||||
return denied;
|
||||
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt);
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -115,7 +129,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
AgentConfig agentConfig,
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt)
|
||||
CancellationToken externalCt,
|
||||
string runId,
|
||||
string? source)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -157,7 +173,11 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
|
||||
var response = await _client.CompleteAsync(request, ct);
|
||||
@@ -194,7 +214,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct);
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||||
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
}
|
||||
@@ -282,11 +302,14 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken externalCt,
|
||||
string? source = null)
|
||||
string? source = null,
|
||||
string? taskId = null)
|
||||
{
|
||||
if (await CheckBudgetAsync(agentConfig, externalCt) is { } denied)
|
||||
return denied;
|
||||
|
||||
var runId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// Abbrechbar sein, schon bevor der Lauf an der Reihe ist — sonst hängt eine
|
||||
// wartende Nachricht auch dann noch, wenn der Benutzer längst abgebrochen hat.
|
||||
using var runCts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
|
||||
@@ -307,8 +330,9 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source);
|
||||
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId);
|
||||
await RecordUsageAsync(agentConfig, result);
|
||||
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
@@ -323,7 +347,8 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string userMessage,
|
||||
string instanceId,
|
||||
CancellationToken runCt,
|
||||
string? source)
|
||||
string? source,
|
||||
string runId)
|
||||
{
|
||||
var logger = _loggerFactory.CreateLogger($"ClawdDotNet.Core.Engine.Chat.{agentConfig.AgentId}");
|
||||
var loopGuard = new LoopGuard(agentConfig.LoopGuard);
|
||||
@@ -380,7 +405,11 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
{
|
||||
Model = agentConfig.Model,
|
||||
Messages = messages,
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null
|
||||
Tools = toolDefinitions.Count > 0 ? toolDefinitions : null,
|
||||
// B11/T8: Ausgabe deckeln — die teuerste Token-Art gegen Ausreißer schützen.
|
||||
MaxTokens = agentConfig.LoopGuard.MaxResponseTokens > 0
|
||||
? agentConfig.LoopGuard.MaxResponseTokens
|
||||
: null
|
||||
};
|
||||
|
||||
var response = await _client.CompleteAsync(request, ct);
|
||||
@@ -418,7 +447,7 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
foreach (var toolCall in assistantMessage.ToolCalls)
|
||||
{
|
||||
var toolResult = await ExecuteToolCallAsync(
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct);
|
||||
toolCall, agentConfig, instanceId, tools, logger, ct, runId, source);
|
||||
messages.Add(ChatMessage.ToolResponse(toolCall.Id, toolResult));
|
||||
}
|
||||
|
||||
@@ -538,6 +567,12 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
return _runningChats.ContainsKey(agentId);
|
||||
}
|
||||
|
||||
/// <summary>Anzahl Agenten mit mindestens einem aktiven Chat-Lauf — für Diagnose/Heartbeat.</summary>
|
||||
public int RunningChatCount
|
||||
{
|
||||
get { lock (_lock) return _runningChats.Count; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Momentaufnahme des Konversationskontexts eines Agenten — also der Nachrichten,
|
||||
/// die beim nächsten Schritt tatsächlich an das Modell gehen.
|
||||
@@ -805,9 +840,19 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
string instanceId,
|
||||
IReadOnlyList<IAgentTool> availableTools,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
string runId,
|
||||
string? source)
|
||||
{
|
||||
var toolName = toolCall.Function.Name;
|
||||
var arguments = toolCall.Function.Arguments ?? "";
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
// Das Audit wird von der Engine gestempelt (A3) — Herkunft aus dem Wissen der
|
||||
// Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf
|
||||
// nicht scheitern lassen.
|
||||
Task Audit(AuditStatus status, string summary)
|
||||
=> RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary, sw.ElapsedMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -815,29 +860,36 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
|
||||
var tool = availableTools.FirstOrDefault(t => t.Name == toolName);
|
||||
if (tool is null)
|
||||
{
|
||||
await Audit(AuditStatus.NotFound, $"Tool '{toolName}' nicht zugewiesen/unbekannt");
|
||||
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
|
||||
}
|
||||
|
||||
// Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
|
||||
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
|
||||
if (_stagingGate is not null)
|
||||
{
|
||||
var intercept = await _stagingGate.InterceptAsync(
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct);
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Denied)
|
||||
{
|
||||
await Audit(AuditStatus.Denied, intercept.Message);
|
||||
return JsonSerializer.Serialize(new { error = intercept.Message });
|
||||
}
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Staged)
|
||||
{
|
||||
await Audit(AuditStatus.Staged, intercept.Message);
|
||||
return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis
|
||||
}
|
||||
}
|
||||
|
||||
var input = string.IsNullOrWhiteSpace(toolCall.Function.Arguments)
|
||||
? default
|
||||
: JsonDocument.Parse(toolCall.Function.Arguments).RootElement;
|
||||
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
|
||||
|
||||
var context = new AgentToolContext(
|
||||
agentConfig.AgentId,
|
||||
instanceId,
|
||||
toolConfig,
|
||||
_stateStore,
|
||||
toolLogger,
|
||||
ct,
|
||||
agentConfig.WorkspacePath,
|
||||
agentConfig.SharedWorkspacePath,
|
||||
this,
|
||||
_memoryRepository);
|
||||
var context = BuildToolContext(agentConfig, instanceId, toolName, ct);
|
||||
|
||||
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
|
||||
|
||||
@@ -846,7 +898,10 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
await Audit(AuditStatus.Error, result.ErrorMessage ?? "");
|
||||
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||
}
|
||||
|
||||
var content = TruncateToolResult(result.Content, agentConfig.MaxToolResultChars);
|
||||
if (content.Length != result.Content.Length)
|
||||
@@ -856,22 +911,92 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
toolName, result.Content.Length, agentConfig.MaxToolResultChars);
|
||||
}
|
||||
|
||||
await Audit(AuditStatus.Ok, "");
|
||||
return content;
|
||||
}
|
||||
catch (ToolAccessDeniedException ex)
|
||||
{
|
||||
logger.LogWarning("Tool access denied: {Message}", ex.Message);
|
||||
await Audit(AuditStatus.Denied, ex.Message);
|
||||
return JsonSerializer.Serialize(new { error = ex.Message });
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
|
||||
// Schritt weiter und der Abbruch greift erst verzögert.
|
||||
// Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit —
|
||||
// der Aufruf kam nicht zum Abschluss.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Tool {Tool} threw an exception", toolName);
|
||||
await Audit(AuditStatus.Error, ex.Message);
|
||||
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
private AgentToolContext BuildToolContext(
|
||||
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct)
|
||||
{
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(toolName, out var cfg)
|
||||
? cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var toolLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Execution");
|
||||
|
||||
return new AgentToolContext(
|
||||
agentConfig.AgentId,
|
||||
instanceId,
|
||||
toolConfig,
|
||||
_stateStore,
|
||||
toolLogger,
|
||||
ct,
|
||||
agentConfig.WorkspacePath,
|
||||
agentConfig.SharedWorkspacePath,
|
||||
this,
|
||||
_memoryRepository,
|
||||
_taskRepository);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen freigegebenen, eingefrorenen Aufruf aus (A2) — mit gültigem Tool-Kontext,
|
||||
/// aber ohne LLM-Schleife und ohne erneute Staging-Prüfung. Genau der übergebene
|
||||
/// Argument-JSON wird ausgeführt (Plan-Freeze).
|
||||
/// </summary>
|
||||
public async Task<string> ExecuteApprovedCallAsync(
|
||||
string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
|
||||
{
|
||||
var config = _agentConfigProvider?.Invoke().FirstOrDefault(a => a.AgentId == agentId);
|
||||
if (config is null)
|
||||
return JsonSerializer.Serialize(new { error = $"Agent '{agentId}' nicht gefunden." });
|
||||
|
||||
var agentTool = _toolRegistry.GetForAgent(config).FirstOrDefault(t => t.Name == tool)
|
||||
?? _toolRegistry.Get(tool);
|
||||
if (agentTool is null)
|
||||
return JsonSerializer.Serialize(new { error = $"Tool '{tool}' nicht gefunden." });
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var input = string.IsNullOrWhiteSpace(argumentsJson)
|
||||
? default
|
||||
: JsonDocument.Parse(argumentsJson).RootElement;
|
||||
|
||||
var context = BuildToolContext(config, _instanceId, tool, ct);
|
||||
var result = await agentTool.ExecuteAsync(input, context, ct);
|
||||
|
||||
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
|
||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||
status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
|
||||
|
||||
return result.Success
|
||||
? TruncateToolResult(result.Content, config.MaxToolResultChars)
|
||||
: JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||
AuditStatus.Error, ex.Message, sw.ElapsedMilliseconds);
|
||||
return JsonSerializer.Serialize(new { error = $"Tool execution failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
@@ -938,6 +1063,84 @@ public sealed class AgentEngine : IAgentMessageRouter
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audit-Log und Receipts (A3) ───
|
||||
|
||||
/// <summary>
|
||||
/// Schreibt einen Tool-Aufruf ins Audit-Log. Best effort — ein Fehler hierbei darf den
|
||||
/// Lauf nicht scheitern lassen; die eigentliche Arbeit ist bereits getan.
|
||||
/// </summary>
|
||||
private async Task RecordAuditAsync(
|
||||
string runId, AgentConfig agentConfig, string? source, string tool,
|
||||
string arguments, AuditStatus status, string summary, long durationMs)
|
||||
{
|
||||
if (_auditRepository is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _auditRepository.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
Tool = tool,
|
||||
Arguments = Cap(arguments, 4_000),
|
||||
Status = status,
|
||||
Summary = Cap(summary, 500),
|
||||
DurationMs = durationMs,
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Audit")
|
||||
.LogWarning(ex, "Audit-Eintrag konnte nicht geschrieben werden ({Tool})", tool);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hält den Abschluss-Beleg eines Laufs fest (Receipt). Best effort, wie beim Audit.
|
||||
/// </summary>
|
||||
private async Task RecordReceiptAsync(
|
||||
string runId, AgentConfig agentConfig, AgentRunResult result, string? source, string? taskId)
|
||||
{
|
||||
if (_auditRepository is null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var estimate = _pricing?.Estimate(agentConfig.Model, result.PromptTokens, result.CompletionTokens);
|
||||
|
||||
await _auditRepository.RecordReceiptAsync(new RunReceipt
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentConfig.AgentId,
|
||||
Model = agentConfig.Model,
|
||||
Source = AuditSource.Normalize(source),
|
||||
TaskId = taskId,
|
||||
Status = result.Status.ToString(),
|
||||
StepCount = result.StepCount,
|
||||
PromptTokens = result.PromptTokens,
|
||||
CompletionTokens = result.CompletionTokens,
|
||||
CachedTokens = result.CachedTokens,
|
||||
CostUsd = estimate?.Usd ?? 0m,
|
||||
CostIsKnown = estimate?.IsKnown ?? false,
|
||||
DurationMs = (long)result.Duration.TotalMilliseconds,
|
||||
ResultRef = Cap(result.FinalMessage ?? "", 500),
|
||||
OccurredAt = DateTime.Now
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_loggerFactory.CreateLogger("ClawdDotNet.Core.Engine.Receipt")
|
||||
.LogWarning(ex, "Receipt konnte nicht geschrieben werden für {AgentId}", agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Cap(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
|
||||
/// <summary>Sammelt die Token-Zahlen über alle Schritte eines Runs.</summary>
|
||||
private sealed class TokenTally
|
||||
{
|
||||
|
||||
@@ -18,4 +18,5 @@ public static class ChatSource
|
||||
public const string Telegram = "telegram";
|
||||
public const string AgentComm = "agentcomm";
|
||||
public const string Job = "job";
|
||||
public const string Task = "task";
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
public sealed class AgentScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly string _instanceId;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _schedulerTasks = new();
|
||||
private readonly Dictionary<string, AgentRunResult?> _lastResults = new();
|
||||
private readonly Lock _resultsLock = new();
|
||||
|
||||
public event Action<string, AgentRunResult>? OnRunCompleted;
|
||||
|
||||
public AgentScheduler(AgentEngine engine, string instanceId, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_engine = engine;
|
||||
_instanceId = instanceId;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling");
|
||||
}
|
||||
|
||||
public void RegisterAgent(AgentConfig agentConfig)
|
||||
{
|
||||
if (agentConfig.Scheduler is null)
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Registering scheduled agent: {AgentId}, cron='{Cron}', runOnStart={RunOnStart}",
|
||||
agentConfig.AgentId, agentConfig.Scheduler.Cron, agentConfig.Scheduler.RunOnStart);
|
||||
|
||||
var task = RunScheduledAgentAsync(agentConfig, _cts.Token);
|
||||
_schedulerTasks.Add(task);
|
||||
}
|
||||
|
||||
public void RegisterAll(IEnumerable<AgentConfig> agents)
|
||||
{
|
||||
foreach (var agent in agents)
|
||||
RegisterAgent(agent);
|
||||
}
|
||||
|
||||
public async Task<AgentRunResult> RunNowAsync(AgentConfig agentConfig, string userMessage, CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Manual run triggered: {AgentId}", agentConfig.AgentId);
|
||||
var result = await _engine.RunAsync(agentConfig, userMessage, _instanceId, ct);
|
||||
StoreResult(agentConfig.AgentId, result);
|
||||
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public AgentRunResult? GetLastResult(string agentId)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(agentId);
|
||||
}
|
||||
|
||||
private async Task RunScheduledAgentAsync(AgentConfig agentConfig, CancellationToken ct)
|
||||
{
|
||||
var scheduler = agentConfig.Scheduler!;
|
||||
|
||||
if (scheduler.RunOnStart)
|
||||
{
|
||||
await ExecuteScheduledRunAsync(agentConfig, ct);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scheduler.Cron))
|
||||
return;
|
||||
|
||||
var cron = CronExpression.Parse(scheduler.Cron);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = cron.GetNextOccurrence(now);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
_logger.LogWarning("No next occurrence found for agent {AgentId}", agentConfig.AgentId);
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = next.Value - now;
|
||||
_logger.LogDebug("Agent {AgentId} next run at {NextRun}", agentConfig.AgentId, next.Value);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExecuteScheduledRunAsync(agentConfig, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteScheduledRunAsync(AgentConfig agentConfig, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _engine.RunAsync(
|
||||
agentConfig,
|
||||
agentConfig.Scheduler?.TaskMessage ?? "Führe deine zugewiesenen Aufgaben aus.",
|
||||
_instanceId,
|
||||
ct);
|
||||
|
||||
StoreResult(agentConfig.AgentId, result);
|
||||
OnRunCompleted?.Invoke(agentConfig.AgentId, result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Scheduled run completed: {AgentId}, status={Status}, tokens={Tokens}",
|
||||
agentConfig.AgentId, result.Status, result.TokensUsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Scheduled run failed for {AgentId}", agentConfig.AgentId);
|
||||
}
|
||||
}
|
||||
|
||||
private void StoreResult(string agentId, AgentRunResult result)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
_lastResults[agentId] = result;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_schedulerTasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,9 @@ public sealed class CronExpression
|
||||
|
||||
foreach (var part in field.Split(','))
|
||||
{
|
||||
if (part.Length == 0)
|
||||
throw Bad(field, "leeres Teilfeld");
|
||||
|
||||
if (part == "*")
|
||||
{
|
||||
for (var i = min; i <= max; i++) result.Add(i);
|
||||
@@ -97,23 +100,44 @@ public sealed class CronExpression
|
||||
else if (part.Contains('/'))
|
||||
{
|
||||
var split = part.Split('/');
|
||||
var start = split[0] == "*" ? min : int.Parse(split[0]);
|
||||
var step = int.Parse(split[1]);
|
||||
if (split.Length != 2)
|
||||
throw Bad(field, "Schrittangabe erwartet die Form 'basis/schritt'");
|
||||
|
||||
var start = split[0] == "*" ? min : ParseNumber(field, split[0], min, max);
|
||||
var step = ParseNumber(field, split[1], 1, max); // Schritt 0 wäre eine Endlosschleife
|
||||
for (var i = start; i <= max; i += step) result.Add(i);
|
||||
}
|
||||
else if (part.Contains('-'))
|
||||
{
|
||||
var split = part.Split('-');
|
||||
var from = int.Parse(split[0]);
|
||||
var to = int.Parse(split[1]);
|
||||
if (split.Length != 2)
|
||||
throw Bad(field, "Bereich erwartet die Form 'von-bis'");
|
||||
|
||||
var from = ParseNumber(field, split[0], min, max);
|
||||
var to = ParseNumber(field, split[1], min, max);
|
||||
if (from > to)
|
||||
throw Bad(field, $"Bereich {from}-{to} ist rückwärts");
|
||||
|
||||
for (var i = from; i <= to; i++) result.Add(i);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(int.Parse(part));
|
||||
result.Add(ParseNumber(field, part, min, max));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int ParseNumber(string field, string value, int min, int max)
|
||||
{
|
||||
if (!int.TryParse(value, out var n))
|
||||
throw Bad(field, $"'{value}' ist keine Zahl");
|
||||
if (n < min || n > max)
|
||||
throw Bad(field, $"{n} liegt außerhalb von {min}-{max}");
|
||||
return n;
|
||||
}
|
||||
|
||||
private static FormatException Bad(string field, string reason)
|
||||
=> new($"Ungültiges Cron-Feld '{field}': {reason}.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Deutet Zeitzonen-Kennungen unabhängig davon, auf welchem Betriebssystem sie
|
||||
/// geschrieben wurden.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Zeitzonen werden auf Windows und Linux
|
||||
/// unterschiedlich benannt — <c>"W. Europe Standard Time"</c> gegen
|
||||
/// <c>"Europe/Berlin"</c>. Task-Dateien sind Markdown im geteilten Arbeitsverzeichnis
|
||||
/// und wandern zwischen Rechnern. Eine Kennung, die auf dem einen System entstanden ist,
|
||||
/// muss auf dem anderen lesbar bleiben.
|
||||
///
|
||||
/// Zuvor fing <c>TaskSchedule</c> die unbekannte Kennung ab und rechnete <b>still</b>
|
||||
/// in UTC weiter. Ein Task, der um 08:00 Ortszeit laufen sollte, lief damit im Sommer
|
||||
/// um 06:00 — ohne Meldung, ohne Logeintrag. Deshalb hier: beide Schreibweisen deuten,
|
||||
/// und was sich nicht deuten lässt, meldet <see cref="TryResolve"/> als <c>null</c>
|
||||
/// zurück, statt es zu erraten.
|
||||
///
|
||||
/// <para><b>Voraussetzung auf dem Zielsystem:</b> Die Umsetzung zwischen beiden
|
||||
/// Schreibweisen kommt aus den ICU-Daten, die Zeitzonen selbst aus <c>tzdata</c>. In
|
||||
/// einem schlanken Abbild (Alpine ohne <c>icu-libs</c>, distroless) oder bei
|
||||
/// <c>InvariantGlobalization=true</c> fehlen sie — dann schlägt jede Auflösung außer
|
||||
/// UTC fehl. Beides gehört ins Abbild.</para>
|
||||
/// </summary>
|
||||
public static class TimeZones
|
||||
{
|
||||
/// <summary>
|
||||
/// Die lokale Zeitzone in IANA-Schreibweise (<c>Europe/Berlin</c>).
|
||||
///
|
||||
/// Das ist die Form, die in Task-Dateien geschrieben werden soll: Sie gilt auf
|
||||
/// Linux, macOS und — seit .NET 8 — auch auf Windows.
|
||||
/// </summary>
|
||||
public static string LocalIanaId => ToIana(TimeZoneInfo.Local.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Löst eine Kennung auf, gleich ob IANA- oder Windows-Schreibweise.
|
||||
/// Gibt <c>null</c> zurück, wenn sie auf diesem System nicht auflösbar ist —
|
||||
/// der Aufrufer muss dann entscheiden, und zwar sichtbar.
|
||||
/// </summary>
|
||||
public static TimeZoneInfo? TryResolve(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||
|
||||
var value = id.Trim();
|
||||
|
||||
if (string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase))
|
||||
return TimeZoneInfo.Utc;
|
||||
|
||||
// Direkt versuchen: .NET nimmt je nach Version und Plattform bereits beide
|
||||
// Formen an. Wenn das reicht, sind wir fertig.
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(value); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
|
||||
// Sonst die jeweils andere Schreibweise versuchen.
|
||||
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana))
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(iana); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
|
||||
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(value, out var windows))
|
||||
{
|
||||
try { return TimeZoneInfo.FindSystemTimeZoneById(windows); }
|
||||
catch (TimeZoneNotFoundException) { }
|
||||
catch (InvalidTimeZoneException) { }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ob die Kennung auf diesem System auflösbar ist. Leer gilt als gültig — das
|
||||
/// bedeutet „keine Angabe" und wird vom Aufrufer als UTC gedeutet.
|
||||
/// </summary>
|
||||
public static bool IsKnown(string? id)
|
||||
=> string.IsNullOrWhiteSpace(id) || TryResolve(id) is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Bringt eine Kennung auf IANA-Schreibweise. Lässt sie sich nicht umsetzen, kommt
|
||||
/// sie unverändert zurück — eine unbekannte Kennung zu verfälschen wäre schlimmer,
|
||||
/// als sie durchzureichen.
|
||||
/// </summary>
|
||||
public static string ToIana(string? id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return "";
|
||||
|
||||
var value = id.Trim();
|
||||
|
||||
// Enthält einen Schrägstrich → bereits IANA (Windows-Kennungen haben keinen).
|
||||
if (value.Contains('/')) return value;
|
||||
|
||||
return TimeZoneInfo.TryConvertWindowsIdToIanaId(value, out var iana) ? iana : value;
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Scheduling;
|
||||
|
||||
public sealed class ToolJobScheduler : IAsyncDisposable
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly string _instanceId;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly List<Task> _schedulerTasks = new();
|
||||
private readonly Dictionary<string, ToolJobResult?> _lastResults = new();
|
||||
private readonly Lock _resultsLock = new();
|
||||
|
||||
public event Action<string, string, ToolJobResult>? OnJobTick;
|
||||
|
||||
public ToolJobScheduler(
|
||||
AgentEngine engine,
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
string instanceId)
|
||||
{
|
||||
_engine = engine;
|
||||
_toolRegistry = toolRegistry;
|
||||
_stateStore = stateStore;
|
||||
_instanceId = instanceId;
|
||||
_loggerFactory = loggerFactory;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Scheduling.ToolJob");
|
||||
}
|
||||
|
||||
public void RegisterAll(IEnumerable<AgentConfig> agents)
|
||||
{
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
foreach (var jobConfig in agent.ToolJobs)
|
||||
{
|
||||
if (!jobConfig.Enabled)
|
||||
continue;
|
||||
|
||||
var tool = _toolRegistry.Get(jobConfig.ToolName);
|
||||
if (tool is not IToolJobProvider provider)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool '{ToolName}' for job '{JobId}' on agent '{AgentId}' is not a IToolJobProvider or not found",
|
||||
jobConfig.ToolName, jobConfig.JobId, agent.AgentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registering tool job: Agent={AgentId}, Tool={Tool}, JobType={JobType}, Cron={Cron}",
|
||||
agent.AgentId, jobConfig.ToolName, jobConfig.JobTypeId, jobConfig.Cron);
|
||||
|
||||
var task = RunToolJobAsync(agent, jobConfig, provider, _cts.Token);
|
||||
_schedulerTasks.Add(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ToolJobResult? GetLastResult(string jobId)
|
||||
{
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(jobId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen Tool-Job sofort manuell aus (außerhalb des Cron-Zeitplans).
|
||||
/// </summary>
|
||||
public async Task<ToolJobResult> TriggerJobAsync(AgentConfig agentConfig, ToolJobConfig jobConfig, CancellationToken ct)
|
||||
{
|
||||
var tool = _toolRegistry.Get(jobConfig.ToolName);
|
||||
if (tool is not IToolJobProvider provider)
|
||||
return ToolJobResult.NoAction($"Tool '{jobConfig.ToolName}' ist kein IToolJobProvider oder nicht registriert.");
|
||||
|
||||
_logger.LogInformation(
|
||||
"Manual trigger: Agent={AgentId}, Job={JobId}, Type={JobType}",
|
||||
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId);
|
||||
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
|
||||
lock (_resultsLock)
|
||||
return _lastResults.GetValueOrDefault(jobConfig.JobId)
|
||||
?? ToolJobResult.NoAction("Job wurde ausgeführt, aber kein Ergebnis vorhanden.");
|
||||
}
|
||||
|
||||
private async Task RunToolJobAsync(
|
||||
AgentConfig agentConfig,
|
||||
ToolJobConfig jobConfig,
|
||||
IToolJobProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (jobConfig.RunOnStart)
|
||||
{
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobConfig.Cron))
|
||||
return;
|
||||
|
||||
var cron = CronExpression.Parse(jobConfig.Cron);
|
||||
|
||||
while (!ct.IsCancellationRequested && jobConfig.Enabled)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = cron.GetNextOccurrence(now);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
_logger.LogWarning("No next occurrence for tool job {JobId}", jobConfig.JobId);
|
||||
return;
|
||||
}
|
||||
|
||||
var delay = next.Value - now;
|
||||
_logger.LogInformation("Tool job {JobId} ({JobType}) next tick at {NextRun}",
|
||||
jobConfig.JobId, jobConfig.JobTypeId, next.Value);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ExecuteTickAsync(agentConfig, jobConfig, provider, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteTickAsync(
|
||||
AgentConfig agentConfig,
|
||||
ToolJobConfig jobConfig,
|
||||
IToolJobProvider provider,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var jobLogger = _loggerFactory.CreateLogger($"ClawdDotNet.Tools.{jobConfig.ToolName}.Job");
|
||||
|
||||
if (!agentConfig.Tools.ContainsKey(jobConfig.ToolName))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool '{ToolName}' is no longer assigned to agent '{AgentId}' — disabling job '{JobId}'",
|
||||
jobConfig.ToolName, agentConfig.AgentId, jobConfig.JobId);
|
||||
|
||||
jobConfig.Enabled = false;
|
||||
|
||||
var disabledResult = new ToolJobResult(false, null,
|
||||
$"Job deaktiviert: Agent '{agentConfig.DisplayName}' hat keinen Zugriff auf Tool '{jobConfig.ToolName}'");
|
||||
lock (_resultsLock)
|
||||
_lastResults[jobConfig.JobId] = disabledResult;
|
||||
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, disabledResult);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var toolConfig = agentConfig.Tools.TryGetValue(jobConfig.ToolName, out var cfg)
|
||||
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var result = await provider.ExecuteJobAsync(
|
||||
jobConfig.JobTypeId, toolConfig, _stateStore, jobLogger, ct,
|
||||
agentConfig.AgentId, agentConfig.WorkspacePath);
|
||||
|
||||
lock (_resultsLock)
|
||||
_lastResults[jobConfig.JobId] = result;
|
||||
|
||||
OnJobTick?.Invoke(agentConfig.AgentId, jobConfig.JobId, result);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Tool job tick: Agent={AgentId}, Job={JobId}, Type={JobType}, Wake={Wake}, Log={Log}",
|
||||
agentConfig.AgentId, jobConfig.JobId, jobConfig.JobTypeId, result.ShouldWakeAgent, result.LogSummary);
|
||||
|
||||
if (result.ShouldWakeAgent && !string.IsNullOrWhiteSpace(result.WakeMessage))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tool job waking agent: Agent={AgentId}, Job={JobId}, ChatContext={UseChatContext}",
|
||||
agentConfig.AgentId, jobConfig.JobId, result.UseChatContext);
|
||||
|
||||
if (result.UseChatContext)
|
||||
await _engine.ChatAsync(agentConfig, result.WakeMessage, _instanceId, ct, source: ChatSource.Job);
|
||||
else
|
||||
await _engine.RunAsync(agentConfig, result.WakeMessage, _instanceId, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Tool job tick failed: Agent={AgentId}, Job={JobId}",
|
||||
agentConfig.AgentId, jobConfig.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(_schedulerTasks);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ public static class ConfigSecrets
|
||||
telegram.Password2FA = transform(telegram.Password2FA);
|
||||
}
|
||||
|
||||
if (config.Watchdog is { } watchdog)
|
||||
watchdog.AgentToken = transform(watchdog.AgentToken) ?? "";
|
||||
|
||||
foreach (var agent in config.Agents)
|
||||
Apply(agent, transform);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Security.Cryptography;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet den lokalen Schlüssel, mit dem <see cref="SecretProtector"/> die
|
||||
/// <c>enc:v2</c>-Werte sichert.
|
||||
///
|
||||
/// <para><b>Ein Schlüssel je Benutzer und Rechner.</b> Er liegt als
|
||||
/// <c>secret.key</c> in <see cref="AppPaths.ConfigDirectory"/> — bewusst außerhalb
|
||||
/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
|
||||
/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
|
||||
/// nützt auf einem anderen Rechner nichts.</para>
|
||||
///
|
||||
/// <para><b>Rechte.</b> Unter Unix <c>0600</c>. Unter Windows erbt die Datei die
|
||||
/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
|
||||
/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
|
||||
/// plattformübergreifend ist.</para>
|
||||
/// </summary>
|
||||
public static class SecretKeyStore
|
||||
{
|
||||
private const string KeyFileName = "secret.key";
|
||||
private const int KeySize = 32; // AES-256
|
||||
|
||||
/// <summary>DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows).</summary>
|
||||
private static readonly byte[] KeyEntropy =
|
||||
System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
|
||||
|
||||
private static readonly Lock Gate = new();
|
||||
private static byte[]? _cached;
|
||||
private static string? _overrideDirectory;
|
||||
|
||||
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Verlegt den Schlüssel — für Tests, damit sie den echten Benutzerschlüssel weder
|
||||
/// lesen noch überschreiben.
|
||||
/// </summary>
|
||||
public static void UseDirectory(string? directory)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_overrideDirectory = directory;
|
||||
_cached = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den Schlüssel oder legt ihn beim ersten Aufruf an.
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
|
||||
public static byte[] GetOrCreateKey()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_cached is not null) return _cached;
|
||||
|
||||
var directory = AppPaths.EnsureDirectory(Directory());
|
||||
var path = Path.Combine(directory, KeyFileName);
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var stored = Unwrap(File.ReadAllBytes(path));
|
||||
if (stored.Length == KeySize)
|
||||
{
|
||||
_cached = stored;
|
||||
return _cached;
|
||||
}
|
||||
|
||||
// Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen
|
||||
// würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt,
|
||||
// warum — deshalb hier abbrechen und den Pfad nennen.
|
||||
throw new CryptographicException(
|
||||
$"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). "
|
||||
+ "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen.");
|
||||
}
|
||||
|
||||
var key = RandomNumberGenerator.GetBytes(KeySize);
|
||||
|
||||
// Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der
|
||||
// würde alle Geheimnisse dieser Installation unlesbar machen.
|
||||
AtomicFile.WriteAllBytes(path, Wrap(key));
|
||||
AppPaths.RestrictToOwner(path);
|
||||
|
||||
_cached = key;
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
|
||||
|
||||
/// <summary>Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden.</summary>
|
||||
private static byte[] Wrap(byte[] key)
|
||||
=> OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key;
|
||||
|
||||
private static byte[] Unwrap(byte[] stored)
|
||||
=> OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored;
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static byte[] ProtectWithDpapi(byte[] key)
|
||||
=> ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser);
|
||||
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
|
||||
private static byte[] UnprotectWithDpapi(byte[] stored)
|
||||
=> ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Security;
|
||||
|
||||
@@ -12,23 +13,46 @@ namespace ClawdDotNet.Core.Security;
|
||||
/// AgentSettings.json und InstanceConfig.json. Wer die Dateien lesen konnte — ein
|
||||
/// Backup, eine Dateifreigabe, ein versehentlicher Commit — hatte alle Zugänge.
|
||||
///
|
||||
/// Verwendet wird DPAPI im Benutzerkontext: Die Daten lassen sich nur von demselben
|
||||
/// Windows-Benutzer auf demselben Rechner entschlüsseln. Das schützt gegen Weitergabe
|
||||
/// der Datei, nicht gegen einen Angreifer, der bereits als dieser Benutzer läuft —
|
||||
/// für einen lokal laufenden Dienst ist das die angemessene Stufe.
|
||||
/// <para><b>Zwei Formate.</b></para>
|
||||
///
|
||||
/// Verschlüsselte Werte tragen ein Präfix, damit Klartext aus älteren Konfigurationen
|
||||
/// weiterhin gelesen und beim nächsten Speichern automatisch übernommen wird.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>enc:v1:</c> — DPAPI im Benutzerkontext. Nur unter Windows lesbar. Wird
|
||||
/// nicht mehr geschrieben, aber weiterhin gelesen: bestehende Installationen sollen
|
||||
/// ohne Zutun weiterlaufen und wandern beim nächsten Speichern von selbst auf v2.</item>
|
||||
/// <item><c>enc:v2:</c> — AES-256-GCM mit einem Schlüssel aus
|
||||
/// <see cref="AppPaths.ConfigDirectory"/>. Läuft auf jeder Plattform.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para><b>Warum v2 überhaupt nötig wurde.</b> Die vorige Fassung gab unter Linux
|
||||
/// stillschweigend den Klartext zurück — <c>Protect</c> verschlüsselte dort schlicht
|
||||
/// nicht. Auf einem Server, der per SSH erreichbar ist und gesichert wird, wäre das
|
||||
/// schlechter gewesen als auf einem Einzelplatz-Windows.</para>
|
||||
///
|
||||
/// <para><b>Schutzstufe.</b> Dieselbe wie DPAPI zuvor: gegen Weitergabe der
|
||||
/// Konfigurationsdatei, gegen ein Backup, gegen einen versehentlichen Commit — nicht
|
||||
/// gegen einen Angreifer, der bereits als dieser Benutzer läuft. Der Schlüssel liegt
|
||||
/// deshalb bewusst <b>außerhalb</b> des Instanzverzeichnisses: Eine Sicherung der
|
||||
/// Instanz enthält ihn nicht, und ob Geheimnisse mitreisen, entscheidet weiterhin
|
||||
/// allein die Sicherungsrichtlinie in <c>BackupService</c>.</para>
|
||||
/// </summary>
|
||||
public static class SecretProtector
|
||||
{
|
||||
private const string Prefix = "enc:v1:";
|
||||
private const string PrefixV1 = "enc:v1:";
|
||||
private const string PrefixV2 = "enc:v2:";
|
||||
|
||||
/// <summary>Zusätzlicher Kontext, damit ein Wert nicht in anderem Zusammenhang wiederverwendbar ist.</summary>
|
||||
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v1");
|
||||
|
||||
/// <summary>Wird v2 als Zusatzangabe mitgeschrieben und beim Entschlüsseln geprüft.</summary>
|
||||
private static readonly byte[] AssociatedData = Encoding.UTF8.GetBytes("ClawdDotNet.Secrets.v2");
|
||||
|
||||
private const int NonceSize = 12; // AES-GCM: vorgeschriebene Länge
|
||||
private const int TagSize = 16;
|
||||
|
||||
public static bool IsProtected(string? value)
|
||||
=> value?.StartsWith(Prefix, StringComparison.Ordinal) == true;
|
||||
=> value is not null
|
||||
&& (value.StartsWith(PrefixV2, StringComparison.Ordinal)
|
||||
|| value.StartsWith(PrefixV1, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Verschlüsselt einen Wert. Bereits verschlüsselte und leere Werte bleiben unverändert,
|
||||
@@ -39,18 +63,35 @@ public static class SecretProtector
|
||||
if (string.IsNullOrEmpty(plainText) || IsProtected(plainText))
|
||||
return plainText;
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return plainText;
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = ProtectWindows(Encoding.UTF8.GetBytes(plainText));
|
||||
return Prefix + Convert.ToBase64String(encrypted);
|
||||
var key = SecretKeyStore.GetOrCreateKey();
|
||||
|
||||
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
||||
var plain = Encoding.UTF8.GetBytes(plainText);
|
||||
var cipher = new byte[plain.Length];
|
||||
var tag = new byte[TagSize];
|
||||
|
||||
using (var aes = new AesGcm(key, TagSize))
|
||||
aes.Encrypt(nonce, plain, cipher, tag, AssociatedData);
|
||||
|
||||
// nonce ‖ tag ‖ ciphertext — feste Längen vorn, damit das Zerlegen eindeutig ist.
|
||||
var payload = new byte[NonceSize + TagSize + cipher.Length];
|
||||
nonce.CopyTo(payload, 0);
|
||||
tag.CopyTo(payload, NonceSize);
|
||||
cipher.CopyTo(payload, NonceSize + TagSize);
|
||||
|
||||
return PrefixV2 + Convert.ToBase64String(payload);
|
||||
}
|
||||
catch (CryptographicException)
|
||||
catch (Exception ex) when (ex is CryptographicException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Lieber unverschlüsselt weiterarbeiten als die Konfiguration verlieren.
|
||||
return plainText;
|
||||
// Kein Schlüssel anlegbar (etwa ein schreibgeschütztes Konfigurationsverzeichnis).
|
||||
// Den Wert unverschlüsselt zu speichern wäre die stille Rückkehr zu genau dem
|
||||
// Zustand, den S7 behoben hat — deshalb hier abbrechen statt weiterreichen.
|
||||
throw new SecretProtectionException(
|
||||
"Ein Wert konnte nicht verschlüsselt werden, weil der lokale Schlüssel nicht "
|
||||
+ $"lesbar oder anlegbar ist ({SecretKeyStore.KeyFilePath}). Ohne ihn würden "
|
||||
+ "Zugangsdaten im Klartext gespeichert — der Vorgang wurde abgebrochen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,34 +101,74 @@ public static class SecretProtector
|
||||
/// </summary>
|
||||
public static string? Unprotect(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || !IsProtected(value))
|
||||
return value;
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return value;
|
||||
if (value.StartsWith(PrefixV2, StringComparison.Ordinal))
|
||||
return UnprotectV2(value[PrefixV2.Length..]);
|
||||
|
||||
var payload = value[Prefix.Length..];
|
||||
if (value.StartsWith(PrefixV1, StringComparison.Ordinal))
|
||||
return UnprotectV1(value[PrefixV1.Length..]);
|
||||
|
||||
return value; // Klartext aus älteren Konfigurationen
|
||||
}
|
||||
|
||||
private static string UnprotectV2(string payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var decrypted = UnprotectWindows(Convert.FromBase64String(payload));
|
||||
return Encoding.UTF8.GetString(decrypted);
|
||||
var raw = Convert.FromBase64String(payload);
|
||||
if (raw.Length < NonceSize + TagSize)
|
||||
throw new CryptographicException("Der verschlüsselte Block ist unvollständig.");
|
||||
|
||||
var key = SecretKeyStore.GetOrCreateKey();
|
||||
|
||||
var nonce = raw.AsSpan(0, NonceSize);
|
||||
var tag = raw.AsSpan(NonceSize, TagSize);
|
||||
var cipher = raw.AsSpan(NonceSize + TagSize);
|
||||
var plain = new byte[cipher.Length];
|
||||
|
||||
using (var aes = new AesGcm(key, TagSize))
|
||||
aes.Decrypt(nonce, cipher, tag, plain, AssociatedData);
|
||||
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException)
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException
|
||||
or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Etwa nach Benutzerwechsel oder Rechnerwechsel: Der Wert ist hier nicht
|
||||
// lesbar. Ihn als Klartext auszugeben wäre falsch — dann würde ein
|
||||
// unbrauchbarer Schlüssel an die API gehen.
|
||||
throw new SecretProtectionException(
|
||||
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die " +
|
||||
"Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. " +
|
||||
"Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
|
||||
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
|
||||
+ "Konfiguration von einem anderen Rechner oder Benutzer stammt — der Schlüssel "
|
||||
+ $"dazu liegt in {SecretKeyStore.KeyFilePath} und reist nicht mit. "
|
||||
+ "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static byte[] ProtectWindows(byte[] data)
|
||||
=> ProtectedData.Protect(data, Entropy, DataProtectionScope.CurrentUser);
|
||||
private static string UnprotectV1(string payload)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
// Der Fall beim Umzug einer Windows-Instanz auf Linux. Ihn als Klartext
|
||||
// durchzureichen wäre falsch — dann ginge ein unbrauchbarer Schlüssel an die API.
|
||||
throw new SecretProtectionException(
|
||||
"Dieser Wert wurde mit der Windows-Verschlüsselung (DPAPI) gesichert und lässt "
|
||||
+ "sich hier nicht lesen. Beim Umzug einer Instanz von Windows müssen die "
|
||||
+ "betroffenen Werte einmal neu eingetragen werden; danach liegen sie im "
|
||||
+ "plattformübergreifenden Format vor.",
|
||||
new PlatformNotSupportedException("DPAPI ist nur unter Windows verfügbar."));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(UnprotectWindows(Convert.FromBase64String(payload)));
|
||||
}
|
||||
catch (Exception ex) when (ex is CryptographicException or FormatException)
|
||||
{
|
||||
throw new SecretProtectionException(
|
||||
"Ein verschlüsselter Wert konnte nicht gelesen werden. Das passiert, wenn die "
|
||||
+ "Konfiguration von einem anderen Windows-Benutzer oder Rechner stammt. "
|
||||
+ "Bitte den betroffenen Wert in den Einstellungen neu eintragen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static byte[] UnprotectWindows(byte[] data)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Die Staging-Warteschlange in der Instanz-Datenbank. Der bedingte Statuswechsel
|
||||
/// (<see cref="TryTransitionAsync"/>) ist dieselbe atomare Claim-Technik wie beim
|
||||
/// Taskboard: Er verhindert, dass zwei Reviewer denselben Vorschlag doppelt entscheiden.
|
||||
/// </summary>
|
||||
public sealed class SqliteStagingRepository : IStagingRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteStagingRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task<long> AppendAsync(StagedCall call, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO StagedCalls
|
||||
(RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, CreatedAt)
|
||||
VALUES
|
||||
(@runId, @agentId, @instanceId, @tool, @action, @args, @proposal, 'Pending', @createdAt);
|
||||
SELECT last_insert_rowid();
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@runId", call.RunId);
|
||||
cmd.Parameters.AddWithValue("@agentId", call.AgentId);
|
||||
cmd.Parameters.AddWithValue("@instanceId", call.InstanceId);
|
||||
cmd.Parameters.AddWithValue("@tool", call.Tool);
|
||||
cmd.Parameters.AddWithValue("@action", (object?)call.Action ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@args", call.ArgumentsJson);
|
||||
cmd.Parameters.AddWithValue("@proposal", call.Proposal);
|
||||
cmd.Parameters.AddWithValue("@createdAt", Format(DateTime.UtcNow));
|
||||
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct));
|
||||
}, ct);
|
||||
|
||||
public async Task<StagedCall?> GetAsync(long id, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Id = @id LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? Read(reader) : null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls WHERE Status = 'Pending' ORDER BY Id";
|
||||
|
||||
var results = new List<StagedCall>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<bool> TryTransitionAsync(
|
||||
long id, StagingStatus from, StagingStatus to,
|
||||
string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE StagedCalls
|
||||
SET Status = @to, DecidedBy = @decidedBy, DecidedAt = @now,
|
||||
RejectionReason = @reason
|
||||
WHERE Id = @id AND Status = @from
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@to", to.ToString());
|
||||
cmd.Parameters.AddWithValue("@decidedBy", (object?)decidedBy ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@reason", (object?)rejectionReason ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@from", from.ToString());
|
||||
return await cmd.ExecuteNonQueryAsync(ct) == 1;
|
||||
}, ct);
|
||||
|
||||
public Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE StagedCalls
|
||||
SET Status = @status, ResultRef = @resultRef, DecidedAt = @now
|
||||
WHERE Id = @id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@status", status.ToString());
|
||||
cmd.Parameters.AddWithValue("@resultRef", (object?)resultRef ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<int> CountPendingAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM StagedCalls WHERE Status = 'Pending'";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
"Id, RunId, AgentId, InstanceId, Tool, Action, ArgumentsJson, Proposal, Status, " +
|
||||
"CreatedAt, DecidedAt, DecidedBy, ResultRef, RejectionReason";
|
||||
|
||||
private static StagedCall Read(SqliteDataReader r) => new()
|
||||
{
|
||||
Id = r.GetInt64(0),
|
||||
RunId = r.GetString(1),
|
||||
AgentId = r.GetString(2),
|
||||
InstanceId = r.GetString(3),
|
||||
Tool = r.GetString(4),
|
||||
Action = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
ArgumentsJson = r.GetString(6),
|
||||
Proposal = r.GetString(7),
|
||||
Status = Enum.TryParse<StagingStatus>(r.GetString(8), out var s) ? s : StagingStatus.Pending,
|
||||
CreatedAt = Parse(r.GetString(9)),
|
||||
DecidedAt = r.IsDBNull(10) ? null : Parse(r.GetString(10)),
|
||||
DecidedBy = r.IsDBNull(11) ? null : r.GetString(11),
|
||||
ResultRef = r.IsDBNull(12) ? null : r.GetString(12),
|
||||
RejectionReason = r.IsDBNull(13) ? null : r.GetString(13)
|
||||
};
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>Was die Engine mit einem Aufruf tun soll, nachdem das Gate ihn geprüft hat.</summary>
|
||||
public enum StagingOutcome
|
||||
{
|
||||
/// <summary>Normal ausführen.</summary>
|
||||
Proceed,
|
||||
|
||||
/// <summary>Als Vorschlag angelegt — die Ausführung wartet auf Freigabe.</summary>
|
||||
Staged,
|
||||
|
||||
/// <summary>Abgelehnt (Policy <c>deny</c>).</summary>
|
||||
Denied
|
||||
}
|
||||
|
||||
/// <summary>Ergebnis der Gate-Prüfung samt der Nachricht, die der Agent als Tool-Ergebnis sieht.</summary>
|
||||
public readonly record struct StagingInterception(StagingOutcome Outcome, string Message, long StagedId)
|
||||
{
|
||||
public static StagingInterception Proceed { get; } = new(StagingOutcome.Proceed, "", 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Durchsetzungspunkt (A2): prüft die Policy für einen konkreten Aufruf und legt bei
|
||||
/// <c>approve</c> einen eingefrorenen Vorschlag an, statt auszuführen. Optional an der
|
||||
/// Engine — ohne Gate läuft alles wie bisher.
|
||||
/// </summary>
|
||||
public sealed class StagingGate
|
||||
{
|
||||
private readonly StagingPolicy _policy;
|
||||
private readonly IStagingRepository _repo;
|
||||
|
||||
public StagingGate(StagingPolicy policy, IStagingRepository repo)
|
||||
{
|
||||
_policy = policy;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task<StagingInterception> InterceptAsync(
|
||||
string agentId, string instanceId, string runId,
|
||||
string tool, string argumentsJson, CancellationToken ct)
|
||||
{
|
||||
var action = ExtractAction(argumentsJson);
|
||||
|
||||
switch (_policy.Decide(tool, action))
|
||||
{
|
||||
case StagingDecision.Auto:
|
||||
return StagingInterception.Proceed;
|
||||
|
||||
case StagingDecision.Deny:
|
||||
return new StagingInterception(
|
||||
StagingOutcome.Denied,
|
||||
$"Aktion '{Label(tool, action)}' ist gesperrt (Policy: deny) und wurde nicht ausgeführt.",
|
||||
0);
|
||||
|
||||
case StagingDecision.Approve:
|
||||
var id = await _repo.AppendAsync(new StagedCall
|
||||
{
|
||||
RunId = runId,
|
||||
AgentId = agentId,
|
||||
InstanceId = instanceId,
|
||||
Tool = tool,
|
||||
Action = action,
|
||||
ArgumentsJson = argumentsJson,
|
||||
Proposal = BuildProposal(tool, action, argumentsJson)
|
||||
}, ct);
|
||||
|
||||
return new StagingInterception(
|
||||
StagingOutcome.Staged,
|
||||
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. " +
|
||||
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt; du wirst danach " +
|
||||
"mit dem Ergebnis geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
|
||||
id);
|
||||
|
||||
default:
|
||||
return StagingInterception.Proceed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Liest das <c>action</c>-Argument, wenn vorhanden — der Aktionsschlüssel der Policy.</summary>
|
||||
public static string? ExtractAction(string argumentsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argumentsJson))
|
||||
return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argumentsJson);
|
||||
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("action", out var a)
|
||||
&& a.ValueKind == JsonValueKind.String
|
||||
? a.GetString()
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Label(string tool, string? action)
|
||||
=> string.IsNullOrWhiteSpace(action) ? tool : $"{tool}.{action}";
|
||||
|
||||
private static string BuildProposal(string tool, string? action, string argumentsJson)
|
||||
{
|
||||
var args = argumentsJson.Length > 500 ? argumentsJson[..500] + "…" : argumentsJson;
|
||||
return $"{Label(tool, action)} {args}".Trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen freigegebenen, eingefrorenen Aufruf aus — mit gültigem Tool-Kontext, aber
|
||||
/// ohne LLM-Schleife. Von der Engine implementiert.
|
||||
/// </summary>
|
||||
public interface IFrozenCallExecutor
|
||||
{
|
||||
Task<string> ExecuteApprovedCallAsync(
|
||||
string agentId, string tool, string argumentsJson, string runId, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>Was mit einem Tool-Aufruf geschehen soll — die Policy-Entscheidung.</summary>
|
||||
public enum StagingDecision
|
||||
{
|
||||
/// <summary>Ausführen wie bisher.</summary>
|
||||
Auto,
|
||||
|
||||
/// <summary>Stagen und auf menschliche Freigabe warten.</summary>
|
||||
Approve,
|
||||
|
||||
/// <summary>Gar nicht erst vorschlagen — ablehnen.</summary>
|
||||
Deny
|
||||
}
|
||||
|
||||
/// <summary>Lebenszyklus eines eingefrorenen Aufrufs.</summary>
|
||||
public enum StagingStatus
|
||||
{
|
||||
/// <summary>Vorgeschlagen, wartet auf Entscheidung.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Freigegeben und beansprucht (wird ausgeführt).</summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>Freigegeben und ausgeführt.</summary>
|
||||
Executed,
|
||||
|
||||
/// <summary>Freigegeben, aber die Ausführung schlug fehl.</summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>Abgelehnt.</summary>
|
||||
Rejected
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein eingefrorener, konkreter Tool-Aufruf, der auf eine Freigabe wartet. „Eingefroren"
|
||||
/// heißt: Tool, Aktion und die <b>exakten</b> Argumente zum Zeitpunkt des Vorschlags.
|
||||
/// Ausgeführt wird genau das (Plan-Freeze) — nie eine nachträglich veränderte Fassung.
|
||||
/// </summary>
|
||||
public sealed record StagedCall
|
||||
{
|
||||
public long Id { get; init; }
|
||||
|
||||
/// <summary>Lauf, aus dem der Vorschlag stammt — verbindet ihn mit dem Audit-Log.</summary>
|
||||
public string RunId { get; init; } = "";
|
||||
|
||||
public string AgentId { get; init; } = "";
|
||||
public string InstanceId { get; init; } = "";
|
||||
|
||||
public string Tool { get; init; } = "";
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>Die eingefrorenen Argumente (roher JSON, exakt wie vom Modell geschickt).</summary>
|
||||
public string ArgumentsJson { get; init; } = "";
|
||||
|
||||
/// <summary>Kurze, menschenlesbare Zusammenfassung des Vorschlags.</summary>
|
||||
public string Proposal { get; init; } = "";
|
||||
|
||||
public StagingStatus Status { get; init; } = StagingStatus.Pending;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime? DecidedAt { get; init; }
|
||||
public string? DecidedBy { get; init; }
|
||||
|
||||
/// <summary>Nach der Ausführung: kurzer Verweis auf das Ergebnis.</summary>
|
||||
public string? ResultRef { get; init; }
|
||||
|
||||
/// <summary>Bei Ablehnung: der Grund.</summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
}
|
||||
|
||||
public interface IStagingRepository
|
||||
{
|
||||
/// <summary>Legt einen Vorschlag an und gibt seine Id zurück.</summary>
|
||||
Task<long> AppendAsync(StagedCall call, CancellationToken ct);
|
||||
|
||||
Task<StagedCall?> GetAsync(long id, CancellationToken ct);
|
||||
|
||||
/// <summary>Die offenen Vorschläge (Pending), älteste zuerst — für die Review-Ansicht.</summary>
|
||||
Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Atomarer, bedingter Statuswechsel: nur wirksam, wenn der Vorschlag noch im Status
|
||||
/// <paramref name="from"/> steht. Verhindert, dass zwei Reviewer denselben Vorschlag
|
||||
/// doppelt entscheiden. Gibt zurück, ob der Wechsel gelang.
|
||||
/// </summary>
|
||||
Task<bool> TryTransitionAsync(
|
||||
long id, StagingStatus from, StagingStatus to,
|
||||
string? decidedBy, string? rejectionReason, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>Setzt Endstatus und Ergebnis-Verweis nach der Ausführung (zweite Phase).</summary>
|
||||
Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct);
|
||||
|
||||
Task<int> CountPendingAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet je (Tool, Aktion), ob ein Aufruf läuft, gestaged oder abgelehnt wird.
|
||||
///
|
||||
/// Auflösung vom Speziellen zum Allgemeinen: <c>Tool.Aktion</c> → <c>Tool</c> → Standard.
|
||||
/// Rein und ohne Zustand, damit die Entscheidung testbar bleibt.
|
||||
/// </summary>
|
||||
public sealed class StagingPolicy
|
||||
{
|
||||
private readonly Dictionary<string, StagingDecision> _rules;
|
||||
private readonly StagingDecision _default;
|
||||
|
||||
public StagingPolicy(
|
||||
IReadOnlyDictionary<string, StagingDecision>? rules = null,
|
||||
StagingDecision defaultDecision = StagingDecision.Auto)
|
||||
{
|
||||
_rules = new Dictionary<string, StagingDecision>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in rules ?? DefaultRules)
|
||||
_rules[key] = value;
|
||||
_default = defaultDecision;
|
||||
}
|
||||
|
||||
public StagingDecision Decide(string tool, string? action)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(action)
|
||||
&& _rules.TryGetValue($"{tool}.{action}", out var specific))
|
||||
return specific;
|
||||
|
||||
if (_rules.TryGetValue(tool, out var byTool))
|
||||
return byTool;
|
||||
|
||||
return _default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eingebaute Standardregeln — genau die irreversiblen Aktionen aus der Roadmap, nach
|
||||
/// Sichtung der Tools. Lesende Aktionen bleiben bewusst außen vor (Staging soll
|
||||
/// schützen, nicht lähmen). Überschreibbar per Konfiguration.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, StagingDecision> DefaultRules { get; } =
|
||||
new Dictionary<string, StagingDecision>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Mail.send"] = StagingDecision.Approve,
|
||||
["Telegram.send_message"] = StagingDecision.Approve,
|
||||
["Database.insert"] = StagingDecision.Approve,
|
||||
["Database.upsert"] = StagingDecision.Approve,
|
||||
["FileRW.delete"] = StagingDecision.Approve,
|
||||
["FTP.upload"] = StagingDecision.Approve,
|
||||
["FTP.delete"] = StagingDecision.Approve
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
public enum StagingResultKind { NotFound, AlreadyDecided, Executed, Failed, Rejected }
|
||||
|
||||
public readonly record struct StagingResult(StagingResultKind Kind, string Message)
|
||||
{
|
||||
public static StagingResult NotFound { get; } = new(StagingResultKind.NotFound, "Vorschlag nicht gefunden.");
|
||||
public static StagingResult AlreadyDecided { get; } =
|
||||
new(StagingResultKind.AlreadyDecided, "Der Vorschlag wurde bereits entschieden.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Freigabe-/Ablehnungs-Seite von A2 — die API, die die Review-Oberfläche aufruft.
|
||||
///
|
||||
/// Kein pausierter Lauf: Bei Freigabe wird der <b>eingefrorene</b> Aufruf direkt
|
||||
/// ausgeführt (Plan-Freeze), das Ergebnis festgehalten und der Agent über einen
|
||||
/// Folge-Task (A1) mit dem Ergebnis geweckt. Jede Entscheidung wird als Approval-Record
|
||||
/// ins Audit-Log (A3) geschrieben.
|
||||
/// </summary>
|
||||
public sealed class StagingService
|
||||
{
|
||||
private readonly IStagingRepository _repo;
|
||||
private readonly IFrozenCallExecutor _executor;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly IAuditRepository? _audit;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public StagingService(
|
||||
IStagingRepository repo,
|
||||
IFrozenCallExecutor executor,
|
||||
TaskboardService board,
|
||||
ILoggerFactory loggerFactory,
|
||||
IAuditRepository? audit = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_executor = executor;
|
||||
_board = board;
|
||||
_audit = audit;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct) => _repo.ListPendingAsync(ct);
|
||||
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
|
||||
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
|
||||
|
||||
public async Task<StagingResult> ApproveAsync(long id, string decidedBy, CancellationToken ct)
|
||||
{
|
||||
var call = await _repo.GetAsync(id, ct);
|
||||
if (call is null) return StagingResult.NotFound;
|
||||
|
||||
// Atomar beanspruchen — ein zweiter Reviewer läuft ins Leere, bevor irgendetwas
|
||||
// ausgeführt wird.
|
||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Approved, decidedBy, null, DateTime.UtcNow, ct))
|
||||
return StagingResult.AlreadyDecided;
|
||||
|
||||
string result;
|
||||
StagingStatus final;
|
||||
try
|
||||
{
|
||||
// Genau der eingefrorene Aufruf — nie eine neu formulierte Fassung.
|
||||
result = await _executor.ExecuteApprovedCallAsync(
|
||||
call.AgentId, call.Tool, call.ArgumentsJson, Guid.NewGuid().ToString("N"), ct);
|
||||
final = StagingStatus.Executed;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
result = $"Ausführung fehlgeschlagen: {ex.Message}";
|
||||
final = StagingStatus.Failed;
|
||||
_logger.LogError(ex, "Freigegebener Aufruf #{Id} ({Tool}) schlug fehl", id, call.Tool);
|
||||
}
|
||||
|
||||
await _repo.FinalizeAsync(id, final, Cap(result, 500), DateTime.UtcNow, ct);
|
||||
await RecordAuditAsync(call, decidedBy, final == StagingStatus.Executed ? AuditStatus.Ok : AuditStatus.Error,
|
||||
$"Freigegeben von {decidedBy}", ct);
|
||||
await WakeAgentAsync(call,
|
||||
$"Freigabe-Ergebnis: {call.Tool}",
|
||||
$"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben und ausgeführt.\n\n" +
|
||||
$"Ergebnis:\n{result}\n\nSetze deine Arbeit fort.", ct);
|
||||
|
||||
return new StagingResult(
|
||||
final == StagingStatus.Executed ? StagingResultKind.Executed : StagingResultKind.Failed, result);
|
||||
}
|
||||
|
||||
public async Task<StagingResult> RejectAsync(long id, string decidedBy, string reason, CancellationToken ct)
|
||||
{
|
||||
var call = await _repo.GetAsync(id, ct);
|
||||
if (call is null) return StagingResult.NotFound;
|
||||
|
||||
if (!await _repo.TryTransitionAsync(id, StagingStatus.Pending, StagingStatus.Rejected, decidedBy, reason, DateTime.UtcNow, ct))
|
||||
return StagingResult.AlreadyDecided;
|
||||
|
||||
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
|
||||
await WakeAgentAsync(call,
|
||||
$"Freigabe abgelehnt: {call.Tool}",
|
||||
$"[Ablehnung] Deine vorgelegte Aktion '{Label(call)}' wurde abgelehnt.\n\n" +
|
||||
$"Grund: {reason}\n\nFühre sie nicht erneut ohne Rücksprache aus.", ct);
|
||||
|
||||
return new StagingResult(StagingResultKind.Rejected, reason);
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private async Task WakeAgentAsync(StagedCall call, string title, string body, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Title = title,
|
||||
Body = body,
|
||||
Status = TaskItemStatus.Todo,
|
||||
Assignee = "@" + call.AgentId,
|
||||
Type = TaskItemType.Work
|
||||
}, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Folge-Task für Vorschlag #{Id} konnte nicht angelegt werden", call.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordAuditAsync(
|
||||
StagedCall call, string decidedBy, AuditStatus status, string summary, CancellationToken ct)
|
||||
{
|
||||
if (_audit is null) return;
|
||||
try
|
||||
{
|
||||
await _audit.AppendAsync(new AuditEntry
|
||||
{
|
||||
RunId = call.RunId,
|
||||
AgentId = call.AgentId,
|
||||
Model = "",
|
||||
Source = "approval",
|
||||
Tool = call.Tool,
|
||||
Arguments = Cap(call.ArgumentsJson, 4_000),
|
||||
Status = status,
|
||||
Summary = Cap(summary, 500),
|
||||
OccurredAt = DateTime.Now
|
||||
}, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Approval-Record für #{Id} konnte nicht geschrieben werden", call.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Label(StagedCall call)
|
||||
=> string.IsNullOrWhiteSpace(call.Action) ? call.Tool : $"{call.Tool}.{call.Action}";
|
||||
|
||||
private static string Cap(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Wo die Anwendung ihre eigenen Dateien ablegt — getrennt nach Programm und Daten.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Bisher lagen Einstellungen und Arbeitsordner neben
|
||||
/// der Programmdatei (<c>AppDomain.CurrentDomain.BaseDirectory</c>). Unter Windows in
|
||||
/// einem Benutzerverzeichnis geht das; unter Linux liegt die Anwendung in <c>/opt</c>
|
||||
/// oder <c>/usr/lib</c> und ist für den Dienstbenutzer <b>nicht schreibbar</b>.
|
||||
///
|
||||
/// Deshalb hier die übliche Trennung: Programm bleibt, wo es installiert ist, Daten
|
||||
/// wandern in das Verzeichnis des Benutzers (XDG unter Linux, <c>%APPDATA%</c> unter
|
||||
/// Windows). Beides lässt sich per Umgebungsvariable überschreiben — für Dienste, die
|
||||
/// nach <c>/var/lib</c> schreiben sollen, und für Tests.
|
||||
/// </summary>
|
||||
public static class AppPaths
|
||||
{
|
||||
private const string AppFolder = "ClawdDotNet";
|
||||
|
||||
/// <summary>Einstellungen und Schlüssel. Klein, selten geschrieben, gehört gesichert.</summary>
|
||||
public static string ConfigDirectory { get; } = ResolveConfig();
|
||||
|
||||
/// <summary>Instanzen, Datenbanken, Logs. Groß, oft geschrieben.</summary>
|
||||
public static string DataDirectory { get; } = ResolveData();
|
||||
|
||||
/// <summary>
|
||||
/// Das Verzeichnis der Programmdatei.
|
||||
///
|
||||
/// Weiterhin nötig, um bestehende Windows-Installationen zu finden: Dort liegt die
|
||||
/// <c>AppSettings.json</c> noch am alten Ort, und ein Update darf sie nicht
|
||||
/// verwaisen lassen.
|
||||
/// </summary>
|
||||
public static string ProgramDirectory => AppContext.BaseDirectory;
|
||||
|
||||
/// <summary>Legt das Verzeichnis an und schränkt unter Unix die Rechte auf den Benutzer ein.</summary>
|
||||
public static string EnsureDirectory(string path)
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
|
||||
// 0700: In den Konfigurationsverzeichnissen liegen Schlüssel und Zugangsdaten.
|
||||
// Unter Windows regelt das die Vererbung aus dem Benutzerprofil, unter Linux
|
||||
// wären es sonst je nach umask 0755 — für alle lesbar.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(path,
|
||||
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
catch (IOException) { /* etwa auf Netzlaufwerken ohne Rechteverwaltung */ }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>Schränkt eine Datei unter Unix auf <c>0600</c> ein. Unter Windows wirkungslos.</summary>
|
||||
public static void RestrictToOwner(string filePath)
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
catch (IOException) { }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
|
||||
// ─── Auflösung ───
|
||||
|
||||
private static string ResolveConfig()
|
||||
{
|
||||
if (FromEnvironment("CLAWD_CONFIG_DIR") is { } explicitDir)
|
||||
return explicitDir;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), AppFolder);
|
||||
|
||||
if (FromEnvironment("XDG_CONFIG_HOME") is { } xdg)
|
||||
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
|
||||
|
||||
if (FromEnvironment("HOME") is { } home)
|
||||
return Path.Combine(home, ".config", AppFolder.ToLowerInvariant());
|
||||
|
||||
// Ein Dienst ohne HOME. Lieber ein fester, dokumentierter Ort als ein relativer
|
||||
// Pfad, der vom Arbeitsverzeichnis abhängt und beim nächsten Start woanders liegt.
|
||||
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string ResolveData()
|
||||
{
|
||||
if (FromEnvironment("CLAWD_DATA_DIR") is { } explicitDir)
|
||||
return explicitDir;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolder);
|
||||
|
||||
if (FromEnvironment("XDG_DATA_HOME") is { } xdg)
|
||||
return Path.Combine(xdg, AppFolder.ToLowerInvariant());
|
||||
|
||||
if (FromEnvironment("HOME") is { } home)
|
||||
return Path.Combine(home, ".local", "share", AppFolder.ToLowerInvariant());
|
||||
|
||||
return Path.Combine("/var/lib", AppFolder.ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static string? FromEnvironment(string name)
|
||||
{
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>GetFolderPath</c> kann eine leere Zeichenkette liefern (Dienstkonto ohne
|
||||
/// geladenes Profil). <c>Path.Combine("", …)</c> ergäbe dann einen relativen Pfad —
|
||||
/// die Datei landete im Arbeitsverzeichnis und wäre beim nächsten Start verschwunden.
|
||||
/// </summary>
|
||||
private static string Combine(string root, string folder)
|
||||
=> string.IsNullOrWhiteSpace(root)
|
||||
? Path.Combine(AppContext.BaseDirectory, folder)
|
||||
: Path.Combine(root, folder);
|
||||
}
|
||||
@@ -29,11 +29,15 @@ public static class AtomicFile
|
||||
/// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt
|
||||
/// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab.
|
||||
/// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar.
|
||||
///
|
||||
/// Der Schlüssel kommt aus <see cref="PathBoundary.CanonicalKey"/>: Unter Windows
|
||||
/// meinen "Config.json" und "config.json" dieselbe Datei und brauchen dieselbe
|
||||
/// Sperre, unter Linux sind es zwei Dateien, die sich keine teilen dürfen.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();
|
||||
|
||||
private static SemaphoreSlim LockFor(string fullPath)
|
||||
=> PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
|
||||
=> PathLocks.GetOrAdd(PathBoundary.CanonicalKey(fullPath), _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht und begrenzt Dateipfade so, wie es das jeweilige Dateisystem tut.
|
||||
///
|
||||
/// Hintergrund: Die Einschließungsprüfungen im Projekt verglichen mit
|
||||
/// <c>OrdinalIgnoreCase</c> — die Annahme von Windows, dass Groß- und Kleinschreibung
|
||||
/// keine Rolle spielt. Unter Linux ist das falsch: <c>/home/x/Workspace</c> und
|
||||
/// <c>/home/x/workspace</c> sind zwei verschiedene Verzeichnisse. Ein Kandidat im
|
||||
/// zweiten würde als „innerhalb" des ersten durchgehen.
|
||||
///
|
||||
/// Betroffen waren drei Sandbox-Grenzen (FileRW, FTP) und die Archiventpackung.
|
||||
///
|
||||
/// Zweiter Punkt, den es unter Windows so nicht gab: <b>symbolische Verknüpfungen</b>.
|
||||
/// <c>Path.GetFullPath</c> löst sie nicht auf — es rechnet nur <c>..</c> heraus. Legt
|
||||
/// ein Agent in seinem Arbeitsverzeichnis eine Verknüpfung nach <c>/etc</c> an, liegt
|
||||
/// <c>workspace/etc/passwd</c> nach reiner Zeichenkettenrechnung innerhalb, zeigt aber
|
||||
/// hinaus. <see cref="Canonicalize"/> löst deshalb jeden Pfadabschnitt auf.
|
||||
/// </summary>
|
||||
public static class PathBoundary
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie das Dateisystem Pfade vergleicht.
|
||||
///
|
||||
/// Nur Windows und macOS führen Groß- und Kleinschreibung zusammen. Bei macOS ist
|
||||
/// das genau genommen eine Frage des Dateisystems (HFS+/APFS meist ja, aber
|
||||
/// case-sensitive formatierbar) — dort auf der sicheren Seite zu liegen heißt,
|
||||
/// den zusammenführenden Vergleich zu wählen: Er weist im Zweifel zu viel ab,
|
||||
/// statt zu wenig.
|
||||
/// </summary>
|
||||
public static StringComparison Comparison { get; } =
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
/// <inheritdoc cref="Comparison"/>
|
||||
public static StringComparer Comparer { get; } =
|
||||
OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
|
||||
? StringComparer.OrdinalIgnoreCase
|
||||
: StringComparer.Ordinal;
|
||||
|
||||
/// <summary>Absoluter Pfad mit genau einem abschließenden Trenner.</summary>
|
||||
public static string NormalizeDirectory(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return full.EndsWith(Path.DirectorySeparatorChar)
|
||||
? full
|
||||
: full + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob <paramref name="candidate"/> im Verzeichnis <paramref name="root"/> liegt.
|
||||
///
|
||||
/// Verglichen wird auf Verzeichnisgrenzen, nicht auf Zeichenketten-Präfixen: Ohne
|
||||
/// den abschließenden Trenner gälte <c>…/Workspace-Backup</c> als Teil von
|
||||
/// <c>…/Workspace</c>.
|
||||
///
|
||||
/// Symbolische Verknüpfungen werden aufgelöst (<see cref="Canonicalize"/>) — ein
|
||||
/// Pfad, der nur über eine Verknüpfung hinauszeigt, gilt als außerhalb.
|
||||
/// </summary>
|
||||
public static bool IsInside(string candidate, string root)
|
||||
{
|
||||
var normalizedRoot = Canonicalize(NormalizeDirectory(root));
|
||||
var normalizedCandidate = Canonicalize(Path.GetFullPath(candidate));
|
||||
|
||||
// Der Root selbst gilt als innerhalb.
|
||||
if (string.Equals(
|
||||
normalizedCandidate.TrimEnd(Path.DirectorySeparatorChar),
|
||||
normalizedRoot.TrimEnd(Path.DirectorySeparatorChar),
|
||||
Comparison))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return normalizedCandidate.StartsWith(
|
||||
NormalizeDirectory(normalizedRoot), Comparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schlüssel für Sperren und Wörterbücher, die einen Pfad eindeutig meinen sollen.
|
||||
///
|
||||
/// Unter Windows werden Schreibweisen zusammengeführt, unter Linux nicht — dort
|
||||
/// sind zwei Schreibweisen zwei Dateien und dürfen sich keine Sperre teilen.
|
||||
/// </summary>
|
||||
public static string CanonicalKey(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
return Comparison == StringComparison.OrdinalIgnoreCase
|
||||
? full.ToLowerInvariant()
|
||||
: full;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst symbolische Verknüpfungen in jedem Abschnitt des Pfades auf.
|
||||
///
|
||||
/// Abschnittsweise, weil eine Verknüpfung mitten im Pfad genügt: Zeigt
|
||||
/// <c>workspace/daten</c> nach <c>/etc</c>, dann liegt <c>workspace/daten/passwd</c>
|
||||
/// außerhalb — obwohl weder der Anfang noch das Ende des Pfades eine Verknüpfung
|
||||
/// ist. <c>ResolveLinkTarget(returnFinalTarget: true)</c> folgt dabei ganzen Ketten,
|
||||
/// ein Aufruf je Abschnitt genügt also.
|
||||
///
|
||||
/// Noch nicht existierende Abschnitte bleiben unverändert — für einen Pfad, der erst
|
||||
/// angelegt werden soll, ist das der richtige Umgang: Was es nicht gibt, kann keine
|
||||
/// Verknüpfung sein, und der bereits vorhandene Teil davor wurde geprüft.
|
||||
/// </summary>
|
||||
public static string Canonicalize(string path)
|
||||
{
|
||||
var full = Path.GetFullPath(path);
|
||||
|
||||
var root = Path.GetPathRoot(full);
|
||||
if (string.IsNullOrEmpty(root))
|
||||
return full;
|
||||
|
||||
var rest = full[root.Length..]
|
||||
.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
|
||||
StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in rest)
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
|
||||
string? target;
|
||||
try
|
||||
{
|
||||
// Gibt null zurück, wenn der Abschnitt keine Verknüpfung ist oder
|
||||
// nicht existiert. Wirft bei Zyklen und bei zu tiefen Ketten.
|
||||
target = Directory.Exists(current)
|
||||
? Directory.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName
|
||||
: File.ResolveLinkTarget(current, returnFinalTarget: true)?.FullName;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Zyklus oder unauflösbare Kette. Der Pfad ist damit nicht bestimmbar —
|
||||
// wir geben zurück, was wir haben. Die Einschließungsprüfung entscheidet
|
||||
// dann auf der bisherigen, unaufgelösten Fassung: Sie weist im Zweifel ab.
|
||||
return full;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return full;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(target))
|
||||
{
|
||||
current = Path.IsPathRooted(target)
|
||||
? target
|
||||
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(current) ?? root, target));
|
||||
}
|
||||
}
|
||||
|
||||
return Path.GetFullPath(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Erzeugt Dateinamen, die auf Windows <b>und</b> Linux gültig sind.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): <see cref="Path.GetInvalidFileNameChars"/> liefert
|
||||
/// unter Windows 41 Zeichen, unter Unix genau zwei (<c>\0</c> und <c>/</c>). Wer sich
|
||||
/// darauf verlässt, erzeugt unter Linux Namen wie <c>bericht:2026.zip</c> — dort
|
||||
/// zulässig, unter Windows nicht anlegbar.
|
||||
///
|
||||
/// Das trifft alles, was zwischen Systemen wandert: Sicherungsarchive, Logdateien,
|
||||
/// Task-Dateien im geteilten Arbeitsverzeichnis. Deshalb hier bewusst der strengere
|
||||
/// Maßstab auf beiden Plattformen — ein paar Unterstriche mehr sind billiger als eine
|
||||
/// Sicherung, die sich auf dem Zielsystem nicht auspacken lässt.
|
||||
/// </summary>
|
||||
public static class PortableFileName
|
||||
{
|
||||
/// <summary>Unter Windows unzulässig, unter Unix erlaubt — hier immer ersetzt.</summary>
|
||||
private static readonly char[] WindowsReserved =
|
||||
['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
|
||||
|
||||
/// <summary>
|
||||
/// Gerätenamen, die Windows unabhängig von der Endung nicht als Datei zulässt.
|
||||
/// Unter Linux völlig gewöhnliche Namen — deshalb fällt es dort erst beim
|
||||
/// Zurückspielen auf.
|
||||
/// </summary>
|
||||
private static readonly string[] ReservedNames =
|
||||
[
|
||||
"CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
|
||||
];
|
||||
|
||||
public static string Sanitize(string? name, string fallback = "unbenannt")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return fallback;
|
||||
|
||||
var chars = name.Select(c =>
|
||||
c < 32 || WindowsReserved.Contains(c) ? '_' : c).ToArray();
|
||||
|
||||
// Windows schneidet abschließende Punkte und Leerzeichen stillschweigend ab —
|
||||
// aus "bericht." würde "bericht", und zwei Dateien fielen zusammen.
|
||||
var result = new string(chars).TrimEnd('.', ' ');
|
||||
|
||||
if (result.Length == 0)
|
||||
return fallback;
|
||||
|
||||
var stem = Path.GetFileNameWithoutExtension(result);
|
||||
if (ReservedNames.Contains(stem, StringComparer.OrdinalIgnoreCase))
|
||||
result = "_" + result;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,112 @@ public sealed class SqliteStorage
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunUsage_Recent
|
||||
ON RunUsage (OccurredAt DESC);
|
||||
|
||||
-- Taskboard (A1): Ausführungszustand der Aufgaben. Die Definition lebt in
|
||||
-- Markdown-Dateien; diese Tabelle spiegelt sie und macht das Claiming atomar.
|
||||
CREATE TABLE IF NOT EXISTS Tasks (
|
||||
Id TEXT PRIMARY KEY,
|
||||
Title TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT 'todo',
|
||||
Type TEXT NOT NULL DEFAULT 'work',
|
||||
Priority INTEGER NOT NULL DEFAULT 3,
|
||||
Assignee TEXT NOT NULL DEFAULT '@human',
|
||||
WhenKind TEXT NULL,
|
||||
WhenValue TEXT NULL,
|
||||
WhenTz TEXT NULL,
|
||||
RequireApproval INTEGER NOT NULL DEFAULT 0,
|
||||
Acceptance TEXT NOT NULL DEFAULT '',
|
||||
-- Blocker als "|a|b|"; die Begrenzer verhindern Teiltreffer bei der Suche.
|
||||
BlockedBy TEXT NOT NULL DEFAULT '',
|
||||
OnlyWhenMarketOpen INTEGER NOT NULL DEFAULT 0,
|
||||
Body TEXT NOT NULL DEFAULT '',
|
||||
FileName TEXT NOT NULL DEFAULT '',
|
||||
-- Nur für Typ tool_job: welches Tool mit welcher Job-Art getickt wird.
|
||||
ToolName TEXT NOT NULL DEFAULT '',
|
||||
JobTypeId TEXT NOT NULL DEFAULT '',
|
||||
-- Ausführungszustand: nur das Board schreibt hier.
|
||||
LastOccurrence TEXT NULL,
|
||||
ClaimToken TEXT NULL,
|
||||
ClaimedAt TEXT NULL,
|
||||
CreatedAt TEXT NOT NULL,
|
||||
UpdatedAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Tasks_Status
|
||||
ON Tasks (Status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_Tasks_Assignee
|
||||
ON Tasks (Assignee);
|
||||
|
||||
-- Audit-Log (A3): ein Eintrag je Tool-Aufruf. Append-only — die Herkunft
|
||||
-- stempelt die Engine, Korrekturen sind neue Zeilen.
|
||||
CREATE TABLE IF NOT EXISTS AuditLog (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT 'unknown',
|
||||
Tool TEXT NOT NULL,
|
||||
Arguments TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL,
|
||||
Summary TEXT NOT NULL DEFAULT '',
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
|
||||
ON AuditLog (RunId, Id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_AuditLog_Recent
|
||||
ON AuditLog (Id DESC);
|
||||
|
||||
-- Abschluss-Belege (Receipts): ein Beleg je Lauf, verknüpft mit einem Task.
|
||||
CREATE TABLE IF NOT EXISTS RunReceipts (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
Model TEXT NOT NULL DEFAULT '',
|
||||
Source TEXT NOT NULL DEFAULT 'unknown',
|
||||
TaskId TEXT NULL,
|
||||
Status TEXT NOT NULL DEFAULT '',
|
||||
StepCount INTEGER NOT NULL DEFAULT 0,
|
||||
PromptTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CompletionTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CachedTokens INTEGER NOT NULL DEFAULT 0,
|
||||
CostUsd TEXT NOT NULL DEFAULT '0',
|
||||
CostIsKnown INTEGER NOT NULL DEFAULT 0,
|
||||
DurationMs INTEGER NOT NULL DEFAULT 0,
|
||||
ResultRef TEXT NOT NULL DEFAULT '',
|
||||
OccurredAt TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Task
|
||||
ON RunReceipts (TaskId);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_RunReceipts_Run
|
||||
ON RunReceipts (RunId);
|
||||
|
||||
-- Staging (A2): eingefrorene, freigabepflichtige Tool-Aufrufe. Ausgeführt wird
|
||||
-- genau der gespeicherte Argument-JSON (Plan-Freeze).
|
||||
CREATE TABLE IF NOT EXISTS StagedCalls (
|
||||
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
RunId TEXT NOT NULL,
|
||||
AgentId TEXT NOT NULL,
|
||||
InstanceId TEXT NOT NULL DEFAULT '',
|
||||
Tool TEXT NOT NULL,
|
||||
Action TEXT NULL,
|
||||
ArgumentsJson TEXT NOT NULL DEFAULT '',
|
||||
Proposal TEXT NOT NULL DEFAULT '',
|
||||
Status TEXT NOT NULL DEFAULT 'Pending',
|
||||
CreatedAt TEXT NOT NULL,
|
||||
DecidedAt TEXT NULL,
|
||||
DecidedBy TEXT NULL,
|
||||
ResultRef TEXT NULL,
|
||||
RejectionReason TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
|
||||
ON StagedCalls (Status, Id);
|
||||
""";
|
||||
|
||||
cmd.ExecuteNonQuery();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet Ordner und Dateien im Dateimanager des Systems.
|
||||
///
|
||||
/// Hintergrund (Linux-Portierung): Die Oberfläche rief an vier Stellen direkt
|
||||
/// <c>explorer.exe</c> auf. Hier liegt das plattformneutral — und in Core statt in der
|
||||
/// Oberfläche, weil die künftige Avalonia-Fassung dieselben Aufrufe braucht.
|
||||
///
|
||||
/// Bewusst ohne Rückmeldung im Fehlerfall: Einen Ordner zu öffnen ist eine
|
||||
/// Bequemlichkeit. Schlägt es fehl (kein Dateimanager installiert, Dienst ohne
|
||||
/// Sitzung), soll das den Aufrufer nicht beschäftigen.
|
||||
/// </summary>
|
||||
public static class SystemShell
|
||||
{
|
||||
/// <summary>Öffnet ein Verzeichnis im Dateimanager.</summary>
|
||||
public static bool OpenFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
return false;
|
||||
|
||||
return Open(path);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet den Ordner einer Datei und hebt sie nach Möglichkeit hervor.
|
||||
///
|
||||
/// Das Hervorheben können nur Windows und macOS. Unter Linux gibt es keinen
|
||||
/// Aufruf, der über alle Dateimanager hinweg funktioniert — dort wird nur der
|
||||
/// Ordner geöffnet. Das ist der kleinere Verlust gegenüber einer Liste von
|
||||
/// Sonderfällen je Desktop-Umgebung.
|
||||
/// </summary>
|
||||
public static bool RevealFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
return false;
|
||||
|
||||
var folder = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||
if (string.IsNullOrEmpty(folder)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return Start("explorer.exe", ["/select,", Path.GetFullPath(path)]);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Start("open", ["-R", Path.GetFullPath(path)]);
|
||||
}
|
||||
catch { /* siehe Klassenkommentar */ }
|
||||
|
||||
return OpenFolder(folder);
|
||||
}
|
||||
|
||||
private static bool Open(string target)
|
||||
{
|
||||
var full = Path.GetFullPath(target);
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsLinux())
|
||||
return Start("xdg-open", [full]);
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
return Start("open", [full]);
|
||||
|
||||
// Windows: UseShellExecute lässt die Shell entscheiden, statt explorer.exe
|
||||
// festzuschreiben.
|
||||
using var process = Process.Start(new ProcessStartInfo(full)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
return process is not null;
|
||||
}
|
||||
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Startet mit <c>ArgumentList</c> statt einer Argumentzeichenkette — so muss nichts
|
||||
/// maskiert werden, und ein Pfad mit Leerzeichen oder Anführungszeichen kann keinen
|
||||
/// zusätzlichen Aufruf einschleusen.
|
||||
/// </summary>
|
||||
private static bool Start(string fileName, string[] arguments)
|
||||
{
|
||||
var info = new ProcessStartInfo(fileName)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
info.ArgumentList.Add(argument);
|
||||
|
||||
using var process = Process.Start(info);
|
||||
return process is not null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Einmalige Überführung der improvisierten <c>coordination/*.md</c>-Dateien ins Taskboard
|
||||
/// (A1, Schritt 4).
|
||||
///
|
||||
/// Bewusst konservativ, weil die Altdateien kein Schema haben: Nur <c>task_*.md</c> gelten
|
||||
/// als Aufgaben und werden als <b>Backlog</b>-Aufgaben mit Assignee <c>@human</c>
|
||||
/// übernommen — ein Mensch ordnet sie zu, bevor irgendetwas läuft. <c>status_*</c>,
|
||||
/// <c>broadcast</c>, Incident-Berichte und <c>*.json</c>-Artefakte sind keine Aufgaben und
|
||||
/// bleiben unangetastet. Was sich nicht sicher deuten lässt, wird nicht verfälscht.
|
||||
///
|
||||
/// Idempotent: Eine übernommene Datei wandert nach <c>coordination/migrated/</c> — sie
|
||||
/// wird nicht gelöscht (die Historie bleibt), aber ein zweiter Start findet sie nicht mehr.
|
||||
/// </summary>
|
||||
public sealed class CoordinationMigration
|
||||
{
|
||||
private readonly TaskboardService _board;
|
||||
private readonly string _coordinationDir;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private static readonly Regex TaskHeading =
|
||||
new(@"^#\s*Task\b[^:]*:\s*(.+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex AnyHeading =
|
||||
new(@"^#\s+(.+)$", RegexOptions.Compiled);
|
||||
|
||||
public CoordinationMigration(
|
||||
TaskboardService board, string coordinationDirectory, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_board = board;
|
||||
_coordinationDir = coordinationDirectory;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Migration");
|
||||
}
|
||||
|
||||
/// <summary>Führt die Migration aus. Gibt die Zahl der übernommenen Aufgaben zurück.</summary>
|
||||
public async Task<int> RunAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(_coordinationDir))
|
||||
return 0;
|
||||
|
||||
var migratedDir = Path.Combine(_coordinationDir, "migrated");
|
||||
var count = 0;
|
||||
|
||||
// Nur die oberste Ebene — der migrated/-Unterordner wird so nie erneut gelesen.
|
||||
foreach (var path in Directory.EnumerateFiles(_coordinationDir, "task_*.md"))
|
||||
{
|
||||
string text;
|
||||
try { text = AtomicFile.ReadAllText(path); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Migration: {File} nicht lesbar — übersprungen", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
var title = ExtractTitle(text, fileName);
|
||||
|
||||
var body =
|
||||
$"_Übernommen aus coordination/{fileName} bei der Taskboard-Migration (A1). " +
|
||||
"Assignee und Status bitte prüfen._\n\n" + text.Trim();
|
||||
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Title = title,
|
||||
Body = body,
|
||||
Status = TaskItemStatus.Backlog, // erst nach menschlicher Sichtung bereit
|
||||
Assignee = TaskAssignee.Human, // sicherer Standard, bis jemand zuordnet
|
||||
Type = TaskItemType.Work
|
||||
}, ct);
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(migratedDir);
|
||||
File.Move(path, Path.Combine(migratedDir, fileName), overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Migration: {File} übernommen, konnte aber nicht verschoben werden", fileName);
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
_logger.LogInformation("Taskboard-Migration: {Count} coordination/task_*-Datei(en) übernommen", count);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static string ExtractTitle(string text, string fileName)
|
||||
{
|
||||
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var match = TaskHeading.Match(line.Trim());
|
||||
if (match.Success)
|
||||
return Trim(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
foreach (var line in text.Replace("\r\n", "\n").Split('\n'))
|
||||
{
|
||||
var match = AnyHeading.Match(line.Trim());
|
||||
if (match.Success)
|
||||
return Trim(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
// Kein Titel im Text: aus dem Dateinamen ableiten ("task_video_x" → "video x").
|
||||
var stem = Path.GetFileNameWithoutExtension(fileName);
|
||||
if (stem.StartsWith("task_", StringComparison.OrdinalIgnoreCase))
|
||||
stem = stem[5..];
|
||||
return Trim(stem.Replace('_', ' '));
|
||||
}
|
||||
|
||||
private static string Trim(string value)
|
||||
{
|
||||
var v = value.Trim();
|
||||
return v.Length > 120 ? v[..120].TrimEnd() : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Verbindet den Scanner mit der Engine: übersetzt den Assignee einer fälligen Aufgabe in
|
||||
/// einen konkreten Lauf.
|
||||
///
|
||||
/// - <c>@new</c> / <c>@new:<agent></c> → <see cref="AgentEngine.RunAsync"/>: frischer
|
||||
/// Lauf ohne Historie.
|
||||
/// - <c>@<agent></c> → <see cref="AgentEngine.ChatAsync"/>: der Agent mit seinem
|
||||
/// bestehenden Kontext. Das Agent-Gate der Engine serialisiert solche Läufe (B2).
|
||||
///
|
||||
/// Ist der Ziel-Agent nicht auflösbar (unbekannt, oder bloßes <c>@new</c> bei mehreren
|
||||
/// Agenten), scheitert der Dispatch mit einer klaren Meldung, statt einen falschen Agenten
|
||||
/// zu raten.
|
||||
/// </summary>
|
||||
public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
{
|
||||
private readonly AgentEngine _engine;
|
||||
private readonly Func<IReadOnlyList<AgentConfig>> _agents;
|
||||
private readonly string _instanceId;
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public EngineTaskDispatcher(
|
||||
AgentEngine engine,
|
||||
Func<IReadOnlyList<AgentConfig>> agents,
|
||||
string instanceId,
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_engine = engine;
|
||||
_agents = agents;
|
||||
_instanceId = instanceId;
|
||||
_toolRegistry = toolRegistry;
|
||||
_stateStore = stateStore;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher");
|
||||
}
|
||||
|
||||
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
|
||||
if (task.Type == TaskItemType.ToolJob)
|
||||
return await DispatchToolJobAsync(task, ct);
|
||||
|
||||
var agents = _agents();
|
||||
var message = BuildMessage(task);
|
||||
var kind = TaskAssignee.KindOf(task.Assignee);
|
||||
|
||||
AgentRunResult result;
|
||||
switch (kind)
|
||||
{
|
||||
case TaskAssigneeKind.New:
|
||||
{
|
||||
var config = ResolveFreshAgent(agents, TaskAssignee.AgentId(task.Assignee));
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)",
|
||||
task.Id, task.Assignee);
|
||||
return false;
|
||||
}
|
||||
result = await _engine.RunAsync(
|
||||
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
|
||||
break;
|
||||
}
|
||||
case TaskAssigneeKind.Agent:
|
||||
{
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
var config = agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId);
|
||||
return false;
|
||||
}
|
||||
result = await _engine.ChatAsync(
|
||||
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// @human wird vom Scanner gar nicht erst angestoßen.
|
||||
return false;
|
||||
}
|
||||
|
||||
return result.Status == AgentRunStatus.Completed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tickt einen <see cref="IToolJobProvider"/> (Poll) und weckt den Ziel-Agenten nur,
|
||||
/// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts
|
||||
/// zu tun war — ein Poll ohne Fund ist kein Fehler.
|
||||
/// </summary>
|
||||
private async Task<bool> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
|
||||
{
|
||||
if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert",
|
||||
task.Id, task.ToolName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
|
||||
var agentId = TaskAssignee.AgentId(task.Assignee);
|
||||
var config = _agents().FirstOrDefault(a => a.AgentId == agentId);
|
||||
var toolConfig = config is not null && config.Tools.TryGetValue(task.ToolName, out var cfg)
|
||||
? (IReadOnlyDictionary<string, object?>)cfg.AsReadOnly()
|
||||
: new Dictionary<string, object?>().AsReadOnly();
|
||||
|
||||
var result = await provider.ExecuteJobAsync(
|
||||
task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);
|
||||
|
||||
if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage))
|
||||
return true; // Poll lief, nichts zu wecken
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde",
|
||||
task.Id, agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos.
|
||||
if (result.UseChatContext)
|
||||
await _engine.ChatAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
|
||||
else
|
||||
await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static AgentConfig? ResolveFreshAgent(IReadOnlyList<AgentConfig> agents, string agentId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(agentId))
|
||||
return agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
|
||||
// Bloßes @new ohne Agent: nur eindeutig, wenn die Instanz genau einen Agenten hat.
|
||||
return agents.Count == 1 ? agents[0] : null;
|
||||
}
|
||||
|
||||
private static string BuildMessage(TaskItem task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(task.Body.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Acceptance))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Abnahmekriterien (das Ergebnis wird daran gemessen):");
|
||||
sb.AppendLine(task.Acceptance.Trim());
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Ausführungszustand des Taskboards. Die Definition der Aufgaben lebt in
|
||||
/// Markdown-Dateien; dieses Repository ist die DB-Seite, die das Claiming atomar macht
|
||||
/// (was ein Dateisystem nicht verlässlich kann) und dem Scanner erlaubt, ohne
|
||||
/// Dateizugriff zu entscheiden.
|
||||
/// </summary>
|
||||
public interface ITaskRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Legt eine Aufgabe an oder aktualisiert ihre Definition (Importer). Idempotent über
|
||||
/// die <see cref="TaskItem.Id"/> — ein zweiter Import erzeugt keine Dublette. Der
|
||||
/// Ausführungszustand (Marker, Claim) bleibt dabei unangetastet.
|
||||
/// </summary>
|
||||
Task<TaskItem> UpsertAsync(TaskItem task, CancellationToken ct);
|
||||
|
||||
Task<TaskItem?> GetAsync(string id, CancellationToken ct);
|
||||
|
||||
Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Beansprucht einen fälligen Termin atomar. Genau ein gleichzeitiger Aufruf gewinnt;
|
||||
/// jeder weitere erhält <c>false</c>. Setzt den Marker (<paramref name="occurrenceKey"/>)
|
||||
/// sofort — ein Termin ist damit auch dann verbraucht, wenn der Lauf später scheitert
|
||||
/// oder der Prozess abstürzt (at-most-once, kein Retry-Sturm).
|
||||
/// </summary>
|
||||
/// <param name="occurrenceKey">Sortierbarer ISO-UTC-Zeitstempel des Termins.</param>
|
||||
/// <param name="leaseCutoff">
|
||||
/// Claims, die älter sind, gelten als verwaist (abgestürzter Lauf) und dürfen
|
||||
/// überschrieben werden.
|
||||
/// </param>
|
||||
Task<bool> TryClaimAsync(
|
||||
string id, string occurrenceKey, string claimToken,
|
||||
DateTime now, DateTime leaseCutoff, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Schließt einen beanspruchten Lauf ab und setzt den Endstatus. Nur wirksam, solange
|
||||
/// der Aufrufer den Claim noch hält (<paramref name="claimToken"/> passt) — ein Lauf,
|
||||
/// der seine Lease verloren hat, überschreibt nichts mehr. Gibt zurück, ob der Claim
|
||||
/// noch gehörte.
|
||||
/// </summary>
|
||||
Task<bool> CompleteClaimAsync(
|
||||
string id, string claimToken, TaskItemStatus finalStatus,
|
||||
DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
|
||||
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Reconciliation beim Start: verwaiste Claims (älter als <paramref name="leaseCutoff"/>)
|
||||
/// lösen und die betroffenen Aufgaben von <see cref="TaskItemStatus.InProgress"/> zurück
|
||||
/// auf <see cref="TaskItemStatus.Todo"/> stellen. Gibt die Zahl der zurückgesetzten
|
||||
/// Aufgaben zurück.
|
||||
/// </summary>
|
||||
Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Aufgaben, die auf <paramref name="blockerId"/> warten — Grundlage für den
|
||||
/// Auto-Dispatch, wenn der letzte Blocker fertig wird.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct);
|
||||
|
||||
Task<int> CountAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Überführt die alten Scheduler-Konfigurationen ins Taskboard — der letzte Schritt, um
|
||||
/// die Alt-Scheduler abzulösen: alles Periodische ist danach ein Task.
|
||||
///
|
||||
/// - Ein <c>scheduler</c> (Agent nach Cron) → ein Task mit <c>when: cron</c> und Assignee
|
||||
/// <c>@new:<agent></c> (frischer Lauf, wie der alte AgentScheduler).
|
||||
/// - Jeder <c>toolJobs</c>-Eintrag (Poll) → ein Task vom Typ <c>tool_job</c>.
|
||||
///
|
||||
/// Einmalig und nicht-destruktiv: Existiert der Task (stabile Id) schon, wird er
|
||||
/// übersprungen — spätere Änderungen an der Task-Datei bleiben erhalten.
|
||||
/// </summary>
|
||||
public sealed class SchedulerTaskMigration
|
||||
{
|
||||
private readonly TaskboardService _board;
|
||||
private readonly ITaskRepository _repo;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public SchedulerTaskMigration(TaskboardService board, ITaskRepository repo, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_board = board;
|
||||
_repo = repo;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.SchedulerMigration");
|
||||
}
|
||||
|
||||
public async Task<int> RunAsync(IEnumerable<AgentConfig> agents, CancellationToken ct)
|
||||
{
|
||||
// IANA-Schreibweise, nicht TimeZoneInfo.Local.Id: Unter Windows lieferte das
|
||||
// "W. Europe Standard Time", unter Linux "Europe/Berlin". Die erzeugten
|
||||
// Task-Dateien wandern zwischen Rechnern — sie brauchen die Form, die überall
|
||||
// gilt.
|
||||
var localTz = TimeZones.LocalIanaId;
|
||||
var created = 0;
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
if (agent.Scheduler is { } scheduler && !string.IsNullOrWhiteSpace(scheduler.Cron))
|
||||
created += await MigrateSchedulerAsync(agent, scheduler, localTz, ct);
|
||||
|
||||
foreach (var job in agent.ToolJobs)
|
||||
created += await MigrateToolJobAsync(agent, job, localTz, ct);
|
||||
}
|
||||
|
||||
if (created > 0)
|
||||
_logger.LogInformation("Scheduler-Migration: {Count} Task(s) aus Alt-Konfiguration angelegt", created);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private async Task<int> MigrateSchedulerAsync(
|
||||
AgentConfig agent, SchedulerConfig scheduler, string tz, CancellationToken ct)
|
||||
{
|
||||
var id = $"sched-{agent.AgentId}";
|
||||
if (await _repo.GetAsync(id, ct) is not null)
|
||||
return 0;
|
||||
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = $"Geplanter Lauf: {Display(agent)}",
|
||||
Body = scheduler.TaskMessage,
|
||||
Status = TaskItemStatus.Todo,
|
||||
Type = TaskItemType.Work,
|
||||
Assignee = $"@new:{agent.AgentId}", // frischer Lauf, wie der alte AgentScheduler
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = scheduler.Cron, TimeZone = tz }
|
||||
}, ct);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private async Task<int> MigrateToolJobAsync(
|
||||
AgentConfig agent, ToolJobConfig job, string tz, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(job.Cron) || string.IsNullOrWhiteSpace(job.ToolName))
|
||||
return 0;
|
||||
|
||||
var id = $"tj-{agent.AgentId}-{job.JobId}";
|
||||
if (await _repo.GetAsync(id, ct) is not null)
|
||||
return 0;
|
||||
|
||||
await _board.CreateAsync(new TaskItem
|
||||
{
|
||||
Id = id,
|
||||
Title = $"Poll: {job.ToolName}/{job.JobTypeId} → {Display(agent)}",
|
||||
Body = $"Wiederkehrender {job.ToolName}-Poll ({job.JobTypeId}). Weckt {Display(agent)} bei einem Ereignis.",
|
||||
// Deaktivierte Jobs kommen als backlog — der Scanner nimmt nur todo/backlog...
|
||||
// backlog wird aber nicht geclaimt, also ruht der Poll, bis jemand ihn aktiviert.
|
||||
Status = job.Enabled ? TaskItemStatus.Todo : TaskItemStatus.Backlog,
|
||||
Type = TaskItemType.ToolJob,
|
||||
ToolName = job.ToolName,
|
||||
JobTypeId = job.JobTypeId,
|
||||
Assignee = $"@{agent.AgentId}", // Zielagent zum Wecken
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = job.Cron, TimeZone = tz }
|
||||
}, ct);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static string Display(AgentConfig agent)
|
||||
=> string.IsNullOrWhiteSpace(agent.DisplayName) ? agent.AgentId : agent.DisplayName;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Der Ausführungszustand des Taskboards in der Instanz-Datenbank.
|
||||
///
|
||||
/// Der Kern ist <see cref="TryClaimAsync"/>: ein bedingtes <c>UPDATE</c>, das genau eine
|
||||
/// Zeile trifft und damit garantiert, dass nie zwei Läufe denselben Termin ziehen. Genau
|
||||
/// das kann ein Dateisystem nicht verlässlich — deshalb liegt der Zustand hier und nicht
|
||||
/// bei den Task-Dateien.
|
||||
///
|
||||
/// Zeitangaben werden als sortierbares ISO-UTC (<c>"O"</c>) abgelegt, damit
|
||||
/// Zeichenketten-Vergleiche in SQL derselben Ordnung folgen wie die Zeit selbst.
|
||||
/// </summary>
|
||||
public sealed class SqliteTaskRepository : ITaskRepository
|
||||
{
|
||||
private readonly SqliteStorage _storage;
|
||||
|
||||
public SqliteTaskRepository(SqliteStorage storage) => _storage = storage;
|
||||
|
||||
public Task<TaskItem> UpsertAsync(TaskItem task, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var existing = await ReadAsync(conn, task.Id, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
using var insert = conn.CreateCommand();
|
||||
insert.CommandText = """
|
||||
INSERT INTO Tasks
|
||||
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
|
||||
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt)
|
||||
VALUES
|
||||
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
|
||||
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
|
||||
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
|
||||
""";
|
||||
BindDefinition(insert, task);
|
||||
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
|
||||
insert.Parameters.AddWithValue("@createdAt", Format(now));
|
||||
insert.Parameters.AddWithValue("@updatedAt", Format(now));
|
||||
await insert.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Nur die Definition aktualisieren. Status und Ausführungszustand
|
||||
// (Marker, Claim) gehören der DB — ein Re-Import darf einen laufenden
|
||||
// oder abgeschlossenen Zustand nicht zurücksetzen. Status-Änderungen
|
||||
// laufen über SetStatusAsync bzw. den Scanner.
|
||||
using var update = conn.CreateCommand();
|
||||
update.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Title = @title, Type = @type, Priority = @priority, Assignee = @assignee,
|
||||
WhenKind = @whenKind, WhenValue = @whenValue, WhenTz = @whenTz,
|
||||
RequireApproval = @requireApproval, Acceptance = @acceptance,
|
||||
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
|
||||
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
|
||||
UpdatedAt = @updatedAt
|
||||
WHERE Id = @id
|
||||
""";
|
||||
BindDefinition(update, task);
|
||||
update.Parameters.AddWithValue("@updatedAt", Format(now));
|
||||
await update.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
return (await ReadAsync(conn, task.Id, ct))!;
|
||||
}, ct);
|
||||
|
||||
public async Task<TaskItem?> GetAsync(string id, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
return await ReadAsync(conn, id, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
var sql = new StringBuilder("SELECT " + Columns + " FROM Tasks WHERE 1 = 1");
|
||||
|
||||
if (query.Status is { } status)
|
||||
{
|
||||
sql.Append(" AND Status = @status");
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
|
||||
}
|
||||
else if (!query.IncludeArchived)
|
||||
{
|
||||
sql.Append(" AND Status <> 'archived'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Assignee))
|
||||
{
|
||||
sql.Append(" AND Assignee = @assignee COLLATE NOCASE");
|
||||
cmd.Parameters.AddWithValue("@assignee", query.Assignee.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
sql.Append(" AND (Title LIKE @search ESCAPE '\\' COLLATE NOCASE"
|
||||
+ " OR Body LIKE @search ESCAPE '\\' COLLATE NOCASE)");
|
||||
cmd.Parameters.AddWithValue("@search", "%" + Escape(query.Search.Trim()) + "%");
|
||||
}
|
||||
|
||||
// Wichtiges zuerst, dann das Aktuellste — damit eine Kappung das Richtige behält.
|
||||
sql.Append(" ORDER BY Priority DESC, UpdatedAt DESC LIMIT @limit");
|
||||
cmd.Parameters.AddWithValue("@limit", Math.Clamp(query.Limit, 1, 500));
|
||||
cmd.CommandText = sql.ToString();
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<bool> TryClaimAsync(
|
||||
string id, string occurrenceKey, string claimToken,
|
||||
DateTime now, DateTime leaseCutoff, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET ClaimToken = @token, ClaimedAt = @now, Status = 'in_progress',
|
||||
LastOccurrence = @occ, UpdatedAt = @now
|
||||
WHERE Id = @id
|
||||
AND Status = 'todo'
|
||||
AND (LastOccurrence IS NULL OR LastOccurrence < @occ)
|
||||
AND (ClaimToken IS NULL OR ClaimedAt < @leaseCutoff)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@occ", occurrenceKey);
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) == 1;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> CompleteClaimAsync(
|
||||
string id, string claimToken, TaskItemStatus finalStatus, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = @status, ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
|
||||
WHERE Id = @id AND ClaimToken = @token
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(finalStatus));
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
cmd.Parameters.AddWithValue("@token", claimToken);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Tasks SET Status = @status, UpdatedAt = @now WHERE Id = @id";
|
||||
cmd.Parameters.AddWithValue("@status", TaskText.Of(status));
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}, ct);
|
||||
|
||||
public Task<int> ReleaseStaleClaimsAsync(DateTime leaseCutoff, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Tasks
|
||||
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL, UpdatedAt = @now
|
||||
WHERE Status = 'in_progress' AND ClaimToken IS NOT NULL AND ClaimedAt < @leaseCutoff
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@leaseCutoff", Format(leaseCutoff));
|
||||
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
public async Task<IReadOnlyList<TaskItem>> ListBlockedByAsync(string blockerId, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns
|
||||
+ " FROM Tasks WHERE BlockedBy LIKE @needle ESCAPE '\\'";
|
||||
cmd.Parameters.AddWithValue("@needle", "%|" + Escape(blockerId.Trim()) + "|%");
|
||||
|
||||
var results = new List<TaskItem>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Tasks";
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt";
|
||||
|
||||
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@id", task.Id);
|
||||
cmd.Parameters.AddWithValue("@title", task.Title);
|
||||
cmd.Parameters.AddWithValue("@type", TaskText.Of(task.Type));
|
||||
cmd.Parameters.AddWithValue("@priority", Math.Clamp(task.Priority, 1, 5));
|
||||
cmd.Parameters.AddWithValue("@assignee", task.Assignee);
|
||||
cmd.Parameters.AddWithValue("@whenKind", (object?)(task.When is null ? null : TaskText.Of(task.When.Kind)) ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@whenValue", (object?)task.When?.Value ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@whenTz", (object?)task.When?.TimeZone ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@requireApproval", task.RequireApproval ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@acceptance", task.Acceptance);
|
||||
cmd.Parameters.AddWithValue("@blockedBy", SerializeBlockedBy(task.BlockedBy));
|
||||
cmd.Parameters.AddWithValue("@market", task.OnlyWhenMarketOpen ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("@body", task.Body);
|
||||
cmd.Parameters.AddWithValue("@fileName", task.FileName);
|
||||
cmd.Parameters.AddWithValue("@toolName", task.ToolName);
|
||||
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
|
||||
}
|
||||
|
||||
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM Tasks WHERE Id = @id LIMIT 1";
|
||||
cmd.Parameters.AddWithValue("@id", id);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
return await reader.ReadAsync(ct) ? Read(reader) : null;
|
||||
}
|
||||
|
||||
private static TaskItem Read(SqliteDataReader r)
|
||||
{
|
||||
TaskWhen? when = null;
|
||||
if (!r.IsDBNull(6) && TaskText.WhenKind(r.GetString(6)) is { } kind)
|
||||
{
|
||||
when = new TaskWhen
|
||||
{
|
||||
Kind = kind,
|
||||
Value = r.IsDBNull(7) ? "" : r.GetString(7),
|
||||
TimeZone = r.IsDBNull(8) ? "" : r.GetString(8)
|
||||
};
|
||||
}
|
||||
|
||||
return new TaskItem
|
||||
{
|
||||
Id = r.GetString(0),
|
||||
Title = r.GetString(1),
|
||||
Status = TaskText.Status(r.GetString(2)),
|
||||
Type = TaskText.Type(r.GetString(3)),
|
||||
Priority = r.GetInt32(4),
|
||||
Assignee = r.GetString(5),
|
||||
When = when,
|
||||
RequireApproval = r.GetInt32(9) != 0,
|
||||
Acceptance = r.GetString(10),
|
||||
BlockedBy = DeserializeBlockedBy(r.GetString(11)),
|
||||
OnlyWhenMarketOpen = r.GetInt32(12) != 0,
|
||||
Body = r.GetString(13),
|
||||
FileName = r.GetString(14),
|
||||
ToolName = r.GetString(15),
|
||||
JobTypeId = r.GetString(16),
|
||||
LastOccurrence = r.IsDBNull(17) ? null : r.GetString(17),
|
||||
ClaimToken = r.IsDBNull(18) ? null : r.GetString(18),
|
||||
ClaimedAt = r.IsDBNull(19) ? null : Parse(r.GetString(19)),
|
||||
CreatedAt = Parse(r.GetString(20)),
|
||||
UpdatedAt = Parse(r.GetString(21))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Blocker als "|a|b|" — die Begrenzer erlauben eine Suche nach ganzen Ids,
|
||||
/// ohne dass <c>t-1</c> auch <c>t-10</c> trifft.</summary>
|
||||
private static string SerializeBlockedBy(IReadOnlyList<string> ids)
|
||||
{
|
||||
var cleaned = ids
|
||||
.Select(x => x.Trim().Replace("|", ""))
|
||||
.Where(x => x.Length > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
return cleaned.Count == 0 ? "" : "|" + string.Join("|", cleaned) + "|";
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> DeserializeBlockedBy(string raw)
|
||||
=> raw.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static string Escape(string value) => value
|
||||
.Replace(@"\", @"\\")
|
||||
.Replace("%", @"\%")
|
||||
.Replace("_", @"\_");
|
||||
|
||||
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime Parse(string value)
|
||||
=> DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Liest und schreibt eine Task-Datei: ein YAML-artiger Frontmatter-Block zwischen
|
||||
/// <c>---</c>-Zeilen, gefolgt vom Rumpf.
|
||||
///
|
||||
/// Bewusst ein enger, eigener Parser statt einer YAML-Bibliothek: Die Frontmatter ist
|
||||
/// ein kleiner, flacher Satz aus Skalaren, einem verschachtelten <c>when</c>, einer
|
||||
/// Inline-Liste und einem Block-Skalar. Dafür eine Abhängigkeit hereinzuziehen passt
|
||||
/// nicht zum Stil des Projekts — und ein voller YAML-Parser würde Formen zulassen, die
|
||||
/// wir gar nicht deuten wollen. Unbekanntes wird übergangen, nicht erraten.
|
||||
/// </summary>
|
||||
public static class TaskFrontmatter
|
||||
{
|
||||
/// <summary>
|
||||
/// Zerlegt eine Task-Datei in ihre Definition. Gibt <c>false</c> mit einer Meldung
|
||||
/// zurück, wenn kein Frontmatter-Block gefunden wird — der Ausführungszustand
|
||||
/// (Marker, Claim) wird hier nicht berührt, er kommt aus der DB.
|
||||
/// </summary>
|
||||
public static bool TryParse(string text, out TaskItem task, out string? error)
|
||||
{
|
||||
task = new TaskItem();
|
||||
error = null;
|
||||
|
||||
var normalized = (text ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
|
||||
// Öffnendes und schließendes "---" finden (BOM/Leerzeilen davor tolerieren).
|
||||
var lines = normalized.Split('\n');
|
||||
var open = -1;
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var t = lines[i].TrimStart('').Trim();
|
||||
if (t.Length == 0) continue;
|
||||
if (t == "---") { open = i; break; }
|
||||
break; // erste nicht-leere Zeile ist kein Fence
|
||||
}
|
||||
|
||||
if (open < 0)
|
||||
{
|
||||
error = "Kein Frontmatter-Block gefunden (erwartet '---' als erste Zeile).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var close = -1;
|
||||
for (var i = open + 1; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i].Trim() == "---") { close = i; break; }
|
||||
}
|
||||
|
||||
if (close < 0)
|
||||
{
|
||||
error = "Frontmatter-Block nicht geschlossen (zweites '---' fehlt).";
|
||||
return false;
|
||||
}
|
||||
|
||||
var block = lines[(open + 1)..close];
|
||||
var body = string.Join('\n', lines[(close + 1)..]).Trim('\n');
|
||||
|
||||
var scalars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var when = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var blockedBy = new List<string>();
|
||||
string? acceptance = null;
|
||||
|
||||
var j = 0;
|
||||
while (j < block.Length)
|
||||
{
|
||||
var raw = block[j];
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length == 0 || trimmed.StartsWith('#')) { j++; continue; }
|
||||
|
||||
// Nur Zeilen ohne Einrückung sind Top-Level-Schlüssel; eingerückte Zeilen
|
||||
// gehören zum jeweils vorangehenden Schlüssel und werden dort mitgelesen.
|
||||
if (Indent(raw) > 0) { j++; continue; }
|
||||
|
||||
var colon = trimmed.IndexOf(':');
|
||||
if (colon < 0) { j++; continue; }
|
||||
|
||||
var key = trimmed[..colon].Trim();
|
||||
var rest = trimmed[(colon + 1)..].Trim();
|
||||
|
||||
if (key.Equals("when", StringComparison.OrdinalIgnoreCase) && rest.Length == 0)
|
||||
{
|
||||
j = CollectChildren(block, j + 1, child =>
|
||||
{
|
||||
var c = child.IndexOf(':');
|
||||
if (c < 0) return;
|
||||
when[child[..c].Trim()] = Unquote(child[(c + 1)..].Trim());
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.Equals("acceptance", StringComparison.OrdinalIgnoreCase) && IsBlockScalar(rest))
|
||||
{
|
||||
acceptance = ReadBlockScalar(block, ref j);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.Equals("blocked_by", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (rest.StartsWith('['))
|
||||
{
|
||||
blockedBy.AddRange(ParseInlineList(rest));
|
||||
j++;
|
||||
}
|
||||
else if (rest.Length == 0)
|
||||
{
|
||||
// Block-Liste: nachfolgende "- item"-Zeilen.
|
||||
j = CollectChildren(block, j + 1, child =>
|
||||
{
|
||||
var item = child.TrimStart();
|
||||
if (item.StartsWith('-'))
|
||||
blockedBy.Add(Unquote(item[1..].Trim()));
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
blockedBy.AddRange(ParseInlineList(rest));
|
||||
j++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
scalars[key] = Unquote(rest);
|
||||
j++;
|
||||
}
|
||||
|
||||
TaskWhen? whenValue = null;
|
||||
if (TaskText.WhenKind(when.GetValueOrDefault("kind")) is { } kind)
|
||||
{
|
||||
whenValue = new TaskWhen
|
||||
{
|
||||
Kind = kind,
|
||||
Value = when.GetValueOrDefault("value", ""),
|
||||
TimeZone = when.GetValueOrDefault("tz", "")
|
||||
};
|
||||
}
|
||||
|
||||
task = new TaskItem
|
||||
{
|
||||
Id = scalars.GetValueOrDefault("id", ""),
|
||||
Title = scalars.GetValueOrDefault("title", ""),
|
||||
Status = TaskText.Status(scalars.GetValueOrDefault("status")),
|
||||
Type = TaskText.Type(scalars.GetValueOrDefault("type")),
|
||||
Priority = ParsePriority(scalars.GetValueOrDefault("priority")),
|
||||
Assignee = EmptyToDefault(scalars.GetValueOrDefault("assignee"), TaskAssignee.Human),
|
||||
When = whenValue,
|
||||
RequireApproval = ParseBool(scalars.GetValueOrDefault("require_approval")),
|
||||
Acceptance = acceptance?.Trim() ?? "",
|
||||
BlockedBy = blockedBy,
|
||||
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
|
||||
ToolName = scalars.GetValueOrDefault("tool_name", ""),
|
||||
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
|
||||
Body = body
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Schreibt eine Definition zurück in Dateiform. Der Ausführungszustand
|
||||
/// bleibt außen vor — er gehört in die DB, nicht in die Datei.</summary>
|
||||
public static string Serialize(TaskItem task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("---\n");
|
||||
sb.Append($"id: {Scalar(task.Id)}\n");
|
||||
sb.Append($"title: {Scalar(task.Title)}\n");
|
||||
sb.Append($"status: {TaskText.Of(task.Status)}\n");
|
||||
sb.Append($"type: {TaskText.Of(task.Type)}\n");
|
||||
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
|
||||
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
|
||||
|
||||
if (task.When is { } w)
|
||||
{
|
||||
sb.Append("when:\n");
|
||||
sb.Append($" kind: {TaskText.Of(w.Kind)}\n");
|
||||
sb.Append($" value: {Scalar(w.Value)}\n");
|
||||
sb.Append($" tz: {Scalar(w.TimeZone)}\n");
|
||||
}
|
||||
|
||||
// Nur für Poll-Tasks (tool_job): welches Tool mit welcher Job-Art getickt wird.
|
||||
if (task.Type == TaskItemType.ToolJob || !string.IsNullOrWhiteSpace(task.ToolName))
|
||||
{
|
||||
sb.Append($"tool_name: {Scalar(task.ToolName)}\n");
|
||||
sb.Append($"job_type: {Scalar(task.JobTypeId)}\n");
|
||||
}
|
||||
|
||||
sb.Append($"require_approval: {(task.RequireApproval ? "true" : "false")}\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Acceptance))
|
||||
{
|
||||
sb.Append("acceptance: |\n");
|
||||
foreach (var line in task.Acceptance.Replace("\r\n", "\n").Split('\n'))
|
||||
sb.Append(" ").Append(line).Append('\n');
|
||||
}
|
||||
|
||||
if (task.BlockedBy.Count > 0)
|
||||
sb.Append($"blocked_by: [{string.Join(", ", task.BlockedBy)}]\n");
|
||||
|
||||
sb.Append($"onlyWhenMarketOpen: {(task.OnlyWhenMarketOpen ? "true" : "false")}\n");
|
||||
sb.Append("---\n");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
sb.Append('\n').Append(task.Body.Replace("\r\n", "\n").TrimEnd('\n')).Append('\n');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private static int Indent(string line)
|
||||
{
|
||||
var n = 0;
|
||||
while (n < line.Length && line[n] == ' ') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/// <summary>Ruft <paramref name="onChild"/> für jede eingerückte Folgezeile auf und
|
||||
/// gibt den Index der ersten nicht mehr zugehörigen Zeile zurück.</summary>
|
||||
private static int CollectChildren(string[] block, int start, Action<string> onChild)
|
||||
{
|
||||
var k = start;
|
||||
while (k < block.Length)
|
||||
{
|
||||
var line = block[k];
|
||||
if (line.Trim().Length == 0) { k++; continue; }
|
||||
if (Indent(line) == 0) break;
|
||||
onChild(line.Trim());
|
||||
k++;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
private static bool IsBlockScalar(string rest) => rest is "|" or "|-" or "|+" or ">" or ">-";
|
||||
|
||||
/// <summary>Liest einen eingerückten Block-Skalar und entfernt die gemeinsame
|
||||
/// Einrückung. Setzt <paramref name="j"/> hinter den Block.</summary>
|
||||
private static string ReadBlockScalar(string[] block, ref int j)
|
||||
{
|
||||
var collected = new List<string>();
|
||||
var k = j + 1;
|
||||
var commonIndent = int.MaxValue;
|
||||
|
||||
while (k < block.Length)
|
||||
{
|
||||
var line = block[k];
|
||||
if (line.Trim().Length == 0) { collected.Add(""); k++; continue; }
|
||||
if (Indent(line) == 0) break;
|
||||
commonIndent = Math.Min(commonIndent, Indent(line));
|
||||
collected.Add(line);
|
||||
k++;
|
||||
}
|
||||
|
||||
j = k;
|
||||
if (commonIndent == int.MaxValue) commonIndent = 0;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var line in collected)
|
||||
sb.Append(line.Length >= commonIndent ? line[commonIndent..] : line).Append('\n');
|
||||
|
||||
return sb.ToString().TrimEnd('\n');
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ParseInlineList(string rest)
|
||||
{
|
||||
var inner = rest.Trim();
|
||||
if (inner.StartsWith('[')) inner = inner[1..];
|
||||
if (inner.EndsWith(']')) inner = inner[..^1];
|
||||
|
||||
return inner.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(Unquote)
|
||||
.Where(s => s.Length > 0);
|
||||
}
|
||||
|
||||
private static string Unquote(string value)
|
||||
{
|
||||
var v = value.Trim();
|
||||
if (v.Length >= 2 && v[0] == '"' && v[^1] == '"')
|
||||
return v[1..^1].Replace("\\\"", "\"").Replace("\\\\", "\\");
|
||||
if (v.Length >= 2 && v[0] == '\'' && v[^1] == '\'')
|
||||
return v[1..^1].Replace("''", "'");
|
||||
return v;
|
||||
}
|
||||
|
||||
/// <summary>Quotiert nur, wenn nötig — hält die Datei sonst gut lesbar. Ein führendes
|
||||
/// <c>@</c> (Assignee) etwa ist in YAML ein reserviertes Zeichen und muss quotiert
|
||||
/// werden.</summary>
|
||||
private static string Scalar(string? value)
|
||||
{
|
||||
var v = value ?? "";
|
||||
if (v.Length == 0) return "\"\"";
|
||||
|
||||
var needsQuote =
|
||||
v != v.Trim()
|
||||
|| "@-[]{}>|*&!%#`,\"'".Contains(v[0])
|
||||
|| v.Contains(':')
|
||||
|| v.Contains('#')
|
||||
|| v.Contains('\n');
|
||||
|
||||
if (!needsQuote) return v;
|
||||
|
||||
return "\"" + v.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private static bool ParseBool(string? value)
|
||||
=> value?.Trim().ToLowerInvariant() is "true" or "yes" or "1" or "on";
|
||||
|
||||
private static int ParsePriority(string? value)
|
||||
=> int.TryParse(value?.Trim(), out var p) ? Math.Clamp(p, 1, 5) : 3;
|
||||
|
||||
private static string EmptyToDefault(string? value, string fallback)
|
||||
=> string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Lebenszyklus einer Aufgabe. Als Zeichenkette in der DB und im Frontmatter abgelegt,
|
||||
/// damit beide Seiten dieselbe, menschenlesbare Form teilen.
|
||||
/// </summary>
|
||||
public enum TaskItemStatus
|
||||
{
|
||||
/// <summary>Angelegt, aber noch nicht zur Ausführung freigegeben.</summary>
|
||||
Backlog,
|
||||
|
||||
/// <summary>Bereit; der Scanner darf sie beim nächsten fälligen Termin nehmen.</summary>
|
||||
Todo,
|
||||
|
||||
/// <summary>Ein Lauf hat sie beansprucht und arbeitet daran.</summary>
|
||||
InProgress,
|
||||
|
||||
/// <summary>Erledigt, wartet aber auf Review (<c>require_approval</c>).</summary>
|
||||
InReview,
|
||||
|
||||
/// <summary>Abgeschlossen.</summary>
|
||||
Done,
|
||||
|
||||
/// <summary>Verworfen, ohne erledigt zu sein.</summary>
|
||||
Canceled,
|
||||
|
||||
/// <summary>Wartet auf einen offenen Blocker (<c>blocked_by</c>) oder auf eine Meldung.</summary>
|
||||
Blocked,
|
||||
|
||||
/// <summary>Aus dem Board genommen (Datei gelöscht, aufgeräumt) — bleibt für die Historie.</summary>
|
||||
Archived
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Art der Aufgabe. Ein Mensch ist einfach ein Assignee, kein Sonderpfad — <c>approval</c>
|
||||
/// und <c>human_input</c> unterscheiden sich nur darin, dass ihr Ergebnis von einem
|
||||
/// Menschen statt von einem Modell kommt.
|
||||
/// </summary>
|
||||
public enum TaskItemType
|
||||
{
|
||||
Work,
|
||||
Approval,
|
||||
HumanInput,
|
||||
|
||||
/// <summary>
|
||||
/// Ein wiederkehrender Poll: Der Scanner tickt beim fälligen Termin einen
|
||||
/// <c>IToolJobProvider</c> (siehe <see cref="TaskItem.ToolName"/>/<see cref="TaskItem.JobTypeId"/>)
|
||||
/// und weckt den Agenten nur, wenn der Tick etwas meldet. Ersetzt den früheren
|
||||
/// ToolJobScheduler — alles Periodische ist jetzt ein Task.
|
||||
/// </summary>
|
||||
ToolJob
|
||||
}
|
||||
|
||||
/// <summary>Wie ein Termin zu verstehen ist.</summary>
|
||||
public enum TaskWhenKind
|
||||
{
|
||||
/// <summary>Einmaliger Zeitpunkt (ISO 8601).</summary>
|
||||
At,
|
||||
|
||||
/// <summary>Wiederkehrendes Intervall (z. B. <c>30m</c>, <c>2h</c>).</summary>
|
||||
Every,
|
||||
|
||||
/// <summary>5-Felder-Cron-Ausdruck.</summary>
|
||||
Cron
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Termin. Die Zeitzone ist Pflicht — das ist die Antwort auf B7 (Cron lief bisher
|
||||
/// zonen-blind in Lokalzeit). Ohne Zone lässt sich <c>0 7 * * *</c> nicht eindeutig
|
||||
/// deuten.
|
||||
/// </summary>
|
||||
public sealed record TaskWhen
|
||||
{
|
||||
public TaskWhenKind Kind { get; init; }
|
||||
public string Value { get; init; } = "";
|
||||
|
||||
/// <summary>IANA-Zeitzone, etwa <c>Europe/Berlin</c>.</summary>
|
||||
public string TimeZone { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer eine Aufgabe ausführt. Löst das implizite <c>UseChatContext</c>-Flag (T7) durch
|
||||
/// eine explizite Angabe am Auftrag ab.
|
||||
/// </summary>
|
||||
public enum TaskAssigneeKind
|
||||
{
|
||||
/// <summary><c>@new</c> — frischer Lauf ohne Historie (<c>RunAsync</c>).</summary>
|
||||
New,
|
||||
|
||||
/// <summary><c>@<agentId></c> — bestehender Agent mit seinem Kontext (<c>ChatAsync</c>).</summary>
|
||||
Agent,
|
||||
|
||||
/// <summary><c>@human</c> — wartet auf einen Menschen; kein Modell-Lauf.</summary>
|
||||
Human
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deutet den <c>assignee</c>-String, ohne ihn selbst zu speichern. Formen:
|
||||
/// <c>@human</c>, <c>@<agentId></c> (bestehender Kontext), <c>@new</c> bzw.
|
||||
/// <c>@new:<agentId></c> (frischer Lauf ohne Historie). Bei bloßem <c>@new</c> ohne
|
||||
/// Agent bleibt die Ziel-Id offen — dann muss die Instanz genau einen Agenten haben.
|
||||
/// </summary>
|
||||
public static class TaskAssignee
|
||||
{
|
||||
public const string New = "@new";
|
||||
public const string Human = "@human";
|
||||
|
||||
public static TaskAssigneeKind KindOf(string? assignee)
|
||||
{
|
||||
var value = assignee?.Trim();
|
||||
if (string.Equals(value, Human, StringComparison.OrdinalIgnoreCase))
|
||||
return TaskAssigneeKind.Human;
|
||||
if (string.Equals(value, New, StringComparison.OrdinalIgnoreCase)
|
||||
|| value?.StartsWith("@new:", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return TaskAssigneeKind.New;
|
||||
return TaskAssigneeKind.Agent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die reine Agent-Id. Für <c>@<agent></c> der Teil hinter dem <c>@</c>, für
|
||||
/// <c>@new:<agent></c> der Teil hinter dem Doppelpunkt. Leer bei <c>@human</c>
|
||||
/// und bloßem <c>@new</c>.
|
||||
/// </summary>
|
||||
public static string AgentId(string? assignee)
|
||||
{
|
||||
var value = assignee?.Trim() ?? "";
|
||||
switch (KindOf(value))
|
||||
{
|
||||
case TaskAssigneeKind.Agent:
|
||||
return value.StartsWith('@') ? value[1..] : value;
|
||||
case TaskAssigneeKind.New:
|
||||
var colon = value.IndexOf(':');
|
||||
return colon >= 0 ? value[(colon + 1)..].Trim() : "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Aufgabe — die Spiegelung der Frontmatter-Definition zusammen mit dem
|
||||
/// Ausführungszustand. Die Definition ist in der Datei die Wahrheit, der
|
||||
/// Ausführungszustand in der DB (siehe Taskboard-Konzept). Diese Zeile hält beides,
|
||||
/// damit der Scanner ohne Dateizugriff entscheiden kann.
|
||||
/// </summary>
|
||||
public sealed record TaskItem
|
||||
{
|
||||
// ─── Definition (aus dem Frontmatter gespiegelt) ───
|
||||
|
||||
/// <summary>Stabile Identität. Überlebt das Umbenennen der Datei.</summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
public string Title { get; init; } = "";
|
||||
public TaskItemStatus Status { get; init; } = TaskItemStatus.Todo;
|
||||
public TaskItemType Type { get; init; } = TaskItemType.Work;
|
||||
|
||||
/// <summary>1 (niedrig) .. 5 (hoch).</summary>
|
||||
public int Priority { get; init; } = 3;
|
||||
|
||||
public string Assignee { get; init; } = TaskAssignee.Human;
|
||||
|
||||
/// <summary>Termin; <c>null</c> = einmalige, sofort fällige Aufgabe.</summary>
|
||||
public TaskWhen? When { get; init; }
|
||||
|
||||
/// <summary>Gilt erst nach Review als <see cref="TaskItemStatus.Done"/>.</summary>
|
||||
public bool RequireApproval { get; init; }
|
||||
|
||||
/// <summary>Abnahmekriterien, gegen die das Ergebnis geprüft wird.</summary>
|
||||
public string Acceptance { get; init; } = "";
|
||||
|
||||
/// <summary>Ids der Aufgaben, die erst erledigt sein müssen.</summary>
|
||||
public IReadOnlyList<string> BlockedBy { get; init; } = [];
|
||||
|
||||
/// <summary>C1: Termin nur auslösen, wenn der Markt offen ist.</summary>
|
||||
public bool OnlyWhenMarketOpen { get; init; }
|
||||
|
||||
/// <summary>Auftragsbeschreibung — geht als Aufgabenstellung an den Agenten.</summary>
|
||||
public string Body { get; init; } = "";
|
||||
|
||||
/// <summary>Dateiname relativ zu <c>tasks/</c>. Dient dem Rückschreiben.</summary>
|
||||
public string FileName { get; init; } = "";
|
||||
|
||||
// ─── Nur für Typ tool_job ───
|
||||
|
||||
/// <summary>Das zu tickende Tool (z. B. <c>Telegram</c>) — nur bei <c>tool_job</c>.</summary>
|
||||
public string ToolName { get; init; } = "";
|
||||
|
||||
/// <summary>Die Job-Art des Tools (z. B. <c>telegram_poll</c>) — nur bei <c>tool_job</c>.</summary>
|
||||
public string JobTypeId { get; init; } = "";
|
||||
|
||||
// ─── Ausführungszustand (nur das Board schreibt) ───
|
||||
|
||||
/// <summary>
|
||||
/// Occurrence-Key des zuletzt behandelten Termins (sortierbares ISO-UTC). Ein neuer
|
||||
/// Termin gilt nur als fällig, wenn er hierüber liegt — so wird ein Termin höchstens
|
||||
/// einmal ausgelöst.
|
||||
/// </summary>
|
||||
public string? LastOccurrence { get; init; }
|
||||
|
||||
/// <summary>Gesetzt, solange ein Lauf die Aufgabe beansprucht.</summary>
|
||||
public string? ClaimToken { get; init; }
|
||||
|
||||
public DateTime? ClaimedAt { get; init; }
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public DateTime UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Wiederkehrend? Solche Tasks kehren nach dem Feuern auf <c>todo</c> zurück statt auf
|
||||
/// <c>done</c> — sonst würde ein Cron-Task nur ein einziges Mal laufen. Ein
|
||||
/// <c>tool_job</c> ist immer ein Poll und damit wiederkehrend.
|
||||
/// </summary>
|
||||
public bool IsRecurring
|
||||
=> Type == TaskItemType.ToolJob
|
||||
|| When is { Kind: TaskWhenKind.Cron or TaskWhenKind.Every };
|
||||
}
|
||||
|
||||
/// <summary>Suchkriterien für <see cref="ITaskRepository.ListAsync"/>.</summary>
|
||||
public sealed record TaskQuery
|
||||
{
|
||||
public TaskItemStatus? Status { get; init; }
|
||||
|
||||
/// <summary>Filtert auf einen Assignee (roher String, z. B. <c>@crawler</c>).</summary>
|
||||
public string? Assignee { get; init; }
|
||||
|
||||
/// <summary>Freitext über Titel und Beschreibung.</summary>
|
||||
public string? Search { get; init; }
|
||||
|
||||
/// <summary>Archivierte werden standardmäßig ausgeblendet.</summary>
|
||||
public bool IncludeArchived { get; init; }
|
||||
|
||||
public int Limit { get; init; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der
|
||||
/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar.
|
||||
/// </summary>
|
||||
public interface ITaskDispatcher
|
||||
{
|
||||
/// <summary>Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss.</summary>
|
||||
Task<bool> DispatchAsync(TaskItem task, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob ein marktabhängiger Termin (<see cref="TaskItem.OnlyWhenMarketOpen"/>)
|
||||
/// jetzt laufen darf. Platzhalter, bis C1 (Marktkalender) den echten Kalender liefert.
|
||||
/// </summary>
|
||||
public interface IMarketCalendar
|
||||
{
|
||||
bool IsOpen(DateTime nowUtc);
|
||||
}
|
||||
|
||||
/// <summary>Bis C1 kommt: der Markt gilt als immer offen.</summary>
|
||||
public sealed class AlwaysOpenMarketCalendar : IMarketCalendar
|
||||
{
|
||||
public bool IsOpen(DateTime nowUtc) => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Taktgeber des Taskboards (A1). Ein einziger Takt prüft, was fällig ist —
|
||||
/// keine langen Delays (B6). Persistiert wird nur der Last-Fired-Marker; ein
|
||||
/// fehlgeschlagener Lauf bleibt der einzige Versuch für diesen Termin (kein Retry-Sturm).
|
||||
///
|
||||
/// Der heikle Kern ist streng gegen drei Invarianten gebaut, die die Repository-Tests
|
||||
/// und die Scanner-Tests absichern:
|
||||
/// 1. Nie zwei Läufe auf denselben Termin — das atomare <see cref="ITaskRepository.TryClaimAsync"/>.
|
||||
/// 2. Kein Dispatch bei offenem Blocker — blockierte Aufgaben sind nicht claimbar.
|
||||
/// 3. Doppelter Takt = ein Lauf — konkurrierende Claims um denselben Occurrence-Key.
|
||||
/// </summary>
|
||||
public sealed class TaskScanner : IAsyncDisposable
|
||||
{
|
||||
private readonly ITaskRepository _repo;
|
||||
private readonly ITaskDispatcher _dispatcher;
|
||||
private readonly IMarketCalendar _market;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeSpan _tick;
|
||||
private readonly TimeSpan _lease;
|
||||
|
||||
/// <summary>Aufgaben, deren unauflösbare Zeitzone bereits gemeldet wurde — der Takt
|
||||
/// läuft jede Minute, die Meldung soll nicht mitlaufen.</summary>
|
||||
private readonly HashSet<string> _warnedTimeZones = [];
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _loop;
|
||||
|
||||
public TaskScanner(
|
||||
ITaskRepository repo,
|
||||
ITaskDispatcher dispatcher,
|
||||
ILoggerFactory loggerFactory,
|
||||
IMarketCalendar? market = null,
|
||||
TimeProvider? clock = null,
|
||||
TimeSpan? tick = null,
|
||||
TimeSpan? lease = null)
|
||||
{
|
||||
_repo = repo;
|
||||
_dispatcher = dispatcher;
|
||||
_market = market ?? new AlwaysOpenMarketCalendar();
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Scanner");
|
||||
_tick = tick ?? TimeSpan.FromSeconds(60);
|
||||
_lease = lease ?? TimeSpan.FromMinutes(15);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_loop ??= RunLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>Läuft die Taktschleife gerade?</summary>
|
||||
public bool IsRunning => _loop is { IsCompleted: false };
|
||||
|
||||
/// <summary>
|
||||
/// Die Schleife lief und ist beendet — anders als „noch nicht gestartet". Der
|
||||
/// Unterschied zählt für die Zustandsmeldung an den Watchdog: Ein Scanner, der noch
|
||||
/// auf die Startabgleichung wartet, ist in Ordnung; einer, dessen Schleife
|
||||
/// ausgestiegen ist, bedeutet, dass keine Aufgabe mehr läuft.
|
||||
/// </summary>
|
||||
public bool HasStopped => _loop is { IsCompleted: true };
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
// PeriodicTimer über den TimeProvider — im Test steuerbar, im Betrieb driftfrei.
|
||||
using var timer = new PeriodicTimer(_tick, _clock);
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanOnceAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Ein Scanner-Takt ist gescheitert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Durchlauf: fällige Aufgaben beanspruchen und ausführen. Gibt die Zahl der
|
||||
/// tatsächlich angestoßenen Läufe zurück. Öffentlich, damit Tests einen Takt
|
||||
/// deterministisch auslösen können.
|
||||
/// </summary>
|
||||
public async Task<int> ScanOnceAsync(CancellationToken ct)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var leaseCutoff = now - _lease;
|
||||
|
||||
var candidates = await _repo.ListAsync(new TaskQuery { Limit = 500 }, ct);
|
||||
|
||||
var claimed = new List<(TaskItem Task, string Token)>();
|
||||
foreach (var task in candidates)
|
||||
{
|
||||
if (task.Status != TaskItemStatus.Todo)
|
||||
continue; // backlog (Halte-Status), blockiert, laufend, erledigt bleiben außen vor
|
||||
|
||||
// Ein Mensch ist kein Modell-Lauf — solche Aufgaben rührt der Scanner nicht an.
|
||||
if (TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human)
|
||||
continue;
|
||||
|
||||
// Eine Aufgabe mit unauflösbarer Zeitzone feuert nie. Das einmal melden,
|
||||
// sonst sucht man den Fehler bei der Aufgabe statt beim System.
|
||||
if (TaskSchedule.UnresolvableTimeZone(task) is { } badZone
|
||||
&& _warnedTimeZones.Add(task.Id))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Aufgabe {TaskId} ({Title}) hat die Zeitzone '{TimeZone}', die auf diesem "
|
||||
+ "System nicht auflösbar ist — sie wird nicht ausgeführt. Zeitzone in "
|
||||
+ "IANA-Schreibweise eintragen (z.B. 'Europe/Berlin') und sicherstellen, "
|
||||
+ "dass tzdata und ICU vorhanden sind.",
|
||||
task.Id, task.Title, badZone);
|
||||
}
|
||||
|
||||
var occ = TaskSchedule.DueOccurrence(task, now);
|
||||
if (occ is null)
|
||||
continue;
|
||||
|
||||
if (task.OnlyWhenMarketOpen && !_market.IsOpen(now))
|
||||
continue;
|
||||
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (await _repo.TryClaimAsync(task.Id, occ, token, now, leaseCutoff, ct))
|
||||
claimed.Add((task, token));
|
||||
}
|
||||
|
||||
// Gleichzeitig anstoßen — Läufe desselben Agenten serialisiert ohnehin das
|
||||
// Agent-Gate der Engine; verschiedene Agenten laufen echt parallel.
|
||||
await Task.WhenAll(claimed.Select(c => ProcessAsync(c.Task, c.Token, ct)));
|
||||
|
||||
return claimed.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt einen Task sofort aus (manueller „Jetzt ausführen"-Knopf), unabhängig vom
|
||||
/// Termin. Beansprucht ihn atomar wie ein Takt — läuft er schon oder ist er kein
|
||||
/// <c>todo</c>, gibt die Methode <c>false</c> zurück, statt ihn doppelt anzustoßen.
|
||||
/// </summary>
|
||||
public async Task<bool> RunTaskNowAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
var task = await _repo.GetAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return false;
|
||||
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
if (!await _repo.TryClaimAsync(task.Id, now.ToString("O"), token, now, now - _lease, ct))
|
||||
return false;
|
||||
|
||||
await ProcessAsync(task, token, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ProcessAsync(TaskItem task, string token, CancellationToken ct)
|
||||
{
|
||||
bool ok;
|
||||
try
|
||||
{
|
||||
ok = await _dispatcher.DispatchAsync(task, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id);
|
||||
ok = false;
|
||||
}
|
||||
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
|
||||
// Endstatus:
|
||||
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
|
||||
// wird nicht wiederholt).
|
||||
// - Wiederkehrend (Cron/every/tool_job) → zurück auf todo, damit der nächste Termin
|
||||
// feuern kann. Sonst liefe ein Cron-Task nur ein einziges Mal.
|
||||
// - Einmalig → done bzw. in_review (A2).
|
||||
var final = !ok || task.IsRecurring
|
||||
? TaskItemStatus.Todo
|
||||
: task.RequireApproval
|
||||
? TaskItemStatus.InReview
|
||||
: TaskItemStatus.Done;
|
||||
|
||||
await _repo.CompleteClaimAsync(task.Id, token, final, now, ct);
|
||||
|
||||
if (ok && final == TaskItemStatus.Done)
|
||||
await UnblockDependentsAsync(task.Id, now, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-Dispatch: Wird eine Aufgabe fertig, werden die auf sie wartenden Aufgaben
|
||||
/// freigegeben, sobald <b>alle</b> ihre Blocker erledigt sind.
|
||||
/// </summary>
|
||||
private async Task UnblockDependentsAsync(string completedId, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var dependents = await _repo.ListBlockedByAsync(completedId, ct);
|
||||
foreach (var dependent in dependents)
|
||||
{
|
||||
if (dependent.Status != TaskItemStatus.Blocked)
|
||||
continue;
|
||||
|
||||
var allDone = true;
|
||||
foreach (var blockerId in dependent.BlockedBy)
|
||||
{
|
||||
var blocker = await _repo.GetAsync(blockerId, ct);
|
||||
if (blocker is null || blocker.Status != TaskItemStatus.Done)
|
||||
{
|
||||
allDone = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allDone)
|
||||
{
|
||||
await _repo.SetStatusAsync(dependent.Id, TaskItemStatus.Todo, now, ct);
|
||||
_logger.LogInformation(
|
||||
"Aufgabe {TaskId} freigegeben — alle Blocker erledigt", dependent.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_loop is not null)
|
||||
{
|
||||
try { await _loop; }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Entscheidet, ob eine Aufgabe fällig ist, und liefert den Occurrence-Key des fälligen
|
||||
/// Termins — den sortierbaren ISO-UTC-Zeitstempel der geplanten Feuerzeit.
|
||||
///
|
||||
/// Das ist die Antwort auf B6 und B7: Statt eines langen <c>Task.Delay</c> bis zum
|
||||
/// nächsten Termin rechnet der Scanner bei jedem Takt neu aus, was gerade ansteht — es
|
||||
/// gibt keine Delays, die überlaufen könnten. Und weil jeder Termin explizit in seiner
|
||||
/// Zeitzone gedeutet wird, ist er eindeutig, statt zonen-blind in Lokalzeit zu laufen.
|
||||
///
|
||||
/// Höchstens <b>ein</b> Nachholen: Bei einer Lücke (der Rechner war aus) wird der jeweils
|
||||
/// jüngste verpasste Termin genommen, nicht jeder einzelne — sonst löste ein Neustart
|
||||
/// eine Welle aus.
|
||||
/// </summary>
|
||||
public static class TaskSchedule
|
||||
{
|
||||
/// <summary>
|
||||
/// Der Occurrence-Key des fälligen Termins, oder <c>null</c>, wenn nichts ansteht.
|
||||
/// Ein Termin gilt nur als fällig, wenn sein Key über dem Marker
|
||||
/// (<see cref="TaskItem.LastOccurrence"/>) liegt.
|
||||
/// </summary>
|
||||
public static string? DueOccurrence(TaskItem task, DateTime nowUtc)
|
||||
{
|
||||
nowUtc = DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc);
|
||||
|
||||
if (task.When is null)
|
||||
{
|
||||
// Einmalig, ohne Termin: fällig, solange noch nie gelaufen.
|
||||
if (task.LastOccurrence is not null) return null;
|
||||
var basis = task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime();
|
||||
return Iso(basis);
|
||||
}
|
||||
|
||||
return task.When.Kind switch
|
||||
{
|
||||
TaskWhenKind.At => DueAt(task.When, task.LastOccurrence, nowUtc),
|
||||
TaskWhenKind.Every => DueEvery(task, task.LastOccurrence, nowUtc),
|
||||
TaskWhenKind.Cron => DueCron(task, task.LastOccurrence, nowUtc),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Zeitzone des Termins, wenn sie auf diesem System nicht auflösbar ist — sonst
|
||||
/// <c>null</c>.
|
||||
///
|
||||
/// Ein solcher Termin feuert nie (siehe <c>ResolveTimeZone</c>). Damit das nicht
|
||||
/// unbemerkt bleibt, fragt der Scanner hier nach und meldet es einmal je Aufgabe.
|
||||
/// Der häufigste Fall: eine Task-Datei mit Windows-Kennung
|
||||
/// (<c>W. Europe Standard Time</c>) auf einem System ohne ICU-Daten.
|
||||
/// </summary>
|
||||
public static string? UnresolvableTimeZone(TaskItem task)
|
||||
=> task.When is { } when
|
||||
&& !string.IsNullOrWhiteSpace(when.TimeZone)
|
||||
&& !TimeZones.IsKnown(when.TimeZone)
|
||||
? when.TimeZone
|
||||
: null;
|
||||
|
||||
private static string? DueAt(TaskWhen when, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (marker is not null) return null; // ein einmaliger Termin feuert genau einmal
|
||||
if (!TryResolveInstant(when.Value, when.TimeZone, out var targetUtc)) return null;
|
||||
if (nowUtc < targetUtc) return null;
|
||||
return Iso(targetUtc);
|
||||
}
|
||||
|
||||
private static string? DueEvery(TaskItem task, string? marker, DateTime nowUtc)
|
||||
{
|
||||
if (!TryParseInterval(task.When!.Value, out var interval)) return null;
|
||||
|
||||
var anchor = marker is not null
|
||||
? ParseIso(marker)
|
||||
: (task.CreatedAt == default ? nowUtc : task.CreatedAt.ToUniversalTime());
|
||||
|
||||
return nowUtc < anchor + interval ? null : Iso(nowUtc);
|
||||
}
|
||||
|
||||
private static string? DueCron(TaskItem task, string? marker, DateTime nowUtc)
|
||||
{
|
||||
var when = task.When!;
|
||||
CronExpression cron;
|
||||
try { cron = CronExpression.Parse(when.Value); }
|
||||
catch { return null; } // ein kaputter Ausdruck darf den Scanner nicht kippen
|
||||
|
||||
var tz = ResolveTimeZone(when.TimeZone);
|
||||
if (tz is null) return null; // unbekannte Zone: lieber gar nicht als zur falschen Zeit
|
||||
|
||||
var nowLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(nowUtc, tz));
|
||||
|
||||
// Untergrenze der Suche: hinter dem Marker (bereits Gelaufenes ist erledigt), sonst
|
||||
// ab Anlagezeit — eine frische Aufgabe holt keinen Termin von vor ihrer Existenz
|
||||
// nach. Ohne Anlagezeit zählt nur die aktuelle Minute.
|
||||
var lowerUtc = marker is not null ? ParseIso(marker)
|
||||
: task.CreatedAt != default ? task.CreatedAt.ToUniversalTime()
|
||||
: nowUtc;
|
||||
var lowerLocal = Truncate(TimeZoneInfo.ConvertTimeFromUtc(lowerUtc, tz));
|
||||
|
||||
var limit = lowerLocal;
|
||||
DateTime? matchLocal = null;
|
||||
for (var candidate = nowLocal; candidate >= limit; candidate = candidate.AddMinutes(-1))
|
||||
{
|
||||
if (cron.Matches(candidate)) { matchLocal = candidate; break; }
|
||||
}
|
||||
|
||||
if (matchLocal is null) return null;
|
||||
|
||||
// Ungültige Ortszeit (Sprung bei der Zeitumstellung) darf nicht werfen.
|
||||
var unspecified = DateTime.SpecifyKind(matchLocal.Value, DateTimeKind.Unspecified);
|
||||
if (tz.IsInvalidTime(unspecified)) return null;
|
||||
|
||||
var occUtc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
||||
var occ = Iso(occUtc);
|
||||
|
||||
// Nur fällig, wenn der Termin echt über dem Marker liegt.
|
||||
return marker is not null && string.CompareOrdinal(occ, marker) <= 0 ? null : occ;
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
/// <summary>Deutet einen <c>at</c>-Wert: mit 'Z' als UTC, sonst als Wanduhrzeit in der
|
||||
/// angegebenen Zeitzone.</summary>
|
||||
private static bool TryResolveInstant(string value, string timeZone, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
var v = value.Trim();
|
||||
if (v.Length == 0) return false;
|
||||
|
||||
if (v.EndsWith('Z') || v.EndsWith('z'))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal, out var dto))
|
||||
{
|
||||
utc = dto.UtcDateTime;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out var local))
|
||||
{
|
||||
var tz = ResolveTimeZone(timeZone);
|
||||
if (tz is null) return false; // unbekannte Zone: der Termin ist nicht bestimmbar
|
||||
|
||||
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
|
||||
if (tz.IsInvalidTime(unspecified)) return false;
|
||||
utc = TimeZoneInfo.ConvertTimeToUtc(unspecified, tz);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Ein Intervall wie <c>30s</c>, <c>15m</c>, <c>2h</c>, <c>1d</c>.</summary>
|
||||
private static bool TryParseInterval(string value, out TimeSpan interval)
|
||||
{
|
||||
interval = default;
|
||||
var v = value.Trim().ToLowerInvariant();
|
||||
if (v.Length < 2) return false;
|
||||
|
||||
var unit = v[^1];
|
||||
if (!int.TryParse(v[..^1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n <= 0)
|
||||
return false;
|
||||
|
||||
interval = unit switch
|
||||
{
|
||||
's' => TimeSpan.FromSeconds(n),
|
||||
'm' => TimeSpan.FromMinutes(n),
|
||||
'h' => TimeSpan.FromHours(n),
|
||||
'd' => TimeSpan.FromDays(n),
|
||||
_ => TimeSpan.Zero
|
||||
};
|
||||
return interval > TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst die Zeitzone eines Termins auf. Keine Angabe bedeutet UTC.
|
||||
///
|
||||
/// Eine <b>unbekannte</b> Zone gibt <c>null</c> zurück — und der Aufrufer behandelt
|
||||
/// den Termin dann als nicht fällig. Früher fiel dieser Fall still auf UTC zurück;
|
||||
/// ein Task für 08:00 Ortszeit lief damit im Sommer um 06:00, ohne dass irgendwo
|
||||
/// etwas auffiel. Gar nicht zu laufen ist der ehrlichere Fehler: Er fällt auf.
|
||||
///
|
||||
/// Sichtbar wird er beim Import — <see cref="TaskboardService"/> weist eine Aufgabe
|
||||
/// mit unbekannter Zone mit Meldung ab.
|
||||
/// </summary>
|
||||
private static TimeZoneInfo? ResolveTimeZone(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id)) return TimeZoneInfo.Utc;
|
||||
return TimeZones.TryResolve(id);
|
||||
}
|
||||
|
||||
private static DateTime Truncate(DateTime value)
|
||||
=> new(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind);
|
||||
|
||||
private static string Iso(DateTime utc) => utc.ToUniversalTime().ToString("O");
|
||||
|
||||
private static DateTime ParseIso(string value)
|
||||
=> DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt)
|
||||
? dt.ToUniversalTime()
|
||||
: DateTime.MinValue;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Übersetzt die Aufzählungen in ihre menschenlesbare Form und zurück. Dieselbe
|
||||
/// Schreibweise wird im Frontmatter wie in der DB verwendet, damit beide Seiten sich
|
||||
/// nicht auseinanderentwickeln. Unbekannte Eingaben fallen auf einen sicheren Standard
|
||||
/// zurück, statt zu werfen — eine von Hand editierte Datei soll das Board nicht kippen.
|
||||
/// </summary>
|
||||
public static class TaskText
|
||||
{
|
||||
public static string Of(TaskItemStatus status) => status switch
|
||||
{
|
||||
TaskItemStatus.Backlog => "backlog",
|
||||
TaskItemStatus.Todo => "todo",
|
||||
TaskItemStatus.InProgress => "in_progress",
|
||||
TaskItemStatus.InReview => "in_review",
|
||||
TaskItemStatus.Done => "done",
|
||||
TaskItemStatus.Canceled => "canceled",
|
||||
TaskItemStatus.Blocked => "blocked",
|
||||
TaskItemStatus.Archived => "archived",
|
||||
_ => "todo"
|
||||
};
|
||||
|
||||
public static TaskItemStatus Status(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"backlog" => TaskItemStatus.Backlog,
|
||||
"todo" => TaskItemStatus.Todo,
|
||||
"in_progress" => TaskItemStatus.InProgress,
|
||||
"in_review" => TaskItemStatus.InReview,
|
||||
"done" => TaskItemStatus.Done,
|
||||
"canceled" or "cancelled" => TaskItemStatus.Canceled,
|
||||
"blocked" => TaskItemStatus.Blocked,
|
||||
"archived" => TaskItemStatus.Archived,
|
||||
_ => TaskItemStatus.Todo
|
||||
};
|
||||
|
||||
public static string Of(TaskItemType type) => type switch
|
||||
{
|
||||
TaskItemType.Work => "work",
|
||||
TaskItemType.Approval => "approval",
|
||||
TaskItemType.HumanInput => "human_input",
|
||||
TaskItemType.ToolJob => "tool_job",
|
||||
_ => "work"
|
||||
};
|
||||
|
||||
public static TaskItemType Type(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"approval" => TaskItemType.Approval,
|
||||
"human_input" => TaskItemType.HumanInput,
|
||||
"tool_job" => TaskItemType.ToolJob,
|
||||
_ => TaskItemType.Work
|
||||
};
|
||||
|
||||
public static string Of(TaskWhenKind kind) => kind switch
|
||||
{
|
||||
TaskWhenKind.At => "at",
|
||||
TaskWhenKind.Every => "every",
|
||||
TaskWhenKind.Cron => "cron",
|
||||
_ => "cron"
|
||||
};
|
||||
|
||||
/// <summary>Gibt <c>null</c> zurück, wenn die Angabe keine bekannte Terminart ist.</summary>
|
||||
public static TaskWhenKind? WhenKind(string? value) => value?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"at" => TaskWhenKind.At,
|
||||
"every" => TaskWhenKind.Every,
|
||||
"cron" => TaskWhenKind.Cron,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Die Brücke der Wahrheitsaufteilung: Markdown-Dateien unter <c>SharedWorkspace/tasks/</c>
|
||||
/// halten die Definition, die DB (<see cref="ITaskRepository"/>) den Ausführungszustand.
|
||||
/// Dieser Dienst hält beide im Gleichschritt und wird von drei Seiten genutzt — dem
|
||||
/// Agenten-Tool, dem Scanner und dem Start (Reconciliation).
|
||||
///
|
||||
/// Bewusst zustandslos über den Aufrufen: nur Repository und Verzeichnis, keine
|
||||
/// gepufferten Aufgaben. So kann jede Seite ihn nach Bedarf erzeugen.
|
||||
/// </summary>
|
||||
public sealed class TaskboardService
|
||||
{
|
||||
private readonly ITaskRepository _repo;
|
||||
private readonly string _tasksDir;
|
||||
|
||||
public TaskboardService(ITaskRepository repo, string tasksDirectory)
|
||||
{
|
||||
_repo = repo;
|
||||
_tasksDir = tasksDirectory;
|
||||
}
|
||||
|
||||
public string TasksDirectory => _tasksDir;
|
||||
|
||||
// ─── Lesen (direkt aus der DB) ───
|
||||
|
||||
public Task<TaskItem?> GetAsync(string id, CancellationToken ct) => _repo.GetAsync(id, ct);
|
||||
|
||||
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
|
||||
=> _repo.ListAsync(query, ct);
|
||||
|
||||
// ─── Anlegen ───
|
||||
|
||||
/// <summary>
|
||||
/// Legt eine Aufgabe an: vergibt (falls nötig) eine stabile Id, schreibt die Datei und
|
||||
/// spiegelt sie in die DB. Datei und DB entstehen in einem Zug.
|
||||
/// </summary>
|
||||
public async Task<TaskItem> CreateAsync(TaskItem definition, CancellationToken ct)
|
||||
{
|
||||
var id = string.IsNullOrWhiteSpace(definition.Id) ? await NewIdAsync(ct) : definition.Id.Trim();
|
||||
var fileName = string.IsNullOrWhiteSpace(definition.FileName)
|
||||
? BuildFileName(definition.Title, id)
|
||||
: definition.FileName;
|
||||
|
||||
var toWrite = definition with { Id = id, FileName = fileName };
|
||||
WriteFile(toWrite);
|
||||
return await _repo.UpsertAsync(toWrite, ct);
|
||||
}
|
||||
|
||||
// ─── Ändern ───
|
||||
|
||||
/// <summary>
|
||||
/// Ändert die Definition einer Aufgabe (Tool-Aktion). Schreibt Datei und DB. Eine
|
||||
/// Status-Änderung wird zusätzlich explizit gesetzt — der Import spiegelt nur die
|
||||
/// Definition und lässt den Ausführungszustand absichtlich unangetastet, damit ein
|
||||
/// blinder Re-Import einen laufenden Zustand nicht zurücksetzt.
|
||||
/// </summary>
|
||||
public async Task<TaskItem?> UpdateAsync(
|
||||
string id, Func<TaskItem, TaskItem> mutate, CancellationToken ct)
|
||||
{
|
||||
var current = await _repo.GetAsync(id, ct);
|
||||
if (current is null) return null;
|
||||
|
||||
var updated = mutate(current) with { Id = id, FileName = current.FileName };
|
||||
WriteFile(updated);
|
||||
await _repo.UpsertAsync(updated, ct);
|
||||
|
||||
if (updated.Status != current.Status)
|
||||
await _repo.SetStatusAsync(id, updated.Status, DateTime.UtcNow, ct);
|
||||
|
||||
return await _repo.GetAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an.</summary>
|
||||
public Task<TaskItem?> AddCommentAsync(string id, string author, string text, CancellationToken ct)
|
||||
=> UpdateAsync(id, current =>
|
||||
{
|
||||
var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
|
||||
var block = $"---\n**{author}** ({stamp}):\n\n{text.Trim()}";
|
||||
var body = string.IsNullOrWhiteSpace(current.Body) ? block : $"{current.Body.TrimEnd()}\n\n{block}";
|
||||
return current with { Body = body };
|
||||
}, ct);
|
||||
|
||||
// ─── Import (Datei → DB) ───
|
||||
|
||||
/// <summary>
|
||||
/// Liest alle Task-Dateien und spiegelt sie in die DB. DB-Zeilen, deren Datei
|
||||
/// verschwunden ist, werden archiviert (nicht gelöscht — die Historie bleibt). Gibt
|
||||
/// die Zahl der importierten Dateien zurück.
|
||||
/// </summary>
|
||||
public async Task<int> ImportAllAsync(CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(_tasksDir))
|
||||
return 0;
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var imported = 0;
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(_tasksDir, "*.md"))
|
||||
{
|
||||
var task = await ImportFileAsync(path, ct);
|
||||
if (task is not null) { seen.Add(task.Id); imported++; }
|
||||
}
|
||||
|
||||
// Verwaiste Zeilen archivieren. Der Deckel von 500 ist für den Start unkritisch;
|
||||
// wächst das Board darüber hinaus, gehört ohnehin das Aufräumen (siehe Konzept) her.
|
||||
var known = await _repo.ListAsync(new TaskQuery { IncludeArchived = true, Limit = 500 }, ct);
|
||||
foreach (var task in known)
|
||||
if (task.Status != TaskItemStatus.Archived && !seen.Contains(task.Id))
|
||||
await _repo.SetStatusAsync(task.Id, TaskItemStatus.Archived, DateTime.UtcNow, ct);
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
/// <summary>Importiert eine einzelne Datei. Eine von Hand angelegte Datei ohne
|
||||
/// <c>id</c> bekommt eine zugewiesen und wird einmalig kanonisch zurückgeschrieben.</summary>
|
||||
public async Task<TaskItem?> ImportFileAsync(string path, CancellationToken ct)
|
||||
{
|
||||
string text;
|
||||
try { text = AtomicFile.ReadAllText(path); }
|
||||
catch { return null; }
|
||||
|
||||
if (!TaskFrontmatter.TryParse(text, out var definition, out _))
|
||||
return null;
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(definition.Id))
|
||||
{
|
||||
definition = definition with { Id = await NewIdAsync(ct), FileName = fileName };
|
||||
WriteFile(definition); // Id festschreiben, damit sie den nächsten Start überlebt
|
||||
}
|
||||
else
|
||||
{
|
||||
definition = definition with { FileName = fileName };
|
||||
}
|
||||
|
||||
return await _repo.UpsertAsync(definition, ct);
|
||||
}
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private void WriteFile(TaskItem definition)
|
||||
{
|
||||
Directory.CreateDirectory(_tasksDir);
|
||||
var path = Path.Combine(_tasksDir, definition.FileName);
|
||||
AtomicFile.WriteAllText(path, TaskFrontmatter.Serialize(definition));
|
||||
}
|
||||
|
||||
private async Task<string> NewIdAsync(CancellationToken ct)
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var id = "t-" + Guid.NewGuid().ToString("N")[..6];
|
||||
if (await _repo.GetAsync(id, ct) is null)
|
||||
return id;
|
||||
}
|
||||
return "t-" + Guid.NewGuid().ToString("N")[..12];
|
||||
}
|
||||
|
||||
private static string BuildFileName(string title, string id)
|
||||
{
|
||||
var slug = Slug(title);
|
||||
var suffix = id.StartsWith("t-", StringComparison.Ordinal) ? id[2..] : id;
|
||||
return (slug.Length == 0 ? "task" : slug) + "-" + suffix + ".md";
|
||||
}
|
||||
|
||||
private static string Slug(string title)
|
||||
{
|
||||
var chars = title.Trim().ToLowerInvariant()
|
||||
.Select(c => char.IsLetterOrDigit(c) ? c : '-')
|
||||
.ToArray();
|
||||
var slug = new string(chars);
|
||||
|
||||
while (slug.Contains("--"))
|
||||
slug = slug.Replace("--", "-");
|
||||
slug = slug.Trim('-');
|
||||
|
||||
if (slug.Length > 40)
|
||||
slug = slug[..40].Trim('-');
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.Core.Tools;
|
||||
@@ -14,5 +15,6 @@ public sealed record AgentToolContext(
|
||||
string? WorkspacePath = null,
|
||||
string? SharedWorkspacePath = null,
|
||||
IAgentMessageRouter? MessageRouter = null,
|
||||
IMemoryRepository? Memory = null
|
||||
IMemoryRepository? Memory = null,
|
||||
ITaskRepository? Tasks = null
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:ClawdDotNet.Desktop"
|
||||
x:Class="ClawdDotNet.Desktop.App"
|
||||
RequestedThemeVariant="Default">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator />
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
|
||||
<StyleInclude Source="avares://ClawdDotNet/Styles/Shell.axaml" />
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
@@ -0,0 +1,213 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Desktop.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private AppHost? _host;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Erst beenden, wenn wir es sagen: Zwischen Instanzauswahl und Hauptfenster
|
||||
// ist kurz gar kein Fenster offen. Mit OnLastWindowClose würde die Anwendung
|
||||
// in genau dieser Lücke aussteigen.
|
||||
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
desktop.ShutdownRequested += async (_, _) =>
|
||||
{
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
};
|
||||
|
||||
// Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren,
|
||||
// damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf
|
||||
// dem die Fenster des Startvorgangs überhaupt erscheinen könnten.
|
||||
_ = StartAsync(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var result = await AppHost.StartAsync(new AppHost.Callbacks
|
||||
{
|
||||
SelectInstance = SelectInstanceAsync,
|
||||
License = new AvaloniaLicensePrompt(),
|
||||
TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt),
|
||||
Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:")
|
||||
});
|
||||
|
||||
if (result.Error is { } error)
|
||||
{
|
||||
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Fehler", error);
|
||||
desktop.Shutdown(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Host is null)
|
||||
{
|
||||
// Abbruch durch den Benutzer oder fehlende Lizenz — beides ist bereits
|
||||
// erklärt worden, hier kommt keine weitere Meldung hinterher.
|
||||
desktop.Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
_host = result.Host;
|
||||
|
||||
HookErrorReporting(_host);
|
||||
HookLicenseWatch(_host, desktop);
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(_host)
|
||||
};
|
||||
|
||||
desktop.MainWindow.Show();
|
||||
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
|
||||
|
||||
await ShowUpdateNoticeAsync(_host);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters.
|
||||
///
|
||||
/// <para>Erst hier verdrahtet, nicht in <c>Main</c>: Vor dem Aufbau gibt es weder
|
||||
/// Einstellungen noch Token, und ohne die wäre der Meldeweg ohnehin der Leerlauf.
|
||||
/// Die Kehrseite ist bewusst in Kauf genommen — ein Absturz <em>während</em> des
|
||||
/// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.</para>
|
||||
/// </summary>
|
||||
private static void HookErrorReporting(AppHost host)
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
{
|
||||
if (args.ExceptionObject is Exception ex)
|
||||
{
|
||||
// Der Prozess endet gleich: kurze Frist, dann weiterlaufen lassen.
|
||||
host.Errors.ReportAsync(ex, fatal: args.IsTerminating)
|
||||
.Wait(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
_ = host.Errors.ReportAsync(args.Exception, fatal: false);
|
||||
|
||||
// Ohne Observe reißt eine unbeobachtete Ausnahme in manchen Konfigurationen
|
||||
// den Prozess mit — und das wäre eine Nebenwirkung des Meldens.
|
||||
args.SetObserved();
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += (_, args) =>
|
||||
{
|
||||
_ = host.Errors.ReportAsync(args.Exception, fatal: false);
|
||||
|
||||
// Ein Fehler in einem Ereignisbehandler soll die Oberfläche nicht beenden.
|
||||
args.Handled = true;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage.</summary>
|
||||
private static void HookLicenseWatch(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (host.LicenseWatch is null) return;
|
||||
|
||||
host.LicenseWatch.Revoked += async message =>
|
||||
{
|
||||
await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Lizenz", message);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(2));
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: Wann aktualisiert
|
||||
/// wird, entscheidet der Benutzer — eine Anwendung, die sich beim Start selbst
|
||||
/// beendet, um sich zu erneuern, ist genau dann im Weg, wenn man sie braucht.
|
||||
/// </summary>
|
||||
private static async Task ShowUpdateNoticeAsync(AppHost host)
|
||||
{
|
||||
if (host.Deploymentcenter is null) return;
|
||||
|
||||
// Die Prüfung läuft nebenher; kurz Zeit geben, dann aufgeben.
|
||||
for (var waited = 0; host.Deploymentcenter.Update is null && waited < 10; waited++)
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
|
||||
if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
|
||||
|
||||
await new AvaloniaLicensePrompt().ShowInfoAsync(
|
||||
update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update",
|
||||
$"Version {update.LatestVersion} ist verfügbar."
|
||||
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
|
||||
}
|
||||
|
||||
private static async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var viewModel = new InstancePickerViewModel(directories);
|
||||
var window = new InstancePickerWindow { DataContext = viewModel };
|
||||
|
||||
// Schließt der Benutzer das Fenster, gilt das als Abbruch — sonst wartete
|
||||
// der Start für immer auf eine Auswahl, die nie kommt.
|
||||
window.Closed += (_, _) => viewModel.CancelCommand.Execute(null);
|
||||
|
||||
window.Show();
|
||||
|
||||
var path = await viewModel.Result;
|
||||
window.Close();
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Einzeilige Abfrage — ersetzt <c>Microsoft.VisualBasic.Interaction.InputBox</c>,
|
||||
/// das die Telegram-Anmeldung an Windows band.
|
||||
/// </summary>
|
||||
private static async Task<string> AskAsync(string title, string prompt)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource<string>();
|
||||
|
||||
var input = new TextBox();
|
||||
var ok = new Button { Content = "OK", IsDefault = true };
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 420,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = prompt, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
input,
|
||||
ok
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ok.Click += (_, _) => window.Close();
|
||||
window.Closed += (_, _) => completion.TrySetResult(input.Text?.Trim() ?? "");
|
||||
|
||||
window.Show();
|
||||
input.Focus();
|
||||
|
||||
return await completion.Task;
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Desktop</RootNamespace>
|
||||
<AssemblyName>ClawdDotNet</AssemblyName>
|
||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||
|
||||
<!-- Avalonia legt AXAML-Dateien selbst als AvaloniaResource an; die Standard-Globs
|
||||
wuerden sie zusaetzlich als None einsammeln. -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
|
||||
<!-- Kein Konsolenfenster unter Windows, aber unter Linux ein normaler Prozess.
|
||||
WinExe verhaelt sich dort ohnehin wie Exe. -->
|
||||
<BuiltInComInteropSupport>false</BuiltInComInteropSupport>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.1" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.App\ClawdDotNet.App.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Färbt den Hintergrund einer Chat-Sprechblase für Benutzer-Nachrichten.
|
||||
/// </summary>
|
||||
public sealed class ChatBubbleBrushConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatBubbleBrushConverter Instance = new();
|
||||
|
||||
private static readonly IBrush UserBubble = new SolidColorBrush(Color.FromArgb(0x28, 0x00, 0x78, 0xD4));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isUser && isUser)
|
||||
return UserBubble;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Richtet Chat-Sprechblasen aus: Rechts für den Benutzer, links für den Agenten.
|
||||
/// </summary>
|
||||
public sealed class ChatAlignmentConverter : IValueConverter
|
||||
{
|
||||
public static readonly ChatAlignmentConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is bool isUser && isUser)
|
||||
return HorizontalAlignment.Right;
|
||||
|
||||
return HorizontalAlignment.Left;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Tool-Name mit dem ConverterParameter übereinstimmt.
|
||||
/// </summary>
|
||||
public sealed class ToolMatchConverter : IValueConverter
|
||||
{
|
||||
public static readonly ToolMatchConverter Instance = new();
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is string toolName && parameter is string targetTool)
|
||||
return toolName.Equals(targetTool, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Färbt eine Logzeile nach ihrer Stufe.
|
||||
///
|
||||
/// Bewusst nur Warnung und Fehler hervorgehoben — alles einzufärben macht die Ansicht
|
||||
/// unruhig und lenkt von genau den Zeilen ab, die auffallen sollen. Der Rest behält die
|
||||
/// Vordergrundfarbe des Themas und funktioniert damit hell wie dunkel.
|
||||
/// </summary>
|
||||
public sealed class LogLevelBrushConverter : IValueConverter
|
||||
{
|
||||
public static readonly LogLevelBrushConverter Instance = new();
|
||||
|
||||
private static readonly IBrush Error = new SolidColorBrush(Color.FromRgb(0xE8, 0x4B, 0x4B));
|
||||
private static readonly IBrush Warn = new SolidColorBrush(Color.FromRgb(0xD8, 0x9C, 0x2A));
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> value as string switch
|
||||
{
|
||||
"ERR" or "FTL" => Error,
|
||||
"WRN" => Warn,
|
||||
// null heißt: Bindung greift nicht, das Steuerelement behält seine eigene
|
||||
// Farbe aus dem Thema.
|
||||
_ => null
|
||||
};
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException("Nur zur Anzeige.");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Einstiegspunkt der Oberfläche.
|
||||
///
|
||||
/// Bewusst schlank: Hier wird nur Avalonia hochgefahren. Alles Fachliche —
|
||||
/// Einstellungen, Instanzauswahl, Engine, Scanner, Watchdog — baut
|
||||
/// <see cref="App"/> über die Anwendungsschicht auf, damit derselbe Aufbau später
|
||||
/// auch ohne Fenster laufen kann.
|
||||
///
|
||||
/// Kein <c>[STAThread]</c> mehr: Das war eine COM-Anforderung von WinForms. Avalonia
|
||||
/// braucht es nicht, und unter Linux hätte es ohnehin keine Bedeutung.
|
||||
/// </summary>
|
||||
public static int Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
/// <summary>
|
||||
/// Auch vom Vorschau-Werkzeug des Editors aufgerufen — deshalb öffentlich und
|
||||
/// getrennt von <see cref="Main"/>.
|
||||
/// </summary>
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
// Inter wird mitgeliefert, statt sich auf Systemschriften zu verlassen:
|
||||
// Ein schlankes Linux-Abbild hat oft gar keine, und dann bleibt die
|
||||
// Oberfläche leer.
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
|
||||
namespace ClawdDotNet.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lizenzabfrage über Fenster.
|
||||
///
|
||||
/// Der Aufbau läuft auf einem Hintergrundfaden — Avalonia besteht aber darauf, dass
|
||||
/// Fenster auf dem Oberflächenfaden entstehen. Deshalb geht hier alles über
|
||||
/// <see cref="Dispatcher.UIThread"/>. Das ist der Grund, warum
|
||||
/// <see cref="ILicensePrompt"/> durchgehend asynchron ist: Die WinForms-Fassung konnte
|
||||
/// <c>ShowDialog</c> einfach blockierend aufrufen, weil sie ohnehin auf dem
|
||||
/// Oberflächenfaden lief.
|
||||
/// </summary>
|
||||
public sealed class AvaloniaLicensePrompt : ILicensePrompt
|
||||
{
|
||||
public async Task<string?> RequestKeyAsync(string hardwareId, string? problem, string? currentKey)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var viewModel = new LicenseViewModel(hardwareId, problem, currentKey);
|
||||
var window = new LicenseWindow { DataContext = viewModel };
|
||||
|
||||
window.Show();
|
||||
|
||||
var key = await viewModel.Result;
|
||||
window.Close();
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
public Task ShowInfoAsync(string title, string message)
|
||||
=> ShowMessageAsync(title, message);
|
||||
|
||||
public Task ShowErrorAsync(string title, string message)
|
||||
=> ShowMessageAsync(title, message);
|
||||
|
||||
/// <summary>
|
||||
/// Ein Meldungsfenster.
|
||||
///
|
||||
/// Avalonia bringt kein <c>MessageBox</c> mit — bewusst, weil es auf allen
|
||||
/// Plattformen anders aussähe. Ein schlichtes Fenster ist hier ausreichend und
|
||||
/// erspart uns eine weitere Abhängigkeit für drei Aufrufstellen.
|
||||
/// </summary>
|
||||
private static async Task ShowMessageAsync(string title, string message)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var completion = new TaskCompletionSource();
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Content = "OK",
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
|
||||
IsDefault = true
|
||||
};
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Title = title,
|
||||
Width = 460,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Avalonia.Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap },
|
||||
button
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
button.Click += (_, _) => window.Close();
|
||||
window.Closed += (_, _) => completion.TrySetResult();
|
||||
|
||||
window.Show();
|
||||
await completion.Task;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!--
|
||||
Gemeinsame Anmutung der Oberflaeche.
|
||||
|
||||
Bewusst wenige, benannte Regeln statt Formatierung an jedem Steuerelement: Die
|
||||
WinForms-Fassung hatte Groessen, Abstaende und Schriften ueber 130 Stellen in den
|
||||
Designer-Dateien verteilt: Jede Aenderung war eine Suche.
|
||||
-->
|
||||
|
||||
<Style Selector="TextBlock.heading">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="Opacity" Value="0.7" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- Werkzeugleiste ueber einer Ansicht -->
|
||||
<Style Selector="StackPanel.toolbar">
|
||||
<Setter Property="Orientation" Value="Horizontal" />
|
||||
<Setter Property="Spacing" Value="6" />
|
||||
<Setter Property="Margin" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Padding" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- Statusleiste am unteren Rand -->
|
||||
<Style Selector="Border.statusbar">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemControlForegroundBaseLowBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,1,0,0" />
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
@@ -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 & 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 & 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 & 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 & 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
Reference in New Issue
Block a user