From dde974485db3e4859f9e1ee0f2354890b0254987 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:06:41 +0800 Subject: [PATCH] workflow: a lane's held tasks page past 20 through a control, not a dead line --- changelog.d/2256-held-tasks-page.fixed.md | 1 + docs/subsystems/workflow.md | 6 +- plugins/workflow/client.js | 35 ++++++-- plugins/workflow/index.js | 2 +- plugins/workflow/src/service.js | 20 ++++- plugins/workflow/test/client.test.js | 100 ++++++++++++++++++++++ plugins/workflow/test/held-page.test.js | 83 ++++++++++++++++++ 7 files changed, 233 insertions(+), 14 deletions(-) create mode 100644 changelog.d/2256-held-tasks-page.fixed.md create mode 100644 plugins/workflow/test/held-page.test.js diff --git a/changelog.d/2256-held-tasks-page.fixed.md b/changelog.d/2256-held-tasks-page.fixed.md new file mode 100644 index 000000000..a26425d90 --- /dev/null +++ b/changelog.d/2256-held-tasks-page.fixed.md @@ -0,0 +1 @@ +A workflow lane that holds more cards than its page listed now offers the rest: `workflow/state` takes a `heldLimit` (20 by default, 200 at most), and the stage's dead `3 more held tasks` line is a **Show 3 more held tasks** button that unfolds every held id — each still re-matchable — and folds back to **Show less** (#2256). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 55260ae37..65c82f05d 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -38,12 +38,12 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | --- | --- | | `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that ingests a step result and denies delivery to persistent Bot chats. | | `plugins/workflow/src/graph.js` | Nodes, edges, positions, and named inputs and outputs. A node starts when its forward predecessors are settled and a taken edge reaches it. Same-rank nodes run together. A forward cycle is refused. A loop edge is the only cycle for success paths. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. An `onFail` edge provides failure recovery: when a node fails with recoveries remaining, the workflow resets the body from the upstream target through the failing node and re-runs it in order (tracked by `failAttempts` per edge, with `max` counting recoveries, 1–8, default 3). After the recoveries are used, the node fails permanently. | -| `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes and the task claims that named it — a claim is judged on its own record (`kind === 'workflow'` and that id), not by the route map, which its own cleanup has already emptied (#1632); a run Bot whose release failed is kept in the store's own `orphans` list — not in a variable — and the load-time `reap` retries it, once per id, capped, so deleting a workflow whose Bot will not go does not strand that Bot under a name nothing can reach (the #1616 contract). Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin ends every live run still marked `running` (nodes in flight are failed, the run is archived) and then releases every run Bot and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown cannot persist a run as `running` with no ephemeral map and cannot leave hidden Bots that no state names, for every live run in a lane rather than only the one archived last (#1387, #1414, #1472). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed: it is retried twice with a short backoff, and a step still unanswered after its workflow's `stepTimeoutMinutes` (45 minutes by default) fails the run and releases its Bots (#1530, #1580). A stored claim keeps a task only while the card names nothing new: when the card's own fields name another live workflow or Bot, the claim and its route are dropped and the card is matched again, and `rematch: true` on `ingest` drops that one claim first (#1647). `workflow/state` carries, per workflow, the ids it holds — `heldTasks` capped at 20 sorted ids beside `heldTaskCount` — and a Bot claim is not listed there (#1647). Each public run also carries `finishedAt` (`null` while the run is live, else the moment it ended) and `currentStep` (`{ id, name }` of the node it walks now, else `null`) — pure additions for the Inbox, which reads them off `workflow/state` (#1706); a paused run keeps `status: 'running'` with `paused: true`, so it is not finished and still names its node. An assignment carries its instruction: `assign` reads `brief` — multi-line, up to 8000 characters, kept whole rather than flattened into the 300-character title — onto `task.brief`, a parked card keeps it in `workflow/state` and in `workflows.json`, and `stepBody` hands the node `task.brief`, falling back to the title only when there is none; before #1869 the node received the title twice and the brief never. `runNamed` gives a brief the same meaning and derives the title from its first line, and a field `assign` does not read comes back in `ignored` instead of disappearing (#1869). | +| `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes and the task claims that named it — a claim is judged on its own record (`kind === 'workflow'` and that id), not by the route map, which its own cleanup has already emptied (#1632); a run Bot whose release failed is kept in the store's own `orphans` list — not in a variable — and the load-time `reap` retries it, once per id, capped, so deleting a workflow whose Bot will not go does not strand that Bot under a name nothing can reach (the #1616 contract). Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin ends every live run still marked `running` (nodes in flight are failed, the run is archived) and then releases every run Bot and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown cannot persist a run as `running` with no ephemeral map and cannot leave hidden Bots that no state names, for every live run in a lane rather than only the one archived last (#1387, #1414, #1472). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed: it is retried twice with a short backoff, and a step still unanswered after its workflow's `stepTimeoutMinutes` (45 minutes by default) fails the run and releases its Bots (#1530, #1580). A stored claim keeps a task only while the card names nothing new: when the card's own fields name another live workflow or Bot, the claim and its route are dropped and the card is matched again, and `rematch: true` on `ingest` drops that one claim first (#1647). `workflow/state` carries, per workflow, the ids it holds — `heldTasks`, a page of 20 sorted ids beside the whole `heldTaskCount` — and a Bot claim is not listed there (#1647); the read takes a `heldLimit` (20 by default, 200 at most, any other value falls back to 20), so a reader can ask for the rest of the page without turning the poll into a full read (#2256). Each public run also carries `finishedAt` (`null` while the run is live, else the moment it ended) and `currentStep` (`{ id, name }` of the node it walks now, else `null`) — pure additions for the Inbox, which reads them off `workflow/state` (#1706); a paused run keeps `status: 'running'` with `paused: true`, so it is not finished and still names its node. An assignment carries its instruction: `assign` reads `brief` — multi-line, up to 8000 characters, kept whole rather than flattened into the 300-character title — onto `task.brief`, a parked card keeps it in `workflow/state` and in `workflows.json`, and `stepBody` hands the node `task.brief`, falling back to the title only when there is none; before #1869 the node received the title twice and the brief never. `runNamed` gives a brief the same meaning and derives the title from its first line, and a field `assign` does not read comes back in `ignored` instead of disappearing (#1869). | | `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. `node.tools` names the connectors that node may call: `spawn` passes the cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows only the connectors it names (`github` covers `mcp__github__*`, `composio` covers `mcp__composio__*`) — a node that names none reaches none. File and shell rights do not come from that list: every node Bot gets the `danger-full-access` preset a normal sidebar Bot has, and `EPHEMERAL_DENIED_TOOLS` still refuses the team tools at the guard and approval layers. The list is also stored in `fields.tools` for the UI. | | `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; both of its choice lists draw twelve at a time and a `Show {count} more cards` / `Show {count} more Bots` row says how many they are not showing, expanding to the rest and folding back to `Show less`, so no card or local Bot is out of reach while the default view keeps its length, #2244), 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 the count, with the word `running`, in a visually hidden `.wf-sr` span — the dot and the digit sit inside an `aria-hidden` wrap, so the number alone would be silent to a reader — and its accessible name therefore stays the name its content gives: the lane's name, the `Workflow` badge, its Project and its preview, plus the count; a row-level `aria-label` replaced all of that and made two lanes that share a name read alike (#2222, #2230). An idle row draws no dot, no count, no digit and no hidden text, so its name stays its own text. 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; both of its choice lists draw twelve at a time and a `Show {count} more cards` / `Show {count} more Bots` row says how many they are not showing, expanding to the rest and folding back to `Show less`, so no card or local Bot is out of reach while the default view keeps its length, #2244), 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 the count, with the word `running`, in a visually hidden `.wf-sr` span — the dot and the digit sit inside an `aria-hidden` wrap, so the number alone would be silent to a reader — and its accessible name therefore stays the name its content gives: the lane's name, the `Workflow` badge, its Project and its preview, plus the count; a row-level `aria-label` replaced all of that and made two lanes that share a name read alike (#2222, #2230). An idle row draws no dot, no count, no digit and no hidden text, so its name stays its own text. 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`; when `heldTaskCount` exceeds that page the rest is a `Show {count} more held tasks` control — a `wf-more` button with `data-more="held"` and `aria-expanded` — that reads the wider page with `heldLimit: 200` and folds back to `Show less`, so the ids behind the cap are reachable instead of being named and dropped, #2256), 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 @@ -121,7 +121,7 @@ conclusion about the pull request. ## Tests -`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow `stepTimeoutMinutes` that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), the stage chat's scroll rule (a new visible line follows, a filtered node reply moves nothing), the assign dialog's preselected card and its failed and empty board states (#1457, #1458), a sidebar click that leaves the Workflows section in place, and the claim rules — a re-routed card moves with one claim and one route, the same target writes nothing, a trigger never invalidates, the held list is capped and omits a Bot claim, `rematch: true` drops one claim, and the panel's one re-match action goes through `ingest` (#1647), and the assigned brief reaching the step prompt while a task without one still gets its title, a parked card keeping its brief on disk, and an unknown `assign` field coming back in `ignored` (#1869). Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`, which also pins that a card written through `track_tasks` reaches the listener (#1462). The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`. +`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow `stepTimeoutMinutes` that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), the stage chat's scroll rule (a new visible line follows, a filtered node reply moves nothing), the assign dialog's preselected card and its failed and empty board states (#1457, #1458), a sidebar click that leaves the Workflows section in place, and the claim rules — a re-routed card moves with one claim and one route, the same target writes nothing, a trigger never invalidates, the held list is capped and omits a Bot claim, the held page answering `heldLimit` up to its 200 cap and the stage unfolding it behind its `wf-more` row (#2256), `rematch: true` drops one claim, and the panel's one re-match action goes through `ingest` (#1647), and the assigned brief reaching the step prompt while a task without one still gets its title, a parked card keeping its brief on disk, and an unknown `assign` field coming back in `ignored` (#1869). Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`, which also pins that a card written through `track_tasks` reaches the listener (#1462). The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`. ## Connector ceiling (#1544) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index ad1eba4da..523e0352f 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -148,6 +148,7 @@ window.__ModuleLoader__.load({ 'Show less': '收起', 'Show {count} more cards': '展开另外 {count} 张卡片', 'Show {count} more Bots': '展开另外 {count} 个 Bot', + 'Show {count} more held tasks': '展开另外持有中的 {count} 个任务', // Stage timeline (#1489). The key is the English line; service.js and // narrate.js keep the Chinese text only as the fallback for rows written // before the key existed. @@ -553,11 +554,17 @@ window.__ModuleLoader__.load({ } // Stage: view/control only — progress and flowchart; no message composer (#1459). - function Stage({ workflow, roster, locked, readOnlyReason, stageLeft, drawer, tab, focusedRunId, onFocusRun, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop, onRematch }) { + function Stage({ workflow, roster, locked, readOnlyReason, stageLeft, drawer, tab, focusedRunId, onFocusRun, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop, onRematch, onHeldMore }) { const chatRef = useRef(null) const chatFoot = useRef({ height: 0, shown: 0 }) const liveRuns = liveStageRuns(workflow) const held = workflow.heldTasks ?? [] + // #2256: the id list is a page. Folded, the reader sees the page the state read + // carries; unfolded, every id the host sent for the wider read. + const [heldWide, setHeldWide] = useState(false) + // Another lane is another page: switching folds whatever the last one had unfolded. + useEffect(() => { setHeldWide(false) }, [workflow.id]) + const heldShown = heldWide ? held : held.slice(0, HELD_PAGE) const viewRun = resolveFocusedRun(workflow, focusedRunId) const showSummary = (workflow.queueDepth ?? 0) > 0 || liveRuns.length > 1 const visible = stageMessages(workflow, workflow.messages, roster) @@ -586,11 +593,16 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? liveRuns.length, depth: workflow.queueDepth ?? 0 }))) : null, held.length > 0 ? h('div', { className: 'wf-held' }, h('div', { className: 'wf-held-head' }, tr('Tasks this workflow holds')), - held.map(taskId => h('div', { className: 'wf-held-row', key: taskId }, + heldShown.map(taskId => h('div', { className: 'wf-held-row', key: taskId }, h('span', { className: 'wf-held-id', title: taskId }, taskId), locked ? null : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onRematch?.(taskId) }, tr('Re-match')))), - (workflow.heldTaskCount ?? held.length) > held.length - ? h('div', { className: 'wf-held-more' }, tr('{count} more held tasks', { count: (workflow.heldTaskCount ?? held.length) - held.length })) + // #2249 gave this page the one unfold control; #2256 puts the held ids behind + // it, so the rest of the list is reachable instead of being named and dropped. + heldWide || (workflow.heldTaskCount ?? held.length) > heldShown.length + ? h('button', { + type: 'button', className: 'wf-more wf-held-more', 'data-more': 'held', 'aria-expanded': heldWide, + onClick: () => { const next = !heldWide; setHeldWide(next); onHeldMore?.(next) }, + }, heldWide ? tr('Show less') : tr('Show {count} more held tasks', { count: (workflow.heldTaskCount ?? held.length) - heldShown.length })) : null) : null, liveRuns.length > 0 ? h('div', { className: 'wf-strips' }, liveRuns.map(run => h(RunStrip, { @@ -1418,6 +1430,10 @@ window.__ModuleLoader__.load({ } const ASSIGN_VISIBLE = 12 + // #2256: the ids one lane may hold is a page too. The state read lists 20, and a + // reader who unfolds the rest asks for the widest page the host serves (200). + const HELD_PAGE = 20 + const HELD_LIMIT = 200 function AssignModal({ workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId, onClose, onConfirm, onRetry }) { // #2241: two lanes drawn from different Projects read the same inside this dialog, so the @@ -2001,8 +2017,14 @@ window.__ModuleLoader__.load({ const [readError, setReadError] = useState('') + // #2256: unfolding a lane's held ids asks for the wider page, and the 2s poll keeps + // asking for it while it stays unfolded — otherwise the next read would fold it back. + const heldWideRef = useRef(false) + // A lane that is not open must not keep the wider read alive. + useEffect(() => { heldWideRef.current = false }, [openId]) + const refresh = async () => { - const value = await stageCall('workflow/state', undefined, 'The workflow host did not answer') + const value = await stageCall('workflow/state', heldWideRef.current ? { heldLimit: HELD_LIMIT } : undefined, 'The workflow host did not answer') if (value && Array.isArray(value.workflows)) { setPack(value); setReadError('') } else setReadError(value?.error ?? 'The workflow host did not answer') return value @@ -2326,6 +2348,7 @@ window.__ModuleLoader__.load({ }) }, onRematch: rematchTask, + onHeldMore: (wide) => { heldWideRef.current = wide; void refresh() }, })) : null, assignOpen ? portal(h(AssignModal, { workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId: openId || workflows[0]?.id, @@ -2418,7 +2441,7 @@ window.__ModuleLoader__.load({ .wf-held-head{font-size:12px;color:var(--bt-ink-2,#666)} .wf-held-row{display:flex;align-items:center;gap:8px;min-width:0} .wf-held-id{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px} -.wf-held-more{font-size:12px;color:var(--bt-ink-2,#666)} +.wf-held-more{font-size:12px;margin-top:2px} .wf-tk{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(360px,42vw)}.wf-meta{font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap} .wf-segs{display:flex;gap:3px;flex:1;min-width:80px} .wf-seg{height:5px;border-radius:3px;background:var(--bt-active,rgba(119,119,119,.17));flex:1} diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 1c2ee9a59..98f2ee91f 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -104,7 +104,7 @@ export function apply(ctx, config = {}) { }) void service.reap?.().catch(error => fb.warn?.('flybot-workflow: reap failed: %s', error)) const offs = [ - fb.api.handle('workflow/state', () => service.state(), { read: true }), + fb.api.handle('workflow/state', body => service.state(body), { read: true }), fb.api.handle('workflow/create', body => service.create(body)), fb.api.handle('workflow/rename', body => service.rename(body)), fb.api.handle('workflow/delete', body => service.delete(body)), diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 30554fd3c..29ce52af9 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -25,6 +25,9 @@ const MAX_ORPHANS = 50 // How many held task ids a workflow lists in `workflow/state`; the count beside // them is always the whole truth (#1647). const MAX_HELD_TASKS = 20 +// #2256: that list is a page, so a reader can ask for the rest of it. `heldLimit` +// is honoured up to this many ids — a wider page than the poll reads, never a full read. +const MAX_HELD_LIMIT = 200 // #1869: the fields `workflow/assign` reads. Anything else is named back in // `ignored`, because a typo (`brif`) used to be dropped in silence. const ASSIGN_FIELDS = new Set(['workflowId', 'taskId', 'id', 'title', 'brief', 'callerBotId', 'projectId', 'botId', 'handoff', 'handoffContext', 'acceptance', 'evidence']) @@ -355,8 +358,17 @@ export function createWorkflowService({ state.logs[workflowId] = log.slice(-MAX_MESSAGES) } + // How many ids one `workflow/state` read lists. Anything that is not a positive + // integer keeps the default page, and the cap keeps a reader from turning the poll + // into a full read (#2256). + function heldLimitOf(body) { + const value = Number(body?.heldLimit) + if (!Number.isInteger(value) || value < 1) return MAX_HELD_TASKS + return Math.min(value, MAX_HELD_LIMIT) + } + // Task ids each workflow holds, sorted. `heldTaskCount` is the whole truth and - // the list beside it is capped (#1647); a Bot claim is not a workflow claim. + // the list beside it is a page (#1647, #2256); a Bot claim is not a workflow claim. function heldTaskIds() { const held = new Map() for (const [taskId, claim] of Object.entries(state.claims ?? {})) { @@ -369,7 +381,7 @@ export function createWorkflowService({ return held } - function present() { + function present(heldLimit = MAX_HELD_TASKS) { const roster = bots() ?? [] const held = heldTaskIds() return { @@ -403,7 +415,7 @@ export function createWorkflowService({ preview: rowPreview(item, previewRun(item.id)), run: publicRun(item, displayRun(item.id)), messages: state.logs[item.id] ?? [], - heldTasks: (held.get(item.id) ?? []).slice(0, MAX_HELD_TASKS), + heldTasks: (held.get(item.id) ?? []).slice(0, heldLimit), heldTaskCount: (held.get(item.id) ?? []).length, }) }), } @@ -1116,7 +1128,7 @@ export function createWorkflowService({ } return { - state() { return present() }, + state(body) { return present(heldLimitOf(body)) }, rename(body = {}) { if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } const workflowId = String(body.workflowId ?? '') diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 0ee353e95..84ab1219e 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4474,3 +4474,103 @@ test('#2254: the named confirm button posts the same assign body', async () => { view.unmount() }) }) + +// #2256: a lane that holds more cards than the state page lists drew 20 rows and then a +// dead line — `3 more held tasks` as a plain div, with no `aria-expanded`, and the 21st id +// not in the DOM at all. The line is now the #2249 `wf-more` control: it asks the host for +// the wider page, draws every held id, and folds back to the first 20. +test('#2256: the held list draws its rest behind a wf-more button that asks for the wider page', async () => { + const portals = [] + const reactDom = { + createPortal(node, container) { + if (!container || container.nodeType !== 1) throw new Error('Target container is not a DOM element.') + portals.push(container) + return { type: 'portal', props: { container }, children: [node] } + }, + } + const opened = loadClient(reactDom) + const body = { nodeType: 1 } + const prevBody = document.body + document.body = body + const ids = Array.from({ length: 23 }, (_, index) => `task-held-${String(index + 1).padStart(2, '0')}`) + const asked = [] + const call = async (name, payload) => { + if (name !== 'workflow/state') return { ok: true } + asked.push(payload) + const limit = Number(payload?.heldLimit) + const shown = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 200) : 20 + return { workflows: [{ ...delivery(), heldTasks: ids.slice(0, shown), heldTaskCount: ids.length }], templates: [] } + } + const view = mount(opened.Section, { roster: { bots: [] }, call, actions: {} }) + try { + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + + const rows = () => findAll(view.tree, node => node.props?.className === 'wf-held-row') + const more = () => findAll(view.tree, node => node.type === 'button' && node.props?.['data-more'] === 'held') + assert.equal(rows().length, 20, 'the first page is the 20 ids the state carries') + assert.equal(more().length, 1, 'the rest is a control, not a dead line') + assert.equal(more()[0].props['aria-expanded'], false, 'and it says it is folded') + assert.equal(textOf(more()[0]), 'Show 3 more held tasks') + + more()[0].props.onClick() + await settle() + assert.deepEqual(asked.at(-1), { heldLimit: 200 }, 'unfolding asks the host for the wider page') + assert.equal(more()[0].props['aria-expanded'], true) + assert.equal(textOf(more()[0]), 'Show less') + assert.equal(rows().length, 23, 'every held id is drawn once unfolded') + const last = rows().at(-1) + assert.match(textOf(last), /task-held-23/, 'the 23rd id is in the DOM') + const rematch = findAll(last, node => node.type === 'button' && textOf(node) === 'Re-match')[0] + assert.ok(rematch, 'and the 23rd id is reachable, with the same Re-match every row has') + rematch.props.onClick() + await settle() + assert.deepEqual(asked.at(-1), { heldLimit: 200 }, 'a re-match keeps the page the reader unfolded') + assert.equal(rows().length, 23, 'so the row does not fold under the pointer') + + more()[0].props.onClick() + await settle() + assert.deepEqual(asked.at(-1), undefined, 'folding asks for the default page again') + assert.equal(rows().length, 20) + assert.equal(more()[0].props['aria-expanded'], false) + } finally { + view.unmount() + document.body = prevBody + } +}) + +// #2256: within one page there is nothing to unfold, so the control must not appear — +// and the block a reader sees is exactly what it was before this change. +test('#2256: a lane within one page draws no more-row and keeps its held block as it was', async () => { + const portals = [] + const reactDom = { + createPortal(node, container) { + portals.push(container) + return { type: 'portal', props: { container }, children: [node] } + }, + } + const opened = loadClient(reactDom) + const body = { nodeType: 1 } + const prevBody = document.body + document.body = body + const ids = ['task-held-01', 'task-held-02', 'task-held-03'] + const call = async (name) => { + if (name !== 'workflow/state') return { ok: true } + return { workflows: [{ ...delivery(), heldTasks: ids, heldTaskCount: ids.length }], templates: [] } + } + const view = mount(opened.Section, { roster: { bots: [] }, call, actions: {} }) + try { + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + const block = findAll(view.tree, node => node.props?.className === 'wf-held')[0] + assert.ok(block, 'the held block is drawn') + assert.equal(findAll(view.tree, node => node.props?.['data-more'] === 'held').length, 0, 'a full page draws no more-row') + assert.equal(findAll(view.tree, node => node.props?.className === 'wf-held-row').length, 3) + assert.equal(textOf(block), `Tasks this workflow holds${ids.map(id => `${id}Re-match`).join('')}`, 'the block reads exactly as it did') + } finally { + view.unmount() + document.body = prevBody + } +}) diff --git a/plugins/workflow/test/held-page.test.js b/plugins/workflow/test/held-page.test.js new file mode 100644 index 000000000..a03ad9b83 --- /dev/null +++ b/plugins/workflow/test/held-page.test.js @@ -0,0 +1,83 @@ +// #2256: `workflow/state` lists held task ids, and the list is a page. A lane +// holding 23 cards drew 20 and then a dead line saying "3 more held tasks": the +// rows behind the cap could not be reached at all. The read now takes a +// `heldLimit` — 20 by default, 200 at most, and only for an integer above zero — +// while `heldTaskCount` stays the whole truth on either page. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createWorkflowService } from '../src/service.js' + +const CLOCK = 1_700_000_000_000 +const roster = [{ id: 'main', name: 'Chief', role: 'lead', isMain: true, remote: false }] + +function serviceAt(dir, persist = true) { + return createWorkflowService({ + dir, + persist, + now: () => CLOCK, + bots: () => roster, + spawn: async () => 'eph', + release: async () => {}, + dispatch: async () => ({ ok: true }), + // Step deadlines are timers; they are not part of what these cases assert. + setTimer: () => 0, + clearTimer: () => {}, + }) +} + +async function laneWith(svc, count) { + const created = svc.create({ name: 'Alpha', nodes: [{ id: 'only', name: 'Only', prompt: 'Do it.' }], edges: [] }) + assert.equal(created.ok, true, created.error) + const alpha = created.workflows.find(item => item.name === 'Alpha') + const ids = [] + for (let i = 1; i <= count; i += 1) { + const id = `task-held-${String(i).padStart(2, '0')}` + ids.push(id) + const routed = await svc.ingest({ id, status: 'open', title: `Ship ${i}`, workflowId: alpha.id }) + assert.equal(routed.ok, true, routed.error) + } + return { alpha, ids } +} + +test('#2256: the state read pages held ids, 20 by default and every one when asked', async () => { + const dir = mkdtempSync(join(tmpdir(), 'wf-held-page-')) + try { + const svc = serviceAt(dir) + const { alpha, ids } = await laneWith(svc, 23) + const item = body => svc.state(body).workflows.find(workflow => workflow.id === alpha.id) + + assert.deepEqual(item().heldTasks, ids.slice(0, 20), 'no argument keeps the 20-id page') + assert.equal(item().heldTaskCount, 23, 'the default page still reports the whole count') + + assert.deepEqual(item({ heldLimit: 200 }).heldTasks, ids, 'heldLimit: 200 lists every held id') + assert.equal(item({ heldLimit: 200 }).heldTaskCount, 23, 'the wider page keeps the whole count') + assert.deepEqual(item({ heldLimit: 23 }).heldTasks, ids, 'a limit just above the page is honoured') + + // A poll must not be able to ask for an unbounded read: anything that is not a + // positive integer falls back to the default page. + for (const bad of [{ heldLimit: 0 }, { heldLimit: -5 }, { heldLimit: 'many' }, { heldLimit: 3.5 }]) { + assert.deepEqual(item(bad).heldTasks, ids.slice(0, 20), `${JSON.stringify(bad)} falls back to the default page`) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('#2256: heldLimit is capped at 200, so a reader never turns the poll into a full read', async () => { + const dir = mkdtempSync(join(tmpdir(), 'wf-held-cap-')) + try { + const svc = serviceAt(dir, false) + const { alpha, ids } = await laneWith(svc, 205) + const item = body => svc.state(body).workflows.find(workflow => workflow.id === alpha.id) + + const wide = item({ heldLimit: 1000 }) + assert.equal(wide.heldTasks.length, 200, 'the widest page is 200 ids') + assert.deepEqual(wide.heldTasks, [...ids].sort().slice(0, 200), 'and they are the first 200 of the sorted list') + assert.equal(wide.heldTaskCount, 205, 'the count is still the whole truth past the cap') + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})