diff --git a/changelog.d/1457-1458-assign-dialog.fixed.md b/changelog.d/1457-1458-assign-dialog.fixed.md new file mode 100644 index 000000000..b8e4cd2c4 --- /dev/null +++ b/changelog.d/1457-1458-assign-dialog.fixed.md @@ -0,0 +1 @@ +The "Hand a task to a Workflow" dialog now selects the first card of the Tasks board as soon as the board arrives, so `Assign` posts the task instead of closing on a silent no-op, and an empty board leaves it disabled. When the Tasks board cannot be loaded the dialog shows the error with a `Retry` control instead of claiming there are no tasks to assign, and `Assign` stays disabled while the load is in flight or failed. ([#1457](https://github.com/ThinkFlowLab/FlyBot/issues/1457), [#1458](https://github.com/ThinkFlowLab/FlyBot/issues/1458)) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index e4effd2a6..fd1540cf3 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -35,7 +35,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), 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). | +| `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). | ## Entry points @@ -62,7 +62,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, ## 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), and a sidebar click that leaves the Workflows section in place. 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), and a sidebar click that leaves the Workflows section in place. 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 64999450e..1410daee2 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -111,6 +111,9 @@ window.__ModuleLoader__.load({ 'Rename workflow': '重命名 Workflow', 'Are you sure you want to delete "{name}"? This cannot be undone.': '确定删除「{name}」?此操作无法撤销。', 'No open tasks on the Tasks board.': 'Tasks 看板上没有可分配的任务。', + 'Loading the Tasks board…': '正在读取 Tasks 看板…', + 'Could not load the Tasks board.': '无法读取 Tasks 看板。', + Retry: '重试', 'Could not rename this workflow': '无法重命名该 Workflow', 'Could not delete this workflow': '无法删除该 Workflow', 'Could not save this workflow': '无法保存该 Workflow', @@ -939,9 +942,17 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'wf-btn wf-danger', onClick: onConfirm }, tr('Delete'))))) } - function AssignModal({ workflows, tasks, roster, workflowId, onClose, onConfirm }) { + function AssignModal({ workflows, tasks, tasksStatus, tasksError, roster, workflowId, onClose, onConfirm, onRetry }) { const [taskId, setTaskId] = useState(tasks[0]?.id ?? '') const [target, setTarget] = useState(workflowId || workflows[0]?.id || '') + // #1457: the dialog opens before loadTasks() answers, so the first client render can have no + // rows; select the first card once they arrive, or when the selected card is gone. + useEffect(() => { + if (tasks.length === 0) return + if (taskId && tasks.some(task => task.id === taskId)) return + setTaskId(tasks[0].id) + }, [tasks, taskId]) + const canAssign = tasksStatus === 'ready' && tasks.some(task => task.id === taskId) const seen = new Set() const bots = (roster?.bots ?? []).filter(bot => { if (!bot || bot.remote === true || !bot.id || !bot.name) return false @@ -957,14 +968,20 @@ window.__ModuleLoader__.load({ h('p', { className: 'wf-hint' }, tr('A task can go to a Workflow, not only to one Bot. The Workflow then runs each Bot in order, or side by side.'))), h('div', { className: 'wf-modal-body' }, h('div', { className: 'wf-step-h' }, h('b', null, '1'), tr('Pick a task card')), - tasks.length === 0 - ? h('div', { className: 'wf-muted wf-cards-empty' }, tr('No open tasks on the Tasks board.')) - : h('div', { className: 'wf-cards' }, tasks.map(task => h('button', { - key: task.id, type: 'button', className: `wf-kcard ${taskId === task.id ? 'sel' : ''}`, onClick: () => setTaskId(task.id), - }, - h('div', { className: 'wf-id' }, `${shortId(task.id)} · ${priorityOf(task)}`), - h('div', { className: 'wf-tt' }, task.title), - h('div', { className: 'wf-kcol' }, `● ${tr(COLUMN_KEY[columnOf(task)] ?? 'Not started')}`)))), + tasksStatus === 'failed' + ? h('div', { className: 'wf-assign-fail' }, + h('div', { className: 'wf-err', role: 'alert' }, tasksError || tr('Could not load the Tasks board.')), + h('button', { type: 'button', className: 'wf-btn', onClick: () => onRetry?.() }, tr('Retry'))) + : tasksStatus !== 'ready' + ? h('div', { className: 'wf-muted wf-cards-empty' }, tr('Loading the Tasks board…')) + : tasks.length === 0 + ? h('div', { className: 'wf-muted wf-cards-empty' }, tr('No open tasks on the Tasks board.')) + : h('div', { className: 'wf-cards' }, tasks.map(task => h('button', { + key: task.id, type: 'button', className: `wf-kcard ${taskId === task.id ? 'sel' : ''}`, onClick: () => setTaskId(task.id), + }, + h('div', { className: 'wf-id' }, `${shortId(task.id)} · ${priorityOf(task)}`), + h('div', { className: 'wf-tt' }, task.title), + h('div', { className: 'wf-kcol' }, `● ${tr(COLUMN_KEY[columnOf(task)] ?? 'Not started')}`)))), h('div', { className: 'wf-step-h' }, h('b', null, '2'), tr('Pick who runs it')), h('div', { className: 'wf-targets' }, workflows.map(item => h('button', { @@ -985,7 +1002,7 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-rad' }))))), h('div', { className: 'wf-modal-footer' }, h('button', { type: 'button', className: 'wf-btn', onClick: onClose }, tr('Cancel')), - h('button', { type: 'button', className: 'wf-btn wf-dark', onClick: () => onConfirm({ taskId, target }) }, tr('Assign'))))) + h('button', { type: 'button', className: 'wf-btn wf-dark', disabled: !canAssign, onClick: () => { if (canAssign) onConfirm({ taskId, target }) } }, tr('Assign'))))) } function CreateModal({ roster, templates, onClose, onCreate, onTemplate }) { @@ -1379,6 +1396,8 @@ window.__ModuleLoader__.load({ const [assignOpen, setAssignOpen] = useState(false) const [creating, setCreating] = useState(false) const [tasks, setTasks] = useState([]) + const [tasksStatus, setTasksStatus] = useState('idle') + const [tasksError, setTasksError] = useState('') const [tip, setTip] = useState(null) const [error, setError] = useState('') const [editing, setEditing] = useState(false) @@ -1429,7 +1448,9 @@ window.__ModuleLoader__.load({ const root = globalThis.__flybot__ if (!root) return undefined const prev = root.openWorkflowAssign - root.openWorkflowAssign = () => { setAssignOpen(true); setOpenId(id => id) } + // #1457: this path opened the dialog without ever loading the board, so it could only + // ever show the frozen empty state. + root.openWorkflowAssign = () => { setAssignOpen(true); setOpenId(id => id); void loadTasks() } return () => { root.openWorkflowAssign = prev } }, []) @@ -1474,8 +1495,15 @@ window.__ModuleLoader__.load({ }, [open?.id]) const loadTasks = async () => { + setTasksStatus('loading') + setTasksError('') const host = globalThis.__flybot__?.host - if (typeof host !== 'function') { setTasks([]); return } + if (typeof host !== 'function') { + setTasks([]) + setTasksError(tr('Could not load the Tasks board.')) + setTasksStatus('failed') + return + } try { const rows = await host('tasks', {}) const real = Array.isArray(rows) @@ -1484,9 +1512,14 @@ window.__ModuleLoader__.load({ })) : [] setTasks(real) + setTasksStatus('ready') } catch (loadError) { - void loadError + // #1458: an empty list here used to be rendered as "no tasks to assign", which told the + // owner the board was empty instead of that the load had failed. + const detail = String(loadError?.message ?? '').trim() setTasks([]) + setTasksError(`${tr('Could not load the Tasks board.')}${detail ? ` (${detail})` : ''}`) + setTasksStatus('failed') } } @@ -1633,8 +1666,8 @@ window.__ModuleLoader__.load({ }, })) : null, assignOpen ? portal(h(AssignModal, { - workflows, tasks, roster, workflowId: openId || workflows[0]?.id, - onClose: () => setAssignOpen(false), onConfirm: confirmAssign, + workflows, tasks, tasksStatus, tasksError, roster, workflowId: openId || workflows[0]?.id, + onClose: () => setAssignOpen(false), onConfirm: confirmAssign, onRetry: loadTasks, })) : null, creating ? portal(h(CreateModal, { roster, templates: pack?.templates, onClose: () => setCreating(false), onCreate: create, @@ -1840,6 +1873,7 @@ window.__ModuleLoader__.load({ .wf-canvas-fit{position:relative;width:0;margin:0 auto;transform-origin:50% 0} .wf-canvas-view .wf-wires path{stroke:var(--bt-ink-2,rgba(20,20,20,.55));stroke-width:2.5} .wf-cards-empty{padding:8px 4px 12px} +.wf-assign-fail{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:4px 0 8px} .wf-context-menu{position:fixed;z-index:95;min-width:140px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px} .wf-context-menu button{border:0;background:transparent;color:inherit;font:inherit;font-size:12.5px;text-align:left;padding:8px 10px;border-radius:8px;cursor:pointer} .wf-context-menu button:hover{background:var(--bt-hover,rgba(119,119,119,.09))} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 4c0cbfaa5..f86cb8daf 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -1954,3 +1954,157 @@ test('a workflow host call that never answers settles as an error', async () => const value = await client.settledCall(async () => new Promise(() => {}), 'workflow/state', undefined, 'The workflow host did not answer', 5) assert.deepEqual(value, { ok: false, error: 'The workflow host did not answer' }) }) + +// #1457: the dialog opens before the board arrives, so the first client render +// selected nothing and `Assign` looked accepted while it posted nothing. +test('#1457: the assign dialog selects the first loaded card and Assign posts it', 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) => { + if (channel !== 'tasks') return [] + return [ + { id: 'task-live-1', title: 'Real board card one', status: 'open', priority: 'P1' }, + { id: 'task-live-2', title: 'Real board card two', status: 'open', priority: 'P2' }, + ] + }, + } + 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 cards = findAll(view.tree, node => typeof node.props?.className === 'string' && node.props.className.startsWith('wf-kcard')) + assert.equal(cards.length, 2) + const selected = cards.filter(card => card.props.className.includes('sel')) + assert.equal(selected.length, 1, 'exactly one card is preselected') + assert.ok(textOf(selected[0]).includes('Real board card one')) + + const assign = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0] + assert.ok(assign, 'Assign button') + assert.equal(assign.props.disabled, false) + + 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.taskId, 'task-live-1') + assert.equal(posted[0].body.title, 'Real board card one') + assert.equal(textOf(view.tree).includes('Pick a task card'), false, 'the dialog closes') + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + +test('#1457: an empty board keeps Assign disabled, so the click is not a silent no-op', 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 () => [] } + 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() + assert.ok(textOf(view.tree).includes('No open tasks on the Tasks board.')) + const assign = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0] + assert.equal(assign.props.disabled, true) + assign.props.onClick() + await settle() + assert.equal(calls.some(item => item.name === 'workflow/assign'), false) + 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 () => { + 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] } + } + let broken = true + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => { + if (channel !== 'tasks') return [] + if (broken) throw new Error('host is busy') + return [{ id: 'task-live-1', title: 'Real board card one', 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 text = textOf(view.tree) + assert.equal(text.includes('No open tasks on the Tasks board.'), false, 'a failed load is not an empty board') + assert.ok(text.includes('host is busy'), 'the host message is shown') + assert.ok(findAll(view.tree, node => String(node.props?.className ?? '').includes('wf-err')).length > 0) + + const assign = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0] + assert.equal(assign.props.disabled, true, 'Assign is not clickable while the load failed') + assign.props.onClick() + await settle() + assert.equal(calls.some(item => item.name === 'workflow/assign'), false) + + broken = false + const retry = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Retry')[0] + assert.ok(retry, 'a Retry control') + retry.props.onClick() + await settle() + assert.ok(textOf(view.tree).includes('Real board card one'), 'Retry loads the board') + assert.equal(findAll(view.tree, node => String(node.props?.className ?? '').includes('wf-err')).length, 0) + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +}) + +test('#1458: Assign stays disabled while the Tasks board is still loading', async () => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + let release + const pending = new Promise(resolve => { release = resolve }) + const prev = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => (channel === 'tasks' ? pending.then(() => [{ id: 'task-live-1', title: 'Real board card one', status: 'open' }]) : []), + } + 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 early = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0] + assert.equal(early.props.disabled, true, 'no click before the cards are there') + release() + await settle() + const later = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Assign')[0] + assert.equal(later.props.disabled, false, 'clickable once a card is selected') + view.unmount() + } finally { + globalThis.__flybot__ = prev + } +})