feat(orchestrator): tasks tab with register, inbox and queue

Add a Tasks tab that lists the kvb-org-rasci register next to the inbox
and the heavy-job queue, in list, board and RASCI matrix views, each
task with its roles, WSJF, open work and the merge flow of its worktree.

- Task priority pins and WSJF estimates are signed with the operator's
  passkey and applied by the runner through rasci.py; the hub verifies
  the signature itself before it queues a job or logs the change, the
  runner verifies it again and runs only the checked argv, from /, with
  a pinned PATH and a 30 s process-group limit.
- The runner refuses task changes into a council that is a symlink, not
  owned by its uid or given by a relative path, and while the installed
  council.py, nofollow.py or rasci.py differ from the tree.
- Only tasks the hub can sign whole are editable (task slug and length).
- Page lookups keyed by register text use Object.hasOwn, so a status or
  party named like an Object.prototype member cannot break the views.
- The inbox screen renders through the tasks screen, so its task filter
  shows with "Wartet auf Operator" pressed.

Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox).

🎯 Fix: 93 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 88
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 23:43:36 +00:00
1 parent 01f791a1f4
commit c1310c3322
37 files changed
+1436 -80

No files matched your search

+22 -4
View File
@@ -42,7 +42,18 @@
"local-branch"
],
"reason": "dirty: 1 paths",
"result": null
"result": null,
"flow": {
"pull": [
"origin/main",
"store-i18n"
],
"push": [
"main",
"production"
],
"source": "file"
}
},
{
"branch": "feature/done",
@@ -77,7 +88,8 @@
"remote-branch"
],
"reason": "merged into main by #12",
"result": null
"result": null,
"flow": null
},
{
"branch": "feature/kept",
@@ -135,7 +147,8 @@
"skip": null,
"actions": [],
"reason": "default or protected branch",
"result": null
"result": null,
"flow": null
},
{
"branch": null,
@@ -164,7 +177,12 @@
"worktree"
],
"reason": "unmerged: 1 commits not in origin/main",
"result": null
"result": null,
"flow": {
"pull": [],
"push": [],
"source": "invalid"
}
}
]
},
+92 -9
View File
@@ -32,8 +32,10 @@ from test_hub_offices import host as office_host
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy"]
TASK_VIEWS = ("list", "board", "rasci")
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)]
SUBS = ["Inbox", "Regeln", "Radio"]
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
HELPERS = """
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const until = async (check, ms) => {
@@ -91,6 +93,13 @@ PROBE = """(async () => {@HELPERS@
org: all("#main .org-chart .org-row[data-office]").map((row) => [row.dataset.office, text(row.querySelector(".org-holder"))]),
toggles: all("#main .org-toggle").map((button) => [name(button), button.getAttribute("aria-expanded")]),
order: all("#main .agent-order li").map((item) => item.dataset.name),
taskFilters: all('#main [aria-label="Aufgabenfilter"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
taskRows: all("#main .task-row").map((row) => [row.dataset.task, row.classList.contains("open-work")]),
columns: all("#main .task-column").map((column) => [column.dataset.column, all(".task-card", column).map((card) => card.dataset.task)]),
matrix: [all("#main table.matrix thead th").length, all("#main table.matrix tbody tr").length, all("#main table.matrix td .letter").map(text).join("")],
flows: all("#main .flow").map((flow) => flow.dataset.flow),
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
openWork: all("#main .open-work").length,
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
};
})()""".replace("@HELPERS@", HELPERS)
@@ -139,13 +148,38 @@ AFTER_TAKEOVER = """(async () => {@HELPERS@
}, 15000);
return {holder: text(row)};
})()""".replace("@HELPERS@", HELPERS)
INBOX_CHIP = """(async () => {@HELPERS@
const chip = (id) => document.querySelector(`#main [aria-label="Aufgabenfilter"] button[data-filter="${id}"]`);
chip("operator").click();
await until(() => location.hash === "#stream/inbox" && document.querySelector("#main .inbox-list"), 4000);
const inbox = {hash: location.hash, items: all("#main .inbox-list > li").length, counted: Number(text(chip("operator").querySelector(".count"))), pressed: chip("operator").getAttribute("aria-pressed"), current: all("#tabs button[aria-current=page]").map(name)};
chip("freigabe").click();
await until(() => location.hash.startsWith("#tasks/"), 4000);
await sleep(300);
return {inbox, back: {hash: location.hash, rows: all("#main .task-row").map((row) => [row.dataset.task, text(row.querySelector(".pill"))])}};
})()""".replace("@HELPERS@", HELPERS)
PIN = """(async () => {@HELPERS@
const row = await until(() => all("#main .task-row").find((item) => item.dataset.task === "work/docs"), 8000);
if (!row) {
return {error: text(document.getElementById("main"))};
}
row.querySelector('button[aria-label="Priorität"]').click();
await sleep(400);
const field = document.querySelector('.sheet-box input[aria-label="Priorität"]');
field.value = "2";
field.dispatchEvent(new Event("input", {bubbles: true}));
const preview = all(".sheet-box .task-edit .command code").map(text);
all(".sheet-box button").find((button) => name(button) === "Anheften").click();
const done = await until(() => document.querySelector(".sheet-box [data-jobs]"), 20000);
return {title: text(document.querySelector(".sheet-head .panel-title")), preview, jobs: done ? done.dataset.jobs : null, toasts: all("#toasts .toast").map(text)};
})()""".replace("@HELPERS@", HELPERS)
ACTIONS = """(async () => {@HELPERS@
const press = async (key) => {
document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true}));
await sleep(300);
return location.hash;
};
const found = {keys: [await press("2"), await press("3"), await press("1")]};
const found = {keys: [await press("2"), await press("3"), await press("4"), await press("1")]};
document.getElementById("settings-open").click();
await sleep(300);
found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id);
@@ -554,12 +588,25 @@ class TestOrchestratorE2E(HubCase):
super().setUp()
OUT.mkdir(parents=True, exist_ok=True)
now = time.time()
self.root = self.write_tree()
feature = f"{self.root}/worktrees/github.com/me/demo/feature"
self.claim("s1", "Skills session")
self.write_council({
"work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "x"},
"chair/claim.json": {"office": "chair", "session": "s1", "runtime": "claude", "name": "Skills session", "since": iso(now - 7200), "beat": now, "work": "allocate"},
"identity/oidc/claim.json": {"office": "identity/oidc", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "oidc line"},
"rasci.json": {"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}},
"rasci.json": {
"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}},
"work/feature": {
"title": "Merge-Flow im Hub zeigen", "status": "running", "worker": "Skills session", "objective": "Der Operator sieht, wohin ein Branch fließt", "workdir": feature, "prio": 1,
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow im Tree", "met": True}, {"text": "Flow in Tasks", "met": False}],
"estimates": {"Skills": {"value": 8, "complexity": 3, "at": iso(now - 600)}},
},
"work/docs": {"title": "README nachziehen", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
"work/old": {"title": "Alte Linie", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}},
"operator/push": {"title": "git push", "workdir": "/repo", "status": "done", "roles": {"Operator": "R"}},
},
"queue.json": {"seq": 3, "entries": [{"id": "gate-3", "kind": "gate", "task": "work/feature", "workdir": feature, "session": "s1", "name": "Skills", "status": "waiting", "enqueued": now - 120, "cmd": "make test"}]},
"radio-silence.json": {"since": now - 60, "until": now + 3600, "by": "Docs", "text": "disk full"},
"news.jsonl": self.jsonl([
{"ts": now - 600, "from": "Skills", "to": ["Stage"], "text": "rebase please", "class": "post", "v": 2},
@@ -572,7 +619,6 @@ class TestOrchestratorE2E(HubCase):
self.subagents(now)
self.request("feat: orchestrator", "")
self.create_ask(ASK)
self.root = self.write_tree()
self.base = f"http://{self.host}/"
def subagents(self, now: float) -> None:
@@ -722,6 +768,7 @@ class TestOrchestratorE2E(HubCase):
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "branch:"), "shot": self.shot("1366-artifacts-3d-picked")})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#agents/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "s2"), "shot": self.shot("1366-agents-3d-picked")})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]})
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-freigabe")})
data = self.drive(steps, None)
(OUT / "chromium-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
@@ -731,8 +778,8 @@ class TestOrchestratorE2E(HubCase):
self.assertNotIn("error", probe)
self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator"))
self.assertEqual(probe["header"], ["palette-open", "settings-open"])
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], SUBS))
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None})
self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS))
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None, "tab-tasks": None})
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full")
self.assertFalse(probe["rail"])
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
@@ -769,11 +816,25 @@ class TestOrchestratorE2E(HubCase):
for view in ("orbit", "chord"):
with self.subTest(view=view):
self.assertGreaterEqual(by_hash[f"#artifacts/{view}"]["marks"], 4)
self.assertEqual((artifacts["flows"], artifacts["openWork"]), (["file", "invalid"], 5))
for state in ("offene Arbeit: nicht committet, Aufgabe offen", "offene Arbeit: ungepusht, Aufgabe offen", "offene Arbeit: nicht gemergt", "3 Worktrees mit offener Arbeit"):
with self.subTest(state=state):
self.assertIn(state, artifacts["states"])
self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"])
order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"]
listed = by_hash["#tasks/list"]
self.assertEqual(listed["views"], ["Liste", "Board", "RASCI"])
self.assertEqual(listed["taskFilters"], [["alle", "true", "6"], ["offen", "false", "4"], ["arbeit", "false", "4"], ["operator", "false", "2"], ["freigabe", "false", "1"], ["beendet", "false", "2"]])
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
self.assertEqual(listed["flows"], ["file", "file"])
self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["blocked", ["work/docs"]], ["done", ["work/old", "operator/push"]], ["stopped", []]])
self.assertEqual(by_hash["#tasks/rasci"]["matrix"], [6, 6, "CIRARASRARAR"])
self.assertEqual([probe["taskFilters"][3][1] for probe in probes if probe["hash"] == "#stream/inbox"], ["true"] * len(WIDTHS))
extra = data["results"][len(probes) :]
self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
actions = extra[3]
self.assertNotIn("error", actions)
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#stream"])
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"])
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
self.assertEqual((actions["settingsUnlabelled"], actions["drawerUnlabelled"]), (0, 0))
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "SECURITE"])
@@ -782,10 +843,13 @@ class TestOrchestratorE2E(HubCase):
with self.subTest(tool=tool):
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
self.assertEqual(actions["drawer"], [["post", True], ["call", False]])
artifact_pick, agent_pick, calm = extra[4:]
artifact_pick, agent_pick, calm, chips = extra[4:]
self.assertEqual(artifact_pick["panel"], artifact_pick["label"])
self.assertEqual(agent_pick["panel"], agent_pick["label"])
self.assertRegex(calm["note"], r"Kraft · ohne Bewegung \(prefers-reduced-motion\)$")
self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]})
self.assertGreaterEqual(chips["inbox"]["items"], 1)
self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue-Job"]]})
def test_chromium_sends_a_call_from_the_agent_panel_and_shows_its_receipt(self):
handled = []
@@ -1055,6 +1119,25 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual(probe["console"], "erledigtExit-Code 0 nach 9 s")
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
signer = self.pin()
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"}
local = f"http://localhost:{self.port}/"
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#tasks/list", "wait": 1200, "probe": PIN, "shot": self.shot("1366-tasks-priority-signed")}], authenticator)
(OUT / "chromium-task-priority.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
pinned = data["results"][0]
self.assertNotIn("error", pinned)
self.assertEqual((pinned["title"], pinned["preview"][0]), ("work/docs", "rasci.py set work/docs --prio 2"))
self.assertEqual(pinned["toasts"], ["Priorität work/docs: an den Runner übergebenrasci.py set work/docs --prio 2"])
jobs = self.runner_jobs()
self.assertEqual([(job["id"], job["kind"], job["argv"]) for job in jobs], [(pinned["jobs"], "task", ["set", "work/docs", "--prio", "2"])])
self.run_runner()
register = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
self.assertEqual((register["work/docs"]["prio"], register["work/feature"]["prio"]), (2, 1))
self.assertEqual([(row["task"], row["prio"]) for row in self.snapshot()["tasks"][:2]], [("work/feature", 1), ("work/docs", 2)])
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_walks_every_screen_at_three_widths(self):
shots = [(width, height, screen, OUT / f"firefox-{width}-{screen.replace('/', '-')}.png") for width, height in WIDTHS for screen in SCREENS]
@@ -1071,7 +1154,7 @@ class TestOrchestratorE2E(HubCase):
with self.subTest(width=width, screen=screen):
self.assertNotIn("error", probe)
self.assertEqual(probe["hash"], f"#{screen}")
self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], SUBS))
self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS))
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
self.assertFalse(probe["wide"], "the page scrolls sideways")
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
+15
View File
@@ -5,6 +5,7 @@ import itertools
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
@@ -20,6 +21,8 @@ from hub_support import (
HUB,
OPTIONS,
PAGE,
RASCI,
RUNNER,
SCORE,
TREE,
FakeScripts,
@@ -156,6 +159,18 @@ class HubCase(unittest.TestCase):
def client(self) -> dict[str, str]:
return {"Host": self.host, "X-Kvb-Orchestrator": "1"}
def runner_jobs(self) -> list[dict]:
status, _, body = self.call("GET", "/api/runner/jobs", None, self.client())
self.assertEqual(status, 200, body)
return json.loads(body)["jobs"]
def run_runner(self, register: bool = True, council: Path | None = None) -> None:
trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")]
flags = ["--rasci", str(RASCI), "--council", str(council or self.council)] if register else []
command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, *flags, "--once"]
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr)
def settle(self, check: Callable[[], bool]) -> None:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
+1
View File
@@ -21,6 +21,7 @@ sys.path[:0] = [str(HUB), str(HUB / "client")]
CLIENT = HUB / "client" / "permission_hook.py"
RUNNER = HUB / "client" / "runner.py"
APPROVAL = HUB / "client" / "approval.py"
RASCI = REPO_ROOT / "skills" / "kvb-org-rasci" / "scripts" / "rasci.py"
SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_server", HUB / "server.py")
server = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = server
-12
View File
@@ -17,7 +17,6 @@ from hub_support import (
GIT_ENV,
PAGE,
PRESENT,
RUNNER,
TREE,
runner,
signing,
@@ -33,17 +32,6 @@ class TestHubExecutors(HubCase):
def trust(self, name: str) -> verifier.Trust:
return verifier.Trust(approvers=self.approvers, replay=self.replay / f"{name}.json", origin=self.origin)
def runner_jobs(self) -> list[dict]:
status, _, body = self.call("GET", "/api/runner/jobs", None, self.client())
self.assertEqual(status, 200, body)
return json.loads(body)["jobs"]
def run_runner(self) -> None:
trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")]
command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, "--once"]
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False)
self.assertEqual(done.returncode, 0, done.stderr)
def test_the_runner_runs_only_signed_commands_once(self):
command = "echo hello && echo world >&2"
register = {
+19 -8
View File
@@ -10,16 +10,19 @@ NAV_PROBE = """
const N = require(process.argv[1]);
const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope",
"#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane",
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio"];
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio",
"#tasks", "#tasks/board", "#tasks/rasci", "#tasks/orbit", "#agents/board", "#tasks/hierarchy"];
console.log(JSON.stringify({
tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]),
views: N.VIEWS.map(([id, label]) => [id, label]),
taskViews: N.TASK_VIEWS.map(([id, label]) => [id, label]),
agents: N.viewsOf("agents").map(([id, label]) => [id, label]),
artifacts: N.viewsOf("artifacts").map(([id, label]) => [id, label]),
tasks: N.viewsOf("tasks").map(([id, label]) => [id, label]),
icon: N.ICONS.hierarchy,
routes: hashes.map((hash) => N.route(hash)),
hashes: [["stream", null], ["inbox", null], ["rules", null], ["radio", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)),
tabOf: ["stream", "inbox", "rules", "radio", "agents", "artifacts"].map((screen) => N.tabOf(screen)),
hashes: [["stream", null], ["inbox", null], ["rules", null], ["radio", null], ["agents", "chord"], ["artifacts", "3d"], ["tasks", "board"]].map(([screen, view]) => N.hashOf(screen, view)),
tabOf: ["stream", "inbox", "rules", "radio", "agents", "artifacts", "tasks"].map((screen) => N.tabOf(screen)),
}));
"""
@@ -30,12 +33,14 @@ class TestNavModule(unittest.TestCase):
done = subprocess.run(["node", "-e", NAV_PROBE, str(NAV)], capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
def test_the_navigation_has_three_entries_and_stream_has_inbox_rules_and_radio(self):
self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]])
def test_the_navigation_has_four_entries_and_stream_has_inbox_rules_and_radio(self):
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]]
self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]])
def test_agents_and_artifacts_offer_five_views_in_order(self):
def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_three(self):
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared))
self.assertEqual((self.data["taskViews"], self.data["tasks"]), ([["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]],) * 2)
def test_only_agents_add_the_hierarchy_after_3d(self):
self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchie"]])
@@ -67,11 +72,17 @@ class TestNavModule(unittest.TestCase):
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
{"screen": "radio", "view": None, "redirect": None},
stream,
{"screen": "tasks", "view": None, "redirect": None},
{"screen": "tasks", "view": "board", "redirect": None},
{"screen": "tasks", "view": "rasci", "redirect": None},
{"screen": "tasks", "view": None, "redirect": None},
{"screen": "agents", "view": None, "redirect": None},
{"screen": "tasks", "view": None, "redirect": None},
])
def test_every_screen_has_its_own_hash_and_tab(self):
self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#stream/radio", "#agents/chord", "#artifacts/3d"])
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts"])
self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#stream/radio", "#agents/chord", "#artifacts/3d", "#tasks/board"])
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts", "tasks"])
if __name__ == "__main__":
+1 -1
View File
@@ -151,7 +151,7 @@ class TestHubState(HubCase):
set(state),
{
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
"register", "agent_order", "radio", "claims", "grants", "night", "silence", "graph",
"register", "tasks", "agent_order", "radio", "claims", "grants", "night", "silence", "graph",
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
"badges", "kinds",
},
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import json
import os
import signal
import sys
import time
import unittest
from pathlib import Path
from unittest import mock
from hub_case import HubCase
from hub_support import runner, signing, verifier
from kvb_orchestrator import skill_scripts, tasks, tree
BROKEN = {"pull": [], "push": [], "source": "invalid"}
FLOW = {"pull": ["origin/main", "store-i18n"], "push": ["main", "production"], "source": "file"}
PRIO = ["set", "work/feature", "--prio", "3"]
ESTIMATE = ["estimate", "work/feature", "Operator", "--value", "8", "--complexity", "3"]
def change(argv: list, task: str = "work/feature") -> dict:
return {"kind": "task", "id": task, "argv": argv}
class TestHubTasks(HubCase):
def places(self) -> tuple[str, str, str]:
root = self.write_tree()
return str(root), f"{root}/repos/github.com/me/demo", f"{root}/worktrees/github.com/me/demo"
def worktrees(self) -> dict[str, dict]:
data = json.loads(self.get("/api/tree")[2])
found = {wt["path"]: wt for repo in data["repos"] for entry in repo["branches"] for wt in entry["worktrees"]}
return {**found, **{wt["path"]: wt for repo in data["repos"] for wt in repo["detached"]}}
def test_the_tree_maps_each_worktree_flow_and_a_missing_one_is_none(self):
root, demo, trees = self.places()
flows = {path: wt["flow"] for path, wt in self.worktrees().items()}
self.assertEqual(flows, {
f"{trees}/feature": FLOW,
f"{trees}/feature-done": None,
demo: None,
f"{trees}/detached": BROKEN,
f"{root}/worktrees/x/y/lone": None,
f"{root}/outside/elsewhere": None,
})
def test_a_flow_of_the_wrong_shape_reads_as_invalid_and_long_lists_are_capped(self):
for raw in (
"x",
["main"],
{"pull": ["a"], "push": ["b"]},
{"pull": "a", "push": [], "source": "file"},
{"pull": [1], "push": [], "source": "file"},
{"pull": ["a b"], "push": [], "source": "file"},
{"pull": ["a\nb"], "push": [], "source": "file"},
{"pull": ["x" * 256], "push": [], "source": "file"},
{"pull": ["a"], "push": ["b"], "source": "File!"},
{"pull": ["a"], "push": ["b"], "source": 3},
{"pull": ["a"], "push": ["b"], "source": "invalid"},
):
with self.subTest(raw=raw):
self.assertEqual(tree.flow({"flow": raw}), BROKEN)
self.assertIsNone(tree.flow({}))
self.assertIsNone(tree.flow({"flow": None}))
many = tree.flow({"flow": {"pull": [f"b{n}" for n in range(40)], "push": ["main"], "source": "config", "extra": 1}})
self.assertEqual(many, {"pull": [f"b{n}" for n in range(tree.FLOW_LIMIT)], "push": ["main"], "source": "config"})
def test_worktrees_carry_their_open_work_and_the_open_tasks_on_them(self):
root, demo, trees = self.places()
self.write_council({"rasci.json": {
"work/feature": {"status": "running", "workdir": f"{trees}/feature"},
"work/old": {"status": "done", "workdir": f"{trees}/feature-done"},
}})
found = self.worktrees()
self.assertEqual({path: wt["open_work"] for path, wt in found.items()}, {
f"{trees}/feature": ["dirty", "task"],
f"{trees}/feature-done": [],
demo: ["unpushed"],
f"{trees}/detached": ["unmerged"],
f"{root}/worktrees/x/y/lone": ["unmerged"],
f"{root}/outside/elsewhere": [],
})
self.assertEqual((found[f"{trees}/feature"]["tasks"], found[f"{trees}/feature-done"]["tasks"]), (["work/feature"], []))
def test_the_state_lists_every_task_ranked_with_roles_wsjf_flow_and_open_work(self):
_, _, trees = self.places()
now = time.time()
register = {
"work/feature": {
"title": "Feature", "status": "running", "worker": "Skills session", "objective": "Ship it", "workdir": f"{trees}/feature",
"roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "a", "met": True}, {"text": "b", "met": False}],
"estimates": {"Skills": {"value": 8, "complexity": 2, "at": "2026-11-07T08:00:00+00:00"}, "Docs": {"value": 5, "complexity": 3}},
},
"work/pinned": {"title": "Pinned", "status": "blocked", "prio": 1, "branch": "feature/done", "roles": {"Stage": "R"}},
"work/scored": {"title": "Scored", "status": "queued", "estimates": {"Docs": {"value": 3, "complexity": 1}}},
"work/done": {"title": "Done", "status": "done", "end": "2026-11-07T09:00:00+00:00", "workdir": "/nowhere"},
"operator/push": {"title": "git push", "workdir": "/repo", "status": "queued", "roles": {"Operator": "R"}},
"work/bad": {"title": ["x"], "status": "running", "prio": True, "estimates": {"X": {"value": 4, "complexity": 2}, "Y": "no", "Z": {"value": True, "complexity": 1}}, "roles": {"Z": "RQ", "W": 5}, "key_results": "no"},
"broken": "not a row",
}
queue = {"seq": 9, "entries": [
{"id": "gate-7", "kind": "gate", "task": "work/feature", "workdir": f"{trees}/feature", "session": "s1", "name": "Stage", "status": "waiting", "enqueued": now - 60, "cmd": "make test"},
{"id": "deploy-8", "kind": "deploy", "task": "work/pinned", "workdir": "/elsewhere", "session": "s2", "name": "Docs", "status": "active", "enqueued": now - 30, "cmd": "make deploy"},
{"id": "bad-9", "enqueued": "yesterday"},
"not an entry",
]}
self.write_council({"rasci.json": register, "queue.json": queue})
rows = {row["task"]: row for row in self.snapshot()["tasks"]}
self.assertEqual(list(rows), ["work/pinned", "work/scored", "work/feature", "operator/push", "work/bad", "queue/gate-7", "queue/deploy-8", "work/done"])
feature = rows["work/feature"]
self.assertEqual((feature["kind"], feature["status"], feature["met"], feature["total"], feature["prio"], feature["editable"], feature["ended"]), ("task", "running", 1, 2, None, True, False))
self.assertEqual(feature["roles"], {"Skills": "RA", "Operator": "C", "Docs": "I"})
self.assertEqual(feature["estimates"], {"Skills": {"value": 8, "complexity": 2}, "Docs": {"value": 5, "complexity": 3}})
self.assertEqual(feature["wsjf"], {"value": 6.5, "complexity": 2.5, "score": 2.6})
self.assertEqual((feature["worktree"], feature["worktree_branch"], feature["repo"], feature["flow"], feature["open_work"]), (f"{trees}/feature", "feature", "github.com/me/demo", FLOW, ["task", "dirty"]))
pinned = rows["work/pinned"]
self.assertEqual((pinned["prio"], pinned["worktree"], pinned["worktree_branch"], pinned["flow"], pinned["open_work"]), (1, f"{trees}/feature-done", "feature/done", None, ["task"]))
self.assertEqual(rows["work/scored"]["wsjf"], {"value": 3, "complexity": 1, "score": 3.0})
bad = rows["work/bad"]
self.assertEqual((bad["title"], bad["prio"], bad["estimates"], bad["wsjf"], bad["roles"], bad["total"]), ("['x']", None, {}, None, {"Z": "R"}, 0))
command = rows["operator/push"]
self.assertEqual((command["kind"], command["title"], command["editable"], command["open_work"]), ("command", "git push", False, ["task"]))
gate, deploy = rows["queue/gate-7"], rows["queue/deploy-8"]
self.assertEqual((gate["kind"], gate["status"], gate["title"], gate["worker"], gate["editable"], gate["flow"]), ("queue", "awaiting", "make test", "Stage", False, FLOW))
self.assertEqual(gate["queue"], {"kind": "gate", "parent": "work/feature", "enqueued": now - 60})
self.assertEqual((deploy["status"], deploy["flow"], deploy["queue"]["parent"]), ("running", None, "work/pinned"))
done = rows["work/done"]
self.assertEqual((done["ended"], done["editable"], done["open_work"], done["worktree"]), (True, False, [], ""))
def test_only_the_newest_ended_tasks_are_listed(self):
register = {f"work/old-{n:03d}": {"status": "done", "end": f"2026-11-{1 + n // 24:02d}T{n % 24:02d}:00:00+00:00"} for n in range(30)}
self.write_council({"rasci.json": {**register, "work/open": {"status": "queued"}}})
with mock.patch.object(tasks, "ENDED_SHOWN", 5):
listed = [row["task"] for row in self.snapshot()["tasks"]]
self.assertEqual(listed, ["work/open", "work/old-029", "work/old-028", "work/old-027", "work/old-026", "work/old-025"])
def test_a_task_change_needs_a_known_open_task_and_one_of_three_argv_shapes(self):
self.write_council({"rasci.json": {
"work/feature": {"title": "F", "status": "running"},
"work/done": {"status": "done"},
"operator/push": {"title": "git push", "workdir": "/repo", "status": "queued"},
}})
for argv in (
["set", "work/feature", "--prio", "0"],
["set", "work/feature", "--prio", "1000"],
["set", "work/feature", "--prio", "03"],
["set", "work/feature", "--prio", 3],
["set", "work/feature", "--prio", "3", "--status", "done"],
["set", "work/other", "--prio", "3"],
["estimate", "work/feature", "Skills", "--value", "8", "--complexity", "3"],
["estimate", "work/feature", "Operator", "--value", "4", "--complexity", "3"],
["estimate", "work/feature", "Operator", "--complexity", "3", "--value", "8"],
["estimate", "work/feature", "Operator", "--drop", "--value", "8"],
["rm", "work/feature"],
"set work/feature --prio 3",
):
with self.subTest(argv=argv):
status, _, body = self.post_json("/api/sign", {"items": [change(argv)]}, self.guard(self.host))
self.assertEqual(status, 400, body)
refused = (
([change(["set", "operator/push", "--prio", "3"], "operator/push")], 400),
([change(["set", "work/x;rm", "--prio", "3"], "work/x;rm")], 400),
([{**change(PRIO), "extra": 1}], 400),
([change(PRIO), change(PRIO)], 400),
([change(PRIO, "x")], 400),
([change(["set", "work/nope", "--prio", "3"], "work/nope")], 404),
([change(["set", "work/done", "--prio", "3"], "work/done")], 409),
)
for items, code in refused:
with self.subTest(items=items):
self.assertEqual(self.post_json("/api/sign", {"items": items}, self.guard(self.host))[0], code)
prepared = self.prepare([change(PRIO), change(ESTIMATE), change(["estimate", "work/feature", "Operator", "--drop"])])
self.assertEqual(len(prepared["payload"]["items"]), 3)
self.assertIn(signing.item_hash(signing.task_item("work/feature", PRIO)), prepared["payload"]["items"])
self.assertEqual(self.get("/api/tasks")[0], 405)
self.assertEqual(self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": None}, self.client())[0], 403)
self.assertEqual(self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": None}, self.guard(self.host))[0], 400)
forged = {**self.signed([change(PRIO)], self.pin()), "assertion": {}}
status, _, body = self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": forged}, self.guard(self.host))
self.assertEqual(status, 400, body)
self.assertIn("nicht signiert", body.decode() if isinstance(body, bytes) else body)
snapshot = self.snapshot()
self.assertEqual([job for job in snapshot["jobs"] if job["kind"] == "task"], [])
self.assertNotIn("Aufgabe signiert geändert", json.dumps(snapshot, ensure_ascii=False))
def test_a_task_the_hub_cannot_sign_whole_is_not_editable(self):
long_slug = "work/" + "x" * 600
self.write_council({"rasci.json": {long_slug: {"status": "running"}, "work/a b": {"status": "running"}}})
self.assertEqual([task["editable"] for task in self.snapshot()["tasks"]], [False, False])
def test_a_signed_priority_and_estimate_reach_the_register_through_the_runner(self):
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "roles": {"Skills": "RA"}}}})
signer = self.pin()
items = [change(PRIO), change(ESTIMATE)]
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))
self.assertEqual(status, 202, body)
self.assertEqual((len(json.loads(body)["jobs"]), json.loads(body)["status"]), (2, "queued"))
queued = sorted(self.runner_jobs(), key=lambda job: job["argv"][0], reverse=True)
self.assertEqual([(job["kind"], job["task"], job["argv"], job["command"], job["directory"]) for job in queued], [
("task", "work/feature", PRIO, "rasci.py set work/feature --prio 3", ""),
("task", "work/feature", ESTIMATE, "rasci.py estimate work/feature Operator --value 8 --complexity 3", ""),
])
trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "old.json", origin=self.origin)
self.assertIn("does not list this item", runner.refusal(queued[0], trust, time.time()))
self.run_runner()
stored = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"]
self.assertEqual((stored["prio"], stored["estimates"]["Operator"]["value"], stored["estimates"]["Operator"]["complexity"], stored["roles"]), (3, 8, 3, {"Skills": "RA"}))
jobs = {job["command"]: job for job in self.snapshot()["jobs"]}
self.assertEqual([(job["status"], job["code"]) for job in jobs.values()], [("done", 0), ("done", 0)])
self.assertEqual(self.snapshot()["tasks"][0]["prio"], 3)
self.assertNotIn(skill_scripts.OPERATOR_SCRIPT, [script for script, _ in self.scripts.calls])
proof = queued[0]["proof"]
forged = (
(["set", "work/feature", "--prio", "9"], proof, "does not list this item"),
(["rm", "work/feature"], proof, "no task priority"),
(PRIO, proof, "already used"),
(PRIO, None, "no signature"),
)
for argv, relayed, reason in forged:
with self.subTest(argv=argv, reason=reason):
fields = {"kind": "task", "task": "work/feature", "argv": argv, "command": "x", "directory": "", "sha256": "", "status": "queued", "proof": relayed}
tampered = self.httpd.hub.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
self.run_runner()
record = self.httpd.hub.jobs.get(tampered["id"])
self.assertEqual(record["status"], "refused")
self.assertTrue(record["output"].startswith(runner.REFUSED), record["output"])
self.assertIn(reason, record["output"])
self.assertEqual(json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"]["prio"], 3)
def test_a_runner_without_the_register_flags_refuses_task_changes(self):
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running"}}})
signer = self.pin()
items = [change(PRIO)]
self.assertEqual(self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))[0], 202)
self.run_runner(register=False)
job = next(job for job in self.snapshot()["jobs"] if job["kind"] == "task")
self.assertEqual(job["status"], "refused")
self.assertIn(runner.UNREGISTERED, job["output"])
self.assertNotIn("prio", json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"])
def test_a_runner_refuses_task_changes_into_a_symlinked_or_foreign_council(self):
self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running"}}})
linked = self.council.with_name("linked-council")
linked.symlink_to(self.council)
signer = self.pin()
items = [change(PRIO)]
self.assertEqual(self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))[0], 202)
self.run_runner(council=linked)
job = next(job for job in self.snapshot()["jobs"] if job["kind"] == "task")
self.assertEqual(job["status"], "refused")
self.assertIn(f"{linked} is a symlink, no directory or not owned by uid", job["output"])
self.assertNotIn("prio", json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"])
self.assertIsNone(runner.council_refusal(self.council))
for path in (self.council / "rasci.json", self.council / "missing", Path(""), Path("council")):
with self.subTest(path=path):
self.assertTrue(runner.council_refusal(path).startswith("refused: "))
with mock.patch("os.geteuid", return_value=os.geteuid() + 1):
self.assertIn("not owned by uid", runner.council_refusal(self.council))
def test_a_signed_task_change_never_writes_through_a_register_swapped_for_a_link(self):
register = {"work/feature": {"title": "F", "status": "running"}}
self.write_council({"rasci.json": register})
signer = self.pin()
items = [change(PRIO)]
self.assertEqual(self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))[0], 202)
target = self.outside / "elsewhere.json"
target.write_text(json.dumps(register), encoding="utf-8")
before = target.read_bytes()
linked = self.council / "rasci.json"
linked.unlink()
linked.symlink_to(target)
self.run_runner()
job = next(job for job in self.snapshot()["jobs"] if job["kind"] == "task")
self.assertEqual(job["status"], "failed", job["output"])
self.assertIn("is no regular file below", job["output"])
self.assertEqual((linked.is_symlink(), target.read_bytes()), (True, before))
def test_a_task_change_runs_only_its_checked_argv_from_the_root_within_its_limit(self):
register = runner.Register(rasci=Path("/x/rasci.py"), council=Path("/c"))
with mock.patch.object(runner, "spawn", return_value=0) as spawn:
runner.change({"task": "work/feature", "argv": PRIO}, register, print)
with self.assertRaises(signing.Refused):
runner.change({"task": "work/feature", "argv": ["rm", "work/feature"]}, register, print)
argv, cwd, env, _, limit = spawn.call_args.args
self.assertEqual((spawn.call_count, argv[1:], cwd, env["AGENTS_COUNCIL_ROOT"], limit), (1, ["-I", "-B", "/x/rasci.py", *PRIO], "/", "/c", runner.TASK_LIMIT))
def test_a_program_past_its_limit_is_killed_with_its_whole_process_group(self):
reports = []
child = "import subprocess, sys, time; subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); time.sleep(60)"
started = time.monotonic()
code = runner.spawn([sys.executable, "-c", child], "/", None, reports.append, 0.5)
self.assertLess(time.monotonic() - started, 10)
self.assertEqual((code, reports[-1]["code"]), (-signal.SIGKILL, -signal.SIGKILL))
self.assertIn("killed after 0.5 s", reports[-1]["output"])
def test_open_tasks_queue_rows_parties_and_task_keys_are_capped(self):
many = {f"P{n}": {"value": 3, "complexity": 1} for n in range(9)}
register = {f"work/t{n}": {"status": "queued", "prio": n + 1, "estimates": many, "roles": {party: "R" for party in many}} for n in range(6)}
register["work/" + "x" * 900] = {"status": "queued"}
now = time.time()
queue = {"seq": 7, "entries": [{"id": f"gate-{n}", "kind": "gate", "task": "work/t0", "status": "waiting", "enqueued": now - 100 + n, "cmd": "make test"} for n in range(7)]}
self.write_council({"rasci.json": register, "queue.json": queue})
with mock.patch.multiple(tasks, OPEN_SHOWN=4, QUEUE_SHOWN=3, PARTIES_SHOWN=2):
rows = self.snapshot()["tasks"]
self.assertEqual([row["task"] for row in rows], ["work/t0", "work/t1", "work/t2", "work/t3", "queue/gate-0", "queue/gate-1", "queue/gate-2"])
self.assertEqual((len(rows[0]["estimates"]), len(rows[0]["roles"])), (2, 2))
self.assertEqual(max(len(row["task"]) for row in self.snapshot()["tasks"]), tasks.TEXT_SHOWN)
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -149,6 +149,49 @@ class TestOrchestratorMigration(unittest.TestCase):
self.assertIn("run 'make install' to enable", done.stderr)
self.assertNotIn(f"{NEW}-offices.path", json.loads(self.state.read_text(encoding="utf-8"))["enabled"])
def test_the_runner_unit_waits_for_the_installed_skill_scripts_of_this_tree(self):
self.assertIn(f"{NEW}-runner.service", self.install()["enabled"])
runner = f"{NEW}-runner.service"
for script in HOST_SCRIPTS:
with self.subTest(script=script):
installed = self.home / ".claude" / "skills" / script
kept = installed.read_text(encoding="utf-8")
installed.write_text(kept + "\n", encoding="utf-8")
state = json.loads(self.state.read_text(encoding="utf-8"))
self.state.write_text(json.dumps({**state, "calls": [], "enabled": sorted({*state["enabled"], runner})}), encoding="utf-8")
done = subprocess.run(["bash", str(SCRIPT), "install"], env=self.env, capture_output=True, text=True, timeout=120, check=False)
self.assertEqual(done.returncode, 0, done.stderr)
self.assertIn(f"run 'make install' to enable {runner}", done.stderr)
after = json.loads(self.state.read_text(encoding="utf-8"))
self.assertNotIn(runner, after["enabled"])
self.assertNotIn(["systemctl", "--user", "restart", runner], after["calls"])
refused = subprocess.run(["bash", str(SCRIPT), "runner"], env=self.env, capture_output=True, text=True, timeout=120, check=False)
self.assertEqual(refused.returncode, 1, refused.stderr)
self.assertNotIn(runner, json.loads(self.state.read_text(encoding="utf-8"))["enabled"])
installed.write_text(kept, encoding="utf-8")
def test_install_refreshes_and_restarts_only_an_enabled_runner_unit(self):
units = self.home / ".config" / "systemd" / "user"
(units / "default.target.wants" / f"{OLD}-runner.service").unlink()
(units / f"{OLD}-runner.service").unlink()
shipped = (REPO_ROOT / "orchestrator" / "systemd" / f"{NEW}-runner.service").read_text(encoding="utf-8")
restart = ["systemctl", "--user", "restart", f"{NEW}-runner.service"]
for enabled in (False, True):
with self.subTest(enabled=enabled):
self.write(units / f"{NEW}-runner.service", "[Unit]\nstale\n")
state = json.loads(self.state.read_text(encoding="utf-8"))
self.state.write_text(json.dumps({**state, "calls": [], "enabled": [f"{NEW}-runner.service"] if enabled else []}), encoding="utf-8")
calls = self.install()["calls"]
self.assertEqual((units / f"{NEW}-runner.service").read_text(encoding="utf-8") == shipped, enabled)
self.assertEqual(restart in calls, enabled)
def test_every_shipped_service_pins_the_path(self):
units = sorted((REPO_ROOT / "orchestrator" / "systemd").glob("*.service"))
self.assertIn(f"{NEW}-runner.service", [unit.name for unit in units])
for unit in units:
with self.subTest(unit=unit.name):
self.assertIn("Environment=PATH=/usr/bin:/bin", unit.read_text(encoding="utf-8").splitlines())
def test_install_ships_no_bytecode_from_the_tree(self):
planted = REPO_ROOT / "orchestrator" / "client" / "__pycache__" / "planted.cpython-0.pyc"
planted.parent.mkdir(exist_ok=True)