Files
skills/tests/test_orchestrator_migration.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 6f940b4415 feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface
Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked.

Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once.

MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46).

Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 101 %
⏱️ Time: –
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 87
🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 22:20:06 +00:00

148 lines
8.0 KiB
Python

"""Run orchestrator.sh install twice in a scratch HOME with stub docker, claude and systemctl."""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "scripts" / "orchestrator.sh"
OLD = "kvb-hub"
NEW = "kvb-orchestrator"
STUB = r'''#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
tool, args = Path(sys.argv[0]).name, sys.argv[1:]
file = Path(os.environ["STUB_STATE"])
state = json.loads(file.read_text())
state["calls"].append([tool, *args])
code = 0
if tool == "docker" and args[:2] == ["image", "inspect"]:
code = 0 if args[2] in state["images"] else 1
elif tool == "docker" and args[:2] == ["image", "rm"]:
code = 0 if args[2] in state["images"] else 1
state["images"] = [image for image in state["images"] if image != args[2]]
elif tool == "docker" and args[0] == "compose" and "up" in args:
state["images"] = sorted({*state["images"], "kvb-orchestrator:local"})
elif tool == "claude" and args[:2] == ["mcp", "get"]:
code = 0 if args[2] in state["mcp"] else 1
elif tool == "claude" and args[:2] == ["mcp", "remove"]:
code = 0 if args[-1] in state["mcp"] else 1
state["mcp"].pop(args[-1], None)
elif tool == "claude" and args[:2] == ["mcp", "add"]:
state["mcp"][args[-2]] = args[-1]
elif tool == "systemctl":
verb, unit = args[1], args[-1]
if verb == "is-enabled":
print("enabled" if unit in state["enabled"] else "disabled")
code = 0 if unit in state["enabled"] else 1
elif verb == "disable":
state["enabled"] = [name for name in state["enabled"] if name != unit]
elif verb == "enable":
state["enabled"] = sorted({*state["enabled"], unit})
elif verb == "is-active":
print("active")
file.write_text(json.dumps(state))
sys.exit(code)
'''
def snapshot(root: Path) -> dict:
found = {}
for path in sorted(root.rglob("*")):
key = str(path.relative_to(root))
if path.is_symlink():
found[key] = ("link", os.readlink(path))
elif path.is_dir():
found[key] = ("dir", path.stat().st_mode & 0o777)
else:
found[key] = ("file", path.stat().st_mode & 0o777, hashlib.sha256(path.read_bytes()).hexdigest())
return found
@unittest.skipUnless(shutil.which("bash") and shutil.which("python3"), "bash or python3 missing")
class TestOrchestratorMigration(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.home = self.root / "home"
stubs = self.root / "bin"
stubs.mkdir()
(stubs / "stub").write_text(STUB, encoding="utf-8")
(stubs / "stub").chmod(0o755)
for tool in ("docker", "claude", "systemctl"):
(stubs / tool).symlink_to("stub")
self.state = self.root / "stub.json"
self.state.write_text(json.dumps({"calls": [], "images": [f"{OLD}:local"], "mcp": {OLD: "http://127.0.0.1:8737/mcp"}, "enabled": [f"{OLD}-runner.service"]}), encoding="utf-8")
self.env = {**os.environ, "HOME": str(self.home), "PATH": f"{stubs}:{os.environ['PATH']}", "STUB_STATE": str(self.state), "AGENTS_COUNCIL_ROOT": str(self.root / "council")}
share, local, config = self.home / ".local" / "share", self.home / ".local" / "state", self.home / ".config"
self.write(share / OLD / "compose.yml", f"name: {OLD}\n")
self.write(share / OLD / ".env", "KVB_HUB_PORT=8737\n")
self.write(local / OLD / "rules.json", '{"rule": 1}\n')
self.write(local / f"{OLD}-approvals" / "runner.json", '{"used": {}}\n')
self.write(config / OLD / "approvers.json", '{"credentials": []}\n', 0o600)
(config / OLD).chmod(0o700)
units = config / "systemd" / "user"
self.write(units / f"{OLD}-runner.service", "[Unit]\n")
(units / "default.target.wants").mkdir()
(units / "default.target.wants" / f"{OLD}-runner.service").symlink_to(units / f"{OLD}-runner.service")
self.other = {"matcher": "Bash", "hooks": [{"type": "command", "command": "other-hook"}]}
legacy = {"matcher": "Bash", "hooks": [{"type": "command", "command": f"python3 {share}/{OLD}/client/permission_hook.py --port 8737"}]}
self.write(self.home / ".claude" / "settings.json", json.dumps({"model": "x", "hooks": {"PermissionRequest": [self.other, legacy], "Stop": [self.other]}}), 0o640)
def write(self, path: Path, text: str, mode: int = 0o644) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
path.chmod(mode)
def install(self) -> dict:
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)
return json.loads(self.state.read_text(encoding="utf-8"))
def test_install_migrates_every_trace_once(self):
first = self.install()
share, local, config = self.home / ".local" / "share", self.home / ".local" / "state", self.home / ".config"
units = config / "systemd" / "user"
for gone in (share / OLD, local / OLD, local / f"{OLD}-approvals", config / OLD, units / f"{OLD}-runner.service", units / "default.target.wants" / f"{OLD}-runner.service"):
with self.subTest(gone=gone):
self.assertFalse(gone.exists() or gone.is_symlink())
self.assertEqual((local / NEW / "rules.json").read_text(encoding="utf-8"), '{"rule": 1}\n')
self.assertEqual((local / f"{NEW}-approvals" / "runner.json").read_text(encoding="utf-8"), '{"used": {}}\n')
self.assertEqual(((config / NEW).stat().st_mode & 0o777, (config / NEW / "approvers.json").stat().st_mode & 0o777), (0o700, 0o600))
self.assertTrue((units / f"{NEW}-runner.service").is_file())
self.assertIn(f"KVB_ORCHESTRATOR_STATE={local / NEW}", (share / NEW / ".env").read_text(encoding="utf-8"))
settings = self.home / ".claude" / "settings.json"
data = json.loads(settings.read_text(encoding="utf-8"))
hooks = data["hooks"]["PermissionRequest"]
self.assertEqual((data["model"], data["hooks"]["Stop"], hooks[0], len(hooks)), ("x", [self.other], self.other, 2))
self.assertIn(f"{share / NEW}/client/permission_hook.py", hooks[1]["hooks"][0]["command"])
self.assertEqual(settings.stat().st_mode & 0o777, 0o640)
self.assertIn(f"{OLD}/client", (self.home / ".claude" / f"settings.json.before-{NEW}-migration").read_text(encoding="utf-8"))
self.assertEqual((first["images"], first["mcp"], first["enabled"]), ([f"{NEW}:local"], {NEW: "http://127.0.0.1:8737/mcp"}, [f"{NEW}-runner.service"]))
self.assertIn(["docker", "compose", "--file", str(share / OLD / "compose.yml"), "down", "--remove-orphans"], first["calls"])
before = snapshot(self.home)
second = self.install()
self.assertEqual(snapshot(self.home), before)
self.assertEqual({key: second[key] for key in ("images", "mcp", "enabled")}, {key: first[key] for key in ("images", "mcp", "enabled")})
legacy_calls = [call for call in second["calls"][len(first["calls"]) :] if any(OLD in arg for arg in call)]
self.assertEqual(legacy_calls, [["docker", "image", "inspect", f"{OLD}:local"], ["claude", "mcp", "get", OLD]])
def test_old_names_live_only_in_the_migration(self):
done = subprocess.run(["git", "grep", "-niE", "kvb[-_]hub", "--", ".", ":!docs/requirements", f":!{Path(__file__).relative_to(REPO_ROOT)}"], cwd=REPO_ROOT, capture_output=True, text=True, check=False)
hits = sorted(line.split(":", 2)[0] + ":" + line.split(":", 2)[2].strip() for line in done.stdout.splitlines())
self.assertEqual(hits, ['orchestrator/templates/page-state.js:let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));', 'scripts/orchestrator.sh:LEGACY="kvb-hub"'])
if __name__ == "__main__":
unittest.main()