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