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>
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from collections.abc import Callable
|
|
from http import HTTPStatus
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from . import NAME
|
|
from .errors import Conflict, EngineError, Missing, ToolError, Unavailable
|
|
from .hub import Hub
|
|
|
|
GUARD = "X-Kvb-Orchestrator"
|
|
PLAIN_CSP = "default-src 'none'; frame-ancestors 'none'"
|
|
LENGTH = re.compile(r"[0-9]{1,9}")
|
|
QUERY_FIELDS = 32
|
|
|
|
|
|
class HubServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
hub: Hub
|
|
|
|
|
|
class Exchange(BaseHTTPRequestHandler):
|
|
server: HubServer
|
|
server_version = NAME
|
|
sys_version = ""
|
|
timeout = 60
|
|
|
|
def send(self, status: HTTPStatus, content_type: str, body: bytes, headers: dict[str, str]) -> None:
|
|
self.send_response(status)
|
|
common = {
|
|
"Content-Type": content_type,
|
|
"Content-Length": str(len(body)),
|
|
"Cache-Control": "no-store",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "no-referrer",
|
|
}
|
|
for name, value in {**common, **headers}.items():
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def send_text(self, status: HTTPStatus, message: str) -> None:
|
|
self.send(status, "text/plain; charset=utf-8", f"{message}\n".encode(), {"Content-Security-Policy": PLAIN_CSP})
|
|
|
|
def send_json(self, status: HTTPStatus, payload: dict) -> None:
|
|
self.send(status, "application/json", json.dumps(payload).encode(), {"Content-Security-Policy": PLAIN_CSP})
|
|
|
|
def not_allowed(self) -> None:
|
|
self.send_response(HTTPStatus.METHOD_NOT_ALLOWED)
|
|
self.send_header("Allow", "POST")
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def answer(self, produce: Callable[[], dict], status: HTTPStatus) -> None:
|
|
self.attempt(produce, lambda payload: self.send_json(status, payload))
|
|
|
|
def attempt(self, produce: Callable[[], object], deliver: Callable[[object], None]) -> None:
|
|
try:
|
|
result = produce()
|
|
except ToolError as error:
|
|
self.send_text(HTTPStatus.BAD_REQUEST, str(error))
|
|
except Missing:
|
|
self.send_text(HTTPStatus.NOT_FOUND, "not found")
|
|
except Conflict as error:
|
|
self.send_json(HTTPStatus.CONFLICT, error.payload)
|
|
except Unavailable as error:
|
|
self.send_text(HTTPStatus.SERVICE_UNAVAILABLE, str(error))
|
|
except EngineError as error:
|
|
self.send_text(HTTPStatus.BAD_GATEWAY, str(error))
|
|
except OSError as error:
|
|
self.send_text(HTTPStatus.INTERNAL_SERVER_ERROR, f"storage failed: {error.strerror}")
|
|
else:
|
|
deliver(result)
|
|
|
|
def query(self) -> dict[str, list[str]]:
|
|
try:
|
|
return parse_qs(urlsplit(self.path).query, keep_blank_values=True, max_num_fields=QUERY_FIELDS)
|
|
except ValueError as error:
|
|
raise ToolError(str(error)) from error
|
|
|
|
def host(self) -> str | None:
|
|
hosts = self.headers.get_all("Host")
|
|
if hosts is None or len(hosts) != 1 or hosts[0] not in self.server.hub.hosts():
|
|
return None
|
|
return hosts[0]
|
|
|
|
def guarded(self, host: str) -> bool:
|
|
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") == [f"http://{host}"]
|
|
|
|
def local(self, host: str) -> bool:
|
|
origins = self.headers.get_all("Origin")
|
|
return self.headers.get_all(GUARD) == ["1"] and (origins is None or origins == [f"http://{host}"])
|
|
|
|
def client(self) -> bool:
|
|
return self.headers.get_all(GUARD) == ["1"] and self.headers.get_all("Origin") is None
|
|
|
|
def body_length(self, limit: int) -> int | None:
|
|
length = self.headers.get("Content-Length")
|
|
if length is None or not LENGTH.fullmatch(length):
|
|
self.send_text(HTTPStatus.LENGTH_REQUIRED, "Content-Length required")
|
|
return None
|
|
if int(length) > limit:
|
|
self.send_text(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "body too large")
|
|
return None
|
|
return int(length)
|