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>
This commit is contained in:
1 parent
54f3725aa1
commit
6f940b4415
125 files changed
+1725
-782
No files matched your search
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
from urllib.parse import quote
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import ASK
|
||||
from kvb_orchestrator import council, events, settings, skill_scripts, view
|
||||
|
||||
|
||||
class TestHubSurface(HubCase):
|
||||
def test_the_header_holds_only_the_title_the_palette_and_the_settings(self):
|
||||
html = self.index()
|
||||
self.assertIn("<title>Agent Orchestrator</title>", html)
|
||||
header = re.search(r'<header class="top">(.*?)</header>', html, re.DOTALL).group(1)
|
||||
self.assertIn("<h1>Agent Orchestrator</h1>", header)
|
||||
self.assertEqual(re.findall(r'<button[^>]*id="([^"]+)"', header), ["palette-open", "settings-open"])
|
||||
self.assertEqual(re.findall(r"<(?:nav|select|input|textarea)\b", header), [])
|
||||
for gone in ('id="rail"', 'id="inbox-bar"', 'id="status"', 'id="board-open"', 'id="rules-open"', 'id="timebar"'):
|
||||
with self.subTest(gone=gone):
|
||||
self.assertNotIn(gone, html)
|
||||
self.assertIn('<nav class="tabs" id="tabs"', html)
|
||||
|
||||
def test_badges_count_waiting_items_active_agents_and_open_runner_jobs(self):
|
||||
now = 1000.0
|
||||
inbox = [{"until": None}, {"until": None}, {"until": now + 60}]
|
||||
claims = [{"agent": "A", "beat": now - 10}, {"agent": "A", "beat": now - 20}, {"agent": "B", "beat": now - 900}, {"agent": "C", "beat": now - 901}, {"agent": "D", "beat": None}]
|
||||
jobs = [{"status": "queued"}, {"status": "running"}, {"status": "done"}, {"status": "failed"}]
|
||||
self.assertEqual(view.badges(inbox, claims, jobs, now), {"inbox": 2, "agents": 2, "artifacts": 2})
|
||||
self.assertEqual(view.badges([], [], [], now), {"inbox": 0, "agents": 0, "artifacts": 0})
|
||||
self.claim("s1", "Skills session")
|
||||
self.create_ask(ASK)
|
||||
self.assertEqual(self.snapshot()["badges"], {"inbox": 1, "agents": 1, "artifacts": 0})
|
||||
self.assertIn("<title>(1) Agent Orchestrator</title>", self.index())
|
||||
|
||||
def test_register_workers_map_to_the_agent_named_before_the_bracket(self):
|
||||
for raw, name in (("Stage (this session)", "Stage"), ("infinito-nexus-core-c8 (Production)", "infinito-nexus-core-c8"), ("Skills session", "Skills"), (" Docs ", "Docs"), ("(only)", "(only)"), (None, "")):
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(council.agent(raw), name)
|
||||
self.claim("s1", "Stage")
|
||||
self.write_council({"rasci.json": {"work/a": {"title": "A", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}}})
|
||||
labels = [node["label"] for node in self.snapshot()["graph"]["nodes"]]
|
||||
self.assertEqual(labels.count("Stage"), 1)
|
||||
self.assertNotIn("Stage (this session)", labels)
|
||||
|
||||
def test_radio_rows_carry_the_seven_classes_and_old_rows_keep_their_meaning(self):
|
||||
rows = [{"ts": 10.0 + n, "from": "A", "to": ["B"], "text": "x", "class": cls, "v": 2} for n, cls in enumerate(("post", "call", "receipt"))]
|
||||
rows += [{"ts": 20.0 + n, "from": "A", "to": [], "text": "x", "class": cls, "v": 2} for n, cls in enumerate(("newsletter", "broadcast", "mayday", "pan-pan", "security"))]
|
||||
rows += [
|
||||
{"ts": 30.0, "from": "A", "to": ["B"], "text": "x", "class": "direct"},
|
||||
{"ts": 31.0, "from": "A", "to": ["B"], "text": "x", "class": "direct", "wake": True},
|
||||
{"ts": 32.0, "from": "A", "to": [], "text": "x", "class": "broadcast"},
|
||||
{"ts": 33.0, "from": "A", "to": ["B"], "text": "x"},
|
||||
{"ts": 34.0, "from": "A", "to": [], "text": "MAYDAY disk full"},
|
||||
{"ts": 35.0, "from": "A", "to": [], "text": "x", "class": "shout", "v": 2},
|
||||
]
|
||||
self.assertEqual([row["class"] for row in council.radio_rows(rows, 0, 100)], [
|
||||
"post", "call", "receipt", "newsletter", "broadcast", "mayday", "pan-pan", "security",
|
||||
"post", "call", "newsletter", "post", "mayday", "newsletter",
|
||||
])
|
||||
self.assertEqual(council.CLASSES, ("post", "call", "newsletter", "broadcast", "mayday", "pan-pan", "security"))
|
||||
|
||||
def test_read_aloud_lists_the_seven_classes_and_migrates_old_choices(self):
|
||||
self.assertEqual(settings.speech_setting({"on": True, "classes": ["security", "mayday", "post", "call", "newsletter", "broadcast", "pan-pan"], "muted": []})["classes"], list(council.CLASSES))
|
||||
for classes in (["direct", "mayday"], ["post"], ["mayday", "receipt"]):
|
||||
with self.subTest(classes=classes), self.assertRaises(settings.ToolError):
|
||||
settings.speech_setting({"on": True, "classes": classes, "muted": []})
|
||||
stored = self.state / "old-settings.json"
|
||||
stored.write_text(json.dumps({**settings.SETTINGS, "speech": {"on": True, "classes": ["direct", "pan-pan", "mayday"], "muted": []}}), encoding="utf-8")
|
||||
self.assertEqual(settings.Settings(stored).get()["speech"]["classes"], ["post", "call", "mayday", "pan-pan"])
|
||||
|
||||
def test_funk_an_alle_sends_the_chosen_class_and_newsletter_goes_by_post(self):
|
||||
def board(payload: object) -> int:
|
||||
return self.post_json("/api/board/broadcast", payload, self.guard(self.host))[0]
|
||||
|
||||
def sent() -> tuple[list[str], str]:
|
||||
script, args = self.scripts.calls[-1]
|
||||
self.assertEqual(script, skill_scripts.RADIO)
|
||||
self.assertRegex(args[-1], r"^\[kvb-orchestrator msg [0-9a-f]{16}\] ")
|
||||
return args[:-1], args[-1].split("] ", 1)[1]
|
||||
|
||||
self.assertEqual(board({"text": "main is green", "class": "newsletter"}), 200)
|
||||
self.assertEqual(sent(), (["post", "--session", "Operator"], "main is green"))
|
||||
for cls in ("broadcast", "pan-pan", "security", "mayday"):
|
||||
with self.subTest(cls=cls):
|
||||
self.assertEqual(board({"text": "hear me", "class": cls}), 200)
|
||||
self.assertEqual(sent(), (["send", cls, "--session", "Operator"], "hear me"))
|
||||
calls = len(self.scripts.calls)
|
||||
for payload in ({"text": "x", "class": "post"}, {"text": "x", "class": "call"}, {"text": "x"}, {"text": "x", "class": 1}):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(board(payload), 400)
|
||||
self.assertEqual(len(self.scripts.calls), calls)
|
||||
|
||||
def test_the_agent_panel_sends_a_post_or_a_call(self):
|
||||
self.claim("s1", "Skills session")
|
||||
|
||||
def send(payload: dict) -> tuple[int, dict]:
|
||||
status, _, body = self.post_json("/api/messages", {"to": "Skills", "text": "rebase", "upload": "", **payload}, self.guard(self.host))
|
||||
return status, json.loads(body) if status == 201 else {}
|
||||
|
||||
status, sent = send({"class": "post"})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] rebase"]))
|
||||
status, sent = send({"class": "call"})
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["send", "call", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] rebase"]))
|
||||
for payload in ({"class": "newsletter"}, {"class": "mayday"}, {}):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(send(payload)[0], 400)
|
||||
|
||||
def test_the_stream_has_four_categories_and_transcripts_stay_out_by_default(self):
|
||||
self.assertEqual({key: value["label"] for key, value in events.VIEWS.items()}, {"fuer-dich": "Für dich", "funk": "Funk", "arbeit": "Arbeit", "transkripte": "Transkripte"})
|
||||
self.assertEqual(events.VIEWS["transkripte"]["kinds"], ["transcript"])
|
||||
self.assertNotIn("transcript", events.VIEWS["fuer-dich"]["kinds"] + events.VIEWS["funk"]["kinds"] + events.VIEWS["arbeit"]["kinds"])
|
||||
self.assertEqual(sorted(events.VIEWS["fuer-dich"]["kinds"]), sorted(events.VIEWS["funk"]["kinds"] + events.VIEWS["arbeit"]["kinds"]))
|
||||
self.assertEqual(self.snapshot()["kinds"], dict(events.KINDS))
|
||||
|
||||
def test_the_search_line_filters_by_agent_kind_and_age(self):
|
||||
now = time.time()
|
||||
news = [
|
||||
{"ts": now - 7200, "from": "Docs", "to": ["Skills"], "text": "old deploy"},
|
||||
{"ts": now - 60, "from": "Docs", "to": ["Skills"], "text": "fresh deploy"},
|
||||
{"ts": now - 30, "from": "Ops", "to": [], "text": "deploy done"},
|
||||
]
|
||||
self.write_council({"news.jsonl": self.jsonl(news)})
|
||||
self.request("feat: deploy", "")
|
||||
|
||||
def texts(q: str) -> list[str]:
|
||||
return [f"{row['kind']}:{row['agent'] or row['title']}:{row['text'] or row['title']}" for row in self.events(f"kinds={','.join(events.EVENT_KINDS)}&q={quote(q)}")["events"]]
|
||||
|
||||
self.assertEqual(texts("deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done", "review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("agent:Docs"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("art:funk deploy"), ["radio:Docs:old deploy", "radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
self.assertEqual(texts("art:review"), ["review:feat: deploy:feat: deploy"])
|
||||
self.assertEqual(texts("seit:1h agent:Docs"), ["radio:Docs:fresh deploy"])
|
||||
self.assertEqual(texts("seit:5m done"), ["radio:Ops:deploy done"])
|
||||
since = time.strftime("%Y-%m-%dT%H:%M", time.localtime(now - 600))
|
||||
self.assertEqual(texts(f"seit:{since} art:funk"), ["radio:Docs:fresh deploy", "radio:Ops:deploy done"])
|
||||
for q in ("art:bogus", "seit:soon", "seit:5y", "agent:"):
|
||||
with self.subTest(q=q):
|
||||
self.assertEqual(self.get(f"/api/events?q={quote(q)}")[0], 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user