feat(orchestrator): tasks tab with register, inbox and queue

Add a Tasks tab that lists the kvb-org-rasci register next to the inbox
and the heavy-job queue, in list, board and RASCI matrix views, each
task with its roles, WSJF, open work and the merge flow of its worktree.

- Task priority pins and WSJF estimates are signed with the operator's
  passkey and applied by the runner through rasci.py; the hub verifies
  the signature itself before it queues a job or logs the change, the
  runner verifies it again and runs only the checked argv, from /, with
  a pinned PATH and a 30 s process-group limit.
- The runner refuses task changes into a council that is a symlink, not
  owned by its uid or given by a relative path, and while the installed
  council.py, nofollow.py or rasci.py differ from the tree.
- Only tasks the hub can sign whole are editable (task slug and length).
- Page lookups keyed by register text use Object.hasOwn, so a status or
  party named like an Object.prototype member cannot break the views.
- The inbox screen renders through the tasks screen, so its task filter
  shows with "Wartet auf Operator" pressed.

Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox).

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 23:43:36 +00:00
1 parent 01f791a1f4
commit c1310c3322
37 files changed
+1436 -80

No files matched your search

+2
View File
@@ -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/<slug>`) and presidents (`work/<slug>`); 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 <office>` 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 <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, 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=<blob>` 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:<port>`; ES256, EdDSA and RS256, so a hardware key can be pinned beside it). To register, open the hub at `http://localhost:<port>/` (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 <port> --approvers ~/.config/kvb-orchestrator/approvers.json --credential '<blob>'`, 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 <port> --timeout <seconds> --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/<id>/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.
+121 -10
View File
@@ -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 <rasci> <argv>` from / without a shell, after checking the
argv shape itself, and kills its process group after 30 s. Refuses task
changes without --rasci and --council or into a council that is a
symlink or another uid's, and every other job. Posts the last 200 output lines
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:
+35
View File
@@ -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 <task> --prio <1-999>`,
`estimate <task> Operator --value <fib> --complexity <fib>` or
`estimate <task> 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}
@@ -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)
+14 -4
View File
@@ -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,
+4 -1
View File
@@ -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:
+2 -1
View File
@@ -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
+10 -2
View File
@@ -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}
+1 -1
View File
@@ -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}},
+70
View File
@@ -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"}
+167
View File
@@ -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]),
]
+41 -6
View File
@@ -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}
+3
View File
@@ -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,
@@ -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
+8
View File
@@ -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" %}
</style>
</head>
<body>
@@ -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" %}
+6 -4
View File
@@ -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;
+15 -3
View File
@@ -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") {
+1 -1
View File
@@ -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"});
+68
View File
@@ -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;
}
+1 -1
View File
@@ -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")) {
+1 -1
View File
@@ -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)})),
+4 -2
View File
@@ -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();
}
+1 -1
View File
@@ -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};
+104
View File
@@ -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);
}
+177
View File
@@ -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."));
}
+10 -3
View File
@@ -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", () => {
+44
View File
@@ -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; }
+16 -4
View File
@@ -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}"
+22 -4
View File
@@ -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"
}
}
]
},
+92 -9
View File
@@ -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)
+15
View File
@@ -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:
+1
View File
@@ -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
-12
View File
@@ -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 = {
+19 -8
View File
@@ -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__":
+1 -1
View File
@@ -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",
},
+312
View File
@@ -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()
+43
View File
@@ -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)