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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 17:03:39 +02:00
co-authored by Claude Opus 5.5
parent 609298dd7a
commit 29d953a5eb
17 changed files with 458 additions and 30 deletions
@@ -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 = """
@@ -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<TaskItem?> 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)
};
}
+78
View File
@@ -0,0 +1,78 @@
using ClawdDotNet.Core.Organisation;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// #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.
///
/// <para><b>The board of a task is not stored, it follows from the organisation:</b> 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.</para>
///
/// <para><b>Who sees which board:</b> 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.</para>
///
/// <para><b>Without units nothing changes:</b> there is one board, and everyone sees it.</para>
/// </summary>
public static class TaskBoard
{
/// <summary>The unit whose board the task is on; <c>null</c> for the top level.</summary>
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;
}
/// <summary>
/// What an agent may see — <c>null</c> for "everything": without units, without an
/// organisation (outside the engine), or for an agent the organisation does not know.
/// </summary>
public static Func<TaskItem, bool>? 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);
}
/// <summary>The units whose boards an agent sees: its own, and all below the unit it leads.</summary>
public static IReadOnlySet<string> BoardsOf(OrgChart org, string agentId)
{
var boards = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (org.UnitOf(agentId) is { } own)
boards.Add(own.Id);
if (org.LedBy(agentId) is { } led)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pending = new Stack<string>();
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;
}
}
@@ -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)
{
+27
View File
@@ -228,6 +228,13 @@ public sealed record TaskItem
/// </summary>
public bool CatchUpPending { get; init; }
/// <summary>
/// #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 <see cref="TaskBoard"/>.
/// </summary>
public string CreatedBy { get; init; } = "";
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
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;
/// <summary>
/// #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.
/// </summary>
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;
}
}
+22 -1
View File
@@ -31,7 +31,28 @@ public sealed class TaskboardService
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
=> _repo.ListAsync(query, ct);
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
/// <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);