Ablage je Einheit, Stufe 1: benannte Arbeitsbereiche mit Mitgliedern (#684)

- WorkspaceConfig in der Instanzkonfiguration: Id, Name, Zweck,
  Eigentuemer-Einheit, Mitglieder (Einheit oder Agent, Stufe Read/Write/
  Manage, Untereinheiten ein/aus), geschuetzte Pfade. Das Recht steht am
  Bereich, nicht am Agenten.
- OrgChart: AccessTo, WorkspacesFor, UnitWorkspace; Problems nennt
  ungueltige Ids und tote Verweise. WorkspaceLayout: Bereiche liegen unter
  Agents/SharedWorkspaces/<id>/, neben dem unveraenderten SharedWorkspace.
- FileRW: workspace ist ein Name - personal, shared (wie bisher ueber
  sharedAccessLevel), unit (eigene Einheit) oder eine Bereichs-Id. Ein
  unbekannter und ein nicht erlaubter Bereich bekommen dieselbe Antwort.
  Loeschen braucht Manage wie frueher Admin. Der #662-Hinweis sucht in allen
  erlaubten Bereichen.
- Mit Einheiten ist shared:/tasks/ fuer FileRW gesperrt: Aufgaben laufen
  ueber das Brett, sonst umginge eine direkt geschriebene Datei Brett- und
  Adressbuchpruefung. Ohne Einheiten unveraendert.
- Organisationsabschnitt im Prompt nennt die Bereiche mit Stufe und Zweck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 16:52:03 +02:00
co-authored by Claude Opus 5.5
parent ba5fe4cdf7
commit ff20ce2563
8 changed files with 901 additions and 139 deletions
@@ -0,0 +1,133 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #684: Who may use which workspace. The right stands at the workspace — a member list of
/// units and agents with a level — and the tree decides what a unit entry covers.
/// </summary>
public sealed class WorkspaceAccessTests
{
private static readonly UnitConfig[] Units =
[
new() { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "head" },
new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" },
new() { Id = "marketing", Name = "Marketing" }
];
private static readonly AgentConfig[] Agents =
[
new() { AgentId = "head", UnitId = "purchasing" },
new() { AgentId = "scout", UnitId = "suppliers" },
new() { AgentId = "ads", UnitId = "marketing" },
new() { AgentId = "ceo" }
];
private static OrgChart Chart(params WorkspaceConfig[] workspaces) => new(Units, Agents, workspaces);
private static WorkspaceConfig Workspace(string id, string? owner, params WorkspaceMember[] members)
=> new() { Id = id, Name = id, OwnerUnitId = owner, Members = [.. members] };
[Fact]
public void A_unit_entry_covers_the_units_below()
{
var org = Chart(Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write }));
org.AccessTo("head", "purchasing").ShouldBe(WorkspaceAccess.Write);
org.AccessTo("scout", "purchasing").ShouldBe(WorkspaceAccess.Write);
org.AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.None);
org.AccessTo("ceo", "purchasing").ShouldBe(WorkspaceAccess.None, "the top level is not a member of every unit");
}
[Fact]
public void Without_sub_units_the_entry_covers_the_unit_alone()
{
var org = Chart(Workspace("round", null,
new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read, IncludeSubUnits = false }));
org.AccessTo("head", "round").ShouldBe(WorkspaceAccess.Read);
org.AccessTo("scout", "round").ShouldBe(WorkspaceAccess.None);
}
[Fact]
public void The_highest_level_of_all_matching_entries_counts()
{
var org = Chart(Workspace("articles", "purchasing",
new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read },
new WorkspaceMember { AgentId = "scout", Access = WorkspaceAccess.Manage }));
org.AccessTo("scout", "articles").ShouldBe(WorkspaceAccess.Manage);
org.AccessTo("head", "articles").ShouldBe(WorkspaceAccess.Read);
}
[Fact]
public void An_agent_on_the_top_level_can_be_named_directly()
=> Chart(Workspace("board", null, new WorkspaceMember { AgentId = "ceo", Access = WorkspaceAccess.Write }))
.AccessTo("ceo", "board").ShouldBe(WorkspaceAccess.Write);
[Fact]
public void The_own_unit_s_workspace_comes_first_and_only_visible_ones_are_listed()
{
var org = Chart(
Workspace("articles", "purchasing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }),
Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }),
Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write }));
var grants = org.WorkspacesFor("ads");
grants.Select(g => g.Workspace.Id).ShouldBe(["marketing", "articles"]);
grants[0].IsOwnUnit.ShouldBeTrue();
org.UnitWorkspace("marketing")!.Id.ShouldBe("marketing");
}
[Fact]
public void A_workspace_carrying_a_unit_s_id_but_owned_by_another_is_not_that_unit_s()
=> Chart(Workspace("marketing", "purchasing")).UnitWorkspace("marketing").ShouldBeNull();
[Fact]
public void Broken_references_and_reserved_names_are_named()
{
var problems = Chart(
Workspace("shared", null),
Workspace("orphan", "gone", new WorkspaceMember { UnitId = "nowhere" }, new WorkspaceMember { AgentId = "ghost" })).Problems();
problems.ShouldContain(p => p.Contains("'shared' has an invalid id"));
problems.ShouldContain(p => p.Contains("'gone'"));
problems.ShouldContain(p => p.Contains("'nowhere'"));
problems.ShouldContain(p => p.Contains("'ghost'"));
}
[Fact]
public void Workspaces_live_next_to_the_one_shared_folder()
{
var shared = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace");
WorkspaceLayout.PathOf(shared, "purchasing")
.ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"));
WorkspaceLayout.PathOf(shared + Path.DirectorySeparatorChar, "purchasing")
.ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"), "a trailing separator changes nothing");
WorkspaceLayout.PathOf(shared, "../escape").ShouldBeNull();
WorkspaceLayout.PathOf(null, "purchasing").ShouldBeNull();
}
[Fact]
public void The_briefing_names_the_workspaces_and_what_the_agent_may_do_there()
{
var org = Chart(
Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }),
new WorkspaceConfig
{
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing", Purpose = "One file per article.",
Members = [new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }]
});
var section = OrgBriefing.For(org, "ads", DateTime.UtcNow)!;
section.ShouldContain("\"unit\" — your unit's workspace (also \"marketing\"), read and write");
section.ShouldContain("\"articles\" (Article master), read only — One file per article.");
OrgBriefing.For(org, "scout", DateTime.UtcNow)!.ShouldNotContain("workspaces", Case.Insensitive,
"the scout is in none of them — and must not learn that they exist");
}
}