- TaskBoard: das Brett einer Aufgabe wird nicht gespeichert, es folgt aus der Organisation - Einheit des Zustaendigen, sonst des Anlegers. Offene Aufgaben ziehen damit beim Umzug mit. Sichtbar: das eigene Brett, fuer eine Leitung alle darunter, und was man selbst angelegt hat (nach oben Gereichtes bleibt im Blick). - TaskItem.CreatedBy, in der Datei als created_by und in der DB. Ein Import aus einer Datei ohne das Feld behaelt, was die DB weiss. - Taskboard-Tool: task_list, task_find und die Dublettenpruefung sehen nur die eigenen Bretter - eine aehnliche Buchhaltungsaufgabe haelt keine Marketingaufgabe auf und bekommt keinen Kommentar von dort. task_update und task_comment antworten auf eine fremde Aufgabe wie auf eine unbekannte. - TaskboardService.ListAsync mit Sichtfilter: erst filtern, dann kappen. - WebUI: jede Aufgabe nennt ihr Brett, ein Filter waehlt eines aus. - Konzept (Abschnitt 3) und Einrichtungshandbuch nachgetragen. Ohne Einheiten aendert sich nichts - ein Brett fuer alle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
8.1 KiB
C#
212 lines
8.1 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// #684: A list restricted to what one agent may see. The boards cannot be expressed in
|
|
/// the database query — they follow from the organisation — so the whole board is read
|
|
/// and filtered here, then capped: filtering after the database's cap would silently
|
|
/// drop tasks of the own unit behind those of others.
|
|
/// </summary>
|
|
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, Func<TaskItem, bool>? visible, CancellationToken ct)
|
|
{
|
|
if (visible is null)
|
|
return await _repo.ListAsync(query, ct);
|
|
|
|
var all = await _repo.ListAllAsync(query.IncludeArchived || query.Status == TaskItemStatus.Archived, ct);
|
|
return all
|
|
.Where(visible)
|
|
.Where(query.Matches)
|
|
.OrderByDescending(t => t.Priority)
|
|
.ThenByDescending(t => t.UpdatedAt)
|
|
.Take(Math.Clamp(query.Limit, 1, 500))
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>The whole board, without the cap of <see cref="ListAsync(TaskQuery, CancellationToken)"/>.</summary>
|
|
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
|
|
=> _repo.ListAllAsync(includeArchived, 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. 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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|