Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1504.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 2 additions & 0 deletions docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 22 additions & 13 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '并行分支 · 全部通过后汇合',
Expand All @@ -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()
Expand Down Expand Up @@ -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)) {
Expand All @@ -928,7 +934,7 @@ window.__ModuleLoader__.load({
} else setError(value?.error ?? 'The workflow host did not answer')
})
return () => { gone = true }
}, [])
}, [attempt])

useEffect(() => {
ensureCss()
Expand All @@ -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('') }
})
}

Expand Down Expand Up @@ -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' },
Expand All @@ -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('') }
})
},
},
Expand All @@ -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,
Expand Down
101 changes: 101 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
Loading