From 513b85ace9a54c2eb86eb055add7f708984597aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:13:16 +0000 Subject: [PATCH 1/3] Fix workflow concurrent run UI and flowchart layout Runs tab and stage header now list every live run; each gets its own Pause, Resume, and Stop. Orthogonal wire paths and parallel frames stay off the node plane. Per-run reset stops one run without clearing siblings. Fixes #1463 Co-authored-by: Danyang Chen --- changelog.d/1463.fixed.md | 1 + docs/subsystems/workflow.md | 4 +- plugins/workflow/client.js | 139 +++++++++++++++++++-------- plugins/workflow/src/service.js | 33 +++++++ plugins/workflow/test/client.test.js | 106 ++++++++++++++++++++ plugins/workflow/test/queue.test.js | 30 ++++++ 6 files changed, 270 insertions(+), 43 deletions(-) create mode 100644 changelog.d/1463.fixed.md diff --git a/changelog.d/1463.fixed.md b/changelog.d/1463.fixed.md new file mode 100644 index 000000000..b3d240f20 --- /dev/null +++ b/changelog.d/1463.fixed.md @@ -0,0 +1 @@ +Workflow stage: each concurrent live run gets its own progress strip with Pause, Resume, and Stop; the Runs list matches `activeCount`; flowchart wires use orthogonal routing with parallel frames behind nodes (Fixes [#1463](https://github.com/ThinkFlowLab/FlyBot/issues/1463)). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 8a1b98c2d..d027caadb 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -33,7 +33,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. | | `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. | | `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: progress strip and flowchart drawer — no message composer), assign dialog (scrollable with sticky footer), flowchart drawer (collapsed by default, per-node inspector), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while one is active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | +| `plugins/workflow/client.js` | Sidebar section (with mini-flowcharts), workflow stage (view/control: one card per live run with its own title, node line, segment progress, status chip, and Pause / Resume / Stop; click a card or its Flow button to focus that run in the drawer flowchart; optional aggregate chip when several run or the queue is non-empty — no message composer), assign dialog (scrollable with sticky footer), flowchart drawer (collapsed by default, per-node inspector), and Settings → Workflow graphs (canvas-first with inspector panel). The product UI starts only live runs (assign, ingest). The stage shows the queue and refreshes state while runs are active; a running node uses a breathing border animation. Visual flowcharts replace text chains across all surfaces. Sidebar rows (including factory ids such as `delivery`) support Delete with confirm. The stage and dialogs portal onto `document.body`; a missing container stays in the section tree. | ## Entry points @@ -47,7 +47,7 @@ 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 advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. 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. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those 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. +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. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card, and the chip count does not exceed that list. 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. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 390bd63e0..c63ea5158 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -45,6 +45,7 @@ window.__ModuleLoader__.load({ 'Close': '关闭', Pause: '暂停', Resume: '继续', + Stop: '停止', Progress: '进度', 'New workflow': '新建 Workflow', Name: '名称', @@ -129,6 +130,8 @@ window.__ModuleLoader__.load({ // Zero-width plane center line: shift wire coordinates so strokes stay inside the SVG box. const WIRE_SPAN = 480 const NODE_WIRE_OUT = 56 + const NODE_W = 168 + const NODE_H = 72 function workflowNodeIds(workflow) { return new Set((workflow?.nodes ?? []).map(node => node.id)) @@ -339,15 +342,38 @@ window.__ModuleLoader__.load({ }))) } + function RunStrip({ workflow, run, onPause, onResume, onStop, onTab, onOpenDrawer }) { + const running = (run.running ?? []).map(id => nodeOf(workflow, id)).filter(Boolean) + const chipClass = run.status === 'done' ? 'done' : (run.paused ? 'queued' : (running[0] ? 'running' : 'queued')) + const chipLabel = run.status === 'done' ? tr('Done') : (run.paused ? tr('Pause') : (running[0] ? tr('Running') : tr('Queued'))) + const 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'))) + return h('div', { className: 'wf-strip', 'data-run-id': run.id }, + h('div', { className: 'wf-strip-id' }, + h('div', { className: 'wf-tk', title: run.task?.title ?? '' }, taskSummary(run.task, run)), + h('div', { className: 'wf-meta' }, meta)), + 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 ${chipClass}` }, chipLabel), + run.status === 'running' ? (run.paused + ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-pri', onClick: () => onResume?.(run.id) }, tr('Resume')) + : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onPause?.(run.id) }, tr('Pause'))) : null, + run.status === 'running' ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-danger-outline', onClick: () => onStop?.(run.id) }, tr('Stop')) : null, + h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => { onTab('runs'); onOpenDrawer?.() } }, tr('Progress')), + h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => { onTab('flow'); onOpenDrawer?.() } }, tr('Flow'))) + } + // Stage: view/control only — progress and flowchart; no message composer (#1459). - function Stage({ workflow, roster, stageLeft, drawer, tab, onCloseDrawer, onToggleDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume }) { + function Stage({ workflow, roster, stageLeft, drawer, tab, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop }) { const chatRef = useRef(null) - const run = workflow.run?.live === true ? workflow.run : null + const liveRuns = liveStageRuns(workflow) + const showSummary = (workflow.queueDepth ?? 0) > 0 || liveRuns.length > 1 useEffect(() => { const el = chatRef.current if (el) el.scrollTop = el.scrollHeight }, [workflow.messages.length]) - const running = (run?.running ?? []).map(id => nodeOf(workflow, id)).filter(Boolean) const visible = stageMessages(workflow, workflow.messages, roster) return h('div', { className: 'wf-stage', style: { left: stageLeft } }, h('div', { className: 'wf-main' }, @@ -358,23 +384,12 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-chev', 'aria-hidden': true }, '▾')), h('div', { className: 'wf-actions' }, 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', 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, + showSummary ? h('div', { className: 'wf-strip wf-strip-summary' }, + h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? liveRuns.length, depth: workflow.queueDepth ?? 0 }))) : null, + liveRuns.length > 0 ? h('div', { className: 'wf-strips' }, + liveRuns.map(run => h(RunStrip, { + key: run.id, workflow, run, onPause, onResume, onStop, onTab, onOpenDrawer, + }))) : null, visible.length > 0 ? h('div', { className: 'wf-chat', ref: chatRef }, h('div', { className: 'wf-chat-in' }, visible.map(message => h(Bubble, { key: message.id, message, workflow, roster })))) : null), @@ -434,8 +449,49 @@ window.__ModuleLoader__.load({ const y1 = (from.y ?? 0) + NODE_WIRE_OUT const x2 = (to.x ?? 0) + WIRE_SPAN const y2 = to.y ?? 0 - const mid = (y1 + y2) / 2 - return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}` + if (Math.abs(x1 - x2) < 0.5) { + const mid = (y1 + y2) / 2 + return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}` + } + const midY = Math.round((y1 + y2) / 2) + return `M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}` + } + + function parallelFrameStyle(members) { + const minX = Math.min(...members.map(node => node.x ?? 0)) + const maxX = Math.max(...members.map(node => node.x ?? 0)) + const minY = Math.min(...members.map(node => node.y ?? 0)) + const maxY = Math.max(...members.map(node => (node.y ?? 0) + NODE_H)) + const pad = 10 + return { + left: minX - NODE_W / 2 - pad, + top: minY - 20, + width: maxX - minX + NODE_W + pad * 2, + height: maxY - minY + pad, + } + } + + function liveStageRuns(workflow) { + let liveRuns = (workflow.runs ?? []).filter(run => run?.live === true && run.status === 'running') + if (liveRuns.length === 0 && workflow.run?.live === true && workflow.run.status === 'running') { + liveRuns = [workflow.run] + } + return liveRuns + } + + function runHistoryRows(workflow) { + const history = workflow.history ?? [] + const queued = workflow.queue ?? [] + const liveRuns = liveStageRuns(workflow) + if (liveRuns.length === 0 && history.length === 0 && queued.length === 0) return null + const rows = liveRuns.map(run => ({ + task: run.task, + status: 'running', + current: workflow.run?.id === run.id, + })) + for (const task of queued) rows.push({ task, status: 'queued', current: false }) + for (const item of history) rows.push({ ...item, current: false }) + return rows } function wireSvgProps(height) { @@ -529,12 +585,9 @@ window.__ModuleLoader__.load({ (workflow.layers ?? []).filter(layer => layer.length > 1).map(layer => { const members = layer.map(id => nodes.find(node => node.id === id)).filter(Boolean) if (members.length < 2) return null - const minX = Math.min(...members.map(node => node.x)) - const maxX = Math.max(...members.map(node => node.x)) - const y = Math.min(...members.map(node => node.y)) return h('div', { key: layer.join('+'), className: 'wf-par wf-abs', - style: { left: minX - 108, top: y - 18, width: maxX - minX + 216, height: 92 }, + style: parallelFrameStyle(members), }, h('div', { className: 'wf-plabel' }, '并行分支 · 全部通过后汇合')) }), nodes.map(node => h('div', { @@ -547,8 +600,8 @@ window.__ModuleLoader__.load({ if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return setGesture({ kind: 'move', id: node.id, ox: event.clientX, oy: event.clientY, x: node.x, y: node.y }) }, - onMouseEnter: (event) => onTip?.({ x: event.clientX, y: event.clientY, node }), - onMouseLeave: () => onTip?.(null), + onMouseEnter: editing ? (event) => onTip?.({ x: event.clientX, y: event.clientY, node }) : null, + onMouseLeave: editing ? () => onTip?.(null) : null, }, editing ? h('button', { type: 'button', className: 'wf-port out', 'data-port': 'out', 'aria-label': node.name, @@ -601,13 +654,8 @@ window.__ModuleLoader__.load({ } function Runs({ workflow }) { - const history = workflow.history ?? workflow.run?.history ?? [] - const queued = workflow.queue ?? [] - const current = workflow.run?.task - if (!current && history.length === 0 && queued.length === 0) return h('div', { className: 'wf-muted' }, tr('No runs yet.')) - const rows = history.map(item => ({ ...item, current: false })) - 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 }) + const rows = runHistoryRows(workflow) + if (!rows) return h('div', { className: 'wf-muted' }, tr('No runs yet.')) return h('div', null, rows.map((item, index) => h('div', { key: index, className: 'wf-hist' }, 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'))))) @@ -1165,16 +1213,20 @@ window.__ModuleLoader__.load({ }) }, onCloseDrawer: () => setDrawer(false), + onOpenDrawer: () => setDrawer(true), onToggleDrawer: () => { setDrawer(value => !value); setTab('flow') }, onTab: (key) => { setTab(key); setDrawer(true) }, onReset: () => { void call('workflow/reset', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, onAssign: () => { setAssignOpen(true); void loadTasks() }, onTip: setTip, - onPause: () => { - void call('workflow/pause', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) + onPause: (runId) => { + void call('workflow/pause', { workflowId: open.id, runId }).then(value => { if (value?.workflows) setPack(value) }) + }, + onResume: (runId) => { + void call('workflow/resume', { workflowId: open.id, runId }).then(value => { if (value?.workflows) setPack(value) }) }, - onResume: () => { - void call('workflow/resume', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) + onStop: (runId) => { + void call('workflow/reset', { workflowId: open.id, runId }).then(value => { if (value?.workflows) setPack(value) }) }, })) : null, assignOpen ? portal(h(AssignModal, { @@ -1244,7 +1296,10 @@ 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:8px;flex-wrap:wrap} +.wf-strips{display:flex;flex-direction:column;gap:6px;margin:0 auto 4px;width:min(720px,calc(100% - 48px))} +.wf-strip{margin:0;width:100%;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;box-sizing:border-box} +.wf-strip-summary{justify-content:center;border-style:dashed;background:transparent;margin-bottom:2px} +.wf-btn.wf-danger-outline{border-color:rgba(229,72,77,.45);color:#e5484d} .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} @@ -1351,7 +1406,8 @@ window.__ModuleLoader__.load({ .wf-muted{font-size:12px;color:var(--bt-ink-3,#9a9a9a);padding:4px 8px} .wf-err{font-size:12px;color:#e5484d;padding:4px 8px} .wf-assign-row{opacity:.9} -.wf-par{position:relative;border:1px dashed var(--bt-ink-3,#9a9a9a);border-radius:14px;padding:16px 8px 8px} +.wf-par{position:relative;z-index:0;pointer-events:none;border:1px dashed var(--bt-ink-3,#9a9a9a);border-radius:14px;padding:16px 8px 8px} +.wf-node.wf-abs{z-index:1} .wf-plabel{position:absolute;top:-9px;left:50%;transform:translateX(-50%);font-size:10.5px;color:var(--bt-ink-2,rgba(20,20,20,.6));background:var(--bt-card,#fff);padding:0 6px;white-space:nowrap} .wf-settings-canvas{padding:16px} .wf-settings-layout{display:flex;flex-direction:column;gap:16px} @@ -1388,6 +1444,7 @@ window.__ModuleLoader__.load({ return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, chatPreview, wirePath, WIRE_SPAN, NODE_WIRE_OUT, + runHistoryRows, liveStageRuns, parallelFrameStyle, NODE_W, botPaletteSubtitle, botTint, } }, diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index ee9e53057..52ec6d5f9 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -849,6 +849,39 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no }, reset(body = {}) { const workflowId = String(body.workflowId ?? '') + const runId = body.runId ? String(body.runId) : '' + if (runId) { + const stopOne = () => { + const item = workflow(workflowId) + if (!item) return { ok: false, error: 'No such workflow' } + const lane = laneOf(workflowId) + const run = lane.active.find(itemRun => itemRun.id === runId) + if (!run || run.live !== true || run.status !== 'running') return { ok: false, error: 'No live run to stop' } + const ids = Object.values(run.ephemeral ?? {}) + const ended = { ...run, status: 'failed', updatedAt: now() } + delete ended.ephemeral + delete ended.handed + lane.active = lane.active.filter(itemRun => itemRun.id !== runId) + lane.last = ended + lane.history = [...lane.history, { task: ended.task, status: ended.status, at: ended.updatedAt }].slice(-20) + push(workflowId, [{ kind: 'system', tone: 'bad', text: `■ 已停止 · ${summarizeTask(run.task)}` }], now()) + write() + return { ok: true, ids } + } + if (typeof spawn !== 'function') { + const saved = stopOne() + if (saved.ok === false) return saved + drainSync() + return present() + } + return hostSerial(async () => { + const saved = stopOne() + if (saved.ok === false) return saved + await releaseAll(saved.ids, release, warn) + await drainAll() + return present() + }) + } const take = () => { const item = workflow(workflowId) if (!item) return { ok: false, error: 'No such workflow' } diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 4cfd1c65e..d45cf91f3 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -406,6 +406,40 @@ test('sidebar preview ignores a stale busy preview when nothing is live', async view.unmount() }) +test('two concurrent live runs each get their own pause and stop controls', async () => { + const graph = delivery() + graph.activeCount = 2 + graph.queueDepth = 0 + graph.runs = [ + { ...graph.run, id: 'run-a', task: { id: '#1422', title: 'PR 1422' }, taskSummary: '#1422 · PR 1422' }, + { ...graph.run, id: 'run-b', task: { id: '#1395', title: 'PR 1395' }, taskSummary: '#1395 · PR 1395' }, + ] + graph.run = graph.runs[0] + 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() + await settle() + const strips = findAll(view.tree, node => node.props?.className === 'wf-strip' && node.props?.['data-run-id']) + assert.equal(strips.length, 2) + const pauseButtons = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Pause') + assert.equal(pauseButtons.length, 2) + const stopButtons = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Stop') + assert.equal(stopButtons.length, 2) + pauseButtons[1].props.onClick() + await settle() + assert.ok(calls.some(item => item.name === 'workflow/pause' && item.body.runId === 'run-b')) + stopButtons[0].props.onClick() + await settle() + assert.ok(calls.some(item => item.name === 'workflow/reset' && item.body.runId === 'run-a')) + view.unmount() +}) + test('a live run shows pause, resume, and progress controls', async () => { const graph = delivery() graph.activeCount = 1 @@ -560,6 +594,60 @@ test('a sidebar workflow row can be deleted from the context menu', async () => view.unmount() }) +test('run history lists every live run that activeCount includes', () => { + const workflow = { + activeCount: 2, + history: [], + queue: [], + runs: [ + { id: 'run-a', live: true, status: 'running', task: { id: '#1422', title: 'PR 1422' } }, + { id: 'run-b', live: true, status: 'running', task: { id: '#1395', title: 'PR 1395' } }, + ], + run: { id: 'run-a', live: true, status: 'running', task: { id: '#1422', title: 'PR 1422' } }, + } + const rows = client.runHistoryRows(workflow) + assert.equal(rows.filter(row => row.status === 'running').length, 2) + assert.equal(rows.length, 2) +}) + +test('run history does not invent a second running row when only one live run exists', () => { + const workflow = { + activeCount: 1, + history: [{ task: { id: '#1', title: 'Old' }, status: 'done' }], + queue: [], + runs: [{ id: 'run-a', live: true, status: 'running', task: { id: '#1422', title: 'PR 1422' } }], + run: { id: 'run-a', live: true, status: 'running', task: { id: '#1422', title: 'PR 1422' } }, + } + const rows = client.runHistoryRows(workflow) + assert.equal(rows.filter(row => row.status === 'running').length, 1) +}) + +test('the runs tab shows one running row per concurrent live run', async () => { + const graph = delivery() + graph.activeCount = 2 + graph.queueDepth = 0 + graph.runs = [ + { id: 'run-a', live: true, status: 'running', task: { id: '#1422', title: 'PR 1422' }, taskSummary: '#1422 · PR 1422' }, + { id: 'run-b', live: true, status: 'running', task: { id: '#1395', title: 'PR 1395' }, taskSummary: '#1395 · PR 1395' }, + ] + graph.run = graph.runs[0] + 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() + findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() + button(view.tree, 'Progress').props.onClick() + await settle() + const running = findAll(view.tree, node => node.props?.className === 'wf-chip running') + assert.equal(running.length, 2) + const text = textOf(view.tree) + assert.ok(text.includes('PR 1422')) + assert.ok(text.includes('PR 1395')) + view.unmount() +}) + test('wire paths for a linear three-node graph connect each step vertically', () => { const nodes = [ { id: 'a', x: 0, y: 0 }, @@ -575,6 +663,24 @@ test('wire paths for a linear three-node graph connect each step vertically', () assert.match(bc, new RegExp(`^M ${span} ${y1b} C ${span} ${(y1b + 216) / 2}, ${span} ${(y1b + 216) / 2}, ${span} 216$`)) }) +test('wire paths between parallel nodes use orthogonal segments, not wide curves', () => { + const from = { x: 80, y: 260 } + const to = { x: 320, y: 380 } + const path = client.wirePath(from, to) + assert.match(path, /^M .+ L .+ L .+ L /) + assert.equal(path.includes('C '), false) +}) + +test('parallel branch frames wrap node bounds without hard-coded width', () => { + const members = [ + { id: 'a', x: 80, y: 380 }, + { id: 'b', x: 320, y: 380 }, + ] + const frame = client.parallelFrameStyle(members) + assert.equal(frame.width, 320 - 80 + client.NODE_W + 20) + assert.equal(frame.left, 80 - client.NODE_W / 2 - 10) +}) + test('the workflow stage mounts no message composer', async () => { const graph = delivery() const prevFlybot = globalThis.__flybot__ diff --git a/plugins/workflow/test/queue.test.js b/plugins/workflow/test/queue.test.js index bc39290fc..1a2000f0e 100644 --- a/plugins/workflow/test/queue.test.js +++ b/plugins/workflow/test/queue.test.js @@ -47,6 +47,36 @@ function delivery(state) { return state.workflows.find(item => item.id === DELIVERY_ID) } +test('stopping one live run leaves the other running and releases only its Bots', async () => { + const { svc, spawned, released } = harness() + svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 2 }) + const first = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1422', title: 'PR 1422' }) + const second = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1395', title: 'PR 1395' }) + const lane = delivery(second) + assert.equal(lane.activeCount, 2) + const victim = lane.runs.find(run => run.task.id === '#1422') + const kept = lane.runs.find(run => run.task.id === '#1395') + const botsBefore = spawned.length + const stopped = await svc.reset({ workflowId: DELIVERY_ID, runId: victim.id }) + const after = delivery(stopped) + assert.equal(after.activeCount, 1) + assert.equal(after.runs[0].task.id, '#1395') + assert.equal(after.runs[0].id, kept.id) + assert.ok(released.length > 0) + assert.equal(spawned.length, botsBefore) +}) + +test('activeCount matches the number of live runs returned in workflow.runs', async () => { + const { svc } = harness() + svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 2 }) + await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1422', title: 'PR 1422' }) + const both = delivery(await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1395', title: 'PR 1395' })) + assert.equal(both.activeCount, 2) + assert.equal(both.runs.length, 2) + assert.equal(both.runs.filter(run => run.live === true && run.status === 'running').length, 2) + assert.equal(both.activeCount, both.runs.length) +}) + test('a second assign queues behind a live run and does not release its Bots', async () => { const { svc, spawned, released } = harness() svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 1 }) From b688abbfe6a459f175cd649764c67086511e09c7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:26:52 +0000 Subject: [PATCH 2/3] Give each concurrent workflow run its own progress card and focus Per-run strips stack title, node line, segment bar, and controls. Focused run drives the drawer flowchart; aggregate chip stays summary-only. Co-authored-by: Danyang Chen --- plugins/workflow/client.js | 85 ++++++++++++++++++++-------- plugins/workflow/test/client.test.js | 33 ++++++++++- 2 files changed, 92 insertions(+), 26 deletions(-) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index c63ea5158..3f3b21936 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -342,7 +342,7 @@ window.__ModuleLoader__.load({ }))) } - function RunStrip({ workflow, run, onPause, onResume, onStop, onTab, onOpenDrawer }) { + function RunStrip({ workflow, run, focused, onFocus, onPause, onResume, onStop, onTab, onOpenDrawer }) { const running = (run.running ?? []).map(id => nodeOf(workflow, id)).filter(Boolean) const chipClass = run.status === 'done' ? 'done' : (run.paused ? 'queued' : (running[0] ? 'running' : 'queued')) const chipLabel = run.status === 'done' ? tr('Done') : (run.paused ? tr('Pause') : (running[0] ? tr('Running') : tr('Queued'))) @@ -351,24 +351,37 @@ window.__ModuleLoader__.load({ : (running[0] ? `${tr('Running')} · ${running.map(node => node.name).join(' ‖ ')} · ${run.doneCount}/${run.nodeCount}` : (run.status === 'done' ? tr('Done') : tr('Waiting for a task'))) - return h('div', { className: 'wf-strip', 'data-run-id': run.id }, - h('div', { className: 'wf-strip-id' }, - h('div', { className: 'wf-tk', title: run.task?.title ?? '' }, taskSummary(run.task, run)), - h('div', { className: 'wf-meta' }, meta)), - 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 ${chipClass}` }, chipLabel), - run.status === 'running' ? (run.paused - ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-pri', onClick: () => onResume?.(run.id) }, tr('Resume')) - : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onPause?.(run.id) }, tr('Pause'))) : null, - run.status === 'running' ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-danger-outline', onClick: () => onStop?.(run.id) }, tr('Stop')) : null, - h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => { onTab('runs'); onOpenDrawer?.() } }, tr('Progress')), - h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => { onTab('flow'); onOpenDrawer?.() } }, tr('Flow'))) + const focus = () => onFocus?.(run.id) + const stopBubble = (event) => { event?.stopPropagation?.() } + return h('div', { + className: `wf-strip ${focused ? 'wf-strip-focus' : ''}`, + 'data-run-id': run.id, + role: 'button', + tabIndex: 0, + onClick: focus, + onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); focus() } }, + }, + h('div', { className: 'wf-strip-head' }, + h('div', { className: 'wf-strip-id' }, + h('div', { className: 'wf-tk', title: run.task?.title ?? '' }, taskSummary(run.task, run)), + h('div', { className: 'wf-meta' }, meta)), + h('span', { className: `wf-chip ${chipClass}` }, chipLabel)), + h('div', { className: 'wf-strip-progress', 'aria-label': tr('Progress'), title: tr('Progress') }, + h('div', { className: 'wf-segs' }, (run.marks ?? []).map((mark, index) => h('span', { key: index, className: `wf-seg ${mark}` })))), + h('div', { className: 'wf-strip-actions' }, + run.status === 'running' ? (run.paused + ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-pri', onClick: (event) => { stopBubble(event); onResume?.(run.id) } }, tr('Resume')) + : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: (event) => { stopBubble(event); onPause?.(run.id) } }, tr('Pause'))) : null, + run.status === 'running' ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-danger-outline', onClick: (event) => { stopBubble(event); onStop?.(run.id) } }, tr('Stop')) : null, + h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: (event) => { stopBubble(event); focus(); onTab('runs'); onOpenDrawer?.() } }, tr('Progress')), + h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: (event) => { stopBubble(event); focus(); onTab('flow'); onOpenDrawer?.() } }, tr('Flow')))) } // Stage: view/control only — progress and flowchart; no message composer (#1459). - function Stage({ workflow, roster, stageLeft, drawer, tab, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop }) { + function Stage({ workflow, roster, stageLeft, drawer, tab, focusedRunId, onFocusRun, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop }) { const chatRef = useRef(null) const liveRuns = liveStageRuns(workflow) + const viewRun = resolveFocusedRun(workflow, focusedRunId) const showSummary = (workflow.queueDepth ?? 0) > 0 || liveRuns.length > 1 useEffect(() => { const el = chatRef.current @@ -388,7 +401,8 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? liveRuns.length, depth: workflow.queueDepth ?? 0 }))) : null, liveRuns.length > 0 ? h('div', { className: 'wf-strips' }, liveRuns.map(run => h(RunStrip, { - key: run.id, workflow, run, onPause, onResume, onStop, onTab, onOpenDrawer, + key: run.id, workflow, run, focused: viewRun?.id === run.id, + onFocus: onFocusRun, onPause, onResume, onStop, onTab, onOpenDrawer, }))) : null, visible.length > 0 ? h('div', { className: 'wf-chat', ref: chatRef }, h('div', { className: 'wf-chat-in' }, @@ -396,7 +410,7 @@ window.__ModuleLoader__.load({ drawer ? h('aside', { className: 'wf-drawer' }, h('div', { className: 'wf-dr-head' }, h('button', { type: 'button', className: 'wf-icon', 'aria-label': tr('Close'), onClick: onCloseDrawer }, '×')), - h(DrawerBody, { workflow, roster, tab, onTab, onReset, onTip, editing, onToggleEdit, onCommit })) : null) + h(DrawerBody, { workflow, roster, tab, onTab, onReset, onTip, editing, onToggleEdit, onCommit, viewRun })) : null) } function Bubble({ message, workflow, roster }) { @@ -412,8 +426,8 @@ window.__ModuleLoader__.load({ 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?.live === true ? workflow.run : null + function DrawerBody({ workflow, roster, tab, onTab, onReset, onTip, editing, onToggleEdit, onCommit, viewRun }) { + const run = viewRun?.live === true ? viewRun : null return h('div', { className: 'wf-dr-body' }, h('div', { className: 'wf-dr-title' }, h('div', { className: 'wf-ico wf-ico-lg' }, glyph()), @@ -479,6 +493,16 @@ window.__ModuleLoader__.load({ return liveRuns } + function resolveFocusedRun(workflow, focusedRunId) { + const live = liveStageRuns(workflow) + if (focusedRunId) { + const picked = live.find(run => run.id === focusedRunId) + if (picked) return picked + } + if (workflow.run?.live === true && workflow.run.status === 'running') return workflow.run + return live[0] ?? null + } + function runHistoryRows(workflow) { const history = workflow.history ?? [] const queued = workflow.queue ?? [] @@ -1008,6 +1032,7 @@ window.__ModuleLoader__.load({ const [tip, setTip] = useState(null) const [error, setError] = useState('') const [editing, setEditing] = useState(false) + const [focusedRunId, setFocusedRunId] = useState(null) const [rowMenu, setRowMenu] = useState(null) const [renaming, setRenaming] = useState(null) const [deleting, setDeleting] = useState(null) @@ -1093,6 +1118,13 @@ window.__ModuleLoader__.load({ const workflows = pack?.workflows ?? [] const open = workflows.find(item => item.id === openId) ?? null + useEffect(() => { + if (!open) { setFocusedRunId(null); return } + const live = liveStageRuns(open) + if (live.length === 0) { setFocusedRunId(null); return } + if (!live.some(run => run.id === focusedRunId)) setFocusedRunId(live[0].id) + }, [open?.id, open?.activeCount, open?.runs?.length, focusedRunId]) + const loadTasks = async () => { const host = globalThis.__flybot__?.host if (typeof host !== 'function') { setTasks([]); return } @@ -1203,7 +1235,8 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-txt' }, h('span', { className: 'wf-t1' }, tr('Hand a task to a Workflow')))), error ? h('div', { className: 'wf-err', role: 'alert' }, error) : null, open ? portal(h(Stage, { - workflow: open, roster, stageLeft, drawer, tab, + workflow: open, roster, stageLeft, drawer, tab, focusedRunId, + onFocusRun: setFocusedRunId, editing, onToggleEdit: () => { setEditing(value => !value); setDrawer(true); setTab('flow') }, onCommit: (graph) => { @@ -1296,9 +1329,15 @@ 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-strips{display:flex;flex-direction:column;gap:6px;margin:0 auto 4px;width:min(720px,calc(100% - 48px))} -.wf-strip{margin:0;width:100%;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;box-sizing:border-box} -.wf-strip-summary{justify-content:center;border-style:dashed;background:transparent;margin-bottom:2px} +.wf-strips{display:flex;flex-direction:column;gap:8px;margin:0 auto 4px;width:min(720px,calc(100% - 48px))} +.wf-strip{margin:0;width:100%;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;box-sizing:border-box;cursor:pointer;text-align:left;color:inherit} +.wf-strip:focus-visible{outline:2px solid var(--bt-accent,#4d6bfe);outline-offset:2px} +.wf-strip-focus{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px rgba(77,107,254,.14)} +.wf-strip-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;width:100%} +.wf-strip-progress{width:100%} +.wf-strip-progress .wf-segs{width:100%} +.wf-strip-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.wf-strip-summary{justify-content:center;border-style:dashed;background:transparent;margin-bottom:2px;cursor:default;flex-direction:row} .wf-btn.wf-danger-outline{border-color:rgba(229,72,77,.45);color:#e5484d} .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} @@ -1444,7 +1483,7 @@ window.__ModuleLoader__.load({ return { name: 'flybot-workflow', inject: ['flybot'], apply, Section, WorkflowSettings, tr, columnOf, shortId, stageMessages, taskSummary, chatPreview, wirePath, WIRE_SPAN, NODE_WIRE_OUT, - runHistoryRows, liveStageRuns, parallelFrameStyle, NODE_W, + runHistoryRows, liveStageRuns, resolveFocusedRun, parallelFrameStyle, NODE_W, botPaletteSubtitle, botTint, } }, diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index d45cf91f3..f226845b7 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -406,13 +406,35 @@ test('sidebar preview ignores a stale busy preview when nothing is live', async view.unmount() }) +test('two concurrent live runs each show independent progress strips', () => { + const workflow = { + runs: [ + { + id: 'run-a', live: true, status: 'running', + task: { id: '#1422', title: 'PR 1422' }, + running: ['planner'], doneCount: 1, nodeCount: 6, + marks: ['done', 'running', 'queued', 'queued', 'queued'], + }, + { + id: 'run-b', live: true, status: 'running', + task: { id: '#1395', title: 'PR 1395' }, + running: ['coder'], doneCount: 3, nodeCount: 6, + marks: ['done', 'done', 'done', 'running', 'queued'], + }, + ], + run: { id: 'run-a', live: true, status: 'running' }, + } + assert.equal(client.resolveFocusedRun(workflow, 'run-b').doneCount, 3) + assert.equal(client.resolveFocusedRun(workflow, null).id, 'run-a') +}) + test('two concurrent live runs each get their own pause and stop controls', async () => { const graph = delivery() graph.activeCount = 2 graph.queueDepth = 0 graph.runs = [ - { ...graph.run, id: 'run-a', task: { id: '#1422', title: 'PR 1422' }, taskSummary: '#1422 · PR 1422' }, - { ...graph.run, id: 'run-b', task: { id: '#1395', title: 'PR 1395' }, taskSummary: '#1395 · PR 1395' }, + { ...graph.run, id: 'run-a', task: { id: '#1422', title: 'PR 1422' }, taskSummary: '#1422 · PR 1422', doneCount: 1, marks: ['done', 'running', 'queued', 'queued', 'queued'] }, + { ...graph.run, id: 'run-b', task: { id: '#1395', title: 'PR 1395' }, taskSummary: '#1395 · PR 1395', doneCount: 4, marks: ['done', 'done', 'done', 'done', 'running'] }, ] graph.run = graph.runs[0] const calls = [] @@ -425,8 +447,13 @@ test('two concurrent live runs each get their own pause and stop controls', asyn await settle() findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick() await settle() - const strips = findAll(view.tree, node => node.props?.className === 'wf-strip' && node.props?.['data-run-id']) + const strips = findAll(view.tree, node => node.props?.className?.includes('wf-strip') && node.props?.['data-run-id']) assert.equal(strips.length, 2) + const progressRows = findAll(view.tree, node => node.props?.className === 'wf-strip-progress') + assert.equal(progressRows.length, 2) + const text = textOf(view.tree) + assert.ok(text.includes('1/6')) + assert.ok(text.includes('4/6')) const pauseButtons = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Pause') assert.equal(pauseButtons.length, 2) const stopButtons = findAll(view.tree, node => node.type === 'button' && textOf(node) === 'Stop') From 12442c94609fefa05d0da2ab1e65ccd05a3caf8f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 15:42:02 +0000 Subject: [PATCH 3/3] Remove unused variable in workflow queue test (oxlint) Co-authored-by: Danyang Chen --- plugins/workflow/test/queue.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/workflow/test/queue.test.js b/plugins/workflow/test/queue.test.js index 1a2000f0e..ac1b1d21b 100644 --- a/plugins/workflow/test/queue.test.js +++ b/plugins/workflow/test/queue.test.js @@ -50,7 +50,7 @@ function delivery(state) { test('stopping one live run leaves the other running and releases only its Bots', async () => { const { svc, spawned, released } = harness() svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 2 }) - const first = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1422', title: 'PR 1422' }) + await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1422', title: 'PR 1422' }) const second = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1395', title: 'PR 1395' }) const lane = delivery(second) assert.equal(lane.activeCount, 2)