Files
skills/orchestrator/kvb_orchestrator/gating.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 c8b2e586de fix(orchestrator): hub gate view reads recorded parents through gates.opened
W5 moved recorded parents into a journal that gates.opened() loads into a TEMP table; the W3 hub opened gates.db raw, so parents_of() raised and the gate view stayed empty. opened() and journal() take the kept directory explicitly (CLI: watch.KEPT, hub: its /kept mount), opened() closes the connection when setup raises, and the W3 tests use opened() plus a test for a parent from the kept journal.

🎯 Fix: 95 %
💥 Break: 3 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 92
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:07:17 +00:00

133 lines
5.2 KiB
Python

from __future__ import annotations
import functools
import importlib.util
import sqlite3
import time
from contextlib import closing
from pathlib import Path
from types import ModuleType
from .errors import ToolError
from .fields import decode, expect
from .jobs import GATE_JOB
from .signed import signing
GATES_SCRIPT = "kvb-check-gate/scripts/gates.py"
GATES_DB = "gates.db"
GATE_TASK = "gates"
CHANGE_LIMIT = 100
CHANGE_KEYS = ("kind", "argv")
def gate_request(raw: object) -> bool:
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == GATE_JOB for entry in raw)
def shown_command(argv: list[str]) -> str:
return " ".join(["gates.py", *argv])
def gates_module(script: Path) -> ModuleType | None:
"""Returns:
The installed gates.py as a module, for its opened(), resolve() and
parents_of(), which only read the database and the parent journals;
loaded again after an install changed it, None when it is missing or
does not load.
"""
try:
changed = script.stat().st_mtime_ns
except OSError:
return None
return loaded(script, changed)
@functools.cache
def loaded(script: Path, changed: int) -> ModuleType | None:
spec = importlib.util.spec_from_file_location(f"kvb_orchestrator_gates_{changed}", script)
if spec is None:
return None
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except (OSError, ImportError, RuntimeError, SyntaxError):
return None
return module
def branch_gates(gates: ModuleType, db: sqlite3.Connection, repo: str, branch: str) -> dict:
return {
"parents": [{"parent": parent, "source": source} for parent, source in gates.parents_of(db, repo, branch)],
"rules": dict(db.execute("SELECT action, level FROM rules WHERE repo = ? AND branch = ?", (repo, branch))),
"resolved": {action: dict(zip(("level", "source"), gates.resolve(db, repo, branch, action))) for action in gates.ACTIONS},
}
def read_gates(gates: ModuleType | None, db: Path, scan: dict) -> dict:
"""Args:
gates: the gates.py module, or None.
db: gates.db under the read-only mount of ~/.local/state/agents-council.
scan: the tree; its repositories and branches are the ones shown.
Returns:
{repo: {branch: {parents, rules, resolved}}} as `gates.py show --json`
prints it, read through gates.opened() with the database read-only,
the kept parent journal from the same mount and without git; {}
without the script or a readable database.
"""
if gates is None or not db.is_file():
return {}
try:
with closing(gates.opened(db, False, db.parent)) as connection:
return {repo["path"]: {entry["name"]: branch_gates(gates, connection, repo["path"], entry["name"]) for entry in repo["branches"]} for repo in scan["repos"]}
except sqlite3.Error:
return {}
class Gating:
def gate_view(self, scan: dict) -> dict:
try:
return read_gates(gates_module(self.scripts.skills / GATES_SCRIPT), self.kept / GATES_DB, scan)
except Exception: # noqa: BLE001
return {}
def gate_changes(self, raw: object) -> list[dict]:
"""Args:
raw: the page's items, each {"kind": "gate", "argv"} with the
gates.py arguments of one rule set|drop or parents add|drop;
the staged changes go under one passkey assertion.
Returns:
The signed items, each with an argv signing.gate_argv() accepts.
"""
if not isinstance(raw, list) or not 1 <= len(raw) <= CHANGE_LIMIT:
raise ToolError(f"items must be a list of 1 to {CHANGE_LIMIT} gate changes")
signed: list[dict] = []
for entry in raw:
if not isinstance(entry, dict):
raise ToolError("every item must be an object with kind and argv")
expect(entry, CHANGE_KEYS)
try:
item = signing.gate_item(signing.gate_argv(entry["argv"]))
except signing.Refused as error:
raise ToolError(f"gate change: {error}") from None
if item in signed:
raise ToolError("items must be distinct")
signed.append(item)
return signed
def change_gates(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items", "proof"))
items = self.gate_changes(payload["items"])
proof = self.covered(payload["proof"], items, time.time())
for item in items:
self.operator_proven(proof, item)
ids = []
for item in items:
fields = {"kind": GATE_JOB, "task": GATE_TASK, "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof}
ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"])
commands = [shown_command(item["argv"]) for item in items]
self.log.add({"kind": "run", "title": f"Gate change signed: {len(items)}", "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
return {"jobs": ids, "status": "queued"}