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,115 @@
|
||||
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)}
|
||||
Reference in new issue
Block a user