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:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:14:36 +00:00
1 parent fd8fc08603
commit 8e63b21e21
18 files changed
+1375 -76

No files matched your search

+5 -5
View File
@@ -175,8 +175,8 @@ flowchart TD
### W2 Kanban
- [ ] 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] Columns Waiting, Running and Done; no Blocked or Stopped column.
- [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.
- [ ] Swimlanes P1, P2, P3, P4+ and Unpinned, collapsible, Unpinned sorted by
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
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.
- [ ] Each affected agent receives one post listing the operator's changes to
its rows.
- [x] Each affected agent receives one post listing the operator's changes to
its rows that `rasci.py` applied, once the whole signed batch ended.
- [ ] A click on a card opens its detail popup.
### W2 Forecast
@@ -331,7 +331,7 @@ The work packages meet only through these interfaces:
### Tests
- 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/*`.
- [ ] Gates resolve (own rule, strictest of several ancestors, none);
parents derive; receipt tree sha with unstaged and untracked files;
+5 -2
View File
@@ -16,9 +16,12 @@ Returns:
Runs as the operator, outside any sandbox, every operator command a
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
in the signed directory, each signature once and before its expiry.
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
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
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.
+24 -7
View File
@@ -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")
ESTIMATOR = "Operator"
COMMANDS = "operator/"
STATUSES = ("queued", "running", "blocked", "done", "stopped")
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
WARNINGS = (
@@ -156,14 +157,31 @@ def task_item(task: str, argv: list[str]) -> dict:
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]:
"""Args:
task: the register task the change is for, a rasci.py slug outside
operator/.
argv: the rasci.py arguments, one of `set <task> --prio <1-999>`,
`estimate <task> Operator --value <fib> --complexity <fib>`,
`estimate <task> Operator --value <fib>` (the operator's business
value alone) or `estimate <task> Operator --drop`.
argv: the rasci.py arguments, one of `set <task>` with exactly one
of `--prio <1-999>`, `--unpin`, `--status <status>`,
`--after <other>` or `--drop-after <other>` (other a register
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:
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):
raise Refused("the argv is no list of texts")
rest = argv[3:]
pinned = argv[:3] == ["set", task, "--prio"] and len(argv) == 4 and bool(PRIO.fullmatch(argv[3]))
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))
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
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:
+2 -1
View File
@@ -94,4 +94,5 @@ class Jobs:
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
tail = "\n".join(output.splitlines()[-20:])
self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
return {"id": job_id, "status": status, "code": code, "runs": runs}
told = self.tell_applied(job) if job.get("kind") == TASK_JOB else []
return {"id": job_id, "status": status, "code": code, "runs": runs, "told": told}
+69 -9
View File
@@ -1,16 +1,21 @@
from __future__ import annotations
import secrets
import time
from .council import HUMAN, read_claims
from .denying import requester_of
from .errors import Conflict, Missing, ToolError
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 .signed import signing
from .tasks import editable
from .skill_scripts import RADIO
from .tasks import editable, movable
CHANGE_LIMIT = 100
CHANGE_KEYS = ("kind", "id", "argv")
TOLD = "Operator changed your tasks"
def task_request(raw: object) -> bool:
@@ -21,17 +26,41 @@ def shown_command(argv: list[str]) -> str:
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:
def task_changes(self, raw: object) -> list[dict]:
"""Args:
raw: the page's items, each {"kind": "task", "id", "argv"} with
the rasci.py arguments of one priority pin, WSJF estimate or
operator business value; a bulk change over a selection is
one item per task under one passkey assertion.
the rasci.py arguments of one priority pin, status,
dependency, WSJF estimate or operator business value; a
bulk change or a board batch is one item per change under
one passkey assertion.
Returns:
The signed items, each for an open register task outside
operator/ and hotfix/ whose argv signing.task_argv() accepts.
The signed items, each for a register task outside operator/
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:
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"])
if row is None:
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)})
item = signing.task_item(entry["id"], argv)
if item in signed:
@@ -56,6 +85,36 @@ class Tasking:
signed.append(item)
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:
payload = decode(body)
expect(payload, ("items", "proof"))
@@ -64,8 +123,9 @@ class Tasking:
for item in items:
self.operator_proven(proof, item)
ids = []
batch = secrets.token_hex(8)
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"])
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"}})
+28 -2
View File
@@ -28,9 +28,13 @@ def kind_of(task: str) -> str:
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
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:
@@ -131,6 +135,7 @@ def task_row(task: str, row: dict, trees: list[dict]) -> dict:
"kind": kind_of(task),
"ended": ended,
"editable": editable(task, row),
"movable": movable(task),
"prio": prio_of(row),
"estimates": estimates,
"wsjf": wsjf(estimates),
@@ -138,6 +143,7 @@ def task_row(task: str, row: dict, trees: list[dict]) -> dict:
"points": points(estimates),
"start": iso_epoch(row.get("start")),
"end": iso_epoch(row.get("end")) if ended else None,
"updated": iso_epoch(row.get("updated")),
"after": after_of(row),
"base": shown(row.get("base"))[:TEXT_SHOWN],
"roles": roles_of(row),
@@ -163,6 +169,7 @@ def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
"kind": "queue",
"ended": False,
"editable": False,
"movable": False,
"prio": None,
"estimates": {},
"wsjf": None,
@@ -170,6 +177,7 @@ def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
"points": None,
"start": (epoch(entry.get("granted")) or created) if entry.get("status") == "active" else None,
"end": None,
"updated": None,
"after": [],
"base": "-",
"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]]
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:
return row["branch"] if row["branch"] != "-" else row["worktree_branch"]
+4 -2
View File
@@ -38,7 +38,7 @@ from .reviewing import review_row
from .settings import media_origins, station_origins, valid_settings
from .signed import signing
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
JOBS_SHOWN = 20
@@ -108,6 +108,7 @@ class View:
queue = queue_entries(self.council)
order = stored_order(self.council / AGENT_ORDER)
rows = task_rows(tasks, queue, self.tree.get())
chain = queue_chain(queue, tasks, order, now)
return {
"settings": settings,
"media": media_origins(settings),
@@ -134,7 +135,8 @@ class View:
"operator": operator_rows(tasks),
"register": register_rows(tasks),
"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),
"radio": radio,
"claims": claims,
+6
View File
@@ -412,6 +412,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "style-tasks.css" %}
{% include "style-kanban.css" %}
{% include "style-bubbles.css" %}
</style>
</head>
@@ -462,6 +464,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "faces.js" %}
{% include "plan.js" %}
{% include "kanban.js" %}
</script>
<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 #}
@@ -536,6 +540,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-tasks-gantt.js" %}
{% include "page-kanban.js" %}
{% include "page-choices.js" %}
{% include "page-time.js" %}
+171
View File
@@ -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;
}
+226
View File
@@ -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);
}
+46 -13
View File
@@ -3,6 +3,7 @@
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 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 TREND_DAYS = [1, 365];
const TREND_REACH = 3;
@@ -108,10 +109,40 @@
return marks;
}
function trendNote(trend) {
const forecast = trend ? trend.forecast : null;
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": forecast === null ? "" : String(forecast)}, icon("bi-graph-up-arrow"), words);
function finishNotes(trend, finish) {
if (!trend) {
return h("span", {class: "burn-forecast", "data-forecast": ""}, icon("bi-graph-up-arrow"), "No forecast: too few points for a trend");
}
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) {
@@ -127,22 +158,22 @@
}
}
function burnSpan(picked, trend) {
function burnSpan(picked, trend, finish) {
const forecast = trend ? trend.forecast : null;
if (forecast === null) {
return {reach: picked.to, end: picked.to};
}
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)};
const latest = Math.max(picked.to, ...Object.values(finish).filter((at) => at !== null));
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)};
}
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 series = Plan.burn(picked.rows, {estimation: estimation(), from: picked.from, to: picked.to}).points;
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 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 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`});
@@ -157,10 +188,12 @@
}
if (trend) {
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));
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]}`)),
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}`));
}
+2 -23
View File
@@ -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_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_FILTERS = [
["all", "All", "bi-list-check", () => true],
@@ -18,7 +18,6 @@
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 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"));
@@ -80,26 +79,6 @@
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) {
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();
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,
gantt: ganttChart,
burn: (found) => burnChart(rows, found),
+30 -6
View File
@@ -9,6 +9,9 @@ const Plan = (() => {
const TRENDED = ["scope", "done", "remaining"];
const OUTSIDE = ["queue", "command"];
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) {
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
* the office's holder, over the whole time).
* @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) {
const own = (name) => (row) => row.worker === name || Object.hasOwn(row.roles, name);
const [kind, ...rest] = String(choice).split(":");
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 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") {
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));
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 reachable = (t) => (t !== null && Number.isFinite(t) && Math.abs(t) <= LAST_SECOND ? t : null);
function leastSquares(pairs) {
const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
@@ -233,11 +240,28 @@ const Plan = (() => {
}
const rest = lines.remaining;
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);
/**
* @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 = {
prio: () => null,
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
@@ -270,7 +294,7 @@ const Plan = (() => {
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) {
module.exports = Plan;
+38
View File
@@ -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); }
+5 -5
View File
@@ -116,7 +116,7 @@ PROBE = """(async () => {@HELPERS@
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"))]),
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("")],
flows: all("#main .flow").map((flow) => flow.dataset.flow),
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["taskRows"], [[task, task not in ("work/old", "operator/push")] for task in order])
self.assertEqual(listed["flows"], ["file", "file"])
self.assertEqual(by_hash["#tasks/board"]["columns"], [["queued", ["queue/gate-3"]], ["running", ["work/feature", "work/stage"]], ["blocked", ["work/docs"]], ["done", ["work/old", "operator/push"]], ["stopped", []]])
self.assertEqual(by_hash["#tasks/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([probe["taskFilters"][3][1] for probe in probes if probe["hash"] == "#stream/inbox"], ["true"] * len(WIDTHS))
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["rows"] == 1, fit["layout"]), (width != 390, ["toolbar", "chips", "body"]), fit)
if name == "board":
self.assertEqual(fit["columns"], ["queued", "running", "blocked", "done", "stopped"])
self.assertEqual(fit["columns"], ["queued", "running", "done"])
hub = self.httpd.hub
if not signs:
self.assertEqual((self.scripts.calls, hub.messages.listed()), ([], []))
@@ -2647,11 +2647,11 @@ class TestOrchestratorE2E(HubCase):
self.assertAlmostEqual(begin, start, delta=0.01)
self.assertAlmostEqual(end, forecast if forecast is not None else probe["points"][-1][0], delta=0.01)
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
self.assertAlmostEqual(probe["note"][0], 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:
self.trend_work(time.time())
+331
View File
@@ -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()
+382
View File
@@ -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()
+1 -1
View File
@@ -151,7 +151,7 @@ class TestHubState(HubCase):
set(state),
{
"now", "since", "until", "first", "alarm_since", "reviews", "asks", "decisions", "messages", "operator",
"register", "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",
"badges", "kinds",
},