Merge feature/workbench-w3-artifacts into unify/skills-20261112
Conflicts kept both sides: runner.py docstring (W2 board changes and W3 gate changes), signing.py constants (W2 STATUSES and W3 GATE_*), index.html.j2 styles (W1 diff and W3 ancestry). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f5b2352f19
33 files changed
+1997
-106
No files matched your search
@@ -219,7 +219,7 @@ flowchart TD
|
||||
|
||||
### W3 Artifacts
|
||||
|
||||
- [ ] "Clean all merged" shows the gc dry run of every `merged-clean` item; one
|
||||
- [x] "Clean all merged" shows the gc dry run of every `merged-clean` item; one
|
||||
passkey runs the signed job.
|
||||
- [ ] Without a GitHub token, local branches merged by ancestry into the
|
||||
default branch and without a remote branch can be deleted; the others
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
@@ -21,9 +24,12 @@ Returns:
|
||||
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the
|
||||
argv shape itself, and kills its process group after 30 s; rasci.py
|
||||
refuses a cycle or a done with an open key result, and its reason is the
|
||||
job's output. Refuses task
|
||||
job's output. 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, and every other job. Posts the last 200 output lines
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -55,8 +61,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):
|
||||
@@ -116,6 +124,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.
|
||||
@@ -164,6 +190,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.
|
||||
@@ -227,22 +270,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)
|
||||
|
||||
@@ -255,6 +302,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)
|
||||
@@ -262,7 +310,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:
|
||||
|
||||
@@ -28,6 +28,11 @@ FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
|
||||
ESTIMATOR = "Operator"
|
||||
COMMANDS = "operator/"
|
||||
STATUSES = ("queued", "running", "blocked", "done", "stopped")
|
||||
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 = (
|
||||
@@ -201,6 +206,72 @@ def task_argv(task: object, argv: object) -> list[str]:
|
||||
raise Refused("the argv is no task priority, status, dependency 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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
@@ -41,6 +41,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"
|
||||
@@ -59,7 +61,7 @@ CHOICES_ROUTE = "/api/choices"
|
||||
CHOICES_SIGN_ROUTE = "/api/choices/sign"
|
||||
DIFF_ROUTE = re.compile(r"/api/diffs/(review|ask)/([0-9a-f]{16})")
|
||||
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):
|
||||
@@ -173,6 +175,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -417,6 +417,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "style-bubbles.css" %}
|
||||
|
||||
{% include "style-diff.css" %}
|
||||
|
||||
{% include "style-ancestry.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -463,6 +465,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" %}
|
||||
@@ -538,6 +542,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" %}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1818,7 +1818,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertIn(["twig", "true"], by_hash["#agents/tree"]["spawn"])
|
||||
self.assertIn(["twig", "false"], by_hash["#agents/tree"]["spawn"])
|
||||
artifacts = by_hash["#artifacts/tree"]
|
||||
self.assertEqual(artifacts["deletes"], [["Delete all merged", True, "Delete all merged blocked: Secret Service locked"], ["Delete", True, "Delete blocked: Secret Service locked"]])
|
||||
self.assertEqual(artifacts["deletes"], [["Delete all merged", True, "Delete all merged blocked: Secret Service locked"]])
|
||||
for state in ("merged by #12 into main", "upstream gone", "protected", "1 commits unpushed", "clean", "not committed: 0 staged, 0 changed, 1 new", "gc skips: ignored files beyond known caches: notes.txt"):
|
||||
with self.subTest(state=state):
|
||||
self.assertIn(state, artifacts["states"])
|
||||
@@ -2125,6 +2125,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
def test_chromium_deletes_a_merged_branch_after_dry_run_and_signature(self):
|
||||
tree = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||||
tree["delete_ready"], tree["delete_reason"] = True, ""
|
||||
next(item for item in tree["repos"][0]["items"] if item["branch"] == "feature/done").update(worktree=None, worktree_state=None, actions=["local-branch", "remote-branch"], compose_projects=[], images=[])
|
||||
self.tree_file.write_text(json.dumps(tree), encoding="utf-8")
|
||||
demo = f"{self.root}/repos/github.com/me/demo"
|
||||
Path(demo).mkdir(parents=True)
|
||||
@@ -2132,8 +2133,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
fake.parent.mkdir()
|
||||
fake.write_text(FAKE_PKGMGR.format(python=sys.executable), encoding="utf-8")
|
||||
fake.chmod(0o755)
|
||||
worktree = f"{self.root}/worktrees/github.com/me/demo/feature-done"
|
||||
(fake.parent / "plan.json").write_text(json.dumps([{"branch": "feature/done", "worktree": worktree, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None}]), encoding="utf-8")
|
||||
(fake.parent / "plan.json").write_text(json.dumps([{"branch": "feature/done", "worktree": None, "local_sha": DONE_TIP, "head": DONE_TIP, "state": "merged-clean", "skip": None, "actions": ["local-branch"]}]), encoding="utf-8")
|
||||
following = json.loads(json.dumps(tree))
|
||||
following["repos"][0]["items"] = [item for item in following["repos"][0]["items"] if item["branch"] != "feature/done"]
|
||||
(fake.parent / "next-tree.json").write_text(json.dumps(following), encoding="utf-8")
|
||||
@@ -2158,7 +2158,7 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual(opened["title"], "Delete feature/done")
|
||||
self.assertEqual(len(opened["dry"]), 1)
|
||||
self.assertTrue(opened["dry"][0].startswith("feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12"), opened["dry"])
|
||||
self.assertIn("stays: remote-branch (local only)", opened["dry"][0])
|
||||
self.assertIn("stays: remote-branch (local only; the host refuses a plan that deletes it)", opened["dry"][0])
|
||||
self.assertEqual((confirmed["result"], confirmed["text"]), ("done", "feature/donedeleted"), confirmed)
|
||||
self.assertEqual(confirmed["toasts"], ["Delete: donefeature/done: deleted"])
|
||||
self.assertEqual(after["past"], ["done", "deleted"])
|
||||
@@ -2167,8 +2167,8 @@ class TestOrchestratorE2E(HubCase):
|
||||
self.assertEqual([[(item["branch"], item["status"]) for item in result["items"]] for result in written], [[("feature/done", "done")]])
|
||||
calls = [json.loads(line)["argv"] for line in (fake.parent / "calls.jsonl").read_text(encoding="utf-8").splitlines()]
|
||||
self.assertEqual(calls, [
|
||||
["branch", "cleanup", "--json", "--local", "--branch", "feature/done"],
|
||||
["branch", "cleanup", "--apply", "--json", "--local", "--branch", "feature/done"],
|
||||
["branch", "cleanup", "--json", "--branch", "feature/done"],
|
||||
["branch", "cleanup", "--apply", "--json", "--branch", "feature/done"],
|
||||
["branch", "tree", "--format", "json", "--output", str(self.tree_file)],
|
||||
])
|
||||
|
||||
|
||||
@@ -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
@@ -232,8 +232,14 @@ class HubCase(unittest.TestCase):
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
def write_tree(self) -> Path:
|
||||
return self.write_fixture("branch-tree.json")
|
||||
|
||||
def write_fixture(self, fixture: str) -> Path:
|
||||
"""Args:
|
||||
fixture: the tree file under tests/ to write, @ROOT@ replaced.
|
||||
"""
|
||||
root = self.outside / "scan"
|
||||
sample = (Path(__file__).parent / "branch-tree.json").read_text(encoding="utf-8")
|
||||
sample = (Path(__file__).parent / fixture).read_text(encoding="utf-8")
|
||||
self.tree_file.write_text(sample.replace("@ROOT@", str(root)), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
@@ -83,6 +83,8 @@ ESTIMATE = REPO_ROOT / "skills" / "kvb-org-queue-estimate" / "scripts" / "estima
|
||||
|
||||
|
||||
class FakeScripts:
|
||||
skills = REPO_ROOT / "skills"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, list[str]]] = []
|
||||
self.code = 0
|
||||
|
||||
@@ -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()
|
||||
@@ -69,6 +69,16 @@ class TestHubIcons(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):
|
||||
root = self.write_tree()
|
||||
demo = f"{root}/worktrees/github.com/me/demo"
|
||||
|
||||
+131
-19
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -11,16 +14,18 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
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_SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_cleanup_runner", CLEANUP)
|
||||
host = importlib.util.module_from_spec(CLEANUP_SPEC)
|
||||
CLEANUP_SPEC.loader.exec_module(host)
|
||||
INSTALLED_PKGMGR = shutil.which("pkgmgr")
|
||||
DONE_TIP = "5b1e6fd3a7c0e2d94f8a61b3c2d7e9f0a1b2c3d4"
|
||||
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}
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -29,22 +34,48 @@ from pathlib import Path
|
||||
here = Path(__file__).parent
|
||||
with (here / "calls.jsonl").open("a", encoding="utf-8") as log:
|
||||
log.write(json.dumps({{"argv": sys.argv[1:], "cwd": os.getcwd()}}) + "\\n")
|
||||
if sys.argv[1:3] == ["branch", "tree"]:
|
||||
parser = argparse.ArgumentParser(prog="pkgmgr")
|
||||
branch = parser.add_subparsers(dest="command", required=True).add_parser("branch").add_subparsers(dest="subcommand", required=True)
|
||||
tree = branch.add_parser("tree")
|
||||
tree.add_argument("--format", choices=("json", "yaml"), default="json")
|
||||
tree.add_argument("--output")
|
||||
cleanup = branch.add_parser("cleanup")
|
||||
for flag in ("--apply", "--json", "--all", "--sizes"):
|
||||
cleanup.add_argument(flag, action="store_true")
|
||||
for option in ("--branch", "--exclude", "--merged-into"):
|
||||
cleanup.add_argument(option, action="append", default=[])
|
||||
cleanup.add_argument("--min-age", type=float)
|
||||
args = parser.parse_args()
|
||||
if args.subcommand == "tree":
|
||||
following = here / "next-tree.json"
|
||||
rewritten = following.read_text(encoding="utf-8") if following.exists() else json.dumps({{"rewritten": True}})
|
||||
Path(sys.argv[sys.argv.index("--output") + 1]).write_text(rewritten, encoding="utf-8")
|
||||
Path(args.output).write_text(rewritten, encoding="utf-8")
|
||||
raise SystemExit(0)
|
||||
print(json.dumps({{"items": json.loads((here / "plan.json").read_text(encoding="utf-8"))}}))
|
||||
items = json.loads((here / "plan.json").read_text(encoding="utf-8"))
|
||||
if args.apply:
|
||||
outcome = here / "outcome.txt"
|
||||
result = outcome.read_text(encoding="utf-8") if outcome.exists() else "done"
|
||||
items = [{{**item, "result": result}} for item in items]
|
||||
plan = {{"host_pid_namespace": True, "repos": [{{"repo": os.getcwd(), "default": "origin/main", "merged_into": [], "warnings": [], "items": items}}]}}
|
||||
print(json.dumps(plan) if args.json else "\\n".join(str(item["branch"]) for item in items))
|
||||
"""
|
||||
|
||||
|
||||
class TestHubCleanup(HubCase):
|
||||
def ready(self, ready: bool) -> dict:
|
||||
def ready(self, ready: bool, held: bool) -> dict:
|
||||
"""Args:
|
||||
ready: delete_ready of the tree file.
|
||||
held: keep feature/done in its worktree; otherwise it is a local
|
||||
branch without one.
|
||||
"""
|
||||
root = self.write_tree()
|
||||
data = json.loads(self.tree_file.read_text(encoding="utf-8"))
|
||||
data["delete_ready"], data["delete_reason"] = ready, "" if ready else "Secret Service locked"
|
||||
done = next(item for item in data["repos"][0]["items"] if item["branch"] == "feature/done")
|
||||
if not held:
|
||||
done.update(worktree=None, worktree_state=None, actions=["local-branch", "remote-branch"], compose_projects=[], images=[])
|
||||
self.tree_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
return signing.cleanup_item(f"{root}/repos/github.com/me/demo", "feature/done", f"{root}/worktrees/github.com/me/demo/feature-done", DONE_TIP)
|
||||
return signing.cleanup_item(f"{root}/repos/github.com/me/demo", "feature/done", done["worktree"] or "", DONE_TIP)
|
||||
|
||||
def branches(self) -> dict:
|
||||
status, _, body = self.get("/api/tree")
|
||||
@@ -57,27 +88,24 @@ class TestHubCleanup(HubCase):
|
||||
return status, body
|
||||
|
||||
def test_the_tree_offers_a_delete_only_for_merged_clean_branches_without_skip_reason(self):
|
||||
item = self.ready(False)
|
||||
item = self.ready(False, False)
|
||||
branches = self.branches()
|
||||
done = branches["feature/done"]
|
||||
self.assertEqual(done["cleanup"], item)
|
||||
self.assertEqual((done["cleanup"], done["held"]), (item, ""))
|
||||
self.assertEqual(done["plan"], [
|
||||
"feature/done @ 5b1e6fd3a7c0: merged-clean, merged by #12",
|
||||
f"removes the worktree {item['worktree']}",
|
||||
"deletes the local branch feature/done",
|
||||
"stays: remote-branch (local only)",
|
||||
"stops the Compose stack demo-feature-done without -v",
|
||||
"removes the image demo:feature-done",
|
||||
"stays: remote-branch (local only; the host refuses a plan that deletes it)",
|
||||
])
|
||||
self.assertEqual((done["merged_by"], done["merged_into"], done["gone"]), ("#12", "main", True))
|
||||
self.assertEqual({name: entry["cleanup"] for name, entry in branches.items() if name != "feature/done"}, {"feature": None, "feature/kept": None, "main": None})
|
||||
self.assertEqual(branches["feature/kept"]["skip"], "ignored files beyond known caches: notes.txt")
|
||||
|
||||
def test_signing_a_delete_needs_delete_ready_and_exactly_an_offered_item(self):
|
||||
item = self.ready(False)
|
||||
item = self.ready(False, False)
|
||||
status, body = self.sign([item])
|
||||
self.assertEqual((status, body.decode()), (400, "delete blocked: Secret Service locked\n"))
|
||||
item = self.ready(True)
|
||||
item = self.ready(True, False)
|
||||
kept = {**item, "branch": "feature/kept", "worktree": "", "tip": "0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d"}
|
||||
for name, items in (("changed tip", [{**item, "tip": OTHER_TIP}]), ("skip reason", [kept]), ("pattern", [{**item, "branch": "feature/*"}]), ("twice", [item, item]), ("extra key", [{**item, "command": "rm -rf /"}])):
|
||||
with self.subTest(name=name):
|
||||
@@ -86,8 +114,37 @@ class TestHubCleanup(HubCase):
|
||||
self.assertEqual(status, 200, body)
|
||||
self.assertEqual(json.loads(body)["payload"]["items"], [signing.item_hash(item)])
|
||||
|
||||
def test_a_branch_held_by_a_worktree_offers_no_delete_and_names_branch_gc(self):
|
||||
item = self.ready(True, True)
|
||||
done = self.branches()["feature/done"]
|
||||
held = f"held by worktree {item['worktree']}: pkgmgr branch cleanup skips it, use branch gc (kvb-git-branch-gc, pkgmgr gc)"
|
||||
self.assertEqual((done["cleanup"], done["held"], done["plan"][1]), (None, held, held))
|
||||
status, body = self.sign([item])
|
||||
self.assertEqual((status, body.decode()), (400, f"feature/done in {item['repository']} is no merged-clean branch without skip reason at that tip in the tree file\n"))
|
||||
page = (HUB / "templates" / "page-tree.js").read_text(encoding="utf-8")
|
||||
self.assertIn('if (entry.held) {\n return h("span", {class: "hint held", title: entry.held}, chip("bi-folder-symlink", entry.held, "neutral"));', page)
|
||||
self.assertIn("deleteDialog(items, locked, held)", page)
|
||||
self.assertIn("held by a worktree: not deleted here", (HUB / "templates" / "page-cleanup.js").read_text(encoding="utf-8"))
|
||||
|
||||
@unittest.skipUnless(INSTALLED_PKGMGR, "no pkgmgr installed")
|
||||
def test_every_volume_text_states_the_compose_down_argv_of_the_installed_pkgmgr(self):
|
||||
found = subprocess.run([str(Path(INSTALLED_PKGMGR).parent / "python"), "-I", "-c", "import pkgmgr.actions.branch.gc as gc; print(gc.__file__)"], capture_output=True, text=True, timeout=60, check=True)
|
||||
calls = [node for node in ast.walk(ast.parse(Path(found.stdout.strip()).read_text(encoding="utf-8"))) if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "_docker" and node.args and isinstance(node.args[0], ast.List)]
|
||||
downs = [["docker", *(part.value if isinstance(part, ast.Constant) else "{project}" for part in call.args[0].elts)] for call in calls if any(isinstance(part, ast.Constant) and part.value == "down" for part in call.args[0].elts)]
|
||||
self.assertEqual(len(downs), 1, downs)
|
||||
command = " ".join(downs[0]).format(project="demo-feature-done")
|
||||
self.ready(True, True)
|
||||
plan = self.branches()["feature/done"]["plan"]
|
||||
self.assertIn(f"branch gc would run {command}, which deletes the volumes of demo-feature-done", plan)
|
||||
self.assertIn("-v", downs[0])
|
||||
dialog = (HUB / "templates" / "page-cleanup.js").read_text(encoding="utf-8")
|
||||
self.assertIn("never touches a worktree, Compose stack or volume", dialog)
|
||||
self.assertIn("its docker compose down -v deletes the stack's volumes", dialog)
|
||||
texts = [*plan, dialog, (HUB / "kvb_orchestrator" / "tree.py").read_text(encoding="utf-8")]
|
||||
self.assertFalse([text for text in texts if "without -v" in text])
|
||||
|
||||
def test_a_signed_delete_becomes_a_job_without_any_command(self):
|
||||
item = self.ready(True)
|
||||
item = self.ready(True, False)
|
||||
signer = self.pin()
|
||||
proof = signer.proof(self.prepare([item])["payload"], self.origin)
|
||||
self.assertEqual(self.post_json("/api/cleanup", {"items": [item], "proof": None}, self.guard(self.host))[0], 400)
|
||||
@@ -127,7 +184,7 @@ class TestHostCleanup(unittest.TestCase):
|
||||
self.calls = self.fake.parent / "calls.jsonl"
|
||||
self.tree = base / "tree" / "branch-tree.json"
|
||||
self.tree.parent.mkdir()
|
||||
self.tree.write_text(json.dumps({"repos": [{"path": str(self.repo), "known": True}, {"path": str(base / "stranger"), "known": False}]}), encoding="utf-8")
|
||||
self.tree.write_text(json.dumps({"delete_ready": True, "repos": [{"path": str(self.repo), "known": True}, {"path": str(base / "stranger"), "known": False}]}), encoding="utf-8")
|
||||
self.approvers = base / "config" / "approvers.json"
|
||||
self.trust = verifier.Trust(approvers=self.approvers, replay=base / "replay" / "cleanup.json", origin=ORIGIN)
|
||||
self.signer = key(-7, "main")
|
||||
@@ -154,7 +211,7 @@ class TestHostCleanup(unittest.TestCase):
|
||||
def test_a_signed_merged_clean_item_is_dry_run_then_deleted_by_exact_name(self):
|
||||
result, calls = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()])
|
||||
self.assertEqual([(entry["status"], entry["reason"]) for entry in result["items"]], [("done", "")])
|
||||
selector = ["--json", "--local", "--branch", "feature/done"]
|
||||
selector = ["--json", "--branch", "feature/done"]
|
||||
self.assertEqual(calls, [
|
||||
{"argv": ["branch", "cleanup", *selector], "cwd": str(self.repo)},
|
||||
{"argv": ["branch", "cleanup", "--apply", *selector], "cwd": str(self.repo)},
|
||||
@@ -163,6 +220,22 @@ class TestHostCleanup(unittest.TestCase):
|
||||
self.assertEqual(json.loads(self.tree.read_text(encoding="utf-8")), {"rewritten": True})
|
||||
self.assertEqual(result["tree"], {"code": 0, "error": ""})
|
||||
|
||||
def test_an_apply_that_exits_0_but_skips_or_reports_no_result_is_failed_with_pkgmgrs_reason_and_keeps_the_tree(self):
|
||||
outcome = self.fake.parent / "outcome.txt"
|
||||
before = self.tree.read_text(encoding="utf-8")
|
||||
cases = (("skipped", "skipped: changed since plan (dirty: 1 paths)", "skipped: changed since plan (dirty: 1 paths)"), ("no result", "", "pkgmgr reported no result for feature/done"))
|
||||
for name, result, reason in cases:
|
||||
with self.subTest(name=name):
|
||||
outcome.write_text(result, encoding="utf-8")
|
||||
done, calls = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()])
|
||||
self.assertEqual([(entry["status"], entry["reason"], entry["code"]) for entry in done["items"]], [("failed", reason, 0)])
|
||||
self.assertEqual([call["argv"][:3] for call in calls], [["branch", "cleanup", "--json"], ["branch", "cleanup", "--apply"]])
|
||||
self.assertNotIn("tree", done)
|
||||
self.assertEqual(self.tree.read_text(encoding="utf-8"), before)
|
||||
outcome.write_text("done; image kept: demo:feature-done", encoding="utf-8")
|
||||
done, _ = self.run_job([self.item], self.proof([self.item], self.signer), [self.entry()])
|
||||
self.assertEqual([(entry["status"], entry["reason"]) for entry in done["items"]], [("done", "")])
|
||||
|
||||
def test_every_doubtful_item_is_refused_and_changes_nothing(self):
|
||||
pattern = {**self.item, "branch": "feature/*"}
|
||||
option = {**self.item, "branch": "--all"}
|
||||
@@ -181,13 +254,14 @@ class TestHostCleanup(unittest.TestCase):
|
||||
("skip reason", [self.item], self.proof([self.item], self.signer), [self.entry(skip="ignored files")], "gc skip reason", 1),
|
||||
("other worktree", [self.item], self.proof([self.item], self.signer), [self.entry(worktree="/elsewhere")], "worktree changed", 1),
|
||||
("pattern match in pkgmgr", [self.item], self.proof([self.item], self.signer), [self.entry(), self.entry(branch="feature/done-too")], "other branches too", 1),
|
||||
("remote branch delete", [self.item], self.proof([self.item], self.signer), [self.entry(actions=["local-branch", "remote-branch:origin"])], "a signed delete is local only", 1),
|
||||
)
|
||||
for name, items, proof, plan, reason, dry_runs in cases:
|
||||
with self.subTest(name=name):
|
||||
result, calls = self.run_job(items, proof, plan)
|
||||
self.assertEqual([entry["status"] for entry in result["items"]], ["refused"])
|
||||
self.assertIn(reason, result["items"][0]["reason"])
|
||||
self.assertEqual([call["argv"] for call in calls], [["branch", "cleanup", "--json", "--local", "--branch", items[0]["branch"]]] * dry_runs)
|
||||
self.assertEqual([call["argv"] for call in calls], [["branch", "cleanup", "--json", "--branch", items[0]["branch"]]] * dry_runs)
|
||||
self.assertNotIn("tree", result)
|
||||
|
||||
def test_a_signature_deletes_once_and_a_malformed_job_nothing(self):
|
||||
@@ -217,6 +291,44 @@ class TestHostCleanup(unittest.TestCase):
|
||||
self.assertEqual((self.results / f"{job_id}.json").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
|
||||
@unittest.skipUnless(INSTALLED_PKGMGR, "no pkgmgr installed")
|
||||
class TestInstalledPkgmgrContract(unittest.TestCase):
|
||||
def git(self, cwd: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", *args], cwd=cwd, env={**os.environ, **GIT_ENV}, check=True, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
def test_the_installed_pkgmgr_takes_the_runners_dry_run_argv_and_prints_a_plan_the_runner_parses_and_accepts(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base = Path(tmp)
|
||||
origin, repo = base / "origin.git", base / "repo"
|
||||
self.git(base, "init", "-q", "--bare", "-b", "main", str(origin))
|
||||
self.git(base, "init", "-q", "-b", "main", str(repo))
|
||||
self.git(repo, "remote", "add", "origin", origin.as_uri())
|
||||
self.git(repo, "commit", "-q", "--allow-empty", "-m", "one")
|
||||
self.git(repo, "checkout", "-q", "-b", "side")
|
||||
self.git(repo, "commit", "-q", "--allow-empty", "-m", "side")
|
||||
tip = self.git(repo, "rev-parse", "HEAD")
|
||||
self.git(repo, "checkout", "-q", "main")
|
||||
self.git(repo, "merge", "-q", "--no-ff", "-m", "merge side", "side")
|
||||
self.git(origin, "fetch", "-q", str(repo), "main:main")
|
||||
self.git(repo, "fetch", "-q", "origin")
|
||||
self.git(repo, "remote", "set-head", "origin", "main")
|
||||
code, stdout, stderr = host.run(Path(INSTALLED_PKGMGR), host.plan_argv("side", False), str(repo))
|
||||
self.assertEqual(code, 0, stderr)
|
||||
plan = host.plan_items(stdout)
|
||||
self.assertIsNotNone(plan, stdout[:500])
|
||||
self.assertEqual([(entry["branch"], entry["state"], entry["merged_by"], entry["merged_into"], entry["skip"], entry["actions"]) for entry in plan], [("side", "merged-clean", "ancestor", "origin/main", None, ["local-branch"])])
|
||||
self.assertIsNone(host.mismatch(plan[0], {"tip": tip, "worktree": ""}))
|
||||
self.assertTrue(signing.token_free(plan[0], "main"))
|
||||
self.assertEqual(self.git(repo, "rev-parse", "refs/heads/side"), tip)
|
||||
held = base / "held"
|
||||
self.git(repo, "worktree", "add", "-q", str(held), "side")
|
||||
code, stdout, stderr = host.run(Path(INSTALLED_PKGMGR), host.plan_argv("side", False), str(repo))
|
||||
self.assertEqual(code, 0, stderr)
|
||||
plan = host.plan_items(stdout)
|
||||
self.assertEqual([(entry["worktree"], entry["actions"], bool(entry["skip"])) for entry in plan], [(os.path.realpath(held), [], True)], plan)
|
||||
self.assertIn("gc skip reason", host.mismatch(plan[0], {"tip": tip, "worktree": os.path.realpath(held)}))
|
||||
|
||||
|
||||
class TestHostCleanupGitConfig(unittest.TestCase):
|
||||
def test_pkgmgr_runs_no_hook_the_repository_config_names(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user