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:
co-authored by
Claude Opus 5.5
parent
ba5fe4cdf7
commit
ff20ce2563
@@ -179,6 +179,14 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("units")]
|
||||
public List<UnitConfig> Units { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// #684: Shared workspaces with member lists — one per unit, plus those shared across
|
||||
/// units on purpose. The instance's one shared folder is not listed here; it stays the
|
||||
/// workspace <c>shared</c>, governed by each agent's level as before.
|
||||
/// </summary>
|
||||
[JsonPropertyName("workspaces")]
|
||||
public List<WorkspaceConfig> Workspaces { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("services")]
|
||||
public List<ServiceConfig> Services { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ClawdDotNet.Core.Config;
|
||||
|
||||
/// <summary>
|
||||
/// #684: A shared workspace — a folder several agents work in, with a named owner and a list
|
||||
/// of who may use it how.
|
||||
///
|
||||
/// <para><b>The right stands at the workspace, not at the agent</b> (Organisation-Konzept,
|
||||
/// section 3). Before, every agent carried one <c>sharedAccessLevel</c> for the one shared
|
||||
/// folder of the instance, and "accounting may write its files, marketing may only read
|
||||
/// them" could not be said. Here each workspace lists its members; whoever is not on the
|
||||
/// list does not see it — not even that it exists.</para>
|
||||
///
|
||||
/// <para><b>The one shared folder stays what it was.</b> The folder <c>SharedWorkspace</c>
|
||||
/// with the per-agent level keeps working as the workspace <c>shared</c>, so every running
|
||||
/// installation and every task that names it goes on as before. Workspaces configured here
|
||||
/// live next to it, under <c>SharedWorkspaces/<id>/</c>. There is never a second
|
||||
/// value for the same thing: <c>shared</c> is governed only by the agent's level, every
|
||||
/// workspace here only by its member list.</para>
|
||||
/// </summary>
|
||||
public sealed class WorkspaceConfig
|
||||
{
|
||||
/// <summary>The name agents use and the folder name; lower case, digits and dashes.</summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>What goes in here — shown to the members so they file things in the right place.</summary>
|
||||
[JsonPropertyName("purpose")]
|
||||
public string Purpose { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// The unit the workspace belongs to — what is filed here is its property, and stays
|
||||
/// when an agent moves on. <c>null</c>: owned by the instance (the human).
|
||||
/// </summary>
|
||||
[JsonPropertyName("ownerUnitId")]
|
||||
public string? OwnerUnitId { get; set; }
|
||||
|
||||
[JsonPropertyName("members")]
|
||||
public List<WorkspaceMember> Members { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Folders in which files can only be added, not changed or deleted — except by members
|
||||
/// with <see cref="WorkspaceAccess.Manage"/>. Relative to the workspace.
|
||||
/// </summary>
|
||||
[JsonPropertyName("protectedPaths")]
|
||||
public List<string> ProtectedPaths { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #684: One entry on a workspace's member list — a unit or a single agent, with a level.
|
||||
/// A unit entry includes the units below it unless <see cref="IncludeSubUnits"/> is off;
|
||||
/// a unit's own workspace is meant for the whole department.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceMember
|
||||
{
|
||||
[JsonPropertyName("unitId")]
|
||||
public string? UnitId { get; set; }
|
||||
|
||||
[JsonPropertyName("agentId")]
|
||||
public string? AgentId { get; set; }
|
||||
|
||||
[JsonPropertyName("access")]
|
||||
public WorkspaceAccess Access { get; set; } = WorkspaceAccess.Read;
|
||||
|
||||
[JsonPropertyName("includeSubUnits")]
|
||||
public bool IncludeSubUnits { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #684: What a member may do in a workspace. Each level includes the ones below it.
|
||||
/// Deleting and changing protected files needs <see cref="Manage"/> — as the old
|
||||
/// <c>Admin</c> level did for the one shared folder.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<WorkspaceAccess>))]
|
||||
public enum WorkspaceAccess
|
||||
{
|
||||
None = 0,
|
||||
Read = 1,
|
||||
Write = 2,
|
||||
Manage = 3
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user