Files
skills/orchestrator/kvb_orchestrator/timeline.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 6f940b4415 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>
2026-11-06 22:20:06 +00:00

116 lines
7.5 KiB
Python

from __future__ import annotations
import time
from .asking import ask_state
from .council import MAYDAY_ACTIVE, NEWS, radio_rows, read_claims, sessions
from .crons import cron_event
from .errors import Missing, ToolError
from .events import event, event_filters, wanted
from .fields import instant, single, whole
from .names import Directory
from .records import iso_epoch, read_jsonl
from .register import REGISTER, operator_rows, read_register
from .signed import signing
from .transcripts import STREAM_CUT, STREAM_KINDS, matches
STREAM_LIMIT = 2000
EVENT_LIMIT = 3000
class Timeline:
def stream(self, query: dict[str, list[str]]) -> dict:
self.transcripts.refresh()
names = sessions(read_claims(self.council))
wanted = single(query, "kind")
kinds = None if not wanted else set(wanted.split(","))
if kinds is not None and not kinds <= set(STREAM_KINDS):
raise ToolError(f"kind must be a comma list of {', '.join(STREAM_KINDS)}")
filters = {
"agent": single(query, "agent") or "",
"session": single(query, "session") or "",
"kinds": kinds,
"q": (single(query, "q") or "").lower(),
"begin": instant(query, "from"),
"end": instant(query, "to"),
}
limit = min(whole(query, "limit") or STREAM_LIMIT, STREAM_LIMIT)
picked = [
{**entry, "agent": names.get(entry["session"], ""), "text": entry["text"][:STREAM_CUT], "truncated": len(entry["text"]) > STREAM_CUT}
for entry in self.transcripts.since(whole(query, "after") or 0)
if matches(entry, names.get(entry["session"], ""), filters)
]
picked.sort(key=lambda entry: (entry["ts"], entry["id"]))
return {"entries": picked[-limit:], "last": self.transcripts.last(), "sessions": names}
def entry(self, entry_id: int) -> dict:
record = self.transcripts.entry(entry_id)
if record is None:
raise Missing(str(entry_id))
return {"id": record["id"], "text": record["text"]}
def timeline(self, begin: float, end: float, now: float, tasks: dict[str, dict], claims: list[dict], radio: list[dict], directory: Directory) -> list[dict]:
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'}"
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"]]}
rows.append(event({"id": f"radio:{row['ts']}:{index}", "ts": row["ts"], "kind": "radio", "part": row["class"], **who, "title": title, "text": row["text"], "waiting": alarm, "ref": ref}))
for review in self.reviews.listed():
created = iso_epoch(review["created"]) or now
if created <= end and (review["status"] == "pending" or created >= begin):
ref = {key: review.get(key, "") for key in ("id", "status", "gate", "score", "branch", "worktree", "tree", "files")}
rows.append(event({"id": f"review:{review['id']}", "ts": created, "kind": "review", "title": review["title"], "text": review["notes"][:STREAM_CUT], "waiting": review["status"] == "pending", "ref": ref}))
for ask in self.asks.listed():
state = ask_state(ask, now)
if ask["created_ts"] <= end and (state["status"] == "pending" or ask["created_ts"] >= begin):
ref = {"id": ask["id"], "status": state["status"], "cwd": ask["cwd"], "tool": ask["tool"], "warnings": signing.warnings(ask["command"])}
who = {"agent": directory.at(ask["session"], ask["created_ts"]), "now": directory.now(ask["session"]), "session": ask["session"]}
rows.append(event({"id": f"ask:{ask['id']}", "ts": ask["created_ts"], "kind": "ask", **who, "title": ask["tool"], "text": ask["command"], "waiting": state["status"] == "pending", "ref": ref}))
for decision in self.decisions.listed():
if decision["created_ts"] <= end and (decision["status"] == "pending" or decision["created_ts"] >= begin):
ref = {key: decision.get(key, "") for key in ("id", "status", "choice", "recommended", "options")}
text = " · ".join(option["label"] for option in decision["options"])
source = directory.key(decision["agent"], decision["created_ts"])
who = {"agent": directory.at(source, decision["created_ts"]) or decision["agent"], "now": directory.now(source), "session": source}
rows.append(event({"id": f"decision:{decision['id']}", "ts": decision["created_ts"], "kind": "decision", **who, "title": decision["question"][:300], "text": text, "waiting": decision["status"] == "pending", "ref": ref}))
held = self.open_jobs()
for row in operator_rows(tasks):
first = self.watch.first(row["task"], now)
if first <= end:
ref = {key: row[key] for key in ("task", "directory", "check", "status")}
rows.append(event({"id": f"operator:{row['task']}", "ts": first, "kind": "operator", "title": row["command"], "text": row["why"], "waiting": row["task"] not in held, "ref": ref}))
rows += [change for change in self.watch.listed() if begin <= change["ts"] <= end]
rows += [cron_event(cron, directory) for cron in self.schedules(claims, now) if cron["created"] <= end and (cron["active"] or cron["created"] >= begin)]
rows += [row for row in self.log.listed() if begin <= row["ts"] <= end]
rows += directory.renames(begin, end)
return sorted(rows, key=lambda row: (row["ts"], row["id"]))
def events(self, query: dict[str, list[str]]) -> dict:
now = time.time()
begin, end = self.span(query, now)
filters = event_filters(query)
limit = min(whole(query, "limit") or EVENT_LIMIT, EVENT_LIMIT)
after = whole(query, "after") or 0
self.transcripts.refresh()
claims = read_claims(self.council)
news = read_jsonl(self.council / NEWS)
directory = self.directory(claims, news, now)
filters["aliases"] = directory.aliases(filters["agent"]) if filters["agent"] else set()
tasks = read_register(self.council / REGISTER)
self.watch.see(tasks, now)
rows = [row for row in self.timeline(begin, end, now, tasks, claims, news, directory) if wanted(row, filters)]
live = instant(query, "to") is None
entries = []
for entry in self.transcripts.since(after) if "transcript" in filters["kinds"] else []:
shaped = event({
"id": f"transcript:{entry['id']}", "ts": entry["ts"], "kind": "transcript", "part": entry["kind"], "agent": directory.at(entry["session"], entry["ts"]),
"now": directory.now(entry["session"]), "session": entry["session"], "sub": entry["sub"], "title": entry["kind"], "text": entry["text"][:STREAM_CUT],
"ref": {"entry": entry["id"], "truncated": len(entry["text"]) > STREAM_CUT},
})
if entry["ts"] >= begin and (live or entry["ts"] <= end) and wanted(shaped, filters):
entries.append(shaped)
return {"events": rows[-limit:], "entries": entries[-limit:], "last": self.transcripts.last(), "sessions": sessions(claims)}