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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1753-workflow-idle-flow.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The room's Flow panel now paints the last run's node statuses for an idle workflow, the same statuses Settings → Workflows draws, instead of showing every node as Queued; a workflow that never ran shows Idle in both views ([#1753](https://github.com/ThinkFlowLab/FlyBot/issues/1753)).
2 changes: 1 addition & 1 deletion docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ 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`. Read-only, the drawer's Flow tab lays the graph out from its steps instead (`flowLayout`, #1652): one row per layer, a parallel layer inside its dashed frame, node boxes sized to the measured panel width, and wires routed orthogonally by `flowWires` from the drawn, measured boxes — one bus per fork or join, skip and loop edges in the right gutter — so stored editor coordinates never misplace a node or leave a wire hanging. In Edit, the drawer paints that record fitted to its fixed 400px panel: `fitPlane` scales node centres (never the node boxes) into the panel width, wires and parallel frames use the fitted positions, and a pointer delta is divided by the fit scale before it is stored, so an edit still commits the same world coordinates. Because only the centres scale, a parallel layer's 168px cards overlap once it holds three or more branches in a 364px drawer, and the later card takes clicks meant for the branch's out-port; `editPlane` therefore keeps the fitted plane only while a layer's cards stay apart, and otherwise lays the graph out in `flowLayout`'s rows, column widths and wrap with the drawn coordinates as the panel's own, so there a drag is a plain pointer delta (#1677). A drag or a palette drop in that laid-out drawer plane changes structure, not layout: it commits no coordinates, and a stored `x`/`y` is only ever written from the Settings canvas, whose record is where layout lives (#1686). The plane says so in one line, `Layout lives in Settings` (zh: 布局在 Settings 画布改), when a drag there is refused. The Settings canvas paints the same record scaled to its own width (invariant 2), not as a second scroll container. 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.
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`. Read-only, the drawer's Flow tab lays the graph out from its steps instead (`flowLayout`, #1652): one row per layer, a parallel layer inside its dashed frame, node boxes sized to the measured panel width, and wires routed orthogonally by `flowWires` from the drawn, measured boxes — one bus per fork or join, skip and loop edges in the right gutter — so stored editor coordinates never misplace a node or leave a wire hanging. In Edit, the drawer paints that record fitted to its fixed 400px panel: `fitPlane` scales node centres (never the node boxes) into the panel width, wires and parallel frames use the fitted positions, and a pointer delta is divided by the fit scale before it is stored, so an edit still commits the same world coordinates. Because only the centres scale, a parallel layer's 168px cards overlap once it holds three or more branches in a 364px drawer, and the later card takes clicks meant for the branch's out-port; `editPlane` therefore keeps the fitted plane only while a layer's cards stay apart, and otherwise lays the graph out in `flowLayout`'s rows, column widths and wrap with the drawn coordinates as the panel's own, so there a drag is a plain pointer delta (#1677). A drag or a palette drop in that laid-out drawer plane changes structure, not layout: it commits no coordinates, and a stored `x`/`y` is only ever written from the Settings canvas, whose record is where layout lives (#1686). The plane says so in one line, `Layout lives in Settings` (zh: 布局在 Settings 画布改), when a drag there is refused. The Settings canvas paints the same record scaled to its own width (invariant 2), not as a second scroll container. 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. The drawer's Flow tab and Settings' graph paint one node state: the last run's `nodeStatus`, and Idle when the workflow has no run at all (#1753).
13. The stage chat follows a new line only while the reader is at the bottom. A line the stage filters out (a workflow node's own reply) changes no pixel, so it moves the chat nowhere; a reader who scrolled up keeps their place until they return to the bottom.

## One task identity (#1696)
Expand Down
25 changes: 18 additions & 7 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ window.__ModuleLoader__.load({
}

const STATUS_KEY = { queued: 'Queued', running: 'Running', done: 'Done', failed: 'Failed', skipped: 'Skipped', idle: 'Idle' }
// One graph has one state source: the last run's nodeStatus, and Idle when no run
// exists at all (#1753). Settings paints exactly this; the room Flow used to drop
// a finished run and default every seat to Queued, which read as pending work.
function nodeState(status, id) {
if (!status) return 'idle'
return status[id] ?? 'queued'
}
// Why a run failed, from the record the host keeps (#1493). A failure it did not
// name still reads Failed: the reason is added information, never a requirement.
const FAILURE_KEY = {
Expand Down Expand Up @@ -604,6 +611,10 @@ window.__ModuleLoader__.load({

function DrawerBody({ workflow, roster, tab, onTab, onReset, onTip, editing, onToggleEdit, onCommit, viewRun }) {
const run = viewRun?.live === true ? viewRun : null
// The Flow tab paints the run the stage already has — the live run if there is
// one, else the last one — so an idle workflow shows its last result, the same
// graph.run.nodeStatus Settings draws, instead of an empty map (#1753).
const nodeStatus = run?.nodeStatus ?? workflow.run?.nodeStatus ?? null
return h('div', { className: 'wf-dr-body' },
h('div', { className: 'wf-dr-title' },
h('div', { className: 'wf-ico wf-ico-lg' }, glyph()),
Expand All @@ -613,7 +624,7 @@ window.__ModuleLoader__.load({
['flow', 'details', 'runs'].map(key => h('button', {
key, type: 'button', role: 'tab', 'aria-selected': tab === key, className: tab === key ? 'on' : '', onClick: () => onTab(key),
}, tr(key === 'flow' ? 'Flow' : key === 'details' ? 'Details' : 'Runs')))),
tab === 'flow' ? h(Flow, { workflow, run, roster, onTip, editing, onCommit }) : null,
tab === 'flow' ? h(Flow, { workflow, run, nodeStatus, roster, onTip, editing, onCommit }) : null,
tab === 'details' ? h(Details, { workflow, roster }) : null,
tab === 'runs' ? h(Runs, { workflow }) : null,
h('div', { className: 'wf-ctl' },
Expand Down Expand Up @@ -1032,7 +1043,7 @@ window.__ModuleLoader__.load({
}
}

function Flow({ workflow, run, roster, onTip, editing, onCommit }) {
function Flow({ workflow, run, nodeStatus, roster, onTip, editing, onCommit }) {
const [gesture, setGesture] = useState(null)
const [ghost, setGhost] = useState(null)
const [dragPreview, setDragPreview] = useState(null)
Expand All @@ -1042,7 +1053,7 @@ window.__ModuleLoader__.load({
const [termOpen, setTermOpen] = useState(false)
const [measure, setMeasure] = useState({ width: 0, heights: {} })
const canvasRef = useRef(null)
const status = run?.nodeStatus ?? {}
const status = nodeStatus ?? run?.nodeStatus ?? null
// Read the drawn panel width and node heights after every paint and on
// resize, so the wires follow the boxes the browser actually laid out. Both
// planes are measured: the Read-only one puts its heights into `flowLayout`,
Expand Down Expand Up @@ -1202,7 +1213,7 @@ window.__ModuleLoader__.load({
return h('div', {
key: node.id,
'data-node': node.id,
className: `wf-node wf-abs ${status[node.id] ?? 'queued'} ${selectedNode === node.id ? 'selected' : ''}`,
className: `wf-node wf-abs ${nodeState(status, node.id)} ${selectedNode === node.id ? 'selected' : ''}`,
style: plane.wrapped ? { left: spot.x - spot.w / 2, top: spot.y, width: spot.w } : { left: spot.x - NODE_W / 2, top: spot.y },
onClick: () => editing ? setSelectedNode(node.id) : null,
onPointerDown: (event) => {
Expand All @@ -1227,7 +1238,7 @@ window.__ModuleLoader__.load({
setSelectedNode(null)
},
}, '×') : null,
h(FlowNode, { node, status: status[node.id] ?? 'queued', roster }))
h(FlowNode, { node, status: nodeState(status, node.id), roster }))
})) : null,
run?.status === 'done' ? h('div', { className: 'wf-term on' }, tr('Done')) : null,
editing && selected ? h('div', { className: 'wf-node-inspector' },
Expand Down Expand Up @@ -1263,9 +1274,9 @@ window.__ModuleLoader__.load({
if (!box) return null
return h('div', {
key: node.id, 'data-node': node.id,
className: `wf-node wf-abs ${status[node.id] ?? 'queued'}`,
className: `wf-node wf-abs ${nodeState(status, node.id)}`,
style: { left: box.left, top: box.top, width: box.w },
}, h(FlowNode, { node, status: status[node.id] ?? 'queued', roster }))
}, h(FlowNode, { node, status: nodeState(status, node.id), roster }))
}))
}

Expand Down
62 changes: 62 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,68 @@
settings.unmount()
})

const NODE_STATES = ['queued', 'running', 'done', 'failed', 'skipped', 'idle']
const nodeStatesOf = tree => Object.fromEntries(
findAll(tree, node => typeof node.props?.['data-node'] === 'string' && String(node.props.className ?? '').split(' ').includes('wf-node'))
.map(node => [node.props['data-node'], String(node.props.className).split(' ').find(name => NODE_STATES.includes(name))]),
)

// Settings → Workflows paints graph.run.nodeStatus (core/test/workflows-page.test.js pins
// both the last-run chips and the "no run is Idle, not Queued" rule). The room Flow reads
// the same field from the same read, so an idle workflow cannot disagree with Settings (#1753).
test('an idle workflow paints its last run node status in the room Flow, the same statuses Settings paints (#1753)', async () => {
const graph = delivery()
graph.activeCount = 0
graph.runs = []
graph.run = {
...graph.run,
status: 'done',
running: [],
doneCount: 4,
nodeStatus: { planner: 'done', writer: 'done', coder: 'done', reviewer: 'done', tester: 'queued', deployer: 'queued' },
}
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()
await settle()
// A finished run has no live strip, so the stage pill is the drawer's only handle.
findAll(view.tree, node => node.type === 'button' && node.props?.className === 'wf-pill')[0].props.onClick()
await settle()
button(view.tree, 'Flow').props.onClick()
await settle()
assert.deepEqual(nodeStatesOf(view.tree), {
planner: 'done', writer: 'done', coder: 'done', reviewer: 'done', tester: 'queued', deployer: 'queued',
}, 'the room Flow draws the last run statuses Settings draws')
assert.equal(findAll(view.tree, node => node.props?.className === 'wf-node wf-abs queued').length, 2)
view.unmount()
})

test('a workflow that never ran paints idle seats in the room Flow, not a fake queue (#1753)', async () => {
const graph = { ...delivery(), run: null, runs: [] }
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()
await settle()
findAll(view.tree, node => node.type === 'button' && node.props?.className === 'wf-pill')[0].props.onClick()
await settle()
button(view.tree, 'Flow').props.onClick()
await settle()
assert.deepEqual(nodeStatesOf(view.tree), {
planner: 'idle', writer: 'idle', coder: 'idle', reviewer: 'idle', tester: 'idle', deployer: 'idle',
}, 'a graph with no run is idle in the room Flow, as it is in Settings')
assert.match(textOf(view.tree), /Idle/)
assert.doesNotMatch(textOf(view.tree), /Queued/, 'an idle workflow must not report a pending queue')
view.unmount()
})

test('the workflow stage mounts no message composer', async () => {
const graph = delivery()
const prevFlybot = globalThis.__flybot__
Expand Down Expand Up @@ -2686,7 +2748,7 @@
// The hint's text is read when the drag is refused, so the host translation is put in place
// after the drawer mounted: a stub standing in for the whole host at mount time is read by the
// page's own reads (avatars, tool links) and hides the workflow list.
globalThis.__flybot__ = { ...(previous ?? {}), t: (key) => (key === 'Tasks' ? '任务' : key) }

Check warning on line 2751 in plugins/workflow/test/client.test.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
try {
nodeAt('r1').props.onPointerDown({ clientX: 40, clientY: 160, target: { dataset: {} } })
canvas().props.onPointerMove({ clientX: 96, clientY: 208 })
Expand Down
Loading