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)). 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' }) +})