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/1457-1458-assign-dialog.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The "Hand a task to a Workflow" dialog now selects the first card of the Tasks board as soon as the board arrives, so `Assign` posts the task instead of closing on a silent no-op, and an empty board leaves it disabled. When the Tasks board cannot be loaded the dialog shows the error with a `Retry` control instead of claiming there are no tasks to assign, and `Assign` stays disabled while the load is in flight or failed. ([#1457](https://github.com/ThinkFlowLab/FlyBot/issues/1457), [#1458](https://github.com/ThinkFlowLab/FlyBot/issues/1458))
4 changes: 2 additions & 2 deletions 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` | Factory delivery/weekly graphs plus `EARLY_END_GUIDANCE` for Claim/Verify/Triage early-end markers; 发布交付流水线 (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, fitted to the 400px panel so the graph needs no inner horizontal scroll), 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. |
| `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; it selects the first card once the board loads, keeps `Assign` disabled until a card is selected, and shows a failed Tasks load as an error with a `Retry` instead of the empty-board hint, #1457, #1458), flowchart drawer (collapsed by default, per-node inspector, fitted to the 400px panel so the graph needs no inner horizontal scroll), 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 All @@ -59,7 +59,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher,

## Tests

`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`.
`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), the assign dialog's preselected card and its failed and empty board states (#1457, #1458), and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`.

## Connector ceiling (#1544)

Expand Down
64 changes: 49 additions & 15 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ window.__ModuleLoader__.load({
'Rename workflow': '重命名 Workflow',
'Are you sure you want to delete "{name}"? This cannot be undone.': '确定删除「{name}」?此操作无法撤销。',
'No open tasks on the Tasks board.': 'Tasks 看板上没有可分配的任务。',
'Loading the Tasks board…': '正在读取 Tasks 看板…',
'Could not load the Tasks board.': '无法读取 Tasks 看板。',
Retry: '重试',
'Could not rename this workflow': '无法重命名该 Workflow',
'Could not delete this workflow': '无法删除该 Workflow',
'Could not save this workflow': '无法保存该 Workflow',
Expand Down Expand Up @@ -812,9 +815,17 @@ window.__ModuleLoader__.load({
h('button', { type: 'button', className: 'wf-btn wf-danger', onClick: onConfirm }, tr('Delete')))))
}

function AssignModal({ workflows, tasks, roster, workflowId, onClose, onConfirm }) {
function AssignModal({ workflows, tasks, tasksStatus, tasksError, roster, workflowId, onClose, onConfirm, onRetry }) {
const [taskId, setTaskId] = useState(tasks[0]?.id ?? '')
const [target, setTarget] = useState(workflowId || workflows[0]?.id || '')
// #1457: the dialog opens before loadTasks() answers, so the first client render can have no
// rows; select the first card once they arrive, or when the selected card is gone.
useEffect(() => {
if (tasks.length === 0) return
if (taskId && tasks.some(task => task.id === taskId)) return
setTaskId(tasks[0].id)
}, [tasks, taskId])
const canAssign = tasksStatus === 'ready' && tasks.some(task => task.id === taskId)
const seen = new Set()
const bots = (roster?.bots ?? []).filter(bot => {
if (!bot || bot.remote === true || !bot.id || !bot.name) return false
Expand All @@ -830,14 +841,20 @@ window.__ModuleLoader__.load({
h('p', { className: 'wf-hint' }, tr('A task can go to a Workflow, not only to one Bot. The Workflow then runs each Bot in order, or side by side.'))),
h('div', { className: 'wf-modal-body' },
h('div', { className: 'wf-step-h' }, h('b', null, '1'), tr('Pick a task card')),
tasks.length === 0
? h('div', { className: 'wf-muted wf-cards-empty' }, tr('No open tasks on the Tasks board.'))
: h('div', { className: 'wf-cards' }, tasks.map(task => h('button', {
key: task.id, type: 'button', className: `wf-kcard ${taskId === task.id ? 'sel' : ''}`, onClick: () => setTaskId(task.id),
},
h('div', { className: 'wf-id' }, `${shortId(task.id)} · ${priorityOf(task)}`),
h('div', { className: 'wf-tt' }, task.title),
h('div', { className: 'wf-kcol' }, `● ${tr(COLUMN_KEY[columnOf(task)] ?? 'Not started')}`)))),
tasksStatus === 'failed'
? h('div', { className: 'wf-assign-fail' },
h('div', { className: 'wf-err', role: 'alert' }, tasksError || tr('Could not load the Tasks board.')),
h('button', { type: 'button', className: 'wf-btn', onClick: () => onRetry?.() }, tr('Retry')))
: tasksStatus !== 'ready'
? h('div', { className: 'wf-muted wf-cards-empty' }, tr('Loading the Tasks board…'))
: tasks.length === 0
? h('div', { className: 'wf-muted wf-cards-empty' }, tr('No open tasks on the Tasks board.'))
: h('div', { className: 'wf-cards' }, tasks.map(task => h('button', {
key: task.id, type: 'button', className: `wf-kcard ${taskId === task.id ? 'sel' : ''}`, onClick: () => setTaskId(task.id),
},
h('div', { className: 'wf-id' }, `${shortId(task.id)} · ${priorityOf(task)}`),
h('div', { className: 'wf-tt' }, task.title),
h('div', { className: 'wf-kcol' }, `● ${tr(COLUMN_KEY[columnOf(task)] ?? 'Not started')}`)))),
h('div', { className: 'wf-step-h' }, h('b', null, '2'), tr('Pick who runs it')),
h('div', { className: 'wf-targets' },
workflows.map(item => h('button', {
Expand All @@ -858,7 +875,7 @@ window.__ModuleLoader__.load({
h('span', { className: 'wf-rad' }))))),
h('div', { className: 'wf-modal-footer' },
h('button', { type: 'button', className: 'wf-btn', onClick: onClose }, tr('Cancel')),
h('button', { type: 'button', className: 'wf-btn wf-dark', onClick: () => onConfirm({ taskId, target }) }, tr('Assign')))))
h('button', { type: 'button', className: 'wf-btn wf-dark', disabled: !canAssign, onClick: () => { if (canAssign) onConfirm({ taskId, target }) } }, tr('Assign')))))
}

function CreateModal({ roster, templates, onClose, onCreate, onTemplate }) {
Expand Down Expand Up @@ -1247,6 +1264,8 @@ window.__ModuleLoader__.load({
const [assignOpen, setAssignOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [tasks, setTasks] = useState([])
const [tasksStatus, setTasksStatus] = useState('idle')
const [tasksError, setTasksError] = useState('')
const [tip, setTip] = useState(null)
const [error, setError] = useState('')
const [editing, setEditing] = useState(false)
Expand Down Expand Up @@ -1295,7 +1314,9 @@ window.__ModuleLoader__.load({
const root = globalThis.__flybot__
if (!root) return undefined
const prev = root.openWorkflowAssign
root.openWorkflowAssign = () => { setAssignOpen(true); setOpenId(id => id) }
// #1457: this path opened the dialog without ever loading the board, so it could only
// ever show the frozen empty state.
root.openWorkflowAssign = () => { setAssignOpen(true); setOpenId(id => id); void loadTasks() }
return () => { root.openWorkflowAssign = prev }
}, [])

Expand Down Expand Up @@ -1349,8 +1370,15 @@ window.__ModuleLoader__.load({
}, [open?.id])

const loadTasks = async () => {
setTasksStatus('loading')
setTasksError('')
const host = globalThis.__flybot__?.host
if (typeof host !== 'function') { setTasks([]); return }
if (typeof host !== 'function') {
setTasks([])
setTasksError(tr('Could not load the Tasks board.'))
setTasksStatus('failed')
return
}
try {
const rows = await host('tasks', {})
const real = Array.isArray(rows)
Expand All @@ -1359,9 +1387,14 @@ window.__ModuleLoader__.load({
}))
: []
setTasks(real)
setTasksStatus('ready')
} catch (loadError) {
void loadError
// #1458: an empty list here used to be rendered as "no tasks to assign", which told the
// owner the board was empty instead of that the load had failed.
const detail = String(loadError?.message ?? '').trim()
setTasks([])
setTasksError(`${tr('Could not load the Tasks board.')}${detail ? ` (${detail})` : ''}`)
setTasksStatus('failed')
}
}

Expand Down Expand Up @@ -1505,8 +1538,8 @@ window.__ModuleLoader__.load({
},
})) : null,
assignOpen ? portal(h(AssignModal, {
workflows, tasks, roster, workflowId: openId || workflows[0]?.id,
onClose: () => setAssignOpen(false), onConfirm: confirmAssign,
workflows, tasks, tasksStatus, tasksError, roster, workflowId: openId || workflows[0]?.id,
onClose: () => setAssignOpen(false), onConfirm: confirmAssign, onRetry: loadTasks,
})) : null,
creating ? portal(h(CreateModal, {
roster, templates: pack?.templates, onClose: () => setCreating(false), onCreate: create,
Expand Down Expand Up @@ -1707,6 +1740,7 @@ window.__ModuleLoader__.load({
.wf-canvas-fit{position:relative;width:0;margin:0 auto;transform-origin:50% 0}
.wf-canvas-view .wf-wires path{stroke:var(--bt-ink-2,rgba(20,20,20,.55));stroke-width:2.5}
.wf-cards-empty{padding:8px 4px 12px}
.wf-assign-fail{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:4px 0 8px}
.wf-context-menu{position:fixed;z-index:95;min-width:140px;background:var(--bt-card,#fff);border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:flex;flex-direction:column;gap:2px}
.wf-context-menu button{border:0;background:transparent;color:inherit;font:inherit;font-size:12.5px;text-align:left;padding:8px 10px;border-radius:8px;cursor:pointer}
.wf-context-menu button:hover{background:var(--bt-hover,rgba(119,119,119,.09))}
Expand Down
Loading
Loading