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>
209 lines
9.4 KiB
Python
209 lines
9.4 KiB
Python
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)
|