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:
1 parent
db3c5264f2
commit
90c56cb786
64 files changed
+82829
-451
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>')
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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"]]))
|
||||
Reference in new issue
Block a user