From b3bdbbb2f9217b93e382e264b3857b3b3281e304 Mon Sep 17 00:00:00 2001 From: FlyBot Dev-5 Date: Thu, 5 Nov 2026 03:40:40 +0000 Subject: [PATCH] workflow: hand a card's facts to the run from the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's Hand a task to a Workflow built `workflow/assign` from the card's id and title, and its own read of the Tasks board dropped `brief` on the way in, so a card that carried a work order reached the new run with none and the node fell back to the title. The read keeps `brief` now, and the assign body sends the card's own text verbatim — an explicit empty string when the card holds none, so a brief an earlier call cached cannot come back. Fixes #2495 Owner: Dev-5 Written-by: Dev-5 Reviewer: Birfy Claim-Id: ecb278a60cca446e8fbdfdbe27c0c484 --- .../2495-workflow-panel-assign-brief.fixed.md | 1 + docs/subsystems/workflow.md | 2 +- plugins/workflow/client.js | 8 +- plugins/workflow/test/client.test.js | 78 +++++++++++++++++-- 4 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2495-workflow-panel-assign-brief.fixed.md diff --git a/changelog.d/2495-workflow-panel-assign-brief.fixed.md b/changelog.d/2495-workflow-panel-assign-brief.fixed.md new file mode 100644 index 000000000..d0de56845 --- /dev/null +++ b/changelog.d/2495-workflow-panel-assign-brief.fixed.md @@ -0,0 +1 @@ +Handing a card to a Workflow from the panel now carries the card's own facts into the run instead of dropping them: the assign body sends the card's `brief` verbatim, and an explicit empty one when the card holds none, so a brief cached by an earlier call cannot come back ([#2495](https://github.com/ThinkFlowLab/FlyBot/issues/2495)). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index cf0d775ff..d070b9dc1 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -43,7 +43,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; 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, and a card picked past the twelfth is drawn first once the list is folded again, so the twelve rows never hide the card the button is about to send (#2257), #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`, whose count is what pressing it reaches past the rows drawn rather than everything the lane holds — that reads the wider page with `heldLimit: 200` and folds back to `Show less`; that page stops at 200, so an unfolded list still short of `heldTaskCount` draws a `wf-held-note` line `Showing {shown} of {count} held tasks`, and neither the count nor the button promises an id it cannot show (#2256, #2260), 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; the hand-off carries the card's own facts — the assign body sends the card's `brief` verbatim and an explicit empty one when the card holds none, so a run never starts on the title where the card had a work order and a brief an earlier call cached cannot come back (#2495); 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, and a card picked past the twelfth is drawn first once the list is folded again, so the twelve rows never hide the card the button is about to send (#2257), #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`, whose count is what pressing it reaches past the rows drawn rather than everything the lane holds — that reads the wider page with `heldLimit: 200` and folds back to `Show less`; that page stops at 200, so an unfolded list still short of `heldTaskCount` draws a `wf-held-note` line `Showing {shown} of {count} held tasks`, and neither the count nor the button promises an id it cannot show (#2256, #2260), 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 diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 3ae759bf0..dde5b571e 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -2195,6 +2195,9 @@ window.__ModuleLoader__.load({ const real = Array.isArray(rows) ? rows.filter(task => task && task.title && task.status !== 'cancelled').map(task => ({ id: task.id, title: task.title, status: task.status, priority: task.priority, column: columnOf(task), + // #2495: the card is the authority on its own facts, and this list is the panel's + // only read of the card — dropping `brief` here is what made the hand-off title-only. + brief: task.brief, })) : [] setTasks(real) @@ -2268,7 +2271,10 @@ window.__ModuleLoader__.load({ actions?.openSession?.(botId) return } - const value = await call('workflow/assign', { workflowId: target, taskId: task.id, title: task.title }) + // #2495: hand the card's own facts over, and say so explicitly even when it holds none — + // an omitted `brief` keeps whatever an earlier call cached under this task id. + const brief = typeof task.brief === 'string' ? task.brief : '' + const value = await call('workflow/assign', { workflowId: target, taskId: task.id, title: task.title, brief }) if (value?.ok === false) { setError(value.error ?? ''); return } setPack(value) setOpenId(target) diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 8dcb3687e..5664b3573 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -2543,6 +2543,74 @@ test('#1457: an empty board keeps Assign disabled, so the click is not a silent } }) +// #2495: Hand a task to a Workflow is the panel's own way into `workflow/assign`, and it built the +// body from the card's id and title only. A card that held facts arrived at the new run with no +// brief; the service falls back to the title, so the work order vanished without a word. +test('#2495: a card that holds facts hands them to the workflow verbatim', 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 prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => (channel !== 'tasks' ? [] : [ + { id: 'task-facts', title: 'Ship the release notes', status: 'open', priority: 'P1', brief: 'FACTS A\nSecond line.' }, + ]), + } + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick() + await settle() + const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0] + assign.props.onClick() + await settle() + const posted = calls.filter(item => item.name === 'workflow/assign') + assert.equal(posted.length, 1, 'the click posts one assign') + assert.equal(posted[0].body.brief, 'FACTS A\nSecond line.', 'the facts ride with the hand-off, verbatim') + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + +// #2495: a card that holds none sends an explicit empty brief. Leaving the key out would let the +// service keep whatever a much earlier call cached under that task id, and the panel is the caller +// that knows the card holds nothing now (#2490's non-blocking hole). +test('#2495: a card with no facts sends an empty brief, so a cached one cannot come back', 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 prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => (channel !== 'tasks' ? [] : [ + { id: 'task-bare', title: 'Ship the release notes', status: 'open', priority: 'P1' }, + ]), + } + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.props?.className === 'wf-row wf-assign-row')[0].props.onClick() + await settle() + const assign = findAll(view.tree, node => node.type === 'button' && textOf(node).startsWith('Assign'))[0] + assign.props.onClick() + await settle() + const posted = calls.filter(item => item.name === 'workflow/assign') + assert.equal(posted.length, 1, 'the click posts one assign') + assert.equal(posted[0].body.brief, '', 'an explicit empty brief, not a missing key') + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + // #1458: a rejected Tasks call used to be rendered as a genuinely empty board, // so the owner was told there is nothing to assign instead of that the load failed. test('#1458: a failed Tasks load shows the error and a Retry, never the empty-board hint', async () => { @@ -4278,7 +4346,7 @@ test('#2241: picking a lane still posts the same assign body', async () => { await settle() const posts = calls.filter(item => item.name === 'workflow/assign') assert.equal(posts.length, 1, 'one assign is posted') - assert.deepEqual(posts[0].body, { workflowId: 'lane-alpha', taskId: 'task-live-1', title: 'Real board card one' }, 'the submit body is unchanged') + assert.deepEqual(posts[0].body, { workflowId: 'lane-alpha', taskId: 'task-live-1', title: 'Real board card one', brief: '' }, 'the submit body is unchanged') view.unmount() }) }) @@ -4343,7 +4411,7 @@ test('#2244: expanding the card list offers the twentieth card', async () => { assignOf(dialogOf(ui.view)).props.onClick() await settle() assert.equal(ui.posted().length, 1, 'the click posts one assign') - assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20' }) + assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20', brief: '' }) } finally { ui.view.unmount() } }) }) @@ -4357,7 +4425,7 @@ test('#2244: twelve cards keep today\'s dialog and the assign body', async () => assignOf(dialogOf(ui.view)).props.onClick() await settle() assert.equal(ui.posted().length, 1) - assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-1', title: 'Board card 1' }) + assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-1', title: 'Board card 1', brief: '' }) } finally { ui.view.unmount() } }) }) @@ -4537,7 +4605,7 @@ test('#2254: the named confirm button posts the same assign body', async () => { await settle() const posts = calls.filter(item => item.name === 'workflow/assign') assert.equal(posts.length, 1, 'one assign is posted') - assert.deepEqual(posts[0].body, { workflowId: 'lane-beta', taskId: 'task-live-1', title: 'Real board card one' }, 'the submit body is unchanged') + assert.deepEqual(posts[0].body, { workflowId: 'lane-beta', taskId: 'task-live-1', title: 'Real board card one', brief: '' }, 'the submit body is unchanged') view.unmount() }) }) @@ -4671,7 +4739,7 @@ test('#2257: collapsing the card list keeps the picked card in view', async () = assignOf(dialogOf(ui.view)).props.onClick() await settle() assert.equal(ui.posted().length, 1, 'one assign is posted') - assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20' }, 'the submit body is unchanged') + assert.deepEqual(ui.posted()[0].body, { workflowId: 'delivery', taskId: 'task-20', title: 'Board card 20', brief: '' }, 'the submit body is unchanged') } finally { ui.view.unmount() } }) })