feat(orchestrator): Agents and Artifacts views with spawn tree, orbit, 3D and signed branch deletes

Phase 4 of requirement 001. Agents and Artifacts each offer Orbit, Chord, Swimlane, Tree and 3D: the spawn tree (page-spawn.js) and the artifacts orbit (page-artifacts.js, kvb_orchestrator/artifacts.py) replace the "folgt" placeholders, 3D runs on the vendored 3d-force-graph and three.js (MIT, LICENSE files kept, served from the image under the unchanged CSP), and a Bootstrap Icons sprite (icons.py, MIT) replaces the emoji glyphs, also in the meditation page.

Signed branch deletes: the Tree view offers a delete only for merged-clean branches without a gc skip reason; the operator signs the exact item with the passkey, the hub writes a job without any command (kvb_orchestrator/cleanup.py), and the host path unit runs client/cleanup.py, which verifies the signature, dry-runs pkgmgr branch cleanup for that exact branch, refuses patterns, option names, changed tips, other worktrees and plans naming other branches, deletes, and rewrites the branch tree. pkgmgr runs with core.hooksPath=/dev/null, core.fsmonitor=false and the config hook events disabled through GIT_CONFIG_COUNT, because the cwd is a repository agents can write and pkgmgr releases before 4c8b137 delete without scan mode (test with a positive control that both hook kinds fire without it).

3D labels go into the tooltip as a text node: the vendored tooltip renders a string label as HTML.

Tests: 503 OK, Chromium (CDP) and Firefox (BiDi) walks over every screen at three widths.

MEDUSA acceptances (kvb-think-criticize skeptic 2026-11-07): WEB-FLASK-004 view.py:135 (file template, autoescape on, state via tojson), MEDUSA-MM-SCAN-058 server.py:69/:85 (substring pla in --templates and Places), RH005 client/cleanup.py:199 (raise SystemExit(main())), B704 icons.py:45 (sprite baked into the image, names checked against it), EXA002 page-artifacts.js:221 (openAgent only opens the drawer), STG002 page-constants.js:64 (U+2212 in a static label), PL002 page-graph3d.js:44 (CSS class space-tools; the real tooltip sink fixed), B603 client/cleanup.py:75 (argv without shell; hook hole closed).

🎯 Fix: 92 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 87
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 15:03:14 +00:00
1 parent db3c5264f2
commit 90c56cb786
64 files changed
+82829 -451

No files matched your search

+4 -1
View File
@@ -4,6 +4,7 @@ import secrets
import time
from .asking import ask_state
from .cleanup import cleanup_request
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect, field
from .records import digest, shown
@@ -142,7 +143,9 @@ class Approving:
def sign(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items",))
hashes = [signing.item_hash(item["signed"]) for item in self.checked(payload["items"]) if item["signed"] is not None]
raw = payload["items"]
signed = self.deletable(raw) if cleanup_request(raw) else [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
hashes = [signing.item_hash(item) for item in signed]
credentials = [key["id"] for key in self.keys()]
if not hashes:
return {"payload": None, "challenge": None, "credentials": credentials}
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import itertools
import re
import threading
from collections.abc import Callable
from pathlib import Path
from .records import epoch, iso_epoch, read_json, shown
from .register import ENDED, status_of
from .tree import inside, owns
PUSH = re.compile(r"\b(?:push|gipsn|git-sign-push)\b")
GATES = ("gate", "test")
LANE_LIMIT = 200
SHARED_SHOWN = 20
TITLE_LIMIT = 160
def artefacts_of(task: str, row: dict) -> list[dict]:
listed = row.get("artifacts")
found = []
for entry in listed if isinstance(listed, list) else []:
ref = entry.get("ref") if isinstance(entry, dict) else None
if isinstance(ref, str) and ":" in ref.strip(":"):
kind, _, name = ref.partition(":")
found.append({"task": task, "ref": ref, "kind": kind, "name": name, "added": shown(entry.get("added")), "ended": status_of(row) in ENDED})
return found
def lane(paths: list[str], reviews: list[dict], history: list[dict], jobs: list[dict]) -> list[dict]:
"""Args:
paths: the worktree paths of one branch.
reviews: the hub's reviews; an approved one stands for the commit
the agent makes right after the approval.
history: queue-history.jsonl rows; test and gate kinds count as gates.
jobs: the runner jobs; a push command counts as a push.
Returns:
The branch's swimlane events, oldest first, each with ts, kind
(commit, review, gate or push), title and ok (true, false or None).
"""
def mine(path: object) -> bool:
return isinstance(path, str) and any(inside(path, folder) for folder in paths)
events = []
for review in (review for review in reviews if mine(review.get("worktree"))):
created, title = iso_epoch(review.get("created")), shown(review.get("title"))[:TITLE_LIMIT]
events.append({"ts": created, "kind": "review", "title": title, "ok": None})
if review.get("gate") in ("pass", "fail"):
events.append({"ts": created, "kind": "gate", "title": f"Gate {review['gate']}: {title}", "ok": review["gate"] == "pass"})
if review.get("status") == "approved":
events.append({"ts": iso_epoch(review.get("decided")), "kind": "commit", "title": title, "ok": True})
for row in (row for row in history if mine(row.get("workdir")) and str(row.get("kind", "")).startswith(GATES)):
events.append({"ts": epoch(row.get("end")) or epoch(row.get("start")), "kind": "gate", "title": shown(row.get("kind")), "ok": row.get("exit") == 0})
for job in (job for job in jobs if mine(job.get("directory")) and PUSH.search(str(job.get("command", "")))):
finished = job.get("status") in ("done", "failed", "refused")
when = epoch(job.get("finished")) or epoch(job.get("started")) or epoch(job.get("created_ts"))
events.append({"ts": when, "kind": "push", "title": shown(job.get("command"))[:TITLE_LIMIT], "ok": job.get("status") == "done" if finished else None})
return sorted((event for event in events if event["ts"] is not None), key=lambda event: event["ts"])[-LANE_LIMIT:]
class Touched:
def __init__(self, stored: Callable[[str, str], Path]) -> None:
"""Args:
stored: maps a review id and suffix to its stored file; the
.json lists the files the review touched and never changes,
so each is read once.
"""
self.stored = stored
self.lock = threading.Lock()
self.known: dict[str, frozenset[str]] = {}
def of(self, review_id: str) -> frozenset[str]:
with self.lock:
if review_id not in self.known:
data = read_json(self.stored(review_id, ".json"))
files = data.get("files") if isinstance(data, dict) else None
self.known[review_id] = frozenset(entry["path"] for entry in files or [] if isinstance(entry, dict) and isinstance(entry.get("path"), str) and entry["path"])
return self.known[review_id]
def enrich(scan: dict, tasks: dict[str, dict], reviews: list[dict], touched: Touched, history: list[dict], jobs: list[dict]) -> dict:
"""Returns:
The scan with each worktree's register artefacts, the tasks whose
artefacts belong to no worktree, each branch's touched files and
swimlane events, and per repository the file overlaps between
branches and the merges the gc classification names.
"""
placed: set[str] = set()
repos = []
for entry in scan["repos"]:
branches, files = [], {}
for item in entry["branches"]:
trees = []
for tree in item["worktrees"]:
held = [(task, row) for task, row in sorted(tasks.items()) if owns(row, tree["path"], item["name"])]
placed.update(task for task, _ in held)
trees.append({**tree, "artefacts": [found for task, row in held for found in artefacts_of(task, row)]})
paths = [tree["path"] for tree in trees]
files[item["name"]] = frozenset().union(*(touched.of(review["id"]) for review in reviews if isinstance(review.get("worktree"), str) and any(inside(review["worktree"], path) for path in paths)))
branches.append({**item, "worktrees": trees, "files": len(files[item["name"]]), "events": lane(paths, reviews, history, jobs)})
overlaps = []
for a, b in itertools.combinations(sorted(files), 2):
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"]]
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}
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import json
import os
import secrets
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from .errors import Missing, ToolError
from .fields import decode, expect
from .records import read_json, stamp
from .tree import offered
ITEM_KEYS = ("kind", "repository", "branch", "worktree", "tip")
JOB_KEYS = ("repository", "branch", "worktree", "tip")
JOB_VERSION = 1
ITEM_LIMIT = 100
RESULTS_SHOWN = 20
@dataclass(frozen=True)
class Spool:
"""Args:
root: the host-mounted directory; the hub writes signed jobs into
jobs/ by rename, the host runner writes each result into
results/<id>.json and removes the job.
"""
root: Path
@property
def jobs(self) -> Path:
return self.root / "jobs"
@property
def results(self) -> Path:
return self.root / "results"
def ready(self) -> Spool:
for folder in (self.root, self.jobs, self.results):
folder.mkdir(mode=0o700, parents=True, exist_ok=True)
return self
def put(self, job_id: str, job: dict) -> None:
# written beside jobs/ and renamed in: the path unit wakes on a non-empty jobs/, never on a half-written file
with tempfile.NamedTemporaryFile("w", dir=self.root, prefix=f".{job_id}-", delete=False, encoding="utf-8") as handle:
json.dump(job, handle, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(handle.name, self.jobs / f"{job_id}.json")
def cleanup_request(raw: object) -> bool:
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == "cleanup" for entry in raw)
class Cleaning:
def deletable(self, raw: object) -> list[dict]:
"""Args:
raw: the items the page chose, each {"kind": "cleanup",
"repository", "branch", "worktree", "tip"} as /api/tree
offered it.
Returns:
The signed items, each still a merged-clean branch without a gc
skip reason at the same tip in the current tree file.
"""
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"löschen gesperrt: {scan.get('delete_reason') or scan.get('error') or 'delete_ready ist false'}")
known = offered(scan)
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 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:
raise ToolError("items must be distinct")
chosen.append(entry)
return chosen
def cleanup(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items", "proof"))
items = self.deletable(payload["items"])
proof = self.covered(payload["proof"], items, time.time())
job_id = secrets.token_hex(8)
job = {"v": JOB_VERSION, "id": job_id, "created": stamp(), "items": [{key: item[key] for key in JOB_KEYS} for item in items], "proof": proof}
self.spool.put(job_id, job)
names = ", ".join(item["branch"] for item in items)
self.log.add({"kind": "run", "title": f"Löschen signiert: {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 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):
return {**result, "id": job_id, "status": "done"}
if (self.spool.jobs / f"{job_id}.json").is_file():
return {"id": job_id, "status": "queued", "items": []}
raise Missing(job_id)
def cleanup_results(self) -> list[dict]:
try:
paths = sorted(self.spool.results.glob("*.json"), key=lambda path: path.stat().st_mtime)[-RESULTS_SHOWN:]
except OSError:
return []
found = [read_json(path) for path in paths]
return [result for result in found if isinstance(result, dict) and isinstance(result.get("items"), list)]
+18 -1
View File
@@ -34,7 +34,12 @@ RUNNER_ROUTE = re.compile(r"/api/runner/([0-9a-f]{16})/(claim|report)")
PROOF_ROUTE = re.compile(r"/api/reviews/([0-9a-f]{16})")
SIGN_ROUTE = "/api/sign"
RULE_SIGN_ROUTE = "/api/rules/sign"
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE)
CLEANUP_ROUTE = "/api/cleanup"
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
SCRIPT_TYPE = "text/javascript; charset=utf-8"
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE)
class Handler(Exchange):
@@ -52,10 +57,16 @@ class Handler(Exchange):
item = ITEM.fullmatch(path)
entry = ENTRY.fullmatch(path)
proof = PROOF_ROUTE.fullmatch(path)
vendored = VENDOR_ROUTE.fullmatch(path)
cleanup = CLEANUP_STATE.fullmatch(path)
if self.host() is None:
self.send_text(HTTPStatus.FORBIDDEN, "forbidden host")
elif path == "/":
self.index()
elif vendored and vendored.group(1) in VENDOR:
self.vendored(VENDOR[vendored.group(1)])
elif cleanup:
self.answer(lambda: hub.cleanup_state(cleanup.group(1)), HTTPStatus.OK)
elif proof:
self.answer(lambda: hub.review_proof(proof.group(1)), HTTPStatus.OK)
elif path == "/api/state":
@@ -112,6 +123,8 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.sign(body), HTTPStatus.OK)
if path == RULE_SIGN_ROUTE:
return lambda body: self.answer(lambda: hub.sign_rule(body), HTTPStatus.OK)
if path == CLEANUP_ROUTE:
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
if inbox:
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo:
@@ -188,6 +201,10 @@ class Handler(Exchange):
headers = {"Content-Security-Policy": csp, "X-Frame-Options": "DENY"}
self.send(HTTPStatus.OK, "text/html; charset=utf-8", page.encode("utf-8"), headers)
def vendored(self, name: str) -> None:
headers = {"Content-Security-Policy": PLAIN_CSP, "Cache-Control": "private, max-age=86400"}
self.attempt((self.server.hub.vendor / name).read_bytes, lambda data: self.send(HTTPStatus.OK, SCRIPT_TYPE, data, headers))
def stored(self, review_id: str, name: str) -> None:
suffix, content_type, csp = STORED[name]
reviews = self.server.hub.reviews
+17 -2
View File
@@ -5,12 +5,16 @@ from dataclasses import dataclass
from pathlib import Path
import jinja2
from markupsafe import Markup
from .approving import Approving
from .artifacts import Touched
from .asking import Asking
from .batches import Pending
from .cleanup import Cleaning, Spool
from .engines import Engines, Speech
from .events import Log, Watch
from .icons import sheet
from .inbox import Inbox
from .jobs import Jobs
from .language import Language, Model
@@ -34,6 +38,8 @@ from .view import View
class Places:
tree_file: Path
timers: Path
vendor: Path
cleanup: Path
def environment(templates: Path) -> jinja2.Environment:
@@ -49,7 +55,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Cleaning, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
@@ -75,6 +81,10 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
settings: Settings
speech: Speech
approvers: Path
vendor: Path
icons: Markup
spool: Spool
touched: Touched
def hosts(self) -> tuple[str, str]:
return f"127.0.0.1:{self.public_port}", f"localhost:{self.public_port}"
@@ -86,7 +96,12 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Inbox, Rules, Jobs, Sour
def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_state: Path, transcripts: Path, scripts: Runner, templates: Path, places: Places, model: Model, engines: Engines, approvers: Path) -> Hub:
uploads = state / "uploads"
uploads.mkdir(exist_ok=True)
reviews = Reviews(state)
return Hub(
vendor=places.vendor,
icons=sheet(places.vendor, templates),
spool=Spool(places.cleanup).ready(),
touched=Touched(reviews.stored),
approvers=approvers,
speech=Speech(engines, state / "tts"),
rules=Ledger(state / "rules.json"),
@@ -103,7 +118,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
council=council,
pages=pages,
public_port=public_port,
reviews=Reviews(state),
reviews=reviews,
asks=Ledger(state / "asks.json"),
decisions=Ledger(state / "decisions.json"),
messages=Ledger(state / "messages.json"),
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import re
from pathlib import Path
from markupsafe import Markup
from .errors import ToolError
SPRITE = Path("bootstrap-icons") / "bootstrap-icons.svg"
SYMBOL = re.compile(r'<symbol class="bi bi-[a-z0-9-]+" viewBox="0 0 16 16" id="([a-z0-9-]+)">(.*?)</symbol>', re.DOTALL)
NAME = re.compile(r'"#?bi-([a-z0-9]+(?:-[a-z0-9]+)*)"')
SOURCES = ("*.js", "*.j2")
def symbols(sprite: Path) -> dict[str, str]:
return dict(SYMBOL.findall(sprite.read_text(encoding="utf-8")))
def used(templates: Path) -> list[str]:
names: set[str] = set()
for pattern in SOURCES:
for path in sorted(templates.glob(pattern)):
names.update(NAME.findall(path.read_text(encoding="utf-8")))
return sorted(names)
def sheet(vendor: Path, templates: Path) -> Markup:
"""Args:
vendor: the directory holding the vendored Bootstrap Icons sprite.
templates: the page sources; every string literal "bi-<name>" or
"#bi-<name>" in them names an icon the page shows.
Returns:
A hidden inline SVG holding exactly those symbols, each as
bi-<name>, so the page draws them with <use href="#bi-<name>"> and
loads nothing.
"""
known = symbols(vendor / SPRITE)
names = used(templates)
missing = [name for name in names if name not in known]
if missing:
raise ToolError(f"icons missing from {vendor / SPRITE}: {', '.join(missing)}")
body = "".join(f'<symbol id="bi-{name}" viewBox="0 0 16 16">{known[name]}</symbol>' for name in names)
return Markup(f'<svg xmlns="http://www.w3.org/2000/svg" class="sprite" aria-hidden="true" focusable="false">{body}</svg>')
+6 -2
View File
@@ -2,13 +2,14 @@ from __future__ import annotations
import time
from .artifacts import enrich
from .council import HUMAN, agent, read_claims
from .crons import cron_state, queue_rows, timer_rows
from .errors import ToolError
from .fields import single
from .lifetimes import edge_times, lifetime, session_gone, subagent_rows
from .names import PSEUDO, SESSION_ID, Directory, title_sightings
from .records import epoch, iso_epoch
from .records import epoch, iso_epoch, read_jsonl
from .register import REGISTER, read_register
from .tree import assign
@@ -45,7 +46,10 @@ class Sources:
def repositories(self) -> dict:
tasks = read_register(self.council / REGISTER)
return assign(self.tree.get(), tasks, read_claims(self.council), self.reviews.listed())
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()}
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}
+44 -7
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from .council import agent
from .records import iso_epoch
from .register import ENDED, status_of
from .signed import signing
LABEL_PARTS = 3
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
@@ -31,19 +32,47 @@ def worktree(item: dict) -> dict:
}
def branch(item: dict) -> dict:
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.
"""
tip = (item["local_sha"] or item["head"] or "")[:12]
merged = f", gemergt durch {item['merged_by']}" if item["merged_by"] else ""
lines = [f"{item['branch']} @ {tip}: {item['state']}{merged}"]
for action in item["actions"]:
if action == "worktree":
lines.append(f"entfernt den Worktree {item['worktree']}")
elif action == "local-branch":
lines.append(f"löscht den lokalen Branch {item['branch']}")
elif action.startswith("remote"):
lines.append(f"bleibt: {action} (nur lokal)")
else:
lines.append(action)
lines += [f"stoppt den Compose-Stack {name} ohne -v" for name in item["compose_projects"]]
lines += [f"entfernt das Image {name}" for name in item["images"]]
return lines
def branch(item: dict, path: str) -> 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"])
return {
"name": item["branch"],
"head": item["local_sha"] or item["head"] or remote,
"head": head,
"upstream": item["upstream"] or "",
"ahead": item["ahead"] or 0,
"behind": item["behind"] or 0,
"gone": item["gone"],
"state": item["state"],
"merged_by": item["merged_by"],
"merged_into": item["merged_into"],
"skip": item["skip"],
"reason": item["reason"],
"plan": dry_run(item),
"cleanup": signing.cleanup_item(path, item["branch"], item["worktree"] or "", item["local_sha"]) if open_to_delete else None,
"worktrees": [worktree(item)] if item["worktree"] else [],
}
@@ -53,11 +82,15 @@ 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) for item in items if item["branch"]],
"branches": [branch(item, entry["path"]) 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 read_tree(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
@@ -75,6 +108,13 @@ def inside(path: str, folder: str) -> bool:
return path == folder or path.startswith(f"{folder.rstrip('/')}/")
def owns(row: dict, path: str, name: str) -> bool:
workdir = row.get("workdir")
if isinstance(workdir, str):
return workdir.startswith("/") and inside(workdir, path)
return row.get("branch") == name
def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list[dict]) -> dict:
current = {task: row for task, row in tasks.items() if status_of(row) not in ENDED}
holders: dict[str, set[str]] = {}
@@ -92,10 +132,7 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list
for tree in item["worktrees"]:
workers = set()
for task, row in current.items():
workdir, name = row.get("workdir"), row.get("branch")
by_dir = isinstance(workdir, str) and workdir.startswith("/") and inside(workdir, tree["path"])
by_branch = not isinstance(workdir, str) and name == item["name"]
if by_dir or by_branch:
if owns(row, tree["path"], item["name"]):
workers |= holders[task]
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
branches.append({**item, "worktrees": trees})
+1 -1
View File
@@ -132,5 +132,5 @@ class View:
def render(self, nonce: str) -> tuple[str, str]:
now = time.time()
state = self.state(now - RADIO_WINDOW, now, now, 0)
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state)
page = self.templates.get_template("index.html.j2").render(nonce=nonce, state=state, icons=self.icons)
return page, INDEX_CSP.format(nonce, " ".join(["'self'", *state["media"]]))