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>
This commit is contained in:
Richard
2026-09-24 12:54:16 +02:00
co-authored by Claude Opus 5.5
parent 68a432fec6
commit e4ecf2d5ca
10 changed files with 866 additions and 2 deletions
+113
View File
@@ -1795,3 +1795,116 @@ td.klein {
.team-flaeche { flex-direction: column; } .team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; } .team-seitenleiste { width: auto; }
} }
/* ─── 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;
}
.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; }
}
@@ -36,6 +36,7 @@ import { renderUsageTimeline } from "./views/usage-timeline.js";
import { renderRunDetail } from "./views/run-detail.js"; import { renderRunDetail } from "./views/run-detail.js";
import { renderPromptHistory } from "./views/prompt-history.js"; import { renderPromptHistory } from "./views/prompt-history.js";
import { renderTeam, leaveTeam } from "./views/team.js"; import { renderTeam, leaveTeam } from "./views/team.js";
import { renderOrganisation } from "./views/organisation.js";
// ─── 1. Routen registrieren ─── // ─── 1. Routen registrieren ───
@@ -53,6 +54,7 @@ registriereRoute("/agenten", () => { verlasseAnsicht(); return rendereAgenten();
registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); }); registriereRoute("/agenten/:agentId", (p) => { verlasseAnsicht(); return rendereAgenten(p); });
registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); }); registriereRoute("/aufgaben", () => { verlasseAnsicht(); return rendereAufgaben(); });
registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); }); registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); }); registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); }); registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); }); registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
@@ -68,6 +68,65 @@
"nav.agents": "Agenten", "nav.agents": "Agenten",
"nav.tasks": "Aufgaben", "nav.tasks": "Aufgaben",
"nav.team": "Teamübersicht", "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.",
"team.title": "Teamübersicht", "team.title": "Teamübersicht",
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.", "team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.", "team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
@@ -68,6 +68,65 @@
"nav.agents": "Agents", "nav.agents": "Agents",
"nav.tasks": "Tasks", "nav.tasks": "Tasks",
"nav.team": "Team overview", "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.",
"team.title": "Team overview", "team.title": "Team overview",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.", "team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.", "team.noInstance": "No instance selected. Choose an active instance in the header.",
@@ -61,6 +61,12 @@
</svg> </svg>
<span class="nav-label" data-i18n="nav.team">Team overview</span> <span class="nav-label" data-i18n="nav.team">Team overview</span>
</a> </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"> <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"> <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"/> <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"/>
@@ -0,0 +1,471 @@
// 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).
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"]);
let data = null;
let selection = null; // { kind: "unit" | "agent", 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;
return sel.kind === "unit"
? data.units.find((u) => u.id === sel.id)
: data.agents.find((a) => a.agentId === sel.id);
}
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);
}
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;
}
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
}
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(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(", ") || "—"}` })
]),
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() })
}))
])
];
}
+1 -1
View File
@@ -97,7 +97,7 @@ public sealed record ServiceDto(
public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = ""); public sealed record ServiceInputDto(string Name, string Type, int Port, string Description = "");
/// <summary>Ein neuer Agent.</summary> /// <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> /// <summary>Eine Aufgabe vom Taskboard.</summary>
public sealed record TaskDto( public sealed record TaskDto(
@@ -0,0 +1,63 @@
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);
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);
@@ -31,6 +31,7 @@ internal static class ApiEndpoints
api.MapUsage(); api.MapUsage();
api.MapAnalytics(); api.MapAnalytics();
api.MapLogs(); api.MapLogs();
api.MapOrganisation();
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
app.MapEvents(); app.MapEvents();
@@ -126,7 +127,7 @@ internal static class ApiEndpoints
agents.MapPost("/", (AppHost host, NewAgentDto input) => agents.MapPost("/", (AppHost host, NewAgentDto input) =>
host.AgentAdmin.Add(new NewAgent( 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) => agents.MapDelete("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Remove(agentId).ToResponse()); host.AgentAdmin.Remove(agentId).ToResponse());
@@ -0,0 +1,90 @@
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
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();
return new OrganisationDto(units, agents, chart.Problems(), AgentAdministration.KnownTools);
});
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());
});
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.")));
}
private static StructurePreviewDto ToDto(StructurePreview preview)
=> new(preview.AffectedAgents, preview.ToolsLost, preview.Running, preview.Problems, preview.CanApply);
}