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>
133 lines
5.2 KiB
Python
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"}
|