diff --git a/changelog.d/1454.fixed.md b/changelog.d/1454.fixed.md new file mode 100644 index 000000000..51bf16213 --- /dev/null +++ b/changelog.d/1454.fixed.md @@ -0,0 +1 @@ +Workflow stage now shows short task summaries with pause, resume, and progress tied to live runs only; sidebar status never mirrors scripted or seeded runs (`demos[]` and `workflow/demo-step` removed). Factory workflows such as the delivery preset can be removed from the sidebar via Delete (confirm), not reset-only ([#1454](https://github.com/ThinkFlowLab/FlyBot/issues/1454)). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 1d0b72669..815fcf25e 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -28,17 +28,17 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | --- | --- | | `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that reports a step result. | | `plugins/workflow/src/graph.js` | Nodes, edges, positions, and named inputs and outputs. A node starts when its forward predecessors are settled and a taken edge reaches it. Same-rank nodes run together. A forward cycle is refused. A loop edge is the only cycle. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. | -| `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes. Both refuse factory presets. A spawn hook makes one run Bot per node. Disposing the plugin releases every run Bot first and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown that is cut short cannot leave hidden Bots that no state names (#1387). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed. | +| `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes. Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin releases every run Bot first and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown that is cut short cannot leave hidden Bots that no state names (#1387). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed. | | `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. `permissionFor` picks `danger-full-access` when the node names a shell, otherwise `workspace-write`. | | `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. The canned delivery copy is the demo player only. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), chat stage (normal conversation UI), 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, composer); it does not call `workflow/demo-step`. The stage shows the queue and refreshes state while one is active. Visual flowcharts replace text chains across all surfaces. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | +| `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), chat stage (normal conversation UI), 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, composer). The stage shows the queue and refreshes state while one is active. 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. | ## Entry points - Endpoint `workflow/state` and `workflow/templates` are reads. -- `workflow/create`, `workflow/rename`, `workflow/delete`, `workflow/assign`, `workflow/concurrency`, `workflow/graph`, `workflow/advance`, `workflow/demo-step`, `workflow/reset`, `workflow/say`, `workflow/ingest` write. `workflow/concurrency` sets that workflow's `maxConcurrent` (an integer from 1 to 8). `workflow/rename` updates a workflow's name and optional description; it refuses factory presets and read-only hosts. `workflow/delete` removes a workflow from state, releases its ephemeral Bots if a run exists, cleans logs/runs/routes, and refuses factory presets (#1434). +- `workflow/create`, `workflow/rename`, `workflow/delete`, `workflow/assign`, `workflow/concurrency`, `workflow/graph`, `workflow/advance`, `workflow/reset`, `workflow/say`, `workflow/ingest` write. `workflow/concurrency` sets that workflow's `maxConcurrent` (an integer from 1 to 8). `workflow/rename` updates a workflow's name and optional description; read-only hosts are refused. `workflow/delete` removes a workflow from state, releases its ephemeral Bots if a run exists, and cleans logs/runs/routes. - Tool `run_workflow` starts a named run with a brief. Tool `define_workflow` writes the same graph the canvas edits. - `flybot.tasks.onWrite` calls `ingest`. A new Bot match returns `{ ownerBotId }`. A Workflow match starts the run inside the plugin and leaves the card's owner to the host. @@ -46,8 +46,8 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. 2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`. Status highlighting reads that same graph. Replacing edges does not recompute who is already running. -3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply is the chat line. Nodes start only when their forward predecessors finish and edges reach them. `workflow/demo-step` exists for unit tests and scripted fixtures only; the browser client does not call it. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the seeded fixture. Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. -4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. An old store run is a demo unless it is still `running` and has ephemeral Bot ids. History records a finished or failed run only. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. +3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply is the chat line. Nodes start only when their forward predecessors finish and edges reach them. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not. +4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. 5. A node declares up to eight inputs and eight outputs. A required input is an output of the same name on an ancestor, or `task`, `title`, or `brief` on a root. A start with no provider is refused, and Bots spawned for that attempt are released. On a Bot-backed run a successor stays queued until its required inputs are present. If every forward predecessor is done, failed or skipped and an input is still missing, that node fails and the run stops. The dispatch text carries `Input : `. The Bot reports `OUTPUT : `; a single declared output may be the reply body. Nodes start in edge order. 6. Each node is a real ephemeral Bot for that run, then it is removed. A permanent sidebar Bot (`botId`) is optional and is not required for the step to run. `tools` on the node is what that Bot may use: `github` grants `mcp__github__*` (pull requests, reviews, comments); `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell; any other step gets `workspace-write`. A hidden run Bot cannot show an approval card, so a granted tool is allowed and a connector it did not name is refused. The host stores this on the run Bot as `stepTools` and `permission`. 7. A loop edge (`loop: true`, `max` from 1 to 8, default 3, optional `until: { output, eq }`) is the only cycle. The body runs again until `until` matches or `max` runs have finished, and the forward edges wait until then. Restarting the body drops the branch choice made inside it and queues a sibling that the previous pass skipped, so the next pass can take the other side. A `when: { output, eq }` edge is taken only when that output equals `eq`. `when: { else: true }` is taken only when no other `when` matched. A node whose forward edges are all closed is `skipped` and does not block a join. A run cannot spin: every loop has a max. Settings → Workflow graphs can add a link, a condition or a loop. @@ -55,8 +55,8 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 9. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup. 10. `enabled: false` registers no endpoint, tool or listener and writes nothing. 11. A sidebar click opens the stage or a dialog and leaves the Workflows section in place. The stage and the dialogs portal onto `document.body`. When that container is missing they stay in the section tree; the click does not throw. -12. A node's prompt and the graph are one record. Settings → Workflow graphs (canvas-first with per-node inspector), the flowchart drawer (collapsed by default, inspector on select), and `define_workflow` all write `workflow/graph`. A run reads that record; it does not define it. After a live run finishes, the stage shows `lane.last` for that workflow until a new live run starts. Seeded fixture scripts stay in `state.demos` for tests; the product UI does not step them. +12. A node's prompt and the graph are one record. Settings → Workflow graphs (canvas-first with per-node inspector), the flowchart drawer (collapsed by default, inspector on select), and `define_workflow` all write `workflow/graph`. A run reads that record; it does not define it. After a live run finishes, the stage shows the last finished live run (`lane.last` with `live === true`) until a new live run starts. ## 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, 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, templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (factory refuse, cleanup on delete, #1434), 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`. +`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, 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, templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, 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`. diff --git a/plugins/workflow/README.md b/plugins/workflow/README.md index 14dba2ad7..ee1dc3451 100644 --- a/plugins/workflow/README.md +++ b/plugins/workflow/README.md @@ -71,7 +71,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab npm test ``` -Endpoints, all under `flybot/workflow/`: `state`, `templates` (read), `create`, `assign`, `concurrency`, `advance`, `demo-step`, `reset`, `say`, `graph`, `ingest`. +Endpoints, all under `flybot/workflow/`: `state`, `templates` (read), `create`, `assign`, `concurrency`, `advance`, `reset`, `say`, `graph`, `ingest`. ## 中文 diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 05864c947..171b68d89 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -43,6 +43,9 @@ window.__ModuleLoader__.load({ 'Not started': '待办', 'In progress': '进行中', 'Close': '关闭', + Pause: '暂停', + Resume: '继续', + Progress: '进度', 'New workflow': '新建 Workflow', Name: '名称', 'Add a Bot': '添加 Bot', @@ -177,6 +180,29 @@ window.__ModuleLoader__.load({ if (text.startsWith('task-')) return `#${text.slice(-6)}` return text } + const CHAT_PREVIEW = 240 + function taskSummary(task, run) { + if (run?.taskSummary) return run.taskSummary + const id = shortId(task?.id ?? '') + const title = String(task?.title ?? '').replace(/\s+/g, ' ').trim() + if (!id && !title) return '' + if (!title) return id + if (!id) return title.length > 72 ? `${title.slice(0, 69)}…` : title + const room = Math.max(24, 72 - id.length - 3) + const short = title.length > room ? `${title.slice(0, room - 1)}…` : title + return `${id} · ${short}` + } + function chatPreview(text) { + const line = String(text ?? '').replace(/\s+/g, ' ').trim() + if (line.length <= CHAT_PREVIEW) return line + return `${line.slice(0, CHAT_PREVIEW - 1)}…` + } + function sidebarPreview(item) { + const busy = (item.activeCount ?? 0) > 0 && item.run?.live === true + if (busy) return item.preview || tr('Waiting for a task') + if (item.trigger === 'schedule' && item.scheduleLabel) return `下次运行:${item.scheduleLabel}` + return tr('Waiting for a task') + } function glyph() { return h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', 'aria-hidden': true }, h('rect', { x: 9, y: 2, width: 6, height: 5, rx: 1.5 }), @@ -260,9 +286,9 @@ window.__ModuleLoader__.load({ } // Stage: reuse normal FlyBot conversation UI instead of custom card style - function Stage({ workflow, roster, stageLeft, drawer, tab, draft, onDraft, onCloseDrawer, onToggleDrawer, onTab, onAssign, onSend, onTip, onReset, editing, onToggleEdit, onCommit }) { + function Stage({ workflow, roster, stageLeft, drawer, tab, draft, onDraft, onCloseDrawer, onToggleDrawer, onTab, onAssign, onSend, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume }) { const chatRef = useRef(null) - const run = workflow.run + const run = workflow.run?.live === true ? workflow.run : null useEffect(() => { const el = chatRef.current if (el) el.scrollTop = el.scrollHeight @@ -284,11 +310,11 @@ window.__ModuleLoader__.load({ return h('div', { className: 'wf-stage', style: { left: stageLeft } }, h(ConversationHeader, { title: workflow.name, - subtitle: run?.task ? `${run.task.id} ${run.task.title}` : tr('Waiting for a task'), + subtitle: run ? taskSummary(run.task, run) : tr('Waiting for a task'), onAction: onToggleDrawer }), h(Conversation, { - messages, + messages: messages.map(msg => ({ ...msg, text: chatPreview(msg.text) })), onSend: (text) => { onDraft(text); onSend() } })) } @@ -306,13 +332,20 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onAssign }, tr('Hand a task to a Workflow')))), run ? h('div', { className: 'wf-strip' }, h('div', { className: 'wf-strip-id' }, - h('div', { className: 'wf-tk' }, `${run.task?.id ?? ''} ${run.task?.title ?? ''}`), - h('div', { className: 'wf-meta' }, running[0] - ? `${tr('Running')} · ${running.map(node => node.name).join(' ‖ ')} · ${run.doneCount}/${run.nodeCount}` - : (run.status === 'done' ? tr('Done') : tr('Waiting for a task')))), - h('div', { className: 'wf-segs' }, (run.marks ?? []).map((mark, index) => h('span', { key: index, className: `wf-seg ${mark}` }))), - h('span', { className: `wf-chip ${run.status === 'done' ? 'done' : (running[0] ? 'running' : 'queued')}` }, run.status === 'done' ? tr('Done') : (running[0] ? tr('Running') : tr('Queued'))), + h('div', { className: 'wf-tk', title: run.task?.title ?? '' }, taskSummary(run.task, run)), + h('div', { className: 'wf-meta' }, run.paused + ? `${tr('Pause')} · ${running.map(node => node.name).join(' ‖ ') || '—'} · ${run.doneCount}/${run.nodeCount}` + : (running[0] + ? `${tr('Running')} · ${running.map(node => node.name).join(' ‖ ')} · ${run.doneCount}/${run.nodeCount}` + : (run.status === 'done' ? tr('Done') : tr('Waiting for a task'))))), + h('div', { className: 'wf-segs', 'aria-label': tr('Progress'), title: tr('Progress') }, (run.marks ?? []).map((mark, index) => h('span', { key: index, className: `wf-seg ${mark}` }))), + h('span', { className: `wf-chip ${run.status === 'done' ? 'done' : (run.paused ? 'queued' : (running[0] ? 'running' : 'queued'))}` }, + run.status === 'done' ? tr('Done') : (run.paused ? tr('Pause') : (running[0] ? tr('Running') : tr('Queued')))), (workflow.queueDepth > 0 || (workflow.activeCount ?? 0) > 1) ? h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? 0, depth: workflow.queueDepth ?? 0 })) : null, + run.status === 'running' ? (run.paused + ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-pri', onClick: onResume }, tr('Resume')) + : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: onPause }, tr('Pause'))) : null, + h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => { onTab('runs'); onToggleDrawer() } }, tr('Progress')), h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onTab('flow') }, tr('Flow'))) : null, h('div', { className: 'wf-chat', ref: chatRef }, h('div', { className: 'wf-chat-in' }, @@ -333,19 +366,19 @@ window.__ModuleLoader__.load({ function Bubble({ message, workflow, roster }) { if (message.kind === 'time') return h('div', { className: 'wf-ts' }, message.text) - if (message.kind === 'system') return h('div', { className: `wf-sys ${message.tone ?? ''}` }, message.text) - if (message.kind === 'user') return h('div', { className: 'wf-user' }, message.text) + if (message.kind === 'system') return h('div', { className: `wf-sys ${message.tone ?? ''}` }, chatPreview(message.text)) + if (message.kind === 'user') return h('div', { className: 'wf-user' }, chatPreview(message.text)) const node = nodeOf(workflow, message.botId) ?? { id: message.botId, name: message.botId } return h('div', { className: 'wf-msg' }, h(Mark, { node, roster, size: 28 }), h('div', null, h('div', { className: 'wf-who' }, node.name, message.step ? h('span', { className: 'wf-step' }, `步骤 ${message.step}`) : null), - h('div', { className: 'wf-bub' }, message.text, + h('div', { className: 'wf-bub' }, chatPreview(message.text), message.attachments?.length ? h('div', { className: 'wf-att' }, message.attachments.map(name => h('span', { key: name }, name))) : null))) } function DrawerBody({ workflow, roster, tab, onTab, onReset, onTip, editing, onToggleEdit, onCommit }) { - const run = workflow.run + const run = workflow.run?.live === true ? workflow.run : null return h('div', { className: 'wf-dr-body' }, h('div', { className: 'wf-dr-title' }, h('div', { className: 'wf-ico wf-ico-lg' }, glyph()), @@ -439,7 +472,7 @@ window.__ModuleLoader__.load({ key: bot.id, type: 'button', className: 'wf-chip', 'data-bot': bot.id, onPointerDown: (event) => { event.preventDefault?.(); setGesture({ kind: 'add', bot }) }, }, bot.name))) : null, - h('div', { className: 'wf-term' }, run?.task ? `${run.task.id} ${run.task.title}` : tr('Waiting for a task')), + run ? h('div', { className: 'wf-term', title: run.task?.title ?? '' }, taskSummary(run.task, run)) : null, h('div', { className: 'wf-plane', 'data-plane': '1', style: { height } }, h('svg', { className: 'wf-wires', 'aria-hidden': true, ...wireSvgProps(height) }, edges.map(edge => { @@ -505,13 +538,13 @@ window.__ModuleLoader__.load({ } function FlowNode({ node, status, roster }) { - return [ - h('span', { className: `wf-chip ${status}` }, tr(STATUS_KEY[status] ?? 'Idle')), + return h('div', { className: 'wf-node-row' }, h(Mark, { node, roster, size: 28 }), h('span', { className: 'wf-nt' }, - h('span', { className: 'wf-nm' }, node.name), - h('span', { className: 'wf-rl' }, node.role)), - ] + h('span', { className: 'wf-nm' }, + node.name, + h('span', { className: `wf-chip wf-chip-inline ${status}` }, tr(STATUS_KEY[status] ?? 'Idle'))), + h('span', { className: 'wf-rl' }, node.role))) } function Details({ workflow }) { @@ -537,7 +570,7 @@ window.__ModuleLoader__.load({ for (const task of queued) rows.push({ task, status: 'queued', current: false }) if (current && !rows.some(item => item.task?.id === current.id && item.status === workflow.run.status)) rows.push({ task: current, status: workflow.run.status, current: true }) return h('div', null, rows.map((item, index) => h('div', { key: index, className: 'wf-hist' }, - h('span', null, `${item.task?.id ?? ''} ${item.task?.title ?? ''}`), + h('span', { title: item.task?.title ?? '' }, taskSummary(item.task, item)), h('span', { className: `wf-chip ${item.status === 'done' ? 'done' : item.status === 'running' ? 'running' : item.status === 'failed' ? 'failed' : 'queued'}` }, tr(item.status === 'done' ? 'Done' : item.status === 'running' ? 'Running' : item.status === 'failed' ? 'Failed' : 'Queued'))))) } @@ -991,8 +1024,6 @@ window.__ModuleLoader__.load({ } } - const factoryIds = new Set((pack?.templates ?? []).map(item => item.id)) - const confirmRename = async (workflowId, name) => { const trimmed = String(name ?? '').trim() if (!trimmed) return @@ -1071,16 +1102,16 @@ window.__ModuleLoader__.load({ key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, onClick: () => { setOpenId(item.id) }, onContextMenu: (event) => { - if (factoryIds.has(item.id)) return event.preventDefault() + event.stopPropagation?.() setRowMenu({ x: event.clientX, y: event.clientY, workflow: item }) }, }, h('span', { className: 'wf-ico' }, h(MiniFlowchart, { workflow: item, roster })), h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow')), - h('span', { className: 'wf-t2' }, item.preview || tr('Waiting for a task'))), - item.run?.status === 'running' ? h('span', { className: 'wf-live', 'aria-hidden': true }) : null)), + h('span', { className: 'wf-t2' }, sidebarPreview(item))), + (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live', 'aria-hidden': true }) : null)), h('button', { type: 'button', className: 'wf-row wf-assign-row', onClick: () => { setAssignOpen(true); void loadTasks() } }, h('span', { className: 'wf-ico' }, glyph()), h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), @@ -1107,6 +1138,12 @@ window.__ModuleLoader__.load({ void call('workflow/say', { workflowId: open.id, text }).then(value => { if (value?.workflows) setPack(value) }) }, onTip: setTip, + onPause: () => { + void call('workflow/pause', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) + }, + onResume: () => { + void call('workflow/resume', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) + }, })) : null, assignOpen ? portal(h(AssignModal, { workflows, tasks, roster, workflowId: openId || workflows[0]?.id, @@ -1129,13 +1166,14 @@ window.__ModuleLoader__.load({ h('div', null, tip.node.role), h('div', { className: 'wf-k' }, 'Instructions'), h('div', null, tip.node.instructions)) : null, - rowMenu ? h('div', { + rowMenu ? portal(h('div', { className: 'wf-context-menu', style: { left: rowMenu.x, top: rowMenu.y }, + onPointerDown: (event) => event.stopPropagation(), onClick: (event) => event.stopPropagation(), }, h('button', { type: 'button', onClick: () => { setRenaming(rowMenu.workflow); setRowMenu(null) } }, tr('Rename')), - h('button', { type: 'button', className: 'wf-danger-text', onClick: () => { setDeleting(rowMenu.workflow); setRowMenu(null) } }, tr('Delete'))) : null, + h('button', { type: 'button', className: 'wf-danger-text', onClick: () => { setDeleting(rowMenu.workflow); setRowMenu(null) } }, tr('Delete')))) : null, renaming ? portal(h(RenameModal, { workflow: renaming, onClose: () => setRenaming(null), @@ -1172,8 +1210,8 @@ window.__ModuleLoader__.load({ .wf-btn.wf-pri{background:var(--bt-accent,#4d6bfe);border-color:var(--bt-accent,#4d6bfe);color:#fff} .wf-btn.wf-dark{background:var(--bt-user,#070707);color:var(--bt-user-ink,#fcfcfc);border-color:var(--bt-user,#070707)} .wf-btn.wf-sm{height:28px} -.wf-strip{margin:0 auto 4px;width:min(720px,calc(100% - 48px));border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:9px 12px;display:flex;align-items:center;gap:12px} -.wf-tk{font-weight:600}.wf-meta{font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6))} +.wf-strip{margin:0 auto 4px;width:min(720px,calc(100% - 48px));border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:9px 12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.wf-tk{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(360px,42vw)}.wf-meta{font-size:11.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));white-space:nowrap} .wf-segs{display:flex;gap:3px;flex:1;min-width:80px} .wf-seg{height:5px;border-radius:3px;background:var(--bt-active,rgba(119,119,119,.17));flex:1} .wf-seg.done{background:var(--bt-accent,#4d6bfe)}.wf-seg.failed{background:#e5484d}.wf-seg.running{background:rgba(77,107,254,.35)} @@ -1206,11 +1244,13 @@ window.__ModuleLoader__.load({ .wf-dr-body{flex:1;overflow:auto;padding:4px 18px 20px} .wf-dr-title{display:flex;flex-direction:column;align-items:center;gap:4px;margin-bottom:10px} .wf-dr-title h2{font-size:16px;font-weight:600;margin:0} -.wf-dr-title p{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));text-align:center;line-height:1.5;margin:0} +.wf-dr-title p{font-size:12px;color:var(--bt-ink-2,rgba(20,20,20,.6));text-align:center;line-height:1.5;margin:0;max-width:100%;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} .wf-tabs{display:flex;justify-content:center;gap:4px;margin:6px 0 12px} .wf-tabs button{padding:4px 10px;border-radius:7px;border:0;background:transparent;color:var(--bt-ink-2,rgba(20,20,20,.6));cursor:pointer;font:inherit} .wf-tabs button.on{background:var(--bt-active,rgba(119,119,119,.17));color:var(--bt-ink,#141414)} -.wf-term{font-size:11px;color:var(--bt-ink-2,rgba(20,20,20,.6));border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:3px 10px} +.wf-term{font-size:11px;color:var(--bt-ink-2,rgba(20,20,20,.6));border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:3px 10px;max-width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.wf-node-row{display:flex;align-items:center;gap:8px;width:100%;min-width:0} +.wf-chip-inline{margin-left:6px;font-size:9.5px;vertical-align:middle;font-weight:500} .wf-term.on{color:#16a34a;background:rgba(22,163,74,.1);border-color:transparent} .wf-canvas{display:flex;flex-direction:column;align-items:center;gap:8px;min-height:440px;position:relative;overflow:auto} .wf-palette{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;width:100%} @@ -1229,7 +1269,6 @@ window.__ModuleLoader__.load({ .wf-node.running{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 3px rgba(77,107,254,.12)} .wf-node.done{border-color:rgba(22,163,74,.35)}.wf-node.failed{border-color:#e5484d} .wf-node.selected{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px rgba(77,107,254,.2)} -.wf-node .wf-chip{position:absolute;top:-9px;right:8px} .wf-nt{min-width:0;flex:1;display:flex;flex-direction:column} .wf-nm{font-weight:600;font-size:12.5px}.wf-rl{font-size:11px;color:var(--bt-ink-3,#9a9a9a);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .wf-box{border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:10px;background:var(--bt-card,#fff);padding:10px 12px} @@ -1298,6 +1337,6 @@ window.__ModuleLoader__.load({ return off } - return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages } + return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, chatPreview } }, }) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index d236c50fb..eba515df9 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -99,8 +99,9 @@ export function apply(ctx, config = {}) { fb.api.handle('workflow/assign', body => service.assign(body)), fb.api.handle('workflow/concurrency', body => service.setConcurrency(body)), fb.api.handle('workflow/advance', body => service.advance(body)), - fb.api.handle('workflow/demo-step', body => service.demoStep(body)), fb.api.handle('workflow/reset', body => service.reset(body)), + fb.api.handle('workflow/pause', body => service.pause(body)), + fb.api.handle('workflow/resume', body => service.resume(body)), fb.api.handle('workflow/say', body => service.say(body)), fb.api.handle('workflow/graph', body => service.saveGraph(body)), fb.api.handle('workflow/templates', () => ({ ok: true, templates: service.state().templates }), { read: true }), diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js index 4464420f3..b9dc09c4d 100644 --- a/plugins/workflow/src/graph.js +++ b/plugins/workflow/src/graph.js @@ -651,9 +651,7 @@ export function orderedIds(graph, status, want) { return ids } -// The demo player (and the "next" button) ask the graph what one step is. -// A node marked failOnce fails the first time it is the running choice, then -// a later step retries it. The join still waits until that node is done. +// Scripted step chooser for graph unit tests (failOnce retry ordering). export function nextDemoOp(graph, status, memory = {}) { ensure(graph) const failed = orderedIds(graph, status, 'failed') @@ -687,15 +685,29 @@ export function chainLabel(graph) { return graph.layers.map(layer => layer.map(id => nodeById(graph, id)?.name ?? id).join(' ‖ ')).join(' → ') } +export function summarizeTask(task, maxTitle = 72) { + const id = cleanLine(task?.id, 80) + const title = cleanLine(task?.title, maxTitle) + if (!id && !title) return '' + if (!title) return id + if (!id) return title + return `${id} · ${title}` +} + export function rowPreview(workflow, run) { - if (run?.status === 'running') { + const live = run?.live === true + if (live && run?.status === 'running') { const id = orderedIds(workflow, run.nodeStatus, 'running')[0] const name = id ? nodeById(workflow, id)?.name : '' const task = run.task?.id ?? '' + if (run.paused === true) { + return name && task ? `已暂停 · ${name} · ${task}` : (task ? `已暂停 · ${task}` : '已暂停') + } return name ? `${name} 正在处理 ${task}…` : '运行中' } if (workflow.trigger === 'schedule' && workflow.scheduleLabel) return `下次运行:${workflow.scheduleLabel}` - return run?.status === 'done' && run.task?.id ? `✓ ${run.task.id} 已完成` : '等待任务' + if (live && run?.status === 'done' && run.task?.id) return `✓ ${run.task.id} 已完成` + return '等待任务' } export function stepLabel(graph, nodeId) { diff --git a/plugins/workflow/src/narrate.js b/plugins/workflow/src/narrate.js index dbb53c053..772e4d222 100644 --- a/plugins/workflow/src/narrate.js +++ b/plugins/workflow/src/narrate.js @@ -1,8 +1,8 @@ -// Chat lines for one graph step. Delivery-template nodes use the demo's copy; -// any other graph gets a short line that names the Bot and the task. Text is +// Chat lines for one graph step. Delivery-template nodes use canned copy when +// no Bot reply is quoted; any other graph gets a short line that names the Bot and the task. Text is // plain: the client renders it as a text node. -import { nodeById, stepLabel } from './graph.js' +import { nodeById, stepLabel, summarizeTask } from './graph.js' import { DELIVERY_ID } from './template.js' function names(workflow, ids) { @@ -34,11 +34,11 @@ function liveText(value, fallback) { return text || fallback } -// A live run quotes the Bot. The delivery script's canned lines stay on the demo path. +// A live run quotes the Bot when event.text is set; otherwise doneText fills in. function liveLines(workflow, task, event) { - const label = `${task?.id ?? ''} ${task?.title ?? ''}`.trim() + const label = summarizeTask(task) if (event.type === 'start') { - return [{ kind: 'system', tone: 'accent', text: `任务 ${label} 已进入 Workflow,${names(workflow, event.started)} 认领` }] + return [{ kind: 'system', tone: 'accent', text: `开始 · ${label} · ${names(workflow, event.started)}` }] } if (event.type === 'fail') { const node = nodeById(workflow, event.nodeId) @@ -59,7 +59,7 @@ function liveLines(workflow, task, event) { if (event.joined && event.layerSize > 1 && event.started.length > 0) { lines.push({ kind: 'system', text: `⇢ 并行分支汇合(join)→ ${names(workflow, event.started)}` }) } - if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ Workflow 运行完成 · ${task.id} · ${workflow.nodes.length} 个节点` }) + if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ 完成 · ${summarizeTask(task)} · ${workflow.nodes.length} 步` }) return lines } if (event.type === 'note') return [{ kind: 'system', tone: event.tone, text: event.text }] @@ -96,7 +96,7 @@ export function linesFor(workflow, task, event) { if (event.joined && event.layerSize > 1 && event.started.length > 0) { lines.push({ kind: 'system', text: `⇢ 并行分支汇合(join)→ ${names(workflow, event.started)}` }) } - if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ Workflow 运行完成 · ${task.id} · ${workflow.nodes.length} 个节点` }) + if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ 完成 · ${summarizeTask(task)} · ${workflow.nodes.length} 步` }) return lines } if (event.type === 'reset') return [{ kind: 'system', text: '↺ 已重置运行' }] diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 8eddd73c6..e2e30c181 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -1,7 +1,6 @@ // Workflow records and the runs a board task starts. The graph rules live in // graph.js. A workflow keeps a FIFO queue and up to maxConcurrent live runs -// (default 2). A live node stays running until its Bot replies. The demo -// player is a separate script and does not move a live run. +// (default 2). A live node stays running until its Bot replies. import { randomBytes } from 'node:crypto' import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' @@ -9,12 +8,12 @@ import { join } from 'node:path' import { releaseAll, spawnAll } from './ephemeral.js' import { chainLabel, cleanLine, cleanPorts, cleanPrompt, cleanTools, completeNode, doneCount, failNode, freshStatus, layerMarks, - missingProviders, nextDemoOp, nodeById, normalizeGraph, orderedIds, readStepResult, resolveInputs, retryNode, - rowPreview, startFrontier, unmetRequired, validateGraph, + missingProviders, nodeById, normalizeGraph, orderedIds, readStepResult, resolveInputs, retryNode, + rowPreview, startFrontier, summarizeTask, unmetRequired, validateGraph, } from './graph.js' import { matchTask } from './match.js' import { linesFor } from './narrate.js' -import { DELIVERY_ID, DELIVERY_STEPS, WEEKLY_ID, deliveryWorkflow, fixtureTask, templateById, templateCatalog, weeklyWorkflow } from './template.js' +import { DELIVERY_ID, DELIVERY_STEPS, WEEKLY_ID, deliveryWorkflow, templateById, templateCatalog, weeklyWorkflow } from './template.js' const MAX_WORKFLOWS = 24 const MAX_MESSAGES = 200 @@ -66,6 +65,7 @@ function publicRun(workflow, run) { id: run.id, workflowId: run.workflowId, task: run.task, + taskSummary: summarizeTask(run.task), status: run.status, nodeStatus: { ...run.nodeStatus }, steps: run.steps, @@ -78,6 +78,7 @@ function publicRun(workflow, run) { startedAt: run.startedAt, updatedAt: run.updatedAt, ...(run.live === true ? { live: true } : {}), + ...(run.paused === true ? { paused: true } : {}), ...(run.ephemeral && Object.keys(run.ephemeral).length > 0 ? { ephemeral: { ...run.ephemeral } } : {}), } } @@ -101,12 +102,12 @@ function blankRun(workflow, task, now, history = []) { export function createWorkflowService({ dir, persist = true, now = () => Date.now(), bots = () => [], readOnly = () => false, warn = () => {}, spawn, release, dispatch } = {}) { const file = dir ? join(dir, FILE) : null - let state = load(file, warn) ?? seed(now()) + let state = load(file, warn) ?? seed() state.routes ??= {} state.claims ??= {} state.lanes ??= {} - state.demos ??= {} if (!state.laneFormat) migrateRuns(state) + purgeDemos(state) let seq = state.seq ?? 1 // One lock for every start and release. The host cap is read and updated // only while this is held, so two workflows cannot both pass it. @@ -179,8 +180,8 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no queue: (laneOf(item.id).queue ?? []).map(task => ({ id: task.id, title: task.title })), history: [...(laneOf(item.id).history ?? [])], runs: runningOf(laneOf(item.id)).map(run => publicRun(item, run)), - preview: rowPreview(item, primaryRun(item.id)), - run: publicRun(item, primaryRun(item.id)), + preview: rowPreview(item, previewRun(item.id)), + run: publicRun(item, displayRun(item.id)), messages: state.logs[item.id] ?? [], }) }), } @@ -196,7 +197,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no } function runningOf(lane) { - return (lane?.active ?? []).filter(run => run && run.status === 'running') + return (lane?.active ?? []).filter(run => run && run.live === true && run.status === 'running') } function globalRunning() { @@ -211,23 +212,23 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no return Math.min(MAX_CONCURRENT, value) } - function primaryRun(id) { + function displayRun(id) { const live = runningOf(laneOf(id)) if (live.length > 0) return live[0] - // A finished lane.last stays up until Replay. demoStep marks the script - // as the run on stage; otherwise the graph stays on that finished lane - // while the canned lines append to the demo. - const demo = state.demos[id] - if (state.demoOnTop?.[id] === true && demo) return demo - if (laneOf(id).last) return laneOf(id).last - return demo ?? null + const last = laneOf(id).last + if (last?.live === true) return last + return null + } + + function previewRun(id) { + const live = runningOf(laneOf(id)) + return live.length > 0 ? live[0] : null } function storeRun(workflowId, run) { const lane = laneOf(workflowId) const index = lane.active.findIndex(item => item.id === run.id) if (index >= 0) lane.active[index] = run - else if (state.demos[workflowId]?.id === run.id) state.demos[workflowId] = run } function sentOk(result) { @@ -355,7 +356,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no // leave the node open: the next hand-off tries again. No second send in // this call — a 429 must not be hammered. async function handOff(item, run) { - if (typeof dispatch !== 'function' || !run?.ephemeral) return run + if (typeof dispatch !== 'function' || !run?.ephemeral || run.paused === true) return run let current = run const running = orderedIds(item, current.nodeStatus, 'running') for (const nodeId of running) { @@ -479,13 +480,12 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no function beginRun(item, task, ephemeral) { const holes = missingProviders(item) if (holes.length > 0) return { ok: false, error: `Node ${holes[0].nodeId} input ${holes[0].name} has no upstream provider` } - delete state.demoOnTop?.[item.id] let run = { ...blankRun(item, task, now(), []), live: true } if (ephemeral) run.ephemeral = ephemeral const at = now() push(item.id, [ { kind: 'time', text: '刚刚' }, - { kind: 'system', text: `来自 Tasks 看板:${task.id} ${task.title} → 分配给 Workflow「${item.name}」` }, + { kind: 'system', text: `任务已分配 · ${summarizeTask(task)} → ${item.name}` }, ], at) const stepped = applyOp(item, run, { type: 'start' }) run = stepped.run @@ -529,7 +529,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no const lane = laneOf(item.id) state.queueSeq = (state.queueSeq ?? 0) + 1 lane.queue.push({ id: task.id, title: task.title, enqueuedAt: now(), queueSeq: state.queueSeq }) - push(item.id, [{ kind: 'system', text: `排队:${task.id} ${task.title}(前面 ${lane.queue.length - 1} 个,同时运行上限 ${maxOf(item)})` }], now()) + push(item.id, [{ kind: 'system', text: `排队 · ${summarizeTask(task)}(前面 ${lane.queue.length - 1} 个)` }], now()) write() } @@ -569,7 +569,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no if (runId) return lane.active.find(run => run.id === runId) ?? null const live = lane.active.find(run => run.status === 'running' && (!nodeId || run.nodeStatus?.[nodeId] === 'running')) if (live) return live - return state.demos[workflowId] ?? null + return null } function commitAdvance(workflowId, op) { @@ -626,8 +626,6 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no const workflowId = String(body.workflowId ?? '') const item = workflow(workflowId) if (!item) return { ok: false, error: 'No such workflow' } - const template = templateById(item.id) - if (template) return { ok: false, error: 'Cannot rename a factory preset' } const name = cleanLine(body.name, 80) if (!name) return { ok: false, error: 'A workflow needs a name' } item.name = name @@ -642,17 +640,19 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no const workflowId = String(body.workflowId ?? '') const item = workflow(workflowId) if (!item) return { ok: false, error: 'No such workflow' } - const template = templateById(item.id) - if (template) return { ok: false, error: 'Cannot delete a factory preset' } - const run = state.runs[workflowId] - const releasing = run ? Object.values(run.ephemeral ?? {}) : [] + const lane = laneOf(workflowId) + const releasing = (lane.active ?? []).flatMap(run => Object.values(run.ephemeral ?? {})) const finish = () => { state.workflows = state.workflows.filter(workflow => workflow.id !== workflowId) + delete state.lanes[workflowId] delete state.runs[workflowId] delete state.logs[workflowId] Object.keys(state.routes).forEach(taskId => { if (state.routes[taskId]?.id === workflowId) delete state.routes[taskId] }) + Object.keys(state.claims ?? {}).forEach(taskId => { + if (state.routes[taskId]?.id === workflowId) delete state.claims[taskId] + }) write() return present() } @@ -788,32 +788,42 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no ownsBot(botId) { return findEphemeral(botId) !== null }, - // Replay the scripted demo. It never moves a live run. - demoStep(body = {}) { + pause(body = {}) { + if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } const workflowId = String(body.workflowId ?? '') + const item = workflow(workflowId) + if (!item) return { ok: false, error: 'No such workflow' } + const lane = laneOf(workflowId) + const runId = body.runId ? String(body.runId) : '' + let run = runId ? lane.active.find(itemRun => itemRun.id === runId) : lane.active.find(itemRun => itemRun.live === true && itemRun.status === 'running') + if (!run || run.live !== true) return { ok: false, error: 'No live run to pause' } + if (run.paused === true) return present() + run = { ...run, paused: true, updatedAt: now() } + storeRun(workflowId, run) + push(workflowId, [{ kind: 'system', text: `⏸ 已暂停 · ${summarizeTask(run.task)}` }], now()) + write() + return present() + }, + resume(body = {}) { if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + const workflowId = String(body.workflowId ?? '') const item = workflow(workflowId) if (!item) return { ok: false, error: 'No such workflow' } - if (runningOf(laneOf(workflowId)).length > 0) return { ok: false, error: 'A live run is in progress' } - try { - let run = state.demos[item.id] - if (!run) throw new Error('This workflow has no task') - state.demoOnTop ??= {} - state.demoOnTop[item.id] = true - if (run.status === 'failed') throw new Error('This run is already finished') - const op = nextDemoOp(item, run.nodeStatus, { failedOnce: run.failedOnce }) - if (op.type === 'restart') { - run = blankRun(item, run.task, now(), run.history) - push(item.id, [{ kind: 'time', text: '重新演示' }], now()) - } - const stepped = applyOp(item, run, op.type === 'restart' ? { type: 'start' } : op) - state.demos[item.id] = stepped.run - push(item.id, stepped.lines, now()) - write() + const lane = laneOf(workflowId) + const runId = body.runId ? String(body.runId) : '' + let run = runId ? lane.active.find(itemRun => itemRun.id === runId) : lane.active.find(itemRun => itemRun.live === true && itemRun.status === 'running') + if (!run || run.live !== true) return { ok: false, error: 'No live run to resume' } + if (run.paused !== true) return present() + run = { ...run, paused: false, updatedAt: now() } + storeRun(workflowId, run) + push(workflowId, [{ kind: 'system', text: `▶ 已继续 · ${summarizeTask(run.task)}` }], now()) + write() + if (typeof spawn !== 'function') return present() + return hostSerial(async () => { + const latest = lane.active.find(itemRun => itemRun.id === run.id) ?? run + await handOff(item, latest) return present() - } catch (error) { - return refuse(error) - } + }) }, reset(body = {}) { const workflowId = String(body.workflowId ?? '') @@ -822,20 +832,13 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no if (!item) return { ok: false, error: 'No such workflow' } const lane = laneOf(workflowId) const ids = lane.active.flatMap(run => Object.values(run.ephemeral ?? {})) - const live = lane.active[0] ?? lane.last + const live = lane.active[0] ?? (lane.last?.live === true ? lane.last : null) lane.active = [] lane.queue = [] - if (live) { - const kept = { ...blankRun(item, live.task, now(), lane.history), id: live.id, startedAt: live.startedAt } - lane.last = kept - push(item.id, linesFor(item, live.task, { type: 'reset' }), now()) - } - const demo = state.demos[item.id] - if (demo) { - state.demos[item.id] = { ...blankRun(item, demo.task, now(), demo.history), id: demo.id, startedAt: demo.startedAt } - if (!live) push(item.id, linesFor(item, demo.task, { type: 'reset' }), now()) - } - if (!live && !demo) return { ok: false, error: 'This workflow has no task' } + if (!live?.task) return { ok: false, error: 'This workflow has no task' } + const kept = { ...blankRun(item, live.task, now(), lane.history), id: live.id, startedAt: live.startedAt } + lane.last = kept + push(item.id, linesFor(item, live.task, { type: 'reset' }), now()) write() return { ok: true, ids } } @@ -857,8 +860,8 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no if (!text) throw new Error('A message needs text') const at = now() const lines = [{ kind: 'user', text }] - const run = primaryRun(item.id) - const running = run ? orderedIds(item, run.nodeStatus, 'running')[0] : undefined + const run = displayRun(item.id) + const running = run?.live === true ? orderedIds(item, run.nodeStatus, 'running')[0] : undefined if (running) { const node = nodeById(item, running) lines.push({ kind: 'bot', botId: running, step: '进行中', text: `${node?.name ?? running}:收到。我继续处理「${run.task?.title ?? ''}」。` }) @@ -886,13 +889,15 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no item.nodes = next.nodes item.edges = next.edges item.layers = next.layers - const run = primaryRun(item.id) - if (run) { + const lane = laneOf(item.id) + const run = displayRun(item.id) + if (run?.live === true) { const ids = new Set(item.nodes.map(node => node.id)) const nodeStatus = {} for (const id of ids) nodeStatus[id] = run.nodeStatus?.[id] ?? 'queued' - run.nodeStatus = nodeStatus - run.updatedAt = now() + const nextRun = { ...run, nodeStatus, updatedAt: now() } + if (lane.active.some(itemRun => itemRun.id === run.id)) storeRun(item.id, nextRun) + else if (lane.last?.id === run.id) lane.last = nextRun } }) }, @@ -938,12 +943,6 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no write() return { ok: true, matched: true, already: false, decision } } - const demo = state.demos[decision.id] - if (demo && demo.task?.id === taskId && demo.status === 'running') { - state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } - write() - return { ok: true, matched: true, already: true, decision } - } if (queuedOrRunning(decision.id, taskId)) { state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } write() @@ -1045,81 +1044,65 @@ function seat(nodes, roster) { }) } -function seed(now) { +function emptyLane() { + return { active: [], queue: [], last: null, history: [] } +} + +function seed() { const delivery = normalizeGraph(deliveryWorkflow()) const weekly = normalizeGraph(weeklyWorkflow()) - const task = fixtureTask() - const logs = { [DELIVERY_ID]: [], [WEEKLY_ID]: [] } - const runs = {} - let seq = 0 - const push = (workflowId, lines) => { - for (const line of lines) { - seq += 1 - logs[workflowId].push({ id: `m${seq}`, at: now, ...line }) - } - } - push(DELIVERY_ID, [ - { kind: 'time', text: 'Today 9:07 PM' }, - { kind: 'system', text: 'Workflow「发布交付流水线」已创建 · 成员 Planner、Writer、Coder、Reviewer、Tester、Deployer' }, - { kind: 'user', text: '@发布交付流水线 #1280 今天要上,notice 太长了,帮我缩短并发布。' }, - ]) - let run = blankRun(delivery, task, now) - for (const op of [{ type: 'start' }, { type: 'complete', nodeId: 'planner' }, { type: 'complete', nodeId: 'writer' }]) { - const result = op.type === 'start' - ? { ...startFrontier(delivery, run.nodeStatus), joined: false, layerSize: 0 } - : completeNode(delivery, run.nodeStatus, op.nodeId) - run = { ...run, status: 'running', nodeStatus: result.status, steps: run.steps + 1, updatedAt: now } - push(DELIVERY_ID, linesFor(delivery, task, { - type: op.type, nodeId: op.nodeId, started: result.started ?? [], - joined: result.joined === true, finished: false, layerSize: result.layerSize ?? 0, - })) + return { + version: 1, + workflows: [delivery, weekly], + runs: {}, + lanes: { [DELIVERY_ID]: emptyLane(), [WEEKLY_ID]: emptyLane() }, + claims: {}, + routes: {}, + logs: { [DELIVERY_ID]: [], [WEEKLY_ID]: [] }, + seq: 0, + laneFormat: true, } - runs[DELIVERY_ID] = run - const weeklyTask = { id: 'W42', title: '周报' } - const done = freshStatus(weekly) - for (const id of Object.keys(done)) done[id] = 'done' - runs[WEEKLY_ID] = { - ...blankRun(weekly, weeklyTask, now), - status: 'done', - nodeStatus: done, - steps: 4, - stepBudget: 4, - history: [{ task: weeklyTask, status: 'done', at: now }], +} + +function purgeDemos(state) { + if (!state.demos || typeof state.demos !== 'object') { + delete state.demos + return } - push(WEEKLY_ID, [ - { kind: 'time', text: '上周五 17:00' }, - { kind: 'system', tone: 'accent', text: '定时触发:周报 W42' }, - { kind: 'bot', botId: 'researcher', step: '1/3', text: '本周行业动态 12 条已整理。' }, - { kind: 'bot', botId: 'analyst', step: '2/3', text: '核心指标周环比:DAU +4.1%,留存 +0.6pt。' }, - { kind: 'bot', botId: 'writer', step: '3/3', text: '周报已发布。' }, - { kind: 'system', tone: 'ok', text: '✓ 运行完成' }, - ]) - return { - version: 1, workflows: [delivery, weekly], runs: {}, demos: runs, lanes: {}, claims: {}, routes: {}, - logs, seq, laneFormat: true, + for (const [id, run] of Object.entries(state.demos)) { + if (!run || typeof run !== 'object') continue + const lane = state.lanes[id] ?? emptyLane() + const finished = run.status === 'done' || run.status === 'failed' + if (finished && run.task) { + lane.history = [...lane.history, { task: run.task, status: run.status, at: run.updatedAt ?? 0 }].slice(-20) + } + if (lane.last?.live !== true) lane.last = null + state.lanes[id] = lane } + delete state.demos } function migrateRuns(state) { state.lanes ??= {} - state.demos ??= {} state.claims ??= {} if (state.laneFormat === true) return for (const [id, run] of Object.entries(state.runs ?? {})) { if (!run || typeof run !== 'object' || state.lanes[id]) continue const history = Array.isArray(run.history) ? run.history.filter(item => item?.status === 'done' || item?.status === 'failed') : [] const bots = run.ephemeral && typeof run.ephemeral === 'object' ? Object.keys(run.ephemeral) : [] - // Only a run that still has its Bots occupies a slot. The seeded script - // has none, so it stays a demo and replay can move it. if (bots.length > 0 && run.status === 'running') { state.lanes[id] = { active: [{ ...run, live: true }], queue: [], last: null, history } continue } - const demo = { ...run } - delete demo.live - state.demos[id] = demo const finished = run.status === 'done' || run.status === 'failed' - state.lanes[id] = { active: [], queue: [], last: finished ? { ...demo } : null, history } + state.lanes[id] = { + active: [], + queue: [], + last: finished && run.live === true ? { ...run, live: true } : null, + history: finished && run.task + ? [...history, { task: run.task, status: run.status, at: run.updatedAt ?? 0 }].slice(-20) + : history, + } } state.runs = {} state.laneFormat = true diff --git a/plugins/workflow/test/assign.test.js b/plugins/workflow/test/assign.test.js index c650a0089..c338a35de 100644 --- a/plugins/workflow/test/assign.test.js +++ b/plugins/workflow/test/assign.test.js @@ -25,13 +25,8 @@ function delivery(state) { test('assigning a board task starts the run at the first Bot and posts the claim', () => { const svc = service() const opened = delivery(svc.state()) - assert.equal(opened.preview, 'Coder 正在处理 #1280…') - assert.equal(opened.run.steps, 3) - assert.equal(opened.run.stepBudget, 10) - assert.equal(opened.run.doneCount, 2) - assert.deepEqual(opened.run.running, ['coder']) - assert.equal(opened.run.nodeStatus.deployer, 'queued') - assert.ok(opened.messages.some(message => message.kind === 'user' && message.text.includes('#1280'))) + assert.equal(opened.preview, '等待任务') + assert.equal(opened.run, null) const assigned = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1281', title: '发布说明:Workflow Beta 上线' }) const run = delivery(assigned).run @@ -43,8 +38,8 @@ test('assigning a board task starts the run at the first Bot and posts the claim assert.equal(run.nodeStatus.deployer, 'queued') const claim = delivery(assigned).messages.filter(message => message.tone === 'accent').at(-1) assert.match(claim.text, /#1281/) - assert.match(claim.text, /Planner 认领/) - assert.ok(delivery(assigned).messages.some(message => message.text.includes('Tasks 看板'))) + assert.match(claim.text, /Planner/) + assert.ok(delivery(assigned).messages.some(message => message.text.includes('任务已分配'))) assert.equal(svc.assign({ workflowId: DELIVERY_ID, taskId: '', title: 'x' }).ok, false) }) @@ -77,19 +72,21 @@ test('advance walks the parallel join: Deployer stays queued until both branches assert.equal(step('deployer', 'done').ok, false) }) -test('the demo step fails Tester once and still joins both branches', () => { +test('failOnce on Tester fails once and still joins both branches', () => { const svc = service() - svc.reset({ workflowId: DELIVERY_ID }) - const ops = [] - for (let i = 0; i < 12 && delivery(svc.state()).run.status !== 'done'; i += 1) { - const before = delivery(svc.state()).run.nodeStatus.tester - svc.demoStep({ workflowId: DELIVERY_ID }) - const after = delivery(svc.state()).run.nodeStatus.tester - if (before !== after) ops.push(`${before}->${after}`) - } - assert.ok(ops.includes('running->failed'), ops.join(',')) - assert.equal(delivery(svc.state()).run.status, 'done') - assert.equal(delivery(svc.state()).run.nodeStatus.deployer, 'done') + svc.assign({ workflowId: DELIVERY_ID, taskId: 'task-abcdef123456', title: 'Ship the note' }) + const step = (nodeId, outcome) => svc.advance({ workflowId: DELIVERY_ID, nodeId, outcome }) + step('planner', 'done') + step('writer', 'done') + step('coder', 'done') + step('reviewer', 'done') + const failed = step('tester', 'failed') + assert.equal(delivery(failed).run.nodeStatus.tester, 'running') + step('tester', 'done') + const joined = step('deployer', 'done') + const done = delivery(joined).run + assert.equal(done.status, 'done') + assert.equal(done.nodeStatus.deployer, 'done') }) test('create seats a local Bot and refuses a remote one', () => { @@ -134,9 +131,12 @@ test('a read-only host refuses assign, and a say lands in the workflow chat', () const live = service() const said = live.say({ workflowId: DELIVERY_ID, text: '请把 notice 再短一点' }) const messages = delivery(said).messages - assert.equal(messages.at(-2).kind, 'user') - assert.match(messages.at(-1).text, /Coder/) - assert.match(messages.at(-1).text, /请把 notice 再短一点|收到/) + assert.ok(messages.some(message => message.kind === 'user' && message.text.includes('请把 notice 再短一点'))) + live.assign({ workflowId: DELIVERY_ID, taskId: '#say', title: 'Notice tweak' }) + const withRun = live.say({ workflowId: DELIVERY_ID, text: '继续' }) + const tail = delivery(withRun).messages + assert.equal(tail.at(-2).kind, 'user') + assert.match(tail.at(-1).text, /Planner|收到/) }) test('the store reloads a workflow file and drops a file that does not parse', () => { @@ -168,6 +168,9 @@ test('a dragged graph persists and the reloaded run still highlights those nodes const dir = mkdtempSync(join(tmpdir(), 'flybot-wf-edit-')) try { const first = createWorkflowService({ dir, persist: true, now: () => 5, bots: () => roster }) + first.assign({ workflowId: DELIVERY_ID, taskId: '#edit', title: 'Persist the graph' }) + first.advance({ workflowId: DELIVERY_ID, nodeId: 'planner', outcome: 'done' }) + first.advance({ workflowId: DELIVERY_ID, nodeId: 'writer', outcome: 'done' }) const before = delivery(first.state()) const coder = before.nodes.find(node => node.id === 'coder') const saved = first.saveGraph({ @@ -257,13 +260,18 @@ test('an open task auto-starts the matching workflow, and a miss stays unassigne assert.equal(matchTask({ id: '#1', title: 'x', status: 'open', assignee: 'Remote' }, { workflows, bots }), null) const held = svc.ingest({ id: '#1280', title: '缩短交付组群 notice', status: 'open' }) - assert.equal(held.already, true) - assert.equal(delivery(svc.state()).run.steps, 3) + assert.equal(held.matched, true) + assert.equal(held.already, false) + assert.equal(delivery(svc.state()).run.live, true) + assert.equal(delivery(svc.state()).run.task.id, '#1280') + assert.match(delivery(svc.state()).preview, /正在处理/) const weekly = svc.ingest({ id: '#50', title: '周五周报', status: 'open' }) assert.equal(weekly.matched, true) assert.equal(weekly.decision.id, 'weekly') - assert.equal(svc.state().workflows.find(item => item.id === 'weekly').run.status, 'running') + const weekRun = svc.state().workflows.find(item => item.id === 'weekly').run + assert.equal(weekRun.live, true) + assert.equal(weekRun.status, 'running') const again = svc.ingest({ id: '#50', title: '周五周报', status: 'open' }) assert.equal(again.already, true) @@ -301,7 +309,7 @@ test('apply registers the workflow endpoints and stays quiet when disabled', () assert.equal(off.enabled, false) }) -test('rename changes workflow name and refuses factory presets', () => { +test('rename changes workflow name, including seeded delivery rows', () => { const svc = service() const lane = svc.create({ name: 'Release lane', @@ -318,7 +326,9 @@ test('rename changes workflow name and refuses factory presets', () => { assert.equal(svc.rename({ workflowId: 'missing' }).ok, false) assert.equal(svc.rename({ workflowId: laneId, name: '' }).ok, false) - assert.equal(svc.rename({ workflowId: DELIVERY_ID, name: 'Changed' }).ok, false) + const deliveryRenamed = svc.rename({ workflowId: DELIVERY_ID, name: '发布交付流水线 改' }) + assert.equal(deliveryRenamed.ok, true) + assert.equal(deliveryRenamed.workflows.find(item => item.id === DELIVERY_ID).name, '发布交付流水线 改') }) test('delete removes workflow and releases its ephemeral Bots', () => { @@ -338,14 +348,18 @@ test('delete removes workflow and releases its ephemeral Bots', () => { assert.equal(result.ok, true) assert.ok(!result.workflows.some(item => item.id === laneId)) assert.equal(svc.delete({ workflowId: 'missing' }).ok, false) - assert.equal(svc.delete({ workflowId: DELIVERY_ID }).ok, false) + const dropDelivery = svc.delete({ workflowId: DELIVERY_ID }) + assert.equal(dropDelivery.ok, true) + assert.ok(!dropDelivery.workflows.some(item => item.id === DELIVERY_ID)) }) } assert.equal(deleted.ok, true) assert.ok(!deleted.workflows.some(item => item.id === laneId)) assert.equal(svc.delete({ workflowId: 'missing' }).ok, false) - assert.equal(svc.delete({ workflowId: DELIVERY_ID }).ok, false) + const dropDelivery = svc.delete({ workflowId: DELIVERY_ID }) + assert.equal(dropDelivery.ok, true) + assert.ok(!dropDelivery.workflows.some(item => item.id === DELIVERY_ID)) }) test('delete cleans up runs, logs, and routes', () => { diff --git a/plugins/workflow/test/branch.test.js b/plugins/workflow/test/branch.test.js index 24c407568..f16af4cea 100644 --- a/plugins/workflow/test/branch.test.js +++ b/plugins/workflow/test/branch.test.js @@ -219,7 +219,7 @@ test('advance walks an if/else to done, and a loop clears the hand-off so the bo assert.equal(dispatched.at(-1).nodeId, 'next') }) -test('replay shows the demo again after a live run has finished', () => { +test('after a live run finishes the stage keeps that run until a new one starts', () => { const svc = createWorkflowService({ persist: false, now: () => 5 }) const opened = svc.assign({ workflowId: DELIVERY_ID, taskId: '#live', title: 'A real task' }) const row = opened.workflows.find(item => item.id === DELIVERY_ID) @@ -237,18 +237,7 @@ test('replay shows the demo again after a live run has finished', () => { assert.equal(finished.run.status, 'done') assert.equal(finished.run.task.id, '#live') assert.equal(finished.run.nodeStatus.deployer, 'done') - const replay = svc.demoStep({ workflowId: DELIVERY_ID }) - const shown = replay.workflows.find(item => item.id === DELIVERY_ID) - assert.equal(shown.run.task.id, '#1280') - assert.equal(shown.run.live, undefined) - assert.equal(shown.run.status, 'running') - assert.equal(shown.run.nodeStatus.coder, 'done') - assert.equal(shown.run.nodeStatus.reviewer, 'running') - assert.equal(shown.run.nodeStatus.deployer, 'queued') - const again = svc.demoStep({ workflowId: DELIVERY_ID }) - const moved = again.workflows.find(item => item.id === DELIVERY_ID) - assert.equal(moved.run.task.id, '#1280') - assert.equal(moved.run.nodeStatus.reviewer, 'running') - assert.equal(moved.run.nodeStatus.tester, 'failed') - assert.notEqual(moved.run.nodeStatus.deployer, 'done') + assert.equal(finished.preview, '等待任务') + assert.equal(finished.run.status, 'done') + assert.equal(finished.run.task.id, '#live') }) diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 11240e242..ce86fa662 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -128,7 +128,8 @@ function delivery() { name: '发布交付流水线', description: 'Ship a notice', chain: 'Planner → Writer → Coder → Reviewer ‖ Tester → Deployer', - preview: 'Coder 正在处理 #1280…', + preview: '等待任务', + activeCount: 0, trigger: 'manual', layers: [['planner'], ['writer'], ['coder'], ['reviewer', 'tester'], ['deployer']], nodes: [ @@ -149,8 +150,10 @@ function delivery() { ], messages: [{ id: 'm1', kind: 'user', text: SCRIPT }], run: { + live: true, status: 'running', task: { id: '#1280', title: '缩短交付组群 notice' }, + taskSummary: '#1280 · 缩短交付组群 notice', nodeStatus: { planner: 'done', writer: 'done', coder: 'running', reviewer: 'queued', tester: 'queued', deployer: 'queued' }, running: ['coder'], doneCount: 2, @@ -180,7 +183,7 @@ test('the sidebar opens the flowchart, and a drag, a wire and a dropped Bot are await settle() assert.ok(calls.some(item => item.name === 'workflow/state')) assert.ok(textOf(view.tree).includes('发布交付流水线')) - assert.ok(textOf(view.tree).includes('Coder 正在处理 #1280…')) + assert.ok(textOf(view.tree).includes('Waiting for a task')) findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Flow')[0].props.onClick() @@ -387,6 +390,43 @@ test('settings can add a loop edge and a condition edge', async () => { settings.unmount() }) +test('sidebar preview ignores a stale busy preview when nothing is live', async () => { + const graph = delivery() + graph.preview = 'Coder 正在处理 #1280…' + graph.activeCount = 0 + graph.run = null + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + assert.ok(textOf(view.tree).includes('Waiting for a task')) + assert.equal(textOf(view.tree).includes('Coder 正在处理'), false) + view.unmount() +}) + +test('a live run shows pause, resume, and progress controls', async () => { + const graph = delivery() + graph.activeCount = 1 + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph], templates: [] } + return { ok: true, workflows: [graph] } + } + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + const labels = findAll(view.tree, node => node.type === 'button').map(node => textOf(node)) + assert.ok(labels.includes('Pause')) + assert.ok(labels.includes('Progress')) + button(view.tree, 'Pause').props.onClick() + await settle() + assert.ok(calls.some(item => item.name === 'workflow/pause' && item.body.workflowId === 'delivery')) + view.unmount() +}) + test('the stage has no canned demo controls', async () => { const graph = delivery() const call = async (name) => { @@ -452,6 +492,49 @@ test('the flowchart canvas CSS clips overflow inside its panel', () => { assert.match(source, /\.wf-stage\{[^}]*overflow:hidden/) }) +test('delivery workflow rows are deletable even when template ids match', async () => { + const graph = delivery() + const calls = [] + let workflows = [graph] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') { + return { + workflows, + templates: [{ id: 'delivery', name: '发布交付流水线' }, { id: 'weekly', name: '周报汇总流程' }], + } + } + if (name === 'workflow/delete' && body.workflowId === 'delivery') { + workflows = [] + return { workflows } + } + return { ok: true, workflows } + } + const prevBody = document.body + document.body = { nodeType: 1 } + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0] + assert.ok(row) + row.props.onContextMenu({ preventDefault() {}, stopPropagation() {}, clientX: 40, clientY: 40 }) + await settle() + const deleteBtn = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Delete')[0] + assert.ok(deleteBtn, 'context menu Delete') + deleteBtn.props.onClick() + await settle() + const confirm = findAll(view.tree, node => node.type === 'button' && node.props.className === 'wf-btn wf-danger')[0] + assert.ok(confirm, 'delete confirm') + confirm.props.onClick() + await settle() + assert.ok(calls.some(item => item.name === 'workflow/delete' && item.body.workflowId === 'delivery')) + assert.equal(textOf(view.tree).includes('发布交付流水线'), false) + view.unmount() + } finally { + document.body = prevBody + } +}) + test('a sidebar workflow row can be deleted from the context menu', async () => { const graph = { ...delivery(), id: 'lane-a', name: 'Lane A' } const calls = [] @@ -464,7 +547,7 @@ test('a sidebar workflow row can be deleted from the context menu', async () => const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) await settle() const row = findAll(view.tree, node => node.type === 'button' && textOf(node).includes('Lane A'))[0] - row.props.onContextMenu({ preventDefault() {}, clientX: 40, clientY: 40 }) + row.props.onContextMenu({ preventDefault() {}, stopPropagation() {}, clientX: 40, clientY: 40 }) await settle() const deleteBtn = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Delete')[0] assert.ok(deleteBtn) diff --git a/plugins/workflow/test/demo-ui.test.js b/plugins/workflow/test/demo-ui.test.js new file mode 100644 index 000000000..bfb2a6050 --- /dev/null +++ b/plugins/workflow/test/demo-ui.test.js @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createWorkflowService } from '../src/service.js' +import { DELIVERY_ID, WEEKLY_ID } from '../src/template.js' + +test('a fresh store has no seeded runs in workflow/state', () => { + const svc = createWorkflowService({ persist: false, now: () => 1 }) + const state = svc.state() + const delivery = state.workflows.find(item => item.id === DELIVERY_ID) + const weekly = state.workflows.find(item => item.id === WEEKLY_ID) + assert.equal(delivery.preview, '等待任务') + assert.equal(delivery.run, null) + assert.equal(delivery.activeCount, 0) + assert.match(weekly.preview, /周五 17:00/) + assert.equal(weekly.run, null) +}) diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index af3a5511f..730a1bcd1 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -133,8 +133,9 @@ test('a failed step tears the instances down, a fail-once step keeps them, and r assert.equal(held.workflows.find(item => item.id === retry.id).run.status, 'running') assert.equal(again.released.length, 0) const cancelled = await again.svc.reset({ workflowId: retry.id }) - assert.equal(cancelled.workflows.find(item => item.id === retry.id).run.status, 'idle') - assert.equal(cancelled.workflows.find(item => item.id === retry.id).run.ephemeral, undefined) + const cleared = cancelled.workflows.find(item => item.id === retry.id) + assert.equal(cleared.run, null) + assert.equal(cleared.activeCount, 0) assert.deepEqual(again.released.sort(), again.spawned.map(item => item.id).sort()) }) @@ -429,8 +430,7 @@ test('a finished run stays done, and a run with no ephemeral Bots is not touched assert.equal(kept.status, 'done') assert.equal(kept.updatedAt, finishedAt) const bare = after.state().workflows.find(item => item.id === idle.id).run - assert.equal(bare.status, 'running') - assert.equal(bare.updatedAt, 6) + assert.equal(bare, null) // Nothing to release means nothing is written: the file it loaded is intact. assert.equal(readFileSync(join(dir, 'workflows.json'), 'utf8'), written) } finally { diff --git a/plugins/workflow/test/graph.test.js b/plugins/workflow/test/graph.test.js index 9bfd12c35..7d8b0f9f1 100644 --- a/plugins/workflow/test/graph.test.js +++ b/plugins/workflow/test/graph.test.js @@ -3,7 +3,7 @@ import { test } from 'node:test' import { addNode, chainLabel, completeNode, connectNodes, doneCount, failNode, freshStatus, layerMarks, moveNode, nextDemoOp, normalizeGraph, reconnectEdge, removeEdge, removeNode, retryNode, - rowPreview, startFrontier, validateGraph, + rowPreview, startFrontier, summarizeTask, validateGraph, } from '../src/graph.js' import { deliveryWorkflow, weeklyWorkflow } from '../src/template.js' @@ -146,10 +146,15 @@ test('a canvas can add and remove a local Bot without losing the other seats', ( assert.throws(() => addNode(graph, { id: 'planner', name: 'Planner' }), /listed twice/) }) -test('the sidebar line names the running Bot, otherwise the schedule', () => { +test('the sidebar line names a live running Bot, never a demo script', () => { const status = runToCoder() - const run = { status: 'running', nodeStatus: status, task: { id: '#1280', title: 'notice' } } - assert.equal(rowPreview(delivery, run), 'Coder 正在处理 #1280…') - assert.equal(rowPreview(weeklyWorkflow(), { status: 'done', nodeStatus: {}, task: { id: 'W42' } }), '下次运行:周五 17:00') + const live = { status: 'running', live: true, nodeStatus: status, task: { id: '#1280', title: 'notice' } } + assert.equal(rowPreview(delivery, live), 'Coder 正在处理 #1280…') + const demo = { status: 'running', nodeStatus: status, task: { id: '#1280', title: 'notice' } } + assert.equal(rowPreview(delivery, demo), '等待任务') + const paused = { ...live, paused: true } + assert.equal(rowPreview(delivery, paused), '已暂停 · Coder · #1280') + assert.equal(rowPreview(weeklyWorkflow(), { status: 'done', live: true, nodeStatus: {}, task: { id: 'W42' } }), '下次运行:周五 17:00') assert.equal(rowPreview(delivery, null), '等待任务') + assert.equal(summarizeTask({ id: '#9', title: 'a'.repeat(120) }).length <= 80, true) }) diff --git a/plugins/workflow/test/pause.test.js b/plugins/workflow/test/pause.test.js new file mode 100644 index 000000000..125345690 --- /dev/null +++ b/plugins/workflow/test/pause.test.js @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { DELIVERY_ID } from '../src/template.js' +import { createWorkflowService } from '../src/service.js' + +function delivery(state) { + return state.workflows.find(item => item.id === DELIVERY_ID) +} + +test('pause and resume gate a live run and update the sidebar line', async () => { + const svc = createWorkflowService({ persist: false, now: () => 1, bots: () => [] }) + const opened = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#77', title: 'Ship the fix' }) + const row = delivery(opened) + assert.equal(row.run.live, true) + assert.match(row.preview, /正在处理/) + const paused = svc.pause({ workflowId: DELIVERY_ID }) + const parked = delivery(paused) + assert.equal(parked.run.paused, true) + assert.match(parked.preview, /已暂停/) + const resumed = await svc.resume({ workflowId: DELIVERY_ID }) + const back = delivery(resumed) + assert.equal(back.run.paused, undefined) + assert.match(back.preview, /正在处理/) +}) + +test('pause refuses when there is no live run', () => { + const svc = createWorkflowService({ persist: false, now: () => 1, bots: () => [] }) + const refused = svc.pause({ workflowId: DELIVERY_ID }) + assert.equal(refused.ok, false) +}) diff --git a/plugins/workflow/test/queue.test.js b/plugins/workflow/test/queue.test.js index b47b9dd8e..bc39290fc 100644 --- a/plugins/workflow/test/queue.test.js +++ b/plugins/workflow/test/queue.test.js @@ -108,7 +108,7 @@ test('the queued task starts only after the live run finishes, in FIFO order', a assert.equal(delivery(svc.state()).queueDepth, 0) }) -test('a node stays running until the Bot replies, and demo-step does not move a live run', async () => { +test('a node stays running until the Bot replies', async () => { const { svc, dispatched } = harness() const started = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#7', title: 'Wait for the Bot' }) const run = delivery(started).run @@ -118,11 +118,6 @@ test('a node stays running until the Bot replies, and demo-step does not move a assert.equal(dispatched[0].step.nodeId, 'planner') assert.match(dispatched[0].step.inputs.task, /#7/) - const demo = await svc.demoStep({ workflowId: DELIVERY_ID }) - assert.equal(demo.ok, false) - assert.equal(delivery(svc.state()).run.nodeStatus.planner, 'running') - assert.equal(delivery(svc.state()).run.nodeStatus.writer, 'queued') - const writer = await svc.stepResult({ botId: run.ephemeral.planner, ok: true, @@ -179,7 +174,8 @@ test('assigning to a Bot does not start a workflow, even when the title matches assert.equal(routed.decision.id, 'bot-coder') assert.equal(delivery(svc.state()).activeCount, 0) assert.equal(spawned.length, 0) - assert.equal(delivery(svc.state()).run.task.id, '#1280') + assert.equal(delivery(svc.state()).run, null) + assert.equal(delivery(svc.state()).preview, '等待任务') const remote = svc.assign({ botId: 'bot-friend', taskId: '#52', title: 'Nope' }) assert.equal(remote.ok, false) @@ -243,7 +239,7 @@ test('outputs travel along edges and a node does not start without them', async assert.match(done.workflows.find(item => item.id === made.id).messages.map(message => message.text).join('\n'), /model call failed/) }) -test('an old store keeps the seeded script as a demo, so replay still advances', () => { +test('an old store drops scripted runs and keeps only live abandoned runs', () => { const dir = mkdtempSync(join(tmpdir(), 'flybot-wf-old-')) try { const deliveryGraph = normalizeGraph(deliveryWorkflow()) @@ -286,32 +282,15 @@ test('an old store keeps the seeded script as a demo, so replay still advances', const svc = createWorkflowService({ dir, persist: true, now: () => 9, bots: () => [] }) const opened = delivery(svc.state()) assert.equal(opened.activeCount, 0) - assert.equal(opened.run.task.id, '#1280') - assert.equal(opened.run.live, undefined) - assert.equal(opened.run.nodeStatus.coder, 'running') - const stepped = svc.demoStep({ workflowId: DELIVERY_ID }) - assert.equal(stepped.ok, true) - const after = delivery(stepped) - assert.equal(after.activeCount, 0) - assert.equal(after.run.nodeStatus.coder, 'done') - const week = stepped.workflows.find(item => item.id === WEEKLY_ID) + assert.equal(opened.run, null) + assert.equal(opened.preview, '等待任务') + const week = svc.state().workflows.find(item => item.id === WEEKLY_ID) assert.equal(week.activeCount, 0) - assert.equal(week.run.status, 'done') - assert.equal(week.run.nodeStatus.researcher, 'done') - const replay = svc.demoStep({ workflowId: WEEKLY_ID }) - assert.equal(replay.ok, true) - const replayed = replay.workflows.find(item => item.id === WEEKLY_ID) - assert.equal(replayed.activeCount, 0) - assert.equal(replayed.run.status, 'running') - assert.equal(replayed.run.nodeStatus.researcher, 'running') - assert.equal(replayed.run.nodeStatus.writer, 'queued') - const replayNext = svc.demoStep({ workflowId: WEEKLY_ID }) - assert.equal(replayNext.workflows.find(item => item.id === WEEKLY_ID).run.nodeStatus.analyst, 'running') - const solo = replay.workflows.find(item => item.id === 'solo') + assert.equal(week.run, null) + assert.match(week.preview, /周五 17:00/) + const solo = svc.state().workflows.find(item => item.id === 'solo') assert.equal(solo.activeCount, 1) - const refused = svc.demoStep({ workflowId: 'solo' }) - assert.equal(refused.ok, false) - assert.match(refused.error, /A live run is in progress/) + assert.equal(solo.run?.live, true) } finally { rmSync(dir, { recursive: true, force: true }) }