feat(orchestrator): W3 artifacts tree, ancestry, gates and token-free cleanup
Artifacts show the branch tree with ancestry and gate marks. The cleanup runner calls pkgmgr branch cleanup with its real argv and parses its repos[*].items output, refuses any plan with a remote-branch action, marks an item done only when pkgmgr reports it done, and offers no delete for a branch a worktree holds. Every text states what the pkgmgr command does to volumes. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W3 e2e never ran; the client switches to --local once pkgmgr ships it. 🎯 Fix: 90 % 💥 Break: 6 % 💸 Cost: 30 % ⏱️ Time: 40 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ⛔ break 6 % > 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd8fc08603
commit
f7a59d43a5
33 files changed
+1998
-107
No files matched your search
@@ -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:
|
||||
@@ -18,9 +21,12 @@ Returns:
|
||||
in the signed directory, each signature once and before its expiry.
|
||||
Runs every signed task change (priority pin or WSJF estimate) as
|
||||
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the
|
||||
argv shape itself, and kills its process group after 30 s. Refuses task
|
||||
changes without --rasci and --council or into a council that is a
|
||||
symlink or another uid's, and every other job. Posts the last 200 output lines
|
||||
argv shape itself, and kills its process group after 30 s. Runs every
|
||||
signed gate change (rule set|drop, parents add|drop) the same way as
|
||||
`python3 -I -B <gates> <argv>` with the operator's HOME, so gates.py
|
||||
writes its default gates.db. Refuses task changes without --rasci and
|
||||
--council or into a council that is a symlink or another uid's, gate
|
||||
changes without --gates, and every other job. Posts the last 200 output lines
|
||||
back while it runs and the exit code at its end. The exit code is 0.
|
||||
"""
|
||||
|
||||
@@ -52,8 +58,10 @@ BEAT = 2.0
|
||||
SAFE_PATH = "/usr/bin:/bin"
|
||||
TASK_JOB = "task"
|
||||
TASK_LIMIT = 30.0
|
||||
GATE_JOB = "gate"
|
||||
REFUSED = "refused: no valid operator signature for this command"
|
||||
UNREGISTERED = "refused: this runner was started without --rasci and --council; reinstall its unit with make orchestrator-runner"
|
||||
UNGATED = "refused: this runner was started without --gates; reinstall its unit with make orchestrator-runner"
|
||||
|
||||
|
||||
class Register(NamedTuple):
|
||||
@@ -113,6 +121,24 @@ def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def gate_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
|
||||
"""Args:
|
||||
job: the claimed gate change with argv and proof.
|
||||
trust: the pinned keys, the runner's replay store and the origin.
|
||||
now: the current time in epoch seconds.
|
||||
|
||||
Returns:
|
||||
Why the change must not run, or None when signing.gate_argv()
|
||||
accepts its argv and a pinned key signed it.
|
||||
"""
|
||||
try:
|
||||
argv = signing.gate_argv(job["argv"])
|
||||
verifier.check(job.get("proof"), signing.gate_item(argv), "", trust, now)
|
||||
except (signing.Refused, OSError) as error:
|
||||
return f"{REFUSED}: {error}"
|
||||
return None
|
||||
|
||||
|
||||
def council_refusal(council: Path) -> str | None:
|
||||
"""Args:
|
||||
council: the directory rasci.py writes the register into.
|
||||
@@ -161,6 +187,23 @@ def change(job: dict, register: Register, report: Callable[[dict], None]) -> int
|
||||
return spawn([sys.executable, "-I", "-B", str(register.rasci), *argv], "/", env, report, TASK_LIMIT)
|
||||
|
||||
|
||||
def gate(job: dict, gates: Path, report: Callable[[dict], None]) -> int:
|
||||
"""Args:
|
||||
job: the claimed and verified gate change with argv.
|
||||
gates: the gates script.
|
||||
report: called with {"output", "code", "refused"}.
|
||||
|
||||
Returns:
|
||||
The exit code of gates.py, run from / on the argv gate_argv() checked.
|
||||
|
||||
Raises:
|
||||
signing.Refused for an argv of any other shape.
|
||||
"""
|
||||
argv = signing.gate_argv(job["argv"])
|
||||
env = {"PATH": SAFE_PATH, "HOME": str(Path.home())}
|
||||
return spawn([sys.executable, "-I", "-B", str(gates), *argv], "/", env, report, TASK_LIMIT)
|
||||
|
||||
|
||||
def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int:
|
||||
"""Args:
|
||||
argv: the program and its arguments, run without a shell.
|
||||
@@ -224,22 +267,26 @@ def sender(base: str, job_id: str) -> Callable[[dict], None]:
|
||||
return send
|
||||
|
||||
|
||||
def cycle(base: str, trust: verifier.Trust, register: Register | None) -> None:
|
||||
def cycle(base: str, trust: verifier.Trust, register: Register | None, gates: Path | None = None) -> None:
|
||||
for job in call(base, "/api/runner/jobs", None)["jobs"]:
|
||||
try:
|
||||
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
|
||||
except urllib.error.HTTPError:
|
||||
continue
|
||||
send = sender(base, claimed["id"])
|
||||
task = claimed.get("kind") == TASK_JOB
|
||||
if not task:
|
||||
reason = refusal(claimed, trust, time.time())
|
||||
else:
|
||||
kind = claimed.get("kind")
|
||||
if kind == TASK_JOB:
|
||||
reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time())
|
||||
elif kind == GATE_JOB:
|
||||
reason = UNGATED if gates is None else gate_refusal(claimed, trust, time.time())
|
||||
else:
|
||||
reason = refusal(claimed, trust, time.time())
|
||||
if reason is not None:
|
||||
send({"output": reason, "code": None, "refused": True})
|
||||
elif task:
|
||||
elif kind == TASK_JOB:
|
||||
change(claimed, register, send)
|
||||
elif kind == GATE_JOB:
|
||||
gate(claimed, gates, send)
|
||||
else:
|
||||
execute(claimed, send)
|
||||
|
||||
@@ -252,6 +299,7 @@ def main() -> int:
|
||||
parser.add_argument("--replay", type=Path, required=True)
|
||||
parser.add_argument("--rasci", type=Path)
|
||||
parser.add_argument("--council", type=Path)
|
||||
parser.add_argument("--gates", type=Path)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
base = hub_api.base_url(args.port)
|
||||
@@ -259,7 +307,7 @@ def main() -> int:
|
||||
register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None
|
||||
while True:
|
||||
try:
|
||||
cycle(base, trust, register)
|
||||
cycle(base, trust, register, args.gates)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if args.once:
|
||||
|
||||
@@ -27,6 +27,11 @@ PRIO = re.compile(r"[1-9][0-9]{0,2}")
|
||||
FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
|
||||
ESTIMATOR = "Operator"
|
||||
COMMANDS = "operator/"
|
||||
GATE_LEVELS = ("none", "quick", "test", "quality-high", "e2e")
|
||||
GATE_ACTIONS = ("commit", "merge", "push", "pr")
|
||||
GATE_REPO = re.compile(r"/[^\x00-\x1f\x7f]{0,4095}")
|
||||
GATE_BRANCH = re.compile(r"(?![-/])(?!.*\.\.)(?!.*@\{)(?!.*//)[^\x00-\x20\x7f~^:?*\[\\]{1,255}(?<![/.])")
|
||||
TOKEN_FREE_MERGE = "ancestor"
|
||||
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
||||
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
||||
WARNINGS = (
|
||||
@@ -184,6 +189,72 @@ def task_argv(task: object, argv: object) -> list[str]:
|
||||
raise Refused("the argv is no task priority or WSJF estimate change of this task")
|
||||
|
||||
|
||||
def gate_item(argv: list[str]) -> dict:
|
||||
return {"kind": "gate", "argv": argv}
|
||||
|
||||
|
||||
def gate_argv(argv: object) -> list[str]:
|
||||
"""Args:
|
||||
argv: the gates.py arguments, one of `rule set --repo R --branch B
|
||||
--action A --level L`, `rule drop --repo R --branch B --action A`,
|
||||
`parents add --repo R --branch B --parent P` (source operator) or
|
||||
`parents drop --repo R --branch B --parent P`; R an absolute path,
|
||||
B and P branch names, P not B.
|
||||
|
||||
Returns:
|
||||
argv, the hub and the runner check it the same way.
|
||||
|
||||
Raises:
|
||||
Refused for every other argv.
|
||||
"""
|
||||
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
|
||||
raise Refused("the argv is no list of texts")
|
||||
head, values = argv[:2], dict(zip(argv[2::2], argv[3::2]))
|
||||
names = {("rule", "set"): ("--repo", "--branch", "--action", "--level"), ("rule", "drop"): ("--repo", "--branch", "--action"), ("parents", "add"): ("--repo", "--branch", "--parent"), ("parents", "drop"): ("--repo", "--branch", "--parent")}.get(tuple(head))
|
||||
if names is None or argv[2::2] != list(names) or len(argv) != 2 + 2 * len(names):
|
||||
raise Refused("the argv is no gates.py rule set|drop or parents add|drop of one branch")
|
||||
repo = values["--repo"]
|
||||
if not GATE_REPO.fullmatch(repo) or "/../" in f"{repo}/" or "/./" in f"{repo}/":
|
||||
raise Refused("the repository is no absolute path")
|
||||
branches = [values[name] for name in ("--branch", "--parent") if name in values]
|
||||
if not all(GATE_BRANCH.fullmatch(name) for name in branches) or len(set(branches)) != len(branches):
|
||||
raise Refused("the branch or parent is no branch name, or the branch is its own parent")
|
||||
if values.get("--action", GATE_ACTIONS[0]) not in GATE_ACTIONS or values.get("--level", GATE_LEVELS[0]) not in GATE_LEVELS:
|
||||
raise Refused(f"the action must be one of {', '.join(GATE_ACTIONS)} and the level one of {', '.join(GATE_LEVELS)}")
|
||||
return argv
|
||||
|
||||
|
||||
def names_default(ref: object, default: object) -> bool:
|
||||
"""Args:
|
||||
ref: a ref as pkgmgr names it, bare such as main or behind one
|
||||
remote such as origin/main, the remote-tracking ref it compares
|
||||
against when the remote has a HEAD.
|
||||
default: the repository's default branch as the tree file names it,
|
||||
without the remote (pkgmgr tree.py strips it).
|
||||
|
||||
Returns:
|
||||
True when ref is the default branch.
|
||||
"""
|
||||
if not isinstance(ref, str) or not isinstance(default, str) or not default:
|
||||
return False
|
||||
remote, _, name = ref.partition("/")
|
||||
return ref == default or (bool(remote) and name == default)
|
||||
|
||||
|
||||
def token_free(entry: dict, default: object) -> bool:
|
||||
"""Args:
|
||||
entry: a pkgmgr tree or gc plan item with merged_by, merged_into and
|
||||
remotes.
|
||||
default: the repository's default branch as the tree file names it.
|
||||
|
||||
Returns:
|
||||
True for a local branch merged by ancestry into the default branch
|
||||
(names_default) that has no remote branch: deleting it needs no
|
||||
GitHub token.
|
||||
"""
|
||||
return entry.get("merged_by") == TOKEN_FREE_MERGE and names_default(entry.get("merged_into"), default) and not entry.get("remotes")
|
||||
|
||||
|
||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from .cleanup import cleanup_request
|
||||
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
|
||||
from .errors import Conflict, Missing, ToolError
|
||||
from .fields import decode, expect, field
|
||||
from .gating import gate_request
|
||||
from .offices import office_request
|
||||
from .records import digest, shown
|
||||
from .register import REGISTER, adder, operator_rows, read_register, runnable
|
||||
@@ -250,6 +251,8 @@ class Approving:
|
||||
signed = self.appointable(raw)
|
||||
elif task_request(raw):
|
||||
signed = self.task_changes(raw)
|
||||
elif gate_request(raw):
|
||||
signed = self.gate_changes(raw)
|
||||
else:
|
||||
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
|
||||
return self.challenge_for(signed)
|
||||
|
||||
@@ -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"}
|
||||
@@ -40,6 +40,8 @@ CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
|
||||
OFFICES_ROUTE = "/api/offices"
|
||||
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
|
||||
TASKS_ROUTE = "/api/tasks"
|
||||
GATES_ROUTE = "/api/gates"
|
||||
TOKEN_ROUTE = "/api/cleanup/token"
|
||||
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
|
||||
SCRIPT_TYPE = "text/javascript; charset=utf-8"
|
||||
FONT_TYPE = "font/woff2"
|
||||
@@ -57,7 +59,7 @@ SETTINGS_SIGN_ROUTE = "/api/settings/sign"
|
||||
CHOICES_ROUTE = "/api/choices"
|
||||
CHOICES_SIGN_ROUTE = "/api/choices/sign"
|
||||
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE)
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE)
|
||||
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE, GATES_ROUTE, TOKEN_ROUTE)
|
||||
|
||||
|
||||
class Handler(Exchange):
|
||||
@@ -168,6 +170,10 @@ class Handler(Exchange):
|
||||
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
|
||||
if path == TASKS_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED)
|
||||
if path == GATES_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.change_gates(body), HTTPStatus.ACCEPTED)
|
||||
if path == TOKEN_ROUTE:
|
||||
return lambda body: self.answer(lambda: hub.token_row(body), HTTPStatus.CREATED)
|
||||
if inbox:
|
||||
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
|
||||
if undo:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -413,6 +413,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "style-tasks.css" %}
|
||||
|
||||
{% include "style-bubbles.css" %}
|
||||
|
||||
{% include "style-ancestry.css" %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -457,6 +459,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "nav.js" %}
|
||||
|
||||
{% include "ancestry.js" %}
|
||||
|
||||
{% include "popup.js" %}
|
||||
|
||||
{% include "faces.js" %}
|
||||
@@ -528,6 +532,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-cleanup.js" %}
|
||||
|
||||
{% include "page-ancestry.js" %}
|
||||
|
||||
{% include "page-offices.js" %}
|
||||
|
||||
{% include "page-tasks.js" %}
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user