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
@@ -98,10 +98,42 @@ public static class OrgBriefing
sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}");
}
AppendWorkspaces(sb, org, agentId);
sb.Append("Agents not named here cannot be reached from your position — go through your lead.");
return sb.ToString();
}
/// <summary>
/// #684: The shared workspaces the agent may use. FileRW's own description cannot name
/// them — it is the same for every agent — so they are listed here, where the agent's
/// position is described anyway, and change with it when it moves.
/// </summary>
private static void AppendWorkspaces(StringBuilder sb, OrgChart org, string agentId)
{
var grants = org.WorkspacesFor(agentId);
if (grants.Count == 0)
return;
sb.AppendLine("Your shared workspaces (FileRW, parameter \"workspace\"):");
foreach (var grant in grants)
{
var workspace = grant.Workspace;
var name = grant.IsOwnUnit
? $"\"{WorkspaceLayout.Unit}\" — your unit's workspace (also \"{workspace.Id}\")"
: $"\"{workspace.Id}\"" + (string.IsNullOrWhiteSpace(workspace.Name) || workspace.Name == workspace.Id ? "" : $" ({workspace.Name})");
var access = grant.Access switch
{
Config.WorkspaceAccess.Read => "read only",
Config.WorkspaceAccess.Write => "read and write",
_ => "read, write and delete"
};
sb.AppendLine($"- {name}, {access}{Describe(workspace.Purpose)}");
}
if (grants.Any(g => g.IsOwnUnit))
sb.AppendLine("What your unit works on belongs in \"unit\", where your colleagues find it; \"personal\" is yours alone.");
}
/// <summary>
/// Replaces the organisation section of a system prompt, appends it, or removes it —
/// leaving everything before it untouched. Existing chat contexts keep their system
+79 -2
View File
@@ -24,12 +24,17 @@ public sealed class OrgChart
private readonly Dictionary<string, AgentConfig> _agents;
private readonly Dictionary<string, List<UnitConfig>> _children;
private readonly Dictionary<string, List<AgentConfig>> _members;
private readonly Dictionary<string, WorkspaceConfig> _workspaces;
/// <summary>The key under which top-level units and agents without a unit are grouped.</summary>
private const string Root = "";
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents)
public OrgChart(IEnumerable<UnitConfig> units, IEnumerable<AgentConfig> agents, IEnumerable<WorkspaceConfig>? workspaces = null)
{
_workspaces = new Dictionary<string, WorkspaceConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var workspace in workspaces ?? [])
_workspaces.TryAdd(workspace.Id, workspace);
_units = new Dictionary<string, UnitConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var unit in units)
_units.TryAdd(unit.Id, unit);
@@ -57,7 +62,7 @@ public sealed class OrgChart
}
}
public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents);
public static OrgChart From(InstanceConfig instance) => new(instance.Units, instance.Agents, instance.Workspaces);
/// <summary>Whether the instance has any structure at all.</summary>
public bool HasUnits => _units.Count > 0;
@@ -241,6 +246,63 @@ public sealed class OrgChart
return ceiling;
}
// ─── workspaces (#684) ───
public IReadOnlyCollection<WorkspaceConfig> Workspaces => _workspaces.Values;
public WorkspaceConfig? Workspace(string? workspaceId)
=> workspaceId is not null && _workspaces.TryGetValue(workspaceId, out var workspace) ? workspace : null;
/// <summary>
/// A unit's own workspace: the one owned by the unit that carries its id. Created with
/// the unit; an agent reaches it as <c>unit</c> without knowing the id.
/// </summary>
public WorkspaceConfig? UnitWorkspace(string? unitId)
=> Workspace(unitId) is { } workspace
&& string.Equals(workspace.OwnerUnitId, unitId, StringComparison.OrdinalIgnoreCase)
? workspace
: null;
/// <summary>
/// How far an agent may use a workspace — the highest level among the entries that name
/// it: the agent itself, its unit, or a unit above it whose entry includes sub-units.
/// </summary>
public WorkspaceAccess AccessTo(string agentId, string workspaceId)
{
if (Workspace(workspaceId) is not { } workspace || Agent(agentId) is null)
return WorkspaceAccess.None;
var unitId = UnitOf(agentId)?.Id;
var above = unitId is null
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: Ancestors(unitId).Select(u => u.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
var access = WorkspaceAccess.None;
foreach (var member in workspace.Members)
{
var matches =
(member.AgentId is { Length: > 0 } a && string.Equals(a, agentId, StringComparison.OrdinalIgnoreCase))
|| (member.UnitId is { Length: > 0 } u && unitId is not null
&& (string.Equals(u, unitId, StringComparison.OrdinalIgnoreCase)
|| (member.IncludeSubUnits && above.Contains(u))));
if (matches && member.Access > access)
access = member.Access;
}
return access;
}
/// <summary>Every workspace the agent may use, with its level — its own unit's first.</summary>
public IReadOnlyList<WorkspaceGrant> WorkspacesFor(string agentId)
{
var own = UnitWorkspace(UnitOf(agentId)?.Id);
return _workspaces.Values
.Select(w => new WorkspaceGrant(w, AccessTo(agentId, w.Id), ReferenceEquals(w, own)))
.Where(g => g.Access > WorkspaceAccess.None)
.OrderByDescending(g => g.IsOwnUnit)
.ThenBy(g => g.Workspace.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// What is wrong with the structure — empty when it is sound. Checked when the
/// administration writes and when an instance starts; the tree is still usable with
@@ -264,6 +326,21 @@ public sealed class OrgChart
: $"Unit '{unit.Id}' names the lead '{leaderId}', which does not exist.");
}
foreach (var workspace in _workspaces.Values)
{
if (!WorkspaceLayout.IsValidId(workspace.Id))
problems.Add($"Workspace '{workspace.Id}' has an invalid id — lower case letters, digits and dashes, not one of {string.Join(", ", WorkspaceLayout.Reserved)}.");
if (workspace.OwnerUnitId is { Length: > 0 } owner && !_units.ContainsKey(owner))
problems.Add($"Workspace '{workspace.Id}' belongs to the unit '{owner}', which does not exist.");
foreach (var member in workspace.Members)
{
if (member.UnitId is { Length: > 0 } u && !_units.ContainsKey(u))
problems.Add($"Workspace '{workspace.Id}' names the unit '{u}' as member, which does not exist.");
if (member.AgentId is { Length: > 0 } a && !_agents.ContainsKey(a))
problems.Add($"Workspace '{workspace.Id}' names the agent '{a}' as member, which does not exist.");
}
}
foreach (var agent in _agents.Values)
{
if (agent.UnitId is { Length: > 0 } unitId && !_units.ContainsKey(unitId))
@@ -0,0 +1,56 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Organisation;
/// <summary>#684: A workspace an agent may use, and how far.</summary>
/// <param name="IsOwnUnit">The workspace of the agent's own unit — reachable as <c>unit</c>.</param>
public sealed record WorkspaceGrant(WorkspaceConfig Workspace, WorkspaceAccess Access, bool IsOwnUnit);
/// <summary>
/// #684: Where the workspaces live on disk, and which names are taken.
///
/// <para>The instance's one shared folder is <c>Agents/SharedWorkspace</c>, and it stays
/// there: FTP, RocketChat, the agent editor, the task files and the backups all find it by
/// that path. The workspaces with member lists live beside it, one folder each under
/// <c>Agents/SharedWorkspaces/</c>. Found from the shared folder's path, which every tool
/// context already carries, so nothing new has to be passed around.</para>
/// </summary>
public static class WorkspaceLayout
{
public const string Personal = "personal";
public const string Shared = "shared";
/// <summary>The own unit's workspace, without having to know the unit's id.</summary>
public const string Unit = "unit";
/// <summary>The folder next to <c>SharedWorkspace</c> that holds the workspaces.</summary>
public const string ContainerFolder = "SharedWorkspaces";
/// <summary>Names a workspace cannot have — they mean something else in FileRW.</summary>
public static readonly IReadOnlyList<string> Reserved = [Personal, Shared, Unit];
/// <summary>Letters, digits, '-' and '_' — the same rule as for unit ids, since a unit's
/// workspace carries the unit's id — and not one of the reserved names.</summary>
public static bool IsValidId(string? id)
=> !string.IsNullOrWhiteSpace(id)
&& id.Length <= 64
&& id.All(c => char.IsLetterOrDigit(c) || c is '-' or '_')
&& !Reserved.Contains(id, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// The folder of a workspace, from the path of the instance's shared folder;
/// <c>null</c> when that is not configured or the id is not valid.
/// </summary>
public static string? PathOf(string? sharedWorkspacePath, string workspaceId)
{
if (string.IsNullOrWhiteSpace(sharedWorkspacePath) || !IsValidId(workspaceId))
return null;
var shared = Path.GetFullPath(sharedWorkspacePath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var agentsDirectory = Path.GetDirectoryName(shared);
return agentsDirectory is null
? null
: Path.Combine(agentsDirectory, ContainerFolder, workspaceId);
}
}