feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user