From 29d953a5eb8be34beea4faafa781be0332fc1d39 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 24 Sep 2026 17:03:39 +0200 Subject: [PATCH] Brett je Einheit, Stufe 3: Aufgaben sehen, wer sie sehen soll (#684) - 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 --- docs/InstanceSetupGuide.md | 2 + docs/Organisation-Konzept.md | 19 ++ src/ClawdDotNet.Core/Storage/SqliteStorage.cs | 3 + .../Tasks/SqliteTaskRepository.cs | 12 +- src/ClawdDotNet.Core/Tasks/TaskBoard.cs | 78 ++++++++ src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs | 3 + src/ClawdDotNet.Core/Tasks/TaskModels.cs | 27 +++ .../Tasks/TaskboardService.cs | 23 ++- src/ClawdDotNet.Manager.Web/wwwroot/app.css | 5 + .../wwwroot/i18n/de.json | 3 + .../wwwroot/i18n/en.json | 3 + .../wwwroot/views/tasks.js | 39 +++- .../TaskboardTool.cs | 57 ++++-- src/ClawdDotNet.Web/Contracts/Dtos.cs | 6 +- src/ClawdDotNet.Web/Contracts/Mapping.cs | 11 +- src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs | 11 +- .../Taskboard/UnitBoardTests.cs | 186 ++++++++++++++++++ 17 files changed, 458 insertions(+), 30 deletions(-) create mode 100644 src/ClawdDotNet.Core/Tasks/TaskBoard.cs create mode 100644 tests/ClawdDotNet.Tools.Tests/Taskboard/UnitBoardTests.cs diff --git a/docs/InstanceSetupGuide.md b/docs/InstanceSetupGuide.md index 1ebd265..90ca3fc 100644 --- a/docs/InstanceSetupGuide.md +++ b/docs/InstanceSetupGuide.md @@ -64,6 +64,8 @@ Instance-{Name}/ ├── AgentList.json ← Agenten-Register (Name, Beschreibung, Ordner) ├── SharedWorkspace/ ← Geteilter Arbeitsbereich │ └── coordination/ ← Status-Dateien für Inter-Agenten-Kommunikation + ├── SharedWorkspaces/ ← Bereiche mit Mitgliederliste (#684), einer je Einheit + │ └── {bereich}/ ← in FileRW als workspace "unit" oder "{bereich}" └── Agent-{Name}/ ├── AgentSettings.json ← Agent-Konfiguration (Modell, Tools, Scheduler) ├── Identity.md ← WER ist der Agent (Rolle, Expertise) diff --git a/docs/Organisation-Konzept.md b/docs/Organisation-Konzept.md index 8400c21..d877f26 100644 --- a/docs/Organisation-Konzept.md +++ b/docs/Organisation-Konzept.md @@ -244,6 +244,25 @@ Vier Festlegungen: Für bestehende Instanzen bleibt `workspace: "shared"` gültig und zeigt auf den einen Bereich, den es dort gibt. Ohne das bräche jede Aufgabe, die heute läuft. +**Umgesetzt am 2026-09-24 (#684), mit zwei Abweichungen im Wortlaut, nicht in der Sache:** + +- Der Ordner `SharedWorkspace` wird **nicht umbenannt**. Er bleibt der Bereich `shared` + mit dem bisherigen `sharedAccessLevel` am Agenten; die neuen Bereiche liegen daneben + unter `SharedWorkspaces//`. Am alten Pfad hängen FTP, RocketChat, der Agenten-Editor, + die Aufgabendateien und die Sicherungen — eine Umbenennung hätte jede laufende + Installation gebrochen. Für jeden Bereich gibt es genau eine Quelle des Rechts: für + `shared` die Stufe am Agenten, für alle anderen die Mitgliederliste. +- Der eigene Bereich einer Einheit trägt ihre Id; ein Agent erreicht ihn in FileRW als + `unit`, ohne die Id zu kennen. Welche Bereiche er hat, steht im Organisationsabschnitt + seines Prompts. +- **Mit Einheiten ist `shared:/tasks/` für FileRW gesperrt.** Eine direkt geschriebene + Aufgabendatei ginge an jeder Prüfung des Bretts vorbei. Aufgaben laufen nur über das + Taskboard. Ohne Einheiten ändert sich nichts. +- **Das Brett einer Aufgabe wird nicht gespeichert**, es folgt aus der Organisation: die + Einheit des Zuständigen, sonst die des Anlegers (`created_by`). Offene Aufgaben ziehen + damit beim Umzug von selbst mit. Wer eine Aufgabe angelegt hat, sieht sie auch, wenn sie + auf dem Brett der Leitung liegt. + ### Die eine Regel, die alles zusammenhält **Ein Agent kennt nur seine Einheit und seine Leitung.** Alles andere geht über die diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index db31519..ce6e70e 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -377,6 +377,9 @@ public sealed class SqliteStorage SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL"); SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0"); + // #684: Who created a task — decides its board when nobody in particular is assigned. + SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''"); + // Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen. using var index = connection.CreateCommand(); index.CommandText = """ diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs index 0a7d7ff..ae0b0dd 100644 --- a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs +++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs @@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository 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) + ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy) VALUES (@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz, @requireApproval, @acceptance, @blockedBy, @market, @body, @fileName, - @toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt); + @toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy); """; BindDefinition(insert, task); insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status)); @@ -60,6 +60,7 @@ public sealed class SqliteTaskRepository : ITaskRepository RequireApproval = @requireApproval, Acceptance = @acceptance, BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body, FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType, + CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END, UpdatedAt = @updatedAt WHERE Id = @id """; @@ -331,7 +332,7 @@ public sealed class SqliteTaskRepository : ITaskRepository "Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " + "RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " + "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, " - + "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending"; + + "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy"; private static void BindDefinition(SqliteCommand cmd, TaskItem task) { @@ -351,6 +352,8 @@ public sealed class SqliteTaskRepository : ITaskRepository cmd.Parameters.AddWithValue("@fileName", task.FileName); cmd.Parameters.AddWithValue("@toolName", task.ToolName); cmd.Parameters.AddWithValue("@jobType", task.JobTypeId); + // #684: a file without it (written by hand, or older) keeps what the database knows. + cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? ""); } private static async Task ReadAsync(SqliteConnection conn, string id, CancellationToken ct) @@ -401,7 +404,8 @@ public sealed class SqliteTaskRepository : ITaskRepository BudgetMissCount = r.GetInt32(22), LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)), DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)), - CatchUpPending = r.GetInt32(25) != 0 + CatchUpPending = r.GetInt32(25) != 0, + CreatedBy = r.GetString(26) }; } diff --git a/src/ClawdDotNet.Core/Tasks/TaskBoard.cs b/src/ClawdDotNet.Core/Tasks/TaskBoard.cs new file mode 100644 index 0000000..3661bf3 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskBoard.cs @@ -0,0 +1,78 @@ +using ClawdDotNet.Core.Organisation; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// #684: One board per unit. With 50 agents one board for everything is worthless as an +/// overview — the test instance had 159 tasks with seven agents — and a bookkeeping task +/// could slow down the creation of a marketing task through the duplicate check. +/// +/// The board of a task is not stored, it follows from the organisation: the unit +/// of the agent it is assigned to, or — for a task for a human or a fresh run — the unit of +/// the agent that created it. So open tasks move with an agent that moves (Organisation- +/// Konzept, section 3), and nothing has to be migrated. A task that belongs to no unit is on +/// the board of the top level. +/// +/// Who sees which board: an agent its own unit's; a lead also the boards of every +/// unit below the one it leads; everyone the tasks they created themselves, so a task handed +/// up to the lead stays in view of whoever handed it up. A task across units is a delegation +/// to the other unit's lead — the address book decides that, not this. +/// +/// Without units nothing changes: there is one board, and everyone sees it. +/// +public static class TaskBoard +{ + /// The unit whose board the task is on; null for the top level. + public static string? UnitOf(TaskItem task, OrgChart org) + { + var assigned = TaskAssignee.AgentId(task.Assignee); + if (assigned.Length > 0 && org.Agent(assigned) is not null) + return org.UnitOf(assigned)?.Id; + + if (!string.IsNullOrWhiteSpace(task.CreatedBy) && org.Agent(task.CreatedBy) is not null) + return org.UnitOf(task.CreatedBy)?.Id; + + return null; + } + + /// + /// What an agent may see — null for "everything": without units, without an + /// organisation (outside the engine), or for an agent the organisation does not know. + /// + public static Func? VisibleTo(OrgChart? org, string agentId) + { + if (org is not { HasUnits: true } || org.Agent(agentId) is null) + return null; + + var boards = BoardsOf(org, agentId); + var topLevel = org.UnitOf(agentId) is null; + + return task => + string.Equals(task.CreatedBy, agentId, StringComparison.OrdinalIgnoreCase) + || (UnitOf(task, org) is { } unit ? boards.Contains(unit) : topLevel); + } + + /// The units whose boards an agent sees: its own, and all below the unit it leads. + public static IReadOnlySet BoardsOf(OrgChart org, string agentId) + { + var boards = new HashSet(StringComparer.OrdinalIgnoreCase); + if (org.UnitOf(agentId) is { } own) + boards.Add(own.Id); + + if (org.LedBy(agentId) is { } led) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var pending = new Stack(); + pending.Push(led.Id); + while (pending.TryPop(out var unitId)) + { + if (!seen.Add(unitId)) + continue; // a broken tree must not hang this + boards.Add(unitId); + foreach (var child in org.Children(unitId)) + pending.Push(child.Id); + } + } + return boards; + } +} diff --git a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs index e854ced..b9ce8f8 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs @@ -152,6 +152,7 @@ public static class TaskFrontmatter OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")), ToolName = scalars.GetValueOrDefault("tool_name", ""), JobTypeId = scalars.GetValueOrDefault("job_type", ""), + CreatedBy = scalars.GetValueOrDefault("created_by", ""), Body = body }; return true; @@ -169,6 +170,8 @@ public static class TaskFrontmatter 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 (!string.IsNullOrWhiteSpace(task.CreatedBy)) + sb.Append($"created_by: {Scalar(task.CreatedBy)}\n"); if (task.When is { } w) { diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs index 9f75f22..27e8564 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskModels.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs @@ -228,6 +228,13 @@ public sealed record TaskItem /// public bool CatchUpPending { get; init; } + /// + /// #684: The agent that created the task; empty for tasks from a human, from a file + /// written by hand, or from before this field. Decides the board of a task nobody in + /// particular is assigned to — see . + /// + public string CreatedBy { get; init; } = ""; + /// A one-time task that was refused for lack of budget and carried over. public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0; @@ -269,4 +276,24 @@ public sealed record TaskQuery public bool IncludeArchived { get; init; } public int Limit { get; init; } = 50; + + /// + /// #684: The same filter as the repository's query, for a list that is already in + /// memory — when a board has to be filtered first and the database cannot do that. + /// + public bool Matches(TaskItem task) + { + if (Status is { } status ? task.Status != status : !IncludeArchived && task.Status == TaskItemStatus.Archived) + return false; + if (!string.IsNullOrWhiteSpace(Assignee) && !string.Equals(task.Assignee, Assignee.Trim(), StringComparison.OrdinalIgnoreCase)) + return false; + if (!string.IsNullOrWhiteSpace(Search)) + { + var search = Search.Trim(); + if (!task.Title.Contains(search, StringComparison.OrdinalIgnoreCase) + && !task.Body.Contains(search, StringComparison.OrdinalIgnoreCase)) + return false; + } + return true; + } } diff --git a/src/ClawdDotNet.Core/Tasks/TaskboardService.cs b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs index 171c8e1..ac9db00 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskboardService.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskboardService.cs @@ -31,7 +31,28 @@ public sealed class TaskboardService public Task> ListAsync(TaskQuery query, CancellationToken ct) => _repo.ListAsync(query, ct); - /// The whole board, without the cap of . + /// + /// #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. + /// + public async Task> ListAsync(TaskQuery query, Func? 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(); + } + + /// The whole board, without the cap of . public Task> ListAllAsync(CancellationToken ct, bool includeArchived = false) => _repo.ListAllAsync(includeArchived, ct); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index add3181..e07f519 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -2372,3 +2372,8 @@ tr.org-ausreisser td { @media (max-width: 900px) { .org-raster { grid-template-columns: 1fr; } } + +/* #684: the board filter above the task cards */ +.taskboard-bretter { + margin-bottom: 12px; +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 84d4cae..fd9b567 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -346,6 +346,9 @@ "tasks.budgetMissedLast": "zuletzt {time}", "tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}", "tasks.budgetCatchUp": "ein Nachholauf ab {time}", + "tasks.board": "Brett", + "tasks.boardAll": "Alle Bretter", + "tasks.boardTopLevel": "Oberste Ebene", "services.title": "Dienste", "services.newService": "Neuer Dienst", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 883dd5c..e5ff02c 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -346,6 +346,9 @@ "tasks.budgetMissedLast": "last at {time}", "tasks.budgetCarriedOver": "carried over, due again from {time}", "tasks.budgetCatchUp": "one catch-up run from {time}", + "tasks.board": "Board", + "tasks.boardAll": "All boards", + "tasks.boardTopLevel": "Top level", "services.title": "Services", "services.newService": "New Service", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js index 2d44bb0..38d3198 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js @@ -58,6 +58,9 @@ const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"]; let zeigeAbgeschlossene = false; +/** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */ +let brettFilter = ""; + export async function rendereAufgaben() { const container = document.getElementById("ansicht-inhalt"); if (!container) return; @@ -114,9 +117,14 @@ async function ladeTaskboard() { try { const alle = (await api("/tasks?limit=200")) || []; - const tasks = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status)); + const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status)); + const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter)); container.replaceChildren(); + // #684: With units, every task is on a unit's board — pick one to see only that. + const bretter = brettAuswahl(alle); + if (bretter) container.append(bretter); + if (tasks.length === 0) { container.append( erstelleKarte(t("tasks.empty"), [ @@ -141,6 +149,30 @@ async function ladeTaskboard() { } } +/** + * #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it + * shows only boards that hold something; absent when no task is on a unit's board. + */ +function brettAuswahl(tasks) { + const bretter = new Map(); + for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board); + if (bretter.size === 0) return null; + + const auswahl = erstelleElement("select", { + "aria-label": t("tasks.board"), + onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); } + }, + erstelleElement("option", { value: "", text: t("tasks.boardAll") }), + erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }), + ...[...bretter.entries()] + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad }))); + auswahl.value = brettFilter; + + return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" }, + erstelleElement("span", { text: t("tasks.board") }), auswahl); +} + function terminText(task) { if (!task.when) return t("tasks.scheduleNone"); const art = task.when.kind; @@ -155,7 +187,10 @@ function erstelleAufgabenKarte(task) { "div", { klasse: "task-status-zeile gruppe-horizontal" }, erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"), - erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}` }) + erstelleElement("span", { + klasse: "gedaempft text-klein", + text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}` + }) ) ); diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs index 631e773..e1385e4 100644 --- a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs @@ -22,7 +22,8 @@ public sealed class TaskboardTool : IAgentTool public string Name => "Taskboard"; public string Description => """ - The shared taskboard — persists across runs and restarts. + The taskboard — persists across runs and restarts. If your instance has units, you + see your unit's board; a lead also sees the boards of the units below. Use it to track and distribute work instead of keeping it in context: own open items, assignments to other agents, recurring schedules. @@ -157,15 +158,18 @@ public sealed class TaskboardTool : IAgentTool && CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach) return ToolResult.Fail(outOfReach); + // #684: The boards this agent sees; null means all of them (no units). + var visible = TaskBoard.VisibleTo(context.Organisation, context.AgentId); + try { return action switch { - "task_create" => await CreateAsync(input, board, context.AgentId, ct), - "task_find" => await FindAsync(input, board, ct), - "task_list" => await ListAsync(input, board, ct), - "task_update" => await UpdateAsync(input, board, ct), - "task_comment" => await CommentAsync(input, board, context.AgentId, ct), + "task_create" => await CreateAsync(input, board, context.AgentId, visible, ct), + "task_find" => await FindAsync(input, board, visible, ct), + "task_list" => await ListAsync(input, board, visible, ct), + "task_update" => await UpdateAsync(input, board, visible, ct), + "task_comment" => await CommentAsync(input, board, context.AgentId, visible, ct), _ => ToolResult.Fail($"Unknown action: {action}") }; } @@ -206,13 +210,13 @@ public sealed class TaskboardTool : IAgentTool /// the first sentence. /// private static async Task FindAsync( - JsonElement input, TaskboardService board, CancellationToken ct) + JsonElement input, TaskboardService board, Func? visible, CancellationToken ct) { var title = GetString(input, "title") ?? GetString(input, "search"); if (string.IsNullOrWhiteSpace(title)) return ToolResult.Fail("'title' is required — what should be searched for?"); - var treffer = await SucheAehnlicheAsync(board, title, null, ct); + var treffer = await SucheAehnlicheAsync(board, title, null, visible, ct); if (treffer.Count == 0) return ToolResult.Ok($"No open task resembles \"{title.Trim()}\". You can create it with task_create."); @@ -232,18 +236,29 @@ public sealed class TaskboardTool : IAgentTool return ToolResult.Ok(sb.ToString()); } - /// Offene Aufgaben mit verwandtem Titel, engste zuerst. + /// + /// Offene Aufgaben mit verwandtem Titel, engste zuerst — nur auf den Brettern, die der + /// Agent sieht (#684). Eine aehnliche Aufgabe der Buchhaltung haelt keine Marketing-Aufgabe + /// auf, und die Ablehnung verraete sonst, was dort liegt. + /// private static async Task> SucheAehnlicheAsync( - TaskboardService board, string title, string? exceptId, CancellationToken ct) + TaskboardService board, string title, string? exceptId, Func? visible, CancellationToken ct) { // Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks // stayed invisible once the board grew beyond that. var alle = await board.ListAllAsync(ct); - return TaskSimilarity.FindOpen(alle, title, exceptId); + return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId); } + /// + /// #684: A task on a board the agent does not see is answered like one that does not + /// exist — the answer must not tell that it is there. + /// + private static async Task CanSeeAsync(TaskboardService board, string id, Func? visible, CancellationToken ct) + => visible is null || (await board.GetAsync(id, ct) is { } task && visible(task)); + private static async Task CreateAsync( - JsonElement input, TaskboardService board, string author, CancellationToken ct) + JsonElement input, TaskboardService board, string author, Func? visible, CancellationToken ct) { var title = GetString(input, "title"); if (string.IsNullOrWhiteSpace(title)) @@ -256,7 +271,7 @@ public sealed class TaskboardTool : IAgentTool // existing one — so that later one can see how often and for how long. if (GetBool(input, "force") != true) { - var dublette = (await SucheAehnlicheAsync(board, title, null, ct)) + var dublette = (await SucheAehnlicheAsync(board, title, null, visible, ct)) .FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task; if (dublette is not null) @@ -285,7 +300,9 @@ public sealed class TaskboardTool : IAgentTool RequireApproval = GetBool(input, "require_approval") ?? false, Acceptance = GetString(input, "acceptance")?.Trim() ?? "", BlockedBy = GetStringList(input, "blocked_by"), - OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false + OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false, + // #684: decides the board when the task is for a human or a fresh run. + CreatedBy = author }; var created = await board.CreateAsync(definition, ct); @@ -296,7 +313,7 @@ public sealed class TaskboardTool : IAgentTool } private static async Task ListAsync( - JsonElement input, TaskboardService board, CancellationToken ct) + JsonElement input, TaskboardService board, Func? visible, CancellationToken ct) { var query = new TaskQuery { @@ -306,7 +323,7 @@ public sealed class TaskboardTool : IAgentTool Limit = GetInt(input, "limit") ?? 50 }; - var tasks = await board.ListAsync(query, ct); + var tasks = await board.ListAsync(query, visible, ct); if (tasks.Count == 0) return ToolResult.Ok("No matching tasks."); @@ -314,11 +331,13 @@ public sealed class TaskboardTool : IAgentTool } private static async Task UpdateAsync( - JsonElement input, TaskboardService board, CancellationToken ct) + JsonElement input, TaskboardService board, Func? visible, CancellationToken ct) { var id = GetString(input, "id"); if (string.IsNullOrWhiteSpace(id)) return ToolResult.Fail("'id' is required."); + if (!await CanSeeAsync(board, id.Trim(), visible, ct)) + return ToolResult.Fail($"No task found with ID '{id}'."); if (!TryReadWhen(input, out var when, out var whenError)) return ToolResult.Fail(whenError!); @@ -348,11 +367,13 @@ public sealed class TaskboardTool : IAgentTool } private static async Task CommentAsync( - JsonElement input, TaskboardService board, string author, CancellationToken ct) + JsonElement input, TaskboardService board, string author, Func? visible, CancellationToken ct) { var id = GetString(input, "id"); if (string.IsNullOrWhiteSpace(id)) return ToolResult.Fail("'id' is required."); + if (!await CanSeeAsync(board, id.Trim(), visible, ct)) + return ToolResult.Fail($"No task found with ID '{id}'."); var text = GetString(input, "text"); if (string.IsNullOrWhiteSpace(text)) diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 71114ec..6bf1378 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -117,7 +117,11 @@ public sealed record TaskDto( DateTime? NextRunUtc = null, TaskLastRunDto? LastRun = null, string? Hinweis = null, - TaskBudgetMissDto? BudgetMiss = null); + TaskBudgetMissDto? BudgetMiss = null, + string? CreatedBy = null, + // #684: the unit whose board the task is on, computed from the organisation; null on the top level. + string? Board = null, + string? BoardPath = null); /// /// #716: Runs of this task the budget refused — only present while something is open diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index cb6e916..6142e02 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -153,7 +153,16 @@ internal static class Mapping BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null ? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil, task.CatchUpPending, task.IsCarriedOver) - : null); + : null, + CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy); + + /// #684: The board of a task, for a list the human sees whole. + public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org) + { + if (!org.HasUnits || Core.Tasks.TaskBoard.UnitOf(task, org) is not { } unit) + return dto; + return dto with { Board = unit, BoardPath = org.PathOf(unit) }; + } /// /// Ein wartender Aufruf samt seiner eingefrorenen Argumente. diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 3d6bfcb..45f5a17 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -260,9 +260,14 @@ internal static class ApiEndpoints // #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren // /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte. tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) => - await host.TaskAdmin.ListAsync(limit, ct) is { } list - ? Results.Ok(list.Select(t => t.ToDto()).ToList()) - : Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.")); + { + if (await host.TaskAdmin.ListAsync(limit, ct) is not { } list) + return Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."); + + // #684: A human sees every board; each task says which one it is on. + var org = host.Organisation.Current(); + return Results.Ok(list.Select(t => t.ToDto().WithBoard(t.Task, org)).ToList()); + }); tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) => (await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse()); diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/UnitBoardTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/UnitBoardTests.cs new file mode 100644 index 0000000..1fc0447 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/UnitBoardTests.cs @@ -0,0 +1,186 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.Taskboard; +using ClawdDotNet.Tools.Tests.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Taskboard; + +/// +/// #684: One board per unit. The bookkeeper's tasks do not show up for marketing, do not +/// hold up a marketing task as a "duplicate", and cannot be commented on from outside. +/// +/// +/// marketing (lead: marketing-lead) +/// └── amazon (lead: amazon-lead) · ads +/// accounting (lead: accounting-lead) · bookkeeper +/// ceo on the top level +/// +/// +public sealed class UnitBoardTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + private readonly TaskboardTool _tool = new(); + + private readonly List _agents = + [ + new() { AgentId = "ceo" }, + new() { AgentId = "marketing-lead", UnitId = "marketing" }, + new() { AgentId = "amazon-lead", UnitId = "amazon" }, + new() { AgentId = "ads", UnitId = "amazon" }, + new() { AgentId = "accounting-lead", UnitId = "accounting" }, + new() { AgentId = "bookkeeper", UnitId = "accounting" } + ]; + + private readonly List _units = + [ + new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, + new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" }, + new() { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" } + ]; + + public UnitBoardTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + } + + public void Dispose() + { + SqliteStorage.ReleasePooledConnections(_directory); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleanup is secondary */ } + } + + private string TasksDir => Path.Combine(_directory, "SharedWorkspace", "tasks"); + + private async Task Run(string agentId, object input, bool withUnits = true) + { + var context = new AgentToolContext( + agentId, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, + CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo) + { + Organisation = withUnits ? new OrgChart(_units, _agents) : new OrgChart([], _agents) + }; + return await _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None); + } + + private async Task Create(string agentId, string title, string assignee = "@human") + { + var result = await Run(agentId, new { action = "task_create", title, assignee, force = true }); + result.Success.ShouldBeTrue(result.ErrorMessage); + return result.Content.Split(' ', 4)[2]; // "Task created: — …" + } + + private async Task List(string agentId, bool withUnits = true) + => (await Run(agentId, new { action = "task_list" }, withUnits)).Content; + + [Fact] + public async Task Each_unit_sees_its_own_board() + { + await Create("bookkeeper", "Book the invoices of September"); + await Create("ads", "Raise the bid for chairs"); + + var marketing = await List("ads"); + var accounting = await List("bookkeeper"); + + marketing.ShouldContain("Raise the bid"); + marketing.ShouldNotContain("invoices"); + accounting.ShouldContain("invoices"); + accounting.ShouldNotContain("Raise the bid"); + } + + [Fact] + public async Task A_lead_also_sees_the_boards_of_the_units_below() + { + await Create("ads", "Raise the bid for chairs"); + await Create("bookkeeper", "Book the invoices of September"); + + var lead = await List("marketing-lead"); + + lead.ShouldContain("Raise the bid", Case.Sensitive, "amazon lies below marketing"); + lead.ShouldNotContain("invoices"); + } + + [Fact] + public async Task A_task_handed_up_stays_in_view_of_whoever_handed_it_up() + { + // amazon-lead's lead is marketing-lead — the task lands on marketing's board … + await Create("amazon-lead", "Approve the new budget", "@marketing-lead"); + + (await List("marketing-lead")).ShouldContain("Approve the new budget"); + // … and the one who created it still sees it, although amazon's board does not hold it. + (await List("amazon-lead")).ShouldContain("Approve the new budget"); + (await List("ads")).ShouldNotContain("Approve the new budget"); + } + + [Fact] + public async Task A_similar_task_on_another_board_is_no_duplicate() + { + await Create("bookkeeper", "Check the supplier invoice"); + + var result = await Run("ads", new { action = "task_create", title = "Check the supplier invoice" }); + + result.Success.ShouldBeTrue(result.ErrorMessage); + result.Content.ShouldNotContain("Observed again"); + (await Run("bookkeeper", new { action = "task_find", title = "Check the supplier invoice" })).Content + .ShouldContain("1 open task(s)", Case.Sensitive, "and nothing was written onto the bookkeeper's task"); + } + + [Fact] + public async Task A_task_on_another_board_cannot_be_changed_or_commented_on() + { + var id = await Create("bookkeeper", "Book the invoices of September"); + + var update = await Run("ads", new { action = "task_update", id, status = "canceled" }); + var comment = await Run("ads", new { action = "task_comment", id, text = "not needed" }); + var unknown = await Run("ads", new { action = "task_comment", id = "t-does-not-exist", text = "x" }); + + update.Success.ShouldBeFalse(); + comment.Success.ShouldBeFalse(); + comment.ErrorMessage!.Replace(id, "X").ShouldBe(unknown.ErrorMessage!.Replace("t-does-not-exist", "X"), + "the same answer as for a task that does not exist"); + (await _repo.GetAsync(id, default))!.Status.ShouldBe(TaskItemStatus.Todo); + } + + [Fact] + public async Task An_open_task_moves_with_its_agent() + { + await Create("amazon-lead", "Prepare the listing", "@ads"); + (await List("bookkeeper")).ShouldNotContain("Prepare the listing"); + + _agents.Single(a => a.AgentId == "ads").UnitId = "accounting"; + + (await List("bookkeeper")).ShouldContain("Prepare the listing", Case.Sensitive, "the board follows the assignee"); + } + + [Fact] + public async Task Without_units_there_is_one_board_for_everyone() + { + await Create("bookkeeper", "Book the invoices of September"); + + (await List("ads", withUnits: false)).ShouldContain("invoices"); + } + + [Fact] + public async Task The_creator_is_kept_in_the_file_and_survives_a_re_import() + { + var id = await Create("ads", "Raise the bid for chairs"); + var file = Directory.GetFiles(TasksDir).Single(); + File.ReadAllText(file).ShouldContain("created_by: ads"); + + // A file written by hand without the field keeps what the database knows. + File.WriteAllText(file, File.ReadAllText(file).Replace("created_by: ads\n", "").Replace("created_by: ads\r\n", "")); + await new TaskboardService(_repo, TasksDir).ImportAllAsync(default); + + (await _repo.GetAsync(id, default))!.CreatedBy.ShouldBe("ads"); + } +}