feat(orchestrator): W2 Kanban board with swimlanes and forecast
Tasks gain a Kanban board: columns Waiting, Running and Done, swimlanes P1 to P4+ and Unpinned by WSJF, after-dependency arrows and the dashed queue line; drag and drop stages status, pin and dependency moves that apply with one passkey. The burn chart gets the trend and a forecast. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W2 e2e never ran. 🎯 Fix: 80 % 💥 Break: 10 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 72 🚦 Gate: ⛔ fix 80 % < 90, break 10 % > 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd8fc08603
commit
8e63b21e21
18 files changed
+1375
-76
No files matched your search
@@ -175,8 +175,8 @@ flowchart TD
|
|||||||
|
|
||||||
### W2 Kanban
|
### W2 Kanban
|
||||||
|
|
||||||
- [ ] Columns Waiting, Running and Done; no Blocked or Stopped column.
|
- [x] Columns Waiting, Running and Done; no Blocked or Stopped column.
|
||||||
- [ ] A blocked task carries a red Blocked label, a stopped one a grey Stopped
|
- [x] A blocked task carries a red Blocked label, a stopped one a grey Stopped
|
||||||
label in Done; `operator/*` rows are not on the board.
|
label in Done; `operator/*` rows are not on the board.
|
||||||
- [ ] Swimlanes P1, P2, P3, P4+ and Unpinned, collapsible, Unpinned sorted by
|
- [ ] Swimlanes P1, P2, P3, P4+ and Unpinned, collapsible, Unpinned sorted by
|
||||||
WSJF rank; Done cards older than 24 h hidden until shown.
|
WSJF rank; Done cards older than 24 h hidden until shown.
|
||||||
@@ -190,8 +190,8 @@ flowchart TD
|
|||||||
- [ ] Staged moves show at once as pending; "Apply n changes" signs them with
|
- [ ] Staged moves show at once as pending; "Apply n changes" signs them with
|
||||||
one passkey; the host executor applies them through `rasci.py`, refuses
|
one passkey; the host executor applies them through `rasci.py`, refuses
|
||||||
a cycle or a `done` with an open key result, and the card shows why.
|
a cycle or a `done` with an open key result, and the card shows why.
|
||||||
- [ ] Each affected agent receives one post listing the operator's changes to
|
- [x] Each affected agent receives one post listing the operator's changes to
|
||||||
its rows.
|
its rows that `rasci.py` applied, once the whole signed batch ended.
|
||||||
- [ ] A click on a card opens its detail popup.
|
- [ ] A click on a card opens its detail popup.
|
||||||
|
|
||||||
### W2 Forecast
|
### W2 Forecast
|
||||||
@@ -331,7 +331,7 @@ The work packages meet only through these interfaces:
|
|||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
- Unit:
|
- Unit:
|
||||||
- [ ] Board staging, lanes and refusals (cycle, open key result); forecast
|
- [x] Board staging, lanes and refusals (cycle, open key result); forecast
|
||||||
fixed, growing and never; stale; scope without `operator/*`.
|
fixed, growing and never; stale; scope without `operator/*`.
|
||||||
- [ ] Gates resolve (own rule, strictest of several ancestors, none);
|
- [ ] Gates resolve (own rule, strictest of several ancestors, none);
|
||||||
parents derive; receipt tree sha with unstaged and untracked files;
|
parents derive; receipt tree sha with unstaged and untracked files;
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ Returns:
|
|||||||
Runs as the operator, outside any sandbox, every operator command a
|
Runs as the operator, outside any sandbox, every operator command a
|
||||||
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
|
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
|
||||||
in the signed directory, each signature once and before its expiry.
|
in the signed directory, each signature once and before its expiry.
|
||||||
Runs every signed task change (priority pin or WSJF estimate) as
|
Runs every signed task change (priority pin, status, dependency or WSJF
|
||||||
|
estimate; a board batch is one job per change) as
|
||||||
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the
|
`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
|
argv shape itself, and kills its process group after 30 s; rasci.py
|
||||||
|
refuses a cycle or a done with an open key result, and its reason is the
|
||||||
|
job's output. Refuses task
|
||||||
changes without --rasci and --council or into a council that is a
|
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
|
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.
|
back while it runs and the exit code at its end. The exit code is 0.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ PRIO = re.compile(r"[1-9][0-9]{0,2}")
|
|||||||
FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
|
FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
|
||||||
ESTIMATOR = "Operator"
|
ESTIMATOR = "Operator"
|
||||||
COMMANDS = "operator/"
|
COMMANDS = "operator/"
|
||||||
|
STATUSES = ("queued", "running", "blocked", "done", "stopped")
|
||||||
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
|
||||||
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
|
||||||
WARNINGS = (
|
WARNINGS = (
|
||||||
@@ -156,14 +157,31 @@ def task_item(task: str, argv: list[str]) -> dict:
|
|||||||
return {"kind": "task", "id": task, "argv": argv}
|
return {"kind": "task", "id": task, "argv": argv}
|
||||||
|
|
||||||
|
|
||||||
|
def board_flags(task: str, flags: list[str]) -> bool:
|
||||||
|
if flags == ["--unpin"]:
|
||||||
|
return True
|
||||||
|
if len(flags) != 2:
|
||||||
|
return False
|
||||||
|
flag, value = flags
|
||||||
|
if flag == "--prio":
|
||||||
|
return bool(PRIO.fullmatch(value))
|
||||||
|
if flag == "--status":
|
||||||
|
return value in STATUSES
|
||||||
|
linked = flag in ("--after", "--drop-after")
|
||||||
|
return linked and value != task and bool(TASK.fullmatch(value)) and not value.startswith(COMMANDS)
|
||||||
|
|
||||||
|
|
||||||
def task_argv(task: object, argv: object) -> list[str]:
|
def task_argv(task: object, argv: object) -> list[str]:
|
||||||
"""Args:
|
"""Args:
|
||||||
task: the register task the change is for, a rasci.py slug outside
|
task: the register task the change is for, a rasci.py slug outside
|
||||||
operator/.
|
operator/.
|
||||||
argv: the rasci.py arguments, one of `set <task> --prio <1-999>`,
|
argv: the rasci.py arguments, one of `set <task>` with exactly one
|
||||||
`estimate <task> Operator --value <fib> --complexity <fib>`,
|
of `--prio <1-999>`, `--unpin`, `--status <status>`,
|
||||||
`estimate <task> Operator --value <fib>` (the operator's business
|
`--after <other>` or `--drop-after <other>` (other a register
|
||||||
value alone) or `estimate <task> Operator --drop`.
|
slug outside operator/), `estimate <task> Operator --value <fib>
|
||||||
|
--complexity <fib>`, `estimate <task> Operator --value <fib>`
|
||||||
|
(the operator's business value alone) or
|
||||||
|
`estimate <task> Operator --drop`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
argv, the hub and the runner check it the same way.
|
argv, the hub and the runner check it the same way.
|
||||||
@@ -176,12 +194,11 @@ def task_argv(task: object, argv: object) -> list[str]:
|
|||||||
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
|
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
|
||||||
raise Refused("the argv is no list of texts")
|
raise Refused("the argv is no list of texts")
|
||||||
rest = argv[3:]
|
rest = argv[3:]
|
||||||
pinned = argv[:3] == ["set", task, "--prio"] and len(argv) == 4 and bool(PRIO.fullmatch(argv[3]))
|
|
||||||
valued = len(rest) in (2, 4) and rest[0] == "--value" and rest[1] in FIBONACCI
|
valued = len(rest) in (2, 4) and rest[0] == "--value" and rest[1] in FIBONACCI
|
||||||
estimated = valued and (len(rest) == 2 or (rest[2] == "--complexity" and rest[3] in FIBONACCI))
|
estimated = valued and (len(rest) == 2 or (rest[2] == "--complexity" and rest[3] in FIBONACCI))
|
||||||
if pinned or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])):
|
if (argv[:2] == ["set", task] and board_flags(task, argv[2:])) or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])):
|
||||||
return argv
|
return argv
|
||||||
raise Refused("the argv is no task priority or WSJF estimate change of this task")
|
raise Refused("the argv is no task priority, status, dependency or WSJF estimate change of this task")
|
||||||
|
|
||||||
|
|
||||||
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
|
||||||
|
|||||||
@@ -94,4 +94,5 @@ class Jobs:
|
|||||||
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
|
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
|
||||||
tail = "\n".join(output.splitlines()[-20:])
|
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}})
|
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}
|
told = self.tell_applied(job) if job.get("kind") == TASK_JOB else []
|
||||||
|
return {"id": job_id, "status": status, "code": code, "runs": runs, "told": told}
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from .council import HUMAN, read_claims
|
||||||
|
from .denying import requester_of
|
||||||
from .errors import Conflict, Missing, ToolError
|
from .errors import Conflict, Missing, ToolError
|
||||||
from .fields import decode, expect
|
from .fields import decode, expect
|
||||||
from .jobs import TASK_JOB
|
from .jobs import JOB_OPEN, TASK_JOB
|
||||||
from .register import REGISTER, read_register, status_of
|
from .register import REGISTER, read_register, status_of
|
||||||
from .signed import signing
|
from .signed import signing
|
||||||
from .tasks import editable
|
from .skill_scripts import RADIO
|
||||||
|
from .tasks import editable, movable
|
||||||
|
|
||||||
CHANGE_LIMIT = 100
|
CHANGE_LIMIT = 100
|
||||||
CHANGE_KEYS = ("kind", "id", "argv")
|
CHANGE_KEYS = ("kind", "id", "argv")
|
||||||
|
TOLD = "Operator changed your tasks"
|
||||||
|
|
||||||
|
|
||||||
def task_request(raw: object) -> bool:
|
def task_request(raw: object) -> bool:
|
||||||
@@ -21,17 +26,41 @@ def shown_command(argv: list[str]) -> str:
|
|||||||
return " ".join(["rasci.py", *argv])
|
return " ".join(["rasci.py", *argv])
|
||||||
|
|
||||||
|
|
||||||
|
def changeable(task: str, row: dict, argv: list[str]) -> bool:
|
||||||
|
return movable(task) if argv[:1] == ["set"] and argv[2:3] == ["--status"] else editable(task, row)
|
||||||
|
|
||||||
|
|
||||||
|
def affected(items: list[dict], tasks: dict[str, dict]) -> dict[str, list[str]]:
|
||||||
|
"""Args:
|
||||||
|
items: the signed task items.
|
||||||
|
tasks: the register rows by task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Per worker of a changed row, the shown commands of its changes in
|
||||||
|
item order; a row without a worker or the Operator's own tells
|
||||||
|
nobody.
|
||||||
|
"""
|
||||||
|
found: dict[str, list[str]] = {}
|
||||||
|
for item in items:
|
||||||
|
worker = tasks.get(item["id"], {}).get("worker")
|
||||||
|
if isinstance(worker, str) and worker.strip() not in ("", "-", HUMAN):
|
||||||
|
found.setdefault(worker, []).append(shown_command(item["argv"]))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
class Tasking:
|
class Tasking:
|
||||||
def task_changes(self, raw: object) -> list[dict]:
|
def task_changes(self, raw: object) -> list[dict]:
|
||||||
"""Args:
|
"""Args:
|
||||||
raw: the page's items, each {"kind": "task", "id", "argv"} with
|
raw: the page's items, each {"kind": "task", "id", "argv"} with
|
||||||
the rasci.py arguments of one priority pin, WSJF estimate or
|
the rasci.py arguments of one priority pin, status,
|
||||||
operator business value; a bulk change over a selection is
|
dependency, WSJF estimate or operator business value; a
|
||||||
one item per task under one passkey assertion.
|
bulk change or a board batch is one item per change under
|
||||||
|
one passkey assertion.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The signed items, each for an open register task outside
|
The signed items, each for a register task outside operator/
|
||||||
operator/ and hotfix/ whose argv signing.task_argv() accepts.
|
and hotfix/ whose argv signing.task_argv() accepts; only a
|
||||||
|
status change may reopen an ended task.
|
||||||
"""
|
"""
|
||||||
if not isinstance(raw, list) or not 1 <= len(raw) <= CHANGE_LIMIT:
|
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")
|
raise ToolError(f"items must be a list of 1 to {CHANGE_LIMIT} task changes")
|
||||||
@@ -48,7 +77,7 @@ class Tasking:
|
|||||||
row = tasks.get(entry["id"])
|
row = tasks.get(entry["id"])
|
||||||
if row is None:
|
if row is None:
|
||||||
raise Missing(entry["id"])
|
raise Missing(entry["id"])
|
||||||
if not editable(entry["id"], row):
|
if not changeable(entry["id"], row, argv):
|
||||||
raise Conflict({"id": entry["id"], "status": status_of(row)})
|
raise Conflict({"id": entry["id"], "status": status_of(row)})
|
||||||
item = signing.task_item(entry["id"], argv)
|
item = signing.task_item(entry["id"], argv)
|
||||||
if item in signed:
|
if item in signed:
|
||||||
@@ -56,6 +85,36 @@ class Tasking:
|
|||||||
signed.append(item)
|
signed.append(item)
|
||||||
return signed
|
return signed
|
||||||
|
|
||||||
|
def tell_workers(self, items: list[dict]) -> list[str]:
|
||||||
|
"""Returns:
|
||||||
|
The radio addresses that got one post listing the operator's
|
||||||
|
changes to their rows.
|
||||||
|
"""
|
||||||
|
claims = read_claims(self.council)
|
||||||
|
told = []
|
||||||
|
for worker, commands in affected(items, read_register(self.council / REGISTER)).items():
|
||||||
|
address = requester_of(claims, worker)[1]
|
||||||
|
if address and self.scripts.run(RADIO, ["post", "--to", address, "--session", HUMAN, f"{TOLD}: {'; '.join(commands)}"])["code"] == 0:
|
||||||
|
told.append(address)
|
||||||
|
return told
|
||||||
|
|
||||||
|
def tell_applied(self, job: dict) -> list[str]:
|
||||||
|
"""Args:
|
||||||
|
job: a task job that just ended.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The radio addresses told once the last job of its signed batch
|
||||||
|
ended: one post per worker listing the changes rasci.py applied
|
||||||
|
(exit 0); a change it refused, the runner refused or a job
|
||||||
|
still open tells nobody, and a batch is told once.
|
||||||
|
"""
|
||||||
|
with self.jobs.changed:
|
||||||
|
batch = sorted((other for other in self.jobs.listed() if other.get("batch") == job.get("batch")), key=lambda other: other["created_ts"])
|
||||||
|
if job.get("batch") is None or any(other["status"] in JOB_OPEN or other.get("told") for other in batch):
|
||||||
|
return []
|
||||||
|
self.jobs.change(batch[0]["id"], {"told": True})
|
||||||
|
return self.tell_workers([{"id": other["task"], "argv": other["argv"]} for other in batch if other["status"] == "done"])
|
||||||
|
|
||||||
def change_tasks(self, body: bytes) -> dict:
|
def change_tasks(self, body: bytes) -> dict:
|
||||||
payload = decode(body)
|
payload = decode(body)
|
||||||
expect(payload, ("items", "proof"))
|
expect(payload, ("items", "proof"))
|
||||||
@@ -64,8 +123,9 @@ class Tasking:
|
|||||||
for item in items:
|
for item in items:
|
||||||
self.operator_proven(proof, item)
|
self.operator_proven(proof, item)
|
||||||
ids = []
|
ids = []
|
||||||
|
batch = secrets.token_hex(8)
|
||||||
for item in items:
|
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}
|
fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof, "batch": batch}
|
||||||
ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"])
|
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]
|
commands = [shown_command(item["argv"]) for item in items]
|
||||||
self.log.add({"kind": "run", "title": f"Task change signed: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
|
self.log.add({"kind": "run", "title": f"Task change signed: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
|
||||||
|
|||||||
@@ -28,9 +28,13 @@ def kind_of(task: str) -> str:
|
|||||||
return "hotfix" if task.startswith(HOTFIX) else "task"
|
return "hotfix" if task.startswith(HOTFIX) else "task"
|
||||||
|
|
||||||
|
|
||||||
def editable(task: str, row: dict) -> bool:
|
def movable(task: str) -> bool:
|
||||||
shown_whole = len(task) <= TEXT_SHOWN and signing.TASK.fullmatch(task) is not None
|
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
|
return shown_whole and kind_of(task) == "task"
|
||||||
|
|
||||||
|
|
||||||
|
def editable(task: str, row: dict) -> bool:
|
||||||
|
return movable(task) and status_of(row) not in ENDED
|
||||||
|
|
||||||
|
|
||||||
def fibonacci(value: object) -> bool:
|
def fibonacci(value: object) -> bool:
|
||||||
@@ -131,6 +135,7 @@ def task_row(task: str, row: dict, trees: list[dict]) -> dict:
|
|||||||
"kind": kind_of(task),
|
"kind": kind_of(task),
|
||||||
"ended": ended,
|
"ended": ended,
|
||||||
"editable": editable(task, row),
|
"editable": editable(task, row),
|
||||||
|
"movable": movable(task),
|
||||||
"prio": prio_of(row),
|
"prio": prio_of(row),
|
||||||
"estimates": estimates,
|
"estimates": estimates,
|
||||||
"wsjf": wsjf(estimates),
|
"wsjf": wsjf(estimates),
|
||||||
@@ -138,6 +143,7 @@ def task_row(task: str, row: dict, trees: list[dict]) -> dict:
|
|||||||
"points": points(estimates),
|
"points": points(estimates),
|
||||||
"start": iso_epoch(row.get("start")),
|
"start": iso_epoch(row.get("start")),
|
||||||
"end": iso_epoch(row.get("end")) if ended else None,
|
"end": iso_epoch(row.get("end")) if ended else None,
|
||||||
|
"updated": iso_epoch(row.get("updated")),
|
||||||
"after": after_of(row),
|
"after": after_of(row),
|
||||||
"base": shown(row.get("base"))[:TEXT_SHOWN],
|
"base": shown(row.get("base"))[:TEXT_SHOWN],
|
||||||
"roles": roles_of(row),
|
"roles": roles_of(row),
|
||||||
@@ -163,6 +169,7 @@ def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
|
|||||||
"kind": "queue",
|
"kind": "queue",
|
||||||
"ended": False,
|
"ended": False,
|
||||||
"editable": False,
|
"editable": False,
|
||||||
|
"movable": False,
|
||||||
"prio": None,
|
"prio": None,
|
||||||
"estimates": {},
|
"estimates": {},
|
||||||
"wsjf": None,
|
"wsjf": None,
|
||||||
@@ -170,6 +177,7 @@ def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
|
|||||||
"points": None,
|
"points": None,
|
||||||
"start": (epoch(entry.get("granted")) or created) if entry.get("status") == "active" else None,
|
"start": (epoch(entry.get("granted")) or created) if entry.get("status") == "active" else None,
|
||||||
"end": None,
|
"end": None,
|
||||||
|
"updated": None,
|
||||||
"after": [],
|
"after": [],
|
||||||
"base": "-",
|
"base": "-",
|
||||||
"roles": {},
|
"roles": {},
|
||||||
@@ -250,6 +258,24 @@ def queue_chain(queue: list[tuple[dict, float]], tasks: dict[str, dict], agents:
|
|||||||
return [(shown(entry.get("id"))[:TEXT_SHOWN], entry.get("status") == "waiting") for entry, _ in [*active, *waiting]]
|
return [(shown(entry.get("id"))[:TEXT_SHOWN], entry.get("status") == "waiting") for entry, _ in [*active, *waiting]]
|
||||||
|
|
||||||
|
|
||||||
|
def queue_line(chain: list[tuple[str, bool]], queue: list[tuple[dict, float]]) -> list[str]:
|
||||||
|
"""Args:
|
||||||
|
chain: queue_chain().
|
||||||
|
queue: the kvb-org-queue entries with their enqueue time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The tasks holding a live queue entry, each once, in the grant order
|
||||||
|
of their first entry.
|
||||||
|
"""
|
||||||
|
owners = {shown(entry.get("id"))[:TEXT_SHOWN]: entry.get("task") for entry, _ in queue}
|
||||||
|
line: list[str] = []
|
||||||
|
for job, _ in chain:
|
||||||
|
task = owners.get(job)
|
||||||
|
if isinstance(task, str) and len(task) <= TEXT_SHOWN and task not in line:
|
||||||
|
line.append(task)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
def branch_of(row: dict) -> str:
|
def branch_of(row: dict) -> str:
|
||||||
return row["branch"] if row["branch"] != "-" else row["worktree_branch"]
|
return row["branch"] if row["branch"] != "-" else row["worktree_branch"]
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from .reviewing import review_row
|
|||||||
from .settings import media_origins, station_origins, valid_settings
|
from .settings import media_origins, station_origins, valid_settings
|
||||||
from .signed import signing
|
from .signed import signing
|
||||||
from .skill_scripts import OUTPUT_LIMIT
|
from .skill_scripts import OUTPUT_LIMIT
|
||||||
from .tasks import queue_chain, task_edges, task_rows
|
from .tasks import queue_chain, queue_line, task_edges, task_rows
|
||||||
|
|
||||||
RADIO_WINDOW = 86400
|
RADIO_WINDOW = 86400
|
||||||
JOBS_SHOWN = 20
|
JOBS_SHOWN = 20
|
||||||
@@ -108,6 +108,7 @@ class View:
|
|||||||
queue = queue_entries(self.council)
|
queue = queue_entries(self.council)
|
||||||
order = stored_order(self.council / AGENT_ORDER)
|
order = stored_order(self.council / AGENT_ORDER)
|
||||||
rows = task_rows(tasks, queue, self.tree.get())
|
rows = task_rows(tasks, queue, self.tree.get())
|
||||||
|
chain = queue_chain(queue, tasks, order, now)
|
||||||
return {
|
return {
|
||||||
"settings": settings,
|
"settings": settings,
|
||||||
"media": media_origins(settings),
|
"media": media_origins(settings),
|
||||||
@@ -134,7 +135,8 @@ class View:
|
|||||||
"operator": operator_rows(tasks),
|
"operator": operator_rows(tasks),
|
||||||
"register": register_rows(tasks),
|
"register": register_rows(tasks),
|
||||||
"tasks": rows,
|
"tasks": rows,
|
||||||
"task_edges": task_edges(rows, queue_chain(queue, tasks, order, now)),
|
"task_edges": task_edges(rows, chain),
|
||||||
|
"queue_line": queue_line(chain, queue),
|
||||||
"agent_order": agent_order(tasks, claims, order),
|
"agent_order": agent_order(tasks, claims, order),
|
||||||
"radio": radio,
|
"radio": radio,
|
||||||
"claims": claims,
|
"claims": claims,
|
||||||
|
|||||||
@@ -412,6 +412,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
|
|
||||||
{% include "style-tasks.css" %}
|
{% include "style-tasks.css" %}
|
||||||
|
|
||||||
|
{% include "style-kanban.css" %}
|
||||||
|
|
||||||
{% include "style-bubbles.css" %}
|
{% include "style-bubbles.css" %}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -462,6 +464,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
{% include "faces.js" %}
|
{% include "faces.js" %}
|
||||||
|
|
||||||
{% include "plan.js" %}
|
{% include "plan.js" %}
|
||||||
|
|
||||||
|
{% include "kanban.js" %}
|
||||||
</script>
|
</script>
|
||||||
<script nonce="{{ nonce }}">
|
<script nonce="{{ nonce }}">
|
||||||
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||||
@@ -536,6 +540,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
|
|
||||||
{% include "page-tasks-gantt.js" %}
|
{% include "page-tasks-gantt.js" %}
|
||||||
|
|
||||||
|
{% include "page-kanban.js" %}
|
||||||
|
|
||||||
{% include "page-choices.js" %}
|
{% include "page-choices.js" %}
|
||||||
|
|
||||||
{% include "page-time.js" %}
|
{% include "page-time.js" %}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const Kanban = (() => {
|
||||||
|
"use strict";
|
||||||
|
const COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["done", "Done"]];
|
||||||
|
const LANES = [["p1", "P1"], ["p2", "P2"], ["p3", "P3"], ["p4", "P4+"], ["unpinned", "Unpinned"]];
|
||||||
|
const PINS = {p1: 1, p2: 2, p3: 3};
|
||||||
|
const FREE_FROM = 4;
|
||||||
|
const OFF = ["command"];
|
||||||
|
const DAY = 86400;
|
||||||
|
const REFUSED = ["failed", "refused"];
|
||||||
|
|
||||||
|
const onBoard = (row) => !OFF.includes(row.kind);
|
||||||
|
const laneOf = (prio) => (prio === null ? "unpinned" : prio >= FREE_FROM ? "p4" : `p${Math.max(prio, 1)}`);
|
||||||
|
const labelOf = (row) => (row.status === "blocked" || row.status === "stopped" ? row.status : null);
|
||||||
|
const old = (row, now) => columnOf(row) === "done" && row.end !== null && now - row.end > DAY;
|
||||||
|
const score = (row) => (row.wsjf ? row.wsjf.score : null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param row - a task row.
|
||||||
|
* @returns "done" for done and stopped, "running" for running and for blocked once started, else "queued".
|
||||||
|
*/
|
||||||
|
function columnOf(row) {
|
||||||
|
if (row.status === "done" || row.status === "stopped") {
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
return row.status === "running" || (row.status === "blocked" && row.start !== null) ? "running" : "queued";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ranked(a, b) {
|
||||||
|
const pins = (a.prio === null) - (b.prio === null) || (a.prio || 0) - (b.prio || 0);
|
||||||
|
const scores = (score(a) === null) - (score(b) === null) || (score(b) || 0) - (score(a) || 0);
|
||||||
|
return pins || scores || a.task.localeCompare(b.task, "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rows - the task rows to place, staged changes applied.
|
||||||
|
* @param options - {now, old: true shows Done cards that ended more than 24 h ago}.
|
||||||
|
* @returns {lanes, hidden}: per lane of LANES {id, label, count, cells: {column: rows}}, each cell in pin then WSJF
|
||||||
|
* order (WSJF rank alone in Unpinned), and the count of hidden old Done cards. Operator commands are never placed;
|
||||||
|
* a pin below 1 counts as P1.
|
||||||
|
*/
|
||||||
|
function board(rows, options) {
|
||||||
|
const placed = rows.filter(onBoard);
|
||||||
|
const hidden = options.old ? [] : placed.filter((row) => old(row, options.now));
|
||||||
|
const shown = placed.filter((row) => !hidden.includes(row)).sort(ranked);
|
||||||
|
const lanes = LANES.map(([id, label]) => {
|
||||||
|
const own = shown.filter((row) => laneOf(row.prio) === id);
|
||||||
|
return {id, label, count: own.length, cells: Object.fromEntries(COLUMNS.map(([column]) => [column, own.filter((row) => columnOf(row) === column)]))};
|
||||||
|
});
|
||||||
|
return {lanes, hidden: hidden.length};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rows - every task row, staged changes applied.
|
||||||
|
* @param task - the task that moves into P4+.
|
||||||
|
* @returns the lowest pin from 4 no other open task holds.
|
||||||
|
*/
|
||||||
|
function freePin(rows, task) {
|
||||||
|
const held = new Set(rows.filter((row) => row.task !== task && !row.ended && row.prio !== null).map((row) => row.prio));
|
||||||
|
let pin = FREE_FROM;
|
||||||
|
while (held.has(pin)) {
|
||||||
|
pin += 1;
|
||||||
|
}
|
||||||
|
return pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slotOf = (target) => (target.after ? `after:${target.after}` : target.drop ? `after:${target.drop}` : target.lane ? "prio" : "status");
|
||||||
|
const slotOfArgv = (argv) => (argv[2] === "--after" || argv[2] === "--drop-after" ? `after:${argv[3]}` : argv[2] === "--status" ? "status" : "prio");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param row - the hub's row of the moved card.
|
||||||
|
* @param target - {column}, {status}, {lane}, {after: the task it follows} or {drop: the task it no longer follows}.
|
||||||
|
* @param rows - every task row, staged changes applied, for the free pin.
|
||||||
|
* @returns the rasci.py argv of the move against the hub's row, or null when the row already reads so.
|
||||||
|
*/
|
||||||
|
function move(row, target, rows) {
|
||||||
|
if (target.column) {
|
||||||
|
return columnOf(row) === target.column ? null : ["set", row.task, "--status", target.column];
|
||||||
|
}
|
||||||
|
if (target.status) {
|
||||||
|
return row.status === target.status ? null : ["set", row.task, "--status", target.status];
|
||||||
|
}
|
||||||
|
if (target.lane) {
|
||||||
|
if (laneOf(row.prio) === target.lane) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return target.lane === "unpinned" ? ["set", row.task, "--unpin"] : ["set", row.task, "--prio", String(PINS[target.lane] || freePin(rows, row.task))];
|
||||||
|
}
|
||||||
|
if (target.after) {
|
||||||
|
return target.after === row.task || row.after.includes(target.after) ? null : ["set", row.task, "--after", target.after];
|
||||||
|
}
|
||||||
|
return target.drop && row.after.includes(target.drop) ? ["set", row.task, "--drop-after", target.drop] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param staged - the staged changes, each {task, argv}.
|
||||||
|
* @param row - the hub's row of the moved card.
|
||||||
|
* @param target - as move() takes it.
|
||||||
|
* @param rows - every task row, staged changes applied.
|
||||||
|
* @returns the new staged list: the move replaces an earlier one of the same task and kind (status, pin, or one
|
||||||
|
* dependency), and a move back to the hub's state leaves only the drop of that earlier one.
|
||||||
|
*/
|
||||||
|
function stage(staged, row, target, rows) {
|
||||||
|
const slot = slotOf(target);
|
||||||
|
const kept = staged.filter((change) => !(change.task === row.task && slotOfArgv(change.argv) === slot));
|
||||||
|
const argv = move(row, target, rows);
|
||||||
|
return argv ? [...kept, {task: row.task, argv}] : kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rows - the hub's task rows.
|
||||||
|
* @param staged - the staged changes.
|
||||||
|
* @returns the rows as they read once the staged changes are applied, a changed row with pending, the argv
|
||||||
|
* staged for it; the others unchanged.
|
||||||
|
*/
|
||||||
|
function pending(rows, staged) {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const own = staged.filter((change) => change.task === row.task);
|
||||||
|
if (!own.length) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const next = {...row, after: [...row.after], pending: own.map((change) => change.argv)};
|
||||||
|
for (const {argv} of own) {
|
||||||
|
const [, , flag, value] = argv;
|
||||||
|
if (flag === "--status") {
|
||||||
|
Object.assign(next, {status: value, ended: value === "done" || value === "stopped", end: null});
|
||||||
|
} else if (flag === "--prio") {
|
||||||
|
next.prio = Number(value);
|
||||||
|
} else if (flag === "--unpin") {
|
||||||
|
next.prio = null;
|
||||||
|
} else if (flag === "--after") {
|
||||||
|
next.after.push(value);
|
||||||
|
} else {
|
||||||
|
next.after = next.after.filter((task) => task !== value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param rows - the shown cards' rows, staged changes applied.
|
||||||
|
* @param line - the hub's queue_line: tasks holding a live queue entry, in grant order.
|
||||||
|
* @returns {arrows, queue}: one {from, to} per after dependency between two shown cards, and the shown tasks of the
|
||||||
|
* queue line in its order.
|
||||||
|
*/
|
||||||
|
function lines(rows, line) {
|
||||||
|
const shown = new Set(rows.map((row) => row.task));
|
||||||
|
const arrows = rows.flatMap((row) => row.after.filter((task) => task !== row.task && shown.has(task)).map((from) => ({from, to: row.task})));
|
||||||
|
return {arrows, queue: line.filter((task) => shown.has(task))};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param jobs - the hub's jobs.
|
||||||
|
* @param task - a card's task.
|
||||||
|
* @returns {failed, applied, total} over the task's changes in its newest signed batch (a job without a batch is
|
||||||
|
* one of its own): failed the jobs rasci.py or the runner refused, oldest first, applied the count rasci.py ran;
|
||||||
|
* null when none of them failed.
|
||||||
|
*/
|
||||||
|
function why(jobs, task) {
|
||||||
|
const own = jobs.filter((job) => job.kind === "task" && job.task === task).sort((a, b) => a.created_ts - b.created_ts);
|
||||||
|
const newest = own[own.length - 1];
|
||||||
|
const batch = !newest ? [] : newest.batch ? own.filter((job) => job.batch === newest.batch) : [newest];
|
||||||
|
const failed = batch.filter((job) => REFUSED.includes(job.status));
|
||||||
|
return failed.length ? {failed, applied: batch.filter((job) => job.status === "done").length, total: batch.length} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {COLUMNS, LANES, FREE_FROM, onBoard, columnOf, laneOf, labelOf, board, freePin, move, stage, pending, lines, why};
|
||||||
|
})();
|
||||||
|
if (typeof module === "object" && module.exports) {
|
||||||
|
module.exports = Kanban;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
const KANBAN_LABELS = {blocked: ["Blocked", "bad"], stopped: ["Stopped", "neutral"]};
|
||||||
|
const KANBAN_COLUMN_ICONS = {queued: "bi-hourglass", running: "bi-play-circle", done: "bi-check2-circle"};
|
||||||
|
const KANBAN_DRAG = "application/x-kvb-task";
|
||||||
|
const kanban = {staged: [], collapsed: new Set(), old: false, dragging: null};
|
||||||
|
|
||||||
|
function restage(next) {
|
||||||
|
kanban.staged = next;
|
||||||
|
taskView.sig = "";
|
||||||
|
drawTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageMove(task, targets) {
|
||||||
|
const hub = state.tasks.find((row) => row.task === task);
|
||||||
|
if (!hub) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let next = kanban.staged;
|
||||||
|
for (const target of targets.filter((entry) => (entry.column || entry.status ? hub.movable : hub.editable))) {
|
||||||
|
next = Kanban.stage(next, hub, target, Kanban.pending(state.tasks, next));
|
||||||
|
}
|
||||||
|
restage(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyStaged() {
|
||||||
|
const changes = kanban.staged.map(({task, argv}) => ({task, argv}));
|
||||||
|
if (changes.length && (await changeTasks(changes, `Apply ${changes.length} changes`, null))) {
|
||||||
|
restage([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardWhy(task) {
|
||||||
|
const found = Kanban.why(state.jobs, task);
|
||||||
|
if (!found) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const share = `${found.applied}/${found.total}`;
|
||||||
|
const partly = found.applied ? h("span", {class: "pill kanban-partly", "data-tone": "warn", "data-partly": share, role: "img", ...named(`Partly applied: ${share}`, `${task}: ${found.applied} of the ${found.total} changes of the last batch applied, the others refused`)}, icon("bi-exclamation-diamond"), share) : null;
|
||||||
|
return [partly, found.failed.map((job) => h("p", {class: "task-why", "data-job": job.id, title: job.command}, icon("bi-exclamation-triangle"), job.output.trim().split("\n").pop() || job.status))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(name, label, run, hint) {
|
||||||
|
return h("button", {type: "button", class: "btn small ghost", ...named(label, hint), on: {click: (event) => {
|
||||||
|
event.currentTarget.closest("details").open = false;
|
||||||
|
run();
|
||||||
|
}}}, icon(name), label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanMenu(row, cards) {
|
||||||
|
const columns = TASK_COLUMNS.map(([id, label]) => menuItem(KANBAN_COLUMN_ICONS[id], label, () => stageMove(row.task, [{column: id}]), `Move ${row.task} to ${label}`));
|
||||||
|
const lanes = row.editable ? Kanban.LANES.map(([id, label]) => menuItem("bi-pin-angle", label, () => stageMove(row.task, [{lane: id}]), `Move ${row.task} to ${label}`)) : [];
|
||||||
|
const others = cards.filter((other) => other.task !== row.task && !row.after.includes(other.task));
|
||||||
|
const follow = row.editable && others.length ? h("select", {class: "field", ...named("Follows", `${row.task} follows the chosen task`), on: {change: (event) => stageMove(row.task, [{after: event.target.value}])}},
|
||||||
|
h("option", {value: "", selected: true, disabled: true}, "…"), others.map((other) => h("option", {value: other.task}, other.task))) : null;
|
||||||
|
const drops = row.editable ? row.after.map((task) => menuItem("bi-x-lg", task, () => stageMove(row.task, [{drop: task}]), `Remove: ${row.task} no longer follows ${task}`)) : [];
|
||||||
|
return h("details", {class: "menu kanban-menu", on: {click: (event) => event.stopPropagation(), keydown: (event) => event.stopPropagation()}},
|
||||||
|
h("summary", named("Move", `Move ${row.task}: column, lane and dependencies by keyboard`), icon("bi-three-dots-vertical")),
|
||||||
|
h("div", {class: "menu-box"}, h("div", {class: "actions", role: "group", "aria-label": "Column"}, columns), lanes.length ? h("div", {class: "actions", role: "group", "aria-label": "Lane"}, lanes) : null, follow, drops.length ? h("div", {class: "actions", role: "group", "aria-label": "Follows"}, drops) : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragFrom(task, mode) {
|
||||||
|
return (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
kanban.dragging = {task, mode};
|
||||||
|
event.dataTransfer.setData(KANBAN_DRAG, task);
|
||||||
|
event.dataTransfer.effectAllowed = mode === "card" ? "move" : "link";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardLabels(row) {
|
||||||
|
const label = Kanban.labelOf(row);
|
||||||
|
const stale = Plan.stale(row, state.now);
|
||||||
|
const stop = stale && row.editable ? iconBtn("bi-stop-circle", "Stop", "btn small ghost kanban-stop", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
stageMove(row.task, [{status: "stopped"}]);
|
||||||
|
}, `Stop ${row.task}: stage the status stopped`) : null;
|
||||||
|
return [
|
||||||
|
label ? h("span", {class: "pill", "data-tone": KANBAN_LABELS[label][1], "data-label": label}, KANBAN_LABELS[label][0]) : null,
|
||||||
|
stale ? h("span", {class: "pill kanban-stale", "data-tone": "warn", "data-label": "stale"}, "Stale", stop) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardOf = (task) => () => [...document.querySelectorAll("#main .kanban-card")].find((node) => node.dataset.task === task) || null;
|
||||||
|
|
||||||
|
function kanbanCard(row, lane, column, cards) {
|
||||||
|
const open = () => taskSelect(row.task, cardOf(row.task));
|
||||||
|
const own = (event) => !event.target.closest("button, summary, details, select, .kanban-handle");
|
||||||
|
const after = () => kanban.dragging && kanban.dragging.mode === "after" && kanban.dragging.task !== row.task;
|
||||||
|
return h("li", {
|
||||||
|
class: ["task-card", "kanban-card", row.pending ? "pending" : ""].filter(Boolean).join(" "), "data-task": row.task, "data-lane": lane, "data-column": column, tabindex: 0,
|
||||||
|
"data-pending": row.pending ? row.pending.map((argv) => argv.join(" ")).join("\n") : null, draggable: row.movable ? "true" : null,
|
||||||
|
on: {
|
||||||
|
click: (event) => own(event) && open(),
|
||||||
|
keydown: (event) => {
|
||||||
|
if ((event.key === "Enter" || event.key === " ") && event.target === event.currentTarget) {
|
||||||
|
event.preventDefault();
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dragstart: dragFrom(row.task, "card"),
|
||||||
|
dragend: () => {
|
||||||
|
kanban.dragging = null;
|
||||||
|
},
|
||||||
|
dragover: (event) => after() && event.preventDefault(),
|
||||||
|
drop: (event) => {
|
||||||
|
if (after()) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const from = kanban.dragging.task;
|
||||||
|
kanban.dragging = null;
|
||||||
|
stageMove(row.task, [{after: from}]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), cardLabels(row), h("span", {class: "spacer"}),
|
||||||
|
row.pending ? h("span", {class: "kanban-pending", role: "img", ...named("Staged", row.pending.map(shownArgv).join("\n"))}, icon("bi-pencil")) : null,
|
||||||
|
openJobs(row.task).length ? h("span", {class: "kanban-wait", role: "img", ...named("At the runner", `${row.task}: a signed change waits for the runner`)}, icon("bi-hourglass-split")) : null,
|
||||||
|
row.editable ? h("span", {class: "kanban-handle", role: "img", draggable: "true", ...named("Follows", `Drag onto the card that waits for ${row.task}`), on: {dragstart: dragFrom(row.task, "after"), dragend: () => {
|
||||||
|
kanban.dragging = null;
|
||||||
|
}}}, icon("bi-arrow-right-circle")) : null,
|
||||||
|
row.movable ? kanbanMenu(row, cards) : null),
|
||||||
|
h("span", {class: "kanban-title"}, titleOf(row)),
|
||||||
|
row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null,
|
||||||
|
cardWhy(row.task));
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanCell(lane, column, label, cards) {
|
||||||
|
const card = () => kanban.dragging && kanban.dragging.mode === "card";
|
||||||
|
return h("ol", {class: "kanban-cell task-cards", "data-lane": lane.id, "data-column": column, "aria-label": `${lane.label} · ${label}`, on: {
|
||||||
|
dragover: (event) => card() && event.preventDefault(),
|
||||||
|
drop: (event) => {
|
||||||
|
if (card()) {
|
||||||
|
event.preventDefault();
|
||||||
|
const task = kanban.dragging.task;
|
||||||
|
kanban.dragging = null;
|
||||||
|
stageMove(task, [{column}, {lane: lane.id}]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}, lane.cells[column].map((row) => kanbanCard(row, lane.id, column, cards)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanLane(lane, cards) {
|
||||||
|
const open = !kanban.collapsed.has(lane.id);
|
||||||
|
const id = `kanban-lane-${lane.id}`;
|
||||||
|
const toggle = h("button", {type: "button", class: "lane-toggle", "aria-expanded": String(open), "aria-controls": id, title: `${lane.label}: ${open ? "collapse" : "expand"}`, on: {click: () => {
|
||||||
|
if (open) {
|
||||||
|
kanban.collapsed.add(lane.id);
|
||||||
|
} else {
|
||||||
|
kanban.collapsed.delete(lane.id);
|
||||||
|
}
|
||||||
|
taskView.sig = "";
|
||||||
|
drawTasks();
|
||||||
|
}}}, icon(open ? "bi-chevron-down" : "bi-chevron-right"), lane.label, counter(lane.count));
|
||||||
|
return h("section", {class: "kanban-lane", "data-lane": lane.id, "data-open": String(open)}, toggle,
|
||||||
|
h("div", {class: "kanban-cells", id, hidden: !open}, TASK_COLUMNS.map(([column, label]) => kanbanCell(lane, column, label, cards))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanBar(hidden, stale) {
|
||||||
|
const count = kanban.staged.length;
|
||||||
|
const words = `Apply ${count} changes`;
|
||||||
|
const apply = h("button", {type: "button", class: "btn small primary", id: "kanban-apply", disabled: count === 0, ...named(words, `${words}: one passkey signs every staged move, the runner applies them through rasci.py`), on: {click: applyStaged}}, icon("bi-check2-all"), counter(count));
|
||||||
|
const discard = h("button", {type: "button", class: "btn small icon-only", id: "kanban-discard", disabled: count === 0, ...named("Discard", "Discard the staged moves"), on: {click: () => restage([])}}, icon("bi-x-circle"));
|
||||||
|
const older = h("button", {type: "button", class: "btn small", id: "kanban-old", "aria-pressed": String(kanban.old), ...named("Older Done", `Done cards older than 24 h: ${hidden}, ${kanban.old ? "shown" : "hidden"}`), on: {click: () => {
|
||||||
|
kanban.old = !kanban.old;
|
||||||
|
taskView.sig = "";
|
||||||
|
drawTasks();
|
||||||
|
}}}, icon(kanban.old ? "bi-eye" : "bi-eye-slash"), counter(hidden));
|
||||||
|
const stalled = h("span", {class: "pill", id: "kanban-stale", "data-tone": "warn", role: "img", ...named(`Stale: ${stale}`, `Stale: ${stale} tasks without a register change for ${Plan.STALE_DAYS} days, counted apart`)}, icon("bi-hourglass-bottom"), String(stale));
|
||||||
|
const listed = count ? h("ol", {class: "kanban-staged", id: "kanban-staged", "aria-label": `${words}: the passkey signs exactly these`},
|
||||||
|
kanban.staged.map(({task, argv}) => h("li", {"data-task": task}, h("code", {}, shownArgv(argv))))) : null;
|
||||||
|
return [h("div", {class: "kanban-bar", role: "toolbar", "aria-label": "Board"}, apply, discard, older, stalled), listed];
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanLines(box, rows) {
|
||||||
|
const svg = box.querySelector("svg.kanban-lines");
|
||||||
|
const base = box.getBoundingClientRect();
|
||||||
|
const rectOf = (task) => {
|
||||||
|
const node = [...box.querySelectorAll(".kanban-card")].find((card) => card.dataset.task === task);
|
||||||
|
const found = node && node.getClientRects().length ? node.getBoundingClientRect() : null;
|
||||||
|
return found && {x: found.left - base.left, y: found.top - base.top, w: found.width, h: found.height};
|
||||||
|
};
|
||||||
|
const centre = (r) => ({x: r.x + r.w / 2, y: r.y + r.h / 2});
|
||||||
|
const rim = (r, toward) => {
|
||||||
|
const c = centre(r);
|
||||||
|
const [dx, dy] = [toward.x - c.x, toward.y - c.y];
|
||||||
|
const scale = Math.min(dx ? r.w / 2 / Math.abs(dx) : Infinity, dy ? r.h / 2 / Math.abs(dy) : Infinity, 1);
|
||||||
|
return {x: c.x + dx * scale, y: c.y + dy * scale};
|
||||||
|
};
|
||||||
|
const hub = new Map(state.tasks.map((row) => [row.task, row]));
|
||||||
|
const {arrows, queue} = Kanban.lines(rows, state.queue_line);
|
||||||
|
const marks = [];
|
||||||
|
for (const {from, to} of arrows) {
|
||||||
|
const [a, b] = [rectOf(from), rectOf(to)];
|
||||||
|
if (a && b) {
|
||||||
|
const [start, end] = [rim(a, centre(b)), rim(b, centre(a))];
|
||||||
|
const d = `M${fixed(start.x)},${fixed(start.y)} L${fixed(end.x)},${fixed(end.y)}`;
|
||||||
|
const staged = !(hub.has(to) && hub.get(to).after.includes(from));
|
||||||
|
marks.push(s("g", {class: `kanban-arrow${staged ? " pending" : ""}`, "data-from": from, "data-to": to, on: {click: () => stageMove(to, [{drop: from}])}},
|
||||||
|
s("title", {}, `${to} follows ${from}: click to remove`), s("path", {class: "kanban-hit", d}), s("path", {class: "kanban-link", d, "marker-end": "url(#kanban-tip)"})));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stops = queue.map(rectOf).filter(Boolean).map(centre);
|
||||||
|
if (stops.length > 1) {
|
||||||
|
marks.push(s("polyline", {class: "kanban-queue", "data-tasks": queue.filter((task) => rectOf(task)).join(" "), points: stops.map((at) => `${fixed(at.x)},${fixed(at.y)}`).join(" ")}, s("title", {}, "Queue order")));
|
||||||
|
}
|
||||||
|
svg.setAttribute("width", box.scrollWidth);
|
||||||
|
svg.setAttribute("height", box.scrollHeight);
|
||||||
|
svg.replaceChildren(s("defs", {}, s("marker", {id: "kanban-tip", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, markerUnits: "userSpaceOnUse", orient: "auto"}, s("path", {class: "kanban-tip", d: "M0,0 L10,5 L0,10 z"}))), ...marks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kanbanBoard(shown) {
|
||||||
|
const known = new Set(state.tasks.filter((row) => row.movable).map((row) => row.task));
|
||||||
|
if (kanban.staged.some((change) => !known.has(change.task))) {
|
||||||
|
kanban.staged = kanban.staged.filter((change) => known.has(change.task));
|
||||||
|
}
|
||||||
|
const ids = new Set(shown.map((row) => row.task));
|
||||||
|
const rows = Kanban.pending(state.tasks, kanban.staged).filter((row) => ids.has(row.task) && Kanban.onBoard(row));
|
||||||
|
const {lanes} = Kanban.board(rows, {now: state.now, old: kanban.old});
|
||||||
|
const cards = lanes.flatMap((lane) => Object.values(lane.cells).flat());
|
||||||
|
const head = h("div", {class: "kanban-head"}, TASK_COLUMNS.map(([id, label]) => h("h3", {class: "task-column group-title", "data-column": id}, icon(KANBAN_COLUMN_ICONS[id]), label,
|
||||||
|
counter(cards.filter((row) => Kanban.columnOf(row) === id).length))));
|
||||||
|
const box = h("div", {class: "kanban"}, head, lanes.map((lane) => kanbanLane(lane, cards)), s("svg", {class: "kanban-lines", "aria-hidden": "true"}));
|
||||||
|
const watch = new ResizeObserver(() => (box.isConnected ? kanbanLines(box, cards) : watch.disconnect()));
|
||||||
|
watch.observe(box);
|
||||||
|
return h("div", {class: "kanban-box"}, kanbanBar(Kanban.board(rows, {now: state.now, old: false}).hidden, rows.filter((row) => Plan.stale(row, state.now)).length), box);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
const EDGE_WORDS = {after: "Follows (rasci.py set --after)", base: "Branches from (base branch)", queue: "Waits behind (queue order)"};
|
const EDGE_WORDS = {after: "Follows (rasci.py set --after)", base: "Branches from (base branch)", queue: "Waits behind (queue order)"};
|
||||||
const SERIES_WORDS = {scope: "Scope (burnup)", done: "Done (burnup)", remaining: "Remaining (burndown)", unestimated: "Unestimated tasks"};
|
const SERIES_WORDS = {scope: "Scope (burnup)", done: "Done (burnup)", remaining: "Remaining (burndown)", unestimated: "Unestimated tasks"};
|
||||||
const STEP_WORDS = [["started", "Started"], ["ended", "Ended"], ["joined", "Joined the scope"]];
|
const STEP_WORDS = [["started", "Started"], ["ended", "Ended"], ["joined", "Joined the scope"]];
|
||||||
|
const FINISH_WORDS = {fixed: ["Finish at fixed scope", "bi-flag"], growing: ["Finish at growing scope", "bi-graph-up-arrow"]};
|
||||||
const ESTIMATION_ICONS = {none: "bi-1-circle", points: "bi-suit-diamond"};
|
const ESTIMATION_ICONS = {none: "bi-1-circle", points: "bi-suit-diamond"};
|
||||||
const TREND_DAYS = [1, 365];
|
const TREND_DAYS = [1, 365];
|
||||||
const TREND_REACH = 3;
|
const TREND_REACH = 3;
|
||||||
@@ -108,10 +109,40 @@
|
|||||||
return marks;
|
return marks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trendNote(trend) {
|
function finishNotes(trend, finish) {
|
||||||
const forecast = trend ? trend.forecast : null;
|
if (!trend) {
|
||||||
const words = forecast !== null ? `Forecast: ${absolute.format(stampOf(forecast))}` : trend ? "No forecast: the trend never reaches 0 remaining, so done never meets scope" : "No forecast: too few points for a trend";
|
return h("span", {class: "burn-forecast", "data-forecast": ""}, icon("bi-graph-up-arrow"), "No forecast: too few points for a trend");
|
||||||
return h("span", {class: "burn-forecast", "data-forecast": forecast === null ? "" : String(forecast)}, icon("bi-graph-up-arrow"), words);
|
}
|
||||||
|
return Object.entries(FINISH_WORDS).map(([kind, [words, name]]) => {
|
||||||
|
const at = finish[kind];
|
||||||
|
const when = at === null ? "∞" : absolute.format(stampOf(at));
|
||||||
|
return h("span", {class: `burn-key burn-finish k-${kind}${kind === "growing" ? " burn-forecast" : ""}`, "data-finish": kind, "data-forecast": at === null ? "" : String(at), role: "img", ...named(`${words}: ${when}`)}, icon(name), when);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param series - the burn points.
|
||||||
|
* @param trend - Plan.trend() of the points.
|
||||||
|
* @param finish - Plan.finishes() of the points.
|
||||||
|
* @param reach - the chart's last time.
|
||||||
|
* @returns one {kind, at, from: [t, level], to: [t, level]} per reached finish, from the last point to its date cut at
|
||||||
|
* reach: the fixed-scope finish runs level at the last scope, the growing-scope one along the scope trend.
|
||||||
|
*/
|
||||||
|
function finishSegments(series, trend, finish, reach) {
|
||||||
|
const last = series[series.length - 1];
|
||||||
|
const levels = {fixed: () => last.scope, growing: (t) => Plan.lineAt(trend.lines.scope, t)};
|
||||||
|
return Object.keys(FINISH_WORDS).filter((kind) => finish[kind] !== null).map((kind) => {
|
||||||
|
const end = Math.min(finish[kind], reach);
|
||||||
|
return {kind, at: finish[kind], from: [last.t, levels[kind](last.t)], to: [end, levels[kind](end)]};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function burnFinish(svg, segments, x, y) {
|
||||||
|
for (const {kind, at, from, to} of segments) {
|
||||||
|
const words = s("title", {}, `${FINISH_WORDS[kind][0]}: ${absolute.format(stampOf(at))}`);
|
||||||
|
svg.append(s("path", {class: `burn-finish-line k-${kind}`, "data-finish": kind, "data-from": from[0], "data-to": to[0], "data-at": at, d: `M${fixed(x(from[0]))},${fixed(y(from[1]))} L${fixed(x(to[0]))},${fixed(y(to[1]))}`}, words),
|
||||||
|
s("circle", {class: `burn-finish-end k-${kind}`, "data-finish": kind, cx: fixed(x(to[0])), cy: fixed(y(to[1])), r: 4}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function burnTrend(svg, trend, end, x, y) {
|
function burnTrend(svg, trend, end, x, y) {
|
||||||
@@ -127,22 +158,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function burnSpan(picked, trend) {
|
function burnSpan(picked, trend, finish) {
|
||||||
const forecast = trend ? trend.forecast : null;
|
const forecast = trend ? trend.forecast : null;
|
||||||
if (forecast === null) {
|
const latest = Math.max(picked.to, ...Object.values(finish).filter((at) => at !== null));
|
||||||
return {reach: picked.to, end: picked.to};
|
const reach = Math.min(latest, picked.to + TREND_REACH * Math.max(picked.to - picked.from, 1));
|
||||||
}
|
return {reach, end: forecast === null ? picked.to : Math.min(forecast, reach)};
|
||||||
const reach = Math.min(Math.max(forecast, picked.to), picked.to + TREND_REACH * Math.max(picked.to - picked.from, 1));
|
|
||||||
return {reach, end: Math.min(forecast, reach)};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function burnChart(rows, shown) {
|
function burnChart(rows, shown) {
|
||||||
const picked = Plan.scoped(rows, taskView.scope, {filtered: shown, since: state.since, until: state.until, now: state.now, holders: lineHolders()});
|
const picked = Plan.scoped(rows, taskView.scope, {filtered: shown, since: state.since, until: state.until, now: state.now, holders: lineHolders()});
|
||||||
const series = Plan.burn(picked.rows, {estimation: estimation(), from: picked.from, to: picked.to}).points;
|
const series = Plan.burn(picked.rows, {estimation: estimation(), from: picked.from, to: picked.to}).points;
|
||||||
const trend = Plan.trend(series, settings.trend);
|
const trend = Plan.trend(series, settings.trend);
|
||||||
const {reach, end} = burnSpan(picked, trend);
|
const finish = Plan.finishes(series, trend);
|
||||||
|
const {reach, end} = burnSpan(picked, trend, finish);
|
||||||
const names = estimation() === "points" ? Plan.SERIES : Plan.SERIES.filter((name) => name !== "unestimated");
|
const names = estimation() === "points" ? Plan.SERIES : Plan.SERIES.filter((name) => name !== "unestimated");
|
||||||
const ends = trend ? Plan.TRENDED.flatMap((name) => [trend.start, end].map((t) => Plan.lineAt(trend.lines[name], t))) : [];
|
const segments = trend ? finishSegments(series, trend, finish, reach) : [];
|
||||||
|
const ends = trend ? [...Plan.TRENDED.flatMap((name) => [trend.start, end].map((t) => Plan.lineAt(trend.lines[name], t))), ...segments.flatMap((segment) => [segment.from[1], segment.to[1]])] : [];
|
||||||
const top = Math.max(1, ...series.flatMap((point) => names.map((name) => point[name])), ...ends);
|
const top = Math.max(1, ...series.flatMap((point) => names.map((name) => point[name])), ...ends);
|
||||||
const unit = estimation() === "points" ? "story points; unestimated tasks as a line of their own" : "tasks";
|
const unit = estimation() === "points" ? "story points; unestimated tasks as a line of their own" : "tasks";
|
||||||
const svg = s("svg", {class: "chart burn", viewBox: `0 0 ${BURN.width} ${BURN.height}`, role: "img", "aria-label": `Burndown and burnup in ${unit}; hover a point for the work packages of its step`});
|
const svg = s("svg", {class: "chart burn", viewBox: `0 0 ${BURN.width} ${BURN.height}`, role: "img", "aria-label": `Burndown and burnup in ${unit}; hover a point for the work packages of its step`});
|
||||||
@@ -157,10 +188,12 @@
|
|||||||
}
|
}
|
||||||
if (trend) {
|
if (trend) {
|
||||||
burnTrend(svg, trend, end, x, y);
|
burnTrend(svg, trend, end, x, y);
|
||||||
|
burnFinish(svg, segments, x, y);
|
||||||
}
|
}
|
||||||
svg.append(burnPoints(series, Plan.steps(picked.rows, series, picked.from), names, x, y));
|
svg.append(burnPoints(series, Plan.steps(picked.rows, series, picked.from), names, x, y));
|
||||||
|
const stale = h("span", {class: "pill burn-stale", "data-tone": "warn", "data-stale": String(picked.stale.length), role: "img", ...named(`Stale: ${picked.stale.length}`, `Stale: ${picked.stale.length} tasks without a register change for ${Plan.STALE_DAYS} days, counted apart`)}, icon("bi-hourglass-bottom"), String(picked.stale.length));
|
||||||
const legend = h("div", {class: "legend"}, names.map((name) => h("span", {class: `burn-key k-${name}`, "data-series": name}, `${SERIES_WORDS[name]}: ${last[name]}`)),
|
const legend = h("div", {class: "legend"}, names.map((name) => h("span", {class: `burn-key k-${name}`, "data-series": name}, `${SERIES_WORDS[name]}: ${last[name]}`)),
|
||||||
settings.trend.mode === "off" ? null : trendNote(trend));
|
settings.trend.mode === "off" ? null : finishNotes(trend, finish), stale);
|
||||||
return h("div", {class: "burn-box"}, svg, legend, h("p", {class: "muted"}, `${picked.rows.length} tasks in scope, counted in ${unit}`));
|
return h("div", {class: "burn-box"}, svg, legend, h("p", {class: "muted"}, `${picked.rows.length} tasks in scope, counted in ${unit}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const TASK_STATUS = {queued: ["waiting", "info"], running: ["running", "ok"], blocked: ["blocked", "bad"], done: ["done", "neutral"], stopped: ["stopped", "neutral"], awaiting: ["waiting for a grant", "warn"]};
|
const TASK_STATUS = {queued: ["waiting", "info"], running: ["running", "ok"], blocked: ["blocked", "bad"], done: ["done", "neutral"], stopped: ["stopped", "neutral"], awaiting: ["waiting for a grant", "warn"]};
|
||||||
const TASK_COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["blocked", "Blocked"], ["done", "Done"], ["stopped", "Stopped"]];
|
const TASK_COLUMNS = Kanban.COLUMNS;
|
||||||
const TASK_KINDS = {command: ["Command", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue job", "teal"]};
|
const TASK_KINDS = {command: ["Command", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue job", "teal"]};
|
||||||
const TASK_FILTERS = [
|
const TASK_FILTERS = [
|
||||||
["all", "All", "bi-list-check", () => true],
|
["all", "All", "bi-list-check", () => true],
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
false: "ETA: the end of the ended task",
|
false: "ETA: the end of the ended task",
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
|
|
||||||
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
|
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
|
||||||
const titleOf = (row) => (row.title !== "-" ? row.title : row.task);
|
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"));
|
const openJobs = (task) => state.jobs.filter((job) => job.kind === "task" && job.task === task && (job.status === "queued" || job.status === "running"));
|
||||||
@@ -80,26 +79,6 @@
|
|||||||
workChips(row.open_work));
|
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"}, "empty"));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLetters(letters) {
|
function roleLetters(letters) {
|
||||||
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
|
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
|
||||||
}
|
}
|
||||||
@@ -239,7 +218,7 @@
|
|||||||
const etas = etaOf();
|
const etas = etaOf();
|
||||||
return h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map((row) => taskItem(row, etas.get(row.task))));
|
return h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map((row) => taskItem(row, etas.get(row.task))));
|
||||||
},
|
},
|
||||||
board: taskBoard,
|
board: kanbanBoard,
|
||||||
rasci: taskMatrix,
|
rasci: taskMatrix,
|
||||||
gantt: ganttChart,
|
gantt: ganttChart,
|
||||||
burn: (found) => burnChart(rows, found),
|
burn: (found) => burnChart(rows, found),
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const Plan = (() => {
|
|||||||
const TRENDED = ["scope", "done", "remaining"];
|
const TRENDED = ["scope", "done", "remaining"];
|
||||||
const OUTSIDE = ["queue", "command"];
|
const OUTSIDE = ["queue", "command"];
|
||||||
const DAY = 86400;
|
const DAY = 86400;
|
||||||
|
const STALE_DAYS = 3;
|
||||||
|
|
||||||
|
const stale = (row, now) => !row.ended && typeof row.updated === "number" && now - row.updated >= STALE_DAYS * DAY;
|
||||||
|
|
||||||
function median(values) {
|
function median(values) {
|
||||||
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||||
@@ -134,19 +137,22 @@ const Plan = (() => {
|
|||||||
* window), "agent:<name>" (the tasks the agent works on or holds a RASCI role in) or "line:<office>" (those of
|
* window), "agent:<name>" (the tasks the agent works on or holds a RASCI role in) or "line:<office>" (those of
|
||||||
* the office's holder, over the whole time).
|
* the office's holder, over the whole time).
|
||||||
* @param context - {filtered, since, until, now, holders: {office: holder}}.
|
* @param context - {filtered, since, until, now, holders: {office: holder}}.
|
||||||
* @returns {rows, from, to}: the scope's rows without queue jobs and commands and its time range.
|
* @returns {rows, stale, from, to}: the scope's rows without queue jobs, operator commands and stale tasks, the
|
||||||
|
* stale ones apart, and its time range.
|
||||||
*/
|
*/
|
||||||
function scoped(rows, choice, context) {
|
function scoped(rows, choice, context) {
|
||||||
const own = (name) => (row) => row.worker === name || Object.hasOwn(row.roles, name);
|
const own = (name) => (row) => row.worker === name || Object.hasOwn(row.roles, name);
|
||||||
const [kind, ...rest] = String(choice).split(":");
|
const [kind, ...rest] = String(choice).split(":");
|
||||||
const key = rest.join(":");
|
const key = rest.join(":");
|
||||||
const chosen = kind === "filter" ? context.filtered : kind === "agent" ? rows.filter(own(key)) : kind === "line" ? rows.filter(own(context.holders[key])) : rows;
|
const chosen = kind === "filter" ? context.filtered : kind === "agent" ? rows.filter(own(key)) : kind === "line" ? rows.filter(own(context.holders[key])) : rows;
|
||||||
const kept = chosen.filter((row) => !OUTSIDE.includes(row.kind));
|
const inside = chosen.filter((row) => !OUTSIDE.includes(row.kind));
|
||||||
|
const kept = inside.filter((row) => !stale(row, context.now));
|
||||||
|
const apart = inside.filter((row) => stale(row, context.now));
|
||||||
if (kind === "filter") {
|
if (kind === "filter") {
|
||||||
return {rows: kept, from: context.since, to: context.until};
|
return {rows: kept, stale: apart, from: context.since, to: context.until};
|
||||||
}
|
}
|
||||||
const first = Math.min(...kept.flatMap((row) => [row.start, row.end]).filter((value) => value !== null));
|
const first = Math.min(...kept.flatMap((row) => [row.start, row.end]).filter((value) => value !== null));
|
||||||
return {rows: kept, from: Number.isFinite(first) ? Math.min(first, context.now) : context.now - 86400, to: context.now};
|
return {rows: kept, stale: apart, from: Number.isFinite(first) ? Math.min(first, context.now) : context.now - 86400, to: context.now};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,6 +202,7 @@ const Plan = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LAST_SECOND = 8.64e12;
|
const LAST_SECOND = 8.64e12;
|
||||||
|
const reachable = (t) => (t !== null && Number.isFinite(t) && Math.abs(t) <= LAST_SECOND ? t : null);
|
||||||
|
|
||||||
function leastSquares(pairs) {
|
function leastSquares(pairs) {
|
||||||
const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
|
const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||||
@@ -233,11 +240,28 @@ const Plan = (() => {
|
|||||||
}
|
}
|
||||||
const rest = lines.remaining;
|
const rest = lines.remaining;
|
||||||
const reached = rest.slope < 0 ? rest.t0 - rest.y0 / rest.slope : null;
|
const reached = rest.slope < 0 ? rest.t0 - rest.y0 / rest.slope : null;
|
||||||
return {start, lines, forecast: reached !== null && Number.isFinite(reached) && Math.abs(reached) <= LAST_SECOND ? reached : null};
|
return {start, lines, forecast: reachable(reached)};
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineAt = (line, t) => line.y0 + line.slope * (t - line.t0);
|
const lineAt = (line, t) => line.y0 + line.slope * (t - line.t0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param points - burn() points, in time order.
|
||||||
|
* @param found - trend() of the points, or null.
|
||||||
|
* @returns {fixed, growing}: the finish at fixed scope, where the done trend reaches the last scope (the last point
|
||||||
|
* when nothing remains, never before it), and the finish at growing scope, where the done trend meets the scope
|
||||||
|
* trend (trend()'s forecast); each null when never reached, both null without a trend.
|
||||||
|
*/
|
||||||
|
function finishes(points, found) {
|
||||||
|
if (!found) {
|
||||||
|
return {fixed: null, growing: null};
|
||||||
|
}
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
const done = found.lines.done;
|
||||||
|
const fixed = last.remaining <= 0 ? last.t : done.slope > 0 ? Math.max(last.t, done.t0 + (last.scope - done.y0) / done.slope) : null;
|
||||||
|
return {fixed: reachable(fixed), growing: found.forecast};
|
||||||
|
}
|
||||||
|
|
||||||
const KEYS = {
|
const KEYS = {
|
||||||
prio: () => null,
|
prio: () => null,
|
||||||
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
|
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
|
||||||
@@ -270,7 +294,7 @@ const Plan = (() => {
|
|||||||
return keyed.map((entry) => entry.row);
|
return keyed.map((entry) => entry.row);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {FORECASTS, ESTIMATIONS, TRENDS, SORTS, SERIES, TRENDED, STATUS_ORDER, median, medians, forecastLength, bars, etas, scoped, burn, steps, trend, lineAt, sortRows};
|
return {FORECASTS, ESTIMATIONS, TRENDS, SORTS, SERIES, TRENDED, STATUS_ORDER, STALE_DAYS, median, medians, forecastLength, bars, etas, stale, scoped, burn, steps, trend, finishes, lineAt, sortRows};
|
||||||
})();
|
})();
|
||||||
if (typeof module === "object" && module.exports) {
|
if (typeof module === "object" && module.exports) {
|
||||||
module.exports = Plan;
|
module.exports = Plan;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.kanban-box { display: grid; gap: 8px; min-width: 0; }
|
||||||
|
.kanban-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
|
.kanban-bar .pill { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.kanban { position: relative; display: grid; gap: 6px; min-width: 0; }
|
||||||
|
.kanban-head, .kanban-cells { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
|
||||||
|
.kanban-head .task-column { display: flex; align-items: center; gap: 6px; margin: 0; padding: 2px 6px; }
|
||||||
|
.kanban-lane { display: grid; gap: 4px; min-width: 0; padding: 6px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
|
||||||
|
.lane-toggle { display: inline-flex; align-items: center; gap: 6px; justify-self: start; padding: 2px 6px; border: 0; border-radius: 6px; background: none; color: var(--text); font: inherit; font-weight: 650; cursor: pointer; }
|
||||||
|
.lane-toggle:hover { background: var(--hover); }
|
||||||
|
.kanban-cells[hidden] { display: none; }
|
||||||
|
.kanban-cell { align-content: start; min-height: 40px; min-width: 0; padding: 4px; border-radius: 8px; background: color-mix(in srgb, var(--sunken) 55%, transparent); }
|
||||||
|
.kanban-card { cursor: pointer; }
|
||||||
|
.kanban-card .head-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; margin: 0; }
|
||||||
|
.kanban-card .task-id { flex: 1 1 100%; }
|
||||||
|
.kanban-card.pending { border-style: dashed; border-color: var(--accent); }
|
||||||
|
.kanban-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.kanban-title { overflow-wrap: anywhere; }
|
||||||
|
.kanban-handle { display: inline-flex; color: var(--muted); cursor: grab; }
|
||||||
|
.kanban-pending, .kanban-wait { display: inline-flex; color: var(--accent); }
|
||||||
|
.kanban-stale { display: inline-flex; align-items: center; gap: 2px; }
|
||||||
|
.kanban-stale .kanban-stop { min-width: 0; padding: 0 2px; }
|
||||||
|
.kanban-menu > summary { display: inline-flex; list-style: none; cursor: pointer; }
|
||||||
|
.kanban-menu > summary::-webkit-details-marker { display: none; }
|
||||||
|
.kanban-menu .menu-box { display: grid; gap: 6px; min-width: 12rem; }
|
||||||
|
.kanban-staged { display: flex; flex-wrap: wrap; gap: 4px; max-height: 6rem; margin: 0; padding: 0; overflow: auto; list-style: none; font-size: var(--fs-2xs); }
|
||||||
|
.kanban-staged li { padding: 1px 6px; border: 1px dashed var(--accent); border-radius: 6px; overflow-wrap: anywhere; }
|
||||||
|
.kanban-partly { display: inline-flex; align-items: center; gap: 2px; }
|
||||||
|
.task-why { display: flex; gap: 4px; margin: 0; color: var(--bad); font-size: var(--fs-2xs); overflow-wrap: anywhere; }
|
||||||
|
svg.kanban-lines { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
|
||||||
|
svg .kanban-link { fill: none; stroke: var(--violet); stroke-width: 1.8; }
|
||||||
|
svg .kanban-hit { fill: none; stroke: transparent; stroke-width: 12; pointer-events: stroke; cursor: pointer; }
|
||||||
|
svg .kanban-arrow.pending .kanban-link { opacity: .55; }
|
||||||
|
svg .kanban-tip { fill: var(--violet); }
|
||||||
|
svg .kanban-queue { fill: none; stroke: var(--warn); stroke-width: 1.6; stroke-dasharray: 6 4; }
|
||||||
|
svg .burn-finish-line { fill: none; stroke: var(--c); stroke-width: 2; stroke-dasharray: 8 3; }
|
||||||
|
svg .burn-finish-end { fill: var(--c); }
|
||||||
|
svg.burn .k-fixed, .burn-key.k-fixed { --c: var(--violet); }
|
||||||
|
svg.burn .k-growing, .burn-key.k-growing { --c: var(--teal); }
|
||||||
@@ -116,7 +116,7 @@ PROBE = """(async () => {@HELPERS@
|
|||||||
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
order: all("#main .agent-order li").map((item) => item.dataset.name),
|
||||||
taskFilters: all('#main [aria-label="Task filters"] button').map((button) => [button.dataset.filter, button.getAttribute("aria-pressed"), text(button.querySelector(".count"))]),
|
taskFilters: all('#main [aria-label="Task filters"] 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")]),
|
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)]),
|
columns: all("#main .task-column").map((column) => [column.dataset.column, all(`#main .kanban-cell[data-column="${column.dataset.column}"] .task-card`).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("")],
|
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),
|
flows: all("#main .flow").map((flow) => flow.dataset.flow),
|
||||||
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
|
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
|
||||||
@@ -1822,7 +1822,7 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertEqual(listed["taskFilters"], [["all", "true", "6"], ["open", "false", "4"], ["work", "false", "4"], ["operator", "false", "2"], ["grant", "false", "1"], ["ended", "false", "2"]])
|
self.assertEqual(listed["taskFilters"], [["all", "true", "6"], ["open", "false", "4"], ["work", "false", "4"], ["operator", "false", "2"], ["grant", "false", "1"], ["ended", "false", "2"]])
|
||||||
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
|
self.assertEqual(listed["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
|
||||||
self.assertEqual(listed["flows"], ["file", "file"])
|
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/board"]["columns"], [["queued", ["work/docs", "queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["done", []]])
|
||||||
self.assertEqual(by_hash["#tasks/rasci"]["matrix"], [6, 6, "CIRARASRARAR"])
|
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))
|
self.assertEqual([probe["taskFilters"][3][1] for probe in probes if probe["hash"] == "#stream/inbox"], ["true"] * len(WIDTHS))
|
||||||
extra = data["results"][len(probes) :]
|
extra = data["results"][len(probes) :]
|
||||||
@@ -2406,7 +2406,7 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertEqual((fit["hash"], fit["bar"], fit["choices"], fit["tall"], fit["wide"]), (f"#tasks/{name}", CHOICE_CHANNELS, 0, False, False), fit)
|
self.assertEqual((fit["hash"], fit["bar"], fit["choices"], fit["tall"], fit["wide"]), (f"#tasks/{name}", CHOICE_CHANNELS, 0, False, False), fit)
|
||||||
self.assertEqual((fit["rows"] == 1, fit["layout"]), (width != 390, ["toolbar", "chips", "body"]), fit)
|
self.assertEqual((fit["rows"] == 1, fit["layout"]), (width != 390, ["toolbar", "chips", "body"]), fit)
|
||||||
if name == "board":
|
if name == "board":
|
||||||
self.assertEqual(fit["columns"], ["queued", "running", "blocked", "done", "stopped"])
|
self.assertEqual(fit["columns"], ["queued", "running", "done"])
|
||||||
hub = self.httpd.hub
|
hub = self.httpd.hub
|
||||||
if not signs:
|
if not signs:
|
||||||
self.assertEqual((self.scripts.calls, hub.messages.listed()), ([], []))
|
self.assertEqual((self.scripts.calls, hub.messages.listed()), ([], []))
|
||||||
@@ -2647,11 +2647,11 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertAlmostEqual(begin, start, delta=0.01)
|
self.assertAlmostEqual(begin, start, delta=0.01)
|
||||||
self.assertAlmostEqual(end, forecast if forecast is not None else probe["points"][-1][0], delta=0.01)
|
self.assertAlmostEqual(end, forecast if forecast is not None else probe["points"][-1][0], delta=0.01)
|
||||||
if forecast is None:
|
if forecast is None:
|
||||||
self.assertEqual((probe["note"], probe["marker"]), ([None, "No forecast: the trend never reaches 0 remaining, so done never meets scope"], None))
|
self.assertEqual((probe["note"], probe["marker"]), ([None, "∞"], None))
|
||||||
return
|
return
|
||||||
self.assertAlmostEqual(probe["note"][0], forecast, delta=0.01)
|
self.assertAlmostEqual(probe["note"][0], forecast, delta=0.01)
|
||||||
self.assertAlmostEqual(probe["marker"], forecast, delta=0.01)
|
self.assertAlmostEqual(probe["marker"], forecast, delta=0.01)
|
||||||
self.assertTrue(probe["note"][1].startswith("Forecast: "), probe["note"])
|
self.assertNotEqual(probe["note"][1], "∞", probe["note"])
|
||||||
|
|
||||||
def check_trend_eta_3d(self, browser: str, driver: str, binary: str) -> None:
|
def check_trend_eta_3d(self, browser: str, driver: str, binary: str) -> None:
|
||||||
self.trend_work(time.time())
|
self.trend_work(time.time())
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
sys.path[:0] = [str(HERE.parent), str(HERE)]
|
||||||
|
|
||||||
|
import test_orchestrator_e2e as base
|
||||||
|
from hub_case import HubCase
|
||||||
|
from hub_support import iso
|
||||||
|
from kvb_orchestrator import skill_scripts, tasking
|
||||||
|
|
||||||
|
OUT = Path(os.environ.get("E2E_OUT", "/tmp/workbench-w2-shots"))
|
||||||
|
DAY = 86400
|
||||||
|
WHY = "rasci: work/a: 1 key result(s) open; mark them met or set stopped"
|
||||||
|
STAGED = [
|
||||||
|
["work/c", "set work/c --status running"],
|
||||||
|
["work/c", "set work/c --prio 2"],
|
||||||
|
["work/c", "set work/c --after work/a"],
|
||||||
|
["work/b", "set work/b --drop-after work/a"],
|
||||||
|
["work/d", "set work/d --prio 4"],
|
||||||
|
["work/stale", "set work/stale --status stopped"],
|
||||||
|
]
|
||||||
|
EMPTY = {"queued": [], "running": [], "done": []}
|
||||||
|
BOARD_HELPERS = """
|
||||||
|
let asked = 0;
|
||||||
|
const get = navigator.credentials.get.bind(navigator.credentials);
|
||||||
|
navigator.credentials.get = (options) => {
|
||||||
|
asked += 1;
|
||||||
|
return @SIGNS@ ? get(options) : Promise.reject(new DOMException("The operator canceled.", "NotAllowedError"));
|
||||||
|
};
|
||||||
|
const posted = [];
|
||||||
|
const send = window.fetch.bind(window);
|
||||||
|
window.fetch = (path, init) => {
|
||||||
|
if (["/api/sign", "/api/tasks"].includes(String(path))) {
|
||||||
|
posted.push([String(path), JSON.parse(init.body).items.map((item) => [item.id, item.argv.join(" ")])]);
|
||||||
|
}
|
||||||
|
return send(path, init);
|
||||||
|
};
|
||||||
|
const fresh = async (shown, prefix) => until(() => all("#toasts .toast").filter((box) => !shown.has(box)).map(text).find((entry) => entry.startsWith(prefix)), 20000);
|
||||||
|
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1});
|
||||||
|
const card = (task) => all("#main .kanban-card").find((node) => node.dataset.task === task) || null;
|
||||||
|
const cell = (lane, column) => document.querySelector(`#main .kanban-cell[data-lane="${lane}"][data-column="${column}"]`);
|
||||||
|
const drag = async (source, target) => {
|
||||||
|
const data = new DataTransfer();
|
||||||
|
const fire = (node, type) => node.dispatchEvent(new DragEvent(type, {bubbles: true, cancelable: true, dataTransfer: data}));
|
||||||
|
fire(source, "dragstart");
|
||||||
|
fire(target, "dragenter");
|
||||||
|
fire(target, "dragover");
|
||||||
|
fire(target, "drop");
|
||||||
|
fire(source, "dragend");
|
||||||
|
await sleep(400);
|
||||||
|
};
|
||||||
|
const read = () => ({
|
||||||
|
columns: all("#main .kanban-head .task-column").map((node) => node.dataset.column),
|
||||||
|
lanes: all("#main .kanban-lane").map((lane) => [lane.dataset.lane, lane.querySelector(".lane-toggle").getAttribute("aria-expanded"),
|
||||||
|
Object.fromEntries(all(".kanban-cell", lane).map((box) => [box.dataset.column, all(".kanban-card", box).map((node) => node.dataset.task)]))]),
|
||||||
|
labels: Object.fromEntries(all("#main .kanban-card").map((node) => [node.dataset.task, all(".pill[data-label]", node).map((pill) => pill.dataset.label)]).filter(([, found]) => found.length)),
|
||||||
|
arrows: all("#main svg.kanban-lines .kanban-arrow").map((node) => [node.dataset.from, node.dataset.to, node.classList.contains("pending")]),
|
||||||
|
solid: all("#main svg.kanban-lines .kanban-link").map((path) => getComputedStyle(path).strokeDasharray === "none"),
|
||||||
|
queue: (() => {
|
||||||
|
const line = document.querySelector("#main svg.kanban-lines .kanban-queue");
|
||||||
|
return line ? [line.dataset.tasks, getComputedStyle(line).strokeDasharray !== "none"] : null;
|
||||||
|
})(),
|
||||||
|
pending: Object.fromEntries(all("#main .kanban-card.pending").map((node) => [node.dataset.task, node.dataset.pending])),
|
||||||
|
apply: (() => {
|
||||||
|
const button = document.getElementById("kanban-apply");
|
||||||
|
return [button.disabled, button.getAttribute("aria-label"), text(button.querySelector(".count"))];
|
||||||
|
})(),
|
||||||
|
old: (() => {
|
||||||
|
const button = document.getElementById("kanban-old");
|
||||||
|
return [button.getAttribute("aria-pressed"), text(button.querySelector(".count"))];
|
||||||
|
})(),
|
||||||
|
stale: text(document.getElementById("kanban-stale")),
|
||||||
|
why: Object.fromEntries(all("#main .kanban-card .task-why").map((node) => [node.closest(".kanban-card").dataset.task, text(node)])),
|
||||||
|
partly: Object.fromEntries(all("#main .kanban-card .kanban-partly").map((node) => [node.closest(".kanban-card").dataset.task, node.dataset.partly])),
|
||||||
|
listed: all("#kanban-staged li").map((item) => [item.dataset.task, text(item)]),
|
||||||
|
unlabelled: all("#main .kanban-box svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length,
|
||||||
|
...fits(),
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
BOARD = """(async () => {@HELPERS@@BOARD_HELPERS@
|
||||||
|
if (!(await until(() => card("work/a") && document.querySelector("#main svg.kanban-lines .kanban-arrow"), 8000))) {
|
||||||
|
return {error: text(document.getElementById("main"))};
|
||||||
|
}
|
||||||
|
const before = read();
|
||||||
|
await drag(card("work/c"), cell("p2", "running"));
|
||||||
|
await drag(card("work/a").querySelector(".kanban-handle"), card("work/c"));
|
||||||
|
const arrow = all("#main svg.kanban-lines .kanban-arrow").find((node) => node.dataset.from === "work/a" && node.dataset.to === "work/b");
|
||||||
|
arrow.dispatchEvent(new MouseEvent("click", {bubbles: true}));
|
||||||
|
await sleep(400);
|
||||||
|
const menu = card("work/d").querySelector("details.kanban-menu");
|
||||||
|
menu.querySelector("summary").click();
|
||||||
|
all("button", menu).find((button) => text(button) === "P4+").click();
|
||||||
|
await sleep(400);
|
||||||
|
card("work/stale").querySelector(".kanban-stop").click();
|
||||||
|
await sleep(400);
|
||||||
|
const staged = read();
|
||||||
|
document.getElementById("kanban-old").click();
|
||||||
|
await sleep(300);
|
||||||
|
const older = read().lanes.find(([lane]) => lane === "unpinned");
|
||||||
|
document.getElementById("kanban-old").click();
|
||||||
|
await sleep(300);
|
||||||
|
document.querySelector('#main .kanban-lane[data-lane="p4"] .lane-toggle').click();
|
||||||
|
await sleep(300);
|
||||||
|
const lane = document.querySelector('#main .kanban-lane[data-lane="p4"]');
|
||||||
|
const folded = [lane.querySelector(".lane-toggle").getAttribute("aria-expanded"), lane.querySelector(".kanban-cells").hidden, read().listed];
|
||||||
|
lane.querySelector(".lane-toggle").click();
|
||||||
|
await sleep(300);
|
||||||
|
card("work/b").querySelector(".kanban-title").click();
|
||||||
|
await sleep(400);
|
||||||
|
const popup = document.getElementById("popup");
|
||||||
|
const popped = [popup.open, popup.getAttribute("aria-label")];
|
||||||
|
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
|
||||||
|
await sleep(300);
|
||||||
|
const shown = new Set(all("#toasts .toast"));
|
||||||
|
document.getElementById("kanban-apply").click();
|
||||||
|
const toast = await fresh(shown, "Apply 6 changes");
|
||||||
|
await sleep(600);
|
||||||
|
return {before, staged, older, folded, popped, toast, asked, posted, after: read()};
|
||||||
|
})()"""
|
||||||
|
FORECAST = """(async () => {@HELPERS@
|
||||||
|
const ready = () => document.querySelector("#main svg.burn") && document.querySelector('#main select[aria-label="Trend"]');
|
||||||
|
if (!(await until(ready, 8000))) {
|
||||||
|
return {error: text(document.getElementById("main"))};
|
||||||
|
}
|
||||||
|
const choose = async (label, value) => {
|
||||||
|
const select = document.querySelector(`#main select[aria-label="${label}"]`);
|
||||||
|
select.value = value;
|
||||||
|
select.dispatchEvent(new Event("change", {bubbles: true}));
|
||||||
|
await sleep(600);
|
||||||
|
};
|
||||||
|
const read = () => ({
|
||||||
|
points: Object.fromEntries(["scope", "done", "remaining"].map((name) => [name, all(`#main svg.burn circle.burn-point[data-series="${name}"]`).map((dot) => [Number(dot.dataset.t), Number(dot.dataset.value)])])),
|
||||||
|
finishes: all("#main .burn-finish").map((node) => [node.dataset.finish, node.dataset.forecast === "" ? null : Number(node.dataset.forecast), text(node), node.getAttribute("aria-label"), getComputedStyle(node, "::before").backgroundColor]),
|
||||||
|
lines: all("#main svg.burn path.burn-finish-line").map((path) => [path.dataset.finish, Number(path.dataset.at), getComputedStyle(path).stroke, path.getAttribute("d").match(/-?\\d+(?:\\.\\d+)?/g).map(Number)]),
|
||||||
|
stale: (() => {
|
||||||
|
const pill = document.querySelector("#main .burn-stale");
|
||||||
|
return [pill.dataset.stale, text(pill), pill.getAttribute("aria-label")];
|
||||||
|
})(),
|
||||||
|
tall: document.documentElement.scrollHeight > window.innerHeight,
|
||||||
|
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
|
||||||
|
});
|
||||||
|
await choose("Trend", "regression");
|
||||||
|
const whole = read();
|
||||||
|
await choose("Scope", "agent:Docs");
|
||||||
|
const docs = read();
|
||||||
|
await choose("Scope", "all");
|
||||||
|
return {whole, docs};
|
||||||
|
})()"""
|
||||||
|
|
||||||
|
|
||||||
|
def probe(template: str, signs: bool) -> str:
|
||||||
|
helpers = BOARD_HELPERS.replace("@SIGNS@", "true" if signs else "false")
|
||||||
|
return template.replace("@HELPERS@", base.HELPERS).replace("@BOARD_HELPERS@", helpers)
|
||||||
|
|
||||||
|
|
||||||
|
def regression(points: list[list[float]]) -> tuple[float, float, float] | None:
|
||||||
|
mean_t = sum(t for t, _ in points) / len(points)
|
||||||
|
mean_y = sum(y for _, y in points) / len(points)
|
||||||
|
spread = sum((t - mean_t) ** 2 for t, _ in points)
|
||||||
|
return (mean_t, mean_y, sum((t - mean_t) * (y - mean_y) for t, y in points) / spread) if spread else None
|
||||||
|
|
||||||
|
|
||||||
|
def finishes(points: dict) -> dict:
|
||||||
|
"""Returns:
|
||||||
|
The finish at fixed and at growing scope, computed here
|
||||||
|
independently of plan.js from the drawn burn points.
|
||||||
|
"""
|
||||||
|
done, remaining = regression(points["done"]), regression(points["remaining"])
|
||||||
|
last_t, last_scope = points["scope"][-1]
|
||||||
|
t0, y0, slope = done
|
||||||
|
fixed = last_t if points["remaining"][-1][1] <= 0 else max(last_t, t0 + (last_scope - y0) / slope) if slope > 0 else None
|
||||||
|
r0, ry, rslope = remaining
|
||||||
|
return {"fixed": fixed, "growing": r0 - ry / rslope if rslope < 0 else None}
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which(os.environ.get("CHROMIUM", "chromium")), "no Chromium")
|
||||||
|
class TestWorkbenchKanban(HubCase):
|
||||||
|
drive = base.TestOrchestratorE2E.drive
|
||||||
|
authenticator = base.TestOrchestratorE2E.authenticator
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
now = time.time()
|
||||||
|
fresh = iso(now - 3600)
|
||||||
|
self.write_council({
|
||||||
|
"rasci.json": {
|
||||||
|
"work/a": {"title": "Alpha", "status": "queued", "worker": "Stage", "prio": 1, "key_results": [{"text": "shipped", "met": False}], "updated": fresh},
|
||||||
|
"work/b": {"title": "Bravo", "status": "running", "worker": "Docs", "prio": 2, "start": iso(now - 3.9 * DAY), "after": ["work/a"], "updated": fresh},
|
||||||
|
"work/blocked": {"title": "Blocked one", "status": "blocked", "worker": "Stage", "prio": 3, "updated": fresh},
|
||||||
|
"work/p5": {"title": "Five", "status": "queued", "worker": "Stage", "prio": 5, "updated": fresh},
|
||||||
|
"work/c": {"title": "Charlie", "status": "queued", "worker": "Stage", "estimates": {"Stage": {"value": 4, "complexity": 2}}, "updated": fresh},
|
||||||
|
"work/d": {"title": "Delta", "status": "queued", "worker": "Skills", "estimates": {"Skills": {"value": 5, "complexity": 1}}, "updated": fresh},
|
||||||
|
"work/stale": {"title": "Stale one", "status": "running", "worker": "Stage", "start": iso(now - 5 * DAY), "updated": iso(now - 4 * DAY)},
|
||||||
|
"work/stopped": {"title": "Stopped one", "status": "stopped", "worker": "Stage", "start": iso(now - 3.7 * DAY), "end": iso(now - 3600)},
|
||||||
|
"work/old": {"title": "Old one", "status": "done", "worker": "Stage", "start": iso(now - 4 * DAY), "end": iso(now - 2 * DAY)},
|
||||||
|
"work/done": {"title": "Done one", "status": "done", "worker": "Stage", "start": iso(now - 3.8 * DAY), "end": iso(now - 7200)},
|
||||||
|
"operator/push": {"title": "git push", "workdir": "/repo", "status": "queued"},
|
||||||
|
},
|
||||||
|
"queue.json": {"seq": 2, "entries": [
|
||||||
|
{"id": "gate-1", "seq": 1, "kind": "gate", "task": "work/b", "name": "Docs", "hotfix": None, "status": "active", "enqueued": now - 300, "granted": now - 200, "cmd": "make test"},
|
||||||
|
{"id": "gate-2", "seq": 2, "kind": "gate", "task": "work/c", "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 100, "granted": None, "cmd": "make test"},
|
||||||
|
]},
|
||||||
|
})
|
||||||
|
signer = self.pin()
|
||||||
|
items = [{"kind": "task", "id": "work/a", "argv": ["set", "work/a", "--status", "done"]}, {"kind": "task", "id": "work/a", "argv": ["set", "work/a", "--prio", "1"]}]
|
||||||
|
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))
|
||||||
|
self.assertEqual((status, self.scripts.calls), (202, []), body)
|
||||||
|
self.run_runner()
|
||||||
|
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Stage", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/a --prio 1"])])
|
||||||
|
self.told = len(self.scripts.calls)
|
||||||
|
|
||||||
|
def steps(self, browser: str, signs: bool) -> list[dict]:
|
||||||
|
local = f"http://localhost:{self.port}/"
|
||||||
|
steps = []
|
||||||
|
for width, height in base.WIDTHS:
|
||||||
|
steps += [
|
||||||
|
{"width": width, "height": height, "url": f"{local}?{width}#tasks/board", "wait": 1200, "probe": probe(BOARD, signs), "shot": str(OUT / f"{browser}-{width}-kanban.png")},
|
||||||
|
{"width": width, "height": height, "url": f"{local}?{width}-burn#tasks/burn", "wait": 1200, "probe": probe(FORECAST, signs), "shot": str(OUT / f"{browser}-{width}-forecast.png")},
|
||||||
|
]
|
||||||
|
return steps
|
||||||
|
|
||||||
|
def check_board(self, result: dict, signs: bool) -> None:
|
||||||
|
self.assertNotIn("error", result, result)
|
||||||
|
before, staged, after = result["before"], result["staged"], result["after"]
|
||||||
|
self.assertEqual(before["columns"], ["queued", "running", "done"])
|
||||||
|
self.assertEqual(before["lanes"], [
|
||||||
|
["p1", "true", {**EMPTY, "queued": ["work/a"]}],
|
||||||
|
["p2", "true", {**EMPTY, "running": ["work/b"]}],
|
||||||
|
["p3", "true", {**EMPTY, "queued": ["work/blocked"]}],
|
||||||
|
["p4", "true", {**EMPTY, "queued": ["work/p5"]}],
|
||||||
|
["unpinned", "true", {**EMPTY, "queued": ["work/d", "work/c", "queue/gate-2"], "running": ["queue/gate-1", "work/stale"], "done": ["work/done", "work/stopped"]}],
|
||||||
|
])
|
||||||
|
self.assertEqual(before["labels"], {"work/blocked": ["blocked"], "work/stale": ["stale"], "work/stopped": ["stopped"]})
|
||||||
|
self.assertEqual((before["arrows"], before["solid"], before["queue"]), ([["work/a", "work/b", False]], [True], ["work/b work/c", True]))
|
||||||
|
self.assertEqual((before["old"], before["stale"], before["apply"], before["pending"]), (["false", "1"], "1", [True, "Apply 0 changes", "0"], {}))
|
||||||
|
self.assertEqual((before["why"], before["partly"], before["listed"]), ({"work/a": WHY}, {"work/a": "1/2"}, []))
|
||||||
|
self.assertEqual(staged["lanes"], [
|
||||||
|
["p1", "true", {**EMPTY, "queued": ["work/a"]}],
|
||||||
|
["p2", "true", {**EMPTY, "running": ["work/c", "work/b"]}],
|
||||||
|
["p3", "true", {**EMPTY, "queued": ["work/blocked"]}],
|
||||||
|
["p4", "true", {**EMPTY, "queued": ["work/d", "work/p5"]}],
|
||||||
|
["unpinned", "true", {**EMPTY, "queued": ["queue/gate-2"], "running": ["queue/gate-1"], "done": ["work/done", "work/stale", "work/stopped"]}],
|
||||||
|
])
|
||||||
|
self.assertEqual(staged["arrows"], [["work/a", "work/c", True]])
|
||||||
|
self.assertEqual(staged["pending"], {
|
||||||
|
"work/c": "set work/c --status running\nset work/c --prio 2\nset work/c --after work/a",
|
||||||
|
"work/b": "set work/b --drop-after work/a",
|
||||||
|
"work/d": "set work/d --prio 4",
|
||||||
|
"work/stale": "set work/stale --status stopped",
|
||||||
|
})
|
||||||
|
self.assertEqual((staged["apply"], staged["stale"], staged["labels"]["work/stale"]), ([False, "Apply 6 changes", "6"], "0", ["stopped"]))
|
||||||
|
self.assertEqual(result["older"], ["unpinned", "true", {**EMPTY, "queued": ["queue/gate-2"], "running": ["queue/gate-1"], "done": ["work/done", "work/old", "work/stale", "work/stopped"]}])
|
||||||
|
listed = [[task, f"rasci.py {argv}"] for task, argv in STAGED]
|
||||||
|
self.assertEqual((staged["listed"], result["folded"], result["popped"]), (listed, ["false", True, listed], [True, "work/b"]))
|
||||||
|
self.assertTrue(result["toast"].startswith(f"Apply 6 changes: {'handed to the runner' if signs else 'not signed'}"), result["toast"])
|
||||||
|
signed = [["/api/sign", STAGED]]
|
||||||
|
self.assertEqual((result["asked"], result["posted"]), (1, [*signed, ["/api/tasks", STAGED]] if signs else signed))
|
||||||
|
self.assertEqual(after["apply"], [True, "Apply 0 changes", "0"] if signs else [False, "Apply 6 changes", "6"])
|
||||||
|
for read in (before, staged, after):
|
||||||
|
self.assertEqual((read["unlabelled"], read["tall"], read["wide"]), (0, False, False), read)
|
||||||
|
|
||||||
|
def check_forecast(self, result: dict) -> None:
|
||||||
|
self.assertNotIn("error", result, result)
|
||||||
|
whole, docs = result["whole"], result["docs"]
|
||||||
|
expected = finishes(whole["points"])
|
||||||
|
self.assertTrue(expected["fixed"] is not None and expected["growing"] is not None and expected["growing"] > expected["fixed"], expected)
|
||||||
|
found = {kind: (at, words, label, colour) for kind, at, words, label, colour in whole["finishes"]}
|
||||||
|
lines = {kind: (at, stroke, path) for kind, at, stroke, path in whole["lines"]}
|
||||||
|
self.assertEqual((sorted(found), sorted(lines), len(whole["lines"])), (["fixed", "growing"], ["fixed", "growing"], 2))
|
||||||
|
for kind, at in expected.items():
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
self.assertAlmostEqual(found[kind][0], at, delta=0.01)
|
||||||
|
self.assertAlmostEqual(lines[kind][0], at, delta=0.01)
|
||||||
|
self.assertNotEqual(found[kind][1], "∞")
|
||||||
|
self.assertEqual(found[kind][3], lines[kind][1])
|
||||||
|
self.assertNotEqual(lines["fixed"][1], lines["growing"][1])
|
||||||
|
fixed_path, growing_path = lines["fixed"][2], lines["growing"][2]
|
||||||
|
self.assertAlmostEqual(fixed_path[1], fixed_path[3], delta=0.05, msg=f"the fixed-scope finish runs level at the last scope: {fixed_path}")
|
||||||
|
self.assertLess(growing_path[3], growing_path[1] - 1, f"the growing-scope finish rises along the scope trend: {growing_path}")
|
||||||
|
self.assertEqual(whole["stale"][:2], ["1", "1"])
|
||||||
|
self.assertEqual([entry[:3] for entry in docs["finishes"]], [["fixed", None, "∞"], ["growing", None, "∞"]])
|
||||||
|
self.assertEqual((docs["lines"], docs["stale"][:2]), ([], ["0", "0"]))
|
||||||
|
for read in (whole, docs):
|
||||||
|
self.assertEqual((read["tall"], read["wide"]), (False, False), read)
|
||||||
|
|
||||||
|
def check(self, data: dict, browser: str, signs: bool) -> None:
|
||||||
|
results = iter(data["results"])
|
||||||
|
for width, _ in base.WIDTHS:
|
||||||
|
with self.subTest(browser=browser, width=width):
|
||||||
|
self.check_board(next(results), signs)
|
||||||
|
self.check_forecast(next(results))
|
||||||
|
self.assertEqual(self.scripts.calls[self.told:], [])
|
||||||
|
if not signs:
|
||||||
|
return
|
||||||
|
self.run_runner()
|
||||||
|
stage = f"{tasking.TOLD}: rasci.py set work/c --status running; rasci.py set work/c --prio 2; rasci.py set work/c --after work/a; rasci.py set work/stale --status stopped"
|
||||||
|
batch = [
|
||||||
|
(skill_scripts.RADIO, ["post", "--to", "Stage", "--session", "Operator", stage]),
|
||||||
|
(skill_scripts.RADIO, ["post", "--to", "Docs", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/b --drop-after work/a"]),
|
||||||
|
(skill_scripts.RADIO, ["post", "--to", "Skills", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/d --prio 4"]),
|
||||||
|
]
|
||||||
|
self.assertEqual(self.scripts.calls[self.told:], batch * len(base.WIDTHS))
|
||||||
|
stored = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((stored["work/c"]["status"], stored["work/c"]["prio"], stored["work/c"]["after"], "after" in stored["work/b"]), ("running", 2, ["work/a"], False))
|
||||||
|
self.assertEqual((stored["work/d"]["prio"], stored["work/stale"]["status"], stored["work/a"]["status"]), (4, "stopped", "queued"))
|
||||||
|
|
||||||
|
def test_chromium_stages_drags_signs_one_batch_and_draws_both_forecasts_at_three_widths(self):
|
||||||
|
data = self.drive(self.steps("chromium", True), self.authenticator())
|
||||||
|
(OUT / "chromium-kanban.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
|
||||||
|
self.check(data, "chromium", True)
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
|
||||||
|
def test_firefox_stages_drags_asks_the_passkey_and_draws_both_forecasts_storing_nothing_unsigned(self):
|
||||||
|
data = self.drive(self.steps("firefox", False), None, "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
|
||||||
|
(OUT / "firefox-kanban.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
|
||||||
|
self.assertEqual([error for error in data["errors"] if "/favicon.ico" not in error], [])
|
||||||
|
self.check(data, "firefox", False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from hub_case import HubCase
|
||||||
|
from hub_support import HUB, PLAN, iso, runner, signing
|
||||||
|
from kvb_orchestrator import skill_scripts, tasking
|
||||||
|
|
||||||
|
KANBAN = HUB / "templates" / "kanban.js"
|
||||||
|
DAY = 86400
|
||||||
|
KANBAN_PROBE = """
|
||||||
|
const K = require(process.argv[1]);
|
||||||
|
const H = 3600, D = 86400, now = 100 * D;
|
||||||
|
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, prio: null, wsjf: null, after: [], movable: true, editable: true, ...fields});
|
||||||
|
const rows = [
|
||||||
|
row("work/p1", {prio: 1, status: "running", start: now - H}),
|
||||||
|
row("work/p2", {prio: 2, status: "blocked"}),
|
||||||
|
row("work/p3", {prio: 3, status: "blocked", start: now - H}),
|
||||||
|
row("work/p4", {prio: 4}),
|
||||||
|
row("work/p7", {prio: 7, status: "stopped", ended: true, end: now - H}),
|
||||||
|
row("work/low", {wsjf: {score: 1.5}}),
|
||||||
|
row("work/high", {wsjf: {score: 4}}),
|
||||||
|
row("work/none", {}),
|
||||||
|
row("work/also", {wsjf: {score: 4}}),
|
||||||
|
row("work/old", {status: "done", ended: true, end: now - D - 1}),
|
||||||
|
row("work/fresh", {status: "done", ended: true, end: now - D + 1}),
|
||||||
|
row("operator/push", {kind: "command"}),
|
||||||
|
row("queue/gate-1", {kind: "queue", status: "running"}),
|
||||||
|
];
|
||||||
|
const hub = (task) => rows.find((entry) => entry.task === task);
|
||||||
|
const place = (old) => {
|
||||||
|
const found = K.board(rows, {now, old});
|
||||||
|
return {hidden: found.hidden, lanes: found.lanes.map((lane) => [lane.id, lane.label, lane.count, Object.fromEntries(Object.entries(lane.cells).map(([column, cards]) => [column, cards.map((entry) => entry.task)]))])};
|
||||||
|
};
|
||||||
|
const shown = (staged) => staged.map((change) => [change.task, change.argv.join(" ")]);
|
||||||
|
let staged = [];
|
||||||
|
staged = K.stage(staged, hub("work/none"), {column: "running"}, rows);
|
||||||
|
staged = K.stage(staged, hub("work/none"), {lane: "p2"}, rows);
|
||||||
|
staged = K.stage(staged, hub("work/low"), {lane: "p4"}, K.pending(rows, staged));
|
||||||
|
staged = K.stage(staged, hub("work/high"), {lane: "p4"}, K.pending(rows, staged));
|
||||||
|
staged = K.stage(staged, hub("work/p1"), {lane: "unpinned"}, rows);
|
||||||
|
staged = K.stage(staged, hub("work/none"), {after: "work/p1"}, rows);
|
||||||
|
const first = shown(staged);
|
||||||
|
staged = K.stage(staged, hub("work/none"), {column: "done"}, rows);
|
||||||
|
const replaced = shown(staged);
|
||||||
|
staged = K.stage(staged, hub("work/none"), {lane: "unpinned"}, rows);
|
||||||
|
staged = K.stage(staged, hub("work/none"), {drop: "work/p1"}, rows);
|
||||||
|
const back = shown(staged);
|
||||||
|
const applied = Object.fromEntries(K.pending(rows, staged).filter((entry) => entry.pending).map((entry) => [entry.task, [entry.status, entry.ended, entry.prio, entry.after, entry.pending.length]]));
|
||||||
|
const dep = row("work/dep", {after: ["work/p1", "work/p2"]});
|
||||||
|
const linked = [row("a", {}), row("b", {after: ["a"]}), row("c", {after: ["a", "b", "gone", "c"]})];
|
||||||
|
const job = (id, task, batch, status, created_ts, output) => ({id, kind: "task", task, batch, status, created_ts, output: output || "", command: id});
|
||||||
|
const jobs = [
|
||||||
|
job("pin", "work/x", "b2", "done", 21),
|
||||||
|
job("old", "work/x", "b1", "failed", 10, "rasci: old"),
|
||||||
|
job("status", "work/x", "b2", "failed", 20, "x\\nrasci: work/x: 1 key result(s) open"),
|
||||||
|
job("lone", "work/y", undefined, "refused", 5, "refused: expired"),
|
||||||
|
job("cmd", "work/z", "b3", "failed", 30),
|
||||||
|
job("fixed", "work/w", "b4", "failed", 1),
|
||||||
|
job("later", "work/w", "b5", "done", 2),
|
||||||
|
{id: "run", task: "work/v", status: "failed", created_ts: 3, output: ""},
|
||||||
|
];
|
||||||
|
const reasons = (found) => found && {...found, failed: found.failed.map((entry) => entry.id)};
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
columns: K.COLUMNS, lanes: K.LANES, freeFrom: K.FREE_FROM,
|
||||||
|
sorted: [["running", null], ["blocked", null], ["blocked", 5], ["queued", null], ["done", 1], ["stopped", 1], ["awaiting", null]].map(([status, start]) => K.columnOf(row("x", {status, start}))),
|
||||||
|
labels: rows.slice(0, 5).map(K.labelOf),
|
||||||
|
lanesOf: [null, 0, -1, 1, 2, 3, 4, 9].map(K.laneOf),
|
||||||
|
belowOne: K.board([row("work/zero", {prio: 0}), row("work/minus", {prio: -3})], {now, old: false}).lanes.map((lane) => [lane.id, lane.count]),
|
||||||
|
why: Object.fromEntries(["work/x", "work/y", "work/z", "work/w", "work/v", "work/none"].map((task) => [task, reasons(K.why(jobs, task))])),
|
||||||
|
onBoard: rows.filter(K.onBoard).length,
|
||||||
|
hidden: place(false), shown: place(true),
|
||||||
|
pins: [K.freePin(rows, "work/none"), K.freePin([...rows, row("work/five", {prio: 5})], "work/none"), K.freePin(rows, "work/p4")],
|
||||||
|
first, replaced, back, applied,
|
||||||
|
untouched: K.pending(rows, staged).filter((entry, index) => entry === rows[index]).length,
|
||||||
|
noops: [K.move(hub("work/p1"), {column: "running"}, rows), K.move(hub("work/p3"), {column: "running"}, rows), K.move(hub("work/p2"), {lane: "p2"}, rows), K.move(hub("work/p7"), {lane: "p4"}, rows),
|
||||||
|
K.move(hub("work/p3"), {after: "work/p3"}, rows), K.move(dep, {after: "work/p1"}, rows), K.move(dep, {drop: "work/p3"}, rows), K.move(hub("work/p7"), {status: "stopped"}, rows)],
|
||||||
|
moves: [K.move(hub("work/none"), {status: "stopped"}, rows), K.move(dep, {drop: "work/p1"}, rows), K.move(hub("work/fresh"), {column: "running"}, rows)],
|
||||||
|
pendingAfter: K.pending([dep], [{task: "work/dep", argv: ["set", "work/dep", "--after", "work/p3"]}, {task: "work/dep", argv: ["set", "work/dep", "--drop-after", "work/p1"]}])[0].after,
|
||||||
|
lines: K.lines(linked, ["gone", "c", "a"]),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
FORECAST_PROBE = """
|
||||||
|
const P = require(process.argv[1]);
|
||||||
|
const D = 86400, now = 100 * D;
|
||||||
|
const points = (scope, done) => scope.map((value, index) => ({t: index * D, scope: value, done: done[index], remaining: value - done[index], unestimated: 0}));
|
||||||
|
const days = (t) => (t === null ? null : t / D);
|
||||||
|
const finish = (found, mode) => {
|
||||||
|
const reached = P.finishes(found, P.trend(found, {mode, days: 7}));
|
||||||
|
return [days(reached.fixed), days(reached.growing)];
|
||||||
|
};
|
||||||
|
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, points: null, worker: "-", roles: {}, updated: null, ...fields});
|
||||||
|
const rows = [
|
||||||
|
row("work/a", {status: "running", start: now - 5 * D, updated: now - 4 * D}),
|
||||||
|
row("work/b", {status: "running", start: now - 2 * D, updated: now - D}),
|
||||||
|
row("work/c", {status: "done", ended: true, start: now - 6 * D, end: now - 5 * D, updated: now - 5 * D}),
|
||||||
|
row("operator/push", {kind: "command", updated: now - 9 * D}),
|
||||||
|
row("queue/x", {kind: "queue", status: "awaiting"}),
|
||||||
|
];
|
||||||
|
const scope = (choice) => {
|
||||||
|
const found = P.scoped(rows, choice, {filtered: rows, since: now - D, until: now, now, holders: {}});
|
||||||
|
return {rows: found.rows.map((entry) => entry.task), stale: found.stale.map((entry) => entry.task), from: found.from / D, to: found.to / D};
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
fixedScope: finish(points([4, 4, 4], [0, 1, 2]), "regression"),
|
||||||
|
growing: finish(points([4, 5, 6], [0, 2, 4]), "regression"),
|
||||||
|
growingNever: finish(points([4, 5, 6], [0, 1, 2]), "regression"),
|
||||||
|
never: finish(points([4, 4, 4], [1, 1, 1]), "regression"),
|
||||||
|
finished: finish(points([2, 3, 3], [0, 2, 3]), "regression"),
|
||||||
|
velocity: finish(points([4, 5, 6], [0, 2, 4]), "velocity"),
|
||||||
|
noTrend: P.finishes(points([1], [0]), null),
|
||||||
|
beyond: P.finishes([{t: 0, scope: 1, done: 0, remaining: 1}, {t: 1, scope: 1, done: 1e-13, remaining: 1 - 1e-13}], P.trend([{t: 0, scope: 1, done: 0, remaining: 1}, {t: 1, scope: 1, done: 1e-13, remaining: 1 - 1e-13}], {mode: "regression", days: 7})),
|
||||||
|
staleDays: P.STALE_DAYS,
|
||||||
|
stale: [row("t", {updated: now - 3 * D}), row("t", {updated: now - 3 * D + 1}), row("t", {updated: now - 10 * D, ended: true}), row("t", {}), row("t", {updated: undefined})].map((entry) => P.stale(entry, now)),
|
||||||
|
all: scope("all"), filter: scope("filter"),
|
||||||
|
burn: P.burn(P.scoped(rows, "all", {filtered: rows, since: now - D, until: now, now, holders: {}}).rows, {estimation: "none", from: now - 6 * D, to: now}).points.pop(),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def change(argv: list, task: str) -> dict:
|
||||||
|
return {"kind": "task", "id": task, "argv": argv}
|
||||||
|
|
||||||
|
|
||||||
|
class TestKanbanModule(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
done = subprocess.run(["node", "-e", KANBAN_PROBE, str(KANBAN)], capture_output=True, text=True, timeout=60, check=True)
|
||||||
|
cls.data = json.loads(done.stdout)
|
||||||
|
|
||||||
|
def test_the_board_has_waiting_running_and_done_and_blocked_and_stopped_are_labels(self):
|
||||||
|
self.assertEqual(self.data["columns"], [["queued", "Waiting"], ["running", "Running"], ["done", "Done"]])
|
||||||
|
self.assertEqual(self.data["sorted"], ["running", "queued", "running", "queued", "done", "done", "queued"])
|
||||||
|
self.assertEqual(self.data["labels"], [None, "blocked", "blocked", None, "stopped"])
|
||||||
|
|
||||||
|
def test_operator_commands_stay_off_the_board_and_queue_jobs_stay_on_it(self):
|
||||||
|
self.assertEqual(self.data["onBoard"], 12)
|
||||||
|
placed = [task for lane in self.data["shown"]["lanes"] for cells in lane[3].values() for task in cells]
|
||||||
|
self.assertEqual(len(placed), 12)
|
||||||
|
self.assertEqual([task for task in placed if task.startswith(("operator/", "queue/"))], ["queue/gate-1"])
|
||||||
|
|
||||||
|
def test_lanes_are_p1_to_p3_p4_plus_and_unpinned_by_wsjf_rank_and_old_done_cards_hide(self):
|
||||||
|
self.assertEqual((self.data["lanes"], self.data["freeFrom"]), ([["p1", "P1"], ["p2", "P2"], ["p3", "P3"], ["p4", "P4+"], ["unpinned", "Unpinned"]], 4))
|
||||||
|
self.assertEqual(self.data["lanesOf"], ["unpinned", "p1", "p1", "p1", "p2", "p3", "p4", "p4"])
|
||||||
|
self.assertEqual(self.data["belowOne"], [["p1", 2], ["p2", 0], ["p3", 0], ["p4", 0], ["unpinned", 0]])
|
||||||
|
empty = {"queued": [], "running": [], "done": []}
|
||||||
|
self.assertEqual(self.data["hidden"], {"hidden": 1, "lanes": [
|
||||||
|
["p1", "P1", 1, {**empty, "running": ["work/p1"]}],
|
||||||
|
["p2", "P2", 1, {**empty, "queued": ["work/p2"]}],
|
||||||
|
["p3", "P3", 1, {**empty, "running": ["work/p3"]}],
|
||||||
|
["p4", "P4+", 2, {**empty, "queued": ["work/p4"], "done": ["work/p7"]}],
|
||||||
|
["unpinned", "Unpinned", 6, {**empty, "queued": ["work/also", "work/high", "work/low", "work/none"], "running": ["queue/gate-1"], "done": ["work/fresh"]}],
|
||||||
|
]})
|
||||||
|
self.assertEqual(self.data["shown"]["hidden"], 0)
|
||||||
|
self.assertEqual(self.data["shown"]["lanes"][4][3]["done"], ["work/fresh", "work/old"])
|
||||||
|
|
||||||
|
def test_a_card_shows_every_refusal_of_its_newest_batch_and_how_much_of_it_applied(self):
|
||||||
|
self.assertEqual(self.data["why"], {
|
||||||
|
"work/x": {"failed": ["status"], "applied": 1, "total": 2},
|
||||||
|
"work/y": {"failed": ["lone"], "applied": 0, "total": 1},
|
||||||
|
"work/z": {"failed": ["cmd"], "applied": 0, "total": 1},
|
||||||
|
"work/w": None,
|
||||||
|
"work/v": None,
|
||||||
|
"work/none": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_p4_plus_takes_the_lowest_pin_from_4_no_other_open_task_holds(self):
|
||||||
|
self.assertEqual(self.data["pins"], [5, 6, 4])
|
||||||
|
|
||||||
|
def test_moves_stage_one_change_each_and_a_later_move_of_the_same_kind_replaces_it(self):
|
||||||
|
self.assertEqual(self.data["first"], [
|
||||||
|
["work/none", "set work/none --status running"],
|
||||||
|
["work/none", "set work/none --prio 2"],
|
||||||
|
["work/low", "set work/low --prio 5"],
|
||||||
|
["work/high", "set work/high --prio 6"],
|
||||||
|
["work/p1", "set work/p1 --unpin"],
|
||||||
|
["work/none", "set work/none --after work/p1"],
|
||||||
|
])
|
||||||
|
self.assertEqual(self.data["replaced"], [*self.data["first"][1:], ["work/none", "set work/none --status done"]])
|
||||||
|
self.assertEqual(self.data["back"], [
|
||||||
|
["work/low", "set work/low --prio 5"], ["work/high", "set work/high --prio 6"], ["work/p1", "set work/p1 --unpin"], ["work/none", "set work/none --status done"],
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_staged_moves_show_at_once_as_pending_rows(self):
|
||||||
|
self.assertEqual(self.data["applied"], {
|
||||||
|
"work/p1": ["running", False, None, [], 1],
|
||||||
|
"work/low": ["queued", False, 5, [], 1],
|
||||||
|
"work/high": ["queued", False, 6, [], 1],
|
||||||
|
"work/none": ["done", True, None, [], 1],
|
||||||
|
})
|
||||||
|
self.assertEqual(self.data["untouched"], 9)
|
||||||
|
self.assertEqual(self.data["pendingAfter"], ["work/p2", "work/p3"])
|
||||||
|
|
||||||
|
def test_a_move_to_where_the_card_already_is_stages_nothing(self):
|
||||||
|
self.assertEqual(self.data["noops"], [None] * 8)
|
||||||
|
self.assertEqual(self.data["moves"], [["set", "work/none", "--status", "stopped"], ["set", "work/dep", "--drop-after", "work/p1"], ["set", "work/fresh", "--status", "running"]])
|
||||||
|
|
||||||
|
def test_arrows_run_into_the_dependent_card_several_per_card_and_the_queue_line_keeps_queue_order(self):
|
||||||
|
self.assertEqual(self.data["lines"], {"arrows": [{"from": "a", "to": "b"}, {"from": "a", "to": "c"}, {"from": "b", "to": "c"}], "queue": ["c", "a"]})
|
||||||
|
|
||||||
|
|
||||||
|
class TestForecast(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
done = subprocess.run(["node", "-e", FORECAST_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
|
||||||
|
cls.data = json.loads(done.stdout)
|
||||||
|
|
||||||
|
def assertDays(self, found: list, expected: list) -> None:
|
||||||
|
for got, want in zip(found, expected, strict=True):
|
||||||
|
if want is None:
|
||||||
|
self.assertIsNone(got)
|
||||||
|
else:
|
||||||
|
self.assertAlmostEqual(got, want, places=9)
|
||||||
|
|
||||||
|
def test_at_fixed_scope_both_finishes_meet(self):
|
||||||
|
self.assertDays(self.data["fixedScope"], [4, 4])
|
||||||
|
|
||||||
|
def test_a_growing_scope_finishes_later_than_the_fixed_one(self):
|
||||||
|
self.assertDays(self.data["growing"], [3, 4])
|
||||||
|
self.assertDays(self.data["velocity"], [3, 4])
|
||||||
|
|
||||||
|
def test_a_finish_that_is_never_reached_is_none_and_shown_as_infinity(self):
|
||||||
|
self.assertDays(self.data["growingNever"], [6, None])
|
||||||
|
self.assertDays(self.data["never"], [None, None])
|
||||||
|
self.assertEqual(self.data["noTrend"], {"fixed": None, "growing": None})
|
||||||
|
self.assertIsNone(self.data["beyond"]["growing"])
|
||||||
|
self.assertIsNone(self.data["beyond"]["fixed"])
|
||||||
|
|
||||||
|
def test_nothing_remaining_finishes_at_the_last_point(self):
|
||||||
|
self.assertDays(self.data["finished"], [2, 2])
|
||||||
|
|
||||||
|
def test_a_task_without_a_register_change_for_three_days_is_stale(self):
|
||||||
|
self.assertEqual((self.data["staleDays"], self.data["stale"]), (3, [True, False, False, False, False]))
|
||||||
|
|
||||||
|
def test_the_scope_leaves_out_operator_commands_and_queue_jobs_and_counts_stale_tasks_apart(self):
|
||||||
|
self.assertEqual(self.data["all"], {"rows": ["work/b", "work/c"], "stale": ["work/a"], "from": 94, "to": 100})
|
||||||
|
self.assertEqual(self.data["filter"], {"rows": ["work/b", "work/c"], "stale": ["work/a"], "from": 99, "to": 100})
|
||||||
|
self.assertEqual((self.data["burn"]["scope"], self.data["burn"]["done"]), (2, 1))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBoardArgv(unittest.TestCase):
|
||||||
|
def test_the_signed_register_contract_takes_exactly_one_board_change(self):
|
||||||
|
for flags in (["--status", "running"], ["--status", "stopped"], ["--after", "work/b"], ["--drop-after", "work/b"], ["--prio", "4"], ["--unpin"]):
|
||||||
|
with self.subTest(flags=flags):
|
||||||
|
argv = ["set", "work/a", *flags]
|
||||||
|
self.assertEqual(signing.task_argv("work/a", argv), argv)
|
||||||
|
for flags in (["--status", "paused"], ["--status"], ["--after", "work/a"], ["--after", "operator/push"], ["--after", "Work/B"], ["--unpin", "--prio", "4"], ["--status", "done", "--prio", "2"], ["--drop-after"], ["--title", "x"], []):
|
||||||
|
with self.subTest(flags=flags), self.assertRaises(signing.Refused):
|
||||||
|
signing.task_argv("work/a", ["set", "work/a", *flags])
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubKanban(HubCase):
|
||||||
|
def board_register(self) -> dict:
|
||||||
|
return {
|
||||||
|
"work/a": {"title": "A", "status": "queued", "worker": "Stage", "roles": {"Stage": "RA"}, "key_results": [{"text": "shipped", "met": False}], "updated": iso(time.time() - 4 * DAY)},
|
||||||
|
"work/b": {"title": "B", "status": "running", "worker": "Docs", "after": ["work/a"], "updated": iso(time.time() - DAY)},
|
||||||
|
"work/c": {"title": "C", "status": "queued", "worker": "Stage"},
|
||||||
|
"work/d": {"title": "D", "status": "done", "worker": "Docs", "end": iso(time.time() - 2 * DAY)},
|
||||||
|
"work/e": {"title": "E", "status": "queued", "worker": "-"},
|
||||||
|
"operator/push": {"title": "git push", "workdir": "/repo", "status": "queued"},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_the_state_carries_the_change_time_the_movable_rows_and_the_queue_line(self):
|
||||||
|
now = time.time()
|
||||||
|
register = self.board_register()
|
||||||
|
entries = [
|
||||||
|
{"id": "gate-1", "seq": 1, "kind": "gate", "task": "work/c", "name": "Stage", "hotfix": None, "status": "active", "enqueued": now - 300, "granted": now - 200, "cmd": "make test"},
|
||||||
|
{"id": "gate-2", "seq": 2, "kind": "gate", "task": "work/a", "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 100, "granted": None, "cmd": "make test"},
|
||||||
|
{"id": "gate-3", "seq": 3, "kind": "gate", "task": "work/c", "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 50, "granted": None, "cmd": "make test"},
|
||||||
|
{"id": "gate-4", "seq": 4, "kind": "gate", "task": None, "name": "Stage", "hotfix": None, "status": "waiting", "enqueued": now - 40, "granted": None, "cmd": "make test"},
|
||||||
|
]
|
||||||
|
self.write_council({"rasci.json": register, "queue.json": {"seq": 4, "entries": entries}})
|
||||||
|
state = self.snapshot()
|
||||||
|
rows = {row["task"]: row for row in state["tasks"]}
|
||||||
|
self.assertAlmostEqual(rows["work/a"]["updated"], now - 4 * DAY, delta=2)
|
||||||
|
self.assertIsNone(rows["work/c"]["updated"])
|
||||||
|
self.assertEqual({task: (row["movable"], row["editable"]) for task, row in rows.items() if not task.startswith("queue/")}, {
|
||||||
|
"work/a": (True, True), "work/b": (True, True), "work/c": (True, True), "work/d": (True, False), "work/e": (True, True), "operator/push": (False, False),
|
||||||
|
})
|
||||||
|
self.assertEqual((rows["queue/gate-1"]["movable"], rows["queue/gate-1"]["updated"]), (False, None))
|
||||||
|
self.assertEqual(state["queue_line"], ["work/c", "work/a"])
|
||||||
|
|
||||||
|
def test_a_signed_board_batch_reaches_the_register_through_the_runner_and_tells_each_worker_once(self):
|
||||||
|
self.write_council({"rasci.json": self.board_register()})
|
||||||
|
self.claim("s1", "Stage session")
|
||||||
|
signer = self.pin()
|
||||||
|
items = [
|
||||||
|
change(["set", "work/c", "--status", "running"], "work/c"),
|
||||||
|
change(["set", "work/c", "--prio", "4"], "work/c"),
|
||||||
|
change(["set", "work/a", "--after", "work/c"], "work/a"),
|
||||||
|
change(["set", "work/b", "--drop-after", "work/a"], "work/b"),
|
||||||
|
change(["set", "work/d", "--status", "running"], "work/d"),
|
||||||
|
change(["set", "work/e", "--unpin"], "work/e"),
|
||||||
|
]
|
||||||
|
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"]), self.scripts.calls), (6, []))
|
||||||
|
self.run_runner()
|
||||||
|
self.assertEqual(self.scripts.calls, [
|
||||||
|
(skill_scripts.RADIO, ["post", "--to", "Stage session", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/c --status running; rasci.py set work/c --prio 4; rasci.py set work/a --after work/c"]),
|
||||||
|
(skill_scripts.RADIO, ["post", "--to", "Docs", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/b --drop-after work/a; rasci.py set work/d --status running"]),
|
||||||
|
])
|
||||||
|
stored = json.loads((self.council / "rasci.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual((stored["work/c"]["status"], stored["work/c"]["prio"], stored["work/a"]["after"], "after" in stored["work/b"]), ("running", 4, ["work/c"], False))
|
||||||
|
self.assertEqual((stored["work/d"]["status"], "end" in stored["work/d"], "prio" in stored["work/e"]), ("running", False, False))
|
||||||
|
self.assertTrue(all("updated" in stored[task] for task in ("work/a", "work/b", "work/c", "work/d")))
|
||||||
|
self.assertEqual([job["status"] for job in self.snapshot()["jobs"] if job["kind"] == "task"], ["done"] * 6)
|
||||||
|
|
||||||
|
def test_the_executor_refuses_a_cycle_a_done_with_an_open_key_result_and_a_forged_change_and_says_why(self):
|
||||||
|
self.write_council({"rasci.json": self.board_register()})
|
||||||
|
before = (self.council / "rasci.json").read_bytes()
|
||||||
|
signer = self.pin()
|
||||||
|
items = [change(["set", "work/a", "--after", "work/b"], "work/a"), change(["set", "work/a", "--status", "done"], "work/a")]
|
||||||
|
proof = self.signed(items, signer)
|
||||||
|
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": proof}, self.guard(self.host))
|
||||||
|
self.assertEqual(status, 202, body)
|
||||||
|
fields = {"kind": "task", "task": "work/a", "argv": ["set", "work/a", "--status", "stopped"], "command": "x", "directory": "", "sha256": "", "status": "queued", "proof": proof}
|
||||||
|
forged = self.httpd.hub.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})
|
||||||
|
self.run_runner()
|
||||||
|
jobs = {job["command"]: job for job in self.snapshot()["jobs"] if job["kind"] == "task"}
|
||||||
|
cycle, done = jobs["rasci.py set work/a --after work/b"], jobs["rasci.py set work/a --status done"]
|
||||||
|
self.assertEqual((cycle["status"], cycle["code"]), ("failed", 2))
|
||||||
|
self.assertIn("rasci: work/a: --after work/b closes a cycle", cycle["output"])
|
||||||
|
self.assertEqual((done["status"], done["code"]), ("failed", 2))
|
||||||
|
self.assertIn("rasci: work/a: 1 key result(s) open", done["output"])
|
||||||
|
record = self.httpd.hub.jobs.get(forged["id"])
|
||||||
|
self.assertEqual(record["status"], "refused")
|
||||||
|
self.assertTrue(record["output"].startswith(runner.REFUSED), record["output"])
|
||||||
|
self.assertIn("does not list this item", record["output"])
|
||||||
|
self.assertEqual((self.council / "rasci.json").read_bytes(), before)
|
||||||
|
self.assertEqual(self.scripts.calls, [])
|
||||||
|
|
||||||
|
def test_only_the_changes_rasci_applied_are_told_once_their_whole_batch_ended(self):
|
||||||
|
self.write_council({"rasci.json": self.board_register()})
|
||||||
|
signer = self.pin()
|
||||||
|
items = [change(["set", "work/a", "--status", "done"], "work/a"), change(["set", "work/a", "--prio", "2"], "work/a")]
|
||||||
|
status, _, body = self.post_json("/api/tasks", {"items": items, "proof": self.signed(items, signer)}, self.guard(self.host))
|
||||||
|
self.assertEqual((status, self.scripts.calls), (202, []), body)
|
||||||
|
self.run_runner()
|
||||||
|
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["post", "--to", "Stage", "--session", "Operator", f"{tasking.TOLD}: rasci.py set work/a --prio 2"])])
|
||||||
|
jobs = [job for job in self.snapshot()["jobs"] if job["kind"] == "task"]
|
||||||
|
self.assertEqual(sorted(job["status"] for job in jobs), ["done", "failed"])
|
||||||
|
self.assertEqual(len({job["batch"] for job in jobs}), 1)
|
||||||
|
self.assertEqual(self.httpd.hub.tell_applied(jobs[0]), [])
|
||||||
|
self.assertEqual(len(self.scripts.calls), 1)
|
||||||
|
|
||||||
|
def test_the_hub_signs_a_status_move_of_an_ended_task_but_no_other_change_of_it_and_no_operator_row(self):
|
||||||
|
self.write_council({"rasci.json": self.board_register()})
|
||||||
|
good = self.guard(self.host)
|
||||||
|
self.assertEqual(self.post_json("/api/sign", {"items": [change(["set", "work/d", "--status", "queued"], "work/d")]}, good)[0], 200)
|
||||||
|
for items, code in (
|
||||||
|
([change(["set", "work/d", "--prio", "2"], "work/d")], 409),
|
||||||
|
([change(["set", "work/d", "--after", "work/a"], "work/d")], 409),
|
||||||
|
([change(["set", "operator/push", "--status", "done"], "operator/push")], 400),
|
||||||
|
([change(["set", "work/a", "--after", "operator/push"], "work/a")], 400),
|
||||||
|
([change(["set", "work/a", "--status", "done", "--prio", "1"], "work/a")], 400),
|
||||||
|
):
|
||||||
|
with self.subTest(items=items):
|
||||||
|
self.assertEqual(self.post_json("/api/sign", {"items": items}, good)[0], code)
|
||||||
|
self.assertEqual(self.scripts.calls, [])
|
||||||
|
|
||||||
|
def test_the_page_ships_the_board_modules_and_every_board_control_is_a_labelled_icon(self):
|
||||||
|
html = self.index()
|
||||||
|
self.assertIn(KANBAN.read_text(encoding="utf-8").strip(), html)
|
||||||
|
self.assertIn("function kanbanBoard(shown)", html)
|
||||||
|
self.assertIn(".kanban-lines", html)
|
||||||
|
self.assertIn("const TASK_COLUMNS = Kanban.COLUMNS;", html)
|
||||||
|
page = (HUB / "templates" / "page-kanban.js").read_text(encoding="utf-8")
|
||||||
|
controls = [line for line in page.splitlines() if re.search(r'h\("(?:button|summary|span)", (?:\{|named)', line) and "icon(" in line or 'h("button"' in line]
|
||||||
|
self.assertGreaterEqual(len(controls), 6)
|
||||||
|
for line in controls:
|
||||||
|
with self.subTest(line=line.strip()[:90]):
|
||||||
|
self.assertTrue("named(" in line or "title:" in line, line)
|
||||||
|
self.assertNotIn("innerHTML", page)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -151,7 +151,7 @@ class TestHubState(HubCase):
|
|||||||
set(state),
|
set(state),
|
||||||
{
|
{
|
||||||
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
|
||||||
"register", "tasks", "task_edges", "agent_order", "radio", "claims", "grants", "night", "night_parts", "presence", "silence", "graph",
|
"register", "tasks", "task_edges", "queue_line", "agent_order", "radio", "claims", "grants", "night", "night_parts", "presence", "silence", "graph",
|
||||||
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
"inbox", "pending", "rules", "approvers", "jobs", "views", "llm", "people", "settings", "media", "speech",
|
||||||
"badges", "kinds",
|
"badges", "kinds",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user