feat(orchestrator): W3 artifacts tree, ancestry, gates and token-free cleanup

Artifacts show the branch tree with ancestry and gate marks. The cleanup runner calls pkgmgr branch cleanup with its real argv and parses its repos[*].items output, refuses any plan with a remote-branch action, marks an item done only when pkgmgr reports it done, and offers no delete for a branch a worktree holds. Every text states what the pkgmgr command does to volumes. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W3 e2e never ran; the client switches to --local once pkgmgr ships it.

🎯 Fix: 90 %
💥 Break: 6 %
💸 Cost: 30 %
⏱️ Time: 40 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ⛔ break 6 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:15:14 +00:00
1 parent fd8fc08603
commit f7a59d43a5
33 files changed
+1998 -107

No files matched your search

+1 -1
View File
@@ -205,7 +205,7 @@ flowchart TD
### W3 Artifacts ### 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. passkey runs the signed job.
- [ ] Without a GitHub token, local branches merged by ancestry into the - [ ] Without a GitHub token, local branches merged by ancestry into the
default branch and without a remote branch can be deleted; the others default branch and without a remote branch can be deleted; the others
+65 -23
View File
@@ -16,12 +16,17 @@ Returns:
Runs once over every job in --jobs, as the operator on the host: per item 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, it verifies the operator's passkey signature over repository, branch,
worktree and tip, then in the repository runs `pkgmgr branch cleanup worktree and tip, then in the repository runs `pkgmgr branch cleanup
--json --local --branch <name>` as a dry run and, only when that plan --json --branch <name>` as a dry run and, only when that plan names
names exactly this branch as merged-clean without skip reason at the exactly this branch as merged-clean without skip reason at the signed
signed tip and worktree, `pkgmgr branch cleanup --apply --json --local tip and worktree and deletes no remote branch, `pkgmgr branch cleanup
--branch <name>`. Every other item is refused and nothing changes for --apply --json --branch <name>`. While the tree file says pkgmgr has no GitHub token
it. Writes each item's result, rewrites the tree file after a deletion (delete_ready false), only a branch the dry run shows merged by ancestry
and removes the job. The exit code is 0. 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 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]|^-|\.\.|@\{") UNSAFE = re.compile(r"[*?\[\]\\\s\x00-\x1f\x7f]|^-|\.\.|@\{")
OUTPUT_LIMIT = 16 << 10 OUTPUT_LIMIT = 16 << 10
TIMEOUT = 600 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 # 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 # pkgmgr releases before 4c8b137 run git branch -D without scan mode
GIT_OVERRIDES = ( GIT_OVERRIDES = (
@@ -62,12 +70,18 @@ def git_overrides() -> dict[str, str]:
return env 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: try:
data = json.loads(tree.read_text(encoding="utf-8")) 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): except (OSError, ValueError, KeyError, TypeError, AttributeError):
return set() return {}, False
def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]: 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:] 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: 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: try:
data = json.loads(stdout) data = json.loads(stdout)
except ValueError: except ValueError:
return None return None
items = data.get("items") if isinstance(data, dict) else data repos = data.get("repos") if isinstance(data, dict) else None
if not isinstance(items, list) or not all(isinstance(entry, dict) for entry in items): if not isinstance(repos, list) or not all(isinstance(repo, dict) and isinstance(repo.get("items"), list) for repo in repos):
return None 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: 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]}" return f"tip changed: signed {item['tip'][:12]}, now {str(tip)[:12]}"
if (entry.get("worktree") or "") != item["worktree"]: if (entry.get("worktree") or "") != item["worktree"]:
return f"worktree changed: signed {item['worktree'] or 'none'}, now {entry.get('worktree') or 'none'}" 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 return None
@@ -114,16 +148,20 @@ class Cleanup:
self.tree = tree self.tree = tree
self.pkgmgr = pkgmgr 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: """Args:
item: one signed item with repository, branch, worktree and tip. item: one signed item with repository, branch, worktree and tip.
proof: the job's {"payload", "assertion"}. 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. now: the current time in epoch seconds.
Returns: Returns:
The item with status done, refused or failed and the reason; 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: def refused(reason: str) -> dict:
return {**item, "status": "refused", "reason": reason} return {**item, "status": "refused", "reason": reason}
@@ -136,8 +174,7 @@ class Cleanup:
return refused("unknown repository: pkgmgr does not list it") return refused("unknown repository: pkgmgr does not list it")
if UNSAFE.search(item["branch"]) or not item["branch"]: 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") 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, plan_argv(item["branch"], False), item["repository"])
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", *selector], item["repository"])
plan = plan_items(stdout) if code == 0 else None plan = plan_items(stdout) if code == 0 else None
if plan is None: if plan is None:
return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}") return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}")
@@ -149,10 +186,15 @@ class Cleanup:
reason = mismatch(plan[0], item) reason = mismatch(plan[0], item)
if reason: if reason:
return refused(reason) return refused(reason)
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", "--apply", *selector], item["repository"]) if not ready and not signing.token_free(plan[0], known[item["repository"]]):
applied = plan_items(stdout) return refused(TOKEN_NEEDED)
status = "done" if code == 0 else "failed" code, stdout, stderr = run(self.pkgmgr, plan_argv(item["branch"], True), item["repository"])
return {**item, "status": status, "reason": "" if code == 0 else stderr.strip()[-500:], "code": code, "applied": applied if applied is not None else []} 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: def job(self, path: Path, now: float) -> dict:
try: try:
@@ -162,8 +204,8 @@ class Cleanup:
items = job.get("items") if isinstance(job, dict) else None 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: 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"} return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing deleted"}
known = known_repositories(self.tree) known, ready = tree_facts(self.tree)
results = [self.item(item, job.get("proof"), known, now) for item in items] results = [self.item(item, job.get("proof"), known, ready, now) for item in items]
result = {"id": path.stem, "finished": time.time(), "items": results} result = {"id": path.stem, "finished": time.time(), "items": results}
if any(entry["status"] == "done" for entry in 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)) code, _, stderr = run(self.pkgmgr, ["branch", "tree", "--format", "json", "--output", str(self.tree)], str(self.tree.parent))
+58 -10
View File
@@ -10,6 +10,9 @@
~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional. ~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional.
--council: the council directory holding rasci.json, the agents' --council: the council directory holding rasci.json, the agents'
AGENTS_COUNCIL_ROOT; optional, task changes need both. 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. --once: handle the queued jobs once and exit.
Returns: Returns:
@@ -18,9 +21,12 @@ Returns:
in the signed directory, each signature once and before its expiry. in the signed directory, each signature once and before its expiry.
Runs every signed task change (priority pin or WSJF estimate) as Runs every signed task change (priority pin or WSJF estimate) as
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the `python3 -I -B <rasci> <argv>` from / without a shell, after checking the
argv shape itself, and kills its process group after 30 s. Refuses task argv shape itself, and kills its process group after 30 s. Runs every
changes without --rasci and --council or into a council that is a signed gate change (rule set|drop, parents add|drop) the same way as
symlink or another uid's, and every other job. Posts the last 200 output lines `python3 -I -B <gates> <argv>` 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. 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" SAFE_PATH = "/usr/bin:/bin"
TASK_JOB = "task" TASK_JOB = "task"
TASK_LIMIT = 30.0 TASK_LIMIT = 30.0
GATE_JOB = "gate"
REFUSED = "refused: no valid operator signature for this command" 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" 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): class Register(NamedTuple):
@@ -113,6 +121,24 @@ def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
return 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: def council_refusal(council: Path) -> str | None:
"""Args: """Args:
council: the directory rasci.py writes the register into. 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) 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: def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int:
"""Args: """Args:
argv: the program and its arguments, run without a shell. 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 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"]: for job in call(base, "/api/runner/jobs", None)["jobs"]:
try: try:
claimed = call(base, f"/api/runner/{job['id']}/claim", {}) claimed = call(base, f"/api/runner/{job['id']}/claim", {})
except urllib.error.HTTPError: except urllib.error.HTTPError:
continue continue
send = sender(base, claimed["id"]) send = sender(base, claimed["id"])
task = claimed.get("kind") == TASK_JOB kind = claimed.get("kind")
if not task: if kind == TASK_JOB:
reason = refusal(claimed, trust, time.time())
else:
reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time()) 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: if reason is not None:
send({"output": reason, "code": None, "refused": True}) send({"output": reason, "code": None, "refused": True})
elif task: elif kind == TASK_JOB:
change(claimed, register, send) change(claimed, register, send)
elif kind == GATE_JOB:
gate(claimed, gates, send)
else: else:
execute(claimed, send) execute(claimed, send)
@@ -252,6 +299,7 @@ def main() -> int:
parser.add_argument("--replay", type=Path, required=True) parser.add_argument("--replay", type=Path, required=True)
parser.add_argument("--rasci", type=Path) parser.add_argument("--rasci", type=Path)
parser.add_argument("--council", type=Path) parser.add_argument("--council", type=Path)
parser.add_argument("--gates", type=Path)
parser.add_argument("--once", action="store_true") parser.add_argument("--once", action="store_true")
args = parser.parse_args() args = parser.parse_args()
base = hub_api.base_url(args.port) 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 register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None
while True: while True:
try: try:
cycle(base, trust, register) cycle(base, trust, register, args.gates)
except (OSError, ValueError, KeyError, TypeError): except (OSError, ValueError, KeyError, TypeError):
pass pass
if args.once: if args.once:
+71
View File
@@ -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") FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
ESTIMATOR = "Operator" ESTIMATOR = "Operator"
COMMANDS = "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}(?<![/.])")
TOKEN_FREE_MERGE = "ancestor"
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"} APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"} RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
WARNINGS = ( WARNINGS = (
@@ -184,6 +189,72 @@ def task_argv(task: object, argv: object) -> list[str]:
raise Refused("the argv is no task priority or WSJF estimate change of this task") 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: def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires} return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
@@ -9,6 +9,7 @@ from .cleanup import cleanup_request
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
from .errors import Conflict, Missing, ToolError from .errors import Conflict, Missing, ToolError
from .fields import decode, expect, field from .fields import decode, expect, field
from .gating import gate_request
from .offices import office_request from .offices import office_request
from .records import digest, shown from .records import digest, shown
from .register import REGISTER, adder, operator_rows, read_register, runnable from .register import REGISTER, adder, operator_rows, read_register, runnable
@@ -250,6 +251,8 @@ class Approving:
signed = self.appointable(raw) signed = self.appointable(raw)
elif task_request(raw): elif task_request(raw):
signed = self.task_changes(raw) signed = self.task_changes(raw)
elif gate_request(raw):
signed = self.gate_changes(raw)
else: else:
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None] signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
return self.challenge_for(signed) return self.challenge_for(signed)
+3 -1
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .records import epoch, iso_epoch, read_json, shown from .records import epoch, iso_epoch, read_json, shown
from .register import ENDED, status_of from .register import ENDED, status_of
from .signed import signing
from .tree import inside, owns from .tree import inside, owns
PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b") 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]) shared = sorted(files[a] & files[b])
if shared: if shared:
overlaps.append({"a": a, "b": b, "count": len(shared), "files": shared[:SHARED_SHOWN]}) 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}) 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)] 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} return {**scan, "repos": repos, "tasks": loose}
+28 -4
View File
@@ -11,6 +11,8 @@ from pathlib import Path
from .errors import Missing, ToolError from .errors import Missing, ToolError
from .fields import decode, expect from .fields import decode, expect
from .records import read_json, stamp from .records import read_json, stamp
from .register import OPERATOR
from .skill_scripts import OPERATOR_SCRIPT
from .tree import offered from .tree import offered
ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip") ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip")
@@ -18,6 +20,10 @@ JOB_KEYS = ("repository", "branch", "worktree", "tip")
JOB_VERSION = 1 JOB_VERSION = 1
ITEM_LIMIT = 100 ITEM_LIMIT = 100
RESULTS_SHOWN = 20 RESULTS_SHOWN = 20
TOKEN_ROW = "pkgmgr-github-token"
TOKEN_COMMAND = "env secret-tool store --label=pkgmgr-github service pkgmgr:github username 'github.com|-' </dev/tty"
TOKEN_REASON = "Store the GitHub token in the Secret Service under the key pkgmgr reads; Clean all merged then also deletes remote and squash-merged branches."
TOKEN_ADDER = "Operator"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -65,19 +71,24 @@ class Cleaning:
Returns: Returns:
The signed items, each still a merged-clean branch without a gc The signed items, each still a merged-clean branch without a gc
skip reason at the same tip in the current tree file. skip reason at the same tip in the current tree file; without
the GitHub token (delete_ready false) each also token-free.
""" """
if not isinstance(raw, list) or not 1 <= len(raw) <= ITEM_LIMIT: if not isinstance(raw, list) or not 1 <= len(raw) <= ITEM_LIMIT:
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects") raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
scan = self.tree.get() scan = self.tree.get()
if scan.get("delete_ready") is not True: ready = scan.get("delete_ready") is True
raise ToolError(f"delete blocked: {scan.get('delete_reason') or scan.get('error') or 'delete_ready is false'}") if scan.get("error"):
known = offered(scan) raise ToolError(f"delete blocked: {scan['error']}")
known = offered(scan, ready)
locked = [] if ready else offered(scan, True)
chosen = [] chosen = []
for entry in raw: for entry in raw:
if not isinstance(entry, dict): if not isinstance(entry, dict):
raise ToolError("every item must be an object with kind, repository, branch, worktree and tip") raise ToolError("every item must be an object with kind, repository, branch, worktree and tip")
expect(entry, ITEM_KEYS) expect(entry, ITEM_KEYS)
if entry in locked and entry not in known:
raise ToolError(f"delete blocked: {scan.get('delete_reason') or 'delete_ready is false'}")
if entry not in known: if entry not in known:
raise ToolError(f"{entry.get('branch')!s:.200} in {entry.get('repository')!s:.200} is no merged-clean branch without skip reason at that tip in the tree file") raise ToolError(f"{entry.get('branch')!s:.200} in {entry.get('repository')!s:.200} is no merged-clean branch without skip reason at that tip in the tree file")
if entry in chosen: if entry in chosen:
@@ -97,6 +108,19 @@ class Cleaning:
self.log.add({"kind": "run", "title": f"Delete signed: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}}) self.log.add({"kind": "run", "title": f"Delete signed: {names}"[:200], "text": "\n".join(f"{item['repository']} {item['branch']} @ {item['tip'][:12]}" for item in items), "ref": {"cleanup": job_id, "status": "queued"}})
return {"id": job_id, "status": "queued", "items": job["items"]} return {"id": job_id, "status": "queued", "items": job["items"]}
def token_row(self, body: bytes) -> 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: def cleanup_state(self, job_id: str) -> dict:
result = read_json(self.spool.results / f"{job_id}.json") result = read_json(self.spool.results / f"{job_id}.json")
if isinstance(result, dict) and isinstance(result.get("items"), list): if isinstance(result, dict) and isinstance(result.get("items"), list):
+131
View File
@@ -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"}
+7 -1
View File
@@ -40,6 +40,8 @@ CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
OFFICES_ROUTE = "/api/offices" OFFICES_ROUTE = "/api/offices"
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})") OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
TASKS_ROUTE = "/api/tasks" TASKS_ROUTE = "/api/tasks"
GATES_ROUTE = "/api/gates"
TOKEN_ROUTE = "/api/cleanup/token"
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})") VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
SCRIPT_TYPE = "text/javascript; charset=utf-8" SCRIPT_TYPE = "text/javascript; charset=utf-8"
FONT_TYPE = "font/woff2" FONT_TYPE = "font/woff2"
@@ -57,7 +59,7 @@ SETTINGS_SIGN_ROUTE = "/api/settings/sign"
CHOICES_ROUTE = "/api/choices" CHOICES_ROUTE = "/api/choices"
CHOICES_SIGN_ROUTE = "/api/choices/sign" CHOICES_SIGN_ROUTE = "/api/choices/sign"
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE) 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): class Handler(Exchange):
@@ -168,6 +170,10 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED) return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
if path == TASKS_ROUTE: if path == TASKS_ROUTE:
return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED) 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: if inbox:
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK) return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo: if undo:
+2 -1
View File
@@ -16,6 +16,7 @@ from .cleanup import Cleaning, Spool
from .denying import Denying from .denying import Denying
from .engines import Engines, Speech from .engines import Engines, Speech
from .events import Log, Watch from .events import Log, Watch
from .gating import Gating
from .icons import sheet from .icons import sheet
from .inbox import Inbox from .inbox import Inbox
from .jobs import Jobs from .jobs import Jobs
@@ -65,7 +66,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True) @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 council: Path
pages: Path pages: Path
public_port: int public_port: int
+7 -5
View File
@@ -12,15 +12,17 @@ JOB_OPEN = ("queued", "running")
RUN_OUTPUT = 64 << 10 RUN_OUTPUT = 64 << 10
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished") CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
TASK_JOB = "task" TASK_JOB = "task"
GATE_JOB = "gate"
CHANGES = (TASK_JOB, GATE_JOB)
def runner_job(job: dict) -> dict: def runner_job(job: dict) -> dict:
"""Returns: """Returns:
What the runner claims: an operator command, or a task change with What the runner claims: an operator command, or a task or gate change
kind and argv; a runner that predates task changes verifies the with kind and argv; a runner that predates such changes verifies them
latter as an operator command and refuses it. 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")} 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()}) job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()})
if not changed: if not changed:
raise Conflict({"id": job_id, "status": job["status"]}) 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 [] runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
tail = "\n".join(output.splitlines()[-20:]) 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}}) self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
@@ -17,6 +17,8 @@ ESTIMATE = "kvb-org-queue-estimate/scripts/estimate.py"
class Runner(Protocol): class Runner(Protocol):
skills: Path
def run(self, script: str, args: list[str]) -> dict: ... def run(self, script: str, args: list[str]) -> dict: ...
+1 -1
View File
@@ -49,7 +49,7 @@ class Sources:
reviews = self.reviews.listed() reviews = self.reviews.listed()
scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews) scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews)
history = read_jsonl(self.council / "queue-history.jsonl") 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]: 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} beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
+37 -14
View File
@@ -18,6 +18,8 @@ FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}")
INVALID = "invalid" INVALID = "invalid"
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age # renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
TREE_HEARTBEAT = "tree-heartbeat" 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: 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]: def dry_run(item: dict) -> list[str]:
"""Returns: """Returns:
The lines `pkgmgr branch cleanup --local` would act on for one tree What the runner's `pkgmgr branch cleanup` does for one tree item,
item, read from the gc plan the tree file carries; a remote branch read from the gc plan the tree file carries: it deletes the local
always stays. 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] tip = (item["local_sha"] or item["head"] or "")[:12]
merged = f", merged by {item['merged_by']}" if item["merged_by"] else "" merged = f", merged by {item['merged_by']}" if item["merged_by"] else ""
lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"] 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"]: for action in item["actions"]:
if action == "worktree": if action == "local-branch":
lines.append(f"removes the worktree {item['worktree']}")
elif action == "local-branch":
lines.append(f"deletes the local branch {item['branch']}") lines.append(f"deletes the local branch {item['branch']}")
elif action.startswith("remote"): 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: else:
lines.append(action) 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 return lines
def branch(item: dict, path: str) -> dict: def branch(item: dict, path: str, default: object) -> dict:
remote = next(iter(item["remotes"].values()), "") remote = next(iter(item["remotes"].values()), "")
head = item["local_sha"] or item["head"] or remote 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 { return {
"held": held,
"token_free": signing.token_free(item, default),
"name": item["branch"], "name": item["branch"],
"head": head, "head": head,
"upstream": item["upstream"] or "", "upstream": item["upstream"] or "",
@@ -116,13 +131,21 @@ def repo(entry: dict) -> dict:
return { return {
**{key: value for key, value in entry.items() if key != "items"}, **{key: value for key, value in entry.items() if key != "items"},
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]), "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"]], "detached": [worktree(item) for item in items if item["detached"]],
} }
def offered(scan: dict) -> list[dict]: def offered(scan: dict, ready: bool) -> list[dict]:
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None] """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: def read_tree(path: Path) -> dict:
@@ -5,7 +5,7 @@ Description=Agent Orchestrator runner: runs the operator commands a pinned passk
Type=simple Type=simple
Environment=PATH=/usr/bin:/bin Environment=PATH=/usr/bin:/bin
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env 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 Restart=always
RestartSec=5 RestartSec=5
+97
View File
@@ -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;
}
+6
View File
@@ -413,6 +413,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "style-tasks.css" %} {% include "style-tasks.css" %}
{% include "style-bubbles.css" %} {% include "style-bubbles.css" %}
{% include "style-ancestry.css" %}
</style> </style>
</head> </head>
<body> <body>
@@ -457,6 +459,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "nav.js" %} {% include "nav.js" %}
{% include "ancestry.js" %}
{% include "popup.js" %} {% include "popup.js" %}
{% include "faces.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-cleanup.js" %}
{% include "page-ancestry.js" %}
{% include "page-offices.js" %} {% include "page-offices.js" %}
{% include "page-tasks.js" %} {% include "page-tasks.js" %}
+167
View File
@@ -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);
}
});
+5 -3
View File
@@ -224,7 +224,7 @@
body.push(meta([["Path", data.path], ["Remote", data.remote], ["Default", data.default], ["Forge", data.forge], ["As of", data.scanned_at]]), 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`)); 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") { } 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)), 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))); h("h3", {}, "Dry run"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") { } else if (node.kind === "worktree") {
@@ -256,13 +256,14 @@
drawArtifacts(); drawArtifacts();
}}}) : null; }}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : 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(() => { window.setTimeout(() => {
drawArtifacts(); drawArtifacts();
fetchTree(); fetchTree();
}, 0); }, 0);
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker, 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); const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox);
return h("div", {class: "graph"}, controls, body); return h("div", {class: "graph"}, controls, body);
} }
@@ -296,6 +297,7 @@
drawTree(); drawTree();
return; return;
} }
tree.dom.apply.replaceChildren(gateApply() || "");
const empty = !tree.data || tree.data.error; 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))}`; tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) { if (empty) {
+7 -5
View File
@@ -8,14 +8,16 @@
return {repo, entry}; return {repo, entry};
} }
function deleteDialog(items) { function deleteDialog(items, locked = [], held = []) {
if (!items.length) { if (!items.length) {
return; return;
} }
const rows = items.map((item) => { const rows = [...items, ...locked, ...held].map((item) => {
const {repo, entry} = planOf(item); const {repo, entry} = planOf(item);
return h("li", {class: "dry-run"}, const waits = locked.includes(item);
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch)), 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"))); h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
}); });
const outcome = h("div", {class: "mini", "aria-live": "polite"}); 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}`, [ 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), h("ul", {class: "mini", "aria-label": "Dry run"}, rows),
outcome, outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm), h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
+1
View File
@@ -117,5 +117,6 @@
const running = all.filter((entry) => entry.running).length; const running = all.filter((entry) => entry.running).length;
spawn.dom.list.replaceChildren( 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`)), 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))); h("ul", {class: "spawn-tree root"}, spawnItem(root)));
} }
+31 -11
View File
@@ -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) { function deleteButton(entry) {
if (!entry.cleanup) { if (entry.held) {
return null; 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 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); const button = iconBtn("bi-trash3", "Delete", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
button.disabled = !ready; 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() { function deleteAll() {
const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup); const branches = (tree.data.repos || []).flatMap((repo) => repo.branches);
const ready = tree.data.delete_ready === true && items.length > 0; const items = branches.filter(deletableNow).map((entry) => entry.cleanup);
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 waiting = branches.filter(tokenLocked);
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items), hint); 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; 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) { function leafRow(found) {
@@ -82,7 +101,7 @@
const result = resultOf(repo, entry); const result = resultOf(repo, entry);
return h("div", {class: "branch-row"}, 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("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)); 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.")); 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; return;
} }
tree.dom.removeAll.replaceChildren(deleteAll()); tree.dom.removeAll.replaceChildren(deleteAll(), gateApply() || "");
const q = tree.q.trim().toLowerCase(); const q = tree.q.trim().toLowerCase();
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q)); const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
const repos = []; const repos = [];
@@ -124,6 +143,7 @@
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} branches`), counter(`${trees.length} worktrees`), 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, 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), 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)), 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); detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "detached HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => { details.addEventListener("toggle", () => {
+17
View File
@@ -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; }
+273
View File
@@ -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
}
]
}
]
}
+6 -6
View File
@@ -1801,7 +1801,7 @@ class TestOrchestratorE2E(HubCase):
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"]) self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"]) self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
artifacts = by_hash["#artifacts/tree"] 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"): 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): with self.subTest(state=state):
self.assertIn(state, artifacts["states"]) 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): 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 = json.loads(self.tree_file.read_text(encoding="utf-8"))
tree["delete_ready"], tree["delete_reason"] = True, "" 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") self.tree_file.write_text(json.dumps(tree), encoding="utf-8")
demo = f"{self.root}/repos/github.com/me/demo" demo = f"{self.root}/repos/github.com/me/demo"
Path(demo).mkdir(parents=True) Path(demo).mkdir(parents=True)
@@ -2115,8 +2116,7 @@ class TestOrchestratorE2E(HubCase):
fake.parent.mkdir() fake.parent.mkdir()
fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8") fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8")
fake.chmod(0o755) 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": None, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None, "actions": ["local-branch"]}]), encoding="utf-8")
(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")
following = json.loads(json.dumps(tree)) following = json.loads(json.dumps(tree))
following["repos"][0]["items"] = [item for item in following["repos"][0]["items"] if item["branch"] != "feature/done"] 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") (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(opened["title"], "Delete feature/done")
self.assertEqual(len(opened["dry"]), 1) self.assertEqual(len(opened["dry"]), 1)
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12"), opened["dry"]) 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["result"], confirmed["text"]), ("done", "feature/donedeleted"), confirmed)
self.assertEqual(confirmed["toasts"], ["Delete: donefeature/done: deleted"]) self.assertEqual(confirmed["toasts"], ["Delete: donefeature/done: deleted"])
self.assertEqual(after["past"], ["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")]]) 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()] calls = [json.loads(line)["argv"] for line in (fake.parent / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
self.assertEqual(calls, [ self.assertEqual(calls, [
["branch", "cleanup", "--json", "--local", "--branch", "feature/done"], ["branch", "cleanup", "--json", "--branch", "feature/done"],
["branch", "cleanup", "--apply", "--json", "--local", "--branch", "feature/done"], ["branch", "cleanup", "--apply", "--json", "--branch", "feature/done"],
["branch", "tree", "--format", "json", "--output", str(self.tree_file)], ["branch", "tree", "--format", "json", "--output", str(self.tree_file)],
]) ])
+215
View File
@@ -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()
+7 -1
View File
@@ -232,8 +232,14 @@ class HubCase(unittest.TestCase):
path.write_text(text, encoding="utf-8") path.write_text(text, encoding="utf-8")
def write_tree(self) -> Path: 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" 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") self.tree_file.write_text(sample.replace("@ROOT@", str(root)), encoding="utf-8")
return root return root
+2
View File
@@ -83,6 +83,8 @@ ESTIMATE = REPO_ROOT / "skills" / "kvb-org-queue-estimate" / "scripts" / "estima
class FakeScripts: class FakeScripts:
skills = REPO_ROOT / "skills"
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, list[str]]] = [] self.calls: list[tuple[str, list[str]]] = []
self.code = 0 self.code = 0
+144
View File
@@ -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'<symbol id="{name}"', html)
if __name__ == "__main__":
unittest.main()
+10
View File
@@ -69,6 +69,16 @@ class TestHubIcons(HubCase):
class TestHubArtifacts(HubCase): class TestHubArtifacts(HubCase):
def test_a_merge_pkgmgr_names_into_the_remote_tracking_default_points_at_the_default_branch_node(self):
self.write_fixture("branch-tree-tokenless.json")
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
merges = {repo["name"]: repo["merges"] for repo in json.loads(body)["repos"]}
self.assertEqual(merges, {
"github.com/anon/web": [{"from": "chore/ancestor-local", "into": "main", "by": "ancestor"}, {"from": "fix/remote-merged", "into": "main", "by": "ancestor"}],
"git.example.org/anon/notes": [{"from": "topic/ancestor-local", "into": "master", "by": "ancestor"}, {"from": "topic/patch-local", "into": "master", "by": "patch"}],
})
def test_the_tree_carries_artefact_leaves_overlaps_merges_and_lanes(self): def test_the_tree_carries_artefact_leaves_overlaps_merges_and_lanes(self):
root = self.write_tree() root = self.write_tree()
demo = f"{root}/worktrees/github.com/me/demo" demo = f"{root}/worktrees/github.com/me/demo"
+131 -19
View File
@@ -1,8 +1,11 @@
from __future__ import annotations from __future__ import annotations
import ast
import importlib.util import importlib.util
import json import json
import os
import secrets import secrets
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -11,16 +14,18 @@ import unittest
from pathlib import Path from pathlib import Path
from hub_case import HubCase from hub_case import HubCase
from hub_support import HUB, ORIGIN, key, signing, verifier from hub_support import GIT_ENV, HUB, ORIGIN, key, signing, verifier
CLEANUP = HUB / "client" / "cleanup.py" CLEANUP = HUB / "client" / "cleanup.py"
CLEANUP_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_cleanup_runner", CLEANUP) CLEANUP_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_cleanup_runner", CLEANUP)
host = importlib.util.module_from_spec(CLEANUP_SPEC) host = importlib.util.module_from_spec(CLEANUP_SPEC)
CLEANUP_SPEC.loader.exec_module(host) CLEANUP_SPEC.loader.exec_module(host)
INSTALLED_PKGMGR = shutil.which("pkgmgr")
DONE_TIP = "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4" DONE_TIP = "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4"
OTHER_TIP = "1111111111111111111111111111111111111111" OTHER_TIP = "1111111111111111111111111111111111111111"
# Param: format with python=<interpreter>; plan.json beside it is the dry-run plan, next-tree.json the rewritten tree. # Param: format with python=<interpreter>; 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} FAKE_PKGMGR = """#!{python}
import argparse
import json import json
import os import os
import sys import sys
@@ -29,22 +34,48 @@ from pathlib import Path
here = Path(__file__).parent here = Path(__file__).parent
with (here / "calls.jsonl").open("a", encoding="utf-8") as log: with (here / "calls.jsonl").open("a", encoding="utf-8") as log:
log.write(json.dumps({{"argv": sys.argv[1:], "cwd": os.getcwd()}}) + "\\n") 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" following = here / "next-tree.json"
rewritten = following.read_text(encoding="utf-8") if following.exists() else json.dumps({{"rewritten": True}}) 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) 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): 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() root = self.write_tree()
data = json.loads(self.tree_file.read_text(encoding="utf-8")) data = json.loads(self.tree_file.read_text(encoding="utf-8"))
data["delete_ready"], data["delete_reason"] = ready, "" if ready else "Secret Service locked" 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") 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: def branches(self) -> dict:
status, _, body = self.get("/api/tree") status, _, body = self.get("/api/tree")
@@ -57,27 +88,24 @@ class TestHubCleanup(HubCase):
return status, body return status, body
def test_the_tree_offers_a_delete_only_for_merged_clean_branches_without_skip_reason(self): 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() branches = self.branches()
done = branches["feature/done"] done = branches["feature/done"]
self.assertEqual(done["cleanup"], item) self.assertEqual((done["cleanup"], done["held"]), (item, ""))
self.assertEqual(done["plan"], [ self.assertEqual(done["plan"], [
"feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12", "feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12",
f"removes the worktree {item['worktree']}",
"deletes the local branch feature/done", "deletes the local branch feature/done",
"stays: remote-branch (local only)", "stays: remote-branch (local only; the host refuses a plan that deletes it)",
"stops the Compose stack demo-feature-done without -v",
"removes the image demo:feature-done",
]) ])
self.assertEqual((done["merged_by"], done["merged_into"], done["gone"]), ("#12", "main", True)) 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({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") 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): 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]) status, body = self.sign([item])
self.assertEqual((status, body.decode()), (400, "delete blocked: Secret Service locked\n")) 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"} 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 /"}])): 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): with self.subTest(name=name):
@@ -86,8 +114,37 @@ class TestHubCleanup(HubCase):
self.assertEqual(status, 200, body) self.assertEqual(status, 200, body)
self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(item)]) 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): def test_a_signed_delete_becomes_a_job_without_any_command(self):
item = self.ready(True) item = self.ready(True, False)
signer = self.pin() signer = self.pin()
proof = signer.proof(self.prepare([item])["payload"], self.origin) 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) 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.calls = self.fake.parent / "calls.jsonl"
self.tree = base / "tree" / "branch-tree.json" self.tree = base / "tree" / "branch-tree.json"
self.tree.parent.mkdir() 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.approvers = base / "config" / "approvers.json"
self.trust = verifier.Trust(approvers=self.approvers, replay=base / "replay" / "cleanup.json", origin=ORIGIN) self.trust = verifier.Trust(approvers=self.approvers, replay=base / "replay" / "cleanup.json", origin=ORIGIN)
self.signer = key(-7, "main") 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): 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()]) 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", "")]) 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, [ self.assertEqual(calls, [
{"argv": ["branch", "cleanup", *selector], "cwd": str(self.repo)}, {"argv": ["branch", "cleanup", *selector], "cwd": str(self.repo)},
{"argv": ["branch", "cleanup", "--apply", *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(json.loads(self.tree.read_text(encoding="utf-8")), {"rewritten": True})
self.assertEqual(result["tree"], {"code": 0, "error": ""}) 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): def test_every_doubtful_item_is_refused_and_changes_nothing(self):
pattern = {**self.item, "branch": "feature/*"} pattern = {**self.item, "branch": "feature/*"}
option = {**self.item, "branch": "--all"} 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), ("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), ("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), ("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: for name, items, proof, plan, reason, dry_runs in cases:
with self.subTest(name=name): with self.subTest(name=name):
result, calls = self.run_job(items, proof, plan) result, calls = self.run_job(items, proof, plan)
self.assertEqual([entry["status"] for entry in result["items"]], ["refused"]) self.assertEqual([entry["status"] for entry in result["items"]], ["refused"])
self.assertIn(reason, result["items"][0]["reason"]) 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) self.assertNotIn("tree", result)
def test_a_signature_deletes_once_and_a_malformed_job_nothing(self): 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) 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): class TestHostCleanupGitConfig(unittest.TestCase):
def test_pkgmgr_runs_no_hook_the_repository_config_names(self): def test_pkgmgr_runs_no_hook_the_repository_config_names(self):
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
+241
View File
@@ -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=<interpreter>; 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:<remote> (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|-", "</dev/tty"])
self.assertEqual(args[args.index("--check") + 1], "manual")
self.assertIsNone(operator_script.unrunnable(command, "/"))
self.assertEqual(self.post_json("/api/cleanup/token", {"extra": 1}, self.guard(self.host))[0], 400)
self.assertEqual(self.post_json("/api/cleanup/token", {}, {"Host": self.host})[0], 403)
self.assertEqual(self.get("/api/cleanup/token")[0], 405)
class TestHostCleanupWithoutToken(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
base = Path(tmp.name)
self.repo = base / "repo"
self.git("init", "-q", "-b", "main", str(self.repo), cwd=base)
self.git("commit", "-q", "--allow-empty", "-m", "one")
self.git("branch", "local-merged")
self.git("branch", "remote-merged")
self.git("branch", "squash-candidate")
self.git("commit", "-q", "--allow-empty", "-m", "two")
self.git("checkout", "-q", "squash-candidate")
self.git("commit", "-q", "--allow-empty", "-m", "side")
self.git("checkout", "-q", "main")
self.git("remote", "add", "origin", "ssh://git@git.example.org/anon/repo.git")
self.git("update-ref", "refs/remotes/origin/main", self.tip("main"))
self.git("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main")
self.git("update-ref", "refs/remotes/origin/remote-merged", self.tip("remote-merged"))
self.jobs, self.results = base / "jobs", base / "results"
self.jobs.mkdir()
self.results.mkdir()
self.fake = base / "bin" / "pkgmgr"
self.fake.parent.mkdir()
self.fake.write_text(SCRATCH_PKGMGR.format(python=sys.executable), encoding="utf-8")
self.fake.chmod(0o755)
self.tree = base / "tree" / "branch-tree.json"
self.tree.parent.mkdir()
self.tree.write_text(json.dumps({"delete_ready": False, "delete_reason": "no GitHub token in the Secret Service", "repos": [{"path": str(self.repo), "known": True, "default": "main"}]}), 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")
self.signer.pin(self.approvers, ORIGIN)
def git(self, *args: str, cwd: Path | None = None) -> 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()
+221
View File
@@ -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()