diff --git a/README.md b/README.md index 5294ada..e568662 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,8 @@ Both commands also patch the target's `.claude/settings.local.json` — the mach The global install also runs the Agent Orchestrator, a local review page in which the operator approves or rejects the agents' staged commits (the `kvb-git-diff` skill): it copies `orchestrator/` to `~/.local/share/kvb-orchestrator`, starts it there with `docker compose up -d --build` (restart `unless-stopped`, bound to `127.0.0.1` only, state in `~/.local/state/kvb-orchestrator`) and registers its MCP server for Claude Code at user scope. Its port is `KVB_ORCHESTRATOR_PORT` in `orchestrator/.env`; the hub page is `http://localhost:/`, the MCP endpoint `/mcp`. Without docker the hub is skipped and the commit skills ask in chat. The hub reads new radio traffic aloud in maritime phraseology and takes push-to-talk input; its image builds both engines from pinned, sha256-verified sources and runs each as a separate process: Piper (`piper-tts`, GPL-3.0-or-later) with the `en_US-libritts-high` voice (904 speakers, trained on LibriTTS, CC BY 4.0, from `rhasspy/piper-voices`) and whisper.cpp (MIT) with the multilingual `ggml-base-q8_0` model (OpenAI Whisper weights, MIT). The operator's own voice is `KVB_ORCHESTRATOR_OPERATOR_VOICE` in `orchestrator/.env`. Its Tree reads only the file `pkgmgr branch tree` writes: the install clones `PKGMGR_REPO` (default the GitHub repository, never the sandbox-writable local checkout), builds the newest `v*` tag a fully trusted GPG key signed into `~/.local/share/kvb-orchestrator/pkgmgr`, and enables the `kvb-orchestrator-tree.timer` user timer, which writes `~/.local/state/pkgmgr/tree/branch-tree.json` every minute while the hub page polls; the hub mounts that directory read-only and starts no git. A signed tag without `branch tree` leaves the timer off. +The hub page loads nothing from the network: its icons come from Bootstrap Icons 1.13.1 (`orchestrator/vendor/bootstrap-icons`, MIT), inlined per page as the symbols it uses, and its 3D views from `3d-force-graph` 1.80.1 and `three` 0.186.1 (`orchestrator/vendor`, MIT, served by the hub under `/vendor/`). In the Artifacts Tree, "Löschen" on a `merged-clean` branch without skip reason, or "Alle gemergten löschen", shows the dry-run lines and asks for the passkey signature; the hub then writes a job (repository, branch, worktree and tip per item, no command) into `~/.local/state/kvb-orchestrator/cleanup/jobs`. The `kvb-orchestrator-cleanup.path` user unit runs `client/cleanup.py` on the host, which verifies the signature against the pinned keys, runs `pkgmgr branch cleanup --json --local --branch ` as a dry run in the repository, then `--apply` only when that plan names exactly the signed branch as `merged-clean` at the signed tip and worktree, refuses everything else unchanged, writes each result to `cleanup/results` and rewrites the tree file. The buttons stay disabled while the tree file says `delete_ready: false`. + Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=` pins the passkey that "Passkey registrieren" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage). The hub is only a relay: every approval of an ask, a staged commit or an operator command is signed end to end with a WebAuthn passkey held in KeePassXC (through KeePassXC-Browser, relying party `localhost`, origin `http://localhost:`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:/` (WebAuthn needs the domain; on `127.0.0.1` the page links there), choose "Passkey registrieren" in the settings and run the host command the page shows, `python3 ~/.local/share/kvb-orchestrator/client/approval.py pin --port --approvers ~/.config/kvb-orchestrator/approvers.json --credential ''`, in a terminal outside every sandbox: it pins the key in `~/.config/kvb-orchestrator/approvers.json` with mode 0600, a file no sandboxed agent can write and the hub mounts read-only; the hub never pins. Each approval, or one bulk selection, is one signature over the hashes of every item, a nonce and an expiry; each auto-approval rule is signed once. The executors verify it themselves against the pinned keys and fail closed: the runner (`client/runner.py`, the `kvb-orchestrator-runner` user service) runs only signed commands, each signature once, recorded in `~/.local/state/kvb-orchestrator-approvals/runner.json`; the PermissionRequest hook (`client/permission_hook.py --port --timeout --approvers ~/.config/kvb-orchestrator/approvers.json --replay ~/.local/state/kvb-orchestrator-approvals/hook.json`) allows only a signed item that matches its own tool, command, cwd and session, else it leaves the decision to Claude Code's own prompt; the commit skills run `approval.py verify` on the staged tree before committing, else they ask in the terminal. With no key pinned, every executor refuses; decisions and rejections stay unsigned. A signed rule never covers a command holding `;`, `&`, `|`, a backtick, `$`, `<`, `>`, parentheses or a line break: such a command always asks. The hub page is trusted while it asks for a signature or shows the blob to pin: whoever controls the hub container can show one item and have another signed, or hand over a key of its own. Any local process, sandboxed agents included, can also send the unsigned operator actions (messages, decision choices and rules, board actions, rejections) with the hub's guard headers, and the container reads the host `/tmp` for the review pages in `/tmp/kvb-git-diff`. All three close with the agent VMs of core requirement 093, like the Docker-socket gap. diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 993c206..d7f2961 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -118,8 +118,8 @@ from three views down to any agent, branch or artefact. items, and "Löschen" needs the operator signature (`/api/sign`). Rejected: a second click without signature, one click without preview. - **Execution**: the orchestrator writes a signed job into a host - directory; a systemd user path unit runs `pkgmgr branch gc --apply` for - exactly those items and writes the result back. Rejected: an + directory; a systemd user path unit runs `pkgmgr branch cleanup --apply + --local` for exactly those items and writes the result back. Rejected: an `operator.py` row (not direct). 22. **Organisation update**: a new skill `kvb-org-update` tells every agent and client which skills changed and how to use them, as one @@ -348,8 +348,8 @@ flowchart TD and tip sha per item, no command) into a host-mounted job directory and holds no git, token or key. - [ ] A systemd user path unit verifies the signature on the host and runs - `pkgmgr branch gc --apply --local` for exactly the signed items, matched - exactly, never as a pattern; `--local` removes the worktree, the local + `pkgmgr branch cleanup --apply --local` for exactly the signed items, + matched exactly, never as a pattern; `--local` removes the worktree, the local branch, the compose stack without `-v` and its images, never a remote branch, and needs no SSH key. - [ ] The unit refuses, and changes nothing for, an item whose tip or state diff --git a/orchestrator/Dockerfile b/orchestrator/Dockerfile index 9850fc7..59c4bdf 100644 --- a/orchestrator/Dockerfile +++ b/orchestrator/Dockerfile @@ -38,5 +38,6 @@ COPY server.py /app/server.py COPY kvb_orchestrator /app/kvb_orchestrator COPY client/signing.py /app/client/signing.py COPY templates /app/templates +COPY vendor /app/vendor ENTRYPOINT ["python3", "/app/server.py"] diff --git a/orchestrator/client/cleanup.py b/orchestrator/client/cleanup.py new file mode 100755 index 0000000..080c177 --- /dev/null +++ b/orchestrator/client/cleanup.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Args: + --port: port of the Agent Orchestrator; the signature's origin is + http://localhost:. + --jobs: the directory the hub writes signed jobs into, e.g. + ~/.local/state/kvb-orchestrator/cleanup/jobs. + --results: the directory each job's result goes to as .json. + --approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json. + --replay: this runner's replay store, outside the hub's reach, e.g. + ~/.local/state/kvb-orchestrator-approvals/cleanup.json. + --tree: the tree file `pkgmgr branch tree` writes; it names the + repositories pkgmgr knows and is rewritten after a deletion. + --pkgmgr: the pkgmgr executable built from a signed tag. + +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 ` 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 `. 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. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import time +from pathlib import Path + +import signing +import verifier + +JOB = re.compile(r"[0-9a-f]{16}\.json") +ITEM_KEYS = ("repository", "branch", "worktree", "tip") +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 +# 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 = ( + ("core.hooksPath", "/dev/null"), + ("core.fsmonitor", "false"), + ("hook.reference-transaction.enabled", "false"), + ("hook.post-checkout.enabled", "false"), + ("hook.post-index-change.enabled", "false"), +) + + +def git_overrides() -> dict[str, str]: + env = {"GIT_CONFIG_COUNT": str(len(GIT_OVERRIDES))} + for index, (name, value) in enumerate(GIT_OVERRIDES): + env[f"GIT_CONFIG_KEY_{index}"] = name + env[f"GIT_CONFIG_VALUE_{index}"] = value + return env + + +def known_repositories(tree: Path) -> set[str]: + 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)} + except (OSError, ValueError, KeyError, TypeError, AttributeError): + return set() + + +def run(pkgmgr: Path, argv: list[str], cwd: str) -> tuple[int, str, str]: + try: + done = subprocess.run([str(pkgmgr), *argv], cwd=cwd, env={**os.environ, **git_overrides()}, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace", timeout=TIMEOUT, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + return 127, "", str(error) + return done.returncode, done.stdout, done.stderr[-OUTPUT_LIMIT:] + + +def plan_items(stdout: str) -> list[dict] | None: + 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): + return None + return items + + +def shaped(item: object) -> bool: + if not isinstance(item, dict) or set(item) != set(ITEM_KEYS) or not all(isinstance(item[key], str) for key in ITEM_KEYS): + return False + return item["repository"].startswith("/") and bool(TIP.fullmatch(item["tip"])) and (item["worktree"] == "" or item["worktree"].startswith("/")) + + +def mismatch(entry: dict, item: dict) -> str | None: + if entry.get("state") != "merged-clean": + return f"state is {entry.get('state')}, not merged-clean" + if entry.get("skip"): + return f"gc skip reason: {entry['skip']}" + tip = entry.get("local_sha") or entry.get("head") + if tip != item["tip"]: + 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'}" + return None + + +class Cleanup: + def __init__(self, trust: verifier.Trust, tree: Path, pkgmgr: Path) -> None: + self.trust = trust + self.tree = tree + self.pkgmgr = pkgmgr + + def item(self, item: dict, proof: object, known: set[str], 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. + 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. + """ + def refused(reason: str) -> dict: + return {**item, "status": "refused", "reason": reason} + + try: + verifier.check(proof, signing.cleanup_item(item["repository"], item["branch"], item["worktree"], item["tip"]), "", self.trust, now) + except (signing.Refused, OSError) as error: + return refused(f"no valid operator signature: {error}") + if item["repository"] not in known or not Path(item["repository"]).is_dir(): + 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"]) + plan = plan_items(stdout) if code == 0 else None + if plan is None: + return refused(f"the dry run failed ({code}): {stderr.strip()[-500:]}") + others = sorted({str(entry.get("branch")) for entry in plan} - {item["branch"]}) + if others: + return refused(f"the dry run names other branches too, a pattern match: {', '.join(others)[:500]}") + if len(plan) != 1: + return refused(f"the dry run names {len(plan)} items for {item['branch']}") + 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 []} + + def job(self, path: Path, now: float) -> dict: + try: + job = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + return {"id": path.stem, "finished": now, "items": [], "error": f"unreadable job: {error}"} + 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] + 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)) + result["tree"] = {"code": code, "error": stderr.strip()[-500:]} + return result + + +def cycle(jobs: Path, results: Path, cleanup: Cleanup) -> int: + handled = 0 + for path in sorted(jobs.iterdir()): + if not JOB.fullmatch(path.name) or not path.is_file(): + path.unlink(missing_ok=True) + continue + verifier.write_private(results / path.name, cleanup.job(path, time.time())) + path.unlink(missing_ok=True) + handled += 1 + return handled + + +def main() -> int: + parser = argparse.ArgumentParser(prog="cleanup") + parser.add_argument("--port", type=int, required=True) + for name in ("--jobs", "--results", "--approvers", "--replay", "--tree", "--pkgmgr"): + parser.add_argument(name, type=Path, required=True) + args = parser.parse_args() + trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port)) + args.results.mkdir(mode=0o700, parents=True, exist_ok=True) + cycle(args.jobs, args.results, Cleanup(trust, args.tree, args.pkgmgr)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index 3f7809f..f661513 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -65,6 +65,10 @@ def operator_item(task: str, command: str, directory: str) -> dict: return {"kind": "operator", "id": task, "command": command, "directory": directory} +def cleanup_item(repository: str, branch: str, worktree: str, tip: str) -> dict: + return {"kind": "cleanup", "repository": repository, "branch": branch, "worktree": worktree, "tip": tip} + + def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict: return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires} diff --git a/orchestrator/compose.yml b/orchestrator/compose.yml index 66c8cb8..a7f791f 100644 --- a/orchestrator/compose.yml +++ b/orchestrator/compose.yml @@ -48,6 +48,10 @@ services: - /tree/branch-tree.json - --timers - /timers + - --vendor + - /app/vendor + - --cleanup + - /state/cleanup - --llm-url - "${KVB_ORCHESTRATOR_LLM_URL}" - --llm-model diff --git a/orchestrator/kvb_orchestrator/approving.py b/orchestrator/kvb_orchestrator/approving.py index 0065d02..9b1028f 100644 --- a/orchestrator/kvb_orchestrator/approving.py +++ b/orchestrator/kvb_orchestrator/approving.py @@ -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} diff --git a/orchestrator/kvb_orchestrator/artifacts.py b/orchestrator/kvb_orchestrator/artifacts.py new file mode 100644 index 0000000..f45ad24 --- /dev/null +++ b/orchestrator/kvb_orchestrator/artifacts.py @@ -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} diff --git a/orchestrator/kvb_orchestrator/cleanup.py b/orchestrator/kvb_orchestrator/cleanup.py new file mode 100644 index 0000000..eade53e --- /dev/null +++ b/orchestrator/kvb_orchestrator/cleanup.py @@ -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/.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)] diff --git a/orchestrator/kvb_orchestrator/handler.py b/orchestrator/kvb_orchestrator/handler.py index 715bf52..49e541f 100644 --- a/orchestrator/kvb_orchestrator/handler.py +++ b/orchestrator/kvb_orchestrator/handler.py @@ -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 diff --git a/orchestrator/kvb_orchestrator/hub.py b/orchestrator/kvb_orchestrator/hub.py index eedfee6..87df256 100644 --- a/orchestrator/kvb_orchestrator/hub.py +++ b/orchestrator/kvb_orchestrator/hub.py @@ -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"), diff --git a/orchestrator/kvb_orchestrator/icons.py b/orchestrator/kvb_orchestrator/icons.py new file mode 100644 index 0000000..b1d914e --- /dev/null +++ b/orchestrator/kvb_orchestrator/icons.py @@ -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'(.*?)', 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-" or + "#bi-" in them names an icon the page shows. + + Returns: + A hidden inline SVG holding exactly those symbols, each as + bi-, so the page draws them with 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'{known[name]}' for name in names) + return Markup(f'') diff --git a/orchestrator/kvb_orchestrator/sources.py b/orchestrator/kvb_orchestrator/sources.py index bda8259..1dcce1a 100644 --- a/orchestrator/kvb_orchestrator/sources.py +++ b/orchestrator/kvb_orchestrator/sources.py @@ -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} diff --git a/orchestrator/kvb_orchestrator/tree.py b/orchestrator/kvb_orchestrator/tree.py index 97407b0..5851557 100644 --- a/orchestrator/kvb_orchestrator/tree.py +++ b/orchestrator/kvb_orchestrator/tree.py @@ -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}) diff --git a/orchestrator/kvb_orchestrator/view.py b/orchestrator/kvb_orchestrator/view.py index 7ab4309..f0768f6 100644 --- a/orchestrator/kvb_orchestrator/view.py +++ b/orchestrator/kvb_orchestrator/view.py @@ -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"]])) diff --git a/orchestrator/server.py b/orchestrator/server.py index 40031c7..28d877a 100755 --- a/orchestrator/server.py +++ b/orchestrator/server.py @@ -22,6 +22,11 @@ --tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted read-only; read again whenever it changes. --timers: directory holding the systemd user units (*.timer). + --vendor: directory holding the vendored Bootstrap Icons sprite, + 3d-force-graph and three with their licence texts. + --cleanup: host-mounted directory the hub writes signed branch deletions + into (jobs/) and reads their results from (results/); the host's + kvb-orchestrator-cleanup path unit runs them. --llm-url: base URL of an OpenAI-compatible chat endpoint; empty turns the natural-language command line off. --llm-model: model name sent to that endpoint; empty turns it off. @@ -61,7 +66,7 @@ def main() -> int: parser.add_argument("--bind", required=True) for name in ("--listen-port", "--public-port"): parser.add_argument(name, type=int, required=True) - paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--llm-key-file", "--approvers") + paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--llm-key-file", "--approvers") for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"): parser.add_argument(name, type=Path, required=True) for name in ("--llm-url", "--llm-model", "--operator-voice"): @@ -77,7 +82,7 @@ def main() -> int: transcripts=args.transcripts, scripts=Scripts(skills=args.skills, council=args.council, home=args.state), templates=args.templates, - places=Places(tree_file=args.tree_file, timers=args.timers), + places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup), model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)), engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch), approvers=args.approvers, diff --git a/orchestrator/systemd/kvb-orchestrator-cleanup.path b/orchestrator/systemd/kvb-orchestrator-cleanup.path new file mode 100644 index 0000000..d1e2cae --- /dev/null +++ b/orchestrator/systemd/kvb-orchestrator-cleanup.path @@ -0,0 +1,10 @@ +[Unit] +Description=Agent Orchestrator cleanup: wake on a signed branch deletion the Agent Orchestrator wrote + +[Path] +DirectoryNotEmpty=%h/.local/state/kvb-orchestrator/cleanup/jobs +MakeDirectory=true +DirectoryMode=0700 + +[Install] +WantedBy=default.target diff --git a/orchestrator/systemd/kvb-orchestrator-cleanup.service b/orchestrator/systemd/kvb-orchestrator-cleanup.service new file mode 100644 index 0000000..31103db --- /dev/null +++ b/orchestrator/systemd/kvb-orchestrator-cleanup.service @@ -0,0 +1,9 @@ +[Unit] +Description=Agent Orchestrator cleanup: verify the operator signature, then delete exactly the signed merged branches locally through pkgmgr + +[Service] +Type=oneshot +Environment=PATH=/usr/bin:/bin +EnvironmentFile=%h/.local/share/kvb-orchestrator/.env +ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/cleanup.py --port ${KVB_ORCHESTRATOR_PORT} --jobs %h/.local/state/kvb-orchestrator/cleanup/jobs --results %h/.local/state/kvb-orchestrator/cleanup/results --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/cleanup.json --tree %h/.local/state/pkgmgr/tree/branch-tree.json --pkgmgr %h/.local/share/kvb-orchestrator/pkgmgr/bin/pkgmgr +TimeoutStartSec=1800 diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index 6790831..eee2a7d 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -46,7 +46,11 @@ h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01e .subtabs { display: inline-flex; gap: 2px; padding-left: var(--sp-1); border-left: 1px solid var(--line); } .subtabs button { font-size: var(--fs-sm); } .tabs button { display: inline-flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: 0; border-radius: 10px; background: transparent; color: var(--muted); font-weight: 560; cursor: pointer; transition: background .15s, color .15s; } -.icon-btn { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; } +.sprite { position: absolute; width: 0; height: 0; overflow: hidden; } +.bi { flex: none; width: 1em; height: 1em; fill: currentColor; vertical-align: -.125em; } +.icon-only { min-width: 34px; justify-content: center; font-size: var(--fs-lg); line-height: 1; } +.btn.small.icon-only { min-width: 30px; padding: 4px 8px; font-size: var(--fs-md); } +.bi.caret { width: .7em; height: .7em; opacity: .7; } .btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); } .tabs button:hover { background: var(--hover); color: var(--text); } .tabs button[aria-current="page"] { background: var(--accent-soft); color: var(--accent); } @@ -256,6 +260,23 @@ svg .mark:hover { opacity: 1; } svg .mark:hover line, svg .mark:hover path { stroke-width: 2.6; } svg .pulse { fill: var(--c); filter: drop-shadow(0 0 4px var(--c)); } svg .pulse-ring { fill: none; stroke: var(--c); stroke-width: 2.5; } +svg .node.a-repo circle, svg .node.sun circle { fill: color-mix(in srgb, var(--warn) 70%, var(--panel)); stroke: var(--warn); stroke-width: 3; } +svg .node.a-worktree circle { fill: var(--sunken); stroke: var(--muted); } +svg .node.a-worktree.dirty circle { fill: var(--warn-soft); stroke: var(--warn); } +svg .node.satellite circle { fill: var(--accent); stroke: none; } +svg .node.cargo circle { fill: var(--teal); stroke: none; } +svg .node.tone-ok circle { fill: var(--ok-soft); stroke: var(--ok); } +svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); } +svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); } +svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); } +svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); } +svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; } +svg .edge.merge { stroke-dasharray: 5 5; } +svg .lane-mark { stroke-width: 1.2; } +svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); } +svg .lane-mark.tone-bad { fill: var(--bad); stroke: var(--bad); } +svg .lane-mark.tone-info { fill: var(--info); stroke: var(--info); } +svg .lane-mark.tone-neutral { fill: var(--muted); stroke: var(--muted); } input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel); font-size: var(--fs-sm); } .custom { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } .avatar { display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-lg); line-height: 1; } @@ -358,16 +379,40 @@ body.sky-look .sky:active { cursor: grabbing; } .sky-panel { position: fixed; bottom: var(--sp-5); left: 50%; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 6px; max-width: calc(100vw - 24px); padding: 8px 12px; transform: translateX(-50%); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); } .sky-panel[hidden] { display: none; } .compass { min-width: 220px; font-weight: 620; font-variant-numeric: tabular-nums; text-align: center; } +.states { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; } +.state { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; background: var(--neutral-soft); color: var(--muted); font-size: var(--fs-xs); font-weight: 650; line-height: 20px; white-space: nowrap; } +.state[data-tone="ok"] { background: var(--ok-soft); color: var(--ok); } +.state[data-tone="bad"] { background: var(--bad-soft); color: var(--bad); } +.state[data-tone="warn"] { background: var(--warn-soft); color: var(--warn); } +.state[data-tone="info"] { background: var(--info-soft); color: var(--info); } +.state[data-tone="violet"] { background: var(--violet-soft); color: var(--violet); } +.wt-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; width: 100%; } +.leaves { display: grid; gap: 4px; width: 100%; margin: 0; padding: 0 0 0 18px; list-style: none; } +.leaf { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-sm); } +.hint { color: var(--muted); font-size: var(--fs-xs); } +.delete-all { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.dry-run { margin: 4px 0 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-family: var(--mono); font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; } +pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radius: 8px; font-size: var(--fs-sm); white-space: pre-wrap; overflow-wrap: anywhere; } +.spawn-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 18px; list-style: none; } +.spawn-tree.root { padding-left: 0; } +.spawn-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 5px 8px; border-radius: 8px; } +.spawn-row:hover { background: var(--hover); } +.graph3d { position: relative; width: 100%; height: clamp(360px, 68vh, 820px); overflow: hidden; border-radius: var(--radius-sm); cursor: grab; touch-action: none; } +.graph3d:active { cursor: grabbing; } +.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; } +.graph3d .scene-container { position: relative; } +.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); } +{{ icons }}