diff --git a/README.md b/README.md index 815ba71..b613d0b 100644 --- a/README.md +++ b/README.md @@ -411,6 +411,8 @@ The hub page loads nothing from the network: its icons come from Bootstrap Icons The Agents view "Hierarchie" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/`) and presidents (`work/`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim ` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin. +The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "wartet auf Freigabe", and the filter "Wartet auf Operator" is the inbox, which the Inbox entry under Stream opens. A task's sheet pins its priority (`rasci.py set --prio `) or sets and drops the operator's WSJF estimate (`rasci.py estimate Operator --value --complexity ` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci --council `, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar. + 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). For approvals 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. In the inbox, an operator command run with "Ausführen" shows the runner's output in a console under its row until "Schließen". "Ablehnen" on an ask, an operator row or a rule request signs the denial with its channel and optional reason (a hash no approval shares) and, after the 3 s undo, tells the requesting agent by post or by the radio class picked, never mayday; the hub verifies that signature against the pinned keys before it stores or sends anything, the hook passes the reason on only when it verifies the signature too, and a denied operator row ends as `stopped`. Agents ask for an auto-approval rule with the MCP tool `rule_request` (the rules form's limits; a request stays open for the ask lifetime of one hour, at most 20 in all, and 3 per agent name, which only spreads the queue since MCP names no caller); its row shows a dry run over the last 7 days, and only "Annehmen" with the passkey stores it. The hub stores no rule, of any kind, whose passkey assertion it cannot verify. 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 review 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 (decision choices, the priority order, settings that add no stream origin, review rejections, the bare ask denial `/api/asks//deny`, console dismissals) 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/orchestrator/client/runner.py b/orchestrator/client/runner.py index 14752d8..fef6c2f 100755 --- a/orchestrator/client/runner.py +++ b/orchestrator/client/runner.py @@ -6,28 +6,42 @@ --approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json. --replay: the runner's replay store, outside the hub's reach, e.g. ~/.local/state/kvb-orchestrator-approvals/runner.json. + --rasci: the installed register script, e.g. + ~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional. + --council: the council directory holding rasci.json, the agents' + AGENTS_COUNCIL_ROOT; optional, task changes need both. --once: handle the queued jobs once and exit. Returns: Runs as the operator, outside any sandbox, every operator command a pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash in the signed directory, each signature once and before its expiry. - Refuses every other job. Posts the last 200 output lines back while it - runs and the exit code at its end. The exit code is 0. + Runs every signed task change (priority pin or WSJF estimate) as + `python3 -I -B ` from / without a shell, after checking the + argv shape itself, and kills its process group after 30 s. Refuses task + changes without --rasci and --council or into a council that is a + symlink or another uid's, and every other job. Posts the last 200 output lines + back while it runs and the exit code at its end. The exit code is 0. """ from __future__ import annotations import argparse import collections +import contextlib import json +import os +import signal +import stat import subprocess +import sys import threading import time import urllib.error import urllib.request from collections.abc import Callable from pathlib import Path +from typing import NamedTuple import hub_api import signing @@ -35,7 +49,16 @@ import verifier LINES = 200 BEAT = 2.0 +SAFE_PATH = "/usr/bin:/bin" +TASK_JOB = "task" +TASK_LIMIT = 30.0 REFUSED = "refused: no valid operator signature for this command" +UNREGISTERED = "refused: this runner was started without --rasci and --council; reinstall its unit with make orchestrator-runner" + + +class Register(NamedTuple): + rasci: Path + council: Path def call(base: str, path: str, payload: dict | None) -> dict: @@ -72,6 +95,43 @@ def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None: return None +def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None: + """Args: + job: the claimed task change with task, argv and proof. + trust: the pinned keys, the runner's replay store and the origin. + now: the current time in epoch seconds. + + Returns: + Why the change must not run, or None when its argv has one of the + shapes signing.task_argv() allows and a pinned key signed it. + """ + try: + argv = signing.task_argv(job["task"], job["argv"]) + verifier.check(job.get("proof"), signing.task_item(job["task"], argv), "", trust, now) + except (signing.Refused, OSError) as error: + return f"{REFUSED}: {error}" + return None + + +def council_refusal(council: Path) -> str | None: + """Args: + council: the directory rasci.py writes the register into. + + Returns: + Why no task change may run there, or None for a real directory of + the runner's own uid, given by an absolute path. + """ + if not council.is_absolute(): + return f"refused: the council {council} is no absolute path" + try: + found = os.lstat(council) + except OSError as error: + return f"refused: the council {council} cannot be read: {error}" + if not stat.S_ISDIR(found.st_mode) or found.st_uid != os.geteuid(): + return f"refused: the council {council} is a symlink, no directory or not owned by uid {os.geteuid()}" + return None + + def execute(job: dict, report: Callable[[dict], None]) -> int: """Args: job: the claimed and verified job with command and directory. @@ -81,12 +141,46 @@ def execute(job: dict, report: Callable[[dict], None]) -> int: Returns: The exit code. """ + return spawn(["bash", "-c", job["command"]], job["directory"], None, report) + + +def change(job: dict, register: Register, report: Callable[[dict], None]) -> int: + """Args: + job: the claimed and verified task change with task and argv. + register: the register script and the council it writes. + report: called with {"output", "code", "refused"}. + + Returns: + The exit code of rasci.py, run from / on the argv task_argv() checked. + + Raises: + signing.Refused for an argv of any other shape. + """ + argv = signing.task_argv(job["task"], job["argv"]) + env = {"PATH": SAFE_PATH, "HOME": str(Path.home()), "AGENTS_COUNCIL_ROOT": str(register.council)} + return spawn([sys.executable, "-I", "-B", str(register.rasci), *argv], "/", env, report, TASK_LIMIT) + + +def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int: + """Args: + argv: the program and its arguments, run without a shell. + cwd: the directory it runs in. + env: its whole environment, or None for the runner's own. + report: called with {"output", "code", "refused"}; code None while + the program runs. + limit: seconds until its whole process group is killed, or None to + let it run. + + Returns: + The exit code, -9 after the kill. + """ tail: collections.deque = collections.deque(maxlen=LINES) lock = threading.Lock() try: process = subprocess.Popen( - ["bash", "-c", job["command"]], - cwd=job["directory"], + argv, + cwd=cwd, + env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, @@ -99,11 +193,19 @@ def execute(job: dict, report: Callable[[dict], None]) -> int: return 127 reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True) reader.start() + deadline = None if limit is None else time.monotonic() + limit while True: try: - code = process.wait(BEAT) + code = process.wait(BEAT if deadline is None else max(0.0, min(BEAT, deadline - time.monotonic()))) break except subprocess.TimeoutExpired: + if deadline is not None and time.monotonic() >= deadline: + with contextlib.suppress(ProcessLookupError): + os.killpg(process.pid, signal.SIGKILL) + code = process.wait() + with lock: + tail.append(f"killed after {limit:g} s\n") + break with lock: output = "".join(tail) report({"output": output, "code": None, "refused": False}) @@ -122,18 +224,24 @@ def sender(base: str, job_id: str) -> Callable[[dict], None]: return send -def cycle(base: str, trust: verifier.Trust) -> None: +def cycle(base: str, trust: verifier.Trust, register: Register | None) -> None: for job in call(base, "/api/runner/jobs", None)["jobs"]: try: claimed = call(base, f"/api/runner/{job['id']}/claim", {}) except urllib.error.HTTPError: continue send = sender(base, claimed["id"]) - reason = refusal(claimed, trust, time.time()) - if reason is None: - execute(claimed, send) + task = claimed.get("kind") == TASK_JOB + if not task: + reason = refusal(claimed, trust, time.time()) else: + reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time()) + if reason is not None: send({"output": reason, "code": None, "refused": True}) + elif task: + change(claimed, register, send) + else: + execute(claimed, send) def main() -> int: @@ -142,13 +250,16 @@ def main() -> int: parser.add_argument("--interval", type=float, required=True) parser.add_argument("--approvers", type=Path, required=True) parser.add_argument("--replay", type=Path, required=True) + parser.add_argument("--rasci", type=Path) + parser.add_argument("--council", type=Path) parser.add_argument("--once", action="store_true") args = parser.parse_args() base = hub_api.base_url(args.port) trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port)) + register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None while True: try: - cycle(base, trust) + cycle(base, trust, register) except (OSError, ValueError, KeyError, TypeError): pass if args.once: diff --git a/orchestrator/client/signing.py b/orchestrator/client/signing.py index a62ebcd..74e1a7e 100644 --- a/orchestrator/client/signing.py +++ b/orchestrator/client/signing.py @@ -22,6 +22,11 @@ WORK = re.compile(r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{1,300}") HOLDER = re.compile(r"[^\x00-\x1f\x7f]{0,200}") OFFICE_ACTIONS = ("appoint", "takeover", "recall") OFFICE_KEYS = ("action", "office", "holder", "session", "work") +TASK = re.compile(r"[a-z0-9][a-z0-9._-]{0,99}(?:/[a-z0-9][a-z0-9._-]{0,99}){0,7}") +PRIO = re.compile(r"[1-9][0-9]{0,2}") +FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89") +ESTIMATOR = "Operator" +COMMANDS = "operator/" APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"} RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"} WARNINGS = ( @@ -138,6 +143,36 @@ def stream_item(origins: list[str]) -> dict: return {"kind": "streams", "origins": origins} +def task_item(task: str, argv: list[str]) -> dict: + return {"kind": "task", "id": task, "argv": argv} + + +def task_argv(task: object, argv: object) -> list[str]: + """Args: + task: the register task the change is for, a rasci.py slug outside + operator/. + argv: the rasci.py arguments, one of `set --prio <1-999>`, + `estimate Operator --value --complexity ` or + `estimate Operator --drop`. + + Returns: + argv, the hub and the runner check it the same way. + + Raises: + Refused for every other task or argv. + """ + if not isinstance(task, str) or not TASK.fullmatch(task) or task.startswith(COMMANDS): + raise Refused("the task is no register task slug outside operator/") + if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv): + raise Refused("the argv is no list of texts") + rest = argv[3:] + pinned = argv[:3] == ["set", task, "--prio"] and len(argv) == 4 and bool(PRIO.fullmatch(argv[3])) + estimated = len(rest) == 4 and rest[0] == "--value" and rest[1] in FIBONACCI and rest[2] == "--complexity" and rest[3] in FIBONACCI + if pinned or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])): + return argv + raise Refused("the argv is no task priority or WSJF estimate change of this task") + + 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/kvb_orchestrator/approving.py b/orchestrator/kvb_orchestrator/approving.py index d4d31bc..801617a 100644 --- a/orchestrator/kvb_orchestrator/approving.py +++ b/orchestrator/kvb_orchestrator/approving.py @@ -21,6 +21,7 @@ from .signed import ( signing, verifier, ) +from .tasking import task_request BATCH_LIMIT = 100 INBOX_KINDS = ("review", "ask", "decision", "operator", "rule") @@ -242,6 +243,8 @@ class Approving: signed = self.deletable(raw) elif office_request(raw): signed = self.appointable(raw) + elif task_request(raw): + signed = self.task_changes(raw) else: signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None] return self.challenge_for(signed) diff --git a/orchestrator/kvb_orchestrator/crons.py b/orchestrator/kvb_orchestrator/crons.py index 5093bb3..a5b907d 100644 --- a/orchestrator/kvb_orchestrator/crons.py +++ b/orchestrator/kvb_orchestrator/crons.py @@ -41,14 +41,24 @@ def timer_rows(folder: Path) -> list[dict]: return rows -def queue_rows(council: Path) -> list[dict]: +def queue_entries(council: Path) -> list[tuple[dict, float]]: + """Returns: + Each kvb-org-queue entry of queue.json with its enqueue time; an + entry without one is no entry. + """ data = read_json(council / "queue.json") entries = data.get("entries") if isinstance(data, dict) else None - rows = [] + found = [] for entry in entries if isinstance(entries, list) else []: created = epoch(entry.get("enqueued")) if isinstance(entry, dict) else None - if created is None: - continue + if created is not None: + found.append((entry, created)) + return found + + +def queue_rows(council: Path) -> list[dict]: + rows = [] + for entry, created in queue_entries(council): running = "läuft" if entry.get("status") == "active" else "wartet" rows.append({ "key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created, diff --git a/orchestrator/kvb_orchestrator/handler.py b/orchestrator/kvb_orchestrator/handler.py index 4049a87..40abc94 100644 --- a/orchestrator/kvb_orchestrator/handler.py +++ b/orchestrator/kvb_orchestrator/handler.py @@ -39,6 +39,7 @@ CLEANUP_ROUTE = "/api/cleanup" CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})") OFFICES_ROUTE = "/api/offices" OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})") +TASKS_ROUTE = "/api/tasks" 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" @@ -48,7 +49,7 @@ MESSAGE_SIGN_ROUTE = "/api/messages/sign" BOARD_SIGN_ROUTE = "/api/board/sign" SETTINGS_SIGN_ROUTE = "/api/settings/sign" WAV_POSTS = (STT_ROUTE, VOICE_ROUTE) -ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE) +ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE) class Handler(Exchange): @@ -157,6 +158,8 @@ class Handler(Exchange): return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED) if path == OFFICES_ROUTE: return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED) + if path == TASKS_ROUTE: + return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED) if inbox: return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK) if undo: diff --git a/orchestrator/kvb_orchestrator/hub.py b/orchestrator/kvb_orchestrator/hub.py index cdd3aae..b2316e1 100644 --- a/orchestrator/kvb_orchestrator/hub.py +++ b/orchestrator/kvb_orchestrator/hub.py @@ -31,6 +31,7 @@ from .settings import Settings from .signed import signing from .skill_scripts import Runner from .sources import Sources +from .tasking import Tasking from .timeline import Timeline from .transcripts import Transcripts from .tree import TREE_HEARTBEAT, Tree @@ -60,7 +61,7 @@ def environment(templates: Path) -> jinja2.Environment: @dataclass(frozen=True) -class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language): +class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language): council: Path pages: Path public_port: int diff --git a/orchestrator/kvb_orchestrator/jobs.py b/orchestrator/kvb_orchestrator/jobs.py index 96c407e..6270863 100644 --- a/orchestrator/kvb_orchestrator/jobs.py +++ b/orchestrator/kvb_orchestrator/jobs.py @@ -11,10 +11,17 @@ from .skill_scripts import OPERATOR_SCRIPT, OUTPUT_LIMIT JOB_OPEN = ("queued", "running") RUN_OUTPUT = 64 << 10 CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished") +TASK_JOB = "task" def runner_job(job: dict) -> dict: - return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")} + """Returns: + What the runner claims: an operator command, or a task change with + kind and argv; a runner that predates task changes verifies the + latter as an operator command and refuses it. + """ + change = {"kind": TASK_JOB, "argv": job["argv"]} if job.get("kind") == TASK_JOB else {} + return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, **change, "proof": job.get("proof")} def console_of(job: dict | None) -> dict | None: @@ -83,7 +90,8 @@ class Jobs: job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()}) if not changed: raise Conflict({"id": job_id, "status": job["status"]}) - runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if status == "done" else [] + command_done = status == "done" and job.get("kind") != TASK_JOB + runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else [] tail = "\n".join(output.splitlines()[-20:]) self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}}) return {"id": job_id, "status": status, "code": code, "runs": runs} diff --git a/orchestrator/kvb_orchestrator/language.py b/orchestrator/kvb_orchestrator/language.py index 845e7af..5e8382b 100644 --- a/orchestrator/kvb_orchestrator/language.py +++ b/orchestrator/kvb_orchestrator/language.py @@ -26,7 +26,7 @@ class NoRedirect(urllib.request.HTTPRedirectHandler): LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect) -SCREENS = ("stream", "inbox", "rules", "agents", "artifacts") +SCREENS = ("stream", "inbox", "rules", "agents", "artifacts", "tasks") IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."} NL_ACTIONS = [ {"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}}, diff --git a/orchestrator/kvb_orchestrator/tasking.py b/orchestrator/kvb_orchestrator/tasking.py new file mode 100644 index 0000000..ae31c70 --- /dev/null +++ b/orchestrator/kvb_orchestrator/tasking.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import time + +from .errors import Conflict, Missing, ToolError +from .fields import decode, expect +from .jobs import TASK_JOB +from .register import REGISTER, read_register, status_of +from .signed import signing +from .tasks import editable + +CHANGE_LIMIT = 10 +CHANGE_KEYS = ("kind", "id", "argv") + + +def task_request(raw: object) -> bool: + return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == TASK_JOB for entry in raw) + + +def shown_command(argv: list[str]) -> str: + return " ".join(["rasci.py", *argv]) + + +class Tasking: + def task_changes(self, raw: object) -> list[dict]: + """Args: + raw: the page's items, each {"kind": "task", "id", "argv"} with + the rasci.py arguments of one priority pin or WSJF estimate. + + Returns: + The signed items, each for an open register task outside + operator/ and hotfix/ whose argv signing.task_argv() accepts. + """ + if not isinstance(raw, list) or not 1 <= len(raw) <= CHANGE_LIMIT: + raise ToolError(f"items must be a list of 1 to {CHANGE_LIMIT} task changes") + tasks = read_register(self.council / REGISTER) + signed: list[dict] = [] + for entry in raw: + if not isinstance(entry, dict): + raise ToolError("every item must be an object with kind, id and argv") + expect(entry, CHANGE_KEYS) + try: + argv = signing.task_argv(entry["id"], entry["argv"]) + except signing.Refused as error: + raise ToolError(f"{entry['id']!s:.200}: {error}") from None + row = tasks.get(entry["id"]) + if row is None: + raise Missing(entry["id"]) + if not editable(entry["id"], row): + raise Conflict({"id": entry["id"], "status": status_of(row)}) + item = signing.task_item(entry["id"], argv) + if item in signed: + raise ToolError("items must be distinct") + signed.append(item) + return signed + + def change_tasks(self, body: bytes) -> dict: + payload = decode(body) + expect(payload, ("items", "proof")) + items = self.task_changes(payload["items"]) + proof = self.covered(payload["proof"], items, time.time()) + for item in items: + self.still_signed(proof, item) + ids = [] + for item in items: + fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof} + ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"]) + commands = [shown_command(item["argv"]) for item in items] + self.log.add({"kind": "run", "title": f"Aufgabe signiert geändert: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}}) + return {"jobs": ids, "status": "queued"} diff --git a/orchestrator/kvb_orchestrator/tasks.py b/orchestrator/kvb_orchestrator/tasks.py new file mode 100644 index 0000000..e680e1a --- /dev/null +++ b/orchestrator/kvb_orchestrator/tasks.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import itertools +import statistics + +from .council import agent +from .records import iso_epoch, shown +from .register import ENDED, OPERATOR, parties, register_row, status_of +from .signed import signing +from .tree import inside + +ENDED_SHOWN = 100 +OPEN_SHOWN = 300 +QUEUE_SHOWN = 100 +PARTIES_SHOWN = 40 +TEXT_SHOWN = 500 +FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89) +LETTERS = "RASCI" +HOTFIX = "hotfix/" +QUEUE = "queue/" + + +def kind_of(task: str) -> str: + if task.startswith(OPERATOR): + return "command" + return "hotfix" if task.startswith(HOTFIX) else "task" + + +def editable(task: str, row: dict) -> bool: + shown_whole = len(task) <= TEXT_SHOWN and signing.TASK.fullmatch(task) is not None + return shown_whole and kind_of(task) == "task" and status_of(row) not in ENDED + + +def fibonacci(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value in FIBONACCI + + +def estimates_of(row: dict) -> dict[str, dict]: + raw = row.get("estimates") + found = {} + for party, entry in itertools.islice(raw.items(), PARTIES_SHOWN) if isinstance(raw, dict) else (): + if isinstance(entry, dict) and fibonacci(entry.get("value")) and fibonacci(entry.get("complexity")): + found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry["complexity"]} + return found + + +def wsjf(estimates: dict[str, dict]) -> dict | None: + """Returns: + Median business value, median complexity and their quotient, the + same reading as rasci.py's wsjf(); None without an estimate. + """ + if not estimates: + return None + value = statistics.median(entry["value"] for entry in estimates.values()) + complexity = statistics.median(entry["complexity"] for entry in estimates.values()) + return {"value": value, "complexity": complexity, "score": round(value / complexity, 2)} + + +def prio_of(row: dict) -> int | None: + prio = row.get("prio") + return prio if isinstance(prio, int) and not isinstance(prio, bool) else None + + +def roles_of(row: dict) -> dict[str, str]: + letters = {party[:TEXT_SHOWN]: "".join(letter for letter in LETTERS if letter in held) for party, held in itertools.islice(parties(row).items(), PARTIES_SHOWN)} + return {party: held for party, held in letters.items() if held} + + +def worktrees(scan: dict) -> list[dict]: + found = [] + for repo in scan["repos"]: + trees = [(entry["name"], tree) for entry in repo["branches"] for tree in entry["worktrees"]] + [("", tree) for tree in repo["detached"]] + found += [{"path": tree["path"], "branch": name, "repo": repo["name"], "flow": tree["flow"], "open_work": tree["open_work"]} for name, tree in trees] + return found + + +def placed(row: dict, trees: list[dict]) -> dict | None: + """Returns: + The worktree a register row or queue entry works in: the innermost + one holding its workdir, else the only one on its branch. + """ + workdir, name = row.get("workdir"), row.get("branch") + if isinstance(workdir, str) and workdir.startswith("/"): + holding = [tree for tree in trees if inside(workdir, tree["path"])] + if holding: + return max(holding, key=lambda tree: len(tree["path"])) + named = [tree for tree in trees if isinstance(name, str) and name and tree["branch"] == name] + return named[0] if len(named) == 1 else None + + +def located(row: dict, trees: list[dict], open_task: bool) -> dict: + tree = placed(row, trees) + own = ["task"] if open_task else [] + if tree is None: + return {"worktree": "", "worktree_branch": "", "repo": "", "flow": None, "open_work": own} + return {"worktree": tree["path"], "worktree_branch": tree["branch"], "repo": tree["repo"], "flow": tree["flow"], "open_work": [*own, *tree["open_work"]]} + + +def task_row(task: str, row: dict, trees: list[dict]) -> dict: + estimates = estimates_of(row) + ended = status_of(row) in ENDED + shown_row = {key: value[:TEXT_SHOWN] if isinstance(value, str) else value for key, value in register_row(task, row).items()} + return { + **shown_row, + "kind": kind_of(task), + "ended": ended, + "editable": editable(task, row), + "prio": prio_of(row), + "estimates": estimates, + "wsjf": wsjf(estimates), + "roles": roles_of(row), + "queue": None, + **located(row, trees, not ended), + } + + +def queue_row(entry: dict, created: float, trees: list[dict]) -> dict: + text = {name: shown(entry.get(name))[:TEXT_SHOWN] for name in ("id", "cmd", "kind", "task", "workdir")} + return { + "task": f"{QUEUE}{text['id']}", + "title": text["cmd"], + "status": "running" if entry.get("status") == "active" else "awaiting", + "worker": agent(entry.get("name"))[:TEXT_SHOWN] or "-", + "responsible": "-", + "accountable": "-", + "objective": "-", + "branch": "-", + "workdir": text["workdir"], + "met": 0, + "total": 0, + "kind": "queue", + "ended": False, + "editable": False, + "prio": None, + "estimates": {}, + "wsjf": None, + "roles": {}, + "queue": {"kind": text["kind"], "parent": text["task"], "enqueued": created}, + **located(entry, trees, False), + } + + +def rank(task: str, row: dict) -> tuple: + prio, score = prio_of(row), wsjf(estimates_of(row)) + return (prio is None, prio or 0, score is None, -(score["score"] if score else 0), task) + + +def task_rows(tasks: dict[str, dict], queue: list[tuple[dict, float]], scan: dict) -> list[dict]: + """Args: + tasks: the register rows by task. + queue: the kvb-org-queue entries with their enqueue time. + scan: the pkgmgr tree as read_tree() maps it. + + Returns: + The OPEN_SHOWN first open tasks in register rank (pin, then WSJF, + then name), the QUEUE_SHOWN oldest queue jobs, then the ENDED_SHOWN + most recently ended tasks; each with its worktree's flow and open work. + """ + trees = worktrees(scan) + current = sorted((task for task, row in tasks.items() if status_of(row) not in ENDED), key=lambda task: rank(task, tasks[task])) + ended = sorted((task for task, row in tasks.items() if status_of(row) in ENDED), key=lambda task: (-(iso_epoch(tasks[task].get("end")) or 0), task)) + jobs = sorted(queue, key=lambda pair: pair[1])[:QUEUE_SHOWN] + return [ + *(task_row(task, tasks[task], trees) for task in current[:OPEN_SHOWN]), + *(queue_row(entry, created, trees) for entry, created in jobs), + *(task_row(task, tasks[task], trees) for task in ended[:ENDED_SHOWN]), + ] diff --git a/orchestrator/kvb_orchestrator/tree.py b/orchestrator/kvb_orchestrator/tree.py index 5851557..628d205 100644 --- a/orchestrator/kvb_orchestrator/tree.py +++ b/orchestrator/kvb_orchestrator/tree.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import threading from dataclasses import dataclass from pathlib import Path @@ -11,24 +12,57 @@ from .register import ENDED, status_of from .signed import signing LABEL_PARTS = 3 +FLOW_LIMIT = 16 +FLOW_REF = re.compile(r"[^\x00-\x20\x7f]{1,255}") +FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}") +INVALID = "invalid" # renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age TREE_HEARTBEAT = "tree-heartbeat" +def flow(item: dict) -> dict | None: + """Returns: + The worktree's pkgmgr merge flow: pull, the sources merged into the + branch before a gate, and push, the targets the finished branch + merges into, each capped at FLOW_LIMIT; None when the item names no + flow, source invalid with empty lists when pkgmgr found the flow + file broken or the field has the wrong shape. + """ + raw = item.get("flow") + if raw is None: + return None + broken = {"pull": [], "push": [], "source": INVALID} + if not isinstance(raw, dict) or not isinstance(raw.get("source"), str) or not FLOW_SOURCE.fullmatch(raw["source"]) or raw["source"] == INVALID: + return broken + lists = [raw.get(side)[:FLOW_LIMIT] if isinstance(raw.get(side), list) else None for side in ("pull", "push")] + if not all(names is not None and all(isinstance(name, str) and FLOW_REF.fullmatch(name) for name in names) for names in lists): + return broken + return {"pull": lists[0], "push": lists[1], "source": raw["source"]} + + +def work(item: dict, dirty: bool | None) -> list[str]: + ahead = item["ahead"] + found = (("dirty", dirty is True), ("unpushed", isinstance(ahead, int) and ahead > 0), ("unmerged", item["state"] == "unmerged")) + return [reason for reason, hit in found if hit] + + def worktree(item: dict) -> dict: state = item["worktree_state"] unread = state == "changed" and item["staged"] is None + dirty = None if state == "missing" or unread else state == "changed" return { "path": item["worktree"], "head": item["head"] or "", "branch": item["branch"] or "", "prunable": state == "missing", - "dirty": None if state == "missing" or unread else state == "changed", + "dirty": dirty, "state": state, "staged": item["staged"], "unstaged": item["unstaged"], "untracked": item["untracked"], "locked": item["locked"], + "flow": flow(item), + "open_work": work(item, dirty), } @@ -131,12 +165,13 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list trees = [] for tree in item["worktrees"]: workers = set() - for task, row in current.items(): - if owns(row, tree["path"], item["name"]): - workers |= holders[task] - trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])}) + held = sorted(task for task, row in current.items() if owns(row, tree["path"], item["name"])) + for task in held: + workers |= holders[task] + open_work = [*tree["open_work"], "task"] if held else tree["open_work"] + trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"]), "tasks": held, "open_work": open_work}) branches.append({**item, "worktrees": trees}) - repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"])} for tree in entry["detached"]]}) + repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"]), "tasks": []} for tree in entry["detached"]]}) return {**scan, "repos": repos} diff --git a/orchestrator/kvb_orchestrator/view.py b/orchestrator/kvb_orchestrator/view.py index 2126b20..f8efe4b 100644 --- a/orchestrator/kvb_orchestrator/view.py +++ b/orchestrator/kvb_orchestrator/view.py @@ -13,6 +13,7 @@ from .council import ( sessions, silence_of, ) +from .crons import queue_entries from .errors import ToolError from .events import KINDS, VIEWS from .fields import decode, instant, single @@ -35,6 +36,7 @@ from .reviewing import review_row from .settings import media_origins, valid_settings from .signed import signing from .skill_scripts import OUTPUT_LIMIT +from .tasks import task_rows RADIO_WINDOW = 86400 JOBS_SHOWN = 20 @@ -126,6 +128,7 @@ class View: "messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end], "operator": operator_rows(tasks), "register": register_rows(tasks), + "tasks": task_rows(tasks, queue_entries(self.council), self.tree.get()), "agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)), "radio": radio, "claims": claims, diff --git a/orchestrator/systemd/kvb-orchestrator-runner.service b/orchestrator/systemd/kvb-orchestrator-runner.service index 864a4b5..1356e03 100644 --- a/orchestrator/systemd/kvb-orchestrator-runner.service +++ b/orchestrator/systemd/kvb-orchestrator-runner.service @@ -3,8 +3,9 @@ Description=Agent Orchestrator runner: runs the operator commands a pinned passk [Service] Type=simple +Environment=PATH=/usr/bin:/bin EnvironmentFile=%h/.local/share/kvb-orchestrator/.env -ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json +ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json --rasci %h/.claude/skills/kvb-org-rasci/scripts/rasci.py --council ${KVB_ORCHESTRATOR_COUNCIL} Restart=always RestartSec=5 diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index bd08b34..bcc7b12 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -407,6 +407,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "style-inbox.css" %} {% include "style-ui.css" %} + +{% include "style-tasks.css" %} @@ -506,10 +508,16 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi {% include "page-tree.js" %} +{% include "page-flow.js" %} + {% include "page-cleanup.js" %} {% include "page-offices.js" %} +{% include "page-tasks.js" %} + +{% include "page-tasks-prio.js" %} + {% include "page-time.js" %} {% include "page-pulses.js" %} diff --git a/orchestrator/templates/nav.js b/orchestrator/templates/nav.js index 12428a8..29aad75 100644 --- a/orchestrator/templates/nav.js +++ b/orchestrator/templates/nav.js @@ -1,15 +1,17 @@ const Nav = (() => { "use strict"; - const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]]; + const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]]; const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]}; const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]; + const TASK_VIEWS = [["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]]; const OWN = {agents: [["hierarchy", "Hierarchie"]]}; const ICONS = { - stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", + stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check", orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2", + list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", }; const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]}; - const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])]; + const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]); const known = (screen, view) => viewsOf(screen).some(([id]) => id === view); function tabOf(screen) { @@ -36,7 +38,7 @@ const Nav = (() => { return {screen: "stream", view: null, redirect: null}; } - return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf}; + return {TABS, SUBS, VIEWS, TASK_VIEWS, ICONS, viewsOf, route, hashOf, tabOf}; })(); if (typeof module === "object" && module.exports) { module.exports = Nav; diff --git a/orchestrator/templates/page-artifacts.js b/orchestrator/templates/page-artifacts.js index 70acab9..f25e727 100644 --- a/orchestrator/templates/page-artifacts.js +++ b/orchestrator/templates/page-artifacts.js @@ -49,6 +49,9 @@ for (const overlap of repo.overlaps || []) { links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count}); } + for (const edge of flowEdges(repo)) { + links.push({source: `branch:${repo.path}\u0000${edge.from}`, target: `branch:${repo.path}\u0000${edge.into}`, kind: "flow", side: edge.side}); + } } for (const task of data.tasks || []) { const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""}); @@ -113,7 +116,7 @@ const holder = model.byId.get(`wt:${wt.path}`); const leaves = model.nodes.filter((entry) => entry.parent === holder.id); leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null))); - layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null)); + layer.append(artMark(holder, moon, 7, [wt.state === "changed" ? "dirty" : "", wt.open_work.length ? "open" : ""].filter(Boolean).join(" "), null)); }); layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null)); }); @@ -148,6 +151,13 @@ s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`))); } } + for (const edge of flowEdges(repo)) { + const from = spots.get(edge.from); + const to = spots.get(edge.into); + const bend = edge.side === "pull" ? 0.55 : 0.15; + arcs.append(s("path", {class: "edge t-direct flow", "data-side": edge.side, d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed((from.x + to.x) * bend)},${fixed((from.y + to.y) * bend)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-direct)"}, + s("title", {}, `${edge.from} → ${edge.into}: Merge-Flow, ${edge.side === "pull" ? `${edge.into} zieht ${edge.ref} vor dem Gate` : `${edge.from} mergt fertig in ${edge.ref}`}`))); + } const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null)); const labels = branches.map((branch) => { const at = spots.get(branch.name); @@ -192,7 +202,7 @@ function artLegend(view) { const words = { orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt", - chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation", + chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation · gepunktet = Merge-Flow des Worktrees", swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾", }; const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : []; @@ -212,9 +222,11 @@ h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`)); } else if (node.kind === "branch") { body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]), + data.worktrees.map((wt) => flowDetails(wt.flow, data.name)), h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data))); } else if (node.kind === "worktree") { - body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]), + body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]), + flowDetails(data.flow, data.branch), data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."), data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path))); } else if (node.kind === "agent") { diff --git a/orchestrator/templates/page-constants.js b/orchestrator/templates/page-constants.js index 9db4ceb..79f1535 100644 --- a/orchestrator/templates/page-constants.js +++ b/orchestrator/templates/page-constants.js @@ -60,7 +60,7 @@ direct: [[740, 0, 0.2]], radio: [[520, 0, 0.07]], }; - const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]]; + const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]]; const calm = window.matchMedia("(prefers-reduced-motion: reduce)"); const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"}); const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"}); diff --git a/orchestrator/templates/page-flow.js b/orchestrator/templates/page-flow.js new file mode 100644 index 0000000..9fd3dcf --- /dev/null +++ b/orchestrator/templates/page-flow.js @@ -0,0 +1,68 @@ + const WORK_CHIPS = {dirty: ["bi-pencil-square", "nicht committet", "warn"], unpushed: ["bi-cloud-upload", "ungepusht", "info"], unmerged: ["bi-signpost-split", "nicht gemergt", "neutral"], task: ["bi-card-checklist", "Aufgabe offen", "violet"]}; + const FLOW_SIDES = {pull: "zieht vor dem Gate", push: "mergt fertig in"}; + + function workChips(reasons) { + if (!reasons || !reasons.length) { + return null; + } + return h("span", {class: "states work", role: "group", "aria-label": "Offene Arbeit"}, reasons.map((reason) => { + const [name, label, tone] = Object.hasOwn(WORK_CHIPS, reason) ? WORK_CHIPS[reason] : ["bi-question-circle", reason, "neutral"]; + return h("span", {class: "state", "data-tone": tone, title: `offene Arbeit: ${label}`}, icon(name), label); + })); + } + + function flowLine(flow, branch) { + if (!flow) { + return null; + } + if (flow.source === "invalid") { + return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge-Flow ungültig: pkgmgr kann die Flow-Datei nicht lesen"); + } + const refs = (side) => (flow[side].length ? flow[side].map((ref) => h("code", {class: "flow-ref", title: `${FLOW_SIDES[side]}: ${ref}`}, ref)) : h("span", {class: "muted"}, "–")); + const arrow = () => h("span", {class: "flow-arrow", ...named("fließt nach")}, icon("bi-arrow-right")); + return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge-Flow: ${flow.pull.join(", ") || "keine Quelle"} nach ${branch || "–"} nach ${flow.push.join(", ") || "kein Ziel"}`}, + h("span", {class: "flow-label"}, icon("bi-sign-merge-right"), "Flow"), + h("span", {class: "flow-side"}, refs("pull")), arrow(), + h("code", {class: "flow-ref here", title: "dieser Branch"}, branch || "–"), arrow(), + h("span", {class: "flow-side"}, refs("push"))); + } + + function flowDetails(flow, branch) { + if (!flow) { + return h("p", {class: "muted"}, "Kein Merge-Flow gesetzt."); + } + if (flow.source === "invalid") { + return flowLine(flow, branch); + } + return [h("h3", {}, "Merge-Flow"), flowLine(flow, branch), meta([["Zieht vor dem Gate", flow.pull.join(", ")], ["Mergt fertig in", flow.push.join(", ")], ["Quelle", flow.source]])]; + } + + function flowTarget(names, ref) { + if (names.has(ref)) { + return ref; + } + const bare = ref.includes("/") ? ref.slice(ref.indexOf("/") + 1) : ""; + return names.has(bare) ? bare : null; + } + + function flowEdges(repo) { + const names = new Set(repo.branches.map((entry) => entry.name)); + const edges = []; + for (const branch of repo.branches) { + for (const flow of branch.worktrees.map((wt) => wt.flow).filter((found) => found && found.source !== "invalid")) { + for (const ref of flow.pull) { + const from = flowTarget(names, ref); + if (from && from !== branch.name) { + edges.push({from, into: branch.name, side: "pull", ref}); + } + } + for (const ref of flow.push) { + const into = flowTarget(names, ref); + if (into && into !== branch.name) { + edges.push({from: branch.name, into, side: "push", ref}); + } + } + } + } + return edges; + } diff --git a/orchestrator/templates/page-keyboard.js b/orchestrator/templates/page-keyboard.js index cfa215c..ea11d74 100644 --- a/orchestrator/templates/page-keyboard.js +++ b/orchestrator/templates/page-keyboard.js @@ -54,7 +54,7 @@ const inInbox = ui.screen === "inbox"; const current = inInbox ? visibleInbox()[ui.cursor] : null; const index = Number(event.key) - 1; - if (/^[1-3]$/.test(event.key)) { + if (/^[1-9]$/.test(event.key) && index < Nav.TABS.length) { event.preventDefault(); go(Nav.TABS[index][0], null); } else if (inInbox && (event.key === "j" || event.key === "k")) { diff --git a/orchestrator/templates/page-palette.js b/orchestrator/templates/page-palette.js index 7889272..23c1bca 100644 --- a/orchestrator/templates/page-palette.js +++ b/orchestrator/templates/page-palette.js @@ -1,7 +1,7 @@ function paletteItems() { return [ ...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]), - ...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))), + ...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))), ...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})), ...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})), ...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})), diff --git a/orchestrator/templates/page-screens.js b/orchestrator/templates/page-screens.js index 08f5ac7..68ada43 100644 --- a/orchestrator/templates/page-screens.js +++ b/orchestrator/templates/page-screens.js @@ -44,11 +44,11 @@ if (ui.screen === "agents") { return JSON.stringify([ui.views.agents]); } - return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []); + return JSON.stringify(ui.screen === "artifacts" || ui.screen === "tasks" ? [ui.views[ui.screen]] : ui.screen === "rules" ? state.rules : []); } function screenOf() { - return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen](); + return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen](); } function render(force) { @@ -66,6 +66,7 @@ radioDom = null; inboxDom = null; inboxSig = ""; + taskView.dom = null; const main = document.getElementById("main"); main.setAttribute("aria-labelledby", `tab-${ui.screen}`); main.replaceChildren(screenOf()); @@ -82,6 +83,7 @@ drawToolbar(); drawInbox(); drawRadio(); + drawTasks(); drawStatus(); tick(); } diff --git a/orchestrator/templates/page-state.js b/orchestrator/templates/page-state.js index 5eebbef..f844562 100644 --- a/orchestrator/templates/page-state.js +++ b/orchestrator/templates/page-state.js @@ -27,7 +27,7 @@ let state = JSON.parse(document.getElementById("state").textContent); const KINDS = Object.entries(state.kinds); - const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0}; + const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0}; const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))}; const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""}; const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null}; diff --git a/orchestrator/templates/page-tasks-prio.js b/orchestrator/templates/page-tasks-prio.js new file mode 100644 index 0000000..879aa12 --- /dev/null +++ b/orchestrator/templates/page-tasks-prio.js @@ -0,0 +1,104 @@ + const FIBONACCI = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89]; + const PRIO_MAX = 999; + const ESTIMATOR = HUMAN; + const shownArgv = (argv) => ["rasci.py", ...argv].join(" "); + const prioText = (text) => (/^[1-9][0-9]{0,2}$/.test(text.trim()) ? String(Number(text.trim())) : ""); + + function fibonacciPicker(label, picked) { + return h("select", {class: "field", "aria-label": label}, FIBONACCI.map((value) => h("option", {value, selected: value === picked}, String(value)))); + } + + async function changeTask(row, argv, label, outcome) { + const items = [{kind: "task", id: row.task, argv}]; + outcome.replaceChildren(h("p", {class: "muted"}, "Signiere mit dem Passkey …")); + const signed = await proofFor(items, label); + if (signed === null) { + outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben.")); + return; + } + const result = await sendJson("/api/tasks", {items, proof: signed.proof}); + if (!result.ok || !result.data) { + toast(`${label}: nicht möglich`, fail(result), false); + outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim())); + return; + } + outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signiert und an den Runner übergeben (Job ${result.data.jobs.join(", ")}); er ruft rasci.py auf dem Host auf.`)); + toast(`${label}: an den Runner übergeben`, [shownArgv(argv)], true); + await load(); + } + + function priorityForm(row) { + const outcome = h("div", {class: "mini", "aria-live": "polite"}); + const own = row.estimates[ESTIMATOR] || null; + const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priorität", placeholder: "1"}); + const value = fibonacciPicker("Geschäftswert", own ? own.value : 5); + const complexity = fibonacciPicker("Komplexität", own ? own.complexity : 3); + const pinCode = h("code", {}); + const estimateCode = h("code", {}); + const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)]; + const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value]; + const pin = h("button", {type: "button", class: "btn small primary", ...named("Anheften", "Anheften: Priorität mit dem Passkey signieren")}, icon("bi-pin-angle-fill"), "Anheften"); + const estimate = h("button", {type: "button", class: "btn small primary", ...named("Schätzung speichern", "Schätzung signiert an den Runner geben")}, icon("bi-calculator"), "Schätzung speichern"); + const sync = () => { + pin.disabled = !prioText(prio.value); + pinCode.textContent = pin.disabled ? `Priorität 1 bis ${PRIO_MAX} eingeben` : shownArgv(prioArgv()); + estimateCode.textContent = shownArgv(estimateArgv()); + }; + for (const field of [prio, value, complexity]) { + field.addEventListener("input", sync); + } + pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priorität ${row.task}`, outcome)); + estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome)); + const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Schätzung verwerfen", "Schätzung verwerfen: deine Schätzung mit dem Passkey signiert löschen"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Schätzung verwerfen") : null; + sync(); + return h("section", {class: "task-edit", "aria-label": "Priorität und WSJF"}, + h("h3", {}, "Priorität anheften"), + h("div", {class: "task-form"}, h("label", {}, "Priorität (1 zuerst)", prio), pin), + h("div", {class: "command"}, pinCode), + h("h3", {}, `WSJF schätzen als ${ESTIMATOR}`), + h("div", {class: "task-form"}, h("label", {}, "Geschäftswert", value), h("label", {}, "Komplexität", complexity), estimate, drop), + h("div", {class: "command"}, estimateCode), + h("p", {class: "muted"}, "Dein Passkey signiert genau diesen Aufruf; der Runner auf dem Host prüft die Signatur und die Form der Argumente und startet rasci.py ohne Shell. Die Priorität der Agenten bleibt unberührt."), + onLocalhost() ? null : h("p", {class: "muted"}, "Signieren geht nur unter ", localLink()), + outcome); + } + + function taskRoles(row) { + const entries = Object.entries(row.roles); + return [h("h3", {}, "RASCI", counter(entries.length)), + entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "Keine Rollen eingetragen.")]; + } + + function taskEstimates(row) { + const entries = Object.entries(row.estimates); + const score = row.wsjf; + return [h("h3", {}, "WSJF-Schätzungen", counter(entries.length)), + entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Geschäftswert ${entry.value} · Komplexität ${entry.complexity}`))))) : h("p", {class: "muted"}, "Noch keine Schätzung: die Aufgabe rangiert nach den geschätzten."), + score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null]; + } + + function taskJobs(row) { + const jobs = state.jobs.filter((job) => job.kind === "task" && job.task === row.task).slice(-5).reverse(); + return jobs.length ? [h("h3", {}, "Signierte Änderungen", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status}, + h("div", {class: "row"}, h("code", {}, job.command), status(job.status)), + job.status === "refused" || job.status === "failed" ? h("pre", {class: "notes"}, job.output) : null)))] : null; + } + + function taskSheet(id) { + const row = state.tasks.find((entry) => entry.task === id); + if (!row) { + return; + } + const facts = [["Ziel", row.objective], ["Key Results", row.total ? `${row.met} von ${row.total} erfüllt` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]]; + openSheet(row.task, [ + h("div", {class: "who"}, taskBadges(row, true), row.worker !== "-" ? [face(row.worker, false), person(row.worker)] : null), + h("p", {class: "text"}, titleOf(row)), + meta(row.queue ? [["Queue", `${row.queue.kind} für ${row.queue.parent}`], ...facts.slice(2)] : facts), + workChips(row.open_work), + flowDetails(row.flow, branchOf(row)), + taskRoles(row), + row.kind === "task" ? taskEstimates(row) : null, + row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Beendet: Priorität und WSJF bleiben, wie sie sind." : "Priorität und WSJF gibt es nur für Aufgaben des Registers, nicht für Befehle, Hotfixes und Queue-Jobs."), + taskJobs(row), + ], false); + } diff --git a/orchestrator/templates/page-tasks.js b/orchestrator/templates/page-tasks.js new file mode 100644 index 0000000..db28a5e --- /dev/null +++ b/orchestrator/templates/page-tasks.js @@ -0,0 +1,177 @@ + const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]}; + const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]]; + const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]}; + const TASK_FILTERS = [ + ["alle", "Alle", "bi-list-check", () => true], + ["offen", "Offen", "bi-play-circle", (row) => !row.ended], + ["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0], + ["operator", "Wartet auf Operator", "bi-person-raised-hand", null], + ["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"], + ["beendet", "Beendet", "bi-check2-circle", (row) => row.ended], + ]; + const ROLE_TONES = {R: "ok", A: "violet", S: "info", C: "warn", I: "neutral"}; + const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"}; + const MATRIX_PARTIES = 40; + const taskView = {filter: "alle", q: "", dom: null, sig: ""}; + + const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued"); + const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : ""); + const titleOf = (row) => (row.title !== "-" ? row.title : row.task); + const openJobs = (task) => state.jobs.filter((job) => job.kind === "task" && job.task === task && (job.status === "queued" || job.status === "running")); + + function taskStatus(row) { + const [label, tone] = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status] : [row.status, "neutral"]; + return pill(label, tone); + } + + function taskBadges(row, withStatus) { + const [kindName, kindTone] = Object.hasOwn(TASK_KINDS, row.kind) ? TASK_KINDS[row.kind] : [null, null]; + const score = row.wsjf; + return [ + kindName ? pill(kindName, kindTone) : null, + withStatus ? taskStatus(row) : null, + row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null, + row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "angeheftete Priorität"}, `Prio ${row.prio}`) : null, + score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null, + openJobs(row.task).length ? pill("Änderung beim Runner", "warn") : null, + ]; + } + + function roleSummary(row) { + const holders = (letter) => Object.entries(row.roles).filter(([, letters]) => letters.includes(letter)).map(([party]) => party); + return ["R", "A"].map((letter) => (holders(letter).length ? h("span", {title: ROLE_WORDS[letter]}, `${letter}: ${holders(letter).join(", ")}`) : null)); + } + + function taskPlace(row) { + if (row.kind === "queue") { + return h("span", {}, `${row.queue.kind} für ${row.queue.parent} · eingereiht `, when(row.queue.enqueued)); + } + const place = row.worktree || (row.workdir !== "-" ? row.workdir : ""); + return place ? h("code", {title: row.repo || place}, place) : null; + } + + function opener(row) { + return h("button", {type: "button", class: "linkish", title: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row)); + } + + function taskItem(row) { + const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" "); + return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status}, + h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}), + row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null), + opener(row), + row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${row.objective}`) : null, + h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)), + flowLine(row.flow, branchOf(row)), + workChips(row.open_work)); + } + + function taskCard(row) { + const classes = ["task-card", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" "); + return h("li", {class: classes, "data-task": row.task}, + h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task)), + opener(row), + h("div", {class: "states"}, taskBadges(row, row.status === "awaiting")), + row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null, + row.flow ? flowLine(row.flow, branchOf(row)) : null, + workChips(row.open_work)); + } + + function taskBoard(rows) { + return h("div", {class: "task-board"}, TASK_COLUMNS.map(([id, label]) => { + const cards = rows.filter((row) => columnOf(row) === id); + return h("section", {class: "card task-column", "data-column": id, "aria-label": label}, + h("h3", {class: "group-title"}, label, counter(cards.length)), + cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer")); + })); + } + + function roleLetters(letters) { + return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter)); + } + + function taskMatrix(rows) { + const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "de")); + if (!parties.length) { + return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder I."); + } + const columns = parties.slice(0, MATRIX_PARTIES); + const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)), + parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} weitere Parteien nicht gezeigt`) : null); + const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party))))); + const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null}, + h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))), + columns.map((party) => { + const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : ""; + return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters)); + })); + const table = h("table", {class: "matrix", "aria-label": "RASCI-Matrix: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line))); + return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend); + } + + function chooseTaskFilter(id) { + if (id === "operator") { + go("inbox", null); + return; + } + taskView.filter = id; + if (ui.screen === "inbox") { + go("tasks", null); + } else { + drawTasks(); + } + } + + function taskChips(rows, inbox) { + return TASK_FILTERS.map(([id, label, name, test]) => { + const total = test ? rows.filter(test).length : waitingCount(); + return h("button", {type: "button", class: "chip preset", "data-filter": id, "aria-pressed": String(inbox ? id === "operator" : id === taskView.filter), title: `${label}: ${total}`, on: {click: () => chooseTaskFilter(id)}}, + icon(name), label, counter(total)); + }); + } + + function taskViewSwitch(inbox) { + return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", { + type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)}, + }, icon(Nav.ICONS[id])))); + } + + function tasksScreen() { + const inbox = ui.screen === "inbox"; + const view = ui.views.tasks; + const chips = h("div", {class: "chips", role: "group", "aria-label": "Aufgabenfilter"}); + const count = h("span", {class: "muted"}); + const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => { + taskView.q = event.target.value; + drawTasks(); + }}}); + const body = inbox ? inboxScreen() : h("div", {class: "tasks-body"}); + taskView.dom = {chips, count, body, inbox, view}; + taskView.sig = ""; + return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), chips, body); + } + + function drawTasks() { + const dom = taskView.dom; + if (!dom) { + return; + } + const q = taskView.q.trim().toLowerCase(); + const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]); + if (sig === taskView.sig) { + return; + } + taskView.sig = sig; + const rows = state.tasks; + dom.chips.replaceChildren(...taskChips(rows, dom.inbox)); + if (dom.inbox) { + dom.count.textContent = `${rows.length} Aufgaben im Register`; + return; + } + const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter); + const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q)); + const shown = rows.filter(test).filter(hit); + dom.count.textContent = `${shown.length} von ${rows.length} Aufgaben`; + const views = {list: (found) => h("ol", {class: "task-list"}, found.map(taskItem)), board: taskBoard, rasci: taskMatrix}; + dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter.")); + } diff --git a/orchestrator/templates/page-tree.js b/orchestrator/templates/page-tree.js index 9d6e147..719a837 100644 --- a/orchestrator/templates/page-tree.js +++ b/orchestrator/templates/page-tree.js @@ -50,16 +50,21 @@ h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null); } + function openWork(wt) { + return wt.open_work.length ? chip("bi-lightning-charge", `offene Arbeit: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null; + } + function worktreeRow(wt) { const review = wt.review; - return h("div", {class: "wt"}, + return h("div", {class: wt.open_work.length ? "wt open-work" : "wt", "data-open-work": wt.open_work.join(" ") || null}, h("div", {class: "wt-head"}, - h("span", {class: "states"}, worktreeStates(wt)), + h("span", {class: "states"}, worktreeStates(wt), openWork(wt)), h("code", {}, wt.path), h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)), wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"), review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null, copyButton(wt.path)), + flowLine(wt.flow, wt.branch), wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null); } @@ -114,9 +119,11 @@ const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached); const dirty = trees.filter((wt) => wt.dirty).length; const merged = repo.branches.filter((entry) => entry.cleanup).length; + const busy = trees.filter((wt) => wt.open_work.length).length; const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)}, h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`), - dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null), + dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null, + busy ? chip("bi-lightning-charge", `${busy} Worktrees mit offener Arbeit`, "warn", busy) : null), branches.map((entry) => branchRow(repo, entry)), detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null); details.addEventListener("toggle", () => { diff --git a/orchestrator/templates/style-tasks.css b/orchestrator/templates/style-tasks.css new file mode 100644 index 0000000..c7d5374 --- /dev/null +++ b/orchestrator/templates/style-tasks.css @@ -0,0 +1,44 @@ +.graph.tasks { grid-template-columns: minmax(0, 1fr); } +.tasks-body { min-width: 0; } +.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); } +.tasks > .chips { margin-bottom: 0; } +.chip .count { margin-left: 2px; } +.task-list, .task-cards { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } +.task-row { display: grid; gap: 6px; min-width: 0; padding: var(--sp-3); border: 1px solid var(--line); border-radius: 12px; background: var(--glass-card); } +.task-row .head-row { margin-bottom: 0; } +.task-row > .linkish { justify-self: start; font-size: var(--fs-lg); text-align: left; } +.task-row.open-work, .task-card.open-work, .wt.open-work, .matrix tr.open-work > th { box-shadow: inset 3px 0 0 var(--warn); } +.task-row.ended, .task-card.ended { opacity: .72; } +.task-id { font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; } +.task-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; min-width: 0; color: var(--muted); font-size: var(--fs-sm); } +.task-meta code { overflow-wrap: anywhere; } +.task-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 12px; align-items: start; } +.task-column { display: grid; gap: 8px; min-width: 0; padding: var(--sp-3); } +.task-card { display: grid; gap: 4px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); } +.task-card .linkish { overflow-wrap: anywhere; } +.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; font-size: var(--fs-sm); } +.flow-label { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-weight: 650; } +.flow-side { display: inline-flex; flex-wrap: wrap; gap: 4px; min-width: 0; } +.flow-ref { padding: 0 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--sunken); line-height: 20px; overflow-wrap: anywhere; } +.flow-ref.here { border-color: transparent; background: var(--accent-soft); color: var(--accent); font-weight: 650; } +.flow-arrow { display: inline-flex; color: var(--muted); } +.flow.invalid { gap: 6px; color: var(--bad); } +.states.work .state { gap: 5px; } +.matrix-box { display: grid; gap: 8px; min-width: 0; } +.matrix-scroll { max-width: 100%; max-height: 70vh; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); } +table.matrix { border-collapse: separate; border-spacing: 0; font-size: var(--fs-sm); } +table.matrix th, table.matrix td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: center; vertical-align: middle; white-space: nowrap; } +table.matrix thead th { position: sticky; top: 0; z-index: 1; background: var(--panel); vertical-align: bottom; } +table.matrix th[scope="row"], table.matrix thead th:first-child { position: sticky; left: 0; background: var(--panel); text-align: left; } +table.matrix thead th:first-child { z-index: 2; } +table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-width: 320px; white-space: normal; } +.matrix-task { display: grid; gap: 2px; } +.matrix .party { display: inline-block; max-height: 150px; overflow: hidden; text-overflow: ellipsis; writing-mode: vertical-rl; transform: rotate(180deg); } +.matrix td .letter + .letter { margin-left: 2px; } +.task-edit { display: grid; gap: 8px; } +.task-edit h3 { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; } +.task-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; } +.task-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); } +.task-form input[type="number"] { width: 120px; } +svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; } +svg .edge.flow { stroke-dasharray: 2 5; } diff --git a/scripts/orchestrator.sh b/scripts/orchestrator.sh index e1551b3..119a8ea 100644 --- a/scripts/orchestrator.sh +++ b/scripts/orchestrator.sh @@ -4,8 +4,9 @@ # write its .env, build and start the container, register the MCP # server for Claude Code, build pkgmgr from the newest signed tag of # PKGMGR_REPO and enable the tree timer and the cleanup path unit, -# enable the offices path unit; re-enable the runner and re-register the hook when the migration -# took them over +# enable the offices path unit; re-enable the runner and re-register +# the hook when the migration took them over; an enabled runner gets +# the fresh unit and a restart # migrate move every trace of the earlier name to the kvb-orchestrator names # once: container, image, code, state, approvals, pinned keys, MCP # server, PermissionRequest hook, runner unit; a second run changes @@ -13,7 +14,9 @@ # check image imports, container, logs, HTTP, pinned keys, hook, runner, # disk; exit 1 when a check fails # pin pin the credential blob the "Passkey registrieren" settings show -# runner install, enable and restart the runner user service +# runner install, enable and restart the runner user service; disable it +# and exit 1 while the installed council.py, nofollow.py or +# rasci.py differ from this tree # hook register the PermissionRequest hook in ~/.claude/settings.json, # replacing an earlier entry, keeping everything else and the file # mode, after a copy to settings.json.before-kvb-orchestrator-hook @@ -254,17 +257,26 @@ install_all() { install_hub tree_timer offices_unit - if ((TOOK_RUNNER)); then runner || true; fi + if ((TOOK_RUNNER)) || runner_enabled; then runner || true; fi if ((TOOK_HOOK)); then if [[ -f "${HUB}/client/permission_hook.py" ]]; then hook; else warn "skills: run 'make orchestrator-hook' once the Agent Orchestrator is installed"; fi fi } +runner_enabled() { + has_systemd && [[ "$(systemctl --user is-enabled "${RUNNER}" 2>/dev/null || true)" == "enabled" ]] +} + runner() { if ! has_systemd; then warn "skills: no systemd user session; the runner is off and approved operator commands wait." return 1 fi + if ! skills_current; then + systemctl --user disable --now "${RUNNER}" >/dev/null 2>&1 || true + warn "skills: ${INSTALLED_SKILLS} holds other council.py, nofollow.py or rasci.py than this tree; run 'make install' to enable ${RUNNER}." + return 1 + fi cp -f "${HUB}/systemd/${RUNNER}" "${UNITS}/" systemctl --user daemon-reload systemctl --user enable "${RUNNER}" diff --git a/tests/branch-tree.json b/tests/branch-tree.json index eaea48e..b98d76a 100644 --- a/tests/branch-tree.json +++ b/tests/branch-tree.json @@ -42,7 +42,18 @@ "local-branch" ], "reason": "dirty: 1 paths", - "result": null + "result": null, + "flow": { + "pull": [ + "origin/main", + "store-i18n" + ], + "push": [ + "main", + "production" + ], + "source": "file" + } }, { "branch": "feature/done", @@ -77,7 +88,8 @@ "remote-branch" ], "reason": "merged into main by #12", - "result": null + "result": null, + "flow": null }, { "branch": "feature/kept", @@ -135,7 +147,8 @@ "skip": null, "actions": [], "reason": "default or protected branch", - "result": null + "result": null, + "flow": null }, { "branch": null, @@ -164,7 +177,12 @@ "worktree" ], "reason": "unmerged: 1 commits not in origin/main", - "result": null + "result": null, + "flow": { + "pull": [], + "push": [], + "source": "invalid" + } } ] }, diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index 39e0bb1..5b5a7ac 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -32,8 +32,10 @@ from test_hub_offices import host as office_host OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots")) WIDTHS = ((1920, 1080), (1366, 768), (390, 844)) VIEWS = ("orbit", "chord", "swimlane", "tree", "3d") -SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy"] +TASK_VIEWS = ("list", "board", "rasci") +SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)] SUBS = ["Inbox", "Regeln", "Radio"] +TABS = ["Stream", "Agents", "Artifacts", "Tasks"] HELPERS = """ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const until = async (check, ms) => { @@ -91,6 +93,13 @@ PROBE = """(async () => {@HELPERS@ org: all("#main .org-chart .org-row[data-office]").map((row) => [row.dataset.office, text(row.querySelector(".org-holder"))]), toggles: all("#main .org-toggle").map((button) => [name(button), button.getAttribute("aria-expanded")]), order: all("#main .agent-order li").map((item) => item.dataset.name), + taskFilters: all('#main [aria-label="Aufgabenfilter"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]), + taskRows: all("#main .task-row").map((row) => [row.dataset.task, row.classList.contains("open-work")]), + columns: all("#main .task-column").map((column) => [column.dataset.column, all(".task-card", column).map((card) => card.dataset.task)]), + matrix: [all("#main table.matrix thead th").length, all("#main table.matrix tbody tr").length, all("#main table.matrix td .letter").map(text).join("")], + flows: all("#main .flow").map((flow) => flow.dataset.flow), + flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side), + openWork: all("#main .open-work").length, wide: document.documentElement.scrollWidth > window.innerWidth + 1, }; })()""".replace("@HELPERS@", HELPERS) @@ -139,13 +148,38 @@ AFTER_TAKEOVER = """(async () => {@HELPERS@ }, 15000); return {holder: text(row)}; })()""".replace("@HELPERS@", HELPERS) +INBOX_CHIP = """(async () => {@HELPERS@ + const chip = (id) => document.querySelector(`#main [aria-label="Aufgabenfilter"] button[data-filter="${id}"]`); + chip("operator").click(); + await until(() => location.hash === "#stream/inbox" && document.querySelector("#main .inbox-list"), 4000); + const inbox = {hash: location.hash, items: all("#main .inbox-list > li").length, counted: Number(text(chip("operator").querySelector(".count"))), pressed: chip("operator").getAttribute("aria-pressed"), current: all("#tabs button[aria-current=page]").map(name)}; + chip("freigabe").click(); + await until(() => location.hash.startsWith("#tasks/"), 4000); + await sleep(300); + return {inbox, back: {hash: location.hash, rows: all("#main .task-row").map((row) => [row.dataset.task, text(row.querySelector(".pill"))])}}; +})()""".replace("@HELPERS@", HELPERS) +PIN = """(async () => {@HELPERS@ + const row = await until(() => all("#main .task-row").find((item) => item.dataset.task === "work/docs"), 8000); + if (!row) { + return {error: text(document.getElementById("main"))}; + } + row.querySelector('button[aria-label="Priorität"]').click(); + await sleep(400); + const field = document.querySelector('.sheet-box input[aria-label="Priorität"]'); + field.value = "2"; + field.dispatchEvent(new Event("input", {bubbles: true})); + const preview = all(".sheet-box .task-edit .command code").map(text); + all(".sheet-box button").find((button) => name(button) === "Anheften").click(); + const done = await until(() => document.querySelector(".sheet-box [data-jobs]"), 20000); + return {title: text(document.querySelector(".sheet-head .panel-title")), preview, jobs: done ? done.dataset.jobs : null, toasts: all("#toasts .toast").map(text)}; +})()""".replace("@HELPERS@", HELPERS) ACTIONS = """(async () => {@HELPERS@ const press = async (key) => { document.dispatchEvent(new KeyboardEvent("keydown", {key, bubbles: true})); await sleep(300); return location.hash; }; - const found = {keys: [await press("2"), await press("3"), await press("1")]}; + const found = {keys: [await press("2"), await press("3"), await press("4"), await press("1")]}; document.getElementById("settings-open").click(); await sleep(300); found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id); @@ -554,12 +588,25 @@ class TestOrchestratorE2E(HubCase): super().setUp() OUT.mkdir(parents=True, exist_ok=True) now = time.time() + self.root = self.write_tree() + feature = f"{self.root}/worktrees/github.com/me/demo/feature" self.claim("s1", "Skills session") self.write_council({ "work/s2/claim.json": {"office": "work/s2", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "x"}, "chair/claim.json": {"office": "chair", "session": "s1", "runtime": "claude", "name": "Skills session", "since": iso(now - 7200), "beat": now, "work": "allocate"}, "identity/oidc/claim.json": {"office": "identity/oidc", "session": "s2", "runtime": "claude", "name": "Stage", "since": iso(now - 7200), "beat": now - 2000, "work": "oidc line"}, - "rasci.json": {"work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}}, + "rasci.json": { + "work/stage": {"title": "Stage line", "status": "running", "worker": "Stage (this session)", "roles": {"Stage (this session)": "RA"}}, + "work/feature": { + "title": "Merge-Flow im Hub zeigen", "status": "running", "worker": "Skills session", "objective": "Der Operator sieht, wohin ein Branch fließt", "workdir": feature, "prio": 1, + "roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "Flow im Tree", "met": True}, {"text": "Flow in Tasks", "met": False}], + "estimates": {"Skills": {"value": 8, "complexity": 3, "at": iso(now - 600)}}, + }, + "work/docs": {"title": "README nachziehen", "status": "blocked", "worker": "Docs", "branch": "main", "roles": {"Docs": "R", "Maintainer": "A", "Skills": "S"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}}, + "work/old": {"title": "Alte Linie", "status": "done", "worker": "Stage", "end": iso(now - 86400), "roles": {"Stage": "RA"}}, + "operator/push": {"title": "git push", "workdir": "/repo", "status": "done", "roles": {"Operator": "R"}}, + }, + "queue.json": {"seq": 3, "entries": [{"id": "gate-3", "kind": "gate", "task": "work/feature", "workdir": feature, "session": "s1", "name": "Skills", "status": "waiting", "enqueued": now - 120, "cmd": "make test"}]}, "radio-silence.json": {"since": now - 60, "until": now + 3600, "by": "Docs", "text": "disk full"}, "news.jsonl": self.jsonl([ {"ts": now - 600, "from": "Skills", "to": ["Stage"], "text": "rebase please", "class": "post", "v": 2}, @@ -572,7 +619,6 @@ class TestOrchestratorE2E(HubCase): self.subagents(now) self.request("feat: orchestrator", "") self.create_ask(ASK) - self.root = self.write_tree() self.base = f"http://{self.host}/" def subagents(self, now: float) -> None: @@ -722,6 +768,7 @@ class TestOrchestratorE2E(HubCase): steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "branch:"), "shot": self.shot("1366-artifacts-3d-picked")}) steps.append({"width": 1366, "height": 768, "url": f"{self.base}#agents/3d", "wait": 900, "probe": PICK_3D.replace("@PREFIX@", "s2"), "shot": self.shot("1366-agents-3d-picked")}) steps.append({"width": 1366, "height": 768, "url": f"{self.base}#artifacts/3d", "wait": 900, "probe": PROBE, "shot": self.shot("1366-artifacts-3d-reduced-motion"), "media": [{"name": "prefers-reduced-motion", "value": "reduce"}]}) + steps.append({"width": 1366, "height": 768, "url": f"{self.base}#tasks/list", "wait": 900, "probe": INBOX_CHIP, "shot": self.shot("1366-tasks-freigabe")}) data = self.drive(steps, None) (OUT / "chromium-walk.json").write_text(json.dumps(data, indent=1), encoding="utf-8") self.assertEqual(data["errors"], []) @@ -731,8 +778,8 @@ class TestOrchestratorE2E(HubCase): self.assertNotIn("error", probe) self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator")) self.assertEqual(probe["header"], ["palette-open", "settings-open"]) - self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], SUBS)) - self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None}) + self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS)) + self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None, "tab-tasks": None}) self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full") self.assertFalse(probe["rail"]) self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True)) @@ -769,11 +816,25 @@ class TestOrchestratorE2E(HubCase): for view in ("orbit", "chord"): with self.subTest(view=view): self.assertGreaterEqual(by_hash[f"#artifacts/{view}"]["marks"], 4) + self.assertEqual((artifacts["flows"], artifacts["openWork"]), (["file", "invalid"], 5)) + for state in ("offene Arbeit: nicht committet, Aufgabe offen", "offene Arbeit: ungepusht, Aufgabe offen", "offene Arbeit: nicht gemergt", "3 Worktrees mit offener Arbeit"): + with self.subTest(state=state): + self.assertIn(state, artifacts["states"]) + self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"]) + order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"] + listed = by_hash["#tasks/list"] + self.assertEqual(listed["views"], ["Liste", "Board", "RASCI"]) + self.assertEqual(listed["taskFilters"], [["alle", "true", "6"], ["offen", "false", "4"], ["arbeit", "false", "4"], ["operator", "false", "2"], ["freigabe", "false", "1"], ["beendet", "false", "2"]]) + self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order]) + self.assertEqual(listed["flows"], ["file", "file"]) + self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["blocked", ["work/docs"]], ["done", ["work/old", "operator/push"]], ["stopped", []]]) + self.assertEqual(by_hash["#tasks/rasci"]["matrix"], [6, 6, "CIRARASRARAR"]) + self.assertEqual([probe["taskFilters"][3][1] for probe in probes if probe["hash"] == "#stream/inbox"], ["true"] * len(WIDTHS)) extra = data["results"][len(probes) :] self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"]) actions = extra[3] self.assertNotIn("error", actions) - self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#stream"]) + self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/rasci", "#stream"]) self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"]) self.assertEqual((actions["settingsUnlabelled"], actions["drawerUnlabelled"]), (0, 0)) self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "SECURITE"]) @@ -782,10 +843,13 @@ class TestOrchestratorE2E(HubCase): with self.subTest(tool=tool): self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"]) self.assertEqual(actions["drawer"], [["post", True], ["call", False]]) - artifact_pick, agent_pick, calm = extra[4:] + artifact_pick, agent_pick, calm, chips = extra[4:] self.assertEqual(artifact_pick["panel"], artifact_pick["label"]) self.assertEqual(agent_pick["panel"], agent_pick["label"]) self.assertRegex(calm["note"], r"Kraft · ohne Bewegung \(prefers-reduced-motion\)$") + self.assertEqual({key: value for key, value in chips["inbox"].items() if key != "counted"}, {"hash": "#stream/inbox", "items": chips["inbox"]["counted"], "pressed": "true", "current": ["Inbox"]}) + self.assertGreaterEqual(chips["inbox"]["items"], 1) + self.assertEqual(chips["back"], {"hash": "#tasks/list", "rows": [["queue/gate-3", "Queue-Job"]]}) def test_chromium_sends_a_call_from_the_agent_panel_and_shows_its_receipt(self): handled = [] @@ -1055,6 +1119,25 @@ class TestOrchestratorE2E(HubCase): self.assertEqual(probe["console"], "erledigtExit-Code 0 nach 9 s") self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot) + def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self): + signer = self.pin() + private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----")) + authenticator = {"credentialId": base64.b64encode(signer.raw_id).decode(), "privateKey": private, "rpId": "localhost"} + local = f"http://localhost:{self.port}/" + data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#tasks/list", "wait": 1200, "probe": PIN, "shot": self.shot("1366-tasks-priority-signed")}], authenticator) + (OUT / "chromium-task-priority.json").write_text(json.dumps(data, indent=1), encoding="utf-8") + self.assertEqual(data["errors"], []) + pinned = data["results"][0] + self.assertNotIn("error", pinned) + self.assertEqual((pinned["title"], pinned["preview"][0]), ("work/docs", "rasci.py set work/docs --prio 2")) + self.assertEqual(pinned["toasts"], ["Priorität work/docs: an den Runner übergebenrasci.py set work/docs --prio 2"]) + jobs = self.runner_jobs() + self.assertEqual([(job["id"], job["kind"], job["argv"]) for job in jobs], [(pinned["jobs"], "task", ["set", "work/docs", "--prio", "2"])]) + self.run_runner() + register = json.loads((self.council / "rasci.json").read_text(encoding="utf-8")) + self.assertEqual((register["work/docs"]["prio"], register["work/feature"]["prio"]), (2, 1)) + self.assertEqual([(row["task"], row["prio"]) for row in self.snapshot()["tasks"][:2]], [("work/feature", 1), ("work/docs", 2)]) + @unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox") def test_firefox_walks_every_screen_at_three_widths(self): shots = [(width, height, screen, OUT / f"firefox-{width}-{screen.replace('/', '-')}.png") for width, height in WIDTHS for screen in SCREENS] @@ -1071,7 +1154,7 @@ class TestOrchestratorE2E(HubCase): with self.subTest(width=width, screen=screen): self.assertNotIn("error", probe) self.assertEqual(probe["hash"], f"#{screen}") - self.assertEqual((probe["tabs"], probe["subs"]), (["Stream", "Agents", "Artifacts"], SUBS)) + self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS)) self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []}) self.assertFalse(probe["wide"], "the page scrolls sideways") self.assertTrue(shot.is_file() and shot.stat().st_size > 1000, shot) diff --git a/tests/hub_case.py b/tests/hub_case.py index 701b77a..e532811 100644 --- a/tests/hub_case.py +++ b/tests/hub_case.py @@ -5,6 +5,7 @@ import itertools import json import os import subprocess +import sys import tempfile import threading import time @@ -20,6 +21,8 @@ from hub_support import ( HUB, OPTIONS, PAGE, + RASCI, + RUNNER, SCORE, TREE, FakeScripts, @@ -156,6 +159,18 @@ class HubCase(unittest.TestCase): def client(self) -> dict[str, str]: return {"Host": self.host, "X-Kvb-Orchestrator": "1"} + def runner_jobs(self) -> list[dict]: + status, _, body = self.call("GET", "/api/runner/jobs", None, self.client()) + self.assertEqual(status, 200, body) + return json.loads(body)["jobs"] + + def run_runner(self, register: bool = True, council: Path | None = None) -> None: + trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")] + flags = ["--rasci", str(RASCI), "--council", str(council or self.council)] if register else [] + command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, *flags, "--once"] + done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + def settle(self, check: Callable[[], bool]) -> None: deadline = time.monotonic() + 10 while time.monotonic() < deadline: diff --git a/tests/hub_support.py b/tests/hub_support.py index d671e01..b558c2b 100644 --- a/tests/hub_support.py +++ b/tests/hub_support.py @@ -21,6 +21,7 @@ sys.path[:0] = [str(HUB), str(HUB / "client")] CLIENT = HUB / "client" / "permission_hook.py" RUNNER = HUB / "client" / "runner.py" APPROVAL = HUB / "client" / "approval.py" +RASCI = REPO_ROOT / "skills" / "kvb-org-rasci" / "scripts" / "rasci.py" SPEC = importlib.util.spec_from_file_location("kvb_orchestrator_server", HUB / "server.py") server = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = server diff --git a/tests/test_hub_executors.py b/tests/test_hub_executors.py index e52da0d..3ae06fb 100644 --- a/tests/test_hub_executors.py +++ b/tests/test_hub_executors.py @@ -17,7 +17,6 @@ from hub_support import ( GIT_ENV, PAGE, PRESENT, - RUNNER, TREE, runner, signing, @@ -33,17 +32,6 @@ class TestHubExecutors(HubCase): def trust(self, name: str) -> verifier.Trust: return verifier.Trust(approvers=self.approvers, replay=self.replay / f"{name}.json", origin=self.origin) - def runner_jobs(self) -> list[dict]: - status, _, body = self.call("GET", "/api/runner/jobs", None, self.client()) - self.assertEqual(status, 200, body) - return json.loads(body)["jobs"] - - def run_runner(self) -> None: - trust = ["--approvers", str(self.approvers), "--replay", str(self.replay / "runner.json")] - command = [sys.executable, str(RUNNER), "--port", str(self.port), "--interval", "0.1", *trust, "--once"] - done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=False) - self.assertEqual(done.returncode, 0, done.stderr) - def test_the_runner_runs_only_signed_commands_once(self): command = "echo hello && echo world >&2" register = { diff --git a/tests/test_hub_nav.py b/tests/test_hub_nav.py index 1e75470..22e53c8 100644 --- a/tests/test_hub_nav.py +++ b/tests/test_hub_nav.py @@ -10,16 +10,19 @@ NAV_PROBE = """ const N = require(process.argv[1]); const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope", "#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane", - "#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio"]; + "#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio", + "#tasks", "#tasks/board", "#tasks/rasci", "#tasks/orbit", "#agents/board", "#tasks/hierarchy"]; console.log(JSON.stringify({ tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]), views: N.VIEWS.map(([id, label]) => [id, label]), + taskViews: N.TASK_VIEWS.map(([id, label]) => [id, label]), agents: N.viewsOf("agents").map(([id, label]) => [id, label]), artifacts: N.viewsOf("artifacts").map(([id, label]) => [id, label]), + tasks: N.viewsOf("tasks").map(([id, label]) => [id, label]), icon: N.ICONS.hierarchy, routes: hashes.map((hash) => N.route(hash)), - hashes: [["stream", null], ["inbox", null], ["rules", null], ["radio", null], ["agents", "chord"], ["artifacts", "3d"]].map(([screen, view]) => N.hashOf(screen, view)), - tabOf: ["stream", "inbox", "rules", "radio", "agents", "artifacts"].map((screen) => N.tabOf(screen)), + hashes: [["stream", null], ["inbox", null], ["rules", null], ["radio", null], ["agents", "chord"], ["artifacts", "3d"], ["tasks", "board"]].map(([screen, view]) => N.hashOf(screen, view)), + tabOf: ["stream", "inbox", "rules", "radio", "agents", "artifacts", "tasks"].map((screen) => N.tabOf(screen)), })); """ @@ -30,12 +33,14 @@ class TestNavModule(unittest.TestCase): done = subprocess.run(["node", "-e", NAV_PROBE, str(NAV)], capture_output=True, text=True, timeout=60, check=True) cls.data = json.loads(done.stdout) - def test_the_navigation_has_three_entries_and_stream_has_inbox_rules_and_radio(self): - self.assertEqual(self.data["tabs"], [["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]], ["agents", "Agents", []], ["artifacts", "Artifacts", []]]) + def test_the_navigation_has_four_entries_and_stream_has_inbox_rules_and_radio(self): + stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]] + self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]]) - def test_agents_and_artifacts_offer_five_views_in_order(self): + def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_three(self): shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]] self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared)) + self.assertEqual((self.data["taskViews"], self.data["tasks"]), ([["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]],) * 2) def test_only_agents_add_the_hierarchy_after_3d(self): self.assertEqual(self.data["agents"][-2:], [["3d", "3D"], ["hierarchy", "Hierarchie"]]) @@ -67,11 +72,17 @@ class TestNavModule(unittest.TestCase): {"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"}, {"screen": "radio", "view": None, "redirect": None}, stream, + {"screen": "tasks", "view": None, "redirect": None}, + {"screen": "tasks", "view": "board", "redirect": None}, + {"screen": "tasks", "view": "rasci", "redirect": None}, + {"screen": "tasks", "view": None, "redirect": None}, + {"screen": "agents", "view": None, "redirect": None}, + {"screen": "tasks", "view": None, "redirect": None}, ]) def test_every_screen_has_its_own_hash_and_tab(self): - self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#stream/radio", "#agents/chord", "#artifacts/3d"]) - self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts"]) + self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#stream/radio", "#agents/chord", "#artifacts/3d", "#tasks/board"]) + self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts", "tasks"]) if __name__ == "__main__": diff --git a/tests/test_hub_state.py b/tests/test_hub_state.py index 4074c84..dec61b6 100644 --- a/tests/test_hub_state.py +++ b/tests/test_hub_state.py @@ -151,7 +151,7 @@ class TestHubState(HubCase): set(state), { "now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator", - "register", "agent_order", "radio", "claims", "grants", "night", "silence", "graph", + "register", "tasks", "agent_order", "radio", "claims", "grants", "night", "silence", "graph", "inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech", "badges", "kinds", }, diff --git a/tests/test_hub_tasks.py b/tests/test_hub_tasks.py new file mode 100644 index 0000000..ac52160 --- /dev/null +++ b/tests/test_hub_tasks.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +import json +import os +import signal +import sys +import time +import unittest +from pathlib import Path +from unittest import mock + +from hub_case import HubCase +from hub_support import runner, signing, verifier +from kvb_orchestrator import skill_scripts, tasks, tree + +BROKEN = {"pull": [], "push": [], "source": "invalid"} +FLOW = {"pull": ["origin/main", "store-i18n"], "push": ["main", "production"], "source": "file"} +PRIO = ["set", "work/feature", "--prio", "3"] +ESTIMATE = ["estimate", "work/feature", "Operator", "--value", "8", "--complexity", "3"] + + +def change(argv: list, task: str = "work/feature") -> dict: + return {"kind": "task", "id": task, "argv": argv} + + +class TestHubTasks(HubCase): + def places(self) -> tuple[str, str, str]: + root = self.write_tree() + return str(root), f"{root}/repos/github.com/me/demo", f"{root}/worktrees/github.com/me/demo" + + def worktrees(self) -> dict[str, dict]: + data = json.loads(self.get("/api/tree")[2]) + found = {wt["path"]: wt for repo in data["repos"] for entry in repo["branches"] for wt in entry["worktrees"]} + return {**found, **{wt["path"]: wt for repo in data["repos"] for wt in repo["detached"]}} + + def test_the_tree_maps_each_worktree_flow_and_a_missing_one_is_none(self): + root, demo, trees = self.places() + flows = {path: wt["flow"] for path, wt in self.worktrees().items()} + self.assertEqual(flows, { + f"{trees}/feature": FLOW, + f"{trees}/feature-done": None, + demo: None, + f"{trees}/detached": BROKEN, + f"{root}/worktrees/x/y/lone": None, + f"{root}/outside/elsewhere": None, + }) + + def test_a_flow_of_the_wrong_shape_reads_as_invalid_and_long_lists_are_capped(self): + for raw in ( + "x", + ["main"], + {"pull": ["a"], "push": ["b"]}, + {"pull": "a", "push": [], "source": "file"}, + {"pull": [1], "push": [], "source": "file"}, + {"pull": ["a b"], "push": [], "source": "file"}, + {"pull": ["a\nb"], "push": [], "source": "file"}, + {"pull": ["x" * 256], "push": [], "source": "file"}, + {"pull": ["a"], "push": ["b"], "source": "File!"}, + {"pull": ["a"], "push": ["b"], "source": 3}, + {"pull": ["a"], "push": ["b"], "source": "invalid"}, + ): + with self.subTest(raw=raw): + self.assertEqual(tree.flow({"flow": raw}), BROKEN) + self.assertIsNone(tree.flow({})) + self.assertIsNone(tree.flow({"flow": None})) + many = tree.flow({"flow": {"pull": [f"b{n}" for n in range(40)], "push": ["main"], "source": "config", "extra": 1}}) + self.assertEqual(many, {"pull": [f"b{n}" for n in range(tree.FLOW_LIMIT)], "push": ["main"], "source": "config"}) + + def test_worktrees_carry_their_open_work_and_the_open_tasks_on_them(self): + root, demo, trees = self.places() + self.write_council({"rasci.json": { + "work/feature": {"status": "running", "workdir": f"{trees}/feature"}, + "work/old": {"status": "done", "workdir": f"{trees}/feature-done"}, + }}) + found = self.worktrees() + self.assertEqual({path: wt["open_work"] for path, wt in found.items()}, { + f"{trees}/feature": ["dirty", "task"], + f"{trees}/feature-done": [], + demo: ["unpushed"], + f"{trees}/detached": ["unmerged"], + f"{root}/worktrees/x/y/lone": ["unmerged"], + f"{root}/outside/elsewhere": [], + }) + self.assertEqual((found[f"{trees}/feature"]["tasks"], found[f"{trees}/feature-done"]["tasks"]), (["work/feature"], [])) + + def test_the_state_lists_every_task_ranked_with_roles_wsjf_flow_and_open_work(self): + _, _, trees = self.places() + now = time.time() + register = { + "work/feature": { + "title": "Feature", "status": "running", "worker": "Skills session", "objective": "Ship it", "workdir": f"{trees}/feature", + "roles": {"Skills session": "RA", "Operator": "C", "Docs": "I"}, "key_results": [{"text": "a", "met": True}, {"text": "b", "met": False}], + "estimates": {"Skills": {"value": 8, "complexity": 2, "at": "2026-11-07T08:00:00+00:00"}, "Docs": {"value": 5, "complexity": 3}}, + }, + "work/pinned": {"title": "Pinned", "status": "blocked", "prio": 1, "branch": "feature/done", "roles": {"Stage": "R"}}, + "work/scored": {"title": "Scored", "status": "queued", "estimates": {"Docs": {"value": 3, "complexity": 1}}}, + "work/done": {"title": "Done", "status": "done", "end": "2026-11-07T09:00:00+00:00", "workdir": "/nowhere"}, + "operator/push": {"title": "git push", "workdir": "/repo", "status": "queued", "roles": {"Operator": "R"}}, + "work/bad": {"title": ["x"], "status": "running", "prio": True, "estimates": {"X": {"value": 4, "complexity": 2}, "Y": "no", "Z": {"value": True, "complexity": 1}}, "roles": {"Z": "RQ", "W": 5}, "key_results": "no"}, + "broken": "not a row", + } + queue = {"seq": 9, "entries": [ + {"id": "gate-7", "kind": "gate", "task": "work/feature", "workdir": f"{trees}/feature", "session": "s1", "name": "Stage", "status": "waiting", "enqueued": now - 60, "cmd": "make test"}, + {"id": "deploy-8", "kind": "deploy", "task": "work/pinned", "workdir": "/elsewhere", "session": "s2", "name": "Docs", "status": "active", "enqueued": now - 30, "cmd": "make deploy"}, + {"id": "bad-9", "enqueued": "yesterday"}, + "not an entry", + ]} + self.write_council({"rasci.json": register, "queue.json": queue}) + rows = {row["task"]: row for row in self.snapshot()["tasks"]} + self.assertEqual(list(rows), ["work/pinned", "work/scored", "work/feature", "operator/push", "work/bad", "queue/gate-7", "queue/deploy-8", "work/done"]) + feature = rows["work/feature"] + self.assertEqual((feature["kind"], feature["status"], feature["met"], feature["total"], feature["prio"], feature["editable"], feature["ended"]), ("task", "running", 1, 2, None, True, False)) + self.assertEqual(feature["roles"], {"Skills": "RA", "Operator": "C", "Docs": "I"}) + self.assertEqual(feature["estimates"], {"Skills": {"value": 8, "complexity": 2}, "Docs": {"value": 5, "complexity": 3}}) + self.assertEqual(feature["wsjf"], {"value": 6.5, "complexity": 2.5, "score": 2.6}) + self.assertEqual((feature["worktree"], feature["worktree_branch"], feature["repo"], feature["flow"], feature["open_work"]), (f"{trees}/feature", "feature", "github.com/me/demo", FLOW, ["task", "dirty"])) + pinned = rows["work/pinned"] + self.assertEqual((pinned["prio"], pinned["worktree"], pinned["worktree_branch"], pinned["flow"], pinned["open_work"]), (1, f"{trees}/feature-done", "feature/done", None, ["task"])) + self.assertEqual(rows["work/scored"]["wsjf"], {"value": 3, "complexity": 1, "score": 3.0}) + bad = rows["work/bad"] + self.assertEqual((bad["title"], bad["prio"], bad["estimates"], bad["wsjf"], bad["roles"], bad["total"]), ("['x']", None, {}, None, {"Z": "R"}, 0)) + command = rows["operator/push"] + self.assertEqual((command["kind"], command["title"], command["editable"], command["open_work"]), ("command", "git push", False, ["task"])) + gate, deploy = rows["queue/gate-7"], rows["queue/deploy-8"] + self.assertEqual((gate["kind"], gate["status"], gate["title"], gate["worker"], gate["editable"], gate["flow"]), ("queue", "awaiting", "make test", "Stage", False, FLOW)) + self.assertEqual(gate["queue"], {"kind": "gate", "parent": "work/feature", "enqueued": now - 60}) + self.assertEqual((deploy["status"], deploy["flow"], deploy["queue"]["parent"]), ("running", None, "work/pinned")) + done = rows["work/done"] + self.assertEqual((done["ended"], done["editable"], done["open_work"], done["worktree"]), (True, False, [], "")) + + def test_only_the_newest_ended_tasks_are_listed(self): + register = {f"work/old-{n:03d}": {"status": "done", "end": f"2026-11-{1 + n // 24:02d}T{n % 24:02d}:00:00+00:00"} for n in range(30)} + self.write_council({"rasci.json": {**register, "work/open": {"status": "queued"}}}) + with mock.patch.object(tasks, "ENDED_SHOWN", 5): + listed = [row["task"] for row in self.snapshot()["tasks"]] + self.assertEqual(listed, ["work/open", "work/old-029", "work/old-028", "work/old-027", "work/old-026", "work/old-025"]) + + def test_a_task_change_needs_a_known_open_task_and_one_of_three_argv_shapes(self): + self.write_council({"rasci.json": { + "work/feature": {"title": "F", "status": "running"}, + "work/done": {"status": "done"}, + "operator/push": {"title": "git push", "workdir": "/repo", "status": "queued"}, + }}) + for argv in ( + ["set", "work/feature", "--prio", "0"], + ["set", "work/feature", "--prio", "1000"], + ["set", "work/feature", "--prio", "03"], + ["set", "work/feature", "--prio", 3], + ["set", "work/feature", "--prio", "3", "--status", "done"], + ["set", "work/other", "--prio", "3"], + ["estimate", "work/feature", "Skills", "--value", "8", "--complexity", "3"], + ["estimate", "work/feature", "Operator", "--value", "4", "--complexity", "3"], + ["estimate", "work/feature", "Operator", "--complexity", "3", "--value", "8"], + ["estimate", "work/feature", "Operator", "--drop", "--value", "8"], + ["rm", "work/feature"], + "set work/feature --prio 3", + ): + with self.subTest(argv=argv): + status, _, body = self.post_json("/api/sign", {"items": [change(argv)]}, self.guard(self.host)) + self.assertEqual(status, 400, body) + refused = ( + ([change(["set", "operator/push", "--prio", "3"], "operator/push")], 400), + ([change(["set", "work/x;rm", "--prio", "3"], "work/x;rm")], 400), + ([{**change(PRIO), "extra": 1}], 400), + ([change(PRIO), change(PRIO)], 400), + ([change(PRIO, "x")], 400), + ([change(["set", "work/nope", "--prio", "3"], "work/nope")], 404), + ([change(["set", "work/done", "--prio", "3"], "work/done")], 409), + ) + for items, code in refused: + with self.subTest(items=items): + self.assertEqual(self.post_json("/api/sign", {"items": items}, self.guard(self.host))[0], code) + prepared = self.prepare([change(PRIO), change(ESTIMATE), change(["estimate", "work/feature", "Operator", "--drop"])]) + self.assertEqual(len(prepared["payload"]["items"]), 3) + self.assertIn(signing.item_hash(signing.task_item("work/feature", PRIO)), prepared["payload"]["items"]) + self.assertEqual(self.get("/api/tasks")[0], 405) + self.assertEqual(self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": None}, self.client())[0], 403) + self.assertEqual(self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": None}, self.guard(self.host))[0], 400) + forged = {**self.signed([change(PRIO)], self.pin()), "assertion": {}} + status, _, body = self.post_json("/api/tasks", {"items": [change(PRIO)], "proof": forged}, self.guard(self.host)) + self.assertEqual(status, 400, body) + self.assertIn("nicht signiert", body.decode() if isinstance(body, bytes) else body) + snapshot = self.snapshot() + self.assertEqual([job for job in snapshot["jobs"] if job["kind"] == "task"], []) + self.assertNotIn("Aufgabe signiert geändert", json.dumps(snapshot, ensure_ascii=False)) + + def test_a_task_the_hub_cannot_sign_whole_is_not_editable(self): + long_slug = "work/" + "x" * 600 + self.write_council({"rasci.json": {long_slug: {"status": "running"}, "work/a b": {"status": "running"}}}) + self.assertEqual([task["editable"] for task in self.snapshot()["tasks"]], [False, False]) + + def test_a_signed_priority_and_estimate_reach_the_register_through_the_runner(self): + self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running", "roles": {"Skills": "RA"}}}}) + signer = self.pin() + items = [change(PRIO), change(ESTIMATE)] + status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host)) + self.assertEqual(status, 202, body) + self.assertEqual((len(json.loads(body)["jobs"]), json.loads(body)["status"]), (2, "queued")) + queued = sorted(self.runner_jobs(), key=lambda job: job["argv"][0], reverse=True) + self.assertEqual([(job["kind"], job["task"], job["argv"], job["command"], job["directory"]) for job in queued], [ + ("task", "work/feature", PRIO, "rasci.py set work/feature --prio 3", ""), + ("task", "work/feature", ESTIMATE, "rasci.py estimate work/feature Operator --value 8 --complexity 3", ""), + ]) + trust = verifier.Trust(approvers=self.approvers, replay=self.replay / "old.json", origin=self.origin) + self.assertIn("does not list this item", runner.refusal(queued[0], trust, time.time())) + self.run_runner() + stored = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"] + self.assertEqual((stored["prio"], stored["estimates"]["Operator"]["value"], stored["estimates"]["Operator"]["complexity"], stored["roles"]), (3, 8, 3, {"Skills": "RA"})) + jobs = {job["command"]: job for job in self.snapshot()["jobs"]} + self.assertEqual([(job["status"], job["code"]) for job in jobs.values()], [("done", 0), ("done", 0)]) + self.assertEqual(self.snapshot()["tasks"][0]["prio"], 3) + self.assertNotIn(skill_scripts.OPERATOR_SCRIPT, [script for script, _ in self.scripts.calls]) + proof = queued[0]["proof"] + forged = ( + (["set", "work/feature", "--prio", "9"], proof, "does not list this item"), + (["rm", "work/feature"], proof, "no task priority"), + (PRIO, proof, "already used"), + (PRIO, None, "no signature"), + ) + for argv, relayed, reason in forged: + with self.subTest(argv=argv, reason=reason): + fields = {"kind": "task", "task": "work/feature", "argv": argv, "command": "x", "directory": "", "sha256": "", "status": "queued", "proof": relayed} + tampered = self.httpd.hub.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None}) + self.run_runner() + record = self.httpd.hub.jobs.get(tampered["id"]) + self.assertEqual(record["status"], "refused") + self.assertTrue(record["output"].startswith(runner.REFUSED), record["output"]) + self.assertIn(reason, record["output"]) + self.assertEqual(json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"]["prio"], 3) + + def test_a_runner_without_the_register_flags_refuses_task_changes(self): + self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running"}}}) + signer = self.pin() + items = [change(PRIO)] + self.assertEqual(self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))[0], 202) + self.run_runner(register=False) + job = next(job for job in self.snapshot()["jobs"] if job["kind"] == "task") + self.assertEqual(job["status"], "refused") + self.assertIn(runner.UNREGISTERED, job["output"]) + self.assertNotIn("prio", json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"]) + + def test_a_runner_refuses_task_changes_into_a_symlinked_or_foreign_council(self): + self.write_council({"rasci.json": {"work/feature": {"title": "F", "status": "running"}}}) + linked = self.council.with_name("linked-council") + linked.symlink_to(self.council) + signer = self.pin() + items = [change(PRIO)] + self.assertEqual(self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))[0], 202) + self.run_runner(council=linked) + job = next(job for job in self.snapshot()["jobs"] if job["kind"] == "task") + self.assertEqual(job["status"], "refused") + self.assertIn(f"{linked} is a symlink, no directory or not owned by uid", job["output"]) + self.assertNotIn("prio", json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))["work/feature"]) + self.assertIsNone(runner.council_refusal(self.council)) + for path in (self.council / "rasci.json", self.council / "missing", Path(""), Path("council")): + with self.subTest(path=path): + self.assertTrue(runner.council_refusal(path).startswith("refused: ")) + with mock.patch("os.geteuid", return_value=os.geteuid() + 1): + self.assertIn("not owned by uid", runner.council_refusal(self.council)) + + def test_a_signed_task_change_never_writes_through_a_register_swapped_for_a_link(self): + register = {"work/feature": {"title": "F", "status": "running"}} + self.write_council({"rasci.json": register}) + signer = self.pin() + items = [change(PRIO)] + self.assertEqual(self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))[0], 202) + target = self.outside / "elsewhere.json" + target.write_text(json.dumps(register), encoding="utf-8") + before = target.read_bytes() + linked = self.council / "rasci.json" + linked.unlink() + linked.symlink_to(target) + self.run_runner() + job = next(job for job in self.snapshot()["jobs"] if job["kind"] == "task") + self.assertEqual(job["status"], "failed", job["output"]) + self.assertIn("is no regular file below", job["output"]) + self.assertEqual((linked.is_symlink(), target.read_bytes()), (True, before)) + + def test_a_task_change_runs_only_its_checked_argv_from_the_root_within_its_limit(self): + register = runner.Register(rasci=Path("/x/rasci.py"), council=Path("/c")) + with mock.patch.object(runner, "spawn", return_value=0) as spawn: + runner.change({"task": "work/feature", "argv": PRIO}, register, print) + with self.assertRaises(signing.Refused): + runner.change({"task": "work/feature", "argv": ["rm", "work/feature"]}, register, print) + argv, cwd, env, _, limit = spawn.call_args.args + self.assertEqual((spawn.call_count, argv[1:], cwd, env["AGENTS_COUNCIL_ROOT"], limit), (1, ["-I", "-B", "/x/rasci.py", *PRIO], "/", "/c", runner.TASK_LIMIT)) + + def test_a_program_past_its_limit_is_killed_with_its_whole_process_group(self): + reports = [] + child = "import subprocess, sys, time; subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); time.sleep(60)" + started = time.monotonic() + code = runner.spawn([sys.executable, "-c", child], "/", None, reports.append, 0.5) + self.assertLess(time.monotonic() - started, 10) + self.assertEqual((code, reports[-1]["code"]), (-signal.SIGKILL, -signal.SIGKILL)) + self.assertIn("killed after 0.5 s", reports[-1]["output"]) + + def test_open_tasks_queue_rows_parties_and_task_keys_are_capped(self): + many = {f"P{n}": {"value": 3, "complexity": 1} for n in range(9)} + register = {f"work/t{n}": {"status": "queued", "prio": n + 1, "estimates": many, "roles": {party: "R" for party in many}} for n in range(6)} + register["work/" + "x" * 900] = {"status": "queued"} + now = time.time() + queue = {"seq": 7, "entries": [{"id": f"gate-{n}", "kind": "gate", "task": "work/t0", "status": "waiting", "enqueued": now - 100 + n, "cmd": "make test"} for n in range(7)]} + self.write_council({"rasci.json": register, "queue.json": queue}) + with mock.patch.multiple(tasks, OPEN_SHOWN=4, QUEUE_SHOWN=3, PARTIES_SHOWN=2): + rows = self.snapshot()["tasks"] + self.assertEqual([row["task"] for row in rows], ["work/t0", "work/t1", "work/t2", "work/t3", "queue/gate-0", "queue/gate-1", "queue/gate-2"]) + self.assertEqual((len(rows[0]["estimates"]), len(rows[0]["roles"])), (2, 2)) + self.assertEqual(max(len(row["task"]) for row in self.snapshot()["tasks"]), tasks.TEXT_SHOWN) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_orchestrator_migration.py b/tests/test_orchestrator_migration.py index 6104241..336d339 100644 --- a/tests/test_orchestrator_migration.py +++ b/tests/test_orchestrator_migration.py @@ -149,6 +149,49 @@ class TestOrchestratorMigration(unittest.TestCase): self.assertIn("run 'make install' to enable", done.stderr) self.assertNotIn(f"{NEW}-offices.path", json.loads(self.state.read_text(encoding="utf-8"))["enabled"]) + def test_the_runner_unit_waits_for_the_installed_skill_scripts_of_this_tree(self): + self.assertIn(f"{NEW}-runner.service", self.install()["enabled"]) + runner = f"{NEW}-runner.service" + for script in HOST_SCRIPTS: + with self.subTest(script=script): + installed = self.home / ".claude" / "skills" / script + kept = installed.read_text(encoding="utf-8") + installed.write_text(kept + "\n", encoding="utf-8") + state = json.loads(self.state.read_text(encoding="utf-8")) + self.state.write_text(json.dumps({**state, "calls": [], "enabled": sorted({*state["enabled"], runner})}), encoding="utf-8") + done = subprocess.run(["bash", str(SCRIPT), "install"], env=self.env, capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertIn(f"run 'make install' to enable {runner}", done.stderr) + after = json.loads(self.state.read_text(encoding="utf-8")) + self.assertNotIn(runner, after["enabled"]) + self.assertNotIn(["systemctl", "--user", "restart", runner], after["calls"]) + refused = subprocess.run(["bash", str(SCRIPT), "runner"], env=self.env, capture_output=True, text=True, timeout=120, check=False) + self.assertEqual(refused.returncode, 1, refused.stderr) + self.assertNotIn(runner, json.loads(self.state.read_text(encoding="utf-8"))["enabled"]) + installed.write_text(kept, encoding="utf-8") + + def test_install_refreshes_and_restarts_only_an_enabled_runner_unit(self): + units = self.home / ".config" / "systemd" / "user" + (units / "default.target.wants" / f"{OLD}-runner.service").unlink() + (units / f"{OLD}-runner.service").unlink() + shipped = (REPO_ROOT / "orchestrator" / "systemd" / f"{NEW}-runner.service").read_text(encoding="utf-8") + restart = ["systemctl", "--user", "restart", f"{NEW}-runner.service"] + for enabled in (False, True): + with self.subTest(enabled=enabled): + self.write(units / f"{NEW}-runner.service", "[Unit]\nstale\n") + state = json.loads(self.state.read_text(encoding="utf-8")) + self.state.write_text(json.dumps({**state, "calls": [], "enabled": [f"{NEW}-runner.service"] if enabled else []}), encoding="utf-8") + calls = self.install()["calls"] + self.assertEqual((units / f"{NEW}-runner.service").read_text(encoding="utf-8") == shipped, enabled) + self.assertEqual(restart in calls, enabled) + + def test_every_shipped_service_pins_the_path(self): + units = sorted((REPO_ROOT / "orchestrator" / "systemd").glob("*.service")) + self.assertIn(f"{NEW}-runner.service", [unit.name for unit in units]) + for unit in units: + with self.subTest(unit=unit.name): + self.assertIn("Environment=PATH=/usr/bin:/bin", unit.read_text(encoding="utf-8").splitlines()) + def test_install_ships_no_bytecode_from_the_tree(self): planted = REPO_ROOT / "orchestrator" / "client" / "__pycache__" / "planted.cpython-0.pyc" planted.parent.mkdir(exist_ok=True)