From 97f8705310fbbff4c170766a1b5187ae63c20f75 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:48:10 +0800 Subject: [PATCH] docs(changelog): stage controls report a refused call (#1521) --- changelog.d/1521.fixed.md | 1 + docs/subsystems/workflow.md | 2 +- plugins/workflow/client.js | 71 ++++++++++++++-- plugins/workflow/test/client.test.js | 117 +++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1521.fixed.md diff --git a/changelog.d/1521.fixed.md b/changelog.d/1521.fixed.md new file mode 100644 index 000000000..ea3aac6f2 --- /dev/null +++ b/changelog.d/1521.fixed.md @@ -0,0 +1 @@ +The Workflow stage controls — graph save, Reset, Pause, Resume and Stop — now report a refused or unanswered call in the stage alert instead of leaving the button dead, and a call that does not answer within 10 s is reported as a failure. ([#1521](https://github.com/ThinkFlowLab/FlyBot/issues/1521)) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 883006c49..013477c91 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -33,7 +33,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` | 发布交付流水线 (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), 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. | +| `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), 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. | ## Entry points diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index fe7aa0254..780037871 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -107,6 +107,11 @@ window.__ModuleLoader__.load({ 'No open tasks on the Tasks board.': 'Tasks 看板上没有可分配的任务。', 'Could not rename this workflow': '无法重命名该 Workflow', 'Could not delete this workflow': '无法删除该 Workflow', + 'Could not save this workflow': '无法保存该 Workflow', + 'Could not reset this workflow': '无法重置该 Workflow', + 'Could not pause this run': '无法暂停本次运行', + 'Could not resume this run': '无法继续本次运行', + 'Could not stop this run': '无法停止本次运行', 'Reading workflows…': '正在读取 Workflow…', 'Changes are saved automatically': '更改会自动保存', 'Next run: {time}': '下次运行:{time}', @@ -118,6 +123,39 @@ window.__ModuleLoader__.load({ const host = globalThis.__flybot__?.t return typeof host === 'function' && host('Tasks') === '任务' } + // A stage control calls the host too: a throw, a rejection or a host that never answers + // settles as one error string, so Pause, Stop, Reset and a graph save cannot fail silently. + // Same shape as readWorkflows()/workflowCallError() in core/src/client/workflows.js. + const CALL_TIMEOUT_MS = 10000 + function callError(failure, fallback) { + if (typeof failure === 'string' && failure.trim()) return failure.trim() + const message = typeof failure?.message === 'string' ? failure.message.trim() : '' + return message || fallback + } + function settledCall(call, name, body, fallback, timeoutMs = CALL_TIMEOUT_MS) { + return new Promise((resolve) => { + let settled = false + const finish = (value) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve(value) + } + const timer = setTimeout(() => finish({ ok: false, error: fallback }), timeoutMs) + let pending + try { + pending = body === undefined ? call(name) : call(name, body) + } catch (failure) { + finish({ ok: false, error: callError(failure, fallback) }) + return + } + Promise.resolve(pending) + .then((value) => finish(value === null || value === undefined + ? { ok: false, error: fallback } + : (value.ok === false ? { ok: false, error: callError(value.error, fallback) } : value))) + .catch((failure) => finish({ ok: false, error: callError(failure, fallback) })) + }) + } function fill(text, params) { return params ? text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : text } @@ -1073,6 +1111,8 @@ window.__ModuleLoader__.load({ const [deleting, setDeleting] = useState(null) const stageLeft = useConversationInset() + const stageCall = (name, body, fallback) => settledCall(call, name, body, fallback) + const refresh = async () => { const value = await call('workflow/state') if (value && Array.isArray(value.workflows)) setPack(value) @@ -1283,8 +1323,9 @@ window.__ModuleLoader__.load({ editing, onToggleEdit: () => { setEditing(value => !value); setDrawer(true); setTab('flow') }, onCommit: (graph) => { - void call('workflow/graph', { workflowId: open.id, nodes: graph.nodes, edges: graph.edges }).then(value => { + void stageCall('workflow/graph', { workflowId: open.id, nodes: graph.nodes, edges: graph.edges }, 'Could not save this workflow').then(value => { if (value?.ok === false) { setError(value.error ?? ''); return } + setError('') if (value?.workflows) setPack(value) }) }, @@ -1292,17 +1333,35 @@ window.__ModuleLoader__.load({ onOpenDrawer: () => setDrawer(true), onToggleDrawer: () => { setDrawer(value => !value); setTab('flow') }, onTab: (key) => { setTab(key); setDrawer(true) }, - onReset: () => { void call('workflow/reset', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, + onReset: () => { + void stageCall('workflow/reset', { workflowId: open.id }, 'Could not reset this workflow').then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + setError('') + if (value?.workflows) setPack(value) + }) + }, onAssign: () => { setAssignOpen(true); void loadTasks() }, onTip: setTip, onPause: (runId) => { - void call('workflow/pause', { workflowId: open.id, runId }).then(value => { if (value?.workflows) setPack(value) }) + void stageCall('workflow/pause', { workflowId: open.id, runId }, 'Could not pause this run').then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + setError('') + if (value?.workflows) setPack(value) + }) }, onResume: (runId) => { - void call('workflow/resume', { workflowId: open.id, runId }).then(value => { if (value?.workflows) setPack(value) }) + void stageCall('workflow/resume', { workflowId: open.id, runId }, 'Could not resume this run').then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + setError('') + if (value?.workflows) setPack(value) + }) }, onStop: (runId) => { - void call('workflow/reset', { workflowId: open.id, runId }).then(value => { if (value?.workflows) setPack(value) }) + void stageCall('workflow/reset', { workflowId: open.id, runId }, 'Could not stop this run').then(value => { + if (value?.ok === false) { setError(value.error ?? ''); return } + setError('') + if (value?.workflows) setPack(value) + }) }, })) : null, assignOpen ? portal(h(AssignModal, { @@ -1530,7 +1589,7 @@ window.__ModuleLoader__.load({ name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, wirePath, WIRE_SPAN, NODE_WIRE_OUT, runHistoryRows, liveStageRuns, resolveFocusedRun, parallelFrameStyle, NODE_W, - botPaletteSubtitle, botTint, + botPaletteSubtitle, botTint, settledCall, } }, }) diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 8dbd6b92a..301291ce7 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -169,6 +169,21 @@ async function settle() { for (let i = 0; i < 4; i++) await new Promise(resolve => setImmediate(resolve)) } +// A rejected call that no `.catch` claims is an unhandled rejection: node fails the run, +// and the page says nothing. Catch them so a test can assert "nothing was swallowed". +async function withUnhandled(fn) { + const unhandled = [] + const onUnhandled = (reason) => unhandled.push(reason) + process.on('unhandledRejection', onUnhandled) + try { + return await fn(unhandled) + } finally { + process.off('unhandledRejection', onUnhandled) + } +} + +const alertsOf = view => findAll(view.tree, node => node.props?.role === 'alert').map(textOf) + test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are saved', async () => { const calls = [] const graph = delivery() @@ -855,3 +870,105 @@ test('Chinese labels follow the host Tasks translation', () => { globalThis.__flybot__ = prev } }) + +test('a failed stage graph save is reported, not swallowed', async (t) => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [{ id: 'delivery', name: '发布交付流水线' }] } + if (name === 'workflow/graph') throw new Error('The workflow store refused the write') + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + t.after(() => view.unmount()) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Flow')[0].props.onClick() + button(view.tree, 'Edit').props.onClick() + const coder = findAll(view.tree, node => node.props?.['data-node'] === 'coder')[0] + coder.props.onPointerDown({ clientX: 10, clientY: 20, target: { dataset: {} } }) + let canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerMove({ clientX: 58, clientY: 44 }) + canvas = findAll(view.tree, node => node.props?.className === 'wf-canvas')[0] + canvas.props.onPointerUp({ clientX: 58, clientY: 44, target: { dataset: {} } }) + await settle() + assert.deepEqual(alertsOf(view), ['The workflow store refused the write']) + assert.deepEqual(unhandled, []) + }) +}) + +test('a failed stage reset is reported instead of leaving the button dead', async (t) => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + if (name === 'workflow/reset') throw new Error('The reset was refused') + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + t.after(() => view.unmount()) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Flow')[0].props.onClick() + button(view.tree, '↺ Reset').props.onClick() + await settle() + assert.deepEqual(alertsOf(view), ['The reset was refused']) + assert.deepEqual(unhandled, []) + }) +}) + +test('a failed pause or stop from the run strip is reported', async (t) => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + graph.activeCount = 1 + let failure = '' + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + if (name === 'workflow/pause' || name === 'workflow/reset') throw new Error(failure) + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + t.after(() => view.unmount()) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + failure = 'The pause was refused' + button(view.tree, 'Pause').props.onClick() + await settle() + assert.deepEqual(alertsOf(view), ['The pause was refused']) + failure = 'The stop was refused' + button(view.tree, 'Stop').props.onClick() + await settle() + assert.deepEqual(alertsOf(view), ['The stop was refused']) + assert.deepEqual(unhandled, []) + }) +}) + +test('a failed resume from the run strip is reported', async (t) => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + graph.activeCount = 1 + graph.run.paused = true + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + if (name === 'workflow/resume') throw new Error('The resume was refused') + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + t.after(() => view.unmount()) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + await settle() + button(view.tree, 'Resume').props.onClick() + await settle() + assert.deepEqual(alertsOf(view), ['The resume was refused']) + assert.deepEqual(unhandled, []) + }) +}) + +test('a stage control that never answers settles as an error', async () => { + const value = await client.settledCall(async () => new Promise(() => {}), 'workflow/pause', { workflowId: 'delivery' }, 'The run did not answer', 5) + assert.deepEqual(value, { ok: false, error: 'The run did not answer' }) +})