feat(orchestrator): hierarchy view from council offices with a separate agent order
The Agents views gain a Hierarchie view: an org chart of the chair and the line offices read from the council claims. The operator appoints, hands over and recalls an office there; the page signs each change with the passkey and a host unit (client/offices.py, offices path unit) verifies the signature, removes a live holder itself and runs a plain council.py claim for the successor. council.py loses the --operator flag, so no agent can override a live holder. The agent order is now separate from task priority: rasci.py agents order stores it in agent-order.json beside the register through the nofollow helpers and never writes a --prio pin. It only breaks ties of equal task rank, also among the queue grants. The Hierarchie view edits it as a drag list. Host units run the installed skill scripts, so orchestrator.sh keeps the offices unit off while the installed council.py, nofollow.py or rasci.py differ from the tree. Job files, council files and hub records are read with O_NOFOLLOW and O_NONBLOCK and size caps; nofollow.rewrite replaces a claim without creating directories, so a beat never revives a released office. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 79 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8e180be256
commit
4540494c30
53 files changed
+1821
-211
No files matched your search
@@ -402,6 +402,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
|
||||
.graph3d .scene-container { position: relative; }
|
||||
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
|
||||
{% include "style-hierarchy.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -485,6 +486,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-spawn.js" %}
|
||||
|
||||
{% include "page-hierarchy.js" %}
|
||||
|
||||
{% include "page-agent-order.js" %}
|
||||
|
||||
{% include "page-graph3d.js" %}
|
||||
|
||||
{% include "page-artifacts.js" %}
|
||||
@@ -493,6 +498,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-cleanup.js" %}
|
||||
|
||||
{% include "page-offices.js" %}
|
||||
|
||||
{% include "page-time.js" %}
|
||||
|
||||
{% include "page-pulses.js" %}
|
||||
|
||||
@@ -3,12 +3,14 @@ const Nav = (() => {
|
||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
|
||||
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
|
||||
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
|
||||
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
|
||||
const ICONS = {
|
||||
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d",
|
||||
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
|
||||
};
|
||||
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
|
||||
const known = (view) => VIEWS.some(([id]) => id === view);
|
||||
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
|
||||
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
|
||||
|
||||
function tabOf(screen) {
|
||||
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
|
||||
@@ -22,19 +24,19 @@ const Nav = (() => {
|
||||
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
|
||||
if (Object.hasOwn(OLD, head)) {
|
||||
const [screen, fallback] = OLD[head];
|
||||
const view = known(sub) ? sub : fallback;
|
||||
const view = known(screen, sub) ? sub : fallback;
|
||||
return {screen, view, redirect: hashOf(screen, view)};
|
||||
}
|
||||
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
|
||||
return {screen: sub, view: null, redirect: null};
|
||||
}
|
||||
if (TABS.some(([id]) => id === head) && head !== "stream") {
|
||||
return {screen: head, view: known(sub) ? sub : null, redirect: null};
|
||||
return {screen: head, view: known(head, sub) ? sub : null, redirect: null};
|
||||
}
|
||||
return {screen: "stream", view: null, redirect: null};
|
||||
}
|
||||
|
||||
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
|
||||
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Nav;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const orderDom = {box: null, dragged: null, focus: null};
|
||||
|
||||
function openAgentOrder() {
|
||||
go("agents", "hierarchy");
|
||||
window.setTimeout(() => {
|
||||
const title = document.getElementById("agent-order-title");
|
||||
if (title) {
|
||||
title.scrollIntoView({block: "nearest"});
|
||||
title.focus();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function syncOrder() {
|
||||
const stored = state.agent_order;
|
||||
if (ui.order === null || ui.order.length !== stored.length || ui.order.some((name) => !stored.includes(name))) {
|
||||
ui.order = [...stored];
|
||||
}
|
||||
}
|
||||
|
||||
function placeAgent(next, focus) {
|
||||
ui.order = next;
|
||||
orderDom.focus = focus;
|
||||
drawAgentOrder();
|
||||
}
|
||||
|
||||
function moveAgent(name, step) {
|
||||
const from = ui.order.indexOf(name);
|
||||
const to = from + step;
|
||||
if (from < 0 || to < 0 || to >= ui.order.length) {
|
||||
return;
|
||||
}
|
||||
const next = ui.order.filter((entry) => entry !== name);
|
||||
next.splice(to, 0, name);
|
||||
placeAgent(next, `${step < 0 ? "up" : "down"}:${name}`);
|
||||
}
|
||||
|
||||
function dropAgent(name) {
|
||||
const dragged = orderDom.dragged;
|
||||
orderDom.dragged = null;
|
||||
if (dragged === null || dragged === name) {
|
||||
return;
|
||||
}
|
||||
const next = ui.order.filter((entry) => entry !== dragged);
|
||||
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
|
||||
placeAgent(next, null);
|
||||
}
|
||||
|
||||
function orderItem(name, index) {
|
||||
const up = officeBtn("bi-arrow-up", `${name} nach oben`, `${name} nach oben: höhere Priorität`, () => moveAgent(name, -1), `up:${name}`);
|
||||
const down = officeBtn("bi-arrow-down", `${name} nach unten`, `${name} nach unten: niedrigere Priorität`, () => moveAgent(name, 1), `down:${name}`);
|
||||
up.disabled = index === 0;
|
||||
down.disabled = index === ui.order.length - 1;
|
||||
const item = h("li", {draggable: "true", "data-name": name}, icon("bi-grip-vertical", "grip"), h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name), h("span", {class: "spacer"}), up, down);
|
||||
item.addEventListener("dragstart", (event) => {
|
||||
orderDom.dragged = name;
|
||||
item.classList.add("dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", name);
|
||||
});
|
||||
item.addEventListener("dragend", () => item.classList.remove("dragging"));
|
||||
item.addEventListener("dragover", (event) => event.preventDefault());
|
||||
item.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dropAgent(name);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
|
||||
async function saveAgentOrder() {
|
||||
if (await act("order", "Priorität", {agents: ui.order})) {
|
||||
ui.order = null;
|
||||
drawAgentOrder();
|
||||
}
|
||||
}
|
||||
|
||||
function drawAgentOrder() {
|
||||
const box = orderDom.box;
|
||||
if (!box || !box.isConnected) {
|
||||
orderDom.box = null;
|
||||
return;
|
||||
}
|
||||
syncOrder();
|
||||
const key = orderDom.focus || focusKey(box);
|
||||
orderDom.focus = null;
|
||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.agent_order);
|
||||
box.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Priorität der Agenten", counter(ui.order.length))),
|
||||
h("p", {class: "muted"}, "Ordnet die Freigaben des Chairs und entscheidet nur bei gleicher Aufgabenpriorität; die --prio-Pins der Aufgaben bleiben unverändert."),
|
||||
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Reihenfolge der Agenten"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "Kein Agent mit Amt oder offener Aufgabe."),
|
||||
h("div", {class: "actions"},
|
||||
h("span", {class: "muted"}, "Ziehen oder Pfeile zum Umsortieren"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Zurücksetzen"),
|
||||
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Reihenfolge speichern")));
|
||||
const target = key && [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
|
||||
if (target && target.disabled) {
|
||||
refocus(box, key.startsWith("up:") ? `down:${key.slice(3)}` : key.startsWith("down:") ? `up:${key.slice(5)}` : "order:title");
|
||||
} else {
|
||||
refocus(box, key);
|
||||
}
|
||||
}
|
||||
@@ -135,6 +135,6 @@
|
||||
}
|
||||
|
||||
function agentNames() {
|
||||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]);
|
||||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
@@ -64,7 +64,7 @@
|
||||
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
||||
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
|
||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", prioritySheet), night), clear, mode};
|
||||
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", openAgentOrder, "Priorität: Reihenfolge der Agenten in der Hierarchie"), night), clear, mode};
|
||||
}
|
||||
|
||||
function drawToolbar() {
|
||||
@@ -75,54 +75,3 @@
|
||||
graphDom.toolbar.clear.disabled = !state.silence;
|
||||
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
|
||||
}
|
||||
|
||||
function priorityContent() {
|
||||
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
|
||||
ui.order = [...state.priority];
|
||||
}
|
||||
const list = h("ol", {class: "order-list"});
|
||||
let dragged = null;
|
||||
ui.order.forEach((name, index) => {
|
||||
const item = h("li", {draggable: "true", "data-name": name}, h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name));
|
||||
item.addEventListener("dragstart", (event) => {
|
||||
dragged = name;
|
||||
item.classList.add("dragging");
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", name);
|
||||
});
|
||||
item.addEventListener("dragend", () => item.classList.remove("dragging"));
|
||||
item.addEventListener("dragover", (event) => event.preventDefault());
|
||||
item.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
if (dragged === null || dragged === name) {
|
||||
return;
|
||||
}
|
||||
const next = ui.order.filter((entry) => entry !== dragged);
|
||||
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
|
||||
ui.order = next;
|
||||
prioritySheet();
|
||||
});
|
||||
list.append(item);
|
||||
});
|
||||
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
|
||||
return [
|
||||
ui.order.length ? list : h("p", {class: "muted"}, "Kein Agent mit offener Aufgabe."),
|
||||
h("div", {class: "actions"},
|
||||
h("span", {class: "muted"}, "Ziehen zum Umsortieren"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn ghost", disabled: !changed, on: {click: () => {
|
||||
ui.order = [...state.priority];
|
||||
prioritySheet();
|
||||
}}}, "Zurücksetzen"),
|
||||
h("button", {type: "button", class: "btn primary", disabled: !changed, on: {click: async () => {
|
||||
if (await act("order", "Priorität", {agents: ui.order})) {
|
||||
ui.order = null;
|
||||
prioritySheet();
|
||||
}
|
||||
}}}, "Reihenfolge speichern")),
|
||||
];
|
||||
}
|
||||
|
||||
function prioritySheet() {
|
||||
openSheet("Priorität der Agenten", priorityContent(), false);
|
||||
}
|
||||
@@ -47,30 +47,36 @@
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
|
||||
followCleanup(result.data.id, outcome, Date.now());
|
||||
followJob(`/api/cleanup/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||
const {good, lines} = showJob(data, outcome, "branch", RESULT_LABELS);
|
||||
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, lines, good);
|
||||
fetchTree();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function followCleanup(id, outcome, began) {
|
||||
async function followJob(url, outcome, began, finish) {
|
||||
let data = null;
|
||||
try {
|
||||
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {cache: "no-store"});
|
||||
const response = await fetch(url, {cache: "no-store"});
|
||||
data = response.ok ? await response.json() : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!data || data.status !== "done") {
|
||||
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
|
||||
window.setTimeout(() => followCleanup(id, outcome, began), CLEANUP_POLL);
|
||||
window.setTimeout(() => followJob(url, outcome, began, finish), CLEANUP_POLL);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_LABELS[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
||||
finish(data);
|
||||
}
|
||||
|
||||
function showJob(data, outcome, key, labels) {
|
||||
const lines = data.error ? [data.error] : data.items.map((item) => `${item[key]}: ${(labels[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
|
||||
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
|
||||
const [label, tone] = RESULT_LABELS[item.status] || [item.status, "neutral"];
|
||||
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item.branch), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
||||
const [label, tone] = labels[item.status] || [item.status, "neutral"];
|
||||
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item[key]), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
|
||||
})));
|
||||
const good = !data.error && data.items.every((item) => item.status === "done");
|
||||
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
|
||||
fetchTree();
|
||||
return {good: !data.error && data.items.every((item) => item.status === "done"), lines};
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
const NONCE = document.currentScript ? document.currentScript.nonce : "";
|
||||
const ACTIVE_BEAT = 900;
|
||||
const DAY = 86400;
|
||||
const CHAIR = "Maintainer";
|
||||
const HUMAN = "Operator";
|
||||
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
|
||||
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||||
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
|
||||
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: CHAIR}).id;
|
||||
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: HUMAN}).id;
|
||||
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
||||
|
||||
function toast(title, lines, good) {
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
}
|
||||
|
||||
function viewSwitch(screen) {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {
|
||||
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
|
||||
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
|
||||
}, icon(Nav.ICONS[id]))));
|
||||
}
|
||||
@@ -191,6 +191,6 @@
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const toolbar = agentsToolbar();
|
||||
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
||||
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
const hier = {chart: null, sig: "", closed: new Set(), ids: 0};
|
||||
const OFFICE_ICONS = {chair: "bi-award", line: "bi-person-badge", cluster: "bi-collection", work: "bi-briefcase", other: "bi-signpost-split"};
|
||||
const OFFICE_GROUPS = [["cluster", "Organisatoren", "Organisator"], ["work", "Präsidenten", "Präsident"], ["other", "Weitere Ämter", "Amt"]];
|
||||
|
||||
function hierarchyScreen() {
|
||||
hier.chart = h("section", {class: "card org-chart", "aria-labelledby": "org-title"});
|
||||
orderDom.box = h("section", {class: "card agent-order", id: "agent-order", "aria-labelledby": "agent-order-title"});
|
||||
hier.sig = "";
|
||||
return h("div", {class: "hierarchy-body"}, hier.chart, orderDom.box);
|
||||
}
|
||||
|
||||
function hierarchySig() {
|
||||
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
|
||||
return JSON.stringify([held, state.agent_order, state.people]);
|
||||
}
|
||||
|
||||
function drawHierarchy(force) {
|
||||
if (!hier.chart || !hier.chart.isConnected) {
|
||||
hier.chart = null;
|
||||
return;
|
||||
}
|
||||
const sig = hierarchySig();
|
||||
if (!force && sig === hier.sig) {
|
||||
return;
|
||||
}
|
||||
hier.sig = sig;
|
||||
drawChart();
|
||||
drawAgentOrder();
|
||||
}
|
||||
|
||||
function focusKey(box) {
|
||||
const at = document.activeElement;
|
||||
return at && box.contains(at) ? at.dataset.focus || null : null;
|
||||
}
|
||||
|
||||
function refocus(box, key) {
|
||||
const found = key === null ? null : [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
|
||||
if (found) {
|
||||
found.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function officeBtn(name, label, hint, run, focus) {
|
||||
const button = iconBtn(name, label, "btn small ghost", run, hint);
|
||||
button.dataset.focus = focus;
|
||||
return button;
|
||||
}
|
||||
|
||||
function holderOf(claim) {
|
||||
return claim.agent || (claim.session === "-" ? "" : claim.session);
|
||||
}
|
||||
|
||||
function officeRow(claim, label, title) {
|
||||
const who = holderOf(claim);
|
||||
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
|
||||
return h("div", {class: "org-row", "data-office": claim.office},
|
||||
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `${who} öffnen`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
active ? chip("bi-play-circle", "aktiv", "ok") : chip("bi-pause-circle", "ohne Herzschlag", "neutral"),
|
||||
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||
h("span", {class: "org-actions"},
|
||||
officeBtn("bi-arrow-left-right", "Übergeben", `Übergeben: ${claim.office} an eine andere Session, mit Passkey-Signatur`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
|
||||
officeBtn("bi-person-x", "Abberufen", `Abberufen: ${claim.office} frei machen, mit Passkey-Signatur`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
|
||||
}
|
||||
|
||||
function vacantRow(office, label) {
|
||||
return h("div", {class: "org-row vacant", "data-office": office},
|
||||
h("span", {class: "org-office"}, icon(OFFICE_ICONS.chair), h("strong", {}, label)),
|
||||
pill("unbesetzt", "warn"),
|
||||
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Besetzen", `Besetzen: ${office} einer Session geben, mit Passkey-Signatur`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
|
||||
}
|
||||
|
||||
function groupRow(glyph, label, count, note) {
|
||||
return h("div", {class: "org-row group"}, h("span", {class: "org-office"}, icon(glyph), h("strong", {}, label)), counter(count), h("span", {class: "muted"}, note));
|
||||
}
|
||||
|
||||
function orgNode(id, label, row, children) {
|
||||
if (!children.length) {
|
||||
return h("li", {}, h("div", {class: "org-line"}, h("span", {class: "org-gap", "aria-hidden": "true"}), row));
|
||||
}
|
||||
const open = !hier.closed.has(id);
|
||||
hier.ids += 1;
|
||||
const list = h("ul", {class: "org-tree", id: `org-${hier.ids}`, hidden: !open}, children);
|
||||
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Zuklappen" : "Aufklappen"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
|
||||
toggle.addEventListener("click", () => {
|
||||
const now = list.hidden;
|
||||
list.hidden = !now;
|
||||
toggle.setAttribute("aria-expanded", String(now));
|
||||
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Zuklappen" : "Aufklappen"}: ${label}`);
|
||||
if (now) {
|
||||
hier.closed.delete(id);
|
||||
} else {
|
||||
hier.closed.add(id);
|
||||
}
|
||||
});
|
||||
return h("li", {"data-node": id}, h("div", {class: "org-line"}, toggle, row), list);
|
||||
}
|
||||
|
||||
function chartTree() {
|
||||
const claims = state.claims;
|
||||
const dimensions = new Map();
|
||||
for (const claim of claims.filter((entry) => entry.kind === "line")) {
|
||||
dimensions.set(claim.dimension, [...(dimensions.get(claim.dimension) || []), claim]);
|
||||
}
|
||||
const lines = [...dimensions].sort(([a], [b]) => a.localeCompare(b)).map(([dimension, held]) => orgNode(`dimension:${dimension}`, dimension,
|
||||
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "Linie" : "Linien"),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Repräsentant"), []))));
|
||||
const groups = OFFICE_GROUPS.map(([kind, label, title]) => [kind, label, title, claims.filter((entry) => entry.kind === kind)]).filter(([, , , held]) => held.length)
|
||||
.map(([kind, label, title, held]) => orgNode(`group:${kind}`, label, groupRow(OFFICE_ICONS[kind], label, held.length, kind === "other" ? "" : `${kind}/…`),
|
||||
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, kind === "other" ? claim.office : claim.office.slice(kind.length + 1), title), []))));
|
||||
const chair = claims.find((entry) => entry.kind === "chair");
|
||||
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Amt") : vacantRow("chair", "Chair"), [...lines, ...groups]);
|
||||
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "vergibt jedes Amt mit Passkey-Signatur"));
|
||||
return h("ul", {class: "org-tree root"}, orgNode("operator", HUMAN, root, [chairNode]));
|
||||
}
|
||||
|
||||
function drawChart() {
|
||||
const key = focusKey(hier.chart);
|
||||
hier.ids = 0;
|
||||
const count = state.claims.length;
|
||||
hier.chart.replaceChildren(
|
||||
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchie", counter(count)),
|
||||
h("span", {class: "muted"}, "Formale Ämter aus dem Council: Operator → Chair → Dimensionen → Linien, dazu Organisatoren und Präsidenten"),
|
||||
h("span", {class: "spacer"}),
|
||||
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("Neues Amt", "Neues Amt: ein freies Amt einer Session geben, mit Passkey-Signatur"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "Neues Amt")),
|
||||
chartTree());
|
||||
refocus(hier.chart, key);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const OFFICE_RESULTS = {done: ["erledigt", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
|
||||
const OFFICE_VERBS = {
|
||||
appoint: ["Besetzen", "bi-person-plus", "council.py claim --operator auf dem Host: die gewählte Session erhält das freie Amt."],
|
||||
takeover: ["Übergeben", "bi-arrow-left-right", "council.py takeover --operator auf dem Host: das Amt geht an die gewählte Session, auch wenn der bisherige Inhaber noch lebt."],
|
||||
recall: ["Abberufen", "bi-person-x", "council.py release --operator auf dem Host: das Amt wird frei, auch wenn der Inhaber noch lebt."],
|
||||
};
|
||||
const OFFICE_HINT = "Der Host prüft deine Passkey-Signatur selbst und verweigert, wenn sich der Inhaber seit der Signatur geändert hat.";
|
||||
|
||||
function successors(office, holder) {
|
||||
const [dimension, line] = office.split("/");
|
||||
const lined = Boolean(line) && dimension !== "work" && dimension !== "cluster";
|
||||
return state.graph.nodes
|
||||
.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id) && node.id !== holder)
|
||||
.filter((node) => !lined || !state.claims.some((claim) => claim.session === node.id && claim.kind === "line" && claim.dimension === dimension && claim.office !== office))
|
||||
.map((node) => [node.id, `${labelOf(node.id)} · ${node.id.slice(0, 8)}`]);
|
||||
}
|
||||
|
||||
function officeSheet(action, claim) {
|
||||
const office = claim ? claim.office : "";
|
||||
const holder = claim && action !== "appoint" ? claim.session : "";
|
||||
const [verb, glyph, text] = OFFICE_VERBS[action];
|
||||
const name = h("input", {class: "field", "aria-label": "Amt", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/linie, cluster/slug oder work/slug"});
|
||||
const picks = successors(office, holder);
|
||||
const session = h("select", {class: "field", "aria-label": "Session"}, picks.map(([id, label]) => h("option", {value: id}, label)));
|
||||
const work = h("input", {class: "field", "aria-label": "Arbeit", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "woran die Session in diesem Amt arbeitet"});
|
||||
const outcome = h("div", {class: "mini", "aria-live": "polite"});
|
||||
const recall = action === "recall";
|
||||
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: mit deiner Passkey-Signatur an den Host übergeben`)}, icon(glyph), verb);
|
||||
confirm.addEventListener("click", async () => {
|
||||
const item = {kind: "office", action, office: name.value.trim(), holder, session: recall ? "" : session.value, work: recall ? "" : work.value.trim()};
|
||||
confirm.disabled = true;
|
||||
if (!await signOffice([item], outcome, verb)) {
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
openSheet(office ? `${office}: ${verb}` : "Neues Amt besetzen", [
|
||||
h("p", {class: "muted"}, text, " ", OFFICE_HINT),
|
||||
holder ? meta([["Inhaber", holderOf(claim) || holder, holder]]) : null,
|
||||
h("div", {class: "office-form"},
|
||||
h("label", {}, "Amt", name),
|
||||
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "Keine passende Session bekannt.")),
|
||||
recall ? null : h("label", {}, "Arbeit", work)),
|
||||
outcome,
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
|
||||
], false);
|
||||
(office ? (recall ? confirm : session) : name).focus();
|
||||
}
|
||||
|
||||
async function signOffice(items, outcome, verb) {
|
||||
const signed = await proofFor(items, verb);
|
||||
if (signed === null) {
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
|
||||
return false;
|
||||
}
|
||||
const result = await sendJson("/api/offices", {items, proof: signed.proof});
|
||||
if (!result.ok || !result.data) {
|
||||
toast(`${verb}: nicht möglich`, fail(result), false);
|
||||
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
|
||||
return false;
|
||||
}
|
||||
outcome.replaceChildren(h("p", {class: "muted"},`Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit ruft jetzt council.py auf.`));
|
||||
followJob(`/api/offices/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
|
||||
const {good, lines} = showJob(data, outcome, "office", OFFICE_RESULTS);
|
||||
toast(`${verb}: ${good ? "erledigt" : "nicht ausgeführt"}`, lines, good);
|
||||
load();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
function paletteItems() {
|
||||
return [
|
||||
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
|
||||
...["agents", "artifacts"].flatMap((screen) => Nav.VIEWS.map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
|
||||
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
|
||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
|
||||
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
|
||||
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
|
||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
|
||||
{label: "Priorität …", hint: "Reihenfolge der Agenten", run: prioritySheet},
|
||||
{label: "Priorität …", hint: "Reihenfolge der Agenten in der Hierarchie", run: openAgentOrder},
|
||||
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
|
||||
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
|
||||
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
stage();
|
||||
drawSide();
|
||||
drawSpawn();
|
||||
drawHierarchy(force);
|
||||
drawToolbar();
|
||||
drawInbox();
|
||||
drawStatus();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function stage() {
|
||||
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
|
||||
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree" || ui.views.agents === "hierarchy") {
|
||||
return;
|
||||
}
|
||||
graphDom.pulses = null;
|
||||
@@ -11,7 +11,7 @@
|
||||
}
|
||||
|
||||
function drawSide() {
|
||||
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
|
||||
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
|
||||
graphDom.aside.replaceChildren(...side());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.hierarchy-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; align-items: start; }
|
||||
@media (max-width: 960px) { .hierarchy-body { grid-template-columns: minmax(0, 1fr); } }
|
||||
.org-chart .inbox-head { flex-wrap: wrap; }
|
||||
.org-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 16px; list-style: none; border-left: 1px solid var(--line); margin-left: 14px; }
|
||||
.org-tree.root { margin-left: 0; padding-left: 0; border-left: 0; }
|
||||
.org-tree[hidden] { display: none; }
|
||||
.org-line { display: flex; align-items: flex-start; gap: 4px; }
|
||||
.org-toggle { margin-top: 3px; }
|
||||
.org-gap { flex: none; width: 30px; }
|
||||
.org-row { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; padding: 5px 8px; border-radius: 8px; }
|
||||
.org-row:hover { background: var(--hover); }
|
||||
.org-row.vacant { border: 1px dashed var(--line-strong); }
|
||||
.org-row.group .org-office { color: var(--muted); }
|
||||
.org-office, .org-holder { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
|
||||
.org-work { flex: 1 1 140px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.org-actions { display: inline-flex; gap: 2px; margin-left: auto; }
|
||||
.agent-order .order-list li { gap: 8px; padding: 6px 8px; }
|
||||
.agent-order .order-list .linkish { min-width: 0; }
|
||||
.agent-order .grip { flex: none; color: var(--muted); }
|
||||
.office-form { display: grid; gap: 10px; margin: 10px 0; }
|
||||
.office-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
Reference in new issue
Block a user