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
RichardandClaude Opus 5.5 920d3fed64 Lagebild, Stufe 1: Einheiten als aufklappbare Container (#711)
- TeamOverview.RollUp: je Einheit die Zustaende aller Agenten darin und
  darunter, offene Freigaben und der dringlichste Zustand (Urgency:
  haengt > Problem > Freigabe > Budget > arbeitet > frei). Gezaehlt wird in
  der Instanz, die Seite zeichnet nur.
- /api/team liefert Einheiten und je Agent Einheit und Leitung.
- team.js: Einheiten als Container, zugeklappt als Kasten mit
  Zusammenfassung ("6 agents · ⚠ 1 · ✋ 1 · ▶ 2") in der Farbe des
  dringlichsten Zustands. Kanten laufen zum sichtbaren Stellvertreter und
  werden gebuendelt (×n); was in einer zugeklappten Einheit bleibt, wird
  nicht gezeichnet. Bis 15 Agenten alles offen, darueber nur die oberste
  Ebene; der Zustand je Instanz im localStorage.
- Seitenleiste fuer Einheiten: Pfad, Ziel, Mitglieder (Leitung zuerst),
  Untereinheiten, Auf-/Zuklappen, Link zur Organisation.
- Layout: rectpacking trifft das Seitenverhaeltnis nur grob; es werden
  bis zu vier Vorgaben probiert und die zur Flaeche passendste behalten.
- Sprachschluessel en/de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:27:33 +02:00
RichardandClaude Opus 5.5 5b8656d969 Tests: Verbindungspools nur der eigenen Datenbank leeren statt aller
Die zufaelligen roten Laeufe der vollen Suite (jedes Mal ein anderer Test,
zuletzt PromptHistoryTests mit einer reinen Funktion) hatten eine Ursache:
Zwanzig Testklassen riefen beim Aufraeumen SqliteConnection.ClearAllPools()
auf. Das ist prozessweit und nahm einem parallel laufenden Test die
Verbindung mitten im Oeffnen weg - ObjectDisposedException in ApplyPragmas.

- SqliteStorage.ReleasePooledConnections(pfad) leert nur die Pools der
  Datenbanken unter einer Datei oder einem Verzeichnis.
- BackupService oeffnet die Quelle fuer VACUUM INTO ohne Pool: eine
  einmalige Kopie soll die Datei danach nicht offen halten.
- PoolReleaseTests: die freigegebene Datenbank laesst sich loeschen, die
  andere arbeitet weiter.

Vier volle Laeufe hintereinander gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:27:33 +02:00
RichardandClaude Opus 5.5 f8d0ce94fe Test: Parallelitaet verschiedener Agenten ueber Ueberlappung statt Wanduhr pruefen
Verschiedene_Agenten_laufen_wirklich_parallel liess die Attrappe nur mit Text
antworten: Das langsame Werkzeug lief nie, und geprueft wurde, ob drei leere
Laeufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
Agenten und schlug unter Last fehl. Nach dem Merge von #683 fiel ein Core-Test
einmal in einem parallelen Volllauf, danach in elf Laeufen nicht mehr; dieser
war der letzte mit einer engen Wanduhr-Schwelle. Jetzt ruft jeder Agent das
Werkzeug auf, und bewiesen wird die Ueberlappung (MaxConcurrent > 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:59:46 +02:00
RichardandClaude Opus 5.5 f34756f4bb Merge feat/683-einheiten: Einheiten, Adressbuch, Verwaltung, Organisationsseite (#683)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:54:17 +02:00
RichardandClaude Opus 5.5 e4ecf2d5ca Einheiten, Stufe 4: API und WebUI-Seite "Organisation" (#683)
- /api/organisation: Baum (Einheiten mit Pfad, Tiefe, eigener und
  wirksamer Obergrenze, Vorgaben; Agenten mit Einheit, Leitung, effektiver
  Leitung, Werkzeugen, Budget), Probleme der Struktur, bekannte Werkzeuge.
  Anlegen, Aendern, Loeschen, Leitung, Umzug von Agent und Einheit,
  Obergrenze, Sammelwerte mit Vorschau. Umzug und Obergrenze kennen
  preview=true; die Entscheidung trifft allein OrgAdministration.
- Neue Agenten koennen ueber die API gleich in eine Einheit gelegt werden.
- WebUI #/organisation: links der Baum (oberste Ebene, Einheiten, Leitungen
  mit Stern, Mitglieder), rechts die Bearbeitung. Eine Begruendung fuer das
  Audit gilt fuer jede Aenderung. Wo etwas verloren gehen kann, zuerst die
  Vorschau: laufende Agenten und Probleme halten an, wegfallende Werkzeuge
  muessen in einem Dialog bestaetigt werden. Sammelwerte als Tabelle mit
  Ausreissern markiert und abgewaehlt. Bei aktivierter Begrenzung ist
  vorausgewaehlt, was von oben ohnehin erlaubt ist. Sprachpakete en/de.

Geprueft im Browser mit einem Pruefgestell (API durch Attrappe ersetzt):
Baum, Auswahl, Obergrenze mit Bestaetigungsdialog, Sammelwert-Vorschau
mit Ausreisser und das Schreiben nur der gewaehlten Agenten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:54:16 +02:00
RichardandClaude Opus 5.5 68a432fec6 Einheiten, Stufe 3: Verwaltung mit Umzug, Rechte-Obergrenze und Sammelwerten (#683)
OrgAdministration (App), damit WebUI und Desktop dieselben Regeln anwenden:
- Einheiten anlegen, umbenennen, Ziel setzen, loeschen (nur leer); Leitung
  setzen (nur ein Mitglied).
- Agent umziehen: haelt fest, seit wann und woher (UnitSinceUtc,
  PreviousUnitId); eine Leitung, die geht, laesst ihre Einheit ohne Leitung.
  Einheit umziehen: nie unter sich selbst; die Mitglieder bleiben in ihrer
  Einheit, ihre Position aendert sich, und das wird ihnen gesagt.
- Kein Umzug unter einem laufenden Agenten. Das Konzept sagt "bei gestoppter
  Instanz", damit niemand mitten im Lauf andere Rechte bekommt. Die Verwaltung
  laeuft in der Instanz und kann sie nicht stoppen; sie lehnt ab, solange ein
  betroffener Agent laeuft. Das dient demselben Zweck, nur enger.
- Rechte-Obergrenze: beim Schreiben geprueft (SetToolEnabled, SetToolSettings,
  und als Sicherheitsnetz Save() fuer den Desktop, der Werkzeuge direkt
  umschaltet). Obergrenze senken und in eine Obergrenze umziehen zeigen in
  der Vorschau, welche Werkzeuge wegfallen, und brauchen dafuer eine
  ausdrueckliche Bestaetigung.
- Sammelwerte (Tagesbudget, Tokens, Modell, whenExhausted) mit Vorschau fuer
  die Einheit samt Untereinheiten: ein Wert je Agent oder ein verteilter
  Gesamtbetrag im Verhaeltnis der bisherigen Werte. Ausreisser (Faktor 3 zum
  Median der anderen) sind markiert und standardmaessig nicht gewaehlt.
  Geschrieben wird nur, was gewaehlt ist; die Einheit merkt sich den Wert nur
  als Vorgabe fuer neue Mitglieder.
- Ein neuer Agent, der direkt in eine Einheit gelegt wird, bekommt deren
  Vorgaben; ein umgezogener behaelt seine Werte.
- Jede Strukturaenderung steht mit Begruendung im Audit (Quelle "admin",
  Werkzeug "Organisation").

Der Organisationsabschnitt nennt der Leitung jetzt auch, wer die Einheit
kuerzlich verlassen hat und wohin: Die abgebende Leitung wird unterrichtet,
nicht nur die aufnehmende.

Tests: OrgAdministrationTests (16). 1.333 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:48:34 +02:00
RichardandClaude Opus 5.5 626fc8d593 Einheiten, Stufe 2: das Adressbuch durchsetzen und jedem Agenten seinen Platz sagen (#683)
Durchgesetzt vom Code, nicht nur im Prompt beschrieben:
- Nachricht und Spawn an einen Agenten ausserhalb des Adressbuchs werden vor
  jedem Modellaufruf abgelehnt; die Antwort nennt den offenen Weg (die eigene
  Leitung, sonst @human).
- list_agents zeigt nur noch das Adressbuch.
- task_create und task_update pruefen die Zuweisung an einen Agenten gegen
  dasselbe Adressbuch; sonst liesse es sich ueber eine Aufgabe umgehen.
- Die Delegationstiefe folgt der Tiefe des Baums (Tiefe + 1), der
  eingestellte Wert bleibt Untergrenze.

Unterrichtet werden, wie das Konzept verlangt:
- OrgBriefing erzeugt einen Abschnitt "# Organisation" im Systemprompt:
  Einheit mit Pfad und Ziel, seit wann (mit Hinweis, wenn frisch), die
  effektive Leitung oder "@human", als Leitung die Untereinheiten und neue
  Mitglieder, und wen man sonst erreicht. Er ist immer wahr, weil er aus dem
  Baum kommt; ein Umzug aendert ihn von selbst.
- In einem laufenden Chat wird nur dieser Abschnitt ersetzt; der uebrige
  Systemprompt bleibt, wie er war (#193).
- Ohne Einheiten gibt es keinen Abschnitt und keine Einschraenkung. Bestehende
  Instanzen behalten ihren Prompt und ihren Prompt-Cache.

AppHost setzt den Organisationsbaum als Provider (frisch je Frage, eine
Aenderung gilt ab dem naechsten Lauf) und protokolliert Probleme der Struktur
beim Start.

Tests: AddressBookEnforcementTests (10), AssignmentReachTests (4). 1.317 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:43:10 +02:00
RichardandClaude Opus 5.5 d014a20c38 Einheiten, Stufe 1: Organisationsbaum und Adressbuch als reine Rechnung (#683)
- UnitConfig (Id, Name, ParentId, LeaderAgentId, Goal, ToolCeiling, Defaults)
  in der Instanzkonfiguration neben den Agenten; AgentConfig.UnitId und
  UnitSinceUtc. Bewusst Konfiguration statt Datenbank: Die Werkzeugrechte der
  Agenten stehen schon dort, die Wahrheit ueber einen Agenten bleibt an einem
  Ort. Strukturaenderungen gehen ins Audit (Stufe 3).
- OrgChart: Pfade, Tiefe, Leitung, effektive Leitung (die naechste Leitung
  darueber, wenn die eigene Einheit keine hat oder man sie selbst leitet),
  Adressbuch, Delegationstiefe aus dem Baum (Tiefe + 1, der eingestellte Wert
  bleibt Untergrenze), Rechte-Obergrenze als Schnittmenge entlang des Pfades,
  und eine Liste der Probleme (unbekannte Verweise, Zyklen, Leitung ist kein
  Mitglied, Werkzeuge ueber der Obergrenze).
- Adressbuch: eigene Einheit und effektive Leitung; als Leitung die
  Leitungen der Untereinheiten (bei leitungslosen Untereinheiten deren
  Mitglieder), der Geschwistereinheiten und die eigene Leitung; die oberste
  Ebene und die Leitungen der obersten Einheiten sehen sich gegenseitig. Jede
  Linie ist in beide Richtungen begehbar. Ohne Einheiten sieht jeder jeden,
  wie bisher.

Tests: OrgChartTests (20) am Onlinehandel-Beispiel aus dem Konzept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:39:11 +02:00
RichardandClaude Opus 5.5 dc11961805 Merge feat/711-lagebild: Teamuebersicht, Stufe 0 (#711)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:34:46 +02:00
RichardandClaude Opus 5.5 f627918729 Lagebild, Stufe 0: Teamuebersicht in der WebUI (#711)
Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es,
auf einen Blick. Stufe 0 kommt ohne Einheiten aus; mit #683 werden die Agenten
zu aufklappbaren Containern, und die Ansicht faellt mit #688 zusammen.

Kern:
- AgentActivity: fuehrt die laufenden Laeufe (Beginn, Quelle, Aufgabe,
  Aufrufer) und die letzten Verbindungen (begrenzt auf 500), meldet Start,
  Ende und Verbindung als Ereignis. Vorher war nur ein Lauf ueber ChatAsync
  "laufend"; ein frischer Lauf (@new) war unsichtbar, und niemand hielt fest,
  wer wen gerufen hatte.
- Die Engine meldet beide Einstiege an; "arbeitet" beginnt hinter dem
  Agententor.

Anwendung:
- TeamOverview stellt den Lagebericht zusammen. Die Zustaende entscheidet der
  Code aus erfassten Tatsachen: laufender Lauf, offene Freigabe, Status des
  letzten Belegs, Laufzeit gegen den Median des Agenten (haengt ab dem
  Dreifachen, fruehestens nach 5 Minuten, ohne Verlauf nach 15). Rangfolge:
  haengt, wartet auf Freigabe, arbeitet; Problem und Budget nur, solange
  nichts Neues laeuft.

Web:
- GET /api/team, Ereignis "activity" (started, finished, link); das Ereignis
  "run" traegt jetzt die Agenten-Id.
- Ansicht #/team mit Cytoscape.js und ELK (bei Bedarf geladen): Layout aus
  der Struktur statt von Hand, Farbe und Symbol je Zustand, Kanten verblassen
  nach 30 s, Klick oeffnet eine Seitenleiste (laufende Laeufe, Freigaben,
  letzter Lauf im Wortlaut) mit "Freigaben oeffnen" vorne, danach Chat und
  Laeufe. Sprachpakete en/de.
- Ein Layout, das lief, solange die Flaeche noch 0 px breit war, wird neu
  gerechnet, sobald sie eine Breite hat (im Pruefgestell gefunden).

Geprueft im Browser mit einem Pruefgestell (API und Ereignisstrom durch
Attrappen ersetzt): sieben Agenten in allen Zustaenden, Seitenleiste,
Live-Kante und Verblassen.

Tests: AgentActivityTests (8), TeamOverviewTests (14). 1.283 gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:34:46 +02:00
RichardandClaude Opus 5.5 d587dc3f20 gitattributes: eingecheckte Fremdbibliotheken ohne Zeilenende-Umschreibung
Mit text=auto haette Git die Dateien unter Windows beim Auschecken auf CRLF
umgeschrieben, und die SHA-256-Werte in vendor/README.md stimmten nicht mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:21:39 +02:00
RichardandClaude Opus 5.5 991a901cab WebUI: Cytoscape.js, ELK und cytoscape-elk eingecheckt (#711)
Fuer die Teamuebersicht (Lagebild, #711), auf Freigabe des Nutzers. Als Dateien
im wwwroot statt ueber ein CDN, weil die Zielmaschine womoeglich kein Internet
hat (WebUI-Leitfaden).

- cytoscape 3.34.3 (MIT), elkjs 0.9.3 (EPL-2.0), cytoscape-elk 2.3.0 (MIT)
- aus der npm-Registry, Tarballs gegen dist.integrity (SHA-512) geprueft
- elkjs bewusst 0.9.3: cytoscape-elk 2.3.0 verlangt ^0.9.3
- Lizenztexte daneben, vendor/README.md mit Version, Herkunft und SHA-256

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:21:25 +02:00
RichardandClaude Opus 5.5 0f49e04261 Test: wackelnde Wanduhr-Pruefung aus dem Parallelitaetstest entfernt (#551)
Freigegebene_Werkzeuge_laufen_tatsaechlich_gleichzeitig pruefte neben dem
Hoechststand gleichzeitiger Aufrufe auch eine Laufzeit unter 750 ms. Am
2026-09-24 schlug sie nach dem Merge von #716 mit 927 ms fehl, als die
Testprojekte parallel liefen; beim erneuten Lauf war sie gruen. Die Zeit misst
die Last der Maschine, nicht die Engine. Der Hoechststand bleibt als Nachweis.
Vier volle Durchlaeufe danach ohne Fehler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:16:56 +02:00
76 changed files with 14935 additions and 218 deletions
+3
View File
@@ -24,3 +24,6 @@
*.jpg binary
*.zip binary
*.dll binary
# Vendored third-party files stay byte for byte as published (checksums in vendor/README.md)
src/ClawdDotNet.Manager.Web/wwwroot/vendor/** -text
+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).
+18 -1
View File
@@ -113,6 +113,10 @@ public sealed class AppHost : IAsyncDisposable
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance));
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
@@ -211,6 +215,9 @@ public sealed class AppHost : IAsyncDisposable
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
public AgentAdministration AgentAdmin => _agentAdmin.Value;
/// <summary>#683: Units, moves, tool ceilings, unit-wide values. Stateless.</summary>
public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
@@ -562,6 +569,12 @@ public sealed class AppHost : IAsyncDisposable
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
});
// #683: The organisation tree, fresh from the configuration on every question, so a
// change made through the administration applies to the next run without a restart.
engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance);
foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems())
logger.LogWarning("Organisation: {Problem}", problem);
engine.LoadPersistedChats();
var (scanner, staging, board) = BuildTaskboard(
@@ -677,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);
@@ -1,6 +1,7 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Staging;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
@@ -9,7 +10,8 @@ public sealed record NewAgent(
string AgentId,
string DisplayName,
string Model,
string Description = "");
string Description = "",
string? UnitId = null);
/// <summary>
/// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen.
@@ -86,6 +88,10 @@ public sealed class AgentAdministration(AppHost host)
if (Find(agentId) is not null)
return AdminResult.Failure($"Ein Agent mit der ID '{agentId}' existiert bereits.");
var unitId = string.IsNullOrWhiteSpace(request.UnitId) ? null : request.UnitId.Trim();
if (unitId is not null && host.Organisation.Current().Unit(unitId) is null)
return AdminResult.Failure($"There is no unit '{unitId}'.");
string agentDir;
try
@@ -111,6 +117,14 @@ public sealed class AgentAdministration(AppHost host)
config.Model = request.Model;
config.Description = request.Description;
// #683: Placed into a unit, the agent starts with the unit's defaults.
if (unitId is not null)
{
config.UnitId = unitId;
config.UnitSinceUtc = DateTime.UtcNow;
host.Organisation.ApplyDefaultsToNewAgent(config);
}
host.Directories.SaveAgentSettings(agentDir, config);
host.Instance.Agents.Add(config);
@@ -181,6 +195,10 @@ public sealed class AgentAdministration(AppHost host)
if (enabled)
{
// #683: What the unit may not have, a member cannot be granted.
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
return AdminResult.Failure(refused);
if (!agent.Tools.ContainsKey(toolName))
agent.Tools[toolName] = new Dictionary<string, object?>();
}
@@ -278,6 +296,10 @@ public sealed class AgentAdministration(AppHost host)
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
// #683: Settings for a tool the agent does not have yet grant it — the ceiling applies.
if (!agent.Tools.ContainsKey(toolName) && host.Organisation.CeilingRefusal(agent, toolName) is { } refused)
return AdminResult.Failure(refused);
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
// "verboten" - und damit die Rechte des Agenten.
@@ -309,6 +331,22 @@ public sealed class AgentAdministration(AppHost host)
/// </summary>
public void Save()
{
// #683: The desktop toggles tools directly on the configuration objects and only
// saves here. The ceiling is a rule of writing, so it is enforced here as well: a
// tool beyond it does not get saved.
var chart = host.Organisation.Current();
foreach (var agent in host.Instance.Agents)
{
if (chart.ToolCeiling(agent.UnitId) is not { } ceiling)
continue;
foreach (var tool in agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList())
{
agent.Tools.Remove(tool);
host.LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation").LogWarning(
"Tool {Tool} removed from {AgentId}: beyond the tool ceiling of its unit", tool, agent.AgentId);
}
}
foreach (var agent in host.Instance.Agents)
PersistAgent(agent);
@@ -329,7 +367,7 @@ public sealed class AgentAdministration(AppHost host)
/// werden sie gelesen, und was in <c>AgentSettings.json</c> danebenstünde, überschriebe
/// der nächste Ladevorgang ohnehin.</para>
/// </summary>
private void PersistAgent(AgentConfig agent)
internal void PersistAgent(AgentConfig agent)
{
// Ein Agent ohne Verzeichnis ist einer aus einer von Hand gepflegten
// Instanzdatei. Ihm hier einen Ordner anzulegen waere eine Nebenwirkung des
@@ -0,0 +1,799 @@
using System.Text.Json;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.App.Services;
/// <summary>A unit-wide value that can be written into the members (#683).</summary>
public enum UnitSetting
{
DailyCostUsd,
DailyTokens,
Model,
WhenExhausted
}
/// <summary>How a unit-wide number is turned into per-agent values.</summary>
public enum UnitSettingMode
{
/// <summary>Every agent gets the value ("each gets 10 $").</summary>
PerAgent,
/// <summary>The value is a total, split in proportion to the current values ("the unit gets 100 $").</summary>
Distribute
}
/// <summary>One line of a preview: what would change at one agent.</summary>
public sealed record UnitSettingChange(
string AgentId,
string DisplayName,
string Current,
string Proposed,
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,
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
IReadOnlyList<string> Running,
IReadOnlyList<string> Problems)
{
public bool CanApply => Running.Count == 0 && Problems.Count == 0;
}
/// <summary>
/// #683: Units — create, rename, lead, move, limit — and unit-wide values written into the
/// members. The rules live here, not in a user interface, so the WebUI and the desktop
/// apply the same ones (like <see cref="AgentAdministration"/>).
///
/// <para><b>Three rules from the concept (section 3):</b></para>
/// <list type="number">
/// <item><b>No value overrides another at run time.</b> A unit-wide value is written into
/// the agents after a preview; the unit keeps it only as the default for agents that join.
/// Outliers — the specialist with a deliberately large budget — are marked and not
/// selected by default.</item>
/// <item><b>The tool ceiling is checked when writing.</b> A tool beyond it cannot be
/// granted; lowering it removes the tools from the agents, after a preview.</item>
/// <item><b>A move never happens under a running agent.</b> The concept says "with the
/// instance stopped", so that no agent gets different rights in the middle of a run. The
/// administration runs inside the instance and cannot stop it; it refuses while an affected
/// agent runs, which serves the same purpose more narrowly.</item>
/// </list>
///
/// <para>Every structural change is written to the audit log with its reason — a change of
/// structure is a change of rights (#205, #641).</para>
/// </summary>
public sealed class OrgAdministration(AppHost host, ILogger logger)
{
/// <summary>A value this far from the median of the others is an outlier.</summary>
public const decimal OutlierFactor = 3m;
private OrgChart Chart => OrgChart.From(host.Instance);
public OrgChart Current() => Chart;
// ─── units ───
public AdminResult CreateUnit(string id, string name, string? parentId, string? goal = null, string reason = "")
{
id = (id ?? "").Trim();
if (id.Length == 0 || id.Any(c => !(char.IsLetterOrDigit(c) || c is '-' or '_')))
return AdminResult.Failure("A unit id consists of letters, digits, '-' and '_'.");
if (string.IsNullOrWhiteSpace(name))
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.");
host.Instance.Units.Add(new UnitConfig
{
Id = id,
Name = name.Trim(),
ParentId = string.IsNullOrWhiteSpace(parentId) ? null : parentId.Trim(),
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, with its workspace '{id}'.");
}
public AdminResult UpdateUnit(string unitId, string? name, string? goal, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (name is not null)
{
if (string.IsNullOrWhiteSpace(name))
return AdminResult.Failure("A unit needs a name.");
unit.Name = name.Trim();
}
if (goal is not null)
unit.Goal = goal.Trim();
Save(null, "unit_update", new { unitId, name, goal }, reason);
return AdminResult.Success($"Unit '{unit.Name}' updated.");
}
/// <summary>Only an empty unit can go — members and sub-units have to be moved first.</summary>
public AdminResult DeleteUnit(string unitId, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var chart = Chart;
if (chart.MembersOf(unit.Id).Count > 0 || chart.Children(unit.Id).Count > 0)
return AdminResult.Failure($"Unit '{unit.Name}' still has members or sub-units. Move them first.");
host.Instance.Units.Remove(unit);
// #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>
public AdminResult SetLeader(string unitId, string? agentId, string reason = "")
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
if (!string.IsNullOrWhiteSpace(agentId))
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return AdminResult.Failure($"There is no agent '{agentId}'.");
if (!string.Equals(agent.UnitId, unit.Id, StringComparison.OrdinalIgnoreCase))
return AdminResult.Failure($"'{agentId}' is not a member of '{unit.Name}'. Move it there first.");
}
unit.LeaderAgentId = string.IsNullOrWhiteSpace(agentId) ? null : agentId.Trim();
Save(null, "unit_lead", new { unitId, agentId }, reason);
return AdminResult.Success(unit.LeaderAgentId is null
? $"'{unit.Name}' has no lead now; the lead above takes over."
: $"'{unit.LeaderAgentId}' leads '{unit.Name}'.");
}
// ─── moves ───
public StructurePreview PreviewMoveAgent(string agentId, string? targetUnitId)
{
var chart = Chart;
var problems = new List<string>();
if (chart.Agent(agentId) is null)
problems.Add($"There is no agent '{agentId}'.");
if (!string.IsNullOrWhiteSpace(targetUnitId) && chart.Unit(targetUnitId) is null)
problems.Add($"There is no unit '{targetUnitId}'.");
return Preview([agentId], NullIfEmpty(targetUnitId), movingUnit: null, problems);
}
/// <summary>
/// Moves an agent. Tools beyond the new unit's ceiling are removed only with
/// <paramref name="dropExcessTools"/> — otherwise the move is refused and says which.
/// A lead leaving its unit leaves it without a lead.
/// </summary>
public AdminResult MoveAgent(string agentId, string? targetUnitId, string reason, bool dropExcessTools = false)
{
targetUnitId = NullIfEmpty(targetUnitId);
var preview = PreviewMoveAgent(agentId, targetUnitId);
if (Refusal(preview, dropExcessTools) is { } refused)
return refused;
var agent = host.AgentAdmin.Find(agentId)!;
if (string.Equals(agent.UnitId, targetUnitId, StringComparison.OrdinalIgnoreCase))
return AdminResult.Success($"'{agentId}' is already there.");
var from = agent.UnitId;
if (FindUnit(from) is { } oldUnit
&& string.Equals(oldUnit.LeaderAgentId, agentId, StringComparison.OrdinalIgnoreCase))
oldUnit.LeaderAgentId = null;
DropTools(preview);
agent.PreviousUnitId = from;
agent.UnitId = targetUnitId;
agent.UnitSinceUtc = DateTime.UtcNow;
host.AgentAdmin.PersistAgent(agent);
Save(agentId, "agent_move", new { agentId, from, to = targetUnitId, toolsLost = preview.ToolsLost }, reason);
return AdminResult.Success($"'{agentId}' moved to {Describe(targetUnitId)}.");
}
public StructurePreview PreviewMoveUnit(string unitId, string? newParentId)
{
var chart = Chart;
var problems = new List<string>();
newParentId = NullIfEmpty(newParentId);
if (chart.Unit(unitId) is null)
problems.Add($"There is no unit '{unitId}'.");
else if (newParentId is not null)
{
if (chart.Unit(newParentId) is null)
problems.Add($"There is no unit '{newParentId}'.");
else if (string.Equals(newParentId, unitId, StringComparison.OrdinalIgnoreCase)
|| chart.Ancestors(newParentId).Any(u => string.Equals(u.Id, unitId, StringComparison.OrdinalIgnoreCase)))
problems.Add("A unit cannot be moved under itself or one of its own sub-units.");
}
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
return Preview(agents, targetUnitId: null, movingUnit: (unitId, newParentId), problems);
}
/// <summary>
/// Moves a unit with everything in it. Its members keep their unit; what changes is
/// what lies above — leads, reach and possibly the tool ceiling.
/// </summary>
public AdminResult MoveUnit(string unitId, string? newParentId, string reason, bool dropExcessTools = false)
{
newParentId = NullIfEmpty(newParentId);
var preview = PreviewMoveUnit(unitId, newParentId);
if (Refusal(preview, dropExcessTools) is { } refused)
return refused;
var unit = FindUnit(unitId)!;
var from = unit.ParentId;
DropTools(preview);
unit.ParentId = newParentId;
// The members changed position although not unit: tell them, like a move.
var now = DateTime.UtcNow;
foreach (var agentId in preview.AffectedAgents)
{
if (host.AgentAdmin.Find(agentId) is { } agent)
{
agent.UnitSinceUtc = now;
host.AgentAdmin.PersistAgent(agent);
}
}
Save(null, "unit_move", new { unitId, from, to = newParentId, toolsLost = preview.ToolsLost }, reason);
return AdminResult.Success($"Unit '{unit.Name}' moved under {Describe(newParentId)}.");
}
// ─── tool ceiling ───
public StructurePreview PreviewToolCeiling(string unitId, IReadOnlyCollection<string>? tools)
{
var chart = Chart;
var problems = new List<string>();
if (chart.Unit(unitId) is null)
problems.Add($"There is no unit '{unitId}'.");
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).Select(a => a.AgentId).ToList();
return Preview(agents, targetUnitId: null, movingUnit: null, problems, ceiling: (unitId, tools));
}
/// <summary>
/// Sets the ceiling. Lowering it removes the tools beyond it from the members — shown by
/// <see cref="PreviewToolCeiling"/> first, and only with <paramref name="dropExcessTools"/>,
/// like a move. <c>null</c> lifts the ceiling.
/// </summary>
public AdminResult SetToolCeiling(
string unitId, IReadOnlyCollection<string>? tools, string reason, bool dropExcessTools = false)
{
var preview = PreviewToolCeiling(unitId, tools);
if (Refusal(preview, dropExcessTools) is { } refused)
return refused;
var unit = FindUnit(unitId)!;
DropTools(preview);
unit.ToolCeiling = tools?.Select(t => t.Trim()).Where(t => t.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Save(null, "unit_ceiling", new { unitId, tools = unit.ToolCeiling, toolsLost = preview.ToolsLost }, reason);
return AdminResult.Success(unit.ToolCeiling is null
? $"'{unit.Name}' has no tool ceiling now."
: $"Tool ceiling of '{unit.Name}' set: {string.Join(", ", unit.ToolCeiling)}.");
}
/// <summary>
/// Whether an agent may be granted a tool — called by <see cref="AgentAdministration"/>
/// when writing. <c>null</c> means yes; otherwise the reason.
/// </summary>
public string? CeilingRefusal(AgentConfig agent, string toolName)
{
if (Chart.ToolCeiling(agent.UnitId) is not { } ceiling || ceiling.Contains(toolName))
return null;
return $"'{toolName}' is beyond the tool ceiling of the unit '{Chart.UnitOf(agent.AgentId)?.Name}'. " +
"Raise the ceiling of the unit or move the agent.";
}
// ─── unit-wide values ───
/// <summary>
/// What writing a unit-wide value would change, per agent below the unit (sub-units
/// included). Outliers are marked and not selected by default: the specialist with a
/// deliberately large budget must not be overwritten by accident.
/// </summary>
public IReadOnlyList<UnitSettingChange> PreviewSetting(
string unitId, UnitSetting setting, string value, UnitSettingMode mode = UnitSettingMode.PerAgent)
{
var chart = Chart;
if (chart.Unit(unitId) is null)
return [];
var agents = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id)).ToList();
var numeric = setting is UnitSetting.DailyCostUsd or UnitSetting.DailyTokens;
if (!numeric)
{
return agents.Select(a => new UnitSettingChange(
a.AgentId, a.DisplayName, CurrentText(a, setting), value.Trim(), Outlier: false,
SelectedByDefault: true)).ToList();
}
if (!decimal.TryParse(value, System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out var number) || number < 0)
return [];
var current = agents.Select(a => CurrentNumber(a, setting)).ToList();
var outliers = OutlierFlags(current);
IReadOnlyList<decimal> proposed;
if (mode == UnitSettingMode.Distribute)
{
// In proportion to the current values, so a specialist keeps its larger share.
// Without any current values, evenly.
var sum = current.Sum();
proposed = current.Select(c => sum > 0 ? number * c / sum : agents.Count == 0 ? 0 : number / agents.Count).ToList();
}
else
{
proposed = current.Select(_ => number).ToList();
}
return agents.Select((a, i) => new UnitSettingChange(
a.AgentId, a.DisplayName,
Format(current[i], setting), Format(proposed[i], setting),
outliers[i], SelectedByDefault: !outliers[i])).ToList();
}
/// <summary>
/// Writes the values chosen from a preview into the agents, one audit entry each, and —
/// with <paramref name="rememberAsDefault"/> — keeps the value as the unit's default for
/// agents that join.
/// </summary>
public AdminResult ApplySetting(
string unitId, UnitSetting setting, IReadOnlyDictionary<string, string> valuesByAgent,
string reason, string? defaultValue = null)
{
if (FindUnit(unitId) is not { } unit)
return AdminResult.Failure($"There is no unit '{unitId}'.");
var chart = Chart;
var below = Subtree(chart, unitId).SelectMany(u => chart.MembersOf(u.Id))
.Select(a => a.AgentId).ToHashSet(StringComparer.OrdinalIgnoreCase);
var written = 0;
foreach (var (agentId, value) in valuesByAgent)
{
if (!below.Contains(agentId) || host.AgentAdmin.Find(agentId) is not { } agent)
return AdminResult.Failure($"'{agentId}' is not below '{unit.Name}'.");
if (Write(agent, setting, value) is { } invalid)
return AdminResult.Failure(invalid);
host.AgentAdmin.PersistAgent(agent);
Audit(agentId, "unit_setting", new { unitId, setting = setting.ToString(), value }, reason);
written++;
}
if (defaultValue is not null)
RememberDefault(unit, setting, defaultValue);
Save(null, "unit_setting_done", new { unitId, setting = setting.ToString(), written, defaultValue }, reason);
return AdminResult.Success($"{written} agent(s) in '{unit.Name}' updated.");
}
/// <summary>
/// Gives an agent that is new to a unit the unit's defaults — only then. A move does not
/// apply them: it would overwrite exactly the values an experienced agent was tuned to.
/// </summary>
public void ApplyDefaultsToNewAgent(AgentConfig agent)
{
if (FindUnit(agent.UnitId) is not { } unit)
return;
var d = unit.Defaults;
if (!string.IsNullOrWhiteSpace(d.Model)) agent.Model = d.Model;
if (d.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost;
if (d.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens;
if (!string.IsNullOrWhiteSpace(d.WhenExhausted)) agent.Budget.WhenExhausted = d.WhenExhausted;
}
// ─── helpers ───
private StructurePreview Preview(
IReadOnlyList<string> agents, string? targetUnitId, (string UnitId, string? NewParent)? movingUnit,
List<string> problems, (string UnitId, IReadOnlyCollection<string>? Tools)? ceiling = null)
{
// The structure as it would be afterwards — the ceiling is computed on that.
var units = host.Instance.Units.Select(Clone).ToList();
if (movingUnit is { } m && units.FirstOrDefault(u => Same(u.Id, m.UnitId)) is { } moved)
moved.ParentId = m.NewParent;
if (ceiling is { } c && units.FirstOrDefault(u => Same(u.Id, c.UnitId)) is { } limited)
limited.ToolCeiling = c.Tools?.ToList();
var after = new OrgChart(units, host.Instance.Agents);
var lost = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var agentId in agents)
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
continue;
// Moving an agent: its new unit. Moving a unit or setting a ceiling: every agent
// stays in its unit — what changes is the structure above it.
var unitAfter = movingUnit is null && ceiling is null ? targetUnitId : agent.UnitId;
if (after.ToolCeiling(unitAfter) is { } limit)
{
var beyond = agent.Tools.Keys.Where(t => !limit.Contains(t)).ToList();
if (beyond.Count > 0)
lost[agentId] = beyond;
}
}
var running = (host.Engine?.Activity.ActiveRuns() ?? [])
.Select(r => r.AgentId)
.Where(id => agents.Contains(id, StringComparer.OrdinalIgnoreCase))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return new StructurePreview(agents, lost, running, problems);
}
private static AdminResult? Refusal(StructurePreview preview, bool dropExcessTools)
{
if (preview.Problems.Count > 0)
return AdminResult.Failure(string.Join(" ", preview.Problems));
if (preview.Running.Count > 0)
return AdminResult.Failure(
$"Not while {string.Join(", ", preview.Running)} run(s): an agent must not get different rights in the middle of a run. Try again when the run is over.");
if (preview.ToolsLost.Count > 0 && !dropExcessTools)
return AdminResult.Failure(
"The new position has a tool ceiling these agents are beyond: " +
string.Join("; ", preview.ToolsLost.Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value)}")) +
". Confirm to remove those tools, or raise the ceiling first.");
return null;
}
private void DropTools(StructurePreview preview)
{
foreach (var (agentId, tools) in preview.ToolsLost)
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
continue;
foreach (var tool in tools)
agent.Tools.Remove(tool);
host.AgentAdmin.PersistAgent(agent);
}
}
private static IEnumerable<UnitConfig> Subtree(OrgChart chart, string unitId)
{
if (chart.Unit(unitId) is not { } root)
yield break;
var stack = new Stack<UnitConfig>([root]);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (stack.Count > 0)
{
var unit = stack.Pop();
if (!seen.Add(unit.Id))
continue;
yield return unit;
foreach (var child in chart.Children(unit.Id))
stack.Push(child);
}
}
private static bool[] OutlierFlags(IReadOnlyList<decimal> values)
{
var flags = new bool[values.Count];
for (var i = 0; i < values.Count; i++)
{
var others = values.Where((_, j) => j != i && values[j] > 0).Order().ToList();
if (others.Count == 0 || values[i] <= 0)
continue;
var median = others[others.Count / 2];
flags[i] = values[i] >= median * OutlierFactor || values[i] <= median / OutlierFactor;
}
return flags;
}
private static decimal CurrentNumber(AgentConfig agent, UnitSetting setting) => setting switch
{
UnitSetting.DailyCostUsd => agent.Budget.DailyCostUsd,
UnitSetting.DailyTokens => agent.Budget.DailyTokens,
_ => 0
};
private static string CurrentText(AgentConfig agent, UnitSetting setting) => setting switch
{
UnitSetting.Model => agent.Model,
UnitSetting.WhenExhausted => agent.Budget.WhenExhausted,
_ => Format(CurrentNumber(agent, setting), setting)
};
private static string Format(decimal value, UnitSetting setting) => setting == UnitSetting.DailyTokens
? decimal.Round(value).ToString(System.Globalization.CultureInfo.InvariantCulture)
: decimal.Round(value, 2).ToString("0.00", System.Globalization.CultureInfo.InvariantCulture);
private static string? Write(AgentConfig agent, UnitSetting setting, string value)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
switch (setting)
{
case UnitSetting.DailyCostUsd when decimal.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var cost) && cost >= 0:
agent.Budget.DailyCostUsd = cost;
return null;
case UnitSetting.DailyTokens when long.TryParse(value, System.Globalization.NumberStyles.Number, inv, out var tokens) && tokens >= 0:
agent.Budget.DailyTokens = tokens;
return null;
case UnitSetting.Model when !string.IsNullOrWhiteSpace(value):
agent.Model = value.Trim();
return null;
case UnitSetting.WhenExhausted when value.Trim() is "expire" or "catchUpOnce":
agent.Budget.WhenExhausted = value.Trim();
return null;
default:
return $"'{value}' is not a valid value for {setting}.";
}
}
private static void RememberDefault(UnitConfig unit, UnitSetting setting, string value)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
switch (setting)
{
case UnitSetting.DailyCostUsd: unit.Defaults.DailyCostUsd = decimal.Parse(value, inv); break;
case UnitSetting.DailyTokens: unit.Defaults.DailyTokens = long.Parse(value, inv); break;
case UnitSetting.Model: unit.Defaults.Model = value.Trim(); break;
case UnitSetting.WhenExhausted: unit.Defaults.WhenExhausted = value.Trim(); break;
}
}
// ─── 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));
private string Describe(string? unitId)
=> FindUnit(unitId) is { } unit ? $"'{unit.Name}'" : "the top level";
private static UnitConfig Clone(UnitConfig u) => new()
{
Id = u.Id, Name = u.Name, ParentId = u.ParentId, LeaderAgentId = u.LeaderAgentId,
Goal = u.Goal, ToolCeiling = u.ToolCeiling?.ToList(), Defaults = u.Defaults
};
private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private void Save(string? agentId, string action, object details, string reason)
{
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
Audit(agentId ?? "", action, details, reason);
}
private void Audit(string agentId, string action, object details, string reason)
{
if (host.AuditRepository is not { } audit)
return;
try
{
audit.AppendAsync(new AuditEntry
{
RunId = "",
AgentId = agentId,
Source = "admin",
Tool = "Organisation",
Arguments = JsonSerializer.Serialize(new { action, details }),
Status = AuditStatus.Ok,
Summary = string.IsNullOrWhiteSpace(reason) ? action : $"{action}: {reason.Trim()}",
OccurredAt = DateTime.UtcNow
}, CancellationToken.None).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// The change itself is saved; a missing audit line must not undo it.
logger.LogError(ex, "Organisation change {Action} could not be written to the audit log", action);
}
}
}
@@ -0,0 +1,241 @@
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services;
/// <summary>What an agent is doing, as the team overview shows it (#711).</summary>
public enum TeamState
{
/// <summary>Nothing running, nothing open, the last run went fine (or there was none).</summary>
Idle,
/// <summary>At least one run in progress.</summary>
Working,
/// <summary>A run has taken much longer than this agent's runs usually take.</summary>
Stuck,
/// <summary>Tool calls of this agent wait for a human decision.</summary>
WaitingForApproval,
/// <summary>The last run failed, hit a limit or found the target busy.</summary>
Problem,
/// <summary>The last run was refused because the budget is used up.</summary>
BudgetExhausted
}
/// <summary>One agent in the team overview.</summary>
public sealed record TeamMember(
string AgentId,
string DisplayName,
string Model,
TeamState State,
IReadOnlyList<ActiveRun> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs,
string? UnitId = null,
bool IsLead = false);
/// <summary>
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
/// its own members and those of every unit below. A collapsed container shows this, so a
/// problem three levels down is still visible from the top.
/// </summary>
public sealed record TeamUnit(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<TeamState, int> States,
int PendingApprovals,
TeamState Worst);
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshot(
IReadOnlyList<TeamMember> Members,
IReadOnlyList<AgentLink> RecentLinks,
DateTime AtUtc,
IReadOnlyList<TeamUnit>? Units = null);
/// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck.
///
/// <para><b>Detecting is code, interpreting is model</b> (Organisation-Konzept, 10a). Every
/// state here comes from a fact the instance recorded: a run in progress, an open staged
/// call, the status of the last receipt. Nothing is judged by a model, so the picture is
/// the same for everyone who looks and costs nothing to draw.</para>
///
/// <para><b>Order of states.</b> When several apply, the one that needs someone first wins:
/// stuck, then waiting for approval, then working; a problem or an exhausted budget only
/// shows while nothing new is running — a new run is the attempt to get past it.</para>
/// </summary>
public sealed class TeamOverview(
AgentEngine? engine,
StagingService? staging,
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null,
Func<OrgChart>? organisation = null)
{
/// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
/// <summary>How long recent links stay in the snapshot — the overview fades them out.</summary>
public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
/// <summary>A run counts as stuck after this many times the agent's typical duration…</summary>
public const int StuckFactor = 3;
/// <summary>…but never before this — short runs vary a lot in relative terms.</summary>
public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
/// <summary>Without history, a run counts as stuck after this long.</summary>
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
{
var now = _clock.GetUtcNow().UtcDateTime;
var configured = agents();
var active = engine?.Activity.ActiveRuns() ?? [];
var links = engine?.Activity.RecentLinks(LinkWindow) ?? [];
var pending = staging is null ? [] : await staging.ListPendingAsync(ct);
var pendingByAgent = pending
.GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var lastReceipt = new Dictionary<string, RunReceipt>(StringComparer.OrdinalIgnoreCase);
if (audit is not null)
{
var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct);
foreach (var receipt in receipts)
{
if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt)
lastReceipt[receipt.AgentId] = receipt;
}
}
var org = organisation?.Invoke() ?? new OrgChart([], configured);
var members = new List<TeamMember>();
foreach (var agent in configured)
{
var runs = active
.Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.StartedAtUtc)
.ToList();
var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId);
lastReceipt.TryGetValue(agent.AgentId, out var last);
var typical = await TypicalRunAsync(agent.AgentId, ct);
var state = Classify(runs, approvals, last?.Status, typical, now);
members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
}
return new TeamSnapshot(members, links, now, RollUp(org, members));
}
/// <summary>
/// How much a state needs someone — the higher, the sooner. A container takes the most
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
/// </summary>
public static int Urgency(TeamState state) => state switch
{
TeamState.Stuck => 5,
TeamState.Problem => 4,
TeamState.WaitingForApproval => 3,
TeamState.BudgetExhausted => 2,
TeamState.Working => 1,
_ => 0
};
/// <summary>
/// Every unit with the states of all agents in it and below. Pure, like
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
/// </summary>
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
{
var result = new List<TeamUnit>();
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
{
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
// cycle, so a broken tree cannot hang the overview.
var inside = members
.Where(m => m.UnitId is { } id
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
.ToList();
var states = inside
.GroupBy(m => m.State)
.ToDictionary(g => g.Key, g => g.Count());
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
result.Add(new TeamUnit(
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst));
}
return result;
}
/// <summary>
/// The state from the facts. Pure, so the rules can be tested without an instance.
/// </summary>
public static TeamState Classify(
IReadOnlyList<ActiveRun> runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc)
{
if (runs.Count > 0)
{
var limit = typicalRun > TimeSpan.Zero
? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks))
: StuckWithoutHistory;
if (runs.Any(r => nowUtc - r.StartedAtUtc > limit))
return TeamState.Stuck;
}
if (pendingApprovals > 0)
return TeamState.WaitingForApproval;
if (runs.Count > 0)
return TeamState.Working;
return lastRunStatus switch
{
nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted,
nameof(AgentRunStatus.Failed)
or nameof(AgentRunStatus.LoopLimitExceeded)
or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem,
_ => TeamState.Idle
};
}
/// <summary>The agent's typical run duration — the median of its recent runs.</summary>
private async Task<TimeSpan> TypicalRunAsync(string agentId, CancellationToken ct)
{
if (usage is null)
return TimeSpan.Zero;
var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct);
var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList();
return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]);
}
}
@@ -0,0 +1,132 @@
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Activity;
/// <summary>A run that is in progress right now.</summary>
public sealed record ActiveRun(
string RunId,
string AgentId,
string Source,
string? TaskId,
string? CalledBy,
DateTime StartedAtUtc);
/// <summary>
/// One agent reaching another — for now a delegated run (<see cref="Kind"/> =
/// <c>delegation</c>). Meetings and escalations will add their own kinds.
/// </summary>
public sealed record AgentLink(string From, string To, string Kind, DateTime AtUtc);
/// <summary>
/// #711: Who is working right now, and who reached whom — the live side of the team overview.
///
/// <para><b>Why a component of its own.</b> The engine already tracked running chats, but
/// only those that went through <c>ChatAsync</c>; a fresh run (<c>@new</c>) was invisible to
/// <c>IsRunning</c>. And nothing recorded that agent A had called agent B — the delegation
/// chain knew it for the length of the call and then forgot. Here both are kept and
/// announced, independent of which engine path a run takes.</para>
///
/// <para><b>Memory only, and bounded.</b> This is the view of "now". What happened
/// yesterday is in the audit log; the time line (#711, later) replays it from there.</para>
/// </summary>
public sealed class AgentActivity
{
/// <summary>How many recent links are kept for a page that connects late.</summary>
public const int RecentLinkCapacity = 500;
private readonly Lock _lock = new();
private readonly Dictionary<string, ActiveRun> _active = new(StringComparer.Ordinal);
private readonly Queue<AgentLink> _recentLinks = new();
private readonly TimeProvider _clock;
public AgentActivity(TimeProvider? clock = null) => _clock = clock ?? TimeProvider.System;
public event Action<ActiveRun>? RunStarted;
/// <summary>A run ended — with the <c>AgentRunStatus</c> name, or <c>Aborted</c>.</summary>
public event Action<ActiveRun, string>? RunFinished;
public event Action<AgentLink>? LinkObserved;
/// <summary>
/// Announces a run. Dispose the handle when it ends; call <see cref="RunHandle.Complete"/>
/// first to report how. A delegated run (<paramref name="chain"/> deeper than its root)
/// also records the link from the caller.
/// </summary>
public RunHandle Begin(string runId, string agentId, string source, string? taskId, DelegationChain? chain)
{
var now = _clock.GetUtcNow().UtcDateTime;
var calledBy = chain is { Depth: > 0 } ? chain.Agents[^2] : null;
var run = new ActiveRun(runId, agentId, source, taskId, calledBy, now);
AgentLink? link = calledBy is null ? null : new AgentLink(calledBy, agentId, "delegation", now);
lock (_lock)
{
_active[runId] = run;
if (link is not null)
{
_recentLinks.Enqueue(link);
while (_recentLinks.Count > RecentLinkCapacity)
_recentLinks.Dequeue();
}
}
Raise(() => RunStarted?.Invoke(run));
if (link is not null)
Raise(() => LinkObserved?.Invoke(link));
return new RunHandle(this, run);
}
/// <summary>The runs in progress right now.</summary>
public IReadOnlyList<ActiveRun> ActiveRuns()
{
lock (_lock)
return [.. _active.Values];
}
/// <summary>Links observed within <paramref name="window"/>, oldest first.</summary>
public IReadOnlyList<AgentLink> RecentLinks(TimeSpan window)
{
var since = _clock.GetUtcNow().UtcDateTime - window;
lock (_lock)
return [.. _recentLinks.Where(l => l.AtUtc >= since)];
}
private void End(ActiveRun run, string status)
{
lock (_lock)
{
if (!_active.Remove(run.RunId))
return;
}
Raise(() => RunFinished?.Invoke(run, status));
}
/// <summary>A subscriber that throws must not break the run it is watching.</summary>
private static void Raise(Action raise)
{
try { raise(); }
catch { /* observers are best effort */ }
}
public sealed class RunHandle : IDisposable
{
private readonly AgentActivity _owner;
private readonly ActiveRun _run;
private string _status = "Aborted";
internal RunHandle(AgentActivity owner, ActiveRun run)
{
_owner = owner;
_run = run;
}
/// <summary>How the run ended. Without it, disposal reports <c>Aborted</c>.</summary>
public void Complete(string status) => _status = status;
public void Dispose() => _owner.End(_run, _status);
}
}
+4 -1
View File
@@ -157,7 +157,10 @@ public sealed class BackupService
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = sourcePath,
Mode = SqliteOpenMode.ReadOnly
Mode = SqliteOpenMode.ReadOnly,
// Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei
// danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen.
Pooling = false
}.ToString();
using var conn = new SqliteConnection(connectionString);
@@ -10,6 +10,24 @@ public sealed class AgentConfig
[JsonPropertyName("displayName")]
public string DisplayName { get; set; } = "";
/// <summary>#683: The unit this agent belongs to; <c>null</c> for the top level.</summary>
[JsonPropertyName("unitId")]
public string? UnitId { get; set; }
/// <summary>
/// #683: Since when the agent is in its unit (UTC) — so it can be told that it moved,
/// and its lead that someone joined.
/// </summary>
[JsonPropertyName("unitSinceUtc")]
public DateTime? UnitSinceUtc { get; set; }
/// <summary>
/// #683: The unit the agent left with its last move — so the lead it left is told as
/// well as the one it joined. <c>null</c> after the first placement.
/// </summary>
[JsonPropertyName("previousUnitId")]
public string? PreviousUnitId { get; set; }
[JsonPropertyName("model")]
public string Model { get; set; } = "anthropic/claude-sonnet-4-5";
@@ -172,6 +172,21 @@ public sealed class InstanceConfig
[JsonPropertyName("agents")]
public List<AgentConfig> Agents { get; set; } = new();
/// <summary>
/// #683: Departments, teams, groups — the organisation tree. Empty means no structure:
/// every agent can reach every other, as before units existed.
/// </summary>
[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();
}
+72
View File
@@ -0,0 +1,72 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Config;
/// <summary>
/// #683: A unit — department, team, group — as a node in the organisation tree.
///
/// <para><b>Stored in the instance configuration, next to the agents.</b> An agent's tools
/// already live there; a unit's rules belong beside them, so the truth about an agent is in
/// one place. Every structural change is written to the audit log by the administration,
/// which is what the concept asked the database for.</para>
///
/// <para><b>No value here overrides an agent at run time</b> (Organisation-Konzept, section
/// 3). <see cref="Defaults"/> are only the starting values for agents that join;
/// <see cref="ToolCeiling"/> is checked when an agent's tools are written, never laid over
/// them while it runs. What stands at the agent is what applies.</para>
/// </summary>
public sealed class UnitConfig
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("name")]
public string Name { get; set; } = "";
/// <summary>The parent unit; <c>null</c> for a top-level unit.</summary>
[JsonPropertyName("parentId")]
public string? ParentId { get; set; }
/// <summary>
/// The agent leading this unit — one of its members. May be empty: then the nearest
/// unit above that has a lead takes over.
/// </summary>
[JsonPropertyName("leaderAgentId")]
public string? LeaderAgentId { get; set; }
/// <summary>What the unit exists for — the top of every task chain in it (#714).</summary>
[JsonPropertyName("goal")]
public string Goal { get; set; } = "";
/// <summary>
/// The tools members of this unit may have at most; <c>null</c> means no ceiling. A
/// ceiling applies to sub-units as well — the effective ceiling is the intersection
/// along the path to the root.
/// </summary>
[JsonPropertyName("toolCeiling")]
public List<string>? ToolCeiling { get; set; }
/// <summary>Starting values for agents that join this unit.</summary>
[JsonPropertyName("defaults")]
public UnitDefaults Defaults { get; set; } = new();
}
/// <summary>
/// #683: What an agent gets when it joins a unit. <c>null</c> leaves the agent's own value.
/// Changing these does not touch existing members — writing a value into them is a
/// separate, previewed step.
/// </summary>
public sealed class UnitDefaults
{
[JsonPropertyName("model")]
public string? Model { get; set; }
[JsonPropertyName("dailyCostUsd")]
public decimal? DailyCostUsd { get; set; }
[JsonPropertyName("dailyTokens")]
public long? DailyTokens { get; set; }
[JsonPropertyName("whenExhausted")]
public string? WhenExhausted { get; set; }
}
@@ -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
}
+72 -6
View File
@@ -41,6 +41,24 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// </summary>
public BudgetGuard? BudgetGuard => _budgetGuard;
/// <summary>#711: Who is working right now and who reached whom — for the team overview.</summary>
public Activity.AgentActivity Activity { get; } = new();
/// <summary>
/// #683: The organisation tree, built fresh from the configuration on each call. Without
/// it, the instance has no structure and every agent reaches every other, as before.
/// </summary>
public Func<Organisation.OrgChart>? OrganisationProvider { get; set; }
private Organisation.OrgChart CurrentOrganisation()
=> OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []);
/// <summary>The agent's system prompt with its organisation section (#683).</summary>
private string SystemPromptFor(AgentConfig agentConfig, bool hasTools)
=> Organisation.OrgBriefing.WithSection(
UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools),
Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime));
/// <summary>
/// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8).
/// A→B ist die Tiefe 1, A→B→C die Tiefe 2.
@@ -179,8 +197,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var runId = Guid.NewGuid().ToString("N");
var chain = delegation ?? DelegationChain.Root(agentConfig.AgentId);
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await RunCoreAsync(agentConfig, userMessage, instanceId, externalCt, runId, source, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
@@ -221,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
var messages = new List<ChatMessage>();
// #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil.
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
// #683: dazu der Abschnitt zur Organisation.
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
@@ -498,8 +519,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
try
{
// #711: "working" starts past the gate — waiting for it is not working.
using var activity = Activity.Begin(runId, agentConfig.AgentId, source ?? AuditSource.Direct, taskId, chain);
var recorder = CreateRecorder(agentConfig);
var result = await ChatCoreAsync(agentConfig, userMessage, instanceId, runCts.Token, source, runId, chain, recorder);
activity.Complete(result.Status.ToString());
await RecordUsageAsync(agentConfig, result);
await RecordReceiptAsync(runId, agentConfig, result, source, taskId);
await SaveTranscriptAsync(recorder, runId, agentConfig, result, source ?? AuditSource.Direct, taskId);
@@ -548,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
messages = new List<ChatMessage>();
// #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren
// Systemprompt; die Regel kommt mit dem naechsten neuen Kontext.
var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0);
var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0);
if (!string.IsNullOrWhiteSpace(systemPrompt))
messages.Add(ChatMessage.System(systemPrompt));
_chatContexts[agentConfig.AgentId] = messages;
}
else if (messages.Count > 0 && messages[0].Role == "system")
{
// #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem
// Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest
// des bestehenden Systemprompts bleibt, wie er war (#193).
var section = Organisation.OrgBriefing.For(
CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime);
var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section);
if (refreshed != messages[0].Content)
messages[0] = ChatMessage.System(refreshed);
}
}
// Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt
@@ -1044,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
$"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " +
"kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren.";
if (callerChain.Depth >= MaxDelegationDepth)
// #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze.
var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth);
if (callerChain.Depth >= maxDepth)
return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " +
$"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " +
$"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " +
"oder gib die Aufgabe an den Auftraggeber zurück.";
return null;
}
/// <summary>
/// #683: The address book, enforced — not merely described in the prompt. A refused
/// contact is an answer to the caller, naming the way that is open: its lead.
/// </summary>
private string? CheckAddressBook(string fromAgentId, string toAgentId)
{
var org = CurrentOrganisation();
if (org.CanAddress(fromAgentId, toAgentId))
return null;
var lead = org.EffectiveLeaderOf(fromAgentId);
return $"'{toAgentId}' is not in your address book: from your position in the organisation you " +
"cannot reach this agent directly. " +
(lead is not null
? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on."
: "Create a task for @human if it needs someone outside your reach.");
}
public IReadOnlyList<AgentInfo> ListAgents(string callerAgentId)
{
var configs = _agentConfigProvider?.Invoke() ?? [];
// #683: Only the agent's address book — the cut of the organisation.
var reachable = CurrentOrganisation().AddressBook(callerAgentId);
return configs
.Where(a => a.AgentId != callerAgentId)
.Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId))
.Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId)))
.ToList();
}
@@ -1089,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (targetConfig is null)
return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found.");
if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach)
return new AgentMessageResult(false, null, outOfReach);
if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused)
return new AgentMessageResult(false, null, refused);
@@ -1141,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits
// delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen.
if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach)
return new AgentSpawnResult(false, targetAgentId, null, outOfReach);
if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused)
return new AgentSpawnResult(false, targetAgentId, null, refused);
@@ -1598,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
this,
_memoryRepository,
_taskRepository,
chain ?? DelegationChain.Root(agentConfig.AgentId));
chain ?? DelegationChain.Root(agentConfig.AgentId))
{
Organisation = CurrentOrganisation()
};
}
/// <summary>
@@ -0,0 +1,166 @@
using System.Text;
namespace ClawdDotNet.Core.Organisation;
/// <summary>
/// #683: The "Organisation" section of an agent's system prompt — its unit, its lead, whom it
/// can reach, and what changed recently.
///
/// <para><b>Why the code writes it and not the agent's own texts.</b> The concept demands
/// that agents are told when they move, and warns: an agent that has to infer its lead from
/// a prompt escalates to the wrong place. A section generated from the tree is always true —
/// a move changes it by itself, for the agent that moved and for both leads.</para>
///
/// <para><b>Without units there is no section</b>, so instances without a structure keep
/// exactly the prompt they had — and the prompt cache that goes with it.</para>
/// </summary>
public static class OrgBriefing
{
/// <summary>Starts the section; also how an existing chat context finds it to replace it.</summary>
public const string Heading = "# Organisation";
/// <summary>How long a move or a new colleague is pointed out.</summary>
public static readonly TimeSpan RecentChange = TimeSpan.FromDays(7);
private const int MaxDescription = 120;
public static string? For(OrgChart org, string agentId, DateTime nowUtc)
{
if (!org.HasUnits || org.Agent(agentId) is not { } agent)
return null;
var sb = new StringBuilder();
sb.AppendLine(Heading);
var unit = org.UnitOf(agentId);
if (unit is null)
{
sb.AppendLine("You are on the top level of this instance, above all units.");
}
else
{
sb.Append($"You belong to the unit \"{unit.Name}\" ({org.PathOf(unit.Id)})");
if (agent.UnitSinceUtc is { } since)
{
sb.Append($", since {since:yyyy-MM-dd}");
if (nowUtc - since < RecentChange)
sb.Append(" — this is recent: your lead, your colleagues and possibly your tools changed");
}
sb.AppendLine(".");
if (!string.IsNullOrWhiteSpace(unit.Goal))
sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}");
}
var lead = org.EffectiveLeaderOf(agentId);
if (lead is not null)
sb.AppendLine($"Your lead: {Name(lead)}. Questions, problems and anything outside your unit go to your lead.");
else
sb.AppendLine("There is no lead agent above you. What you cannot decide goes to a human: create a task for @human.");
if (org.LedBy(agentId) is { } led)
{
sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead.");
var subUnits = org.Children(led.Id);
if (subUnits.Count > 0)
{
sb.AppendLine("Its sub-units: " + string.Join("; ", subUnits.Select(u =>
org.LeaderOf(u.Id) is { } l ? $"{u.Name} (lead: {l.AgentId})" : $"{u.Name} (no lead — its members report to you)")) + ".");
}
var joined = org.MembersOf(led.Id)
.Where(m => m.AgentId != agentId && m.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
.ToList();
if (joined.Count > 0)
sb.AppendLine("New in your unit: " + string.Join(", ", joined.Select(m => $"{m.AgentId} since {m.UnitSinceUtc:yyyy-MM-dd}")) + ".");
// The lead that was left is told as well as the one that was joined.
var left = org.Agents
.Where(a => string.Equals(a.PreviousUnitId, led.Id, StringComparison.OrdinalIgnoreCase)
&& a.UnitSinceUtc is { } s && nowUtc - s < RecentChange)
.ToList();
if (left.Count > 0)
sb.AppendLine("Left your unit recently: " + string.Join(", ", left.Select(a =>
$"{a.AgentId} (now in {(org.UnitOf(a.AgentId) is { } u ? u.Name : "the top level")})")) +
". Hand over what is still open with them.");
}
var contacts = org.AddressBook(agentId)
.Where(id => lead is null || !string.Equals(id, lead.AgentId, StringComparison.OrdinalIgnoreCase))
.Select(id => org.Agent(id)!)
.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
.ToList();
if (contacts.Count > 0)
{
sb.AppendLine("You can also reach:");
foreach (var contact in contacts)
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
/// prompt otherwise (#193); only this section follows the structure.
/// </summary>
public static string WithSection(string systemPrompt, string? section)
{
var start = systemPrompt.IndexOf("\n\n" + Heading + "\n", StringComparison.Ordinal);
var basePrompt = start >= 0 ? systemPrompt[..start] : systemPrompt;
if (start < 0 && systemPrompt.StartsWith(Heading + "\n", StringComparison.Ordinal))
basePrompt = "";
if (string.IsNullOrWhiteSpace(section))
return basePrompt;
return basePrompt.Length == 0 ? section : basePrompt + "\n\n" + section;
}
private static string Name(Config.AgentConfig agent)
=> string.IsNullOrWhiteSpace(agent.DisplayName) || agent.DisplayName == agent.AgentId
? agent.AgentId
: $"{agent.DisplayName} ({agent.AgentId})";
private static string Describe(string? description)
{
if (string.IsNullOrWhiteSpace(description))
return "";
var line = description.ReplaceLineEndings(" ").Trim();
return " — " + (line.Length > MaxDescription ? line[..MaxDescription] + "…" : line);
}
}
@@ -0,0 +1,401 @@
using ClawdDotNet.Core.Config;
namespace ClawdDotNet.Core.Organisation;
/// <summary>
/// #683: The organisation tree of an instance — units, members, leads — and everything that
/// follows from it: who an agent can reach, who its lead is, how deep delegation may go,
/// which tools a unit's members may have at most.
///
/// <para><b>One rule holds it together: an agent knows only its unit and its lead.</b>
/// The cut of the address book is the organisation (Organisation-Konzept, section 3). It
/// is computed from the tree, never configured, so it cannot be widened by accident —
/// whoever wants an agent to reach further moves it in the tree, visibly.</para>
///
/// <para><b>Pure.</b> Built from the configuration, no I/O, no state. Every question is
/// answered from the snapshot it was built from, so the engine, the administration and
/// the tests all see the same organisation.</para>
///
/// <para><b>Without units nothing changes:</b> every agent reaches every other, as before.</para>
/// </summary>
public sealed class OrgChart
{
private readonly Dictionary<string, UnitConfig> _units;
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, 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);
_agents = new Dictionary<string, AgentConfig>(StringComparer.OrdinalIgnoreCase);
foreach (var agent in agents)
_agents.TryAdd(agent.AgentId, agent);
_children = new Dictionary<string, List<UnitConfig>>(StringComparer.OrdinalIgnoreCase);
foreach (var unit in _units.Values)
{
var parent = KnownParent(unit) ?? Root;
if (!_children.TryGetValue(parent, out var list))
_children[parent] = list = [];
list.Add(unit);
}
_members = new Dictionary<string, List<AgentConfig>>(StringComparer.OrdinalIgnoreCase);
foreach (var agent in _agents.Values)
{
var key = KnownUnit(agent) ?? Root;
if (!_members.TryGetValue(key, out var list))
_members[key] = list = [];
list.Add(agent);
}
}
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;
public IReadOnlyCollection<UnitConfig> Units => _units.Values;
public IReadOnlyCollection<AgentConfig> Agents => _agents.Values;
public UnitConfig? Unit(string? unitId)
=> unitId is not null && _units.TryGetValue(unitId, out var unit) ? unit : null;
public AgentConfig? Agent(string agentId) => _agents.GetValueOrDefault(agentId);
/// <summary>The unit an agent belongs to; <c>null</c> for the top level (or an unknown unit).</summary>
public UnitConfig? UnitOf(string agentId)
=> _agents.TryGetValue(agentId, out var agent) ? Unit(KnownUnit(agent)) : null;
/// <summary>Direct sub-units; <c>null</c> asks for the top-level units.</summary>
public IReadOnlyList<UnitConfig> Children(string? unitId)
=> _children.TryGetValue(unitId ?? Root, out var list) ? list : [];
/// <summary>Direct members; <c>null</c> asks for the agents on the top level.</summary>
public IReadOnlyList<AgentConfig> MembersOf(string? unitId)
=> _members.TryGetValue(unitId ?? Root, out var list) ? list : [];
/// <summary>The units above, nearest first — not including the unit itself.</summary>
public IReadOnlyList<UnitConfig> Ancestors(string unitId)
{
var result = new List<UnitConfig>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { unitId };
var current = Unit(unitId);
while (current is not null && KnownParent(current) is { } parentId && seen.Add(parentId))
{
current = _units[parentId];
result.Add(current);
}
return result;
}
/// <summary>"Marketing / Marketplaces / Amazon".</summary>
public string PathOf(string unitId)
{
if (Unit(unitId) is not { } unit)
return "";
var names = Ancestors(unitId).Select(u => u.Name).Reverse().Append(unit.Name);
return string.Join(" / ", names);
}
/// <summary>Depth of a unit: a top-level unit is 1.</summary>
public int Depth(string unitId) => Unit(unitId) is null ? 0 : Ancestors(unitId).Count + 1;
/// <summary>The deepest unit's depth; 0 without units.</summary>
public int TreeDepth => _units.Keys.Select(Depth).DefaultIfEmpty(0).Max();
/// <summary>
/// How many hand-overs a delegation chain may have: enough to go from the top to the
/// bottom of the tree and one step further, and never less than the configured floor.
/// Before #683 this was a fixed 3 — lead, department, team, agent already used it up.
/// </summary>
public int DelegationDepth(int configuredFloor) => Math.Max(configuredFloor, TreeDepth + 1);
/// <summary>The lead of a unit, if it has one that is actually a member.</summary>
public AgentConfig? LeaderOf(string? unitId)
{
if (Unit(unitId) is not { LeaderAgentId: { Length: > 0 } leaderId } unit)
return null;
return _agents.TryGetValue(leaderId, out var leader)
&& string.Equals(KnownUnit(leader), unit.Id, StringComparison.OrdinalIgnoreCase)
? leader
: null;
}
/// <summary>The unit this agent leads, if any.</summary>
public UnitConfig? LedBy(string agentId)
{
var unit = UnitOf(agentId);
return unit is not null && LeaderOf(unit.Id) is { } leader
&& string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase)
? unit
: null;
}
/// <summary>
/// Who the agent answers to: the lead of its unit — or, if it leads that unit itself or
/// the unit has no lead, the nearest lead above. <c>null</c> means: the human.
/// </summary>
public AgentConfig? EffectiveLeaderOf(string agentId)
{
var unit = UnitOf(agentId);
if (unit is null)
return null;
foreach (var candidate in new[] { unit }.Concat(Ancestors(unit.Id)))
{
if (LeaderOf(candidate.Id) is { } leader
&& !string.Equals(leader.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
return leader;
}
return null;
}
/// <summary>
/// Everyone the agent may address — the cut of the address book (section 3):
/// <list type="bullet">
/// <item>the members of its own unit and its effective lead;</item>
/// <item>as a lead: the leads of its sub-units — and, where a sub-unit has no lead, that
/// sub-unit's members, because this lead is who they answer to — and the leads of its
/// sibling units;</item>
/// <item>on the top level: each other and the leads of the top-level units, both ways.</item>
/// </list>
/// Without units: everyone.
/// </summary>
public IReadOnlySet<string> AddressBook(string agentId)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!_agents.ContainsKey(agentId))
return result;
if (!HasUnits)
{
result.UnionWith(_agents.Keys);
result.Remove(agentId);
return result;
}
var unit = UnitOf(agentId);
foreach (var member in MembersOf(unit?.Id))
result.Add(member.AgentId);
if (EffectiveLeaderOf(agentId) is { } lead)
result.Add(lead.AgentId);
if (unit is null)
{
// Top level: the house management reaches the heads of the top-level units.
AddLeadsBelow(null, result);
}
else if (LedBy(agentId) is { } led)
{
AddLeadsBelow(led.Id, result);
foreach (var sibling in Children(KnownParent(led)))
{
if (!string.Equals(sibling.Id, led.Id, StringComparison.OrdinalIgnoreCase)
&& LeaderOf(sibling.Id) is { } siblingLead)
result.Add(siblingLead.AgentId);
}
// A top-level lead reaches the top level — the same line, seen from below.
if (KnownParent(led) is null)
foreach (var member in MembersOf(null))
result.Add(member.AgentId);
}
result.Remove(agentId);
return result;
}
public bool CanAddress(string fromAgentId, string toAgentId)
=> AddressBook(fromAgentId).Contains(toAgentId);
/// <summary>
/// The tools a member of the unit may have at most: the intersection of the ceilings
/// along the path to the top. <c>null</c> means no ceiling anywhere on the path.
/// </summary>
public IReadOnlySet<string>? ToolCeiling(string? unitId)
{
if (Unit(unitId) is not { } unit)
return null;
HashSet<string>? ceiling = null;
foreach (var node in new[] { unit }.Concat(Ancestors(unit.Id)))
{
if (node.ToolCeiling is null)
continue;
if (ceiling is null)
ceiling = new HashSet<string>(node.ToolCeiling, StringComparer.OrdinalIgnoreCase);
else
ceiling.IntersectWith(node.ToolCeiling);
}
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
/// problems (unknown references count as top level), but they are named.
/// </summary>
public IReadOnlyList<string> Problems()
{
var problems = new List<string>();
foreach (var unit in _units.Values)
{
if (string.IsNullOrWhiteSpace(unit.Id))
problems.Add("A unit has no id.");
if (unit.ParentId is { Length: > 0 } parentId && !_units.ContainsKey(parentId))
problems.Add($"Unit '{unit.Id}' names the parent '{parentId}', which does not exist.");
if (IsInCycle(unit.Id))
problems.Add($"Unit '{unit.Id}' is part of a cycle — a unit cannot contain itself.");
if (unit.LeaderAgentId is { Length: > 0 } leaderId && LeaderOf(unit.Id) is null)
problems.Add(_agents.ContainsKey(leaderId)
? $"Unit '{unit.Id}' names '{leaderId}' as lead, but that agent is not a member of it."
: $"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))
problems.Add($"Agent '{agent.AgentId}' belongs to the unit '{unitId}', which does not exist.");
if (ToolCeiling(KnownUnit(agent)) is { } ceiling)
{
var beyond = agent.Tools.Keys.Where(t => !ceiling.Contains(t)).ToList();
if (beyond.Count > 0)
problems.Add($"Agent '{agent.AgentId}' has tools beyond its unit's ceiling: {string.Join(", ", beyond)}.");
}
}
return problems;
}
// ─── helpers ───
private void AddLeadsBelow(string? unitId, HashSet<string> into)
{
foreach (var child in Children(unitId))
{
if (LeaderOf(child.Id) is { } lead)
{
into.Add(lead.AgentId);
continue;
}
// No lead there: its members answer to the lead above — so this one sees them,
// and looks further down for the next leads.
foreach (var member in MembersOf(child.Id))
into.Add(member.AgentId);
AddLeadsBelow(child.Id, into);
}
}
/// <summary>The parent id if it names an existing unit and does not close a cycle.</summary>
private string? KnownParent(UnitConfig unit)
=> unit.ParentId is { Length: > 0 } parentId && _units.ContainsKey(parentId) && !IsInCycle(unit.Id)
? _units[parentId].Id
: null;
private string? KnownUnit(AgentConfig agent)
=> agent.UnitId is { Length: > 0 } unitId && _units.TryGetValue(unitId, out var unit) ? unit.Id : null;
private bool IsInCycle(string unitId)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var current = unitId;
while (current is not null && _units.TryGetValue(current, out var unit))
{
if (!seen.Add(current))
return true;
current = unit.ParentId is { Length: > 0 } p ? p : null;
}
return false;
}
}
@@ -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);
}
}
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Storage;
@@ -19,6 +20,9 @@ public sealed class SqliteStorage
private readonly string _connectionString;
private readonly SemaphoreSlim _writeGate = new(1, 1);
/// <summary>Every database opened in this process: full path → connection string.</summary>
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
public string DatabasePath { get; }
public SqliteStorage(string databasePath)
@@ -37,6 +41,7 @@ public sealed class SqliteStorage
Pooling = true,
DefaultTimeout = 30
}.ToString();
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
Initialize();
@@ -51,6 +56,33 @@ public sealed class SqliteStorage
}
}
/// <summary>
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
/// database file, or a directory and everything below it — so the files can be deleted
/// or replaced.
///
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
/// takes the connections of every other database in use. The tests used it to clean up
/// their temporary databases, and a test running in parallel then lost its connection
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
/// few full runs, always in a different test.</para>
/// </summary>
public static void ReleasePooledConnections(string path)
{
var full = Path.GetFullPath(path);
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
foreach (var (database, connectionString) in OpenedDatabases)
{
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
continue;
using var connection = new SqliteConnection(connectionString);
SqliteConnection.ClearPool(connection);
}
}
public SqliteConnection OpenConnection()
{
var connection = new SqliteConnection(_connectionString);
@@ -345,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);
@@ -34,4 +34,11 @@ public sealed record AgentToolContext(
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
/// </summary>
public bool FreigabeGeprueft { get; init; }
/// <summary>
/// #683: The organisation at the time of the call. Tools that let an agent address
/// another — a task assignment, for instance — check the address book here, so it cannot
/// be walked around. <c>null</c> in tests and outside the engine: no structure, no limit.
/// </summary>
public Organisation.OrgChart? Organisation { get; init; }
}
+656
View File
@@ -1721,3 +1721,659 @@ div.klein,
td.klein {
font-size: 12px;
}
/* ─── 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;
flex-direction: column;
gap: 12px;
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;
}
/* 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-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;
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;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
background: var(--surface);
border-left: 1px solid var(--divider);
}
.team-seitenleiste[hidden] { display: none; }
.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;
word-break: break-word;
max-height: 160px;
overflow-y: auto;
font-family: var(--schrift-mono);
}
.team-aktionen {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
}
.team-eintrag {
justify-content: flex-start;
width: 100%;
text-align: left;
}
.team-legende {
flex-wrap: wrap;
gap: 16px;
}
@media (prefers-reduced-motion: reduce) {
.team-link,
.team-zeile.blinkt { animation: none; }
.team-welt.gleitend { transition: none; }
}
@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) ─── */
.org-ansicht {
display: flex;
flex-direction: column;
gap: 12px;
}
.org-raster {
display: grid;
grid-template-columns: minmax(260px, 360px) 1fr;
gap: 16px;
align-items: start;
}
.org-baum {
background: var(--surface);
border: 1px solid var(--divider);
padding: 12px;
}
.org-baum-kopf {
justify-content: space-between;
margin-bottom: 8px;
}
.org-knoten-liste {
display: flex;
flex-direction: column;
}
.org-ebene-titel {
padding: 6px 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
button.org-knoten {
width: 100%;
justify-content: space-between;
border: none;
border-left: 3px solid transparent;
background: transparent;
padding-top: 5px;
padding-bottom: 5px;
text-align: left;
}
button.org-knoten.aktiv {
border-left-color: var(--accent);
background: var(--surface-hover);
}
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;
gap: 12px;
}
.org-detail .karte {
display: flex;
flex-direction: column;
gap: 8px;
}
.org-grund input {
width: 100%;
}
.org-werkzeuge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 4px 12px;
}
.org-werkzeug {
font-size: 13px;
}
.org-einstellung {
flex-wrap: wrap;
}
.org-liste {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
tr.org-ausreisser td {
background: rgba(181, 130, 42, 0.08);
}
.org-probleme {
border: 1px solid var(--gefahr);
border-left-width: 4px;
padding: 10px 14px;
font-size: 13px;
}
.org-probleme ul {
margin: 6px 0 0 18px;
}
@media (max-width: 900px) {
.org-raster { grid-template-columns: 1fr; }
}
/* #684: the board filter above the task cards */
.taskboard-bretter {
margin-bottom: 12px;
}
@@ -35,10 +35,13 @@ import { renderUsageRuns } from "./views/usage-runs.js";
import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js";
import { renderOrganisation } from "./views/organisation.js";
// ─── 1. Routen registrieren ───
function verlasseAnsicht() {
leaveTeam();
trenneLogStream();
trenneAgentenChatsStream();
stoppeInstanzenPolling();
@@ -50,6 +53,8 @@ registriereRoute("/chat/:agentId", (p) => { verlasseAnsicht(); return rendereCha
registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten(); });
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
@@ -67,6 +67,154 @@
"nav.chat": "Chat",
"nav.agents": "Agenten",
"nav.tasks": "Aufgaben",
"nav.team": "Teamübersicht",
"common.saved": "Gespeichert.",
"common.create": "Anlegen",
"nav.organisation": "Organisation",
"org.title": "Organisation",
"org.subtitle": "Einheiten, Leitungen und Mitglieder. Jeder Agent erreicht nur seine Einheit und seine Leitung — die Struktur ist das Adressbuch.",
"org.problems": "Probleme in der Struktur",
"org.structure": "Struktur",
"org.newUnit": "Neue Einheit",
"org.newSubUnit": "Neue Untereinheit",
"org.topLevel": "Oberste Ebene",
"org.leadShort": "Leitung",
"org.unitId": "Kennung (Buchstaben, Ziffern, - und _)",
"org.unitName": "Name",
"org.parent": "Übergeordnete Einheit",
"org.goal": "Ziel der Einheit",
"org.reason": "Begründung der Änderung (geht ins Audit)",
"org.reasonPlaceholder": "z. B. Marktplätze bekommen ein eigenes Team",
"org.selectHint": "Links eine Einheit oder einen Agenten wählen.",
"org.lead": "Leitung",
"org.noLead": "— keine Leitung (die Leitung darüber übernimmt) —",
"org.leadHint": "Die Leitung muss Mitglied der Einheit sein.",
"org.position": "Platz im Baum",
"org.move": "Verschieben",
"org.moveUnitHint": "Die Mitglieder bleiben in der Einheit; es ändert sich, was darüber liegt. Nicht möglich, solange ein betroffener Agent läuft.",
"org.ceiling": "Rechte-Obergrenze",
"org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
"org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
"org.settings": "Sammelwerte",
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
"org.setting.DailyCostUsd": "Tagesbudget (USD)",
"org.setting.DailyTokens": "Tagesbudget (Tokens)",
"org.setting.Model": "Modell",
"org.setting.WhenExhausted": "Wenn das Budget erschöpft ist",
"org.modePerAgent": "jeder Agent bekommt den Wert",
"org.modeDistribute": "Wert ist ein Gesamtbetrag, verteilt",
"org.preview": "Vorschau",
"org.apply": "Gewählte Werte schreiben",
"org.agent": "Agent",
"org.current": "Bisher",
"org.proposed": "Neu",
"org.outlier": "Ausreißer",
"org.outlierHint": "Ausreißer weichen stark von ihren Kollegen ab — oft ein Spezialist mit bewusst anderem Wert. Sie sind standardmäßig nicht gewählt.",
"org.rememberDefault": "Als Vorgabe für neue Mitglieder merken",
"org.settingInvalid": "Dieser Wert ist für diese Einstellung nicht gültig.",
"org.members": "Mitglieder",
"org.noMembers": "Noch keine Mitglieder.",
"org.deleteUnit": "Einheit löschen",
"org.unit": "Einheit",
"org.answersTo": "Berichtet an",
"org.human": "einen Menschen",
"org.since": "In dieser Einheit seit {time}",
"org.tools": "Werkzeuge",
"org.moveAgent": "Agent verschieben",
"org.moveAgentHint": "Adressbuch, Leitung und womöglich Werkzeuge ändern sich. Der Agent und beide Leitungen erfahren es in ihrem nächsten Lauf.",
"org.toolsLostTitle": "Werkzeuge würden entfernt",
"org.toolsLostIntro": "Der neue Platz hat eine Rechte-Obergrenze. Diese Agenten verlören Werkzeuge:",
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
"org.workspaces": "Arbeitsbereiche",
"org.workspacesHint": "Gemeinsame Ordner mit Mitgliederliste. Wer nicht auf der Liste steht, sieht den Bereich nicht.",
"org.noWorkspaces": "Noch keine Arbeitsbereiche — eine Einheit bekommt ihren beim Anlegen.",
"org.newWorkspace": "Neuer Arbeitsbereich",
"org.newWorkspaceHint": "Für etwas, das mehrere Einheiten bewusst teilen, etwa einen Artikelstamm. Die Eigentümer-Einheit verwaltet ihn; Leser kommen danach dazu.",
"org.workspaceId": "Id (Ordnername)",
"org.workspaceName": "Name",
"org.purpose": "Zweck",
"org.owner": "Eigentümer",
"org.noOwner": "— Instanz (Mensch) —",
"org.fileCount": "{n} Dateien",
"org.unitOwnWorkspace": "eigener Bereich der Einheit",
"org.unitWorkspace": "Arbeitsbereich",
"org.unitWorkspaceMissing": "Diese Einheit hat noch keinen eigenen Arbeitsbereich.",
"org.createUnitWorkspace": "Arbeitsbereich anlegen",
"org.editWorkspace": "Arbeitsbereich bearbeiten",
"org.protectedPaths": "Geschützte Ordner",
"org.protectedHint": "Durch Komma getrennt. Dort können Dateien hinzukommen, ändern oder löschen dürfen sie nur Verwalter des Bereichs.",
"org.memberEntries": "Mitgliederliste",
"org.membersHint": "Das Recht steht am Bereich. Ein Einheitseintrag gilt auch für die Einheiten darunter, wenn nicht abgeschaltet. Löschen braucht Verwalten.",
"org.noWorkspaceMembers": "niemand",
"org.memberKind": "Art",
"org.memberWho": "Wer",
"org.memberUnit": "Einheit",
"org.memberAgent": "Agent",
"org.accessLevel": "Stufe",
"org.access.Read": "lesen",
"org.access.Write": "schreiben",
"org.access.Manage": "verwalten",
"org.includeSubUnits": "inkl. Untereinheiten",
"org.addMember": "Mitglied hinzufügen",
"org.removeMember": "Mitglied entfernen",
"org.whoGetsIn": "Wer hineinkommt",
"org.nobodyHasAccess": "Niemand — nur ein Mensch kommt heran.",
"org.deleteWorkspace": "Arbeitsbereich löschen",
"org.deleteWorkspaceHint": "Nur ein leerer Bereich lässt sich löschen. Abgelegtes wird von hier aus nie gelöscht.",
"org.unitWorkspaceStays": "Der eigene Bereich einer Einheit geht mit der Einheit. Hält er dann noch Dateien, bleibt er ohne Eigentümer.",
"team.title": "Teamübersicht",
"team.subtitle": "Jede Einheit ist eine Karte, verbunden wie die Organisation. Eine Linie trägt die Farbe des dringlichsten Zustands darunter, eine rote Linie führt also zum Problem. Klick auf Karte oder Agent zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
"team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "Live-Verbindung (verblasst nach 30 s)",
"team.state.Idle": "bereit",
"team.state.Working": "arbeitet",
"team.state.Stuck": "hängt",
"team.state.WaitingForApproval": "wartet auf Freigabe",
"team.state.Problem": "Problem",
"team.state.BudgetExhausted": "Budget erschöpft",
"team.running": "Läuft gerade",
"team.since": "seit {time}",
"team.task": "Aufgabe {id}",
"team.calledBy": "gerufen von {agent}",
"team.stuckHint": "Läufe dieses Agenten dauern sonst etwa {minutes} Min.",
"team.approvals": "{n} Werkzeugaufruf/-aufrufe warten auf Freigabe",
"team.lastRun": "Letzter Lauf",
"team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen",
"team.openRuns": "Läufe anzeigen",
"team.unitAgentsOne": "1 Agent",
"team.unitAgents": "{n} Agenten",
"team.legendLead": "Leitung der Einheit",
"team.leads": "Leitet",
"team.memberOf": "Mitglied von",
"team.members": "Mitglieder",
"team.subUnits": "Untereinheiten",
"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",
@@ -198,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",
@@ -67,6 +67,154 @@
"nav.chat": "Chat",
"nav.agents": "Agents",
"nav.tasks": "Tasks",
"nav.team": "Team overview",
"common.saved": "Saved.",
"common.create": "Create",
"nav.organisation": "Organisation",
"org.title": "Organisation",
"org.subtitle": "Units, leads and members. Every agent only reaches its unit and its lead — the structure is the address book.",
"org.problems": "Problems in the structure",
"org.structure": "Structure",
"org.newUnit": "New unit",
"org.newSubUnit": "New sub-unit",
"org.topLevel": "Top level",
"org.leadShort": "lead",
"org.unitId": "Id (letters, digits, - and _)",
"org.unitName": "Name",
"org.parent": "Parent unit",
"org.goal": "Goal of the unit",
"org.reason": "Reason for the change (goes into the audit log)",
"org.reasonPlaceholder": "e.g. marketplaces get their own team",
"org.selectHint": "Select a unit or an agent on the left.",
"org.lead": "Lead",
"org.noLead": "— no lead (the lead above takes over) —",
"org.leadHint": "The lead must be a member of the unit.",
"org.position": "Position in the tree",
"org.move": "Move",
"org.moveUnitHint": "The members stay in the unit; what changes is what lies above it. Not possible while an affected agent is running.",
"org.ceiling": "Tool ceiling",
"org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
"org.ceilingLimit": "Limit the tools of this unit",
"org.ceilingInherited": "Limited from above to: {tools}",
"org.settings": "Unit-wide values",
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
"org.setting.DailyCostUsd": "Daily budget (USD)",
"org.setting.DailyTokens": "Daily budget (tokens)",
"org.setting.Model": "Model",
"org.setting.WhenExhausted": "When the budget is exhausted",
"org.modePerAgent": "each agent gets the value",
"org.modeDistribute": "value is a total, distributed",
"org.preview": "Preview",
"org.apply": "Write the selected values",
"org.agent": "Agent",
"org.current": "Current",
"org.proposed": "New",
"org.outlier": "outlier",
"org.outlierHint": "Outliers differ strongly from their colleagues — often a specialist with a deliberately different value. They are not selected by default.",
"org.rememberDefault": "Remember as default for new members",
"org.settingInvalid": "That value is not valid for this setting.",
"org.members": "Members",
"org.noMembers": "No members yet.",
"org.deleteUnit": "Delete unit",
"org.unit": "Unit",
"org.answersTo": "Answers to",
"org.human": "a human",
"org.since": "In this unit since {time}",
"org.tools": "Tools",
"org.moveAgent": "Move agent",
"org.moveAgentHint": "Its address book, its lead and possibly its tools change. The agent and both leads are told in their next run.",
"org.toolsLostTitle": "Tools would be removed",
"org.toolsLostIntro": "The new position has a tool ceiling. These agents would lose tools:",
"org.toolsLostConfirm": "Remove the tools and apply",
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
"org.workspaces": "Workspaces",
"org.workspacesHint": "Shared folders with a member list. Whoever is not on the list does not see the workspace.",
"org.noWorkspaces": "No workspaces yet — a unit gets one when it is created.",
"org.newWorkspace": "New workspace",
"org.newWorkspaceHint": "For something several units share on purpose, like an article master. The owning unit manages it; add readers afterwards.",
"org.workspaceId": "Id (folder name)",
"org.workspaceName": "Name",
"org.purpose": "Purpose",
"org.owner": "Owner",
"org.noOwner": "— instance (human) —",
"org.fileCount": "{n} files",
"org.unitOwnWorkspace": "the unit's own workspace",
"org.unitWorkspace": "Workspace",
"org.unitWorkspaceMissing": "This unit has no workspace of its own yet.",
"org.createUnitWorkspace": "Create its workspace",
"org.editWorkspace": "Edit workspace",
"org.protectedPaths": "Protected folders",
"org.protectedHint": "Comma separated. Files there can be added, but only members who manage the workspace change or delete them.",
"org.memberEntries": "Member list",
"org.membersHint": "The right stands at the workspace. A unit entry includes the units below it unless switched off. Deleting files needs Manage.",
"org.noWorkspaceMembers": "nobody",
"org.memberKind": "Kind",
"org.memberWho": "Who",
"org.memberUnit": "Unit",
"org.memberAgent": "Agent",
"org.accessLevel": "Level",
"org.access.Read": "read",
"org.access.Write": "write",
"org.access.Manage": "manage",
"org.includeSubUnits": "incl. sub-units",
"org.addMember": "Add member",
"org.removeMember": "Remove member",
"org.whoGetsIn": "Who gets in",
"org.nobodyHasAccess": "Nobody — only a human reaches it.",
"org.deleteWorkspace": "Delete workspace",
"org.deleteWorkspaceHint": "Only an empty workspace can be deleted. Nothing filed is deleted from here.",
"org.unitWorkspaceStays": "A unit's own workspace goes with its unit. If it still holds files then, it stays without owner.",
"team.title": "Team overview",
"team.subtitle": "Every unit is a card, connected as the organisation. A line takes the colour of the most urgent state below it, so a red line leads to the problem. Click a card or an agent for details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
"team.empty": "No agents in this instance.",
"team.legendLink": "live link (fades after 30 s)",
"team.state.Idle": "idle",
"team.state.Working": "working",
"team.state.Stuck": "stuck",
"team.state.WaitingForApproval": "waiting for approval",
"team.state.Problem": "problem",
"team.state.BudgetExhausted": "budget exhausted",
"team.running": "Running now",
"team.since": "since {time}",
"team.task": "task {id}",
"team.calledBy": "called by {agent}",
"team.stuckHint": "Runs of this agent usually take about {minutes} min.",
"team.approvals": "{n} tool call(s) waiting for approval",
"team.lastRun": "Last run",
"team.openApprovals": "Open approvals",
"team.openChat": "Open chat",
"team.openRuns": "Show runs",
"team.unitAgentsOne": "1 agent",
"team.unitAgents": "{n} agents",
"team.legendLead": "lead of the unit",
"team.leads": "Leads",
"team.memberOf": "Member of",
"team.members": "Members",
"team.subUnits": "Sub-units",
"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",
@@ -198,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",
@@ -55,6 +55,18 @@
</svg>
<span class="nav-label" data-i18n="nav.agents">Agents</span>
</a>
<a href="#/team" class="nav-link" title="Team overview" data-i18n-title="nav.team">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M3 3h6v6H3z M15 3h6v6h-6z M9 15h6v6H9z M6 9v3h12V9 M12 12v3"/>
</svg>
<span class="nav-label" data-i18n="nav.team">Team overview</span>
</a>
<a href="#/organisation" class="nav-link" title="Organisation" data-i18n-title="nav.organisation">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 2h6v5H9z M3 17h6v5H3z M15 17h6v5h-6z M12 7v5 M6 17v-5h12v5"/>
</svg>
<span class="nav-label" data-i18n="nav.organisation">Organisation</span>
</a>
<a href="#/aufgaben" class="nav-link" title="Tasks" data-i18n-title="nav.tasks">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
@@ -93,7 +93,7 @@ export function verbindeStrom() {
meldeStatus(false);
};
const bekannteEreignisse = ["chat", "step", "run", "instance", "status"];
const bekannteEreignisse = ["chat", "step", "run", "instance", "status", "activity"];
for (const art of bekannteEreignisse) {
strom.addEventListener(art, (e) => verteileEreignis(art, e.data));
}
+27
View File
@@ -0,0 +1,27 @@
# Vendored libraries
Third-party files the WebUI ships as-is. They live here instead of being loaded from a CDN
because the target machine may have no internet access (WebUI-Leitfaden). Do not edit them;
to update, replace the file and this table together.
Downloaded 2026-09-24 from the npm registry. Each tarball was checked against the
`dist.integrity` (SHA-512) the registry publishes before extracting.
| Package | Version | File | License | SHA-256 of the file |
|---|---|---|---|---|
| [elkjs](https://www.npmjs.com/package/elkjs) | 0.9.3 | `elkjs/elk.bundled.js` | EPL-2.0 | `b0745abd7f23cd91690a1587e377edbe19fd7233c783300290936720546216d4` |
**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.
**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.
@@ -0,0 +1,264 @@
# Eclipse Public License - v 2.0
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
PUBLIC LICENSE (“AGREEMENT”). ANY USE, REPRODUCTION OR DISTRIBUTION OF
THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
## 1. DEFINITIONS
“Contribution” means:
- a\) in the case of the initial Contributor, the initial content
Distributed under this Agreement, and
- b\) in the case of each subsequent Contributor:
- i\) changes to the Program, and
- ii\) additions to the Program;
where such changes and/or additions to the Program originate from
and are Distributed by that particular Contributor. A Contribution
“originates” from a Contributor if it was added to the Program by
such Contributor itself or anyone acting on such Contributor's
behalf. Contributions do not include changes or additions to the
Program that are not Modified Works.
“Contributor” means any person or entity that Distributes the Program.
“Licensed Patents” mean patent claims licensable by a Contributor which
are necessarily infringed by the use or sale of its Contribution alone
or when combined with the Program.
“Program” means the Contributions Distributed in accordance with this
Agreement.
“Recipient” means anyone who receives the Program under this Agreement
or any Secondary License (as applicable), including Contributors.
“Derivative Works” shall mean any work, whether in Source Code or other
form, that is based on (or derived from) the Program and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
“Modified Works” shall mean any work in Source Code or other form that
results from an addition to, deletion from, or modification of the
contents of the Program, including, for purposes of clarity any new file
in Source Code form that contains any contents of the Program. Modified
Works shall not include works that contain only declarations,
interfaces, types, classes, structures, or files of the Program solely
in each case in order to link to, bind by name, or subclass the Program
or Modified Works thereof.
“Distribute” means the acts of a) distributing or b) making available in
any manner that enables the transfer of a copy.
“Source Code” means the form of a Program preferred for making
modifications, including but not limited to software source code,
documentation source, and configuration files.
“Secondary License” means either the GNU General Public License, Version
2.0, or any later versions of that license, including any exceptions or
additional permissions as identified by the initial Contributor.
## 2. GRANT OF RIGHTS
- a\) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free copyright
license to reproduce, prepare Derivative Works of, publicly display,
publicly perform, Distribute and sublicense the Contribution of such
Contributor, if any, and such Derivative Works.
- b\) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free patent license
under Licensed Patents to make, use, sell, offer to sell, import and
otherwise transfer the Contribution of such Contributor, if any, in
Source Code or other form. This patent license shall apply to the
combination of the Contribution and the Program if, at the time the
Contribution is added by the Contributor, such addition of the
Contribution causes such combination to be covered by the
Licensed Patents. The patent license shall not apply to any other
combinations which include the Contribution. No hardware per se is
licensed hereunder.
- c\) Recipient understands that although each Contributor grants the
licenses to its Contributions set forth herein, no assurances are
provided by any Contributor that the Program does not infringe the
patent or other intellectual property rights of any other entity. Each
Contributor disclaims any liability to Recipient for claims brought by
any other entity based on infringement of intellectual property rights
or otherwise. As a condition to exercising the rights and licenses
granted hereunder, each Recipient hereby assumes sole responsibility to
secure any other intellectual property rights needed, if any. For
example, if a third party patent license is required to allow Recipient
to Distribute the Program, it is Recipient's responsibility to acquire
that license before distributing the Program.
- d\) Each Contributor represents that to its knowledge it has sufficient
copyright rights in its Contribution, if any, to grant the copyright
license set forth in this Agreement.
- e\) Notwithstanding the terms of any Secondary License, no Contributor
makes additional grants to any Recipient (other than those set forth in
this Agreement) as a result of such Recipient's receipt of the Program
under the terms of a Secondary License (if permitted under the terms of
Section 3).
## 3. REQUIREMENTS
3.1 If a Contributor Distributes the Program in any form, then:
- a\) the Program must also be made available as Source Code, in accordance
with section 3.2, and the Contributor must accompany the Program with a
statement that the Source Code for the Program is available under this
Agreement, and informs Recipients how to obtain it in a reasonable
manner on or through a medium customarily used for software exchange;
and
- b\) the Contributor may Distribute the Program under a license different
than this Agreement, provided that such license:
- i\) effectively disclaims on behalf of all other Contributors all
warranties and conditions, express and implied, including warranties or
conditions of title and non-infringement, and implied warranties or
conditions of merchantability and fitness for a particular purpose;
- ii\) effectively excludes on behalf of all other Contributors all
liability for damages, including direct, indirect, special, incidental
and consequential damages, such as lost profits;
- iii\) does not attempt to limit or alter the recipients' rights in the
Source Code under section 3.2; and
- iv\) requires any subsequent distribution of the Program by any party to
be under a license that satisfies the requirements of this section 3.
3.2 When the Program is Distributed as Source Code:
- a\) it must be made available under this Agreement, or if the Program (i)
is combined with other material in a separate file or files made
available under a Secondary License, and (ii) the initial Contributor
attached to the Source Code the notice described in Exhibit A of this
Agreement, then the Program may be made available under the terms of
such Secondary Licenses, and
- b\) a copy of this Agreement must be included with each copy of
the Program.
3.3 Contributors may not remove or alter any copyright, patent,
trademark, attribution notices, disclaimers of warranty, or limitations
of liability (‘notices’) contained within the Program from any copy of
the Program which they Distribute, provided that Contributors may add
their own appropriate notices.
## 4. COMMERCIAL DISTRIBUTION
Commercial distributors of software may accept certain responsibilities
with respect to end users, business partners and the like. While this
license is intended to facilitate the commercial use of the Program, the
Contributor who includes the Program in a commercial product offering
should do so in a manner which does not create potential liability for
other Contributors. Therefore, if a Contributor includes the Program in
a commercial product offering, such Contributor (“Commercial
Contributor”) hereby agrees to defend and indemnify every other
Contributor (“Indemnified Contributor”) against any losses, damages and
costs (collectively “Losses”) arising from claims, lawsuits and other
legal actions brought by a third party against the Indemnified
Contributor to the extent caused by the acts or omissions of such
Commercial Contributor in connection with its distribution of the
Program in a commercial product offering. The obligations in this
section do not apply to any claims or Losses relating to any actual or
alleged intellectual property infringement. In order to qualify, an
Indemnified Contributor must: a) promptly notify the Commercial
Contributor in writing of such claim, and b) allow the Commercial
Contributor to control, and cooperate with the Commercial Contributor
in, the defense and any related settlement negotiations. The Indemnified
Contributor may participate in any such claim at its own expense.
For example, a Contributor might include the Program in a commercial
product offering, Product X. That Contributor is then a Commercial
Contributor. If that Commercial Contributor then makes performance
claims, or offers warranties related to Product X, those performance
claims and warranties are such Commercial Contributor's responsibility
alone. Under this section, the Commercial Contributor would have to
defend claims against the other Contributors related to those
performance claims and warranties, and if a court requires any other
Contributor to pay any damages as a result, the Commercial Contributor
must pay those damages.
## 5. NO WARRANTY {#warranty}
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN “AS IS”
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
PURPOSE. Each Recipient is solely responsible for determining the
appropriateness of using and distributing the Program and assumes all
risks associated with its exercise of rights under this Agreement,
including but not limited to the risks and costs of program errors,
compliance with applicable laws, damage to or loss of data, programs or
equipment, and unavailability or interruption of operations.
## 6. DISCLAIMER OF LIABILITY {#disclaimer}
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
## 7. GENERAL
If any provision of this Agreement is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this Agreement, and without further action
by the parties hereto, such provision shall be reformed to the minimum
extent necessary to make such provision valid and enforceable.
If Recipient institutes patent litigation against any entity (including
a cross-claim or counterclaim in a lawsuit) alleging that the Program
itself (excluding combinations of the Program with other software or
hardware) infringes such Recipient's patent(s), then such Recipient's
rights granted under Section 2(b) shall terminate as of the date such
litigation is filed.
All Recipient's rights under this Agreement shall terminate if it fails
to comply with any of the material terms or conditions of this Agreement
and does not cure such failure in a reasonable period of time after
becoming aware of such noncompliance. If all Recipient's rights under
this Agreement terminate, Recipient agrees to cease use and distribution
of the Program as soon as reasonably practicable. However, Recipient's
obligations under this Agreement and any licenses granted by Recipient
relating to the Program shall continue and survive.
Everyone is permitted to copy and distribute copies of this Agreement,
but in order to avoid inconsistency the Agreement is copyrighted and may
only be modified in the following manner. The Agreement Steward reserves
the right to publish new versions (including revisions) of this
Agreement from time to time. No one other than the Agreement Steward has
the right to modify this Agreement. The Eclipse Foundation is the
initial Agreement Steward. The Eclipse Foundation may assign the
responsibility to serve as the Agreement Steward to a suitable separate
entity. Each new version of the Agreement will be given a distinguishing
version number. The Program (including Contributions) may always be
Distributed subject to the version of the Agreement under which it was
received. In addition, after a new version of the Agreement is
published, Contributor may elect to Distribute the Program (including
its Contributions) under the new version.
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
receives no rights or licenses to the intellectual property of any
Contributor under this Agreement, whether expressly, by implication,
estoppel or otherwise. All rights in the Program not expressly granted
under this Agreement are reserved. Nothing in this Agreement is intended
to be enforceable by any entity that is not a Contributor or Recipient.
No third-party beneficiary rights are created under this Agreement.
## Exhibit A – Form of Secondary Licenses Notice {#exhibit-a}
“This Source Code may also be made available under the following
Secondary Licenses when the conditions for such availability set forth
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
version(s), and exceptions or additional permissions here}.”
> Simply including a copy of this Agreement, including this Exhibit A is
> not sufficient to license the Source Code under Secondary Licenses.
>
> If it is not possible or desirable to put the notice in a particular
> file, then You may include the notice in a location (such as a LICENSE
> file in a relevant directory) where a recipient would be likely to
> look for such a notice.
>
> You may add additional accurate notices of copyright ownership.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,730 @@
// ClawdDotNet — Organisation (#683)
//
// Units, leads, members — and the three things that change rights: moving, the tool
// ceiling, unit-wide values. Every rule is the instance's (OrgAdministration); this page
// asks for a preview first wherever something could be lost, and shows it before writing.
//
// 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";
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" | "workspace", id }
let treeBox = null;
let detailBox = null;
let reasonInput = null;
export async function renderOrganisation() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
treeBox = erstelleElement("div", { klasse: "org-baum" });
detailBox = erstelleElement("div", { klasse: "org-detail" });
container.replaceChildren(erstelleElement(
"div",
{ klasse: "org-ansicht" },
erstelleElement("h1", { text: t("org.title") }),
erstelleElement("p", { klasse: "gedaempft", text: t("org.subtitle") }),
erstelleElement("div", { id: "org-probleme" }),
erstelleElement("div", { klasse: "org-raster" }, treeBox, detailBox)
));
await load();
}
async function load() {
try {
data = await api("/organisation");
} catch (err) {
treeBox.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
return;
}
if (selection && !find(selection)) selection = null;
renderProblems();
renderTree();
renderDetail();
}
// ─── helpers ───
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);
}
function membersOf(unitId) {
return data.agents.filter((a) => (a.unitId ?? null) === unitId);
}
function descendantIds(unitId) {
const ids = new Set([unitId]);
let grew = true;
while (grew) {
grew = false;
for (const u of data.units) {
if (u.parentId && ids.has(u.parentId) && !ids.has(u.id)) { ids.add(u.id); grew = true; }
}
}
return ids;
}
function reason() {
return reasonInput?.value.trim() ?? "";
}
async function send(path, method, body, successText) {
try {
const result = await api(path, { method, body });
zeigeMeldung(result?.message ?? successText ?? t("common.saved"), "erfolg");
await load();
return true;
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
return false;
}
}
/**
* Moves and ceiling changes: ask for a preview, stop on problems or running agents, show
* the tools that would be lost and let the human confirm — only then write.
*/
async function withPreview(path, method, body) {
let preview;
try {
preview = await api(path, { method, body: { ...body, preview: true } });
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
return;
}
if (preview.problems.length) { zeigeMeldung(preview.problems.join(" "), "gefahr", 8000); return; }
if (preview.running.length) {
zeigeMeldung(t("org.runningRefusal", { agents: preview.running.join(", ") }), "warnung", 8000);
return;
}
const lost = Object.entries(preview.toolsLost ?? {});
if (!lost.length) {
await send(path, method, { ...body, preview: false });
return;
}
const list = erstelleElement("ul", { klasse: "org-liste" },
...lost.map(([agent, tools]) => erstelleElement("li", { text: `${agent}: ${tools.join(", ")}` })));
const content = erstelleElement("div", {},
erstelleElement("p", { text: t("org.toolsLostIntro") }), list);
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
const confirm = erstelleElement("button", { type: "button", klasse: "gefahr", text: t("org.toolsLostConfirm") });
const modal = erstelleModal(t("org.toolsLostTitle"), content, [cancel, confirm]);
cancel.addEventListener("click", () => modal.schliessen());
confirm.addEventListener("click", async () => {
modal.schliessen();
await send(path, method, { ...body, preview: false, dropExcessTools: true });
});
modal.oeffnen();
}
function unitOptions(select, current, exclude = new Set(), topLabel = t("org.topLevel")) {
select.append(erstelleElement("option", { value: "", text: topLabel }));
for (const unit of data.units) {
if (exclude.has(unit.id)) continue;
const option = erstelleElement("option", { value: unit.id, text: unit.path });
if (unit.id === current) option.selected = true;
select.append(option);
}
if (!current) select.value = "";
return select;
}
// ─── problems ───
function renderProblems() {
const box = document.getElementById("org-probleme");
if (!box) return;
box.replaceChildren(...(data.problems.length
? [erstelleElement("div", { klasse: "org-probleme" },
erstelleElement("strong", { text: t("org.problems") }),
erstelleElement("ul", {}, ...data.problems.map((p) => erstelleElement("li", { text: p }))))]
: []));
}
// ─── tree ───
function renderTree() {
const newUnit = erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("org.newUnit"), onclick: () => openCreateUnit()
});
const list = erstelleElement("div", { klasse: "org-knoten-liste" });
const root = erstelleElement("div", { klasse: "org-ebene" },
erstelleElement("div", { klasse: "org-ebene-titel gedaempft text-klein", text: t("org.topLevel") }));
for (const agent of membersOf(null)) root.append(agentRow(agent, 0));
list.append(root);
for (const unit of childrenOf(null)) list.append(unitBlock(unit, 0));
treeBox.replaceChildren(
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
erstelleElement("h2", { text: t("org.structure") }), newUnit),
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) {
const lead = unit.leaderAgentId ? ` · ${t("org.leadShort")}: ${unit.leaderAgentId}` : "";
const count = membersOf(unit.id).length;
const row = erstelleElement("button", {
type: "button",
klasse: `org-knoten org-einheit ${selection?.kind === "unit" && selection.id === unit.id ? "aktiv" : ""}`,
stil: `padding-left: ${12 + depth * 18}px`,
onclick: () => select({ kind: "unit", id: unit.id })
},
erstelleElement("span", { klasse: "org-name", text: `▣ ${unit.name}` }),
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${count}${lead}` }));
const block = erstelleElement("div", {}, row);
for (const agent of membersOf(unit.id)) block.append(agentRow(agent, depth + 1));
for (const child of childrenOf(unit.id)) block.append(unitBlock(child, depth + 1));
return block;
}
function agentRow(agent, depth) {
return erstelleElement("button", {
type: "button",
klasse: `org-knoten org-agent ${selection?.kind === "agent" && selection.id === agent.agentId ? "aktiv" : ""}`,
stil: `padding-left: ${12 + depth * 18}px`,
onclick: () => select({ kind: "agent", id: agent.agentId })
},
erstelleElement("span", { text: `${agent.isLead ? "★ " : "· "}${agent.displayName || agent.agentId}` }),
erstelleElement("span", { klasse: "gedaempft text-klein", text: agent.agentId }));
}
function select(sel) {
selection = sel;
renderTree();
renderDetail();
}
function openCreateUnit(parentId = null) {
const id = erstelleElement("input", { type: "text", placeholder: "marketing", required: true });
const name = erstelleElement("input", { type: "text", placeholder: "Marketing", required: true });
const parent = unitOptions(erstelleElement("select"), parentId);
const goal = erstelleElement("textarea", { rows: 2 });
const form = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.unitId") }), id,
erstelleElement("label", { text: t("org.unitName") }), name,
erstelleElement("label", { text: t("org.parent") }), parent,
erstelleElement("label", { text: t("org.goal") }), goal);
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.newUnit"), form, [cancel, create]);
cancel.addEventListener("click", () => modal.schliessen());
form.addEventListener("submit", async (e) => {
e.preventDefault();
const ok = await send("/organisation/units", "POST", {
id: id.value.trim(), name: name.value.trim(), parentId: parent.value || null, goal: goal.value, reason: reason()
});
if (ok) {
modal.schliessen();
select({ kind: "unit", id: id.value.trim() });
}
});
modal.oeffnen();
}
// ─── detail ───
function renderDetail() {
const item = find(selection);
reasonInput = erstelleElement("input", { type: "text", placeholder: t("org.reasonPlaceholder") });
const reasonCard = erstelleKarte(null, [
erstelleElement("label", { klasse: "text-klein gedaempft", text: t("org.reason") }), reasonInput
], { klasse: "org-grund" });
if (!item) {
detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }));
return;
}
const panels = selection.kind === "unit" ? unitPanel(item)
: selection.kind === "workspace" ? workspacePanel(item)
: agentPanel(item);
detailBox.replaceChildren(reasonCard, ...panels);
}
function unitPanel(unit) {
const cards = [];
// Name and goal.
const name = erstelleElement("input", { type: "text", value: unit.name });
const goal = erstelleElement("textarea", { rows: 2 });
goal.value = unit.goal ?? "";
cards.push(erstelleKarte(unit.path, [
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.unitName") }), name,
erstelleElement("label", { text: t("org.goal") }), goal,
erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}`, "PATCH",
{ name: name.value, goal: goal.value, reason: reason() })
}))
]));
// Lead.
const lead = erstelleElement("select", {}, erstelleElement("option", { value: "", text: t("org.noLead") }));
for (const member of membersOf(unit.id)) {
const option = erstelleElement("option", { value: member.agentId, text: member.displayName || member.agentId });
if (member.agentId === unit.leaderAgentId) option.selected = true;
lead.append(option);
}
cards.push(erstelleKarte(t("org.lead"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.leadHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, lead,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/leader`, "PUT",
{ agentId: lead.value || null, reason: reason() })
}))
]));
// Position in the tree.
const parent = unitOptions(erstelleElement("select"), unit.parentId, descendantIds(unit.id));
cards.push(erstelleKarte(t("org.position"), [
erstelleElement("div", { klasse: "gruppe-horizontal" }, parent,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("org.move"),
onclick: () => withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/move`, "POST",
{ target: parent.value || null, reason: reason() })
})),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
]));
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(settingsCard(unit));
// Members.
const members = membersOf(unit.id);
cards.push(erstelleKarte(t("org.members"), [
members.length
? erstelleElement("ul", { klasse: "org-liste" }, ...members.map((m) => erstelleElement("li", {},
erstelleElement("button", {
type: "button", klasse: "flach klein", text: `${m.isLead ? "★ " : ""}${m.displayName || m.agentId}`,
onclick: () => select({ kind: "agent", id: m.agentId })
}))))
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noMembers") }),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.newSubUnit"), onclick: () => openCreateUnit(unit.id)
}),
erstelleElement("button", {
type: "button", klasse: "gefahr klein", text: t("org.deleteUnit"),
onclick: async () => {
if (await send(`/organisation/units/${encodeURIComponent(unit.id)}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
selection = null;
renderDetail();
}
}
}))
]));
return cards;
}
function ceilingCard(unit) {
const limited = erstelleElement("input", { type: "checkbox" });
limited.checked = Array.isArray(unit.toolCeiling);
const boxes = data.knownTools.map((tool) => {
const box = erstelleElement("input", { type: "checkbox", value: tool });
// Pre-selected: the unit's own ceiling — or, without one, what is allowed from above anyway.
const allowed = unit.toolCeiling ?? unit.effectiveToolCeiling;
box.checked = !allowed || allowed.includes(tool);
return erstelleElement("label", { klasse: "org-werkzeug" }, box, ` ${tool}`);
});
const grid = erstelleElement("div", { klasse: "org-werkzeuge" }, ...boxes);
const sync = () => { grid.hidden = !limited.checked; };
limited.addEventListener("change", sync);
sync();
const inherited = unit.effectiveToolCeiling && !unit.toolCeiling
? erstelleElement("p", { klasse: "text-klein", text: t("org.ceilingInherited", { tools: unit.effectiveToolCeiling.join(", ") }) })
: null;
return erstelleKarte(t("org.ceiling"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.ceilingHint") }),
inherited,
erstelleElement("label", { klasse: "gruppe-horizontal" }, limited, t("org.ceilingLimit")),
grid,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => {
const tools = limited.checked
? [...grid.querySelectorAll("input:checked")].map((b) => b.value)
: null;
withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/ceiling`, "PUT", { tools, reason: reason() });
}
})
]);
}
function settingsCard(unit) {
const setting = erstelleElement("select", {},
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
const value = erstelleElement("input", { type: "text", placeholder: "10" });
const mode = erstelleElement("select", {},
erstelleElement("option", { value: "PerAgent", text: t("org.modePerAgent") }),
erstelleElement("option", { value: "Distribute", text: t("org.modeDistribute") }));
const remember = erstelleElement("input", { type: "checkbox" });
remember.checked = true;
const result = erstelleElement("div");
setting.addEventListener("change", () => { mode.disabled = !NUMERIC.has(setting.value); });
const preview = async () => {
let rows;
try {
rows = await api(`/organisation/units/${encodeURIComponent(unit.id)}/settings/preview`, {
method: "POST", body: { setting: setting.value, value: value.value.trim(), mode: mode.disabled ? "PerAgent" : mode.value }
});
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
return;
}
if (!rows.length) { result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.settingInvalid") })); return; }
const checks = new Map();
const table = erstelleElement("table", { klasse: "daten-tabelle" },
erstelleElement("thead", {}, erstelleElement("tr", {},
...["", t("org.agent"), t("org.current"), t("org.proposed"), ""].map((h) => erstelleElement("th", { text: h })))),
erstelleElement("tbody", {}, ...rows.map((row) => {
const box = erstelleElement("input", { type: "checkbox" });
box.checked = row.selectedByDefault;
checks.set(row.agentId, { box, value: row.proposed });
return erstelleElement("tr", { klasse: row.outlier ? "org-ausreisser" : "" },
erstelleElement("td", {}, box),
erstelleElement("td", { text: row.displayName || row.agentId }),
erstelleElement("td", { text: row.current }),
erstelleElement("td", { text: row.proposed }),
erstelleElement("td", {}, row.outlier ? erstellePlakette(`⚠ ${t("org.outlier")}`, "warnung") : ""));
})));
const apply = erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("org.apply"),
onclick: () => {
const values = {};
for (const [agentId, { box, value: v }] of checks) if (box.checked) values[agentId] = v;
send(`/organisation/units/${encodeURIComponent(unit.id)}/settings`, "POST", {
setting: setting.value, values, reason: reason(),
defaultValue: remember.checked && mode.value === "PerAgent" ? value.value.trim() : null
});
}
});
result.replaceChildren(
rows.some((r) => r.outlier) ? erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("org.outlierHint")}` }) : "",
table,
erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, remember, t("org.rememberDefault")),
apply);
};
return erstelleKarte(t("org.settings"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.settingsHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal org-einstellung" }, setting, value, mode,
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.preview"), onclick: preview })),
result
]);
}
function agentPanel(agent) {
const unit = data.units.find((u) => u.id === agent.unitId);
const target = unitOptions(erstelleElement("select"), agent.unitId);
return [
erstelleKarte(agent.displayName || agent.agentId, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${agent.agentId} · ${agent.model}` }),
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.workspaces")}: ${agentWorkspaces(agent) || "—"}` })
]),
erstelleKarte(t("org.moveAgent"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, target,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("org.move"),
onclick: () => withPreview(`/organisation/agents/${encodeURIComponent(agent.agentId)}/move`, "POST",
{ target: target.value || null, reason: reason() })
}))
])
];
}
// ─── 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.
@@ -151,15 +152,24 @@ public sealed class TaskboardTool : IAgentTool
if (string.IsNullOrWhiteSpace(action))
return ToolResult.Fail("'action' is required.");
// #683: A task for another agent is a way of reaching it — the same address book
// applies as for a message, otherwise it could be walked around.
if (action is "task_create" or "task_update"
&& 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}")
};
}
@@ -170,6 +180,26 @@ public sealed class TaskboardTool : IAgentTool
}
}
private static string? CheckAssignee(string? assignee, AgentToolContext context)
{
if (context.Organisation is not { HasUnits: true } org || string.IsNullOrWhiteSpace(assignee))
return null;
if (TaskAssignee.KindOf(assignee) == TaskAssigneeKind.Human)
return null;
var target = TaskAssignee.AgentId(assignee);
if (target.Length == 0 || string.Equals(target, context.AgentId, StringComparison.OrdinalIgnoreCase))
return null;
if (org.CanAddress(context.AgentId, target))
return null;
var lead = org.EffectiveLeaderOf(context.AgentId);
return $"'{target}' is not in your address book, so you cannot assign tasks to it. " +
(lead is not null
? $"Assign it to your lead '{lead.AgentId}', who can pass it on."
: "Assign it to @human.");
}
// ─── Aktionen ───
/// <summary>
@@ -180,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.");
@@ -206,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))
@@ -230,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)
@@ -259,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);
@@ -270,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
{
@@ -280,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.");
@@ -288,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!);
@@ -322,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))
+54 -2
View File
@@ -97,7 +97,7 @@ public sealed record ServiceDto(
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
/// <summary>Ein neuer Agent.</summary>
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "");
public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null);
/// <summary>Eine Aufgabe vom Taskboard.</summary>
public sealed record TaskDto(
@@ -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
@@ -125,6 +129,54 @@ public sealed record TaskDto(
/// </summary>
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
/// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(
IReadOnlyList<TeamMemberDto> Members,
IReadOnlyList<AgentLinkDto> Links,
DateTime AtUtc,
IReadOnlyList<TeamUnitDto> Units);
/// <summary>
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
/// most urgent state inside.
/// </summary>
public sealed record TeamUnitDto(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<string, int> States,
int PendingApprovals,
string Worst);
/// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
/// WaitingForApproval, Problem, BudgetExhausted — decided by the instance, not the page.
/// </summary>
public sealed record TeamMemberDto(
string AgentId,
string DisplayName,
string Model,
string State,
IReadOnlyList<ActiveRunDto> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs,
string? UnitId,
bool IsLead);
/// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
/// <summary>#711: One agent reaching another.</summary>
public sealed record AgentLinkDto(string From, string To, string Kind, DateTime AtUtc);
/// <summary>Der letzte Lauf einer Aufgabe (#594) - aus dem Beleg, samt Kosten.</summary>
public sealed record TaskLastRunDto(DateTime OccurredAt, string Status, decimal CostUsd, bool CostIsKnown, string ResultRef);
+30 -1
View File
@@ -69,6 +69,26 @@ internal static class Mapping
public static ChatEntryDto ToDto(this ChatEntry entry)
=> new(entry.Role, entry.Content, entry.Timestamp, entry.Source);
public static TeamSnapshotDto ToDto(this App.Services.TeamSnapshot snapshot)
=> new(
snapshot.Members.Select(m => new TeamMemberDto(
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
m.Runs.Select(ToDto).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
m.UnitId, m.IsLead)).ToList(),
snapshot.RecentLinks.Select(ToDto).ToList(),
snapshot.AtUtc,
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
u.PendingApprovals, u.Worst.ToString())).ToList());
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
public static AgentLinkDto ToDto(this Core.Activity.AgentLink link)
=> new(link.From, link.To, link.Kind, link.AtUtc);
public static TaskDto ToDto(this TaskOverview overview)
=> overview.Task.ToDto() with
{
@@ -133,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.
@@ -0,0 +1,99 @@
namespace ClawdDotNet.Web.Contracts;
/// <summary>#683: The organisation of an instance — units, members, and what is wrong with it.</summary>
public sealed record OrganisationDto(
IReadOnlyList<UnitDto> Units,
IReadOnlyList<OrgMemberDto> Agents,
IReadOnlyList<string> Problems,
IReadOnlyList<string> KnownTools,
IReadOnlyList<WorkspaceDto> Workspaces);
public sealed record UnitDto(
string Id,
string Name,
string? ParentId,
string? LeaderAgentId,
string Goal,
IReadOnlyList<string>? ToolCeiling,
IReadOnlyList<string>? EffectiveToolCeiling,
UnitDefaultsDto Defaults,
string Path,
int Depth);
public sealed record UnitDefaultsDto(string? Model, decimal? DailyCostUsd, long? DailyTokens, string? WhenExhausted);
/// <summary>An agent as the organisation page shows it: where it is, whom it answers to.</summary>
public sealed record OrgMemberDto(
string AgentId,
string DisplayName,
string? UnitId,
bool IsLead,
string? EffectiveLeadId,
DateTime? UnitSinceUtc,
IReadOnlyList<string> Tools,
decimal DailyCostUsd,
long DailyTokens,
string Model);
public sealed record UnitInputDto(string Id, string Name, string? ParentId = null, string? Goal = null, string Reason = "");
public sealed record UnitUpdateDto(string? Name = null, string? Goal = null, string Reason = "");
public sealed record LeaderInputDto(string? AgentId, string Reason = "");
/// <summary>
/// A move or a ceiling change. With <see cref="Preview"/> nothing is written — the answer
/// says what would happen. <see cref="DropExcessTools"/> confirms removing the tools beyond a
/// ceiling that the preview named.
/// </summary>
public sealed record MoveInputDto(string? Target, string Reason = "", bool DropExcessTools = false, bool Preview = false);
public sealed record CeilingInputDto(IReadOnlyList<string>? Tools, string Reason = "", bool DropExcessTools = false, bool Preview = false);
public sealed record StructurePreviewDto(
IReadOnlyList<string> AffectedAgents,
IReadOnlyDictionary<string, IReadOnlyList<string>> ToolsLost,
IReadOnlyList<string> Running,
IReadOnlyList<string> Problems,
bool CanApply);
public sealed record SettingPreviewInputDto(string Setting, string Value, string Mode = "PerAgent");
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 = "");
+11 -4
View File
@@ -31,6 +31,8 @@ internal static class ApiEndpoints
api.MapUsage();
api.MapAnalytics();
api.MapLogs();
api.MapOrganisation();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents();
app.MapControl();
@@ -125,7 +127,7 @@ internal static class ApiEndpoints
agents.MapPost("/", (AppHost host, NewAgentDto input) =>
host.AgentAdmin.Add(new NewAgent(
input.AgentId, input.DisplayName, input.Model, input.Description)).ToResponse());
input.AgentId, input.DisplayName, input.Model, input.Description, input.UnitId)).ToResponse());
agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Remove(agentId).ToResponse());
@@ -258,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());
@@ -71,12 +71,23 @@ internal static class EventEndpoints
void OnRunCompleted(string model, Core.Engine.AgentRunResult result)
=> Publish("run", new
{
agentId = result.AgentId,
model,
status = result.Status.ToString(),
steps = result.StepCount,
tokens = result.TokensUsed
});
// #711: the team overview — who starts, who stops, who reached whom.
void OnRunStarted(Core.Activity.ActiveRun run)
=> Publish("activity", new { kind = "started", run = run.ToDto() });
void OnRunFinished(Core.Activity.ActiveRun run, string status)
=> Publish("activity", new { kind = "finished", run = run.ToDto(), status });
void OnLink(Core.Activity.AgentLink link)
=> Publish("activity", new { kind = "link", link = link.ToDto() });
void OnInstanceChanged(InstanceChange change)
=> Publish("instance", new { kind = change.Kind.ToString(), id = change.Id });
@@ -88,6 +99,9 @@ internal static class EventEndpoints
engine.OnChatEntryAdded += OnChat;
engine.OnStepCompleted += OnStep;
engine.OnRunCompleted += OnRunCompleted;
engine.Activity.RunStarted += OnRunStarted;
engine.Activity.RunFinished += OnRunFinished;
engine.Activity.LinkObserved += OnLink;
}
host.InstanceChanged += OnInstanceChanged;
@@ -106,6 +120,9 @@ internal static class EventEndpoints
e.OnChatEntryAdded -= OnChat;
e.OnStepCompleted -= OnStep;
e.OnRunCompleted -= OnRunCompleted;
e.Activity.RunStarted -= OnRunStarted;
e.Activity.RunFinished -= OnRunFinished;
e.Activity.LinkObserved -= OnLink;
}
host.InstanceChanged -= OnInstanceChanged;
@@ -0,0 +1,146 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>
/// #683: The organisation — units, leads, moves, tool ceilings, unit-wide values. Every
/// decision is made by <see cref="OrgAdministration"/>; this only translates.
/// </summary>
internal static class OrganisationEndpoints
{
public static void MapOrganisation(this RouteGroupBuilder api)
{
var org = api.MapGroup("/organisation");
org.MapGet("/", (AppHost host) =>
{
var chart = host.Organisation.Current();
var units = chart.Units
.OrderBy(u => chart.PathOf(u.Id), StringComparer.OrdinalIgnoreCase)
.Select(u => new UnitDto(
u.Id, u.Name, u.ParentId, u.LeaderAgentId, u.Goal, u.ToolCeiling,
chart.ToolCeiling(u.Id)?.Order(StringComparer.OrdinalIgnoreCase).ToList(),
new UnitDefaultsDto(u.Defaults.Model, u.Defaults.DailyCostUsd, u.Defaults.DailyTokens, u.Defaults.WhenExhausted),
chart.PathOf(u.Id), chart.Depth(u.Id)))
.ToList();
var agents = host.Instance.Agents
.OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase)
.Select(a => new OrgMemberDto(
a.AgentId, a.DisplayName, chart.UnitOf(a.AgentId)?.Id, chart.LedBy(a.AgentId) is not null,
chart.EffectiveLeaderOf(a.AgentId)?.AgentId, a.UnitSinceUtc,
a.Tools.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList(),
a.Budget.DailyCostUsd, a.Budget.DailyTokens, a.Model))
.ToList();
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) =>
host.Organisation.CreateUnit(input.Id, input.Name, input.ParentId, input.Goal, input.Reason).ToResponse());
org.MapPatch("/units/{unitId}", (AppHost host, string unitId, UnitUpdateDto input) =>
host.Organisation.UpdateUnit(unitId, input.Name, input.Goal, input.Reason).ToResponse());
org.MapDelete("/units/{unitId}", (AppHost host, string unitId, string? reason) =>
host.Organisation.DeleteUnit(unitId, reason ?? "").ToResponse());
org.MapPut("/units/{unitId}/leader", (AppHost host, string unitId, LeaderInputDto input) =>
host.Organisation.SetLeader(unitId, input.AgentId, input.Reason).ToResponse());
org.MapPost("/units/{unitId}/move", (AppHost host, string unitId, MoveInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewMoveUnit(unitId, input.Target)))
: host.Organisation.MoveUnit(unitId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
org.MapPost("/agents/{agentId}/move", (AppHost host, string agentId, MoveInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewMoveAgent(agentId, input.Target)))
: host.Organisation.MoveAgent(agentId, input.Target, input.Reason, input.DropExcessTools).ToResponse());
org.MapPut("/units/{unitId}/ceiling", (AppHost host, string unitId, CeilingInputDto input) =>
input.Preview
? Results.Ok(ToDto(host.Organisation.PreviewToolCeiling(unitId, input.Tools)))
: host.Organisation.SetToolCeiling(unitId, input.Tools, input.Reason, input.DropExcessTools).ToResponse());
org.MapPost("/units/{unitId}/settings/preview", (AppHost host, string unitId, SettingPreviewInputDto input) =>
{
if (!Enum.TryParse<UnitSetting>(input.Setting, ignoreCase: true, out var setting)
|| !Enum.TryParse<UnitSettingMode>(input.Mode, ignoreCase: true, out var mode))
return Results.BadRequest(new OutcomeDto(false, "Unknown setting or mode."));
return Results.Ok(host.Organisation.PreviewSetting(unitId, setting, input.Value, mode)
.Select(c => new SettingChangeDto(c.AgentId, c.DisplayName, c.Current, c.Proposed, c.Outlier, c.SelectedByDefault))
.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,252 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #683: Units in the administration — create, lead, move, limit, write unit-wide values.
/// </summary>
public sealed class OrgAdministrationTests : IDisposable
{
private readonly TestHost _test;
public OrgAdministrationTests()
{
_test = new TestHost(
Agent("marketing-lead", "WebFetch", "AgentComm", "Taskboard"),
Agent("ads", "WebFetch", "Taskboard"),
Agent("listing", "WebFetch", "Taskboard"),
Agent("specialist", "WebFetch", "Taskboard", "Database"),
Agent("bookkeeper", "Database", "Taskboard"));
}
public void Dispose() => _test.Dispose();
private static AgentConfig Agent(string id, params string[] tools)
=> new() { AgentId = id, DisplayName = id, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
private OrgAdministration Org => _test.Host.Organisation;
private AgentConfig Find(string id) => _test.Host.AgentAdmin.Find(id)!;
private void BuildMarketing()
{
Org.CreateUnit("marketing", "Marketing", null, "Sell more.").Ok.ShouldBeTrue();
Org.CreateUnit("amazon", "Amazon", "marketing").Ok.ShouldBeTrue();
Org.MoveAgent("marketing-lead", "marketing", "setup").Ok.ShouldBeTrue();
Org.SetLeader("marketing", "marketing-lead").Ok.ShouldBeTrue();
foreach (var id in new[] { "ads", "listing", "specialist" })
Org.MoveAgent(id, "amazon", "setup").Ok.ShouldBeTrue();
}
// ═══════════════════ units ═══════════════════
[Fact]
public void A_unit_needs_a_valid_id_a_name_and_an_existing_parent()
{
Org.CreateUnit("bad id", "X", null).Ok.ShouldBeFalse();
Org.CreateUnit("x", " ", null).Ok.ShouldBeFalse();
Org.CreateUnit("x", "X", "nowhere").Ok.ShouldBeFalse();
Org.CreateUnit("x", "X", null).Ok.ShouldBeTrue();
Org.CreateUnit("x", "Again", null).Ok.ShouldBeFalse("ids are unique");
}
[Fact]
public void Only_a_member_can_lead_a_unit()
{
Org.CreateUnit("marketing", "Marketing", null);
Org.SetLeader("marketing", "ads").Ok.ShouldBeFalse();
Org.MoveAgent("ads", "marketing", "join");
Org.SetLeader("marketing", "ads").Ok.ShouldBeTrue();
}
[Fact]
public void Only_an_empty_unit_can_be_deleted()
{
BuildMarketing();
Org.DeleteUnit("amazon").Ok.ShouldBeFalse("it has members");
foreach (var id in new[] { "ads", "listing", "specialist" })
Org.MoveAgent(id, null, "leave");
Org.DeleteUnit("amazon").Ok.ShouldBeTrue();
}
// ═══════════════════ moves ═══════════════════
[Fact]
public void A_move_records_since_when_and_where_from()
{
BuildMarketing();
Org.MoveAgent("ads", "marketing", "promoted").Ok.ShouldBeTrue();
var ads = Find("ads");
ads.UnitId.ShouldBe("marketing");
ads.PreviousUnitId.ShouldBe("amazon");
ads.UnitSinceUtc.ShouldNotBeNull();
}
[Fact]
public void A_lead_that_moves_leaves_its_unit_without_lead()
{
BuildMarketing();
Org.MoveAgent("marketing-lead", "amazon", "reorg").Ok.ShouldBeTrue();
Org.Current().LeaderOf("marketing").ShouldBeNull();
}
[Fact]
public void A_unit_cannot_be_moved_under_itself()
{
BuildMarketing();
Org.MoveUnit("marketing", "amazon", "oops").Ok.ShouldBeFalse();
Org.MoveUnit("marketing", "marketing", "oops").Ok.ShouldBeFalse();
}
[Fact]
public void A_moved_unit_takes_its_members_along_and_tells_them()
{
BuildMarketing();
Org.CreateUnit("marketplaces", "Marketplaces", "marketing");
var before = Find("ads").UnitSinceUtc;
Org.MoveUnit("amazon", "marketplaces", "a new level in between").Ok.ShouldBeTrue();
Find("ads").UnitId.ShouldBe("amazon", "the unit is the same");
Org.Current().PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
Find("ads").UnitSinceUtc.ShouldNotBe(before, "the position changed, so the briefing says so");
}
// ═══════════════════ tool ceiling ═══════════════════
[Fact]
public void A_move_under_a_lower_ceiling_is_refused_until_confirmed()
{
BuildMarketing();
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeFalse(
"the specialist has Database — that has to be confirmed via the preview first");
var preview = Org.PreviewToolCeiling("amazon", ["WebFetch", "Taskboard"]);
preview.ToolsLost.Keys.ShouldBe(["specialist"]);
preview.ToolsLost["specialist"].ShouldBe(["Database"]);
}
[Fact]
public void Lowering_a_ceiling_removes_the_tools_beyond_it_from_the_members()
{
BuildMarketing();
Org.MoveAgent("specialist", null, "out first").Ok.ShouldBeTrue();
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "no databases in ads").Ok.ShouldBeTrue();
// And from now on it cannot be granted.
var refused = _test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true);
refused.Ok.ShouldBeFalse();
refused.Message.ShouldContain("ceiling");
}
[Fact]
public void Moving_into_a_ceiling_needs_the_tools_beyond_it_dropped_explicitly()
{
Org.CreateUnit("amazon", "Amazon", null);
Org.SetToolCeiling("amazon", ["WebFetch", "Taskboard"], "setup").Ok.ShouldBeTrue();
var refused = Org.MoveAgent("specialist", "amazon", "join");
refused.Ok.ShouldBeFalse();
refused.Message.ShouldContain("Database");
Find("specialist").UnitId.ShouldBeNull("nothing happened");
Org.MoveAgent("specialist", "amazon", "join", dropExcessTools: true).Ok.ShouldBeTrue();
Find("specialist").Tools.Keys.ShouldNotContain("Database");
}
[Fact]
public void A_ceiling_higher_up_limits_the_units_below()
{
BuildMarketing();
Org.MoveAgent("specialist", null, "out first");
Org.SetToolCeiling("marketing", ["WebFetch", "Taskboard", "AgentComm"], "setup").Ok.ShouldBeTrue();
_test.Host.AgentAdmin.SetToolEnabled("ads", "Database", true).Ok.ShouldBeFalse(
"amazon has no ceiling of its own, but marketing above it has");
}
// ═══════════════════ unit-wide values ═══════════════════
[Fact]
public void A_unit_wide_budget_marks_the_specialist_as_an_outlier_and_leaves_it_unselected()
{
BuildMarketing();
Find("ads").Budget.DailyCostUsd = 5;
Find("listing").Budget.DailyCostUsd = 4;
Find("specialist").Budget.DailyCostUsd = 40; // deliberately large
Find("marketing-lead").Budget.DailyCostUsd = 6;
var preview = Org.PreviewSetting("marketing", UnitSetting.DailyCostUsd, "10");
preview.Count.ShouldBe(4, "the unit and its sub-units");
var specialist = preview.Single(c => c.AgentId == "specialist");
specialist.Outlier.ShouldBeTrue();
specialist.SelectedByDefault.ShouldBeFalse();
preview.Where(c => c.AgentId != "specialist").ShouldAllBe(c => c.SelectedByDefault && c.Proposed == "10.00");
}
[Fact]
public void A_distributed_total_keeps_the_proportions()
{
BuildMarketing();
Find("ads").Budget.DailyCostUsd = 10;
Find("listing").Budget.DailyCostUsd = 10;
Find("specialist").Budget.DailyCostUsd = 20;
Find("marketing-lead").Budget.DailyCostUsd = 0;
var preview = Org.PreviewSetting("amazon", UnitSetting.DailyCostUsd, "80", UnitSettingMode.Distribute);
preview.Single(c => c.AgentId == "specialist").Proposed.ShouldBe("40.00");
preview.Single(c => c.AgentId == "ads").Proposed.ShouldBe("20.00");
}
[Fact]
public void Applying_writes_only_the_chosen_agents_and_remembers_the_default()
{
BuildMarketing();
Find("specialist").Budget.DailyCostUsd = 40;
var result = Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
new Dictionary<string, string> { ["ads"] = "10", ["listing"] = "10" }, "budget round", defaultValue: "10");
result.Ok.ShouldBeTrue(result.Message);
Find("ads").Budget.DailyCostUsd.ShouldBe(10);
Find("specialist").Budget.DailyCostUsd.ShouldBe(40, "not chosen, not touched");
Org.Current().Unit("amazon")!.Defaults.DailyCostUsd.ShouldBe(10);
}
[Fact]
public void An_agent_outside_the_unit_cannot_be_written_through_it()
{
BuildMarketing();
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd,
new Dictionary<string, string> { ["bookkeeper"] = "1" }, "sneaky").Ok.ShouldBeFalse();
}
[Fact]
public void A_new_agent_placed_in_a_unit_starts_with_its_defaults_but_a_moved_one_keeps_its_values()
{
BuildMarketing();
Org.ApplySetting("amazon", UnitSetting.DailyCostUsd, new Dictionary<string, string>(), "defaults", defaultValue: "7");
Find("bookkeeper").Budget.DailyCostUsd = 30;
Org.MoveAgent("bookkeeper", "amazon", "moved").Ok.ShouldBeTrue();
Find("bookkeeper").Budget.DailyCostUsd.ShouldBe(30, "a move does not overwrite tuned values");
var created = _test.Host.AgentAdmin.Add(new NewAgent("newbie", "Newbie", "test/model", UnitId: "amazon"));
created.Ok.ShouldBeTrue(created.Message);
Find("newbie").Budget.DailyCostUsd.ShouldBe(7);
Find("newbie").UnitId.ShouldBe("amazon");
}
}
@@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ }
}
@@ -0,0 +1,165 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #711: The rules that decide what the team overview shows for an agent. Every state comes
/// from a recorded fact, never from a model's judgement.
/// </summary>
public sealed class TeamOverviewTests
{
private static readonly DateTime Now = new(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc);
private static ActiveRun RunningFor(TimeSpan duration)
=> new("r", "worker", "task", null, null, Now - duration);
[Fact]
public void Nothing_running_and_a_good_last_run_is_idle()
=> TeamOverview.Classify([], 0, "Completed", TimeSpan.FromMinutes(1), Now).ShouldBe(TeamState.Idle);
[Fact]
public void An_agent_without_any_run_yet_is_idle()
=> TeamOverview.Classify([], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
[Fact]
public void A_run_in_progress_is_working()
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void A_run_far_beyond_the_usual_duration_is_stuck()
// Usually 2 minutes: three times that is 6, running for 7.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(7))], 0, null, TimeSpan.FromMinutes(2), Now)
.ShouldBe(TeamState.Stuck);
[Fact]
public void Short_runs_get_a_floor_before_they_count_as_stuck()
// Usually 20 seconds: three times is one minute, but the floor is five.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(3))], 0, null, TimeSpan.FromSeconds(20), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void Without_history_a_run_is_stuck_only_after_a_long_while()
{
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(10))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Working);
TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(16))], 0, null, TimeSpan.Zero, Now).ShouldBe(TeamState.Stuck);
}
[Fact]
public void An_open_approval_outranks_working()
// What the human can do something about comes first.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 2, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.WaitingForApproval);
[Fact]
public void Stuck_outranks_an_open_approval()
=> TeamOverview.Classify([RunningFor(TimeSpan.FromHours(1))], 2, null, TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Stuck);
[Theory]
[InlineData("Failed")]
[InlineData("LoopLimitExceeded")]
[InlineData("TargetBusy")]
public void A_failed_last_run_is_a_problem(string status)
=> TeamOverview.Classify([], 0, status, TimeSpan.Zero, Now).ShouldBe(TeamState.Problem);
[Fact]
public void A_refused_last_run_shows_the_exhausted_budget()
=> TeamOverview.Classify([], 0, "BudgetExceeded", TimeSpan.Zero, Now).ShouldBe(TeamState.BudgetExhausted);
[Fact]
public void A_new_run_hides_the_old_problem()
// A new run is the attempt to get past it; the problem shows again if it fails.
=> TeamOverview.Classify([RunningFor(TimeSpan.FromMinutes(1))], 0, "Failed", TimeSpan.FromMinutes(1), Now)
.ShouldBe(TeamState.Working);
[Fact]
public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
// ═══════════════════ stage 1: units roll up ═══════════════════
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
private static OrgChart Shop() => new(
[
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new UnitConfig { Id = "accounting", Name = "Accounting" }
],
[
new AgentConfig { AgentId = "ceo" },
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
[Fact]
public void A_problem_deep_down_shows_on_every_container_above_it()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("ceo", null, TeamState.Working),
Member("marketing-lead", "marketing", TeamState.Working),
Member("amazon-lead", "amazon", TeamState.Idle),
Member("ads", "amazon", TeamState.Problem),
Member("bookkeeper", "accounting", TeamState.Idle)
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
marketing.Worst.ShouldBe(TeamState.Problem);
marketing.States[TeamState.Working].ShouldBe(1);
marketing.States[TeamState.Problem].ShouldBe(1);
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Open_approvals_are_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
]);
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
}
[Fact]
public void A_unit_knows_its_path_and_its_lead()
{
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
amazon.Path.ShouldBe("Marketing / Amazon");
amazon.LeaderAgentId.ShouldBe("amazon-lead");
amazon.Agents.ShouldBe(0);
amazon.Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Stuck_is_the_most_urgent_and_idle_the_least()
{
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
byUrgency.First().ShouldBe(TeamState.Stuck);
byUrgency.Last().ShouldBe(TeamState.Idle);
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
}
[Fact]
public void Without_units_there_is_nothing_to_roll_up()
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
.ShouldBeEmpty();
}
@@ -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,143 @@
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Activity;
/// <summary>
/// #711: Who is working right now and who reached whom — the live side of the team overview.
/// </summary>
public sealed class AgentActivityTests
{
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
[Fact]
public void A_run_is_active_until_its_handle_is_disposed()
{
var activity = new AgentActivity(_clock);
using (var run = activity.Begin("r1", "worker", "task", "t-1", null))
{
var active = activity.ActiveRuns().ShouldHaveSingleItem();
active.AgentId.ShouldBe("worker");
active.TaskId.ShouldBe("t-1");
active.CalledBy.ShouldBeNull();
run.Complete("Completed");
}
activity.ActiveRuns().ShouldBeEmpty();
}
[Fact]
public void Start_and_end_are_announced_with_the_status()
{
var activity = new AgentActivity(_clock);
var log = new List<string>();
activity.RunStarted += r => log.Add($"start {r.AgentId}");
activity.RunFinished += (r, status) => log.Add($"end {r.AgentId} {status}");
using (var run = activity.Begin("r1", "worker", "chat", null, null))
run.Complete("LoopLimitExceeded");
using (activity.Begin("r2", "worker", "chat", null, null)) { }
log.ShouldBe(["start worker", "end worker LoopLimitExceeded", "start worker", "end worker Aborted"]);
}
[Fact]
public void A_delegated_run_records_who_called_whom()
{
var activity = new AgentActivity(_clock);
AgentLink? seen = null;
activity.LinkObserved += l => seen = l;
var chain = DelegationChain.Root("lead").Append("buyer");
using (activity.Begin("r1", "buyer", "agent_comm", null, chain)) { }
seen.ShouldNotBeNull();
seen.From.ShouldBe("lead");
seen.To.ShouldBe("buyer");
seen.Kind.ShouldBe("delegation");
activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().From.ShouldBe("lead");
}
[Fact]
public void Old_links_fall_out_of_the_recent_window()
{
var activity = new AgentActivity(_clock);
using (activity.Begin("r1", "buyer", "agent_comm", null, DelegationChain.Root("lead").Append("buyer"))) { }
_clock.Advance(TimeSpan.FromMinutes(11));
activity.RecentLinks(TimeSpan.FromMinutes(10)).ShouldBeEmpty();
}
[Fact]
public void The_memory_of_links_is_bounded()
{
var activity = new AgentActivity(_clock);
var chain = DelegationChain.Root("lead").Append("buyer");
for (var i = 0; i < AgentActivity.RecentLinkCapacity + 50; i++)
using (activity.Begin($"r{i}", "buyer", "agent_comm", null, chain)) { }
activity.RecentLinks(TimeSpan.FromDays(1)).Count.ShouldBe(AgentActivity.RecentLinkCapacity);
}
[Fact]
public void A_subscriber_that_throws_does_not_break_the_run()
{
var activity = new AgentActivity(_clock);
activity.RunStarted += _ => throw new InvalidOperationException("broken page");
activity.RunFinished += (_, _) => throw new InvalidOperationException("broken page");
Should.NotThrow(() =>
{
using var run = activity.Begin("r1", "worker", "chat", null, null);
run.Complete("Completed");
});
activity.ActiveRuns().ShouldBeEmpty();
}
// ─── through the engine ───
[Fact]
public async Task The_engine_announces_a_delegated_run_with_its_caller()
{
var fixture = new EngineFixture();
fixture.AddAgent("lead");
fixture.AddAgent("buyer");
fixture.Client.RespondsWithText("Erledigt.");
var started = new List<ActiveRun>();
var finished = new List<string>();
fixture.Engine.Activity.RunStarted += started.Add;
fixture.Engine.Activity.RunFinished += (_, status) => finished.Add(status);
var result = await fixture.Engine.SendMessageAsync("lead", "buyer", "Bitte pruefen", "inst", null, default);
result.Delivered.ShouldBeTrue(result.Error);
var run = started.ShouldHaveSingleItem();
run.AgentId.ShouldBe("buyer");
run.CalledBy.ShouldBe("lead");
finished.ShouldBe(["Completed"]);
fixture.Engine.Activity.ActiveRuns().ShouldBeEmpty();
fixture.Engine.Activity.RecentLinks(TimeSpan.FromMinutes(1)).ShouldHaveSingleItem().To.ShouldBe("buyer");
}
[Fact]
public async Task A_fresh_run_is_visible_too()
{
// Before #711 only runs through ChatAsync counted as "running"; RunAsync (@new) did not.
var fixture = new EngineFixture();
var agent = fixture.AddAgent("worker");
fixture.Client.RespondsWithText("Fertig.");
var started = new List<ActiveRun>();
fixture.Engine.Activity.RunStarted += started.Add;
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
started.ShouldHaveSingleItem().AgentId.ShouldBe("worker");
}
}
@@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable
}, default);
// Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
}
private string ZipPath => Path.Combine(_root, "sicherung.zip");
@@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { }
}
@@ -116,25 +116,27 @@ public sealed class AgentEngineConcurrencyTests
{
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
// gegeneinander sperren.
var toolDelay = TimeSpan.FromMilliseconds(200);
var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay));
//
// Bis zum 2026-09-24 antwortete die Attrappe hier nur mit Text: Das langsame
// Werkzeug lief nie, die Läufe waren sofort fertig, und geprüft wurde nur, ob drei
// leere Läufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
// Agenten - und schlug unter Last fehl, weil es die Maschine mass statt der Engine.
// Jetzt ruft jeder Agent das Werkzeug wirklich auf, und bewiesen wird die Überlappung.
var tool = FakeTool.Slow(TimeSpan.FromMilliseconds(500));
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-1", "TestTool");
var b = fixture.AddAgent("agent-2", "TestTool");
var c = fixture.AddAgent("agent-3", "TestTool");
fixture.Client.AlwaysRespondsWithText("Antwort");
fixture.Client.RespondsContextually("TestTool");
var sw = System.Diagnostics.Stopwatch.StartNew();
await Task.WhenAll(
fixture.Engine.ChatAsync(a, "x", "test-instance", default),
fixture.Engine.ChatAsync(b, "x", "test-instance", default),
fixture.Engine.ChatAsync(c, "x", "test-instance", default));
sw.Stop();
// Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger.
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450),
"Agenten dürfen sich nicht gegenseitig blockieren");
tool.MaxConcurrent.ShouldBeGreaterThan(1, "Agenten dürfen sich nicht gegenseitig blockieren");
}
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 2_000)
@@ -276,17 +276,16 @@ public class HarnessHaertungTests
("ParallelTool", "{}", "c3"))
.RespondsWithText("Fertig.");
var sw = Stopwatch.StartNew();
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
sw.Stop();
// Der belastbare Nachweis: Aufrufe haben sich überlappt.
//
// Hier stand zusätzlich eine Wanduhr-Prüfung (unter 750 ms, seriell wären es 900).
// Sie schlug am 2026-09-24 mit 927 ms fehl, als die Testprojekte parallel liefen —
// die Zeit misst die Last der Maschine, nicht das Verhalten der Engine. Der
// Höchststand gleichzeitiger Aufrufe beweist dasselbe, ohne zu wackeln.
Assert.True(tool.MaxConcurrent > 1,
$"Erwartet: überlappende Aufrufe. Höchststand war {tool.MaxConcurrent}.");
// Die Laufzeit als zweite, schwächere Bestätigung — seriell wären es 900 ms.
Assert.True(sw.ElapsedMilliseconds < 750,
$"Erwartet: deutlich unter 900 ms. Gemessen: {sw.ElapsedMilliseconds} ms.");
}
[Fact]
@@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -0,0 +1,178 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #683: The address book is enforced by the engine — not merely described in a prompt that
/// a model may or may not follow. And every agent is told where it stands.
/// </summary>
public sealed class AddressBookEnforcementTests
{
/// <summary>marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper.</summary>
private static (EngineFixture Fixture, List<UnitConfig> Units) Shop()
{
var fixture = new EngineFixture();
var units = new List<UnitConfig>
{
new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead", Goal = "Sell on Amazon." },
new() { Id = "accounting", Name = "Accounting" }
};
foreach (var (id, unit) in new[]
{
("marketing-lead", "marketing"), ("amazon-lead", "amazon"), ("ads", "amazon"), ("bookkeeper", "accounting")
})
fixture.AddAgent(id).UnitId = unit;
fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents);
return (fixture, units);
}
// ═══════════════════ messages and spawns ═══════════════════
[Fact]
public async Task A_message_outside_the_address_book_is_refused_with_the_way_that_is_open()
{
var (shop, _) = Shop();
var result = await shop.Engine.SendMessageAsync("ads", "bookkeeper", "Kannst du zahlen?", "inst", null, default);
result.Delivered.ShouldBeFalse();
result.Error.ShouldNotBeNull().ShouldContain("not in your address book");
result.Error.ShouldContain("amazon-lead", Case.Sensitive, "the way that is open: the own lead");
shop.Client.CallCount.ShouldBe(0, "refused before any model call");
}
[Fact]
public async Task A_message_to_the_own_lead_goes_through()
{
var (shop, _) = Shop();
shop.Client.RespondsWithText("Verstanden.");
var result = await shop.Engine.SendMessageAsync("ads", "amazon-lead", "Frage", "inst", null, default);
result.Delivered.ShouldBeTrue(result.Error);
}
[Fact]
public async Task A_spawn_is_held_to_the_same_address_book()
{
var (shop, _) = Shop();
var result = await shop.Engine.SpawnAgentAsync("ads", "bookkeeper", "Buche das", "inst", null, default);
result.Started.ShouldBeFalse();
result.Error.ShouldNotBeNull().ShouldContain("not in your address book");
}
[Fact]
public void The_agent_list_shows_only_the_address_book()
{
var (shop, _) = Shop();
shop.Engine.ListAgents("ads").Select(a => a.AgentId).ShouldBe(["amazon-lead"]);
}
[Fact]
public void Without_units_the_agent_list_is_unchanged()
{
var fixture = new EngineFixture();
fixture.AddAgent("a");
fixture.AddAgent("b");
fixture.AddAgent("c");
fixture.Engine.ListAgents("a").Select(x => x.AgentId).ShouldBe(["b", "c"], ignoreOrder: true);
}
// ═══════════════════ delegation depth ═══════════════════
[Fact]
public async Task The_delegation_limit_follows_the_depth_of_the_tree()
{
// Five levels deep: with the old fixed limit of 3, the bottom could not be reached.
var fixture = new EngineFixture();
var units = new List<UnitConfig>();
string? parent = null;
for (var level = 1; level <= 5; level++)
{
units.Add(new UnitConfig { Id = $"u{level}", Name = $"U{level}", ParentId = parent, LeaderAgentId = $"lead{level}" });
fixture.AddAgent($"lead{level}").UnitId = $"u{level}";
parent = $"u{level}";
}
fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents);
fixture.Client.RespondsWithText("ok");
// lead1 → lead2 → lead3 → lead4 → lead5: the fourth hand-over.
var chain = DelegationChain.Root("lead1").Append("lead2").Append("lead3").Append("lead4");
var result = await fixture.Engine.SendMessageAsync("lead4", "lead5", "weiter", "inst", chain, default);
result.Delivered.ShouldBeTrue(result.Error);
}
// ═══════════════════ the organisation section ═══════════════════
[Fact]
public async Task Every_run_tells_the_agent_its_unit_and_its_lead()
{
var (shop, _) = Shop();
shop.Client.RespondsWithText("ok");
await shop.Engine.RunAsync(shop.Agents.First(a => a.AgentId == "ads"), "Los", "inst", default);
var system = shop.Client.ReceivedRequests[0].Messages[0].Content!;
system.ShouldContain(OrgBriefing.Heading);
system.ShouldContain("\"Amazon\" (Marketing / Amazon)");
system.ShouldContain("Your lead: amazon-lead");
system.ShouldContain("Goal of your unit: Sell on Amazon.");
system.ShouldNotContain("bookkeeper", Case.Sensitive, "not in the address book — not in the prompt either");
}
[Fact]
public async Task A_move_reaches_a_running_chat_context()
{
var (shop, units) = Shop();
var ads = shop.Agents.First(a => a.AgentId == "ads");
shop.Client.RespondsWithText("ok").RespondsWithText("ok");
await shop.Engine.ChatAsync(ads, "Hallo", "inst", default);
shop.Client.ReceivedRequests[0].Messages[0].Content!.ShouldContain("Your lead: amazon-lead");
// Moved to accounting, which has no lead: the next run knows it.
ads.UnitId = "accounting";
ads.UnitSinceUtc = DateTime.UtcNow;
await shop.Engine.ChatAsync(ads, "Und jetzt?", "inst", default);
var system = shop.Client.ReceivedRequests[1].Messages[0].Content!;
system.ShouldContain("\"Accounting\"");
system.ShouldContain("this is recent");
system.ShouldContain("There is no lead agent above you");
system.ShouldStartWith("Du bist ads.", Case.Sensitive, "the rest of the prompt is untouched");
}
[Fact]
public async Task Without_units_the_prompt_has_no_organisation_section()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("solo");
fixture.Client.RespondsWithText("ok");
await fixture.Engine.RunAsync(agent, "Los", "inst", default);
fixture.Client.ReceivedRequests[0].Messages[0].Content!.ShouldNotContain(OrgBriefing.Heading);
}
[Fact]
public void The_section_is_replaced_not_stacked()
{
var once = OrgBriefing.WithSection("Base.", "# Organisation\nA");
var twice = OrgBriefing.WithSection(once, "# Organisation\nB");
twice.ShouldBe("Base.\n\n# Organisation\nB");
OrgBriefing.WithSection(twice, null).ShouldBe("Base.");
}
}
@@ -0,0 +1,226 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Organisation;
/// <summary>
/// #683: The organisation tree and the address book it cuts. The example is the online
/// shop from the concept, trimmed:
///
/// <code>
/// (top level) ceo
/// ├── purchasing lead: purchasing-lead
/// │ ├── suppliers lead: suppliers-lead · negotiator
/// │ └── quality (no lead) · sampler
/// ├── marketing lead: marketing-lead
/// │ └── marketplaces lead: marketplaces-lead
/// │ └── amazon lead: amazon-lead · amazon-ads, amazon-listing
/// └── accounting lead: accounting-lead · bookkeeper
/// </code>
/// </summary>
public sealed class OrgChartTests
{
private static UnitConfig Unit(string id, string? parent = null, string? lead = null, List<string>? ceiling = null)
=> new() { Id = id, Name = char.ToUpperInvariant(id[0]) + id[1..], ParentId = parent, LeaderAgentId = lead, ToolCeiling = ceiling };
private static AgentConfig Agent(string id, string? unit = null, params string[] tools)
=> new() { AgentId = id, DisplayName = id, UnitId = unit, Tools = tools.ToDictionary(t => t, _ => new Dictionary<string, object?>()) };
private static OrgChart Shop() => new(
[
Unit("purchasing", lead: "purchasing-lead"),
Unit("suppliers", "purchasing", "suppliers-lead"),
Unit("quality", "purchasing"),
Unit("marketing", lead: "marketing-lead"),
Unit("marketplaces", "marketing", "marketplaces-lead"),
Unit("amazon", "marketplaces", "amazon-lead"),
Unit("accounting", lead: "accounting-lead")
],
[
Agent("ceo"),
Agent("purchasing-lead", "purchasing"),
Agent("suppliers-lead", "suppliers"),
Agent("negotiator", "suppliers"),
Agent("sampler", "quality"),
Agent("marketing-lead", "marketing"),
Agent("marketplaces-lead", "marketplaces"),
Agent("amazon-lead", "amazon"),
Agent("amazon-ads", "amazon"),
Agent("amazon-listing", "amazon"),
Agent("accounting-lead", "accounting"),
Agent("bookkeeper", "accounting")
]);
// ═══════════════════ the tree ═══════════════════
[Fact]
public void Paths_and_depths_follow_the_tree()
{
var org = Shop();
org.PathOf("amazon").ShouldBe("Marketing / Marketplaces / Amazon");
org.Depth("marketing").ShouldBe(1);
org.Depth("amazon").ShouldBe(3);
org.TreeDepth.ShouldBe(3);
}
[Fact]
public void Delegation_may_reach_from_the_top_to_the_bottom_and_one_step_further()
{
Shop().DelegationDepth(configuredFloor: 3).ShouldBe(4, "before #683 it was a fixed 3");
Shop().DelegationDepth(configuredFloor: 6).ShouldBe(6, "the configured value stays a floor");
new OrgChart([], [Agent("a")]).DelegationDepth(3).ShouldBe(3);
}
// ═══════════════════ leads ═══════════════════
[Fact]
public void A_member_answers_to_the_lead_of_its_unit()
=> Shop().EffectiveLeaderOf("amazon-ads")!.AgentId.ShouldBe("amazon-lead");
[Fact]
public void A_lead_answers_to_the_lead_above()
=> Shop().EffectiveLeaderOf("amazon-lead")!.AgentId.ShouldBe("marketplaces-lead");
[Fact]
public void In_a_unit_without_lead_the_nearest_lead_above_takes_over()
=> Shop().EffectiveLeaderOf("sampler")!.AgentId.ShouldBe("purchasing-lead");
[Fact]
public void Above_a_top_level_lead_there_is_only_the_human()
=> Shop().EffectiveLeaderOf("marketing-lead").ShouldBeNull();
[Fact]
public void A_lead_who_is_not_a_member_does_not_count()
{
var org = new OrgChart([Unit("team", lead: "outsider")], [Agent("outsider"), Agent("member", "team")]);
org.LeaderOf("team").ShouldBeNull();
org.Problems().ShouldContain(p => p.Contains("not a member"));
}
// ═══════════════════ the address book ═══════════════════
[Fact]
public void A_member_sees_its_unit_and_its_lead_and_nothing_else()
{
Shop().AddressBook("amazon-ads").ShouldBe(["amazon-lead", "amazon-listing"], ignoreOrder: true);
}
[Fact]
public void The_logistician_does_not_learn_that_an_ads_agent_exists()
{
// The example from the item: with a flat list, everyone saw everyone.
Shop().CanAddress("bookkeeper", "amazon-ads").ShouldBeFalse();
Shop().CanAddress("amazon-ads", "bookkeeper").ShouldBeFalse();
}
[Fact]
public void A_lead_sees_its_team_its_sub_leads_its_sibling_leads_and_its_own_lead()
{
Shop().AddressBook("marketplaces-lead").ShouldBe(
["amazon-lead", "marketing-lead"], ignoreOrder: true);
Shop().AddressBook("purchasing-lead").ShouldBe(
// sub-lead; the lead-less quality team directly; sibling leads; the top level.
["suppliers-lead", "sampler", "marketing-lead", "accounting-lead", "ceo"], ignoreOrder: true);
}
[Fact]
public void Members_of_a_lead_less_unit_and_the_lead_above_see_each_other()
{
var org = Shop();
org.CanAddress("sampler", "purchasing-lead").ShouldBeTrue();
org.CanAddress("purchasing-lead", "sampler").ShouldBeTrue();
}
[Fact]
public void The_top_level_and_the_top_level_leads_see_each_other()
{
var org = Shop();
org.AddressBook("ceo").ShouldBe(["purchasing-lead", "marketing-lead", "accounting-lead"], ignoreOrder: true);
org.CanAddress("marketing-lead", "ceo").ShouldBeTrue();
}
[Fact]
public void Every_line_can_be_walked_both_ways()
{
// Whoever I can reach as my lead can reach me: escalation and delegation use the
// same line.
var org = Shop();
foreach (var agent in org.Units.SelectMany(u => org.MembersOf(u.Id)).Concat(org.MembersOf(null)))
{
foreach (var other in org.AddressBook(agent.AgentId))
org.CanAddress(other, agent.AgentId).ShouldBeTrue($"{agent.AgentId} → {other} but not back");
}
}
[Fact]
public void Without_units_everyone_reaches_everyone_as_before()
{
var org = new OrgChart([], [Agent("a"), Agent("b"), Agent("c")]);
org.HasUnits.ShouldBeFalse();
org.AddressBook("a").ShouldBe(["b", "c"], ignoreOrder: true);
}
[Fact]
public void An_unknown_agent_reaches_nobody()
=> Shop().AddressBook("ghost").ShouldBeEmpty();
// ═══════════════════ tool ceiling ═══════════════════
[Fact]
public void A_ceiling_applies_along_the_whole_path_down()
{
var org = new OrgChart(
[
Unit("marketing", ceiling: ["WebFetch", "Taskboard", "AgentComm"]),
Unit("amazon", "marketing", ceiling: ["WebFetch", "Taskboard", "FileRW"])
],
[Agent("ads", "amazon")]);
org.ToolCeiling("amazon")!.ShouldBe(["WebFetch", "Taskboard"], ignoreOrder: true,
"FileRW is not allowed further up, AgentComm not here");
org.ToolCeiling("marketing")!.Count.ShouldBe(3);
org.ToolCeiling(null).ShouldBeNull();
}
[Fact]
public void Tools_beyond_the_ceiling_are_named_as_a_problem()
{
var org = new OrgChart(
[Unit("marketing", ceiling: ["WebFetch"])],
[Agent("ads", "marketing", "WebFetch", "Database")]);
org.Problems().ShouldContain(p => p.Contains("ads") && p.Contains("Database"));
}
// ═══════════════════ broken structures ═══════════════════
[Fact]
public void A_cycle_is_named_and_does_not_hang()
{
var org = new OrgChart([Unit("a", "b"), Unit("b", "a")], [Agent("x", "a")]);
org.Problems().ShouldContain(p => p.Contains("cycle"));
org.Ancestors("a").Count.ShouldBeLessThan(3);
org.AddressBook("x").ShouldBeEmpty();
}
[Fact]
public void Unknown_references_are_named()
{
var org = new OrgChart([Unit("team", "nowhere")], [Agent("x", "missing")]);
org.Problems().ShouldContain(p => p.Contains("nowhere"));
org.Problems().ShouldContain(p => p.Contains("missing"));
}
[Fact]
public void A_sound_structure_has_no_problems()
=> Shop().Problems().ShouldBeEmpty();
}
@@ -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");
}
}
@@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -0,0 +1,61 @@
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// Releasing the pool of one database must leave every other database alone. The tests used
/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its
/// connection in the middle of opening it — one red run in every few, always another test.
/// </summary>
public sealed class PoolReleaseTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]);
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private static async Task TouchAsync(SqliteStorage storage)
=> await storage.WriteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')";
await cmd.ExecuteNonQueryAsync();
}, default);
[Fact]
public async Task Releasing_one_directory_frees_its_files_and_nothing_else()
{
var first = new SqliteStorage(Path.Combine(_root, "a", "state.db"));
var second = new SqliteStorage(Path.Combine(_root, "b", "state.db"));
await TouchAsync(first);
await TouchAsync(second);
SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a"));
// The released database can go — on Windows an open handle would prevent that.
Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true));
// The other one keeps working, and so does its pool.
await TouchAsync(second);
await using var connection = await second.OpenConnectionAsync(default);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'";
(await cmd.ExecuteScalarAsync()).ShouldBe("v");
}
[Fact]
public async Task A_database_file_can_be_named_directly()
{
var path = Path.Combine(_root, "c", "state.db");
await TouchAsync(new SqliteStorage(path));
SqliteStorage.ReleasePooledConnections(path);
Should.NotThrow(() => File.Delete(path));
}
}
@@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is best effort */ }
}
@@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
@@ -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.");
}
}
@@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ }
}
@@ -0,0 +1,98 @@
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>
/// #683: A task for another agent is a way of reaching it. The address book that holds for
/// messages holds here too — otherwise it could be walked around with task_create.
/// </summary>
public sealed class AssignmentReachTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
private readonly OrgChart _org = new(
[
new UnitConfig { Id = "amazon", Name = "Amazon", LeaderAgentId = "amazon-lead" },
new UnitConfig { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" }
],
[
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
new AgentConfig { AgentId = "accounting-lead", UnitId = "accounting" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
public AssignmentReachTests()
{
_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()
{
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<ToolResult> Run(string agentId, string json)
{
var context = new AgentToolContext(
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
{
Organisation = _org
};
return _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, context, CancellationToken.None);
}
[Fact]
public async Task A_task_for_an_agent_outside_the_address_book_is_refused()
{
var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@bookkeeper"}""");
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldNotBeNull().ShouldContain("not in your address book");
result.ErrorMessage.ShouldContain("amazon-lead");
}
[Fact]
public async Task A_fresh_run_of_an_agent_outside_is_refused_too()
{
var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@new:bookkeeper"}""");
result.Success.ShouldBeFalse();
}
[Fact]
public async Task A_task_for_the_own_lead_or_the_human_goes_through()
{
(await Run("ads", """{"action":"task_create","title":"Frage an die Leitung","assignee":"@amazon-lead"}""")).Success.ShouldBeTrue();
(await Run("ads", """{"action":"task_create","title":"Frage an den Menschen","assignee":"@human"}""")).Success.ShouldBeTrue();
(await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}""")).Success.ShouldBeTrue();
}
[Fact]
public async Task Reassigning_is_held_to_the_same_rule()
{
var created = await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}""");
var id = created.Content!.Split(' ', ':', '\n').First(w => w.StartsWith("t-"));
var result = await Run("ads", $$"""{"action":"task_update","id":"{{id}}","assignee":"@bookkeeper"}""");
result.Success.ShouldBeFalse();
}
}
@@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis);
try { Directory.Delete(_verzeichnis, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -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");
}
}