Skip to content
Merged
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/1521.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
71 changes: 65 additions & 6 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1283,26 +1323,45 @@ 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)
})
},
onCloseDrawer: () => setDrawer(false),
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, {
Expand Down Expand Up @@ -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,
}
},
})
117 changes: 117 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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' })
})
Loading