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,90 @@
|
||||
import {spawn} from "node:child_process";
|
||||
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from "node:fs";
|
||||
import {tmpdir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
|
||||
// Args: <chromium binary> <plan.json>; the plan lists steps {width, height, url, wait, probe, shot}.
|
||||
// Prints {results, errors}: one probe value per step and every uncaught page exception.
|
||||
const [binary, planFile] = process.argv.slice(2);
|
||||
const plan = JSON.parse(readFileSync(planFile, "utf8"));
|
||||
const profile = mkdtempSync(join(tmpdir(), "cdp-"));
|
||||
// --no-sandbox: the agent sandbox already forbids the user namespaces Chromium's own sandbox needs.
|
||||
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
|
||||
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const address = await new Promise((resolve, reject) => {
|
||||
let text = "";
|
||||
const timer = setTimeout(() => reject(new Error(`no DevTools address: ${text.slice(-500)}`)), 30000);
|
||||
browser.stderr.on("data", (chunk) => {
|
||||
text += chunk;
|
||||
const found = /DevTools listening on (ws:\/\/\S+)/.exec(text);
|
||||
if (found) {
|
||||
clearTimeout(timer);
|
||||
resolve(found[1]);
|
||||
}
|
||||
});
|
||||
browser.on("exit", (code) => reject(new Error(`browser exited with ${code}: ${text.slice(-500)}`)));
|
||||
});
|
||||
|
||||
const socket = new WebSocket(address);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.onopen = resolve;
|
||||
socket.onerror = reject;
|
||||
});
|
||||
const waiting = new Map();
|
||||
const errors = [];
|
||||
let next = 0;
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.method === "Runtime.exceptionThrown") {
|
||||
const details = data.params.exceptionDetails;
|
||||
errors.push(details.exception ? details.exception.description : details.text);
|
||||
}
|
||||
if (data.id && waiting.has(data.id)) {
|
||||
const {resolve, reject} = waiting.get(data.id);
|
||||
waiting.delete(data.id);
|
||||
if (data.error) {
|
||||
reject(new Error(JSON.stringify(data.error)));
|
||||
} else {
|
||||
resolve(data.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function send(method, params, sessionId) {
|
||||
next += 1;
|
||||
const id = next;
|
||||
socket.send(JSON.stringify({id, method, params, ...(sessionId ? {sessionId} : {})}));
|
||||
return new Promise((resolve, reject) => waiting.set(id, {resolve, reject}));
|
||||
}
|
||||
|
||||
const results = [];
|
||||
try {
|
||||
const {targetId} = await send("Target.createTarget", {url: "about:blank"});
|
||||
const {sessionId} = await send("Target.attachToTarget", {targetId, flatten: true});
|
||||
const page = (method, params) => send(method, params, sessionId);
|
||||
await page("Runtime.enable", {});
|
||||
for (const step of plan.steps) {
|
||||
await page("Emulation.setDeviceMetricsOverride", {width: step.width, height: step.height, deviceScaleFactor: 1, mobile: false});
|
||||
if (step.url) {
|
||||
await page("Page.navigate", {url: step.url});
|
||||
}
|
||||
await sleep(step.wait);
|
||||
let value = null;
|
||||
if (step.probe) {
|
||||
const {result, exceptionDetails} = await page("Runtime.evaluate", {expression: step.probe, awaitPromise: true, returnByValue: true});
|
||||
value = exceptionDetails ? {error: exceptionDetails.exception ? exceptionDetails.exception.description : exceptionDetails.text} : result.value;
|
||||
}
|
||||
if (step.shot) {
|
||||
const {data} = await page("Page.captureScreenshot", {format: "png"});
|
||||
writeFileSync(step.shot, Buffer.from(data, "base64"));
|
||||
}
|
||||
results.push(value);
|
||||
}
|
||||
} finally {
|
||||
socket.close();
|
||||
browser.kill();
|
||||
rmSync(profile, {recursive: true, force: true});
|
||||
}
|
||||
console.log(JSON.stringify({results, errors}));
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE.parent))
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import ASK, iso
|
||||
|
||||
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-e2e"))
|
||||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||
SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in ("orbit", "chord", "swimlane", "tree", "3d"))]
|
||||
PROBE = """(() => {
|
||||
const all = (selector) => [...document.querySelectorAll(selector)];
|
||||
const text = (node) => (node ? node.textContent.trim() : null);
|
||||
const label = (button) => text(button.firstChild);
|
||||
const badge = (button) => {
|
||||
const found = button.querySelector(".badge");
|
||||
return found && !found.hidden ? text(found) : null;
|
||||
};
|
||||
const banner = document.getElementById("mayday");
|
||||
return {
|
||||
hash: location.hash,
|
||||
title: document.title,
|
||||
h1: text(document.querySelector("h1")),
|
||||
header: all("header.top button").map((button) => button.id),
|
||||
tabs: all("#tabs > .tab-group > button").map(label),
|
||||
subs: all("#tabs .subtabs button").map(label),
|
||||
badges: Object.fromEntries(all("#tabs button").map((button) => [button.id, badge(button)])),
|
||||
banner: banner.hidden ? null : text(banner),
|
||||
rail: Boolean(document.getElementById("rail")),
|
||||
views: all('#main [aria-label="Ansicht"] button').map(text),
|
||||
chips: all('#main [aria-label="Kategorien"] button').map(text),
|
||||
menus: all("#main details.menu > summary").map(text),
|
||||
time: text(document.querySelector("#time > summary")),
|
||||
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
|
||||
custom: Boolean(document.querySelector('#main details.menu input[type="datetime-local"]')),
|
||||
toolbar: all('#main [role="toolbar"] > *').map((node) => (node.tagName === "DETAILS" ? text(node.querySelector("summary")) : text(node))),
|
||||
toAll: all("#to-all input[type=radio]").map((input) => [input.value, input.checked]),
|
||||
inbox: all("#main .inbox-list > li").length,
|
||||
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||
};
|
||||
})()"""
|
||||
ACTIONS = """(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
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")]};
|
||||
document.getElementById("settings-open").click();
|
||||
await sleep(300);
|
||||
found.ambience = [...document.querySelectorAll(".sheet-box form > section:first-child button")].map((button) => button.id);
|
||||
found.speech = [...document.querySelectorAll('.sheet-box [aria-label="Klassen"] input')].map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
|
||||
document.querySelector(".sheet-head button").click();
|
||||
document.getElementById("palette-open").click();
|
||||
await sleep(300);
|
||||
found.palette = [...document.querySelectorAll(".palette-list li > span:first-child")].map((span) => span.textContent.trim());
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||
await sleep(200);
|
||||
document.querySelector('#main [aria-label="Kategorien"] button:nth-child(2)').click();
|
||||
await sleep(900);
|
||||
const someone = document.querySelector('#main button.linkish[title$="öffnen"]');
|
||||
someone.click();
|
||||
await sleep(500);
|
||||
found.drawer = [...document.querySelectorAll("#drawer input[type=radio]")].map((input) => [input.value, input.checked]);
|
||||
return found;
|
||||
})()"""
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
||||
class TestOrchestratorE2E(HubCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
now = time.time()
|
||||
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"},
|
||||
"rasci.json": {"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}},
|
||||
"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},
|
||||
{"ts": now - 500, "from": "Stage", "to": ["Skills"], "text": "gate red", "class": "call", "v": 2},
|
||||
{"ts": now - 400, "from": "Maintainer", "to": [], "text": "main is green", "class": "newsletter", "v": 2},
|
||||
{"ts": now - 300, "from": "Maintainer", "to": [], "text": "rotate keys", "class": "security", "v": 2},
|
||||
{"ts": now - 60, "from": "Docs", "to": [], "text": "MAYDAY disk full", "class": "mayday", "v": 2},
|
||||
]),
|
||||
})
|
||||
self.request("feat: orchestrator", "")
|
||||
self.create_ask(ASK)
|
||||
repo = self.repos / "github.com" / "me" / "demo"
|
||||
repo.mkdir(parents=True)
|
||||
self.git(repo, "init", "--quiet", "--initial-branch=main")
|
||||
(repo / "a.txt").write_text("a\n", encoding="utf-8")
|
||||
self.git(repo, "add", "a.txt")
|
||||
self.git(repo, "commit", "--quiet", "-m", "a")
|
||||
self.git(repo, "worktree", "add", "--quiet", "-b", "feature", str(self.worktrees / "github.com" / "me" / "demo" / "feature"))
|
||||
self.base = f"http://{self.host}/"
|
||||
|
||||
def drive(self, steps: list[dict]) -> dict:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
|
||||
json.dump({"steps": steps}, handle)
|
||||
self.addCleanup(os.unlink, handle.name)
|
||||
done = subprocess.run(["node", str(HERE / "cdp.mjs"), os.environ.get("CHROMIUM", "chromium"), handle.name], capture_output=True, text=True, timeout=600, check=False)
|
||||
self.assertEqual(done.returncode, 0, done.stderr[-2000:])
|
||||
return json.loads(done.stdout)
|
||||
|
||||
def test_chromium_walks_every_screen_at_three_widths(self):
|
||||
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": str(OUT / f"chromium-{width}-{screen.replace('/', '-')}.png")} for width, height in WIDTHS for screen in SCREENS]
|
||||
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
|
||||
steps.append({"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": ACTIONS, "shot": None})
|
||||
data = self.drive(steps)
|
||||
self.assertEqual(data["errors"], [])
|
||||
probes = data["results"][: len(WIDTHS) * len(SCREENS)]
|
||||
for probe, step in zip(probes, steps, strict=False):
|
||||
with self.subTest(url=step["url"], width=step["width"]):
|
||||
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"], ["Inbox", "Regeln"]))
|
||||
self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-agents": "1", "tab-artifacts": None})
|
||||
self.assertEqual(probe["banner"], "MAYDAY · Docs: disk fullaufheben")
|
||||
self.assertFalse(probe["rail"])
|
||||
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
|
||||
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||||
by_hash = {probe["hash"]: probe for probe in probes}
|
||||
stream = by_hash["#stream"]
|
||||
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
|
||||
self.assertEqual((stream["menus"], stream["custom"]), (["Filter ▾"], True))
|
||||
self.assertEqual(by_hash["#stream/inbox"]["inbox"], 2)
|
||||
for tab in ("agents", "artifacts"):
|
||||
with self.subTest(tab=tab):
|
||||
self.assertEqual(by_hash[f"#{tab}/orbit"]["views"], ["Orbit", "Chord", "Swimlane", "Tree", "3D"])
|
||||
agents = by_hash["#agents/orbit"]
|
||||
self.assertEqual(agents["toolbar"], ["Funk an alle ▾", "Mayday aufheben", "Priorität", "Tag/Nacht ▾"])
|
||||
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
|
||||
redirects = data["results"][len(probes) : len(probes) + 3]
|
||||
self.assertEqual([probe["hash"] for probe in redirects], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
|
||||
actions = data["results"][-1]
|
||||
self.assertNotIn("error", actions)
|
||||
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#stream"])
|
||||
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
|
||||
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "SECURITE"])
|
||||
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
|
||||
for tool in ("Funk an alle …", "Mayday aufheben", "Priorität …", "Abend", "Regeln …", "Einstellungen …", "Himmel ansehen", "Leitstand umschalten", "Benachrichtigungen aktivieren"):
|
||||
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]])
|
||||
|
||||
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||
def test_firefox_renders_every_screen_at_three_widths(self):
|
||||
profile = Path(tempfile.mkdtemp(prefix="firefox-e2e-"))
|
||||
self.addCleanup(shutil.rmtree, profile, True)
|
||||
for width, height in WIDTHS:
|
||||
for screen in SCREENS:
|
||||
shot = OUT / f"firefox-{width}-{screen.replace('/', '-')}.png"
|
||||
shot.unlink(missing_ok=True)
|
||||
with self.subTest(width=width, screen=screen):
|
||||
command = [os.environ.get("FIREFOX", "firefox"), "--headless", "--no-remote", "--profile", str(profile), "--window-size", f"{width},{height}", "--screenshot", str(shot), f"{self.base}#{screen}"]
|
||||
subprocess.run(command, capture_output=True, timeout=120, check=False)
|
||||
self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+3
-3
@@ -103,7 +103,7 @@ class HubCase(unittest.TestCase):
|
||||
return self.rpc("tools/call", {"name": name, "arguments": arguments})["result"]
|
||||
|
||||
def guard(self, host: str) -> dict[str, str]:
|
||||
return {"Host": host, "Origin": f"http://{host}", "X-Kvb-Hub": "1"}
|
||||
return {"Host": host, "Origin": f"http://{host}", "X-Kvb-Orchestrator": "1"}
|
||||
|
||||
def pin(self) -> Key:
|
||||
signer = key(-7, "main")
|
||||
@@ -135,7 +135,7 @@ class HubCase(unittest.TestCase):
|
||||
return json.loads(body)
|
||||
|
||||
def client(self) -> dict[str, str]:
|
||||
return {"Host": self.host, "X-Kvb-Hub": "1"}
|
||||
return {"Host": self.host, "X-Kvb-Orchestrator": "1"}
|
||||
|
||||
def settle(self, check: Callable[[], bool]) -> None:
|
||||
deadline = time.monotonic() + 10
|
||||
@@ -197,7 +197,7 @@ class HubCase(unittest.TestCase):
|
||||
return self.call("POST", path, json.dumps(payload).encode(), {**headers, "Content-Type": "application/json"})
|
||||
|
||||
def local(self) -> dict[str, str]:
|
||||
return {"Host": self.host, "X-Kvb-Hub": "1"}
|
||||
return {"Host": self.host, "X-Kvb-Orchestrator": "1"}
|
||||
|
||||
def create_ask(self, payload: dict) -> str:
|
||||
status, _, body = self.post_json("/api/asks", payload, self.local())
|
||||
|
||||
@@ -16,16 +16,16 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
HUB = REPO_ROOT / "hub"
|
||||
HUB = REPO_ROOT / "orchestrator"
|
||||
sys.path[:0] = [str(HUB), str(HUB / "client")]
|
||||
CLIENT = HUB / "client" / "permission_hook.py"
|
||||
RUNNER = HUB / "client" / "runner.py"
|
||||
APPROVAL = HUB / "client" / "approval.py"
|
||||
SPEC = importlib.util.spec_from_file_location("kvb_hub_server", HUB / "server.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
|
||||
SPEC.loader.exec_module(server)
|
||||
RUNNER_SPEC = importlib.util.spec_from_file_location("kvb_hub_runner", RUNNER)
|
||||
RUNNER_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_runner", RUNNER)
|
||||
runner = importlib.util.module_from_spec(RUNNER_SPEC)
|
||||
RUNNER_SPEC.loader.exec_module(runner)
|
||||
import hub_api # noqa: F401
|
||||
@@ -40,7 +40,7 @@ GIT_ENV = {
|
||||
}
|
||||
TREE = "0123456789abcdef0123456789abcdef01234567"
|
||||
PAGE = "<!doctype html><title>diff</title><script>document.title = 'page script'</script>"
|
||||
HOST_STATE = Path("/home/op/.local/state/kvb-hub")
|
||||
HOST_STATE = Path("/home/op/.local/state/kvb-orchestrator")
|
||||
ASK = {"tool": "Bash", "command": "make deploy", "cwd": "/repo", "session": "s1", "agent": "", "timeout": 60}
|
||||
HOOK = {
|
||||
"session_id": "s1",
|
||||
@@ -61,6 +61,7 @@ STREAMS = [
|
||||
]
|
||||
VOICE = HUB / "templates" / "voice.js"
|
||||
SPACE = HUB / "templates" / "space.js"
|
||||
NAV = HUB / "templates" / "nav.js"
|
||||
|
||||
|
||||
class FakeScripts:
|
||||
|
||||
@@ -32,7 +32,7 @@ from hub_support import (
|
||||
verifier,
|
||||
zero_led,
|
||||
)
|
||||
from kvb_hub import language
|
||||
from kvb_orchestrator import language
|
||||
|
||||
PASSKEY_PAGE = HUB / "templates" / "page-passkey.js"
|
||||
PASSKEY_PROBE = """
|
||||
@@ -298,7 +298,7 @@ class TestApproval(unittest.TestCase):
|
||||
verifier.check(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", self.trust, self.now)
|
||||
|
||||
def test_the_pin_cli_writes_the_pinned_keys_with_mode_0600(self):
|
||||
approvers = self.base / "fresh" / "kvb-hub" / "approvers.json"
|
||||
approvers = self.base / "fresh" / "kvb-orchestrator" / "approvers.json"
|
||||
|
||||
def pin(blob: str, port: str) -> subprocess.CompletedProcess:
|
||||
argv = [sys.executable, str(APPROVAL), "pin", "--port", port, "--approvers", str(approvers), "--credential", blob]
|
||||
|
||||
@@ -41,10 +41,10 @@ class TestHubAsks(HubCase):
|
||||
self.claim("s1", "Skills session")
|
||||
bad_headers = (
|
||||
{"Host": self.host},
|
||||
{**self.local(), "X-Kvb-Hub": "0"},
|
||||
{**self.local(), "X-Kvb-Orchestrator": "0"},
|
||||
{**self.local(), "Origin": "http://evil.example"},
|
||||
{**self.local(), "Origin": "null"},
|
||||
{"Host": "evil.example", "X-Kvb-Hub": "1"},
|
||||
{"Host": "evil.example", "X-Kvb-Orchestrator": "1"},
|
||||
)
|
||||
for headers in bad_headers:
|
||||
with self.subTest(headers=headers):
|
||||
@@ -61,7 +61,7 @@ class TestHubAsks(HubCase):
|
||||
waiter = threading.Thread(target=lambda: results.append(self.ask_status(ask_id, "20")))
|
||||
waiter.start()
|
||||
time.sleep(0.3)
|
||||
for headers in ({"Host": self.host, "X-Kvb-Hub": "1"}, {"Host": self.host, "Origin": f"http://{self.host}"}, {**self.guard(self.host), "Origin": "http://evil.example"}):
|
||||
for headers in ({"Host": self.host, "X-Kvb-Orchestrator": "1"}, {"Host": self.host, "Origin": f"http://{self.host}"}, {**self.guard(self.host), "Origin": "http://evil.example"}):
|
||||
with self.subTest(headers=headers):
|
||||
self.assertEqual(self.call("POST", f"/api/asks/{ask_id}/allow", b"", headers)[0], 403)
|
||||
signer = key(-7, "main")
|
||||
@@ -115,7 +115,7 @@ class TestHubAsks(HubCase):
|
||||
return compromised
|
||||
|
||||
code, out = asked(deny)
|
||||
self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "deny", "message": "The operator denied this in the kvb-hub."}}}))
|
||||
self.assertEqual((code, json.loads(out)), (0, {"hookSpecificOutput": {"hookEventName": "PermissionRequest", "decision": {"behavior": "deny", "message": "The operator denied this in the Agent Orchestrator."}}}))
|
||||
self.assertEqual(asked(allow_by(key(-7, "main"))), (0, ""))
|
||||
self.pin()
|
||||
self.assertEqual(asked(allow_by(key(-8, "stranger"))), (0, ""))
|
||||
@@ -140,7 +140,7 @@ class TestHubAsks(HubCase):
|
||||
with self.subTest(name=name):
|
||||
process = self.hook(self.port, "3", {**HOOK, **change})
|
||||
self.assertEqual((self.finish(process), self.httpd.hub.asks.listed()), ((0, ""), []))
|
||||
self.assertRegex(process.stderr.read(), rf"\Akvb-hub: [^\n]*\b{name}\b[^\n]*\n\Z")
|
||||
self.assertRegex(process.stderr.read(), rf"\Akvb-orchestrator: [^\n]*\b{name}\b[^\n]*\n\Z")
|
||||
process = self.hook(self.port, "20", {**HOOK, "tool_input": {"command": "x" * 4000}})
|
||||
ask = self.first_ask()
|
||||
self.assertEqual(ask["command"], "x" * 4000)
|
||||
@@ -182,7 +182,7 @@ class TestHubAsks(HubCase):
|
||||
waiter.start()
|
||||
time.sleep(0.3)
|
||||
path = f"/api/decisions/{decision_id}/choose"
|
||||
self.assertEqual(self.post_json(path, {"label": "Merge now"}, {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403)
|
||||
self.assertEqual(self.post_json(path, {"label": "Merge now"}, {"Host": self.host, "X-Kvb-Orchestrator": "1"})[0], 403)
|
||||
self.assertEqual(self.post_json(path, {"label": "Later"}, self.guard(self.host))[0], 400)
|
||||
self.assertEqual(self.post_json(path, {"label": "Merge now", "x": 1}, self.guard(self.host))[0], 400)
|
||||
self.assertEqual(self.post_json(path, {"label": "Merge now"}, self.guard(self.host))[0], 200)
|
||||
|
||||
+17
-17
@@ -11,7 +11,7 @@ from urllib.parse import quote
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import ASK, HOST_STATE, iso, server
|
||||
from kvb_hub import handler, messaging, skill_scripts, transcripts
|
||||
from kvb_orchestrator import handler, messaging, skill_scripts, transcripts
|
||||
|
||||
|
||||
class TestHubBoard(HubCase):
|
||||
@@ -30,33 +30,33 @@ class TestHubBoard(HubCase):
|
||||
with mock.patch.object(handler, "UPLOAD_LIMIT", 8):
|
||||
self.assertEqual(self.call("POST", "/api/uploads?name=big", b"123456789", headers)[0], 413)
|
||||
self.assertEqual(self.call("POST", "/api/uploads?name=fits", b"12345678", headers)[0], 201)
|
||||
self.assertEqual(self.call("POST", "/api/uploads?name=x", b"1", {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403)
|
||||
self.assertEqual(self.call("POST", "/api/uploads?name=x", b"1", {"Host": self.host, "X-Kvb-Orchestrator": "1"})[0], 403)
|
||||
self.assertEqual(self.get("/api/uploads")[0], 405)
|
||||
|
||||
def test_operator_messages_go_by_radio_and_verify_over_mcp(self):
|
||||
self.claim("s1", "Skills session")
|
||||
headers = {**self.guard(self.host), "Content-Type": "application/octet-stream"}
|
||||
upload = json.loads(self.call("POST", "/api/uploads?name=plan.md", b"# plan", headers)[2])
|
||||
status, _, body = self.post_json("/api/messages", {"to": "Skills", "text": "please\n rebase", "upload": upload["id"]}, self.guard(self.host))
|
||||
status, _, body = self.post_json("/api/messages", {"to": "Skills", "text": "please\n rebase", "upload": upload["id"], "class": "post"}, self.guard(self.host))
|
||||
self.assertEqual(status, 201, body)
|
||||
sent = json.loads(body)
|
||||
self.assertTrue(sent["ok"])
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Skills session", "--session", "Operator", f"[kvb-hub msg {sent['id']}] please rebase file: {upload['path']}"])])
|
||||
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Skills session", "--session", "Operator", f"[kvb-orchestrator msg {sent['id']}] please rebase file: {upload['path']}"])])
|
||||
verified = self.tool("operator_message", {"id": sent["id"]})["structuredContent"]
|
||||
self.assertEqual((verified["to"], verified["text"], verified["upload"]), ("Skills", "please\n rebase", upload["path"]))
|
||||
self.assertEqual([(row["id"], row["to"], row["upload"]) for row in self.snapshot()["messages"]], [(sent["id"], "Skills", "plan.md")])
|
||||
self.assertTrue(self.tool("operator_message", {"id": "0123456789abcdef"})["isError"])
|
||||
self.assertTrue(self.tool("operator_message", {"id": "../x"})["isError"])
|
||||
for payload in (
|
||||
{"to": "Skills", "text": " ", "upload": ""},
|
||||
{"to": "Skills", "text": "hi", "upload": "0123456789abcdef"},
|
||||
{"to": "Skills", "text": "hi", "upload": "../../etc"},
|
||||
{"to": "two\nlines", "text": "hi", "upload": ""},
|
||||
{"to": "Skills", "text": "hi"},
|
||||
{"to": "Skills", "text": " ", "upload": "", "class": "post"},
|
||||
{"to": "Skills", "text": "hi", "upload": "0123456789abcdef", "class": "post"},
|
||||
{"to": "Skills", "text": "hi", "upload": "../../etc", "class": "post"},
|
||||
{"to": "two\nlines", "text": "hi", "upload": "", "class": "post"},
|
||||
{"to": "Skills", "text": "hi", "class": "post"},
|
||||
):
|
||||
with self.subTest(payload=payload):
|
||||
self.assertEqual(self.post_json("/api/messages", payload, self.guard(self.host))[0], 400)
|
||||
self.assertEqual(self.post_json("/api/messages", {"to": "Skills", "text": "hi", "upload": ""}, {"Host": self.host, "X-Kvb-Hub": "1"})[0], 403)
|
||||
self.assertEqual(self.post_json("/api/messages", {"to": "Skills", "text": "hi", "upload": "", "class": "post"}, {"Host": self.host, "X-Kvb-Orchestrator": "1"})[0], 403)
|
||||
self.assertEqual(len(self.scripts.calls), 1)
|
||||
|
||||
def test_board_actions_run_the_skill_scripts(self):
|
||||
@@ -78,9 +78,9 @@ class TestHubBoard(HubCase):
|
||||
status, result = board("clear-mayday", {})
|
||||
self.assertEqual((status, result["ok"], result["runs"][0]["stdout"]), (200, True, "done"))
|
||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"]))
|
||||
status, result = board("broadcast", {"text": "main is green"})
|
||||
status, result = board("broadcast", {"text": "main is green", "class": "newsletter"})
|
||||
message_id = self.tool("operator_message", {"id": self.snapshot()["messages"][-1]["id"]})["structuredContent"]["id"]
|
||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-hub msg {message_id}] main is green"]))
|
||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RADIO, ["post", "--session", "Operator", f"[kvb-orchestrator msg {message_id}] main is green"]))
|
||||
self.assertEqual(board("order", {"agents": ["A", "B session"]})[0], 200)
|
||||
self.assertEqual(self.scripts.calls[-1], (skill_scripts.RASCI, ["order", "A,B session"]))
|
||||
for payload in ({"agents": ["A,B"]}, {"agents": ["A", "A"]}, {"agents": []}, {"agents": ["-x"]}, {"agents": "A"}, {"text": "x"}):
|
||||
@@ -90,7 +90,7 @@ class TestHubBoard(HubCase):
|
||||
self.assertEqual(board("evening", {})[0], 200)
|
||||
self.assertEqual(self.scripts.calls[calls:], [
|
||||
(skill_scripts.NIGHT, ["set", "evening", "--session", "Operator"]),
|
||||
(skill_scripts.RADIO, ["send", "direct", "--wake", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]),
|
||||
(skill_scripts.RADIO, ["send", "call", "--to", "Gatekeeper", "--to", "Skills session", "--session", "Operator", messaging.NIGHTS["evening"][1]]),
|
||||
])
|
||||
self.assertEqual(board("sunrise", {})[0], 200)
|
||||
self.assertEqual(self.scripts.calls[-2][1], ["clear", "--session", "Operator"])
|
||||
@@ -99,14 +99,14 @@ class TestHubBoard(HubCase):
|
||||
status, result = board("sunset", {})
|
||||
self.assertEqual((status, result["ok"], self.scripts.calls[calls:]), (200, False, [(skill_scripts.NIGHT, ["set", "sunset", "--session", "Operator"])]))
|
||||
self.assertEqual(board("reboot", {})[0], 404)
|
||||
self.assertEqual(board("broadcast", {"text": " "})[0], 400)
|
||||
self.assertEqual(board("broadcast", {"text": " ", "class": "newsletter"})[0], 400)
|
||||
self.assertEqual(self.post_json("/api/board/sunset", {}, {"Host": self.host, "Origin": f"http://{self.host}"})[0], 403)
|
||||
self.assertEqual(self.get("/api/board/sunset")[0], 405)
|
||||
|
||||
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))
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertIn(b"KVB_HUB_LLM_URL", body)
|
||||
self.assertIn(b"KVB_ORCHESTRATOR_LLM_URL", body)
|
||||
seen: list[tuple[str, str | None, dict]] = []
|
||||
replies: list[str] = []
|
||||
|
||||
@@ -142,14 +142,14 @@ class TestHubBoard(HubCase):
|
||||
{"name": "approve", "arguments": {"ids": [review_id, decision_id]}},
|
||||
{"name": "choose", "arguments": {"id": decision_id, "label": "Wait"}},
|
||||
{"name": "message", "arguments": {"to": "Skills", "text": "rebase"}},
|
||||
{"name": "view", "arguments": {"name": "crons"}},
|
||||
{"name": "view", "arguments": {"name": "funk"}},
|
||||
{"name": "snooze", "arguments": {"ids": [ask_id], "hours": 2}},
|
||||
{"name": "approve", "arguments": {"ids": ["ffffffffffffffff"]}},
|
||||
{"name": "reject", "arguments": {"ids": [decision_id]}},
|
||||
{"name": "message", "arguments": {"to": "Nobody", "text": "x"}},
|
||||
{"name": "shutdown", "arguments": {}},
|
||||
{"name": "snooze", "arguments": {"ids": [ask_id], "hours": 500}},
|
||||
{"name": "view", "arguments": {"name": "crons", "extra": 1}},
|
||||
{"name": "view", "arguments": {"name": "funk", "extra": 1}},
|
||||
]}))
|
||||
status, _, body = self.post_json("/api/nl", {"text": "gib alles Grüne frei"}, self.guard(self.host))
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
@@ -23,7 +23,7 @@ from hub_support import (
|
||||
signing,
|
||||
verifier,
|
||||
)
|
||||
from kvb_hub import batches, rules, skill_scripts
|
||||
from kvb_orchestrator import batches, rules, skill_scripts
|
||||
|
||||
# An import named key trips F402: test_rules_are_signed_once_and_checked_by_the_executor rebinds it in a comprehension.
|
||||
key = hub_support.key
|
||||
|
||||
@@ -9,7 +9,7 @@ from unittest import mock
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import iso
|
||||
from kvb_hub import lifetimes, names, orbits
|
||||
from kvb_orchestrator import lifetimes, names, orbits
|
||||
|
||||
|
||||
class TestHubGraph(HubCase):
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest import mock
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import ASK, PAGE, TREE, key, signing
|
||||
from kvb_hub import batches
|
||||
from kvb_orchestrator import batches
|
||||
|
||||
|
||||
class TestHubInbox(HubCase):
|
||||
@@ -96,7 +96,7 @@ class TestHubInbox(HubCase):
|
||||
self.approvers.chmod(0o666)
|
||||
self.assertEqual(self.snapshot()["approvers"], [])
|
||||
html = self.index()
|
||||
for text in ("Passkey registrieren", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-hub/approvers.json", "nicht signiert"):
|
||||
for text in ("Passkey registrieren", "navigator.credentials.get", "navigator.credentials.create", "approval.py pin --port", "~/.config/kvb-orchestrator/approvers.json", "nicht signiert"):
|
||||
with self.subTest(text=text):
|
||||
self.assertIn(text, html)
|
||||
|
||||
@@ -112,8 +112,8 @@ class TestHubInbox(HubCase):
|
||||
|
||||
def test_the_title_counts_the_items_that_are_not_snoozed(self):
|
||||
asks = [self.create_ask(ASK), self.create_ask({**ASK, "command": "make other"})]
|
||||
self.assertIn("<title>(2) kvb-hub</title>", self.index())
|
||||
for ask_id, title in zip(asks, ("(1) kvb-hub", "kvb-hub"), strict=True):
|
||||
self.assertIn("<title>(2) Agent Orchestrator</title>", self.index())
|
||||
for ask_id, title in zip(asks, ("(1) Agent Orchestrator", "Agent Orchestrator"), strict=True):
|
||||
snooze = {"kind": "ask", "id": ask_id, "until": time.time() + 3600, "pinned": False}
|
||||
self.assertEqual(self.post_json("/api/inbox/mark", snooze, self.guard(self.host))[0], 200)
|
||||
with self.subTest(title=title):
|
||||
|
||||
@@ -6,7 +6,7 @@ import unittest
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import PAGE, SCORE, TREE
|
||||
from kvb_hub import handler, mcp
|
||||
from kvb_orchestrator import handler, mcp
|
||||
|
||||
TOOLS = {"review_request", "review_status", "review_wait", "review_list", "decision_request", "decision_wait", "operator_message"}
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestHubMcp(HubCase):
|
||||
init = self.rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}})
|
||||
self.assertEqual(init["result"]["protocolVersion"], "2025-06-18")
|
||||
self.assertIn("tools", init["result"]["capabilities"])
|
||||
self.assertEqual(init["result"]["serverInfo"]["name"], "kvb-hub")
|
||||
self.assertEqual(init["result"]["serverInfo"]["name"], "kvb-orchestrator")
|
||||
self.assertEqual(self.rpc("initialize", {"protocolVersion": "1999-01-01"})["result"]["protocolVersion"], mcp.PROTOCOLS[0])
|
||||
status, _, body = self.post_mcp({"jsonrpc": "2.0", "method": "notifications/initialized"}, {})
|
||||
self.assertEqual((status, body), (202, b""))
|
||||
@@ -95,12 +95,12 @@ class TestHubMcp(HubCase):
|
||||
def test_every_post_guard_answers_before_the_body_arrives(self):
|
||||
guard = self.guard(self.host)
|
||||
for path, headers, status, text in (
|
||||
("/api/asks", {"Host": "evil.example", "X-Kvb-Hub": "1"}, 403, "forbidden host"),
|
||||
("/api/asks", {"Host": self.host}, 403, "forbidden: local POST with the X-Kvb-Hub header and no foreign origin only"),
|
||||
("/api/asks", {"Host": "evil.example", "X-Kvb-Orchestrator": "1"}, 403, "forbidden host"),
|
||||
("/api/asks", {"Host": self.host}, 403, "forbidden: local POST with the X-Kvb-Orchestrator header and no foreign origin only"),
|
||||
("/mcp", {"Host": self.host, "Origin": "http://evil.example", "Content-Type": "application/json"}, 403, "foreign origin"),
|
||||
("/mcp", {"Host": self.host, "Content-Type": "text/plain"}, 415, "use application/json"),
|
||||
("/api/runner/0123456789abcdef/report", guard, 403, "forbidden: the runner calls with the X-Kvb-Hub header and no origin only"),
|
||||
("/api/messages", self.client(), 403, "forbidden: same-origin POST with the X-Kvb-Hub header only"),
|
||||
("/api/runner/0123456789abcdef/report", guard, 403, "forbidden: the runner calls with the X-Kvb-Orchestrator header and no origin only"),
|
||||
("/api/messages", self.client(), 403, "forbidden: same-origin POST with the X-Kvb-Orchestrator header only"),
|
||||
("/api/stt", {**guard, "Content-Type": "text/plain"}, 415, "use audio/wav"),
|
||||
("/api/state", guard, 404, "not found"),
|
||||
("/api/uploads", {**guard, "Content-Length": str(handler.UPLOAD_LIMIT + 1)}, 413, "body too large"),
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from hub_support import NAV
|
||||
|
||||
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"];
|
||||
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]),
|
||||
routes: hashes.map((hash) => N.route(hash)),
|
||||
hashes: [["stream", null], ["inbox", null], ["rules", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)),
|
||||
tabOf: ["stream", "inbox", "rules", "agents", "artifacts"].map((screen) => N.tabOf(screen)),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
class TestNavModule(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
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_and_rules(self):
|
||||
self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]])
|
||||
|
||||
def test_agents_and_artifacts_offer_five_views_in_order(self):
|
||||
self.assertEqual(self.data["views"], [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]])
|
||||
|
||||
def test_hashes_route_and_the_old_ones_redirect(self):
|
||||
stream = {"screen": "stream", "view": None, "redirect": None}
|
||||
self.assertEqual(self.data["routes"], [
|
||||
stream,
|
||||
stream,
|
||||
{"screen": "inbox", "view": None, "redirect": None},
|
||||
{"screen": "rules", "view": None, "redirect": None},
|
||||
stream,
|
||||
{"screen": "agents", "view": "chord", "redirect": None},
|
||||
{"screen": "agents", "view": "3d", "redirect": None},
|
||||
{"screen": "agents", "view": None, "redirect": None},
|
||||
{"screen": "agents", "view": None, "redirect": None},
|
||||
{"screen": "artifacts", "view": "tree", "redirect": None},
|
||||
{"screen": "artifacts", "view": "orbit", "redirect": None},
|
||||
{"screen": "agents", "view": "orbit", "redirect": "#agents/orbit"},
|
||||
{"screen": "agents", "view": "swimlane", "redirect": "#agents/swimlane"},
|
||||
{"screen": "agents", "view": "orbit", "redirect": "#agents/orbit"},
|
||||
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
|
||||
stream,
|
||||
{"screen": "agents", "view": "swimlane", "redirect": None},
|
||||
])
|
||||
|
||||
def test_every_screen_has_its_own_hash_and_tab(self):
|
||||
self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#agents/chord", "#artifacts/3d"])
|
||||
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "agents", "artifacts"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,8 +10,8 @@ from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import AMBIENCE, SPACE, VOICE
|
||||
from kvb_hub import exchange
|
||||
from hub_support import AMBIENCE, NAV, SPACE, VOICE
|
||||
from kvb_orchestrator import exchange
|
||||
|
||||
|
||||
class TestHubPage(HubCase):
|
||||
@@ -62,9 +62,9 @@ class TestHubPage(HubCase):
|
||||
self.assertEqual((state["reviews"][0]["title"], state["reviews"][0]["notes"]), ("<script>alert(1)</script>", "</pre><script>alert(2)</script>"))
|
||||
self.assertEqual((state["register"][0]["title"], state["radio"][0]["from"]), ("<script>alert(3)</script>", "<img src=x>"))
|
||||
|
||||
def test_the_index_scripts_parse_and_embed_the_ambience_voice_and_space_modules(self):
|
||||
def test_the_index_scripts_parse_and_embed_the_ambience_voice_nav_and_space_modules(self):
|
||||
html = self.index()
|
||||
for module in (AMBIENCE, VOICE, SPACE):
|
||||
for module in (AMBIENCE, VOICE, NAV, SPACE):
|
||||
with self.subTest(module=module.name):
|
||||
self.assertIn(module.read_text(encoding="utf-8").strip(), html)
|
||||
scripts = re.findall(r'<script nonce="[^"]+">(.*?)</script>', html, re.DOTALL)
|
||||
@@ -73,7 +73,7 @@ class TestHubPage(HubCase):
|
||||
page.write_text("\n;\n".join(scripts), encoding="utf-8")
|
||||
done = subprocess.run(["node", "--check", str(page)], capture_output=True, text=True, timeout=60, check=False)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
for module in (AMBIENCE, VOICE, SPACE):
|
||||
for module in (AMBIENCE, VOICE, NAV, SPACE):
|
||||
with self.subTest(module=module.name):
|
||||
done = subprocess.run(["node", "--check", str(module)], capture_output=True, text=True, timeout=60, check=False)
|
||||
self.assertEqual(done.returncode, 0, done.stderr)
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest import mock
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import PAGE, TREE, key
|
||||
from kvb_hub import ledger, review_page, reviewing
|
||||
from kvb_orchestrator import ledger, review_page, reviewing
|
||||
|
||||
REVIEW_PAGE = """<!DOCTYPE html><html><head><script>const x = "<details class='file'>";</script></head><body>
|
||||
<section class="group" id="staged" data-files="2">
|
||||
@@ -139,8 +139,8 @@ class TestHubReviews(HubCase):
|
||||
review_id = self.request("feat: guarded", "")
|
||||
good = self.guard(self.host)
|
||||
for headers in (
|
||||
{k: v for k, v in good.items() if k != "X-Kvb-Hub"},
|
||||
{**good, "X-Kvb-Hub": "0"},
|
||||
{k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"},
|
||||
{**good, "X-Kvb-Orchestrator": "0"},
|
||||
{k: v for k, v in good.items() if k != "Origin"},
|
||||
{**good, "Origin": "http://evil.example"},
|
||||
{**good, "Origin": "null"},
|
||||
|
||||
@@ -5,13 +5,13 @@ import unittest
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import STREAMS
|
||||
from kvb_hub import settings
|
||||
from kvb_orchestrator import settings
|
||||
|
||||
|
||||
class TestHubSettings(HubCase):
|
||||
def settings_payload(self, streams: list[dict]) -> dict:
|
||||
colors = {"top": "#101820", "bottom": "#405060", "accent": "#ffaa00"}
|
||||
speech = {"on": True, "classes": ["direct", "pan-pan", "mayday"], "muted": ["name:Docs", "s1"]}
|
||||
speech = {"on": True, "classes": ["call", "mayday", "pan-pan"], "muted": ["name:Docs", "s1"]}
|
||||
space = {"graph": {"axis": "time", "yaw": -45.5, "pitch": 12.25, "zoom": 1.5, "pan_x": -30.0, "pan_y": 12.0}, "sky": {"azimuth": 225.0, "altitude": 20.0, "fov": 120.0}}
|
||||
return {
|
||||
**settings.SETTINGS, "latitude": 52.52, "longitude": 13.405, "theme": "custom", "colors": colors, "music": {"on": True, "volume": 0.3}, "streams": streams, "speech": speech,
|
||||
@@ -26,7 +26,7 @@ class TestHubSettings(HubCase):
|
||||
self.assertEqual(self.snapshot()["settings"], settings.SETTINGS)
|
||||
payload = self.settings_payload(STREAMS)
|
||||
good = self.guard(self.host)
|
||||
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||||
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||||
with self.subTest(headers=headers):
|
||||
self.assertEqual(self.post_json("/api/settings", payload, headers)[0], 403)
|
||||
self.assertFalse((self.state / "settings.json").exists())
|
||||
@@ -47,7 +47,7 @@ class TestHubSettings(HubCase):
|
||||
{**payload, "streams": [{**stream, "to": "25:00"}]},
|
||||
*({**payload, "speech": {**payload["speech"], **change}} for change in (
|
||||
{"on": "yes"},
|
||||
{"classes": ["direct", "pan-pan"]},
|
||||
{"classes": ["call", "pan-pan"]},
|
||||
{"classes": ["mayday", "shout"]},
|
||||
{"classes": ["mayday", "mayday"]},
|
||||
{"classes": "mayday"},
|
||||
@@ -102,7 +102,7 @@ class TestHubSettings(HubCase):
|
||||
shuffled = {**payload, "speech": {"on": False, "classes": ["mayday", "broadcast", "security"], "muted": ["s1", "Operator", "name:Docs"]}}
|
||||
status, _, body = self.post_json("/api/settings", shuffled, good)
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["speech"], {"on": False, "classes": ["broadcast", "security", "mayday"], "muted": ["Operator", "name:Docs", "s1"]})
|
||||
self.assertEqual(json.loads(body)["speech"], {"on": False, "classes": ["broadcast", "mayday", "security"], "muted": ["Operator", "name:Docs", "s1"]})
|
||||
|
||||
def test_settings_written_before_the_radio_filter_keep_their_values(self):
|
||||
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "speech"}
|
||||
|
||||
@@ -7,7 +7,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
from hub_support import SPACE
|
||||
from kvb_hub import settings
|
||||
from kvb_orchestrator import settings
|
||||
|
||||
SPACE_NOW = 1_800_000_000
|
||||
SPACE_LOW = SPACE_NOW - 86400
|
||||
|
||||
@@ -16,7 +16,7 @@ from unittest import mock
|
||||
from hub_case import HubCase
|
||||
from hub_engines import OPERATOR_VOICE, VOICES, log_rows, wav_bytes
|
||||
from hub_support import HOST_STATE, HUB, server
|
||||
from kvb_hub import council, engines, exchange, handler, phrasing
|
||||
from kvb_orchestrator import council, engines, exchange, handler, phrasing
|
||||
|
||||
|
||||
class TestHubSpeech(HubCase):
|
||||
@@ -79,7 +79,7 @@ class TestHubSpeech(HubCase):
|
||||
calls = self.engines.scratch / "piper.jsonl"
|
||||
cache = self.state / "tts"
|
||||
request = {"text": "all stations, this is Docs, board, out", "voice": "en_US-test-high:2"}
|
||||
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||||
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||||
with self.subTest(headers=headers):
|
||||
self.assertEqual(self.post_json("/api/tts", request, headers)[0], 403)
|
||||
for broken in (
|
||||
@@ -136,7 +136,7 @@ class TestHubSpeech(HubCase):
|
||||
def post(body: bytes, headers: dict[str, str]) -> tuple[int, dict, bytes]:
|
||||
return self.call("POST", "/api/stt", body, {**headers, "Content-Type": "audio/wav"})
|
||||
|
||||
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Hub"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||||
for headers in ({k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"}, {k: v for k, v in good.items() if k != "Origin"}, {**good, "Origin": "http://evil.example"}):
|
||||
with self.subTest(headers=headers):
|
||||
self.assertEqual(post(clip, headers)[0], 403)
|
||||
for content_type in ("application/octet-stream", "audio/x-wav", "text/plain", "application/json"):
|
||||
@@ -216,12 +216,12 @@ class TestPhraseology(unittest.TestCase):
|
||||
(("mayday", "Docs", ["Skills"], "mayday, mayday: disk full"), "MAYDAY MAYDAY MAYDAY, this is Docs Docs Docs, disk full, over"),
|
||||
(("pan-pan", "Docs", ["Skills"], "PAN-PAN: slow"), "PAN-PAN PAN-PAN PAN-PAN, all stations, this is Docs, slow, over"),
|
||||
(("security", "Maintainer", ["Skills", "Docs"], "SECURITE rotate"), "SECURITE SECURITE SECURITE, all stations, this is Maintainer, rotate, over"),
|
||||
(("direct", "Skills", ["Maintainer"], "ready"), "Maintainer, Maintainer, this is Skills, ready, over"),
|
||||
(("direct", "Skills", ["Alpha", "Beta"], "ready"), "Alpha and Beta, Alpha and Beta, this is Skills, ready, over"),
|
||||
(("direct", "Docs", ["Ops"], "MAYDAY is only a word here"), "Ops, Ops, this is Docs, MAYDAY is only a word here, over"),
|
||||
(("direct", "Skills", [], "lost target"), "all stations, this is Skills, lost target, out"),
|
||||
(("broadcast", "Maintainer", [], "board"), "all stations, this is Maintainer, board, out"),
|
||||
(("broadcast", "Operator", [], "[kvb-hub msg 0123456789abcdef] deploy now file: /home/op/x.txt"), "all stations, this is Operator, deploy now, out"),
|
||||
(("call", "Skills", ["Maintainer"], "ready"), "Maintainer, Maintainer, this is Skills, ready, over"),
|
||||
(("post", "Skills", ["Alpha", "Beta"], "ready"), "Alpha and Beta, Alpha and Beta, this is Skills, ready, over"),
|
||||
(("call", "Docs", ["Ops"], "MAYDAY is only a word here"), "Ops, Ops, this is Docs, MAYDAY is only a word here, over"),
|
||||
(("post", "Skills", [], "lost target"), "all stations, this is Skills, lost target, out"),
|
||||
(("newsletter", "Maintainer", [], "board"), "all stations, this is Maintainer, board, out"),
|
||||
(("broadcast", "Operator", [], "[kvb-orchestrator msg 0123456789abcdef] deploy now file: /home/op/x.txt"), "all stations, this is Operator, deploy now, out"),
|
||||
(("broadcast", "Docs", [], " two\n lines\t here "), "all stations, this is Docs, two lines here, out"),
|
||||
(("broadcast", "", [], "-"), "all stations, this is unknown station, out"),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest import mock
|
||||
from hub_case import HubCase
|
||||
from hub_engines import OPERATOR_VOICE, VOICES
|
||||
from hub_support import ASK
|
||||
from kvb_hub import council, engines, exchange, phrasing, view
|
||||
from kvb_orchestrator import council, engines, exchange, phrasing, view
|
||||
|
||||
|
||||
class TestHubState(HubCase):
|
||||
@@ -149,6 +149,7 @@ class TestHubState(HubCase):
|
||||
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
||||
"register", "priority", "radio", "claims", "grants", "night", "silence", "graph",
|
||||
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
||||
"badges", "kinds",
|
||||
},
|
||||
)
|
||||
self.assertEqual([row["speech"] for row in state["radio"]], [
|
||||
@@ -170,9 +171,9 @@ class TestHubState(HubCase):
|
||||
self.assertEqual((state["night"], state["silence"], state["priority"]), ("day", None, ["Skills session", "subagent general-purpose (default)"]))
|
||||
radio = [(row["from"], row["to"], row["class"], row["text"]) for row in state["radio"]]
|
||||
self.assertEqual(radio, [
|
||||
("Skills", ["Maintainer"], "direct", "ready"),
|
||||
("Skills", ["Maintainer"], "direct", "again"),
|
||||
("Maintainer", [], "broadcast", "board"),
|
||||
("Skills", ["Maintainer"], "post", "ready"),
|
||||
("Skills", ["Maintainer"], "post", "again"),
|
||||
("Maintainer", [], "newsletter", "board"),
|
||||
("Docs", [], "mayday", "MAYDAY disk full"),
|
||||
("Docs", ["Skills"], "pan-pan", "PAN-PAN slow"),
|
||||
("Maintainer", ["Skills", "Docs"], "security", "rotate"),
|
||||
@@ -196,11 +197,11 @@ class TestHubState(HubCase):
|
||||
self.assertEqual(edges, {
|
||||
("name:Docs", "*", "mayday"): 1,
|
||||
("name:Docs", "s1", "pan-pan"): 1,
|
||||
("name:Maintainer", "*", "broadcast"): 1,
|
||||
("name:Maintainer", "*", "newsletter"): 1,
|
||||
("name:Maintainer", "name:Docs", "security"): 1,
|
||||
("name:Maintainer", "s1", "grant"): 1,
|
||||
("name:Maintainer", "s1", "security"): 1,
|
||||
("s1", "name:Maintainer", "direct"): 2,
|
||||
("s1", "name:Maintainer", "post"): 2,
|
||||
})
|
||||
|
||||
def test_state_needs_the_host(self):
|
||||
|
||||
+10
-10
@@ -8,7 +8,7 @@ from unittest import mock
|
||||
|
||||
from hub_case import HubCase
|
||||
from hub_support import ASK, iso
|
||||
from kvb_hub import events, transcripts
|
||||
from kvb_orchestrator import events, transcripts
|
||||
|
||||
|
||||
class TestHubStream(HubCase):
|
||||
@@ -90,21 +90,21 @@ class TestHubStream(HubCase):
|
||||
|
||||
self.assertEqual(sorted(set(kinds("view=fuer-dich"))), ["ask", "decision", "operator", "radio", "review"])
|
||||
self.assertEqual([row["text"] for row in self.events("view=fuer-dich")["events"] if row["kind"] == "radio"], ["MAYDAY disk full"])
|
||||
self.assertEqual(kinds("view=reviews"), ["review"])
|
||||
self.assertEqual(kinds("view=radio"), ["radio", "radio"])
|
||||
self.assertEqual(kinds("view=operator"), ["operator"])
|
||||
self.assertEqual(kinds("view=entscheidungen"), ["decision"])
|
||||
self.assertEqual(kinds("kinds=review"), ["review"])
|
||||
self.assertEqual(kinds("view=funk"), ["radio", "radio"])
|
||||
self.assertEqual(sorted(set(kinds("view=arbeit"))), ["ask", "decision", "operator", "review"])
|
||||
self.assertEqual(kinds("kinds=decision"), ["decision"])
|
||||
self.assertEqual(kinds("kinds=radio&q=mayday"), ["radio"])
|
||||
self.assertEqual(kinds("kinds=ask,decision&waiting=1"), ["ask", "decision"])
|
||||
self.assertEqual(kinds("kinds=radio&agent=Docs"), ["radio", "radio"])
|
||||
self.assertEqual(kinds("view=register"), [])
|
||||
self.assertEqual(kinds("kinds=register,rename"), [])
|
||||
self.write_council({"rasci.json": {**register, "work/a": {**register["work/a"], "status": "running"}}})
|
||||
self.assertEqual([(row["title"], row["text"]) for row in self.events("view=register")["events"]], [("work/a", "status: queued → running")])
|
||||
for query in ("view=bogus", "kinds=bogus", "parts=bogus", "after=x"):
|
||||
self.assertEqual([(row["title"], row["text"]) for row in self.events("kinds=register,rename")["events"]], [("work/a", "status: queued → running")])
|
||||
for query in ("view=bogus", "view=reviews", "kinds=bogus", "parts=bogus", "after=x"):
|
||||
with self.subTest(query=query):
|
||||
self.assertEqual(self.get(f"/api/events?{query}")[0], 400)
|
||||
sample = events.event({"id": "x", "ts": now, "kind": "review", "title": "feat: x", "waiting": True})
|
||||
filters = events.event_filters({"view": ["reviews"]})
|
||||
filters = events.event_filters({"kinds": ["review"]})
|
||||
self.assertTrue(events.wanted(sample, filters))
|
||||
self.assertFalse(events.wanted({**sample, "kind": "radio"}, filters))
|
||||
self.assertFalse(events.wanted({**sample, "waiting": False}, events.event_filters({"view": ["fuer-dich"]})))
|
||||
@@ -141,7 +141,7 @@ class TestHubStream(HubCase):
|
||||
|
||||
def crons() -> dict:
|
||||
with mock.patch.object(transcripts, "STREAM_SCAN", 0):
|
||||
rows = self.events("view=crons")["events"]
|
||||
rows = self.events("kinds=cron")["events"]
|
||||
return {row["title"]: (row["ref"]["active"], row["agent"], row["text"]) for row in rows}
|
||||
|
||||
self.assertEqual(crons(), {
|
||||
|
||||
@@ -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()
|
||||
@@ -9,7 +9,7 @@ import unittest
|
||||
import wave
|
||||
|
||||
from hub_support import VOICE
|
||||
from kvb_hub import engines, settings
|
||||
from kvb_orchestrator import engines, settings
|
||||
|
||||
VOICE_PROBE = """
|
||||
const V = require(process.argv[1]);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user