From 5b80e216465a05c4c1193d72ee1447e2c5b7b0e3 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 28 Oct 2026 11:27:05 +0000 Subject: [PATCH] Route a card written through track_tasks to the board listeners (#1462) A card a Bot records itself never asked flybot.tasks.onWrite: only task-create and task-update called routeOpenTask, so a `workflow:` on a card the conversation path saved started nothing. trackBotTasks now routes every card it saved, after its own save and before it answers, and returns the views after routing. The Workflow plugin warns once when the host offers no tasks.onWrite and reports `tasksWired: false` in workflow/state; the Workflows panel says a board card cannot start a run there instead of looking manual-only by design. Fixes #1462 --- .../1462-workflow-board-write.fixed.md | 1 + core/src/host/tasks.js | 6 +++ core/test/task-route.test.js | 33 +++++++++++++ docs/subsystems/core-host.md | 2 +- docs/subsystems/workflow.md | 6 +-- patches/ds-bot/files/src/host/tasks.js | 6 +++ patches/ds-bot/files/test/task-route.test.js | 33 +++++++++++++ plugins/workflow/client.js | 3 ++ plugins/workflow/index.js | 7 ++- plugins/workflow/src/service.js | 3 +- plugins/workflow/test/assign.test.js | 17 +++++++ plugins/workflow/test/client.test.js | 22 +++++++++ plugins/workflow/test/host.test.js | 49 ++++++++++++++++++- 13 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1462-workflow-board-write.fixed.md diff --git a/changelog.d/1462-workflow-board-write.fixed.md b/changelog.d/1462-workflow-board-write.fixed.md new file mode 100644 index 000000000..bc6755df8 --- /dev/null +++ b/changelog.d/1462-workflow-board-write.fixed.md @@ -0,0 +1 @@ +A board card a Bot records itself with `track_tasks` now starts the Workflow it names, or queues on that lane when it is at `maxConcurrent`; the Workflow panel also says when a host does not pass board writes to the plugin, instead of looking manual-only by design ([#1462](https://github.com/ThinkFlowLab/FlyBot/issues/1462)). diff --git a/core/src/host/tasks.js b/core/src/host/tasks.js index 224ba741a..7c0870545 100644 --- a/core/src/host/tasks.js +++ b/core/src/host/tasks.js @@ -660,6 +660,7 @@ export function install(rt) { return task } const saved = [] + const kinds = [] const batch = new Map() for (const item of items) { if (item === null || typeof item !== 'object' || Array.isArray(item)) throw invalid('Each task must be an object') @@ -771,9 +772,14 @@ export function install(rt) { if (task.title) batch.set(task.title, task) batch.set(task.id, task) saved.push(task) + kinds.push(isNew ? 'create' : 'update') } prune() await save() + // The conversation path is a board write like task-create and task-update, so a plugin + // has to see it: a card a Bot records itself with `workflow:` starts nothing + // otherwise (#1462). Routing after the save also keeps a routed owner visible. + for (let at = 0; at < saved.length; at += 1) await routeOpenTask(saved[at], kinds[at]) return saved.map(view) } diff --git a/core/test/task-route.test.js b/core/test/task-route.test.js index c3c427379..afc65e2d5 100644 --- a/core/test/task-route.test.js +++ b/core/test/task-route.test.js @@ -94,3 +94,36 @@ test('task-create and task-update apply a local owner and ignore a remote or a t assert.equal(updated.ownerBotName, 'Dev2') assert.equal(seen.filter(item => item.kind === 'update').length, 1) }) + +// The conversation path writes the same board (#1462): a Bot records its own card with +// `track_tasks`, and a `workflow:` on it has to reach the listener like any other write. +test('a card written through track_tasks asks the listener and returns the routed owner', async (t) => { + const rt = await start(t) + const seen = [] + rt.flybot.scope('flybot-workflow').tasks.onWrite((task, kind) => { + seen.push({ title: task.title, kind, owner: task.ownerBotId }) + if (String(task.title).includes('route-me')) return { ownerBotId: 'd2' } + return null + }) + + const [routed] = await rt.trackBotTasks('d1', { tasks: [{ title: 'please route-me' }] }) + assert.equal(routed.title, 'please route-me') + assert.equal(routed.ownerBotId, 'd2', 'the returned view carries the routed owner') + assert.deepEqual(seen, [{ title: 'please route-me', kind: 'create', owner: 'd1' }]) + + const [plain] = await rt.trackBotTasks('d1', { tasks: [{ title: 'leave this card' }] }) + assert.equal(plain.ownerBotId, 'd1') + assert.equal(seen.at(-1).kind, 'create') + + const [updated] = await rt.trackBotTasks('d1', { tasks: [{ id: plain.id, title: 'now route-me' }] }) + assert.equal(updated.ownerBotId, 'd2') + assert.equal(seen.at(-1).kind, 'update') + assert.equal(seen.at(-1).title, 'now route-me') +}) + +test('track_tasks without a listener is still a plain board write', async (t) => { + const rt = await start(t) + const [card] = await rt.trackBotTasks('d1', { tasks: [{ title: 'workflow:delivery' }] }) + assert.equal(card.ownerBotId, 'd1') + assert.equal(card.status, 'open') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index c12d4dc21..cf7c90acd 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -240,7 +240,7 @@ Accepted/failed/cancelled tasks stay terminal. This records the requester's task disposition, not proof that an external runtime stopped; no runtime abort or side-effect rollback is implied. -A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks; `updatedAt` is strictly increasing, so two starts in one millisecond still order) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. After `task-create` and `task-update` save, the host asks `rt.onTaskWrite` (hooks exposes it as `flybot.tasks.onWrite`, [plugin-runtime](plugin-runtime.md)). A returned `ownerBotId` that names a different local Bot is stored and saved once more; that second save does not ask again. A missing, remote or identical id leaves the owner (`core/test/task-route.test.js`, #1318). +A tracked request with a stable id (`task-`) and a small state machine, owned by `src/host/tasks.js` with `state.tasks` (additive; corrupt values reset, no version bump). States: `open → claimed → working → blocked ⇄ working → result-ready → accepted`, with `cancelled`/`failed` terminal and `accepted` reachable only from `result-ready` — a failed or cancelled task can never display as accepted (#54). Transitions after create are the requester's (server-derived `requesterId`, the signed-in actor or the single owner; `bot/forbidden` otherwise, `bot/stale` on invalid moves), recorded with per-status actor entries. Create does not require owning the Bot: a matching per-Bot grant, or both the requester and the Bot as current members of the named room, is enough; outgoing holds stay the owner's (#52 card). A working task's held send blocks **that** task (`meta.taskId` stamped on the send, matching origin when the Bot has several working tasks; `updatedAt` is strictly increasing, so two starts in one millisecond still order) with the approval id; the owner's decide resumes (approve) or cancels (reject) it; deleting the responsible Bot fails its open tasks. `result-ready` is protected from prune while it waits for acceptance. Endpoints `tasks` / `task-create` / `task-accept` / `task-start` / `task-return` / `task-accept-result` / `task-cancel` are an explicit authz allow-list and are record-checked in tasks.js. Records store pointers and a copied summary, never private transcripts. Clarification and `workBotId` wait on #55's exchange wiring. Team mode stays disabled. After `task-create`, `task-update` and `track_tasks` save, the host asks `rt.onTaskWrite` (hooks exposes it as `flybot.tasks.onWrite`, [plugin-runtime](plugin-runtime.md)). A returned `ownerBotId` that names a different local Bot is stored and saved once more; that second save does not ask again. A missing, remote or identical id leaves the owner (`core/test/task-route.test.js`, #1318). `track_tasks` routes every card it saved, after its own save and before it answers, so a card a Bot records itself reaches the same listeners as a board write made through the channel API (#1462). ### Task input waits diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 9c25e6d5a..7dd8eb778 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -40,7 +40,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, - Endpoint `workflow/state` and `workflow/templates` are reads. - `workflow/create`, `workflow/rename`, `workflow/delete`, `workflow/assign`, `workflow/concurrency`, `workflow/graph`, `workflow/advance`, `workflow/reset`, `workflow/ingest` write. `workflow/concurrency` sets that workflow's `maxConcurrent` (an integer from 1 to 8); the same control is in Settings → Workflow graphs. `workflow/step-timeout` sets that workflow's `stepTimeoutMinutes` (an integer from 1 to 120, or `null` to go back to the default), and `workflow/create` accepts the same field. `workflow/rename` updates a workflow's name and optional description; read-only hosts are refused. `workflow/delete` removes a workflow from state, releases its ephemeral Bots if a run exists, and cleans logs/runs/routes. - Tool `run_workflow` starts a named run with a brief. Tool `define_workflow` writes the same graph the canvas edits. -- `flybot.tasks.onWrite` calls `ingest`. A new Bot match returns `{ ownerBotId }`. A Workflow match starts the run inside the plugin and leaves the card's owner to the host. +- `flybot.tasks.onWrite` calls `ingest`. A new Bot match returns `{ ownerBotId }`. A Workflow match starts the run inside the plugin and leaves the card's owner to the host. A host that offers no `tasks.onWrite` warns once and reports `tasksWired: false` in `workflow/state`, and the Workflows panel says that a board card cannot start a run there (#1462). ## Invariants @@ -51,7 +51,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 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. **File and shell rights are the same as a normal sidebar Bot's:** every node Bot gets the `danger-full-access` preset, and `EPHEMERAL_DENIED_TOOLS` still refuses the team-admin tools at the guard and approval layers (#1466, 29 names). **`node.tools` selects connectors only:** `spawn` passes the node's cleaned list to `fb.bot.create`, the host records it as `stepTools`, and `connector-access.js` allows `mcp__github__*` for `github` and `mcp__composio__*` for `composio` — a connector the node did not name is refused, and a node that names none reaches none. The list is also stored in `fields.tools` for the UI. A hidden run Bot cannot show an approval card, so the approval seam answers the same way the guard does. 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 for success paths. 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. An `onFail` edge (`onFail: true`, `max` from 1 to 8, default 3) provides failure recovery: when a node fails, if an `onFail` edge points from that node back to an upstream target and recovery attempts remain (tracked in `failAttempts` per edge), the workflow resets both nodes to their pre-failure states (the target to `running`, the failing node to `queued`) and re-runs from the target through the failing node. After `max` attempts are exhausted, the node fails permanently and the run stops. An `onFail` edge does not interfere with `failOnce` retry (which restarts the same node once) or success-path `loop` edges. Settings → Workflow graphs can add a link, a condition, a loop, or a failure recovery edge. -8. Auto-assign considers an `open` card only. A saved Bot claim beats a workflow trigger. Otherwise first hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger in the title, note, criteria or tags — an ASCII trigger must match a whole word (`notice` does not match `noticeable`, `pr` does not match `Improve`), while a non-ASCII trigger matches inside a longer text because CJK has no word boundary. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted. +8. Auto-assign considers an `open` card only. Every saved board write reaches the hook, including a card a Bot records for itself through `track_tasks`, so a `workflow:` on the card a Bot wrote starts that run (#1462). A saved Bot claim beats a workflow trigger. Otherwise first hit wins: a `workflow:` token, then `workflowId` / `workflow`, then an assignee that names a workflow, then a `bot:` token, then an assignee that names a local Bot, then the longest trigger in the title, note, criteria or tags — an ASCII trigger must match a whole word (`notice` does not match `noticeable`, `pr` does not match `Improve`), while a non-ASCII trigger matches inside a longer text because CJK has no word boundary. A miss returns no route. A second ingest of the same task and target is a no-op, and a live run for that task id is not restarted. 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. @@ -59,7 +59,7 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, ## Tests -`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow `stepTimeoutMinutes` that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`. The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`. +`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, the FIFO queue and concurrency cap, Bot-reply advance, a dispatch that fails without being marked handed, a bounded hand-off retry and a step deadline that fails the run and clears its timer (#1530), a per-workflow `stepTimeoutMinutes` that replaces the ten-minute constant and is refused outside 1..120 (#1580), disposing releases the Bots before the record drops their ids and a released runner name is reusable again, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, failure recovery (onFail edges with max attempts, exhaustion behavior, and compatibility with failOnce and loop edges), templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, a stopped run with its settled node map (#1424), the reply middleware, the client canvas, the settings page (including a condition and a loop), rename and delete (including factory-id rows such as `delivery`), and a sidebar click that leaves the Workflows section in place. Run `npm test` in `plugins/workflow`. The host seam that applies `ownerBotId` is `core/test/task-route.test.js`, which also pins that a card written through `track_tasks` reaches the listener (#1462). The host omission of run Bots, and the grant of `stepTools` plus the hidden-approval answer, is `core/test/flybot.test.js`. Which connectors a run Bot may call is `core/test/connector-access.test.js`, and the chain from a node's `tools` through `spawn` to that guard — the built-in delivery nodes included — is `core/test/workflow-node-connectors.test.js`. ## Connector ceiling (#1544) diff --git a/patches/ds-bot/files/src/host/tasks.js b/patches/ds-bot/files/src/host/tasks.js index 224ba741a..7c0870545 100644 --- a/patches/ds-bot/files/src/host/tasks.js +++ b/patches/ds-bot/files/src/host/tasks.js @@ -660,6 +660,7 @@ export function install(rt) { return task } const saved = [] + const kinds = [] const batch = new Map() for (const item of items) { if (item === null || typeof item !== 'object' || Array.isArray(item)) throw invalid('Each task must be an object') @@ -771,9 +772,14 @@ export function install(rt) { if (task.title) batch.set(task.title, task) batch.set(task.id, task) saved.push(task) + kinds.push(isNew ? 'create' : 'update') } prune() await save() + // The conversation path is a board write like task-create and task-update, so a plugin + // has to see it: a card a Bot records itself with `workflow:` starts nothing + // otherwise (#1462). Routing after the save also keeps a routed owner visible. + for (let at = 0; at < saved.length; at += 1) await routeOpenTask(saved[at], kinds[at]) return saved.map(view) } diff --git a/patches/ds-bot/files/test/task-route.test.js b/patches/ds-bot/files/test/task-route.test.js index c3c427379..afc65e2d5 100644 --- a/patches/ds-bot/files/test/task-route.test.js +++ b/patches/ds-bot/files/test/task-route.test.js @@ -94,3 +94,36 @@ test('task-create and task-update apply a local owner and ignore a remote or a t assert.equal(updated.ownerBotName, 'Dev2') assert.equal(seen.filter(item => item.kind === 'update').length, 1) }) + +// The conversation path writes the same board (#1462): a Bot records its own card with +// `track_tasks`, and a `workflow:` on it has to reach the listener like any other write. +test('a card written through track_tasks asks the listener and returns the routed owner', async (t) => { + const rt = await start(t) + const seen = [] + rt.flybot.scope('flybot-workflow').tasks.onWrite((task, kind) => { + seen.push({ title: task.title, kind, owner: task.ownerBotId }) + if (String(task.title).includes('route-me')) return { ownerBotId: 'd2' } + return null + }) + + const [routed] = await rt.trackBotTasks('d1', { tasks: [{ title: 'please route-me' }] }) + assert.equal(routed.title, 'please route-me') + assert.equal(routed.ownerBotId, 'd2', 'the returned view carries the routed owner') + assert.deepEqual(seen, [{ title: 'please route-me', kind: 'create', owner: 'd1' }]) + + const [plain] = await rt.trackBotTasks('d1', { tasks: [{ title: 'leave this card' }] }) + assert.equal(plain.ownerBotId, 'd1') + assert.equal(seen.at(-1).kind, 'create') + + const [updated] = await rt.trackBotTasks('d1', { tasks: [{ id: plain.id, title: 'now route-me' }] }) + assert.equal(updated.ownerBotId, 'd2') + assert.equal(seen.at(-1).kind, 'update') + assert.equal(seen.at(-1).title, 'now route-me') +}) + +test('track_tasks without a listener is still a plain board write', async (t) => { + const rt = await start(t) + const [card] = await rt.trackBotTasks('d1', { tasks: [{ title: 'workflow:delivery' }] }) + assert.equal(card.ownerBotId, 'd1') + assert.equal(card.status, 'open') +}) diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 1f716f679..898e1e3fc 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -1491,6 +1491,8 @@ window.__ModuleLoader__.load({ h('div', { className: 'wf-sec' }, h('span', null, tr('Workflows')), h('button', { type: 'button', className: 'wf-new', onClick: () => setCreating(true) }, tr('New'))), + pack?.tasksWired === false ? h('div', { className: 'wf-wiring', role: 'status' }, + tr('This host does not pass task writes to Workflows, so a card on the board cannot start a run.')) : null, workflows.length === 0 && !readError ? h('div', { className: 'wf-muted' }, tr('No workflows yet.')) : null, workflows.map(item => h('button', { key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, @@ -1748,6 +1750,7 @@ window.__ModuleLoader__.load({ .wf-tip .wf-k{color:var(--bt-ink-3,#9a9a9a);font-size:11px;margin-top:6px} .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-wiring{font-size:12px;color:var(--bt-ink-3,#9a9a9a);padding:2px 8px 6px;line-height:1.5} .wf-assign-row{opacity:.9} .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} diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index c8e5ee62c..91c7a2e1f 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -88,12 +88,15 @@ export function apply(ctx, config = {}) { return fb.bot.send({ from, to: step.botId, text: lines.join('\n'), role: 'request' }) }, } : {} + // A host that does not hand board writes over cannot start a run from a card (#1462). + const tasksWired = typeof fb.tasks?.onWrite === 'function' const service = createWorkflowService({ dir: persist ? (config.dir ?? join(dshHome, 'bot', 'flybot-workflow')) : undefined, persist, bots: () => fb.bot?.list?.() ?? [], readOnly: () => fb.readOnly?.() === true, warn: (...args) => fb.warn?.(...args), + tasksWired, ...lifecycle, }) void service.reap?.().catch(error => fb.warn?.('flybot-workflow: reap failed: %s', error)) @@ -163,7 +166,7 @@ export function apply(ctx, config = {}) { } else { fb.warn?.('flybot-workflow: send middleware not available; ephemeral bot replies may leak') } - if (typeof fb.tasks?.onWrite === 'function') { + if (tasksWired) { offs.push(fb.tasks.onWrite(task => { const result = service.ingest(task ?? {}) if (result?.matched === true && result.already !== true && result.decision?.kind === 'bot') { @@ -171,6 +174,8 @@ export function apply(ctx, config = {}) { } return null })) + } else { + fb.warn?.('flybot-workflow: tasks.onWrite is not available, so a board card cannot start a run') } // The hooks come off at once; the Bot release is behind a promise, so hand it // back instead of dropping it (#1387). diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 6bf3491f1..844f4d901 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -125,7 +125,7 @@ function blankRun(workflow, task, now, history = []) { } export function createWorkflowService({ - dir, persist = true, now = () => Date.now(), bots = () => [], readOnly = () => false, warn = () => {}, spawn, release, dispatch, + dir, persist = true, now = () => Date.now(), bots = () => [], readOnly = () => false, warn = () => {}, tasksWired = false, spawn, release, dispatch, setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = handle => clearTimeout(handle), } = {}) { const file = dir ? join(dir, FILE) : null @@ -279,6 +279,7 @@ export function createWorkflowService({ return { ok: true, version: 1, + tasksWired, templates: templateCatalog(), workflows: state.workflows.map(raw => { const item = readyWorkflow(raw) diff --git a/plugins/workflow/test/assign.test.js b/plugins/workflow/test/assign.test.js index f7fc9eff5..7f5e2bcb1 100644 --- a/plugins/workflow/test/assign.test.js +++ b/plugins/workflow/test/assign.test.js @@ -325,6 +325,7 @@ test('apply registers the workflow endpoints and stays quiet when disabled', () assert.ok(names.some(([name, read]) => name === 'workflow/templates' && read)) assert.ok(!names.some(([name]) => name === 'workflow/say'), 'the removed say endpoint is not registered') assert.equal(writes.length, 1) + assert.equal(started.service.state().tasksWired, true) assert.deepEqual(writes[0]({ id: '#9', title: 'please', status: 'open', assignee: 'Coder' }), { ownerBotId: 'bot-coder' }) assert.equal(writes[0]({ id: '#10', title: 'nothing', status: 'open' }), null) const tools = [] @@ -335,6 +336,22 @@ test('apply registers the workflow endpoints and stays quiet when disabled', () assert.equal(off.enabled, false) }) +// A host without the board hook is reported, not silently treated as manual-only (#1462). +test('a host without tasks.onWrite warns once and reports the missing wiring in state', () => { + const warnings = [] + const fb = { + bot: { list: () => roster, send: { use() { return () => {} } } }, + readOnly: () => false, + api: { handle() { return () => {} } }, + warn(message) { warnings.push(String(message)) }, + } + const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false }) + assert.equal(started.enabled, true) + assert.equal(started.service.state().tasksWired, false) + assert.equal(warnings.filter(line => line.includes('tasks.onWrite')).length, 1) + assert.equal(warnings.length, 1) +}) + test('rename changes workflow name, including seeded delivery rows', () => { const svc = service() const lane = svc.create({ diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 471d61a56..92811afb2 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -1355,6 +1355,28 @@ test('a stage control that never answers settles as an error', async () => { assert.deepEqual(value, { ok: false, error: 'The run did not answer' }) }) +// A host that never hands board writes over looks manual-only unless the panel says so (#1462). +test('a host without the board wiring says so on the panel, and a wired host says nothing', async () => { + const graph = delivery() + const mountWith = async (tasksWired) => { + const call = async name => (name === 'workflow/state' + ? { ok: true, workflows: [graph], templates: [], tasksWired } + : { ok: true, workflows: [graph] }) + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + return view + } + const unwired = await mountWith(false) + const lines = findAll(unwired.tree, node => node.props?.className === 'wf-wiring').map(textOf) + assert.equal(lines.length, 1) + assert.match(lines[0], /does not pass task writes/) + unwired.unmount() + + const wired = await mountWith(true) + assert.equal(findAll(wired.tree, node => node.props?.className === 'wf-wiring').length, 0) + wired.unmount() +}) + const cssRule = (source, selector) => { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const match = new RegExp(`${escaped}\\{([^}]*)\\}`).exec(source) diff --git a/plugins/workflow/test/host.test.js b/plugins/workflow/test/host.test.js index 0a154bb02..9c8abbfe6 100644 --- a/plugins/workflow/test/host.test.js +++ b/plugins/workflow/test/host.test.js @@ -27,16 +27,35 @@ async function start() { const home = mkdtempSync(join(tmpdir(), 'flybot-workflow-host-')) writeFileSync(join(home, 'state.json'), JSON.stringify(team())) const ctx = fakeCordis() + // A run Bot is dispatched to like any other Bot, so the host has to resolve one. + const agents = new Map() + const agentOf = (id) => { + if (!agents.has(id)) { + agents.set(id, { + session: { id }, id, calls: [], status: 'idle', + inbox: { nextStep: [], nextTurn: [], remove() { return false } }, + followup(message) { this.calls.push(message) }, + steer() {}, inject() {}, cancel() {}, async whenIdle() {}, + }) + } + return agents.get(id) + } Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) }, workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} }, - sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async () => undefined, selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, + sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {}, page: async () => ({ records: [], hasMore: false }) }, sessionTitle: { rename: async () => {} }, sessions: { flush: async () => {} }, }) let route const register = ctx.connection.fetch.register ctx.connection.fetch.register = (options) => { if (options.path.endsWith('bot')) route = options; return register(options) } + // A run Bot is granted a permission preset on creation; the fake profile offers one. + ctx.set('permissionPresets', { + catalog: () => ({ options: [{ value: 'danger-full-access' }], defaultPreset: 'danger-full-access' }), + resolve: () => 'danger-full-access', + set: () => {}, + }) apply(ctx, { home }) const call = async (endpoint, payload) => (await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', body: JSON.stringify({ endpoint, payload }) }))).json() const stop = async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) } @@ -55,3 +74,31 @@ test('workflow plugin on the real host: the removed say endpoint is unknown, not error: { code: 'bot/not-found', message: 'Unknown FlyBot endpoint workflow/say', details: {} }, }) }) + +// The conversation path is a board write (#1462). A card a Bot records through the real +// `track_tasks` tool with `workflow:` starts that run, and a repeat does not start a second. +test('a card written through track_tasks starts the workflow it names', async (t) => { + const h = await start() + t.after(h.stop) + h.ctx.plugin(workflow, { persist: false }) + assert.equal((await h.call('flybot/workflow/state')).value.tasksWired, true) + + const track = h.ctx.registeredTools.get('track_tasks') + assert.ok(track, 'the host registers track_tasks') + const body = await track.execute({ tasks: [{ title: 'workflow:delivery', column: 'not-started' }] }, { agent: { id: 'b' } }) + const taskId = String(body).match(/task-[0-9a-f]+/)?.[0] + assert.ok(taskId, body) + + const lane = async () => (await h.call('flybot/workflow/state')).value.workflows.find(item => item.id === 'delivery') + const deadline = Date.now() + 5000 + while (Date.now() < deadline && (await lane()).activeCount === 0) await new Promise(r => setTimeout(r, 20)) + const started = await lane() + assert.equal(started.activeCount, 1, 'the card a Bot recorded starts the run') + assert.equal(started.run.task.id, taskId) + + await track.execute({ tasks: [{ id: taskId, title: 'workflow:delivery' }] }, { agent: { id: 'b' } }) + await new Promise(r => setTimeout(r, 30)) + const again = await lane() + assert.equal(again.activeCount, 1, 'a second write of the same card starts nothing') + assert.equal(again.run.task.id, taskId) +})