From 4b353d34e6f454ab3b97222b7ff1a91a42c9ad0b Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:19:59 +0800 Subject: [PATCH 1/2] fix(workflow): report a failed host call on the settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workflow/state, workflow/graph and workflow/set-model only had a .then: a throw, a rejection or a host that never answers left the page on Reading workflows…, claimed changes were saved automatically, or said nothing at all, and the rejection went unhandled. Each call now settles through one helper that ends as a single error string, which the page shows in its own .wf-err alert; a failed read offers Try again, a failed save or model change drops the autosave claim, and a successful call clears the alert. The success path is otherwise unchanged. --- docs/subsystems/workflow.md | 2 + plugins/workflow/client.js | 35 ++++++---- plugins/workflow/test/client.test.js | 101 +++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 13 deletions(-) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 117fb5ab1..03d8bf477 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -12,6 +12,8 @@ This is not the workflow-definition engine. That engine (#1272) stores `workflow Settings → Workflows (#1364) is this plugin's list. The page lives in the core client and calls `workflow/state`, `workflow/create` and `workflow/graph`. Factory rows are `templateCatalog()` — id, name, description, triggers, plus the drawable `nodes`, `edges`, `layers` and `chain` — and the owner's rows are the saved workflows. The settings page does not replace the sidebar chat. A sidebar click opens this plugin's stage and leaves the Workflows section in place. +A host call on that page that throws, rejects, or never answers ends on the page's own `.wf-err` alert instead of staying on `Reading workflows…` or looking saved: a failed read offers `Try again`, and a failed save or model change clears the `Changes are saved automatically` hint. A successful call clears the alert again. + ## Owns Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, the browser chat and the flowchart canvas. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 1d3fa976d..a9bf4e9cb 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -115,6 +115,8 @@ window.__ModuleLoader__.load({ 'Could not stop this run': '无法停止本次运行', 'Reading workflows…': '正在读取 Workflow…', 'Changes are saved automatically': '更改会自动保存', + 'Try again': '重试', + 'Could not change the model': '无法更改模型', 'Next run: {time}': '下次运行:{time}', 'Step {step}': '步骤 {step}', 'Parallel branches · Merge after all pass': '并行分支 · 全部通过后汇合', @@ -124,9 +126,11 @@ 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. + // A host call that must end: a throw, a rejection or a host that never answers settles + // as one error string, so a failed read cannot leave the page on "Reading workflows…", + // a failed save cannot look saved, and a stage control (Pause, Stop, Reset) cannot fail + // silently either. Same shape as readWorkflows()/workflowCallError() in + // core/src/client/workflows.js, kept here because this page reads the plugin's own calls. const CALL_TIMEOUT_MS = 10000 function callError(failure, fallback) { if (typeof failure === 'string' && failure.trim()) return failure.trim() @@ -914,11 +918,13 @@ window.__ModuleLoader__.load({ const [editing, setEditing] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(true) - + const [attempt, setAttempt] = useState(0) + useEffect(() => { let gone = false setLoading(true) - void Promise.resolve(call('workflow/state')).then(value => { + setError('') + void settledCall(call, 'workflow/state', undefined, 'The workflow host did not answer').then(value => { if (gone) return setLoading(false) if (value && Array.isArray(value.workflows)) { @@ -928,7 +934,7 @@ window.__ModuleLoader__.load({ } else setError(value?.error ?? 'The workflow host did not answer') }) return () => { gone = true } - }, []) + }, [attempt]) useEffect(() => { ensureCss() @@ -944,9 +950,9 @@ window.__ModuleLoader__.load({ const save = (nodes, edges) => { if (!current) return - void Promise.resolve(call('workflow/graph', { workflowId: current.id, nodes, edges })).then(value => { - if (value?.ok === false) { setError(value.error ?? ''); return } - if (value?.workflows) setPack(value) + void settledCall(call, 'workflow/graph', { workflowId: current.id, nodes, edges }, 'Could not save this workflow').then(value => { + if (value?.ok === false) { setError(value.error ?? tr('Could not save this workflow')); return } + if (value?.workflows) { setPack(value); setError('') } }) } @@ -993,6 +999,9 @@ window.__ModuleLoader__.load({ return h('div', { className: 'wf-settings-canvas', 'data-flybot': 'workflow-settings' }, error ? h('div', { className: 'wf-err', role: 'alert' }, error) : null, + error && !pack && !loading + ? h('button', { type: 'button', className: 'wf-btn', onClick: () => setAttempt(value => value + 1) }, tr('Try again')) + : null, loading ? h('div', { className: 'wf-muted' }, tr('Reading workflows…')) : null, !loading && workflows.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows yet.')) : null, current ? h('div', { className: 'wf-settings-layout' }, @@ -1010,9 +1019,9 @@ window.__ModuleLoader__.load({ value: current.model || '', onChange: (event) => { const model = event.target.value || null - void Promise.resolve(call('workflow/set-model', { workflowId: current.id, model })).then(value => { - if (value?.ok === false) { setError(value.error ?? ''); return } - if (value?.workflows) setPack(value) + void settledCall(call, 'workflow/set-model', { workflowId: current.id, model }, 'Could not change the model').then(value => { + if (value?.ok === false) { setError(value.error ?? tr('Could not change the model')); return } + if (value?.workflows) { setPack(value); setError('') } }) }, }, @@ -1037,7 +1046,7 @@ window.__ModuleLoader__.load({ })), h('button', { type: 'button', className: 'wf-btn', onClick: () => setEditing(!editing) }, editing ? tr('Finish editing') : tr('Edit')), - editing ? h('span', { className: 'wf-autosave-hint' }, tr('Changes are saved automatically')) : null), + editing && !error ? h('span', { className: 'wf-autosave-hint' }, tr('Changes are saved automatically')) : null), h('div', { className: 'wf-canvas-main' }, h(GraphCanvas, { workflow: current, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index d5449a941..2735331e5 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -1383,3 +1383,104 @@ test('the settings page injects the workflow stylesheet without the sidebar sect globalThis.document = prev } }) + +// A failed call must end on the page's own alert, not on a spinner and not on a +// claim that something was saved: the three call sites of work/state, graph and +// set-model each report, recover, and leave no rejection behind. +test('a failed workflow/state read ends on the alert with a retry, not on "Reading workflows…"', async () => { + await withUnhandled(async (unhandled) => { + const calls = [] + let down = true + const call = async (name) => { + calls.push(name) + if (name === 'workflow/state') { + if (down) throw new Error('The workflow host did not answer') + return { workflows: [delivery()], templates: [] } + } + return { ok: true, workflows: [delivery()] } + } + const view = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + assert.deepEqual(calls, ['workflow/state']) + const alerts = findAll(view.tree, node => node.props?.role === 'alert') + assert.deepEqual(alerts.map(textOf), ['The workflow host did not answer']) + assert.equal(textOf(view.tree).includes('Reading workflows…'), false) + assert.deepEqual(unhandled, []) + + down = false + button(view.tree, 'Try again').props.onClick() + await settle() + assert.deepEqual(calls, ['workflow/state', 'workflow/state']) + assert.equal(findAll(view.tree, node => node.props?.role === 'alert').length, 0) + assert.ok(textOf(view.tree).includes('发布交付流水线')) + view.unmount() + }) +}) + +test('a failed graph save shows the alert and stops claiming changes are saved automatically', async () => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + let failure = new Error('The workflow store refused the write') + const calls = [] + const call = async (name) => { + calls.push(name) + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + if (name === 'workflow/graph') { + if (failure) throw failure + return { ok: true, workflows: [graph] } + } + return { ok: true, workflows: [graph] } + } + const view = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + button(view.tree, 'Edit').props.onClick() + await settle() + assert.ok(textOf(view.tree).includes('Changes are saved automatically')) + const pick = (label) => findAll(view.tree, node => node.props?.['aria-label'] === label)[0] + pick('From node').props.onChange({ target: { value: 'coder' } }) + pick('To node').props.onChange({ target: { value: 'planner' } }) + button(view.tree, 'Add link').props.onClick() + await settle() + assert.ok(calls.includes('workflow/graph')) + const alerts = findAll(view.tree, node => node.props?.role === 'alert') + assert.deepEqual(alerts.map(textOf), ['The workflow store refused the write']) + assert.equal(textOf(view.tree).includes('Changes are saved automatically'), false) + assert.deepEqual(unhandled, []) + + failure = null + pick('From node').props.onChange({ target: { value: 'coder' } }) + pick('To node').props.onChange({ target: { value: 'planner' } }) + button(view.tree, 'Add link').props.onClick() + await settle() + assert.equal(findAll(view.tree, node => node.props?.role === 'alert').length, 0) + assert.ok(textOf(view.tree).includes('Changes are saved automatically')) + view.unmount() + }) +}) + +test('a failed model change is reported, not silently taken as saved', async () => { + await withUnhandled(async (unhandled) => { + const graph = delivery() + const call = async (name, body) => { + if (name === 'workflow/state') { + return { workflows: [graph], roster: { models: [{ ref: 'deepseek/pro', name: 'Pro', provider: 'deepseek' }] } } + } + if (name === 'workflow/set-model') throw new Error('The model switch failed') + void body + return { ok: true, workflows: [graph] } + } + const view = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + findAll(view.tree, node => node.props?.['aria-label'] === 'Model')[0].props.onChange({ target: { value: 'deepseek/pro' } }) + await settle() + const alerts = findAll(view.tree, node => node.props?.role === 'alert') + assert.deepEqual(alerts.map(textOf), ['The model switch failed']) + assert.deepEqual(unhandled, []) + view.unmount() + }) +}) + +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' }) +}) From e4133294463e86e9c9d6037453d96dce2350fa0e Mon Sep 17 00:00:00 2001 From: Dev-4 Date: Wed, 28 Oct 2026 06:42:30 +0000 Subject: [PATCH 2/2] changelog: a failed host call on the workflow settings page (#1504) --- changelog.d/1504.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1504.fixed.md diff --git a/changelog.d/1504.fixed.md b/changelog.d/1504.fixed.md new file mode 100644 index 000000000..a8bb33810 --- /dev/null +++ b/changelog.d/1504.fixed.md @@ -0,0 +1 @@ +The Workflow settings page now reports a failed host call instead of staying on "Reading workflows…": a workflow read, graph save or model change whose host call throws, rejects or never answers ends on the page's own alert, a failed read offers "Try again", and a failed save no longer claims changes are saved automatically ([#1504](https://github.com/ThinkFlowLab/FlyBot/issues/1504)).