using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Core.Tasks; /// /// Die Brücke der Wahrheitsaufteilung: Markdown-Dateien unter SharedWorkspace/tasks/ /// halten die Definition, die DB () 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. /// 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 GetAsync(string id, CancellationToken ct) => _repo.GetAsync(id, ct); public Task> ListAsync(TaskQuery query, CancellationToken ct) => _repo.ListAsync(query, ct); /// The whole board, without the cap of . public Task> ListAllAsync(CancellationToken ct, bool includeArchived = false) => _repo.ListAllAsync(includeArchived, ct); // ─── Anlegen ─── /// /// 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. /// public async Task 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 ─── /// /// Ä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. /// public async Task UpdateAsync( string id, Func 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); } /// Hängt einen Kommentar (Ergebnis, Kritik) an den Rumpf an. public Task 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) ─── /// /// 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. /// public async Task ImportAllAsync(CancellationToken ct) { if (!Directory.Exists(_tasksDir)) return 0; var seen = new HashSet(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. Ungekappt (#716): Vorher sah dieser Abgleich nur die // ersten 500 Zeilen, und darüber blieben verwaiste Zeilen still stehen. var known = await _repo.ListAllAsync(includeArchived: true, 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; } /// Importiert eine einzelne Datei. Eine von Hand angelegte Datei ohne /// id bekommt eine zugewiesen und wird einmalig kanonisch zurückgeschrieben. public async Task 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 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; } }