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

+65 -23
View File
@@ -16,12 +16,17 @@ Returns:
Runs once over every job in --jobs, as the operator on the host: per item
it verifies the operator's passkey signature over repository, branch,
worktree and tip, then in the repository runs `pkgmgr branch cleanup
--json --local --branch <name>` as a dry run and, only when that plan
names exactly this branch as merged-clean without skip reason at the
signed tip and worktree, `pkgmgr branch cleanup --apply --json --local
--branch <name>`. Every other item is refused and nothing changes for
it. Writes each item's result, rewrites the tree file after a deletion
and removes the job. The exit code is 0.
--json --branch <name>` as a dry run and, only when that plan names
exactly this branch as merged-clean without skip reason at the signed
tip and worktree and deletes no remote branch, `pkgmgr branch cleanup
--apply --json --branch <name>`. While the tree file says pkgmgr has no GitHub token
(delete_ready false), only a branch the dry run shows merged by ancestry
into the default branch and without a remote branch is deleted; the
others are refused with "token needed". Every other item is refused and
nothing changes for it. An applied item is done only when pkgmgr's
result for it is done; a skipped one or one without result is failed
with pkgmgr's reason. Writes each item's result, rewrites the tree file
after a deletion and removes the job. The exit code is 0.
"""
from __future__ import annotations
@@ -43,6 +48,9 @@ TIP = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
UNSAFE = re.compile(r"[*?\[\]\\\s\x00-\x1f\x7f]|^-|\.\.|@\{")
OUTPUT_LIMIT = 16 << 10
TIMEOUT = 600
DONE = "done"
REMOTE_ACTION = "remote-branch:"
TOKEN_NEEDED = "token needed: without the GitHub token only a local branch merged by ancestry into the default branch and without a remote branch is deleted"
# the cwd is a repository agents can write: its config may name hooks, and
# pkgmgr releases before 4c8b137 run git branch -D without scan mode
GIT_OVERRIDES = (
@@ -62,12 +70,18 @@ def git_overrides() -> dict[str, str]:
return env
def known_repositories(tree: Path) -> set[str]:
def tree_facts(tree: Path) -> tuple[dict[str, str], bool]:
"""Returns:
The repositories the tree file says pkgmgr knows, each with its
default branch, and whether pkgmgr found the GitHub token
(delete_ready); nothing and False for an unreadable file.
"""
try:
data = json.loads(tree.read_text(encoding="utf-8"))
return {entry["path"] for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)}
known = {entry["path"]: entry.get("default") if isinstance(entry.get("default"), str) else "" for entry in data["repos"] if entry.get("known") is True and isinstance(entry.get("path"), str)}
return known, data.get("delete_ready") is True
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return set()
return {}, False
def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]:
@@ -78,15 +92,32 @@ def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]:
return done.returncode, done.stdout, done.stderr[-OUTPUT_LIMIT:]
def plan_argv(branch: str, apply: bool) -> list[str]:
"""Args:
branch: the exact branch name.
apply: execute the plan instead of the dry run.
Returns:
The `pkgmgr branch cleanup` arguments for this branch's plan in JSON.
"""
return ["branch", "cleanup", *(["--apply"] if apply else []), "--json", "--branch", branch]
def plan_items(stdout: str) -> list[dict] | None:
"""Returns:
Every item of `pkgmgr branch cleanup --json`, which prints
{"host_pid_namespace", "repos": [{"repo", "default", "merged_into",
"warnings", "items"}]}; None for any other output.
"""
try:
data = json.loads(stdout)
except ValueError:
return None
items = data.get("items") if isinstance(data, dict) else data
if not isinstance(items, list) or not all(isinstance(entry, dict) for entry in items):
repos = data.get("repos") if isinstance(data, dict) else None
if not isinstance(repos, list) or not all(isinstance(repo, dict) and isinstance(repo.get("items"), list) for repo in repos):
return None
return items
items = [entry for repo in repos for entry in repo["items"]]
return items if all(isinstance(entry, dict) for entry in items) else None
def shaped(item: object) -> bool:
@@ -105,6 +136,9 @@ def mismatch(entry: dict, item: dict) -> str | None:
return f"tip changed: signed {item['tip'][:12]}, now {str(tip)[:12]}"
if (entry.get("worktree") or "") != item["worktree"]:
return f"worktree changed: signed {item['worktree'] or 'none'}, now {entry.get('worktree') or 'none'}"
remote = [str(action) for action in entry.get("actions") or [] if str(action).startswith(REMOTE_ACTION)]
if remote:
return f"the plan also deletes {', '.join(remote)[:200]}; a signed delete is local only"
return None
@@ -114,16 +148,20 @@ class Cleanup:
self.tree = tree
self.pkgmgr = pkgmgr
def item(self, item: dict, proof: object, known: set[str], now: float) -> dict:
def item(self, item: dict, proof: object, known: dict[str, str], ready: bool, now: float) -> dict:
"""Args:
item: one signed item with repository, branch, worktree and tip.
proof: the job's {"payload", "assertion"}.
known: the repositories the tree file says pkgmgr knows.
known: the repositories the tree file says pkgmgr knows, each
with its default branch.
ready: whether pkgmgr holds the GitHub token; without it only a
branch signing.token_free() accepts in the dry run is deleted.
now: the current time in epoch seconds.
Returns:
The item with status done, refused or failed and the reason;
refused and failed items changed nothing.
refused items changed nothing, failed ones are those pkgmgr did
not report done.
"""
def refused(reason: str) -> dict:
return {**item, "status": "refused", "reason": reason}
@@ -136,8 +174,7 @@ class Cleanup:
return refused("unknown repository: pkgmgr does not list it")
if UNSAFE.search(item["branch"]) or not item["branch"]:
return refused("the branch name holds pattern or option characters; only an exact branch name is deleted")
selector = ["--json", "--local", "--branch", item["branch"]]
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", *selector], item["repository"])
code, stdout, stderr = run(self.pkgmgr, plan_argv(item["branch"], False), item["repository"])
plan = plan_items(stdout) if code == 0 else None
if plan is None:
return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}")
@@ -149,10 +186,15 @@ class Cleanup:
reason = mismatch(plan[0], item)
if reason:
return refused(reason)
code, stdout, stderr = run(self.pkgmgr, ["branch", "cleanup", "--apply", *selector], item["repository"])
applied = plan_items(stdout)
status = "done" if code == 0 else "failed"
return {**item, "status": status, "reason": "" if code == 0 else stderr.strip()[-500:], "code": code, "applied": applied if applied is not None else []}
if not ready and not signing.token_free(plan[0], known[item["repository"]]):
return refused(TOKEN_NEEDED)
code, stdout, stderr = run(self.pkgmgr, plan_argv(item["branch"], True), item["repository"])
applied = plan_items(stdout) or []
outcome = next((entry.get("result") for entry in applied if entry.get("branch") == item["branch"]), None)
if code == 0 and isinstance(outcome, str) and outcome.split(";")[0] == DONE:
return {**item, "status": "done", "reason": "", "code": code, "applied": applied}
reason = outcome if isinstance(outcome, str) and outcome else stderr.strip()[-500:] or f"pkgmgr reported no result for {item['branch']}"
return {**item, "status": "failed", "reason": reason[:500], "code": code, "applied": applied}
def job(self, path: Path, now: float) -> dict:
try:
@@ -162,8 +204,8 @@ class Cleanup:
items = job.get("items") if isinstance(job, dict) else None
if not isinstance(items, list) or not items or not all(map(shaped, items)) or job.get("id") != path.stem:
return {"id": path.stem, "finished": now, "items": [], "error": "malformed job: refused, nothing deleted"}
known = known_repositories(self.tree)
results = [self.item(item, job.get("proof"), known, now) for item in items]
known, ready = tree_facts(self.tree)
results = [self.item(item, job.get("proof"), known, ready, now) for item in items]
result = {"id": path.stem, "finished": time.time(), "items": results}
if any(entry["status"] == "done" for entry in results):
code, _, stderr = run(self.pkgmgr, ["branch", "tree", "--format", "json", "--output", str(self.tree)], str(self.tree.parent))
+58 -10
View File
@@ -10,6 +10,9 @@
~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional.
--council: the council directory holding rasci.json, the agents'
AGENTS_COUNCIL_ROOT; optional, task changes need both.
--gates: the installed gates script, e.g.
~/.claude/skills/kvb-check-gate/scripts/gates.py; optional, gate
changes need it.
--once: handle the queued jobs once and exit.
Returns:
@@ -18,9 +21,12 @@ Returns:
in the signed directory, each signature once and before its expiry.
Runs every signed task change (priority pin or WSJF estimate) as
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the
argv shape itself, and kills its process group after 30 s. Refuses task
changes without --rasci and --council or into a council that is a
symlink or another uid's, and every other job. Posts the last 200 output lines
argv shape itself, and kills its process group after 30 s. Runs every
signed gate change (rule set|drop, parents add|drop) the same way as
`python3 -I -B <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.
"""
@@ -52,8 +58,10 @@ BEAT = 2.0
SAFE_PATH = "/usr/bin:/bin"
TASK_JOB = "task"
TASK_LIMIT = 30.0
GATE_JOB = "gate"
REFUSED = "refused: no valid operator signature for this command"
UNREGISTERED = "refused: this runner was started without --rasci and --council; reinstall its unit with make orchestrator-runner"
UNGATED = "refused: this runner was started without --gates; reinstall its unit with make orchestrator-runner"
class Register(NamedTuple):
@@ -113,6 +121,24 @@ def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
return None
def gate_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
"""Args:
job: the claimed gate change with argv and proof.
trust: the pinned keys, the runner's replay store and the origin.
now: the current time in epoch seconds.
Returns:
Why the change must not run, or None when signing.gate_argv()
accepts its argv and a pinned key signed it.
"""
try:
argv = signing.gate_argv(job["argv"])
verifier.check(job.get("proof"), signing.gate_item(argv), "", trust, now)
except (signing.Refused, OSError) as error:
return f"{REFUSED}: {error}"
return None
def council_refusal(council: Path) -> str | None:
"""Args:
council: the directory rasci.py writes the register into.
@@ -161,6 +187,23 @@ def change(job: dict, register: Register, report: Callable[[dict], None]) -> int
return spawn([sys.executable, "-I", "-B", str(register.rasci), *argv], "/", env, report, TASK_LIMIT)
def gate(job: dict, gates: Path, report: Callable[[dict], None]) -> int:
"""Args:
job: the claimed and verified gate change with argv.
gates: the gates script.
report: called with {"output", "code", "refused"}.
Returns:
The exit code of gates.py, run from / on the argv gate_argv() checked.
Raises:
signing.Refused for an argv of any other shape.
"""
argv = signing.gate_argv(job["argv"])
env = {"PATH": SAFE_PATH, "HOME": str(Path.home())}
return spawn([sys.executable, "-I", "-B", str(gates), *argv], "/", env, report, TASK_LIMIT)
def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int:
"""Args:
argv: the program and its arguments, run without a shell.
@@ -224,22 +267,26 @@ def sender(base: str, job_id: str) -> Callable[[dict], None]:
return send
def cycle(base: str, trust: verifier.Trust, register: Register | None) -> None:
def cycle(base: str, trust: verifier.Trust, register: Register | None, gates: Path | None = None) -> None:
for job in call(base, "/api/runner/jobs", None)["jobs"]:
try:
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
except urllib.error.HTTPError:
continue
send = sender(base, claimed["id"])
task = claimed.get("kind") == TASK_JOB
if not task:
reason = refusal(claimed, trust, time.time())
else:
kind = claimed.get("kind")
if kind == TASK_JOB:
reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time())
elif kind == GATE_JOB:
reason = UNGATED if gates is None else gate_refusal(claimed, trust, time.time())
else:
reason = refusal(claimed, trust, time.time())
if reason is not None:
send({"output": reason, "code": None, "refused": True})
elif task:
elif kind == TASK_JOB:
change(claimed, register, send)
elif kind == GATE_JOB:
gate(claimed, gates, send)
else:
execute(claimed, send)
@@ -252,6 +299,7 @@ def main() -> int:
parser.add_argument("--replay", type=Path, required=True)
parser.add_argument("--rasci", type=Path)
parser.add_argument("--council", type=Path)
parser.add_argument("--gates", type=Path)
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
base = hub_api.base_url(args.port)
@@ -259,7 +307,7 @@ def main() -> int:
register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None
while True:
try:
cycle(base, trust, register)
cycle(base, trust, register, args.gates)
except (OSError, ValueError, KeyError, TypeError):
pass
if args.once:
+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")
ESTIMATOR = "Operator"
COMMANDS = "operator/"
GATE_LEVELS = ("none", "quick", "test", "quality-high", "e2e")
GATE_ACTIONS = ("commit", "merge", "push", "pr")
GATE_REPO = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}")
GATE_BRANCH = re.compile(r"(?![-/])(?!.*\.\.)(?!.*@\{)(?!.*//)[^\x00-\x20\x7f~^:?*\[\\]{1,255}(?<![/.])")
TOKEN_FREE_MERGE = "ancestor"
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
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")
def gate_item(argv: list[str]) -> dict:
return {"kind": "gate", "argv": argv}
def gate_argv(argv: object) -> list[str]:
"""Args:
argv: the gates.py arguments, one of `rule set --repo R --branch B
--action A --level L`, `rule drop --repo R --branch B --action A`,
`parents add --repo R --branch B --parent P` (source operator) or
`parents drop --repo R --branch B --parent P`; R an absolute path,
B and P branch names, P not B.
Returns:
argv, the hub and the runner check it the same way.
Raises:
Refused for every other argv.
"""
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
raise Refused("the argv is no list of texts")
head, values = argv[:2], dict(zip(argv[2::2], argv[3::2]))
names = {("rule", "set"): ("--repo", "--branch", "--action", "--level"), ("rule", "drop"): ("--repo", "--branch", "--action"), ("parents", "add"): ("--repo", "--branch", "--parent"), ("parents", "drop"): ("--repo", "--branch", "--parent")}.get(tuple(head))
if names is None or argv[2::2] != list(names) or len(argv) != 2 + 2 * len(names):
raise Refused("the argv is no gates.py rule set|drop or parents add|drop of one branch")
repo = values["--repo"]
if not GATE_REPO.fullmatch(repo) or "/../" in f"{repo}/" or "/./" in f"{repo}/":
raise Refused("the repository is no absolute path")
branches = [values[name] for name in ("--branch", "--parent") if name in values]
if not all(GATE_BRANCH.fullmatch(name) for name in branches) or len(set(branches)) != len(branches):
raise Refused("the branch or parent is no branch name, or the branch is its own parent")
if values.get("--action", GATE_ACTIONS[0]) not in GATE_ACTIONS or values.get("--level", GATE_LEVELS[0]) not in GATE_LEVELS:
raise Refused(f"the action must be one of {', '.join(GATE_ACTIONS)} and the level one of {', '.join(GATE_LEVELS)}")
return argv
def names_default(ref: object, default: object) -> bool:
"""Args:
ref: a ref as pkgmgr names it, bare such as main or behind one
remote such as origin/main, the remote-tracking ref it compares
against when the remote has a HEAD.
default: the repository's default branch as the tree file names it,
without the remote (pkgmgr tree.py strips it).
Returns:
True when ref is the default branch.
"""
if not isinstance(ref, str) or not isinstance(default, str) or not default:
return False
remote, _, name = ref.partition("/")
return ref == default or (bool(remote) and name == default)
def token_free(entry: dict, default: object) -> bool:
"""Args:
entry: a pkgmgr tree or gc plan item with merged_by, merged_into and
remotes.
default: the repository's default branch as the tree file names it.
Returns:
True for a local branch merged by ancestry into the default branch
(names_default) that has no remote branch: deleting it needs no
GitHub token.
"""
return entry.get("merged_by") == TOKEN_FREE_MERGE and names_default(entry.get("merged_into"), default) and not entry.get("remotes")
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
@@ -9,6 +9,7 @@ from .cleanup import cleanup_request
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect, field
from .gating import gate_request
from .offices import office_request
from .records import digest, shown
from .register import REGISTER, adder, operator_rows, read_register, runnable
@@ -250,6 +251,8 @@ class Approving:
signed = self.appointable(raw)
elif task_request(raw):
signed = self.task_changes(raw)
elif gate_request(raw):
signed = self.gate_changes(raw)
else:
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
return self.challenge_for(signed)
+3 -1
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .records import epoch, iso_epoch, read_json, shown
from .register import ENDED, status_of
from .signed import signing
from .tree import inside, owns
PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b")
@@ -105,7 +106,8 @@ def enrich(scan: dict, tasks: dict[str, dict], reviews: list[dict], touched: Tou
shared = sorted(files[a] & files[b])
if shared:
overlaps.append({"a": a, "b": b, "count": len(shared), "files": shared[:SHARED_SHOWN]})
merges = [{"from": item["name"], "into": item["merged_into"] or entry.get("default") or "", "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]]
default = entry.get("default") or ""
merges = [{"from": item["name"], "into": default if signing.names_default(item["merged_into"], default) else item["merged_into"] or default, "by": item["merged_by"]} for item in entry["branches"] if item["merged_by"]]
repos.append({**entry, "branches": branches, "overlaps": overlaps, "merges": merges})
loose = [{"task": task, "title": shown(row.get("title")), "status": status_of(row), "artefacts": artefacts_of(task, row)} for task, row in sorted(tasks.items()) if task not in placed and artefacts_of(task, row)]
return {**scan, "repos": repos, "tasks": loose}
+28 -4
View File
@@ -11,6 +11,8 @@ from pathlib import Path
from .errors import Missing, ToolError
from .fields import decode, expect
from .records import read_json, stamp
from .register import OPERATOR
from .skill_scripts import OPERATOR_SCRIPT
from .tree import offered
ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip")
@@ -18,6 +20,10 @@ JOB_KEYS = ("repository", "branch", "worktree", "tip")
JOB_VERSION = 1
ITEM_LIMIT = 100
RESULTS_SHOWN = 20
TOKEN_ROW = "pkgmgr-github-token"
TOKEN_COMMAND = "env secret-tool store --label=pkgmgr-github service pkgmgr:github username 'github.com|-' </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)
@@ -65,19 +71,24 @@ class Cleaning:
Returns:
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:
raise ToolError(f"items must be a list of 1 to {ITEM_LIMIT} objects")
scan = self.tree.get()
if scan.get("delete_ready") is not True:
raise ToolError(f"delete blocked: {scan.get('delete_reason') or scan.get('error') or 'delete_ready is false'}")
known = offered(scan)
ready = scan.get("delete_ready") is True
if scan.get("error"):
raise ToolError(f"delete blocked: {scan['error']}")
known = offered(scan, ready)
locked = [] if ready else offered(scan, True)
chosen = []
for entry in raw:
if not isinstance(entry, dict):
raise ToolError("every item must be an object with kind, repository, branch, worktree and tip")
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:
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:
@@ -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"}})
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:
result = read_json(self.spool.results / f"{job_id}.json")
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"
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
TASKS_ROUTE = "/api/tasks"
GATES_ROUTE = "/api/gates"
TOKEN_ROUTE = "/api/cleanup/token"
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
SCRIPT_TYPE = "text/javascript; charset=utf-8"
FONT_TYPE = "font/woff2"
@@ -57,7 +59,7 @@ SETTINGS_SIGN_ROUTE = "/api/settings/sign"
CHOICES_ROUTE = "/api/choices"
CHOICES_SIGN_ROUTE = "/api/choices/sign"
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE)
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE)
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE, GATES_ROUTE, TOKEN_ROUTE)
class Handler(Exchange):
@@ -168,6 +170,10 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
if path == TASKS_ROUTE:
return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED)
if path == GATES_ROUTE:
return lambda body: self.answer(lambda: hub.change_gates(body), HTTPStatus.ACCEPTED)
if path == TOKEN_ROUTE:
return lambda body: self.answer(lambda: hub.token_row(body), HTTPStatus.CREATED)
if inbox:
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo:
+2 -1
View File
@@ -16,6 +16,7 @@ from .cleanup import Cleaning, Spool
from .denying import Denying
from .engines import Engines, Speech
from .events import Log, Watch
from .gating import Gating
from .icons import sheet
from .inbox import Inbox
from .jobs import Jobs
@@ -65,7 +66,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Gating, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
+7 -5
View File
@@ -12,15 +12,17 @@ JOB_OPEN = ("queued", "running")
RUN_OUTPUT = 64 << 10
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
TASK_JOB = "task"
GATE_JOB = "gate"
CHANGES = (TASK_JOB, GATE_JOB)
def runner_job(job: dict) -> dict:
"""Returns:
What the runner claims: an operator command, or a task change with
kind and argv; a runner that predates task changes verifies the
latter as an operator command and refuses it.
What the runner claims: an operator command, or a task or gate change
with kind and argv; a runner that predates such changes verifies them
as an operator command and refuses them.
"""
change = {"kind": TASK_JOB, "argv": job["argv"]} if job.get("kind") == TASK_JOB else {}
change = {"kind": job["kind"], "argv": job["argv"]} if job.get("kind") in CHANGES else {}
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, **change, "proof": job.get("proof")}
@@ -90,7 +92,7 @@ class Jobs:
job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()})
if not changed:
raise Conflict({"id": job_id, "status": job["status"]})
command_done = status == "done" and job.get("kind") != TASK_JOB
command_done = status == "done" and job.get("kind") not in CHANGES
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
tail = "\n".join(output.splitlines()[-20:])
self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
@@ -17,6 +17,8 @@ ESTIMATE = "kvb-org-queue-estimate/scripts/estimate.py"
class Runner(Protocol):
skills: Path
def run(self, script: str, args: list[str]) -> dict: ...
+1 -1
View File
@@ -49,7 +49,7 @@ class Sources:
reviews = self.reviews.listed()
scan = assign(self.tree.get(), tasks, read_claims(self.council), reviews)
history = read_jsonl(self.council / "queue-history.jsonl")
return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results()}
return {**enrich(scan, tasks, reviews, self.touched, history, self.jobs.listed()), "cleanups": self.cleanup_results(), "gates": self.gate_view(scan)}
def schedules(self, claims: list[dict], now: float) -> list[dict]:
beats = {claim["session"]: claim["beat"] for claim in claims if claim["beat"] is not None}
+37 -14
View File
@@ -18,6 +18,8 @@ FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}")
INVALID = "invalid"
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
TREE_HEARTBEAT = "tree-heartbeat"
GC_SKILL = "kvb-git-branch-gc"
GC_COMPOSE_DOWN = ("docker", "compose", "-p", "{project}", "down", "-v", "--remove-orphans")
def flow(item: dict) -> dict | None:
@@ -66,34 +68,47 @@ def worktree(item: dict) -> dict:
}
def held_by(worktree: str) -> str:
return f"held by worktree {worktree}: pkgmgr branch cleanup skips it, use branch gc ({GC_SKILL}, pkgmgr gc)"
def dry_run(item: dict) -> list[str]:
"""Returns:
The lines `pkgmgr branch cleanup --local` would act on for one tree
item, read from the gc plan the tree file carries; a remote branch
always stays.
What the runner's `pkgmgr branch cleanup` does for one tree item,
read from the gc plan the tree file carries: it deletes the local
branch and never touches a worktree, Compose stack or volume; a
remote branch stays, the runner refuses a plan that deletes one. A
branch held by a worktree it skips; its lines name what branch gc
would do instead, its `docker compose down -v` deleting the stack's
volumes.
"""
tip = (item["local_sha"] or item["head"] or "")[:12]
merged = f", merged by {item['merged_by']}" if item["merged_by"] else ""
lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"]
if item["worktree"]:
lines.append(held_by(item["worktree"]))
lines.append(f"branch gc would remove the worktree {item['worktree']} and the local branch {item['branch']}")
lines += [f"branch gc would run {' '.join(GC_COMPOSE_DOWN).format(project=name)}, which deletes the volumes of {name}" for name in item["compose_projects"]]
lines += [f"branch gc would remove the image {name}" for name in item["images"]]
return lines
for action in item["actions"]:
if action == "worktree":
lines.append(f"removes the worktree {item['worktree']}")
elif action == "local-branch":
if action == "local-branch":
lines.append(f"deletes the local branch {item['branch']}")
elif action.startswith("remote"):
lines.append(f"stays: {action} (local only)")
lines.append(f"stays: {action} (local only; the host refuses a plan that deletes it)")
else:
lines.append(action)
lines += [f"stops the Compose stack {name} without -v" for name in item["compose_projects"]]
lines += [f"removes the image {name}" for name in item["images"]]
return lines
def branch(item: dict, path: str) -> dict:
def branch(item: dict, path: str, default: object) -> dict:
remote = next(iter(item["remotes"].values()), "")
head = item["local_sha"] or item["head"] or remote
open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"])
held = held_by(item["worktree"]) if item["worktree"] and item["state"] == "merged-clean" else ""
open_to_delete = item["state"] == "merged-clean" and not item["skip"] and bool(item["local_sha"]) and not item["worktree"]
return {
"held": held,
"token_free": signing.token_free(item, default),
"name": item["branch"],
"head": head,
"upstream": item["upstream"] or "",
@@ -116,13 +131,21 @@ def repo(entry: dict) -> dict:
return {
**{key: value for key, value in entry.items() if key != "items"},
"name": "/".join(Path(entry["path"]).parts[-LABEL_PARTS:]),
"branches": [branch(item, entry["path"]) for item in items if item["branch"]],
"branches": [branch(item, entry["path"], entry.get("default")) for item in items if item["branch"]],
"detached": [worktree(item) for item in items if item["detached"]],
}
def offered(scan: dict) -> list[dict]:
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None]
def offered(scan: dict, ready: bool) -> list[dict]:
"""Args:
scan: the tree.
ready: whether pkgmgr holds the GitHub token.
Returns:
The cleanup items deletable now: every one with the token, without
it only those of token-free branches.
"""
return [entry["cleanup"] for repository in scan["repos"] for entry in repository["branches"] if entry["cleanup"] is not None and (ready or entry["token_free"])]
def read_tree(path: Path) -> dict:
@@ -5,7 +5,7 @@ Description=Agent Orchestrator runner: runs the operator commands a pinned passk
Type=simple
Environment=PATH=/usr/bin:/bin
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json --rasci %h/.claude/skills/kvb-org-rasci/scripts/rasci.py --council ${KVB_ORCHESTRATOR_COUNCIL}
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json --rasci %h/.claude/skills/kvb-org-rasci/scripts/rasci.py --council ${KVB_ORCHESTRATOR_COUNCIL} --gates %h/.claude/skills/kvb-check-gate/scripts/gates.py
Restart=always
RestartSec=5
+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-bubbles.css" %}
{% include "style-ancestry.css" %}
</style>
</head>
<body>
@@ -457,6 +459,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "nav.js" %}
{% include "ancestry.js" %}
{% include "popup.js" %}
{% include "faces.js" %}
@@ -528,6 +532,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-cleanup.js" %}
{% include "page-ancestry.js" %}
{% include "page-offices.js" %}
{% include "page-tasks.js" %}
+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]]),
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
} else if (node.kind === "branch") {
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]),
body.push(h("div", {class: "states"}, branchStates(data), gateIcons(node.repo, data.name)), meta([["Upstream", data.upstream], ["Head", data.head.slice(0, 12), data.head], ["Merged into", data.merged_into], ["Reason", data.reason]]),
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
h("h3", {}, "Dry run"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
@@ -256,13 +256,14 @@
drawArtifacts();
}}}) : null;
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll};
const apply = view === "tree" ? null : h("span", {class: "gate-staged"});
tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll, apply};
window.setTimeout(() => {
drawArtifacts();
fetchTree();
}, 0);
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll, apply), count);
const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox);
return h("div", {class: "graph"}, controls, body);
}
@@ -296,6 +297,7 @@
drawTree();
return;
}
tree.dom.apply.replaceChildren(gateApply() || "");
const empty = !tree.data || tree.data.error;
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
if (empty) {
+7 -5
View File
@@ -8,14 +8,16 @@
return {repo, entry};
}
function deleteDialog(items) {
function deleteDialog(items, locked = [], held = []) {
if (!items.length) {
return;
}
const rows = items.map((item) => {
const rows = [...items, ...locked, ...held].map((item) => {
const {repo, entry} = planOf(item);
return h("li", {class: "dry-run"},
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch)),
const waits = locked.includes(item);
const kept = held.includes(item);
return h("li", {class: waits ? "dry-run locked" : kept ? "dry-run held" : "dry-run", "data-token": waits ? "needed" : null, "data-held": kept && entry ? entry.held : null},
h("div", {class: "row"}, h("strong", {}, repo ? repo.name : item.repository), h("code", {}, item.branch), waits ? pill("token needed", "warn") : kept ? pill("held by a worktree: not deleted here", "neutral") : null),
h("pre", {}, (entry ? entry.plan : [`${item.branch} @ ${item.tip.slice(0, 12)}`]).join("\n")));
});
const outcome = h("div", {class: "mini", "aria-live": "polite"});
@@ -27,7 +29,7 @@
}
});
openSheet(items.length > 1 ? `Delete ${items.length} merged branches` : `Delete ${items[0].branch}`, [
h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup --local for exactly these entries: worktree, local branch, Compose stack without -v and its images. No remote branch. The host checks your signature and refuses every entry whose state has changed since."),
h("p", {class: "muted"}, "Dry run of pkgmgr branch cleanup for exactly these entries: it deletes the local branch and never touches a worktree, Compose stack or volume. No remote branch: the host refuses an entry whose plan deletes one. A branch held by a worktree is not deleted here: branch gc (kvb-git-branch-gc, pkgmgr gc) removes it, and its docker compose down -v deletes the stack's volumes. The host checks your signature and refuses every entry whose state has changed since."),
h("ul", {class: "mini", "aria-label": "Dry run"}, rows),
outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", closeSheet), confirm),
+1
View File
@@ -117,5 +117,6 @@
const running = all.filter((entry) => entry.running).length;
spawn.dom.list.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title"}, "Spawn tree", counter(all.length)), h("span", {class: "muted"}, `${running} running · ${all.length - running} finished · Operator → Chair → Sessions → Subagents`)),
agentAncestry(root),
h("ul", {class: "spawn-tree root"}, spawnItem(root)));
}
+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) {
if (!entry.cleanup) {
return null;
if (entry.held) {
return h("span", {class: "hint held", title: entry.held}, chip("bi-folder-symlink", entry.held, "neutral"));
}
const ready = tree.data.delete_ready === true;
if (!entry.cleanup) {
return tokenLocked(entry) ? chip("bi-key", "token needed", "warn") : null;
}
const ready = deletableNow(entry);
const hint = ready ? `Delete: remove ${entry.name} locally, after dry run and signature` : `Delete blocked: ${tree.data.delete_reason || "delete_ready is false"}`;
const button = iconBtn("bi-trash3", "Delete", "btn small danger", () => deleteDialog([entry.cleanup]), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button);
return h("span", {class: "hint", title: hint}, button, ready ? null : chip("bi-key", "token needed", "warn"));
}
async function storeToken() {
const result = await sendJson("/api/cleanup/token", {});
toast(result.ok ? "Token: operator row added" : "Token: no operator row", result.ok && result.data ? [result.data.task] : fail(result), result.ok);
}
function deleteAll() {
const items = (tree.data.repos || []).flatMap((repo) => repo.branches).filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
const ready = tree.data.delete_ready === true && items.length > 0;
const hint = tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason";
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items), hint);
const branches = (tree.data.repos || []).flatMap((repo) => repo.branches);
const items = branches.filter(deletableNow).map((entry) => entry.cleanup);
const waiting = branches.filter(tokenLocked);
const locked = waiting.filter((entry) => entry.cleanup).map((entry) => entry.cleanup);
const held = (tree.data.repos || []).flatMap((repo) => repo.branches.filter((entry) => entry.held).map((entry) => ({repository: repo.path, branch: entry.name, tip: entry.head})));
const ready = items.length > 0;
const hint = !ready && tree.data.delete_ready !== true ? `Delete all merged blocked: ${tree.data.delete_reason || "delete_ready is false"}` : items.length ? `Delete all merged: ${items.length} branches after dry run and signature` : "Delete all merged: no merged branch without a skip reason";
const button = iconBtn("bi-trash3-fill", "Delete all merged", "btn small danger", () => deleteDialog(items, locked, held), hint);
button.disabled = !ready;
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null);
return h("span", {class: "hint", title: hint}, button, items.length ? counter(items.length) : null,
waiting.length ? chip("bi-key", `${waiting.length} token needed`, "warn", waiting.length) : null,
held.length ? chip("bi-folder-symlink", `${held.length} held by a worktree: use branch gc`, "neutral", held.length) : null,
tree.data.delete_ready !== true ? iconBtn("bi-key-fill", "Store token", "btn small soft", storeToken, "Store token: add the operator row that stores the GitHub token in the Secret Service") : null);
}
function leafRow(found) {
@@ -82,7 +101,7 @@
const result = resultOf(repo, entry);
return h("div", {class: "branch-row"},
h("div", {class: "branch-name"}, h("strong", {}, entry.name), h("span", {class: "states"}, branchStates(entry)), entry.upstream ? h("span", {class: "muted"}, `↔ ${entry.upstream}`) : null,
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), deleteButton(entry)),
h("span", {class: "muted", title: entry.head}, entry.head.slice(0, 10)), result ? resultChip(result) : null, h("span", {class: "spacer"}), gateIcons(repo.path, entry.name), deleteButton(entry)),
entry.worktrees.map(worktreeRow));
}
@@ -96,7 +115,7 @@
tree.dom.list.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
return;
}
tree.dom.removeAll.replaceChildren(deleteAll());
tree.dom.removeAll.replaceChildren(deleteAll(), gateApply() || "");
const q = tree.q.trim().toLowerCase();
const hit = (...texts) => !q || texts.some((text) => String(text).toLowerCase().includes(q));
const repos = [];
@@ -124,6 +143,7 @@
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} branches`), counter(`${trees.length} worktrees`),
dirty ? chip("bi-pencil-square", `${dirty} worktrees with changes`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} merged, deletable`, "ok", merged) : null,
busy ? chip("bi-lightning-charge", `${busy} worktrees with open work`, "warn", busy) : null),
repo.branches.length ? branchAncestry(repo) : null,
branches.map((entry) => branchRow(repo, entry)),
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "detached HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => {
+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; }