Author SHA1 Message Date
RichardandClaude Opus 5.5 29d953a5eb Brett je Einheit, Stufe 3: Aufgaben sehen, wer sie sehen soll (#684)
- TaskBoard: das Brett einer Aufgabe wird nicht gespeichert, es folgt aus
  der Organisation - Einheit des Zustaendigen, sonst des Anlegers. Offene
  Aufgaben ziehen damit beim Umzug mit. Sichtbar: das eigene Brett, fuer eine
  Leitung alle darunter, und was man selbst angelegt hat (nach oben
  Gereichtes bleibt im Blick).
- TaskItem.CreatedBy, in der Datei als created_by und in der DB. Ein Import
  aus einer Datei ohne das Feld behaelt, was die DB weiss.
- Taskboard-Tool: task_list, task_find und die Dublettenpruefung sehen nur
  die eigenen Bretter - eine aehnliche Buchhaltungsaufgabe haelt keine
  Marketingaufgabe auf und bekommt keinen Kommentar von dort. task_update und
  task_comment antworten auf eine fremde Aufgabe wie auf eine unbekannte.
- TaskboardService.ListAsync mit Sichtfilter: erst filtern, dann kappen.
- WebUI: jede Aufgabe nennt ihr Brett, ein Filter waehlt eines aus.
- Konzept (Abschnitt 3) und Einrichtungshandbuch nachgetragen.

Ohne Einheiten aendert sich nichts - ein Brett fuer alle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:03:39 +02:00
RichardandClaude Opus 5.5 609298dd7a 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>
2026-09-24 16:58:09 +02:00
RichardandClaude Opus 5.5 ff20ce2563 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>
2026-09-24 16:52:03 +02:00
Richard ba5fe4cdf7 Merge feat/711-lagebild-karten: Lagebild als verbundene Karten (#711) 2026-09-24 16:38:51 +02:00
RichardandClaude Opus 5.5 b2b8a34d9f Lagebild: Einheiten als verbundene Karten, Strukturbaum mit Suche und Filter (#711)
Rueckmeldung zur Teamuebersicht: wirkte veraltet, und die Verbindungen
zwischen den Einheiten fehlten - zugeklappte Container versteckten ganze
Ebenen, man haette sich bis zum Problem durchklicken muessen.

- Jede Einheit ist immer eine Karte, verbunden als Organisationsbaum
  (ELK layered, orthogonale Linien, eckige Anschluesse). Keine Ebene wird
  versteckt. Zugeklappt wird nur die Agentenliste grosser Einheiten: sie
  zeigt, wer Aufmerksamkeit braucht, und "+n weitere".
- Eine Linie traegt die Farbe des dringlichsten Zustands darunter - eine
  rote Linie fuehrt von oben zum Problem.
- Links ein Strukturbaum mit Suche (Agent, Einheit, Modell) und
  Zustandsfilter; Treffer werden hervorgehoben, der Weg dorthin geoeffnet,
  der Rest abgeblendet. Enter springt zum ersten Treffer.
- Leinwand mit Punktraster, Verschieben und Zoomen, schwebende Leisten
  (Live-Verbindungen, Einpassen, Zoom). Live-Verbindungen laufen von Zeile
  zu Zeile, gebuendelt mit Zaehler.
- Hausstil bleibt (Radius 0, App-Akzent), hell und dunkel, bis Handybreite.
- Cytoscape und cytoscape-elk entfernt: ein Canvas-Knoten kann keine
  Agentenliste, Suche oder den Hausstil tragen. Karten sind jetzt HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:38:51 +02:00
Richard 0718a4bd0d Merge feat/711-stufe1: Einheiten im Lagebild, Pool-Fix fuer die Tests (#711) 2026-09-24 15:27:40 +02:00
37 changed files with 3648 additions and 1093 deletions
+2
View File
@@ -64,6 +64,8 @@ Instance-{Name}/
├── AgentList.json ← Agenten-Register (Name, Beschreibung, Ordner)
├── SharedWorkspace/ ← Geteilter Arbeitsbereich
│ └── coordination/ ← Status-Dateien für Inter-Agenten-Kommunikation
├── SharedWorkspaces/ ← Bereiche mit Mitgliederliste (#684), einer je Einheit
│ └── {bereich}/ ← in FileRW als workspace "unit" oder "{bereich}"
└── Agent-{Name}/
├── AgentSettings.json ← Agent-Konfiguration (Modell, Tools, Scheduler)
├── Identity.md ← WER ist der Agent (Rolle, Expertise)
+30 -3
View File
@@ -244,6 +244,25 @@ Vier Festlegungen:
Für bestehende Instanzen bleibt `workspace: "shared"` gültig und zeigt auf den einen
Bereich, den es dort gibt. Ohne das bräche jede Aufgabe, die heute läuft.
**Umgesetzt am 2026-09-24 (#684), mit zwei Abweichungen im Wortlaut, nicht in der Sache:**
- Der Ordner `SharedWorkspace` wird **nicht umbenannt**. Er bleibt der Bereich `shared`
mit dem bisherigen `sharedAccessLevel` am Agenten; die neuen Bereiche liegen daneben
unter `SharedWorkspaces/<id>/`. Am alten Pfad hängen FTP, RocketChat, der Agenten-Editor,
die Aufgabendateien und die Sicherungen — eine Umbenennung hätte jede laufende
Installation gebrochen. Für jeden Bereich gibt es genau eine Quelle des Rechts: für
`shared` die Stufe am Agenten, für alle anderen die Mitgliederliste.
- Der eigene Bereich einer Einheit trägt ihre Id; ein Agent erreicht ihn in FileRW als
`unit`, ohne die Id zu kennen. Welche Bereiche er hat, steht im Organisationsabschnitt
seines Prompts.
- **Mit Einheiten ist `shared:/tasks/` für FileRW gesperrt.** Eine direkt geschriebene
Aufgabendatei ginge an jeder Prüfung des Bretts vorbei. Aufgaben laufen nur über das
Taskboard. Ohne Einheiten ändert sich nichts.
- **Das Brett einer Aufgabe wird nicht gespeichert**, es folgt aus der Organisation: die
Einheit des Zuständigen, sonst die des Anlegers (`created_by`). Offene Aufgaben ziehen
damit beim Umzug von selbst mit. Wer eine Aufgabe angelegt hat, sieht sie auch, wenn sie
auf dem Brett der Leitung liegt.
### Die eine Regel, die alles zusammenhält
**Ein Agent kennt nur seine Einheit und seine Leitung.** Alles andere geht über die
@@ -1239,9 +1258,17 @@ der richtige Handgriff meist schon da.
Ereignistypen mit Absender und Empfänger: Delegation, Task-Zuweisung, später Besprechung
und Eskalation, jeweils mit eigener Linienart. Das Verblassen nach einigen Sekunden
geschieht im Browser.
- **Bibliothek:** Cytoscape.js mit ELK-Layout. Es kann verschachtelte Container und
hierarchisches Layout und bleibt bei Hunderten Nodes flüssig, ohne React. Als Datei im
`wwwroot`, nicht über ein CDN.
- **Bibliothek:** ELK (elkjs) für das Layout, die Karten als HTML, die Linien als SVG.
Als Datei im `wwwroot`, nicht über ein CDN. Stufe 0 und 1 zeichneten mit Cytoscape.js
auf einer Canvas. Das wurde am 2026-09-24 ersetzt: Ein Canvas-Knoten kann keine
Agentenliste, keine Suchhervorhebung und nicht den Hausstil tragen.
- **Gestalt (Rückmeldung 2026-09-24):** Jede Einheit bleibt immer als Karte sichtbar,
verbunden als Baum. Zugeklappt wird nur die Agentenliste einer großen Einheit; sie
zeigt dann, wer Aufmerksamkeit braucht, und „+n weitere". Eine Linie trägt die Farbe
des dringlichsten Zustands darunter, eine rote Linie führt also von oben zum Problem.
Ohne Durchklicken durch zehn Ebenen. Links ein Strukturbaum mit Suche und Zustandsfilter,
der die Wege zu den Treffern zeigt und den Rest abblendet. Hausstil bleibt: Radius 0,
App-Akzent.
- **Nur in der WebUI.** Ein zweiter Graph-Renderer in Avalonia wäre doppelte Arbeit, die
auseinanderläuft. Der Desktop bettet die Ansicht ein oder verlinkt sie.
- Englisch, Texte über Sprachpakete (#661).
+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));
@@ -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/&lt;id&gt;/</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
+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);
}
}
@@ -377,6 +377,9 @@ public sealed class SqliteStorage
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
// #684: Who created a task — decides its board when nobody in particular is assigned.
SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
INSERT INTO Tasks
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt)
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy)
VALUES
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy);
""";
BindDefinition(insert, task);
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
@@ -60,6 +60,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
RequireApproval = @requireApproval, Acceptance = @acceptance,
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
UpdatedAt = @updatedAt
WHERE Id = @id
""";
@@ -331,7 +332,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending";
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -351,6 +352,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
cmd.Parameters.AddWithValue("@fileName", task.FileName);
cmd.Parameters.AddWithValue("@toolName", task.ToolName);
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
// #684: a file without it (written by hand, or older) keeps what the database knows.
cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? "");
}
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
@@ -401,7 +404,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
BudgetMissCount = r.GetInt32(22),
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0
CatchUpPending = r.GetInt32(25) != 0,
CreatedBy = r.GetString(26)
};
}
+78
View File
@@ -0,0 +1,78 @@
using ClawdDotNet.Core.Organisation;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// #684: One board per unit. With 50 agents one board for everything is worthless as an
/// overview — the test instance had 159 tasks with seven agents — and a bookkeeping task
/// could slow down the creation of a marketing task through the duplicate check.
///
/// <para><b>The board of a task is not stored, it follows from the organisation:</b> the unit
/// of the agent it is assigned to, or — for a task for a human or a fresh run — the unit of
/// the agent that created it. So open tasks move with an agent that moves (Organisation-
/// Konzept, section 3), and nothing has to be migrated. A task that belongs to no unit is on
/// the board of the top level.</para>
///
/// <para><b>Who sees which board:</b> an agent its own unit's; a lead also the boards of every
/// unit below the one it leads; everyone the tasks they created themselves, so a task handed
/// up to the lead stays in view of whoever handed it up. A task across units is a delegation
/// to the other unit's lead — the address book decides that, not this.</para>
///
/// <para><b>Without units nothing changes:</b> there is one board, and everyone sees it.</para>
/// </summary>
public static class TaskBoard
{
/// <summary>The unit whose board the task is on; <c>null</c> for the top level.</summary>
public static string? UnitOf(TaskItem task, OrgChart org)
{
var assigned = TaskAssignee.AgentId(task.Assignee);
if (assigned.Length > 0 && org.Agent(assigned) is not null)
return org.UnitOf(assigned)?.Id;
if (!string.IsNullOrWhiteSpace(task.CreatedBy) && org.Agent(task.CreatedBy) is not null)
return org.UnitOf(task.CreatedBy)?.Id;
return null;
}
/// <summary>
/// What an agent may see — <c>null</c> for "everything": without units, without an
/// organisation (outside the engine), or for an agent the organisation does not know.
/// </summary>
public static Func<TaskItem, bool>? VisibleTo(OrgChart? org, string agentId)
{
if (org is not { HasUnits: true } || org.Agent(agentId) is null)
return null;
var boards = BoardsOf(org, agentId);
var topLevel = org.UnitOf(agentId) is null;
return task =>
string.Equals(task.CreatedBy, agentId, StringComparison.OrdinalIgnoreCase)
|| (UnitOf(task, org) is { } unit ? boards.Contains(unit) : topLevel);
}
/// <summary>The units whose boards an agent sees: its own, and all below the unit it leads.</summary>
public static IReadOnlySet<string> BoardsOf(OrgChart org, string agentId)
{
var boards = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (org.UnitOf(agentId) is { } own)
boards.Add(own.Id);
if (org.LedBy(agentId) is { } led)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pending = new Stack<string>();
pending.Push(led.Id);
while (pending.TryPop(out var unitId))
{
if (!seen.Add(unitId))
continue; // a broken tree must not hang this
boards.Add(unitId);
foreach (var child in org.Children(unitId))
pending.Push(child.Id);
}
}
return boards;
}
}
@@ -152,6 +152,7 @@ public static class TaskFrontmatter
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
ToolName = scalars.GetValueOrDefault("tool_name", ""),
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
Body = body
};
return true;
@@ -169,6 +170,8 @@ public static class TaskFrontmatter
sb.Append($"type: {TaskText.Of(task.Type)}\n");
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
if (!string.IsNullOrWhiteSpace(task.CreatedBy))
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (task.When is { } w)
{
+27
View File
@@ -228,6 +228,13 @@ public sealed record TaskItem
/// </summary>
public bool CatchUpPending { get; init; }
/// <summary>
/// #684: The agent that created the task; empty for tasks from a human, from a file
/// written by hand, or from before this field. Decides the board of a task nobody in
/// particular is assigned to — see <see cref="TaskBoard"/>.
/// </summary>
public string CreatedBy { get; init; } = "";
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
@@ -269,4 +276,24 @@ public sealed record TaskQuery
public bool IncludeArchived { get; init; }
public int Limit { get; init; } = 50;
/// <summary>
/// #684: The same filter as the repository's query, for a list that is already in
/// memory — when a board has to be filtered first and the database cannot do that.
/// </summary>
public bool Matches(TaskItem task)
{
if (Status is { } status ? task.Status != status : !IncludeArchived && task.Status == TaskItemStatus.Archived)
return false;
if (!string.IsNullOrWhiteSpace(Assignee) && !string.Equals(task.Assignee, Assignee.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(Search))
{
var search = Search.Trim();
if (!task.Title.Contains(search, StringComparison.OrdinalIgnoreCase)
&& !task.Body.Contains(search, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
}
+22 -1
View File
@@ -31,7 +31,28 @@ public sealed class TaskboardService
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
=> _repo.ListAsync(query, ct);
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
/// <summary>
/// #684: A list restricted to what one agent may see. The boards cannot be expressed in
/// the database query — they follow from the organisation — so the whole board is read
/// and filtered here, then capped: filtering after the database's cap would silently
/// drop tasks of the own unit behind those of others.
/// </summary>
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, Func<TaskItem, bool>? visible, CancellationToken ct)
{
if (visible is null)
return await _repo.ListAsync(query, ct);
var all = await _repo.ListAllAsync(query.IncludeArchived || query.Status == TaskItemStatus.Archived, ct);
return all
.Where(visible)
.Where(query.Matches)
.OrderByDescending(t => t.Priority)
.ThenByDescending(t => t.UpdatedAt)
.Take(Math.Clamp(query.Limit, 1, 500))
.ToList();
}
/// <summary>The whole board, without the cap of <see cref="ListAsync(TaskQuery, CancellationToken)"/>.</summary>
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
=> _repo.ListAllAsync(includeArchived, ct);
+488 -25
View File
@@ -1722,7 +1722,9 @@ td.klein {
font-size: 12px;
}
/* ─── Team overview (#711) ─── */
/* ─── Team overview (#711) ───
A work surface in the blueprint style: dot grid, cards with a head, state stripes,
square ports on the lines, floating bars. Square like everything else. */
.team-ansicht {
display: flex;
@@ -1731,45 +1733,474 @@ td.klein {
height: 100%;
}
.team-kopf {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.team-kopf h1 { margin: 0; }
.team-kopf p { margin: 4px 0 0; max-width: 72ch; }
.team-zusammenfassung {
flex-wrap: wrap;
min-height: 24px;
}
.team-flaeche {
display: flex;
gap: 12px;
flex: 1;
min-height: 420px;
/* Tones: every state has a colour, shared by markers, chips, lines and cards. */
.ton-neutral { --ton: var(--gedaempft); }
.ton-aktiv { --ton: var(--accent); }
.ton-warnung { --ton: var(--warnung); }
.ton-gefahr { --ton: var(--gefahr); }
.team-marker {
color: var(--ton, var(--gedaempft));
font-size: 12px;
text-align: center;
}
.team-graph {
.team-arbeitsflaeche {
flex: none;
/* A fixed height: the tree and the panel scroll inside it, the page does not grow. */
height: max(520px, calc(100vh - 250px));
display: grid;
grid-template-columns: 264px minmax(0, 1fr) auto;
grid-template-rows: minmax(0, 1fr);
border: 1px solid var(--divider);
background: var(--surface);
}
/* The tree on the left */
.team-baum {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--divider);
background: var(--surface);
}
.team-baum-kopf {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--divider);
}
.team-baum-titel {
font-family: var(--schrift-titel);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--gedaempft);
}
.team-suche {
width: 100%;
padding: 7px 10px;
}
.team-filter {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.team-filter button {
padding: 2px 8px;
font-size: 12px;
background: transparent;
color: var(--gedaempft);
}
.team-filter button[aria-pressed="true"] {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.team-treffer-info {
min-height: 16px;
font-size: 12px;
color: var(--gedaempft);
}
.team-baum-liste {
flex: 1;
overflow: auto;
padding: 6px 0;
}
.team-baum-liste ul {
list-style: none;
margin: 0;
padding: 0;
}
.team-baum-eintrag {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 10px 4px calc(8px + var(--tiefe, 0) * 14px);
background: transparent;
border: 0;
font-size: 13px;
text-align: left;
}
.team-baum-eintrag.ausgewaehlt {
background: color-mix(in srgb, var(--accent) 18%, transparent);
box-shadow: inset 2px 0 0 var(--accent);
}
.team-baum-eintrag.treffer .team-baum-name {
color: var(--accent);
font-weight: 600;
}
.team-baum-eintrag.gedimmt { opacity: 0.45; }
.team-pfeil {
flex: none;
width: 14px;
font-size: 10px;
color: var(--gedaempft);
}
.team-baum-name {
flex: 1;
min-width: 0;
min-height: 420px;
background: var(--surface-subtil);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-zahl {
flex: none;
font-size: 11px;
color: var(--gedaempft);
font-variant-numeric: tabular-nums;
}
.team-zahl.ton-aktiv,
.team-zahl.ton-warnung,
.team-zahl.ton-gefahr { color: var(--ton); }
/* The canvas */
.team-leinwand {
position: relative;
overflow: hidden;
min-width: 0;
cursor: grab;
touch-action: none;
user-select: none;
background-color: var(--bg);
background-image: radial-gradient(circle, var(--divider) 1px, transparent 1.3px);
background-size: 20px 20px;
}
.team-leinwand.zieht { cursor: grabbing; }
.team-welt {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
}
.team-welt.gleitend { transition: transform 0.35s ease; }
.team-kanten,
.team-links {
position: absolute;
left: 0;
top: 0;
overflow: visible;
pointer-events: none;
}
.team-links { z-index: 1; }
.team-kante {
fill: none;
stroke: color-mix(in srgb, var(--ton) 55%, var(--divider));
stroke-width: 1.5;
}
.team-kante.ton-gefahr,
.team-kante.ton-warnung {
stroke: var(--ton);
stroke-width: 2;
}
.team-kante.ton-gefahr {
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--gefahr) 70%, transparent));
}
.team-port {
fill: var(--bg);
stroke: color-mix(in srgb, var(--ton) 70%, var(--divider));
stroke-width: 1.5;
}
.team-port.ton-gefahr,
.team-port.ton-warnung { fill: var(--ton); stroke: var(--ton); }
.team-kante.gedimmt,
.team-port.gedimmt { opacity: 0.2; }
.team-link {
fill: none;
stroke: var(--accent);
stroke-width: 2;
stroke-dasharray: 6 5;
animation: team-fluss 0.9s linear infinite;
}
.team-pfeilspitze { fill: var(--accent); }
@keyframes team-fluss {
to { stroke-dashoffset: -11; }
}
.team-link-zahl {
font-family: var(--schrift-text);
font-size: 12px;
font-weight: 600;
fill: var(--text);
paint-order: stroke;
stroke: var(--bg);
stroke-width: 4px;
}
/* Cards */
.team-karte {
position: absolute;
left: 0;
top: 0;
background: var(--surface);
border: 1px solid var(--divider);
border-left: 3px solid var(--ton, var(--divider));
box-shadow: 0 12px 28px -16px rgba(0, 0, 0, 0.55);
transition: opacity 0.2s ease, box-shadow 0.2s ease;
}
.team-karte.ton-neutral { border-left-color: var(--divider); }
.team-karte.ton-gefahr {
border-color: color-mix(in srgb, var(--gefahr) 60%, var(--divider));
border-left-color: var(--gefahr);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--gefahr) 35%, transparent),
0 0 26px -6px color-mix(in srgb, var(--gefahr) 60%, transparent);
}
.team-karte.ton-warnung {
border-color: color-mix(in srgb, var(--warnung) 45%, var(--divider));
border-left-color: var(--warnung);
}
.team-karte.wurzel .team-karte-kopf {
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
}
.team-karte.ausgewaehlt,
.team-karte.treffer {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.team-karte.gedimmt { opacity: 0.3; }
.team-karte-kopf {
display: block;
width: 100%;
padding: 10px 12px 9px;
background: transparent;
border: 0;
border-bottom: 1px solid var(--divider);
text-align: left;
}
.team-karte-titel {
display: flex;
align-items: baseline;
gap: 8px;
font-family: var(--schrift-titel);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.team-karte-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-karte-unter {
margin-top: 2px;
font-size: 12px;
color: var(--gedaempft);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.team-chip {
padding: 1px 6px;
font-size: 11px;
font-variant-numeric: tabular-nums;
color: var(--gedaempft);
border: 1px solid var(--divider);
}
.team-chip.ton-aktiv,
.team-chip.ton-warnung,
.team-chip.ton-gefahr {
color: var(--ton);
border-color: color-mix(in srgb, var(--ton) 55%, transparent);
background: color-mix(in srgb, var(--ton) 12%, transparent);
}
.team-karte-liste { padding: 4px 0; }
.team-zeile {
display: grid;
grid-template-columns: 16px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
width: 100%;
padding: 5px 12px;
background: transparent;
border: 0;
font-size: 13px;
text-align: left;
}
.team-zeile.ausgewaehlt { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.team-zeile.treffer { box-shadow: inset 2px 0 0 var(--accent); }
.team-zeile.gedimmt { opacity: 0.4; }
.team-zeile.blinkt { animation: team-blinkt 1.4s ease; }
@keyframes team-blinkt {
0%, 55% { background: color-mix(in srgb, var(--accent) 35%, transparent); }
100% { background: transparent; }
}
.team-zeile-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.team-zeile-zustand {
font-size: 11px;
color: var(--gedaempft);
}
.team-zeile.ton-aktiv .team-zeile-zustand,
.team-zeile.ton-warnung .team-zeile-zustand,
.team-zeile.ton-gefahr .team-zeile-zustand { color: var(--ton); }
.team-mehr {
display: block;
width: 100%;
padding: 4px 12px 7px 36px;
background: transparent;
border: 0;
font-size: 12px;
text-align: left;
color: var(--accent);
}
.team-mehr:hover:not(:disabled) {
background: transparent;
text-decoration: underline;
}
/* Floating bars */
.team-leiste {
position: absolute;
z-index: 2;
display: flex;
gap: 2px;
padding: 4px;
background: var(--surface);
border: 1px solid var(--divider);
box-shadow: 0 12px 30px -14px rgba(0, 0, 0, 0.6);
}
.team-leiste button {
padding: 6px 12px;
background: transparent;
border: 0;
font-size: 13px;
}
.team-leiste button[aria-pressed="true"] {
background: color-mix(in srgb, var(--accent) 20%, transparent);
box-shadow: inset 0 -2px 0 var(--accent);
}
.team-leiste-mitte {
left: 50%;
bottom: 16px;
transform: translateX(-50%);
}
.team-leiste-zoom {
right: 16px;
bottom: 16px;
}
.team-zoomwert {
align-self: center;
min-width: 48px;
font-size: 12px;
text-align: center;
color: var(--gedaempft);
font-variant-numeric: tabular-nums;
}
/* Side panel */
.team-seitenleiste {
width: 320px;
flex-shrink: 0;
padding: 14px;
background: var(--surface);
border: 1px solid var(--divider);
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
background: var(--surface);
border-left: 1px solid var(--divider);
}
.team-seitenleiste-kopf {
justify-content: space-between;
}
.team-seitenleiste[hidden] { display: none; }
.team-seitenleiste h3 {
margin: 8px 0 0;
}
.team-seitenleiste-kopf { justify-content: space-between; }
.team-seitenleiste h2 { margin: 0; }
.team-seitenleiste h3 { margin: 8px 0 0; }
.team-seitenleiste .team-zeile { padding: 5px 4px; }
.team-ergebnis {
white-space: pre-wrap;
@@ -1786,20 +2217,28 @@ td.klein {
margin-top: 8px;
}
.team-eintrag {
justify-content: flex-start;
width: 100%;
text-align: left;
}
.team-legende {
flex-wrap: wrap;
gap: 16px;
}
.team-eintrag {
text-align: left;
justify-content: flex-start;
width: 100%;
@media (prefers-reduced-motion: reduce) {
.team-link,
.team-zeile.blinkt { animation: none; }
.team-welt.gleitend { transition: none; }
}
@media (max-width: 800px) {
.team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; }
@media (max-width: 900px) {
.team-arbeitsflaeche { height: auto; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto; }
.team-baum { max-height: 280px; border-right: 0; border-bottom: 1px solid var(--divider); }
.team-leinwand { min-height: 60vh; }
.team-seitenleiste { width: auto; border-left: 0; border-top: 1px solid var(--divider); }
}
/* ─── Organisation (#683) ─── */
@@ -1859,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;
@@ -1914,3 +2372,8 @@ tr.org-ausreisser td {
@media (max-width: 900px) {
.org-raster { grid-template-columns: 1fr; }
}
/* #684: the board filter above the task cards */
.taskboard-bretter {
margin-bottom: 12px;
}
@@ -127,11 +127,48 @@
"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": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
"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.",
"team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
"team.legendLink": "Live-Verbindung (verblasst nach 30 s)",
"team.state.Idle": "bereit",
"team.state.Working": "arbeitet",
"team.state.Stuck": "hängt",
@@ -151,14 +188,33 @@
"team.unitAgentsOne": "1 Agent",
"team.unitAgents": "{n} Agenten",
"team.legendLead": "Leitung der Einheit",
"team.legendUnit": "Einheit, Klick öffnet sie",
"team.leads": "Leitet",
"team.memberOf": "Mitglied von",
"team.members": "Mitglieder",
"team.subUnits": "Untereinheiten",
"team.collapse": "Einheit zuklappen",
"team.expand": "Einheit aufklappen",
"team.openOrganisation": "Organisation bearbeiten",
"team.rootSubtitle": "Instanz · oberste Ebene",
"team.structure": "Struktur",
"team.searchPlaceholder": "Agenten, Einheiten, Modelle suchen…",
"team.filterLabel": "Nach Zustand filtern",
"team.filter.all": "Alle",
"team.filter.problems": "Probleme",
"team.filter.waiting": "Freigaben",
"team.filter.working": "Arbeiten",
"team.filter.budget": "Budget",
"team.needsAttention": "{n} brauchen Aufmerksamkeit",
"team.matches": "{n} Treffer",
"team.matchesOne": "1 Treffer",
"team.noMatches": "Keine Treffer",
"team.moreMembers": "+{n} weitere",
"team.showFewer": "Weniger zeigen",
"team.showAllMembers": "Alle Mitglieder zeigen",
"team.zoomIn": "Vergrößern",
"team.zoomOut": "Verkleinern",
"team.fit": "Einpassen",
"team.liveLinks": "Live-Verbindungen",
"team.subtreeHint": "Die Zahlen umfassen alle Einheiten darunter.",
"team.legendLine": "Linie = dringlichster Zustand darunter",
"nav.services": "Dienste",
"nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
@@ -290,6 +346,9 @@
"tasks.budgetMissedLast": "zuletzt {time}",
"tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}",
"tasks.budgetCatchUp": "ein Nachholauf ab {time}",
"tasks.board": "Brett",
"tasks.boardAll": "Alle Bretter",
"tasks.boardTopLevel": "Oberste Ebene",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -127,11 +127,48 @@
"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": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
"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.",
"team.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)",
"team.legendLink": "live link (fades after 30 s)",
"team.state.Idle": "idle",
"team.state.Working": "working",
"team.state.Stuck": "stuck",
@@ -151,14 +188,33 @@
"team.unitAgentsOne": "1 agent",
"team.unitAgents": "{n} agents",
"team.legendLead": "lead of the unit",
"team.legendUnit": "unit, click to open",
"team.leads": "Leads",
"team.memberOf": "Member of",
"team.members": "Members",
"team.subUnits": "Sub-units",
"team.collapse": "Collapse unit",
"team.expand": "Expand unit",
"team.openOrganisation": "Edit organisation",
"team.rootSubtitle": "Instance · top level",
"team.structure": "Structure",
"team.searchPlaceholder": "Search agents, units, models…",
"team.filterLabel": "Filter by state",
"team.filter.all": "All",
"team.filter.problems": "Problems",
"team.filter.waiting": "Approvals",
"team.filter.working": "Working",
"team.filter.budget": "Budget",
"team.needsAttention": "{n} need attention",
"team.matches": "{n} matches",
"team.matchesOne": "1 match",
"team.noMatches": "No matches",
"team.moreMembers": "+{n} more",
"team.showFewer": "Show fewer",
"team.showAllMembers": "Show all members",
"team.zoomIn": "Zoom in",
"team.zoomOut": "Zoom out",
"team.fit": "Fit",
"team.liveLinks": "Live links",
"team.subtreeHint": "Counts include all units below.",
"team.legendLine": "line = most urgent state below",
"nav.services": "Services",
"nav.group.analytics": "Analytics",
"nav.usage": "Token Usage",
@@ -290,6 +346,9 @@
"tasks.budgetMissedLast": "last at {time}",
"tasks.budgetCarriedOver": "carried over, due again from {time}",
"tasks.budgetCatchUp": "one catch-up run from {time}",
"tasks.board": "Board",
"tasks.boardAll": "All boards",
"tasks.boardTopLevel": "Top level",
"services.title": "Services",
"services.newService": "New Service",
+11 -7
View File
@@ -9,15 +9,19 @@ Downloaded 2026-09-24 from the npm registry. Each tarball was checked against th
| Package | Version | File | License | SHA-256 of the file |
|---|---|---|---|---|
| [cytoscape](https://www.npmjs.com/package/cytoscape) | 3.34.3 | `cytoscape/cytoscape.min.js` | MIT | `5f3b5b529546d5af1fc5628590af033b74511a5b6f789f5f4682845863228b91` |
| [elkjs](https://www.npmjs.com/package/elkjs) | 0.9.3 | `elkjs/elk.bundled.js` | EPL-2.0 | `b0745abd7f23cd91690a1587e377edbe19fd7233c783300290936720546216d4` |
| [cytoscape-elk](https://www.npmjs.com/package/cytoscape-elk) | 2.3.0 | `cytoscape-elk/cytoscape-elk.js` | MIT | `25acbb9dea9b0dc6fef0e79b697547ee56e579f0e1c22ae4353131e72c7e140a` |
**Why elkjs 0.9.3 and not the newest.** cytoscape-elk 2.3.0 declares `elkjs ^0.9.3`. A newer
elkjs would be untested with the adapter.
**Why elkjs 0.9.3 and not the newest.** It came in together with cytoscape-elk 2.3.0,
which declared `elkjs ^0.9.3`. Cytoscape and the adapter were removed again on 2026-09-24
(see below); elkjs stays at the version it was checked at. Raising it is a separate change.
**How they are loaded.** All three are UMD bundles and, loaded as classic scripts in this
order, register the globals `cytoscape`, `ELK` and `cytoscapeElk`. The team overview (#711)
loads them on demand, so pages that do not draw a graph do not pay for ~2 MB of script.
**Why Cytoscape is gone.** The team overview (#711) first drew its graph with Cytoscape.js
on a canvas. A canvas node cannot hold a list of agents, a search highlight or the house
style, so the cards became plain HTML placed by ELK, with the lines in SVG. Nothing else
used Cytoscape.
**How it is loaded.** `elk.bundled.js` is a UMD bundle and, loaded as a classic script,
registers the global `ELK`. The team overview loads it on demand, so pages that do not draw
the organisation do not pay for ~1.5 MB of script.
The license texts sit next to each file.
@@ -1,21 +0,0 @@
Copyright (c) 2016-2018, 2020, The Cytoscape Consortium.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,356 +0,0 @@
(function webpackUniversalModuleDefinition(root, factory) {
if(typeof exports === 'object' && typeof module === 'object')
module.exports = factory(require("elkjs/lib/elk.bundled.js"));
else if(typeof define === 'function' && define.amd)
define(["elkjs"], factory);
else if(typeof exports === 'object')
exports["cytoscapeElk"] = factory(require("elkjs/lib/elk.bundled.js"));
else
root["cytoscapeElk"] = factory(root["ELK"]);
})(this, function(__WEBPACK_EXTERNAL_MODULE__632__) {
return /******/ (function() { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ 632:
/***/ (function(module) {
module.exports = __WEBPACK_EXTERNAL_MODULE__632__;
/***/ })
/******/ });
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/************************************************************************/
/******/ /* webpack/runtime/compat get default export */
/******/ !function() {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = function(module) {
/******/ var getter = module && module.__esModule ?
/******/ function() { return module['default']; } :
/******/ function() { return module; };
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ !function() {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = function(exports, definition) {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ }();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ !function() {
/******/ __webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }
/******/ }();
/******/
/************************************************************************/
var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
!function() {
// EXPORTS
__webpack_require__.d(__webpack_exports__, {
"default": function() { return /* binding */ src; }
});
// EXTERNAL MODULE: external {"commonjs":"elkjs/lib/elk.bundled.js","commonjs2":"elkjs/lib/elk.bundled.js","amd":"elkjs","root":"ELK"}
var elk_bundled_js_amd_elkjs_root_ELK_ = __webpack_require__(632);
var elk_bundled_js_amd_elkjs_root_ELK_default = /*#__PURE__*/__webpack_require__.n(elk_bundled_js_amd_elkjs_root_ELK_);
;// CONCATENATED MODULE: ./src/assign.js
// Simple, internal Object.assign() polyfill for options objects etc.
function assign_assign(tgt) {
for (var _len = arguments.length, srcs = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
srcs[_key - 1] = arguments[_key];
}
srcs.forEach(function (src) {
Object.keys(src).forEach(function (k) {
return tgt[k] = src[k];
});
});
return tgt;
}
/* harmony default export */ var src_assign = (Object.assign != null ? Object.assign.bind(Object) : assign_assign);
;// CONCATENATED MODULE: ./src/defaults.js
var defaults = {
nodeDimensionsIncludeLabels: false,
// Boolean which changes whether label dimensions are included when calculating node dimensions
fit: true,
// Whether to fit
padding: 20,
// Padding on fit
animate: false,
// Whether to transition the node positions
animateFilter: function animateFilter() {
return true;
},
// Whether to animate specific nodes when animation is on; non-animated nodes immediately go to their final positions
animationDuration: 500,
// Duration of animation in ms if enabled
animationEasing: undefined,
// Easing of animation if enabled
transform: function transform(node, pos) {
return pos;
},
// A function that applies a transform to the final node position
ready: undefined,
// Callback on layoutready
stop: undefined,
// Callback on layoutstop
nodeLayoutOptions: undefined,
// Special options for only the nodes
elk: {
// Options to pass directly to ELK `layoutOptions`. The subsequent identifier has to be used as property key in quotes.
// E.g. for 'org.eclipse.elk.direction' use:
// 'elk.direction'
// Primary/mandatory, the elk algorithm to use
// one of 'box', 'disco', 'force', 'layered', 'mrtree', 'radial', 'random', 'stress'
// (see https://www.eclipse.org/elk/reference/algorithms.html)
algorithm: undefined
},
priority: function priority() {
return null;
} // Edges with a non-nil value are skipped when geedy edge cycle breaking is enabled
};
/* harmony default export */ var src_defaults = (defaults);
;// CONCATENATED MODULE: ./src/layout.js
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); Object.defineProperty(Constructor, "prototype", { writable: false }); return Constructor; }
var elkOverrides = {};
var getPos = function getPos(ele, options) {
var dims = ele.layoutDimensions(options);
var parent = ele.parent();
var k = ele.scratch('elk');
var p = {
x: k.x,
y: k.y
};
while (parent.nonempty()) {
var kp = parent.scratch('elk');
p.x += kp.x;
p.y += kp.y;
parent = parent.parent();
} // elk considers a node position to be its top-left corner, while cy is the centre
p.x += dims.w / 2;
p.y += dims.h / 2;
return p;
};
var makeNode = function makeNode(node, options) {
var k = {
_cyEle: node,
id: node.id()
}; // Apply nodeLayoutOptions per user-specified function
// e.g. nodeLayoutOptions => n.scratch('layoutOptions')
if (options.nodeLayoutOptions) {
k.layoutOptions = options.nodeLayoutOptions(node);
}
if (!node.isParent()) {
var dims = node.layoutDimensions(options);
var p = node.position(); // the elk position is the top-left corner, cy is the centre
k.x = p.x - dims.w / 2;
k.y = p.y - dims.h / 2;
k.width = dims.w;
k.height = dims.h;
}
node.scratch('elk', k);
return k;
};
var makeEdge = function makeEdge(edge
/*, options*/
) {
var k = {
_cyEle: edge,
id: edge.id(),
source: edge.data('source'),
target: edge.data('target')
};
edge.scratch('elk', k);
return k;
};
var makeGraph = function makeGraph(nodes, edges, options) {
var elkNodes = [];
var elkEdges = [];
var elkEleLookup = {};
var graph = {
id: 'root',
children: [],
edges: []
}; // map all nodes
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i];
var k = makeNode(n, options);
elkNodes.push(k);
elkEleLookup[n.id()] = k;
} // map all edges
for (var _i = 0; _i < edges.length; _i++) {
var e = edges[_i];
var _k = makeEdge(e, options);
elkEdges.push(_k);
elkEleLookup[e.id()] = _k;
} // make hierarchy
for (var _i2 = 0; _i2 < elkNodes.length; _i2++) {
var _k2 = elkNodes[_i2];
var _n = _k2._cyEle;
if (!_n.isChild()) {
graph.children.push(_k2);
} else {
var parent = _n.parent();
var parentK = elkEleLookup[parent.id()];
var children = parentK.children = parentK.children || [];
children.push(_k2);
}
}
for (var _i3 = 0; _i3 < elkEdges.length; _i3++) {
var _k3 = elkEdges[_i3]; // put all edges in the top level for now
// TODO does this cause issues in certain edgecases?
/*let e = k._cyEle;
let parentSrc = e.source().parent();
let parentTgt = e.target().parent();
if ( false && parentSrc.nonempty() && parentTgt.nonempty() && parentSrc.same( parentTgt ) ){
let kp = elkEleLookup[ parentSrc.id() ];
kp.edges = kp.edges || [];
kp.edges.push( k );
} else {*/
graph.edges.push(_k3); //}
}
return graph;
};
var Layout = /*#__PURE__*/function () {
function Layout(options) {
_classCallCheck(this, Layout);
var elkOptions = options.elk;
var cy = options.cy;
this.options = src_assign({}, src_defaults, options);
this.options.elk = src_assign({
aspectRatio: cy.width() / cy.height()
}, src_defaults.elk, elkOptions, elkOverrides);
}
_createClass(Layout, [{
key: "run",
value: function run() {
var layout = this;
var options = this.options;
var eles = options.eles;
var nodes = eles.nodes();
var edges = eles.edges();
var elk = new (elk_bundled_js_amd_elkjs_root_ELK_default())();
var graph = makeGraph(nodes, edges, options);
graph['layoutOptions'] = options.elk;
elk.layout(graph).then(function () {
nodes.filter(function (n) {
return !n.isParent();
}).layoutPositions(layout, options, function (n) {
return getPos(n, options);
});
});
return this;
}
}, {
key: "stop",
value: function stop() {
return this; // chaining
}
}, {
key: "destroy",
value: function destroy() {
return this; // chaining
}
}]);
return Layout;
}();
/* harmony default export */ var layout = (Layout);
;// CONCATENATED MODULE: ./src/index.js
// registers the extension on a cytoscape lib ref
var register = function register(cytoscape) {
if (!cytoscape) {
return;
} // can't register if cytoscape unspecified
cytoscape('layout', 'elk', layout); // register with cytoscape.js
};
if (typeof cytoscape !== 'undefined') {
// expose to global cytoscape (i.e. window.cytoscape)
// eslint-disable-next-line no-undef
register(cytoscape);
}
/* harmony default export */ var src = (register);
}();
__webpack_exports__ = __webpack_exports__["default"];
/******/ return __webpack_exports__;
/******/ })()
;
});
@@ -1,19 +0,0 @@
Copyright (c) 2016-2026, The Cytoscape Consortium.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -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();
}
}
})
]);
}
@@ -58,6 +58,9 @@ const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"];
let zeigeAbgeschlossene = false;
/** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */
let brettFilter = "";
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
@@ -114,9 +117,14 @@ async function ladeTaskboard() {
try {
const alle = (await api("/tasks?limit=200")) || [];
const tasks = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
container.replaceChildren();
// #684: With units, every task is on a unit's board — pick one to see only that.
const bretter = brettAuswahl(alle);
if (bretter) container.append(bretter);
if (tasks.length === 0) {
container.append(
erstelleKarte(t("tasks.empty"), [
@@ -141,6 +149,30 @@ async function ladeTaskboard() {
}
}
/**
* #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it
* shows only boards that hold something; absent when no task is on a unit's board.
*/
function brettAuswahl(tasks) {
const bretter = new Map();
for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board);
if (bretter.size === 0) return null;
const auswahl = erstelleElement("select", {
"aria-label": t("tasks.board"),
onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); }
},
erstelleElement("option", { value: "", text: t("tasks.boardAll") }),
erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }),
...[...bretter.entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad })));
auswahl.value = brettFilter;
return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" },
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
function terminText(task) {
if (!task.when) return t("tasks.scheduleNone");
const art = task.when.kind;
@@ -155,7 +187,10 @@ function erstelleAufgabenKarte(task) {
"div",
{ klasse: "task-status-zeile gruppe-horizontal" },
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}` })
erstelleElement("span", {
klasse: "gedaempft text-klein",
text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}`
})
)
);
File diff suppressed because it is too large Load Diff
+237 -137
View File
@@ -1,3 +1,5 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
@@ -24,9 +26,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
public string Name => "FileRW";
public string Description => "File management across two separate workspace directories:\n" +
public string Description => "File management in workspaces:\n" +
"- workspace='personal': Your PRIVATE folder — only you have access.\n" +
"- workspace='shared': SHARED folder — all agents on the team can access it.\n" +
"- workspace='shared': the instance's SHARED folder, if you have access to it.\n" +
"- workspace='unit': your unit's workspace; other named workspaces you are a member of " +
"are listed in the Organisation section of your instructions.\n" +
"Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" +
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
@@ -49,8 +53,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
},
"workspace": {
"type": "string",
"enum": ["personal", "shared"],
"description": "The workspace in which to execute the action (source workspace for copy). Default is 'personal'."
"description": "The workspace in which to execute the action (source workspace for copy): 'personal', 'shared', 'unit' or the name of a workspace you are a member of. Default is 'personal'."
},
"path": {
"type": "string",
@@ -85,8 +88,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
},
"destinationWorkspace": {
"type": "string",
"enum": ["personal", "shared"],
"description": "Destination workspace for copy. Default is the same workspace as 'workspace'."
"description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'."
},
"destinationPath": {
"type": "string",
@@ -126,36 +128,37 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var action = input.GetProperty("action").GetString()
?? throw new ArgumentException("'action' is required");
var workspace = (input.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
if (ResolveArea(requested, context) is not { } area)
return ToolResult.Fail(UnknownWorkspace(requested!, context));
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(rootPath))
{
var wsName = workspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace";
return ToolResult.Fail($"Configuration error: '{wsName}' is not defined.");
}
if (string.IsNullOrWhiteSpace(area.Root))
return ToolResult.Fail($"Configuration error: the folder of workspace '{area.Name}' is not defined.");
// Sicherstellen, dass rootPath absolut ist
rootPath = Path.GetFullPath(rootPath);
var rootPath = Path.GetFullPath(area.Root);
// Zugriffsprüfung
if (!IsActionAllowed(action, workspace, context))
if (!IsActionAllowed(action, area))
{
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.");
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.");
}
// A workspace with a member list comes into being when it is first used.
if (!area.IsPersonal && area.Name != WorkspaceLayout.Shared)
Directory.CreateDirectory(rootPath);
try
{
return action switch
{
"read" => await HandleReadAsync(input, rootPath, workspace, context, ct),
"read_many" => await HandleReadManyAsync(input, rootPath, workspace, context, ct),
"write" => await HandleWriteAsync(input, rootPath, workspace, context, ct),
"append" => await HandleAppendAsync(input, rootPath, workspace, context, ct),
"list" => await HandleListAsync(input, rootPath, workspace, context, ct),
"delete" => await HandleDeleteAsync(input, rootPath, workspace, context, ct),
"copy" => await HandleCopyAsync(input, rootPath, workspace, context, ct),
"read" => await HandleReadAsync(input, rootPath, area, context, ct),
"read_many" => await HandleReadManyAsync(input, rootPath, area, context, ct),
"write" => await HandleWriteAsync(input, rootPath, area, context, ct),
"append" => await HandleAppendAsync(input, rootPath, area, context, ct),
"list" => await HandleListAsync(input, rootPath, area, context, ct),
"delete" => await HandleDeleteAsync(input, rootPath, area, context, ct),
"copy" => await HandleCopyAsync(input, rootPath, area, context, ct),
"stock_add" => await HandleStockAddAsync(input, context, ct),
_ => ToolResult.Fail($"Unknown action: {action}")
};
@@ -178,22 +181,24 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
return null;
var action = a.GetString() ?? "";
var workspace = (input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
? w.GetString() : null) ?? "personal";
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
? w.GetString() : null;
if (!IsActionAllowed(action, workspace, context))
return $"Access denied: Action '{action}' is not allowed in workspace '{workspace}' for this agent.";
if (ResolveArea(requested, context) is not { } area)
return UnknownWorkspace(requested!, context);
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (action != "delete" || string.IsNullOrWhiteSpace(rootPath))
if (!IsActionAllowed(action, area))
return $"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.";
if (action != "delete" || string.IsNullOrWhiteSpace(area.Root))
return null;
try
{
rootPath = Path.GetFullPath(rootPath);
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
var rootPath = Path.GetFullPath(area.Root);
var path = GetAndValidatePath(input, rootPath, area, false, context);
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted.";
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -205,51 +210,133 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
return null;
}
private bool IsActionAllowed(string action, string workspace, AgentToolContext context)
// ─── Workspaces (#684) ───
/// <summary>The folder in the shared workspace that holds the task files.</summary>
private const string TasksFolder = "tasks";
/// <summary>A workspace as one call sees it: where it lies and what the agent may do there.</summary>
private sealed record Area(string Name, string? Root, WorkspaceAccess Access, IReadOnlyList<string> ProtectedPaths)
{
if (workspace == "personal") return true; // Alles erlaubt
public bool IsPersonal => Name == WorkspaceLayout.Personal;
}
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
/// <summary>
/// Resolves the name an agent gave. "personal" and "shared" work as they always did —
/// "shared" still by the agent's own <c>sharedAccessLevel</c>. "unit" is the own unit's
/// workspace; any other name must be a workspace the agent is a member of. Everything
/// else is <c>null</c>: an unknown name and a workspace the agent may not see get the
/// same answer, so the answer does not reveal that it exists.
/// </summary>
private static Area? ResolveArea(string? requested, AgentToolContext context)
{
var name = string.IsNullOrWhiteSpace(requested) ? WorkspaceLayout.Personal : requested.Trim();
if (level == "Denied") return false;
if (name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase))
return new Area(WorkspaceLayout.Personal, context.WorkspacePath, WorkspaceAccess.Manage, []);
if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase))
return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context));
if (context.Organisation is not { } org)
return null;
var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase)
? org.UnitWorkspace(org.UnitOf(context.AgentId)?.Id)
: org.Workspace(name);
if (workspace is null)
return null;
var access = org.AccessTo(context.AgentId, workspace.Id);
return access == WorkspaceAccess.None
? null
: new Area(workspace.Id, WorkspaceLayout.PathOf(context.SharedWorkspacePath, workspace.Id), access,
NormaliseProtectedPaths(workspace.ProtectedPaths));
}
/// <summary>The answer to a name that is not a workspace this agent may use — with the ones it may.</summary>
private static string UnknownWorkspace(string requested, AgentToolContext context)
{
var names = new List<string> { WorkspaceLayout.Personal };
if (SharedAccess(context) > WorkspaceAccess.None)
names.Add(WorkspaceLayout.Shared);
if (context.Organisation is { } org)
{
names.AddRange(org.WorkspacesFor(context.AgentId).Select(g =>
g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id));
}
return $"Unknown workspace '{requested}'. You can use: {string.Join(", ", names)}.";
}
/// <summary>The one shared folder, by the level the agent carries for it — as before #684.</summary>
private static WorkspaceAccess SharedAccess(AgentToolContext context)
=> (context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied") switch
{
"Read" => WorkspaceAccess.Read,
"ReadWrite" => WorkspaceAccess.Write,
"Admin" => WorkspaceAccess.Manage,
_ => WorkspaceAccess.None
};
private static bool IsActionAllowed(string action, Area area)
{
if (area.IsPersonal) return true; // Alles erlaubt
return action switch
{
"read" or "read_many" or "list" or "copy" => true,
"write" or "append" or "stock_add" => level is "ReadWrite" or "Admin",
"delete" => level is "Admin",
"read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read,
"write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write,
"delete" => area.Access >= WorkspaceAccess.Manage,
_ => false
};
}
/// <summary>Managing a workspace includes changing and deleting protected files — as the old Admin level did.</summary>
private static bool CanOverrideProtection(Area area) => area.Access >= WorkspaceAccess.Manage;
/// <summary>
/// Prüft ob ein Pfad innerhalb eines geschützten Bereichs liegt.
/// Geschützte Pfade erlauben nur: read, list, append, write (nur neue Dateien), stock_add.
/// Kein Überschreiben existierender Dateien, kein Löschen.
/// Admin-Level umgeht den Schutz.
/// Wer den Bereich verwaltet, umgeht den Schutz.
/// </summary>
private bool IsPathProtected(string fullPath, string rootPath, AgentToolContext context)
private static bool IsPathProtected(string fullPath, string rootPath, Area area)
{
var protectedPaths = GetProtectedPaths(context);
if (protectedPaths.Count == 0) return false;
if (area.ProtectedPaths.Count == 0) return false;
var relativePath = Path.GetRelativePath(rootPath, fullPath)
.Replace('\\', '/').TrimStart('/');
// GetProtectedPaths hängt an jeden Eintrag ein "/" an, deshalb greift
// StartsWith hier auf einer echten Verzeichnisgrenze: "stocks/" trifft
// "stocks/x.json", aber nicht "stocks-alt/x.json".
return protectedPaths.Any(pp =>
// Jeder Eintrag endet auf "/", deshalb greift StartsWith hier auf einer echten
// Verzeichnisgrenze: "stocks/" trifft "stocks/x.json", aber nicht "stocks-alt/x.json".
return area.ProtectedPaths.Any(pp =>
relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) ||
relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase));
}
private bool IsAdminLevel(AgentToolContext context)
/// <summary>
/// #684: With units, the task files in the shared folder are closed to FileRW. Written
/// directly, a task file would pass every check the task board makes — the board of the
/// unit, the address book of the assignment. Tasks go through the Taskboard tool. Without
/// units nothing changes, so running installations keep what they have.
/// </summary>
private static bool IsBlocked(string fullPath, Area area, AgentToolContext context)
{
var level = context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied";
return level == "Admin";
if (area.Name != WorkspaceLayout.Shared || context.Organisation?.HasUnits != true || string.IsNullOrWhiteSpace(area.Root))
return false;
var relative = Path.GetRelativePath(Path.GetFullPath(area.Root), fullPath).Replace('\\', '/');
return relative.Equals(TasksFolder, StringComparison.OrdinalIgnoreCase)
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
}
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
=> paths
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
.Where(p => p.Length > 0)
.Select(p => p + "/")
.ToList();
private static List<string> GetProtectedPaths(AgentToolContext context)
{
if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null)
@@ -295,13 +382,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (action is not ("write" or "append"))
return ProbeFinding.Unknown($"Nothing to probe for action '{action ?? "?"}'.");
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(rootPath))
var requested = arguments.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area)
return ProbeFinding.Unknown("Workspace path is unknown.");
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
var path = GetAndValidatePath(arguments, Path.GetFullPath(root), area, true, context);
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
if (!File.Exists(path))
@@ -349,19 +434,23 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
private string GetAndValidatePath(JsonElement input, string rootPath, Area area, bool checkExtension, AgentToolContext context)
{
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
// Prüft Ausbruch aus dem Workspace, absolute Pfade und Alternate Data Streams.
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, workspace);
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, area.Name);
if (IsBlocked(fullPath, area, context))
throw new UnauthorizedAccessException(
"Tasks are kept on the task board — use the Taskboard tool (task_list, task_find, task_create, task_update), not their files.");
// Extension Check
if (checkExtension && !Directory.Exists(fullPath))
{
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
var configKey = workspace == "shared" ? "sharedAllowedExtensions" : "personalAllowedExtensions";
var configKey = area.IsPersonal ? "personalAllowedExtensions" : "sharedAllowedExtensions";
var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je
? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList()
@@ -369,7 +458,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (!allowedExtensions.Contains(extension))
{
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{workspace}'.");
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{area.Name}'.");
}
}
@@ -377,37 +466,42 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
/// <summary>
/// #662: Gibt es den Pfad im jeweils anderen Workspace, sagt die Fehlermeldung das.
/// #662: Gibt es den Pfad in einem anderen Workspace, sagt die Fehlermeldung das.
///
/// <para>Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig
/// <c>workspace: "shared"</c> und bekommt nur "nicht gefunden" zurueck. Es rät dann
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis kommt nur, wenn der
/// Agent den anderen Workspace auch benutzen darf - sonst waere er eine Einladung
/// zu etwas, das ohnehin abgewiesen wird.</para>
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis nennt nur Workspaces,
/// die der Agent fuer diese Aktion auch benutzen darf - sonst waere er eine Einladung
/// zu etwas, das ohnehin abgewiesen wird, und verriete obendrein, dass es sie gibt.</para>
///
/// <para>Seit #684 gibt es mehr als zwei: gesucht wird in allen, die der Agent kennt.</para>
/// </summary>
private string AndererWorkspaceHinweis(
string action, string workspace, string? relativePath, AgentToolContext context, bool verzeichnis)
string action, Area area, string? relativePath, AgentToolContext context, bool verzeichnis)
{
var anderer = workspace == "shared" ? "personal" : "shared";
if (!IsActionAllowed(action, anderer, context))
return "";
var candidates = new List<string> { WorkspaceLayout.Personal, WorkspaceLayout.Shared };
if (context.Organisation is { } org)
candidates.AddRange(org.WorkspacesFor(context.AgentId).Select(g => g.Workspace.Id));
var root = anderer == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(root))
return "";
foreach (var name in candidates.Where(n => !string.Equals(n, area.Name, StringComparison.OrdinalIgnoreCase)))
{
if (ResolveArea(name, context) is not { Root: { Length: > 0 } root } other || !IsActionAllowed(action, other))
continue;
try
{
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, anderer);
var da = verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad);
return da
? $" The path exists in the {anderer} workspace — call with workspace: \"{anderer}\"."
: "";
}
catch
{
return ""; // Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
try
{
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, other.Name);
if (IsBlocked(pfad, other, context))
continue;
if (verzeichnis ? Directory.Exists(pfad) : File.Exists(pfad))
return $" The path exists in the {other.Name} workspace — call with workspace: \"{other.Name}\".";
}
catch
{
// Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
}
}
return "";
}
private static string? RelativerPfad(JsonElement input)
@@ -415,14 +509,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
? p.GetString()
: null;
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
var path = GetAndValidatePath(input, rootPath, area, true, context);
if (!File.Exists(path))
{
return ToolResult.Fail(
$"File not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("read", workspace, RelativerPfad(input), context, verzeichnis: false));
$"File not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("read", area, RelativerPfad(input), context, verzeichnis: false));
}
var fileLock = GetFileLock(path);
@@ -431,7 +525,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
var content = await File.ReadAllTextAsync(path, ct);
var relativePath = Path.GetRelativePath(rootPath, path);
return ToolResult.Ok($"[{workspace}:/{relativePath}]\n{content}");
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
}
finally
{
@@ -458,17 +552,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
/// und <see cref="MaxZeichenJeAufruf"/> Zeichen; was nicht mehr hineinpasst, wird am
/// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann.</para>
/// </summary>
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
{
var (dateien, fehler) = SammleDateien(input, rootPath, workspace, context);
var (dateien, fehler) = SammleDateien(input, rootPath, area, context);
if (fehler is not null)
return ToolResult.Fail(fehler);
if (dateien.Count == 0)
{
return ToolResult.Fail(
$"No matching file found in {workspace} workspace."
+ AndererWorkspaceHinweis("read_many", workspace, RelativerPfad(input), context, verzeichnis: true));
$"No matching file found in {area.Name} workspace."
+ AndererWorkspaceHinweis("read_many", area, RelativerPfad(input), context, verzeichnis: true));
}
var uebersprungen = new List<string>();
@@ -490,13 +584,13 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
finally { sperre.Release(); }
text.Append($"\n--- {workspace}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
gelesen++;
}
uebersprungen.AddRange(dateien.Skip(MaxDateienJeAufruf).Select(d => Path.GetRelativePath(rootPath, d).Replace('\\', '/')));
var kopf = new StringBuilder($"[{workspace} Workspace] {gelesen} of {dateien.Count} file(s) read.");
var kopf = new StringBuilder($"[{area.Name} Workspace] {gelesen} of {dateien.Count} file(s) read.");
if (fehlend.Count > 0)
kopf.Append($"\nNot found: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}");
if (uebersprungen.Count > 0)
@@ -511,7 +605,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
/// Jeder Pfad geht durch dieselbe Pruefung wie ein einzelnes <c>read</c>.
/// </summary>
private (List<string> Dateien, string? Fehler) SammleDateien(
JsonElement input, string rootPath, string workspace, AgentToolContext context)
JsonElement input, string rootPath, Area area, AgentToolContext context)
{
try
{
@@ -523,12 +617,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (eintrag.ValueKind != JsonValueKind.String) continue;
var relativ = eintrag.GetString();
if (string.IsNullOrWhiteSpace(relativ)) continue;
dateien.Add(PruefeLesepfad(relativ, rootPath, workspace, context));
dateien.Add(PruefeLesepfad(relativ, rootPath, area, context));
}
return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null);
}
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), workspace);
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), area.Name);
if (!Directory.Exists(verzeichnis))
return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer.
@@ -544,7 +638,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// Dieselbe Endungspruefung wie beim einzelnen Lesen - was dort verboten ist,
// kommt auch hier nicht durch, nur ohne den Lauf abzubrechen.
return (gefunden.Where(f => IstLesbar(f, workspace, context)).ToList(), null);
return (gefunden.Where(f => IstLesbar(f, area, context)).ToList(), null);
}
catch (UnauthorizedAccessException ex)
{
@@ -552,18 +646,18 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
private string PruefeLesepfad(string relativePath, string rootPath, string workspace, AgentToolContext context)
private string PruefeLesepfad(string relativePath, string rootPath, Area area, AgentToolContext context)
{
var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath });
return GetAndValidatePath(json, rootPath, workspace, true, context);
return GetAndValidatePath(json, rootPath, area, true, context);
}
private bool IstLesbar(string vollerPfad, string workspace, AgentToolContext context)
private bool IstLesbar(string vollerPfad, Area area, AgentToolContext context)
{
try
{
var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) });
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, workspace, true, context);
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, area, true, context);
return true;
}
catch (UnauthorizedAccessException)
@@ -572,9 +666,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
var path = GetAndValidatePath(input, rootPath, area, true, context);
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var fileLock = GetFileLock(path);
@@ -582,7 +676,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
try
{
// Schutz: In geschützten Bereichen darf nur geschrieben werden wenn die Datei NICHT existiert
if (workspace == "shared" && !IsAdminLevel(context) && File.Exists(path) && IsPathProtected(path, rootPath, context))
if (!CanOverrideProtection(area) && File.Exists(path) && IsPathProtected(path, rootPath, area))
{
var relPath = Path.GetRelativePath(rootPath, path);
return ToolResult.Fail(
@@ -600,7 +694,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
// Agenten aber nicht.
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
return ToolResult.Ok($"[{workspace} Workspace] File successfully written: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
{
@@ -608,9 +702,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, true, context);
var path = GetAndValidatePath(input, rootPath, area, true, context);
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
var fileLock = GetFileLock(path);
@@ -625,7 +719,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
await writer.WriteAsync(content.AsMemory(), ct);
return ToolResult.Ok($"[{workspace} Workspace] Content successfully appended to: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
{
@@ -652,14 +746,14 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
/// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die
/// schlimmere Falle.</para>
/// </summary>
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
var path = GetAndValidatePath(input, rootPath, area, false, context);
if (!Directory.Exists(path))
{
return ToolResult.Fail(
$"Directory not found in {workspace} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("list", workspace, RelativerPfad(input), context, verzeichnis: true));
$"Directory not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
+ AndererWorkspaceHinweis("list", area, RelativerPfad(input), context, verzeichnis: true));
}
var muster = GetOptionalString(input, "pattern");
@@ -669,7 +763,9 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
List<string> pfade;
try
{
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster).ToList();
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
.Where(e => !IsBlocked(e, area, context))
.ToList();
}
catch (ArgumentException ex)
{
@@ -706,7 +802,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
: (absteigend ? "newest first" : "oldest first");
var text = new StringBuilder();
text.Append($"[{workspace} Workspace] Directory: {workspace}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
text.Append($"[{area.Name} Workspace] Directory: {area.Name}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}.");
if (!string.IsNullOrWhiteSpace(muster))
text.Append($" Pattern: {muster}.");
@@ -747,12 +843,12 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
private static int? GetInt(JsonElement input, string name)
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, string workspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
{
var path = GetAndValidatePath(input, rootPath, workspace, false, context);
var path = GetAndValidatePath(input, rootPath, area, false, context);
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
if (workspace == "shared" && !IsAdminLevel(context) && IsPathProtected(path, rootPath, context))
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
{
var relPath = Path.GetRelativePath(rootPath, path);
return ToolResult.Fail(
@@ -767,15 +863,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (File.Exists(path))
{
File.Delete(path);
return ToolResult.Ok($"[{workspace} Workspace] File deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
else if (Directory.Exists(path))
{
Directory.Delete(path, true);
return ToolResult.Ok($"[{workspace} Workspace] Directory deleted: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
return ToolResult.Ok($"[{area.Name} Workspace] Directory deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
return ToolResult.Fail($"File or directory not found in {workspace} workspace.");
return ToolResult.Fail($"File or directory not found in {area.Name} workspace.");
}
finally
{
@@ -783,32 +879,36 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
}
}
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, string sourceWorkspace, AgentToolContext context, CancellationToken ct)
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct)
{
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceWorkspace, false, context);
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
var destWorkspace = (input.TryGetProperty("destinationWorkspace", out var dw) ? dw.GetString() : null) ?? sourceWorkspace;
var requestedDestination = input.TryGetProperty("destinationWorkspace", out var dw) && dw.ValueKind == JsonValueKind.String
? dw.GetString()
: null;
var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null;
if (string.IsNullOrWhiteSpace(destRelPath))
return ToolResult.Fail("'destinationPath' is required for copy action.");
var destRootPath = destWorkspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(destRootPath))
return ToolResult.Fail($"Configuration error: '{(destWorkspace == "shared" ? "SharedWorkspace" : "PersonalWorkspace")}' is not defined.");
var destArea = requestedDestination is null ? sourceArea : ResolveArea(requestedDestination, context);
if (destArea is null)
return ToolResult.Fail(UnknownWorkspace(requestedDestination!, context));
if (string.IsNullOrWhiteSpace(destArea.Root))
return ToolResult.Fail($"Configuration error: the folder of workspace '{destArea.Name}' is not defined.");
destRootPath = Path.GetFullPath(destRootPath);
var destRootPath = Path.GetFullPath(destArea.Root);
if (!IsActionAllowed("write", destWorkspace, context))
return ToolResult.Fail($"Access denied: Writing to workspace '{destWorkspace}' is not allowed for this agent.");
if (!IsActionAllowed("write", destArea))
return ToolResult.Fail($"Access denied: Writing to workspace '{destArea.Name}' is not allowed for this agent.");
var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement;
var destPath = GetAndValidatePath(destInput, destRootPath, destWorkspace, false, context);
var destPath = GetAndValidatePath(destInput, destRootPath, destArea, false, context);
if (File.Exists(sourcePath))
{
// Schutz: Kein Überschreiben in geschützten Bereichen per Copy
if (destWorkspace == "shared" && !IsAdminLevel(context) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, context))
if (!CanOverrideProtection(destArea) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, destArea))
{
var relPath = Path.GetRelativePath(destRootPath, destPath);
return ToolResult.Fail(
@@ -832,10 +932,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var destDir = Path.GetDirectoryName(destPath);
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, context)), ct);
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
return ToolResult.Ok($"File copied: {srcLabel} → {dstLabel}");
}
finally
@@ -852,8 +952,8 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
{
await Task.Run(() => CopyDirectory(sourcePath, destPath), ct);
var srcLabel = $"{sourceWorkspace}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destWorkspace}:/{Path.GetRelativePath(destRootPath, destPath)}";
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}");
}
@@ -895,7 +995,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
sharedRoot = Path.GetFullPath(sharedRoot);
// Zugriffsprüfung
if (!IsActionAllowed("stock_add", "shared", context))
if (ResolveArea(WorkspaceLayout.Shared, context) is not { } shared || !IsActionAllowed("stock_add", shared))
return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed.");
// Sanitize Ticker für Verzeichnisname
@@ -22,7 +22,8 @@ public sealed class TaskboardTool : IAgentTool
public string Name => "Taskboard";
public string Description => """
The shared taskboard — persists across runs and restarts.
The taskboard — persists across runs and restarts. If your instance has units, you
see your unit's board; a lead also sees the boards of the units below.
Use it to track and distribute work instead of keeping it in context:
own open items, assignments to other agents, recurring schedules.
@@ -157,15 +158,18 @@ public sealed class TaskboardTool : IAgentTool
&& CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach)
return ToolResult.Fail(outOfReach);
// #684: The boards this agent sees; null means all of them (no units).
var visible = TaskBoard.VisibleTo(context.Organisation, context.AgentId);
try
{
return action switch
{
"task_create" => await CreateAsync(input, board, context.AgentId, ct),
"task_find" => await FindAsync(input, board, ct),
"task_list" => await ListAsync(input, board, ct),
"task_update" => await UpdateAsync(input, board, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
"task_create" => await CreateAsync(input, board, context.AgentId, visible, ct),
"task_find" => await FindAsync(input, board, visible, ct),
"task_list" => await ListAsync(input, board, visible, ct),
"task_update" => await UpdateAsync(input, board, visible, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, visible, ct),
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
@@ -206,13 +210,13 @@ public sealed class TaskboardTool : IAgentTool
/// the first sentence.</para>
/// </summary>
private static async Task<ToolResult> FindAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var title = GetString(input, "title") ?? GetString(input, "search");
if (string.IsNullOrWhiteSpace(title))
return ToolResult.Fail("'title' is required — what should be searched for?");
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
var treffer = await SucheAehnlicheAsync(board, title, null, visible, ct);
if (treffer.Count == 0)
return ToolResult.Ok($"No open task resembles \"{title.Trim()}\". You can create it with task_create.");
@@ -232,18 +236,29 @@ public sealed class TaskboardTool : IAgentTool
return ToolResult.Ok(sb.ToString());
}
/// <summary>Offene Aufgaben mit verwandtem Titel, engste zuerst.</summary>
/// <summary>
/// Offene Aufgaben mit verwandtem Titel, engste zuerst — nur auf den Brettern, die der
/// Agent sieht (#684). Eine aehnliche Aufgabe der Buchhaltung haelt keine Marketing-Aufgabe
/// auf, und die Ablehnung verraete sonst, was dort liegt.
/// </summary>
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
TaskboardService board, string title, string? exceptId, CancellationToken ct)
TaskboardService board, string title, string? exceptId, Func<TaskItem, bool>? visible, CancellationToken ct)
{
// Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks
// stayed invisible once the board grew beyond that.
var alle = await board.ListAllAsync(ct);
return TaskSimilarity.FindOpen(alle, title, exceptId);
return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId);
}
/// <summary>
/// #684: A task on a board the agent does not see is answered like one that does not
/// exist — the answer must not tell that it is there.
/// </summary>
private static async Task<bool> CanSeeAsync(TaskboardService board, string id, Func<TaskItem, bool>? visible, CancellationToken ct)
=> visible is null || (await board.GetAsync(id, ct) is { } task && visible(task));
private static async Task<ToolResult> CreateAsync(
JsonElement input, TaskboardService board, string author, CancellationToken ct)
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var title = GetString(input, "title");
if (string.IsNullOrWhiteSpace(title))
@@ -256,7 +271,7 @@ public sealed class TaskboardTool : IAgentTool
// existing one — so that later one can see how often and for how long.
if (GetBool(input, "force") != true)
{
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
var dublette = (await SucheAehnlicheAsync(board, title, null, visible, ct))
.FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task;
if (dublette is not null)
@@ -285,7 +300,9 @@ public sealed class TaskboardTool : IAgentTool
RequireApproval = GetBool(input, "require_approval") ?? false,
Acceptance = GetString(input, "acceptance")?.Trim() ?? "",
BlockedBy = GetStringList(input, "blocked_by"),
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false,
// #684: decides the board when the task is for a human or a fresh run.
CreatedBy = author
};
var created = await board.CreateAsync(definition, ct);
@@ -296,7 +313,7 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> ListAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var query = new TaskQuery
{
@@ -306,7 +323,7 @@ public sealed class TaskboardTool : IAgentTool
Limit = GetInt(input, "limit") ?? 50
};
var tasks = await board.ListAsync(query, ct);
var tasks = await board.ListAsync(query, visible, ct);
if (tasks.Count == 0)
return ToolResult.Ok("No matching tasks.");
@@ -314,11 +331,13 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> UpdateAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("'id' is required.");
if (!await CanSeeAsync(board, id.Trim(), visible, ct))
return ToolResult.Fail($"No task found with ID '{id}'.");
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
@@ -348,11 +367,13 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> CommentAsync(
JsonElement input, TaskboardService board, string author, CancellationToken ct)
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("'id' is required.");
if (!await CanSeeAsync(board, id.Trim(), visible, ct))
return ToolResult.Fail($"No task found with ID '{id}'.");
var text = GetString(input, "text");
if (string.IsNullOrWhiteSpace(text))
+5 -1
View File
@@ -117,7 +117,11 @@ public sealed record TaskDto(
DateTime? NextRunUtc = null,
TaskLastRunDto? LastRun = null,
string? Hinweis = null,
TaskBudgetMissDto? BudgetMiss = null);
TaskBudgetMissDto? BudgetMiss = null,
string? CreatedBy = null,
// #684: the unit whose board the task is on, computed from the organisation; null on the top level.
string? Board = null,
string? BoardPath = null);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
+10 -1
View File
@@ -153,7 +153,16 @@ internal static class Mapping
BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
task.CatchUpPending, task.IsCarriedOver)
: null);
: null,
CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy);
/// <summary>#684: The board of a task, for a list the human sees whole.</summary>
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
{
if (!org.HasUnits || Core.Tasks.TaskBoard.UnitOf(task, org) is not { } unit)
return dto;
return dto with { Board = unit, BoardPath = org.PathOf(unit) };
}
/// <summary>
/// Ein wartender Aufruf samt seiner eingefrorenen Argumente.
@@ -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 = "");
@@ -260,9 +260,14 @@ internal static class ApiEndpoints
// #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren
// /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte.
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
await host.TaskAdmin.ListAsync(limit, ct) is { } list
? Results.Ok(list.Select(t => t.ToDto()).ToList())
: Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."));
{
if (await host.TaskAdmin.ListAsync(limit, ct) is not { } list)
return Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
// #684: A human sees every board; each task says which one it is on.
var org = host.Organisation.Current();
return Results.Ok(list.Select(t => t.ToDto().WithBoard(t.Task, org)).ToList());
});
tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) =>
(await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse());
@@ -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);
}
@@ -0,0 +1,191 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #684: Workspaces in the administration. A unit gets its own with it; a workspace across
/// units is created on purpose, with an owner; nothing filed is ever deleted from here.
/// </summary>
public sealed class WorkspaceAdministrationTests : IDisposable
{
private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", "ws-" + Guid.NewGuid().ToString("N")[..8], "Agents");
private readonly TestHost _test;
public WorkspaceAdministrationTests()
{
_test = new TestHost(Agent("buyer"), Agent("ads"), Agent("bookkeeper"));
}
public void Dispose()
{
_test.Dispose();
try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private AgentConfig Agent(string id) => new()
{
AgentId = id,
DisplayName = id,
SharedWorkspacePath = Path.Combine(_agents, "SharedWorkspace")
};
private OrgAdministration Org => _test.Host.Organisation;
private List<WorkspaceConfig> Workspaces => _test.Host.Instance.Workspaces;
private void Setup()
{
Org.CreateUnit("purchasing", "Purchasing", null).Ok.ShouldBeTrue();
Org.CreateUnit("marketing", "Marketing", null).Ok.ShouldBeTrue();
Org.MoveAgent("buyer", "purchasing", "setup").Ok.ShouldBeTrue();
Org.MoveAgent("ads", "marketing", "setup").Ok.ShouldBeTrue();
}
private void File(string workspaceId, string name)
{
var folder = Org.WorkspaceFolder(workspaceId)!;
Directory.CreateDirectory(folder);
System.IO.File.WriteAllText(Path.Combine(folder, name), "x");
}
[Fact]
public void A_new_unit_comes_with_its_own_workspace_for_itself_and_below()
{
Setup();
var own = Workspaces.Single(w => w.Id == "purchasing");
own.OwnerUnitId.ShouldBe("purchasing");
own.Members.ShouldHaveSingleItem().UnitId.ShouldBe("purchasing");
own.Members[0].Access.ShouldBe(WorkspaceAccess.Write);
own.Members[0].IncludeSubUnits.ShouldBeTrue();
Org.Current().AccessTo("buyer", "purchasing").ShouldBe(WorkspaceAccess.Write);
Org.Current().AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.None);
}
[Fact]
public void A_unit_cannot_take_a_reserved_name_or_the_id_of_an_existing_workspace()
{
Org.CreateUnit("shared", "Shared", null).Ok.ShouldBeFalse();
Org.CreateWorkspace("articles", "Articles", null, null, []).Ok.ShouldBeTrue();
Org.CreateUnit("articles", "Articles team", null).Ok.ShouldBeFalse();
}
[Fact]
public void A_workspace_across_units_has_an_owner_and_levels()
{
Setup();
var result = Org.CreateWorkspace("articles", "Article master", "One file per article.", "purchasing",
[
new WorkspaceMemberInput("purchasing", null, WorkspaceAccess.Manage),
new WorkspaceMemberInput("marketing", null, WorkspaceAccess.Read)
], "purchasing maintains, marketing reads");
result.Ok.ShouldBeTrue(result.Message);
Org.Current().AccessTo("buyer", "articles").ShouldBe(WorkspaceAccess.Manage);
Org.Current().AccessTo("ads", "articles").ShouldBe(WorkspaceAccess.Read);
}
[Fact]
public void Ids_of_units_are_kept_for_their_own_workspaces()
{
Setup();
Org.CreateWorkspace("marketing", "Other", null, null, []).Ok.ShouldBeFalse();
}
[Theory]
[InlineData("nowhere", null, WorkspaceAccess.Read)]
[InlineData(null, "ghost", WorkspaceAccess.Read)]
[InlineData("marketing", "ads", WorkspaceAccess.Read)]
[InlineData(null, null, WorkspaceAccess.Read)]
[InlineData("marketing", null, WorkspaceAccess.None)]
public void Members_must_be_one_existing_unit_or_agent_with_a_level(string? unit, string? agent, WorkspaceAccess access)
{
Setup();
Org.CreateWorkspace("x", "X", null, null, [new WorkspaceMemberInput(unit, agent, access)]).Ok.ShouldBeFalse();
}
[Fact]
public void The_member_list_is_replaced_and_the_old_one_is_audited_away()
{
Setup();
Org.SetWorkspaceMembers("purchasing",
[
new WorkspaceMemberInput("purchasing", null, WorkspaceAccess.Write),
new WorkspaceMemberInput(null, "ads", WorkspaceAccess.Read)
], "ads may look at the orders").Ok.ShouldBeTrue();
Org.Current().AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.Read);
}
[Fact]
public void Protected_folders_are_folders_inside_the_workspace()
{
Setup();
Org.UpdateWorkspace("purchasing", null, null, ["prices/", "/archive"]).Ok.ShouldBeTrue();
Workspaces.Single(w => w.Id == "purchasing").ProtectedPaths.ShouldBe(["prices", "archive"]);
Org.UpdateWorkspace("purchasing", null, null, ["../outside"]).Ok.ShouldBeFalse();
}
[Fact]
public void Only_an_empty_workspace_can_be_deleted_and_never_a_unit_s_own()
{
Setup();
Org.CreateWorkspace("articles", "Articles", null, "purchasing", []).Ok.ShouldBeTrue();
File("articles", "a-100.md");
Org.DeleteWorkspace("articles").Ok.ShouldBeFalse("it holds a file");
Org.DeleteWorkspace("purchasing").Ok.ShouldBeFalse("it goes with its unit");
System.IO.File.Delete(Path.Combine(Org.WorkspaceFolder("articles")!, "a-100.md"));
Org.DeleteWorkspace("articles").Ok.ShouldBeTrue();
}
[Fact]
public void Deleting_a_unit_keeps_what_its_workspace_holds()
{
Setup();
Org.CreateWorkspace("articles", "Articles", null, "purchasing",
[new WorkspaceMemberInput("purchasing", null, WorkspaceAccess.Manage)]).Ok.ShouldBeTrue();
File("purchasing", "orders.md");
Org.MoveAgent("buyer", null, "leaving").Ok.ShouldBeTrue();
var result = Org.DeleteUnit("purchasing", "closed down");
result.Ok.ShouldBeTrue(result.Message);
result.Message.ShouldContain("'purchasing'");
var kept = Workspaces.Single(w => w.Id == "purchasing");
kept.OwnerUnitId.ShouldBeNull();
kept.Members.ShouldBeEmpty();
Workspaces.Single(w => w.Id == "articles").Members.ShouldBeEmpty("the unit left every member list");
System.IO.File.Exists(Path.Combine(Org.WorkspaceFolder("purchasing")!, "orders.md")).ShouldBeTrue();
}
[Fact]
public void Deleting_a_unit_with_an_empty_workspace_removes_both()
{
Setup();
Org.MoveAgent("ads", null, "leaving").Ok.ShouldBeTrue();
Org.DeleteUnit("marketing").Ok.ShouldBeTrue();
Workspaces.ShouldNotContain(w => w.Id == "marketing");
}
[Fact]
public void A_unit_from_before_workspaces_can_get_its_own()
{
_test.Host.Instance.Units.Add(new UnitConfig { Id = "legacy", Name = "Legacy" });
Org.CreateUnitWorkspace("legacy").Ok.ShouldBeTrue();
Org.Current().UnitWorkspace("legacy").ShouldNotBeNull();
Org.CreateUnitWorkspace("legacy").Message.ShouldContain("already");
}
}
@@ -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");
}
}
@@ -0,0 +1,271 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.FileRW;
/// <summary>
/// #684: Named workspaces with member lists. Purchasing files its things in its own
/// workspace, marketing reads the article master that purchasing owns, and accounting's
/// workspace does not exist for anyone outside accounting — not even as a name.
/// </summary>
public sealed class WorkspaceAreasTests : IDisposable
{
private readonly string _agents;
private readonly FileRWTool _tool = new();
private readonly List<UnitConfig> _units =
[
new() { Id = "purchasing", Name = "Purchasing" },
new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" },
new() { Id = "marketing", Name = "Marketing" },
new() { Id = "accounting", Name = "Accounting" }
];
private readonly List<AgentConfig> _people =
[
new() { AgentId = "buyer", UnitId = "purchasing" },
new() { AgentId = "scout", UnitId = "suppliers" },
new() { AgentId = "ads", UnitId = "marketing" },
new() { AgentId = "copywriter", UnitId = "marketing" },
new() { AgentId = "bookkeeper", UnitId = "accounting" },
new() { AgentId = "auditor" }
];
private readonly List<WorkspaceConfig> _workspaces =
[
Own("purchasing"),
Own("suppliers"),
Own("marketing"),
Own("accounting"),
new()
{
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing",
Members =
[
new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage },
new() { UnitId = "marketing", Access = WorkspaceAccess.Read },
new() { AgentId = "auditor", Access = WorkspaceAccess.Read },
new() { AgentId = "copywriter", Access = WorkspaceAccess.Write }
],
ProtectedPaths = ["prices"]
}
];
private static WorkspaceConfig Own(string unit) => new()
{
Id = unit, Name = unit, OwnerUnitId = unit,
Members = [new() { UnitId = unit, Access = WorkspaceAccess.Write }]
};
public WorkspaceAreasTests()
{
_agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents");
Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace"));
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private string Area(string id) => Path.Combine(_agents, "SharedWorkspaces", id);
private AgentToolContext Context(string agentId, string sharedLevel = "Denied", bool withUnits = true)
{
var personal = Path.Combine(_agents, agentId, "Workspace");
Directory.CreateDirectory(personal);
return new AgentToolContext(
agentId, "inst",
new Dictionary<string, object?> { ["sharedAccessLevel"] = sharedLevel },
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
WorkspacePath: personal,
SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"))
{
Organisation = withUnits ? new OrgChart(_units, _people, _workspaces) : new OrgChart([], _people)
};
}
private Task<ToolResult> Run(string agentId, object input, string sharedLevel = "Denied", bool withUnits = true)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(agentId, sharedLevel, withUnits), CancellationToken.None);
// ═══════════════════ the own unit ═══════════════════
[Fact]
public async Task An_agent_writes_to_its_unit_without_knowing_the_unit_id()
{
var result = await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" });
result.Success.ShouldBeTrue(result.ErrorMessage);
File.ReadAllText(Path.Combine(Area("purchasing"), "orders.md")).ShouldBe("3 pallets");
result.Content.ShouldContain("purchasing:/orders.md", Case.Sensitive, "the answer names the workspace by its id");
}
[Fact]
public async Task A_colleague_finds_it_under_the_same_name()
{
await Run("buyer", new { action = "write", workspace = "purchasing", path = "orders.md", content = "3 pallets" });
// The scout sits in suppliers, below purchasing — the purchasing entry includes sub-units.
var read = await Run("scout", new { action = "read", workspace = "purchasing", path = "orders.md" });
read.Success.ShouldBeTrue(read.ErrorMessage);
read.Content.ShouldContain("3 pallets");
}
[Fact]
public async Task A_unit_s_workspace_does_not_exist_for_other_units()
{
await Run("bookkeeper", new { action = "write", workspace = "unit", path = "salaries.md", content = "secret" });
var probe = await Run("ads", new { action = "read", workspace = "accounting", path = "salaries.md" });
var unknown = await Run("ads", new { action = "read", workspace = "does-not-exist", path = "x.md" });
probe.Success.ShouldBeFalse();
probe.ErrorMessage!.ShouldStartWith("Unknown workspace 'accounting'");
// The same answer as for a name that does not exist — nothing tells marketing it is there.
probe.ErrorMessage.Replace("accounting", "X").ShouldBe(unknown.ErrorMessage!.Replace("does-not-exist", "X"));
probe.ErrorMessage.ShouldNotContain("accounting,");
}
[Fact]
public async Task The_answer_to_an_unknown_name_lists_what_the_agent_can_use()
{
var result = await Run("ads", new { action = "list", workspace = "team" }, sharedLevel: "Read");
result.ErrorMessage!.ShouldContain("personal, shared, unit (= marketing), articles");
}
// ═══════════════════ shared across units ═══════════════════
[Fact]
public async Task Marketing_reads_the_article_master_but_cannot_write_it()
{
await Run("buyer", new { action = "write", workspace = "articles", path = "a-100.md", content = "Chair" });
(await Run("ads", new { action = "read", workspace = "articles", path = "a-100.md" })).Content.ShouldContain("Chair");
var write = await Run("ads", new { action = "write", workspace = "articles", path = "a-100.md", content = "Sofa" });
write.Success.ShouldBeFalse();
write.ErrorMessage!.ShouldContain("not allowed in workspace 'articles'");
}
[Fact]
public async Task A_single_agent_can_be_a_member_without_its_unit()
{
var result = await Run("auditor", new { action = "list", workspace = "articles" });
result.Success.ShouldBeTrue(result.ErrorMessage);
}
[Fact]
public async Task Protected_paths_hold_for_writers_but_not_for_managers()
{
(await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "10" })).Success.ShouldBeTrue();
// The copywriter may write here, but prices are append-only for writers…
var overwrite = await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "1" });
overwrite.Success.ShouldBeFalse();
overwrite.ErrorMessage!.ShouldContain("Protected area");
(await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-101.md", content = "5" })).Success
.ShouldBeTrue("adding a new file is what protected paths allow");
// …while purchasing manages the workspace, which includes changing them, as Admin did.
(await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "12" })).Success.ShouldBeTrue();
File.ReadAllText(Path.Combine(Area("articles"), "prices", "a-100.md")).ShouldBe("12");
}
[Fact]
public async Task Writing_is_not_deleting()
{
await Run("buyer", new { action = "write", workspace = "unit", path = "draft.md", content = "x" });
var delete = await Run("buyer", new { action = "delete", workspace = "unit", path = "draft.md" });
delete.Success.ShouldBeFalse("deleting needs Manage, as it needed Admin before");
File.Exists(Path.Combine(Area("purchasing"), "draft.md")).ShouldBeTrue();
}
[Fact]
public async Task Copy_goes_from_the_own_unit_to_a_shared_workspace()
{
await Run("buyer", new { action = "write", workspace = "unit", path = "a-200.md", content = "Table" });
var copy = await Run("buyer", new { action = "copy", workspace = "unit", path = "a-200.md", destinationWorkspace = "articles", destinationPath = "a-200.md" });
copy.Success.ShouldBeTrue(copy.ErrorMessage);
File.ReadAllText(Path.Combine(Area("articles"), "a-200.md")).ShouldBe("Table");
}
[Fact]
public async Task The_hint_points_to_the_workspace_where_the_file_is()
{
await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" });
var result = await Run("buyer", new { action = "read", path = "orders.md" });
result.ErrorMessage!.ShouldContain("exists in the purchasing workspace");
}
// ═══════════════════ the one shared folder ═══════════════════
[Fact]
public async Task Shared_works_as_before_by_the_agent_s_own_level()
{
(await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "x" }, sharedLevel: "ReadWrite"))
.Success.ShouldBeTrue();
var denied = await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "y" }, sharedLevel: "Read");
denied.ErrorMessage!.ShouldContain("not allowed in workspace 'shared'");
}
[Fact]
public async Task With_units_the_task_files_are_closed_to_FileRW()
{
var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks");
Directory.CreateDirectory(tasks);
File.WriteAllText(Path.Combine(tasks, "t-1.md"), "---\nassignee: bookkeeper\n---");
var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Admin");
var write = await Run("ads", new { action = "write", workspace = "shared", path = "tasks/t-2.md", content = "sneaky" }, sharedLevel: "Admin");
var list = await Run("ads", new { action = "list", workspace = "shared" }, sharedLevel: "Admin");
read.Success.ShouldBeFalse();
read.ErrorMessage!.ShouldContain("Taskboard tool");
write.Success.ShouldBeFalse();
File.Exists(Path.Combine(tasks, "t-2.md")).ShouldBeFalse();
list.Content.ShouldNotContain("tasks/");
}
[Fact]
public async Task Without_units_the_task_files_stay_as_they_were()
{
var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks");
Directory.CreateDirectory(tasks);
File.WriteAllText(Path.Combine(tasks, "t-1.md"), "task");
var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Read", withUnits: false);
read.Success.ShouldBeTrue(read.ErrorMessage);
}
[Fact]
public async Task Without_an_organisation_only_personal_and_shared_exist()
{
var context = new AgentToolContext(
"solo", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None,
WorkspacePath: Path.Combine(_agents, "solo"), SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"));
Directory.CreateDirectory(context.WorkspacePath!);
var result = await _tool.ExecuteAsync(
JsonSerializer.SerializeToElement(new { action = "list", workspace = "unit" }), context, CancellationToken.None);
result.ErrorMessage!.ShouldBe("Unknown workspace 'unit'. You can use: personal.");
}
}
@@ -0,0 +1,186 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>
/// #684: One board per unit. The bookkeeper's tasks do not show up for marketing, do not
/// hold up a marketing task as a "duplicate", and cannot be commented on from outside.
///
/// <code>
/// marketing (lead: marketing-lead)
/// └── amazon (lead: amazon-lead) · ads
/// accounting (lead: accounting-lead) · bookkeeper
/// ceo on the top level
/// </code>
/// </summary>
public sealed class UnitBoardTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
private readonly List<AgentConfig> _agents =
[
new() { AgentId = "ceo" },
new() { AgentId = "marketing-lead", UnitId = "marketing" },
new() { AgentId = "amazon-lead", UnitId = "amazon" },
new() { AgentId = "ads", UnitId = "amazon" },
new() { AgentId = "accounting-lead", UnitId = "accounting" },
new() { AgentId = "bookkeeper", UnitId = "accounting" }
];
private readonly List<UnitConfig> _units =
[
new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new() { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" }
];
public UnitBoardTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private string TasksDir => Path.Combine(_directory, "SharedWorkspace", "tasks");
private async Task<ToolResult> Run(string agentId, object input, bool withUnits = true)
{
var context = new AgentToolContext(
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
{
Organisation = withUnits ? new OrgChart(_units, _agents) : new OrgChart([], _agents)
};
return await _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None);
}
private async Task<string> Create(string agentId, string title, string assignee = "@human")
{
var result = await Run(agentId, new { action = "task_create", title, assignee, force = true });
result.Success.ShouldBeTrue(result.ErrorMessage);
return result.Content.Split(' ', 4)[2]; // "Task created: <id> — …"
}
private async Task<string> List(string agentId, bool withUnits = true)
=> (await Run(agentId, new { action = "task_list" }, withUnits)).Content;
[Fact]
public async Task Each_unit_sees_its_own_board()
{
await Create("bookkeeper", "Book the invoices of September");
await Create("ads", "Raise the bid for chairs");
var marketing = await List("ads");
var accounting = await List("bookkeeper");
marketing.ShouldContain("Raise the bid");
marketing.ShouldNotContain("invoices");
accounting.ShouldContain("invoices");
accounting.ShouldNotContain("Raise the bid");
}
[Fact]
public async Task A_lead_also_sees_the_boards_of_the_units_below()
{
await Create("ads", "Raise the bid for chairs");
await Create("bookkeeper", "Book the invoices of September");
var lead = await List("marketing-lead");
lead.ShouldContain("Raise the bid", Case.Sensitive, "amazon lies below marketing");
lead.ShouldNotContain("invoices");
}
[Fact]
public async Task A_task_handed_up_stays_in_view_of_whoever_handed_it_up()
{
// amazon-lead's lead is marketing-lead — the task lands on marketing's board …
await Create("amazon-lead", "Approve the new budget", "@marketing-lead");
(await List("marketing-lead")).ShouldContain("Approve the new budget");
// … and the one who created it still sees it, although amazon's board does not hold it.
(await List("amazon-lead")).ShouldContain("Approve the new budget");
(await List("ads")).ShouldNotContain("Approve the new budget");
}
[Fact]
public async Task A_similar_task_on_another_board_is_no_duplicate()
{
await Create("bookkeeper", "Check the supplier invoice");
var result = await Run("ads", new { action = "task_create", title = "Check the supplier invoice" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldNotContain("Observed again");
(await Run("bookkeeper", new { action = "task_find", title = "Check the supplier invoice" })).Content
.ShouldContain("1 open task(s)", Case.Sensitive, "and nothing was written onto the bookkeeper's task");
}
[Fact]
public async Task A_task_on_another_board_cannot_be_changed_or_commented_on()
{
var id = await Create("bookkeeper", "Book the invoices of September");
var update = await Run("ads", new { action = "task_update", id, status = "canceled" });
var comment = await Run("ads", new { action = "task_comment", id, text = "not needed" });
var unknown = await Run("ads", new { action = "task_comment", id = "t-does-not-exist", text = "x" });
update.Success.ShouldBeFalse();
comment.Success.ShouldBeFalse();
comment.ErrorMessage!.Replace(id, "X").ShouldBe(unknown.ErrorMessage!.Replace("t-does-not-exist", "X"),
"the same answer as for a task that does not exist");
(await _repo.GetAsync(id, default))!.Status.ShouldBe(TaskItemStatus.Todo);
}
[Fact]
public async Task An_open_task_moves_with_its_agent()
{
await Create("amazon-lead", "Prepare the listing", "@ads");
(await List("bookkeeper")).ShouldNotContain("Prepare the listing");
_agents.Single(a => a.AgentId == "ads").UnitId = "accounting";
(await List("bookkeeper")).ShouldContain("Prepare the listing", Case.Sensitive, "the board follows the assignee");
}
[Fact]
public async Task Without_units_there_is_one_board_for_everyone()
{
await Create("bookkeeper", "Book the invoices of September");
(await List("ads", withUnits: false)).ShouldContain("invoices");
}
[Fact]
public async Task The_creator_is_kept_in_the_file_and_survives_a_re_import()
{
var id = await Create("ads", "Raise the bid for chairs");
var file = Directory.GetFiles(TasksDir).Single();
File.ReadAllText(file).ShouldContain("created_by: ads");
// A file written by hand without the field keeps what the database knows.
File.WriteAllText(file, File.ReadAllText(file).Replace("created_by: ads\n", "").Replace("created_by: ads\r\n", ""));
await new TaskboardService(_repo, TasksDir).ImportAllAsync(default);
(await _repo.GetAsync(id, default))!.CreatedBy.ShouldBe("ads");
}
}