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:
co-authored by
Claude Opus 5.5
parent
609298dd7a
commit
29d953a5eb
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
///
|
||||
/// <code>
|
||||
/// marketing (lead: marketing-lead)
|
||||
/// └── amazon (lead: amazon-lead) · ads
|
||||
/// accounting (lead: accounting-lead) · bookkeeper
|
||||
/// ceo on the top level
|
||||
/// </code>
|
||||
/// </summary>
|
||||
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<AgentConfig> _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<UnitConfig> _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<ToolResult> Run(string agentId, object input, bool withUnits = true)
|
||||
{
|
||||
var context = new AgentToolContext(
|
||||
agentId, "inst", new Dictionary<string, object?>(), 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<string> 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: <id> — …"
|
||||
}
|
||||
|
||||
private async Task<string> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user