From a2acc076a59790e7ea1ce2d8a7c77a2065ed6b63 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:49:44 +0800 Subject: [PATCH] workflow: the list row says how many runs are live, and a read-only mount writes nothing (#2222) A live list row drew a bare colour with no number, so a read-only reader could not tell one running run from five; the row now carries the count beside the dot and in its accessible name. The mount effect also decided whether to ingest from `locked` as its first render saw it, so a window that turned read-only after the list mounted still sent `workflow/ingest` for every open card, a write the host refuses. It now reads the latest value at each step. --- changelog.d/2222-list-live-count.fixed.md | 1 + docs/subsystems/workflow.md | 4 +- plugins/workflow/client.js | 21 ++- plugins/workflow/test/client.test.js | 169 ++++++++++++++++++++++ 4 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 changelog.d/2222-list-live-count.fixed.md diff --git a/changelog.d/2222-list-live-count.fixed.md b/changelog.d/2222-list-live-count.fixed.md new file mode 100644 index 000000000..9a1406cee --- /dev/null +++ b/changelog.d/2222-list-live-count.fixed.md @@ -0,0 +1 @@ +A workflow row with live runs now says how many: the number sits beside the dot and is part of the row's accessible name, so a reader who cannot see the colour can tell one running run from five; an idle row draws no dot, no count and no digit. Opening the list in a read-only window no longer sends `workflow/ingest` for every open card — the Project filter still fills, and a writable window still ingests every open card (#2222). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 066147c77..a5507d67c 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -41,7 +41,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | | `plugins/workflow/src/template.js` | Factory delivery/weekly graphs plus `EARLY_END_GUIDANCE` for Claim/Verify/Triage early-end markers; 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from `--bt-ink-2` rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core `--bt-*` tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from `--bt-accent`, #1654). The stage lists the tasks the open workflow holds (`heldTasks`, with a `{count} more held tasks` summary when `heldTaskCount` exceeds the cap), each with one **Re-match** action that drops that task's claim and matches it again through `workflow/ingest` with `rematch: true` — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, `parallelFrameStyle` frame, one-fold `wirePath`), and one that cannot is laid out in `flowLayout`'s own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the `flowWires` routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's `flowLayout` column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; `parallelFrameStyle` adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). | +| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. A row with live runs draws the number beside the dot and carries `{name} · {count} running` as its accessible name; an idle row draws no dot, no count and no digit, so its name stays its own text (#2222). The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. A stage control that fails — a graph save, Reset, Pause, Resume or Stop that throws, rejects, answers `{ok:false}` or does not answer within 10 s — ends on the section's `.wf-err` alert instead of being swallowed, and a success clears the alert. The flowchart's node role sub-label takes its colour from `--bt-ink-2` rather than a hardcoded grey, so it stays readable on every theme's node card (#1495). The drawer's styling reuses the core `--bt-*` tokens and the Bot details drawer's patterns (title, pill tabs, soft pill buttons, bubble-surfaced node cards; accent tints mixed from `--bt-accent`, #1654). The stage lists the tasks the open workflow holds (`heldTasks`, with a `{count} more held tasks` summary when `heldTaskCount` exceeds the cap), each with one **Re-match** action that drops that task's claim and matches it again through `workflow/ingest` with `rematch: true` — one board read to hand over the card's fields and one state read after, never a second polling loop (#1647). The drawer's Edit plane keeps one copy of the row geometry: a graph whose parallel cards still fit is the fitted plane (scaled centres, `parallelFrameStyle` frame, one-fold `wirePath`), and one that cannot is laid out in `flowLayout`'s own rows — its cards are those boxes, its dashed frame is that band's frame, and its wires are the `flowWires` routing around them, so a 3+ way step at a 364px drawer keeps its frame inside the panel and no wire crosses a card (#1693). A two-Bot parallel step the fitted plane would squeeze — the drawer's fit leaves the pair 4px apart, with each card's out-port (5px) and remove button (8px) past its right edge under the neighbour — is drawn at that row's `flowLayout` column width (154px at a 364px drawer) on its fitted centres and wires, keeping the 12px row gap and a plane whose drags still commit world coordinates; `parallelFrameStyle` adds the 20px label band above the cards and a 10px pad below them, so the fitted frame encloses every card it names (#1738). | ## Entry points @@ -54,7 +54,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. 2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`, but only while **Edit** is on — a factory preset is copied first (#1378). A wire or a reconnect that would list a pair the graph already carries commits nothing new, because `normalizeGraph` refuses a graph that lists one pair twice and then saves none of it: unguarded, the plane would show no edge, no change and only a save error (#2163). Status highlighting reads that same graph. Replacing edges does not recompute who is already running. The Settings canvas paints that graph on the same 960px design plane, scaled to the width the settings page gives it, so the canvas is not a second scroll container inside the page's own scroll; its pointer deltas are divided by the scale, and its wires keep a stronger stroke than a plain divider so an edge can be followed at that size (#1483). - While another dsh holds the Bot team the host is read-only (`roster.readOnly`): the page still reads through `workflow/state`, keeps its controls disabled, and offers no **Edit**, **Add link** or wire port, so it cannot take a change that core would refuse with `bot/read-only` and then drop (#2187). The same window draws the sidebar's workflow list without **New**, the row's **Rename**/**Delete** or **Hand a task to a Workflow**: the list is read-only too, and an `roster.readOnly` that is not a string, or holds only whitespace, counts as writable — trimmed the way `core/src/client/workflows.js:570` trims it (#2200). The Stage a row opens carries the same lock: it prints the host's sentence under `role="status"` and draws no **Hand a task to a Workflow**, no **Re-match**, no **Pause**/**Resume**/**Stop**, no **Edit**/drag palette and no **Reset**, while the row click, **Progress**, **Flow** and viewing a run still work; a writable roster draws every control exactly as before (#2213). The Settings page draws no read-only sentence of its own: the host's Settings header already prints one for every page it shows (`core/src/client/settings.js` `bt-read-only`, `role="status"`, `hostText(roster.readOnly)`), and a second copy inside the page said the same thing twice (#2202). + While another dsh holds the Bot team the host is read-only (`roster.readOnly`): the page still reads through `workflow/state`, keeps its controls disabled, and offers no **Edit**, **Add link** or wire port, so it cannot take a change that core would refuse with `bot/read-only` and then drop (#2187). The same window draws the sidebar's workflow list without **New**, the row's **Rename**/**Delete** or **Hand a task to a Workflow**: the list is read-only too, and an `roster.readOnly` that is not a string, or holds only whitespace, counts as writable — trimmed the way `core/src/client/workflows.js:570` trims it (#2200). The Stage a row opens carries the same lock: it prints the host's sentence under `role="status"` and draws no **Hand a task to a Workflow**, no **Re-match**, no **Pause**/**Resume**/**Stop**, no **Edit**/drag palette and no **Reset**, while the row click, **Progress**, **Flow** and viewing a run still work; a writable roster draws every control exactly as before (#2213). Mounting the list in a read-only window still reads `projects` so the Project filter fills, but it sends no `workflow/ingest` for the open cards: ingest is a claim/match write the host refuses, so a reader opening the list writes nothing; a writable window still ingests every open card (#2222). The read-only sentence can also land after the list has mounted — a cold window starts on the empty roster, `ready: false` and no `readOnly` — so the ingest step asks the value the latest render saw rather than the one the mount saw, and it skips the board read and stops between two cards once the window has turned read-only. The Settings page draws no read-only sentence of its own: the host's Settings header already prints one for every page it shows (`core/src/client/settings.js` `bt-read-only`, `role="status"`, `hostText(roster.readOnly)`), and a second copy inside the page said the same thing twice (#2202). 3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. **Early termination (#1537, #1538, #1546, #1570, #1571):** When a node completes **or fails**, the run ends immediately with status `done`, sets `run.earlyEnd` to the condition name, and does not advance to successors, take a success-path `loop`, or burn `onFail` recovery attempts. The two markers that report the task itself end it only on a structured signal (#1546): `needs-human` and `already-claimed` must be a declared OUTPUT whose value **equals** the marker, or a whole reply line that is exactly `EARLY-END ` in any case. A step that quotes an issue title, negates the label or reports it absent keeps running. `tip-unchanged` (#1570) and `open-pr` (#1571) accept that same structured signal — in a declared OUTPUT the value counts as a whole word, so a brief that names `data-open-project` is not `open-pr` (#1866) — and also honor their prose fallbacks (`tip unchanged` / `unchanged tip`, `open PR already` / `already has an open PR` / `has an open PR` / `found an open PR`), so a Bot that forgets the structured marker cannot soft-lock Claim↔Verify on the same tip. See `checkEarlyEnd` in Key files. Factory prompt guidance lives in `EARLY_END_GUIDANCE` (`plugins/workflow/src/template.js`) for Settings graphs that wire Triage / Claim / Verify: Verify must escalate once on an unchanged tip, not re-run the full suite. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs — the running ones, plus the last run when it is still `live` and ended `failed` — a failed sibling stays on the stage beside whatever is running now, with `run.failure` (`{ code, at, nodeName?, text? }`) naming what ended it, so the room does not have to open Runs to learn that a run failed (#1493, #1494). The failed sibling is left out when a running run carries the same task id: that is a retry, and the live run keeps the row (#1507). History entries carry the run `id` and the same `failure`, so a Runs row shows when the run ended and why, and a row for a run already on the stage is not listed twice. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). `workflow/reset` stopping one live run ends it the same way before its Bots are released: nodes still `running` become `failed`, so the strip cannot keep painting a run that is over (#1424). A graph edit that drops a node that is still running ends that run the way Stop does: the node is failed, the run is archived `failed`, its node Bots are released, and the queue drains (#1549); an id whose release failed stays named on that archived run, and the next load's `reap` retries it, instead of the edit forgetting it (the #1616 contract). Every wait is bounded (#1530). A dispatch that does not land is not marked handed: the chat names the code and the attempt, and it is retried after a short backoff at most twice, never twice inside one hand-off, so a 429 is not hammered. When it still does not land, the step and the run fail with the reason written into the chat. Each running live step also holds a deadline — its workflow's `stepTimeoutMinutes`, `DEFAULT_STEP_TIMEOUT_MINUTES` (45 minutes) unless the record sets one, an integer from 1 to 120 given at create, through `define_workflow`, or through `workflow/step-timeout` (armed even when the host cannot spawn ephemeral Bots; #1530, #1580); when it expires the node and the run fail as `failed`, the run's ephemeral Bots are released, the chat and `run.failure` say the step's deadline was reached (`step deadline reached (45 min)`, the number its lane set), and `warn` names it once; the line never reads as a reply that did not arrive, because a step still working is killed by the same timer (#1868). The deadline is cleared as soon as the step moves, and by Pause, Stop, Reset, delete, `reap` and `dispose`; a paused run holds none, and Resume arms it again from that moment. A timer never outlives the run that started it. A reply that matches no running step — its run ended, or the node already moved — is reported through `warn` once per Bot and run instead of being dropped silently. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. Stop and Reset (`workflow/reset`) follow the same order as `dispose`: they release the run's Bots before the record forgets their ids, and an id whose release failed stays named on the still-live run, so the next Stop, Reset, dispose or load-time `reap` retries it (#1476). A run left that way is marked `paused` and `releasePending` and shows as Paused: it hands off no further node, `resume` is refused with an error, and Stop again is the retry. A whole-workflow Reset that could not release every Bot freezes every run it did not reset, because each of them has lost the ids whose release did go. The markers go away when the run ends: Stop again, `dispose`, `reap`, or its own completion. 4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record, via `workflow/concurrency`, or in Settings → Workflow graphs). A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card with its own `status` (never a hardcoded Running), the chip count does not exceed that list, and a failed sibling appears with its time and reason. On the Runs list, the clock stays in a non-shrinking column with a gap before the status chip, and a Failed row paints the Failed chip once — a generic Failed with no host detail does not also draw `wf-hist-reason` (#1618). 5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input : `. The Bot reports `OUTPUT : `; a single declared output may be the reply body. A declared output a gate reads is required (§3): when a `when`/`else` or `until` edge reads it and the step's reply does not produce it, the step fails with `failure.text` `Missing OUTPUT ` instead of settling, so the gate never reads silence as "not this branch" and the run cannot close `done` on it; a delivered value that does not equal `eq` still completes the step and skips the untaken branch (#1773). Nodes start in edge order. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 3d5740a65..90eed30ee 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -37,6 +37,7 @@ window.__ModuleLoader__.load({ 'One Bot (as before)': '单个 Bot(原有方式)', 'Concurrent runs': '同时运行', '{count} running · queue {depth}': '{count} 个在运行 · 队列 {depth}', + '{name} · {count} running': '{name} · {count} 个在运行', Reset: '重置', Flow: '流程图', Details: 'Details', @@ -1953,6 +1954,11 @@ window.__ModuleLoader__.load({ // `bot/read-only`, so the reader would press a control that cannot work (#2200). const readOnlyReason = typeof roster?.readOnly === 'string' ? roster.readOnly.trim() : '' const locked = readOnlyReason !== '' + // #2222: the roster can land after this section mounts -- a cold window starts on the + // empty roster and the read-only sentence arrives a tick later -- so the mount effect + // below asks what the latest render saw rather than what its first render baked in. + const lockedRef = useRef(locked) + lockedRef.current = locked const stageCall = (name, body, fallback) => settledCall(call, name, body, fallback) @@ -1981,11 +1987,17 @@ window.__ModuleLoader__.load({ } catch (loadError) { void loadError } + // #2222: a read-only window must not write. The board read below only feeds + // `workflow/ingest`, a claim/match write the host refuses; the Project read + // above stays so the Project filter still fills. + if (lockedRef.current) return try { const rows = await host('tasks', {}) if (!Array.isArray(rows) || gone) return for (const task of rows) { if (!task || task.status !== 'open') continue + // The same late roster can land between two cards. + if (lockedRef.current) return const value = await call('workflow/ingest', task) if (gone) return if (value?.workflows) setPack(value) @@ -2195,6 +2207,9 @@ window.__ModuleLoader__.load({ workflows.length > 0 && shown.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows in this Project')) : null, shown.map(item => h('button', { key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, + // A row with live runs says how many: the dot is colour only, and a + // reader who cannot see it still needs the number (P-433). + 'aria-label': (item.activeCount ?? 0) > 0 ? tr('{name} · {count} running', { name: item.name, count: item.activeCount }) : undefined, onClick: () => { setOpenId(item.id) }, onContextMenu: locked ? undefined : (event) => { event.preventDefault() @@ -2207,7 +2222,9 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow'), h('span', { className: `wf-proj${projectName(item.projectId) ? '' : ' none'}` }, projectName(item.projectId) || tr('Unassigned'))), h('span', { className: 'wf-t2' }, sidebarPreview(item))), - (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live', 'aria-hidden': true }) : null)), + (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live-wrap', 'aria-hidden': true }, + h('span', { className: 'wf-live' }), + h('span', { className: 'wf-live-n' }, String(item.activeCount))) : null)), locked ? null : h('button', { type: 'button', className: 'wf-row wf-assign-row', onClick: () => { setAssignOpen(true); void loadTasks() } }, h('span', { className: 'wf-ico' }, glyph()), h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), @@ -2320,6 +2337,8 @@ window.__ModuleLoader__.load({ .wf-t2,.wf-chain{display:block;font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .wf-badge{font-size:9.5px;padding:0 5px;border-radius:4px;background:color-mix(in srgb,var(--bt-accent,#4d6bfe) 12%,transparent);color:var(--bt-accent,#4d6bfe);line-height:15px} .wf-live{width:7px;height:7px;border-radius:50%;background:var(--bt-accent,#4d6bfe);flex:none} +.wf-live-wrap{display:flex;align-items:center;gap:4px;flex:none} +.wf-live-n{font-size:10px;font-weight:650;line-height:1;color:var(--bt-accent,#4d6bfe)} .wf-ico{width:32px;height:32px;border-radius:9px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));display:grid;place-items:center;color:var(--bt-accent,#4d6bfe);flex:none;overflow:hidden;padding:2px;box-sizing:border-box} .wf-mini-flow{display:block;max-width:100%;max-height:100%} .wf-mini-flow-compact{width:100%;height:100%} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 2de8a7870..b60d5b85a 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -632,6 +632,175 @@ test('sidebar preview ignores a stale busy preview when nothing is live', async view.unmount() }) +// #2222 A: the list row's live mark is a bare colour with no number, so a +// read-only reader -- whose only live signal is this view -- cannot tell one +// running run from five. The row draws the count beside the dot and carries it +// in its accessible name; a quiet row draws neither. +test('#2222: a live workflow row says how many runs are live', async () => { + const graph = delivery() + graph.activeCount = 3 + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + try { + await settle() + const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0] + assert.ok(row, 'the sidebar draws the workflow row') + + const count = findAll(view.tree, node => node.props?.className === 'wf-live-n') + assert.equal(count.length, 1, 'one count stands beside the live dot') + assert.equal(textOf(count[0]), '3', 'the count is the number of live runs') + assert.ok(String(row.props['aria-label'] ?? '').includes('3'), 'the accessible name says how many runs are live') + + row.props.onClick() + await settle() + const opened = findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0] + assert.ok(String(opened?.props.className).includes('on'), 'the row still opens the workflow') + } finally { + view.unmount() + } +}) + +// #2222 A: the count is the only thing a read-only window can act on -- its +// reader cannot start or stop a run -- so the row keeps it while every write +// entry is gone. +test('#2222: a read-only window still reads how many runs are live', async () => { + const graph = delivery() + graph.activeCount = 2 + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [], readOnly: 'Another window is using this Bot' }, call, actions: {} }) + try { + await settle() + const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0] + assert.ok(row, 'the read-only sidebar still lists the workflow') + assert.ok(String(row.props['aria-label'] ?? '').includes('2'), 'the read-only row still names the live count') + assert.equal(textOf(findAll(view.tree, node => node.props?.className === 'wf-live-n')[0]), '2') + assert.equal(findAll(view.tree, node => node.type === 'button' && textOf(node) === 'New').length, 0, 'the window is locked') + } finally { + view.unmount() + } +}) + +// #2222 A: an idle row keeps its own text as the accessible name and draws no +// dot, no count and no digit. +test('#2222: a workflow row with nothing live draws no count and no live name', async () => { + const graph = delivery() + graph.activeCount = 0 + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + try { + await settle() + const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes(graph.name))[0] + assert.ok(row, 'the sidebar draws the workflow row') + assert.equal(findAll(view.tree, node => node.props?.className === 'wf-live' || node.props?.className === 'wf-live-n').length, 0, 'no dot and no count when nothing is live') + assert.equal(row.props['aria-label'], undefined, 'the row keeps its own text as the name') + assert.equal(/\d/.test(textOf(row)), false, 'an idle row carries no digit') + } finally { + view.unmount() + } +}) + +// #2222 B: opening the list in a read-only window used to write +// `workflow/ingest` once per open card -- a write the reader never asked for, +// and one the host refuses. The Project filter still fills (its read stays). +test('#2222: a read-only mount sends no workflow/ingest, a writable one still ingests every open card', async () => { + const graph = delivery() + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const tasks = [ + { id: 'task-1', title: 'one', status: 'open' }, + { id: 'task-2', title: 'two', status: 'open' }, + { id: 'task-3', title: 'three', status: 'open' }, + { id: 'task-4', title: 'accepted already', status: 'accepted' }, + ] + const hostCalls = [] + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => { + hostCalls.push(channel) + if (channel === 'tasks') return tasks + if (channel === 'projects') return { projects: [] } + return [] + }, + } + try { + const readOnly = mount(client.Section, { roster: { bots: [], readOnly: 'Another window is using this Bot' }, call, actions: {} }) + await settle() + assert.equal(calls.filter(item => item.name === 'workflow/ingest').length, 0, 'a read-only mount writes nothing') + assert.ok(hostCalls.includes('projects'), 'the Project filter still fills') + readOnly.unmount() + + const writable = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const ingests = calls.filter(item => item.name === 'workflow/ingest') + assert.equal(ingests.length, 3, 'a writable mount still ingests every open card') + assert.deepEqual(ingests.map(item => item.body?.id), ['task-1', 'task-2', 'task-3']) + writable.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + +// #2222 B: the roster lands after the mount. A cold window starts on the empty +// roster (`ready: false`, no `readOnly`) and the read-only sentence arrives a +// tick later, so an effect that baked `locked` in on its first render kept +// writing after the window had gone read-only -- three `workflow/ingest` calls +// for three open cards, every one of them refused by the host. +test('#2222: a read-only roster that arrives after the mount still sends no workflow/ingest', async () => { + const graph = delivery() + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const tasks = [ + { id: 'task-1', title: 'one', status: 'open' }, + { id: 'task-2', title: 'two', status: 'open' }, + { id: 'task-3', title: 'three', status: 'open' }, + ] + let releaseProjects + const projectsGate = new Promise((resolve) => { releaseProjects = resolve }) + const hostCalls = [] + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => { + hostCalls.push(channel) + if (channel === 'projects') return projectsGate + if (channel === 'tasks') return tasks + return [] + }, + } + try { + const view = mount(client.Section, { roster: { bots: [], ready: false }, call, actions: {} }) + await settle() + assert.equal(hostCalls.includes('projects'), true, 'the mount reads the board so the Project filter can fill') + + // The read-only sentence lands while that read is still in flight. + view.rerender({ roster: { bots: [], readOnly: 'Another window is using this Bot' } }) + releaseProjects({ projects: [] }) + await settle() + + assert.equal(calls.filter(item => item.name === 'workflow/ingest').length, 0, 'a roster that went read-only mid-mount writes nothing') + assert.equal(hostCalls.includes('tasks'), false, 'the board read that only feeds ingest is skipped') + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + test('two concurrent live runs each show independent progress strips', () => { const workflow = { runs: [