Merge feature/orchestrator/s52-night-per-agent into master
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # docs/requirements/001-agent-orchestrator.md # orchestrator/kvb_orchestrator/view.py # orchestrator/templates/page-board.js # tests/test_hub_state.py
This commit is contained in:
commit
c3ab6b7e18
18 files changed
+406
-62
No files matched your search
@@ -362,6 +362,15 @@ from three views down to any agent, branch or artefact.
|
|||||||
no passkey. Rejected: hub-wide settings with a signature.
|
no passkey. Rejected: hub-wide settings with a signature.
|
||||||
64. **3D faces**: agent nodes in the 3D view show their face, on by default
|
64. **3D faces**: agent nodes in the 3D view show their face, on by default
|
||||||
and switchable; repositories and branches stay spheres.
|
and switchable; repositories and branches stay spheres.
|
||||||
|
65. **Day/Night per agent** (operator 2026-11-10, decisions 65 and 66): the
|
||||||
|
Day/Night menu lists "All" and one checkbox per claimed agent. All ticked
|
||||||
|
runs the global drain, suspend or resume as before; a subset runs it only
|
||||||
|
for the ticked sessions (`night.py --target`), the passkey signs the
|
||||||
|
board item with those sessions, and the radio call reaches only them.
|
||||||
|
Rejected: a second menu for single agents.
|
||||||
|
66. **Agent panel Day/Night**: every agent panel shows the agent's part and,
|
||||||
|
for a claimed agent, Drain, Suspend and Resume for that agent alone, by
|
||||||
|
day too. Rejected: single-agent parts only during a night.
|
||||||
|
|
||||||
## Approach
|
## Approach
|
||||||
|
|
||||||
@@ -826,6 +835,20 @@ flowchart TD
|
|||||||
- [ ] `make orchestrator-install` enables `kvb-orchestrator-sessions.timer`,
|
- [ ] `make orchestrator-install` enables `kvb-orchestrator-sessions.timer`,
|
||||||
and `make orchestrator-check` reports it active.
|
and `make orchestrator-check` reports it active.
|
||||||
|
|
||||||
|
### Phase 6: Day/Night per agent (S5.2)
|
||||||
|
|
||||||
|
- [ ] `night.py drain|suspend|resume --target=<id>` sets and lifts one
|
||||||
|
session's part by day and during a mode, and `night.py status` prints
|
||||||
|
it as the `you:` line (`tests/test_night.py`).
|
||||||
|
- [ ] The Day/Night menu lists "All" and one checkbox per claimed agent; all
|
||||||
|
ticked sends the unchanged board item, a subset signs and runs only for
|
||||||
|
the ticked sessions, and none ticked disables the three buttons.
|
||||||
|
- [ ] A proof for one set of agents is refused for another set and for all,
|
||||||
|
and an unclaimed session is refused before anything runs
|
||||||
|
(`tests/test_hub_board.py`).
|
||||||
|
- [ ] Every agent panel shows the agent's part and Drain, Suspend and Resume
|
||||||
|
for that agent alone.
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- **Unit**
|
- **Unit**
|
||||||
@@ -857,6 +880,9 @@ flowchart TD
|
|||||||
receipts, a session without a record; the host client's snapshot
|
receipts, a session without a record; the host client's snapshot
|
||||||
from fixture session files with a reused pid; a stale or malformed
|
from fixture session files with a reused pid; a stale or malformed
|
||||||
snapshot read as absent.
|
snapshot read as absent.
|
||||||
|
- [ ] S5.2: night.py targets by day and during a mode, the signed board
|
||||||
|
item with agents (forged set, unclaimed session), the parts in the
|
||||||
|
state.
|
||||||
- **Integration**
|
- **Integration**
|
||||||
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
|
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
|
||||||
entry points (JSON on stdin): the post appears only on the callee's
|
entry points (JSON on stdin): the post appears only on the callee's
|
||||||
@@ -892,3 +918,6 @@ flowchart TD
|
|||||||
- [ ] S5.3: state symbols in every Agents view and the panel, the Status
|
- [ ] S5.3: state symbols in every Agents view and the panel, the Status
|
||||||
menu toggles through a reload, 3D faces on and off, in Chromium and
|
menu toggles through a reload, 3D faces on and off, in Chromium and
|
||||||
Firefox at 1920, 1366 and 390 px.
|
Firefox at 1920, 1366 and 390 px.
|
||||||
|
- [ ] S5.2: agent checkboxes in the Day/Night menu and the agent panel's
|
||||||
|
buttons, signed in Chromium and refused unsigned in Firefox, each at
|
||||||
|
1920, 1366 and 390 px.
|
||||||
@@ -135,8 +135,17 @@ def message_item(to: str, cls: str, text: str, upload: str, voice: str, voice_sh
|
|||||||
return {"kind": "message", "to": to, "class": cls, "text": text, "upload": upload, "voice": voice, "voice_sha256": voice_sha256}
|
return {"kind": "message", "to": to, "class": cls, "text": text, "upload": upload, "voice": voice, "voice_sha256": voice_sha256}
|
||||||
|
|
||||||
|
|
||||||
def board_item(action: str) -> dict:
|
def board_item(action: str, agents: list[str]) -> dict:
|
||||||
return {"kind": "board", "action": action}
|
"""Args:
|
||||||
|
action: the board action.
|
||||||
|
agents: the session ids a night action is limited to, sorted; empty
|
||||||
|
for every agent, which keeps the item of a board action without
|
||||||
|
agents unchanged.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The item the operator's passkey signs.
|
||||||
|
"""
|
||||||
|
return {"kind": "board", "action": action, **({"agents": agents} if agents else {})}
|
||||||
|
|
||||||
|
|
||||||
def stream_item(origins: list[str], urls: list[str]) -> dict:
|
def stream_item(origins: list[str], urls: list[str]) -> dict:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ NIGHTS = {
|
|||||||
"suspend": (["suspend"], "Operator ran kvb-org-suspend in the Agent Orchestrator: run kvb-agent-suspend; verify with night.py status."),
|
"suspend": (["suspend"], "Operator ran kvb-org-suspend in the Agent Orchestrator: run kvb-agent-suspend; verify with night.py status."),
|
||||||
"resume": (["resume"], "Operator ran kvb-org-resume: run kvb-agent-resume; verify with night.py status."),
|
"resume": (["resume"], "Operator ran kvb-org-resume: run kvb-agent-resume; verify with night.py status."),
|
||||||
}
|
}
|
||||||
|
ALONE = {action: f"Operator chose {action} for you alone in the Agent Orchestrator: run kvb-agent-{action}; verify with night.py status." for action in NIGHTS}
|
||||||
UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
UNSAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||||
DIRECT = {"post": ["post"], "call": ["send", "call"]}
|
DIRECT = {"post": ["post"], "call": ["send", "call"]}
|
||||||
TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", "pan-pan", "security", "mayday")}}
|
TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast", "pan-pan", "security", "mayday")}}
|
||||||
@@ -26,6 +27,29 @@ MESSAGE_KEYS = ("to", "text", "upload", "class", "voice")
|
|||||||
SIGNED_BOARD = ("clear-mayday", *NIGHTS, "estimates")
|
SIGNED_BOARD = ("clear-mayday", *NIGHTS, "estimates")
|
||||||
|
|
||||||
|
|
||||||
|
def night_targets(action: str, payload: dict, claims: list[dict]) -> list[str]:
|
||||||
|
"""Args:
|
||||||
|
action: the board action.
|
||||||
|
payload: the request; its optional "agents" lists the session ids a
|
||||||
|
night action is limited to.
|
||||||
|
claims: the council's claims; only a claimed agent's session can be
|
||||||
|
chosen.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The chosen session ids, sorted; empty for every agent.
|
||||||
|
"""
|
||||||
|
agents = payload.pop("agents", [])
|
||||||
|
if not isinstance(agents, list) or not all(isinstance(sid, str) for sid in agents) or len(set(agents)) != len(agents):
|
||||||
|
raise ToolError("agents must be a list of distinct session ids")
|
||||||
|
if agents and action not in NIGHTS:
|
||||||
|
raise ToolError(f"agents apply only to {', '.join(NIGHTS)}")
|
||||||
|
known = {claim["session"] for claim in claims if claim["agent"] != HUMAN and claim["session"] != "-"}
|
||||||
|
unknown = [sid for sid in agents if sid not in known]
|
||||||
|
if unknown:
|
||||||
|
raise ToolError(f"no claimed agent has the session {', '.join(unknown)[:300]}")
|
||||||
|
return sorted(agents)
|
||||||
|
|
||||||
|
|
||||||
def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]:
|
def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]:
|
||||||
cls = payload["class"]
|
cls = payload["class"]
|
||||||
if not isinstance(cls, str) or cls not in choices:
|
if not isinstance(cls, str) or cls not in choices:
|
||||||
@@ -176,16 +200,21 @@ class Messaging:
|
|||||||
|
|
||||||
def sign_board(self, body: bytes) -> dict:
|
def sign_board(self, body: bytes) -> dict:
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("action",))
|
action = payload.get("action")
|
||||||
if payload["action"] not in SIGNED_BOARD:
|
if action not in SIGNED_BOARD:
|
||||||
raise ToolError(f"action must be one of {', '.join(SIGNED_BOARD)}")
|
raise ToolError(f"action must be one of {', '.join(SIGNED_BOARD)}")
|
||||||
return self.challenge_for([signing.board_item(payload["action"])])
|
agents = night_targets(action, payload, read_claims(self.council))
|
||||||
|
expect(payload, ("action",))
|
||||||
|
return self.challenge_for([signing.board_item(action, agents)])
|
||||||
|
|
||||||
def board(self, action: str, body: bytes) -> dict:
|
def board(self, action: str, body: bytes) -> dict:
|
||||||
|
claims = read_claims(self.council)
|
||||||
|
agents = []
|
||||||
if action in SIGNED_BOARD:
|
if action in SIGNED_BOARD:
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
|
agents = night_targets(action, payload, claims)
|
||||||
expect(payload, ("proof",))
|
expect(payload, ("proof",))
|
||||||
self.operator_proven(payload["proof"], signing.board_item(action))
|
self.operator_proven(payload["proof"], signing.board_item(action, agents))
|
||||||
if action == "clear-mayday":
|
if action == "clear-mayday":
|
||||||
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
|
runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])]
|
||||||
elif action == "broadcast":
|
elif action == "broadcast":
|
||||||
@@ -212,8 +241,10 @@ class Messaging:
|
|||||||
runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])]
|
runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])]
|
||||||
elif action in NIGHTS:
|
elif action in NIGHTS:
|
||||||
command, text = NIGHTS[action]
|
command, text = NIGHTS[action]
|
||||||
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
|
if agents:
|
||||||
peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN})
|
text = ALONE[action]
|
||||||
|
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN, *(f"--target={sid}" for sid in agents)])]
|
||||||
|
peers = ordered({claim["address"] for claim in claims if claim["address"] and claim["agent"] != HUMAN and (not agents or claim["session"] in agents)})
|
||||||
if peers and runs[0]["code"] == 0:
|
if peers and runs[0]["code"] == 0:
|
||||||
targets = [part for peer in peers for part in ("--to", peer)]
|
targets = [part for peer in peers for part in ("--to", peer)]
|
||||||
runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text]))
|
runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text]))
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
||||||
const tasks = node ? node.tasks : [];
|
const tasks = node ? node.tasks : [];
|
||||||
const claims = node ? node.claims : [];
|
const claims = node ? node.claims : [];
|
||||||
|
const part = state.night_parts[ui.agent] || state.night;
|
||||||
|
agentDom.night.replaceChildren(pill(...(NIGHT[part] || [part, "neutral"])));
|
||||||
agentDom.info.replaceChildren(
|
agentDom.info.replaceChildren(
|
||||||
...[presenceMark(ui.agent, true)].filter(Boolean),
|
...[presenceMark(ui.agent, true)].filter(Boolean),
|
||||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||||
@@ -109,10 +111,14 @@
|
|||||||
const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …"));
|
const subs = h("div", {}, h("p", {class: "muted"}, "Loading subagents …"));
|
||||||
const log = h("div", {"aria-live": "polite"});
|
const log = h("div", {"aria-live": "polite"});
|
||||||
const life = h("span", {class: "muted"});
|
const life = h("span", {class: "muted"});
|
||||||
|
const night = h("span", {});
|
||||||
|
const claimed = nightAgents().some(([sid]) => sid === key);
|
||||||
|
const nightRow = h("div", {class: "actions agent-night", role: "group", "aria-label": "Day/Night"}, night, h("span", {class: "spacer"}),
|
||||||
|
claimed ? ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, () => [key])) : null);
|
||||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||||
openPopup(name, [
|
openPopup(name, [
|
||||||
popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
|
popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
|
||||||
h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
h("div", {class: "popup-body"}, nightRow, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||||
form,
|
form,
|
||||||
], anchor, () => {
|
], anchor, () => {
|
||||||
agentDom = null;
|
agentDom = null;
|
||||||
@@ -122,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.agent = key;
|
ui.agent = key;
|
||||||
agentDom = {info, history: log};
|
agentDom = {info, history: log, night};
|
||||||
drawAgent();
|
drawAgent();
|
||||||
loadSubagents(key, subs, life);
|
loadSubagents(key, subs, life);
|
||||||
text.focus();
|
text.focus();
|
||||||
|
|||||||
@@ -13,14 +13,19 @@
|
|||||||
|
|
||||||
const actionOf = (action) => ACTIONS.find(([id]) => id === action);
|
const actionOf = (action) => ACTIONS.find(([id]) => id === action);
|
||||||
|
|
||||||
async function confirmAct(action) {
|
const nightAgents = () => [...new Map(state.claims.filter((claim) => claim.agent && claim.agent !== HUMAN && claim.session !== "-").map((claim) => [claim.session, claim.agent]))]
|
||||||
|
.sort(([one, a], [two, b]) => a.localeCompare(b) || one.localeCompare(two));
|
||||||
|
|
||||||
|
async function confirmAct(action, agents) {
|
||||||
const [, label, question] = actionOf(action);
|
const [, label, question] = actionOf(action);
|
||||||
if (!window.confirm(question)) {
|
const names = new Map(nightAgents());
|
||||||
|
if (!window.confirm(agents.length ? question.replace("all agents", agents.map((sid) => names.get(sid) || sid).join(", ")) : question)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const signed = await signedProof("/api/board/sign", {action}, label);
|
const chosen = agents.length ? {agents} : {};
|
||||||
|
const signed = await signedProof("/api/board/sign", {action, ...chosen}, label);
|
||||||
if (signed) {
|
if (signed) {
|
||||||
await act(action, label, {proof: signed.proof});
|
await act(action, label, {proof: signed.proof, ...chosen});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +36,39 @@
|
|||||||
|
|
||||||
const picked = (group) => group.querySelector("input:checked").value;
|
const picked = (group) => group.querySelector("input:checked").value;
|
||||||
|
|
||||||
function actionButton(action, cls, name) {
|
function actionButton(action, cls, name, agents) {
|
||||||
const [, label, , hint] = actionOf(action);
|
const [, label, , hint] = actionOf(action);
|
||||||
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label);
|
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action, agents())}}, name ? icon(name) : null, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nightChosen() {
|
||||||
|
const agents = nightAgents().map(([sid]) => sid);
|
||||||
|
const on = agents.filter((sid) => !ui.nightOff.has(sid));
|
||||||
|
return on.length === agents.length ? [] : on;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPicks(toolbar) {
|
||||||
|
const boxes = [...toolbar.picks.querySelectorAll("input[data-session]")];
|
||||||
|
boxes.forEach((box) => {
|
||||||
|
box.checked = !ui.nightOff.has(box.dataset.session);
|
||||||
|
});
|
||||||
|
toolbar.picks.querySelector("input[data-all]").checked = boxes.every((box) => box.checked);
|
||||||
|
const none = boxes.length > 0 && boxes.every((box) => !box.checked);
|
||||||
|
toolbar.nightButtons.forEach((button) => {
|
||||||
|
button.disabled = none;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAgents(toolbar, sids, on) {
|
||||||
|
sids.forEach((sid) => (on ? ui.nightOff.delete(sid) : ui.nightOff.add(sid)));
|
||||||
|
syncPicks(toolbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPicks(toolbar) {
|
||||||
|
const agents = nightAgents();
|
||||||
|
const box = (label, attrs, sids) => h("label", {class: "check"}, h("input", {type: "checkbox", ...attrs, on: {change: (event) => pickAgents(toolbar, sids, event.target.checked)}}), label);
|
||||||
|
toolbar.picks.replaceChildren(box("All", {"data-all": true}, agents.map(([sid]) => sid)), ...agents.map(([sid, name]) => box(name, {"data-session": sid}, [sid])));
|
||||||
|
syncPicks(toolbar);
|
||||||
}
|
}
|
||||||
|
|
||||||
let spotDom = null;
|
let spotDom = null;
|
||||||
@@ -63,7 +98,7 @@
|
|||||||
gear.setAttribute("aria-expanded", "false");
|
gear.setAttribute("aria-expanded", "false");
|
||||||
gear.setAttribute("aria-controls", "radio-gear");
|
gear.setAttribute("aria-controls", "radio-gear");
|
||||||
const [, label, , hint] = actionOf("clear-mayday");
|
const [, label, , hint] = actionOf("clear-mayday");
|
||||||
const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday"), hint);
|
const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday", []), hint);
|
||||||
const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot},
|
const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot},
|
||||||
h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, radioSide(), gearBox));
|
h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, radioSide(), gearBox));
|
||||||
const toggle = document.getElementById("radio-open");
|
const toggle = document.getElementById("radio-open");
|
||||||
@@ -89,10 +124,12 @@
|
|||||||
|
|
||||||
function agentsToolbar() {
|
function agentsToolbar() {
|
||||||
const mode = h("span", {});
|
const mode = h("span", {});
|
||||||
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
|
const picks = h("div", {class: "night-picks", role: "group", "aria-label": "Agents"});
|
||||||
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
|
const nightButtons = ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small", null, nightChosen));
|
||||||
|
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode, picks,
|
||||||
|
h("div", {class: "actions"}, nightButtons)));
|
||||||
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night, statusMenu());
|
||||||
return {bar, mode};
|
return {bar, mode, picks, nightButtons, agents: ""};
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookBox(id, label, on, run) {
|
function lookBox(id, label, on, run) {
|
||||||
@@ -124,5 +161,11 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
||||||
toolbar.mode.replaceChildren(pill(mode, tone));
|
const alone = Object.values(state.night_parts).filter((part) => part !== "day").length;
|
||||||
|
toolbar.mode.replaceChildren(pill(mode, tone), alone ? pill(`${alone} alone`, "warn") : null);
|
||||||
|
const agents = JSON.stringify(nightAgents());
|
||||||
|
if (agents !== toolbar.agents) {
|
||||||
|
toolbar.agents = agents;
|
||||||
|
drawPicks(toolbar);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
|
||||||
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
|
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
|
||||||
{label: "Radio …", hint: "Post, Call, Newsletter, Broadcast, Pan-Pan, Security, Mayday; the gear holds tones and read aloud", run: openRadio},
|
{label: "Radio …", hint: "Post, Call, Newsletter, Broadcast, Pan-Pan, Security, Mayday; the gear holds tones and read aloud", run: openRadio},
|
||||||
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
|
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id, [])})),
|
||||||
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
|
||||||
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
|
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
|
||||||
{label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet},
|
{label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet},
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
const banner = document.getElementById("mayday");
|
const banner = document.getElementById("mayday");
|
||||||
const silence = state.silence;
|
const silence = state.silence;
|
||||||
banner.hidden = !silence;
|
banner.hidden = !silence;
|
||||||
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday"), "cancel: end the mayday (radio.py clear)")] : []));
|
banner.replaceChildren(...(silence ? [h("p", {class: "text"}, `MAYDAY · ${dash(silence.by)}: ${silence.text}`), iconBtn("bi-x-octagon", "cancel", "btn small", () => confirmAct("clear-mayday", []), "cancel: end the mayday (radio.py clear)")] : []));
|
||||||
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
document.title = `${state.badges.inbox ? `(${state.badges.inbox}) ` : ""}Agent Orchestrator`;
|
||||||
window.setTimeout(measure, 0);
|
window.setTimeout(measure, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
let state = JSON.parse(document.getElementById("state").textContent);
|
let state = JSON.parse(document.getElementById("state").textContent);
|
||||||
const KINDS = Object.entries(state.kinds);
|
const KINDS = Object.entries(state.kinds);
|
||||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, nightOff: new Set(), cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||||
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ body.fit .spot-host { display: flex; flex: 0 1 auto; flex-direction: column; min
|
|||||||
body.fit .radio-spot { flex: 0 1 auto; min-height: 0; }
|
body.fit .radio-spot { flex: 0 1 auto; min-height: 0; }
|
||||||
.radio-side { display: grid; gap: var(--sp-3); align-content: start; min-width: 0; }
|
.radio-side { display: grid; gap: var(--sp-3); align-content: start; min-width: 0; }
|
||||||
.spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
|
.spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
|
||||||
|
.night-picks { display: flex; flex-wrap: wrap; gap: 4px var(--sp-3); max-width: 420px; max-height: 30vh; overflow: auto; }
|
||||||
|
.night-picks:empty { display: none; }
|
||||||
|
.agent-night { align-items: center; margin: 0 0 var(--sp-2); }
|
||||||
.spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; }
|
.spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; }
|
||||||
.radio-gear { min-width: 0; }
|
.radio-gear { min-width: 0; }
|
||||||
.composer { display: grid; gap: var(--sp-2); }
|
.composer { display: grid; gap: var(--sp-2); }
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ description: >
|
|||||||
---
|
---
|
||||||
|
|
||||||
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`
|
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`
|
||||||
prints `you: drain`. On `you: active` or `mode: day` do nothing and say so.
|
prints `you: drain`, by day too when the operator drained this session alone.
|
||||||
|
On another `you:` line, or none, do nothing and say so.
|
||||||
The request asks for less, so it needs no operator approval.
|
The request asks for less, so it needs no operator approval.
|
||||||
|
|
||||||
Follow steps 2 to 4 of the `kvb-agent-suspend` skill (start nothing, end your
|
Follow steps 2 to 4 of the `kvb-agent-suspend` skill (start nothing, end your
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ description: >
|
|||||||
Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py
|
Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py
|
||||||
status`:
|
status`:
|
||||||
|
|
||||||
- **`mode: day`**: `kvb-org-resume` ended the night; resume.
|
- **No `you:` line, or `you: resumed`**: `kvb-org-resume` ended the night or
|
||||||
|
the operator lifted this session's part; resume.
|
||||||
- **`you: drain` or `you: suspend`**: only the operator, typing this command in
|
- **`you: drain` or `you: suspend`**: only the operator, typing this command in
|
||||||
this session, resumes it alone; a peer's request does not. Run `night.py
|
this session, resumes it alone; a peer's request does not. Run `night.py
|
||||||
resume --alone`: the others stay in the mode, and the night rules of `kvb-org-suspend`
|
resume --alone`: the others stay in the mode, and the night rules of `kvb-org-suspend`
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ description: >
|
|||||||
---
|
---
|
||||||
|
|
||||||
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`
|
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py status`
|
||||||
prints `you: suspend`. On `you: active` or `mode: day` do nothing and say so.
|
prints `you: suspend`, by day too when the operator suspended this session
|
||||||
|
alone. On another `you:` line, or none, do nothing and say so.
|
||||||
The request asks for less, so it needs no operator approval, and it ends a
|
The request asks for less, so it needs no operator approval, and it ends a
|
||||||
`kvb-org-robot` run at its next safe point; `kvb-agent-resume` resumes it.
|
`kvb-org-robot` run at its next safe point; `kvb-agent-resume` resumes it.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ When tokens or rate limits run short, the organisation narrows to one working
|
|||||||
agent: the session the operator types this in. Suspend is the hard step;
|
agent: the session the operator types this in. Suspend is the hard step;
|
||||||
`kvb-org-drain` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps
|
`kvb-org-drain` is the soft one. `${CLAUDE_SKILL_DIR}/scripts/night.py` keeps
|
||||||
the state for drain, suspend and resume; every agent verifies a request with
|
the state for drain, suspend and resume; every agent verifies a request with
|
||||||
`night.py status`.
|
`night.py status`. `--target=<session id>` on drain, suspend or resume sets or
|
||||||
|
lifts only that session's part, by day too, as the Agent Orchestrator's agent
|
||||||
|
picks do; every other session keeps its own.
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,23 @@ Environment:
|
|||||||
Args:
|
Args:
|
||||||
drain|suspend [--session <id>]: start the mode with the caller as the
|
drain|suspend [--session <id>]: start the mode with the caller as the
|
||||||
active session; prints every other live session as "<id>\t<name>".
|
active session; prints every other live session as "<id>\t<name>".
|
||||||
|
drain|suspend --target=<id> [--target=<id> ...] [--session <id>]: put
|
||||||
|
only these sessions into the part, by day too; the mode and every
|
||||||
|
other session keep theirs. Prints the targets as "<id>\t<name>".
|
||||||
status [--session <id>]: print the mode, the active session, since when,
|
status [--session <id>]: print the mode, the active session, since when,
|
||||||
and the caller's part (active, resumed, drain or suspend).
|
and the caller's part (active, resumed, drain or suspend); a session
|
||||||
resume [--session <id>]: end the mode; prints every other live session
|
without a part by day gets no "you:" line.
|
||||||
as "<id>\t<name>".
|
resume [--session <id>]: end the mode and every part; prints every other
|
||||||
|
live session as "<id>\t<name>".
|
||||||
|
resume --target=<id> [--target=<id> ...] [--session <id>]: lift these
|
||||||
|
sessions' parts; during a mode they work like the active session.
|
||||||
resume --alone [--session <id>]: resume the caller alone while the others
|
resume --alone [--session <id>]: resume the caller alone while the others
|
||||||
stay in the mode; it then works like the active session.
|
stay in the mode; it then works like the active session.
|
||||||
|
|
||||||
A missing, unreadable or invalid state means day: every agent works. A state
|
A missing, unreadable or invalid state means day: every agent works. A
|
||||||
written before the rename (mode evening or sunset, resumed sessions under
|
session's own part (night.json "parts") outranks the mode. A state written
|
||||||
awake) reads as drain or suspend.
|
before the rename (mode evening or sunset, resumed sessions under awake)
|
||||||
|
reads as drain or suspend.
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 done, 2 usage error.
|
0 done, 2 usage error.
|
||||||
@@ -61,6 +68,22 @@ def migrated(state: object) -> object:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def valid(state: object) -> bool:
|
||||||
|
if not isinstance(state, dict):
|
||||||
|
return False
|
||||||
|
parts = state.get("parts", {})
|
||||||
|
if not isinstance(parts, dict) or not all(isinstance(mode, str) and mode in PARTS for mode in parts.values()):
|
||||||
|
return False
|
||||||
|
if "mode" not in state:
|
||||||
|
return bool(parts)
|
||||||
|
return (
|
||||||
|
isinstance(state["mode"], str)
|
||||||
|
and state["mode"] in PARTS
|
||||||
|
and bool(state.get("active"))
|
||||||
|
and isinstance(state.get("resumed", []), list)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load() -> dict | None:
|
def load() -> dict | None:
|
||||||
try:
|
try:
|
||||||
state = migrated(json.loads(STATE.read_text(encoding="utf-8")))
|
state = migrated(json.loads(STATE.read_text(encoding="utf-8")))
|
||||||
@@ -68,18 +91,32 @@ def load() -> dict | None:
|
|||||||
return None
|
return None
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
state = None
|
state = None
|
||||||
if (
|
if not valid(state):
|
||||||
not isinstance(state, dict)
|
|
||||||
or not isinstance(state.get("mode"), str)
|
|
||||||
or state["mode"] not in PARTS
|
|
||||||
or not state.get("active")
|
|
||||||
or not isinstance(state.get("resumed", []), list)
|
|
||||||
):
|
|
||||||
print(f"night: {STATE} is unreadable or invalid; treated as day", file=sys.stderr)
|
print(f"night: {STATE} is unreadable or invalid; treated as day", file=sys.stderr)
|
||||||
return None
|
return None
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def part_of(state: dict, sid: str) -> str | None:
|
||||||
|
if sid in state.get("parts", {}):
|
||||||
|
return state["parts"][sid]
|
||||||
|
if "mode" not in state:
|
||||||
|
return None
|
||||||
|
if sid == state["active"]:
|
||||||
|
return "active"
|
||||||
|
if sid in state.get("resumed", []):
|
||||||
|
return "resumed"
|
||||||
|
return state["mode"]
|
||||||
|
|
||||||
|
|
||||||
|
def released(state: dict, sids: list[str]) -> dict:
|
||||||
|
state = {**state, "parts": {sid: mode for sid, mode in state.get("parts", {}).items() if sid not in sids}}
|
||||||
|
if "mode" in state:
|
||||||
|
resumed = state.get("resumed", [])
|
||||||
|
state["resumed"] = [*resumed, *(sid for sid in dict.fromkeys(sids) if sid != state["active"] and sid not in resumed)]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
def peers(live: dict[str, str | None], own: str) -> None:
|
def peers(live: dict[str, str | None], own: str) -> None:
|
||||||
for sid, name in sorted(live.items(), key=lambda item: (item[1] or "", item[0])):
|
for sid, name in sorted(live.items(), key=lambda item: (item[1] or "", item[0])):
|
||||||
if sid != own:
|
if sid != own:
|
||||||
@@ -89,6 +126,11 @@ def peers(live: dict[str, str | None], own: str) -> None:
|
|||||||
def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int:
|
def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int:
|
||||||
sid, _ = cn.caller(args.session)
|
sid, _ = cn.caller(args.session)
|
||||||
live = cn.live_sessions()
|
live = cn.live_sessions()
|
||||||
|
if args.target:
|
||||||
|
state = load() or {}
|
||||||
|
save({**state, "parts": {**state.get("parts", {}), **dict.fromkeys(args.target, args.command)}})
|
||||||
|
peers({target: live.get(target) for target in args.target}, "")
|
||||||
|
return 0
|
||||||
save(
|
save(
|
||||||
{
|
{
|
||||||
"mode": args.command,
|
"mode": args.command,
|
||||||
@@ -103,6 +145,9 @@ def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def save(state: dict) -> None:
|
def save(state: dict) -> None:
|
||||||
|
if "mode" not in state and not state.get("parts"):
|
||||||
|
STATE.unlink(missing_ok=True)
|
||||||
|
return
|
||||||
ROOT.mkdir(parents=True, exist_ok=True)
|
ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle:
|
with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle:
|
||||||
json.dump(state, handle)
|
json.dump(state, handle)
|
||||||
@@ -114,28 +159,21 @@ def resume_alone(sid: str) -> int:
|
|||||||
if state is None:
|
if state is None:
|
||||||
print("mode: day")
|
print("mode: day")
|
||||||
return 0
|
return 0
|
||||||
if sid != state["active"] and sid not in state.get("resumed", []):
|
save(released(state, [sid]))
|
||||||
state["resumed"] = [*state.get("resumed", []), sid]
|
|
||||||
save(state)
|
|
||||||
print(f"resumed: {sid}")
|
print(f"resumed: {sid}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_status(args: argparse.Namespace, _: ModuleType) -> int:
|
def cmd_status(args: argparse.Namespace, _: ModuleType) -> int:
|
||||||
state = load()
|
state = load() or {}
|
||||||
if state is None:
|
print(f"mode: {state.get('mode', 'day')}")
|
||||||
print("mode: day")
|
if "mode" in state:
|
||||||
return 0
|
|
||||||
print(f"mode: {state['mode']}")
|
|
||||||
print(f"active: {state.get('name') or '-'} {state['active']}")
|
print(f"active: {state.get('name') or '-'} {state['active']}")
|
||||||
print(f"since: {state.get('since') or '-'}")
|
print(f"since: {state.get('since') or '-'}")
|
||||||
sid = args.session or os.environ.get("CLAUDE_CODE_SESSION_ID")
|
sid = args.session or os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||||
if sid == state["active"]:
|
part = part_of(state, sid) if sid else None
|
||||||
print("you: active")
|
if part:
|
||||||
elif sid in state.get("resumed", []):
|
print(f"you: {PARTS.get(part, part)}")
|
||||||
print("you: resumed")
|
|
||||||
elif sid:
|
|
||||||
print(f"you: {PARTS[state['mode']]}")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -143,6 +181,10 @@ def cmd_resume(args: argparse.Namespace, cn: ModuleType) -> int:
|
|||||||
sid, _ = cn.caller(args.session)
|
sid, _ = cn.caller(args.session)
|
||||||
if args.alone:
|
if args.alone:
|
||||||
return resume_alone(sid)
|
return resume_alone(sid)
|
||||||
|
if args.target:
|
||||||
|
save(released(load() or {}, args.target))
|
||||||
|
peers({target: cn.live_sessions().get(target) for target in args.target}, "")
|
||||||
|
return 0
|
||||||
STATE.unlink(missing_ok=True)
|
STATE.unlink(missing_ok=True)
|
||||||
peers(cn.live_sessions(), sid)
|
peers(cn.live_sessions(), sid)
|
||||||
return 0
|
return 0
|
||||||
@@ -152,11 +194,15 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
for mode in PARTS:
|
for mode in PARTS:
|
||||||
sub.add_parser(mode).set_defaults(func=cmd_set)
|
p = sub.add_parser(mode)
|
||||||
|
p.add_argument("--target", action="append")
|
||||||
|
p.set_defaults(func=cmd_set)
|
||||||
p = sub.add_parser("status")
|
p = sub.add_parser("status")
|
||||||
p.set_defaults(func=cmd_status)
|
p.set_defaults(func=cmd_status)
|
||||||
p = sub.add_parser("resume")
|
p = sub.add_parser("resume")
|
||||||
p.add_argument("--alone", action="store_true")
|
alone = p.add_mutually_exclusive_group()
|
||||||
|
alone.add_argument("--alone", action="store_true")
|
||||||
|
alone.add_argument("--target", action="append")
|
||||||
p.set_defaults(func=cmd_resume)
|
p.set_defaults(func=cmd_resume)
|
||||||
for p in sub.choices.values():
|
for p in sub.choices.values():
|
||||||
p.add_argument("--session")
|
p.add_argument("--session")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ sys.path.insert(0, str(HERE.parent))
|
|||||||
from hub_case import HubCase
|
from hub_case import HubCase
|
||||||
from hub_engines import wav_bytes
|
from hub_engines import wav_bytes
|
||||||
from hub_support import ASK, HOST_STATE, OPTIONS, RUNNER, iso, verifier
|
from hub_support import ASK, HOST_STATE, OPTIONS, RUNNER, iso, verifier
|
||||||
from kvb_orchestrator import skill_scripts
|
from kvb_orchestrator import messaging, skill_scripts
|
||||||
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
from test_hub_cleanup import DONE_TIP, FAKE_PKGMGR, host
|
||||||
from test_hub_offices import COUNCIL_SCRIPT
|
from test_hub_offices import COUNCIL_SCRIPT
|
||||||
from test_hub_offices import host as office_host
|
from test_hub_offices import host as office_host
|
||||||
@@ -677,11 +677,61 @@ NIGHT_LOOK = """(async () => {@HELPERS@
|
|||||||
return {
|
return {
|
||||||
children: [...box.children].map((node) => [node.tagName.toLowerCase(), node.className]),
|
children: [...box.children].map((node) => [node.tagName.toLowerCase(), node.className]),
|
||||||
badge: all(".pill", box).map(text),
|
badge: all(".pill", box).map(text),
|
||||||
|
picks: all(".night-picks label", box).map((label) => [text(label), label.querySelector("input").checked]),
|
||||||
buttons: all("button", box).map((button) => [name(button), button.title]),
|
buttons: all("button", box).map((button) => [name(button), button.title]),
|
||||||
paragraphs: all("p", box).length,
|
paragraphs: all("p", box).length,
|
||||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||||
};
|
};
|
||||||
})()""".replace("@HELPERS@", HELPERS)
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
|
NIGHT_PICK = """(async () => {@HELPERS@
|
||||||
|
const questions = [];
|
||||||
|
window.confirm = (question) => {
|
||||||
|
questions.push(question);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
navigator.credentials.get = @SIGNS@ ? navigator.credentials.get.bind(navigator.credentials) : () => Promise.reject(new DOMException("The operator canceled.", "NotAllowedError"));
|
||||||
|
const posted = [];
|
||||||
|
const send = window.fetch.bind(window);
|
||||||
|
window.fetch = (path, init) => {
|
||||||
|
if (String(path).startsWith("/api/board/")) {
|
||||||
|
const body = JSON.parse(init.body);
|
||||||
|
posted.push([String(path), Object.keys(body), body.action || null, body.agents || null]);
|
||||||
|
}
|
||||||
|
return send(path, init);
|
||||||
|
};
|
||||||
|
const menu = await until(() => all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Day/Night"), 8000);
|
||||||
|
if (!menu) {
|
||||||
|
return {error: text(document.getElementById("main"))};
|
||||||
|
}
|
||||||
|
menu.open = true;
|
||||||
|
await sleep(200);
|
||||||
|
const box = menu.querySelector(".menu-box");
|
||||||
|
const picks = () => all(".night-picks label", box).map((label) => [text(label), label.querySelector("input").checked]);
|
||||||
|
const enabled = () => all(".actions button", box).map((button) => !button.disabled);
|
||||||
|
const tick = (label) => all(".night-picks label", box).find((node) => text(node) === label).querySelector("input").click();
|
||||||
|
const fresh = async (label, click) => {
|
||||||
|
const shown = new Set(all("#toasts .toast"));
|
||||||
|
click();
|
||||||
|
return until(() => all("#toasts .toast").filter((node) => !shown.has(node)).map(text).find((entry) => entry.startsWith(`${label}:`)), 15000);
|
||||||
|
};
|
||||||
|
const found = {badge: all(".pill", box).map(text), start: picks()};
|
||||||
|
tick("Skills");
|
||||||
|
found.one = [picks(), enabled()];
|
||||||
|
found.drain = await fresh("Drain", () => all(".actions button", box).find((button) => name(button) === "Drain").click());
|
||||||
|
found.kept = picks();
|
||||||
|
tick("Stage");
|
||||||
|
found.none = [picks(), enabled()];
|
||||||
|
tick("All");
|
||||||
|
found.all = [picks(), enabled()];
|
||||||
|
menu.open = false;
|
||||||
|
document.getElementById("palette-open").click();
|
||||||
|
const item = await until(() => all(".palette-list li").find((li) => text(li.querySelector("span")) === "Stage"), 4000);
|
||||||
|
item.click();
|
||||||
|
const row = await until(() => document.querySelector("#popup .agent-night"), 4000);
|
||||||
|
found.popup = {badge: all(".pill", row).map(text), buttons: all("button", row).map(name)};
|
||||||
|
found.suspend = await fresh("Suspend", () => all("button", row).find((button) => name(button) === "Suspend").click());
|
||||||
|
return {...found, questions, posted, wide: document.documentElement.scrollWidth > window.innerWidth + 1};
|
||||||
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
FITS = "tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1"
|
FITS = "tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1"
|
||||||
SPOT_SEND = """(async () => {@HELPERS@
|
SPOT_SEND = """(async () => {@HELPERS@
|
||||||
const questions = [];
|
const questions = [];
|
||||||
@@ -1950,8 +2000,9 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
for look, (width, _) in zip(seen, WIDTHS, strict=True):
|
for look, (width, _) in zip(seen, WIDTHS, strict=True):
|
||||||
with self.subTest(browser=browser, width=width):
|
with self.subTest(browser=browser, width=width):
|
||||||
self.assertNotIn("error", look)
|
self.assertNotIn("error", look)
|
||||||
self.assertEqual(look["children"], [["span", ""], ["div", "actions"]])
|
self.assertEqual(look["children"], [["span", ""], ["div", "night-picks"], ["div", "actions"]])
|
||||||
self.assertEqual((look["badge"], look["paragraphs"], look["wide"]), (["Day"], 0, False))
|
self.assertEqual((look["badge"], look["paragraphs"], look["wide"]), (["Day"], 0, False))
|
||||||
|
self.assertEqual(look["picks"], [["All", True], ["Skills", True], ["Stage", True]])
|
||||||
self.assertEqual(look["buttons"], [list(entry) for entry in NIGHT_HINTS])
|
self.assertEqual(look["buttons"], [list(entry) for entry in NIGHT_HINTS])
|
||||||
self.assertNotIn("error", found)
|
self.assertNotIn("error", found)
|
||||||
self.assertEqual(found["labels"], ["Drain", "Suspend", "Resume"])
|
self.assertEqual(found["labels"], ["Drain", "Suspend", "Resume"])
|
||||||
@@ -1979,6 +2030,39 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
def test_firefox_asks_the_passkey_for_drain_suspend_and_resume_and_runs_nothing_unsigned(self):
|
def test_firefox_asks_the_passkey_for_drain_suspend_and_resume_and_runs_nothing_unsigned(self):
|
||||||
self.check_night_menu("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
|
self.check_night_menu("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
|
||||||
|
|
||||||
|
def check_night_picks(self, browser: str, driver: str, binary: str, signs: bool) -> None:
|
||||||
|
"""Args:
|
||||||
|
signs: as in check_night_menu.
|
||||||
|
"""
|
||||||
|
self.write_council({"night.json": {"parts": {"s2": "drain"}}})
|
||||||
|
probe = NIGHT_PICK.replace("@SIGNS@", "true" if signs else "false")
|
||||||
|
steps = [{"width": width, "height": height, "url": f"http://localhost:{self.port}/?{width}#agents/orbit", "wait": 1200, "probe": probe, "shot": str(OUT / f"{browser}-{width}-night-picks.png")} for width, height in WIDTHS]
|
||||||
|
data = self.drive(steps, self.authenticator() if signs else None, driver, binary)
|
||||||
|
(OUT / f"{browser}-night-picks.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
|
||||||
|
everyone, one, nobody = [["All", True], ["Skills", True], ["Stage", True]], [["All", False], ["Skills", False], ["Stage", True]], [["All", False], ["Skills", False], ["Stage", False]]
|
||||||
|
for found, (width, _) in zip(data["results"], WIDTHS, strict=True):
|
||||||
|
with self.subTest(browser=browser, width=width):
|
||||||
|
self.assertNotIn("error", found)
|
||||||
|
self.assertEqual((found["badge"], found["start"], found["wide"]), (["Day", "1 alone"], everyone, False))
|
||||||
|
self.assertEqual((found["one"], found["kept"], found["none"], found["all"]), ([one, [True] * 3], one, [nobody, [False] * 3], [everyone, [True] * 3]))
|
||||||
|
self.assertEqual(found["popup"], {"badge": ["Draining"], "buttons": ["Drain", "Suspend", "Resume"]})
|
||||||
|
self.assertEqual([question.split(":")[0] for question in found["questions"]], ["Drain Stage", "Suspend Stage"])
|
||||||
|
sent = [["/api/board/sign", ["action", "agents"], "drain", ["s2"]], ["/api/board/drain", ["proof", "agents"], None, ["s2"]], ["/api/board/sign", ["action", "agents"], "suspend", ["s2"]], ["/api/board/suspend", ["proof", "agents"], None, ["s2"]]]
|
||||||
|
self.assertEqual(found["posted"], sent if signs else sent[::2])
|
||||||
|
self.assertEqual([found["drain"].split("$")[0].split()[1], found["suspend"].split("$")[0].split()[1]], ["done", "done"] if signs else ["not", "not"])
|
||||||
|
nights = [args for script, args in self.scripts.calls if script == skill_scripts.NIGHT]
|
||||||
|
self.assertEqual(nights, [[action, "--session", "Operator", "--target=s2"] for _ in WIDTHS for action in ("drain", "suspend")] if signs else [])
|
||||||
|
calls = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
|
||||||
|
self.assertEqual(calls, [["send", "call", "--to", "Stage", "--session", "Operator", messaging.ALONE[action]] for _ in WIDTHS for action in ("drain", "suspend")] if signs else [])
|
||||||
|
|
||||||
|
def test_chromium_drains_and_suspends_chosen_agents_from_the_menu_and_the_agent_panel_at_three_widths(self):
|
||||||
|
self.check_night_picks("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), signs=True)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||||
|
def test_firefox_asks_the_passkey_for_chosen_agents_and_runs_nothing_unsigned_at_three_widths(self):
|
||||||
|
self.check_night_picks("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
|
||||||
|
|
||||||
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
||||||
tree = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
tree = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||||||
tree["delete_ready"], tree["delete_reason"] = True, ""
|
tree["delete_ready"], tree["delete_reason"] = True, ""
|
||||||
|
|||||||
+2
-2
@@ -136,8 +136,8 @@ class HubCase(unittest.TestCase):
|
|||||||
self.assertEqual(status, 200, body)
|
self.assertEqual(status, 200, body)
|
||||||
return signer.proof(json.loads(body)["payload"], self.origin)
|
return signer.proof(json.loads(body)["payload"], self.origin)
|
||||||
|
|
||||||
def board_proof(self, action: str, signer: Key) -> dict:
|
def board_proof(self, action: str, signer: Key, agents: list[str] | None = None) -> dict:
|
||||||
status, _, body = self.post_json("/api/board/sign", {"action": action}, self.guard(self.host))
|
status, _, body = self.post_json("/api/board/sign", {"action": action, **({} if agents is None else {"agents": agents})}, self.guard(self.host))
|
||||||
self.assertEqual(status, 200, body)
|
self.assertEqual(status, 200, body)
|
||||||
return signer.proof(json.loads(body)["payload"], self.origin)
|
return signer.proof(json.loads(body)["payload"], self.origin)
|
||||||
|
|
||||||
|
|||||||
+52
-1
@@ -12,7 +12,7 @@ from unittest import mock
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from hub_case import HubCase
|
from hub_case import HubCase
|
||||||
from hub_support import ASK, HOST_STATE, REPO_ROOT, iso, key, server
|
from hub_support import ASK, HOST_STATE, REPO_ROOT, iso, key, server, signing
|
||||||
from kvb_orchestrator import handler, messaging, skill_scripts, transcripts
|
from kvb_orchestrator import handler, messaging, skill_scripts, transcripts
|
||||||
|
|
||||||
|
|
||||||
@@ -153,6 +153,53 @@ class TestHubBoard(HubCase):
|
|||||||
self.assertEqual(self.get("/api/board/sign")[0], 405)
|
self.assertEqual(self.get("/api/board/sign")[0], 405)
|
||||||
self.assertEqual(len(self.scripts.calls), 1)
|
self.assertEqual(len(self.scripts.calls), 1)
|
||||||
|
|
||||||
|
def test_a_night_action_for_chosen_agents_signs_them_and_calls_only_them(self):
|
||||||
|
self.claim("s1", "Skills session")
|
||||||
|
self.claim("s2", "Gatekeeper")
|
||||||
|
signer = self.pin()
|
||||||
|
good = self.guard(self.host)
|
||||||
|
|
||||||
|
def board(action: str, payload: dict) -> tuple[int, bytes]:
|
||||||
|
status, _, body = self.post_json(f"/api/board/{action}", payload, good)
|
||||||
|
return status, body
|
||||||
|
|
||||||
|
self.assertEqual(signing.board_item("drain", []), {"kind": "board", "action": "drain"})
|
||||||
|
proof = self.board_proof("drain", signer, ["s2"])
|
||||||
|
self.assertEqual(board("drain", {"proof": proof})[1], b"not signed: the signature does not list this item\n")
|
||||||
|
self.assertEqual(board("drain", {"proof": self.board_proof("drain", signer), "agents": ["s2"]})[1], b"not signed: the signature does not list this item\n")
|
||||||
|
self.assertEqual(board("drain", {"proof": proof, "agents": ["s2", "s1"]})[1], b"not signed: the signature does not list this item\n")
|
||||||
|
self.assertEqual(self.scripts.calls, [])
|
||||||
|
self.assertEqual(board("drain", {"proof": proof, "agents": ["s2"]})[0], 200)
|
||||||
|
self.assertEqual(self.scripts.calls, [
|
||||||
|
(skill_scripts.NIGHT, ["drain", "--session", "Operator", "--target=s2"]),
|
||||||
|
(skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--session", "Operator", messaging.ALONE["drain"]]),
|
||||||
|
])
|
||||||
|
self.assertIn("run kvb-agent-drain;", messaging.ALONE["drain"])
|
||||||
|
self.assertEqual(board("resume", {"proof": self.board_proof("resume", signer, ["s2", "s1"]), "agents": ["s1", "s2"]})[0], 200)
|
||||||
|
self.assertEqual(self.scripts.calls[-2:], [
|
||||||
|
(skill_scripts.NIGHT, ["resume", "--session", "Operator", "--target=s1", "--target=s2"]),
|
||||||
|
(skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.ALONE["resume"]]),
|
||||||
|
])
|
||||||
|
calls = len(self.scripts.calls)
|
||||||
|
for action, agents in (("drain", ["s9"]), ("drain", ["s2", "s2"]), ("drain", "s2"), ("drain", [2]), ("drain", ["Operator"]), ("clear-mayday", ["s2"]), ("estimates", ["s1"])):
|
||||||
|
with self.subTest(action=action, agents=agents):
|
||||||
|
self.assertEqual(self.post_json("/api/board/sign", {"action": action, "agents": agents}, good)[0], 400)
|
||||||
|
self.assertEqual(board(action, {"proof": None, "agents": agents})[0], 400)
|
||||||
|
self.assertEqual(len(self.scripts.calls), calls)
|
||||||
|
|
||||||
|
def test_the_state_shows_each_sessions_own_part(self):
|
||||||
|
for night, parts in (
|
||||||
|
({"parts": {"s2": "drain", "s3": "noon"}}, {"s2": "drain"}),
|
||||||
|
({"mode": "drain", "active": "Operator", "resumed": ["s1"], "parts": {"s1": "suspend"}}, {"Operator": "day", "s1": "suspend"}),
|
||||||
|
({"mode": "sunset", "active": "Operator", "awake": ["s3"]}, {"Operator": "day", "s3": "day"}),
|
||||||
|
({"active": "Operator", "resumed": ["s1"]}, {}),
|
||||||
|
({"mode": "drain", "parts": ["s1"], "resumed": "s1"}, {}),
|
||||||
|
(["drain"], {}),
|
||||||
|
):
|
||||||
|
with self.subTest(night=night):
|
||||||
|
self.write_council({"night.json": night})
|
||||||
|
self.assertEqual(self.snapshot()["night_parts"], parts)
|
||||||
|
|
||||||
def test_every_night_action_is_a_command_the_shipped_night_script_takes(self):
|
def test_every_night_action_is_a_command_the_shipped_night_script_takes(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
scripts = skill_scripts.Scripts(skills=REPO_ROOT / "skills", council=Path(tmp) / "council", home=Path(tmp))
|
scripts = skill_scripts.Scripts(skills=REPO_ROOT / "skills", council=Path(tmp) / "council", home=Path(tmp))
|
||||||
@@ -162,6 +209,10 @@ class TestHubBoard(HubCase):
|
|||||||
self.assertEqual(run["code"], 0, run["stderr"])
|
self.assertEqual(run["code"], 0, run["stderr"])
|
||||||
status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "Operator"])["stdout"].splitlines()
|
status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "Operator"])["stdout"].splitlines()
|
||||||
self.assertEqual(status[0], "mode: day" if action == "resume" else f"mode: {action}")
|
self.assertEqual(status[0], "mode: day" if action == "resume" else f"mode: {action}")
|
||||||
|
run = scripts.run(skill_scripts.NIGHT, [*command, "--session", "Operator", "--target=s2"])
|
||||||
|
self.assertEqual(run["code"], 0, run["stderr"])
|
||||||
|
status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "s2"])["stdout"].splitlines()
|
||||||
|
self.assertEqual(status[-1].startswith(f"you: {action}"), action != "resume", status)
|
||||||
|
|
||||||
def test_nl_actions_are_validated_against_a_faked_endpoint(self):
|
def test_nl_actions_are_validated_against_a_faked_endpoint(self):
|
||||||
status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host))
|
status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host))
|
||||||
|
|||||||
@@ -85,6 +85,38 @@ class TestNight(unittest.TestCase):
|
|||||||
self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"])
|
self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"])
|
||||||
self.assertFalse((self.root / "night.json").exists())
|
self.assertFalse((self.root / "night.json").exists())
|
||||||
|
|
||||||
|
def test_targets_take_a_part_alone_by_day_and_resume_lifts_only_theirs(self):
|
||||||
|
self.assertEqual(self._out("drain", "--target=s2", "--target=s3"), ["s3\tThree", "s2\tTwo"])
|
||||||
|
self._out("suspend", "--target=s3")
|
||||||
|
self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8")), {"parts": {"s2": "drain", "s3": "suspend"}})
|
||||||
|
self.assertEqual(self._out("status", sid="s2"), ["mode: day", "you: drain (kvb-agent-drain)"])
|
||||||
|
self.assertEqual(self._out("status", sid="s3"), ["mode: day", "you: suspend (kvb-agent-suspend)"])
|
||||||
|
self.assertEqual(self._out("status"), ["mode: day"])
|
||||||
|
self.assertEqual(self._out("resume", "--target=s2"), ["s2\tTwo"])
|
||||||
|
self.assertEqual(self._out("status", sid="s2"), ["mode: day"])
|
||||||
|
self.assertEqual(self._out("resume", "--alone", sid="s3"), ["resumed: s3"])
|
||||||
|
self.assertFalse((self.root / "night.json").exists())
|
||||||
|
|
||||||
|
def test_a_part_outranks_the_mode_and_a_targeted_resume_joins_the_resumed(self):
|
||||||
|
self._out("drain")
|
||||||
|
self._out("suspend", "--target=s2")
|
||||||
|
state = json.loads((self.root / "night.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((state["mode"], state["active"], state["parts"]), ("drain", "s1", {"s2": "suspend"}))
|
||||||
|
self.assertEqual(self._out("status", sid="s2")[3], "you: suspend (kvb-agent-suspend)")
|
||||||
|
self.assertEqual(self._out("status", sid="s3")[3], "you: drain (kvb-agent-drain)")
|
||||||
|
self._out("resume", "--target=s2", "--target=s3", "--target=s1")
|
||||||
|
state = json.loads((self.root / "night.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((state["mode"], state["parts"], state["resumed"]), ("drain", {}, ["s2", "s3"]))
|
||||||
|
self.assertEqual([self._out("status", sid=sid)[3] for sid in ("s1", "s2", "s3")], ["you: active", "you: resumed", "you: resumed"])
|
||||||
|
self._out("suspend", "--target=s3")
|
||||||
|
self._out("resume", sid="s2")
|
||||||
|
self.assertFalse((self.root / "night.json").exists())
|
||||||
|
|
||||||
|
def test_a_target_never_parses_as_an_option(self):
|
||||||
|
self._out("drain", "--target=--alone")
|
||||||
|
self.assertEqual(json.loads((self.root / "night.json").read_text(encoding="utf-8")), {"parts": {"--alone": "drain"}})
|
||||||
|
self.assertEqual(self._run("resume", "--alone", "--target=s2").returncode, 2)
|
||||||
|
|
||||||
def test_bad_state_means_day(self):
|
def test_bad_state_means_day(self):
|
||||||
self.root.mkdir()
|
self.root.mkdir()
|
||||||
for text in (
|
for text in (
|
||||||
@@ -94,6 +126,11 @@ class TestNight(unittest.TestCase):
|
|||||||
json.dumps(["suspend"]),
|
json.dumps(["suspend"]),
|
||||||
json.dumps({"mode": "suspend", "active": "s1", "resumed": "s2"}),
|
json.dumps({"mode": "suspend", "active": "s1", "resumed": "s2"}),
|
||||||
json.dumps({"mode": "sunset", "active": "s1", "awake": "s2"}),
|
json.dumps({"mode": "sunset", "active": "s1", "awake": "s2"}),
|
||||||
|
json.dumps({"parts": {}}),
|
||||||
|
json.dumps({"parts": {"s2": "noon"}}),
|
||||||
|
json.dumps({"parts": {"s2": ["drain"]}}),
|
||||||
|
json.dumps({"parts": ["s2"]}),
|
||||||
|
json.dumps({"mode": "drain", "active": "s1", "parts": {"s2": "evening"}}),
|
||||||
):
|
):
|
||||||
with self.subTest(text=text):
|
with self.subTest(text=text):
|
||||||
(self.root / "night.json").write_text(text, encoding="utf-8")
|
(self.root / "night.json").write_text(text, encoding="utf-8")
|
||||||
|
|||||||
Reference in new issue
Block a user