diff --git a/docs/requirements/003-operator-workbench.md b/docs/requirements/003-operator-workbench.md index d1b34c9..faaa40a 100644 --- a/docs/requirements/003-operator-workbench.md +++ b/docs/requirements/003-operator-workbench.md @@ -205,7 +205,7 @@ flowchart TD ### W3 Artifacts -- [ ] "Clean all merged" shows the gc dry run of every `merged-clean` item; one +- [x] "Clean all merged" shows the gc dry run of every `merged-clean` item; one passkey runs the signed job. - [ ] Without a GitHub token, local branches merged by ancestry into the default branch and without a remote branch can be deleted; the others diff --git a/orchestrator/client/cleanup.py b/orchestrator/client/cleanup.py index 080c177..14cbbeb 100755 --- a/orchestrator/client/cleanup.py +++ b/orchestrator/client/cleanup.py @@ -16,12 +16,17 @@ Returns: Runs once over every job in --jobs, as the operator on the host: per item it verifies the operator's passkey signature over repository, branch, worktree and tip, then in the repository runs `pkgmgr branch cleanup - --json --local --branch ` as a dry run and, only when that plan - names exactly this branch as merged-clean without skip reason at the - signed tip and worktree, `pkgmgr branch cleanup --apply --json --local - --branch `. Every other item is refused and nothing changes for - it. Writes each item's result, rewrites the tree file after a deletion - and removes the job. The exit code is 0. + --json --branch ` as a dry run and, only when that plan names + exactly this branch as merged-clean without skip reason at the signed + tip and worktree and deletes no remote branch, `pkgmgr branch cleanup + --apply --json --branch `. While the tree file says pkgmgr has no GitHub token + (delete_ready false), only a branch the dry run shows merged by ancestry + into the default branch and without a remote branch is deleted; the + others are refused with "token needed". Every other item is refused and + nothing changes for it. An applied item is done only when pkgmgr's + result for it is done; a skipped one or one without result is failed + with pkgmgr's reason. Writes each item's result, rewrites the tree file + after a deletion and removes the job. The exit code is 0. """ from __future__ import annotations @@ -43,6 +48,9 @@ TIP = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}") UNSAFE = re.compile(r"[*?\[\]\\\s\x00-\x1f\x7f]|^-|\.\.|@\{") OUTPUT_LIMIT = 16 << 10 TIMEOUT = 600 +DONE = "done" +REMOTE_ACTION = "remote-branch:" +TOKEN_NEEDED = "token needed: without the GitHub token only a local branch merged by ancestry into the default branch and without a remote branch is deleted" # the cwd is a repository agents can write: its config may name hooks, and # pkgmgr releases before 4c8b137 run git branch -D without scan mode GIT_OVERRIDES = ( @@ -62,12 +70,18 @@ def git_overrides() -> dict[str, str]: return env -def known_repositories(tree: Path) -> set[str]: +def tree_facts(tree: Path) -> tuple[dict[str, str], bool]: + """Returns: + The repositories the tree file says pkgmgr knows, each with its + default branch, and whether pkgmgr found the GitHub token + (delete_ready); nothing and False for an unreadable file. + """ try: data = json.loads(tree.read_text(encoding="utf-8")) - return {entry["path"] for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)} + known = {entry["path"]: entry.get("default") if isinstance(entry.get("default"), str) else "" for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)} + return known, data.get("delete_ready") is True except (OSError, ValueError, KeyError, TypeError, AttributeError): - return set() + return {}, False def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]: @@ -78,15 +92,32 @@ def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]: return done.returncode, done.stdout, done.stderr[-OUTPUT_LIMIT:] +def plan_argv(branch: str, apply: bool) -> list[str]: + """Args: + branch: the exact branch name. + apply: execute the plan instead of the dry run. + + Returns: + The `pkgmgr branch cleanup` arguments for this branch's plan in JSON. + """ + return ["branch", "cleanup", *(["--apply"] if apply else []), "--json", "--branch", branch] + + def plan_items(stdout: str) -> list[dict] | None: + """Returns: + Every item of `pkgmgr branch cleanup --json`, which prints + {"host_pid_namespace", "repos": [{"repo", "default", "merged_into", + "warnings", "items"}]}; None for any other output. + """ try: data = json.loads(stdout) except ValueError: return None - items = data.get("items") if isinstance(data, dict) else data - if not isinstance(items, list) or not all(isinstance(entry, dict) for entry in items): + repos = data.get("repos") if isinstance(data, dict) else None + if not isinstance(repos, list) or not all(isinstance(repo, dict) and isinstance(repo.get("items"), list) for repo in repos): return None - return items + items = [entry for repo in repos for entry in repo["items"]] + return items if all(isinstance(entry, dict) for entry in items) else None def shaped(item: object) -> bool: @@ -105,6 +136,9 @@ def mismatch(entry: dict, item: dict) -> str | None: return f"tip changed: signed {item['tip'][:12]}, now {str(tip)[:12]}" if (entry.get("worktree") or "") != item["worktree"]: return f"worktree changed: signed {item['worktree'] or 'none'}, now {entry.get('worktree') or 'none'}" + remote = [str(action) for action in entry.get("actions") or [] if str(action).startswith(REMOTE_ACTION)] + if remote: + return f"the plan also deletes {', '.join(remote)[:200]}; a signed delete is local only" return None @@ -114,16 +148,20 @@ class Cleanup: self.tree = tree self.pkgmgr = pkgmgr - def item(self, item: dict, proof: object, known: set[str], now: float) -> dict: + def item(self, item: dict, proof: object, known: dict[str, str], ready: bool, now: float) -> dict: """Args: item: one signed item with repository, branch, worktree and tip. proof: the job's {"payload", "assertion"}. - known: the repositories the tree file says pkgmgr knows. + known: the repositories the tree file says pkgmgr knows, each + with its default branch. + ready: whether pkgmgr holds the GitHub token; without it only a + branch signing.token_free() accepts in the dry run is deleted. now: the current time in epoch seconds. Returns: The item with status done, refused or failed and the reason; - refused and failed items changed nothing. + refused items changed nothing, failed ones are those pkgmgr did + not report done. """ def refused(reason: str) -> dict: return {**item, "status": "refused", "reason": reason} @@ -136,8 +174,7 @@ class Cleanup: return refused("unknown repository: pkgmgr does not list it") if UNSAFE.search(item["branch"]) or not item["branch"]: return refused("the branch name holds pattern or option characters; only an exact branch name is deleted") - selector = ["--json", "--local", "--branch", item["branch"]] - code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", *selector], item["repository"]) + code, stdout, stderr = run(self.pkgmgr, plan_argv(item["branch"], False), item["repository"]) plan = plan_items(stdout) if code == 0 else None if plan is None: return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}") @@ -149,10 +186,15 @@ class Cleanup: reason = mismatch(plan[0], item) if reason: return refused(reason) - code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", "--apply", *selector], item["repository"]) - applied = plan_items(stdout) - status = "done" if code == 0 else "failed" - return {**item, "status": status, "reason": "" if code == 0 else stderr.strip()[-500:], "code": code, "applied": applied if applied is not None else []} + if not ready and not signing.token_free(plan[0], known[item["repository"]]): + return refused(TOKEN_NEEDED) + code, stdout, stderr = run(self.pkgmgr, plan_argv(item["branch"], True), item["repository"]) + applied = plan_items(stdout) or [] + outcome = next((entry.get("result") for entry in applied if entry.get("branch") == item["branch"]), None) + if code == 0 and isinstance(outcome, str) and outcome.split(";")[0] == DONE: + return {**item, "status": "done", "reason": "", "code": code, "applied": applied} + reason = outcome if isinstance(outcome, str) and outcome else stderr.strip()[-500:] or f"pkgmgr reported no result for {item['branch']}" + return {**item, "status": "failed", "reason": reason[:500], "code": code, "applied": applied} def job(self, path: Path, now: float) -> dict: try: @@ -162,8 +204,8 @@ class Cleanup: items = job.get("items") if isinstance(job, dict) else None if not isinstance(items, list) or not items or not all(map(shaped, items)) or job.get("id") != path.stem: return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing deleted"} - known = known_repositories(self.tree) - results = [self.item(item, job.get("proof"), known, now) for item in items] + known, ready = tree_facts(self.tree) + results = [self.item(item, job.get("proof"), known, ready, now) for item in items] result = {"id": path.stem, "finished": time.time(), "items": results} if any(entry["status"] == "done" for entry in results): code, _, stderr = run(self.pkgmgr, ["branch", "tree", "--format", "json", "--output", str(self.tree)], str(self.tree.parent)) diff --git a/orchestrator/client/runner.py b/orchestrator/client/runner.py index fef6c2f..8da9cfe 100755 --- a/orchestrator/client/runner.py +++ b/orchestrator/client/runner.py @@ -10,6 +10,9 @@ ~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional. --council: the council directory holding rasci.json, the agents' AGENTS_COUNCIL_ROOT; optional, task changes need both. + --gates: the installed gates script, e.g. + ~/.claude/skills/kvb-check-gate/scripts/gates.py; optional, gate + changes need it. --once: handle the queued jobs once and exit. Returns: @@ -18,9 +21,12 @@ Returns: in the signed directory, each signature once and before its expiry. Runs every signed task change (priority pin or WSJF estimate) as `python3 -I -B ` from / without a shell, after checking the - argv shape itself, and kills its process group after 30 s. Refuses task - changes without --rasci and --council or into a council that is a - symlink or another uid's, and every other job. Posts the last 200 output lines + argv shape itself, and kills its process group after 30 s. Runs every + signed gate change (rule set|drop, parents add|drop) the same way as + `python3 -I -B ` with the operator's HOME, so gates.py + writes its default gates.db. Refuses task changes without --rasci and + --council or into a council that is a symlink or another uid's, gate + changes without --gates, and every other job. Posts the last 200 output lines back while it runs and the exit code at its end. The exit code is 0. """ @@ -52,8 +58,10 @@ BEAT = 2.0 SAFE_PATH = "/usr/bin:/bin" TASK_JOB = "task" TASK_LIMIT = 30.0 +GATE_JOB = "gate" REFUSED = "refused: no valid operator signature for this command" UNREGISTERED = "refused: this runner was started without --rasci and --council; reinstall its unit with make orchestrator-runner" +UNGATED = "refused: this runner was started without --gates; reinstall its unit with make orchestrator-runner" class Register(NamedTuple): @@ -113,6 +121,24 @@ def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None: return None +def gate_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None: + """Args: + job: the claimed gate change with argv and proof. + trust: the pinned keys, the runner's replay store and the origin. + now: the current time in epoch seconds. + + Returns: + Why the change must not run, or None when signing.gate_argv() + accepts its argv and a pinned key signed it. + """ + try: + argv = signing.gate_argv(job["argv"]) + verifier.check(job.get("proof"), signing.gate_item(argv), "", trust, now) + except (signing.Refused, OSError) as error: + return f"{REFUSED}: {error}" + return None + + def council_refusal(council: Path) -> str | None: """Args: council: the directory rasci.py writes the register into. @@ -161,6 +187,23 @@ def change(job: dict, register: Register, report: Callable[[dict], None]) -> int return spawn([sys.executable, "-I", "-B", str(register.rasci), *argv], "/", env, report, TASK_LIMIT) +def gate(job: dict, gates: Path, report: Callable[[dict], None]) -> int: + """Args: + job: the claimed and verified gate change with argv. + gates: the gates script. + report: called with {"output", "code", "refused"}. + + Returns: + The exit code of gates.py, run from / on the argv gate_argv() checked. + + Raises: + signing.Refused for an argv of any other shape. + """ + argv = signing.gate_argv(job["argv"]) + env = {"PATH": SAFE_PATH, "HOME": str(Path.home())} + return spawn([sys.executable, "-I", "-B", str(gates), *argv], "/", env, report, TASK_LIMIT) + + def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int: """Args: argv: the program and its arguments, run without a shell. @@ -224,22 +267,26 @@ def sender(base: str, job_id: str) -> Callable[[dict], None]: return send -def cycle(base: str, trust: verifier.Trust, register: Register | None) -> None: +def cycle(base: str, trust: verifier.Trust, register: Register | None, gates: Path | None = None) -> None: for job in call(base, "/api/runner/jobs", None)["jobs"]: try: claimed = call(base, f"/api/runner/{job['id']}/claim", {}) except urllib.error.HTTPError: continue send = sender(base, claimed["id"]) - task = claimed.get("kind") == TASK_JOB - if not task: - reason = refusal(claimed, trust, time.time()) - else: + kind = claimed.get("kind") + if kind == TASK_JOB: reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time()) + elif kind == GATE_JOB: + reason = UNGATED if gates is None else gate_refusal(claimed, trust, time.time()) + else: + reason = refusal(claimed, trust, time.time()) if reason is not None: send({"output": reason, "code": None, "refused": True}) - elif task: + elif kind == TASK_JOB: change(claimed, register, send) + elif kind == GATE_JOB: + gate(claimed, gates, send) else: execute(claimed, send) @@ -252,6 +299,7 @@ def main() -> int: parser.add_argument("--replay", type=Path, required=True) parser.add_argument("--rasci", type=Path) parser.add_argument("--council", type=Path) + parser.add_argument("--gates", type=Path) parser.add_argument("--once", action="store_true") args = parser.parse_args() base = hub_api.base_url(args.port) @@ -259,7 +307,7 @@ def main() -> int: register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None while True: try: - cycle(base, trust, register) + cycle(base, trust, register, args.gates) except (OSError, ValueError, KeyError, TypeError): pass if args.once: diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index 6cbce2b..0fd7a43 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -27,6 +27,11 @@ PRIO = re.compile(r"[1-9][0-9]{0,2}") FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89") ESTIMATOR = "Operator" COMMANDS = "operator/" +GATE_LEVELS = ("none", "quick", "test", "quality-high", "e2e") +GATE_ACTIONS = ("commit", "merge", "push", "pr") +GATE_REPO = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}") +GATE_BRANCH = re.compile(r"(?![-/])(?!.*\.\.)(?!.*@\{)(?!.*//)[^\x00-\x20\x7f~^:?*\[\\]{1,255}(? list[str]: raise Refused("the argv is no task priority or WSJF estimate change of this task") +def gate_item(argv: list[str]) -> dict: + return {"kind": "gate", "argv": argv} + + +def gate_argv(argv: object) -> list[str]: + """Args: + argv: the gates.py arguments, one of `rule set --repo R --branch B + --action A --level L`, `rule drop --repo R --branch B --action A`, + `parents add --repo R --branch B --parent P` (source operator) or + `parents drop --repo R --branch B --parent P`; R an absolute path, + B and P branch names, P not B. + + Returns: + argv, the hub and the runner check it the same way. + + Raises: + Refused for every other argv. + """ + if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv): + raise Refused("the argv is no list of texts") + head, values = argv[:2], dict(zip(argv[2::2], argv[3::2])) + names = {("rule", "set"): ("--repo", "--branch", "--action", "--level"), ("rule", "drop"): ("--repo", "--branch", "--action"), ("parents", "add"): ("--repo", "--branch", "--parent"), ("parents", "drop"): ("--repo", "--branch", "--parent")}.get(tuple(head)) + if names is None or argv[2::2] != list(names) or len(argv) != 2 + 2 * len(names): + raise Refused("the argv is no gates.py rule set|drop or parents add|drop of one branch") + repo = values["--repo"] + if not GATE_REPO.fullmatch(repo) or "/../" in f"{repo}/" or "/./" in f"{repo}/": + raise Refused("the repository is no absolute path") + branches = [values[name] for name in ("--branch", "--parent") if name in values] + if not all(GATE_BRANCH.fullmatch(name) for name in branches) or len(set(branches)) != len(branches): + raise Refused("the branch or parent is no branch name, or the branch is its own parent") + if values.get("--action", GATE_ACTIONS[0]) not in GATE_ACTIONS or values.get("--level", GATE_LEVELS[0]) not in GATE_LEVELS: + raise Refused(f"the action must be one of {', '.join(GATE_ACTIONS)} and the level one of {', '.join(GATE_LEVELS)}") + return argv + + +def names_default(ref: object, default: object) -> bool: + """Args: + ref: a ref as pkgmgr names it, bare such as main or behind one + remote such as origin/main, the remote-tracking ref it compares + against when the remote has a HEAD. + default: the repository's default branch as the tree file names it, + without the remote (pkgmgr tree.py strips it). + + Returns: + True when ref is the default branch. + """ + if not isinstance(ref, str) or not isinstance(default, str) or not default: + return False + remote, _, name = ref.partition("/") + return ref == default or (bool(remote) and name == default) + + +def token_free(entry: dict, default: object) -> bool: + """Args: + entry: a pkgmgr tree or gc plan item with merged_by, merged_into and + remotes. + default: the repository's default branch as the tree file names it. + + Returns: + True for a local branch merged by ancestry into the default branch + (names_default) that has no remote branch: deleting it needs no + GitHub token. + """ + return entry.get("merged_by") == TOKEN_FREE_MERGE and names_default(entry.get("merged_into"), default) and not entry.get("remotes") + + def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict: return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires} diff --git a/orchestrator/kvb_orchestrator/approving.py b/orchestrator/kvb_orchestrator/approving.py index 64dc5ca..188127d 100644 --- a/orchestrator/kvb_orchestrator/approving.py +++ b/orchestrator/kvb_orchestrator/approving.py @@ -9,6 +9,7 @@ from .cleanup import cleanup_request from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words from .errors import Conflict, Missing, ToolError from .fields import decode, expect, field +from .gating import gate_request from .offices import office_request from .records import digest, shown from .register import REGISTER, adder, operator_rows, read_register, runnable @@ -250,6 +251,8 @@ class Approving: signed = self.appointable(raw) elif task_request(raw): signed = self.task_changes(raw) + elif gate_request(raw): + signed = self.gate_changes(raw) else: signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None] return self.challenge_for(signed) diff --git a/orchestrator/kvb_orchestrator/artifacts.py b/orchestrator/kvb_orchestrator/artifacts.py index f45ad24..7e8f9be 100644 --- a/orchestrator/kvb_orchestrator/artifacts.py +++ b/orchestrator/kvb_orchestrator/artifacts.py @@ -8,6 +8,7 @@ from pathlib import Path from .records import epoch, iso_epoch, read_json, shown from .register import ENDED, status_of +from .signed import signing from .tree import inside, owns PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b") @@ -105,7 +106,8 @@ def enrich(scan: dict, tasks: dict[str, dict], reviews: list[dict], touched: Tou shared = sorted(files[a] & files[b]) if shared: overlaps.append({"a": a, "b": b, "count": len(shared), "files": shared[:SHARED_SHOWN]}) - merges = [{"from": item["name"], "into": item["merged_into"] or entry.get("default") or "", "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]] + default = entry.get("default") or "" + merges = [{"from": item["name"], "into": default if signing.names_default(item["merged_into"], default) else item["merged_into"] or default, "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]] repos.append({**entry, "branches": branches, "overlaps": overlaps, "merges": merges}) loose = [{"task": task, "title": shown(row.get("title")), "status": status_of(row), "artefacts": artefacts_of(task, row)} for task, row in sorted(tasks.items()) if task not in placed and artefacts_of(task, row)] return {**scan, "repos": repos, "tasks": loose} diff --git a/orchestrator/kvb_orchestrator/cleanup.py b/orchestrator/kvb_orchestrator/cleanup.py index 6cdb85f..23db261 100644 --- a/orchestrator/kvb_orchestrator/cleanup.py +++ b/orchestrator/kvb_orchestrator/cleanup.py @@ -11,6 +11,8 @@ from pathlib import Path from .errors import Missing, ToolError from .fields import decode, expect from .records import read_json, stamp +from .register import OPERATOR +from .skill_scripts import OPERATOR_SCRIPT from .tree import offered ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip") @@ -18,6 +20,10 @@ JOB_KEYS = ("repository", "branch", "worktree", "tip") JOB_VERSION = 1 ITEM_LIMIT = 100 RESULTS_SHOWN = 20 +TOKEN_ROW = "pkgmgr-github-token" +TOKEN_COMMAND = "env secret-tool store --label=pkgmgr-github service pkgmgr:github username 'github.com|-' dict: + """Returns: + The operator row that stores the GitHub token in the Secret + Service under pkgmgr's key, added or refreshed through + operator.py; it holds no secret, the operator types the token + when running it. + """ + expect(decode(body), ()) + done = self.scripts.run(OPERATOR_SCRIPT, ["add", TOKEN_ROW, "--dir", "/", "--command", TOKEN_COMMAND, "--reason", TOKEN_REASON, "--check", "manual", "--session", TOKEN_ADDER]) + if done["code"] != 0: + raise ToolError(f"operator.py add failed ({done['code']}): {done['stderr'].strip()[-500:]}") + return {"task": f"{OPERATOR}{TOKEN_ROW}", "status": "queued"} + def cleanup_state(self, job_id: str) -> dict: result = read_json(self.spool.results / f"{job_id}.json") if isinstance(result, dict) and isinstance(result.get("items"), list): diff --git a/orchestrator/kvb_orchestrator/gating.py b/orchestrator/kvb_orchestrator/gating.py new file mode 100644 index 0000000..3862a96 --- /dev/null +++ b/orchestrator/kvb_orchestrator/gating.py @@ -0,0 +1,131 @@ +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 urllib.parse import quote + +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 resolve() and + parents_of(), which read only the database; 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 with the database opened read-only and without git; + {} without the script or a readable database. + """ + if gates is None or not db.is_file(): + return {} + try: + with closing(sqlite3.connect(f"file:{quote(str(db))}?mode=ro", uri=True)) 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"} diff --git a/orchestrator/kvb_orchestrator/handler.py b/orchestrator/kvb_orchestrator/handler.py index 5b35909..80120bc 100644 --- a/orchestrator/kvb_orchestrator/handler.py +++ b/orchestrator/kvb_orchestrator/handler.py @@ -40,6 +40,8 @@ CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})") OFFICES_ROUTE = "/api/offices" OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})") TASKS_ROUTE = "/api/tasks" +GATES_ROUTE = "/api/gates" +TOKEN_ROUTE = "/api/cleanup/token" VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})") SCRIPT_TYPE = "text/javascript; charset=utf-8" FONT_TYPE = "font/woff2" @@ -57,7 +59,7 @@ SETTINGS_SIGN_ROUTE = "/api/settings/sign" CHOICES_ROUTE = "/api/choices" CHOICES_SIGN_ROUTE = "/api/choices/sign" WAV_POSTS = (STT_ROUTE, VOICE_ROUTE) -ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE) +ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE, GATES_ROUTE, TOKEN_ROUTE) class Handler(Exchange): @@ -168,6 +170,10 @@ class Handler(Exchange): return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED) if path == TASKS_ROUTE: return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED) + if path == GATES_ROUTE: + return lambda body: self.answer(lambda: hub.change_gates(body), HTTPStatus.ACCEPTED) + if path == TOKEN_ROUTE: + return lambda body: self.answer(lambda: hub.token_row(body), HTTPStatus.CREATED) if inbox: return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK) if undo: diff --git a/orchestrator/kvb_orchestrator/hub.py b/orchestrator/kvb_orchestrator/hub.py index 51d86ea..cfb1126 100644 --- a/orchestrator/kvb_orchestrator/hub.py +++ b/orchestrator/kvb_orchestrator/hub.py @@ -16,6 +16,7 @@ from .cleanup import Cleaning, Spool from .denying import Denying from .engines import Engines, Speech from .events import Log, Watch +from .gating import Gating from .icons import sheet from .inbox import Inbox from .jobs import Jobs @@ -65,7 +66,7 @@ def environment(templates: Path) -> jinja2.Environment: @dataclass(frozen=True) -class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language): +class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Gating, Inbox, Rules, Jobs, Sources, Timeline, View, Language): council: Path pages: Path public_port: int diff --git a/orchestrator/kvb_orchestrator/jobs.py b/orchestrator/kvb_orchestrator/jobs.py index dd4876b..5153c94 100644 --- a/orchestrator/kvb_orchestrator/jobs.py +++ b/orchestrator/kvb_orchestrator/jobs.py @@ -12,15 +12,17 @@ JOB_OPEN = ("queued", "running") RUN_OUTPUT = 64 << 10 CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished") TASK_JOB = "task" +GATE_JOB = "gate" +CHANGES = (TASK_JOB, GATE_JOB) def runner_job(job: dict) -> dict: """Returns: - What the runner claims: an operator command, or a task change with - kind and argv; a runner that predates task changes verifies the - latter as an operator command and refuses it. + What the runner claims: an operator command, or a task or gate change + with kind and argv; a runner that predates such changes verifies them + as an operator command and refuses them. """ - change = {"kind": TASK_JOB, "argv": job["argv"]} if job.get("kind") == TASK_JOB else {} + change = {"kind": job["kind"], "argv": job["argv"]} if job.get("kind") in CHANGES else {} return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, **change, "proof": job.get("proof")} @@ -90,7 +92,7 @@ class Jobs: job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()}) if not changed: raise Conflict({"id": job_id, "status": job["status"]}) - command_done = status == "done" and job.get("kind") != TASK_JOB + command_done = status == "done" and job.get("kind") not in CHANGES runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else [] tail = "\n".join(output.splitlines()[-20:]) self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}}) diff --git a/orchestrator/kvb_orchestrator/skill_scripts.py b/orchestrator/kvb_orchestrator/skill_scripts.py index d606d0b..73066d1 100644 --- a/orchestrator/kvb_orchestrator/skill_scripts.py +++ b/orchestrator/kvb_orchestrator/skill_scripts.py @@ -17,6 +17,8 @@ ESTIMATE = "kvb-org-queue-estimate/scripts/estimate.py" class Runner(Protocol): + skills: Path + def run(self, script: str, args: list[str]) -> dict: ... diff --git a/orchestrator/kvb_orchestrator/sources.py b/orchestrator/kvb_orchestrator/sources.py index 1dcce1a..27980e2 100644 --- a/orchestrator/kvb_orchestrator/sources.py +++ b/orchestrator/kvb_orchestrator/sources.py @@ -49,7 +49,7 @@ class Sources: reviews = self.reviews.listed() scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews) history = read_jsonl(self.council / "queue-history.jsonl") - return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results()} + return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results(), "gates": self.gate_view(scan)} def schedules(self, claims: list[dict], now: float) -> list[dict]: beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None} diff --git a/orchestrator/kvb_orchestrator/tree.py b/orchestrator/kvb_orchestrator/tree.py index 648c8d8..14ad298 100644 --- a/orchestrator/kvb_orchestrator/tree.py +++ b/orchestrator/kvb_orchestrator/tree.py @@ -18,6 +18,8 @@ FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}") INVALID = "invalid" # renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age TREE_HEARTBEAT = "tree-heartbeat" +GC_SKILL = "kvb-git-branch-gc" +GC_COMPOSE_DOWN = ("docker", "compose", "-p", "{project}", "down", "-v", "--remove-orphans") def flow(item: dict) -> dict | None: @@ -66,34 +68,47 @@ def worktree(item: dict) -> dict: } +def held_by(worktree: str) -> str: + return f"held by worktree {worktree}: pkgmgr branch cleanup skips it, use branch gc ({GC_SKILL}, pkgmgr gc)" + + def dry_run(item: dict) -> list[str]: """Returns: - The lines `pkgmgr branch cleanup --local` would act on for one tree - item, read from the gc plan the tree file carries; a remote branch - always stays. + What the runner's `pkgmgr branch cleanup` does for one tree item, + read from the gc plan the tree file carries: it deletes the local + branch and never touches a worktree, Compose stack or volume; a + remote branch stays, the runner refuses a plan that deletes one. A + branch held by a worktree it skips; its lines name what branch gc + would do instead, its `docker compose down -v` deleting the stack's + volumes. """ tip = (item["local_sha"] or item["head"] or "")[:12] merged = f", merged by {item['merged_by']}" if item["merged_by"] else "" lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"] + if item["worktree"]: + lines.append(held_by(item["worktree"])) + lines.append(f"branch gc would remove the worktree {item['worktree']} and the local branch {item['branch']}") + lines += [f"branch gc would run {' '.join(GC_COMPOSE_DOWN).format(project=name)}, which deletes the volumes of {name}" for name in item["compose_projects"]] + lines += [f"branch gc would remove the image {name}" for name in item["images"]] + return lines for action in item["actions"]: - if action == "worktree": - lines.append(f"removes the worktree {item['worktree']}") - elif action == "local-branch": + if action == "local-branch": lines.append(f"deletes the local branch {item['branch']}") elif action.startswith("remote"): - lines.append(f"stays: {action} (local only)") + lines.append(f"stays: {action} (local only; the host refuses a plan that deletes it)") else: lines.append(action) - lines += [f"stops the Compose stack {name} without -v" for name in item["compose_projects"]] - lines += [f"removes the image {name}" for name in item["images"]] return lines -def branch(item: dict, path: str) -> dict: +def branch(item: dict, path: str, default: object) -> dict: remote = next(iter(item["remotes"].values()), "") head = item["local_sha"] or item["head"] or remote - open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"]) + held = held_by(item["worktree"]) if item["worktree"] and item["state"] == "merged-clean" else "" + open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"]) and not item["worktree"] return { + "held": held, + "token_free": signing.token_free(item, default), "name": item["branch"], "head": head, "upstream": item["upstream"] or "", @@ -116,13 +131,21 @@ def repo(entry: dict) -> dict: return { **{key: value for key, value in entry.items() if key != "items"}, "name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]), - "branches": [branch(item, entry["path"]) for item in items if item["branch"]], + "branches": [branch(item, entry["path"], entry.get("default")) for item in items if item["branch"]], "detached": [worktree(item) for item in items if item["detached"]], } -def offered(scan: dict) -> list[dict]: - return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None] +def offered(scan: dict, ready: bool) -> list[dict]: + """Args: + scan: the tree. + ready: whether pkgmgr holds the GitHub token. + + Returns: + The cleanup items deletable now: every one with the token, without + it only those of token-free branches. + """ + return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None and (ready or entry["token_free"])] def read_tree(path: Path) -> dict: diff --git a/orchestrator/systemd/kvb-orchestrator-runner.service b/orchestrator/systemd/kvb-orchestrator-runner.service index 1356e03..9ae0159 100644 --- a/orchestrator/systemd/kvb-orchestrator-runner.service +++ b/orchestrator/systemd/kvb-orchestrator-runner.service @@ -5,7 +5,7 @@ Description=Agent Orchestrator runner: runs the operator commands a pinned passk Type=simple Environment=PATH=/usr/bin:/bin EnvironmentFile=%h/.local/share/kvb-orchestrator/.env -ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json --rasci %h/.claude/skills/kvb-org-rasci/scripts/rasci.py --council ${KVB_ORCHESTRATOR_COUNCIL} +ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json --rasci %h/.claude/skills/kvb-org-rasci/scripts/rasci.py --council ${KVB_ORCHESTRATOR_COUNCIL} --gates %h/.claude/skills/kvb-check-gate/scripts/gates.py Restart=always RestartSec=5 diff --git a/orchestrator/templates/ancestry.js b/orchestrator/templates/ancestry.js new file mode 100644 index 0000000..5141179 --- /dev/null +++ b/orchestrator/templates/ancestry.js @@ -0,0 +1,97 @@ +const Ancestry = (() => { + "use strict"; + const LEVELS = ["none", "quick", "test", "quality-high", "e2e"]; + const ACTIONS = ["commit", "merge", "push", "pr"]; + const LEVEL_ICONS = {none: "bi-circle", quick: "bi-lightning-charge", test: "bi-check2-circle", "quality-high": "bi-shield-check", e2e: "bi-browser-chrome"}; + const SPACING = {column: 170, row: 96, wrap: 8}; + + function generations(ids, edges) { + const parents = new Map(ids.map((id) => [id, []])); + for (const edge of edges) { + if (parents.has(edge.child) && parents.has(edge.parent) && edge.child !== edge.parent) { + parents.get(edge.child).push(edge.parent); + } + } + const depth = new Map(); + const visit = (id, path) => { + if (depth.has(id)) { + return depth.get(id); + } + if (path.has(id)) { + return -1; + } + path.add(id); + const above = parents.get(id).map((parent) => visit(parent, path)).filter((value) => value >= 0); + path.delete(id); + const value = above.length ? Math.max(...above) + 1 : 0; + depth.set(id, value); + return value; + }; + ids.forEach((id) => visit(id, new Set())); + return depth; + } + + function layout(nodes, edges) { + const ids = nodes.map((node) => node.id); + const depth = generations(ids, edges); + const rows = []; + for (const node of nodes) { + const generation = depth.get(node.id); + (rows[generation] = rows[generation] || []).push(node); + } + const widest = Math.max(1, ...rows.map((row) => Math.min(row ? row.length : 0, SPACING.wrap))); + const width = widest * SPACING.column; + const placed = new Map(); + let line = 0; + rows.forEach((row, generation) => { + const anchor = (node) => { + const xs = edges.filter((edge) => edge.child === node.id && placed.has(edge.parent)).map((edge) => placed.get(edge.parent).x); + return xs.length ? xs.reduce((sum, x) => sum + x, 0) / xs.length : width / 2; + }; + const ordered = [...(row || [])].sort((a, b) => anchor(a) - anchor(b) || String(a.label).localeCompare(String(b.label))); + ordered.forEach((node, index) => { + const band = Math.floor(index / SPACING.wrap); + const count = Math.min(SPACING.wrap, ordered.length - band * SPACING.wrap); + const offset = (width - count * SPACING.column) / 2; + placed.set(node.id, {...node, generation, x: offset + ((index % SPACING.wrap) + 0.5) * SPACING.column, y: (line + band + 0.5) * SPACING.row}); + }); + line += Math.max(1, Math.ceil(ordered.length / SPACING.wrap)); + }); + const links = edges.filter((edge) => placed.has(edge.child) && placed.has(edge.parent) && edge.child !== edge.parent).map((edge) => { + const from = placed.get(edge.parent); + const to = placed.get(edge.child); + return {...edge, derived: edge.source === "derived", x1: from.x, y1: from.y, x2: to.x, y2: to.y}; + }); + return {nodes: ids.map((id) => placed.get(id)), links, width, height: Math.max(line, 1) * SPACING.row}; + } + + function gateMarks(branch, entry) { + const resolved = (entry && entry.resolved) || {}; + return ACTIONS.map((action) => { + const found = resolved[action] || {}; + const level = LEVELS.includes(found.level) ? found.level : "none"; + const source = typeof found.source === "string" ? found.source : ""; + const own = source === branch; + const from = own ? "own rule" : source ? `from ${source}` : "no rule"; + return {action, level, source, own, inherited: !own, icon: LEVEL_ICONS[level], title: `${action}: ${level} · ${from}`}; + }); + } + + function nextRule(level) { + return level === null || level === undefined ? LEVELS[0] : LEVELS.indexOf(level) + 1 < LEVELS.length ? LEVELS[LEVELS.indexOf(level) + 1] : null; + } + + function ruleArgv(repo, branch, action, level) { + const head = ["--repo", repo, "--branch", branch, "--action", action]; + return level === null ? ["rule", "drop", ...head] : ["rule", "set", ...head, "--level", level]; + } + + function parentArgv(repo, branch, parent, drop) { + return ["parents", drop ? "drop" : "add", "--repo", repo, "--branch", branch, "--parent", parent]; + } + + return {LEVELS, ACTIONS, LEVEL_ICONS, SPACING, generations, layout, gateMarks, nextRule, ruleArgv, parentArgv}; +})(); +if (typeof module === "object" && module.exports) { + module.exports = Ancestry; +} diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index f3640e2..b76c9e7 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -413,6 +413,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "style-tasks.css" %} {% include "style-bubbles.css" %} + +{% include "style-ancestry.css" %} @@ -457,6 +459,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "nav.js" %} +{% include "ancestry.js" %} + {% include "popup.js" %} {% include "faces.js" %} @@ -528,6 +532,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "page-cleanup.js" %} +{% include "page-ancestry.js" %} + {% include "page-offices.js" %} {% include "page-tasks.js" %} diff --git a/orchestrator/templates/page-ancestry.js b/orchestrator/templates/page-ancestry.js new file mode 100644 index 0000000..9a19e40 --- /dev/null +++ b/orchestrator/templates/page-ancestry.js @@ -0,0 +1,167 @@ + const gateStage = {items: [], drag: null}; + const DRAG_SLOP = 6; + + function stageGate(key, change) { + gateStage.items = gateStage.items.filter((item) => item.key !== key); + if (change) { + gateStage.items.push({key, ...change}); + } + drawArtifacts(); + } + + function stageParent(repo, branch, parent, drop) { + const key = `parent\u0000${repo}\u0000${branch}\u0000${parent}`; + const staged = gateStage.items.find((item) => item.key === key); + stageGate(key, staged && staged.drop === drop ? null : {argv: Ancestry.parentArgv(repo, branch, parent, drop), repo, branch, parent, drop}); + } + + function gatesOf(repo, name) { + return ((tree.data && tree.data.gates) || {})[repo] ? tree.data.gates[repo][name] || null : null; + } + + function gateIcons(repo, name) { + const found = gatesOf(repo, name); + const own = (found && found.rules) || {}; + return h("span", {class: "gates", role: "group", "aria-label": `Gates of ${name}`}, Ancestry.gateMarks(name, found).map((mark) => { + const key = `rule\u0000${repo}\u0000${name}\u0000${mark.action}`; + const staged = gateStage.items.find((item) => item.key === key); + const current = Object.hasOwn(own, mark.action) ? own[mark.action] : null; + const level = staged ? staged.level || "none" : mark.level; + const title = staged ? `${mark.action}: ${staged.level || "inherited"} · staged` : mark.title; + const cycle = (event) => { + const group = event.currentTarget.closest(".gates"); + const next = Ancestry.nextRule(staged ? staged.level : current); + stageGate(key, next === current ? null : {argv: Ancestry.ruleArgv(repo, name, mark.action, next), level: next}); + if (group && group.isConnected) { + group.replaceWith(gateIcons(repo, name)); + } + }; + return h("button", { + type: "button", class: ["gate-mark", "icon-only", mark.inherited && !staged ? "inherited" : "", staged ? "pending" : ""].filter(Boolean).join(" "), + "data-action": mark.action, "data-level": level, "data-source": mark.source, ...named(`${mark.action} gate`, title), on: {click: cycle}, + }, icon(Ancestry.LEVEL_ICONS[level])); + })); + } + + function gateApply() { + const count = gateStage.items.length; + if (!count) { + return null; + } + return h("span", {class: "hint gate-apply"}, + iconBtn("bi-check2-all", "Apply", "btn small", applyGates, `Apply ${count} gate changes with one passkey signature`), counter(count), + iconBtn("bi-x-circle", "Discard", "btn small ghost", () => { + gateStage.items = []; + drawArtifacts(); + }, "Discard the staged gate changes")); + } + + async function applyGates() { + const items = gateStage.items.map((item) => ({kind: "gate", argv: item.argv})); + if (!items.length) { + return; + } + const signed = await proofFor(items, "Apply"); + if (signed === null) { + return; + } + const result = await sendJson("/api/gates", {items, proof: signed.proof}); + if (!result.ok || !result.data) { + toast("Gates: not applied", fail(result), false); + return; + } + gateStage.items = []; + toast("Gates: signed", [`${items.length} → runner`], true); + drawArtifacts(); + window.setTimeout(fetchTree, 3000); + } + + function ancestryChart(plan, label, mark) { + const width = Math.max(plan.width, Ancestry.SPACING.column); + const svg = s("svg", {viewBox: `0 0 ${width} ${plan.height}`, width, height: plan.height, class: "chart ancestry", role: "group", "aria-label": label}); + const curve = (link) => `M${fixed(link.x1)},${fixed(link.y1)} C${fixed(link.x1)},${fixed((link.y1 + link.y2) / 2)} ${fixed(link.x2)},${fixed((link.y1 + link.y2) / 2)} ${fixed(link.x2)},${fixed(link.y2)}`; + svg.append(s("g", {}, plan.links.map((link) => mark.edge(link, curve(link)))), s("g", {}, plan.nodes.map((node) => mark.node(node)))); + return h("div", {class: "ancestry-frame"}, svg); + } + + function branchAncestry(repo) { + const names = new Set(repo.branches.map((entry) => entry.name)); + const staged = gateStage.items.filter((item) => item.parent && item.repo === repo.path); + const edges = []; + for (const entry of repo.branches) { + for (const found of (gatesOf(repo.path, entry.name) || {parents: []}).parents) { + if (names.has(found.parent)) { + edges.push({child: entry.name, parent: found.parent, source: found.source}); + } + } + } + for (const item of staged.filter((change) => !change.drop && names.has(change.branch) && names.has(change.parent))) { + if (!edges.some((edge) => edge.child === item.branch && edge.parent === item.parent)) { + edges.push({child: item.branch, parent: item.parent, source: "staged"}); + } + } + const plan = Ancestry.layout(repo.branches.map((entry) => ({id: entry.name, label: entry.name})), edges); + const dropping = (link) => staged.some((item) => item.drop && item.branch === link.child && item.parent === link.parent); + return ancestryChart(plan, `Ancestry of ${repo.name}`, { + edge: (link, d) => s("path", { + class: ["ancestry-edge", link.derived ? "derived" : "", link.source === "staged" ? "pending" : "", dropping(link) ? "dropping" : ""].filter(Boolean).join(" "), + d, "data-child": link.child, "data-parent": link.parent, "data-source": link.source, + on: {click: () => stageParent(repo.path, link.child, link.parent, link.source !== "staged")}, + }, s("title", {}, `${link.parent} → ${link.child} · ${link.source}`)), + node: (node) => s("g", { + class: "node a-branch ancestry-node", transform: `translate(${fixed(node.x)} ${fixed(node.y)})`, tabindex: 0, role: "button", "aria-label": node.label, + "data-repo": repo.path, "data-branch": node.id, "data-generation": node.generation, + on: { + pointerdown: (event) => { + gateStage.drag = event.button === 0 ? {repo: repo.path, branch: node.id, x: event.clientX, y: event.clientY} : null; + }, + click: () => artSelect(`branch:${repo.path}\u0000${node.id}`), + keydown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + artSelect(`branch:${repo.path}\u0000${node.id}`); + } + }, + }, + }, s("title", {}, node.label), s("circle", {r: 9}), s("text", {class: "label", y: 26, "text-anchor": "middle"}, short(node.label).slice(0, 24))), + }); + } + + function agentAncestry(root) { + const flat = spawnFlat(root); + const plan = Ancestry.layout(flat.map(({entry}) => ({id: entry.id, label: entry.label, kind: entry.kind})), flat.filter(({parent}) => parent).map(({entry, parent}) => ({child: entry.id, parent: parent.id, source: "recorded"}))); + const open = (node) => { + const found = flat.find(({entry}) => entry.id === node.id); + if (found && found.entry.kind === "twig") { + focusSub(found.entry.session, found.entry.row.id); + } else if (found && found.entry.kind !== "operator") { + select(node.id); + } + }; + return ancestryChart(plan, "Ancestry: operator, chair, sessions, subagents", { + edge: (link, d) => s("path", {class: "ancestry-edge", d, "data-child": link.child, "data-parent": link.parent}), + node: (node) => s("g", { + class: `node ancestry-node k-${node.kind}`, transform: `translate(${fixed(node.x)} ${fixed(node.y)})`, tabindex: 0, role: "button", "aria-label": node.label, + "data-agent": node.id, "data-kind": node.kind, "data-generation": node.generation, + on: {click: () => open(node), keydown: (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open(node); + } + }}, + }, s("title", {}, `${node.label} · ${NODE_KINDS[node.kind] || "Subagent"}`), s("circle", {r: 9}), s("text", {class: "label", y: 26, "text-anchor": "middle"}, short(node.label).slice(0, 24))), + }); + } + + document.addEventListener("pointerup", (event) => { + const from = gateStage.drag; + gateStage.drag = null; + if (!from || Math.hypot(event.clientX - from.x, event.clientY - from.y) < DRAG_SLOP) { + return; + } + const hit = document.elementFromPoint(event.clientX, event.clientY); + const target = hit && hit.closest("[data-branch]"); + if (target && target.dataset.repo === from.repo && target.dataset.branch !== from.branch) { + stageParent(from.repo, from.branch, target.dataset.branch, false); + } + }); diff --git a/orchestrator/templates/page-artifacts.js b/orchestrator/templates/page-artifacts.js index 7ed5e25..3766876 100644 --- a/orchestrator/templates/page-artifacts.js +++ b/orchestrator/templates/page-artifacts.js @@ -224,7 +224,7 @@ body.push(meta([["Path", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["As of", data.scanned_at]]), h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`)); } else if (node.kind === "branch") { - body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]), + body.push(h("div", {class: "states"}, branchStates(data), gateIcons(node.repo, data.name)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]), data.worktrees.map((wt) => flowDetails(wt.flow, data.name)), h("h3", {}, "Dry run"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data))); } else if (node.kind === "worktree") { @@ -256,13 +256,14 @@ drawArtifacts(); }}}) : null; const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null; - tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll}; + const apply = view === "tree" ? null : h("span", {class: "gate-staged"}); + tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll, apply}; window.setTimeout(() => { drawArtifacts(); fetchTree(); }, 0); const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker, - h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count); + h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll, apply), count); const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox); return h("div", {class: "graph"}, controls, body); } @@ -296,6 +297,7 @@ drawTree(); return; } + tree.dom.apply.replaceChildren(gateApply() || ""); const empty = !tree.data || tree.data.error; tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`; if (empty) { diff --git a/orchestrator/templates/page-cleanup.js b/orchestrator/templates/page-cleanup.js index 826e3b2..7d162e3 100644 --- a/orchestrator/templates/page-cleanup.js +++ b/orchestrator/templates/page-cleanup.js @@ -8,14 +8,16 @@ return {repo, entry}; } - function deleteDialog(items) { + function deleteDialog(items, locked = [], held = []) { if (!items.length) { return; } - const rows = items.map((item) => { + const rows = [...items, ...locked, ...held].map((item) => { const {repo, entry} = planOf(item); - return h("li", {class: "dry-run"}, - h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch)), + const waits = locked.includes(item); + const kept = held.includes(item); + return h("li", {class: waits ? "dry-run locked" : kept ? "dry-run held" : "dry-run", "data-token": waits ? "needed" : null, "data-held": kept && entry ? entry.held : null}, + h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch), waits ? pill("token needed", "warn") : kept ? pill("held by a worktree: not deleted here", "neutral") : null), h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n"))); }); const outcome = h("div", {class: "mini", "aria-live": "polite"}); @@ -27,7 +29,7 @@ } }); openSheet(items.length > 1 ? `Delete ${items.length} merged branches` : `Delete ${items[0].branch}`, [ - h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup --local for exactly these entries: worktree, local branch, Compose stack without -v and its images. No remote branch. The host checks your signature and refuses every entry whose state has changed since."), + h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup for exactly these entries: it deletes the local branch and never touches a worktree, Compose stack or volume. No remote branch: the host refuses an entry whose plan deletes one. A branch held by a worktree is not deleted here: branch gc (kvb-git-branch-gc, pkgmgr gc) removes it, and its docker compose down -v deletes the stack's volumes. The host checks your signature and refuses every entry whose state has changed since."), h("ul", {class: "mini", "aria-label": "Dry run"}, rows), outcome, h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm), diff --git a/orchestrator/templates/page-spawn.js b/orchestrator/templates/page-spawn.js index a8d2c53..7ccb75b 100644 --- a/orchestrator/templates/page-spawn.js +++ b/orchestrator/templates/page-spawn.js @@ -117,5 +117,6 @@ const running = all.filter((entry) => entry.running).length; spawn.dom.list.replaceChildren( h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn tree", counter(all.length)), h("span", {class: "muted"}, `${running} running · ${all.length - running} finished · Operator → Chair → Sessions → Subagents`)), + agentAncestry(root), h("ul", {class: "spawn-tree root"}, spawnItem(root))); } diff --git a/orchestrator/templates/page-tree.js b/orchestrator/templates/page-tree.js index 2b1e581..1036b59 100644 --- a/orchestrator/templates/page-tree.js +++ b/orchestrator/templates/page-tree.js @@ -25,24 +25,43 @@ ]; } + const FORGE_UNAVAILABLE = "forge unavailable"; + const deletableNow = (entry) => Boolean(entry.cleanup) && (tree.data.delete_ready === true || entry.token_free === true); + const tokenLocked = (entry) => tree.data.delete_ready !== true && entry.state === "merged-clean" && !entry.held && (entry.cleanup ? entry.token_free !== true : entry.skip === FORGE_UNAVAILABLE); + function deleteButton(entry) { - if (!entry.cleanup) { - return null; + if (entry.held) { + return h("span", {class: "hint held", title: entry.held}, chip("bi-folder-symlink", entry.held, "neutral")); } - const ready = tree.data.delete_ready === true; + if (!entry.cleanup) { + return tokenLocked(entry) ? chip("bi-key", "token needed", "warn") : null; + } + const ready = deletableNow(entry); const hint = ready ? `Delete: remove ${entry.name} locally, after dry run and signature` : `Delete blocked: ${tree.data.delete_reason || "delete_ready is false"}`; const button = iconBtn("bi-trash3", "Delete", "btn small danger", () => deleteDialog([entry.cleanup]), hint); button.disabled = !ready; - return h("span", {class: "hint", title: hint}, button); + return h("span", {class: "hint", title: hint}, button, ready ? null : chip("bi-key", "token needed", "warn")); + } + + async function storeToken() { + const result = await sendJson("/api/cleanup/token", {}); + toast(result.ok ? "Token: operator row added" : "Token: no operator row", result.ok && result.data ? [result.data.task] : fail(result), result.ok); } function deleteAll() { - const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup); - const ready = tree.data.delete_ready === true && items.length > 0; - const hint = tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason"; - const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items), hint); + const branches = (tree.data.repos || []).flatMap((repo) => repo.branches); + const items = branches.filter(deletableNow).map((entry) => entry.cleanup); + const waiting = branches.filter(tokenLocked); + const locked = waiting.filter((entry) => entry.cleanup).map((entry) => entry.cleanup); + const held = (tree.data.repos || []).flatMap((repo) => repo.branches.filter((entry) => entry.held).map((entry) => ({repository: repo.path, branch: entry.name, tip: entry.head}))); + const ready = items.length > 0; + const hint = !ready && tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason"; + const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items, locked, held), hint); button.disabled = !ready; - return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null); + return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null, + waiting.length ? chip("bi-key", `${waiting.length} token needed`, "warn", waiting.length) : null, + held.length ? chip("bi-folder-symlink", `${held.length} held by a worktree: use branch gc`, "neutral", held.length) : null, + tree.data.delete_ready !== true ? iconBtn("bi-key-fill", "Store token", "btn small soft", storeToken, "Store token: add the operator row that stores the GitHub token in the Secret Service") : null); } function leafRow(found) { @@ -82,7 +101,7 @@ const result = resultOf(repo, entry); return h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, entry.name), h("span", {class: "states"}, branchStates(entry)), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : null, - h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), deleteButton(entry)), + h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), gateIcons(repo.path, entry.name), deleteButton(entry)), entry.worktrees.map(worktreeRow)); } @@ -96,7 +115,7 @@ tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open.")); return; } - tree.dom.removeAll.replaceChildren(deleteAll()); + tree.dom.removeAll.replaceChildren(deleteAll(), gateApply() || ""); const q = tree.q.trim().toLowerCase(); const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q)); const repos = []; @@ -124,6 +143,7 @@ h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} branches`), counter(`${trees.length} worktrees`), dirty ? chip("bi-pencil-square", `${dirty} worktrees with changes`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} merged, deletable`, "ok", merged) : null, busy ? chip("bi-lightning-charge", `${busy} worktrees with open work`, "warn", busy) : null), + repo.branches.length ? branchAncestry(repo) : null, branches.map((entry) => branchRow(repo, entry)), detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "detached HEAD")), detached.map(worktreeRow)) : null); details.addEventListener("toggle", () => { diff --git a/orchestrator/templates/style-ancestry.css b/orchestrator/templates/style-ancestry.css new file mode 100644 index 0000000..397bfac --- /dev/null +++ b/orchestrator/templates/style-ancestry.css @@ -0,0 +1,17 @@ +.ancestry-frame { max-width: 100%; min-width: 0; margin: 6px 0 10px; overflow-x: auto; overflow-y: hidden; contain: inline-size; } +svg.ancestry { display: block; margin: 0 auto; touch-action: none; user-select: none; } +svg .ancestry-edge { fill: none; stroke: var(--line-strong); stroke-width: 1.6; cursor: pointer; } +svg .ancestry-edge:hover { stroke: var(--accent); } +svg .ancestry-edge.derived { stroke-dasharray: 5 5; } +svg .ancestry-edge.pending { stroke: var(--info); stroke-dasharray: 2 4; } +svg .ancestry-edge.dropping { stroke: var(--bad); opacity: .5; } +svg .ancestry-node { cursor: grab; } +svg .ancestry-node:active { cursor: grabbing; } +svg .ancestry-node .label { fill: var(--text); font-size: var(--fs-2xs); } +.gates { display: inline-flex; align-items: center; gap: 2px; } +.gate-mark { display: inline-grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--text); cursor: pointer; } +.gate-mark:hover { background: var(--hover); } +.gate-mark.inherited { opacity: .4; } +.gate-mark.pending { outline: 1px dashed var(--info); color: var(--info); } +.gate-apply, .gate-staged { display: inline-flex; align-items: center; gap: 4px; } +.dry-run.locked pre { opacity: .6; } diff --git a/tests/branch-tree-tokenless.json b/tests/branch-tree-tokenless.json new file mode 100644 index 0000000..36a3b0e --- /dev/null +++ b/tests/branch-tree-tokenless.json @@ -0,0 +1,273 @@ +{ + "generated_at": "2026-11-12T11:14:56+00:00", + "host_pid_namespace": true, + "delete_ready": false, + "delete_reason": "no GitHub token in the Secret Service", + "repos": [ + { + "path": "@ROOT@/repos/github.com/anon/web", + "remote": "git@github.com:anon/web.git", + "default": "main", + "missing": false, + "known": true, + "forge": "unavailable: no GitHub token in the Secret Service", + "scanned_at": "2026-11-12T11:14:56+00:00", + "warnings": [ + "forge unavailable: no GitHub token in the Secret Service" + ], + "items": [ + { + "branch": "chore/ancestor-local", + "worktree": null, + "head": null, + "detached": false, + "locked": null, + "worktree_state": null, + "staged": null, + "unstaged": null, + "untracked": null, + "upstream": null, + "ahead": null, + "behind": null, + "gone": false, + "local_sha": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", + "remotes": {}, + "compose_projects": [], + "images": [], + "state": "merged-clean", + "merged": true, + "dirty": 0, + "merged_by": "ancestor", + "merged_into": "origin/main", + "unpushed": null, + "last_activity": 1783873160, + "size_bytes": null, + "skip": "forge unavailable", + "actions": [ + "local-branch" + ], + "reason": "merged by ancestor", + "result": null, + "flow": null + }, + { + "branch": "fix/remote-merged", + "worktree": null, + "head": null, + "detached": false, + "locked": null, + "worktree_state": null, + "staged": null, + "unstaged": null, + "untracked": null, + "upstream": "origin/fix/remote-merged", + "ahead": 0, + "behind": 0, + "gone": false, + "local_sha": "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c", + "remotes": { + "origin": "2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" + }, + "compose_projects": [], + "images": [], + "state": "merged-clean", + "merged": true, + "dirty": 0, + "merged_by": "ancestor", + "merged_into": "origin/main", + "unpushed": null, + "last_activity": 1783873160, + "size_bytes": null, + "skip": "forge unavailable", + "actions": [ + "local-branch", + "remote-branch:origin" + ], + "reason": "merged by ancestor", + "result": null, + "flow": null + }, + { + "branch": "feature/open", + "worktree": null, + "head": null, + "detached": false, + "locked": null, + "worktree_state": null, + "staged": null, + "unstaged": null, + "untracked": null, + "upstream": null, + "ahead": null, + "behind": null, + "gone": false, + "local_sha": "3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d", + "remotes": {}, + "compose_projects": [], + "images": [], + "state": "unmerged", + "merged": false, + "dirty": 0, + "merged_by": null, + "merged_into": null, + "unpushed": 2, + "last_activity": 1783873160, + "size_bytes": null, + "skip": "forge unavailable", + "actions": [], + "reason": "unmerged: 2 commits not in origin/main", + "result": null, + "flow": null + }, + { + "branch": "main", + "worktree": "@ROOT@/repos/github.com/anon/web", + "head": "4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e", + "detached": false, + "locked": null, + "worktree_state": "clean", + "staged": 0, + "unstaged": 0, + "untracked": 0, + "upstream": "origin/main", + "ahead": 0, + "behind": 0, + "gone": false, + "local_sha": "4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e", + "remotes": { + "origin": "4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e" + }, + "compose_projects": [], + "images": [], + "state": "protected", + "merged": true, + "dirty": 0, + "merged_by": null, + "merged_into": null, + "unpushed": null, + "last_activity": 1783873160, + "size_bytes": null, + "skip": null, + "actions": [], + "reason": "default or protected branch", + "result": null, + "flow": null + } + ] + }, + { + "path": "@ROOT@/repos/git.example.org/anon/notes", + "remote": "ssh://git@git.example.org/anon/notes.git", + "default": "master", + "missing": false, + "known": true, + "forge": "none", + "scanned_at": "2026-11-12T11:14:56+00:00", + "warnings": [], + "items": [ + { + "branch": "topic/ancestor-local", + "worktree": null, + "head": null, + "detached": false, + "locked": null, + "worktree_state": null, + "staged": null, + "unstaged": null, + "untracked": null, + "upstream": null, + "ahead": null, + "behind": null, + "gone": false, + "local_sha": "5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f", + "remotes": {}, + "compose_projects": [], + "images": [], + "state": "merged-clean", + "merged": true, + "dirty": 0, + "merged_by": "ancestor", + "merged_into": "origin/master", + "unpushed": null, + "last_activity": 1783873160, + "size_bytes": null, + "skip": null, + "actions": [ + "local-branch" + ], + "reason": "merged by ancestor", + "result": null, + "flow": null + }, + { + "branch": "topic/patch-local", + "worktree": null, + "head": null, + "detached": false, + "locked": null, + "worktree_state": null, + "staged": null, + "unstaged": null, + "untracked": null, + "upstream": null, + "ahead": null, + "behind": null, + "gone": false, + "local_sha": "6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a", + "remotes": {}, + "compose_projects": [], + "images": [], + "state": "merged-clean", + "merged": true, + "dirty": 0, + "merged_by": "patch", + "merged_into": "origin/master", + "unpushed": null, + "last_activity": 1783873160, + "size_bytes": null, + "skip": null, + "actions": [ + "local-branch" + ], + "reason": "merged by patch", + "result": null, + "flow": null + }, + { + "branch": "master", + "worktree": "@ROOT@/repos/git.example.org/anon/notes", + "head": "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b", + "detached": false, + "locked": null, + "worktree_state": "clean", + "staged": 0, + "unstaged": 0, + "untracked": 0, + "upstream": "origin/master", + "ahead": 0, + "behind": 0, + "gone": false, + "local_sha": "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b", + "remotes": { + "origin": "7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b" + }, + "compose_projects": [], + "images": [], + "state": "protected", + "merged": true, + "dirty": 0, + "merged_by": null, + "merged_into": null, + "unpushed": null, + "last_activity": 1783873160, + "size_bytes": null, + "skip": null, + "actions": [], + "reason": "default or protected branch", + "result": null, + "flow": null + } + ] + } + ] +} diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index e790560..9de441b 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -1801,7 +1801,7 @@ class TestOrchestratorE2E(HubCase): self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"]) self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"]) artifacts = by_hash["#artifacts/tree"] - self.assertEqual(artifacts["deletes"], [["Delete all merged", True, "Delete all merged blocked: Secret Service locked"], ["Delete", True, "Delete blocked: Secret Service locked"]]) + self.assertEqual(artifacts["deletes"], [["Delete all merged", True, "Delete all merged blocked: Secret Service locked"]]) for state in ("merged by #12 into main", "upstream gone", "protected", "1 commits unpushed", "clean", "not committed: 0 staged, 0 changed, 1 new", "gc skips: ignored files beyond known caches: notes.txt"): with self.subTest(state=state): self.assertIn(state, artifacts["states"]) @@ -2108,6 +2108,7 @@ class TestOrchestratorE2E(HubCase): def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self): tree = json.loads(self.tree_file.read_text(encoding="utf-8")) tree["delete_ready"], tree["delete_reason"] = True, "" + next(item for item in tree["repos"][0]["items"] if item["branch"] == "feature/done").update(worktree=None, worktree_state=None, actions=["local-branch", "remote-branch"], compose_projects=[], images=[]) self.tree_file.write_text(json.dumps(tree), encoding="utf-8") demo = f"{self.root}/repos/github.com/me/demo" Path(demo).mkdir(parents=True) @@ -2115,8 +2116,7 @@ class TestOrchestratorE2E(HubCase): fake.parent.mkdir() fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8") fake.chmod(0o755) - worktree = f"{self.root}/worktrees/github.com/me/demo/feature-done" - (fake.parent / "plan.json").write_text(json.dumps([{"branch": "feature/done", "worktree": worktree, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None}]), encoding="utf-8") + (fake.parent / "plan.json").write_text(json.dumps([{"branch": "feature/done", "worktree": None, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None, "actions": ["local-branch"]}]), encoding="utf-8") following = json.loads(json.dumps(tree)) following["repos"][0]["items"] = [item for item in following["repos"][0]["items"] if item["branch"] != "feature/done"] (fake.parent / "next-tree.json").write_text(json.dumps(following), encoding="utf-8") @@ -2141,7 +2141,7 @@ class TestOrchestratorE2E(HubCase): self.assertEqual(opened["title"], "Delete feature/done") self.assertEqual(len(opened["dry"]), 1) self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12"), opened["dry"]) - self.assertIn("stays: remote-branch (local only)", opened["dry"][0]) + self.assertIn("stays: remote-branch (local only; the host refuses a plan that deletes it)", opened["dry"][0]) self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donedeleted"), confirmed) self.assertEqual(confirmed["toasts"], ["Delete: donefeature/done: deleted"]) self.assertEqual(after["past"], ["done", "deleted"]) @@ -2150,8 +2150,8 @@ class TestOrchestratorE2E(HubCase): self.assertEqual([[(item["branch"], item["status"]) for item in result["items"]] for result in written], [[("feature/done", "done")]]) calls = [json.loads(line)["argv"] for line in (fake.parent / "calls.jsonl").read_text(encoding="utf-8").splitlines()] self.assertEqual(calls, [ - ["branch", "cleanup", "--json", "--local", "--branch", "feature/done"], - ["branch", "cleanup", "--apply", "--json", "--local", "--branch", "feature/done"], + ["branch", "cleanup", "--json", "--branch", "feature/done"], + ["branch", "cleanup", "--apply", "--json", "--branch", "feature/done"], ["branch", "tree", "--format", "json", "--output", str(self.tree_file)], ]) diff --git a/tests/e2e/test_workbench_w3_e2e.py b/tests/e2e/test_workbench_w3_e2e.py new file mode 100644 index 0000000..77c9a18 --- /dev/null +++ b/tests/e2e/test_workbench_w3_e2e.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import shutil +import sys +import time +import unittest +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path[:0] = [str(HERE.parent), str(HERE)] + +import test_orchestrator_e2e as base +from hub_case import HubCase +from hub_support import REPO_ROOT, iso + +OUT = Path(os.environ.get("E2E_OUT", "/tmp/workbench-w3-shots")) +GATES_SPEC = importlib.util.spec_from_file_location("kvb_gates_for_e2e", REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gates.py") +gates = importlib.util.module_from_spec(GATES_SPEC) +GATES_SPEC.loader.exec_module(gates) +KEPT_TIP = "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d" +CLEAN_ALL = """(async () => {@HELPERS@ + const every = await until(() => document.querySelector('#main button[aria-label="Delete all merged"]'), 8000); + await until(() => document.querySelector("#main svg.ancestry"), 8000); + const token = all("#main .delete-all span.state").map(name); + const store = document.querySelector('#main button[aria-label="Store token"]'); + const before = every ? [every.disabled, every.title] : null; + every.click(); + const sheet = await until(() => document.querySelector("#sheet:not([hidden]) li.dry-run") && document.getElementById("sheet"), 8000); + const rows = all("li.dry-run", sheet).map((row) => [text(row.querySelector("code")), row.dataset.token || (row.dataset.held ? "held" : null), text(row.querySelector("pre")).split("\\n")[0]]); + const confirm = all("#sheet button").find((button) => name(button) === "Delete"); + confirm.click(); + const outcome = await until(() => sheet.querySelector("[data-cleanup]") || all("#sheet p.muted").find((node) => text(node).startsWith("Not signed")), 15000); + const tokenClick = store ? (store.click(), await until(() => all("#toasts .toast").map(text).find((line) => line.startsWith("Token")), 8000)) : null; + return {before, token, store: store ? [store.title, store.getAttribute("aria-label")] : null, rows, outcome: outcome ? (outcome.dataset.cleanup ? "queued" : "unsigned") : null, tokenClick, + wide: document.documentElement.scrollWidth > document.documentElement.clientWidth}; +})()""" +ANCESTRY = """(async () => {@HELPERS@ + const svg = await until(() => document.querySelector('#main svg.ancestry[aria-label="Ancestry of github.com/me/demo"]'), 8000); + if (!svg) { + return {error: "no ancestry"}; + } + const nodes = all("g.ancestry-node", svg).map((node) => [node.dataset.branch, Number(node.dataset.generation)]); + const edges = all("path.ancestry-edge", svg).map((edge) => [edge.dataset.parent, edge.dataset.child, edge.dataset.source, getComputedStyle(edge).strokeDasharray !== "none"]); + const marks = (branch) => { + const row = all("#main .branch-row").find((entry) => text(entry.querySelector(".branch-name strong")) === branch); + return row ? all(".gate-mark", row).map((button) => [button.dataset.action, button.dataset.level, button.classList.contains("inherited"), button.title, Number(getComputedStyle(button).opacity) < 1]) : null; + }; + return {nodes, edges, done: marks("feature/done"), main: marks("main"), wide: document.documentElement.scrollWidth > document.documentElement.clientWidth}; +})()""" +STAGE = """(async () => {@HELPERS@ + const svg = await until(() => document.querySelector("#main svg.ancestry"), 8000); + svg.scrollIntoView({block: "center", inline: "center"}); + await sleep(200); + const center = (branch) => { + const node = svg.querySelector(`g.ancestry-node[data-branch="${CSS.escape(branch)}"] circle`); + const box = node.getBoundingClientRect(); + return {x: box.left + box.width / 2, y: box.top + box.height / 2, node}; + }; + const from = center("feature/kept"); + const onto = center("feature"); + from.node.dispatchEvent(new PointerEvent("pointerdown", {bubbles: true, button: 0, clientX: from.x, clientY: from.y, pointerType: "mouse", isPrimary: true})); + document.dispatchEvent(new PointerEvent("pointerup", {bubbles: true, button: 0, clientX: onto.x, clientY: onto.y, pointerType: "mouse", isPrimary: true})); + const pending = await until(() => document.querySelector('#main path.ancestry-edge.pending[data-child="feature/kept"][data-parent="feature"]'), 4000); + const row = all("#main .branch-row").find((entry) => text(entry.querySelector(".branch-name strong")) === "feature"); + const commit = () => row.isConnected ? row.querySelector('.gate-mark[data-action="commit"]') : all("#main .branch-row").find((entry) => text(entry.querySelector(".branch-name strong")) === "feature").querySelector('.gate-mark[data-action="commit"]'); + commit().click(); + await sleep(100); + commit().click(); + const staged = await until(() => document.querySelector('#main .gate-mark.pending[data-action="commit"][data-level="quick"]'), 4000); + const apply = await until(() => document.querySelector('#main button[aria-label="Apply"]'), 4000); + const count = apply ? text(apply.parentElement.querySelector(".count")) : null; + apply.click(); + const toast = await until(() => all("#toasts .toast").map(text).find((line) => line.startsWith("Gates") || line.startsWith("Not signed") || line.includes("passkey")), 15000); + return {pending: Boolean(pending), staged: Boolean(staged), title: staged ? staged.title : null, count, apply: apply ? [apply.title, apply.getAttribute("aria-label")] : null, toast, + left: all("#main .gate-mark.pending").length + all("#main path.ancestry-edge.pending").length}; +})()""" +TOKENLESS = """(async () => {@HELPERS@ + const every = await until(() => document.querySelector('#main button[aria-label="Delete all merged"]'), 8000); + await until(() => document.querySelector("#main .branch-row"), 8000); + const store = document.querySelector('#main button[aria-label="Store token"]'); + const rows = all("#main .branch-row").map((row) => [text(row.querySelector(".branch-name strong")), Boolean(row.querySelector('span.state[aria-label="token needed"]')), Boolean(row.querySelector('button[aria-label="Delete"]'))]); + return {every: [every.disabled, every.title], token: all("#main .delete-all span.state").map(name), store: store ? store.getAttribute("aria-label") : null, rows}; +})()""" +AGENTS = """(async () => {@HELPERS@ + const svg = await until(() => document.querySelector('#main svg.ancestry[aria-label="Ancestry: operator, chair, sessions, subagents"]'), 8000); + if (!svg) { + return {error: "no agent ancestry"}; + } + await until(() => svg.querySelector('g.ancestry-node[data-kind="twig"]') || document.querySelector('#main svg.ancestry g[data-kind="twig"]'), 8000); + const fresh = document.querySelector("#main svg.ancestry"); + const nodes = all("g.ancestry-node", fresh).map((node) => [node.dataset.kind, Number(node.dataset.generation), node.getBoundingClientRect().top]); + return {nodes, edges: all("path.ancestry-edge", fresh).length, list: all("#main .spawn-tree li").length, wide: document.documentElement.scrollWidth > document.documentElement.clientWidth}; +})()""" + + +def probe(template: str) -> str: + return template.replace("@HELPERS@", base.HELPERS) + + +@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium") +class TestWorkbenchArtifacts(HubCase): + drive = base.TestOrchestratorE2E.drive + authenticator = base.TestOrchestratorE2E.authenticator + subagents = base.TestOrchestratorE2E.subagents + + def setUp(self): + super().setUp() + OUT.mkdir(parents=True, exist_ok=True) + now = time.time() + root = self.write_tree() + data = json.loads(self.tree_file.read_text(encoding="utf-8")) + next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/kept")["skip"] = None + self.tree_file.write_text(json.dumps(data), encoding="utf-8") + self.repo = f"{root}/repos/github.com/me/demo" + db = gates.connect(self.kept / "gates.db") + db.executemany("INSERT INTO rules VALUES (?, ?, ?, ?, ?)", [(self.repo, "main", "commit", "test", 0.0), (self.repo, "feature/done", "pr", "e2e", 0.0)]) + db.executemany("INSERT INTO parents VALUES (?, ?, ?, ?)", [(self.repo, "feature", "main", "recorded"), (self.repo, "feature/done", "feature", "derived"), (self.repo, "feature/done", "feature/kept", "operator"), (self.repo, "feature/kept", "main", "derived")]) + db.commit() + db.close() + self.claim("s1", "Skills session") + self.write_council({ + "work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 60, "work": "x"}, + "chair/claim.json": {"office": "chair", "session": "s1", "runtime": "claude", "name": "Skills session", "since": iso(now - 7200), "beat": now, "work": "allocate"}, + }) + self.subagents(now) + self.base = f"http://{self.host}/" + + def steps(self, browser: str) -> list[dict]: + steps = [] + for width, height in base.WIDTHS: + shot = str(OUT / f"{browser}-{width}") + steps += [ + {"width": width, "height": height, "url": f"{self.base}?{width}#artifacts/tree", "wait": 1200, "probe": probe(ANCESTRY), "shot": f"{shot}-ancestry.png"}, + {"width": width, "height": height, "url": None, "wait": 0, "probe": probe(STAGE), "shot": f"{shot}-staged.png"}, + {"width": width, "height": height, "url": f"{self.base}?{width}-clean#artifacts/tree", "wait": 1200, "probe": probe(CLEAN_ALL), "shot": f"{shot}-clean-all.png"}, + {"width": width, "height": height, "url": f"{self.base}?{width}#agents/tree", "wait": 1200, "probe": probe(AGENTS), "shot": f"{shot}-agents-ancestry.png"}, + ] + return steps + + def check(self, data: dict, browser: str, signs: bool) -> None: + results = iter(data["results"]) + for width, _ in base.WIDTHS: + ancestry, staged, clean, agents = next(results), next(results), next(results), next(results) + with self.subTest(browser=browser, width=width): + for found in (ancestry, agents): + self.assertNotIn("error", found, found) + nodes = dict(ancestry["nodes"]) + self.assertEqual(nodes, {"main": 0, "feature": 1, "feature/kept": 1, "feature/done": 2}) + self.assertEqual(sorted(ancestry["edges"]), [ + ["feature", "feature/done", "derived", True], ["feature/kept", "feature/done", "operator", False], ["main", "feature", "recorded", False], ["main", "feature/kept", "derived", True]]) + self.assertEqual([mark[:3] for mark in ancestry["done"]], [["commit", "test", True], ["merge", "none", True], ["push", "none", True], ["pr", "e2e", False]]) + self.assertEqual([mark[3] for mark in ancestry["done"]][:1], ["commit: test · from main"]) + self.assertEqual([mark[4] for mark in ancestry["done"]], [True, True, True, False]) + self.assertEqual(ancestry["main"][0][:3], ["commit", "test", False]) + self.assertEqual((staged["pending"], staged["staged"], staged["count"]), (True, True, "2"), staged) + self.assertEqual(staged["apply"], ["Apply 2 gate changes with one passkey signature", "Apply"]) + self.assertEqual(staged["left"], 0 if signs else 2, staged) + self.assertEqual(clean["before"], [False, "Delete all merged: 1 branches after dry run and signature"]) + self.assertEqual(clean["token"], ["1 held by a worktree: use branch gc"]) + self.assertEqual(clean["store"], ["Store token: add the operator row that stores the GitHub token in the Secret Service", "Store token"]) + self.assertEqual([row[:2] for row in clean["rows"]], [["feature/kept", None], ["feature/done", "held"]]) + self.assertEqual(clean["outcome"], "queued" if signs else "unsigned") + self.assertTrue(clean["tokenClick"].startswith("Token: operator row added"), clean) + kinds = {} + for kind, generation, _ in agents["nodes"]: + kinds.setdefault(kind, set()).add(generation) + self.assertEqual((kinds["operator"], kinds["chair"]), ({0}, {1})) + self.assertTrue(all(generation >= 2 for generation in kinds.get("session", set()) | kinds.get("subagent", set()) | kinds.get("twig", set())), agents) + self.assertIn("twig", kinds) + tops = {kind: min(top for k, _, top in agents["nodes"] if k == kind) for kind in kinds} + self.assertLess(tops["operator"], tops["chair"]) + self.assertGreater(agents["list"], 0) + self.assertFalse(any(found["wide"] for found in (ancestry, clean, agents)), (ancestry["wide"], clean["wide"], agents["wide"])) + jobs = self.runner_jobs() + gate_jobs = [job["argv"] for job in jobs if job.get("kind") == "gate"] + expected = [["parents", "add", "--repo", self.repo, "--branch", "feature/kept", "--parent", "feature"], ["rule", "set", "--repo", self.repo, "--branch", "feature", "--action", "commit", "--level", "quick"]] + self.assertEqual(sorted(gate_jobs), sorted(expected * len(base.WIDTHS)) if signs else []) + spooled = [json.loads(path.read_text(encoding="utf-8")) for path in sorted((self.cleanup / "jobs").glob("*.json"))] + self.assertEqual([[item["branch"] for item in job["items"]] for job in spooled], [["feature/kept"]] * len(base.WIDTHS) if signs else []) + self.assertTrue(all(job["items"][0]["tip"] == KEPT_TIP for job in spooled)) + tokens = [args for script, args in self.scripts.calls if script.endswith("operator.py") and args[:2] == ["add", "pkgmgr-github-token"]] + self.assertEqual(len(tokens), len(base.WIDTHS)) + + def test_chromium_shows_ancestry_in_both_tabs_stages_a_parent_and_a_gate_and_signs_clean_all_merged_at_three_widths(self): + data = self.drive(self.steps("chromium"), self.authenticator()) + (OUT / "chromium-artifacts.json").write_text(json.dumps(data, indent=1), encoding="utf-8") + self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], []) + self.check(data, "chromium", True) + + def test_chromium_marks_every_forge_unavailable_branch_token_needed_and_offers_store_token_on_the_real_tokenless_tree(self): + self.write_fixture("branch-tree-tokenless.json") + data = json.loads(self.tree_file.read_text(encoding="utf-8")) + data["repos"] = [repo for repo in data["repos"] if repo["forge"] != "none"] + self.tree_file.write_text(json.dumps(data), encoding="utf-8") + width, height = base.WIDTHS[0] + found = self.drive([{"width": width, "height": height, "url": f"{self.base}?tokenless#artifacts/tree", "wait": 1200, "probe": probe(TOKENLESS), "shot": str(OUT / "chromium-tokenless.png")}], self.authenticator()) + self.assertEqual([error for error in found["errors"] if "/favicon.ico" not in error], []) + shown = found["results"][0] + self.assertEqual(shown["every"], [True, "Delete all merged blocked: no GitHub token in the Secret Service"]) + self.assertEqual((shown["token"], shown["store"]), (["2 token needed"], "Store token")) + self.assertEqual(sorted(shown["rows"]), [["chore/ancestor-local", True, False], ["feature/open", False, False], ["fix/remote-merged", True, False], ["main", False, False]]) + + @unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox") + def test_firefox_shows_ancestry_in_both_tabs_and_hands_nothing_over_unsigned_at_three_widths(self): + data = self.drive(self.steps("firefox"), None, "bidi.mjs", os.environ.get("FIREFOX", "firefox")) + (OUT / "firefox-artifacts.json").write_text(json.dumps(data, indent=1), encoding="utf-8") + self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], []) + self.check(data, "firefox", False) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/hub_case.py b/tests/hub_case.py index 674c68d..fd30dfd 100644 --- a/tests/hub_case.py +++ b/tests/hub_case.py @@ -232,8 +232,14 @@ class HubCase(unittest.TestCase): path.write_text(text, encoding="utf-8") def write_tree(self) -> Path: + return self.write_fixture("branch-tree.json") + + def write_fixture(self, fixture: str) -> Path: + """Args: + fixture: the tree file under tests/ to write, @ROOT@ replaced. + """ root = self.outside / "scan" - sample = (Path(__file__).parent / "branch-tree.json").read_text(encoding="utf-8") + sample = (Path(__file__).parent / fixture).read_text(encoding="utf-8") self.tree_file.write_text(sample.replace("@ROOT@", str(root)), encoding="utf-8") return root diff --git a/tests/hub_support.py b/tests/hub_support.py index 1bf9876..3fbd095 100644 --- a/tests/hub_support.py +++ b/tests/hub_support.py @@ -83,6 +83,8 @@ ESTIMATE = REPO_ROOT / "skills" / "kvb-org-queue-estimate" / "scripts" / "estima class FakeScripts: + skills = REPO_ROOT / "skills" + def __init__(self) -> None: self.calls: list[tuple[str, list[str]]] = [] self.code = 0 diff --git a/tests/test_hub_ancestry.py b/tests/test_hub_ancestry.py new file mode 100644 index 0000000..6c7886f --- /dev/null +++ b/tests/test_hub_ancestry.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +import re +import subprocess +import unittest + +from hub_case import HubCase +from hub_support import HUB, signing + +ANCESTRY = HUB / "templates" / "ancestry.js" +PAGE = HUB / "templates" / "page-ancestry.js" +STYLE = HUB / "templates" / "style-ancestry.css" +REPO = "/home/op/Repositories/demo" +# Param: argv[1] is ancestry.js; prints the layouts, gate marks and argv the page builds. +PROBE = """ +const A = require(process.argv[1]); +const node = (id) => ({id, label: id}); +const shape = (plan) => ({ + rows: plan.nodes.map((entry) => [entry.id, entry.generation]), + ys: Object.fromEntries(plan.nodes.map((entry) => [entry.id, entry.y])), + xs: Object.fromEntries(plan.nodes.map((entry) => [entry.id, entry.x])), + links: plan.links.map((link) => [link.parent, link.child, link.derived, link.y1 < link.y2]), + size: [plan.width, plan.height], +}); +const family = shape(A.layout(["main", "feature/a", "feature/b", "feature/ab", "lonely"].map(node), [ + {child: "feature/a", parent: "main", source: "recorded"}, + {child: "feature/b", parent: "main", source: "derived"}, + {child: "feature/ab", parent: "feature/a", source: "operator"}, + {child: "feature/ab", parent: "feature/b", source: "derived"}, + {child: "ghost", parent: "main", source: "recorded"}, +])); +const looped = shape(A.layout(["x", "y", "z"].map(node), [{child: "x", parent: "y"}, {child: "y", parent: "x"}, {child: "z", parent: "z"}])); +const chain = shape(A.layout(["main", "mid", "leaf"].map(node), [{child: "leaf", parent: "main"}, {child: "leaf", parent: "mid"}, {child: "mid", parent: "main"}])); +const roots = Array.from({length: 20}, (_, index) => `b${String(index).padStart(2, "0")}`); +const wide = shape(A.layout([...roots, "child"].map(node), [{child: "child", parent: "b00"}])); +const shown = {resolved: {commit: {level: "test", source: "feature/a"}, merge: {level: "quality-high", source: "main"}, push: {level: "none", source: null}, pr: {level: "e2e", source: "feature/a"}}, rules: {commit: "test", pr: "e2e"}}; +console.log(JSON.stringify({ + family, looped, chain, wide, spacing: A.SPACING, + marks: A.gateMarks("feature/a", shown), + bare: A.gateMarks("lonely", null), + cycle: [null, "none", "quick", "test", "quality-high", "e2e"].map(A.nextRule), + argv: [A.ruleArgv("/r", "b", "push", "test"), A.ruleArgv("/r", "b", "push", null), A.parentArgv("/r", "b", "p", false), A.parentArgv("/r", "b", "p", true)], + icons: A.LEVEL_ICONS, +})); +""" + + +class TestAncestryLayout(unittest.TestCase): + @classmethod + def setUpClass(cls): + done = subprocess.run(["node", "-e", PROBE, str(ANCESTRY)], capture_output=True, text=True, timeout=60, check=True) + cls.data = json.loads(done.stdout) + + def test_generations_run_top_down_and_a_node_with_several_parents_sits_below_the_deepest(self): + family = self.data["family"] + self.assertEqual(family["rows"], [["main", 0], ["feature/a", 1], ["feature/b", 1], ["feature/ab", 2], ["lonely", 0]]) + self.assertLess(family["ys"]["main"], family["ys"]["feature/a"]) + self.assertLess(family["ys"]["feature/a"], family["ys"]["feature/ab"]) + self.assertEqual(family["ys"]["feature/a"], family["ys"]["feature/b"]) + self.assertEqual(self.data["chain"]["rows"], [["main", 0], ["mid", 1], ["leaf", 2]]) + + def test_every_parent_edge_is_drawn_downwards_and_only_derived_ones_are_marked_dashed(self): + self.assertEqual(self.data["family"]["links"], [ + ["main", "feature/a", False, True], + ["main", "feature/b", True, True], + ["feature/a", "feature/ab", False, True], + ["feature/b", "feature/ab", True, True], + ]) + + def test_a_child_sits_between_its_parents_and_unknown_or_looping_edges_do_not_break_the_layout(self): + xs = self.data["family"]["xs"] + self.assertTrue(min(xs["feature/a"], xs["feature/b"]) <= xs["feature/ab"] <= max(xs["feature/a"], xs["feature/b"])) + looped = self.data["looped"] + self.assertEqual(sorted(generation for _, generation in looped["rows"]), [0, 0, 1]) + self.assertEqual([link[:2] for link in looped["links"] if link[0] == link[1]], []) + + def test_a_wide_generation_wraps_into_bands_and_the_next_generation_starts_below_them(self): + wide, spacing = self.data["wide"], self.data["spacing"] + self.assertEqual(wide["size"], [spacing["wrap"] * spacing["column"], 4 * spacing["row"]]) + roots = [name for name, generation in wide["rows"] if generation == 0] + self.assertEqual(len(roots), 20) + self.assertEqual(len({(wide["xs"][name], wide["ys"][name]) for name in roots}), 20) + self.assertEqual(sorted({wide["ys"][name] for name in roots}), [spacing["row"] * band + spacing["row"] / 2 for band in range(3)]) + self.assertEqual(wide["ys"]["child"], 3.5 * spacing["row"]) + self.assertTrue(all(0 < x < wide["size"][0] for x in wide["xs"].values())) + + +class TestGateMarks(unittest.TestCase): + @classmethod + def setUpClass(cls): + done = subprocess.run(["node", "-e", PROBE, str(ANCESTRY)], capture_output=True, text=True, timeout=60, check=True) + cls.data = json.loads(done.stdout) + + def test_each_action_shows_its_resolved_level_with_own_rules_solid_and_inherited_faint_with_the_source(self): + marks = {mark["action"]: mark for mark in self.data["marks"]} + self.assertEqual(list(marks), ["commit", "merge", "push", "pr"]) + self.assertEqual([(marks[a]["level"], marks[a]["own"], marks[a]["inherited"]) for a in marks], [("test", True, False), ("quality-high", False, True), ("none", False, True), ("e2e", True, False)]) + self.assertEqual([marks[a]["title"] for a in marks], ["commit: test · own rule", "merge: quality-high · from main", "push: none · no rule", "pr: e2e · own rule"]) + self.assertEqual([marks[a]["icon"] for a in marks], ["bi-check2-circle", "bi-shield-check", "bi-circle", "bi-browser-chrome"]) + + def test_a_branch_gates_py_does_not_know_shows_none_inherited_for_every_action(self): + self.assertEqual([(mark["level"], mark["inherited"], mark["source"]) for mark in self.data["bare"]], [("none", True, "")] * 4) + + def test_a_click_cycles_the_own_rule_through_every_level_and_back_to_inherited(self): + self.assertEqual(self.data["cycle"], ["none", "quick", "test", "quality-high", "e2e", None]) + + def test_the_staged_argv_is_exactly_what_signing_gate_argv_accepts(self): + for argv in self.data["argv"]: + with self.subTest(argv=argv): + self.assertEqual(signing.gate_argv(argv), argv) + self.assertEqual(self.data["argv"][1], ["rule", "drop", "--repo", "/r", "--branch", "b", "--action", "push"]) + + def test_the_level_icons_are_the_page_levels_of_gates_py(self): + self.assertEqual(list(self.data["icons"]), list(signing.GATE_LEVELS)) + + +class TestAncestryAssets(unittest.TestCase): + def test_the_layout_module_parses_on_its_own_and_derived_edges_are_dashed(self): + done = subprocess.run(["node", "--check", str(ANCESTRY)], capture_output=True, text=True, timeout=60, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertIn("svg .ancestry-edge.derived { stroke-dasharray: 5 5; }", STYLE.read_text(encoding="utf-8")) + self.assertIn(".gate-mark.inherited { opacity: .4; }", STYLE.read_text(encoding="utf-8")) + + def test_every_new_control_is_an_icon_with_title_and_aria_label(self): + page = PAGE.read_text(encoding="utf-8") + for control in re.findall(r'iconBtn\("(bi-[a-z0-9-]+)", "([^"]+)"', page): + with self.subTest(control=control): + self.assertTrue(control[1]) + self.assertIn('...named(`${mark.action} gate`, title)', page) + self.assertIn('role: "button", "aria-label": node.label', page) + + +class TestHubAncestry(HubCase): + def test_the_page_ships_the_layout_module_and_the_gate_level_icons(self): + html = self.index() + self.assertIn(ANCESTRY.read_text(encoding="utf-8").strip(), html) + for name in ("bi-circle", "bi-lightning-charge", "bi-check2-circle", "bi-shield-check", "bi-browser-chrome", "bi-key", "bi-key-fill", "bi-check2-all"): + with self.subTest(name=name): + self.assertIn(f'; plan.json beside it is the dry-run plan, next-tree.json the rewritten tree. +# Param: format with python=; parses argv as pkgmgr's branch_cmd.py does (argparse exits 2 on any other flag) and prints the plan as gc.py _render does, {"host_pid_namespace", "repos": [{"repo", "default", "merged_into", "warnings", "items"}]}; plan.json beside it is the plan's items, next-tree.json the rewritten tree, outcome.txt the result --apply reports per item (done without it), exit 0 also for a skipped item. FAKE_PKGMGR = """#!{python} +import argparse import json import os import sys @@ -29,22 +34,48 @@ from pathlib import Path here = Path(__file__).parent with (here / "calls.jsonl").open("a", encoding="utf-8") as log: log.write(json.dumps({{"argv": sys.argv[1:], "cwd": os.getcwd()}}) + "\\n") -if sys.argv[1:3] == ["branch", "tree"]: +parser = argparse.ArgumentParser(prog="pkgmgr") +branch = parser.add_subparsers(dest="command", required=True).add_parser("branch").add_subparsers(dest="subcommand", required=True) +tree = branch.add_parser("tree") +tree.add_argument("--format", choices=("json", "yaml"), default="json") +tree.add_argument("--output") +cleanup = branch.add_parser("cleanup") +for flag in ("--apply", "--json", "--all", "--sizes"): + cleanup.add_argument(flag, action="store_true") +for option in ("--branch", "--exclude", "--merged-into"): + cleanup.add_argument(option, action="append", default=[]) +cleanup.add_argument("--min-age", type=float) +args = parser.parse_args() +if args.subcommand == "tree": following = here / "next-tree.json" rewritten = following.read_text(encoding="utf-8") if following.exists() else json.dumps({{"rewritten": True}}) - Path(sys.argv[sys.argv.index("--output") + 1]).write_text(rewritten, encoding="utf-8") + Path(args.output).write_text(rewritten, encoding="utf-8") raise SystemExit(0) -print(json.dumps({{"items": json.loads((here / "plan.json").read_text(encoding="utf-8"))}})) +items = json.loads((here / "plan.json").read_text(encoding="utf-8")) +if args.apply: + outcome = here / "outcome.txt" + result = outcome.read_text(encoding="utf-8") if outcome.exists() else "done" + items = [{{**item, "result": result}} for item in items] +plan = {{"host_pid_namespace": True, "repos": [{{"repo": os.getcwd(), "default": "origin/main", "merged_into": [], "warnings": [], "items": items}}]}} +print(json.dumps(plan) if args.json else "\\n".join(str(item["branch"]) for item in items)) """ class TestHubCleanup(HubCase): - def ready(self, ready: bool) -> dict: + def ready(self, ready: bool, held: bool) -> dict: + """Args: + ready: delete_ready of the tree file. + held: keep feature/done in its worktree; otherwise it is a local + branch without one. + """ root = self.write_tree() data = json.loads(self.tree_file.read_text(encoding="utf-8")) data["delete_ready"], data["delete_reason"] = ready, "" if ready else "Secret Service locked" + done = next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/done") + if not held: + done.update(worktree=None, worktree_state=None, actions=["local-branch", "remote-branch"], compose_projects=[], images=[]) self.tree_file.write_text(json.dumps(data), encoding="utf-8") - return signing.cleanup_item(f"{root}/repos/github.com/me/demo", "feature/done", f"{root}/worktrees/github.com/me/demo/feature-done", DONE_TIP) + return signing.cleanup_item(f"{root}/repos/github.com/me/demo", "feature/done", done["worktree"] or "", DONE_TIP) def branches(self) -> dict: status, _, body = self.get("/api/tree") @@ -57,27 +88,24 @@ class TestHubCleanup(HubCase): return status, body def test_the_tree_offers_a_delete_only_for_merged_clean_branches_without_skip_reason(self): - item = self.ready(False) + item = self.ready(False, False) branches = self.branches() done = branches["feature/done"] - self.assertEqual(done["cleanup"], item) + self.assertEqual((done["cleanup"], done["held"]), (item, "")) self.assertEqual(done["plan"], [ "feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12", - f"removes the worktree {item['worktree']}", "deletes the local branch feature/done", - "stays: remote-branch (local only)", - "stops the Compose stack demo-feature-done without -v", - "removes the image demo:feature-done", + "stays: remote-branch (local only; the host refuses a plan that deletes it)", ]) self.assertEqual((done["merged_by"], done["merged_into"], done["gone"]), ("#12", "main", True)) self.assertEqual({name: entry["cleanup"] for name, entry in branches.items() if name != "feature/done"}, {"feature": None, "feature/kept": None, "main": None}) self.assertEqual(branches["feature/kept"]["skip"], "ignored files beyond known caches: notes.txt") def test_signing_a_delete_needs_delete_ready_and_exactly_an_offered_item(self): - item = self.ready(False) + item = self.ready(False, False) status, body = self.sign([item]) self.assertEqual((status, body.decode()), (400, "delete blocked: Secret Service locked\n")) - item = self.ready(True) + item = self.ready(True, False) kept = {**item, "branch": "feature/kept", "worktree": "", "tip": "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d"} for name, items in (("changed tip", [{**item, "tip": OTHER_TIP}]), ("skip reason", [kept]), ("pattern", [{**item, "branch": "feature/*"}]), ("twice", [item, item]), ("extra key", [{**item, "command": "rm -rf /"}])): with self.subTest(name=name): @@ -86,8 +114,37 @@ class TestHubCleanup(HubCase): self.assertEqual(status, 200, body) self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(item)]) + def test_a_branch_held_by_a_worktree_offers_no_delete_and_names_branch_gc(self): + item = self.ready(True, True) + done = self.branches()["feature/done"] + held = f"held by worktree {item['worktree']}: pkgmgr branch cleanup skips it, use branch gc (kvb-git-branch-gc, pkgmgr gc)" + self.assertEqual((done["cleanup"], done["held"], done["plan"][1]), (None, held, held)) + status, body = self.sign([item]) + self.assertEqual((status, body.decode()), (400, f"feature/done in {item['repository']} is no merged-clean branch without skip reason at that tip in the tree file\n")) + page = (HUB / "templates" / "page-tree.js").read_text(encoding="utf-8") + self.assertIn('if (entry.held) {\n return h("span", {class: "hint held", title: entry.held}, chip("bi-folder-symlink", entry.held, "neutral"));', page) + self.assertIn("deleteDialog(items, locked, held)", page) + self.assertIn("held by a worktree: not deleted here", (HUB / "templates" / "page-cleanup.js").read_text(encoding="utf-8")) + + @unittest.skipUnless(INSTALLED_PKGMGR, "no pkgmgr installed") + def test_every_volume_text_states_the_compose_down_argv_of_the_installed_pkgmgr(self): + found = subprocess.run([str(Path(INSTALLED_PKGMGR).parent / "python"), "-I", "-c", "import pkgmgr.actions.branch.gc as gc; print(gc.__file__)"], capture_output=True, text=True, timeout=60, check=True) + calls = [node for node in ast.walk(ast.parse(Path(found.stdout.strip()).read_text(encoding="utf-8"))) if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_docker" and node.args and isinstance(node.args[0], ast.List)] + downs = [["docker", *(part.value if isinstance(part, ast.Constant) else "{project}" for part in call.args[0].elts)] for call in calls if any(isinstance(part, ast.Constant) and part.value == "down" for part in call.args[0].elts)] + self.assertEqual(len(downs), 1, downs) + command = " ".join(downs[0]).format(project="demo-feature-done") + self.ready(True, True) + plan = self.branches()["feature/done"]["plan"] + self.assertIn(f"branch gc would run {command}, which deletes the volumes of demo-feature-done", plan) + self.assertIn("-v", downs[0]) + dialog = (HUB / "templates" / "page-cleanup.js").read_text(encoding="utf-8") + self.assertIn("never touches a worktree, Compose stack or volume", dialog) + self.assertIn("its docker compose down -v deletes the stack's volumes", dialog) + texts = [*plan, dialog, (HUB / "kvb_orchestrator" / "tree.py").read_text(encoding="utf-8")] + self.assertFalse([text for text in texts if "without -v" in text]) + def test_a_signed_delete_becomes_a_job_without_any_command(self): - item = self.ready(True) + item = self.ready(True, False) signer = self.pin() proof = signer.proof(self.prepare([item])["payload"], self.origin) self.assertEqual(self.post_json("/api/cleanup", {"items": [item], "proof": None}, self.guard(self.host))[0], 400) @@ -127,7 +184,7 @@ class TestHostCleanup(unittest.TestCase): self.calls = self.fake.parent / "calls.jsonl" self.tree = base / "tree" / "branch-tree.json" self.tree.parent.mkdir() - self.tree.write_text(json.dumps({"repos": [{"path": str(self.repo), "known": True}, {"path": str(base / "stranger"), "known": False}]}), encoding="utf-8") + self.tree.write_text(json.dumps({"delete_ready": True, "repos": [{"path": str(self.repo), "known": True}, {"path": str(base / "stranger"), "known": False}]}), encoding="utf-8") self.approvers = base / "config" / "approvers.json" self.trust = verifier.Trust(approvers=self.approvers, replay=base / "replay" / "cleanup.json", origin=ORIGIN) self.signer = key(-7, "main") @@ -154,7 +211,7 @@ class TestHostCleanup(unittest.TestCase): def test_a_signed_merged_clean_item_is_dry_run_then_deleted_by_exact_name(self): result, calls = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()]) self.assertEqual([(entry["status"], entry["reason"]) for entry in result["items"]], [("done", "")]) - selector = ["--json", "--local", "--branch", "feature/done"] + selector = ["--json", "--branch", "feature/done"] self.assertEqual(calls, [ {"argv": ["branch", "cleanup", *selector], "cwd": str(self.repo)}, {"argv": ["branch", "cleanup", "--apply", *selector], "cwd": str(self.repo)}, @@ -163,6 +220,22 @@ class TestHostCleanup(unittest.TestCase): self.assertEqual(json.loads(self.tree.read_text(encoding="utf-8")), {"rewritten": True}) self.assertEqual(result["tree"], {"code": 0, "error": ""}) + def test_an_apply_that_exits_0_but_skips_or_reports_no_result_is_failed_with_pkgmgrs_reason_and_keeps_the_tree(self): + outcome = self.fake.parent / "outcome.txt" + before = self.tree.read_text(encoding="utf-8") + cases = (("skipped", "skipped: changed since plan (dirty: 1 paths)", "skipped: changed since plan (dirty: 1 paths)"), ("no result", "", "pkgmgr reported no result for feature/done")) + for name, result, reason in cases: + with self.subTest(name=name): + outcome.write_text(result, encoding="utf-8") + done, calls = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()]) + self.assertEqual([(entry["status"], entry["reason"], entry["code"]) for entry in done["items"]], [("failed", reason, 0)]) + self.assertEqual([call["argv"][:3] for call in calls], [["branch", "cleanup", "--json"], ["branch", "cleanup", "--apply"]]) + self.assertNotIn("tree", done) + self.assertEqual(self.tree.read_text(encoding="utf-8"), before) + outcome.write_text("done; image kept: demo:feature-done", encoding="utf-8") + done, _ = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()]) + self.assertEqual([(entry["status"], entry["reason"]) for entry in done["items"]], [("done", "")]) + def test_every_doubtful_item_is_refused_and_changes_nothing(self): pattern = {**self.item, "branch": "feature/*"} option = {**self.item, "branch": "--all"} @@ -181,13 +254,14 @@ class TestHostCleanup(unittest.TestCase): ("skip reason", [self.item], self.proof([self.item], self.signer), [self.entry(skip="ignored files")], "gc skip reason", 1), ("other worktree", [self.item], self.proof([self.item], self.signer), [self.entry(worktree="/elsewhere")], "worktree changed", 1), ("pattern match in pkgmgr", [self.item], self.proof([self.item], self.signer), [self.entry(), self.entry(branch="feature/done-too")], "other branches too", 1), + ("remote branch delete", [self.item], self.proof([self.item], self.signer), [self.entry(actions=["local-branch", "remote-branch:origin"])], "a signed delete is local only", 1), ) for name, items, proof, plan, reason, dry_runs in cases: with self.subTest(name=name): result, calls = self.run_job(items, proof, plan) self.assertEqual([entry["status"] for entry in result["items"]], ["refused"]) self.assertIn(reason, result["items"][0]["reason"]) - self.assertEqual([call["argv"] for call in calls], [["branch", "cleanup", "--json", "--local", "--branch", items[0]["branch"]]] * dry_runs) + self.assertEqual([call["argv"] for call in calls], [["branch", "cleanup", "--json", "--branch", items[0]["branch"]]] * dry_runs) self.assertNotIn("tree", result) def test_a_signature_deletes_once_and_a_malformed_job_nothing(self): @@ -217,6 +291,44 @@ class TestHostCleanup(unittest.TestCase): self.assertEqual((self.results / f"{job_id}.json").stat().st_mode & 0o777, 0o600) +@unittest.skipUnless(INSTALLED_PKGMGR, "no pkgmgr installed") +class TestInstalledPkgmgrContract(unittest.TestCase): + def git(self, cwd: Path, *args: str) -> str: + return subprocess.run(["git", *args], cwd=cwd, env={**os.environ, **GIT_ENV}, check=True, capture_output=True, text=True).stdout.strip() + + def test_the_installed_pkgmgr_takes_the_runners_dry_run_argv_and_prints_a_plan_the_runner_parses_and_accepts(self): + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + origin, repo = base / "origin.git", base / "repo" + self.git(base, "init", "-q", "--bare", "-b", "main", str(origin)) + self.git(base, "init", "-q", "-b", "main", str(repo)) + self.git(repo, "remote", "add", "origin", origin.as_uri()) + self.git(repo, "commit", "-q", "--allow-empty", "-m", "one") + self.git(repo, "checkout", "-q", "-b", "side") + self.git(repo, "commit", "-q", "--allow-empty", "-m", "side") + tip = self.git(repo, "rev-parse", "HEAD") + self.git(repo, "checkout", "-q", "main") + self.git(repo, "merge", "-q", "--no-ff", "-m", "merge side", "side") + self.git(origin, "fetch", "-q", str(repo), "main:main") + self.git(repo, "fetch", "-q", "origin") + self.git(repo, "remote", "set-head", "origin", "main") + code, stdout, stderr = host.run(Path(INSTALLED_PKGMGR), host.plan_argv("side", False), str(repo)) + self.assertEqual(code, 0, stderr) + plan = host.plan_items(stdout) + self.assertIsNotNone(plan, stdout[:500]) + self.assertEqual([(entry["branch"], entry["state"], entry["merged_by"], entry["merged_into"], entry["skip"], entry["actions"]) for entry in plan], [("side", "merged-clean", "ancestor", "origin/main", None, ["local-branch"])]) + self.assertIsNone(host.mismatch(plan[0], {"tip": tip, "worktree": ""})) + self.assertTrue(signing.token_free(plan[0], "main")) + self.assertEqual(self.git(repo, "rev-parse", "refs/heads/side"), tip) + held = base / "held" + self.git(repo, "worktree", "add", "-q", str(held), "side") + code, stdout, stderr = host.run(Path(INSTALLED_PKGMGR), host.plan_argv("side", False), str(repo)) + self.assertEqual(code, 0, stderr) + plan = host.plan_items(stdout) + self.assertEqual([(entry["worktree"], entry["actions"], bool(entry["skip"])) for entry in plan], [(os.path.realpath(held), [], True)], plan) + self.assertIn("gc skip reason", host.mismatch(plan[0], {"tip": tip, "worktree": os.path.realpath(held)})) + + class TestHostCleanupGitConfig(unittest.TestCase): def test_pkgmgr_runs_no_hook_the_repository_config_names(self): with tempfile.TemporaryDirectory() as tmp: diff --git a/tests/test_hub_cleanup_token.py b/tests/test_hub_cleanup_token.py new file mode 100644 index 0000000..879e5b5 --- /dev/null +++ b/tests/test_hub_cleanup_token.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import secrets +import shlex +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +from hub_case import HubCase +from hub_support import GIT_ENV, HUB, ORIGIN, REPO_ROOT, key, signing, verifier + +CLEANUP_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_cleanup_token_runner", HUB / "client" / "cleanup.py") +host = importlib.util.module_from_spec(CLEANUP_SPEC) +CLEANUP_SPEC.loader.exec_module(host) +OPERATOR_SPEC = importlib.util.spec_from_file_location("kvb_operator_for_token", REPO_ROOT / "skills" / "kvb-org-operator" / "scripts" / "operator.py") +operator_script = importlib.util.module_from_spec(OPERATOR_SPEC) +OPERATOR_SPEC.loader.exec_module(operator_script) +KEPT_TIP = "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d" +DONE_TIP = "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4" +# Param: format with python=; plans one branch of the real repository in its cwd as pkgmgr 2.4.0 `branch cleanup` does without a GitHub token: argv parsed as branch_cmd.py does (exit 2 on any other flag), merged_into the remote HEAD ref (gc.py symbolic-ref --short), an origin on github.com skips every item with "forge unavailable" (gc.py _skip), actions local-branch plus remote-branch: (gc.py _actions), --apply deletes only an item without skip (Item.eligible) and the output is gc.py _render's {"host_pid_namespace", "repos": [{"repo", "default", "merged_into", "warnings", "items"}]}. +SCRATCH_PKGMGR = """#!{python} +import argparse +import json +import os +import subprocess + +parser = argparse.ArgumentParser(prog="pkgmgr") +branch = parser.add_subparsers(dest="command", required=True).add_parser("branch").add_subparsers(dest="subcommand", required=True) +tree = branch.add_parser("tree") +tree.add_argument("--format", choices=("json", "yaml"), default="json") +tree.add_argument("--output") +cleanup = branch.add_parser("cleanup") +for flag in ("--apply", "--json", "--all", "--sizes"): + cleanup.add_argument(flag, action="store_true") +for option in ("--branch", "--exclude", "--merged-into"): + cleanup.add_argument(option, action="append", default=[]) +cleanup.add_argument("--min-age", type=float) +args = parser.parse_args() +if args.subcommand == "tree": + raise SystemExit(0) + + +def git(*argv): + return subprocess.run(["git", *argv], capture_output=True, text=True, check=False) + + +name = args.branch[0] +tip = git("rev-parse", "--verify", "refs/heads/" + name).stdout.strip() +default = git("symbolic-ref", "--short", "refs/remotes/origin/HEAD").stdout.strip() +merged = bool(tip) and git("merge-base", "--is-ancestor", tip, default).returncode == 0 +listed = git("for-each-ref", "refs/remotes", "--format=%(refname:lstrip=2) %(objectname)").stdout.split("\\n") +remotes = {{line.split("/", 1)[0]: line.split(" ")[1] for line in listed if line and line.split(" ")[0].split("/", 1)[1] == name}} +skip = "forge unavailable" if "github.com" in git("remote", "get-url", "origin").stdout else None +actions = ["local-branch", *("remote-branch:" + remote for remote in remotes)] if merged else [] +entry = {{"branch": name, "worktree": None, "local_sha": tip, "head": None, "state": "merged-clean" if merged else "unmerged", "skip": skip, + "merged_by": "ancestor" if merged else None, "merged_into": default if merged else None, "remotes": remotes, "actions": actions, "result": None}} +if args.apply and skip is None: + git("branch", "-D", "--", name) + entry["result"] = "done" +print(json.dumps({{"host_pid_namespace": True, "repos": [{{"repo": os.getcwd(), "default": default, "merged_into": [], "warnings": [], "items": [entry]}}]}})) +""" + + +class TestHubCleanupToken(HubCase): + def tree(self, ready: bool, kept_skip: str | None) -> Path: + root = self.write_tree() + data = json.loads(self.tree_file.read_text(encoding="utf-8")) + data["delete_ready"], data["delete_reason"] = ready, "" if ready else "no GitHub token in the Secret Service" + kept = next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/kept") + kept["skip"] = kept_skip + done = next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/done") + done.update(worktree=None, worktree_state=None, actions=["local-branch"], compose_projects=[], images=[]) + self.tree_file.write_text(json.dumps(data), encoding="utf-8") + return root + + def items(self, root: Path) -> tuple[dict, dict]: + repo = f"{root}/repos/github.com/me/demo" + return (signing.cleanup_item(repo, "feature/kept", "", KEPT_TIP), + signing.cleanup_item(repo, "feature/done", "", DONE_TIP)) + + def branches(self) -> dict: + status, _, body = self.get("/api/tree") + self.assertEqual(status, 200, body) + demo = next(repo for repo in json.loads(body)["repos"] if repo["name"] == "github.com/me/demo") + return {entry["name"]: entry for entry in demo["branches"]} + + def test_only_a_local_branch_merged_by_ancestry_into_the_default_branch_without_remote_is_token_free(self): + self.tree(False, None) + branches = self.branches() + self.assertEqual({name: entry["token_free"] for name, entry in branches.items()}, {"feature": False, "feature/done": False, "feature/kept": True, "main": False}) + entry = {"merged_by": "ancestor", "merged_into": "main", "remotes": {}} + cases = (("ancestry, no remote", entry, "main", True), ("pull request", {**entry, "merged_by": "#12"}, "main", False), + ("remote branch", {**entry, "remotes": {"origin": "a" * 40}}, "main", False), ("other line", {**entry, "merged_into": "store-i18n"}, "main", False), + ("no default", entry, None, False), ("line merge", {**entry, "merged_by": "line:main"}, "main", False), + ("pkgmgr names the remote HEAD", {**entry, "merged_into": "origin/main"}, "main", True), ("other remote line", {**entry, "merged_into": "origin/store-i18n"}, "main", False), + ("nested name", {**entry, "merged_into": "origin/feature/main"}, "main", False), ("no merged_into", {**entry, "merged_into": None}, "main", False)) + for name, candidate, default, free in cases: + with self.subTest(name=name): + self.assertEqual(signing.token_free(candidate, default), free) + + def test_without_the_token_the_dry_run_offers_the_token_free_branch_and_blocks_the_rest(self): + kept, done = self.items(self.tree(False, None)) + status, _, body = self.post_json("/api/sign", {"items": [kept]}, self.guard(self.host)) + self.assertEqual(status, 200, body) + self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(kept)]) + for items in ([done], [kept, done]): + with self.subTest(items=[item["branch"] for item in items]): + status, _, body = self.post_json("/api/sign", {"items": items}, self.guard(self.host)) + self.assertEqual((status, body.decode()), (400, "delete blocked: no GitHub token in the Secret Service\n")) + signer = self.pin() + proof = signer.proof(self.prepare([kept])["payload"], self.origin) + status, _, body = self.post_json("/api/cleanup", {"items": [kept], "proof": proof}, self.guard(self.host)) + self.assertEqual(status, 202, body) + + def test_on_the_real_tokenless_tree_the_ancestor_merged_local_branch_is_token_free_and_only_the_forge_free_one_is_offered(self): + root = self.write_fixture("branch-tree-tokenless.json") + status, _, body = self.get("/api/tree") + self.assertEqual(status, 200, body) + repos = {repo["name"]: {entry["name"]: entry for entry in repo["branches"]} for repo in json.loads(body)["repos"]} + shown = {name: {branch: (entry["token_free"], entry["cleanup"] is not None, entry["skip"]) for branch, entry in branches.items()} for name, branches in repos.items()} + self.assertEqual(shown, { + "github.com/anon/web": {"chore/ancestor-local": (True, False, "forge unavailable"), "fix/remote-merged": (False, False, "forge unavailable"), "feature/open": (False, False, "forge unavailable"), "main": (False, False, None)}, + "git.example.org/anon/notes": {"topic/ancestor-local": (True, True, None), "topic/patch-local": (False, True, None), "master": (False, False, None)}, + }) + notes = repos["git.example.org/anon/notes"] + free, patch = notes["topic/ancestor-local"]["cleanup"], notes["topic/patch-local"]["cleanup"] + self.assertEqual(free, signing.cleanup_item(f"{root}/repos/git.example.org/anon/notes", "topic/ancestor-local", "", "5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f")) + status, _, body = self.post_json("/api/sign", {"items": [free]}, self.guard(self.host)) + self.assertEqual(status, 200, body) + self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(free)]) + status, _, body = self.post_json("/api/sign", {"items": [patch]}, self.guard(self.host)) + self.assertEqual((status, body.decode()), (400, "delete blocked: no GitHub token in the Secret Service\n")) + + def test_with_the_token_every_merged_clean_branch_without_skip_reason_is_offered(self): + kept, done = self.items(self.tree(True, None)) + status, _, body = self.post_json("/api/sign", {"items": [kept, done]}, self.guard(self.host)) + self.assertEqual(status, 200, body) + self.assertEqual(sorted(json.loads(body)["payload"]["items"]), sorted(signing.item_hash(item) for item in (kept, done))) + kept, _ = self.items(self.tree(True, "ignored files beyond known caches: notes.txt")) + self.assertEqual(self.post_json("/api/sign", {"items": [kept]}, self.guard(self.host))[0], 400) + + def test_store_token_adds_one_operator_row_whose_command_stores_the_token_under_pkgmgrs_key(self): + status, _, body = self.post_json("/api/cleanup/token", {}, self.guard(self.host)) + self.assertEqual((status, json.loads(body)), (201, {"task": "operator/pkgmgr-github-token", "status": "queued"})) + self.assertEqual(len(self.scripts.calls), 1) + script, args = self.scripts.calls[0] + self.assertEqual((script, args[:4]), ("kvb-org-operator/scripts/operator.py", ["add", "pkgmgr-github-token", "--dir", "/"])) + command = args[args.index("--command") + 1] + self.assertEqual(shlex.split(command), ["env", "secret-tool", "store", "--label=pkgmgr-github", "service", "pkgmgr:github", "username", "github.com|-", " str: + return subprocess.run(["git", *args], cwd=cwd or self.repo, env={**os.environ, **GIT_ENV}, check=True, capture_output=True, text=True).stdout.strip() + + def tip(self, branch: str) -> str: + return self.git("rev-parse", f"refs/heads/{branch}") + + def branches(self) -> set[str]: + return set(self.git("for-each-ref", "refs/heads", "--format=%(refname:short)").split()) + + def run_job(self, names: list[str]) -> dict: + items = [{"repository": str(self.repo), "branch": name, "worktree": "", "tip": self.tip(name)} for name in names] + hashes = [signing.item_hash(signing.cleanup_item(**item)) for item in items] + proof = self.signer.proof(signing.approval_payload(hashes, secrets.token_hex(16), int(time.time()) + 600), ORIGIN) + job_id = secrets.token_hex(8) + (self.jobs / f"{job_id}.json").write_text(json.dumps({"v": 1, "id": job_id, "created": "x", "items": items, "proof": proof}), encoding="utf-8") + self.assertEqual(host.cycle(self.jobs, self.results, host.Cleanup(self.trust, self.tree, self.fake)), 1) + result = json.loads((self.results / f"{job_id}.json").read_text(encoding="utf-8")) + return {entry["branch"]: entry for entry in result["items"]} + + def test_the_executor_deletes_a_local_merged_branch_without_token_and_refuses_a_remote_one(self): + result = self.run_job(["local-merged", "remote-merged"]) + self.assertEqual(result["local-merged"]["status"], "done", result["local-merged"]["reason"]) + self.assertEqual(result["remote-merged"]["status"], "refused") + self.assertIn("a signed delete is local only", result["remote-merged"]["reason"]) + self.assertEqual(self.branches(), {"main", "remote-merged", "squash-candidate"}) + + def test_without_token_an_unmerged_squash_candidate_stays_and_with_the_token_a_plan_that_deletes_the_remote_branch_is_refused(self): + result = self.run_job(["squash-candidate"]) + self.assertEqual(result["squash-candidate"]["status"], "refused") + self.assertIn("not merged-clean", result["squash-candidate"]["reason"]) + data = json.loads(self.tree.read_text(encoding="utf-8")) + self.tree.write_text(json.dumps({**data, "delete_ready": True}), encoding="utf-8") + remote = self.run_job(["remote-merged"])["remote-merged"] + self.assertEqual((remote["status"], remote["reason"]), ("refused", "the plan also deletes remote-branch:origin; a signed delete is local only")) + self.assertEqual(self.branches(), {"main", "local-merged", "remote-merged", "squash-candidate"}) + + def test_without_token_pkgmgr_skips_every_item_of_a_github_repository_so_the_executor_refuses_and_keeps_even_the_token_free_branch(self): + self.git("remote", "set-url", "origin", "git@github.com:anon/repo.git") + result = self.run_job(["local-merged"])["local-merged"] + self.assertEqual((result["status"], result["reason"]), ("refused", "gc skip reason: forge unavailable")) + self.assertIn("local-merged", self.branches()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub_gates.py b/tests/test_hub_gates.py new file mode 100644 index 0000000..d83d43c --- /dev/null +++ b/tests/test_hub_gates.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import sqlite3 +import subprocess +import sys +import time +import unittest +from contextlib import closing +from pathlib import Path +from unittest import mock + +from hub_case import HubCase +from hub_support import GIT_ENV, REPO_ROOT, RUNNER, runner, signing + +GATES = REPO_ROOT / "skills" / "kvb-check-gate" / "scripts" / "gates.py" +GATES_SPEC = importlib.util.spec_from_file_location("kvb_gates_for_hub", GATES) +gates = importlib.util.module_from_spec(GATES_SPEC) +GATES_SPEC.loader.exec_module(gates) +REPO = "/home/op/Repositories/demo" + + +def rule(action: str, level: str | None, branch: str = "feature/x", repo: str = REPO) -> list[str]: + head = ["--repo", repo, "--branch", branch, "--action", action] + return ["rule", "drop", *head] if level is None else ["rule", "set", *head, "--level", level] + + +def parent(name: str, drop: bool = False, branch: str = "feature/x", repo: str = REPO) -> list[str]: + return ["parents", "drop" if drop else "add", "--repo", repo, "--branch", branch, "--parent", name] + + +class TestGateArgv(unittest.TestCase): + def test_exactly_rule_set_drop_and_parents_add_drop_of_one_branch_pass(self): + for argv in (rule("commit", "test"), rule("pr", "e2e"), rule("merge", None), parent("main"), parent("feature/base", drop=True), rule("push", "none", branch="fix/a-b_c.1")): + with self.subTest(argv=argv): + self.assertEqual(signing.gate_argv(argv), argv) + self.assertEqual(signing.gate_item(argv), {"kind": "gate", "argv": argv}) + + def test_every_other_argv_is_refused(self): + cases = { + "not a list": "rule set", + "no texts": ["rule", "set", 1], + "resolve": ["resolve", "--repo", REPO, "--branch", "b", "--action", "commit"], + "derive": ["parents", "derive", "--repo", REPO], + "receipt": ["receipt", "add", "--repo", REPO, "--tree", "a" * 40, "--level", "e2e", "--log", "/x"], + "show": ["show", "--json"], + "db flag": ["--db", "/tmp/x.db", *rule("commit", "test")], + "source flag": [*parent("main"), "--source", "recorded"], + "reordered": ["rule", "set", "--branch", "feature/x", "--repo", REPO, "--action", "commit", "--level", "test"], + "missing level": rule("commit", "test")[:-1], + "level on drop": [*rule("commit", None), "--level", "test"], + "unknown level": rule("commit", "strict"), + "unknown action": rule("deploy", "test"), + "relative repo": rule("commit", "test", repo="repo"), + "dotdot repo": rule("commit", "test", repo="/home/op/../root"), + "control repo": rule("commit", "test", repo="/home/op/\nrepo"), + "option branch": rule("commit", "test", branch="--level"), + "pattern branch": rule("commit", "test", branch="feature/*"), + "spaced branch": rule("commit", "test", branch="a b"), + "dotdot branch": rule("commit", "test", branch="a..b"), + "trailing slash": rule("commit", "test", branch="feature/"), + "own parent": parent("feature/x"), + "empty parent": parent(""), + } + for name, argv in cases.items(): + with self.subTest(name=name): + self.assertRaises(signing.Refused, signing.gate_argv, argv) + + +class TestHubGates(HubCase): + def stage(self, argvs: list[list[str]]) -> list[dict]: + return [{"kind": "gate", "argv": argv} for argv in argvs] + + def test_a_signed_gate_batch_becomes_one_runner_job_per_change(self): + items = self.stage([rule("commit", "test"), parent("main")]) + payload = self.prepare(items)["payload"] + self.assertEqual(sorted(payload["items"]), sorted(signing.item_hash(signing.gate_item(item["argv"])) for item in items)) + signer = self.pin() + proof = signer.proof(payload, self.origin) + self.assertEqual(self.post_json("/api/gates", {"items": items, "proof": None}, self.guard(self.host))[0], 400) + self.assertEqual(self.post_json("/api/gates", {"items": items, "proof": proof}, {"Host": self.host})[0], 403) + status, _, body = self.post_json("/api/gates", {"items": items, "proof": proof}, self.guard(self.host)) + self.assertEqual(status, 202, body) + self.assertEqual(len(json.loads(body)["jobs"]), 2) + jobs = self.runner_jobs() + self.assertEqual(sorted((job["kind"], job["argv"], job["command"].split()[0]) for job in jobs), sorted(("gate", item["argv"], "gates.py") for item in items)) + self.assertEqual(self.get("/api/gates")[0], 405) + + def test_the_hub_refuses_a_malformed_or_forged_gate_change_before_it_queues(self): + for name, items in (("receipt", self.stage([["receipt", "add", "--repo", REPO, "--tree", "a" * 40, "--level", "e2e", "--log", "/x"]])), ("own parent", self.stage([parent("feature/x")])), ("twice", self.stage([parent("main"), parent("main")])), ("extra key", [{"kind": "gate", "argv": parent("main"), "id": "x"}])): + with self.subTest(name=name): + status, _, body = self.post_json("/api/sign", {"items": items}, self.guard(self.host)) + self.assertEqual(status, 400, body) + signer = self.pin() + proof = signer.proof(self.prepare(self.stage([rule("commit", "test")]))["payload"], self.origin) + status, _, body = self.post_json("/api/gates", {"items": self.stage([rule("commit", "e2e")]), "proof": proof}, self.guard(self.host)) + self.assertEqual(status, 400, body) + self.assertEqual(self.runner_jobs(), []) + + +class TestRunnerGates(HubCase): + def setUp(self): + super().setUp() + self.home = self.outside / "home" + self.home.mkdir() + self.repo = self.outside / "repo" + subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)], env={**os.environ, **GIT_ENV}, check=True) + self.db = self.home / ".local" / "state" / "agents-council" / "gates.db" + + def run_gates_runner(self, flags: list[str]) -> None: + trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")] + command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, *flags, "--once"] + done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False, env={**os.environ, "HOME": str(self.home)}) + self.assertEqual(done.returncode, 0, done.stderr) + + def queue(self, argvs: list[list[str]]) -> list[str]: + items = [{"kind": "gate", "argv": argv} for argv in argvs] + proof = self.signed(items, self.pin()) + status, _, body = self.post_json("/api/gates", {"items": items, "proof": proof}, self.guard(self.host)) + self.assertEqual(status, 202, body) + return json.loads(body)["jobs"] + + def finished(self) -> list[tuple[str, int | None]]: + jobs = sorted(self.httpd.hub.jobs.listed(), key=lambda job: job["created_ts"]) + return [(job["status"], job["code"]) for job in jobs] + + def test_the_runner_writes_a_signed_rule_and_parent_into_the_default_gates_db_of_the_operator(self): + repo = str(self.repo.resolve()) + self.queue([rule("commit", "quality-high", branch="main", repo=repo), parent("main", branch="feature/x", repo=repo)]) + self.run_gates_runner(["--gates", str(GATES)]) + self.assertEqual(self.finished(), [("done", 0), ("done", 0)]) + with closing(sqlite3.connect(self.db)) as db: + self.assertEqual(gates.resolve(db, repo, "feature/x", "commit"), ("quality-high", "main")) + self.assertEqual(gates.parents_of(db, repo, "feature/x"), [("main", "operator")]) + self.assertEqual([call for call in self.scripts.calls if call[0].endswith("operator.py")], []) + + def test_without_gates_the_runner_refuses_and_writes_nothing(self): + self.queue([rule("commit", "test", repo=str(self.repo))]) + self.run_gates_runner([]) + jobs = self.httpd.hub.jobs.listed() + self.assertEqual([(job["status"], job["output"]) for job in jobs], [("refused", runner.UNGATED)]) + self.assertFalse(self.db.exists()) + + def test_the_runner_refuses_an_argv_that_differs_from_the_signed_one(self): + self.queue([rule("commit", "test", repo=str(self.repo))]) + job = self.httpd.hub.jobs.listed()[0] + self.httpd.hub.jobs.change(job["id"], {"argv": rule("commit", "none", repo=str(self.repo))}) + self.run_gates_runner(["--gates", str(GATES)]) + refused = self.httpd.hub.jobs.listed()[0] + self.assertEqual(refused["status"], "refused") + self.assertIn("does not list this item", refused["output"]) + self.assertFalse(self.db.exists()) + + def test_gate_refusal_checks_the_argv_shape_before_any_signature(self): + reason = runner.gate_refusal({"argv": ["receipt", "add"], "proof": None}, mock.Mock(), time.time()) + self.assertIn("gates.py rule set|drop or parents add|drop", reason) + with self.assertRaises(signing.Refused): + runner.gate({"argv": ["show", "--json"]}, GATES, print) + + +class TestHubGateView(HubCase): + def write_db(self, repo: str) -> Path: + db_path = self.kept / "gates.db" + db = gates.connect(db_path) + db.executemany("INSERT INTO rules VALUES (?, ?, ?, ?, ?)", [(repo, "main", "commit", "test", 0.0), (repo, "main", "merge", "quality-high", 0.0), (repo, "feature", "pr", "e2e", 0.0), (repo, "feature/kept", "merge", "quick", 0.0)]) + db.executemany("INSERT INTO parents VALUES (?, ?, ?, ?)", [(repo, "feature", "main", "recorded"), (repo, "feature/done", "feature", "derived"), (repo, "feature/done", "feature/kept", "operator"), (repo, "feature/kept", "main", "derived")]) + db.commit() + db.close() + return db_path + + def test_the_tree_carries_gates_py_show_json_for_every_branch_from_the_read_only_database(self): + root = self.write_tree() + repo = f"{root}/repos/github.com/me/demo" + db_path = self.write_db(repo) + before = db_path.read_bytes() + db_path.chmod(0o444) + self.kept.chmod(0o555) + self.addCleanup(self.kept.chmod, 0o755) + status, _, body = self.get("/api/tree") + self.assertEqual(status, 200, body) + shown = json.loads(body)["gates"] + self.assertEqual(sorted(shown[repo]), ["feature", "feature/done", "feature/kept", "main"]) + with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as db, mock.patch.object(gates, "branches", return_value={name: "0" * 40 for name in shown[repo]}): + self.assertEqual(shown[repo], gates.show(db, [repo])[repo]) + done = shown[repo]["feature/done"] + self.assertEqual(done["parents"], [{"parent": "feature", "source": "derived"}, {"parent": "feature/kept", "source": "operator"}]) + self.assertEqual(done["resolved"], {"commit": {"level": "test", "source": "main"}, "merge": {"level": "quality-high", "source": "main"}, "push": {"level": "none", "source": None}, "pr": {"level": "e2e", "source": "feature"}}) + self.assertEqual(db_path.read_bytes(), before) + self.assertEqual(sorted(path.name for path in self.kept.iterdir()), ["gates.db"]) + + def test_without_a_database_or_with_a_broken_one_the_tree_carries_no_gates(self): + self.write_tree() + self.assertEqual(json.loads(self.get("/api/tree")[2])["gates"], {}) + (self.kept / "gates.db").write_bytes(b"not sqlite") + self.assertEqual(json.loads(self.get("/api/tree")[2])["gates"], {}) + + def test_a_stored_level_outside_levels_or_an_older_gates_py_without_parents_of_keeps_the_tree_up_without_gates(self): + root = self.write_tree() + repo = f"{root}/repos/github.com/me/demo" + db = gates.connect(self.write_db(repo)) + db.execute("UPDATE rules SET level = 'bogus' WHERE branch = 'main' AND action = 'commit'") + db.commit() + status, _, body = self.get("/api/tree") + self.assertEqual((status, json.loads(body)["gates"] if status == 200 else body), (200, {})) + db.execute("UPDATE rules SET level = 'test' WHERE branch = 'main' AND action = 'commit'") + db.commit() + db.close() + self.assertEqual(json.loads(self.get("/api/tree")[2])["gates"][repo]["main"]["rules"]["commit"], "test") + older = self.kept.parent / "older-skills" + script = older / "kvb-check-gate" / "scripts" / "gates.py" + script.parent.mkdir(parents=True) + script.write_text('ACTIONS = ("commit",)\n\n\ndef resolve(db, repo, branch, action):\n return ("none", None)\n', encoding="utf-8") + self.scripts.skills = older + status, _, body = self.get("/api/tree") + self.assertEqual((status, json.loads(body)["gates"] if status == 200 else body), (200, {})) + + +if __name__ == "__main__": + unittest.main()