From a388c6ef77c5d824c96b419e9b619cd6bb70f559 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 8 Nov 2026 13:37:02 +0000 Subject: [PATCH 1/7] fix(orchestrator): S1 fit, rename once, Deutschlandfunk e2e, squelch, tree cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement 001 phase 6, S1: - Fit: Agents and Artifacts in a graph view fill the window from 861 px up (body.fit flex column); the e2e walk probes #agents/orbit at every width for a page no taller than the window. - Rename once: a session seen under two names by one source (two claims) flipped its name on every state build, so the Stream logged a rename each poll. Names.observe keeps only the newest sighting per session and source, and the timeline emits each (session, before, after) pair once. - Deutschlandfunk: root cause was the signed media-src, which never listed the redirect target f111.rndfnk.com that st01.sslstream.dlf.de answers with (fixed in ba97f7c). The e2e now replays that chain, a 302 from one https origin to a second, in Chromium and Firefox, with self-signed throwaway certificates the drivers accept. - Squelch: a settings slider 0..1 scales every tone; at 0 no beep plays, music on or off (Voice.beep skips a silent tone). Stored settings without the key load with 1.0. - Tree: the missing tree file error names `pkgmgr branch tree` and the release and install step that makes the timer write it. Evidence: make test 655 OK, ruff and markdownlint clean (/tmp/s1-make-test-1.log); e2e 17 OK, none skipped (/tmp/s1-e2e-3.log); MEDUSA 14 files, 0 findings. 🎯 Fix: 94 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 90 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- docs/requirements/001-agent-orchestrator.md | 10 ++-- orchestrator/kvb_orchestrator/names.py | 20 ++++++- orchestrator/kvb_orchestrator/settings.py | 2 + orchestrator/kvb_orchestrator/tree.py | 5 +- orchestrator/templates/index.html.j2 | 8 +++ orchestrator/templates/page-alerts.js | 2 +- orchestrator/templates/page-screens.js | 1 + orchestrator/templates/page-settings.js | 4 +- orchestrator/templates/voice.js | 12 ++-- tests/e2e/bidi.mjs | 2 +- tests/e2e/cdp.mjs | 3 +- tests/e2e/test_orchestrator_e2e.py | 62 ++++++++++++++++++--- tests/test_hub_names.py | 31 +++++++++++ tests/test_hub_tree.py | 4 +- tests/test_hub_voice.py | 8 ++- 15 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 tests/test_hub_names.py diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index cbbd626..cd525db 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -581,16 +581,16 @@ flowchart TD ### Phase 6: fixes (S1) -- [ ] `#agents/orbit` at 1920, 1366 and 390 px has +- [x] `#agents/orbit` at 1920, 1366 and 390 px has `document.documentElement.scrollHeight <= innerHeight` (e2e probe). -- [ ] Two state builds with the same claims add no second rename event to +- [x] Two state builds with the same claims add no second rename event to the Stream (unit test on the timeline). -- [ ] The Deutschlandfunk AAC stream plays in Chromium and Firefox through +- [x] The Deutschlandfunk AAC stream plays in Chromium and Firefox through the radio (e2e with a recorded fixture of its redirect chain) and the root cause is named in the commit message. -- [ ] With squelch at 0 no beep plays, music on or off (unit test on the +- [x] With squelch at 0 no beep plays, music on or off (unit test on the audio scheduler). -- [ ] Without a tree file the Tree names the missing `pkgmgr branch tree` +- [x] Without a tree file the Tree names the missing `pkgmgr branch tree` and the release step. ### Phase 6: popups and search (S2) diff --git a/orchestrator/kvb_orchestrator/names.py b/orchestrator/kvb_orchestrator/names.py index a2bf2a1..04531ba 100644 --- a/orchestrator/kvb_orchestrator/names.py +++ b/orchestrator/kvb_orchestrator/names.py @@ -65,9 +65,14 @@ class Directory: def renames(self, begin: float, end: float) -> list[dict]: found = [] + shown = set() for session, own in self.by_session.items(): for before, after in itertools.pairwise(own): - if before["name"] != after["name"] and begin <= after["first_seen"] <= end: + pair = (session, before["name"], after["name"]) + if before["name"] == after["name"] or pair in shown: + continue + shown.add(pair) + if begin <= after["first_seen"] <= end: found.append(event({ "id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"], "now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}", @@ -89,9 +94,20 @@ class Names: self.observed: dict[str, str] = observed if isinstance(observed, dict) else {} def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None: + """Args: + sightings: (session, name, seen, source); of several sightings of + one session from one source only the newest counts, so two + claims under different names cannot flip the name back and + forth on every state build. + """ + newest: dict[tuple[str, str], tuple[str, str, float | None, str]] = {} + for sighting in sightings: + key = (sighting[0], sighting[3]) + if key not in newest or (sighting[2] or -math.inf) >= (newest[key][2] or -math.inf): + newest[key] = sighting with self.lock: changed = False - for session, name, seen, source in sightings: + for session, name, seen, source in newest.values(): key = f"{session}\x00{source}" if not session or not name or self.observed.get(key) == name: continue diff --git a/orchestrator/kvb_orchestrator/settings.py b/orchestrator/kvb_orchestrator/settings.py index 4c180c9..0316225 100644 --- a/orchestrator/kvb_orchestrator/settings.py +++ b/orchestrator/kvb_orchestrator/settings.py @@ -36,6 +36,7 @@ SETTINGS = { "direct": {"on": True, "volume": 0.5}, "radio": {"on": False, "volume": 0.25}, }, + "squelch": 1.0, "notify": True, "streams": [], "speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []}, @@ -195,6 +196,7 @@ def valid_settings(raw: object) -> dict: "colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]}, "music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)}, "sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES}, + "squelch": number_in(data["squelch"], "squelch", 0.0, 1.0), "notify": flag(data["notify"], "notify"), "streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])], "speech": speech_setting(data["speech"]), diff --git a/orchestrator/kvb_orchestrator/tree.py b/orchestrator/kvb_orchestrator/tree.py index 628d205..a5c2d5d 100644 --- a/orchestrator/kvb_orchestrator/tree.py +++ b/orchestrator/kvb_orchestrator/tree.py @@ -191,7 +191,10 @@ class Tree: try: info = self.file.stat() except OSError as error: - return {"repos": [], "scanned": None, "error": f"no pkgmgr tree file at {self.file}: {error.strerror}"} + return {"repos": [], "scanned": None, "error": ( + f"no pkgmgr tree file at {self.file}: {error.strerror}. The tree timer runs `pkgmgr branch tree`, which the installed pkgmgr " + "lacks until a release ships it: release pkgmgr and install it, then the timer writes the file within a minute." + )} stamp = (info.st_mtime_ns, info.st_size) if self.cached is None or stamp != self.stamp: self.cached, self.stamp = read_tree(self.file), stamp diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index bcc7b12..91f65a7 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -209,6 +209,14 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital .graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; } .stage-card { padding: 8px; } .stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); } +@media (min-width: 861px) { + body.fit { display: flex; flex-direction: column; height: 100vh; } + body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; } + body.fit main { padding-bottom: var(--sp-5); } + body.fit .graph-body { flex: 1; align-items: stretch; min-height: 0; } + body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; } + body.fit .panel { position: static; max-height: none; min-height: 0; overflow: auto; } +} .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); } .legend span { display: inline-flex; align-items: center; gap: 6px; } .swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); } diff --git a/orchestrator/templates/page-alerts.js b/orchestrator/templates/page-alerts.js index 4d37aab..25fc6a3 100644 --- a/orchestrator/templates/page-alerts.js +++ b/orchestrator/templates/page-alerts.js @@ -47,7 +47,7 @@ const newRadio = state.radio.filter((row) => !known.radio.has(`${row.ts}|${row.from}|${row.text.slice(0, 40)}`)); const direct = newRadio.filter((row) => row.to.includes(HUMAN)); const present = {mayday: newAlarms.length > 0, review: fresh.some((item) => item.kind !== "operator"), direct: direct.length > 0, radio: newRadio.length > 0}; - const kind = SOUND_KINDS.map(([id]) => id).find((id) => present[id] && settings.sounds[id].on); + const kind = Voice.beep(present, settings, SOUND_KINDS.map(([id]) => id)); if (kind) { play(kind, loudness(kind)); } diff --git a/orchestrator/templates/page-screens.js b/orchestrator/templates/page-screens.js index 68ada43..7477fe7 100644 --- a/orchestrator/templates/page-screens.js +++ b/orchestrator/templates/page-screens.js @@ -70,6 +70,7 @@ const main = document.getElementById("main"); main.setAttribute("aria-labelledby", `tab-${ui.screen}`); main.replaceChildren(screenOf()); + document.body.classList.toggle("fit", ["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])); drawn.screen = ui.screen; drawn.sig = sig; if (g3.host && !g3.host.isConnected) { diff --git a/orchestrator/templates/page-settings.js b/orchestrator/templates/page-settings.js index e4b8e12..f89a31a 100644 --- a/orchestrator/templates/page-settings.js +++ b/orchestrator/templates/page-settings.js @@ -111,6 +111,7 @@ const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value)); return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "an"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))}; }); + const squelch = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.squelch, "aria-label": "Squelch"}); const notify = h("input", {type: "checkbox", checked: settings.notify}); const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts}); const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY wird immer vorgelesen" : null})})); @@ -130,7 +131,7 @@ h("section", {}, h("h3", {}, "Musik"), h("p", {class: "muted"}, "Generative Ambient-Musik, offline erzeugt: ruhig bei leerer Inbox, etwas gespannter, wenn etwas wartet, leiser unter Meldungen."), h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Musik an"), h("label", {}, "Gesamtlautstärke (Musik und Radio)", volume))), h("section", {}, h("h3", {}, "Töne und Benachrichtigungen"), h("p", {class: "muted"}, "MAYDAY und PAN-PAN sind immer am lautesten. Bei mehreren neuen Ereignissen spielt nur das dringendste."), - tones.map((tone) => tone.row), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")), + tones.map((tone) => tone.row), h("label", {}, "Squelch: scales every tone, 0 plays none", squelch), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")), h("section", {}, h("h3", {}, "Funk vorlesen"), h("p", {class: "muted"}, state.speech.tts ? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen." @@ -152,6 +153,7 @@ colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])), music: {on: musicOn.checked, volume: Number(volume.value)}, sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])), + squelch: Number(squelch.value), notify: notify.checked, streams: rows.map((entry) => entry.read()), speech: { diff --git a/orchestrator/templates/voice.js b/orchestrator/templates/voice.js index 18390ca..521678d 100644 --- a/orchestrator/templates/voice.js +++ b/orchestrator/templates/voice.js @@ -12,10 +12,12 @@ const Voice = (() => { } function loudness(tone, settings) { - if (tone !== "mayday") { - return settings.sounds[tone].volume; - } - return Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume); + const level = tone === "mayday" ? Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume) : settings.sounds[tone].volume; + return level * settings.squelch; + } + + function beep(present, settings, kinds) { + return kinds.find((kind) => present[kind] && settings.sounds[kind].on && loudness(kind, settings) > 0) || null; } function speakable(row, speech) { @@ -64,7 +66,7 @@ const Voice = (() => { return buffer; } - return {toneOf, loudness, speakable, enqueue, frames, wav}; + return {toneOf, loudness, beep, speakable, enqueue, frames, wav}; })(); if (typeof module === "object" && module.exports) { module.exports = Voice; diff --git a/tests/e2e/bidi.mjs b/tests/e2e/bidi.mjs index 7efc667..67036a7 100644 --- a/tests/e2e/bidi.mjs +++ b/tests/e2e/bidi.mjs @@ -66,7 +66,7 @@ function send(method, params) { const results = []; try { - await send("session.new", {capabilities: {}}); + await send("session.new", {capabilities: {alwaysMatch: {acceptInsecureCerts: true}}}); await send("session.subscribe", {events: ["log.entryAdded"]}); if (plan.init) { await send("script.addPreloadScript", {functionDeclaration: plan.init}); diff --git a/tests/e2e/cdp.mjs b/tests/e2e/cdp.mjs index 763961a..38a41f0 100644 --- a/tests/e2e/cdp.mjs +++ b/tests/e2e/cdp.mjs @@ -13,7 +13,8 @@ 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. // --enable-unsafe-swiftshader: without a GPU, headless Chromium gives WebGL only through SwiftShader, which it no longer enables by itself. -const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"]; +// --ignore-certificate-errors: the https station fixtures use a throwaway self-signed certificate. +const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-certificate-errors", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--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 KEYS = {Enter: [13, "\r"], Tab: [9, ""]}; diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index e657595..c8f3d78 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -8,6 +8,7 @@ import json import math import os import shutil +import ssl import subprocess import sys import tempfile @@ -17,6 +18,7 @@ import unittest from collections.abc import Callable from pathlib import Path from unittest import mock +from urllib.parse import urlsplit HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE.parent)) @@ -101,6 +103,7 @@ PROBE = """(async () => {@HELPERS@ flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side), openWork: all("#main .open-work").length, wide: document.documentElement.scrollWidth > window.innerWidth + 1, + tall: document.documentElement.scrollHeight > window.innerHeight, }; })()""".replace("@HELPERS@", HELPERS) ORG_OPEN = """(async () => {@HELPERS@ @@ -351,6 +354,8 @@ ADD_STATION = """(async () => {@HELPERS@ return {toasts: all("#toasts .toast").map(text)}; })()""".replace("@HELPERS@", HELPERS) STATION_NAME = "E2E Ton mit einem sehr langen Sendernamen, der in keine Zeile passt" +DLF_PATH = "/dlf/01/high/aac/stream.aac?aggregator=web" +DLF_NODE = "/ard/dlf/01/aac/192/stream.aac?aggregator=web" RADIO_STATE = """(async () => {@HELPERS@@MEDIA@ await until(() => log().some((entry) => entry.play), 8000); return {log: log(), media: await advancing(), toasts: all("#toasts .toast").map(text)}; @@ -481,6 +486,12 @@ def tone(seconds: int, rate: int) -> bytes: class Station(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: + if self.server.moved: + self.send_response(302) + self.send_header("Location", self.server.moved) + self.send_header("Content-Length", "0") + self.end_headers() + return body = self.server.body self.send_response(200) self.send_header("Content-Type", "audio/wav") @@ -677,15 +688,41 @@ class TestOrchestratorE2E(HubCase): patch.start() self.addCleanup(patch.stop) - def station(self) -> str: + def station(self, tls: ssl.SSLContext | None = None, moved: str | None = None) -> str: + """Args: + tls: serves https with this context instead of plain http. + moved: answers every request with a 302 to this URL instead of + the tone. + """ httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Station) - httpd.body = tone(30, 22050) + httpd.body, httpd.moved = tone(30, 22050), moved + if tls: + httpd.socket = tls.wrap_socket(httpd.socket, server_side=True) thread = threading.Thread(target=httpd.serve_forever, args=(0.05,), daemon=True) thread.start() self.addCleanup(httpd.server_close) self.addCleanup(thread.join, 10) self.addCleanup(httpd.shutdown) - return f"http://127.0.0.1:{httpd.server_address[1]}/tone.wav" + return f"{'https' if tls else 'http'}://127.0.0.1:{httpd.server_address[1]}{DLF_PATH if moved else '/tone.wav'}" + + def redirected(self) -> tuple[str, list[str]]: + """Returns: + A station that replays the recorded Deutschlandfunk chain, a 302 + from st01.sslstream.dlf.de to an f111.rndfnk.com node, as a 302 to + a second https origin, and that origin as its released via. + """ + folder = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, folder) + key, cert = folder / "key.pem", folder / "cert.pem" + subprocess.run([ + "openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1", "-days", "1", "-keyout", str(key), "-out", str(cert), + ], capture_output=True, timeout=60, check=True) + tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls.load_cert_chain(cert, key) + node = urlsplit(self.station(tls)) + origin = f"https://{node.netloc}" + return self.station(tls, f"{origin}{DLF_NODE}"), [origin] def check_overlaps(self, browser: str, driver: str, binary: str) -> None: steps = [] @@ -706,20 +743,23 @@ class TestOrchestratorE2E(HubCase): self.assertIn("Verzeichnis (Glob)", rules["review"]["labels"]) self.assertEqual(settings, {"found": [], "outside": []}) - def check_radio(self, browser: str, driver: str, binary: str) -> dict: + def check_radio(self, browser: str, driver: str, binary: str, redirected: bool = False) -> dict: """Args: driver: cdp.mjs saves the station through the settings form and signs its new origin with a virtual passkey; bidi.mjs has no virtual authenticator, so the station is saved before the page loads. + redirected: plays the recorded Deutschlandfunk redirect chain, + saved before the page loads with its resolved via. """ - station = self.station() - signs = driver == "cdp.mjs" + station, via = self.redirected() if redirected else (self.station(), []) + browser = f"{browser}-dlf" if redirected else browser + signs = driver == "cdp.mjs" and not redirected if signs: first = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#stream", "wait": 900, "probe": ADD_STATION.replace("@STATION@", station).replace("@NAME@", STATION_NAME), "shot": None, "gesture": True} else: hub = self.httpd.hub - hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": []}]}, hub.settings.get()) + hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": via}]}, hub.settings.get()) first = {"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": None, "shot": None} steps = [ first, @@ -761,6 +801,13 @@ class TestOrchestratorE2E(HubCase): def test_firefox_plays_a_saved_station(self): self.check_radio("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox")) + def test_chromium_plays_deutschlandfunk_through_its_redirect(self): + self.check_radio("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), redirected=True) + + @unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox") + def test_firefox_plays_deutschlandfunk_through_its_redirect(self): + self.check_radio("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), redirected=True) + 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": self.shot(f"{width}-{screen.replace('/', '-')}")} 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")] @@ -786,6 +833,7 @@ class TestOrchestratorE2E(HubCase): self.assertGreater(probe["icons"], 8) self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []}) self.assertFalse(probe["wide"], "the page scrolls sideways") + self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS)) 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)) diff --git a/tests/test_hub_names.py b/tests/test_hub_names.py new file mode 100644 index 0000000..c3080f8 --- /dev/null +++ b/tests/test_hub_names.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +import hub_support # noqa: F401 +from kvb_orchestrator import names + + +class TestRenameOnce(unittest.TestCase): + def test_two_state_builds_with_the_same_claims_add_no_second_rename(self): + with tempfile.TemporaryDirectory() as folder: + book = names.Names(Path(folder) / "names.json") + claims = [("s1", "Stage", 100.0, "claim"), ("s1", "i18n 2.0", 200.0, "claim"), ("s2", "Main", 50.0, "claim")] + counts = [] + for build in (1000.0, 2000.0): + book.observe(claims, build) + counts.append(len(names.Directory(book.listed()).renames(0, 3000))) + self.assertEqual(counts, [0, 0]) + for build in (3000.0, 4000.0): + book.observe([*claims, ("s1", "Skills", 300.0, "title")], build) + self.assertEqual([row["title"] for row in names.Directory(book.listed()).renames(0, 5000)], ["i18n 2.0 heißt jetzt Skills"]) + + def test_a_name_pair_shows_once_per_session(self): + entries = [{"session": "s1", "name": name, "first_seen": ts, "source": "claim"} for name, ts in (("A", 1), ("B", 2), ("A", 3), ("B", 4))] + self.assertEqual([row["title"] for row in names.Directory(entries).renames(0, 10)], ["A heißt jetzt B", "B heißt jetzt A"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_tree.py b/tests/test_hub_tree.py index e133830..3e2da7c 100644 --- a/tests/test_hub_tree.py +++ b/tests/test_hub_tree.py @@ -52,7 +52,9 @@ class TestHubTree(HubCase): status, _, body = self.get("/api/tree") self.assertEqual(status, 200, body) self.assertEqual((json.loads(body)["repos"], json.loads(body)["scanned"]), ([], None)) - self.assertIn("no pkgmgr tree file", json.loads(body)["error"]) + for words in ("no pkgmgr tree file", "`pkgmgr branch tree`", "release pkgmgr"): + with self.subTest(words=words): + self.assertIn(words, json.loads(body)["error"]) self.tree_file.write_text('{"repos": [{"path": "/x"}]}', encoding="utf-8") self.assertIn("unreadable", json.loads(self.get("/api/tree")[2])["error"]) diff --git a/tests/test_hub_voice.py b/tests/test_hub_voice.py index 292ecc2..ab7f172 100644 --- a/tests/test_hub_voice.py +++ b/tests/test_hub_voice.py @@ -38,6 +38,7 @@ console.log(JSON.stringify({ spoken: filters.map((filter) => rows.map((row) => V.speakable(row, filter))), queue: pending.map((item) => item.cls), loud: settings.map((one) => Object.fromEntries(["mayday", "review", "direct", "radio"].map((tone) => [tone, V.loudness(tone, one)]))), + beeps: settings.map((one) => V.beep({mayday: true, review: true, direct: true, radio: true}, one, ["mayday", "review", "direct", "radio"])), frames: [V.frames(1.5, 16000, 120), V.frames(130, 16000, 120), V.frames(0, 16000, 120)], wav: Buffer.from(V.wav(Float32Array.from([0, 0.5, -0.5, 1, -1, 2, -2]), 16000)).toString("base64"), })); @@ -52,6 +53,7 @@ class TestVoiceModule(unittest.TestCase): settings.SETTINGS, {**settings.SETTINGS, "music": {"on": True, "volume": 1.0}, "sounds": quiet}, {**settings.SETTINGS, "sounds": {**settings.SETTINGS["sounds"], "mayday": {"on": True, "volume": 0.0}, "radio": {"on": True, "volume": 0.8}}}, + *({**settings.SETTINGS, "squelch": 0.0, "music": {"on": music, "volume": 1.0}, "sounds": {name: {"on": True, "volume": 1.0} for name in settings.TONES}} for music in (True, False)), ] command = ["node", "-e", VOICE_PROBE, str(VOICE), json.dumps(cls.settings)] done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True) @@ -63,7 +65,11 @@ class TestVoiceModule(unittest.TestCase): for loud in self.data["loud"]: with self.subTest(loud=loud): self.assertEqual(loud["mayday"], max(loud.values())) - self.assertEqual([loud["mayday"] for loud in self.data["loud"]], [0.9, 1.0, 0.8]) + self.assertEqual([loud["mayday"] for loud in self.data["loud"]], [0.9, 1.0, 0.8, 0.0, 0.0]) + + def test_squelch_at_zero_plays_no_tone_with_music_on_or_off(self): + self.assertEqual(self.data["beeps"], ["mayday", None, "mayday", None, None]) + self.assertEqual([set(loud.values()) for loud in self.data["loud"][3:]], [{0.0}, {0.0}]) def test_the_radio_filter_picks_classes_and_stations(self): self.assertEqual(self.data["spoken"], [ From c0353735180625ffc6b4d284277040b64d06a7f8 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sun, 8 Nov 2026 17:26:51 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(orchestrator)!:=20S0=20English=20every?= =?UTF-8?q?where,=20S=C3=A9curit=C3=A9,=20NATO=20hashes,=20ICAO=20digits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement 001 phase 6, S0: - Speech: the security class shows as "Sécurité" and is spoken as in maritime radio, "say-cure-ee-tay" three times. A hex run of 7 or more characters with a letter and a digit (commit, session, message ids) is spelled in the NATO alphabet; every other digit is spoken on its own in ICAO pronunciation (42305: four two tree zero fife). The spelling runs inside clip() before the length cut, so a phrase still fits SPEECH_LIMIT; the displayed text keeps its digits and hashes. - English: every orchestrator label, tooltip, message, error, script output, document and test string is English (Regeln -> Firewall, Hierarchie -> Hierarchy, Steuerung -> Controls, Ausführen -> Run, ...); the page declares lang="en" and formats dates as en-GB. Task filter ids are English. German stays only in the skill trigger phrases that quote the operator, and as identifiers: the view keys (view=) and the leitstand id. - tests/test_english.py scans every tracked text file for umlauts and German words, with an allowlist of the quoted trigger phrases. BREAKING CHANGE: the Stream search tokens art: and seit: are now kind: and since:; an old art: or seit: token counts as a plain word. Evidence: make test 657 OK, ruff and markdownlint clean (/tmp/s0-make-test-1.log); e2e 17 OK, none skipped (/tmp/s0-e2e-1.log); MEDUSA 86 files, 24 findings on reworded strings accepted after an independent skeptic (21 path:line, 0 rejected, no behaviour change in signing, approving, rules, denying, offices, events), rescan 0 open. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: – 🛡️ Security: 100 % 🏆 Score: 89 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- README.md | 12 +- docs/requirements/001-agent-orchestrator.md | 75 +++--- orchestrator/client/signing.py | 12 +- orchestrator/kvb_orchestrator/approving.py | 22 +- orchestrator/kvb_orchestrator/cleanup.py | 4 +- orchestrator/kvb_orchestrator/crons.py | 4 +- orchestrator/kvb_orchestrator/denying.py | 24 +- orchestrator/kvb_orchestrator/events.py | 34 +-- orchestrator/kvb_orchestrator/jobs.py | 6 +- orchestrator/kvb_orchestrator/language.py | 14 +- orchestrator/kvb_orchestrator/names.py | 4 +- orchestrator/kvb_orchestrator/offices.py | 4 +- orchestrator/kvb_orchestrator/phrasing.py | 26 +- orchestrator/kvb_orchestrator/requesting.py | 6 +- orchestrator/kvb_orchestrator/rules.py | 2 +- orchestrator/kvb_orchestrator/tasking.py | 2 +- orchestrator/kvb_orchestrator/timeline.py | 2 +- orchestrator/kvb_orchestrator/tree.py | 12 +- orchestrator/templates/index.html.j2 | 8 +- orchestrator/templates/nav.js | 6 +- orchestrator/templates/page-agent-order.js | 18 +- orchestrator/templates/page-agent.js | 40 ++-- orchestrator/templates/page-alerts.js | 10 +- orchestrator/templates/page-artifacts.js | 54 ++--- orchestrator/templates/page-board.js | 22 +- orchestrator/templates/page-cleanup.js | 24 +- orchestrator/templates/page-composer.js | 64 ++--- orchestrator/templates/page-console.js | 14 +- orchestrator/templates/page-constants.js | 44 ++-- orchestrator/templates/page-deny.js | 30 +-- orchestrator/templates/page-dom.js | 20 +- orchestrator/templates/page-filters.js | 14 +- orchestrator/templates/page-flow.js | 20 +- orchestrator/templates/page-graph-charts.js | 28 +-- orchestrator/templates/page-graph-layout.js | 4 +- orchestrator/templates/page-graph3d.js | 26 +- orchestrator/templates/page-hierarchy.js | 32 +-- orchestrator/templates/page-inbox.js | 72 +++--- orchestrator/templates/page-keyboard.js | 4 +- orchestrator/templates/page-music.js | 2 +- orchestrator/templates/page-offices.js | 36 +-- orchestrator/templates/page-palette.js | 44 ++-- orchestrator/templates/page-passkey.js | 24 +- orchestrator/templates/page-radio.js | 32 +-- orchestrator/templates/page-radioview.js | 38 +-- orchestrator/templates/page-review.js | 16 +- orchestrator/templates/page-rule-request.js | 12 +- orchestrator/templates/page-rules.js | 44 ++-- orchestrator/templates/page-screens.js | 4 +- orchestrator/templates/page-settings.js | 80 +++---- orchestrator/templates/page-sky.js | 26 +- orchestrator/templates/page-spawn.js | 8 +- orchestrator/templates/page-speech.js | 16 +- orchestrator/templates/page-stream.js | 38 +-- orchestrator/templates/page-tasks-prio.js | 54 ++--- orchestrator/templates/page-tasks.js | 58 ++--- orchestrator/templates/page-time.js | 26 +- orchestrator/templates/page-tree.js | 66 +++--- scripts/orchestrator.sh | 6 +- skills/kvb-check-legal/SKILL.md | 4 +- skills/kvb-org-organize/succession.md | 2 +- tests/e2e/test_orchestrator_e2e.py | 248 ++++++++++---------- tests/hub_support.py | 6 +- tests/test_coach.py | 20 +- tests/test_english.py | 64 +++++ tests/test_hub_ambience.py | 2 +- tests/test_hub_approval.py | 4 +- tests/test_hub_board.py | 10 +- tests/test_hub_cleanup.py | 14 +- tests/test_hub_composer.py | 34 +-- tests/test_hub_console.py | 2 +- tests/test_hub_deny.py | 10 +- tests/test_hub_graph.py | 2 +- tests/test_hub_inbox.py | 8 +- tests/test_hub_names.py | 4 +- tests/test_hub_nav.py | 6 +- tests/test_hub_radio_trouble.py | 4 +- tests/test_hub_redirects.py | 8 +- tests/test_hub_rule_requests.py | 2 +- tests/test_hub_settings.py | 14 +- tests/test_hub_space.py | 2 +- tests/test_hub_speech.py | 14 +- tests/test_hub_state.py | 2 +- tests/test_hub_stream.py | 4 +- tests/test_hub_surface.py | 14 +- tests/test_hub_tasks.py | 4 +- 86 files changed, 1027 insertions(+), 930 deletions(-) create mode 100644 tests/test_english.py diff --git a/README.md b/README.md index 4a58106..386fa32 100644 --- a/README.md +++ b/README.md @@ -405,17 +405,17 @@ Both commands also patch the target's `.claude/settings.local.json` — the mach The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT, 82 MB). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`. Its Tree reads only the file `pkgmgr branch tree` writes: the install clones `PKGMGR_REPO` (default the GitHub repository, never the sandbox-writable local checkout), builds the newest `v*` tag a fully trusted GPG key signed into `~/.local/share/kvb-orchestrator/pkgmgr`, and enables the `kvb-orchestrator-tree.timer` user timer, which writes `~/.local/state/pkgmgr/tree/branch-tree.json` a minute after its previous run while the hub page polls, on at most half a core; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off. -Stream → Radio (`#stream/radio`) is the hub's radio interface. Its station list shows one line per station, the full name in the tooltip, with its state and a play or stop button; when the browser blocks autoplay, the stream fails or the CSP refuses its origin, the row and a toast say so, and one click starts the station. New stations play daily around the clock. While the hub reads a message aloud, the speaking agent pulses there and in the Orbit and Chord views, under the caption " spricht" with the message's first line; under `prefers-reduced-motion` the ring stays still. The composer sends on all seven channels: post and call to one agent, newsletter, broadcast, pan-pan, security and mayday to all. "Aufnehmen" records with MediaRecorder; the hub transcribes the recording with whisper.cpp off the request thread (at most 120 s of audio, a 120 s timeout, four at a time), keeps it for replay in `~/.local/state/kvb-orchestrator/voice` (the newest 50) and puts the transcript into the field to edit; the message goes out as text, with the recording's host path and sha256 only when "Aufnahme anhängen" is ticked. The hub transcribes at most six recordings a minute and 600 s of audio an hour. Every operator message is passkey-signed, and the hub verifies the signature against the pinned keys, each once, before it relays; `operator_message(id, text, to)` answers `match` only for the signed text and recipient; for a denial the signature covers the recipient the inbox resolved at signing, and the answer's text is only the signed reason, never the agent's own words the radio row quotes. "Mayday aufheben", Abend, Sonnenuntergang, Sonnenaufgang and a settings save that adds a stream URL take the passkey the same way. +Stream → Radio (`#stream/radio`) is the hub's radio interface. Its station list shows one line per station, the full name in the tooltip, with its state and a play or stop button; when the browser blocks autoplay, the stream fails or the CSP refuses its origin, the row and a toast say so, and one click starts the station. New stations play daily around the clock. While the hub reads a message aloud, the speaking agent pulses there and in the Orbit and Chord views, under the caption " speaking" with the message's first line; under `prefers-reduced-motion` the ring stays still. The composer sends on all seven channels: post and call to one agent, newsletter, broadcast, pan-pan, security and mayday to all. "Record" records with MediaRecorder; the hub transcribes the recording with whisper.cpp off the request thread (at most 120 s of audio, a 120 s timeout, four at a time), keeps it for replay in `~/.local/state/kvb-orchestrator/voice` (the newest 50) and puts the transcript into the field to edit; the message goes out as text, with the recording's host path and sha256 only when "Attach recording" is ticked. The hub transcribes at most six recordings a minute and 600 s of audio an hour. Every operator message is passkey-signed, and the hub verifies the signature against the pinned keys, each once, before it relays; `operator_message(id, text, to)` answers `match` only for the signed text and recipient; for a denial the signature covers the recipient the inbox resolved at signing, and the answer's text is only the signed reason, never the agent's own words the radio row quotes. "Cancel mayday", Evening, Sunset, Sunrise and a settings save that adds a stream URL take the passkey the same way. -The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Löschen" on a `merged-clean` branch without skip reason, or "Alle gemergten löschen", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch ` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`. +The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Delete" on a `merged-clean` branch without skip reason, or "Delete all merged", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch ` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`. -The Agents view "Hierarchie" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/`) and presidents (`work/`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim ` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin. +The Agents view "Hierarchy" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/`) and presidents (`work/`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim ` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin. -The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "wartet auf Freigabe", and the filter "Wartet auf Operator" is the inbox, which the Inbox entry under Stream opens. A task's sheet pins its priority (`rasci.py set --prio `) or sets and drops the operator's WSJF estimate (`rasci.py estimate Operator --value --complexity ` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci --council `, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar. +The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "waiting for a grant", and the filter "Waiting for operator" is the inbox, which the Inbox entry under Stream opens. A task's sheet pins its priority (`rasci.py set --prio `) or sets and drops the operator's WSJF estimate (`rasci.py estimate Operator --value --complexity ` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci --council `, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar. -Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage). +Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=` pins the passkey that "Register passkey" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage). -For approvals the hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port --approvers ~/.config/kvb-orchestrator/approvers.json --credential ''`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. In the inbox, an operator command run with "Ausführen" shows the runner's output in a console under its row until "Schließen". "Ablehnen" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Annehmen" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port --timeout --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and review rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (decision choices, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks//deny`, console dismissals) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap. +For approvals the hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Register passkey" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port --approvers ~/.config/kvb-orchestrator/approvers.json --credential ''`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. In the inbox, an operator command run with "Run" shows the runner's output in a console under its row until "Close". "Deny" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Accept" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port --timeout --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and review rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (decision choices, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks//deny`, console dismissals) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap. Restart your agent afterwards so it loads the new skills. diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index cd525db..0b6213a 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -47,27 +47,27 @@ from three views down to any agent, branch or artefact. (directory, module, container, systemd unit, MCP server, environment `KVB_ORCHESTRATOR_*`, make targets). Rejected: visible name only (two names for one thing). -10. **Navigation**: Stream (sub-items Inbox and Regeln) · Agents (ex Graph) · +10. **Navigation**: Stream (sub-items Inbox and Firewall) · Agents (ex Graph) · Artifacts (ex Tree). Agents and Artifacts each offer Orbit, Chord, Swimlane, Tree and 3D. 11. **Header**: title · ⌘K · ⚙ only. The status pills become navigation badges (Inbox: waiting items, Agents: active agents, Artifacts: runner - jobs); Mayday shows only as a red banner with "aufheben" while active; the + jobs); Mayday shows only as a red banner with "cancel" while active; the background shows day and night. Rejected: one status dot with a popover - (an extra click), a status block in a side bar, a "Lage" screen (effort), - no header at all. -12. **Tools**: Steuerung (Funk an alle, Mayday aufheben, Priorität, Tag/Nacht) - moves to the Agents toolbar, Regeln under Inbox, Himmel, Musik, - Leitstand and Benachrichtigungen to ⚙ "Ambiente"; ⌘K reaches every one. + (an extra click), a status block in a side bar, a "Situation" screen + (effort), no header at all. +12. **Tools**: Controls (Radio to all, Cancel mayday, Priority, Day/Night) + moves to the Agents toolbar, Firewall under Inbox, Sky, Music, + Control room and Notifications to ⚙ "Ambience"; ⌘K reaches every one. Rejected: one ⋯ menu (hides instead of ordering). -13. **Stream**: four category chips Für dich · Funk · Arbeit · Transkripte; - one search line with `agent:`, `art:`, `seit:`; sub-filters, saved views +13. **Stream**: four category chips For you · Radio · Work · Transcripts; + one search line with `agent:`, `kind:`, `since:`; sub-filters, saved views and a custom range under "Filter ▾"; the time bar becomes "24 h ▾" with the replay inside; transcripts are off by default. Rejected: a self-grouping stream, a Kibana-like query language (effort), preset chips only. 14. **3D**: `3d-force-graph` 1.80.1 on `three` 0.186.1, vendored as in - meta.infinito.nexus; the Canvas depth axes Zeit and Spawn-Tiefe become 3D + meta.infinito.nexus; the Canvas depth axes Time and Spawn depth become 3D options. Rejected: keeping the Orbit-only Canvas depth. 15. **Artifacts scope**: git (repositories → branches → worktrees with their agents and reviews) plus the kvb-org-rasci register artefacts (images, @@ -99,8 +99,7 @@ from three views down to any agent, branch or artefact. locked, stale, protected and the gc skip reason. The Artifacts Tree shows it as icons. Phase 3 builds the data, phase 4 the icons. 21. **Delete from the tree**: merged, orphaned branches are deleted by click - in the Artifacts Tree (phase 4), per item and as "Alle gemergten - löschen": + in the Artifacts Tree (phase 4), per item and as "Delete all merged": - **Credentials**: only while the Secret Service hands pkgmgr the GitHub token, which proves the merge and the missing open pull request; no secret passes the orchestrator container. Rejected: the master password @@ -115,7 +114,7 @@ from three views down to any agent, branch or artefact. `pkgmgr branch gc --apply` deletes today (remote branch and `compose down -v`). - **Confirmation**: the click shows the gc dry-run lines of the chosen - items, and "Löschen" needs the operator signature (`/api/sign`). + items, and "Delete" needs the operator signature (`/api/sign`). Rejected: a second click without signature, one click without preview. - **Execution**: the orchestrator writes a signed job into a host directory; a systemd user path unit runs `pkgmgr branch cleanup --apply @@ -125,19 +124,19 @@ from three views down to any agent, branch or artefact. and client which skills changed and how to use them, as one `newsletter` per update; phase 1 announces the radio change through it. 23. **Phase 5** (23 to 43 operator-confirmed 2026-11-07): the console - output shows under "Ausführen" (Run); a deny picks its channel (default + output shows under "Run"; a deny picks its channel (default `post`); agents request auto-approval rules through the - inbox; the rules form and the Ort fields never overlap; the radio plays + inbox; the rules form and the Location fields never overlap; the radio plays with one line per station and highlights the speaking agent; the operator composer sends text or voice, recorded, transcribed locally with whisper in the container and kept as audio, to a recipient by `post` or - `call`; a Hierarchie view shows the council offices with appoint and + `call`; a Hierarchy view shows the council offices with appoint and recall; the agent order (drag list) is separate from task priority (WSJF plus the operator's pin) and only breaks ties; one Tasks tab holds list, board and RASCI matrix, the inbox as its "waits on Operator" filter and the queue state; the merge flow, a pkgmgr-managed YAML per worktree with several pull sources and push targets, shows in Artifacts and Tasks. -24. **Firewall**: the tab Regeln becomes Firewall and holds every form of +24. **Firewall**: the rules tab becomes Firewall and holds every form of allow-listing: the hub's auto-approval rules (ask, review, decision), allow and deny command patterns, and the Claude settings permissions (`allow`, `ask`, `deny`) and sandbox entries. Rejected: a separate @@ -168,7 +167,7 @@ from three views down to any agent, branch or artefact. `kvb-org-evening` → `kvb-org-drain`, `kvb-org-sunset` → `kvb-org-suspend`, `kvb-org-sunrise` → `kvb-org-resume`, `kvb-agent-relax` → `kvb-agent-drain`, `kvb-agent-sleep` → `kvb-agent-suspend`, - `kvb-agent-wakeup` → `kvb-agent-resume`; `night.py` and the Steuerung + `kvb-agent-wakeup` → `kvb-agent-resume`; `night.py` and the Controls menu follow, `kvb-skill-cleanup` removes the old names. Rejected: sleep / hibernate / wakeup, pause / park / resume. 30. **Agent popup**: a click on an agent opens a fading popup with a simple, @@ -196,7 +195,7 @@ from three views down to any agent, branch or artefact. 36. **Tree cause**: the tree timer builds pkgmgr from the newest signed tag, and v2.3.0 has no `branch tree`, so no tree file exists. The Tree names that cause and the operator step (the pkgmgr release) instead of a bare - "Kein Tree"; with pkgmgr 2.4.0 released the timer writes the file. + "No tree"; with pkgmgr 2.4.0 released the timer writes the file. 37. **Resources**: a tab Resources shows charts on the shared time axis: tokens per agent and accumulated (from the usage fields of the session transcripts), and CPU, RAM, disk and load of the host, sampled by a host @@ -324,7 +323,7 @@ Phases 5 and 6 (decisions 23 to 42): ```mermaid flowchart TD - subgraph W["🧩 5 · Inbox, UI, Hierarchie, Tasks"] + subgraph W["🧩 5 · Inbox, UI, Hierarchy, Tasks"] W1["🛠️ W1 to W4 in own worktrees"] --> W2["🕵️ Skeptic round, fix round per worktree"] W2 --> W3{"🧪 Suite, e2e, MEDUSA, score green?"} W3 -- "🔴 no: fix" --> W2 @@ -422,7 +421,7 @@ flowchart TD `kvb-orchestrator` names. A second run changes nothing (scratch-HOME test). - [ ] The navigation has exactly three top-level entries Stream, Agents and - Artifacts; Stream has the sub-items Inbox and Regeln. + Artifacts; Stream has the sub-items Inbox and Firewall. - [ ] Every agent appears once under its current name: a register worker such as "Stage (this session)" or "infinito-nexus-core-c8 (Production)" maps to the agent named before the bracket. @@ -438,18 +437,18 @@ flowchart TD - [ ] Agents shows the agents with a heartbeat in the last 900 s. - [ ] Artifacts shows the runner jobs that are queued or running. - [ ] A badge at 0 is hidden. -- [ ] A red Mayday banner with "aufheben" exists if and only if a mayday is - active; "aufheben" runs `radio.py clear`. +- [ ] A red Mayday banner with "cancel" exists if and only if a mayday is + active; "cancel" runs `radio.py clear`. - [ ] The Agents toolbar holds: - - [ ] Funk an alle, with the class choice Newsletter, Broadcast, Pan-Pan, + - [ ] Radio to all, with the class choice Newsletter, Broadcast, Pan-Pan, Security, Mayday and Newsletter preselected. - - [ ] Mayday aufheben, Priorität and Tag/Nacht. + - [ ] Cancel mayday, Priority and Day/Night. - [ ] The agent panel sends with the choice Post (preselected) or Call. -- [ ] ⚙ "Ambiente" holds Himmel, Musik, Leitstand and Benachrichtigungen; +- [ ] ⚙ "Ambience" holds Sky, Music, Control room and Notifications; ⌘K lists every former header tool. -- [ ] The Stream shows exactly four category chips Für dich, Funk, Arbeit and - Transkripte, and one search line that filters by `agent:`, `art:` and - `seit:`. +- [ ] The Stream shows exactly four category chips For you, Radio, Work and + Transcripts, and one search line that filters by `agent:`, `kind:` and + `since:`. - [ ] "Filter ▾" holds the kind sub-filters, the transcript parts, the saved views and the custom range. - [ ] The time control reads "24 h ▾" and holds the replay. @@ -515,7 +514,7 @@ flowchart TD - [ ] 3D in both tabs runs on the vendored `3d-force-graph` 1.80.1 and `three` 0.186.1, with their MIT licence texts beside them and no CDN request (CSP unchanged). -- [ ] 3D offers the layouts Kraft, Zeit and Spawn-Tiefe. +- [ ] 3D offers the layouts Force, Time and Spawn depth. - [ ] A click on a 3D node opens the same panel as in 2D. - [ ] 3D respects `prefers-reduced-motion`. @@ -525,10 +524,10 @@ flowchart TD merged (with what merged it), clean, uncommitted changes (with counts), unpushed commits, gone upstream, locked, stale and protected. - [ ] A `merged-clean` item without a gc skip reason shows a delete button, - and the Tree header shows "Alle gemergten löschen"; both are disabled, + and the Tree header shows "Delete all merged"; both are disabled, with the reason as tooltip, while `delete_ready` is false. - [ ] A click shows the gc dry-run lines of exactly the chosen items; - "Löschen" requires the operator signature through `/api/sign`. + "Delete" requires the operator signature through `/api/sign`. - [ ] The orchestrator writes the signed job (repository, branch, worktree and tip sha per item, no command) into a host-mounted job directory and holds no git, token or key. @@ -555,7 +554,7 @@ flowchart TD - [ ] Every icon element has a `title` and an `aria-label` that carry the former text (DOM test over every page). -### Phase 5: inbox, UI, Hierarchie, Tasks +### Phase 5: inbox, UI, Hierarchy, Tasks - [ ] A deny is refused by the hub unless its passkey proof verifies; the PermissionRequest hook quotes a deny reason only when it verifies. @@ -570,12 +569,12 @@ flowchart TD ### Phase 6: English and radio speech (S0) -- [ ] The text handed to the speech engine for a `security` row starts with +- [x] The text handed to the speech engine for a `security` row starts with "say-cure-ee-tay" three times; the page shows "Sécurité". -- [ ] `8e180be` is spoken "eight echo one eight zero bravo echo" and +- [x] `8e180be` is spoken "eight echo one eight zero bravo echo" and `42305` "four two tree zero fife" (unit test on the pronunciation step); the displayed line is unchanged. -- [ ] A scan of the tree finds no German UI, message or document text +- [x] A scan of the tree finds no German UI, message or document text outside quoted trigger phrases (test with an allowlist of those phrases). @@ -615,7 +614,7 @@ flowchart TD `kvb-agent-drain`, `kvb-agent-suspend` and `kvb-agent-resume` exist; the old six names exist nowhere in the tree except in the migration and `kvb-skill-cleanup` history (grep test). -- [ ] The Steuerung menu shows Drain, Suspend and Resume and each needs the +- [ ] The Controls menu shows Drain, Suspend and Resume and each needs the passkey, like the board actions today. ### Phase 6: Tasks with asks and choices (S5) @@ -716,7 +715,7 @@ flowchart TD them. - [ ] `make test` green on the commit of each phase. - **E2E** - - [ ] A headless Chromium and Firefox run opens Stream, Inbox, Regeln and + - [ ] A headless Chromium and Firefox run opens Stream, Inbox, Firewall and every view of Agents and Artifacts at 1920, 1366 and 390 px wide and saves the screenshots shown at each operator checkpoint. - [ ] It sends a Call from the agent panel and sees its receipt in the diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index f4d2869..260fd44 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -30,13 +30,13 @@ COMMANDS = "operator/" APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"} RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"} WARNINGS = ( - ("löscht rekursiv", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")), - ("erzwingt", re.compile(r"--force\b|\bpush\b.*\s-f\b")), - ("pusht", re.compile(r"\bgit\b.*\bpush\b")), + ("deletes recursively", re.compile(r"\brm\s+(?:-\S+\s+)*-\S*[rR]")), + ("forces", re.compile(r"--force\b|\bpush\b.*\s-f\b")), + ("pushes", re.compile(r"\bgit\b.*\bpush\b")), ("root", re.compile(r"\bsudo\b|\bdoas\b")), - ("Pipe in eine Shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")), - ("verwirft Änderungen", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")), - ("Docker löscht", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")), + ("pipes into a shell", re.compile(r"\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b")), + ("discards changes", re.compile(r"\bgit\b.*\b(?:reset\s+--hard|clean\s+-\S*f)")), + ("Docker deletes", re.compile(r"\bdocker\s+(?:system\s+prune|volume\s+(?:rm|prune)|rmi?\b)")), ) diff --git a/orchestrator/kvb_orchestrator/approving.py b/orchestrator/kvb_orchestrator/approving.py index 801617a..68ccf7e 100644 --- a/orchestrator/kvb_orchestrator/approving.py +++ b/orchestrator/kvb_orchestrator/approving.py @@ -81,7 +81,7 @@ class Approving: try: verifier.check(proof, item, "", trust, time.time()) except signing.Refused as error: - raise ToolError(f"nicht signiert: {error}") from None + raise ToolError(f"not signed: {error}") from None return proof def still_signed(self, proof: dict, item: dict) -> None: @@ -94,7 +94,7 @@ class Approving: raise signing.Refused("the signature does not list this item") passkey.verify_assertion(proof["assertion"], proof["payload"], verifier.pinned(self.approvers), signing.origin_of(self.public_port)) except signing.Refused as error: - raise ToolError(f"nicht signiert: {error}") from None + raise ToolError(f"not signed: {error}") from None def covered(self, proof: object, signed: list[dict], now: float) -> dict | None: """Args: @@ -112,7 +112,7 @@ class Approving: raise ToolError("proof must be null when no item needs a signature") return None if not isinstance(proof, dict): - raise ToolError("nicht signiert: an approval of an ask, review or operator command needs the passkey signature") + raise ToolError("not signed: an approval of an ask, review or operator command needs the passkey signature") expect(proof, ("payload", "assertion")) try: payload = signing.checked_approval(proof["payload"], now) @@ -136,15 +136,15 @@ class Approving: item = {"kind": kind_name, "id": record["id"], "verb": "approve", "label": record.get("recommended", ""), "sha256": "", "title": shown(title)[:200], "gate": record.get("gate", ""), "signed": signed, "note": None} batch = self.batches.add([item], f"rule:{rule['id']}", rule.get("proof"), self.apply) words = f"{rule['kind']} {rule['tool']} {rule['pattern']}".replace(" ", " ") - self.log.add({"kind": "rule", "title": f"Regel gibt frei: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}}) + self.log.add({"kind": "rule", "title": f"Rule grants: {item['title']}", "text": words, "ref": {"rule": rule["id"], "batch": batch["id"], "item": item["id"]}}) def apply(self, batch: dict) -> None: for item in batch["items"]: try: outcome = self.settle_item(item, batch["proof"] if item["signed"] is not None else None) except (OSError, ToolError) as error: - outcome = f"fehlgeschlagen: {error}" - verb = "Freigabe" if item["verb"] == "approve" else "Ablehnung" + outcome = f"failed: {error}" + verb = "Grant" if item["verb"] == "approve" else "Denial" label = f" → {item['label']}" if item["label"] else "" ref = {"kind": item["kind"], "id": item["id"], "verb": item["verb"], "source": batch["source"], "outcome": outcome} self.log.add({"kind": "approval", "title": f"{verb} {item['kind']}: {item['title']}{label}", "text": f"{outcome} ({batch['source']})", "ref": ref}) @@ -158,15 +158,15 @@ class Approving: if item["kind"] == "ask": record = self.asks.get(item["id"]) if record is None or ask_state(record, time.time())["status"] == "expired": - return "abgelaufen" + return "expired" record, changed = self.asks.decide(item["id"], {"status": "allowed" if approve else "denied", "proof": proof}) elif item["kind"] == "review": record, changed = self.reviews.decide(item["id"], {"status": "approved" if approve else "rejected", "proof": proof}) else: record, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"]}) if record is None: - return "unbekannt" - return record["status"] if changed else f"schon {record['status']}" + return "unknown" + return record["status"] if changed else f"already {record['status']}" def requester(self, raw: dict, who: str) -> str: """Returns: @@ -216,7 +216,7 @@ class Approving: raise ToolError("a decision is approved with one of its option labels") label = raw["label"] if kind_name == "rule": - return inbox_item(raw, "", f"Regel {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"])) + return inbox_item(raw, "", f"Rule {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"])) title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name] subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else None return inbox_item(raw, label, title, record.get("gate", ""), subject, self.requester(raw, record["session"] if kind_name == "ask" else "")) @@ -259,5 +259,5 @@ class Approving: def undo(self, batch_id: str) -> dict: batch = self.batches.undo(batch_id) titles = ", ".join(item["title"][:80] for item in batch["items"]) - self.log.add({"kind": "approval", "title": f"Rückgängig: {titles}", "text": batch["source"], "ref": {"batch": batch_id}}) + self.log.add({"kind": "approval", "title": f"Undone: {titles}", "text": batch["source"], "ref": {"batch": batch_id}}) return {"id": batch_id, "status": "undone"} diff --git a/orchestrator/kvb_orchestrator/cleanup.py b/orchestrator/kvb_orchestrator/cleanup.py index eade53e..6cdb85f 100644 --- a/orchestrator/kvb_orchestrator/cleanup.py +++ b/orchestrator/kvb_orchestrator/cleanup.py @@ -71,7 +71,7 @@ class Cleaning: raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects") scan = self.tree.get() if scan.get("delete_ready") is not True: - raise ToolError(f"löschen gesperrt: {scan.get('delete_reason') or scan.get('error') or 'delete_ready ist false'}") + raise ToolError(f"delete blocked: {scan.get('delete_reason') or scan.get('error') or 'delete_ready is false'}") known = offered(scan) chosen = [] for entry in raw: @@ -94,7 +94,7 @@ class Cleaning: job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{key: item[key] for key in JOB_KEYS} for item in items], "proof": proof} self.spool.put(job_id, job) names = ", ".join(item["branch"] for item in items) - self.log.add({"kind": "run", "title": f"Löschen signiert: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}}) + self.log.add({"kind": "run", "title": f"Delete signed: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}}) return {"id": job_id, "status": "queued", "items": job["items"]} def cleanup_state(self, job_id: str) -> dict: diff --git a/orchestrator/kvb_orchestrator/crons.py b/orchestrator/kvb_orchestrator/crons.py index a5b907d..04240e5 100644 --- a/orchestrator/kvb_orchestrator/crons.py +++ b/orchestrator/kvb_orchestrator/crons.py @@ -9,7 +9,7 @@ from .records import epoch, read_json, shown CRON_ACTIVE = 900 TIMER_KEYS = ("OnCalendar", "OnActiveSec", "OnBootSec", "OnStartupSec", "OnUnitActiveSec", "OnUnitInactiveSec") -CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Hintergrund", "timer": "Timer", "queue": "Queue"} +CRON_LABELS = {"cron": "Cron", "wakeup": "Wakeup", "background": "Background", "timer": "Timer", "queue": "Queue"} def cron_state(cron: dict, beats: dict[str, float], now: float) -> dict: @@ -59,7 +59,7 @@ def queue_entries(council: Path) -> list[tuple[dict, float]]: def queue_rows(council: Path) -> list[dict]: rows = [] for entry, created in queue_entries(council): - running = "läuft" if entry.get("status") == "active" else "wartet" + running = "running" if entry.get("status") == "active" else "waiting" rows.append({ "key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created, "deleted": None, "until": None, "job": shown(entry.get("id")), "expression": f"{shown(entry.get('kind'))} · {running}", diff --git a/orchestrator/kvb_orchestrator/denying.py b/orchestrator/kvb_orchestrator/denying.py index 0867b14..5958f84 100644 --- a/orchestrator/kvb_orchestrator/denying.py +++ b/orchestrator/kvb_orchestrator/denying.py @@ -76,28 +76,28 @@ class Denying: tasks = read_register(self.council / REGISTER) row = next((row for row in operator_rows(tasks) if row["task"] == item["id"]), None) if row is None or signing.denial_item(operator_signed(row), note["channel"], note["reason"], note["to"]) != item["signed"]: - return "verweigert: die Zeile hat sich seit der Ablehnung geändert" + return "refused: the row changed since the denial" if item["id"] in self.open_jobs(): - return "läuft schon" + return "already running" run = self.scripts.run(RASCI, ["set", item["id"], "--status", "stopped"]) if run["code"] != 0: - return f"fehlgeschlagen: {run['command']} → {run['code']}" + return f"failed: {run['command']} → {run['code']}" command, directory = row["command"][:200], row["directory"] - return f"gestoppt · {self.tell(item, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}" + return f"stopped · {self.tell(item, proof, adder(tasks[item['id']]), f'operator command {command} in {directory}')}" ledger = self.asks if item["kind"] == "ask" else self.rule_requests record = ledger.get(item["id"]) if record is None: - return "unbekannt" + return "unknown" now = time.time() if (ask_state(record, now)["status"] if item["kind"] == "ask" else request_status(record, now)) == "expired": - return "abgelaufen" + return "expired" record, changed = ledger.decide(item["id"], {"status": "denied", "proof": proof, **note}) if not changed: - return f"schon {record['status']}" + return f"already {record['status']}" if item["kind"] == "ask": tool, command = record["tool"], record["command"][:200] - return f"abgelehnt · {self.tell(item, proof, record['session'], f'ask {tool}: {command}')}" - return f"abgelehnt · {self.tell(item, proof, record['agent'], f'rule request {request_words(record)}')}" + return f"denied · {self.tell(item, proof, record['session'], f'ask {tool}: {command}')}" + return f"denied · {self.tell(item, proof, record['agent'], f'rule request {request_words(record)}')}" def addressee(self, who: str) -> tuple[str, str]: return requester_of(read_claims(self.council), who) @@ -120,11 +120,11 @@ class Denying: direct = channel in DIRECT address = self.addressee(note["to"])[1] if direct else EVERYONE if direct and not address: - return f"kein Empfänger für {channel}" + return f"no recipient for {channel}" text = denial_text(what, self.addressee(who)[0], note["reason"]) - where = f"{channel} an {address}" if direct else f"{channel} an alle" + where = f"{channel} to {address}" if direct else f"{channel} to all" fields = {"to": note["to"], "address": address, "text": text, "upload": None, "voice": None, "voice_sha256": "", "class": channel} record = self.messages.add({**fields, "denial": item["signed"], "proof": proof, "created_ts": time.time()}) targets = ["--to", address] if direct else [] run = self.scripts.run(RADIO, [*DENY_CHANNELS[channel], *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {' '.join(text.split())}"]) - return where if run["code"] == 0 else f"{where} fehlgeschlagen ({run['code']})" + return where if run["code"] == 0 else f"{where} failed ({run['code']})" diff --git a/orchestrator/kvb_orchestrator/events.py b/orchestrator/kvb_orchestrator/events.py index a7ea702..3cc4ff6 100644 --- a/orchestrator/kvb_orchestrator/events.py +++ b/orchestrator/kvb_orchestrator/events.py @@ -17,18 +17,18 @@ from .register import status_of from .transcripts import LOG_KEEP, STREAM_CUT, STREAM_KINDS KINDS = ( - ("radio", "Funk"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Entscheidungen"), ("operator", "Operator"), ("register", "Register"), - ("rename", "Namen"), ("cron", "Crons"), ("rule", "Regeln"), ("approval", "Freigaben"), ("run", "Runner"), ("transcript", "Transkripte"), + ("radio", "Radio"), ("review", "Reviews"), ("ask", "Asks"), ("decision", "Decisions"), ("operator", "Operator"), ("register", "Register"), + ("rename", "Names"), ("cron", "Crons"), ("rule", "Firewall"), ("approval", "Grants"), ("run", "Runner"), ("transcript", "Transcripts"), ) EVENT_KINDS = tuple(name for name, _ in KINDS) WORK = [name for name in EVENT_KINDS if name not in ("radio", "transcript")] VIEWS = { - "fuer-dich": {"label": "Für dich", "kinds": ["radio", *WORK], "waiting": True}, - "funk": {"label": "Funk", "kinds": ["radio"], "waiting": False}, - "arbeit": {"label": "Arbeit", "kinds": WORK, "waiting": False}, - "transkripte": {"label": "Transkripte", "kinds": ["transcript"], "waiting": False}, + "fuer-dich": {"label": "For you", "kinds": ["radio", *WORK], "waiting": True}, + "funk": {"label": "Radio", "kinds": ["radio"], "waiting": False}, + "arbeit": {"label": "Work", "kinds": WORK, "waiting": False}, + "transkripte": {"label": "Transcripts", "kinds": ["transcript"], "waiting": False}, } -ART = {word: name for name, label in KINDS for word in (name, label.lower())} +KIND_WORDS = {word: name for name, label in KINDS for word in (name, label.lower())} SPAN = re.compile(r"(\d{1,4})([mhd])") UNITS = {"m": 60, "h": 3600, "d": 86400} @@ -44,14 +44,14 @@ def since_of(word: str, now: float) -> float: try: return datetime.datetime.fromisoformat(word).timestamp() except ValueError: - raise ToolError("seit: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None + raise ToolError("since: takes 30m, 2h, 3d or a date like 2026-11-06 or 2026-11-06T08:30") from None def search(text: str, now: float) -> dict: """Args: - text: the search line: words, `agent:`, `art:` - and `seit:`. - now: the clock `seit:` spans count back from. + text: the search line: words, `agent:`, `kind:` + and `since:`. + now: the clock `since:` spans count back from. Returns: The plain words as `q`, plus `agent`, `kinds` and `since` for each @@ -60,16 +60,16 @@ def search(text: str, now: float) -> dict: found: dict = {"q": []} for word in text.split(): key, colon, value = word.partition(":") - if not colon or key not in ("agent", "art", "seit"): + if not colon or key not in ("agent", "kind", "since"): found["q"].append(word) elif not value: raise ToolError(f"{key}: needs a value") elif key == "agent": found["agent"] = value - elif key == "art": - if value.lower() not in ART: - raise ToolError(f"art: takes one of {', '.join(EVENT_KINDS)}") - found.setdefault("kinds", set()).add(ART[value.lower()]) + elif key == "kind": + if value.lower() not in KIND_WORDS: + raise ToolError(f"kind: takes one of {', '.join(EVENT_KINDS)}") + found.setdefault("kinds", set()).add(KIND_WORDS[value.lower()]) else: found["since"] = since_of(value, now) found["q"] = " ".join(found["q"]).lower() @@ -128,7 +128,7 @@ def register_changes(before: dict[str, dict], after: dict[str, dict], now: float continue if old is None or new is None: row = new if old is None else old - change = f"neu: {shown(row.get('title'))}" if old is None else "entfernt" + change = f"new: {shown(row.get('title'))}" if old is None else "removed" else: keys = sorted(key for key in set(old) | set(new) if old.get(key) != new.get(key)) change = "; ".join(f"{key}: {described(old.get(key))} → {described(new.get(key))}" for key in keys) diff --git a/orchestrator/kvb_orchestrator/jobs.py b/orchestrator/kvb_orchestrator/jobs.py index 6270863..dd4876b 100644 --- a/orchestrator/kvb_orchestrator/jobs.py +++ b/orchestrator/kvb_orchestrator/jobs.py @@ -54,12 +54,12 @@ class Jobs: def queue_job(self, item: dict, proof: dict | None) -> str: row = next((row for row in operator_rows(read_register(self.council / REGISTER)) if row["task"] == item["id"]), None) if row is None or not runnable(row) or operator_signed(row) != item["signed"]: - return "verweigert: die Zeile hat sich seit der Freigabe geändert" + return "refused: the row changed since the grant" if item["id"] in self.open_jobs(): - return "läuft schon" + return "already running" job = {"task": row["task"], "command": row["command"], "directory": row["directory"], "sha256": item["sha256"], "status": "queued", "proof": proof} self.jobs.add({**job, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None, "dismissed": False}) - return "an den Runner übergeben" + return "handed to the runner" def runner_jobs(self) -> dict: return {"jobs": [runner_job(job) for job in self.jobs.listed() if job["status"] == "queued"]} diff --git a/orchestrator/kvb_orchestrator/language.py b/orchestrator/kvb_orchestrator/language.py index 5e8382b..8259d56 100644 --- a/orchestrator/kvb_orchestrator/language.py +++ b/orchestrator/kvb_orchestrator/language.py @@ -87,36 +87,36 @@ def nl_action(raw: object, items: dict[str, dict], agents: list[str]) -> dict: raise ToolError("that operator row holds no command to run") chosen = [nl_item(item, "approve" if name == "approve" else "reject", item["recommended"] if item["kind"] == "decision" else "") for item in picked] lines = [f"{item['kind']} {item['id']}: {item['command'] if item['kind'] == 'operator' else item['title']}" for item in picked] - verb = "Freigeben" if name == "approve" else "Ablehnen" + verb = "Grant" if name == "approve" else "Deny" return {"name": name, "summary": f"{verb}:\n" + "\n".join(lines), "args": {"items": chosen}} if name == "choose": item = items.get(arguments["id"]) if isinstance(arguments["id"], str) else None if item is None or item["kind"] != "decision" or arguments["label"] not in [option["label"] for option in item["options"]]: raise ToolError("choose takes a decision id and one of its option labels") - return {"name": name, "summary": f"Entscheiden: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}} + return {"name": name, "summary": f"Decide: {item['title']} → {arguments['label']}", "args": {"items": [nl_item(item, "approve", arguments["label"])]}} if name in ("snooze", "pin"): picked = nl_ids(arguments, items) hours = arguments.get("hours") if name == "snooze" and (isinstance(hours, bool) or not isinstance(hours, int | float) or not 1 <= hours <= 192): raise ToolError("hours must be between 1 and 192") keys = [{"kind": item["kind"], "id": item["id"]} for item in picked] - words = f"Zurückstellen ({hours} h)" if name == "snooze" else "Anheften" + words = f"Snooze ({hours} h)" if name == "snooze" else "Pin" return {"name": name, "summary": f"{words}: " + ", ".join(item["title"][:80] for item in picked), "args": {"keys": keys, "hours": hours}} if name == "view": if arguments["name"] not in VIEWS: raise ToolError("unknown view") - return {"name": name, "summary": f"Ansicht: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}} + return {"name": name, "summary": f"View: {VIEWS[arguments['name']]['label']}", "args": {"name": arguments["name"]}} if name == "open": if arguments["view"] not in SCREENS: raise ToolError("unknown screen") - return {"name": name, "summary": f"Öffnen: {arguments['view']}", "args": {"view": arguments["view"]}} + return {"name": name, "summary": f"Open: {arguments['view']}", "args": {"view": arguments["view"]}} if name == "message": text = message_text(arguments, "text") if arguments["to"] not in agents: raise ToolError("to must name a known agent") - return {"name": name, "summary": f"Funk an {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}} + return {"name": name, "summary": f"Radio to {arguments['to']}: {text}", "args": {"to": arguments["to"], "text": text}} query = bounded(arguments, "q", 200) - return {"name": name, "summary": f"Suchen: {query}", "args": {"q": query}} + return {"name": name, "summary": f"Search: {query}", "args": {"q": query}} def nl_actions(reply: str, items: list[dict], agents: list[str]) -> dict: diff --git a/orchestrator/kvb_orchestrator/names.py b/orchestrator/kvb_orchestrator/names.py index 04531ba..43e1fbf 100644 --- a/orchestrator/kvb_orchestrator/names.py +++ b/orchestrator/kvb_orchestrator/names.py @@ -75,8 +75,8 @@ class Directory: if begin <= after["first_seen"] <= end: found.append(event({ "id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"], - "now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}", - "text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]}, + "now": self.now(session), "session": session, "title": f"{before['name']} is now called {after['name']}", + "text": f"Source: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]}, })) return found diff --git a/orchestrator/kvb_orchestrator/offices.py b/orchestrator/kvb_orchestrator/offices.py index b2421ec..adb6aff 100644 --- a/orchestrator/kvb_orchestrator/offices.py +++ b/orchestrator/kvb_orchestrator/offices.py @@ -11,7 +11,7 @@ from .signed import signing ITEM_LIMIT = 20 JOB_VERSION = 1 -LABELS = {"appoint": "besetzt", "takeover": "übergeben", "recall": "abberufen"} +LABELS = {"appoint": "appointed", "takeover": "handed over", "recall": "recalled"} def office_request(raw: object) -> bool: @@ -77,7 +77,7 @@ class Offices: self.office_spool.put(job_id, job) names = ", ".join(f"{item['office']} {LABELS[item['action']]}" for item in items) lines = "\n".join(f"{item['action']} {item['office']}: {item['holder'] or '-'} -> {item['session'] or '-'}" for item in items) - self.log.add({"kind": "run", "title": f"Amt signiert: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}}) + self.log.add({"kind": "run", "title": f"Office signed: {names}"[:200], "text": lines, "ref": {"offices": job_id, "status": "queued"}}) return {"id": job_id, "status": "queued", "items": job["items"]} def office_state(self, job_id: str) -> dict: diff --git a/orchestrator/kvb_orchestrator/phrasing.py b/orchestrator/kvb_orchestrator/phrasing.py index 6d5e1c7..a2375bc 100644 --- a/orchestrator/kvb_orchestrator/phrasing.py +++ b/orchestrator/kvb_orchestrator/phrasing.py @@ -8,16 +8,38 @@ SPEECH_LIMIT = 1000 SPOKEN_TEXT = 500 SPOKEN_NAME = 60 SPOKEN_GROUP = 120 -ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "SECURITE"} +ALARM_WORDS = {"pan-pan": "PAN-PAN", "security": "say-cure-ee-tay"} ADDRESSED = ("post", "call", "receipt") CALLS = re.compile(r"^(?:(?:MAYDAY|PAN[ -]?PAN|SECURIT[EÉY])\b[\s:,.;!-]*)+", re.IGNORECASE) HUB_MARK = re.compile(r"\[kvb-orchestrator msg [0-9a-f]{16}\]") HUB_FILE = re.compile(r"\s+file: /\S*$") SPOKEN_LINE = re.compile(LINE.format(SPEECH_LIMIT)) +HASH = re.compile(r"\b(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,}\b|[0-9]", re.IGNORECASE) +NATO = dict(zip("abcdef", ("alfa", "bravo", "charlie", "delta", "echo", "foxtrot"))) +ICAO = dict(zip("0123456789", ("zero", "one", "two", "tree", "four", "fife", "six", "seven", "eight", "niner"))) + + +def pronounced(text: str) -> str: + """Args: + text: a radio text as displayed. + + Returns: + The text with each hex run of 7 or more characters holding a letter + and a digit spelled in the NATO alphabet and every other digit + spoken on its own in ICAO pronunciation. + """ + return HASH.sub(spelled, text) + + +def spelled(found: re.Match) -> str: + start, end = found.span() + before = " " if start and found.string[start - 1].isalnum() else "" + after = " " if end < len(found.string) and found.string[end].isalnum() else "" + return before + " ".join(NATO.get(char) or ICAO[char] for char in found[0].lower()) + after def clip(text: str, limit: int) -> str: - words = " ".join(text.split()) + words = " ".join(pronounced(text).split()) if len(words) <= limit: return words return words[:limit].rsplit(" ", 1)[0] or words[:limit] diff --git a/orchestrator/kvb_orchestrator/requesting.py b/orchestrator/kvb_orchestrator/requesting.py index 5db7cf7..452ba22 100644 --- a/orchestrator/kvb_orchestrator/requesting.py +++ b/orchestrator/kvb_orchestrator/requesting.py @@ -49,7 +49,7 @@ class Requesting: raise ToolError(f"{name} already has {AGENT_CAP} pending rule requests; wait for their answer with rule_request_wait") record = self.rule_requests.add_checked({**fields, "agent": name, "why": why, "status": "pending", "rule": None, "reason": "", "channel": "", "proof": None, "decided": None, "created_ts": now}, capped) - self.log.add({"kind": "rule", "title": f"Regelwunsch von {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}}) + self.log.add({"kind": "rule", "title": f"Rule request from {name}: {request_words(record)}", "text": why, "ref": {"request": record["id"]}}) return {"id": record["id"], "status": record["status"], "url": self.url()} def rule_request_wait(self, arguments: dict) -> dict: @@ -78,7 +78,7 @@ class Requesting: items = [] for record in self.open_requests(now): rule = {key: record[key] for key in RULE_FIELDS} - fields = {"title": f"Regel: {request_words(record)}", "agent": record["agent"], "to": requester_of(claims, record["agent"])[0], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True} + fields = {"title": f"Rule: {request_words(record)}", "agent": record["agent"], "to": requester_of(claims, record["agent"])[0], "detail": record["why"], "rule": rule, "dry_run": self.dry_run(record, now), "deniable": True} items.append({**base, "kind": "rule", "id": record["id"], "ts": record["created_ts"], **fields}) return items @@ -113,5 +113,5 @@ class Requesting: if not changed: raise Conflict(request_state(decided, now)) self.store_rule(rule, fields, proof, True, now) - self.log.add({"kind": "rule", "title": f"Regelwunsch angenommen: {request_words(record)}", "text": f"von {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}}) + self.log.add({"kind": "rule", "title": f"Rule request accepted: {request_words(record)}", "text": f"from {record['agent']}", "ref": {"request": request_id, "rule": rule["id"]}}) return request_state(decided, now) diff --git a/orchestrator/kvb_orchestrator/rules.py b/orchestrator/kvb_orchestrator/rules.py index 1e438a5..f9bb79a 100644 --- a/orchestrator/kvb_orchestrator/rules.py +++ b/orchestrator/kvb_orchestrator/rules.py @@ -72,7 +72,7 @@ class Rules: try: passkey.verify_assertion(assertion, rule, verifier.pinned(self.approvers), signing.origin_of(self.public_port)) except signing.Refused as error: - raise ToolError(f"nicht signiert: {error}") from None + raise ToolError(f"not signed: {error}") from None return {"payload": rule, "assertion": assertion} def store_rule(self, rule: dict, fields: dict, proof: dict, enabled: bool, now: float) -> dict: diff --git a/orchestrator/kvb_orchestrator/tasking.py b/orchestrator/kvb_orchestrator/tasking.py index ae31c70..b28c8a5 100644 --- a/orchestrator/kvb_orchestrator/tasking.py +++ b/orchestrator/kvb_orchestrator/tasking.py @@ -66,5 +66,5 @@ class Tasking: fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof} ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"]) commands = [shown_command(item["argv"]) for item in items] - self.log.add({"kind": "run", "title": f"Aufgabe signiert geändert: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}}) + self.log.add({"kind": "run", "title": f"Task change signed: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}}) return {"jobs": ids, "status": "queued"} diff --git a/orchestrator/kvb_orchestrator/timeline.py b/orchestrator/kvb_orchestrator/timeline.py index acc1e85..0724461 100644 --- a/orchestrator/kvb_orchestrator/timeline.py +++ b/orchestrator/kvb_orchestrator/timeline.py @@ -53,7 +53,7 @@ class Timeline: rows = [] for index, row in enumerate(radio_rows(radio, begin, end)): alarm = row["class"] in ("mayday", "pan-pan") and row["ts"] >= end - MAYDAY_ACTIVE - title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'alle'}" + title = f"{row['from'] or '?'} → {', '.join(row['to']) or 'all'}" source = directory.key(row["from"], row["ts"]) who = {"agent": directory.at(source, row["ts"]) or row["from"], "now": directory.now(source), "session": source} ref = {"to": row["to"], "class": row["class"], "targets": [directory.key(name, row["ts"]) for name in row["to"]]} diff --git a/orchestrator/kvb_orchestrator/tree.py b/orchestrator/kvb_orchestrator/tree.py index a5c2d5d..648c8d8 100644 --- a/orchestrator/kvb_orchestrator/tree.py +++ b/orchestrator/kvb_orchestrator/tree.py @@ -73,19 +73,19 @@ def dry_run(item: dict) -> list[str]: always stays. """ tip = (item["local_sha"] or item["head"] or "")[:12] - merged = f", gemergt durch {item['merged_by']}" if item["merged_by"] else "" + merged = f", merged by {item['merged_by']}" if item["merged_by"] else "" lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"] for action in item["actions"]: if action == "worktree": - lines.append(f"entfernt den Worktree {item['worktree']}") + lines.append(f"removes the worktree {item['worktree']}") elif action == "local-branch": - lines.append(f"löscht den lokalen Branch {item['branch']}") + lines.append(f"deletes the local branch {item['branch']}") elif action.startswith("remote"): - lines.append(f"bleibt: {action} (nur lokal)") + lines.append(f"stays: {action} (local only)") else: lines.append(action) - lines += [f"stoppt den Compose-Stack {name} ohne -v" for name in item["compose_projects"]] - lines += [f"entfernt das Image {name}" for name in item["images"]] + lines += [f"stops the Compose stack {name} without -v" for name in item["compose_projects"]] + lines += [f"removes the image {name}" for name in item["images"]] return lines diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index 91f65a7..b98aad5 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -1,5 +1,5 @@ - + @@ -426,8 +426,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi

Agent Orchestrator

- - + +