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:
Kevin Veen-Birkenbach committed 2026-11-11 07:53:40 +00:00
commit c3ab6b7e18
18 files changed
+408 -64

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.
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=<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
- **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.
+11 -2
View File
@@ -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:
+37 -6
View File
@@ -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]))
+8 -2
View File
@@ -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();
+54 -11
View File
@@ -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);
}
}
+1 -1
View File
@@ -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},
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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};
+3
View File
@@ -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); }
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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`
+2 -1
View File
@@ -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.
+3 -1
View File
@@ -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=<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
+77 -31
View File
@@ -9,16 +9,23 @@ Environment:
Args:
drain|suspend [--session <id>]: start the mode with the caller as the
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,
and the caller's part (active, resumed, drain or suspend).
resume [--session <id>]: end the mode; prints every other live session
as "<id>\t<name>".
and the caller's part (active, resumed, drain or suspend); a session
without a part by day gets no "you:" line.
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
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")
+86 -2
View File
@@ -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, ""
+2 -2
View File
@@ -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)
+52 -1
View File
@@ -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))
+37
View File
@@ -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")