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,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);
}