Ablage je Einheit, Stufe 2: Verwaltung und Organisationsseite (#684)

- OrgAdministration: eine neue Einheit bekommt ihren Bereich (Mitglieder:
  die Einheit samt Untereinheiten, schreiben); reservierte Namen und belegte
  Bereichs-Ids werden als Einheiten-Id abgelehnt. Uebergreifende Bereiche
  anlegen (mit Eigentuemer), Name/Zweck/geschuetzte Ordner aendern,
  Mitgliederliste ersetzen, nur leere Bereiche loeschen, nie den eigenen
  einer Einheit. Eine geloeschte Einheit verlaesst alle Mitgliederlisten;
  ihr Bereich geht nur, wenn er leer ist, sonst bleibt er ohne Eigentuemer.
  Nachruesten des eigenen Bereichs fuer Einheiten von vor #684. Alles im
  AuditLog.
- API unter /api/organisation/workspaces; die Uebersicht liefert je Bereich
  Mitglieder, Dateizahl und - von der Instanz berechnet - wer hineinkommt.
- Organisationsseite: Bereiche unter der Struktur, Bearbeitung mit
  Mitglieder-Editor, "Wer hineinkommt", Bereichskarte an der Einheit,
  Bereiche am Agenten. Sprachschluessel en/de.
- Die Nachschau nach einem Absturz bekommt die Organisation mit, sonst
  fände sie einen Aufruf in einem benannten Bereich nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 16:58:09 +02:00
co-authored by Claude Opus 5.5
parent ff20ce2563
commit 609298dd7a
9 changed files with 850 additions and 10 deletions
+5 -1
View File
@@ -690,7 +690,11 @@ public sealed class AppHost : IAsyncDisposable
loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Probe"),
CancellationToken.None,
agent.WorkspacePath,
agent.SharedWorkspacePath);
agent.SharedWorkspacePath)
{
// #684: a call in a named workspace is only found with the organisation.
Organisation = Core.Organisation.OrgChart.From(instance)
};
}
var recovery = new CrashRecovery(audit, tools, loggerFactory, KontextFuer);
@@ -34,6 +34,9 @@ public sealed record UnitSettingChange(
bool Outlier,
bool SelectedByDefault);
/// <summary>#684: One entry of a workspace's member list, as the administration takes it.</summary>
public sealed record WorkspaceMemberInput(string? UnitId, string? AgentId, WorkspaceAccess Access, bool IncludeSubUnits = true);
/// <summary>What a move or a lowered ceiling would do before it is done.</summary>
public sealed record StructurePreview(
IReadOnlyList<string> AffectedAgents,
@@ -86,6 +89,10 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
return AdminResult.Failure("A unit needs a name.");
if (Chart.Unit(id) is not null)
return AdminResult.Failure($"There is already a unit '{id}'.");
if (!WorkspaceLayout.IsValidId(id))
return AdminResult.Failure($"'{id}' is reserved — a unit's workspace carries its id, and '{id}' already means something in FileRW.");
if (Chart.Workspace(id) is not null)
return AdminResult.Failure($"There is already a workspace '{id}'. A unit's own workspace carries the unit's id — choose another id.");
if (!string.IsNullOrWhiteSpace(parentId) && Chart.Unit(parentId) is null)
return AdminResult.Failure($"There is no unit '{parentId}' to put it under.");
@@ -97,8 +104,11 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
Goal = goal?.Trim() ?? ""
});
// #684: every unit gets its own workspace — the normal case, it costs no decision.
host.Instance.Workspaces.Add(OwnWorkspace(id, name.Trim()));
Save(null, "unit_create", new { id, name, parentId, goal }, reason);
return AdminResult.Success($"Unit '{name.Trim()}' created.");
return AdminResult.Success($"Unit '{name.Trim()}' created, with its workspace '{id}'.");
}
public AdminResult UpdateUnit(string unitId, string? name, string? goal, string reason = "")
@@ -129,8 +139,28 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
return AdminResult.Failure($"Unit '{unit.Name}' still has members or sub-units. Move them first.");
host.Instance.Units.Remove(unit);
Save(null, "unit_delete", new { unitId }, reason);
return AdminResult.Success($"Unit '{unit.Name}' deleted.");
// #684: what was filed belongs to the unit, and the unit is gone — so nothing is
// deleted here. Its entries leave the member lists; its own workspace goes only if
// it is empty, otherwise it stays without owner for a human to decide.
var kept = new List<string>();
foreach (var workspace in host.Instance.Workspaces.ToList())
{
workspace.Members.RemoveAll(m => Same(m.UnitId, unit.Id));
if (!Same(workspace.OwnerUnitId, unit.Id))
continue;
workspace.OwnerUnitId = null;
if (Same(workspace.Id, unit.Id) && !HasFiles(workspace.Id))
host.Instance.Workspaces.Remove(workspace);
else
kept.Add(workspace.Id);
}
Save(null, "unit_delete", new { unitId, workspacesKept = kept }, reason);
return AdminResult.Success(kept.Count == 0
? $"Unit '{unit.Name}' deleted."
: $"Unit '{unit.Name}' deleted. Its workspace(s) {string.Join(", ", kept.Select(k => $"'{k}'"))} still hold files and remain, without owner.");
}
/// <summary>The lead must be a member of the unit. <c>null</c> leaves it without a lead.</summary>
@@ -549,6 +579,177 @@ public sealed class OrgAdministration(AppHost host, ILogger logger)
}
}
// ─── workspaces (#684) ───
/// <summary>
/// A workspace shared across units on purpose — the article master that purchasing owns
/// and marketing reads. It needs a named owner or none (then it belongs to the human),
/// and its id must not be a unit's: those are kept for the units' own workspaces.
/// </summary>
public AdminResult CreateWorkspace(
string id, string name, string? purpose, string? ownerUnitId,
IReadOnlyList<WorkspaceMemberInput> members, string reason = "")
{
id = (id ?? "").Trim();
if (!WorkspaceLayout.IsValidId(id))
return AdminResult.Failure($"A workspace id consists of letters, digits, '-' and '_', and is none of {string.Join(", ", WorkspaceLayout.Reserved)}.");
if (string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A workspace needs a name.");
if (Chart.Workspace(id) is not null)
return AdminResult.Failure($"There is already a workspace '{id}'.");
if (Chart.Unit(id) is not null)
return AdminResult.Failure($"'{id}' is the id of a unit — its own workspace carries it. Choose another id.");
if (!string.IsNullOrWhiteSpace(ownerUnitId) && FindUnit(ownerUnitId) is null)
return AdminResult.Failure($"There is no unit '{ownerUnitId}' to own it.");
if (CheckMembers(members) is { } problem)
return AdminResult.Failure(problem);
host.Instance.Workspaces.Add(new WorkspaceConfig
{
Id = id,
Name = name.Trim(),
Purpose = purpose?.Trim() ?? "",
OwnerUnitId = string.IsNullOrWhiteSpace(ownerUnitId) ? null : FindUnit(ownerUnitId)!.Id,
Members = members.Select(ToMember).ToList()
});
Save(null, "workspace_create", new { id, name, ownerUnitId, members }, reason);
return AdminResult.Success($"Workspace '{name.Trim()}' created.");
}
/// <summary>Name, purpose and protected folders. <c>null</c> leaves a value as it is.</summary>
public AdminResult UpdateWorkspace(
string workspaceId, string? name, string? purpose, IReadOnlyList<string>? protectedPaths, string reason = "")
{
if (FindWorkspace(workspaceId) is not { } workspace)
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
if (name is not null && string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A workspace needs a name.");
var paths = protectedPaths?
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
.Where(p => p.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (paths?.FirstOrDefault(p => p.Split('/').Any(part => part is "." or "..")) is { } bad)
return AdminResult.Failure($"'{bad}' is not a folder inside the workspace.");
if (name is not null) workspace.Name = name.Trim();
if (purpose is not null) workspace.Purpose = purpose.Trim();
if (paths is not null) workspace.ProtectedPaths = paths;
Save(null, "workspace_update", new { workspaceId, name, purpose, protectedPaths = paths }, reason);
return AdminResult.Success($"Workspace '{workspace.Name}' saved.");
}
/// <summary>
/// Replaces the member list. The right stands here, at the workspace — whoever is not on
/// the list does not see it. Works for a unit's own workspace too: widening it to a
/// reading neighbour is exactly the kind of decision that belongs to a human.
/// </summary>
public AdminResult SetWorkspaceMembers(string workspaceId, IReadOnlyList<WorkspaceMemberInput> members, string reason = "")
{
if (FindWorkspace(workspaceId) is not { } workspace)
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
if (CheckMembers(members) is { } problem)
return AdminResult.Failure(problem);
var before = workspace.Members.Select(m => new { m.UnitId, m.AgentId, m.Access, m.IncludeSubUnits }).ToList();
workspace.Members = members.Select(ToMember).ToList();
Save(null, "workspace_members", new { workspaceId, before, after = members }, reason);
return AdminResult.Success($"Members of '{workspace.Name}' saved.");
}
/// <summary>
/// Only an empty workspace can be deleted: nothing filed is ever thrown away from here.
/// A unit's own workspace goes with its unit, not on its own.
/// </summary>
public AdminResult DeleteWorkspace(string workspaceId, string reason = "")
{
if (FindWorkspace(workspaceId) is not { } workspace)
return AdminResult.Failure($"There is no workspace '{workspaceId}'.");
if (Chart.UnitWorkspace(workspace.OwnerUnitId) == workspace)
return AdminResult.Failure($"'{workspace.Id}' is the own workspace of unit '{workspace.OwnerUnitId}' — it goes when the unit is deleted.");
if (HasFiles(workspace.Id))
return AdminResult.Failure($"Workspace '{workspace.Name}' still holds files. Nothing filed is deleted from here — move or remove them first.");
host.Instance.Workspaces.Remove(workspace);
Save(null, "workspace_delete", new { workspaceId }, reason);
return AdminResult.Success($"Workspace '{workspace.Name}' deleted.");
}
/// <summary>
/// Gives a unit its own workspace if it has none — for units that existed before
/// workspaces did. Does nothing for a unit that has one.
/// </summary>
public AdminResult CreateUnitWorkspace(string unitId, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (Chart.UnitWorkspace(unit.Id) is not null)
return AdminResult.Success($"'{unit.Name}' already has its workspace.");
if (Chart.Workspace(unit.Id) is not null)
return AdminResult.Failure($"There is a workspace '{unit.Id}' that does not belong to the unit. Rename or delete it first.");
if (!WorkspaceLayout.IsValidId(unit.Id))
return AdminResult.Failure($"The unit id '{unit.Id}' cannot be a workspace id.");
host.Instance.Workspaces.Add(OwnWorkspace(unit.Id, unit.Name));
Save(null, "workspace_create", new { id = unit.Id, ownerUnitId = unit.Id }, reason);
return AdminResult.Success($"'{unit.Name}' has its workspace now.");
}
/// <summary>The folder of a workspace; <c>null</c> when the instance has no shared folder.</summary>
public string? WorkspaceFolder(string workspaceId)
=> WorkspaceLayout.PathOf(
host.Instance.Agents.Select(a => a.SharedWorkspacePath).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)),
workspaceId);
/// <summary>How many files a workspace holds, in all its folders.</summary>
public int FileCount(string workspaceId)
=> WorkspaceFolder(workspaceId) is { } folder && Directory.Exists(folder)
? Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Count()
: 0;
private bool HasFiles(string workspaceId) => FileCount(workspaceId) > 0;
private static WorkspaceConfig OwnWorkspace(string unitId, string unitName) => new()
{
Id = unitId,
Name = unitName,
OwnerUnitId = unitId,
Members = [new WorkspaceMember { UnitId = unitId, Access = WorkspaceAccess.Write, IncludeSubUnits = true }]
};
private string? CheckMembers(IReadOnlyList<WorkspaceMemberInput> members)
{
foreach (var member in members)
{
var byUnit = !string.IsNullOrWhiteSpace(member.UnitId);
var byAgent = !string.IsNullOrWhiteSpace(member.AgentId);
if (byUnit == byAgent)
return "Each member is either a unit or an agent.";
if (byUnit && FindUnit(member.UnitId) is null)
return $"There is no unit '{member.UnitId}'.";
if (byAgent && host.AgentAdmin.Find(member.AgentId!) is null)
return $"There is no agent '{member.AgentId}'.";
if (member.Access == WorkspaceAccess.None)
return "A member needs at least read access — leave it off the list instead.";
}
return null;
}
private WorkspaceMember ToMember(WorkspaceMemberInput input) => new()
{
UnitId = string.IsNullOrWhiteSpace(input.UnitId) ? null : FindUnit(input.UnitId)!.Id,
AgentId = string.IsNullOrWhiteSpace(input.AgentId) ? null : host.AgentAdmin.Find(input.AgentId!)!.AgentId,
Access = input.Access,
IncludeSubUnits = input.IncludeSubUnits
};
private WorkspaceConfig? FindWorkspace(string? workspaceId)
=> workspaceId is null ? null : host.Instance.Workspaces.FirstOrDefault(w => Same(w.Id, workspaceId));
private UnitConfig? FindUnit(string? unitId)
=> unitId is null ? null : host.Instance.Units.FirstOrDefault(u => Same(u.Id, unitId));
@@ -2298,6 +2298,25 @@ button.org-einheit .org-name {
font-weight: 600;
}
/* #684: workspaces below the structure */
.org-bereiche {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--divider);
}
.org-bereiche > p {
margin: 0 0 6px;
}
.org-mitglieder select {
width: 100%;
}
.org-mitglieder td {
vertical-align: middle;
}
.org-detail {
display: flex;
flex-direction: column;
@@ -127,6 +127,43 @@
"org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:",
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
"org.workspaces": "Arbeitsbereiche",
"org.workspacesHint": "Gemeinsame Ordner mit Mitgliederliste. Wer nicht auf der Liste steht, sieht den Bereich nicht.",
"org.noWorkspaces": "Noch keine Arbeitsbereiche — eine Einheit bekommt ihren beim Anlegen.",
"org.newWorkspace": "Neuer Arbeitsbereich",
"org.newWorkspaceHint": "Für etwas, das mehrere Einheiten bewusst teilen, etwa einen Artikelstamm. Die Eigentümer-Einheit verwaltet ihn; Leser kommen danach dazu.",
"org.workspaceId": "Id (Ordnername)",
"org.workspaceName": "Name",
"org.purpose": "Zweck",
"org.owner": "Eigentümer",
"org.noOwner": "— Instanz (Mensch) —",
"org.fileCount": "{n} Dateien",
"org.unitOwnWorkspace": "eigener Bereich der Einheit",
"org.unitWorkspace": "Arbeitsbereich",
"org.unitWorkspaceMissing": "Diese Einheit hat noch keinen eigenen Arbeitsbereich.",
"org.createUnitWorkspace": "Arbeitsbereich anlegen",
"org.editWorkspace": "Arbeitsbereich bearbeiten",
"org.protectedPaths": "Geschützte Ordner",
"org.protectedHint": "Durch Komma getrennt. Dort können Dateien hinzukommen, ändern oder löschen dürfen sie nur Verwalter des Bereichs.",
"org.memberEntries": "Mitgliederliste",
"org.membersHint": "Das Recht steht am Bereich. Ein Einheitseintrag gilt auch für die Einheiten darunter, wenn nicht abgeschaltet. Löschen braucht Verwalten.",
"org.noWorkspaceMembers": "niemand",
"org.memberKind": "Art",
"org.memberWho": "Wer",
"org.memberUnit": "Einheit",
"org.memberAgent": "Agent",
"org.accessLevel": "Stufe",
"org.access.Read": "lesen",
"org.access.Write": "schreiben",
"org.access.Manage": "verwalten",
"org.includeSubUnits": "inkl. Untereinheiten",
"org.addMember": "Mitglied hinzufügen",
"org.removeMember": "Mitglied entfernen",
"org.whoGetsIn": "Wer hineinkommt",
"org.nobodyHasAccess": "Niemand — nur ein Mensch kommt heran.",
"org.deleteWorkspace": "Arbeitsbereich löschen",
"org.deleteWorkspaceHint": "Nur ein leerer Bereich lässt sich löschen. Abgelegtes wird von hier aus nie gelöscht.",
"org.unitWorkspaceStays": "Der eigene Bereich einer Einheit geht mit der Einheit. Hält er dann noch Dateien, bleibt er ohne Eigentümer.",
"team.title": "Teamübersicht",
"team.subtitle": "Jede Einheit ist eine Karte, verbunden wie die Organisation. Eine Linie trägt die Farbe des dringlichsten Zustands darunter, eine rote Linie führt also zum Problem. Klick auf Karte oder Agent zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
@@ -127,6 +127,43 @@
"org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:",
"org.toolsLostConfirm": "Remove the tools and apply",
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
"org.workspaces": "Workspaces",
"org.workspacesHint": "Shared folders with a member list. Whoever is not on the list does not see the workspace.",
"org.noWorkspaces": "No workspaces yet — a unit gets one when it is created.",
"org.newWorkspace": "New workspace",
"org.newWorkspaceHint": "For something several units share on purpose, like an article master. The owning unit manages it; add readers afterwards.",
"org.workspaceId": "Id (folder name)",
"org.workspaceName": "Name",
"org.purpose": "Purpose",
"org.owner": "Owner",
"org.noOwner": "— instance (human) —",
"org.fileCount": "{n} files",
"org.unitOwnWorkspace": "the unit's own workspace",
"org.unitWorkspace": "Workspace",
"org.unitWorkspaceMissing": "This unit has no workspace of its own yet.",
"org.createUnitWorkspace": "Create its workspace",
"org.editWorkspace": "Edit workspace",
"org.protectedPaths": "Protected folders",
"org.protectedHint": "Comma separated. Files there can be added, but only members who manage the workspace change or delete them.",
"org.memberEntries": "Member list",
"org.membersHint": "The right stands at the workspace. A unit entry includes the units below it unless switched off. Deleting files needs Manage.",
"org.noWorkspaceMembers": "nobody",
"org.memberKind": "Kind",
"org.memberWho": "Who",
"org.memberUnit": "Unit",
"org.memberAgent": "Agent",
"org.accessLevel": "Level",
"org.access.Read": "read",
"org.access.Write": "write",
"org.access.Manage": "manage",
"org.includeSubUnits": "incl. sub-units",
"org.addMember": "Add member",
"org.removeMember": "Remove member",
"org.whoGetsIn": "Who gets in",
"org.nobodyHasAccess": "Nobody — only a human reaches it.",
"org.deleteWorkspace": "Delete workspace",
"org.deleteWorkspaceHint": "Only an empty workspace can be deleted. Nothing filed is deleted from here.",
"org.unitWorkspaceStays": "A unit's own workspace goes with its unit. If it still holds files then, it stays without owner.",
"team.title": "Team overview",
"team.subtitle": "Every unit is a card, connected as the organisation. A line takes the colour of the most urgent state below it, so a red line leads to the problem. Click a card or an agent for details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
@@ -6,6 +6,10 @@
//
// A list, not a graph: this is where the structure is edited. Watching it work is the team
// overview (#711).
//
// #684: Workspaces sit beside the units. A unit gets its own; a workspace across units is
// made on purpose, with an owner. The right stands at the workspace — its member list — and
// the page shows, computed by the instance, who that list actually lets in.
import { api } from "../modules/api.js";
import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js";
@@ -13,9 +17,10 @@ import { t } from "../modules/i18n.js";
const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"];
const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]);
const ACCESS = ["Read", "Write", "Manage"];
let data = null;
let selection = null; // { kind: "unit" | "agent", id }
let selection = null; // { kind: "unit" | "agent" | "workspace", id }
let treeBox = null;
let detailBox = null;
let reasonInput = null;
@@ -57,11 +62,35 @@ async function load() {
function find(sel) {
if (!sel || !data) return null;
if (sel.kind === "workspace") return data.workspaces.find((w) => w.id === sel.id);
return sel.kind === "unit"
? data.units.find((u) => u.id === sel.id)
: data.agents.find((a) => a.agentId === sel.id);
}
/** A unit's own workspace, if it has one. */
function ownWorkspace(unitId) {
return data.workspaces.find((w) => w.isUnitOwn && w.ownerUnitId === unitId);
}
function unitPath(unitId) {
return data.units.find((u) => u.id === unitId)?.path ?? unitId;
}
function accessLabel(access) {
return t(`org.access.${access}`);
}
/** "Purchasing (write, incl. sub-units); ads (read)". */
function membersSummary(workspace) {
if (!workspace.members.length) return t("org.noWorkspaceMembers");
return workspace.members.map((m) => {
const who = m.unitId ? unitPath(m.unitId) : m.agentId;
const sub = m.unitId && m.includeSubUnits ? `, ${t("org.includeSubUnits")}` : "";
return `${who} (${accessLabel(m.access)}${sub})`;
}).join("; ");
}
function childrenOf(unitId) {
return data.units.filter((u) => (u.parentId ?? null) === unitId);
}
@@ -180,7 +209,28 @@ function renderTree() {
treeBox.replaceChildren(
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
erstelleElement("h2", { text: t("org.structure") }), newUnit),
list);
list,
workspaceList());
}
function workspaceList() {
const rows = data.workspaces.map((w) => erstelleElement("button", {
type: "button",
klasse: `org-knoten org-bereich ${selection?.kind === "workspace" && selection.id === w.id ? "aktiv" : ""}`,
onclick: () => select({ kind: "workspace", id: w.id })
},
erstelleElement("span", { klasse: "org-name", text: `▤ ${w.name}` }),
erstelleElement("span", {
klasse: "gedaempft text-klein",
text: `${w.ownerUnitId ? unitPath(w.ownerUnitId) : t("org.noOwner")} · ${t("org.fileCount", { n: w.fileCount })}`
})));
return erstelleElement("div", { klasse: "org-bereiche" },
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
erstelleElement("h2", { text: t("org.workspaces") }),
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.newWorkspace"), onclick: () => openCreateWorkspace() })),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.workspacesHint") }),
...(rows.length ? rows : [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noWorkspaces") })]));
}
function unitBlock(unit, depth) {
@@ -261,7 +311,10 @@ function renderDetail() {
return;
}
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
const panels = selection.kind === "unit" ? unitPanel(item)
: selection.kind === "workspace" ? workspacePanel(item)
: agentPanel(item);
detailBox.replaceChildren(reasonCard, ...panels);
}
function unitPanel(unit) {
@@ -311,6 +364,7 @@ function unitPanel(unit) {
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
]));
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(settingsCard(unit));
@@ -456,7 +510,8 @@ function agentPanel(agent) {
erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }),
erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }),
agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null,
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` })
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` }),
erstelleElement("p", { klasse: "text-klein", text: `${t("org.workspaces")}: ${agentWorkspaces(agent) || "—"}` })
]),
erstelleKarte(t("org.moveAgent"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
@@ -469,3 +524,207 @@ function agentPanel(agent) {
])
];
}
// ─── workspaces (#684) ───
function agentWorkspaces(agent) {
return data.workspaces
.map((w) => ({ w, grant: w.access.find((a) => a.agentId === agent.agentId) }))
.filter((x) => x.grant)
.map((x) => `${x.w.id} (${accessLabel(x.grant.access)})`)
.join(", ");
}
function unitWorkspaceCard(unit) {
const own = ownWorkspace(unit.id);
if (!own) {
return erstelleKarte(t("org.unitWorkspace"), [
erstelleElement("p", { klasse: "text-klein", text: t("org.unitWorkspaceMissing") }),
erstelleElement("button", {
type: "button", klasse: "klein", text: t("org.createUnitWorkspace"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/workspace`, "POST", { reason: reason() })
})
]);
}
return erstelleKarte(t("org.unitWorkspace"), [
erstelleElement("p", { klasse: "text-klein", text: `${own.id} · ${t("org.fileCount", { n: own.fileCount })}` }),
erstelleElement("p", { klasse: "text-klein", text: `${t("org.memberEntries")}: ${membersSummary(own)}` }),
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.editWorkspace"),
onclick: () => select({ kind: "workspace", id: own.id })
})
]);
}
function openCreateWorkspace() {
const id = erstelleElement("input", { type: "text", placeholder: "articles", required: true });
const name = erstelleElement("input", { type: "text", placeholder: "Article master", required: true });
const purpose = erstelleElement("textarea", { rows: 2 });
const owner = unitOptions(erstelleElement("select"), null, new Set(), t("org.noOwner"));
const form = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.workspaceId") }), id,
erstelleElement("label", { text: t("org.workspaceName") }), name,
erstelleElement("label", { text: t("org.purpose") }), purpose,
erstelleElement("label", { text: t("org.owner") }), owner,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.newWorkspaceHint") }));
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") });
const modal = erstelleModal(t("org.newWorkspace"), form, [cancel, create]);
cancel.addEventListener("click", () => modal.schliessen());
form.addEventListener("submit", async (e) => {
e.preventDefault();
// The owning unit manages what it owns; everyone else is added on purpose afterwards.
const members = owner.value ? [{ unitId: owner.value, agentId: null, access: "Manage", includeSubUnits: true }] : [];
const ok = await send("/organisation/workspaces", "POST", {
id: id.value.trim(), name: name.value.trim(), purpose: purpose.value, ownerUnitId: owner.value || null, members, reason: reason()
});
if (ok) {
modal.schliessen();
select({ kind: "workspace", id: id.value.trim() });
}
});
modal.oeffnen();
}
function workspacePanel(workspace) {
const base = `/organisation/workspaces/${encodeURIComponent(workspace.id)}`;
const owner = workspace.ownerUnitId ? unitPath(workspace.ownerUnitId) : t("org.noOwner");
// Name, purpose, protected folders.
const name = erstelleElement("input", { type: "text", value: workspace.name });
const purpose = erstelleElement("textarea", { rows: 2 });
purpose.value = workspace.purpose ?? "";
const protectedPaths = erstelleElement("input", { type: "text", value: workspace.protectedPaths.join(", "), placeholder: "prices, archive" });
const general = erstelleKarte(workspace.name, [
erstelleElement("p", { klasse: "gedaempft text-klein",
text: `${workspace.id} · ${t("org.owner")}: ${owner}${workspace.isUnitOwn ? ` (${t("org.unitOwnWorkspace")})` : ""}` }),
erstelleElement("p", { klasse: "gedaempft text-klein",
text: `${t("org.fileCount", { n: workspace.fileCount })} · SharedWorkspaces/${workspace.id}/` }),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.workspaceName") }), name,
erstelleElement("label", { text: t("org.purpose") }), purpose,
erstelleElement("label", { text: t("org.protectedPaths") }), protectedPaths,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.protectedHint") }),
erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("common.save"),
onclick: () => send(base, "PATCH", {
name: name.value,
purpose: purpose.value,
protectedPaths: protectedPaths.value.split(",").map((x) => x.trim()).filter(Boolean),
reason: reason()
})
}))
]);
return [general, membersCard(workspace, base), accessCard(workspace), deleteWorkspaceCard(workspace, base)];
}
/** The member list, edited as a whole and saved in one go. */
function membersCard(workspace, base) {
const rows = erstelleElement("tbody");
const addRow = (member) => {
const kind = erstelleElement("select", {},
erstelleElement("option", { value: "unit", text: t("org.memberUnit") }),
erstelleElement("option", { value: "agent", text: t("org.memberAgent") }));
kind.value = member.agentId ? "agent" : "unit";
const target = erstelleElement("select");
const fillTarget = () => {
target.replaceChildren();
const options = kind.value === "unit"
? data.units.map((u) => [u.id, u.path])
: data.agents.map((a) => [a.agentId, a.displayName && a.displayName !== a.agentId ? `${a.displayName} (${a.agentId})` : a.agentId]);
for (const [value, text] of options) target.append(erstelleElement("option", { value, text }));
const current = kind.value === "unit" ? member.unitId : member.agentId;
if (current) target.value = current;
};
const access = erstelleElement("select", {},
...ACCESS.map((a) => erstelleElement("option", { value: a, text: accessLabel(a) })));
access.value = member.access ?? "Read";
const sub = erstelleElement("input", { type: "checkbox" });
sub.checked = member.includeSubUnits !== false;
const syncSub = () => { sub.disabled = kind.value !== "unit"; };
kind.addEventListener("change", () => { fillTarget(); syncSub(); });
fillTarget();
syncSub();
const row = erstelleElement("tr", {},
erstelleElement("td", {}, kind),
erstelleElement("td", {}, target),
erstelleElement("td", {}, access),
erstelleElement("td", {}, erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, sub, t("org.includeSubUnits"))),
erstelleElement("td", {}, erstelleElement("button", {
type: "button", klasse: "flach klein", text: "×", title: t("org.removeMember"), "aria-label": t("org.removeMember"),
onclick: () => row.remove()
})));
row.lesen = () => ({
unitId: kind.value === "unit" ? target.value : null,
agentId: kind.value === "agent" ? target.value : null,
access: access.value,
includeSubUnits: kind.value === "unit" ? sub.checked : true
});
rows.append(row);
};
for (const member of workspace.members) addRow(member);
return erstelleKarte(t("org.memberEntries"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.membersHint") }),
erstelleElement("table", { klasse: "daten-tabelle org-mitglieder" },
erstelleElement("thead", {}, erstelleElement("tr", {},
...[t("org.memberKind"), t("org.memberWho"), t("org.accessLevel"), "", ""].map((h) => erstelleElement("th", { text: h })))),
rows),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.addMember"),
onclick: () => addRow({ unitId: data.units[0]?.id ?? null, agentId: data.units.length ? null : data.agents[0]?.agentId, access: "Read" })
}),
erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("common.save"),
onclick: () => send(`${base}/members`, "PUT", {
members: [...rows.children].map((r) => r.lesen()),
reason: reason()
})
}))
]);
}
/** Who the list lets in — computed by the instance from the tree, not guessed here. */
function accessCard(workspace) {
return erstelleKarte(t("org.whoGetsIn"), [
workspace.access.length
? erstelleElement("ul", { klasse: "org-liste" }, ...workspace.access.map((a) => erstelleElement("li", {},
erstelleElement("button", {
type: "button", klasse: "flach klein", text: `${a.agentId} — ${accessLabel(a.access)}`,
onclick: () => select({ kind: "agent", id: a.agentId })
}))))
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.nobodyHasAccess") })
]);
}
function deleteWorkspaceCard(workspace, base) {
if (workspace.isUnitOwn) {
return erstelleKarte(null, [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.unitWorkspaceStays") })]);
}
return erstelleKarte(null, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.deleteWorkspaceHint") }),
erstelleElement("button", {
type: "button", klasse: "gefahr klein", text: t("org.deleteWorkspace"),
disabled: workspace.fileCount > 0,
onclick: async () => {
if (await send(`${base}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
selection = null;
renderDetail();
}
}
})
]);
}
@@ -5,7 +5,8 @@ public sealed record OrganisationDto(
IReadOnlyList<UnitDto> Units,
IReadOnlyList<OrgMemberDto> Agents,
IReadOnlyList<string> Problems,
IReadOnlyList<string> KnownTools);
IReadOnlyList<string> KnownTools,
IReadOnlyList<WorkspaceDto> Workspaces);
public sealed record UnitDto(
string Id,
@@ -61,3 +62,38 @@ public sealed record SettingPreviewInputDto(string Setting, string Value, string
public sealed record SettingChangeDto(string AgentId, string DisplayName, string Current, string Proposed, bool Outlier, bool SelectedByDefault);
public sealed record SettingApplyDto(string Setting, IReadOnlyDictionary<string, string> Values, string Reason = "", string? DefaultValue = null);
/// <summary>
/// #684: A workspace with its member list — and, computed by the instance, who that list
/// actually lets in and how far. <see cref="IsUnitOwn"/> marks a unit's own workspace.
/// </summary>
public sealed record WorkspaceDto(
string Id,
string Name,
string Purpose,
string? OwnerUnitId,
bool IsUnitOwn,
IReadOnlyList<WorkspaceMemberDto> Members,
IReadOnlyList<string> ProtectedPaths,
int FileCount,
IReadOnlyList<WorkspaceAccessDto> Access);
/// <summary>A member entry: a unit or an agent; <see cref="Access"/> is Read, Write or Manage.</summary>
public sealed record WorkspaceMemberDto(string? UnitId, string? AgentId, string Access, bool IncludeSubUnits = true);
public sealed record WorkspaceAccessDto(string AgentId, string Access);
public sealed record WorkspaceInputDto(
string Id,
string Name,
string? Purpose = null,
string? OwnerUnitId = null,
IReadOnlyList<WorkspaceMemberDto>? Members = null,
string Reason = "");
public sealed record WorkspaceUpdateDto(string? Name = null, string? Purpose = null, IReadOnlyList<string>? ProtectedPaths = null, string Reason = "");
public sealed record WorkspaceMembersInputDto(IReadOnlyList<WorkspaceMemberDto> Members, string Reason = "");
/// <summary>A change that needs nothing but the reason for the audit log.</summary>
public sealed record ReasonInputDto(string Reason = "");
@@ -1,5 +1,6 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -38,7 +39,23 @@ internal static class OrganisationEndpoints
a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model))
.ToList();
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools);
var workspaces = chart.Workspaces
.OrderBy(w => w.Id, StringComparer.OrdinalIgnoreCase)
.Select(w => new WorkspaceDto(
w.Id, w.Name, w.Purpose, w.OwnerUnitId,
ReferenceEquals(chart.UnitWorkspace(w.OwnerUnitId), w),
w.Members.Select(m => new WorkspaceMemberDto(m.UnitId, m.AgentId, m.Access.ToString(), m.IncludeSubUnits)).ToList(),
w.ProtectedPaths,
host.Organisation.FileCount(w.Id),
chart.Agents
.Select(a => (a.AgentId, Access: chart.AccessTo(a.AgentId, w.Id)))
.Where(x => x.Access > WorkspaceAccess.None)
.OrderBy(x => x.AgentId, StringComparer.OrdinalIgnoreCase)
.Select(x => new WorkspaceAccessDto(x.AgentId, x.Access.ToString()))
.ToList()))
.ToList();
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools, workspaces);
});
org.MapPost("/units", (AppHost host, UnitInputDto input) =>
@@ -79,12 +96,51 @@ internal static class OrganisationEndpoints
.ToList());
});
MapWorkspaces(org);
org.MapPost("/units/{unitId}/settings", (AppHost host, string unitId, SettingApplyDto input) =>
Enum.TryParse<UnitSetting>(input.Setting, ignoreCase: true, out var setting)
? host.Organisation.ApplySetting(unitId, setting, input.Values, input.Reason, input.DefaultValue).ToResponse()
: Results.BadRequest(new OutcomeDto(false, "Unknown setting.")));
}
// ─── workspaces (#684) ───
private static void MapWorkspaces(RouteGroupBuilder org)
{
org.MapPost("/workspaces", (AppHost host, WorkspaceInputDto input) =>
ToMembers(input.Members ?? []) is not { } members
? Results.BadRequest(new OutcomeDto(false, "Unknown access level — Read, Write or Manage."))
: host.Organisation.CreateWorkspace(input.Id, input.Name, input.Purpose, input.OwnerUnitId, members, input.Reason).ToResponse());
org.MapPatch("/workspaces/{workspaceId}", (AppHost host, string workspaceId, WorkspaceUpdateDto input) =>
host.Organisation.UpdateWorkspace(workspaceId, input.Name, input.Purpose, input.ProtectedPaths, input.Reason).ToResponse());
org.MapPut("/workspaces/{workspaceId}/members", (AppHost host, string workspaceId, WorkspaceMembersInputDto input) =>
ToMembers(input.Members ?? []) is not { } members
? Results.BadRequest(new OutcomeDto(false, "Unknown access level — Read, Write or Manage."))
: host.Organisation.SetWorkspaceMembers(workspaceId, members, input.Reason).ToResponse());
org.MapDelete("/workspaces/{workspaceId}", (AppHost host, string workspaceId, string? reason) =>
host.Organisation.DeleteWorkspace(workspaceId, reason ?? "").ToResponse());
org.MapPost("/units/{unitId}/workspace", (AppHost host, string unitId, ReasonInputDto? input) =>
host.Organisation.CreateUnitWorkspace(unitId, input?.Reason ?? "").ToResponse());
}
/// <summary><c>null</c> when a level is not one of Read, Write, Manage.</summary>
private static List<WorkspaceMemberInput>? ToMembers(IReadOnlyList<WorkspaceMemberDto> members)
{
var result = new List<WorkspaceMemberInput>();
foreach (var member in members)
{
if (!Enum.TryParse<WorkspaceAccess>(member.Access, ignoreCase: true, out var access) || !Enum.IsDefined(access))
return null;
result.Add(new WorkspaceMemberInput(member.UnitId, member.AgentId, access, member.IncludeSubUnits));
}
return result;
}
private static StructurePreviewDto ToDto(StructurePreview preview)
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
}