feat(orchestrator): S5.1 radio spot, compact menus, trend, ETA and Tasks 3D
🧪 Test / 🧪 Lock + lint (push) Canceled after 0s

Requirement 001 decisions 52 to 58 (operator 2026-11-09):

- Radio spot: the Agents toolbar radio button expands inline with the one
  composer (every channel, post and call recipients, Record, Send), a gear
  with the radio settings (tones, squelch, read aloud, moved out of the
  general settings) and Cancel mayday, which leaves the toolbar; the Radio
  tab keeps speakers, stations and the log plus a jump to the spot.
- Day/Night menu: the badge and Drain, Suspend and Resume only; each
  explanation is the button's tooltip.
- Tasks toolbar: views, filter, estimation, sort and business value in one
  wrapping row with a Request choices dropdown; the status chips stay the
  second row.
- Burndown and burnup: a trend selector (off, linear regression, velocity
  over N days) stored in the hub settings, the trend lines and the forecast
  date on the chart, and a tooltip per point with the work packages that
  started, ended or joined there.
- ETA: every list row shows its ETA (end when done, else start or now plus
  the forecast length behind its predecessors), sharing one predecessor
  walk with the Gantt.
- Tasks 3D: a sixth view on the 3D engine with the dependency edges in the
  Gantt colours, layouts Force and Time.
- Steady 3D camera: the view fits once per graph build, refreshes keep the
  operator's camera.
- e2e: Firefox runs with its profile as cwd, so its crashreporter no longer
  drops core dumps into the repository.
- A forecast beyond the last second a Date can hold is no forecast, so a
  nearly flat regression no longer aborts the Tasks render.

Evidence: make test Ran 717 OK, ruff clean, markdownlint 0 errors; e2e 30 of
30 OK in Chromium and Firefox at 1920, 1366 and 390 px; MEDUSA 0 findings
after one skeptic-accepted lexical EXA006.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-10 08:10:51 +00:00
1 parent e94f2800d5
commit 57ab29eb0d
25 files changed
+1390 -215

No files matched your search

+1 -1
View File
@@ -414,7 +414,7 @@ The hub page loads nothing from the network: its icons come from Bootstrap Icons
The Agents view "Hierarchy" draws the council's formal offices as a collapsible org chart: Operator, chair, dimensions with their line representatives, organisers (`cluster/<slug>`) and presidents (`work/<slug>`); the chair in every graph is the holder of the `chair` office, not a fixed name. Appointing, handing over or recalling an office there asks for the passkey signature; the hub writes a job (office, signed holder, successor session, work; no command) into `~/.local/state/kvb-orchestrator/offices/jobs`, and the `kvb-orchestrator-offices.path` user unit runs `client/offices.py` on the host, which verifies the signature, refuses a council directory others can write, a link on the path or a holder that changed since the signature. For a takeover or recall it moves the office aside and removes it only while the claim there still names the signed holder; an appoint or takeover then runs a plain `council.py claim <office>` for the successor by argv. council.py itself has no flag that overrides a live holder. The same view holds the operator's agent order as a drag list with up and down buttons: `rasci.py agents order` stores it in `agent-order.json` beside the register, where it breaks ties of equal task priority and orders the chair's grants without touching any task's `--prio` pin.
The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "waiting for a grant", and the filter "Waits on Operator" is the inbox, which the Inbox entry under Stream opens: every pending review, ask, rule request, operator row and choice card, with no Choices column of its own. "Request choices" has one button per channel, post and call to the agents ticked under "Agents", newsletter, broadcast, pan-pan, Sécurité and mayday to all, that sends the fixed request "send me your open decisions as choices" without a text field, one passkey assertion over every recipient (`/api/choices/sign`, then `/api/choices`). Agents answer with `decision_request`, shown as choice cards, and a click on an option is the passkey-signed answer: after the 3 s undo the hub verifies the signature and posts the answer to the asking agent, whose `decision_wait` returns it as `message` and `answer` for `operator_message`. A task's sheet pins its priority (`rasci.py set <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar.
The Tasks tab (key 4, `#tasks/list`, `#tasks/board`, `#tasks/rasci`) shows the `kvb-org-rasci` register as a list, a board by status and a RASCI matrix; queue jobs from `council/queue.json` appear as "waiting for a grant", and the filter "Waits on Operator" is the inbox, which the Inbox entry under Stream opens: every pending review, ask, rule request, operator row and choice card, with no Choices column of its own. The Tasks toolbar is one row that wraps on narrow screens (views, filter, estimation, sort and business value, trend on the burn view), the status chips the second; every list row shows its ETA, and `#tasks/3d` draws the tasks and their dependencies on the 3D engine. Its "Request choices" dropdown has one button per channel, post and call to the agents ticked in it, newsletter, broadcast, pan-pan, Sécurité and mayday to all, that sends the fixed request "send me your open decisions as choices" without a text field, one passkey assertion over every recipient (`/api/choices/sign`, then `/api/choices`). Agents answer with `decision_request`, shown as choice cards, and a click on an option is the passkey-signed answer: after the 3 s undo the hub verifies the signature and posts the answer to the asking agent, whose `decision_wait` returns it as `message` and `answer` for `operator_message`. A task's sheet pins its priority (`rasci.py set <task> --prio <n>`) or sets and drops the operator's WSJF estimate (`rasci.py estimate <task> Operator --value <fib> --complexity <fib>` or `--drop`) after a passkey signature; the hub writes an argv-only job, and the runner, started with `--rasci <rasci.py> --council <council root>`, verifies it like an operator command and runs `rasci.py` from `/` without a shell, killing it after 30 s. Agent order stays untouched. The install copies the fresh runner unit and restarts the runner when it is enabled, and only while the installed `council.py`, `nofollow.py` and `rasci.py` match this tree: otherwise it disables the runner and asks for `make install`. The runner unit pins `PATH` to `/usr/bin:/bin`, so a signed command names any other program by its full path; a runner started without `--rasci` and `--council` keeps running signed commands and refuses task changes, and so does one whose council is a symlink or another uid's. Each worktree in the tree file may carry `"flow": {"pull": [...], "push": [...], "source": "file"}` or `null`, which the Tree, the Chord edges, the detail panel and the matching task show; a malformed flow shows as `invalid`. Worktrees and tasks with open work (uncommitted, unpushed, unmerged, task not done) carry a warning bar.
Without reinstalling the skills, `make orchestrator-install` installs, builds and starts only the hub, `make orchestrator-check` checks the installed hub (image, container, logs, HTTP, pinned keys, hook, runner, tree timer, disk; exit 1 on a failure), `make orchestrator-pin CREDENTIAL=<blob>` pins the passkey that "Register passkey" shows, `make orchestrator-runner` enables the runner service, and `make orchestrator-hook` registers the PermissionRequest hook in `~/.claude/settings.json`, replacing an earlier Agent Orchestrator entry and keeping the file mode, after a copy to `settings.json.before-kvb-orchestrator-hook` (Claude Code reads `~/.claude/settings.local.json` only in sessions started in the home directory). Agents that reach the Docker socket are root-equivalent: until the agent VMs of core requirement 093 isolate them, the signatures do not hold against such an agent. `make watch-check` shows whether the resource watch hook is stuck (running hooks, holders of the kept queue history, age of the stored stage).
@@ -270,6 +270,39 @@ from three views down to any agent, branch or artefact.
51. **Subtree**: decisions 44 to 50 join subtree S5, Tasks with asks and
choices, in its one worktree. Rejected: a subtree of their own after S8
or right after S1.
52. **Radio spot** (operator 2026-11-09, decisions 52 to 58): the radio
button of the Agents toolbar is the one place to send. It expands inline
and shows the full composer below the toolbar (every channel, the
recipients for post and call, Record, Send), a gear button with every
radio setting (tones per class, squelch, read aloud with its classes and
speakers, moved out of the general settings) and Cancel mayday, which
leaves the toolbar. The Radio tab keeps the stations and the message log
without a composer. Rejected: the Radio tab as the spot, removing the
Radio tab.
53. **Day/Night menu**: only the status badge and the Drain, Suspend and
Resume buttons; each button's explanation is its hover tooltip.
54. **Tasks toolbar**: views, filter, estimation toggle, sort and business
value in one row that wraps on narrow screens; the request choices
become one "Request choices" dropdown in that row; the status chips stay
the second and last row. Rejected: moving the request choices into the
radio spot.
55. **Trend and forecast**: burndown and burnup get a trend selector, `off`
(default), `linear regression` (least squares over the points in the
shown window) and `velocity` (the mean burn rate of the last N days, N
set freely, default 7), stored in the hub settings. The trend is drawn
until remaining reaches 0 (burndown) or done meets scope (burnup); that
date is the forecast, shown on the chart. Hovering a point lists the
work packages that started, ended or joined the scope at that step.
56. **ETA**: every work package in the list carries an ETA: its end when
done, else its start (or now) plus the forecast length of decision 45
for the active estimation mode, behind the latest ETA of its
predecessors.
57. **Tasks 3D**: a sixth Tasks view on the 3D engine of the Agents tab:
tasks as nodes, the dependency edges of decision 44 in the Gantt colors,
layouts Force and Time, the same tooltips and popups. Rejected: a fixed
time-agent-depth layout.
58. **Steady 3D camera**: the 3D view fits the graph once per build; state
refreshes keep the operator's camera.
## Approach
@@ -682,6 +715,32 @@ flowchart TD
operator value instead of the median.
- [x] The task list sorts by each mode of decision 50 in both directions.
### Phase 6: radio spot, compact menus, trend, ETA, Tasks 3D (S5.1)
- [x] The toolbar radio button expands inline and sends a post, a call to
chosen agents and each to-all class, Record included; the Radio tab
shows no composer.
- [x] The radio gear holds the tones, squelch and read-aloud settings, the
general settings no longer do, and a changed value survives a reload.
- [x] Cancel mayday sits in the expanded radio spot and no longer in the
toolbar; it still needs its confirmation.
- [x] The Day/Night menu shows only the badge and three buttons; hovering
each shows its explanation.
- [x] The Tasks toolbar is one row at 1920 and 1366 px; "Request choices" is
a dropdown sending the same signed choice requests as before.
- [x] The trend selector offers off (default), linear regression and
velocity over N days; the line and the forecast date match the
fixture's computed values, and the choice survives a reload.
- [x] Hovering a burn chart point lists exactly the fixture work packages of
that step.
- [x] Every list row shows an ETA, which equals the end for done tasks and
start or now plus the forecast length otherwise, in both estimation
modes.
- [x] The Tasks 3D view draws one node per task and one edge per dependency
of the fixture, in Force and Time layouts.
- [x] Two state refreshes after a manual camera move leave the 3D camera
where the operator put it.
### Tests
- **Unit**
@@ -703,6 +762,8 @@ flowchart TD
- [x] S5 Gantt and estimates: dependency edges and cycle refusal, forecast lengths, burn
sums per mode and scope, newsletter body and its single send, bulk
business value signature (forged, replayed, altered), sort orders.
- [x] S5.1: regression and velocity trend with their forecast dates, point
tooltips, ETA in both modes, radio settings stored from the gear.
- **Integration**
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
entry points (JSON on stdin): the post appears only on the callee's
@@ -728,3 +789,6 @@ flowchart TD
- [x] S5: Gantt with arrows, burndown and burnup, the estimation toggle,
"Request estimates" and a signed bulk business value change, each at
1920, 1366 and 390 px.
- [x] S5.1: radio spot (send, gear, cancel mayday), Day/Night tooltips,
one-row Tasks toolbar, trend and point tooltips, ETA column, Tasks
3D and a steady 3D camera, each at 1920, 1366 and 390 px.
+14
View File
@@ -15,6 +15,8 @@ from .records import read_json, write_atomic
THEMES = ("sky", "light", "dark", "custom")
ESTIMATIONS = ("none", "points")
TRENDS = ("off", "regression", "velocity")
TREND_DAYS = (1, 365)
TONES = ("mayday", "review", "direct", "radio")
COLOR = re.compile(r"#[0-9a-f]{6}")
CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]")
@@ -46,6 +48,7 @@ SETTINGS = {
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
},
"estimation": "none",
"trend": {"mode": "off", "days": 7},
}
SPACE_AXES = ("none", "time", "spawn")
SPACE_LIMITS = {
@@ -182,6 +185,16 @@ def space_setting(raw: object) -> dict:
return {"graph": {"axis": graph["axis"], **numbers["graph"]}, "sky": numbers["sky"]}
def trend_setting(raw: object) -> dict:
entry = section(raw, "trend", tuple(SETTINGS["trend"]))
low, high = TREND_DAYS
if not isinstance(entry["mode"], str) or entry["mode"] not in TRENDS:
raise ToolError(f"trend.mode must be one of {', '.join(TRENDS)}")
if isinstance(entry["days"], bool) or not isinstance(entry["days"], int) or not low <= entry["days"] <= high:
raise ToolError(f"trend.days must be a whole number from {low} to {high}")
return {"mode": entry["mode"], "days": entry["days"]}
def valid_settings(raw: object) -> dict:
data = section(raw, "settings", tuple(SETTINGS))
colors = section(data["colors"], "colors", tuple(SETTINGS["colors"]))
@@ -206,6 +219,7 @@ def valid_settings(raw: object) -> dict:
"speech": speech_setting(data["speech"]),
"space": space_setting(data["space"]),
"estimation": data["estimation"],
"trend": trend_setting(data["trend"]),
}
-1
View File
@@ -68,7 +68,6 @@ h1 { margin: 0; font-size: var(--fs-lg); font-weight: 680; letter-spacing: -.01e
.menu > summary::-webkit-details-marker { display: none; }
.menu-box { position: absolute; top: calc(100% + 6px); z-index: 25; display: grid; gap: var(--sp-3); width: max-content; max-width: min(640px, calc(100vw - 32px)); padding: var(--sp-3) var(--sp-4); background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); }
.menu-box.end { right: 0; }
.menu-box textarea { min-width: min(460px, calc(100vw - 64px)); }
.menu-box h3 { margin: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); }
.classes { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); }
+1 -1
View File
@@ -3,7 +3,7 @@ const Nav = (() => {
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"]];
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]];
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
const ICONS = {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
+57 -32
View File
@@ -11,8 +11,10 @@
return result.ok && result.data && result.data.ok;
}
const actionOf = (action) => ACTIONS.find(([id]) => id === action);
async function confirmAct(action) {
const [, label, question] = ACTIONS.find(([id]) => id === action);
const [, label, question] = actionOf(action);
if (!window.confirm(question)) {
return;
}
@@ -29,52 +31,75 @@
const picked = (group) => group.querySelector("input:checked").value;
function toAll() {
const text = h("textarea", {"aria-label": "Radio to all", placeholder: "Message to all agents …", on: {input: (event) => {
ui.draft = event.target.value;
}}});
text.value = ui.draft;
const classes = choice("to-all", TO_ALL, "newsletter");
const send = async () => {
const cls = picked(classes);
if (!ui.draft.trim() || (cls === "mayday" && !confirmMayday())) {
return;
}
if (await radioSend({to: "*", text: ui.draft, upload: "", cls, voice: ""})) {
ui.draft = "";
text.value = "";
}
};
return h("details", {class: "menu", id: "to-all"}, h("summary", named("Radio to all"), icon("bi-broadcast-pin"), caret()), h("div", {class: "menu-box"},
text, classes, h("p", {class: "muted"}, "Newsletter arrives as post at the end of the turn; Broadcast, Pan-Pan, Security and Mayday at once. Your passkey signs every message."),
h("div", {class: "actions"}, h("span", {class: "spacer"}), iconBtn("bi-send", "Send", "btn soft", send, "Send to all"))));
function actionButton(action, cls, name) {
const [, label, , hint] = actionOf(action);
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label);
}
function openToAll() {
function openSpot(spot, open) {
ui.spot = open;
spot.panel.hidden = !open;
spot.toggle.setAttribute("aria-expanded", String(open));
window.requestAnimationFrame(refresh3d);
if (open) {
spot.text.focus();
}
}
function toggleGear(spot) {
const open = spot.gearBox.hidden;
spot.gearBox.replaceChildren(...(open ? [radioSettings(() => toggleGear(spot))] : []));
spot.gearBox.hidden = !open;
spot.gear.setAttribute("aria-expanded", String(open));
window.requestAnimationFrame(refresh3d);
}
function radioSpot() {
const {form, recipient, text} = composerForm();
const gearBox = h("div", {class: "radio-gear", id: "radio-gear", hidden: true});
const gear = h("button", {type: "button", class: "btn small ghost icon-only spot-toggle", "aria-expanded": "false", "aria-controls": "radio-gear", ...named("Radio settings", "Radio settings: tones, squelch and read aloud")}, icon("bi-gear"));
const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver");
const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot},
h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, gearBox));
const toggle = h("button", {type: "button", class: "btn small spot-toggle", "aria-expanded": String(ui.spot), "aria-controls": "radio-spot", ...named("Radio", "Radio: post, call or a message to all, with Record and the radio settings")}, icon("bi-broadcast-pin"), caret());
const spot = {panel, toggle, gear, gearBox, text};
toggle.addEventListener("click", () => openSpot(spot, !ui.spot));
gear.addEventListener("click", () => toggleGear(spot));
return {toggle, panel, clear, recipient};
}
function openRadio() {
ui.spot = true;
go("agents", null);
window.setTimeout(() => {
const menu = document.getElementById("to-all");
if (menu) {
menu.open = true;
menu.querySelector("textarea").focus();
const field = document.querySelector("#radio-spot textarea");
if (field) {
field.focus();
}
}, 50);
}
function agentsToolbar() {
const clear = iconBtn("bi-life-preserver", "Cancel mayday", "btn small danger", () => confirmAct("clear-mayday"));
const spot = radioSpot();
const mode = h("span", {});
const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
h("p", {class: "muted"}, "Drain: agents finish their task and take nothing new. Suspend: they park their work and idle. Resume: all continue from the handover. Your passkey signs each."),
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, toAll(), clear, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night), clear, mode};
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small")))));
const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, spot.toggle, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night);
return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""};
}
function drawToolbar() {
if (!graphDom || !graphDom.toolbar) {
const toolbar = graphDom && graphDom.toolbar;
if (!toolbar) {
return;
}
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
graphDom.toolbar.clear.disabled = !state.silence;
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
toolbar.clear.disabled = !state.silence;
toolbar.mode.replaceChildren(pill(mode, tone));
const names = agentNames();
if (JSON.stringify(names) !== toolbar.names) {
toolbar.names = JSON.stringify(names);
const chosen = toolbar.recipient.value;
toolbar.recipient.replaceChildren(...names.map((name) => h("option", {value: name, selected: name === chosen}, name)));
}
}
+6 -5
View File
@@ -21,18 +21,19 @@
await load();
}
function choiceBar() {
function choiceMenu() {
const picks = h("div", {class: "choice-agents", role: "group", "aria-label": "Post or call to"});
const count = counter(0);
const menu = h("details", {class: "menu"}, h("summary", named("Agents", "Agents: who gets a post or a call"), icon("bi-people"), count, caret()),
h("div", {class: "menu-box"}, h("h3", {}, "Post or call to"), picks));
const button = ([id, label]) => h("button", {type: "button", class: "btn small", "data-channel": id, ...named(label, `${label}: ask for every open decision as a choice, signed with your passkey. ${CHANNEL_HINTS[id]}`), on: {click: () => requestChoices(id)}},
icon(CHOICE_ICONS[id]), label);
const direct = TO_ONE.map(button);
choiceAsk.dom = {picks, count, direct};
choiceAsk.names = "";
return h("div", {class: "card controls choice-request", role: "group", "aria-label": "Request choices"},
h("span", {class: "group-title"}, "Request choices"), menu, direct, TO_ALL.map(button));
return h("details", {class: "menu choice-request", id: "choice-request"},
h("summary", named("Request choices", "Request choices: every open decision comes back as a choice, signed with your passkey"), icon("bi-ui-checks"), "Request choices", caret()),
h("div", {class: "menu-box end", role: "group", "aria-label": "Choice requests"},
h("h3", {}, "Post or call to", count), picks, h("div", {class: "choice-buttons"}, direct),
h("h3", {}, "To all"), h("div", {class: "choice-buttons"}, TO_ALL.map(button))));
}
function syncChoices() {
+6 -2
View File
@@ -136,7 +136,10 @@
const channel = h("select", {class: "field"}, CHANNELS.map(([id, label]) => h("option", {value: id}, label)));
const recipient = h("select", {class: "field"});
const toField = h("label", {}, "To", recipient);
const text = h("textarea", {"aria-label": "Message", placeholder: "Message to the agents …", maxlength: 20000});
const text = h("textarea", {"aria-label": "Message", placeholder: "Message to the agents …", maxlength: 20000, on: {input: (event) => {
ui.draft = event.target.value;
}}});
text.value = ui.draft;
const replay = h("div", {class: "voice-note", hidden: true});
const hint = h("p", {class: "muted"});
const status = h("span", {class: "muted composer-status", "aria-live": "polite"});
@@ -173,11 +176,12 @@
submit.disabled = true;
if (await radioSend({to: direct ? recipient.value : "*", text: text.value, upload: "", cls: channel.value, voice: attach.checked ? composer.voice : ""})) {
text.value = "";
ui.draft = "";
status.textContent = "";
attachVoice(parts, "");
}
submit.disabled = false;
});
attachVoice(parts, composer.voice);
return {form, recipient};
return {form, recipient, text};
}
+6 -1
View File
@@ -10,7 +10,12 @@
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Choice", operator: "Operator", rule: "Rule request"};
const NIGHT = {day: ["Day", "ok"], drain: ["Draining", "warn"], suspend: ["Suspended", "violet"]};
const ACTIONS = [["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?"], ["drain", "Drain", "Drain all agents: finish the current task, accept nothing new (kvb-agent-drain)?"], ["suspend", "Suspend", "Suspend all agents: park work, write the handover, idle (kvb-agent-suspend)?"], ["resume", "Resume", "Resume all agents from their handover (kvb-agent-resume)?"]];
const ACTIONS = [
["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?", "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it"],
["drain", "Drain", "Drain all agents: finish the current task, accept nothing new (kvb-agent-drain)?", "Drain: the agents finish their task and take nothing new; your passkey signs it"],
["suspend", "Suspend", "Suspend all agents: park work, write the handover, idle (kvb-agent-suspend)?", "Suspend: the agents park their work, write the handover and idle; your passkey signs it"],
["resume", "Resume", "Resume all agents from their handover (kvb-agent-resume)?", "Resume: all agents continue from their handover; your passkey signs it"],
];
const UPLOAD_LIMIT = 50 * 1024 * 1024;
const STREAM_KEEP = 5000;
const CLASSES = {post: "Post", call: "Call", newsletter: "Newsletter", broadcast: "Broadcast", mayday: "Mayday", "pan-pan": "Pan-Pan", security: "Sécurité", receipt: "Read", grant: "Grant"};
+1 -1
View File
@@ -185,5 +185,5 @@
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, pulses: null, toolbar};
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend());
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
return h("div", {class: "graph"}, toolbar.bar, toolbar.spot, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
}
+37 -15
View File
@@ -4,13 +4,29 @@
["spawn", "Spawn depth", "bi-layers", "Depth = spawn depth: each level one generation"],
];
const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"};
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"};
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant", after: "violet", base: "info", queue: "warn"};
const TONE_ROLES = {ok: "ok", bad: "bad", info: "info", warn: "warn", neutral: "muted"};
const DEPENDENCIES = new Set(["after", "base", "queue"]);
const SPAWNED = new Set(["twig", "subagent"]);
const DEPTH_GAP = 90;
const TIME_DEPTH = 640;
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null};
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null, fit: false};
const layoutOf = () => space.views.graph.axis;
const layoutsOf = (tab) => (tab === "tasks" ? LAYOUTS.filter(([id]) => id !== "spawn") : LAYOUTS);
const layoutOf = (tab) => (layoutsOf(tab).some(([id]) => id === space.views.graph.axis) ? space.views.graph.axis : "none");
function fit3d(ms) {
if (g3.graph) {
g3.graph.zoomToFit(ms, 40);
}
}
function builtFit() {
if (g3.fit) {
g3.fit = false;
fit3d(calm.matches ? 0 : 400);
}
}
function loadScript(src) {
return new Promise((resolve, reject) => {
@@ -39,11 +55,11 @@
}
function layoutTools(tab) {
const group = h("div", {class: "segmented", role: "group", "aria-label": "Layout"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${hint}`), "aria-pressed": String(layoutOf() === id), on: {click: () => chooseLayout(tab, id, group)},
const group = h("div", {class: "segmented", role: "group", "aria-label": "Layout"}, layoutsOf(tab).map(([id, label, name, hint]) => h("button", {
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${tab === "tasks" && id === "time" ? "depth = start: earlier lies behind, later in front, forecasts behind their predecessors" : hint}`), "aria-pressed": String(layoutOf(tab) === id), on: {click: () => chooseLayout(tab, id, group)},
}, icon(name))));
return h("div", {class: "space-tools"}, group,
iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => fit3d(calm.matches ? 0 : 600)),
h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the popup"),
nodeSearch(() => model3d(tab).nodes, (node) => pick3d(tab, node)));
}
@@ -52,9 +68,10 @@
for (const button of group.querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button.dataset.layout === id));
}
if (id !== layoutOf()) {
if (id !== layoutOf(tab)) {
setView("graph", {...space.views.graph, axis: id}, false);
g3.sig = "";
g3.fit = true;
if (ui.screen === tab) {
stage3d(tab, g3.host);
}
@@ -87,6 +104,9 @@
if (tab === "agents") {
return agentModel();
}
if (tab === "tasks") {
return taskModel3d();
}
const built = tree.model || {nodes: [], links: []};
return {nodes: built.nodes.map((node) => ({id: node.id, kind: node.kind, label: node.label, depth: node.depth, ts: node.ts, parent: node.parent})), links: built.links.map((link) => ({...link}))};
}
@@ -109,7 +129,7 @@
function colors3d() {
const css = window.getComputedStyle(document.documentElement);
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text", "alarm"]);
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), ...Object.values(TONE_ROLES), "text", "alarm"]);
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
return g3.colors;
}
@@ -146,7 +166,7 @@
focusSub(node.session, node.sub);
return;
}
(tab === "artifacts" ? artSelect : select)(node.id, anchor3d(node.id), () => mark3d(null));
({artifacts: artSelect, tasks: taskSelect}[tab] || select)(node.id, anchor3d(node.id), () => mark3d(null));
mark3d(node.id);
}
@@ -176,7 +196,7 @@
}
function signature3d(tab, data) {
return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts]), data.links.map((link) => [link.source, link.target, link.kind])]);
return JSON.stringify([tab, layoutOf(tab), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts, node.tone]), data.links.map((link) => [link.source, link.target, link.kind])]);
}
async function stage3d(tab, host) {
@@ -195,7 +215,7 @@
dispose3d();
g3.host = host;
g3.tab = tab;
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the search above finds every node`}), h("div", {class: "space-note"}, "Loading 3D …"));
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab}; the search above finds every node`}), h("div", {class: "space-note"}, "Loading 3D …"));
}
g3.sig = sig;
const box = host.querySelector(".graph3d");
@@ -223,9 +243,11 @@
const colors = colors3d();
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1) * (node.id === g3.picked ? 4 : 1))
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[node.tone || KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
.linkDirectionalArrowLength((link) => (DEPENDENCIES.has(link.kind) ? 5 : 0)).linkDirectionalArrowRelPos(1)
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(builtFit);
g3.fit = true;
g3.graph.warmupTicks(calm.matches ? 160 : 80);
if (calm.matches) {
g3.graph.cooldownTicks(0);
@@ -233,13 +255,13 @@
g3.graph.cooldownTime(4000);
}
}
const layout = layoutOf();
const layout = layoutOf(tab);
place(data.nodes, layout);
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
drawPlanes(layout, data.nodes);
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
host.replaceChildren(box, h("div", {class: "space-note"}, words));
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
window.requestAnimationFrame(() => g3.host === host && g3.fit && fit3d(0));
}
function dispose3d() {
+2 -2
View File
@@ -5,11 +5,11 @@
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Category", run: () => useView(id)})),
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "saved view", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: name, hint: "Agent", face: name, run: () => openAgent(name)})),
{label: "Radio to all …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
{label: "Radio …", hint: "Post, Call, Newsletter, Broadcast, Pan-Pan, Security, Mayday; the gear holds tones and read aloud", run: openRadio},
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Day/Night", run: () => confirmAct(id)})),
{label: "Priority …", hint: "agent order in the hierarchy", run: openAgentOrder},
{label: "Firewall …", hint: "automatic grants", run: () => go("rules", null)},
{label: "Settings …", hint: "Ambience, location, colors, music, tones, read radio aloud, internet radio", run: settingsSheet},
{label: "Settings …", hint: "Ambience, location, colors, music, notifications, internet radio", run: settingsSheet},
{label: settings.music.on ? "Music off" : "Music on", hint: "Ambience", run: toggleMusic},
{label: space.look ? "Leave the sky" : "Look at the sky", hint: "h", run: () => skyLook(!space.look)},
{label: "Toggle control room", hint: "Ambience", run: toggleLeitstand},
+3 -9
View File
@@ -101,12 +101,6 @@
radioDom.speakers.replaceChildren(...speakerChips());
drawSpeaker();
}
const names = agentNames();
if (JSON.stringify(names) !== radioDom.names) {
radioDom.names = JSON.stringify(names);
const chosen = radioDom.recipient.value;
radioDom.recipient.replaceChildren(...names.map((name) => h("option", {value: name, selected: name === chosen}, name)));
}
const sent = JSON.stringify(state.messages.slice(-5));
if (sent !== radioDom.sent) {
radioDom.sent = sent;
@@ -120,12 +114,12 @@
const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true});
const stations = h("ul", {class: "stations", "aria-label": "Stations"});
const outbox = h("div", {});
const {form, recipient} = composerForm();
radioDom = {speakers, caption, stations, outbox, recipient, crew: "", names: "", sent: "", stationsSig: ""};
const send = h("button", {type: "button", class: "btn small soft", ...named("Send …", "Send: the radio spot under the Agents toolbar"), on: {click: openRadio}}, icon("bi-broadcast-pin"), "Send …");
radioDom = {speakers, caption, stations, outbox, crew: "", sent: "", stationsSig: ""};
return h("div", {class: "stack radio-view"},
h("h2", {class: "title"}, "Radio"),
h("div", {class: "radio-grid"},
h("section", {class: "card radio-talk", "aria-label": "Radio"}, h("h3", {class: "group-title"}, "Radio"), caption, speakers, form),
h("section", {class: "card radio-talk", "aria-label": "Radio"}, h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Radio"), send), caption, speakers),
h("div", {class: "stack"},
h("section", {class: "card", "aria-label": "Internet radio"},
h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internet radio"), btn("Edit stations", "btn small ghost", settingsSheet)),
+54 -30
View File
@@ -85,6 +85,54 @@
return section;
}
function radioSettings(close) {
const tones = SOUND_KINDS.map(([id, label]) => {
const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} on`});
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} volume`});
const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), settings.music.volume);
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "on"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
});
const squelch = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.squelch, "aria-label": "Squelch"});
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY is always read aloud" : null})}));
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `Read ${entry.now} aloud`})}));
const absent = settings.speech.muted.filter((key) => !people()[key]);
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter", who: HUMAN, line: "radio check"}));
sample.disabled = !state.speech.tts;
const form = h("form", {class: "settings radio-settings", "aria-label": "Radio settings"},
h("section", {}, h("h3", {}, "Tones"), h("p", {class: "muted"}, "MAYDAY and PAN-PAN are always loudest. With several new events only the most urgent one plays."),
tones.map((tone) => tone.row), h("label", {}, "Squelch: scales every tone, 0 plays none", squelch)),
h("section", {}, h("h3", {}, "Read radio aloud"),
h("p", {class: "muted"}, state.speech.tts
? "New radio is read aloud in maritime radio style (SMCP), one message after another, each speaker with its own voice; music and internet radio go quieter meanwhile. The volume follows the tones above; MAYDAY is always spoken, and loudest."
: `The radio stays silent: ${state.speech.tts_off}.`),
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Read radio aloud"), sample),
h("div", {class: "days", role: "group", "aria-label": "Classes"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Speakers"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Cancel", "btn ghost", close), h("button", {type: "submit", class: "btn primary"}, "Save")));
form.addEventListener("submit", async (event) => {
event.preventDefault();
context();
const next = {
...settings,
space: savedSpace(),
sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])),
squelch: Number(squelch.value),
speech: {
on: speechOn.checked,
classes: spoken.filter((entry) => entry.box.checked).map((entry) => entry.id),
muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)],
},
};
if (await saveSettings(next)) {
close();
toast("Radio settings saved", [], true);
}
});
return form;
}
function settingsSheet() {
const number = (value, min, max, label) => h("input", {class: "field", type: "number", min, max, step: "any", value, required: true, "aria-label": label});
const latitude = number(settings.latitude, -90, 90, "Latitude");
@@ -105,21 +153,7 @@
judge();
const musicOn = h("input", {type: "checkbox", checked: settings.music.on});
const volume = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.music.volume, "aria-label": "Overall volume"});
const tones = SOUND_KINDS.map(([id, label]) => {
const on = h("input", {type: "checkbox", checked: settings.sounds[id].on, "aria-label": `${label} on`});
const level = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.sounds[id].volume, "aria-label": `${label} volume`});
const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value));
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "on"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
});
const squelch = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.squelch, "aria-label": "Squelch"});
const notify = h("input", {type: "checkbox", checked: settings.notify});
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY is always read aloud" : null})}));
const stations = Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now))
.map(([key, entry]) => ({key, label: entry.now, box: h("input", {type: "checkbox", checked: !settings.speech.muted.includes(key), "aria-label": `Read ${entry.now} aloud`})}));
const absent = settings.speech.muted.filter((key) => !people()[key]);
const sample = btn("Test", "btn small ghost", () => say({text: state.speech.sample, voice: state.speech.operator, tone: "direct", cls: "newsletter", who: HUMAN, line: "radio check"}));
sample.disabled = !state.speech.tts;
const list = h("div", {class: "mini"});
const rows = [];
settings.streams.forEach((stream) => streamRow(stream, rows, list));
@@ -130,15 +164,8 @@
h("section", {}, h("h3", {}, "Appearance"), h("label", {}, "Colors", theme), custom, verdict),
h("section", {}, h("h3", {}, "Music"), h("p", {class: "muted"}, "Generative ambient music, made offline: calm with an empty inbox, a little tenser when something waits, quieter under messages."),
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Music on"), h("label", {}, "Overall volume (music and radio)", volume))),
h("section", {}, h("h3", {}, "Tones and notifications"), h("p", {class: "muted"}, "MAYDAY and PAN-PAN are always loudest. With several new events only the most urgent one plays."),
tones.map((tone) => tone.row), h("label", {}, "Squelch: scales every tone, 0 plays none", squelch), h("label", {class: "check"}, notify, "Show desktop notifications when the browser allows them")),
h("section", {}, h("h3", {}, "Read radio aloud"),
h("p", {class: "muted"}, state.speech.tts
? "New radio is read aloud in maritime radio style (SMCP), one message after another, each speaker with its own voice; music and internet radio go quieter meanwhile. The volume follows the tones above; MAYDAY is always spoken, and loudest."
: `The radio stays silent: ${state.speech.tts_off}.`),
h("div", {class: "days"}, h("label", {class: "check"}, speechOn, "Read radio aloud"), sample),
h("div", {class: "days", role: "group", "aria-label": "Classes"}, spoken.map((entry) => h("label", {class: "check"}, entry.box, entry.label))),
stations.length ? h("div", {class: "days", role: "group", "aria-label": "Speakers"}, stations.map((entry) => h("label", {class: "check"}, entry.box, entry.label))) : null),
h("section", {}, h("h3", {}, "Notifications"), h("p", {class: "muted"}, "Tones, squelch and reading radio aloud sit behind the gear of the radio spot in the Agents toolbar."),
h("label", {class: "check"}, notify, "Show desktop notifications when the browser allows them")),
h("section", {}, h("h3", {}, "Internet radio"), h("p", {class: "muted"}, "Internet radio at fixed times with a 3 s fade in and out; the music pauses meanwhile. A new station plays around the clock until you change its times; under Stream → Radio you start and stop it by hand. The page may load sound only from these addresses; your passkey signs a new address. If a signed station redirects, the server resolves the redirect when you save a new station or a changed address and releases its target; the note after saving names it."),
list, h("div", {}, btn("+ Station", "btn small soft", () => streamRow({name: "", url: "", from: "00:00", to: "24:00", days: [0, 1, 2, 3, 4, 5, 6], on: true, via: []}, rows, list)))),
passkeySection(),
@@ -152,17 +179,14 @@
theme: theme.value,
colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])),
music: {on: musicOn.checked, volume: Number(volume.value)},
sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])),
squelch: Number(squelch.value),
sounds: settings.sounds,
squelch: settings.squelch,
notify: notify.checked,
streams: rows.map((entry) => entry.read()),
speech: {
on: speechOn.checked,
classes: spoken.filter((entry) => entry.box.checked).map((entry) => entry.id),
muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)],
},
speech: settings.speech,
space: savedSpace(),
estimation: settings.estimation,
trend: settings.trend,
};
if (await saveSettings(next)) {
closeSheet();
+1 -1
View File
@@ -27,7 +27,7 @@
let state = JSON.parse(document.getElementById("state").textContent);
const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", spot: false, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
+107 -14
View File
@@ -2,6 +2,10 @@
const BURN = {width: 900, height: 340, left: 52, right: 24, top: 20, bottom: 40};
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 ESTIMATION_ICONS = {none: "bi-1-circle", points: "bi-suit-diamond"};
const TREND_DAYS = [1, 365];
const TREND_REACH = 3;
const LINE_OFFICE = /^(?!work\/|cluster\/)[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
const estimation = () => settings.estimation;
const cut = (text, size) => (text.length > size ? `${text.slice(0, size - 1)}…` : text);
@@ -88,20 +92,75 @@
return {x, y};
}
function burnPoints(series, changes, names, x, y) {
const marks = s("g", {class: "burn-points"});
series.forEach((point, index) => {
const change = changes[index];
const listed = STEP_WORDS.filter(([key]) => change[key].length).map(([key, label]) => `${label}: ${change[key].join(", ")}`);
const words = listed.length ? listed.join("\n") : "No work package started, ended or joined the scope here";
for (const name of names) {
marks.append(s("circle", {
class: `burn-point k-${name}`, cx: fixed(x(point.t)), cy: fixed(y(point[name])), r: 3.5, "data-step": index, "data-series": name, "data-t": point.t, "data-value": point[name],
...Object.fromEntries(STEP_WORDS.map(([key]) => [`data-${key}`, change[key].join(" ")])),
}, s("title", {}, `${absolute.format(stampOf(point.t))} · ${SERIES_WORDS[name]}: ${point[name]}\n${words}`)));
}
});
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 burnTrend(svg, trend, end, x, y) {
for (const name of Plan.TRENDED) {
const line = trend.lines[name];
svg.append(s("path", {class: `burn-trend k-${name}`, "data-series": name, "data-from": trend.start, "data-to": end, d: `M${fixed(x(trend.start))},${fixed(y(Plan.lineAt(line, trend.start)))} L${fixed(x(end))},${fixed(y(Plan.lineAt(line, end)))}`},
s("title", {}, `${SERIES_WORDS[name]} trend`)));
}
if (trend.forecast !== null) {
const at = fixed(x(end));
svg.append(s("line", {class: "forecast-line", x1: at, x2: at, y1: BURN.top, y2: BURN.height - BURN.bottom, "data-forecast": trend.forecast}));
svg.append(s("text", {class: "forecast-label", x: at, y: BURN.top + 10, dx: -4, "text-anchor": "end"}, `Forecast ${absolute.format(stampOf(trend.forecast))}${end < trend.forecast ? " (beyond the chart)" : ""}`));
}
}
function burnSpan(picked, trend) {
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)};
}
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 names = estimation() === "points" ? Plan.SERIES : Plan.SERIES.filter((name) => name !== "unestimated");
const top = Math.max(1, ...series.flatMap((point) => names.map((name) => point[name])));
const ends = trend ? Plan.TRENDED.flatMap((name) => [trend.start, end].map((t) => Plan.lineAt(trend.lines[name], t))) : [];
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}`});
const {x, y} = burnAxis(svg, picked.from, picked.to, top);
const svg = s("svg", {class: "chart burn", viewBox: `0 0 ${BURN.width} ${BURN.height}`, role: "img", "aria-label": `Burndown and burnup in ${unit}; hover a point for the work packages of its step`});
const {x, y} = burnAxis(svg, picked.from, reach, top);
const last = series[series.length - 1];
if (reach > picked.to) {
svg.append(s("line", {class: "now", x1: fixed(x(picked.to)), x2: fixed(x(picked.to)), y1: BURN.top, y2: BURN.height - BURN.bottom}));
}
for (const name of names) {
const d = series.map((point, index) => (index ? `H${fixed(x(point.t))} V${fixed(y(point[name]))}` : `M${fixed(x(point.t))},${fixed(y(point[name]))}`)).join(" ");
svg.append(s("path", {class: `burn-line k-${name}`, "data-series": name, "data-last": String(last[name]), d}, s("title", {}, `${SERIES_WORDS[name]}: ${last[name]}`)));
}
const legend = h("div", {class: "legend"}, names.map((name) => h("span", {class: `burn-key k-${name}`, "data-series": name}, `${SERIES_WORDS[name]}: ${last[name]}`)));
if (trend) {
burnTrend(svg, trend, end, x, y);
}
svg.append(burnPoints(series, Plan.steps(picked.rows, series, picked.from), names, x, y));
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));
return h("div", {class: "burn-box"}, svg, legend, h("p", {class: "muted"}, `${picked.rows.length} tasks in scope, counted in ${unit}`));
}
@@ -115,6 +174,31 @@
drawTasks();
}
async function chooseTrend(next) {
if (await saveSettings({...settings, trend: next})) {
toast(`Trend: ${Plan.TRENDS.find(([id]) => id === next.mode)[1]}${next.mode === "velocity" ? ` over ${next.days} days` : ""}`, [], true);
}
syncPlan();
drawTasks();
}
function trendControls() {
const mode = h("select", {class: "field", ...named("Trend", "Trend: off, linear regression (least squares over the shown window) or velocity (the mean burn rate of the last N days); the forecast is where it reaches 0 remaining"), on: {change: (event) => chooseTrend({...settings.trend, mode: event.target.value})}},
Plan.TRENDS.map(([id, label]) => h("option", {value: id, selected: id === settings.trend.mode}, label)));
const [low, high] = TREND_DAYS;
const days = h("input", {type: "number", class: "field", min: low, max: high, step: 1, value: settings.trend.days, ...named("Velocity days", `Velocity days: the mean burn rate over the last N days, ${low} to ${high}`), on: {change: (event) => {
const value = Number(event.target.value);
if (Number.isInteger(value) && value >= low && value <= high) {
chooseTrend({...settings.trend, days: value});
} else {
event.target.value = String(settings.trend.days);
}
}}});
const box = h("label", {class: "plan-group", hidden: settings.trend.mode !== "velocity"}, days, "days");
taskView.plan.trend = {mode, days, box};
return h("div", {class: "plan-group", role: "group", "aria-label": "Trend and forecast"}, mode, box);
}
async function requestEstimates() {
const signed = await signedProof("/api/board/sign", {action: "estimates"}, "Request estimates");
if (signed) {
@@ -140,8 +224,8 @@
function bulkControls() {
const value = fibonacciPicker("Business value of the selection", 8);
const count = counter(0);
const button = h("button", {type: "button", class: "btn small primary", id: "bulk-value", disabled: true, ...named("Set business value", "Set the business value of every selected task as Operator, signed once with the passkey"), on: {click: () => bulkValue(Number(value.value))}},
icon("bi-gem"), "Set business value", count);
const button = h("button", {type: "button", class: "btn small primary", id: "bulk-value", disabled: true, ...named("Set business value", "Set business value: of every selected task as Operator, signed once with the passkey"), on: {click: () => bulkValue(Number(value.value))}},
icon("bi-gem"), count);
taskView.bulk = {button, count};
return h("div", {class: "plan-group", role: "group", "aria-label": "Business value of the selection"}, value, button);
}
@@ -163,18 +247,20 @@
if (!choices.some(([id]) => id === taskView.scope)) {
taskView.scope = "all";
}
return h("label", {class: "plan-group"}, "Scope", h("select", {class: "field", "aria-label": "Scope", on: {change: (event) => {
return h("select", {class: "field", ...named("Scope", "Scope: the whole register, the list filter in the time window, or one agent or line"), on: {change: (event) => {
taskView.scope = event.target.value;
drawTasks();
}}}, choices.map(([id, label]) => h("option", {value: id, selected: id === taskView.scope}, label))));
}}}, choices.map(([id, label]) => h("option", {value: id, selected: id === taskView.scope}, label)));
}
function planBar(view) {
const modes = h("div", {class: "segmented", role: "group", "aria-label": "Estimation"}, Plan.ESTIMATIONS.map(([id, label]) => h("button", {type: "button", "data-estimation": id, "aria-pressed": String(estimation() === id), title: `${label}: ${id === "points" ? "the Fibonacci complexity of rasci.py estimate" : "every task counts 1"}`, on: {click: () => chooseEstimation(id)}}, label)));
const request = h("button", {type: "button", class: "btn small", id: "request-estimates", hidden: estimation() !== "points", ...named("Request estimates", "Request estimates: one newsletter asks every agent with a RASCI role in an unestimated task for business value and complexity"), on: {click: requestEstimates}}, icon("bi-send-check"), "Request estimates");
const own = {list: () => [sortControls(), bulkControls()], gantt: forecastControl, burn: scopeControl}[view];
taskView.plan = {modes, request};
return h("div", {class: "card controls plan-bar", role: "toolbar", "aria-label": "Plan"}, modes, request, own ? own() : null);
function planTools(view) {
const modes = h("div", {class: "segmented", role: "group", "aria-label": "Estimation"}, Plan.ESTIMATIONS.map(([id, label]) => h("button", {
type: "button", class: "icon-only", "data-estimation": id, "aria-pressed": String(estimation() === id), ...named(label, `${label}: ${id === "points" ? "the Fibonacci complexity of rasci.py estimate" : "every task counts 1"}`), on: {click: () => chooseEstimation(id)},
}, icon(ESTIMATION_ICONS[id]))));
const request = h("button", {type: "button", class: "btn small icon-only", id: "request-estimates", hidden: estimation() !== "points", ...named("Request estimates", "Request estimates: one newsletter asks every agent with a RASCI role in an unestimated task for business value and complexity"), on: {click: requestEstimates}}, icon("bi-send-check"));
taskView.plan = {modes, request, trend: null};
const own = {list: () => [sortControls(), bulkControls()], gantt: forecastControl, burn: () => [scopeControl(), trendControls()]}[view];
return [modes, request, own ? own() : null];
}
function syncPlan() {
@@ -186,6 +272,13 @@
button.setAttribute("aria-pressed", String(button.dataset.estimation === estimation()));
}
plan.request.hidden = estimation() !== "points";
if (plan.trend) {
plan.trend.mode.value = settings.trend.mode;
plan.trend.box.hidden = settings.trend.mode !== "velocity";
if (document.activeElement !== plan.trend.days) {
plan.trend.days.value = String(settings.trend.days);
}
}
if (taskView.bulk) {
const picked = state.tasks.filter((row) => row.editable && taskView.picked.has(row.task)).length;
taskView.bulk.count.textContent = String(picked);
+65 -10
View File
@@ -13,6 +13,10 @@
const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"};
const MATRIX_PARTIES = 40;
const taskView = {filter: "all", q: "", dom: null, sig: "", sort: "prio", descending: false, forecast: "always", scope: "all", picked: new Set(), plan: null, bulk: null};
const ETA_HINTS = {
true: "ETA: its start, or for a task not yet started now or the latest ETA of its predecessors, plus the forecast length of the estimation mode",
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 : "");
@@ -54,11 +58,20 @@
return h("button", {type: "button", class: "linkish", title: `Open ${row.task}`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
}
function taskItem(row) {
const etaOf = () => Plan.etas(state.tasks, state.task_edges, {now: state.now, estimation: settings.estimation});
function etaMark(found) {
const known = Boolean(found) && found.eta !== null;
const forecast = Boolean(found) && found.forecast;
return h("span", {class: "task-eta", "data-eta": known ? String(found.eta) : "", "data-start": found && found.start !== null ? String(found.start) : "", "data-forecast": String(forecast), title: ETA_HINTS[forecast]},
icon("bi-flag"), "ETA ", known ? absolute.format(stampOf(found.eta)) : "–");
}
function taskItem(row, eta) {
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
const pick = row.editable ? h("input", {type: "checkbox", class: "task-pick", "aria-label": `Select ${row.task}`, title: `Select ${row.task} for a bulk business value`, checked: taskView.picked.has(row.task), on: {change: (event) => pickTask(row.task, event.target.checked)}}) : null;
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
h("div", {class: "head-row"}, pick, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
h("div", {class: "head-row"}, pick, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}), etaMark(eta),
row.editable ? iconBtn("bi-sort-numeric-down", "Priority", "btn small soft", () => taskSheet(row.task), `Priority and WSJF of ${row.task}: pin or estimate, signed with the passkey`) : null),
opener(row),
row.objective !== "-" ? h("div", {class: "muted"}, `Objective: ${row.objective}`) : null,
@@ -110,6 +123,32 @@
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
}
function taskModel3d() {
const rows = shownTasks();
const ids = new Set(rows.map((row) => row.task));
const starts = new Map(Plan.bars(rows, state.task_edges, {now: state.now, estimation: settings.estimation, forecast: "always"}).map((bar) => [bar.task, bar.start]));
const toneOf = (row) => TONE_ROLES[Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status][1] : "neutral"];
return {
nodes: rows.map((row) => ({id: row.task, kind: "task", label: `${row.task} · ${titleOf(row)}`, tone: toneOf(row), depth: 0, ts: starts.get(row.task), parent: null})),
links: state.task_edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to) && edge.from !== edge.to).map((edge) => ({source: edge.from, target: edge.to, kind: edge.kind})),
};
}
function taskSelect(id, anchor, closed) {
const row = state.tasks.find((entry) => entry.task === id);
if (!row) {
return;
}
const at = (ts) => (ts === null ? "" : absolute.format(stampOf(ts)));
const open = h("button", {type: "button", class: "btn small soft", ...named("Open task", `Open ${row.task}: priority, WSJF, RASCI and signed changes`), on: {click: () => {
closePopup();
taskSheet(row.task);
}}}, icon("bi-box-arrow-up-right"), "Open");
openPopup(row.task, [popupHead(icon(Nav.ICONS.tasks), titleOf(row), h("code", {class: "task-id"}, row.task)), h("div", {class: "popup-body"},
h("div", {class: "states"}, taskBadges(row, true)), meta([["Worker", row.worker], ["Branch", branchOf(row)], ["Start", at(row.start)], ["End", at(row.end)]]),
etaMark(etaOf().get(row.task)), h("div", {class: "actions"}, open))], anchor, closed);
}
function chooseTaskFilter(id) {
if (id === "operator") {
go("inbox", null);
@@ -146,12 +185,21 @@
taskView.q = event.target.value;
drawTasks();
}}});
const body = inbox ? inboxScreen() : h("div", {class: "tasks-body"});
taskView.dom = {chips, count, body, inbox, view};
const stage = !inbox && view === "3d" ? h("div", {class: "stage"}) : null;
const body = inbox ? inboxScreen() : stage ? h("div", {class: "card stage-card tasks-body"}, layoutTools("tasks"), stage) : h("div", {class: "tasks-body"});
taskView.dom = {chips, count, body, inbox, view, stage};
taskView.sig = "";
taskView.plan = null;
taskView.bulk = null;
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), inbox ? null : planBar(view), choiceBar(), chips, body);
const bar = h("div", {class: "card controls task-toolbar", role: "toolbar", "aria-label": "Tasks"}, taskViewSwitch(inbox), search, count, inbox ? null : planTools(view), choiceMenu());
return h("div", {class: "graph tasks"}, bar, chips, body);
}
function shownTasks() {
const q = taskView.q.trim().toLowerCase();
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
return state.tasks.filter(test).filter(hit);
}
function drawTasks() {
@@ -159,8 +207,11 @@
if (!dom) {
return;
}
if (dom.stage) {
stage3d("tasks", dom.stage);
}
const q = taskView.q.trim().toLowerCase();
const sig = JSON.stringify([state.tasks, state.jobs, state.task_edges, settings.estimation, taskView.filter, taskView.sort, taskView.descending, taskView.forecast, taskView.scope, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
const sig = JSON.stringify([state.tasks, state.jobs, state.task_edges, settings.estimation, settings.trend, taskView.filter, taskView.sort, taskView.descending, taskView.forecast, taskView.scope, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
if (sig === taskView.sig) {
return;
}
@@ -178,12 +229,16 @@
dom.count.textContent = `${rows.length} tasks in the register`;
return;
}
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
const shown = rows.filter(test).filter(hit);
const shown = shownTasks();
dom.count.textContent = `${shown.length} of ${rows.length} tasks`;
if (dom.stage) {
return;
}
const views = {
list: (found) => h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map(taskItem)),
list: (found) => {
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,
rasci: taskMatrix,
gantt: ganttChart,
+126 -25
View File
@@ -2,10 +2,13 @@ const Plan = (() => {
"use strict";
const FORECASTS = [["always", "always"], ["points", "points only"], ["real", "real only"]];
const ESTIMATIONS = [["none", "No estimate"], ["points", "Story points"]];
const TRENDS = [["off", "off"], ["regression", "linear regression"], ["velocity", "velocity"]];
const SORTS = [["prio", "Prio"], ["wsjf", "WSJF"], ["value", "Business value"], ["complexity", "Complexity"], ["status", "Status"], ["start", "Start"], ["end", "End"], ["agent", "Agent"], ["title", "Title"]];
const STATUS_ORDER = ["queued", "awaiting", "claimed", "running", "blocked", "stale", "done", "stopped"];
const SERIES = ["scope", "done", "remaining", "unestimated"];
const TRENDED = ["scope", "done", "remaining"];
const OUTSIDE = ["queue", "command"];
const DAY = 86400;
function median(values) {
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
@@ -45,16 +48,13 @@ const Plan = (() => {
}
/**
* @param rows - task rows in their shown order.
* @param rows - task rows.
* @param edges - {from, to, kind} edges; only those between the rows count.
* @param options - {now, estimation, forecast}.
* @returns one {task, start, end, forecast, dot} per drawn row: a started task from start to its end, an open one
* to now; a task not yet started, unless forecast is "real", as a forecast behind the latest end of its
* predecessors and now, forecastLength() long, a dot when 0. A task never started is left out under "real", an
* ended one without start and end always.
* @param settle - (row, ahead) => value or null, where ahead() lists the settled values of the row's
* predecessors; a predecessor still being settled (a cycle) and a null value are left out.
* @returns a Map from task to its settled value, each row settled once, in row order.
*/
function bars(rows, edges, options) {
const known = medians(rows);
function settled(rows, edges, settle) {
const byTask = new Map(rows.map((row) => [row.task, row]));
const before = new Map(rows.map((row) => [row.task, []]));
for (const edge of edges) {
@@ -72,23 +72,60 @@ const Plan = (() => {
return null;
}
visiting.add(row.task);
let bar = null;
const value = settle(row, () => before.get(row.task).map((task) => place(byTask.get(task))).filter(Boolean));
visiting.delete(row.task);
placed.set(row.task, value);
return value;
};
rows.forEach(place);
return placed;
}
/**
* @param rows - task rows in their shown order.
* @param edges - {from, to, kind} edges; only those between the rows count.
* @param options - {now, estimation, forecast}.
* @returns one {task, start, end, forecast, dot} per drawn row: a started task from start to its end, an open one
* to now; a task not yet started, unless forecast is "real", as a forecast behind the latest end of its
* predecessors and now, forecastLength() long, a dot when 0. A task never started is left out under "real", an
* ended one without start and end always.
*/
function bars(rows, edges, options) {
const known = medians(rows);
const placed = settled(rows, edges, (row, ahead) => {
if (row.start !== null) {
const end = row.end !== null ? row.end : row.ended ? row.start : Math.max(options.now, row.start);
bar = {task: row.task, start: row.start, end, forecast: false, dot: end <= row.start};
} else if (row.end !== null) {
bar = {task: row.task, start: row.end, end: row.end, forecast: false, dot: true};
} else if (!row.ended && options.forecast !== "real") {
const ahead = before.get(row.task).map((task) => place(byTask.get(task))).filter(Boolean);
const start = Math.max(options.now, ...ahead.map((found) => found.end));
const length = forecastLength(row, options.estimation, options.forecast, known);
bar = {task: row.task, start, end: start + length, forecast: true, dot: length <= 0};
return {task: row.task, start: row.start, end, forecast: false, dot: end <= row.start};
}
visiting.delete(row.task);
placed.set(row.task, bar);
return bar;
};
return rows.map(place).filter(Boolean);
if (row.end !== null) {
return {task: row.task, start: row.end, end: row.end, forecast: false, dot: true};
}
if (row.ended || options.forecast === "real") {
return null;
}
const start = Math.max(options.now, ...ahead().map((found) => found.end));
const length = forecastLength(row, options.estimation, options.forecast, known);
return {task: row.task, start, end: start + length, forecast: true, dot: length <= 0};
});
return rows.map((row) => placed.get(row.task)).filter(Boolean);
}
/**
* @param rows - task rows.
* @param edges - {from, to, kind} edges; only those between the rows count.
* @param options - {now, estimation}.
* @returns a Map from task to {start, eta, forecast}: an ended task's end (null without one); else its start, or
* for a task not yet started the latest of now and its predecessors' ETAs, plus forecastLength() under "always".
*/
function etas(rows, edges, options) {
const known = medians(rows);
return settled(rows, edges, (row, ahead) => {
if (row.ended) {
return {start: row.start, eta: row.end, forecast: false};
}
const start = row.start !== null ? row.start : Math.max(options.now, ...ahead().map((found) => found.eta).filter((eta) => eta !== null));
return {start, eta: start + forecastLength(row, options.estimation, "always", known), forecast: true};
});
}
/**
@@ -123,11 +160,10 @@ const Plan = (() => {
function burn(rows, options) {
const points = options.estimation === "points";
const weigh = (row) => (points ? row.points || 0 : 1);
const joined = (row) => (row.start !== null ? row.start : row.end !== null ? row.end : options.from);
const moments = rows.flatMap((row) => [row.start, row.end]).filter((value) => value !== null && value > options.from && value < options.to);
const times = [...new Set([options.from, ...moments, options.to])].sort((a, b) => a - b);
const at = (t) => {
const present = rows.filter((row) => joined(row) <= t && !(row.status === "stopped" && row.end !== null && row.end <= t));
const present = rows.filter((row) => joinedAt(row, options.from) <= t && !(row.status === "stopped" && row.end !== null && row.end <= t));
const burnt = present.filter((row) => row.status === "done" && row.end !== null && row.end <= t);
const scope = present.reduce((sum, row) => sum + weigh(row), 0);
const done = burnt.reduce((sum, row) => sum + weigh(row), 0);
@@ -137,6 +173,71 @@ const Plan = (() => {
return {points: times.map(at)};
}
const joinedAt = (row, from) => (row.start !== null ? row.start : row.end !== null ? row.end : from);
/**
* @param rows - the scope's task rows.
* @param points - burn() points of the rows.
* @param from - the range start burn() was given.
* @returns per point {started, ended, joined}: the tasks whose start, end or, without a start, entry into the
* scope falls after the previous point and up to this one; the first point takes everything up to it.
*/
function steps(rows, points, from) {
return points.map((point, index) => {
const after = index ? points[index - 1].t : -Infinity;
const within = (t) => t !== null && t > after && t <= point.t;
const tasks = (test) => rows.filter(test).map((row) => row.task);
return {
started: tasks((row) => within(row.start)),
ended: tasks((row) => within(row.end)),
joined: tasks((row) => row.start === null && within(joinedAt(row, from))),
};
});
}
const LAST_SECOND = 8.64e12;
function leastSquares(pairs) {
const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length;
const t0 = mean(pairs.map(([t]) => t));
const y0 = mean(pairs.map(([, y]) => y));
const spread = pairs.reduce((sum, [t]) => sum + (t - t0) ** 2, 0);
return spread > 0 ? {t0, y0, slope: pairs.reduce((sum, [t, y]) => sum + (t - t0) * (y - y0), 0) / spread} : null;
}
function velocity(points, name, start) {
const last = points[points.length - 1];
const before = points.filter((point) => point.t <= start).pop();
return {t0: last.t, y0: last[name], slope: (last[name] - before[name]) / (last.t - start)};
}
/**
* @param points - burn() points, in time order.
* @param choice - {mode: "off", "regression" or "velocity", days: N for velocity}.
* @returns null when off or with fewer than two distinct times, else {start, lines, forecast}: per series of
* TRENDED a line {t0, y0, slope}, its value y0 + slope * (t - t0) with slope per second, fitted by least squares
* over every point (regression, drawn from the first point) or through the last point and the value at
* max(first point, last point - N days), the mean rate of those days (velocity, drawn from there). The forecast
* is where the remaining line reaches 0, which by linearity is where done meets scope; null when it never falls
* or falls only after LAST_SECOND, the last second a Date can hold.
*/
function trend(points, choice) {
if (choice.mode === "off" || points.length < 2) {
return null;
}
const last = points[points.length - 1];
const start = choice.mode === "velocity" ? Math.max(points[0].t, last.t - choice.days * DAY) : points[0].t;
const lines = Object.fromEntries(TRENDED.map((name) => [name, choice.mode === "velocity" ? (start < last.t ? velocity(points, name, start) : null) : leastSquares(points.map((point) => [point.t, point[name]]))]));
if (TRENDED.some((name) => lines[name] === null)) {
return null;
}
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};
}
const lineAt = (line, t) => line.y0 + line.slope * (t - line.t0);
const KEYS = {
prio: () => null,
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
@@ -169,7 +270,7 @@ const Plan = (() => {
return keyed.map((entry) => entry.row);
}
return {FORECASTS, ESTIMATIONS, SORTS, SERIES, STATUS_ORDER, median, medians, forecastLength, bars, scoped, burn, sortRows};
return {FORECASTS, ESTIMATIONS, TRENDS, SORTS, SERIES, TRENDED, STATUS_ORDER, median, medians, forecastLength, bars, etas, scoped, burn, steps, trend, lineAt, sortRows};
})();
if (typeof module === "object" && module.exports) {
module.exports = Plan;
+20 -10
View File
@@ -1,10 +1,15 @@
.graph.tasks { grid-template-columns: minmax(0, 1fr); }
.tasks-body { min-width: 0; }
.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); }
.task-toolbar { gap: 8px 10px; }
.task-toolbar [hidden] { display: none; }
.task-toolbar input[type="search"] { flex: 1 1 120px; min-width: min(120px, 100%); }
.task-toolbar .segmented button.icon-only { min-width: 30px; padding: 4px 8px; }
.task-toolbar input[type="number"] { width: 4.5rem; }
.task-toolbar > .choice-request { margin-left: auto; }
.tasks > .chips { margin-bottom: 0; }
body.fit .graph.tasks > .tasks-body, body.fit .graph.tasks > .inbox { flex: 1; min-height: 0; overflow: auto; }
.choice-request { gap: var(--sp-2) var(--sp-3); }
.choice-agents { display: grid; gap: 6px; max-height: 40vh; overflow: auto; }
.choice-agents { display: grid; gap: 6px; max-height: 30vh; overflow: auto; }
.choice-buttons { display: flex; flex-wrap: wrap; gap: 6px; }
.chip .count { margin-left: 2px; }
.task-list, .task-cards { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.task-row { display: grid; gap: 6px; min-width: 0; padding: var(--sp-3); border: 1px solid var(--line); border-radius: 12px; background: var(--glass-card); }
@@ -43,11 +48,10 @@ table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-widt
.task-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.task-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
.task-form input[type="number"] { width: 120px; }
.plan-bar { gap: var(--sp-2) var(--sp-3); }
.plan-bar [hidden] { display: none; }
.plan-group { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-sm); }
.plan-group select.field { width: auto; min-width: 0; max-width: min(260px, 100%); }
.task-toolbar select.field { width: auto; min-width: 0; max-width: min(11rem, 100%); }
.task-pick { margin: 0; accent-color: var(--accent); }
.task-eta { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; }
.gantt-box, .burn-box { display: grid; gap: 8px; min-width: 0; }
.gantt-scroll { max-width: 100%; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
.gantt-scroll svg.gantt { display: block; max-width: none; }
@@ -70,10 +74,16 @@ svg .gantt-tip { fill: var(--muted); }
.gantt-key.forecast::before { height: 10px; background: repeating-linear-gradient(45deg, var(--accent) 0 3px, var(--accent-soft) 3px 6px); }
svg.burn { width: 100%; height: auto; max-height: 60vh; }
svg .burn-line { fill: none; stroke: var(--c); stroke-width: 2.4; }
svg .burn-line.k-scope, .burn-key.k-scope { --c: var(--info); }
svg .burn-line.k-done, .burn-key.k-done { --c: var(--ok); }
svg .burn-line.k-remaining, .burn-key.k-remaining { --c: var(--accent); }
svg .burn-line.k-unestimated, .burn-key.k-unestimated { --c: var(--warn); }
svg.burn .k-scope, .burn-key.k-scope { --c: var(--info); }
svg.burn .k-done, .burn-key.k-done { --c: var(--ok); }
svg.burn .k-remaining, .burn-key.k-remaining { --c: var(--accent); }
svg.burn .k-unestimated, .burn-key.k-unestimated { --c: var(--warn); }
svg .burn-line.k-unestimated { stroke-dasharray: 6 4; }
svg .burn-trend { fill: none; stroke: var(--c); stroke-width: 1.6; stroke-dasharray: 2 5; stroke-linecap: round; }
svg .burn-point { fill: var(--c); stroke: transparent; stroke-width: 10; cursor: help; }
svg .burn-point:hover { r: 6px; }
svg .forecast-line { stroke: var(--ok); stroke-width: 1.4; stroke-dasharray: 4 3; }
svg .forecast-label { fill: var(--ok); font-size: var(--fs-2xs); font-weight: 650; }
.burn-forecast { display: inline-flex; align-items: center; gap: 6px; }
svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; }
svg .edge.flow { stroke-dasharray: 2 5; }
+7
View File
@@ -37,6 +37,13 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak
.station { grid-template-columns: minmax(0, 1fr) auto auto; }
.station-when { display: none; }
}
.spot-toggle[aria-expanded="true"] { background: var(--accent-soft); color: var(--accent); }
.radio-spot { display: grid; gap: var(--sp-3); max-height: 46vh; overflow: auto; }
.radio-spot[hidden], .radio-gear[hidden] { display: none; }
body.fit .radio-spot { flex: 0 1 auto; min-height: 0; }
.spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
.spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; }
.radio-gear { min-width: 0; }
.composer { display: grid; gap: var(--sp-2); }
.composer-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: var(--sp-2) var(--sp-3); }
.composer-grid label { display: grid; gap: 4px; min-width: 0; color: var(--muted); font-size: var(--fs-sm); }
+2 -1
View File
@@ -17,7 +17,8 @@ const port = await new Promise((resolve) => {
probe.close(() => resolve(free));
});
});
const browser = spawn(binary, ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], {stdio: ["ignore", "ignore", "pipe"]});
// Trip-wire: Firefox still starts "crashreporter --ping-cleanup", which ignores MOZ_CRASHREPORTER_DISABLE and can dump core.<pid> into its cwd; the throwaway profile takes it.
const browser = spawn(binary, ["--headless", "--no-remote", "--profile", profile, "--remote-debugging-port", String(port), "about:blank"], {cwd: profile, stdio: ["ignore", "ignore", "pipe"], env: {...process.env, MOZ_CRASHREPORTER_DISABLE: "1"}});
const address = await new Promise((resolve, reject) => {
let text = "";
+683 -47
View File
@@ -34,7 +34,7 @@ from test_hub_offices import host as office_host
OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn")
TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn", "3d")
SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)]
SUBS = ["Inbox", "Firewall", "Radio"]
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
@@ -54,6 +54,21 @@ HELPERS = """
const all = (selector, root) => [...(root || document).querySelectorAll(selector)];
const text = (node) => (node ? node.textContent.trim() : null);
const name = (node) => (node ? node.getAttribute("aria-label") || text(node) : null);
const toolbarRows = () => {
const bar = document.querySelector('#main [role="toolbar"][aria-label="Tasks"]');
const boxes = bar ? [...bar.children].filter((node) => node.getClientRects().length).map((node) => node.getBoundingClientRect()).sort((a, b) => a.top - b.top) : [];
let [rows, bottom] = [0, -Infinity];
for (const box of boxes) {
if (box.top >= bottom - 1) {
rows += 1;
bottom = box.bottom;
} else {
bottom = Math.max(bottom, box.bottom);
}
}
return rows;
};
const taskLayout = () => all("#main .graph.tasks > *").map((node) => (node.matches('[role="toolbar"]') ? "toolbar" : node.matches('[aria-label="Task filters"]') ? "chips" : "body"));
"""
PROBE = """(async () => {@HELPERS@
await until(() => !document.querySelector("#main .space-note") || !text(document.querySelector("#main .space-note")).startsWith("Loading"), 8000);
@@ -81,7 +96,11 @@ PROBE = """(async () => {@HELPERS@
replay: Boolean(document.querySelector("#time .replay input[type=range]")),
custom: Boolean(document.querySelector('#main details.menu input[type="datetime-local"]')),
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node.querySelector("button") || node)),
toAll: all("#to-all input[type=radio]").map((input) => [input.value, input.checked]),
spot: (() => {
const box = document.getElementById("radio-spot");
return box ? {hidden: box.hidden, channels: [...box.querySelector("form.composer select").options].map((option) => option.value)} : null;
})(),
composers: all("#main form.composer").map((form) => (form.closest("#radio-spot") ? "spot" : "other")),
inbox: all("#main .inbox-list > li").length,
icons: icons.length,
unlabelled: icons.map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160)),
@@ -188,7 +207,8 @@ ACTIONS = """(async () => {@HELPERS@
document.getElementById("settings-open").click();
await sleep(300);
found.ambience = all(".sheet-box form > section:first-child button").map((button) => button.id);
found.speech = all('.sheet-box [aria-label="Classes"] input').map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]);
found.sections = all(".sheet-box form.settings > section > h3").map(text);
found.radioFields = all('.sheet-box [aria-label="Squelch"], .sheet-box [aria-label="Classes"], .sheet-box [aria-label="Speakers"], .sheet-box .sound-row').length;
found.settingsUnlabelled = all(".sheet-box svg.bi").map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).length;
document.querySelector(".sheet-head button").click();
document.getElementById("palette-open").click();
@@ -526,8 +546,16 @@ SPEAK_END = """(async () => {@HELPERS@@SPEAKING@
await sleep(300);
return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length, faces: all("#main .avatar.speaking").length};
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
COMPOSE = """(async () => {@HELPERS@
const form = await until(() => document.querySelector("#main form.composer"), 8000);
SPOT_OPEN = """
const spotToggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000);
if (spotToggle && spotToggle.getAttribute("aria-expanded") !== "true") {
spotToggle.click();
await sleep(300);
}
const spot = document.getElementById("radio-spot");
"""
COMPOSE = """(async () => {@HELPERS@@SPOT_OPEN@
const form = spot ? spot.querySelector("form.composer") : null;
if (!form) {
return {error: "no composer"};
}
@@ -558,7 +586,7 @@ COMPOSE = """(async () => {@HELPERS@
return clashes(form);
})(),
};
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP)
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@CLASHES@", OVERLAP)
COMPOSE_SEND = """(async () => {@HELPERS@
const form = document.querySelector("#main form.composer");
const field = form.querySelector("textarea");
@@ -571,15 +599,36 @@ COMPOSE_SEND = """(async () => {@HELPERS@
return {sent, left: field.value, voice: Boolean(form.querySelector(".voice-note audio"))};
})()""".replace("@HELPERS@", HELPERS)
CLEAR_MAYDAY = """(async () => {@HELPERS@
window.confirm = () => true;
const button = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Cancel mayday" && !node.disabled), 8000);
const questions = [];
let accept = false;
window.confirm = (question) => {
questions.push(question);
return accept;
};
const posted = [];
const send = window.fetch.bind(window);
window.fetch = (path, init) => {
if (String(path).startsWith("/api/board/")) {
posted.push(String(path));
}
return send(path, init);
};
await until(() => document.querySelector('#main [role="toolbar"]'), 8000);
const toolbar = all('#main [role="toolbar"] button, #main [role="toolbar"] summary').map(name);@SPOT_OPEN@
const button = await until(() => spot && all(".spot-head button", spot).find((node) => name(node) === "Cancel mayday" && !node.disabled), 8000);
if (!button) {
return {error: "no enabled Cancel mayday button"};
return {error: "no enabled Cancel mayday button in the radio spot", toolbar};
}
const shown = new Set(all("#toasts .toast"));
const fresh = () => all("#toasts .toast").filter((node) => !shown.has(node)).map(text);
button.click();
const done = await until(() => all("#toasts .toast").map(text).find((toast) => toast.startsWith("Cancel mayday")), 15000);
return {done, toasts: all("#toasts .toast").map(text)};
})()""".replace("@HELPERS@", HELPERS)
await sleep(800);
const declined = {question: questions.slice(), posted: posted.slice(), toasts: fresh()};
accept = true;
button.click();
const done = await until(() => fresh().find((toast) => toast.startsWith("Cancel mayday")), 15000);
return {toolbar, title: button.title, declined, accepted: {question: questions.slice(declined.question.length), posted: posted.slice(declined.posted.length)}, done};
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN)
NIGHT_MENU = """(async () => {@HELPERS@
const questions = [];
window.confirm = (question) => {
@@ -618,6 +667,187 @@ NIGHT_MENU = """(async () => {@HELPERS@
}
return {labels: buttons.map(name), runs};
})()""".replace("@HELPERS@", HELPERS)
NIGHT_LOOK = """(async () => {@HELPERS@
const menu = await until(() => all('#main [role="toolbar"] details.menu').find((node) => name(node.querySelector("summary")) === "Day/Night"), 8000);
if (!menu) {
return {error: text(document.getElementById("main"))};
}
menu.open = true;
await sleep(200);
const box = menu.querySelector(".menu-box");
return {
children: [...box.children].map((node) => [node.tagName.toLowerCase(), node.className]),
badge: all(".pill", box).map(text),
buttons: all("button", box).map((button) => [name(button), button.title]),
paragraphs: all("p", box).length,
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
};
})()""".replace("@HELPERS@", HELPERS)
FITS = "tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1"
SPOT_SEND = """(async () => {@HELPERS@
const questions = [];
window.confirm = (question) => {
questions.push(question);
return true;
};
const toggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000);
if (!toggle) {
return {error: text(document.getElementById("main"))};
}
const bar = document.querySelector('#main [role="toolbar"]');
const spot = document.getElementById("radio-spot");
const closed = [spot.hidden, toggle.getAttribute("aria-expanded")];
toggle.click();
await sleep(400);
const form = spot.querySelector("form.composer");
const [channel, recipient] = all("select", form);
const field = form.querySelector("textarea");
const found = {
closed,
open: [spot.hidden, toggle.getAttribute("aria-expanded"), document.activeElement === field],
inline: [getComputedStyle(spot).position, spot.getBoundingClientRect().top >= bar.getBoundingClientRect().bottom, spot.previousElementSibling === bar],
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node)),
channels: all("option", channel).map((option) => option.value),
record: all("button", form).filter((button) => button.hasAttribute("aria-pressed")).map(text),
heads: all(".spot-head button", spot).map((button) => [name(button), button.title]),
@FITS@,
};
await until(() => [...recipient.options].some((option) => option.value === "Stage"), 8000);
found.runs = [];
for (const cls of found.channels) {
channel.value = cls;
channel.dispatchEvent(new Event("change", {bubbles: true}));
const direct = !recipient.closest("label").hidden;
if (direct) {
recipient.value = "Stage";
}
field.value = `@TEXT@ ${cls}`;
field.dispatchEvent(new Event("input", {bubbles: true}));
const shown = new Set(all("#toasts .toast"));
const prompts = questions.length;
form.querySelector('button[type="submit"]').click();
const toast = await until(() => all("#toasts .toast").filter((node) => !shown.has(node)).map(text).find((entry) => entry.includes(" to ")), 20000);
found.runs.push({cls, direct, toast, question: questions.slice(prompts), left: field.value});
}
document.getElementById("toasts").replaceChildren();
return found;
})()""".replace("@HELPERS@", HELPERS).replace("@FITS@", FITS)
RADIO_JUMP = """(async () => {@HELPERS@
const jump = await until(() => all("#main .radio-view button").find((button) => name(button) === "Send …"), 8000);
if (!jump) {
return {error: text(document.getElementById("main"))};
}
const composers = all("#main form.composer").length;
jump.click();
const spot = await until(() => location.hash.startsWith("#agents/") && document.querySelector("#radio-spot:not([hidden])"), 4000);
await sleep(200);
return {composers, hash: location.hash, open: Boolean(spot), focused: Boolean(spot) && document.activeElement === spot.querySelector("textarea")};
})()""".replace("@HELPERS@", HELPERS)
GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@
const spot = document.getElementById("radio-spot");
const gear = all(".spot-head button", spot).find((button) => name(button) === "Radio settings");
gear.click();
const form = await until(() => spot.querySelector("form.radio-settings"), 4000);
if (!form) {
return {error: "no radio settings behind the gear"};
}
const classes = () => all('[aria-label="Classes"] input', form);
const found = {
expanded: gear.getAttribute("aria-expanded"),
sections: all("section > h3", form).map(text),
tones: all(".sound-row strong", form).map(text),
classes: classes().map((input) => [input.parentElement.textContent.trim(), input.checked, input.disabled]),
clashes: clashes(form),
@FITS@,
};
const squelch = form.querySelector('input[aria-label="Squelch"]');
squelch.value = "@SQUELCH@";
squelch.dispatchEvent(new Event("input", {bubbles: true}));
form.querySelector('input[aria-label="All radio on"]').checked = @TONE@;
classes().find((input) => input.parentElement.textContent.trim() === "Post").checked = @POST@;
const before = new Set(all("#toasts .toast"));
all("button", form).find((button) => button.type === "submit").click();
found.toast = await until(() => all("#toasts .toast").filter((node) => !before.has(node)).map(text).find((entry) => entry.includes("ettings")), 15000);
await sleep(300);
found.after = [gear.getAttribute("aria-expanded"), Boolean(spot.querySelector("form.radio-settings"))];
document.getElementById("settings-open").click();
const sheet = await until(() => document.querySelector(".sheet-box form.settings"), 4000);
found.general = {sections: all("section > h3", sheet).map(text), radio: all('[aria-label="Squelch"], [aria-label="Classes"], [aria-label="Speakers"], .sound-row', sheet).length};
document.querySelector(".sheet-head button").click();
return found;
})()""".replace("@HELPERS@", HELPERS).replace("@CLASHES@", OVERLAP).replace("@FITS@", FITS)
GEAR_READ = """(async () => {@HELPERS@@SPOT_OPEN@
const gear = spot ? all(".spot-head button", spot).find((button) => name(button) === "Radio settings") : null;
if (!gear) {
return {error: text(document.getElementById("main"))};
}
gear.click();
const form = await until(() => spot.querySelector("form.radio-settings"), 4000);
return {
squelch: Number(form.querySelector('input[aria-label="Squelch"]').value),
tone: form.querySelector('input[aria-label="All radio on"]').checked,
post: all('[aria-label="Classes"] input', form).find((input) => input.parentElement.textContent.trim() === "Post").checked,
@FITS@,
};
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@FITS@", FITS)
GRAPH_HOOK = """() => {
let made = null;
Object.defineProperty(window, "ForceGraph3D", {
configurable: true,
get: () => made && ((options) => {
const graph = made(options);
window.e2eGraphs = [...(window.e2eGraphs || []), graph];
return graph;
}),
set: (value) => {
made = value;
},
});
}"""
CAMERA = """(async () => {@HELPERS@
const ready = await until(() => window.e2eGraphs && document.querySelector("#main .graph3d canvas") && !text(document.querySelector("#main .space-note")).startsWith("Loading"), 15000);
if (!ready) {
return {error: text(document.querySelector("#main .graph"))};
}
const graph = window.e2eGraphs[window.e2eGraphs.length - 1];
const position = () => {
const at = graph.cameraPosition();
return [at.x, at.y, at.z];
};
await sleep(5500);
let [fed, stops] = [0, 0];
const stopped = graph.onEngineStop();
graph.onEngineStop(() => {
stops += 1;
stopped();
});
const feed = graph.graphData;
graph.graphData = function (...args) {
fed += args.length;
return feed.apply(graph, args);
};
const built = position();
const canvas = document.querySelector("#main .graph3d canvas");
const rect = canvas.getBoundingClientRect();
for (let turn = 0; turn < 4; turn += 1) {
canvas.dispatchEvent(new WheelEvent("wheel", {deltaY: -120, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, bubbles: true, cancelable: true}));
await sleep(60);
}
await sleep(900);
const moved = position();
const refreshes = [];
for (let round = 0; round < 2; round += 1) {
const [feeds, ends] = [fed, stops];
document.dispatchEvent(new Event("visibilitychange"));
const rebuilt = Boolean(await until(() => fed > feeds, 8000));
const settled = Boolean(await until(() => stops > ends, 10000));
await sleep(900);
refreshes.push({rebuilt, settled, camera: position()});
}
all("#main button").find((button) => name(button) === "Reset view").click();
await sleep(1500);
return {hooked: typeof stopped === "function", built, moved, refreshes, reset: position()};
})()""".replace("@HELPERS@", HELPERS)
def tone(seconds: int, rate: int) -> bytes:
@@ -748,25 +978,26 @@ WATCH = """
}
return send(path, init);
};
const bar = () => document.querySelector('#main [aria-label="Request choices"]');
const bar = () => document.getElementById("choice-request");
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});
"""
CHOICE_VIEW = """(async () => {@HELPERS@@LABELS@
const found = await until(() => document.querySelector('#main [aria-label="Request choices"]') && all("#main .inbox-list > li .options").length === @CARDS@ && document.querySelector('#main [aria-label="Request choices"] details.menu input[type="checkbox"]'), 8000);
const found = await until(() => document.getElementById("choice-request") && all("#main .inbox-list > li .options").length === @CARDS@ && document.querySelector('#choice-request input[type="checkbox"]'), 8000);
if (!found) {
return {error: text(document.getElementById("main"))};
}
const box = document.querySelector('#main [aria-label="Request choices"]');
const menu = box.querySelector("details.menu");
const box = document.getElementById("choice-request");
const chip = document.querySelector('#main [aria-label="Task filters"] button[data-filter="operator"]');
const closed = all("button[data-channel]", box).filter((button) => button.checkVisibility()).length;
return {
kinds: [...new Set(all("#main .inbox-list > li").map((li) => text(li.querySelector(".pill"))))].sort(),
filter: [text(chip).replace(/[0-9]+$/, ""), chip.getAttribute("aria-pressed")],
channels: all("button[data-channel]", box).map((button) => [button.dataset.channel, name(button), button.disabled]),
fields: all("textarea, input:not([type=checkbox])", box).length,
agents: all('input[type="checkbox"]', menu).map((input) => input.value),
multi: [name(menu.querySelector("summary")), text(menu.querySelector("summary .count")), all('input[type="checkbox"]', menu).filter((input) => input.checked).length],
agents: all('input[type="checkbox"]', box).map((input) => input.value),
multi: [name(box.querySelector("summary")), box.open, closed, text(box.querySelector(".menu-box h3 .count")), all('input[type="checkbox"]', box).filter((input) => input.checked).length],
bar: [box.parentElement.getAttribute("aria-label"), toolbarRows(), taskLayout()],
cards: all("#main .inbox-list > li").filter((li) => li.querySelector(".options")).map((li) => [text(li.querySelector(".pill")), all(".option strong", li).map(text)]),
choices: all("#main [data-column], #main .group-title, #main h2, #main h3").filter((node) => node.dataset.column === "choices" || text(node) === "Choices").length,
unlabelled: unlabelled(document),
@@ -775,13 +1006,13 @@ CHOICE_VIEW = """(async () => {@HELPERS@@LABELS@
};
})()""".replace("@HELPERS@", HELPERS).replace("@LABELS@", LABELS)
CHOICE_SEND = """(async () => {@HELPERS@@WATCH@
const menu = bar().querySelector("details.menu");
menu.open = true;
const menu = bar();
menu.querySelector("summary").click();
await sleep(200);
const boxes = all('input[type="checkbox"]', menu).slice(0, 2);
boxes.forEach((box) => box.click());
const counted = text(menu.querySelector("summary .count"));
menu.open = false;
const counted = text(menu.querySelector(".menu-box h3 .count"));
const opened = [menu.open, all("button[data-channel]", menu).filter((button) => button.checkVisibility()).length];
const picked = boxes.map((box) => box.value).sort((a, b) => a.localeCompare(b));
const runs = [];
for (const button of all("button[data-channel]", bar())) {
@@ -792,9 +1023,12 @@ CHOICE_SEND = """(async () => {@HELPERS@@WATCH@
const toast = await fresh(shown, "Choice request");
runs.push({channel: button.dataset.channel, disabled, asked: asked - before, question: questions.slice(prompts), posted: posted.slice(sent), toast});
}
return {picked, counted, runs, fields: all("textarea, input:not([type=checkbox])", bar()).length, ...fits()};
return {picked, counted, opened, runs, fields: all("textarea, input:not([type=checkbox])", bar()).length, ...fits()};
})()""".replace("@HELPERS@", HELPERS).replace("@WATCH@", WATCH)
CHOICE_ANSWER = """(async () => {@HELPERS@@WATCH@
bar().querySelector("summary").click();
await sleep(200);
const closed = [bar().open, all("button[data-channel]", bar()).filter((button) => button.checkVisibility()).length];
const card = await until(() => all("#main .inbox-list > li").find((li) => li.textContent.includes("@QUESTION@")), 8000);
if (!card) {
return {error: text(document.getElementById("main"))};
@@ -805,15 +1039,17 @@ CHOICE_ANSWER = """(async () => {@HELPERS@@WATCH@
option.click();
const toast = await fresh(shown, "Choice: Wait");
const gone = @SIGNS@ ? Boolean(await until(() => !all("#main .inbox-list > li").some((li) => li.textContent.includes("@QUESTION@")), 20000)) : null;
return {title, asked, posted, toast, gone, ...fits()};
return {title, asked, posted, toast, gone, closed, ...fits()};
})()""".replace("@HELPERS@", HELPERS).replace("@WATCH@", WATCH)
TASKS_FIT = """(async () => {@HELPERS@
await until(() => document.querySelector('#main [aria-label="Request choices"]') && document.querySelector("#main .tasks-body > *"), 8000);
await until(() => document.getElementById("choice-request") && document.querySelector("#main .tasks-body > *"), 8000);
document.getElementById("toasts").replaceChildren();
await sleep(300);
return {
hash: location.hash,
bar: all('#main [aria-label="Request choices"] button[data-channel]').map((button) => button.dataset.channel),
bar: all("#choice-request button[data-channel]").map((button) => button.dataset.channel),
rows: toolbarRows(),
layout: taskLayout(),
columns: all("#main .task-column").map((column) => column.dataset.column),
choices: all("#main [data-column], #main .group-title, #main h2, #main h3").filter((node) => node.dataset.column === "choices" || text(node) === "Choices").length,
tall: document.documentElement.scrollHeight > window.innerHeight,
@@ -821,7 +1057,7 @@ TASKS_FIT = """(async () => {@HELPERS@
};
})()""".replace("@HELPERS@", HELPERS)
PLAN_HELPERS = """
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1});
const fits = () => ({tall: document.documentElement.scrollHeight > window.innerHeight, wide: document.documentElement.scrollWidth > window.innerWidth + 1, rows: toolbarRows(), layout: taskLayout()});
const modes = () => all('#main [aria-label="Estimation"] button').map((button) => [button.dataset.estimation, button.getAttribute("aria-pressed")]);
const requestShown = () => {
const button = document.getElementById("request-estimates");
@@ -939,10 +1175,146 @@ BULK = """(async () => {@HELPERS@@PLAN_HELPERS@
found.reset = Boolean(await until(() => requestShown() === false, 8000));
return found;
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
ETA_LIST = """(async () => {@HELPERS@@PLAN_HELPERS@
const list = await until(() => all("#main .task-row .task-eta").length >= 8, 8000);
if (!list) {
return {error: text(document.getElementById("main"))};
}
const number = (value) => (value === "" ? null : Number(value));
const read = () => Object.fromEntries(all("#main .task-row").map((row) => {
const eta = row.querySelector(".task-eta");
return [row.dataset.task, [number(eta.dataset.start), number(eta.dataset.eta), eta.dataset.forecast === "true", text(eta), eta.title]];
}));
const toggle = async (mode) => {
const before = JSON.stringify(read());
all('#main [aria-label="Estimation"] button').find((button) => button.dataset.estimation === mode).click();
await until(() => JSON.stringify(read()) !== before, 8000);
await sleep(300);
return read();
};
const found = {none: read(), modes: modes(), ...fits()};
found.points = await toggle("points");
found.back = await toggle("none");
return found;
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
TREND_READ = """
const trendOf = () => {
const select = document.querySelector('#main select[aria-label="Trend"]');
const days = document.querySelector('#main input[aria-label="Velocity days"]');
const note = document.querySelector("#main .burn-forecast");
const marker = document.querySelector("#main svg.burn .forecast-line");
return {
value: select.value, options: all("option", select).map((option) => [option.value, text(option)]), days: [Number(days.value), days.getClientRects().length > 0],
note: note ? [note.dataset.forecast === "" ? null : Number(note.dataset.forecast), text(note)] : null, marker: marker ? Number(marker.dataset.forecast) : null,
lines: all("#main svg.burn path.burn-trend").map((path) => [path.dataset.series, Number(path.dataset.from), Number(path.dataset.to)]),
points: all('#main svg.burn circle.burn-point[data-series="remaining"]').map((circle) => [Number(circle.dataset.t), Number(circle.dataset.value)]),
};
};
const chart = async () => until(() => document.querySelector("#main svg.burn") && document.querySelector('#main select[aria-label="Trend"]'), 8000);
"""
TREND_SET = """(async () => {@HELPERS@@PLAN_HELPERS@@TREND_READ@
if (!(await chart())) {
return {error: text(document.getElementById("main"))};
}
const before = trendOf();
const shown = new Set(all("#toasts .toast"));
await choose(document.querySelector('#main select[aria-label="Trend"]'), "@MODE@");
const toasts = [await fresh(shown, "Trend: ")];
if ("@MODE@" === "velocity") {
const days = document.querySelector('#main input[aria-label="Velocity days"]');
const again = new Set(all("#toasts .toast"));
days.value = "@DAYS@";
days.dispatchEvent(new Event("change", {bubbles: true}));
toasts.push(await fresh(again, "Trend: velocity over @DAYS@ days"));
}
await sleep(400);
return {before, after: trendOf(), toasts, ...fits()};
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS).replace("@TREND_READ@", TREND_READ)
TREND_RELOADED = """(async () => {@HELPERS@@PLAN_HELPERS@@TREND_READ@
if (!(await chart())) {
return {error: text(document.getElementById("main"))};
}
await sleep(300);
return {...trendOf(), ...fits()};
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS).replace("@TREND_READ@", TREND_READ)
STEP_TIPS = """(async () => {@HELPERS@@PLAN_HELPERS@@TREND_READ@
if (!(await chart())) {
return {error: text(document.getElementById("main"))};
}
const steps = all('#main svg.burn circle.burn-point[data-series="remaining"]').map((circle) => ({
t: Number(circle.dataset.t), started: circle.dataset.started, ended: circle.dataset.ended, joined: circle.dataset.joined, tip: text(circle.querySelector("title")),
series: all(`#main svg.burn circle.burn-point[data-step="${circle.dataset.step}"]`).map((mark) => mark.dataset.series),
}));
const shown = new Set(all("#toasts .toast"));
await choose(document.querySelector('#main select[aria-label="Trend"]'), "off");
const reset = await fresh(shown, "Trend: off");
await sleep(300);
return {steps, reset, note: Boolean(document.querySelector("#main .burn-forecast")), lines: all("#main svg.burn path.burn-trend").length, ...fits()};
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS).replace("@TREND_READ@", TREND_READ)
TASKS_3D = """(async () => {@HELPERS@@PLAN_HELPERS@
const note = () => text(document.querySelector("#main .space-note")) || "";
const ready = await until(() => window.e2eGraphs && document.querySelector("#main .graph3d canvas") && !note().startsWith("Loading"), 15000);
if (!ready) {
return {error: text(document.querySelector("#main .graph"))};
}
const graph = () => window.e2eGraphs[window.e2eGraphs.length - 1];
const id = (end) => (typeof end === "object" ? end.id : end);
const look = () => {
const data = graph().graphData();
const color = graph().linkColor();
return {
note: note(), layouts: all('#main [aria-label="Layout"] button').map((button) => [button.dataset.layout, button.getAttribute("aria-pressed")]),
nodes: data.nodes.map((node) => [node.id, Number.isFinite(node.fz) ? node.fz : null, Number.isFinite(node.ts) ? node.ts : null]),
links: data.links.map((link) => [id(link.source), id(link.target), link.kind, color(link)]),
label: (() => {
const shown = graph().nodeLabel()(data.nodes.find((node) => node.id === "work/t5"));
return shown instanceof Element ? shown.textContent : String(shown);
})(),
};
};
const layout = async (mode, word) => {
all('#main [aria-label="Layout"] button').find((button) => button.dataset.layout === mode).click();
await until(() => note().endsWith(word), 8000);
await sleep(400);
return look();
};
const css = getComputedStyle(document.documentElement);
const palette = Object.fromEntries(["violet", "info", "warn"].map((role) => [role, css.getPropertyValue(`--${role}`).trim()]));
const force = look();
const time = await layout("time", "Time");
const field = document.querySelector('#main input[aria-label="Find a node"]');
field.value = "work/t5";
field.dispatchEvent(new Event("input", {bubbles: true}));
await sleep(200);
const chip = all("#main .hits .chip").find((node) => node.dataset.hit === "work/t5");
if (!chip) {
return {error: "no search hit", force, time};
}
chip.click();
await sleep(600);
const dialog = document.getElementById("popup");
const eta = dialog.querySelector(".task-eta");
const popup = {
open: dialog.open, title: text(dialog.querySelector(".popup-box .panel-title")), id: text(dialog.querySelector(".popup-head .task-id")), picked: document.querySelector("#main .graph3d").dataset.picked || null,
eta: eta ? eta.dataset.eta : null, open_button: name(dialog.querySelector(".popup-body .actions button")), line: dialog.querySelector(".leader line").getAttribute("display") !== "none",
};
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", bubbles: true}));
await sleep(300);
popup.closed = [dialog.open, document.querySelector("#main .graph3d").dataset.picked || null];
const back = await layout("none", "Force");
await sleep(1200);
return {palette, force, time, popup, back: back.note, ...fits()};
})()""".replace("@HELPERS@", HELPERS).replace("@PLAN_HELPERS@", PLAN_HELPERS)
PLAN_VALUES = {1920: "13", 1366: "21", 390: "34"}
TREND_DAYS = {1920: 7, 1366: 3, 390: 14}
STATUS_ORDER = ["queued", "awaiting", "claimed", "running", "blocked", "stale", "done", "stopped"]
CHOICE_REQUEST = "send me your open decisions as choices"
CHOICE_CHANNELS = ["post", "call", "newsletter", "broadcast", "pan-pan", "security", "mayday"]
NIGHT_HINTS = [
("Drain", "Drain: the agents finish their task and take nothing new; your passkey signs it"),
("Suspend", "Suspend: the agents park their work, write the handover and idle; your passkey signs it"),
("Resume", "Resume: all agents continue from their handover; your passkey signs it"),
]
SLOW ="seq -f 'Row %g' 1 60; sleep 4; seq -f 'Row %g' 61 80; sleep 4; echo finished"
@@ -1248,6 +1620,7 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
self.assertFalse(probe["wide"], "the page scrolls sideways")
self.assertEqual((probe["panel"], probe["nodeChips"]), (0, 0))
self.assertEqual(probe["composers"], ["spot"] if probe["hash"].startswith("#agents/") else [])
self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS))
by_hash = {probe["hash"]: probe for probe in probes}
stream = by_hash["#stream"]
@@ -1261,8 +1634,8 @@ class TestOrchestratorE2E(HubCase):
self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"])
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
agents = by_hash["#agents/orbit"]
self.assertEqual(agents["toolbar"], ["Radio to all", "Cancel mayday", "Priority", "Day/Night"])
self.assertEqual(agents["toAll"], [["newsletter", True], ["broadcast", False], ["pan-pan", False], ["security", False], ["mayday", False]])
self.assertEqual(agents["toolbar"], ["Radio", "Priority", "Day/Night"])
self.assertEqual(agents["spot"], {"hidden": True, "channels": CHOICE_CHANNELS})
hierarchy = by_hash["#agents/hierarchy"]
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
self.assertEqual(hierarchy["toggles"], [["Collapse: Operator", "true"], ["Collapse: Chair", "true"], ["Collapse: identity", "true"], ["Collapse: Presidents", "true"]])
@@ -1286,7 +1659,10 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual(by_hash["#artifacts/chord"]["flowEdges"], ["pull", "push"])
order = ["work/feature", "work/docs", "work/stage", "queue/gate-3", "work/old", "operator/push"]
listed = by_hash["#tasks/list"]
self.assertEqual(listed["views"], ["List", "Board", "RASCI", "Gantt", "Burndown and burnup"])
self.assertEqual(listed["views"], ["List", "Board", "RASCI", "Gantt", "Burndown and burnup", "3D"])
self.assertEqual(by_hash["#tasks/3d"]["layouts"], ["Force", "Time"])
self.assertTrue(by_hash["#tasks/3d"]["canvas"], by_hash["#tasks/3d"]["note"])
self.assertRegex(by_hash["#tasks/3d"]["note"], r"^6 nodes · \d+ edges · Force$")
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"])
@@ -1297,12 +1673,11 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual([probe["hash"] for probe in extra[:3]], ["#agents/orbit", "#agents/chord", "#artifacts/tree"])
actions = extra[3]
self.assertNotIn("error", actions)
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/burn", "#stream"])
self.assertEqual(actions["keys"], ["#agents/chord", "#artifacts/tree", "#tasks/3d", "#stream"])
self.assertEqual(actions["ambience"], ["sky-look", "music-toggle", "leitstand", "notify"])
self.assertEqual((actions["settingsUnlabelled"], actions["popupUnlabelled"]), (0, 0))
self.assertEqual([name for name, _, _ in actions["speech"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
self.assertEqual([entry[1:] for entry in actions["speech"] if entry[0] == "MAYDAY"], [[True, True]])
for tool in ("Radio to all …", "Cancel mayday", "Priority …", "Drain", "Suspend", "Resume", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"):
self.assertEqual((actions["sections"], actions["radioFields"]), (["Ambience", "Location", "Appearance", "Music", "Notifications", "Internet radio", "Passkey"], 0))
for tool in ("Radio …", "Cancel mayday", "Priority …", "Drain", "Suspend", "Resume", "Firewall …", "Settings …", "Look at the sky", "Toggle control room", "Enable notifications"):
with self.subTest(tool=tool):
self.assertTrue(any(tool in entry for entry in actions["palette"]), actions["palette"])
self.assertEqual(actions["popup"], [["post", True], ["call", False]])
@@ -1376,10 +1751,10 @@ class TestOrchestratorE2E(HubCase):
def test_chromium_records_transcribes_and_sends_a_signed_call_with_voice(self):
local = f"http://localhost:{self.port}/"
steps = [
{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": self.shot("1366-radio-composer"), "gesture": True},
{"width": 1366, "height": 768, "url": f"{local}#agents/orbit", "wait": 1200, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": self.shot("1366-radio-spot-composer"), "gesture": True},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "false"), "shot": None},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE.replace("@TO@", "Stage"), "shot": None, "gesture": True},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "true"), "shot": self.shot("1366-radio-composer-sent")},
{"width": 1366, "height": 768, "url": None, "wait": 0, "probe": COMPOSE_SEND.replace("@ATTACH@", "true"), "shot": self.shot("1366-radio-spot-composer-sent")},
]
data = self.drive(steps, self.authenticator())
(OUT / "chromium-composer.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
@@ -1406,14 +1781,90 @@ class TestOrchestratorE2E(HubCase):
status, _, body = self.get(f"/api/voice/{note}.wav")
self.assertEqual((status, body[:4]), (200, b"RIFF"))
def test_chromium_clears_the_mayday_only_after_the_passkey_signs(self):
def test_chromium_clears_the_mayday_from_the_radio_spot_only_after_confirmation_and_passkey_at_three_widths(self):
local = f"http://localhost:{self.port}/"
data = self.drive([{"width": 1366, "height": 768, "url": f"{local}#agents/orbit", "wait": 1200, "probe": CLEAR_MAYDAY, "shot": self.shot("1366-mayday-cleared")}], self.authenticator())
steps = [{"width": width, "height": height, "url": f"{local}?{width}#agents/orbit", "wait": 1200, "probe": CLEAR_MAYDAY, "shot": self.shot(f"{width}-radio-spot-mayday-cleared")} for width, height in WIDTHS]
data = self.drive(steps, self.authenticator())
(OUT / "chromium-mayday.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
result = data["results"][0]
self.assertNotIn("error", result)
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])])
question = "Lift the radio silence of the active mayday?"
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
with self.subTest(width=width):
self.assertNotIn("error", result)
self.assertEqual(result["toolbar"], ["Radio", "Priority", "Day/Night", "Drain", "Suspend", "Resume"])
self.assertEqual(result["title"], "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it")
self.assertEqual(result["declined"], {"question": [question], "posted": [], "toasts": []})
self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]})
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
self.assertEqual(self.scripts.calls, [(skill_scripts.RADIO, ["clear", "--force", "--session", "Operator"])] * len(WIDTHS))
def test_chromium_sends_every_class_from_the_radio_spot_and_keeps_its_gear_settings_through_a_reload_at_three_widths(self):
local = f"http://localhost:{self.port}/"
values = {1920: ("0.35", "false", "true"), 1366: ("0.45", "true", "false"), 390: ("0.55", "false", "true")}
steps = [{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": RADIO_JUMP, "shot": self.shot("1366-radio-tab-jump-to-spot")}]
for width, height in WIDTHS:
squelch, tone, post = values[width]
gear = GEAR_SAVE.replace("@SQUELCH@", squelch).replace("@TONE@", tone).replace("@POST@", post)
steps += [
{"width": width, "height": height, "url": f"{local}?{width}#agents/orbit", "wait": 1200, "probe": SPOT_SEND.replace("@TEXT@", f"spot {width}"), "shot": self.shot(f"{width}-radio-spot-sent")},
{"width": width, "height": height, "url": None, "wait": 0, "probe": gear, "shot": None},
{"width": width, "height": height, "url": f"{local}?{width}-reloaded#agents/orbit", "wait": 1200, "probe": GEAR_READ, "shot": self.shot(f"{width}-radio-spot-gear")},
]
data = self.drive(steps, self.authenticator())
(OUT / "chromium-radio-spot.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
labels = dict(zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True))
mayday = "Send a mayday to all? All agents pause writing steps until you cancel it."
jump, *walked = data["results"]
self.assertEqual(jump, {"composers": 0, "hash": "#agents/orbit", "open": True, "focused": True})
for index, (width, _) in enumerate(WIDTHS):
sent, saved, read = walked[3 * index : 3 * (index + 1)]
squelch, tone, post = values[width]
with self.subTest(width=width):
for probe in (sent, saved, read):
self.assertNotIn("error", probe)
self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True]))
self.assertEqual(sent["inline"], ["static", True, True])
self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Radio", "Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"]))
self.assertEqual(sent["heads"], [["Radio settings", "Radio settings: tones, squelch and read aloud"], ["Cancel mayday", "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it"]])
self.assertEqual((sent["tall"], sent["wide"]), (False, False))
self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS)
for run in sent["runs"]:
direct = run["cls"] in ("post", "call")
self.assertEqual((run["direct"], run["question"], run["left"]), (direct, [mayday] if run["cls"] == "mayday" else [], ""), run)
self.assertTrue((run["toast"] or "").startswith(f"{labels[run['cls']]} to {'Stage' if direct else 'all'}: sent"), run)
self.assertEqual((saved["expanded"], saved["sections"], saved["clashes"]), ("true", ["Tones", "Read radio aloud"], {"found": [], "outside": []}))
self.assertEqual(saved["tones"], ["MAYDAY / PAN-PAN", "Reviews, asks, decisions", "Post and calls to you", "All radio"])
self.assertEqual([entry[0] for entry in saved["classes"]], ["Post", "Call", "Newsletter", "Broadcast", "MAYDAY", "PAN-PAN", "Sécurité"])
self.assertEqual([entry[1:] for entry in saved["classes"] if entry[0] == "MAYDAY"], [[True, True]])
self.assertEqual((saved["toast"], saved["after"]), ("Radio settings saved", ["false", False]))
self.assertEqual(saved["general"], {"sections": ["Ambience", "Location", "Appearance", "Music", "Notifications", "Internet radio", "Passkey"], "radio": 0})
self.assertEqual((saved["tall"], saved["wide"]), (False, False))
self.assertEqual(read, {"squelch": float(squelch), "tone": tone == "true", "post": post == "true", "tall": False, "wide": False})
heads = {"post": ["post", "--to", "Stage"], "call": ["send", "call", "--to", "Stage"], "newsletter": ["post"]}
radio = [args for script, args in self.scripts.calls if script == skill_scripts.RADIO]
sends = [(width, cls) for width, _ in WIDTHS for cls in CHOICE_CHANNELS]
self.assertEqual([args[:-1] for args in radio], [[*heads.get(cls, ["send", cls]), "--session", "Operator"] for _, cls in sends])
for args, (width, cls) in zip(radio, sends, strict=True):
with self.subTest(width=width, cls=cls):
self.assertTrue(args[-1].endswith(f"] spot {width} {cls}"), args)
stored = self.httpd.hub.settings.get()
self.assertEqual((stored["squelch"], stored["sounds"]["radio"]["on"], "post" in stored["speech"]["classes"]), (0.55, False, True))
def test_chromium_keeps_the_3d_camera_through_two_state_refreshes_at_three_widths(self):
steps = [{"width": width, "height": height, "url": f"{self.base}?{width}#agents/3d", "wait": 900, "probe": CAMERA, "shot": self.shot(f"{width}-agents-3d-steady-camera")} for width, height in WIDTHS]
data = self.drive(steps, None, init=GRAPH_HOOK)
(OUT / "chromium-camera.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual(data["errors"], [])
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
with self.subTest(width=width):
self.assertNotIn("error", result)
self.assertTrue(result["hooked"], result)
self.assertGreater(math.dist(result["built"], result["moved"]), 1, result)
for refresh in result["refreshes"]:
self.assertEqual((refresh["rebuilt"], refresh["settled"]), (True, True), result)
self.assertLess(math.dist(refresh["camera"], result["moved"]), 0.01, result)
self.assertGreater(math.dist(result["reset"], result["moved"]), 1, result)
def check_night_menu(self, browser: str, driver: str, binary: str, signs: bool) -> None:
"""Args:
@@ -1423,11 +1874,18 @@ class TestOrchestratorE2E(HubCase):
"""
authenticator = self.authenticator()
probe = NIGHT_MENU.replace("@SIGNS@", "true" if signs else "false")
looks = [{"width": width, "height": height, "url": f"http://localhost:{self.port}/?{width}#agents/orbit", "wait": 1200, "probe": NIGHT_LOOK, "shot": str(OUT / f"{browser}-{width}-night-menu-tooltips.png")} for width, height in WIDTHS]
step = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#agents/orbit", "wait": 1200, "probe": probe, "shot": str(OUT / f"{browser}-1366-night-menu.png")}
data = self.drive([step], authenticator if signs else None, driver, binary)
data = self.drive([*looks, step], authenticator if signs else None, driver, binary)
(OUT / f"{browser}-night-menu.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
found = data["results"][0]
*seen, found = data["results"]
for look, (width, _) in zip(seen, WIDTHS, strict=True):
with self.subTest(browser=browser, width=width):
self.assertNotIn("error", look)
self.assertEqual(look["children"], [["span", ""], ["div", "actions"]])
self.assertEqual((look["badge"], look["paragraphs"], look["wide"]), (["Day"], 0, False))
self.assertEqual(look["buttons"], [list(entry) for entry in NIGHT_HINTS])
self.assertNotIn("error", found)
self.assertEqual(found["labels"], ["Drain", "Suspend", "Resume"])
for run, action in zip(found["runs"], ("drain", "suspend", "resume"), strict=True):
@@ -1663,6 +2121,7 @@ class TestOrchestratorE2E(HubCase):
labels = dict(zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True))
self.assertEqual(len(sent["picked"]), 2, sent)
self.assertEqual((sent["counted"], sent["fields"], sent["tall"], sent["wide"]), ("2", 0, False, False))
self.assertEqual(sent["opened"], [True, len(CHOICE_CHANNELS)])
self.assertEqual([run["channel"] for run in sent["runs"]], CHOICE_CHANNELS)
for run in sent["runs"]:
with self.subTest(channel=run["channel"]):
@@ -1675,7 +2134,7 @@ class TestOrchestratorE2E(HubCase):
def answered(self, result: dict, signs: bool) -> None:
self.assertNotIn("error", result)
self.assertEqual((result["title"], result["asked"], result["tall"], result["wide"]), ("Wait: your passkey signs this answer, a post to Stage", 1, False, False))
self.assertEqual((result["title"], result["asked"], result["closed"], result["tall"], result["wide"]), ("Wait: your passkey signs this answer, a post to Stage", 1, [False, 0], False, False))
signed = [["/api/sign", ["items"], None, None]]
self.assertEqual(result["posted"], [*signed, ["/api/inbox/approve", ["items", "proof"], None, None]] if signs else signed)
self.assertTrue((result["toast"] or "").startswith("Choice: Wait" + ("" if signs else ": not signed")), result)
@@ -1703,13 +2162,16 @@ class TestOrchestratorE2E(HubCase):
self.assertEqual(view["kinds"], ["Ask", "Choice", "Operator", "Review", "Rule request"])
self.assertEqual(view["filter"], ["Waits on Operator", "true"])
self.assertEqual(view["channels"], [[channel, label, channel in ("post", "call")] for channel, label in zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True)])
self.assertEqual((view["fields"], view["choices"], view["multi"], view["unlabelled"], view["tall"], view["wide"]), (0, 0, ["Agents", "0", 0], [], False, False))
self.assertEqual((view["fields"], view["choices"], view["multi"], view["unlabelled"], view["tall"], view["wide"]), (0, 0, ["Request choices", False, 0, "0", 0], [], False, False))
self.assertEqual(view["bar"], ["Tasks", view["bar"][1], ["toolbar", "chips", "body"]])
self.assertEqual(view["bar"][1] == 1, width != 390, view["bar"])
self.assertGreaterEqual(len(view["agents"]), 2, view)
self.assertIn(["Choice", ["Merge now", "Wait"]], view["cards"])
self.requested(sent, signs)
self.answered(answer, signs)
for fit, name in zip(fitted, TASK_VIEWS, strict=True):
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"])
hub = self.httpd.hub
@@ -1818,7 +2280,8 @@ class TestOrchestratorE2E(HubCase):
with self.subTest(browser=browser, width=width):
for probe in (gantt, burn, points_on, request, gantt_points, bulk):
self.assertNotIn("error", probe)
self.assertEqual((probe["tall"], probe["wide"]), (False, False), probe)
self.assertEqual((probe["tall"], probe["wide"], probe["layout"]), (False, False, ["toolbar", "chips", "body"]), probe)
self.assertEqual(probe["rows"] == 1, width != 390, probe)
self.planned(gantt, points=False)
self.planned(gantt_points, points=True)
self.assertEqual((burn["modes"], burn["request"]), ([["none", "true"], ["points", "false"]], False))
@@ -1862,6 +2325,179 @@ class TestOrchestratorE2E(HubCase):
def test_firefox_draws_the_gantt_and_burn_and_asks_the_passkey_for_a_bulk_business_value_storing_nothing_unsigned(self):
self.check_plan("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), signs=False)
def trend_work(self, now: float) -> None:
day = 86400
register = {
"work/t1": {"title": "First", "status": "done", "worker": "Stage", "start": iso(now - 10 * day), "end": iso(now - 8 * day), "roles": {"Stage": "RA"}, "estimates": {"Stage": {"value": 5, "complexity": 1}}},
"work/t2": {"title": "Second", "status": "done", "worker": "Docs", "start": iso(now - 10 * day), "end": iso(now - 6 * day), "roles": {"Docs": "RA"}, "estimates": {"Docs": {"value": 3, "complexity": 2}}},
"work/t3": {"title": "Third", "status": "done", "worker": "Docs", "branch": "feature/t3", "start": iso(now - 10 * day), "end": iso(now - 4 * day), "roles": {"Docs": "RA"}, "estimates": {"Docs": {"value": 3, "complexity": 3}}},
"work/t4": {"title": "Fourth", "status": "running", "worker": "Stage", "start": iso(now - day), "roles": {"Stage": "RA"}, "estimates": {"Stage": {"value": 8, "complexity": 5}}},
"work/t5": {"title": "Fifth", "status": "queued", "after": ["work/t4"], "roles": {"Skills": "R"}, "estimates": {"Skills": {"value": 8, "complexity": 3}}},
"work/t6": {"title": "Sixth", "status": "queued", "base": "feature/t3", "roles": {"Docs": "RA"}},
}
queue = {"seq": 2, "entries": [
{"id": "deploy-1", "seq": 1, "kind": "deploy", "task": "work/t4", "workdir": "/repo", "session": "s2", "name": "Stage", "hotfix": None, "status": "active", "enqueued": now - 400, "granted": now - 300, "cmd": "make deploy"},
{"id": "test-2", "seq": 2, "kind": "test", "task": "work/t5", "workdir": "/repo", "session": "s2", "name": "Docs", "hotfix": None, "status": "waiting", "enqueued": now - 60, "granted": None, "cmd": "make test"},
]}
self.write_council({"rasci.json": register, "queue.json": queue})
def trend_steps(self, browser: str) -> list[dict]:
local = f"http://localhost:{self.port}/"
steps = []
for width, height in WIDTHS:
days = str(TREND_DAYS[width])
steps += [
{"width": width, "height": height, "url": f"{local}?{width}#tasks/list", "wait": 1200, "probe": ETA_LIST, "shot": str(OUT / f"{browser}-{width}-tasks-eta.png")},
{"width": width, "height": height, "url": f"{local}?{width}#tasks/burn", "wait": 900, "probe": TREND_SET.replace("@MODE@", "regression").replace("@DAYS@", days), "shot": str(OUT / f"{browser}-{width}-tasks-burn-regression.png")},
{"width": width, "height": height, "url": f"{local}?{width}-regression#tasks/burn", "wait": 1200, "probe": TREND_RELOADED, "shot": None},
{"width": width, "height": height, "url": None, "wait": 0, "probe": TREND_SET.replace("@MODE@", "velocity").replace("@DAYS@", days), "shot": str(OUT / f"{browser}-{width}-tasks-burn-velocity.png")},
{"width": width, "height": height, "url": f"{local}?{width}-velocity#tasks/burn", "wait": 1200, "probe": TREND_RELOADED, "shot": None},
{"width": width, "height": height, "url": None, "wait": 0, "probe": STEP_TIPS, "shot": str(OUT / f"{browser}-{width}-tasks-burn-steps.png")},
{"width": width, "height": height, "url": f"{local}?{width}-3d#tasks/3d", "wait": 1200, "probe": TASKS_3D, "shot": str(OUT / f"{browser}-{width}-tasks-3d.png")},
{"width": width, "height": height, "url": f"{local}?{width}-camera#tasks/3d", "wait": 900, "probe": CAMERA, "shot": str(OUT / f"{browser}-{width}-tasks-3d-steady-camera.png")},
]
return steps
def eta_checked(self, probe: dict, mode: str, rows: dict, edges: list[dict], window: tuple[float, float]) -> None:
"""Args:
probe: task -> [start, eta, forecast, text, title] as the list shows it.
mode: the estimation mode the list was drawn in.
rows: the snapshot's task rows by task.
edges: the snapshot's task edges.
window: the earliest and latest time the page can have taken as now.
"""
day = 86400
points = {"none": {}, "points": {"work/t4": 10 * day, "work/t5": 6 * day}}[mode]
self.assertEqual(sorted(probe), sorted(rows))
for task, (start, eta, forecast, words, title) in probe.items():
with self.subTest(mode=mode, task=task):
row = rows[task]
self.assertTrue(words.startswith("ETA ") and words != "ETA –", words)
if row["ended"]:
self.assertEqual((eta, forecast, title), (row["end"], False, "ETA: the end of the ended task"))
continue
self.assertTrue(forecast)
self.assertAlmostEqual(eta - start, points.get(task, 4 * day), delta=0.01)
ahead = [probe[edge["from"]][1] for edge in edges if edge["to"] == task and edge["from"] in probe and edge["from"] != task]
if row["start"] is not None:
self.assertEqual(start, row["start"])
elif ahead and max(ahead) > window[1]:
self.assertAlmostEqual(start, max(ahead), delta=0.01)
else:
self.assertTrue(window[0] <= start <= window[1], (start, window))
def trend_forecast(self, points: list[list[float]], mode: str, days: int) -> tuple[float, float | None]:
"""Args:
points: [t, remaining] of the drawn burndown points.
Returns:
The trend's start and the time its remaining line reaches 0
(None when it never falls), computed here independently of plan.js.
"""
last_t, last_y = points[-1]
if mode == "regression":
mean_t = sum(t for t, _ in points) / len(points)
mean_y = sum(y for _, y in points) / len(points)
slope = sum((t - mean_t) * (y - mean_y) for t, y in points) / sum((t - mean_t) ** 2 for t, _ in points)
return points[0][0], (mean_t - mean_y / slope if slope < 0 else None)
start = max(points[0][0], last_t - days * 86400)
before = [y for t, y in points if t <= start][-1]
slope = (last_y - before) / (last_t - start)
return start, (last_t - last_y / slope if slope < 0 else None)
def trended(self, probe: dict, mode: str, days: int) -> None:
start, forecast = self.trend_forecast(probe["points"], mode, days)
self.assertEqual(probe["value"], mode)
self.assertEqual(probe["days"], [days, mode == "velocity"])
self.assertEqual(sorted(series for series, *_ in probe["lines"]), ["done", "remaining", "scope"])
for _, begin, end in probe["lines"]:
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))
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"])
def check_trend_eta_3d(self, browser: str, driver: str, binary: str) -> None:
self.trend_work(time.time())
begun = time.time()
data = self.drive(self.trend_steps(browser), None, driver, binary, init=GRAPH_HOOK)
ended = time.time()
(OUT / f"{browser}-trend-eta-3d.json").write_text(json.dumps(data, indent=1), encoding="utf-8")
self.assertEqual([error for error in data["errors"] if "(style-src-elem)" not in error and "/favicon.ico" not in error], [])
snapshot = self.snapshot()
rows = {row["task"]: row for row in snapshot["tasks"]}
edges = snapshot["task_edges"]
self.assertEqual(sorted([edge["kind"], edge["from"], edge["to"]] for edge in edges), [["after", "work/t4", "work/t5"], ["base", "work/t3", "work/t6"], ["queue", "queue/deploy-1", "queue/test-2"]])
trends = [["off", "off"], ["regression", "linear regression"], ["velocity", "velocity"]]
per = 8
for index, (width, _) in enumerate(WIDTHS):
eta, regression, reloaded, velocity, again, tips, shown, camera = data["results"][per * index : per * (index + 1)]
days = TREND_DAYS[width]
kept = TREND_DAYS[WIDTHS[index - 1][0]] if index else 7
with self.subTest(browser=browser, width=width):
for probe in (eta, regression, reloaded, velocity, again, tips, shown):
self.assertNotIn("error", probe)
self.assertEqual((probe["tall"], probe["wide"], probe["layout"]), (False, False, ["toolbar", "chips", "body"]), probe)
self.assertEqual(probe["rows"] == 1, width != 390, probe["rows"])
for mode in ("none", "points", "back"):
self.eta_checked(eta[mode], "points" if mode == "points" else "none", rows, edges, (begun - 1, ended + 1))
self.assertEqual(eta["modes"], [["none", "true"], ["points", "false"]])
self.assertEqual((regression["before"]["value"], regression["before"]["options"], regression["before"]["note"], regression["before"]["lines"]), ("off", trends, None, []))
self.assertEqual(regression["before"]["days"], [kept, False])
self.assertEqual(regression["toasts"], ["Trend: linear regression"])
self.trended(regression["after"], "regression", kept)
self.trended(reloaded, "regression", kept)
self.assertEqual(velocity["toasts"], [f"Trend: velocity over {kept} days", f"Trend: velocity over {days} days"])
self.trended(velocity["after"], "velocity", days)
self.trended(again, "velocity", days)
self.assertEqual([step["t"] for step in tips["steps"]], [t for t, _ in again["points"]])
listed = [[sorted(filter(None, step[key].split(" "))) for key in ("started", "ended", "joined")] for step in tips["steps"]]
self.assertEqual(listed, [[["work/t1", "work/t2", "work/t3"], [], ["work/t5", "work/t6"]], [[], ["work/t1"], []], [[], ["work/t2"], []], [[], ["work/t3"], []], [["work/t4"], [], []], [[], [], []]])
self.assertEqual([step["series"] for step in tips["steps"]], [["scope", "done", "remaining"]] * len(tips["steps"]))
self.assertIn("Ended: work/t1", tips["steps"][1]["tip"])
self.assertIn("No work package started, ended or joined the scope here", tips["steps"][-1]["tip"])
self.assertEqual((tips["reset"], tips["note"], tips["lines"]), ("Trend: off", False, 0))
self.tasks_3d(shown, snapshot)
self.assertTrue(camera["hooked"], camera)
self.assertGreater(math.dist(camera["built"], camera["moved"]), 1, camera)
for refresh in camera["refreshes"]:
self.assertEqual((refresh["rebuilt"], refresh["settled"]), (True, True), camera)
self.assertLess(math.dist(refresh["camera"], camera["moved"]), 0.01, camera)
self.assertEqual(self.httpd.hub.settings.get()["trend"], {"mode": "off", "days": TREND_DAYS[WIDTHS[-1][0]]})
def tasks_3d(self, shown: dict, snapshot: dict) -> None:
tasks = sorted(row["task"] for row in snapshot["tasks"])
colors = {"after": shown["palette"]["violet"], "base": shown["palette"]["info"], "queue": shown["palette"]["warn"]}
edges = sorted([edge["from"], edge["to"], edge["kind"], colors[edge["kind"]]] for edge in snapshot["task_edges"])
self.assertEqual(len(set(colors.values())), 3, colors)
for layout, word in (("force", "Force"), ("time", "Time")):
drawn = shown[layout]
with self.subTest(layout=layout):
self.assertEqual(drawn["note"], f"{len(tasks)} nodes · {len(edges)} edges · {word}")
self.assertEqual(drawn["layouts"], [["none", str(layout == "force").lower()], ["time", str(layout == "time").lower()]])
self.assertEqual(sorted(node for node, *_ in drawn["nodes"]), tasks)
self.assertEqual(sorted(drawn["links"]), edges)
self.assertEqual(drawn["label"], "work/t5 · Fifth")
self.assertEqual([fz for _, fz, _ in shown["force"]["nodes"]], [None] * len(tasks))
timed = sorted(shown["time"]["nodes"], key=lambda node: node[2])
self.assertTrue(all(fz is not None for _, fz, _ in timed), timed)
self.assertEqual([fz for _, fz, _ in timed], sorted(fz for _, fz, _ in timed))
self.assertLess(timed[0][1], timed[-1][1])
popup = shown["popup"]
self.assertEqual((popup["open"], popup["title"], popup["id"], popup["picked"], popup["open_button"], popup["line"]), (True, "Fifth", "work/t5", "work/t5", "Open task", True), popup)
self.assertTrue(popup["eta"], popup)
self.assertEqual((popup["closed"], shown["back"]), ([False, None], f"{len(tasks)} nodes · {len(edges)} edges · Force"))
def test_chromium_draws_the_trend_forecast_point_tips_eta_and_tasks_3d_at_three_widths(self):
self.check_trend_eta_3d("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"))
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_draws_the_trend_forecast_point_tips_eta_and_tasks_3d_at_three_widths(self):
self.check_trend_eta_3d("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def test_chromium_pins_a_task_priority_after_signature_and_the_runner_applies_it(self):
signer = self.pin()
private = "".join(line for line in signer.pem.read_text(encoding="utf-8").splitlines() if not line.startswith("-----"))
+4 -3
View File
@@ -11,7 +11,7 @@ const N = require(process.argv[1]);
const hashes = ["", "#stream", "#stream/inbox", "#stream/rules", "#stream/nope", "#agents/chord", "#agents/3d", "#agents", "#agents/nope",
"#artifacts/tree", "#artifacts/orbit", "#graph", "#graph/swimlane", "#graph/orbit", "#tree", "#bogus", "#agents%2Fswimlane",
"#agents/hierarchy", "#artifacts/hierarchy", "#graph/hierarchy", "#tree/hierarchy", "#stream/radio", "#radio",
"#tasks", "#tasks/board", "#tasks/rasci", "#tasks/orbit", "#agents/board", "#tasks/hierarchy", "#tasks/gantt", "#tasks/burn", "#agents/gantt"];
"#tasks", "#tasks/board", "#tasks/rasci", "#tasks/orbit", "#agents/board", "#tasks/hierarchy", "#tasks/gantt", "#tasks/burn", "#agents/gantt", "#tasks/3d"];
console.log(JSON.stringify({
tabs: N.TABS.map(([id, label]) => [id, label, (N.SUBS[id] || []).map(([sub, name]) => [sub, name])]),
views: N.VIEWS.map(([id, label]) => [id, label]),
@@ -37,10 +37,10 @@ class TestNavModule(unittest.TestCase):
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]]
self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]])
def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_five(self):
def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_six(self):
shared = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]
self.assertEqual((self.data["views"], self.data["artifacts"]), (shared, shared))
task_views = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"]]
task_views = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]]
self.assertEqual((self.data["taskViews"], self.data["tasks"]), (task_views,) * 2)
def test_only_agents_add_the_hierarchy_after_3d(self):
@@ -82,6 +82,7 @@ class TestNavModule(unittest.TestCase):
{"screen": "tasks", "view": "gantt", "redirect": None},
{"screen": "tasks", "view": "burn", "redirect": None},
{"screen": "agents", "view": None, "redirect": None},
{"screen": "tasks", "view": "3d", "redirect": None},
])
def test_every_screen_has_its_own_hash_and_tab(self):
+83 -4
View File
@@ -46,8 +46,33 @@ const scope = (choice) => {
return {tasks: found.rows.map((entry) => entry.task), from: found.from / H, to: found.to / H};
};
const series = (estimation) => P.burn(P.scoped(rows, "all", context).rows, {estimation, from: 70 * H, to: now}).points.map((point) => [point.t / H, point.scope, point.done, point.remaining, point.unestimated]);
const kept = P.scoped(rows, "all", context).rows;
const stepsOf = (from, to) => P.steps(kept, P.burn(kept, {estimation: "none", from, to}).points, from);
const eta = (estimation) => Object.fromEntries([...P.etas(rows, edges, {now, estimation}).entries()].map(([task, found]) => [task, [found.start === null ? null : found.start / H, found.eta === null ? null : found.eta / H, found.forecast]]));
const D = 86400;
const burnt = [
row("t/a", {status: "done", ended: true, start: 0, end: 2 * D}),
row("t/b", {status: "done", ended: true, start: 0, end: 4 * D}),
row("t/c", {status: "done", ended: true, start: 0, end: 6 * D}),
row("t/d", {status: "running", start: 9 * D}),
row("t/e", {}),
];
const burnPoints = P.burn(burnt, {estimation: "none", from: 0, to: 10 * D}).points;
const rising = P.burn([row("t/x", {}), row("t/y", {status: "running", start: 5 * D})], {estimation: "none", from: 0, to: 10 * D}).points;
const trendOf = (points, mode, days) => {
const found = P.trend(points, {mode, days});
return found && {start: found.start / D, forecast: found.forecast === null ? null : found.forecast / D, meets: found.forecast === null ? null : P.lineAt(found.lines.done, found.forecast) - P.lineAt(found.lines.scope, found.forecast), slope: found.lines.remaining.slope * D};
};
console.log(JSON.stringify({
options: [P.FORECASTS, P.ESTIMATIONS, P.SORTS.map(([id]) => id), P.SERIES],
options: [P.FORECASTS, P.ESTIMATIONS, P.TRENDS, P.SORTS.map(([id]) => id), P.SERIES, P.TRENDED],
burnt: burnPoints.map((point) => [point.t / D, point.remaining]),
trends: {
off: trendOf(burnPoints, "off", 7), regression: trendOf(burnPoints, "regression", 7), velocity7: trendOf(burnPoints, "velocity", 7), velocity30: trendOf(burnPoints, "velocity", 30),
velocity3: trendOf(burnPoints, "velocity", 3), rising: trendOf(rising, "regression", 7), risingVelocity: trendOf(rising, "velocity", 7), single: trendOf(burnPoints.slice(-1), "regression", 7),
beyondDates: 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}).forecast,
},
steps: {whole: stepsOf(70 * H, now), window: stepsOf(90 * H, 99 * H), burnt: P.steps(burnt, burnPoints, 0)},
etas: {none: eta("none"), points: eta("points"), loop: [...P.etas(loop, looped, {now, estimation: "none"}).keys()].sort()},
medians: P.medians(rows),
median: [P.median([]), P.median([3, 1, 2]), P.median([4, 1, 3, 2])],
bars: {noneAlways: lay("none", "always"), pointsAlways: lay("points", "always"), pointsOnly: lay("points", "points"), noneOnly: lay("none", "points"), real: lay("points", "real")},
@@ -67,12 +92,66 @@ class TestPlanModule(unittest.TestCase):
done = subprocess.run(["node", "-e", PLAN_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
def test_the_selectors_offer_the_forecasts_estimations_sort_modes_and_series(self):
forecasts, estimations, sorts, series = self.data["options"]
def test_the_selectors_offer_the_forecasts_estimations_trends_sort_modes_and_series(self):
forecasts, estimations, trends, sorts, series, trended = self.data["options"]
self.assertEqual(forecasts, [["always", "always"], ["points", "points only"], ["real", "real only"]])
self.assertEqual(estimations, [["none", "No estimate"], ["points", "Story points"]])
self.assertEqual(trends, [["off", "off"], ["regression", "linear regression"], ["velocity", "velocity"]])
self.assertEqual(sorts, ["prio", "wsjf", "value", "complexity", "status", "start", "end", "agent", "title"])
self.assertEqual(series, ["scope", "done", "remaining", "unestimated"])
self.assertEqual((series, trended), (["scope", "done", "remaining", "unestimated"], ["scope", "done", "remaining"]))
def test_the_regression_forecast_is_where_the_least_squares_line_of_remaining_reaches_zero_and_done_meets_scope(self):
self.assertEqual(self.data["burnt"], [[0, 4], [2, 3], [4, 2], [6, 1], [9, 2], [10, 2]])
found = self.data["trends"]["regression"]
self.assertEqual(found["start"], 0)
self.assertAlmostEqual(found["slope"], -86 / 461, places=9)
self.assertAlmostEqual(found["forecast"], 760 / 43, places=9)
self.assertAlmostEqual(found["meets"], 0, places=9)
def test_the_velocity_forecast_runs_the_mean_rate_of_the_last_n_days_from_the_last_point(self):
trends = self.data["trends"]
self.assertEqual({key: trends[key]["start"] for key in ("velocity7", "velocity30")}, {"velocity7": 3, "velocity30": 0})
self.assertAlmostEqual(trends["velocity7"]["forecast"], 24, places=9)
self.assertAlmostEqual(trends["velocity30"]["forecast"], 20, places=9)
self.assertAlmostEqual(trends["velocity7"]["slope"], -1 / 7, places=9)
self.assertAlmostEqual(trends["velocity7"]["meets"], 0, places=9)
def test_a_trend_that_never_falls_has_no_forecast_and_off_or_a_single_point_no_trend(self):
trends = self.data["trends"]
self.assertEqual((trends["velocity3"]["start"], trends["velocity3"]["forecast"]), (7, None))
self.assertGreater(trends["velocity3"]["slope"], 0)
self.assertEqual((trends["rising"]["forecast"], trends["risingVelocity"]["forecast"]), (None, None))
self.assertEqual((trends["off"], trends["single"]), (None, None))
def test_a_falling_trend_that_reaches_zero_only_after_the_last_date_has_no_forecast(self):
self.assertIsNone(self.data["trends"]["beyondDates"])
def test_each_point_lists_the_tasks_that_started_ended_or_joined_the_scope_at_its_step(self):
steps = self.data["steps"]
empty = {"started": [], "ended": [], "joined": []}
self.assertEqual(steps["burnt"], [
{"started": ["t/a", "t/b", "t/c"], "ended": [], "joined": ["t/e"]},
{**empty, "ended": ["t/a"]}, {**empty, "ended": ["t/b"]}, {**empty, "ended": ["t/c"]}, {**empty, "started": ["t/d"]}, empty,
])
self.assertEqual(steps["whole"], [
{**empty, "joined": ["work/next", "work/tail"]}, {**empty, "started": ["work/a"]}, {**empty, "ended": ["work/a"]}, {**empty, "started": ["work/b"]},
{**empty, "started": ["work/stop"]}, {**empty, "ended": ["work/stop"]}, {**empty, "ended": ["work/b"]}, {**empty, "started": ["work/c"]},
{**empty, "ended": ["work/c"]}, {**empty, "started": ["work/run"]}, empty,
])
self.assertEqual(steps["window"], [
{"started": ["work/a", "work/b", "work/c", "work/stop"], "ended": ["work/a", "work/b", "work/stop"], "joined": ["work/next", "work/tail"]},
{**empty, "ended": ["work/c"]}, {**empty, "started": ["work/run"]}, empty,
])
def test_the_eta_is_the_end_when_ended_else_start_or_now_behind_the_predecessors_plus_the_forecast_length(self):
ended = {"work/a": [80, 82, False], "work/b": [84, 88, False], "work/c": [90, 91, False], "work/stop": [85, 86, False], "operator/push": [None, 70, False]}
self.assertEqual(self.data["etas"]["none"], {
**ended, "work/run": [95, 97, True], "work/next": [100, 102, True], "work/tail": [102, 104, True], "queue/x": [100, 102, True],
})
self.assertEqual(self.data["etas"]["points"], {
**ended, "work/run": [95, 102.5, True], "work/next": [102.5, 107, True], "work/tail": [107, 109, True], "queue/x": [102.5, 104.5, True],
})
self.assertEqual(self.data["etas"]["loop"], ["loop/a", "loop/b"])
def test_medians_take_done_tasks_only_and_the_points_of_those_with_points(self):
self.assertEqual(self.data["medians"], {"duration": 2 * HOUR, "perPoint": 1.5 * HOUR})
+40
View File
@@ -150,6 +150,23 @@ class TestHubSettings(HubCase):
self.assertEqual(status, 200, body)
self.assertEqual(json.loads(body)["settings"]["speech"], {"on": False, "classes": ["broadcast", "mayday", "security"], "muted": ["Operator", "name:Docs", "s1"]})
def test_the_radio_gear_stores_tones_squelch_and_read_aloud_without_a_passkey_and_keeps_every_other_setting(self):
current = self.snapshot()["settings"]
gear = {
**current, "sounds": {**current["sounds"], "radio": {"on": False, "volume": 0.4}}, "squelch": 0.35,
"speech": {"on": True, "classes": ["post", "mayday"], "muted": ["s1"]},
}
self.assertIsNone(self.prepared(gear)["payload"])
status, _, body = self.post_json("/api/settings", {**gear, "proof": None}, self.guard(self.host))
self.assertEqual(status, 200, body)
self.assertEqual(json.loads(body)["settings"], gear)
self.assertEqual(settings.Settings(self.state / "settings.json").get(), gear)
self.assertEqual(self.snapshot()["settings"], gear)
for broken in ({**gear, "squelch": 1.5}, {**gear, "sounds": {**gear["sounds"], "radio": {"on": "no", "volume": 0.4}}}, {**gear, "speech": {**gear["speech"], "classes": ["post"]}}):
with self.subTest(broken=broken):
self.assertEqual(self.post_json("/api/settings", broken, self.guard(self.host))[0], 400)
self.assertEqual(self.snapshot()["settings"], gear)
def test_the_estimation_mode_defaults_to_no_estimate_is_stored_and_survives_a_reload(self):
self.assertEqual((settings.SETTINGS["estimation"], self.snapshot()["settings"]["estimation"]), ("none", "none"))
good = self.guard(self.host)
@@ -166,6 +183,29 @@ class TestHubSettings(HubCase):
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
self.assertEqual(settings.Settings(self.state / "settings.json").get()["estimation"], "none")
def test_the_trend_defaults_to_off_over_seven_days_is_validated_stored_and_survives_a_reload(self):
self.assertEqual((settings.SETTINGS["trend"], self.snapshot()["settings"]["trend"]), ({"mode": "off", "days": 7}, {"mode": "off", "days": 7}))
good = self.guard(self.host)
wrongs = [{"mode": mode, "days": 7} for mode in ("linear", "Regression", "", None, 1)]
wrongs += [{"mode": "velocity", "days": days} for days in (0, 366, 1.5, True, "7", None, -3)]
wrongs += [{"mode": "velocity"}, {"days": 7}, {"mode": "velocity", "days": 7, "extra": 1}, "velocity", ["velocity", 7]]
for wrong in wrongs:
with self.subTest(wrong=wrong):
self.assertEqual(self.post_json("/api/settings", {**settings.SETTINGS, "trend": wrong}, good)[0], 400)
self.assertEqual(self.post_json("/api/settings", {key: value for key, value in settings.SETTINGS.items() if key != "trend"}, good)[0], 400)
self.assertEqual(self.snapshot()["settings"]["trend"], {"mode": "off", "days": 7})
for chosen in ({"mode": "regression", "days": 7}, {"mode": "velocity", "days": 1}, {"mode": "velocity", "days": 365}, {"mode": "velocity", "days": 14}):
with self.subTest(chosen=chosen):
self.assertIsNone(self.prepared({**self.snapshot()["settings"], "trend": chosen})["payload"])
status, _, body = self.post_json("/api/settings", {**self.snapshot()["settings"], "trend": chosen, "proof": None}, good)
self.assertEqual((status, json.loads(body)["settings"]["trend"]), (200, chosen))
self.assertEqual(json.loads((self.state / "settings.json").read_text(encoding="utf-8"))["trend"], {"mode": "velocity", "days": 14})
self.assertEqual(settings.Settings(self.state / "settings.json").get()["trend"], {"mode": "velocity", "days": 14})
self.assertEqual(self.snapshot()["settings"]["trend"], {"mode": "velocity", "days": 14})
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "trend"}
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")
self.assertEqual(settings.Settings(self.state / "settings.json").get()["trend"], {"mode": "off", "days": 7})
def test_settings_written_before_the_radio_filter_keep_their_values(self):
older = {key: value for key, value in self.settings_payload(STREAMS).items() if key != "speech"}
(self.state / "settings.json").write_text(json.dumps(older), encoding="utf-8")