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/workflow-node-bot-baseline-tools.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Every Workflow node Bot now gets the same full permissions a normal sidebar Bot gets, regardless of its `node.tools` configuration. The `tools` field is always omitted from Bot creation, letting every node Bot inherit all registered tools (file/shell, GitHub, Composio, team tools limited by `EPHEMERAL_DENIED_TOOLS`), and all node Bots get `danger-full-access` permission. `node.tools` is stored as documentation only. Previously, nodes were created with `tools: []`, stripping all connectors and causing 6-7 minute delays when prompts expected GitHub or bash access.
4 changes: 2 additions & 2 deletions docs/subsystems/workflow.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
subsystem: workflow
owned_paths:
Expand Down Expand Up @@ -29,7 +29,7 @@
| `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that ingests a step result and denies delivery to persistent Bot chats. |
| `plugins/workflow/src/graph.js` | Nodes, edges, positions, and named inputs and outputs. A node starts when its forward predecessors are settled and a taken edge reaches it. Same-rank nodes run together. A forward cycle is refused. A loop edge is the only cycle. A `when` edge chooses a branch; the other is skipped. A required input with no upstream provider is refused before a run starts. |
| `plugins/workflow/src/service.js` | The store, lanes (active runs, FIFO queue, terminal history), `maxConcurrent`, `rename`, `delete`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. `rename` updates a workflow's name and optional description; `delete` removes the workflow, releases ephemeral Bots, and cleans logs/runs/routes. Factory template ids (e.g. `delivery`) are normal saved rows and may be deleted. A spawn hook makes one run Bot per node. Disposing the plugin ends every live run still marked `running` (nodes in flight are failed, the run is archived) and then releases every run Bot and drops those ids from the saved record only once the release has finished, handing the release back as a promise the host awaits, so a shutdown cannot persist a run as `running` with no ephemeral map and cannot leave hidden Bots that no state names (#1387, #1414). A released run Bot also gives its runner name back to the allocator instead of keeping it for the lifetime of the process. A dispatch that does not land is not marked handed. |
| `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. `permissionFor` picks `danger-full-access` when the node names a shell, otherwise `workspace-write`. |
| `plugins/workflow/src/ephemeral.js` | Builds each node's run Bot from its prompt, role, model and tools, and releases the ids the host returns. The `tools` field is **always omitted** from `fb.bot.create` so every node Bot inherits all registered tools (GitHub, Composio, file/shell tools, and team tools limited by `EPHEMERAL_DENIED_TOOLS` at the guard layer). `node.tools` is stored as documentation only in `fields.tools` but does not restrict the grant. All node Bots get `danger-full-access` permission. |
| `plugins/workflow/src/match.js` | Which open card matches a Workflow or a local Bot. |
| `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report, each node with the inputs and outputs that edge carries. |
| `plugins/workflow/src/narrate.js` | Progress lines for a step. A live run quotes the Bot reply when present; otherwise delivery-template nodes may use canned copy. A join line is only for a rank of more than one node. |
Expand All @@ -49,7 +49,7 @@
3. **A live run advances only when its ephemeral Bot replies, following the graph's edges in order.** That reply advances the run and may appear on the workflow stage log; it is not delivered into a persistent sidebar Bot conversation (for example Chief). The `workflow-step` send middleware denies those replies after `stepResult` runs. Nodes start only when their forward predecessors finish and edges reach them. There is no scripted or seeded run path: `workflow/state` exposes only `live === true` runs. A run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a live run (`assign`, `run_workflow`, `ingest`) spawns one ephemeral Bot per node from that node's prompt, role, model and tools, sends it the step from the Main Bot, and removes every instance when the run finishes, fails for good, or is reset. A `failOnce` retry keeps them. They are hidden and left out of the sidebar. Loading the plugin does not spawn Bots for the default workflows. Plugin unload (`dispose`) and the load-time `reap` both end a live run still marked `running`: in-flight nodes are failed, the run is archived as `failed`, and any ephemeral Bot ids are released. A run already written as `running` with no ephemeral map (an older unload) is healed the same way on the next load (#1414). Loading is also the one moment a run the host left behind is noticed when its ephemeral map is still present: `reap` releases those instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). A dispatch that does not land is not marked handed. The chat names the code, and the next hand-off tries that node again. It is not sent twice in the same hand-off. A reply that says `could not answer your message` or `model call failed` fails that node and does not start its successor. A 429 or rate-limit mention in any other reply does not.
4. Each workflow keeps a FIFO queue and up to `maxConcurrent` live runs (default 2, an integer from 1 to 8 on the record or via `workflow/concurrency`). The host runs at most 6 live runs at once. A task that does not fit is enqueued. When a run finishes, every workflow with a free slot is drained, oldest enqueue first, until the host cap. A new assign on a workflow that already has a queue waits behind it. A task does not replace a running run or release that run's Bots. Migrating an old store keeps only runs that were still `running` with ephemeral Bot ids; other legacy scripted rows are dropped from active state (finished tasks may remain in lane history). History records a finished or failed live run. Lowering the cap does not stop a run that has already started. Assigning `{ botId }` claims the card for that local Bot and does not enqueue. A remote Bot is refused. `activeCount` is `runningOf(lane)` — live runs with `live === true` and `status === 'running'`. The stage aggregate chip and the Runs tab (运行记录) must agree: every such run appears as its own running row and progress card, and the chip count does not exceed that list.
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 <name>: <value>`. The Bot reports `OUTPUT <name>: <value>`; 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. `tools` on the node is what that Bot may use: `github` grants `mcp__github__*` (pull requests, reviews, comments); `bash` or `shell` sets the permission to `danger-full-access` and allows a full-access shell; any other step gets `workspace-write`. A hidden run Bot cannot show an approval card, so a granted tool is allowed and a connector it did not name is refused. The host stores this on the run Bot as `stepTools` and `permission`.
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. **Every node Bot gets the same full permissions a normal sidebar Bot gets:** the `tools` field is always omitted from `fb.bot.create`, letting the Bot inherit all registered tools — file/shell tools, GitHub (`mcp__github__*`: pull requests, reviews, comments), Composio, and team tools — limited only by `EPHEMERAL_DENIED_TOOLS` at the guard/approval layer (#1466, 29 team-admin tools). `node.tools` is stored as documentation in `fields.tools` but does not restrict the grant. All node Bots get `danger-full-access` permission. A hidden run Bot cannot show an approval card, so a granted tool is allowed and a connector it did not name is refused. 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. 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. Settings → Workflow graphs can add a link, a condition or a loop.
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 contained in the title, note or criteria. 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.
Expand Down
15 changes: 7 additions & 8 deletions plugins/workflow/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ export function apply(ctx, config = {}) {
const liveNames = new Map()
const lifecycle = typeof fb.bot?.create === 'function' ? {
async spawn(spec) {
const tools = Array.isArray(spec.tools) ? spec.tools : []
const notes = []
if (tools.length > 0) notes.push(`You can use these tools: ${tools.join(', ')}.`)
if (tools.includes('github')) notes.push('GitHub (mcp__github__*) is granted for this step: issues, pull requests, reviews and comments.')
const prompt = [spec.prompt, ...notes].filter(Boolean).join('\n\n')
// Every Workflow node Bot gets the same full tool/connector grant a normal sidebar Bot
// gets (GitHub, Composio, file/shell, team tools), limited only by EPHEMERAL_DENIED_TOOLS
// at the guard layer. spec.tools is documentation only; the tools field is always omitted
// from fb.bot.create so the Bot inherits all available tools.
const prompt = spec.prompt
let last
for (let n = 0; n < 4; n += 1) {
const taken = new Set([...(fb.bot.list?.() ?? []).map(bot => bot.name), ...liveNames.keys()])
Expand All @@ -49,9 +49,8 @@ export function apply(ctx, config = {}) {
...(spec.model ? { model: spec.model } : {}),
hidden: true,
ephemeral: true,
tools,
permission: permissionFor(tools),
fields: { nodeId: spec.nodeId, tools },
permission: permissionFor(),
fields: { nodeId: spec.nodeId, tools: spec.tools ?? null },
})
liveNames.set(name, id)
return id
Expand Down
17 changes: 9 additions & 8 deletions plugins/workflow/src/ephemeral.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@
// deletes it when the run finishes, fails for good, or is cancelled.
import { cleanLine, cleanPrompt, cleanTools } from './graph.js'

const SHELL = new Set(['bash', 'shell', 'danger-full-access'])

// A step that names a shell gets full access. Anything else can still write
// the workspace. A node with no tools is not left on a read-only default.
export function permissionFor(tools) {
const names = cleanTools(tools)
if (names.some(name => SHELL.has(name))) return 'danger-full-access'
return 'workspace-write'
// Every Workflow node Bot gets the full tool/connector grant a normal sidebar Bot gets
// (GitHub, Composio, file/shell tools, team tools), limited only by EPHEMERAL_DENIED_TOOLS
// at the guard/approval layer (#1466). The node.tools field does not restrict this grant;
// it is treated as documentation only. All node Bots get 'danger-full-access' to match
// normal Bots with shell access.
export function permissionFor() {
return 'danger-full-access'
}

export function nodeConfig(node, roster = [], workflowModel = '') {
Expand All @@ -20,6 +19,8 @@ export function nodeConfig(node, roster = [], workflowModel = '') {
const prompt = cleanPrompt(node?.prompt)
|| cleanPrompt(seated?.systemPrompt || seated?.instructions || '')
|| cleanPrompt(node?.instructions)
// node.tools is kept as documentation only; spawn always omits the tools field so every
// node Bot inherits the full grant a normal Bot gets, limited by EPHEMERAL_DENIED_TOOLS.
return {
nodeId: node.id,
name: node.name,
Expand Down
74 changes: 72 additions & 2 deletions plugins/workflow/test/ephemeral.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,73 @@ test('a spawn that fails halfway removes the instances it already made and does
assert.equal(svc.state().workflows.find(item => item.id === made.id).run, null)
})

test('every node Bot gets full tool grant; node.tools is documentation only', async () => {
const created = []
const fb = {
bot: {
list: () => roster,
async create(spec) {
const id = `bot-${created.length + 1}`
created.push(spec)
return id
},
async remove() { return true },
async send() { return { action: 'allow' } },
},
readOnly: () => false,
api: { handle() { return () => {} } },
tools: { register() { return () => {} } },
warn() {},
}
const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false })

// Node with no tools: tools field omitted, gets full grant, danger-full-access
await started.service.define({
name: 'NoTools',
nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.' }],
edges: [],
})
await started.service.runNamed({ workflow: 'NoTools', brief: 'Test no tools' })
assert.equal('tools' in created[0], false, 'tools field always omitted from fb.bot.create')
assert.equal(created[0].permission, 'danger-full-access', 'all node Bots get full access')
assert.equal(created[0].systemPrompt, 'Do the work.', 'prompt has no tool-specific notes')

// Node with explicit empty tools: same behavior (tools omitted, full grant)
created.length = 0
await started.service.define({
name: 'EmptyTools',
nodes: [{ id: 'worker', name: 'Worker', prompt: 'Do the work.', tools: [] }],
edges: [],
})
await started.service.runNamed({ workflow: 'EmptyTools', brief: 'Test empty' })
assert.equal('tools' in created[0], false, 'tools field omitted even when node.tools is []')
assert.equal(created[0].permission, 'danger-full-access')

// Node with explicit tools: STILL gets full grant (tools is documentation only)
created.length = 0
await started.service.define({
name: 'CustomTools',
nodes: [{ id: 'reader', name: 'Reader', prompt: 'Just read.', tools: ['read'] }],
edges: [],
})
await started.service.runNamed({ workflow: 'CustomTools', brief: 'Test custom' })
assert.equal('tools' in created[0], false, 'tools field omitted regardless of node.tools value')
assert.equal(created[0].permission, 'danger-full-access', 'explicit tools do not restrict grant')
assert.deepEqual(created[0].fields.tools, ['read'], 'fields.tools stores node.tools as documentation')
assert.equal(created[0].systemPrompt, 'Just read.', 'no tool notes added')

// Node with bash in tools: same full grant (node.tools does not affect permission)
created.length = 0
await started.service.define({
name: 'ShellTools',
nodes: [{ id: 'worker', name: 'Worker', prompt: 'Run commands.', tools: ['bash', 'read'] }],
edges: [],
})
await started.service.runNamed({ workflow: 'ShellTools', brief: 'Test shell' })
assert.equal('tools' in created[0], false, 'tools field omitted')
assert.equal(created[0].permission, 'danger-full-access')
})

test('apply wires spawn to a hidden ephemeral create and removes those Bots when the run ends', async () => {
const created = []
const removed = []
Expand Down Expand Up @@ -217,9 +284,12 @@ test('apply wires spawn to a hidden ephemeral create and removes those Bots when
assert.equal(created.length, 1)
assert.equal(created[0].ephemeral, true)
assert.equal(created[0].hidden, true)
assert.equal(created[0].permission, 'workspace-write')
assert.deepEqual(created[0].tools, [])
// Every node Bot omits the tools field and gets danger-full-access, inheriting all available
// tools (GitHub, Composio, file/shell, team tools limited by EPHEMERAL_DENIED_TOOLS).
assert.equal(created[0].permission, 'danger-full-access')
assert.equal('tools' in created[0], false, 'tools field always omitted from fb.bot.create')
assert.match(created[0].systemPrompt, /Write the plan\./)
assert.doesNotMatch(created[0].systemPrompt, /You can use these tools:/, 'no tool-specific notes')
assert.equal(created[0].name.startsWith('Planner '), true)
assert.notEqual(created[0].name, 'Planner')
assert.equal(sent.length, 1)
Expand Down
Loading