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:
1 parent
01f791a1f4
commit
c1310c3322
37 files changed
+1436
-80
No files matched your search
+121
-10
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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}},
|
||||
|
||||
@@ -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"}
|
||||
@@ -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]),
|
||||
]
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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)})),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user