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
+408
-64
No files matched your search
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user