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/2245-assign-requires-target.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Hand a task to a Workflow** keeps **Assign** disabled while no target is picked: with no Workflow defined the dialog no longer looks ready and no longer posts an empty `workflowId` that the host answers with "No such workflow".
2 changes: 2 additions & 0 deletions docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ A host call on that page that throws, rejects, or never answers ends on the page

A confirmation dialog the host refuses — Rename, Delete, Create, or Hand a task to a Workflow — keeps the dialog open and prints the host's own sentence inside the dialog as the **only** `role="alert"` for that refusal: while one of those four dialogs holds the sentence the section draws no `.wf-err` copy of it, because a live region behind the mask is still read out and one refusal would be announced twice (#2193, #2202, #2237). Opening any of the four clears the previous refusal; closing the assign dialog drops its refusal rather than leaving it on the page. A refusal with no dialog open still reports once on the page, and the success path is unchanged.

With no Workflow defined, step 2 of **Hand a task to a Workflow** has no Workflow to default to and **Assign** stays disabled until a target is picked (the Bots in the same list are still offered), so a click can no longer post an empty `workflowId` the host answers with "No such workflow" (#2245).

The **Add link** row applies the drawer's pair rule before it saves (#2173): a pair the graph already lists is a no-op, another kind over that same pair names the kind already there instead of submitting, and the **To** list marks the targets the chosen node already reaches. The host still refuses a duplicated pair — the form is what stops producing one.

A Condition (`when`) or Loop (`until`) link takes its output name from the **From** node's declared `outputs` (#2178): the field is a select over exactly those names, **Add link** waits for one, and a node that declares none keeps the text box for an older graph. The host still refuses a name it cannot honour — `cleanWhen` and `PORT_RE` are unchanged.
Expand Down
5 changes: 4 additions & 1 deletion plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1433,7 +1433,10 @@ window.__ModuleLoader__.load({
if (taskId && tasks.some(task => task.id === taskId)) return
setTaskId(tasks[0].id)
}, [tasks, taskId])
const canAssign = tasksStatus === 'ready' && tasks.some(task => task.id === taskId)
// #2245: with no Workflow defined `target` stays '', and the dialog used to light Assign
// anyway — the click then posted `workflowId: ''`, which the host answers with "No such
// workflow". A target has to exist before the button can be pressed.
const canAssign = tasksStatus === 'ready' && target !== '' && tasks.some(task => task.id === taskId)
const seen = new Set()
const bots = (roster?.bots ?? []).filter(bot => {
if (!bot || bot.remote === true || !bot.id || !bot.name) return false
Expand Down
58 changes: 58 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3009,7 +3009,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 3012 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 Expand Up @@ -4345,3 +4345,61 @@
} finally { ui.view.unmount() }
})
})

// #2245 / P-461: with no Workflow defined step 2 has nothing to pick, so `target` stays ''. The
// dialog still lit **Assign** and posting it sent `workflowId: ''`, which the host answers with
// "No such workflow": a button that looks ready and can only fail. It now stays disabled until a
// target is picked — a Workflow, or a Bot through the same list.
function noLaneCall() {
const calls = []
const call = async (name, body) => {
calls.push({ name, body })
if (name === 'workflow/state') return { workflows: [], templates: [] }
return { ok: true, workflows: [] }
}
return { call, calls }
}

const targetCards = dialog => findAll(dialog, node => String(node.props?.className ?? '').split(' ').includes('wf-tgt'))
const pickedTargets = dialog => targetCards(dialog).filter(node => String(node.props?.className ?? '').split(' ').includes('sel'))

test('no Workflow yet: step 2 offers no target and Assign stays disabled', async () => {
const { call } = noLaneCall()
await withTaskHost(async () => {
const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} })
await settle()
const dialog = await openAssign(view)
assert.ok(dialog, 'the dialog opens even though no Workflow exists')
assert.ok(targetCards(dialog).length > 0, 'step 2 still offers the Bots it can hand the task to')
assert.deepEqual(pickedTargets(dialog), [], 'nothing is selected: there is no Workflow to default to')
assert.equal(assignOf(dialog).props.disabled, true, 'so Assign must stay disabled')
view.unmount()
})
})

test('no Workflow yet: clicking Assign posts nothing', async () => {
const { call, calls } = noLaneCall()
await withTaskHost(async () => {
const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} })
await settle()
const dialog = await openAssign(view)
assignOf(dialog).props.onClick()
await settle()
assert.deepEqual(calls.filter(item => item.name === 'workflow/assign').map(item => item.body), [], 'a click with no target must not reach the host')
assert.ok(dialogOf(view), 'and the dialog stays open')
view.unmount()
})
})

test('positive control: with a Workflow the first one is picked and Assign is live', async () => {
const graph = { ...delivery(), id: 'lane-a', name: 'Lane A' }
const { call } = assignCall(graph, () => ({ ok: true, workflows: [graph] }))
await withTaskHost(async () => {
const view = mount(client.Section, { roster: { bots: [{ id: 'b1', name: 'Writer Bot' }] }, call, actions: {} })
await settle()
const dialog = await openAssign(view)
assert.equal(pickedTargets(dialog).length, 1, 'the first Workflow is picked by default')
assert.equal(assignOf(dialog).props.disabled, false, 'and Assign is live')
view.unmount()
})
})
Loading