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/2256-held-tasks-page.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A workflow lane that holds more cards than its page listed now offers the rest: `workflow/state` takes a `heldLimit` (20 by default, 200 at most), and the stage's dead `3 more held tasks` line is a **Show 3 more held tasks** button that unfolds every held id — each still re-matchable — and folds back to **Show less** (#2256).
6 changes: 3 additions & 3 deletions docs/subsystems/workflow.md

Large diffs are not rendered by default.

35 changes: 29 additions & 6 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ window.__ModuleLoader__.load({
'Show less': '收起',
'Show {count} more cards': '展开另外 {count} 张卡片',
'Show {count} more Bots': '展开另外 {count} 个 Bot',
'Show {count} more held tasks': '展开另外持有中的 {count} 个任务',
// Stage timeline (#1489). The key is the English line; service.js and
// narrate.js keep the Chinese text only as the fallback for rows written
// before the key existed.
Expand Down Expand Up @@ -553,11 +554,17 @@ window.__ModuleLoader__.load({
}

// Stage: view/control only — progress and flowchart; no message composer (#1459).
function Stage({ workflow, roster, locked, readOnlyReason, stageLeft, drawer, tab, focusedRunId, onFocusRun, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop, onRematch }) {
function Stage({ workflow, roster, locked, readOnlyReason, stageLeft, drawer, tab, focusedRunId, onFocusRun, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop, onRematch, onHeldMore }) {
const chatRef = useRef(null)
const chatFoot = useRef({ height: 0, shown: 0 })
const liveRuns = liveStageRuns(workflow)
const held = workflow.heldTasks ?? []
// #2256: the id list is a page. Folded, the reader sees the page the state read
// carries; unfolded, every id the host sent for the wider read.
const [heldWide, setHeldWide] = useState(false)
// Another lane is another page: switching folds whatever the last one had unfolded.
useEffect(() => { setHeldWide(false) }, [workflow.id])
const heldShown = heldWide ? held : held.slice(0, HELD_PAGE)
const viewRun = resolveFocusedRun(workflow, focusedRunId)
const showSummary = (workflow.queueDepth ?? 0) > 0 || liveRuns.length > 1
const visible = stageMessages(workflow, workflow.messages, roster)
Expand Down Expand Up @@ -586,11 +593,16 @@ window.__ModuleLoader__.load({
h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? liveRuns.length, depth: workflow.queueDepth ?? 0 }))) : null,
held.length > 0 ? h('div', { className: 'wf-held' },
h('div', { className: 'wf-held-head' }, tr('Tasks this workflow holds')),
held.map(taskId => h('div', { className: 'wf-held-row', key: taskId },
heldShown.map(taskId => h('div', { className: 'wf-held-row', key: taskId },
h('span', { className: 'wf-held-id', title: taskId }, taskId),
locked ? null : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onRematch?.(taskId) }, tr('Re-match')))),
(workflow.heldTaskCount ?? held.length) > held.length
? h('div', { className: 'wf-held-more' }, tr('{count} more held tasks', { count: (workflow.heldTaskCount ?? held.length) - held.length }))
// #2249 gave this page the one unfold control; #2256 puts the held ids behind
// it, so the rest of the list is reachable instead of being named and dropped.
heldWide || (workflow.heldTaskCount ?? held.length) > heldShown.length
? h('button', {
type: 'button', className: 'wf-more wf-held-more', 'data-more': 'held', 'aria-expanded': heldWide,
onClick: () => { const next = !heldWide; setHeldWide(next); onHeldMore?.(next) },
}, heldWide ? tr('Show less') : tr('Show {count} more held tasks', { count: (workflow.heldTaskCount ?? held.length) - heldShown.length }))
: null) : null,
liveRuns.length > 0 ? h('div', { className: 'wf-strips' },
liveRuns.map(run => h(RunStrip, {
Expand Down Expand Up @@ -1418,6 +1430,10 @@ window.__ModuleLoader__.load({
}

const ASSIGN_VISIBLE = 12
// #2256: the ids one lane may hold is a page too. The state read lists 20, and a
// reader who unfolds the rest asks for the widest page the host serves (200).
const HELD_PAGE = 20
const HELD_LIMIT = 200

function AssignModal({ workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId, onClose, onConfirm, onRetry }) {
// #2241: two lanes drawn from different Projects read the same inside this dialog, so the
Expand Down Expand Up @@ -2001,8 +2017,14 @@ window.__ModuleLoader__.load({

const [readError, setReadError] = useState('')

// #2256: unfolding a lane's held ids asks for the wider page, and the 2s poll keeps
// asking for it while it stays unfolded — otherwise the next read would fold it back.
const heldWideRef = useRef(false)
// A lane that is not open must not keep the wider read alive.
useEffect(() => { heldWideRef.current = false }, [openId])

const refresh = async () => {
const value = await stageCall('workflow/state', undefined, 'The workflow host did not answer')
const value = await stageCall('workflow/state', heldWideRef.current ? { heldLimit: HELD_LIMIT } : undefined, 'The workflow host did not answer')
if (value && Array.isArray(value.workflows)) { setPack(value); setReadError('') }
else setReadError(value?.error ?? 'The workflow host did not answer')
return value
Expand Down Expand Up @@ -2326,6 +2348,7 @@ window.__ModuleLoader__.load({
})
},
onRematch: rematchTask,
onHeldMore: (wide) => { heldWideRef.current = wide; void refresh() },
})) : null,
assignOpen ? portal(h(AssignModal, {
workflows, tasks, tasksStatus, tasksError, error, projects, roster, workflowId: openId || workflows[0]?.id,
Expand Down Expand Up @@ -2418,7 +2441,7 @@ window.__ModuleLoader__.load({
.wf-held-head{font-size:12px;color:var(--bt-ink-2,#666)}
.wf-held-row{display:flex;align-items:center;gap:8px;min-width:0}
.wf-held-id{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}
.wf-held-more{font-size:12px;color:var(--bt-ink-2,#666)}
.wf-held-more{font-size:12px;margin-top:2px}
.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}
Expand Down
2 changes: 1 addition & 1 deletion plugins/workflow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
})
void service.reap?.().catch(error => fb.warn?.('flybot-workflow: reap failed: %s', error))
const offs = [
fb.api.handle('workflow/state', () => service.state(), { read: true }),
fb.api.handle('workflow/state', body => service.state(body), { read: true }),
fb.api.handle('workflow/create', body => service.create(body)),
fb.api.handle('workflow/rename', body => service.rename(body)),
fb.api.handle('workflow/delete', body => service.delete(body)),
Expand All @@ -130,7 +130,7 @@
task: { type: 'string', description: 'Task id, such as #1280. Omitted ids are generated.' },
},
// fb.tools.register hands the calling Bot to execute() as the second argument's botId (#1544).
execute: (args, ctx) => service.runNamed({ ...(args ?? {}), ...(ctx?.botId ? { callerBotId: ctx.botId } : {}) }),

Check warning on line 133 in plugins/workflow/index.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

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

Empty fallbacks in spreads are unnecessary
}))
offs.push(fb.tools.register({
name: 'define_workflow',
Expand All @@ -142,7 +142,7 @@
edges: { type: 'array', required: true, description: 'Hand-offs { from, to }. A fork is two edges out; a join is two edges in. A condition is when: { output, eq } or { else: true }. A loop is loop: true with max (1–8, default 3) and optional until: { output, eq }.' },
stepTimeoutMinutes: { type: 'number', description: 'How long one step may wait for its Bot before the run fails, 1–120. Omit for the 45-minute default.' },
},
execute: (args, ctx) => service.define({ ...(args ?? {}), ...(ctx?.botId ? { callerBotId: ctx.botId } : {}) }),

Check warning on line 145 in plugins/workflow/index.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

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

Empty fallbacks in spreads are unnecessary
}))
}
if (typeof fb.bot?.send?.use === 'function') {
Expand Down
20 changes: 16 additions & 4 deletions plugins/workflow/src/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
// How many held task ids a workflow lists in `workflow/state`; the count beside
// them is always the whole truth (#1647).
const MAX_HELD_TASKS = 20
// #2256: that list is a page, so a reader can ask for the rest of it. `heldLimit`
// is honoured up to this many ids — a wider page than the poll reads, never a full read.
const MAX_HELD_LIMIT = 200
// #1869: the fields `workflow/assign` reads. Anything else is named back in
// `ignored`, because a typo (`brif`) used to be dropped in silence.
const ASSIGN_FIELDS = new Set(['workflowId', 'taskId', 'id', 'title', 'brief', 'callerBotId', 'projectId', 'botId', 'handoff', 'handoffContext', 'acceptance', 'evidence'])
Expand Down Expand Up @@ -243,13 +246,13 @@
}

function clearRunTimers(runId) {
for (const [key, entry] of [...timers]) {

Check warning on line 249 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (entry.runId === runId) clearTimerAt(key)
}
for (const key of [...warned]) {

Check warning on line 252 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (key.endsWith(`:${runId}`)) warned.delete(key)
}
for (const key of [...handOffTries.keys()]) {

Check warning on line 255 in plugins/workflow/src/service.js

View workflow job for this annotation

GitHub Actions / plugins (workflow)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
if (key.startsWith(`${runId}:`)) handOffTries.delete(key)
}
}
Expand Down Expand Up @@ -355,8 +358,17 @@
state.logs[workflowId] = log.slice(-MAX_MESSAGES)
}

// How many ids one `workflow/state` read lists. Anything that is not a positive
// integer keeps the default page, and the cap keeps a reader from turning the poll
// into a full read (#2256).
function heldLimitOf(body) {
const value = Number(body?.heldLimit)
if (!Number.isInteger(value) || value < 1) return MAX_HELD_TASKS
return Math.min(value, MAX_HELD_LIMIT)
}

// Task ids each workflow holds, sorted. `heldTaskCount` is the whole truth and
// the list beside it is capped (#1647); a Bot claim is not a workflow claim.
// the list beside it is a page (#1647, #2256); a Bot claim is not a workflow claim.
function heldTaskIds() {
const held = new Map()
for (const [taskId, claim] of Object.entries(state.claims ?? {})) {
Expand All @@ -369,7 +381,7 @@
return held
}

function present() {
function present(heldLimit = MAX_HELD_TASKS) {
const roster = bots() ?? []
const held = heldTaskIds()
return {
Expand Down Expand Up @@ -403,7 +415,7 @@
preview: rowPreview(item, previewRun(item.id)),
run: publicRun(item, displayRun(item.id)),
messages: state.logs[item.id] ?? [],
heldTasks: (held.get(item.id) ?? []).slice(0, MAX_HELD_TASKS),
heldTasks: (held.get(item.id) ?? []).slice(0, heldLimit),
heldTaskCount: (held.get(item.id) ?? []).length,
}) }),
}
Expand Down Expand Up @@ -1116,7 +1128,7 @@
}

return {
state() { return present() },
state(body) { return present(heldLimitOf(body)) },
rename(body = {}) {
if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' }
const workflowId = String(body.workflowId ?? '')
Expand Down
100 changes: 100 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4474,3 +4474,103 @@ test('#2254: the named confirm button posts the same assign body', async () => {
view.unmount()
})
})

// #2256: a lane that holds more cards than the state page lists drew 20 rows and then a
// dead line — `3 more held tasks` as a plain div, with no `aria-expanded`, and the 21st id
// not in the DOM at all. The line is now the #2249 `wf-more` control: it asks the host for
// the wider page, draws every held id, and folds back to the first 20.
test('#2256: the held list draws its rest behind a wf-more button that asks for the wider page', async () => {
const portals = []
const reactDom = {
createPortal(node, container) {
if (!container || container.nodeType !== 1) throw new Error('Target container is not a DOM element.')
portals.push(container)
return { type: 'portal', props: { container }, children: [node] }
},
}
const opened = loadClient(reactDom)
const body = { nodeType: 1 }
const prevBody = document.body
document.body = body
const ids = Array.from({ length: 23 }, (_, index) => `task-held-${String(index + 1).padStart(2, '0')}`)
const asked = []
const call = async (name, payload) => {
if (name !== 'workflow/state') return { ok: true }
asked.push(payload)
const limit = Number(payload?.heldLimit)
const shown = Number.isInteger(limit) && limit > 0 ? Math.min(limit, 200) : 20
return { workflows: [{ ...delivery(), heldTasks: ids.slice(0, shown), heldTaskCount: ids.length }], templates: [] }
}
const view = mount(opened.Section, { roster: { bots: [] }, call, actions: {} })
try {
await settle()
findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick()
await settle()

const rows = () => findAll(view.tree, node => node.props?.className === 'wf-held-row')
const more = () => findAll(view.tree, node => node.type === 'button' && node.props?.['data-more'] === 'held')
assert.equal(rows().length, 20, 'the first page is the 20 ids the state carries')
assert.equal(more().length, 1, 'the rest is a control, not a dead line')
assert.equal(more()[0].props['aria-expanded'], false, 'and it says it is folded')
assert.equal(textOf(more()[0]), 'Show 3 more held tasks')

more()[0].props.onClick()
await settle()
assert.deepEqual(asked.at(-1), { heldLimit: 200 }, 'unfolding asks the host for the wider page')
assert.equal(more()[0].props['aria-expanded'], true)
assert.equal(textOf(more()[0]), 'Show less')
assert.equal(rows().length, 23, 'every held id is drawn once unfolded')
const last = rows().at(-1)
assert.match(textOf(last), /task-held-23/, 'the 23rd id is in the DOM')
const rematch = findAll(last, node => node.type === 'button' && textOf(node) === 'Re-match')[0]
assert.ok(rematch, 'and the 23rd id is reachable, with the same Re-match every row has')
rematch.props.onClick()
await settle()
assert.deepEqual(asked.at(-1), { heldLimit: 200 }, 'a re-match keeps the page the reader unfolded')
assert.equal(rows().length, 23, 'so the row does not fold under the pointer')

more()[0].props.onClick()
await settle()
assert.deepEqual(asked.at(-1), undefined, 'folding asks for the default page again')
assert.equal(rows().length, 20)
assert.equal(more()[0].props['aria-expanded'], false)
} finally {
view.unmount()
document.body = prevBody
}
})

// #2256: within one page there is nothing to unfold, so the control must not appear —
// and the block a reader sees is exactly what it was before this change.
test('#2256: a lane within one page draws no more-row and keeps its held block as it was', async () => {
const portals = []
const reactDom = {
createPortal(node, container) {
portals.push(container)
return { type: 'portal', props: { container }, children: [node] }
},
}
const opened = loadClient(reactDom)
const body = { nodeType: 1 }
const prevBody = document.body
document.body = body
const ids = ['task-held-01', 'task-held-02', 'task-held-03']
const call = async (name) => {
if (name !== 'workflow/state') return { ok: true }
return { workflows: [{ ...delivery(), heldTasks: ids, heldTaskCount: ids.length }], templates: [] }
}
const view = mount(opened.Section, { roster: { bots: [] }, call, actions: {} })
try {
await settle()
findAll(view.tree, node => node.type === 'button' && textOf(node).includes('发布交付流水线'))[0].props.onClick()
await settle()
const block = findAll(view.tree, node => node.props?.className === 'wf-held')[0]
assert.ok(block, 'the held block is drawn')
assert.equal(findAll(view.tree, node => node.props?.['data-more'] === 'held').length, 0, 'a full page draws no more-row')
assert.equal(findAll(view.tree, node => node.props?.className === 'wf-held-row').length, 3)
assert.equal(textOf(block), `Tasks this workflow holds${ids.map(id => `${id}Re-match`).join('')}`, 'the block reads exactly as it did')
} finally {
view.unmount()
document.body = prevBody
}
})
83 changes: 83 additions & 0 deletions plugins/workflow/test/held-page.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// #2256: `workflow/state` lists held task ids, and the list is a page. A lane
// holding 23 cards drew 20 and then a dead line saying "3 more held tasks": the
// rows behind the cap could not be reached at all. The read now takes a
// `heldLimit` — 20 by default, 200 at most, and only for an integer above zero —
// while `heldTaskCount` stays the whole truth on either page.
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createWorkflowService } from '../src/service.js'

const CLOCK = 1_700_000_000_000
const roster = [{ id: 'main', name: 'Chief', role: 'lead', isMain: true, remote: false }]

function serviceAt(dir, persist = true) {
return createWorkflowService({
dir,
persist,
now: () => CLOCK,
bots: () => roster,
spawn: async () => 'eph',
release: async () => {},
dispatch: async () => ({ ok: true }),
// Step deadlines are timers; they are not part of what these cases assert.
setTimer: () => 0,
clearTimer: () => {},
})
}

async function laneWith(svc, count) {
const created = svc.create({ name: 'Alpha', nodes: [{ id: 'only', name: 'Only', prompt: 'Do it.' }], edges: [] })
assert.equal(created.ok, true, created.error)
const alpha = created.workflows.find(item => item.name === 'Alpha')
const ids = []
for (let i = 1; i <= count; i += 1) {
const id = `task-held-${String(i).padStart(2, '0')}`
ids.push(id)
const routed = await svc.ingest({ id, status: 'open', title: `Ship ${i}`, workflowId: alpha.id })
assert.equal(routed.ok, true, routed.error)
}
return { alpha, ids }
}

test('#2256: the state read pages held ids, 20 by default and every one when asked', async () => {
const dir = mkdtempSync(join(tmpdir(), 'wf-held-page-'))
try {
const svc = serviceAt(dir)
const { alpha, ids } = await laneWith(svc, 23)
const item = body => svc.state(body).workflows.find(workflow => workflow.id === alpha.id)

assert.deepEqual(item().heldTasks, ids.slice(0, 20), 'no argument keeps the 20-id page')
assert.equal(item().heldTaskCount, 23, 'the default page still reports the whole count')

assert.deepEqual(item({ heldLimit: 200 }).heldTasks, ids, 'heldLimit: 200 lists every held id')
assert.equal(item({ heldLimit: 200 }).heldTaskCount, 23, 'the wider page keeps the whole count')
assert.deepEqual(item({ heldLimit: 23 }).heldTasks, ids, 'a limit just above the page is honoured')

// A poll must not be able to ask for an unbounded read: anything that is not a
// positive integer falls back to the default page.
for (const bad of [{ heldLimit: 0 }, { heldLimit: -5 }, { heldLimit: 'many' }, { heldLimit: 3.5 }]) {
assert.deepEqual(item(bad).heldTasks, ids.slice(0, 20), `${JSON.stringify(bad)} falls back to the default page`)
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})

test('#2256: heldLimit is capped at 200, so a reader never turns the poll into a full read', async () => {
const dir = mkdtempSync(join(tmpdir(), 'wf-held-cap-'))
try {
const svc = serviceAt(dir, false)
const { alpha, ids } = await laneWith(svc, 205)
const item = body => svc.state(body).workflows.find(workflow => workflow.id === alpha.id)

const wide = item({ heldLimit: 1000 })
assert.equal(wide.heldTasks.length, 200, 'the widest page is 200 ids')
assert.deepEqual(wide.heldTasks, [...ids].sort().slice(0, 200), 'and they are the first 200 of the sorted list')
assert.equal(wide.heldTaskCount, 205, 'the count is still the whole truth past the cap')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
Loading