diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 0d16224..09bdb9f 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -362,6 +362,15 @@ from three views down to any agent, branch or artefact. no passkey. Rejected: hub-wide settings with a signature. 64. **3D faces**: agent nodes in the 3D view show their face, on by default 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 @@ -826,6 +835,20 @@ flowchart TD - [ ] `make orchestrator-install` enables `kvb-orchestrator-sessions.timer`, and `make orchestrator-check` reports it active. +### Phase 6: Day/Night per agent (S5.2) + +- [ ] `night.py drain|suspend|resume --target=` 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 - **Unit** @@ -857,6 +880,9 @@ flowchart TD receipts, a session without a record; the host client's snapshot from fixture session files with a reused pid; a stale or malformed 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** - [ ] Two scratch sessions exchange `post` and `call` through the real hook 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 menu toggles through a reload, 3D faces on and off, in Chromium and 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. diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index 5579537..6cbce2b 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -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} -def board_item(action: str) -> dict: - return {"kind": "board", "action": action} +def board_item(action: str, agents: list[str]) -> dict: + """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: diff --git a/orchestrator/kvb_orchestrator/messaging.py b/orchestrator/kvb_orchestrator/messaging.py index 2e1689b..78b2c4f 100644 --- a/orchestrator/kvb_orchestrator/messaging.py +++ b/orchestrator/kvb_orchestrator/messaging.py @@ -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."), "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._-]+") DIRECT = {"post": ["post"], "call": ["send", "call"]} 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") +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]: cls = payload["class"] if not isinstance(cls, str) or cls not in choices: @@ -176,16 +200,21 @@ class Messaging: def sign_board(self, body: bytes) -> dict: payload = decode(body) - expect(payload, ("action",)) - if payload["action"] not in SIGNED_BOARD: + action = payload.get("action") + if action not in 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: + claims = read_claims(self.council) + agents = [] if action in SIGNED_BOARD: payload = decode(body) + agents = night_targets(action, payload, claims) 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": runs = [self.scripts.run(RADIO, ["clear", "--force", "--session", HUMAN])] elif action == "broadcast": @@ -212,8 +241,10 @@ class Messaging: runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])] elif action in NIGHTS: command, text = NIGHTS[action] - runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])] - peers = ordered({claim["address"] for claim in read_claims(self.council) if claim["address"] and claim["agent"] != HUMAN}) + if agents: + 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: targets = [part for peer in peers for part in ("--to", peer)] runs.append(self.scripts.run(RADIO, [*DIRECT["call"], *targets, "--session", HUMAN, text])) diff --git a/orchestrator/templates/page-agent.js b/orchestrator/templates/page-agent.js index 90cea5a..fcf31c3 100644 --- a/orchestrator/templates/page-agent.js +++ b/orchestrator/templates/page-agent.js @@ -14,6 +14,8 @@ const node = state.graph.nodes.find((entry) => entry.id === ui.agent); const tasks = node ? node.tasks : []; 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( ...[presenceMark(ui.agent, true)].filter(Boolean), 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 log = h("div", {"aria-live": "polite"}); 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); openPopup(name, [ 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, ], anchor, () => { agentDom = null; @@ -122,7 +128,7 @@ } }); ui.agent = key; - agentDom = {info, history: log}; + agentDom = {info, history: log, night}; drawAgent(); loadSubagents(key, subs, life); text.focus(); diff --git a/orchestrator/templates/page-board.js b/orchestrator/templates/page-board.js index 09a989c..0a15d1b 100644 --- a/orchestrator/templates/page-board.js +++ b/orchestrator/templates/page-board.js @@ -13,14 +13,19 @@ 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); - 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; } - 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) { - 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; - function actionButton(action, cls, name) { + function actionButton(action, cls, name, agents) { 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; @@ -63,7 +98,7 @@ gear.setAttribute("aria-expanded", "false"); gear.setAttribute("aria-controls", "radio-gear"); 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}, 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"); @@ -89,10 +124,12 @@ function agentsToolbar() { 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, - h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small"))))); + const picks = h("div", {class: "night-picks", role: "group", "aria-label": "Agents"}); + 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()); - return {bar, mode}; + return {bar, mode, picks, nightButtons, agents: ""}; } function lookBox(id, label, on, run) { @@ -124,5 +161,11 @@ return; } 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); + } } diff --git a/orchestrator/templates/page-palette.js b/orchestrator/templates/page-palette.js index cd2e21c..8ef7af3 100644 --- a/orchestrator/templates/page-palette.js +++ b/orchestrator/templates/page-palette.js @@ -6,7 +6,7 @@ ...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)})), {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: "Firewall …", hint: "automatic grants", run: () => go("rules", null)}, {label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet}, diff --git a/orchestrator/templates/page-screens.js b/orchestrator/templates/page-screens.js index 89a855b..e7566ca 100644 --- a/orchestrator/templates/page-screens.js +++ b/orchestrator/templates/page-screens.js @@ -99,7 +99,7 @@ const banner = document.getElementById("mayday"); const silence = state.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`; window.setTimeout(measure, 0); } diff --git a/orchestrator/templates/page-state.js b/orchestrator/templates/page-state.js index 2fa8eff..941ce8b 100644 --- a/orchestrator/templates/page-state.js +++ b/orchestrator/templates/page-state.js @@ -32,7 +32,7 @@ let state = JSON.parse(document.getElementById("state").textContent); 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 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}; diff --git a/orchestrator/templates/style-ui.css b/orchestrator/templates/style-ui.css index c337c14..ea6e9a4 100644 --- a/orchestrator/templates/style-ui.css +++ b/orchestrator/templates/style-ui.css @@ -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; } .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); } +.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; } .radio-gear { min-width: 0; } .composer { display: grid; gap: var(--sp-2); } diff --git a/skills/kvb-agent-drain/SKILL.md b/skills/kvb-agent-drain/SKILL.md index bd18610..4e96fdb 100644 --- a/skills/kvb-agent-drain/SKILL.md +++ b/skills/kvb-agent-drain/SKILL.md @@ -6,7 +6,8 @@ description: > --- 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. Follow steps 2 to 4 of the `kvb-agent-suspend` skill (start nothing, end your diff --git a/skills/kvb-agent-resume/SKILL.md b/skills/kvb-agent-resume/SKILL.md index a028fde..4e15fa1 100644 --- a/skills/kvb-agent-resume/SKILL.md +++ b/skills/kvb-agent-resume/SKILL.md @@ -8,7 +8,8 @@ description: > Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-suspend/scripts/night.py 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 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` diff --git a/skills/kvb-agent-suspend/SKILL.md b/skills/kvb-agent-suspend/SKILL.md index 401112e..018a30d 100644 --- a/skills/kvb-agent-suspend/SKILL.md +++ b/skills/kvb-agent-suspend/SKILL.md @@ -6,7 +6,8 @@ description: > --- 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 `kvb-org-robot` run at its next safe point; `kvb-agent-resume` resumes it. diff --git a/skills/kvb-org-suspend/SKILL.md b/skills/kvb-org-suspend/SKILL.md index 4d05d81..af38e0c 100644 --- a/skills/kvb-org-suspend/SKILL.md +++ b/skills/kvb-org-suspend/SKILL.md @@ -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; `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 -`night.py status`. +`night.py status`. `--target=` 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 diff --git a/skills/kvb-org-suspend/scripts/night.py b/skills/kvb-org-suspend/scripts/night.py index 97e59e2..d27a7a5 100755 --- a/skills/kvb-org-suspend/scripts/night.py +++ b/skills/kvb-org-suspend/scripts/night.py @@ -9,16 +9,23 @@ Environment: Args: drain|suspend [--session ]: start the mode with the caller as the active session; prints every other live session as "\t". + drain|suspend --target= [--target= ...] [--session ]: put + only these sessions into the part, by day too; the mode and every + other session keep theirs. Prints the targets as "\t". status [--session ]: print the mode, the active session, since when, - and the caller's part (active, resumed, drain or suspend). - resume [--session ]: end the mode; prints every other live session - as "\t". + and the caller's part (active, resumed, drain or suspend); a session + without a part by day gets no "you:" line. + resume [--session ]: end the mode and every part; prints every other + live session as "\t". + resume --target= [--target= ...] [--session ]: lift these + sessions' parts; during a mode they work like the active session. resume --alone [--session ]: resume the caller alone while the others stay in the mode; it then works like the active session. -A missing, unreadable or invalid state means day: every agent works. A state -written before the rename (mode evening or sunset, resumed sessions under -awake) reads as drain or suspend. +A missing, unreadable or invalid state means day: every agent works. A +session's own part (night.json "parts") outranks the mode. A state written +before the rename (mode evening or sunset, resumed sessions under awake) +reads as drain or suspend. Exit codes: 0 done, 2 usage error. @@ -61,6 +68,22 @@ def migrated(state: object) -> object: 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: try: state = migrated(json.loads(STATE.read_text(encoding="utf-8"))) @@ -68,18 +91,32 @@ def load() -> dict | None: return None except (OSError, ValueError): state = None - if ( - 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) - ): + if not valid(state): print(f"night: {STATE} is unreadable or invalid; treated as day", file=sys.stderr) return None 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: for sid, name in sorted(live.items(), key=lambda item: (item[1] or "", item[0])): 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: sid, _ = cn.caller(args.session) 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( { "mode": args.command, @@ -103,6 +145,9 @@ def cmd_set(args: argparse.Namespace, cn: ModuleType) -> int: 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) with tempfile.NamedTemporaryFile("w", dir=ROOT, delete=False, encoding="utf-8") as handle: json.dump(state, handle) @@ -114,28 +159,21 @@ def resume_alone(sid: str) -> int: if state is None: print("mode: day") return 0 - if sid != state["active"] and sid not in state.get("resumed", []): - state["resumed"] = [*state.get("resumed", []), sid] - save(state) + save(released(state, [sid])) print(f"resumed: {sid}") return 0 def cmd_status(args: argparse.Namespace, _: ModuleType) -> int: - state = load() - if state is None: - print("mode: day") - return 0 - print(f"mode: {state['mode']}") - print(f"active: {state.get('name') or '-'} {state['active']}") - print(f"since: {state.get('since') or '-'}") + state = load() or {} + print(f"mode: {state.get('mode', 'day')}") + if "mode" in state: + print(f"active: {state.get('name') or '-'} {state['active']}") + print(f"since: {state.get('since') or '-'}") sid = args.session or os.environ.get("CLAUDE_CODE_SESSION_ID") - if sid == state["active"]: - print("you: active") - elif sid in state.get("resumed", []): - print("you: resumed") - elif sid: - print(f"you: {PARTS[state['mode']]}") + part = part_of(state, sid) if sid else None + if part: + print(f"you: {PARTS.get(part, part)}") return 0 @@ -143,6 +181,10 @@ def cmd_resume(args: argparse.Namespace, cn: ModuleType) -> int: sid, _ = cn.caller(args.session) if args.alone: 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) peers(cn.live_sessions(), sid) return 0 @@ -152,11 +194,15 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) sub = parser.add_subparsers(dest="command", required=True) 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.set_defaults(func=cmd_status) 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) for p in sub.choices.values(): p.add_argument("--session") diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index ca5709a..dee5d41 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -26,7 +26,7 @@ sys.path.insert(0, str(HERE.parent)) from hub_case import HubCase from hub_engines import wav_bytes 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_offices import COUNCIL_SCRIPT from test_hub_offices import host as office_host @@ -677,11 +677,61 @@ NIGHT_LOOK = """(async () => {@HELPERS@ return { children: [...box.children].map((node) => [node.tagName.toLowerCase(), node.className]), 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]), paragraphs: all("p", box).length, wide: document.documentElement.scrollWidth > window.innerWidth + 1, }; })()""".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" SPOT_SEND = """(async () => {@HELPERS@ const questions = []; @@ -1950,8 +2000,9 @@ class TestOrchestratorE2E(HubCase): for look, (width, _) in zip(seen, WIDTHS, strict=True): with self.subTest(browser=browser, width=width): 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["picks"], [["All", True], ["Skills", True], ["Stage", True]]) self.assertEqual(look["buttons"], [list(entry) for entry in NIGHT_HINTS]) self.assertNotIn("error", found) 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): 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): tree = json.loads(self.tree_file.read_text(encoding="utf-8")) tree["delete_ready"], tree["delete_reason"] = True, "" diff --git a/tests/hub_case.py b/tests/hub_case.py index 9639fe0..11dfe4a 100644 --- a/tests/hub_case.py +++ b/tests/hub_case.py @@ -136,8 +136,8 @@ class HubCase(unittest.TestCase): self.assertEqual(status, 200, body) return signer.proof(json.loads(body)["payload"], self.origin) - def board_proof(self, action: str, signer: Key) -> dict: - status, _, body = self.post_json("/api/board/sign", {"action": action}, self.guard(self.host)) + def board_proof(self, action: str, signer: Key, agents: list[str] | None = None) -> dict: + 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) return signer.proof(json.loads(body)["payload"], self.origin) diff --git a/tests/test_hub_board.py b/tests/test_hub_board.py index bb272aa..a104956 100644 --- a/tests/test_hub_board.py +++ b/tests/test_hub_board.py @@ -12,7 +12,7 @@ from unittest import mock from urllib.parse import quote 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 @@ -153,6 +153,53 @@ class TestHubBoard(HubCase): self.assertEqual(self.get("/api/board/sign")[0], 405) 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): with tempfile.TemporaryDirectory() as 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"]) status = scripts.run(skill_scripts.NIGHT, ["status", "--session", "Operator"])["stdout"].splitlines() 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): status, _, body = self.post_json("/api/nl", {"text": "approve all"}, self.guard(self.host)) diff --git a/tests/test_night.py b/tests/test_night.py index 28d9af6..d0148a2 100644 --- a/tests/test_night.py +++ b/tests/test_night.py @@ -85,6 +85,38 @@ class TestNight(unittest.TestCase): self.assertEqual(self._out("resume", sid="s2"), ["s1\tOne", "s3\tThree"]) 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): self.root.mkdir() for text in ( @@ -94,6 +126,11 @@ class TestNight(unittest.TestCase): json.dumps(["suspend"]), json.dumps({"mode": "suspend", "active": "s1", "resumed": "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): (self.root / "night.json").write_text(text, encoding="utf-8")