feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface
Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54f3725aa1
commit
6f940b4415
125 files changed
+1725
-782
No files matched your search
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .council import CLASSES
|
||||
from .errors import ToolError
|
||||
from .fields import FIELDS, NAME_LIMIT, expect
|
||||
from .records import read_json, write_atomic
|
||||
|
||||
THEMES = ("sky", "light", "dark", "custom")
|
||||
TONES = ("mayday", "review", "direct", "radio")
|
||||
COLOR = re.compile(r"#[0-9a-f]{6}")
|
||||
CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]")
|
||||
MIDNIGHT = "24:00"
|
||||
STREAMS_LIMIT = 20
|
||||
STREAM_URL = re.compile(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&()*+,;=%-]{1,2040}")
|
||||
HOSTNAME = re.compile(r"(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*")
|
||||
DEFAULT_PORTS = {"http": 80, "https": 443}
|
||||
SETTINGS = {
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"theme": "sky",
|
||||
"colors": {"top": "#0b1020", "bottom": "#2b3a67", "accent": "#8fadff"},
|
||||
"music": {"on": False, "volume": 0.4},
|
||||
"sounds": {
|
||||
"mayday": {"on": True, "volume": 0.9},
|
||||
"review": {"on": True, "volume": 0.6},
|
||||
"direct": {"on": True, "volume": 0.5},
|
||||
"radio": {"on": False, "volume": 0.25},
|
||||
},
|
||||
"notify": True,
|
||||
"streams": [],
|
||||
"speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []},
|
||||
"space": {
|
||||
"graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0},
|
||||
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
|
||||
},
|
||||
}
|
||||
SPACE_AXES = ("none", "time", "spawn")
|
||||
SPACE_LIMITS = {
|
||||
"graph": {"yaw": (-180.0, 180.0), "pitch": (-90.0, 90.0), "zoom": (0.25, 4.0), "pan_x": (-2000.0, 2000.0), "pan_y": (-2000.0, 2000.0)},
|
||||
"sky": {"azimuth": (0.0, 360.0), "altitude": (-30.0, 85.0), "fov": (20.0, 240.0)},
|
||||
}
|
||||
MUTED_LIMIT = 200
|
||||
LEGACY_CLASSES = {"direct": ("post", "call")}
|
||||
|
||||
|
||||
def section(value: object, name: str, keys: tuple[str, ...]) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise ToolError(f"{name} must be an object")
|
||||
try:
|
||||
expect(value, keys)
|
||||
except ToolError as error:
|
||||
raise ToolError(f"{name}: {error}") from None
|
||||
return value
|
||||
|
||||
|
||||
def number_in(value: object, name: str, low: float, high: float) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, int | float) or not low <= value <= high:
|
||||
raise ToolError(f"{name} must be a number from {low} to {high}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def flag(value: object, name: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ToolError(f"{name} must be true or false")
|
||||
return value
|
||||
|
||||
|
||||
def color(value: object, name: str) -> str:
|
||||
if not isinstance(value, str) or not COLOR.fullmatch(value):
|
||||
raise ToolError(f"{name} must be a colour like #1a2b3c")
|
||||
return value
|
||||
|
||||
|
||||
def stream_origin(url: object) -> str:
|
||||
if not isinstance(url, str) or not STREAM_URL.fullmatch(url):
|
||||
raise ToolError("a stream url must be an http or https URL without spaces or quotes")
|
||||
parts = urlsplit(url)
|
||||
try:
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
raise ToolError("a stream url needs a valid port") from None
|
||||
host = parts.hostname or ""
|
||||
if parts.username is not None or parts.password is not None or not HOSTNAME.fullmatch(host):
|
||||
raise ToolError("a stream url needs a plain host name and no user info")
|
||||
return f"{parts.scheme}://{host}" if port in (None, DEFAULT_PORTS[parts.scheme]) else f"{parts.scheme}://{host}:{port}"
|
||||
|
||||
|
||||
def clock_minutes(value: object, name: str, end: bool) -> int:
|
||||
if end and value == MIDNIGHT:
|
||||
return 24 * 60
|
||||
if not isinstance(value, str) or not CLOCK.fullmatch(value):
|
||||
raise ToolError(f"{name} must be a time like 07:30")
|
||||
hours, minutes = value.split(":")
|
||||
return int(hours) * 60 + int(minutes)
|
||||
|
||||
|
||||
def stream_entry(raw: object, index: int) -> dict:
|
||||
name = f"streams[{index}]"
|
||||
entry = section(raw, name, ("name", "url", "from", "to", "days", "on"))
|
||||
label, days = entry["name"], entry["days"]
|
||||
if not isinstance(label, str) or not FIELDS["label"][0].fullmatch(label):
|
||||
raise ToolError(f"{name}.name must be one line of 1 to {NAME_LIMIT} characters")
|
||||
stream_origin(entry["url"])
|
||||
if clock_minutes(entry["from"], f"{name}.from", False) == clock_minutes(entry["to"], f"{name}.to", True):
|
||||
raise ToolError(f"{name} must end at another time than it starts")
|
||||
if not isinstance(days, list) or not days or len(set(map(str, days))) != len(days) or any(isinstance(day, bool) or not isinstance(day, int) or not 0 <= day <= 6 for day in days):
|
||||
raise ToolError(f"{name}.days must list distinct weekdays from 0 (Monday) to 6 (Sunday)")
|
||||
return {"name": label, "url": entry["url"], "from": entry["from"], "to": entry["to"], "days": sorted(days), "on": flag(entry["on"], f"{name}.on")}
|
||||
|
||||
|
||||
def tone_setting(raw: object, kind_name: str) -> dict:
|
||||
entry = section(raw, f"sounds.{kind_name}", ("on", "volume"))
|
||||
return {"on": flag(entry["on"], f"sounds.{kind_name}.on"), "volume": number_in(entry["volume"], f"sounds.{kind_name}.volume", 0.0, 1.0)}
|
||||
|
||||
|
||||
def distinct_texts(value: object, valid: Callable[[str], bool], limit: int) -> bool:
|
||||
if not isinstance(value, list) or len(value) > limit or not all(isinstance(item, str) and valid(item) for item in value):
|
||||
return False
|
||||
return len(set(value)) == len(value)
|
||||
|
||||
|
||||
def speech_setting(raw: object) -> dict:
|
||||
entry = section(raw, "speech", ("on", "classes", "muted"))
|
||||
classes, muted = entry["classes"], entry["muted"]
|
||||
if not distinct_texts(classes, lambda name: name in CLASSES, len(CLASSES)):
|
||||
raise ToolError(f"speech.classes must list distinct classes of {', '.join(CLASSES)}")
|
||||
if "mayday" not in classes:
|
||||
raise ToolError("speech.classes must include mayday: a MAYDAY is never quieter than anything else")
|
||||
if not distinct_texts(muted, FIELDS["agent"][0].fullmatch, MUTED_LIMIT):
|
||||
raise ToolError(f"speech.muted must list at most {MUTED_LIMIT} distinct agent keys, each one line of 1 to 200 characters")
|
||||
return {"on": flag(entry["on"], "speech.on"), "classes": [name for name in CLASSES if name in classes], "muted": sorted(muted)}
|
||||
|
||||
|
||||
def space_setting(raw: object) -> dict:
|
||||
entry = section(raw, "space", tuple(SETTINGS["space"]))
|
||||
graph = section(entry["graph"], "space.graph", tuple(SETTINGS["space"]["graph"]))
|
||||
sky = section(entry["sky"], "space.sky", tuple(SETTINGS["space"]["sky"]))
|
||||
if not isinstance(graph["axis"], str) or graph["axis"] not in SPACE_AXES:
|
||||
raise ToolError(f"space.graph.axis must be one of {', '.join(SPACE_AXES)}")
|
||||
views = {"graph": graph, "sky": sky}
|
||||
numbers = {view: {key: number_in(views[view][key], f"space.{view}.{key}", low, high) for key, (low, high) in limits.items()} for view, limits in SPACE_LIMITS.items()}
|
||||
return {"graph": {"axis": graph["axis"], **numbers["graph"]}, "sky": numbers["sky"]}
|
||||
|
||||
|
||||
def valid_settings(raw: object) -> dict:
|
||||
data = section(raw, "settings", tuple(SETTINGS))
|
||||
colors = section(data["colors"], "colors", tuple(SETTINGS["colors"]))
|
||||
music = section(data["music"], "music", ("on", "volume"))
|
||||
sounds = section(data["sounds"], "sounds", TONES)
|
||||
if not isinstance(data["theme"], str) or data["theme"] not in THEMES:
|
||||
raise ToolError(f"theme must be one of {', '.join(THEMES)}")
|
||||
if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT:
|
||||
raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries")
|
||||
return {
|
||||
"latitude": number_in(data["latitude"], "latitude", -90.0, 90.0),
|
||||
"longitude": number_in(data["longitude"], "longitude", -180.0, 180.0),
|
||||
"theme": data["theme"],
|
||||
"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},
|
||||
"notify": flag(data["notify"], "notify"),
|
||||
"streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])],
|
||||
"speech": speech_setting(data["speech"]),
|
||||
"space": space_setting(data["space"]),
|
||||
}
|
||||
|
||||
|
||||
def migrated(stored: object) -> object:
|
||||
speech = stored.get("speech") if isinstance(stored, dict) else None
|
||||
classes = speech.get("classes") if isinstance(speech, dict) else None
|
||||
if not isinstance(classes, list) or not any(name in LEGACY_CLASSES for name in classes):
|
||||
return stored
|
||||
renamed = [new for name in classes for new in LEGACY_CLASSES.get(name, (name,))]
|
||||
return {**stored, "speech": {**speech, "classes": list(dict.fromkeys(renamed))}}
|
||||
|
||||
|
||||
def media_origins(settings: dict) -> list[str]:
|
||||
return sorted({stream_origin(stream["url"]) for stream in settings["streams"]})
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self, file: Path) -> None:
|
||||
self.file = file
|
||||
self.lock = threading.Lock()
|
||||
stored = migrated(read_json(file))
|
||||
try:
|
||||
self.current = valid_settings({**SETTINGS, **stored} if isinstance(stored, dict) else stored)
|
||||
except ToolError:
|
||||
self.current = valid_settings(SETTINGS)
|
||||
|
||||
def get(self) -> dict:
|
||||
with self.lock:
|
||||
return copy.deepcopy(self.current)
|
||||
|
||||
def put(self, raw: object) -> dict:
|
||||
settings = valid_settings(raw)
|
||||
with self.lock:
|
||||
write_atomic(self.file, json.dumps(settings, indent=1, sort_keys=True).encode("utf-8"))
|
||||
self.current = settings
|
||||
return copy.deepcopy(settings)
|
||||
Reference in new issue
Block a user