From aa5612fb2c2270dbf64c7ce580fd3431b93573ae Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:45:55 +0800 Subject: [PATCH] workflow: Assign waits for a target before it can be pressed With no Workflow defined the dialog's target stayed "", but Assign was enabled on the task alone and its click posted workflowId "", which the host answers with "No such workflow". canAssign now also requires a target; the Bots in the same list are still offered, so a Bot can be picked instead. --- .../2245-assign-requires-target.fixed.md | 1 + docs/subsystems/workflow.md | 2 + plugins/workflow/client.js | 5 +- plugins/workflow/test/client.test.js | 58 +++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 changelog.d/2245-assign-requires-target.fixed.md diff --git a/changelog.d/2245-assign-requires-target.fixed.md b/changelog.d/2245-assign-requires-target.fixed.md new file mode 100644 index 000000000..65d7803df --- /dev/null +++ b/changelog.d/2245-assign-requires-target.fixed.md @@ -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". diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index e82c8edc5..c2c21decc 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -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. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 3db270c22..fd8c7a210 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -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 diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index c8ca26db7..2459da33a 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -4345,3 +4345,61 @@ test('#2244: three Bots keep today\'s pick list and the bot:2 body', async () => } 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() + }) +})