diff --git a/changelog.d/1402.changed.md b/changelog.d/1402.changed.md new file mode 100644 index 00000000..4bbd4e4b --- /dev/null +++ b/changelog.d/1402.changed.md @@ -0,0 +1 @@ +A Workflow run now waits for each node's Bot to reply before the next node starts, and that reply is the chat line. A second task queues instead of replacing the run that is already going. Replay demo stays a separate script and does not move a live run; after a live run finishes, the stage keeps that run until Replay steps the script, and from then on the graph follows the demo rather than the finished run. Each node can name its inputs and outputs, and a required input with no upstream provider refuses the start. A dispatch that does not land stays unhanded, is named in the chat, and is sent again on the next hand-off. An older saved demo still replays, and a task waiting on the host cap starts when any run finishes, ahead of a newer assign. A 429 mentioned in a successful reply does not fail the step. A loop edge is the only cycle: it stops at max (1–8, default 3) or when its until output matches, and a when edge chooses a branch while the other is skipped. The next pass of a loop can take the other branch. Each node is an ephemeral Bot for that run, removed when the run ends, and its tools grant GitHub or a shell instead of a permissionless hidden Bot. ([#1402](https://github.com/ThinkFlowLab/FlyBot/issues/1402)) diff --git a/core/src/host/approvals.js b/core/src/host/approvals.js index bcb84faa..76f43ee5 100644 --- a/core/src/host/approvals.js +++ b/core/src/host/approvals.js @@ -10,6 +10,25 @@ const OUTCOMES = { allow: 'allowed-once', reject: 'rejected', 'always-bot': 'all // DSH has no standing grant (its outcomes are one-shot, #540), so the owner's "Always // allow" lives here: on the Bot or on the group, keyed by the kind of permission asked. // A sandbox escalation names its mode in DSH's reason; any other ask is keyed by tool. +// A run Bot has no chat the owner can see, so an approval card would stall the +// step. Connector tools are allowed only when the node's stepTools name them +// (`github` covers `mcp__github__*`). A shell escalation to full access needs +// `bash`, `shell` or `danger-full-access`. Other calls follow the Bot's preset +// and are answered here, because nobody can tap the card. +export function ephemeralToolVerdict(bot, toolName, reason) { + if (!bot?.ephemeral) return undefined + const tools = new Set((bot.stepTools ?? []).map(name => String(name).toLowerCase())) + const grant = (name) => tools.has('*') || tools.has(String(name).toLowerCase()) + const tool = String(toolName ?? '') + if (tool.startsWith('mcp__')) { + const id = tool.split('__')[1] ?? '' + return grant(tool) || (id !== '' && grant(id)) ? 'allowed-once' : 'rejected' + } + const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1] + if (mode === 'danger-full-access') return grant('bash') || grant('shell') || grant('danger-full-access') ? 'allowed-once' : 'rejected' + return 'allowed-once' +} + export function approvalKind(tool, reason) { const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1] return mode ? `${tool}:escalate:${mode}` : String(tool) @@ -30,6 +49,8 @@ export function install(rt) { const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId) const botId = owner?.botId ?? (sessionId === undefined ? undefined : rt.chatOwners.get(sessionId)) if (botId === undefined) return next() + const step = ephemeralToolVerdict(rt.botOf(botId), String(request.toolName ?? ''), request.reason) + if (step !== undefined) return step if (standingCovers(sessionId, botId, owner?.roomId, approvalKind(String(request.toolName ?? ''), request.reason))) return 'allowed-once' const id = randomUUID() const entry = { diff --git a/core/src/host/connector-access.js b/core/src/host/connector-access.js index bdefffff..a046dab8 100644 --- a/core/src/host/connector-access.js +++ b/core/src/host/connector-access.js @@ -8,9 +8,11 @@ // parent's Bot. // - The owner can turn a connector off for one Bot: `bot.connectors = { composio: false }`. Absent // means on, so every Bot, old or new, has the connected connector. -// - A friend's Bot (`remote`) never gets them, and neither does a Session whose Bot or parent chain -// cannot be resolved (fail closed). Sessions that belong to no Bot (the DSH Agent, plain DSH -// sessions) are not touched: they are the owner's own, and no friend can reach them. +// - A friend's Bot (`remote`) never gets them. A run Bot (`ephemeral`) gets a connector +// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose +// Bot or parent chain cannot be resolved fails closed. Sessions that belong to no Bot +// (the DSH Agent, plain DSH sessions) are not touched: they are the owner's own, and no +// friend can reach them. // - A group room turn carries neither the composio tools nor the four schedule tools (#1237): 8 // tools, ~20.4k characters (≈5.2k tokens at the measured 3.92 chars/token) re-sent on every call // of every room turn, and a room round uses neither — an outside app is reached from the Bot's own @@ -42,7 +44,14 @@ export function install(rt) { // Only an explicit false turns it off; a Bot with no record has it (a team saved by #346, where an // absent record meant off, therefore starts on). const turnedOff = (bot, id) => bot?.connectors?.[id] === false - const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) + // A run Bot is not a team member. It gets a connector only when the step + // named it (`github` → mcp__github__*), so a node that did not ask cannot + // open pull requests. + const stepGrants = (bot, id) => { + const tools = bot?.stepTools ?? [] + return tools.includes(id) || tools.includes('*') || tools.some(name => name === id || String(name).startsWith(`mcp__${id}__`)) + } + const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) && (bot.ephemeral === undefined || stepGrants(bot, id)) // The Bot an Agent works for: its own chat, a group Session and an earlier part resolve through // selfOf; a sub-agent Session is followed up to its parent. The answer has three shapes, and only @@ -109,6 +118,7 @@ export function install(rt) { const { bot } = who if (allowed(bot, id)) return undefined if (bot.remote !== undefined) return `The ${id} connector is not available to a friend's Bot.` + if (bot.ephemeral !== undefined) return `The ${id} connector is not granted to this run. The step's tools list is what allows it.` return `The ${id} connector is turned off for ${bot.name}. Its owner can turn it on in this Bot's settings.` }) diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index b95297e8..06737ae8 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -154,9 +154,24 @@ export function install(rt) { }) const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) - async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model } = {}) { + const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/ + function cleanStepTools(value) { + if (!Array.isArray(value)) return [] + const tools = [] + for (const item of value) { + const name = String(item ?? '').trim().toLowerCase() + if (!TOOL_NAME.test(name) || tools.includes(name)) continue + tools.push(name) + if (tools.length === 16) break + } + return tools + } + + async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model, tools, permission } = {}) { checkName('plugin', plugin) await rt.load() + const stepTools = ephemeral === true ? cleanStepTools(tools) : [] + const preset = ephemeral === true ? String(permission ?? '').trim() : '' const bot = await rt.createBot({ name, role, instructions, createdBy, ...(systemPrompt ? { systemPrompt } : {}), @@ -166,6 +181,17 @@ export function install(rt) { // The marker names this plugin. A caller cannot stamp another plugin's name. ...(ephemeral === true ? { ephemeral: { plugin } } : {}), }) + const record = rt.botOf(bot.id) + if (record && stepTools.length > 0) record.stepTools = stepTools + if (record && preset) { + try { + await rt.setPermission(record, preset) + } catch (error) { + await rt.deleteBot(bot.id).catch(error => warn('flybot: could not remove the Bot after a permission failure: %s', error)) + throw error + } + } + if (record && (stepTools.length > 0 || preset)) await rt.save() for (const [key, value] of Object.entries(fields ?? {})) fire('change', { botId: bot.id, plugin, key, value: clone(value), prev: undefined }) return bot.id } diff --git a/core/test/connector-access.test.js b/core/test/connector-access.test.js index 57922278..939e533b 100644 --- a/core/test/connector-access.test.js +++ b/core/test/connector-access.test.js @@ -544,3 +544,31 @@ test('a Bot post carries whose request it is into the room\'s member turns and i assert.deepEqual(await run(TYPED).then(({ post, member, report }) => ({ post, member, report })), { post: 'owner', member: 'owner', report: 'owner' }) assert.deepEqual(await run(FRIEND).then(({ post, member, report }) => ({ post, member, report })), { post: 'other', member: 'other', report: 'other' }, 'a friend-driven poster makes the whole round someone else\'s') }) + +test('a run Bot gets a connector only when its step tools name it', async (t) => { + const state = team() + const run = (id, name, stepTools) => { + state.bots[id] = { + id, sessionId: id, name, role: 'Run', instructions: '', createdAt: 4, + hidden: true, ephemeral: { plugin: 'flybot-workflow' }, + ...(stepTools ? { stepTools } : {}), + } + } + run('coder', 'Coder', ['github']) + run('shell', 'Shell', ['bash']) + run('star', 'Star', ['*']) + run('bare', 'Bare') + const { agentFor, hiddenFrom, guard, stop } = await setup({ state }) + t.after(stop) + for (const id of ['coder', 'shell', 'star', 'bare']) await agentFor(id) + assert.deepEqual(sorted(hiddenFrom('coder')), sorted(COMPOSIO), 'github stays listed') + assert.equal(guard(GITHUB[0], 'coder'), undefined) + assert.match(guard(COMPOSIO[0], 'coder'), /not granted to this run/) + assert.deepEqual(sorted(hiddenFrom('shell')), sorted([...COMPOSIO, ...GITHUB])) + assert.match(guard(GITHUB[0], 'shell'), /not granted to this run/) + assert.deepEqual([...hiddenFrom('star')], []) + assert.equal(guard(GITHUB[0], 'star'), undefined) + assert.equal(guard(COMPOSIO[0], 'star'), undefined) + assert.deepEqual(sorted(hiddenFrom('bare')), sorted([...COMPOSIO, ...GITHUB]), 'no stepTools fails closed') + assert.match(guard(GITHUB[0], 'bare'), /not granted to this run/) +}) diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 6cdf95e6..557c555d 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -27,10 +27,11 @@ async function start(config = {}) { const home = mkdtempSync(join(tmpdir(), 'ds-bot-flybot-')) const state = config.empty === true ? emptyTeam() : team() Object.assign(state.rooms, config.rooms ?? {}) - const { rooms, empty, llm, defaultModel, ...applyConfig } = config + const { rooms, empty, llm, defaultModel, services: extraServices, ...applyConfig } = config writeFileSync(join(home, 'state.json'), JSON.stringify(state)) // A test may serve a model: the profile default rides the DSH service, as in test/memory.test.js. - const services = defaultModel === undefined ? {} : { agentDefaultModel: { currentSelection: () => ({ provider: 'deepseek', model: defaultModel }) } } + const services = { ...(extraServices ?? {}) } + if (defaultModel !== undefined) services.agentDefaultModel = { currentSelection: () => ({ provider: 'deepseek', model: defaultModel }) } const ctx = fakeContext({ services }) const agents = new Map() const agentOf = (id) => { @@ -203,6 +204,57 @@ test('an ephemeral Bot stays off the team and only its plugin can remove it', as assert.equal(await wf.bot.remove(id), false) }) +test('a run Bot is granted the step tools and is not left waiting on a hidden approval', async (t) => { + const writes = [] + const PRESETS = { 'workspace-write': {}, 'danger-full-access': {} } + const { fb, ctx, home, stop } = await start({ + services: { + permissionPresets: { + catalog: () => ({ options: Object.keys(PRESETS).map(value => ({ value, name: value })), defaultOptions: [], defaultPreset: 'workspace-write' }), + resolve: (name) => { if (!(name in PRESETS)) throw new Error(`permission: unknown preset "${name}"`); return PRESETS[name] }, + set: (session, name) => { writes.push(`${session?.id ?? session}=${name}`) }, + }, + }, + }) + t.after(stop) + let sessions = 0 + ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` }) + const wf = fb.scope('flybot-workflow') + const id = await wf.bot.create({ + name: 'Coder ab', + role: 'Code', + instructions: 'Open the pull request.', + ephemeral: true, + tools: ['GitHub', 'read'], + permission: 'workspace-write', + }) + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const bot = saved.bots[id] + assert.equal(bot.permission, 'workspace-write') + assert.deepEqual(bot.stepTools, ['github', 'read']) + assert.deepEqual(writes, [`${id}=workspace-write`]) + const ask = (toolName, reason = '') => ctx.listeners.get('approval/request')[0]( + { agent: { session: { id } }, toolName, reason }, + () => 'from-dsh', + ) + assert.equal(await ask('mcp__github__create_pull_request'), 'allowed-once') + assert.equal(await ask('mcp__composio__execute'), 'rejected') + assert.equal(await ask('bash', 'escalate sandbox to danger-full-access: gh pr create'), 'rejected') + assert.equal(await ask('read'), 'allowed-once') + const shell = await wf.bot.create({ + name: 'Shell ab', + ephemeral: true, + tools: ['bash'], + permission: 'danger-full-access', + }) + const shellAsk = (toolName, reason = '') => ctx.listeners.get('approval/request')[0]( + { agent: { session: { id: shell } }, toolName, reason }, + () => 'from-dsh', + ) + assert.equal(await shellAsk('bash', 'escalate sandbox to danger-full-access: gh pr create'), 'allowed-once') + assert.equal(await shellAsk('mcp__github__create_pull_request'), 'rejected') +}) + test('with no middleware, message_bot behaves as before', async (t) => { const { tool, received, stop } = await start() t.after(stop) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 99cbb546..7b13ebb2 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -234,6 +234,17 @@ export interface CreateBotSpec { ephemeral?: boolean /** Model ref (`provider/model` or a model id). Empty leaves the usual default. */ model?: string + /** + * Tools this run Bot may use. Only read when `ephemeral` is true. + * `github` grants `mcp__github__*`. `bash` or `shell` grants a full-access shell. + * A team Bot ignores this. + */ + tools?: string[] + /** + * DSH permission preset for a run Bot (`workspace-write` or `danger-full-access`). + * Only applied when `ephemeral` is true. A team Bot ignores this. + */ + permission?: string } /** What a send middleware sees. Built-in ds-bot sends arrive with via: 'ds-bot'. */ diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a3c2a877..a722fa5a 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -333,7 +333,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`. - Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`. - Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. A run Bot is the exception: `createBot` stores `hidden` and `ephemeral: { plugin }` before the first save, and the wrapper does not fire `bot.created` for it. Covered by `core/test/flybot.test.js`. -- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. Covered by `core/test/flybot.test.js`. +- A run Bot (#1373) is left out of the channel `state` view, so the sidebar, search and Settings → Bots never list it. `list_bots`, `message_bot`, `stop_bot`, `update_bot` and group member lookup skip it too. `findBot` still sees the name, so a later Bot cannot reuse it. It is not a permissionless shell: `flybot.bot.create` may pass `tools` and `permission` for an ephemeral Bot only. `stepTools` is what connector-access and the approval listener read. `github` grants `mcp__github__*`; a connector the step did not name is hidden and a call is refused (`not granted to this run`). A hidden run Bot cannot show an approval card, so a granted call is `allowed-once` and an ungranted connector or an ungranted full-access shell escalation is `rejected`. Covered by `core/test/flybot.test.js` and `core/test/connector-access.test.js`. - Group Session models (#289, #1127): `applyModel` does not trust `appliedModel` while a Bot is `modelStale`. A failed model selection in a group Session (from `applyModel` or from `room-participant.js` `open()`) marks the Bot stale; the next apply (model-list change, restart, or the Bot's next turn in a known group Session) selects every Session again and clears the mark once all took it. A settled record (`appliedModel` matches and `modelStale` is unset) still re-selects a group Session whose last request used another provider or model, on that apply and before the Session's next turn; the Bot's own chat is left as recorded. A Session whose route cannot be read is left alone, so a settled Bot is not selected again on every refresh. A fresh part takes the Bot's model, not the previous request's route. Covered by `core/test/models.test.js`, `core/test/room-participant.test.js` and `core/test/fresh-part-model.test.js`. ### Face-to-face room lead (#476) diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index a12a21e8..ba6e0f5f 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -65,7 +65,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Run Bots (#1373) -`flybot.bot.create({ ephemeral: true })` makes a run Bot for the calling plugin. The host stores `hidden: true` and `ephemeral: { plugin }` before the first save. The marker is set only there: `fields.set` cannot add it, and a spec cannot name another plugin. `bot.list`, `bot.find`, `flybot/bots` and `bot.created` omit it. `bot.get` still returns it (`hidden`, `systemPrompt`). `bot.remove(id)` deletes it only when `ephemeral.plugin` is the caller; any other Bot throws, and a missing id returns `false`. `CreateBotSpec` also takes `hidden` and `model`. +`flybot.bot.create({ ephemeral: true })` makes a run Bot for the calling plugin. The host stores `hidden: true` and `ephemeral: { plugin }` before the first save. The marker is set only there: `fields.set` cannot add it, and a spec cannot name another plugin. `bot.list`, `bot.find`, `flybot/bots` and `bot.created` omit it. `bot.get` still returns it (`hidden`, `systemPrompt`). `bot.remove(id)` deletes it only when `ephemeral.plugin` is the caller; any other Bot throws, and a missing id returns `false`. `CreateBotSpec` also takes `hidden`, `model`, and — for a run Bot only — `tools` and `permission`. `tools` is stored as `stepTools` (`github` grants `mcp__github__*`). `permission` is a DSH preset (`workspace-write` or `danger-full-access`) applied to that Bot's Session; a failed apply deletes the Bot. A team Bot create ignores both. A run Bot has no visible chat, so an approval card would stall it: a granted tool is allowed, a connector it did not name is refused, and a shell escalation to full access is allowed only when `bash`, `shell` or `danger-full-access` is in `stepTools`. Covered by `core/test/flybot.test.js`. ### Chat-row pending badge diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index c2411044..b30d15e0 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -26,19 +26,19 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, | File | Role | | --- | --- | -| `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, and the board listener. | -| `plugins/workflow/src/graph.js` | Nodes, edges and positions. A node starts when every predecessor is done. Same-rank nodes run together. A cycle is refused. | -| `plugins/workflow/src/service.js` | The store, template copies, `saveGraph`, `define`, `runNamed`, `ingest`. A spawn hook makes one run Bot per node. | -| `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. | +| `plugins/workflow/index.js` | Plugin entry (`flybot-workflow`). Endpoints, `run_workflow`, `define_workflow`, the board listener, and the reply middleware that reports a step result. | +| `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`, `saveGraph`, `define`, `runNamed`, `ingest`, `stepResult`. A spawn hook makes one run Bot per node. 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/match.js` | Which open card matches a Workflow or a local Bot. | -| `plugins/workflow/src/template.js` | 发布交付流水线 (a parallel branch) and the weekly report. | -| `plugins/workflow/src/narrate.js` | Progress lines for a step. A join line is only for a rank of more than one node. | -| `plugins/workflow/client.js` | Sidebar section, chat stage, assign dialog, flowchart editor, and Settings → Workflow graphs. The stage and the dialogs portal onto `document.body`; a missing container stays in the section tree. | +| `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. The canned delivery copy is the demo player only. A join line is only for a rank of more than one node. | +| `plugins/workflow/client.js` | Sidebar section, chat stage, assign dialog, flowchart editor, and Settings → Workflow graphs. Replay demo is disabled while a live run exists. The stage shows the queue and refreshes state while one is active. The stage and the dialogs portal onto `document.body`; a missing container stays in the section tree. | ## Entry points - Endpoint `workflow/state` and `workflow/templates` are reads. -- `workflow/create`, `workflow/assign`, `workflow/graph`, `workflow/advance`, `workflow/demo-step`, `workflow/reset`, `workflow/say`, `workflow/ingest` write. +- `workflow/create`, `workflow/assign`, `workflow/concurrency`, `workflow/graph`, `workflow/advance`, `workflow/demo-step`, `workflow/reset`, `workflow/say`, `workflow/ingest` write. `workflow/concurrency` sets that workflow's `maxConcurrent` (an integer from 1 to 8). - 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. @@ -46,15 +46,17 @@ Everything in `plugins/workflow/`: the graph rules, the run store, the matcher, 1. Friends' Bots are not nodes. A `remote` Bot is refused by create, define and the matcher. 2. The canvas and the run share one graph: nodes, edges and positions. A drag commits `workflow/graph`. Status highlighting reads that same graph. Replacing edges does not recompute who is already running. -3. Progress lines stay scripted, and a run does not create a schedule. `scheduleLabel` is display text. When the host can create Bots, starting a run (`assign`, `run_workflow`, `ingest`, or a demo that starts or restarts) 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 seeded fixture run. - - Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its instances and records the run as `failed`. Nothing resumes it. A run that already ended keeps its own status (#1388). -4. Auto-assign considers an `open` card only. 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. -5. The browser draws text only. Names and messages are React text children. The unit renderer asserts a hostile name is not markup. -6. `enabled: false` registers no endpoint, tool or listener and writes nothing. -7. 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. -8. A node's prompt and the graph are one record. Settings → Workflow graphs, the flowchart's prompt fields, and `define_workflow` all write `workflow/graph`. A run reads that record; it does not define it. +3. A live run advances only when its ephemeral Bot replies, and that reply is the chat line. `workflow/demo-step` replays the seeded script and refuses while that workflow has a live run; the stage does not start the replay timer while `activeCount` is greater than 0. 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 seeded fixture. Loading is also the one moment a run the host left behind is noticed: a run still marked `running` is abandoned, so loading releases its 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. An old store run is a demo unless it is still `running` and has ephemeral Bot ids. History records a finished or failed run only. 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. +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. `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`. +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. +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. +12. A node's prompt and the graph are one record. Settings → Workflow graphs, the flowchart's prompt fields, and `define_workflow` all write `workflow/graph`. A run reads that record; it does not define it. Replay steps the seeded script. After a live run has finished, the stage keeps that run until Replay steps the script. From that step on, the stage shows the demo, including its `nodeStatus`, ahead of the finished `lane.last`. A running live lane still wins. A migrated finished script is the same: it stays done until Replay, and then the presented nodes follow the demo. ## Tests -`plugins/workflow/test/`: graph advance and join, graph edit, assign and persistence, templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, the client canvas, the settings page, 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 is `core/test/flybot.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, assign-to-bot versus assign-to-workflow, inputs and outputs along edges, a loop that breaks and an if/else branch, templates, `define` / `runNamed`, the matcher, ephemeral spawn and cleanup, the reply middleware, the client canvas, the settings page (including a condition and a loop), 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`. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 4f5ca91d..9f476a00 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2594,7 +2594,7 @@ diff --git a/src/host/agents.js b/src/host/agents.js diff --git a/src/host/approvals.js b/src/host/approvals.js --- a/src/host/approvals.js +++ b/src/host/approvals.js -@@ -3,8 +3,21 @@ +@@ -3,8 +3,40 @@ // the group chat. A Bot's own chat keeps DSH's card; its requests are only noted, so // the team view can say who is waiting. import { randomUUID } from 'node:crypto' @@ -2606,6 +2606,25 @@ diff --git a/src/host/approvals.js b/src/host/approvals.js +// DSH has no standing grant (its outcomes are one-shot, #540), so the owner's "Always +// allow" lives here: on the Bot or on the group, keyed by the kind of permission asked. +// A sandbox escalation names its mode in DSH's reason; any other ask is keyed by tool. ++// A run Bot has no chat the owner can see, so an approval card would stall the ++// step. Connector tools are allowed only when the node's stepTools name them ++// (`github` covers `mcp__github__*`). A shell escalation to full access needs ++// `bash`, `shell` or `danger-full-access`. Other calls follow the Bot's preset ++// and are answered here, because nobody can tap the card. ++export function ephemeralToolVerdict(bot, toolName, reason) { ++ if (!bot?.ephemeral) return undefined ++ const tools = new Set((bot.stepTools ?? []).map(name => String(name).toLowerCase())) ++ const grant = (name) => tools.has('*') || tools.has(String(name).toLowerCase()) ++ const tool = String(toolName ?? '') ++ if (tool.startsWith('mcp__')) { ++ const id = tool.split('__')[1] ?? '' ++ return grant(tool) || (id !== '' && grant(id)) ? 'allowed-once' : 'rejected' ++ } ++ const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1] ++ if (mode === 'danger-full-access') return grant('bash') || grant('shell') || grant('danger-full-access') ? 'allowed-once' : 'rejected' ++ return 'allowed-once' ++} ++ +export function approvalKind(tool, reason) { + const mode = /^escalate sandbox to ([\w-]+):/.exec(String(reason ?? ''))?.[1] + return mode ? `${tool}:escalate:${mode}` : String(tool) @@ -2617,10 +2636,12 @@ diff --git a/src/host/approvals.js b/src/host/approvals.js export function install(rt) { const { ctx } = rt -@@ -17,6 +17,7 @@ export function install(rt) { +@@ -17,6 +17,9 @@ export function install(rt) { const owner = sessionId === undefined ? undefined : rt.groupOwners.get(sessionId) const botId = owner?.botId ?? (sessionId === undefined ? undefined : rt.chatOwners.get(sessionId)) if (botId === undefined) return next() ++ const step = ephemeralToolVerdict(rt.botOf(botId), String(request.toolName ?? ''), request.reason) ++ if (step !== undefined) return step + if (standingCovers(sessionId, botId, owner?.roomId, approvalKind(String(request.toolName ?? ''), request.reason))) return 'allowed-once' const id = randomUUID() const entry = { diff --git a/patches/ds-bot/files/src/host/connector-access.js b/patches/ds-bot/files/src/host/connector-access.js index bdefffff..a046dab8 100644 --- a/patches/ds-bot/files/src/host/connector-access.js +++ b/patches/ds-bot/files/src/host/connector-access.js @@ -8,9 +8,11 @@ // parent's Bot. // - The owner can turn a connector off for one Bot: `bot.connectors = { composio: false }`. Absent // means on, so every Bot, old or new, has the connected connector. -// - A friend's Bot (`remote`) never gets them, and neither does a Session whose Bot or parent chain -// cannot be resolved (fail closed). Sessions that belong to no Bot (the DSH Agent, plain DSH -// sessions) are not touched: they are the owner's own, and no friend can reach them. +// - A friend's Bot (`remote`) never gets them. A run Bot (`ephemeral`) gets a connector +// only when its `stepTools` name it (`github` covers `mcp__github__*`). A Session whose +// Bot or parent chain cannot be resolved fails closed. Sessions that belong to no Bot +// (the DSH Agent, plain DSH sessions) are not touched: they are the owner's own, and no +// friend can reach them. // - A group room turn carries neither the composio tools nor the four schedule tools (#1237): 8 // tools, ~20.4k characters (≈5.2k tokens at the measured 3.92 chars/token) re-sent on every call // of every room turn, and a room round uses neither — an outside app is reached from the Bot's own @@ -42,7 +44,14 @@ export function install(rt) { // Only an explicit false turns it off; a Bot with no record has it (a team saved by #346, where an // absent record meant off, therefore starts on). const turnedOff = (bot, id) => bot?.connectors?.[id] === false - const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) + // A run Bot is not a team member. It gets a connector only when the step + // named it (`github` → mcp__github__*), so a node that did not ask cannot + // open pull requests. + const stepGrants = (bot, id) => { + const tools = bot?.stepTools ?? [] + return tools.includes(id) || tools.includes('*') || tools.some(name => name === id || String(name).startsWith(`mcp__${id}__`)) + } + const allowed = (bot, id) => bot.remote === undefined && !turnedOff(bot, id) && (bot.ephemeral === undefined || stepGrants(bot, id)) // The Bot an Agent works for: its own chat, a group Session and an earlier part resolve through // selfOf; a sub-agent Session is followed up to its parent. The answer has three shapes, and only @@ -109,6 +118,7 @@ export function install(rt) { const { bot } = who if (allowed(bot, id)) return undefined if (bot.remote !== undefined) return `The ${id} connector is not available to a friend's Bot.` + if (bot.ephemeral !== undefined) return `The ${id} connector is not granted to this run. The step's tools list is what allows it.` return `The ${id} connector is turned off for ${bot.name}. Its owner can turn it on in this Bot's settings.` }) diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index b95297e8..06737ae8 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -154,9 +154,24 @@ export function install(rt) { }) const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) - async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model } = {}) { + const TOOL_NAME = /^[a-z][a-z0-9_-]{0,63}$/ + function cleanStepTools(value) { + if (!Array.isArray(value)) return [] + const tools = [] + for (const item of value) { + const name = String(item ?? '').trim().toLowerCase() + if (!TOOL_NAME.test(name) || tools.includes(name)) continue + tools.push(name) + if (tools.length === 16) break + } + return tools + } + + async function create({ name, systemPrompt, fields, role, instructions, createdBy, plugin = SHARED, hidden, ephemeral, model, tools, permission } = {}) { checkName('plugin', plugin) await rt.load() + const stepTools = ephemeral === true ? cleanStepTools(tools) : [] + const preset = ephemeral === true ? String(permission ?? '').trim() : '' const bot = await rt.createBot({ name, role, instructions, createdBy, ...(systemPrompt ? { systemPrompt } : {}), @@ -166,6 +181,17 @@ export function install(rt) { // The marker names this plugin. A caller cannot stamp another plugin's name. ...(ephemeral === true ? { ephemeral: { plugin } } : {}), }) + const record = rt.botOf(bot.id) + if (record && stepTools.length > 0) record.stepTools = stepTools + if (record && preset) { + try { + await rt.setPermission(record, preset) + } catch (error) { + await rt.deleteBot(bot.id).catch(error => warn('flybot: could not remove the Bot after a permission failure: %s', error)) + throw error + } + } + if (record && (stepTools.length > 0 || preset)) await rt.save() for (const [key, value] of Object.entries(fields ?? {})) fire('change', { botId: bot.id, plugin, key, value: clone(value), prev: undefined }) return bot.id } diff --git a/patches/ds-bot/files/test/connector-access.test.js b/patches/ds-bot/files/test/connector-access.test.js index 57922278..939e533b 100644 --- a/patches/ds-bot/files/test/connector-access.test.js +++ b/patches/ds-bot/files/test/connector-access.test.js @@ -544,3 +544,31 @@ test('a Bot post carries whose request it is into the room\'s member turns and i assert.deepEqual(await run(TYPED).then(({ post, member, report }) => ({ post, member, report })), { post: 'owner', member: 'owner', report: 'owner' }) assert.deepEqual(await run(FRIEND).then(({ post, member, report }) => ({ post, member, report })), { post: 'other', member: 'other', report: 'other' }, 'a friend-driven poster makes the whole round someone else\'s') }) + +test('a run Bot gets a connector only when its step tools name it', async (t) => { + const state = team() + const run = (id, name, stepTools) => { + state.bots[id] = { + id, sessionId: id, name, role: 'Run', instructions: '', createdAt: 4, + hidden: true, ephemeral: { plugin: 'flybot-workflow' }, + ...(stepTools ? { stepTools } : {}), + } + } + run('coder', 'Coder', ['github']) + run('shell', 'Shell', ['bash']) + run('star', 'Star', ['*']) + run('bare', 'Bare') + const { agentFor, hiddenFrom, guard, stop } = await setup({ state }) + t.after(stop) + for (const id of ['coder', 'shell', 'star', 'bare']) await agentFor(id) + assert.deepEqual(sorted(hiddenFrom('coder')), sorted(COMPOSIO), 'github stays listed') + assert.equal(guard(GITHUB[0], 'coder'), undefined) + assert.match(guard(COMPOSIO[0], 'coder'), /not granted to this run/) + assert.deepEqual(sorted(hiddenFrom('shell')), sorted([...COMPOSIO, ...GITHUB])) + assert.match(guard(GITHUB[0], 'shell'), /not granted to this run/) + assert.deepEqual([...hiddenFrom('star')], []) + assert.equal(guard(GITHUB[0], 'star'), undefined) + assert.equal(guard(COMPOSIO[0], 'star'), undefined) + assert.deepEqual(sorted(hiddenFrom('bare')), sorted([...COMPOSIO, ...GITHUB]), 'no stepTools fails closed') + assert.match(guard(GITHUB[0], 'bare'), /not granted to this run/) +}) diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 6cdf95e6..557c555d 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -27,10 +27,11 @@ async function start(config = {}) { const home = mkdtempSync(join(tmpdir(), 'ds-bot-flybot-')) const state = config.empty === true ? emptyTeam() : team() Object.assign(state.rooms, config.rooms ?? {}) - const { rooms, empty, llm, defaultModel, ...applyConfig } = config + const { rooms, empty, llm, defaultModel, services: extraServices, ...applyConfig } = config writeFileSync(join(home, 'state.json'), JSON.stringify(state)) // A test may serve a model: the profile default rides the DSH service, as in test/memory.test.js. - const services = defaultModel === undefined ? {} : { agentDefaultModel: { currentSelection: () => ({ provider: 'deepseek', model: defaultModel }) } } + const services = { ...(extraServices ?? {}) } + if (defaultModel !== undefined) services.agentDefaultModel = { currentSelection: () => ({ provider: 'deepseek', model: defaultModel }) } const ctx = fakeContext({ services }) const agents = new Map() const agentOf = (id) => { @@ -203,6 +204,57 @@ test('an ephemeral Bot stays off the team and only its plugin can remove it', as assert.equal(await wf.bot.remove(id), false) }) +test('a run Bot is granted the step tools and is not left waiting on a hidden approval', async (t) => { + const writes = [] + const PRESETS = { 'workspace-write': {}, 'danger-full-access': {} } + const { fb, ctx, home, stop } = await start({ + services: { + permissionPresets: { + catalog: () => ({ options: Object.keys(PRESETS).map(value => ({ value, name: value })), defaultOptions: [], defaultPreset: 'workspace-write' }), + resolve: (name) => { if (!(name in PRESETS)) throw new Error(`permission: unknown preset "${name}"`); return PRESETS[name] }, + set: (session, name) => { writes.push(`${session?.id ?? session}=${name}`) }, + }, + }, + }) + t.after(stop) + let sessions = 0 + ctx.sessionController.create = async () => ({ sessionId: `run${++sessions}` }) + const wf = fb.scope('flybot-workflow') + const id = await wf.bot.create({ + name: 'Coder ab', + role: 'Code', + instructions: 'Open the pull request.', + ephemeral: true, + tools: ['GitHub', 'read'], + permission: 'workspace-write', + }) + const saved = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')) + const bot = saved.bots[id] + assert.equal(bot.permission, 'workspace-write') + assert.deepEqual(bot.stepTools, ['github', 'read']) + assert.deepEqual(writes, [`${id}=workspace-write`]) + const ask = (toolName, reason = '') => ctx.listeners.get('approval/request')[0]( + { agent: { session: { id } }, toolName, reason }, + () => 'from-dsh', + ) + assert.equal(await ask('mcp__github__create_pull_request'), 'allowed-once') + assert.equal(await ask('mcp__composio__execute'), 'rejected') + assert.equal(await ask('bash', 'escalate sandbox to danger-full-access: gh pr create'), 'rejected') + assert.equal(await ask('read'), 'allowed-once') + const shell = await wf.bot.create({ + name: 'Shell ab', + ephemeral: true, + tools: ['bash'], + permission: 'danger-full-access', + }) + const shellAsk = (toolName, reason = '') => ctx.listeners.get('approval/request')[0]( + { agent: { session: { id: shell } }, toolName, reason }, + () => 'from-dsh', + ) + assert.equal(await shellAsk('bash', 'escalate sandbox to danger-full-access: gh pr create'), 'allowed-once') + assert.equal(await shellAsk('mcp__github__create_pull_request'), 'rejected') +}) + test('with no middleware, message_bot behaves as before', async (t) => { const { tool, received, stop } = await start() t.after(stop) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 99cbb546..7b13ebb2 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -234,6 +234,17 @@ export interface CreateBotSpec { ephemeral?: boolean /** Model ref (`provider/model` or a model id). Empty leaves the usual default. */ model?: string + /** + * Tools this run Bot may use. Only read when `ephemeral` is true. + * `github` grants `mcp__github__*`. `bash` or `shell` grants a full-access shell. + * A team Bot ignores this. + */ + tools?: string[] + /** + * DSH permission preset for a run Bot (`workspace-write` or `danger-full-access`). + * Only applied when `ephemeral` is true. A team Bot ignores this. + */ + permission?: string } /** What a send middleware sees. Built-in ds-bot sends arrive with via: 'ds-bot'. */ diff --git a/plugins/workflow/README.md b/plugins/workflow/README.md index ed2e827c..c5ef347b 100644 --- a/plugins/workflow/README.md +++ b/plugins/workflow/README.md @@ -6,26 +6,56 @@ This is not the workflow-definition engine (`workflows/.json`, #1272). This ## What you can do -- Open a template. **发布交付流水线** is Planner → Writer → Coder → (Reviewer ‖ Tester) → Deployer. **每周报告** is a short chain with a Friday label. +- Open a template. **发布交付流水线** is Planner → Writer → Coder → (Reviewer ‖ Tester) → Deployer. **每周报告** is a short chain with a Friday label. Each of those nodes names its inputs and outputs. - Create or edit a graph in the flowchart, or in Settings → Workflow graphs. Drag a local Bot onto the canvas, move a node, draw or drop an edge, and edit each node's system prompt. Both surfaces write the same graph. -- Hand a board task to a Workflow, or to one local Bot, from the assign dialog. -- Let an open card assign itself. The matcher is `src/match.js`, first hit, and only while the card is `open`: +- Hand a board task to a Workflow, or to one local Bot, from the assign dialog. A Workflow starts a live run or queues the task. A Bot becomes the card's owner and does not start a run. A remote Bot is refused. +- Let an open card assign itself. The matcher is `src/match.js`, first hit, and only while the card is `open`. A saved Bot claim beats a workflow trigger. Otherwise: 1. a `workflow:` token in the title, note, criteria or tags 2. `workflowId` or `workflow` 3. `assignee` naming a workflow 4. a `bot:` token 5. `assignee` naming a local Bot (a remote Bot never matches) 6. a workflow trigger contained in the title, note or criteria (longest trigger wins) -- A miss stays unassigned for the manual picker. A Bot match sets that Bot as the card's owner so the task drive can wake it. A Workflow match starts the scripted run and posts progress in the Workflow chat. +- A miss stays unassigned for the manual picker. + +## A live run + +Assigning a task, `run_workflow`, or a Workflow match starts a live run. One hidden ephemeral Bot is created per node, used for that step, and removed when the run finishes. A node does not need a permanent sidebar Bot. `tools` on the node is what that Bot can use: `github` grants the GitHub connector (pull requests, reviews, comments), `bash` grants a shell at full access, and every step gets at least workspace write. The Main Bot sends that node its step. The node stays `running` until that Bot replies. The reply is the chat line for the step. The next node starts only after that reply, and only along the edges: every predecessor is done first, and nodes that share a rank run together. + +A second task does not replace a run that is already going, and it does not release that run's Bots. It waits in a FIFO queue. The stage shows a chip when more than one run is active or the queue is not empty, and it refreshes `workflow/state` while a live run exists. That refresh does not advance the graph. + +**Replay demo** plays the seeded script. It is disabled while a live run of that workflow is going, and `workflow/demo-step` refuses with "A live run is in progress". The demo's canned lines are not written for a live run. + +Each workflow allows `maxConcurrent` live runs at once. The default is 2. The value is an integer from 1 to 8, set on create (`maxConcurrent`) or with `workflow/concurrency` (`{ workflowId, maxConcurrent }`). The stage lists the current value under Details. The host runs at most 6 live runs across every workflow. A task that does not fit is queued. When any run finishes, the oldest queued task whose workflow still has room starts, whichever workflow it belongs to. A new assign waits behind a task already queued there. Lowering the cap does not stop a run that has already started. History keeps finished and failed runs only. + +## Inputs and outputs + +A node may declare up to eight inputs and eight outputs. A name matches `^[a-z][a-z0-9-]{0,40}$`, and the type defaults to `text`. Mark an input `required: true` when the node cannot start without it. + +A required input must be an output of the same name on an ancestor. A root node may instead require `task`, `title`, or `brief`, which come from the assignment. Starting a run is refused when a required input has no provider, and any Bots already created for that attempt are removed. + +On a live run, a successor stays queued until its required inputs are present. If every predecessor is already done or failed and an input is still missing, that node fails and the run stops. The dispatch text includes `Input : `. The Bot reports outputs as `OUTPUT : `. A node with one declared output may put that value in the reply body. + +The delivery template passes `task` → `plan` → `copy` → `patch`, then `review` and `report` into `release`. Coder and Tester name `bash`; Coder, Reviewer and Deployer name `github`. The weekly template passes `task` → `notes` → `metrics` → `report`. + +## Branches and loops + +An edge may be a condition or a loop. A condition is `when: { output, eq }` on the edge out of the node that produced `output`. The edge is taken only when that output equals `eq`. `when: { else: true }` is the other branch, taken when no `eq` edge matched. A node that no taken edge reaches is skipped. A later join starts once every incoming edge is either taken and done, or skipped. + +A loop is `loop: true` on the edge that returns to the body, with `max` from 1 to 8 (default 3) and an optional `until: { output, eq }`. The body runs again while `until` does not match and the run count is still under `max`. A branch the previous pass skipped can be chosen the next time. The forward edges wait until the loop stops. A loop is the only cycle the graph allows. Settings → Workflow graphs can add a link, a condition, or a loop. Replay keeps showing a finished live run until you step the demo script; the next replay step is the script, and the graph's node status moves with it. + +## When a step does not land + +A dispatch that does not land — no Main Bot to send from, `ok: false`, or `deny` / `hold` — is not recorded as handed. The Workflow chat names the code, such as `no-sender`, and the node stays open so the next hand-off sends it again. That retry is the next hand-off, not a second send in the same breath, so a burst of 429s is not hammered. The run and its Bots stay until the step is accepted, the run fails for another reason, or it is reset. + +A reply that says the model could not answer (`could not answer your message` or `model call failed`) fails that node in the chat. It is not retried in a loop, and its successor does not start. A 429 or a rate-limit mention in an otherwise successful reply does not fail the node. ## Tools A local Bot uses the same store: -- `run_workflow` — `workflow` (id or exact name) and `brief` (the task). Optional `task` id. -- `define_workflow` — `name`, `nodes` (`{ id, name, prompt?, role?, model?, tools?, botId?, x?, y? }`), `edges` (`{ from, to }`). `prompt` is that node's Bot system prompt. Pass `workflow` to replace one. A friend's Bot is refused. This writes the same graph Settings and the flowchart edit. - -Starting a run makes one hidden Bot per node from that config, asks it to do the step, and removes those Bots when the run finishes, fails for good, or is cancelled. They do not appear in the sidebar. The Workflow chat still posts its scripted progress lines. +- `run_workflow` — `workflow` (id or exact name) and `brief` (the task). Optional `task` id. This joins the same queue a manual assign uses. +- `define_workflow` — `name`, `nodes` (`{ id, name, prompt?, role?, model?, tools?, botId?, inputs?, outputs?, x?, y? }`), `edges` (`{ from, to, when?, loop?, max?, until? }`). `prompt` is that node's Bot system prompt. `tools` equips the ephemeral Bot (`github`, `bash`, `read`, `edit`). `botId` is optional. `inputs` and `outputs` are `{ name, type?, required? }`. `when` chooses an edge; `loop: true` with `max` is the only cycle. Pass `workflow` to replace one. A friend's Bot is refused. This writes the same graph Settings and the flowchart edit. ## Install @@ -43,4 +73,22 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab npm test ``` -Endpoints, all under `flybot/workflow/`: `state`, `templates` (read), `create`, `assign`, `advance`, `demo-step`, `reset`, `say`, `graph`, `ingest`. +Endpoints, all under `flybot/workflow/`: `state`, `templates` (read), `create`, `assign`, `concurrency`, `advance`, `demo-step`, `reset`, `say`, `graph`, `ingest`. + +## 中文 + +Workflow 是一组本地 Bot 的群聊式运行。侧栏打开同一次运行:进度、气泡、流程图。这不是 `workflows/.json` 那套定义引擎(#1272)。 + +把看板任务交给一个 Workflow,会开始一次真实运行,或在已有运行占满时排进 FIFO 队列。交给一个本地 Bot,只把卡片的负责人设成该 Bot,不会启动 Workflow。远程 Bot 会被拒绝。 + +一次真实运行会为每个节点创建一个隐藏的临时 Bot,用完即删。节点不必绑定侧栏里的永久 Bot。`tools` 决定这个 Bot 能用什么:`github` 授予 GitHub 连接器(拉取请求、评审、评论),`bash` 授予完整权限的 shell,其余步骤至少可以写工作区。节点一直处于运行中,直到该 Bot 回复。回复写入 Workflow 聊天,下一节点才沿边开始。正在进行的运行不会被新任务替换,也不会因此释放它的 Bot。历史里只留下已完成或已失败的运行。 + +每个 Workflow 同时运行的上限是 `maxConcurrent`,默认 2,取 1 到 8 的整数,在创建时传入,或调用 `workflow/concurrency`。全部 Workflow 加在一起最多 6 个正在运行的任务。某个运行结束后,所有 Workflow 里最早排队、且自己还有空位的任务会开始。新的分配排在已经排队的任务后面。降低上限不会停掉已经开始的运行。舞台在详情里显示当前上限;有排队或同时多于一个运行时显示队列条。有真实运行时,页面只刷新状态,不播放演示。 + +**重放演示** 只播放预置脚本。该 Workflow 有真实运行时按钮不可用,`workflow/demo-step` 会拒绝。 + +每个节点最多声明 8 个输入和 8 个输出,名字为小写字母开头的短名。必填输入必须由上游同名输出提供;根节点可以用 `task`、`title` 或 `brief`。没有提供者时,运行拒绝开始。Bot 用 `OUTPUT 名字: 值` 交回输出;只声明一个输出时,回复正文即可。 + +边可以是条件或循环。条件是 `when: { output, eq }`,只有该输出等于 `eq` 才走这条边;`when: { else: true }` 是其余分支。没被选中的节点标为跳过,汇合节点不因此卡住。循环边是 `loop: true`,`max` 为 1 到 8(默认 3),可选 `until: { output, eq }`。在达到 `max` 或 `until` 命中之前,循环体再跑一遍,正向的边先等着。上一轮跳过的分支,下一轮可以再选。只有循环边可以成环。设置页可以添加连线、条件或循环。真实运行结束后,舞台仍显示那次运行,直到重放演示再走一步,之后显示的是演示脚本,节点状态跟着演示走。 + +派发没有送达(例如没有 Main 可发送,`no-sender`)不会记成已交接。聊天里写明原因,节点保持运行,下一次交接再发送。同一次交接里不会连发,避免把 429 打成一串。这次运行和它的 Bot 会留到这一步被接受、运行因别的原因失败,或被重置。回复是「could not answer your message」或「model call failed」时,该节点失败且不自动连试,后续节点不开始。成功回复里提到 429 或 rate limit 不会让节点失败。 diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 9aa6a308..444ada0d 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -35,6 +35,10 @@ window.__ModuleLoader__.load({ Workflow: 'Workflow', 'One Bot (as before)': '单个 Bot(原有方式)', 'Run demo': '演示运行', + 'Replay demo': '重放演示', + 'A live run is in progress': '有真实运行在进行,演示不会插队', + 'Concurrent runs': '同时运行', + '{count} running · queue {depth}': '{count} 个在运行 · 队列 {depth}', Pause: '暂停', Next: '下一步', Reset: '重置', @@ -84,6 +88,17 @@ window.__ModuleLoader__.load({ 'Add link': '添加连线', 'From node': '起点', 'To node': '终点', + Link: '连线', + Condition: '条件', + Loop: '循环', + Kind: '连线类型', + when: '当', + until: '直到', + Else: '否则', + 'Max iterations': '最多次数', + 'Output name': '输出名', + Equals: '等于', + Skipped: '跳过', } function zhUi() { @@ -101,7 +116,7 @@ window.__ModuleLoader__.load({ return fill(text, params) } - const STATUS_KEY = { queued: 'Queued', running: 'Running', done: 'Done', failed: 'Failed', idle: 'Idle' } + const STATUS_KEY = { queued: 'Queued', running: 'Running', done: 'Done', failed: 'Failed', skipped: 'Skipped', idle: 'Idle' } const COLUMN_KEY = { 'not-started': 'Not started', 'in-progress': 'In progress', done: 'Done' } const COLUMN_OF = { open: 'not-started', claimed: 'not-started', working: 'in-progress', blocked: 'in-progress', 'result-ready': 'in-progress', accepted: 'done', cancelled: 'done', failed: 'done' } const COLORS = { Planner: '#8b5cf6', Writer: '#f0509a', Coder: '#1fbf6a', Reviewer: '#f5a524', Tester: '#14b8a6', Deployer: '#2f7cf6', '研究员': '#a78bfa', '数据分析师': '#ec4899' } @@ -167,7 +182,7 @@ window.__ModuleLoader__.load({ h('span', { className: 'wf-chev', 'aria-hidden': true }, '▾')), h('div', { className: 'wf-actions' }, h('button', { type: 'button', className: 'wf-btn', onClick: onAssign }, tr('Hand a task to a Workflow')), - h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onPlay }, playing ? tr('Pause') : `▶ ${tr('Run demo')}`))), + h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onPlay, disabled: (workflow.activeCount ?? 0) > 0 }, playing ? tr('Pause') : `▶ ${tr('Replay demo')}`))), run ? h('div', { className: 'wf-strip' }, h('div', { className: 'wf-strip-id' }, h('div', { className: 'wf-tk' }, `${run.task?.id ?? ''} ${run.task?.title ?? ''}`), @@ -176,6 +191,7 @@ window.__ModuleLoader__.load({ : (run.status === 'done' ? tr('Done') : tr('Waiting for a task')))), h('div', { className: 'wf-segs' }, (run.marks ?? []).map((mark, index) => h('span', { key: index, className: `wf-seg ${mark}` }))), h('span', { className: `wf-chip ${run.status === 'done' ? 'done' : (running[0] ? 'running' : 'queued')}` }, run.status === 'done' ? tr('Done') : (running[0] ? tr('Running') : tr('Queued'))), + (workflow.queueDepth > 0 || (workflow.activeCount ?? 0) > 1) ? h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? 0, depth: workflow.queueDepth ?? 0 })) : null, h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onTab('flow') }, tr('Flow'))) : null, h('div', { className: 'wf-chat', ref: chatRef }, h('div', { className: 'wf-chat-in' }, @@ -227,7 +243,7 @@ window.__ModuleLoader__.load({ tab === 'details' ? h(Details, { workflow }) : null, tab === 'runs' ? h(Runs, { workflow }) : null, h('div', { className: 'wf-ctl' }, - h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onPlay }, playing ? tr('Pause') : tr('Run demo')), + h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onPlay, disabled: (workflow.activeCount ?? 0) > 0 }, playing ? tr('Pause') : tr('Replay demo')), h('button', { type: 'button', className: 'wf-btn', onClick: onNext }, `${tr('Next')} ›`), h('button', { type: 'button', className: 'wf-btn', onClick: onReset }, `↺ ${tr('Reset')}`), h('button', { type: 'button', className: 'wf-btn', onClick: onToggleEdit }, editing ? tr('Finish editing') : tr('Edit')))) @@ -384,15 +400,18 @@ window.__ModuleLoader__.load({ h('div', { className: 'wf-kv' }, h('span', null, tr('Model')), h('span', null, tr('Each Bot’s own model'))), h('div', { className: 'wf-kv' }, h('span', null, tr('Permissions')), h('span', null, tr('Each Bot’s own permission'))), h('div', { className: 'wf-kv' }, h('span', null, tr('On failure')), h('span', null, tr(retries ? 'Tester retries once. Any other failed step stops the run.' : 'A failed step stops the run.'))), + h('div', { className: 'wf-kv' }, h('span', null, tr('Concurrent runs')), h('span', null, String(workflow.maxConcurrent ?? 2))), h('div', { className: 'wf-kv' }, h('span', null, tr('Members')), h('span', null, workflow.chain))) } function Runs({ workflow }) { - const history = workflow.run?.history ?? [] + const history = workflow.history ?? workflow.run?.history ?? [] + const queued = workflow.queue ?? [] const current = workflow.run?.task - if (!current && history.length === 0) return h('div', { className: 'wf-muted' }, tr('No runs yet.')) + if (!current && history.length === 0 && queued.length === 0) return h('div', { className: 'wf-muted' }, tr('No runs yet.')) const rows = history.map(item => ({ ...item, current: false })) - if (current) rows.push({ task: current, status: workflow.run.status, current: true }) + for (const task of queued) rows.push({ task, status: 'queued', current: false }) + if (current && !rows.some(item => item.task?.id === current.id && item.status === workflow.run.status)) rows.push({ task: current, status: workflow.run.status, current: true }) return h('div', null, rows.map((item, index) => h('div', { key: index, className: 'wf-hist' }, h('span', null, `${item.task?.id ?? ''} ${item.task?.title ?? ''}`), h('span', { className: `wf-chip ${item.status === 'done' ? 'done' : item.status === 'running' ? 'running' : item.status === 'failed' ? 'failed' : 'queued'}` }, tr(item.status === 'done' ? 'Done' : item.status === 'running' ? 'Running' : item.status === 'failed' ? 'Failed' : 'Queued'))))) @@ -401,7 +420,7 @@ window.__ModuleLoader__.load({ function AssignModal({ workflows, tasks, roster, workflowId, onClose, onConfirm }) { const [taskId, setTaskId] = useState(tasks[1]?.id ?? tasks[0]?.id ?? '') const [target, setTarget] = useState(workflowId || workflows[0]?.id || '') - const bots = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true && bot.id !== roster?.mainBotId).slice(0, 1) + const bots = (roster?.bots ?? []).filter(bot => bot && bot.remote !== true && bot.id && bot.name).slice(0, 12) return h('div', { className: 'wf-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } }, h('div', { className: 'wf-modal', role: 'dialog', 'aria-modal': true, 'aria-label': tr('Hand a task to a Workflow') }, h('h3', null, tr('Hand a task to a Workflow'), @@ -480,11 +499,27 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'wf-btn wf-dark', onClick: () => onCreate({ name, steps }) }, tr('Create'))))) } + function edgeNote(edge) { + if (edge.loop === true) { + const until = edge.until ? ` ${tr('until')} ${edge.until.output}=${edge.until.eq}` : '' + return `${tr('Loop')} ×${edge.max ?? 3}${until}` + } + if (edge.when?.else === true) return tr('Else') + if (edge.when) return `${tr('when')} ${edge.when.output}=${edge.when.eq}` + return '' + } + function WorkflowSettings({ call }) { const [pack, setPack] = useState(null) const [workflowId, setWorkflowId] = useState('') const [from, setFrom] = useState('') const [to, setTo] = useState('') + const [kind, setKind] = useState('link') + const [whenName, setWhenName] = useState('') + const [whenEq, setWhenEq] = useState('') + const [loopMax, setLoopMax] = useState('3') + const [untilName, setUntilName] = useState('') + const [untilEq, setUntilEq] = useState('') const [error, setError] = useState('') useEffect(() => { let gone = false @@ -532,25 +567,55 @@ window.__ModuleLoader__.load({ save(current.nodes.map(item => item.id === node.id ? { ...item, prompt } : item), current.edges) }, }))), - (current.edges ?? []).map(edge => h('div', { key: `${edge.from}>${edge.to}`, className: 'wf-hist' }, - h('span', null, `${nameOf(edge.from)} → ${nameOf(edge.to)}`), - h('button', { - type: 'button', - onClick: () => save(current.nodes, current.edges.filter(item => item.from !== edge.from || item.to !== edge.to)), - }, tr('Remove')))), + (current.edges ?? []).map(edge => { + const note = edgeNote(edge) + return h('div', { key: `${edge.from}>${edge.to}`, className: 'wf-hist' }, + h('span', null, note ? `${nameOf(edge.from)} → ${nameOf(edge.to)} · ${note}` : `${nameOf(edge.from)} → ${nameOf(edge.to)}`), + h('button', { + type: 'button', + onClick: () => save(current.nodes, current.edges.filter(item => item.from !== edge.from || item.to !== edge.to)), + }, tr('Remove'))) + }), h('div', { className: 'wf-add' }, + h('label', { className: 'wf-field' }, tr('Kind'), + h('select', { 'aria-label': tr('Kind'), value: kind, onChange: (event) => setKind(event.target.value) }, + h('option', { value: 'link' }, tr('Link')), + h('option', { value: 'condition' }, tr('Condition')), + h('option', { value: 'loop' }, tr('Loop')))), h('label', { className: 'wf-field' }, tr('From node'), h('select', { 'aria-label': tr('From node'), value: from, onChange: (event) => setFrom(event.target.value) }, current.nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))), h('label', { className: 'wf-field' }, tr('To node'), h('select', { 'aria-label': tr('To node'), value: to, onChange: (event) => setTo(event.target.value) }, current.nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))), + kind === 'condition' ? h('label', { className: 'wf-field' }, tr('Output name'), + h('input', { 'aria-label': tr('Output name'), value: whenName, onChange: (event) => setWhenName(event.target.value) })) : null, + kind === 'condition' ? h('label', { className: 'wf-field' }, tr('Equals'), + h('input', { 'aria-label': tr('Equals'), value: whenEq, onChange: (event) => setWhenEq(event.target.value) })) : null, + kind === 'loop' ? h('label', { className: 'wf-field' }, tr('Max iterations'), + h('input', { 'aria-label': tr('Max iterations'), value: loopMax, onChange: (event) => setLoopMax(event.target.value) })) : null, + kind === 'loop' ? h('label', { className: 'wf-field' }, tr('until'), + h('input', { 'aria-label': tr('until'), value: untilName, onChange: (event) => setUntilName(event.target.value) })) : null, + kind === 'loop' ? h('label', { className: 'wf-field' }, tr('Equals'), + h('input', { 'aria-label': tr('Equals'), value: untilEq, onChange: (event) => setUntilEq(event.target.value) })) : null, h('button', { type: 'button', className: 'wf-btn', onClick: () => { - if (!from || !to || from === to) return + if (!from || !to) return + if (kind !== 'loop' && from === to) return if ((current.edges ?? []).some(edge => edge.from === from && edge.to === to)) return - save(current.nodes, [...(current.edges ?? []), { from, to }]) + let edge = { from, to } + if (kind === 'condition') { + const output = whenName.trim().toLowerCase() + if (!output) return + edge = { from, to, when: { output, eq: whenEq } } + } else if (kind === 'loop') { + const max = Number(loopMax) + edge = { from, to, loop: true, max: Number.isInteger(max) ? max : 3 } + const output = untilName.trim().toLowerCase() + if (output) edge.until = { output, eq: untilEq } + } + save(current.nodes, [...(current.edges ?? []), edge]) }, }, tr('Add link')))) : null) } @@ -623,12 +688,21 @@ window.__ModuleLoader__.load({ }, []) useEffect(() => { if (!playing || !openId) return undefined + const live = (pack?.workflows ?? []).find(item => item.id === openId) + if ((live?.activeCount ?? 0) > 0) { setPlaying(false); return undefined } const timer = setInterval(() => { void call('workflow/demo-step', { workflowId: openId }).then(refresh).then(value => { - const run = value?.workflows?.find(item => item.id === openId)?.run - if (!run || run.status === 'done') setPlaying(false) + const row = value?.workflows?.find(item => item.id === openId) + if (value?.ok === false || (row?.activeCount ?? 0) > 0 || !row?.run || row.run.status === 'done') setPlaying(false) }) }, 1200) return () => clearInterval(timer) - }, [playing, openId]) + }, [playing, openId, pack]) + useEffect(() => { + const live = (pack?.workflows ?? []).some(item => (item.activeCount ?? 0) > 0) + if (!live) return undefined + let gone = false + const timer = setInterval(() => { if (!gone) void refresh() }, 2000) + return () => { gone = true; clearInterval(timer) } + }, [pack]) useEffect(() => { if (!openId || typeof document === 'undefined') return undefined const onClick = (event) => { @@ -665,6 +739,15 @@ window.__ModuleLoader__.load({ if (!task) return if (String(target).startsWith('bot:')) { const botId = String(target).slice(4) + const value = await call('workflow/assign', { botId, taskId: task.id, title: task.title }) + if (value?.ok === false) { setError(value.error ?? ''); return } + const host = globalThis.__flybot__?.host + if (typeof host === 'function') { + try { await host('task-update', { id: task.id }) } catch (updateError) { + // The board can be absent. The route is stored; the next card write applies the owner. + void updateError + } + } setAssignOpen(false) actions?.openSession?.(botId) return @@ -723,7 +806,10 @@ window.__ModuleLoader__.load({ onCloseDrawer: () => setDrawer(false), onToggleDrawer: () => { setDrawer(value => !value); setTab('flow') }, onTab: (key) => { setTab(key); setDrawer(true) }, - onPlay: () => setPlaying(value => !value), + onPlay: () => { + if ((open.activeCount ?? 0) > 0) { setError(tr('A live run is in progress')); setPlaying(false); return } + setPlaying(value => !value) + }, onNext: () => { setPlaying(false); void call('workflow/demo-step', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, onReset: () => { setPlaying(false); void call('workflow/reset', { workflowId: open.id }).then(value => { if (value?.workflows) setPack(value) }) }, onAssign: () => { setAssignOpen(true); void loadTasks() }, diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 9050bf6d..534bcf31 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -2,6 +2,7 @@ // $DSH_HOME/bot/flybot-workflow and answers the chat surface on workflow/*. import { randomBytes } from 'node:crypto' import { join } from 'node:path' +import { permissionFor } from './src/ephemeral.js' import { createWorkflowService } from './src/service.js' function runnerName(base, taken) { @@ -28,7 +29,10 @@ export function apply(ctx, config = {}) { const lifecycle = typeof fb.bot?.create === 'function' ? { async spawn(spec) { const tools = Array.isArray(spec.tools) ? spec.tools : [] - const prompt = [spec.prompt, tools.length > 0 ? `Tools for this step: ${tools.join(', ')}.` : ''].filter(Boolean).join('\n\n') + 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') let last for (let n = 0; n < 4; n += 1) { const taken = new Set([...(fb.bot.list?.() ?? []).map(bot => bot.name), ...liveNames]) @@ -42,6 +46,8 @@ export function apply(ctx, config = {}) { ...(spec.model ? { model: spec.model } : {}), hidden: true, ephemeral: true, + tools, + permission: permissionFor(tools), fields: { nodeId: spec.nodeId, tools }, }) liveNames.add(name) @@ -60,8 +66,14 @@ export function apply(ctx, config = {}) { async dispatch(step) { const from = (fb.bot.list?.() ?? []).find(bot => bot.isMain)?.id if (!from || typeof fb.bot.send !== 'function') return { ok: false, code: 'no-sender' } - const text = `Task ${step.taskId}: ${step.brief}\nDo this step and reply with the result.` - return fb.bot.send({ from, to: step.botId, text, role: 'request' }) + const lines = [`Task ${step.taskId}: ${step.brief}`] + for (const [name, value] of Object.entries(step.inputs ?? {})) { + if (value) lines.push(`Input ${name}: ${value}`) + } + const outputs = (step.outputs ?? []).map(port => port.name).filter(Boolean) + if (outputs.length > 0) lines.push(`When you finish, add one line per output: OUTPUT : (${outputs.join(', ')}).`) + lines.push('Reply with the result of this step.') + return fb.bot.send({ from, to: step.botId, text: lines.join('\n'), role: 'request' }) }, } : {} const service = createWorkflowService({ @@ -77,6 +89,7 @@ export function apply(ctx, config = {}) { fb.api.handle('workflow/state', () => service.state(), { read: true }), fb.api.handle('workflow/create', body => service.create(body)), fb.api.handle('workflow/assign', body => service.assign(body)), + fb.api.handle('workflow/concurrency', body => service.setConcurrency(body)), fb.api.handle('workflow/advance', body => service.advance(body)), fb.api.handle('workflow/demo-step', body => service.demoStep(body)), fb.api.handle('workflow/reset', body => service.reset(body)), @@ -98,16 +111,34 @@ export function apply(ctx, config = {}) { })) offs.push(fb.tools.register({ name: 'define_workflow', - description: 'Create or replace a Workflow graph. nodes are { id, name, prompt?, role?, model?, tools?, botId?, x?, y? } and edges are { from, to }. prompt is that node\'s Bot system prompt. The Settings page and the flowchart edit the same graph. A friend\'s Bot is refused.', + description: 'Create or replace a Workflow graph. nodes are { id, name, prompt?, role?, model?, tools?, botId?, inputs?, outputs?, x?, y? } and edges are { from, to, when?, loop?, max?, until? }. prompt is that node\'s Bot system prompt. tools name what the ephemeral run Bot may use (github grants mcp__github__*; bash grants a shell). A node does not need a permanent botId. inputs and outputs are { name, type?, required? }. A required input must be produced upstream, or be task, title, or brief on a root. when chooses an edge; loop with max (and optional until) is the only cycle. The Settings page and the flowchart edit the same graph. A friend\'s Bot is refused.', parameters: { name: { type: 'string', required: true, description: 'Workflow name' }, workflow: { type: 'string', description: 'Existing id or name to replace. Omit to create.' }, - nodes: { type: 'array', required: true, description: 'Nodes. Each may carry prompt (system prompt), role, model, tools, and an optional local botId.' }, - edges: { type: 'array', required: true, description: 'Hand-offs { from, to }. A fork is two edges out; a join is two edges in.' }, + nodes: { type: 'array', required: true, description: 'Nodes. Each may carry prompt (system prompt), role, model, tools (github, bash, read, edit, …), inputs, outputs, and an optional local botId. Real work uses an ephemeral Bot per run, not a seated botId.' }, + 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 }.' }, }, execute: args => service.define(args ?? {}), })) } + if (typeof fb.bot?.send?.use === 'function') { + const off = fb.bot.send.use({ + name: 'workflow-step', + priority: fb.bot.send.priority?.ROUTING, + handle(msg) { + const from = typeof msg.from === 'string' ? msg.from : '' + if (msg.role !== 'reply' || !from || service.ownsBot(from) !== true) return undefined + const text = String(msg.text ?? '') + const failed = /could not answer your message|model call failed/i.test(text) + // The reply is the step result. Let it deliver, and move the node from it. + void Promise.resolve(service.stepResult({ botId: from, text, ok: !failed })).catch(error => { + fb.warn?.('flybot-workflow: step result failed: %s', error) + }) + return undefined + }, + }) + if (typeof off === 'function') offs.push(off) + } if (typeof fb.tasks?.onWrite === 'function') { offs.push(fb.tasks.onWrite(task => { const result = service.ingest(task ?? {}) diff --git a/plugins/workflow/src/ephemeral.js b/plugins/workflow/src/ephemeral.js index b41ff35b..4a1b7b2c 100644 --- a/plugins/workflow/src/ephemeral.js +++ b/plugins/workflow/src/ephemeral.js @@ -3,6 +3,16 @@ // 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' +} + export function nodeConfig(node, roster = []) { const seated = node?.botId ? roster.find(bot => bot && bot.id === node.botId && bot.remote !== true) diff --git a/plugins/workflow/src/graph.js b/plugins/workflow/src/graph.js index 6eb893e4..4464420f 100644 --- a/plugins/workflow/src/graph.js +++ b/plugins/workflow/src/graph.js @@ -4,7 +4,7 @@ // The same nodes, edges and positions are what the canvas edits and what a // run paints: x/y are the owner's layout, ranks are derived from the edges. -const STATUSES = new Set(['queued', 'running', 'done', 'failed']) +const STATUSES = new Set(['queued', 'running', 'done', 'failed', 'skipped']) const ID_RE = /^[a-z][a-z0-9-]{0,40}$/ const GAP_X = 188 const GAP_Y = 108 @@ -37,13 +37,53 @@ export function cleanTools(value) { if (!Array.isArray(value)) return [] const tools = [] for (const item of value) { - const name = cleanLine(item, 40) + const name = cleanLine(item, 64).toLowerCase() if (name && !tools.includes(name)) tools.push(name) if (tools.length === 16) break } return tools } +const PORT_RE = /^[a-z][a-z0-9-]{0,40}$/ +// A root node's required input is filled from the task when it uses one of these names. +export const TASK_INPUTS = new Set(['task', 'title', 'brief']) + +export function cleanPorts(value, kind) { + if (!Array.isArray(value)) return [] + const ports = [] + const seen = new Set() + for (const item of value) { + const name = cleanLine(typeof item === 'string' ? item : item?.name, 40).toLowerCase() + if (!PORT_RE.test(name) || seen.has(name)) continue + seen.add(name) + const type = cleanLine(typeof item === 'object' && item ? item.type : '', 40) || 'text' + if (kind === 'in' && item?.required === true) ports.push({ name, type, required: true }) + else ports.push({ name, type }) + if (ports.length === 8) break + } + return ports +} + +// The Bot's reply is the step result. `OUTPUT name: value` lines are that +// node's declared outputs; a node with one output may put it in the reply body. +export function readStepResult(text, node) { + const outputs = {} + const kept = [] + for (const line of String(text ?? '').split('\n')) { + const match = /^OUTPUT\s+([a-z][a-z0-9-]{0,40})\s*:\s*(.*)$/i.exec(line.trim()) + if (!match) { kept.push(line); continue } + outputs[match[1].toLowerCase()] = cleanLine(match[2], 2000) + } + const body = kept.join('\n').trim().slice(0, 2000) + const declared = node?.outputs ?? [] + if (declared.length === 1 && !outputs[declared[0].name] && body) outputs[declared[0].name] = body + if (declared.length > 0) { + const allowed = new Set(declared.map(port => port.name)) + for (const name of Object.keys(outputs)) if (!allowed.has(name)) delete outputs[name] + } + return { text: body, outputs } +} + function coord(value, fallback) { const number = Number(value) if (!Number.isFinite(number)) return fallback @@ -60,7 +100,40 @@ export function edgesFromLayers(layers) { return edges } -// Longest-path ranks. A cycle is refused. Nodes keep their list order inside a rank. +function cleanWhen(value, allowElse) { + if (!value || typeof value !== 'object') return null + if (allowElse && value.else === true) return { else: true } + const output = cleanLine(value.output, 40).toLowerCase() + if (!PORT_RE.test(output)) return null + return { output, eq: cleanLine(value.eq, 200) } +} + +// A loop is the only edge that may cycle. max is how many times the body runs +// (1–8, default 3). until stops early when that output equals eq. +function cleanEdge(edge) { + const from = String(edge?.from ?? '') + const to = String(edge?.to ?? '') + if (edge?.loop === true) { + const max = Number(edge.max) + const until = cleanWhen(edge.until, false) + return { + from, + to, + loop: true, + max: Number.isInteger(max) && max >= 1 && max <= 8 ? max : 3, + ...(until ? { until } : {}), + } + } + const when = cleanWhen(edge.when, true) + return { from, to, ...(when ? { when } : {}) } +} + +function edgeKey(edge) { + return `${edge.from}>${edge.to}` +} + +// Longest-path ranks. A forward cycle is refused. A loop edge is not ranked. +// Nodes keep their list order inside a rank. export function ranksOf(nodes, edges) { const ids = nodes.map(node => node.id) const known = new Set(ids) @@ -68,6 +141,7 @@ export function ranksOf(nodes, edges) { const succs = new Map(ids.map(id => [id, []])) for (const edge of edges) { if (!known.has(edge.from) || !known.has(edge.to)) throw new Error(`Link names an unknown Bot ${edge.from} → ${edge.to}`) + if (edge.loop === true) continue if (edge.from === edge.to) throw new Error('A Bot cannot connect to itself') preds.get(edge.to).push(edge.from) succs.get(edge.from).push(edge.to) @@ -160,12 +234,11 @@ export function normalizeGraph(graph) { if (graph.edges.length > 64) throw new Error('A workflow has at most 64 links') const seen = new Set() edges = graph.edges.map(edge => { - const from = String(edge?.from ?? '') - const to = String(edge?.to ?? '') - const key = `${from}>${to}` - if (seen.has(key)) throw new Error(`Link ${from} → ${to} is listed twice`) + const cleaned = cleanEdge(edge) + const key = edgeKey(cleaned) + if (seen.has(key)) throw new Error(`Link ${cleaned.from} → ${cleaned.to} is listed twice`) seen.add(key) - return { from, to } + return cleaned }) } else { checkLayers(graph.layers, ids) @@ -242,6 +315,8 @@ export function addNode(graph, node) { const prompt = cleanPrompt(node.prompt) const model = cleanLine(node.model, 80) const tools = cleanTools(node.tools) + const inputs = cleanPorts(node.inputs, 'in') + const outputs = cleanPorts(node.outputs, 'out') next.nodes.push({ id, name: cleanLine(node.name, 80), @@ -250,6 +325,8 @@ export function addNode(graph, node) { ...(prompt ? { prompt } : {}), ...(model ? { model } : {}), ...(tools.length ? { tools } : {}), + ...(inputs.length ? { inputs } : {}), + ...(outputs.length ? { outputs } : {}), ...(node.botId ? { botId: String(node.botId) } : {}), ...(node.failOnce === true ? { failOnce: true } : {}), ...(Number.isFinite(Number(node.x)) ? { x: coord(node.x, 0) } : {}), @@ -291,35 +368,210 @@ function copyStatus(status) { return next } +function forwardEdges(graph) { + return graph.edges.filter(edge => edge.loop !== true) +} + function predecessors(graph, nodeId) { ensure(graph) - return graph.edges.filter(edge => edge.to === nodeId).map(edge => edge.from) + return forwardEdges(graph).filter(edge => edge.to === nodeId).map(edge => edge.from) +} + +function forwardIncoming(graph, id) { + return forwardEdges(graph).filter(edge => edge.to === id) +} + +// A plain edge from a done node is open. A when edge is open only when this +// run recorded it. A skipped source closes the edge. +function edgeOpen(edge, status, fired) { + if (status[edge.from] !== 'done') return false + if (edge.when) return fired?.[edgeKey(edge)] === true + return true +} + +function ancestors(graph, nodeId, into = new Set()) { + for (const from of predecessors(graph, nodeId)) { + if (into.has(from)) continue + into.add(from) + ancestors(graph, from, into) + } + return into +} + +// A required input must be produced by an upstream output of the same name. +// A root may take `task`, `title` or `brief` from the assignment instead. +export function missingProviders(graph) { + ensure(graph) + const missing = [] + for (const node of graph.nodes) { + const roots = predecessors(graph, node.id).length === 0 + for (const input of node.inputs ?? []) { + if (input.required !== true) continue + const provided = [...ancestors(graph, node.id)].some(id => (nodeById(graph, id)?.outputs ?? []).some(port => port.name === input.name)) + if (!provided && !(roots && TASK_INPUTS.has(input.name))) missing.push({ nodeId: node.id, name: input.name }) + } + } + return missing +} + +function producedValue(graph, nodeId, name, produced) { + const direct = predecessors(graph, nodeId) + for (const id of direct) { + const value = produced?.[id]?.[name] + if (typeof value === 'string' && value !== '') return value + } + for (const id of ancestors(graph, nodeId)) { + if (direct.includes(id)) continue + const value = produced?.[id]?.[name] + if (typeof value === 'string' && value !== '') return value + } + return '' +} + +export function resolveInputs(graph, nodeId, produced, task) { + ensure(graph) + const node = nodeById(graph, nodeId) + const bag = {} + const roots = predecessors(graph, nodeId).length === 0 + for (const input of node?.inputs ?? []) { + let value = producedValue(graph, nodeId, input.name, produced) + if (!value && roots && TASK_INPUTS.has(input.name)) { + if (input.name === 'title' || input.name === 'brief') value = task?.title ?? '' + else value = `${task?.id ?? ''} ${task?.title ?? ''}`.trim() + } + if (value) bag[input.name] = value + } + return bag +} + +export function unmetRequired(graph, nodeId, produced, task) { + const node = nodeById(graph, nodeId) + const have = resolveInputs(graph, nodeId, produced, task) + return (node?.inputs ?? []).filter(input => input.required === true && !have[input.name]).map(input => input.name) +} + +function settled(value) { + return value === 'done' || value === 'skipped' } // The first rank that is not finished, or -1 when every rank is done. +// A skipped node is settled: an untaken branch does not hold the rank open. export function frontierIndex(graph, status) { ensure(graph) for (let index = 0; index < graph.layers.length; index += 1) { - if (graph.layers[index].some(id => status[id] !== 'done')) return index + if (graph.layers[index].some(id => !settled(status[id]))) return index } return -1 } -// Start every queued Bot whose predecessors are all done. -export function startFrontier(graph, status) { +function predSatisfied(graph, id, status, fired) { + const incoming = forwardIncoming(graph, id) + if (incoming.length === 0) return true + if (!incoming.every(edge => settled(status[edge.from]))) return false + return incoming.some(edge => edgeOpen(edge, status, fired)) +} + +// Start every queued Bot whose forward predecessors are settled, and at least +// one incoming edge was taken. fired is omitted for a graph with no conditions. +export function startFrontier(graph, status, fired) { ensure(graph) const next = copyStatus(status) const started = [] for (const id of orderedIds(graph, next, 'queued')) { - if (!predecessors(graph, id).every(pred => next[pred] === 'done')) continue + if (!predSatisfied(graph, id, next, fired)) continue next[id] = 'running' started.push(id) } - const finished = graph.nodes.every(node => next[node.id] === 'done') + const finished = graph.nodes.every(node => settled(next[node.id])) return { status: next, started, finished } } -export function completeNode(graph, status, nodeId) { +function takenFrom(graph, nodeId, outputs) { + const out = forwardEdges(graph).filter(edge => edge.from === nodeId) + const matched = out.some(edge => edge.when && edge.when.else !== true && outputs?.[edge.when.output] === edge.when.eq) + const fired = {} + for (const edge of out) { + let take = false + if (!edge.when) take = true + else if (edge.when.else === true) take = !matched + else take = outputs?.[edge.when.output] === edge.when.eq + if (take) fired[edgeKey(edge)] = true + } + return fired +} + +function loopDecision(graph, nodeId, outputs, loops) { + const edges = graph.edges.filter(edge => edge.loop === true && edge.from === nodeId) + for (const edge of edges) { + const ran = (Number(loops?.[edgeKey(edge)]) || 0) + 1 + if (edge.until && outputs?.[edge.until.output] === edge.until.eq) continue + const max = Number.isInteger(edge.max) ? edge.max : 3 + if (ran >= max) continue + return { edge, ran } + } + return null +} + +// Nodes the loop runs again: forward-reachable from the target that can still +// reach the source. A self-loop is that one node. The exit stays queued. +function loopBody(graph, source, target) { + if (source === target) return new Set([source]) + const forward = new Map() + for (const edge of forwardEdges(graph)) { + if (!forward.has(edge.from)) forward.set(edge.from, []) + forward.get(edge.from).push(edge.to) + } + const reach = new Set() + const stack = [target] + while (stack.length > 0) { + const id = stack.pop() + if (reach.has(id)) continue + reach.add(id) + for (const to of forward.get(id) ?? []) stack.push(to) + } + const rev = new Map() + for (const [from, tos] of forward) { + for (const to of tos) { + if (!rev.has(to)) rev.set(to, []) + rev.get(to).push(from) + } + } + const can = new Set() + const back = [source] + while (back.length > 0) { + const id = back.pop() + if (can.has(id)) continue + can.add(id) + for (const from of rev.get(id) ?? []) back.push(from) + } + const body = new Set() + for (const id of reach) if (can.has(id)) body.add(id) + body.add(source) + body.add(target) + return body +} + +function skipClosed(graph, status, fired) { + const skipped = [] + let changed = true + while (changed) { + changed = false + for (const node of graph.nodes) { + if (status[node.id] !== 'queued') continue + const incoming = forwardIncoming(graph, node.id) + if (incoming.length === 0) continue + const pending = incoming.some(edge => !settled(status[edge.from])) + if (pending) continue + if (incoming.some(edge => edgeOpen(edge, status, fired))) continue + status[node.id] = 'skipped' + skipped.push(node.id) + changed = true + } + } + return skipped +} + +export function completeNode(graph, status, nodeId, ctx = {}) { ensure(graph) if (!nodeById(graph, nodeId)) throw new Error(`Unknown Bot ${nodeId}`) if (status[nodeId] !== 'running') throw new Error(`${nodeId} is not running`) @@ -327,9 +579,51 @@ export function completeNode(graph, status, nodeId) { next[nodeId] = 'done' const index = layerIndex(graph, nodeId) const layer = graph.layers[index] - const joined = layer.every(id => next[id] === 'done') - const follow = startFrontier(graph, next) - return { status: follow.status, started: follow.started, joined, finished: follow.finished, layerSize: layer.length } + const outputs = ctx.outputs && typeof ctx.outputs === 'object' ? ctx.outputs : {} + const loops = { ...(ctx.loops ?? {}) } + const fired = { ...(ctx.fired ?? {}) } + const looping = loopDecision(graph, nodeId, outputs, ctx.loops) + if (looping) { + loops[edgeKey(looping.edge)] = looping.ran + const body = loopBody(graph, nodeId, looping.edge.to) + const reset = [] + for (const id of body) { + next[id] = id === looping.edge.to ? 'running' : 'queued' + reset.push(id) + } + // The previous pass's choice stays in `fired`, and a skipped sibling is + // outside the body. skipClosed only moves queued nodes, so the old branch + // would open again. Drop decisions the body made, and queue every skipped + // node a reset node points at, so the next pass can take the other side. + const opened = new Set(body) + let grew = true + while (grew) { + grew = false + for (const edge of forwardEdges(graph)) { + if (!opened.has(edge.from) || next[edge.to] !== 'skipped') continue + next[edge.to] = 'queued' + reset.push(edge.to) + opened.add(edge.to) + grew = true + } + } + for (const key of Object.keys(fired)) { + const from = key.split('>')[0] + if (opened.has(from)) delete fired[key] + } + return { + status: next, started: [looping.edge.to], joined: false, finished: false, layerSize: layer.length, + loops, fired, looped: true, reset, skipped: [], + } + } + Object.assign(fired, takenFrom(graph, nodeId, outputs)) + const skipped = skipClosed(graph, next, fired) + const follow = startFrontier(graph, next, fired) + const joined = layer.every(id => settled(follow.status[id])) + return { + status: follow.status, started: follow.started, joined, finished: follow.finished, layerSize: layer.length, + loops, fired, looped: false, reset: [], skipped, + } } export function failNode(graph, status, nodeId) { @@ -379,13 +673,13 @@ export function layerMarks(graph, status) { return graph.layers.map(layer => { if (layer.some(id => status[id] === 'failed')) return 'failed' if (layer.some(id => status[id] === 'running')) return 'running' - if (layer.every(id => status[id] === 'done')) return 'done' + if (layer.every(id => settled(status[id]))) return 'done' return 'queued' }) } export function doneCount(status) { - return Object.values(status).filter(value => value === 'done').length + return Object.values(status).filter(value => value === 'done' || value === 'skipped').length } export function chainLabel(graph) { diff --git a/plugins/workflow/src/narrate.js b/plugins/workflow/src/narrate.js index 4ccd4e2f..dbb53c05 100644 --- a/plugins/workflow/src/narrate.js +++ b/plugins/workflow/src/narrate.js @@ -29,7 +29,45 @@ function attachmentsOf(workflow, nodeId) { return undefined } +function liveText(value, fallback) { + const text = String(value ?? '').replace(/\s+/g, ' ').trim() + return text || fallback +} + +// A live run quotes the Bot. The delivery script's canned lines stay on the demo path. +function liveLines(workflow, task, event) { + const label = `${task?.id ?? ''} ${task?.title ?? ''}`.trim() + if (event.type === 'start') { + return [{ kind: 'system', tone: 'accent', text: `任务 ${label} 已进入 Workflow,${names(workflow, event.started)} 认领` }] + } + if (event.type === 'fail') { + const node = nodeById(workflow, event.nodeId) + const text = liveText(event.text, `${node?.name ?? event.nodeId} 这一步失败了,停在本节点。`) + return [{ kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), text }] + } + if (event.type === 'retry') { + const node = nodeById(workflow, event.nodeId) + return [{ kind: 'system', tone: 'bad', text: `↻ ${node?.name ?? event.nodeId} 失败后重试` }] + } + if (event.type === 'complete') { + const node = nodeById(workflow, event.nodeId) + const lines = [{ + kind: 'bot', botId: event.nodeId, step: stepLabel(workflow, event.nodeId), + text: liveText(event.text, `${node?.name ?? event.nodeId} finished this step.`), + }] + if (event.started.length > 1) lines.push({ kind: 'system', text: `∥ 并行分支开始:${names(workflow, event.started)}` }) + if (event.joined && event.layerSize > 1 && event.started.length > 0) { + lines.push({ kind: 'system', text: `⇢ 并行分支汇合(join)→ ${names(workflow, event.started)}` }) + } + if (event.finished) lines.push({ kind: 'system', tone: 'ok', text: `✓ Workflow 运行完成 · ${task.id} · ${workflow.nodes.length} 个节点` }) + return lines + } + if (event.type === 'note') return [{ kind: 'system', tone: event.tone, text: event.text }] + return [] +} + export function linesFor(workflow, task, event) { + if (event.live === true) return liveLines(workflow, task, event) const label = `${task?.id ?? ''} ${task?.title ?? ''}`.trim() if (event.type === 'start') { return [{ kind: 'system', tone: 'accent', text: `任务 ${label} 已进入 Workflow,${names(workflow, event.started)} 认领` }] diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index 1a49f63d..4dbed523 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -1,15 +1,16 @@ -// Workflow records and the run that a board task starts. The graph rules live -// in graph.js; this file stores workflows, appends the chat, and refuses a -// remote Bot or a read-only host. A task id is recorded on the run. Progress -// lines stay scripted. With a spawn hook, each node also gets a run Bot. +// Workflow records and the runs a board task starts. The graph rules live in +// graph.js. A workflow keeps a FIFO queue and up to maxConcurrent live runs +// (default 2). A live node stays running until its Bot replies. The demo +// player is a separate script and does not move a live run. import { randomBytes } from 'node:crypto' import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { releaseAll, spawnAll } from './ephemeral.js' import { - chainLabel, cleanLine, cleanPrompt, cleanTools, completeNode, doneCount, failNode, freshStatus, layerMarks, - nextDemoOp, nodeById, normalizeGraph, orderedIds, retryNode, rowPreview, startFrontier, validateGraph, + chainLabel, cleanLine, cleanPorts, cleanPrompt, cleanTools, completeNode, doneCount, failNode, freshStatus, layerMarks, + missingProviders, nextDemoOp, nodeById, normalizeGraph, orderedIds, readStepResult, resolveInputs, retryNode, + rowPreview, startFrontier, unmetRequired, validateGraph, } from './graph.js' import { matchTask } from './match.js' import { linesFor } from './narrate.js' @@ -18,6 +19,9 @@ import { DELIVERY_ID, DELIVERY_STEPS, WEEKLY_ID, deliveryWorkflow, fixtureTask, const MAX_WORKFLOWS = 24 const MAX_MESSAGES = 200 const FILE = 'workflows.json' +const DEFAULT_CONCURRENT = 2 +const MAX_CONCURRENT = 8 +const HOST_CAP = 6 function idOf(prefix) { return `${prefix}-${randomBytes(4).toString('hex')}` @@ -38,11 +42,24 @@ function publicNode(node, bots) { tools: Array.isArray(node.tools) ? [...node.tools] : [], failOnce: node.failOnce === true, botId: seated?.id ?? null, + ...(Array.isArray(node.inputs) && node.inputs.length ? { inputs: node.inputs.map(port => ({ ...port })) } : {}), + ...(Array.isArray(node.outputs) && node.outputs.length ? { outputs: node.outputs.map(port => ({ ...port })) } : {}), x: node.x, y: node.y, } } +function publicEdge(edge) { + const out = { from: edge.from, to: edge.to } + if (edge.loop === true) { + out.loop = true + out.max = edge.max + if (edge.until) out.until = { output: edge.until.output, eq: edge.until.eq } + } else if (edge.when?.else === true) out.when = { else: true } + else if (edge.when) out.when = { output: edge.when.output, eq: edge.when.eq } + return out +} + function publicRun(workflow, run) { if (!run) return null return { @@ -60,6 +77,7 @@ function publicRun(workflow, run) { history: run.history ?? [], startedAt: run.startedAt, updatedAt: run.updatedAt, + ...(run.live === true ? { live: true } : {}), ...(run.ephemeral && Object.keys(run.ephemeral).length > 0 ? { ephemeral: { ...run.ephemeral } } : {}), } } @@ -74,6 +92,7 @@ function blankRun(workflow, task, now, history = []) { steps: 0, stepBudget: workflow.id === DELIVERY_ID ? DELIVERY_STEPS : workflow.nodes.length + 1, failedOnce: {}, + produced: {}, history, startedAt: now, updatedAt: now, @@ -84,7 +103,19 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no const file = dir ? join(dir, FILE) : null let state = load(file, warn) ?? seed(now()) state.routes ??= {} + state.claims ??= {} + state.lanes ??= {} + state.demos ??= {} + if (!state.laneFormat) migrateRuns(state) let seq = state.seq ?? 1 + // One lock for every start and release. The host cap is read and updated + // only while this is held, so two workflows cannot both pass it. + let hostTail = Promise.resolve() + function hostSerial(fn) { + const run = hostTail.then(() => fn()) + hostTail = run.then(() => {}, () => {}) + return run + } function write() { if (!persist || !file) return @@ -138,114 +169,439 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no trigger: item.trigger ?? 'manual', scheduleLabel: item.scheduleLabel ?? '', nodes: item.nodes.map(node => publicNode(node, roster)), - edges: (item.edges ?? []).map(edge => ({ from: edge.from, to: edge.to })), + edges: (item.edges ?? []).map(publicEdge), layers: item.layers.map(layer => [...layer]), triggers: [...(item.triggers ?? [])], chain: chainLabel(item), - preview: rowPreview(item, state.runs[item.id]), - run: publicRun(item, state.runs[item.id]), + maxConcurrent: maxOf(item), + activeCount: runningOf(laneOf(item.id)).length, + queueDepth: (laneOf(item.id).queue ?? []).length, + queue: (laneOf(item.id).queue ?? []).map(task => ({ id: task.id, title: task.title })), + history: [...(laneOf(item.id).history ?? [])], + runs: runningOf(laneOf(item.id)).map(run => publicRun(item, run)), + preview: rowPreview(item, primaryRun(item.id)), + run: publicRun(item, primaryRun(item.id)), messages: state.logs[item.id] ?? [], }) }), } } + function laneOf(id) { + state.lanes[id] ??= { active: [], queue: [], last: null, history: [] } + const lane = state.lanes[id] + lane.active ??= [] + lane.queue ??= [] + lane.history ??= [] + return lane + } + + function runningOf(lane) { + return (lane?.active ?? []).filter(run => run && run.status === 'running') + } + + function globalRunning() { + let count = 0 + for (const lane of Object.values(state.lanes)) count += runningOf(lane).length + return count + } + + function maxOf(item) { + const value = Number(item?.maxConcurrent) + if (!Number.isInteger(value) || value < 1) return DEFAULT_CONCURRENT + return Math.min(MAX_CONCURRENT, value) + } + + function primaryRun(id) { + const live = runningOf(laneOf(id)) + if (live.length > 0) return live[0] + // A finished lane.last stays up until Replay. demoStep marks the script + // as the run on stage; otherwise the graph stays on that finished lane + // while the canned lines append to the demo. + const demo = state.demos[id] + if (state.demoOnTop?.[id] === true && demo) return demo + if (laneOf(id).last) return laneOf(id).last + return demo ?? null + } + + function storeRun(workflowId, run) { + const lane = laneOf(workflowId) + const index = lane.active.findIndex(item => item.id === run.id) + if (index >= 0) lane.active[index] = run + else if (state.demos[workflowId]?.id === run.id) state.demos[workflowId] = run + } + + function sentOk(result) { + if (result == null) return true + if (result.ok === false) return false + if (result.action === 'deny' || result.action === 'hold') return false + return true + } + + function findEphemeral(botId) { + if (!botId) return null + for (const [workflowId, lane] of Object.entries(state.lanes)) { + for (const run of lane.active ?? []) { + const nodeId = Object.entries(run.ephemeral ?? {}).find(([, id]) => id === botId)?.[0] + if (!nodeId) continue + const item = workflow(workflowId) + if (item) return { item, run, nodeId } + } + } + return null + } + function refuse(error) { return { ok: false, error: error?.message ?? String(error) } } + function gateStarted(item, run, result) { + // A run with Bots waits for each node's outputs. A host that cannot spawn + // still walks the graph when advance() is called directly. + if (run.live !== true || !run.ephemeral) return { result, blocked: [] } + const status = { ...result.status } + const started = [] + const blocked = [] + for (const id of result.started ?? []) { + const missing = unmetRequired(item, id, run.produced, run.task) + if (missing.length === 0) { started.push(id); continue } + const preds = item.edges.filter(edge => edge.to === id && edge.loop !== true).map(edge => edge.from) + const stuck = preds.length === 0 || preds.every(pred => status[pred] === 'done' || status[pred] === 'failed' || status[pred] === 'skipped') + if (stuck) { + status[id] = 'failed' + blocked.push({ id, missing }) + } else status[id] = 'queued' + } + const finished = item.nodes.every(node => status[node.id] === 'done' || status[node.id] === 'skipped') + return { result: { ...result, status, started, finished }, blocked } + } + function applyOp(item, run, op) { + let working = run + if (op.type === 'complete' && op.outputs && typeof op.outputs === 'object') { + working = { ...run, produced: { ...(run.produced ?? {}), [op.nodeId]: op.outputs } } + } let result - if (op.type === 'start') result = { ...startFrontier(item, run.nodeStatus), joined: false, layerSize: 0 } - else if (op.type === 'complete') result = completeNode(item, run.nodeStatus, op.nodeId) - else if (op.type === 'fail') result = failNode(item, run.nodeStatus, op.nodeId) - else if (op.type === 'retry') result = retryNode(item, run.nodeStatus, op.nodeId) + if (op.type === 'start') result = { ...startFrontier(item, working.nodeStatus, working.fired), joined: false, layerSize: 0 } + else if (op.type === 'complete') result = completeNode(item, working.nodeStatus, op.nodeId, { + outputs: working.produced?.[op.nodeId] ?? {}, + loops: working.loops ?? {}, + fired: working.fired ?? {}, + }) + else if (op.type === 'fail') result = failNode(item, working.nodeStatus, op.nodeId) + else if (op.type === 'retry') result = retryNode(item, working.nodeStatus, op.nodeId) else throw new Error(`Unknown step ${op.type}`) - const failedOnce = { ...run.failedOnce } + const gated = gateStarted(item, working, result) + result = gated.result + const failedOnce = { ...working.failedOnce } if (op.type === 'fail') failedOnce[op.nodeId] = true - const finished = result.finished === true - // A node marked failOnce is retried by the demo player. Any other failure - // stops the run, which is what releases its ephemeral Bots. - const stopped = op.type === 'fail' && nodeById(item, op.nodeId)?.failOnce !== true - const steps = finished ? run.stepBudget : run.steps + 1 - const history = finished || stopped - ? [...(run.history ?? []), { task: run.task, status: finished ? 'done' : 'failed', at: now() }].slice(-20) - : (run.history ?? []) - const handed = { ...run.handed } - if (op.type === 'fail') delete handed[op.nodeId] + const blocked = gated.blocked.length > 0 + const finished = result.finished === true && !blocked + // A node marked failOnce is retried. Any other failure stops the run, + // which is what releases its ephemeral Bots. A missing required input + // stops the run the same way: the successor can never start. + const failOnce = op.type === 'fail' && nodeById(item, op.nodeId)?.failOnce === true + const stopped = blocked || (op.type === 'fail' && !failOnce) + const steps = finished ? working.stepBudget : working.steps + 1 + const handed = { ...working.handed } + if (op.type === 'fail' || op.type === 'retry') delete handed[op.nodeId] + if (Array.isArray(result.reset)) for (const id of result.reset) delete handed[id] const next = { - ...run, + ...working, status: finished ? 'done' : stopped ? 'failed' : 'running', nodeStatus: result.status, steps, failedOnce, - history, updatedAt: now(), + ...(result.loops ? { loops: result.loops } : {}), + ...(result.fired ? { fired: result.fired } : {}), ...(Object.keys(handed).length > 0 ? { handed } : {}), } if (Object.keys(handed).length === 0) delete next.handed - const event = { + const lines = linesFor(item, working.task, { type: op.type, nodeId: op.nodeId, started: result.started ?? [], joined: result.joined === true, finished, layerSize: result.layerSize ?? 0, + live: working.live === true, + text: op.text ?? '', + }) + for (const hole of gated.blocked) { + const node = nodeById(item, hole.id) + lines.push({ kind: 'system', tone: 'bad', text: `${node?.name ?? hole.id} 不能开始:缺少输入 ${hole.missing.join(', ')}。` }) } - return { run: next, lines: linesFor(item, run.task, event) } + return { run: next, lines } } + function stepBody(item, run, node) { + const inputs = resolveInputs(item, node.id, run.produced, run.task) + return { + botId: run.ephemeral[node.id], + nodeId: node.id, + name: node.name, + role: node.role ?? '', + prompt: node.prompt ?? node.instructions ?? '', + model: node.model ?? '', + tools: node.tools ?? [], + brief: run.task?.title ?? '', + taskId: run.task?.id ?? '', + inputs, + outputs: node.outputs ?? [], + } + } + + // A dispatch that does not land is not "handed". Say so in the chat and + // leave the node open: the next hand-off tries again. No second send in + // this call — a 429 must not be hammered. async function handOff(item, run) { - if (typeof dispatch !== 'function' || !run?.ephemeral) return - const running = orderedIds(item, run.nodeStatus, 'running') - let wrote = false + if (typeof dispatch !== 'function' || !run?.ephemeral) return run + let current = run + const running = orderedIds(item, current.nodeStatus, 'running') for (const nodeId of running) { - if (run.handed?.[nodeId]) continue + if (current.handed?.[nodeId]) continue + if (current.status === 'failed' || current.status === 'done') break const node = nodeById(item, nodeId) - const botId = run.ephemeral[nodeId] + const botId = current.ephemeral?.[nodeId] if (!botId || !node) continue - const answer = await dispatch({ - botId, - nodeId, - name: node.name, - role: node.role ?? '', - prompt: node.prompt ?? node.instructions ?? '', - model: node.model ?? '', - tools: node.tools ?? [], - brief: run.task?.title ?? '', - taskId: run.task?.id ?? '', - }) - // A refused step is not handed over: leave it unmarked so the next settle - // tries again, and say why in one line — otherwise the run stalls silently. - if (answer?.ok === false) { - warn(`flybot-workflow: step ${nodeId} was not handed over: ${answer.code ?? 'dispatch refused'}`) + const body = stepBody(item, current, node) + let result + try { + result = await dispatch(body) + } catch (error) { + result = { ok: false, code: 'dispatch-failed', error: error?.message ?? String(error) } + } + if (!sentOk(result)) { + const code = result?.code || result?.action || 'dispatch-failed' + warn(`flybot-workflow: step ${nodeId} was not handed over: ${code}`) + push(item.id, [{ kind: 'system', tone: 'bad', text: `${node.name} 没有接到这一步(${code})。` }], now()) continue } - run.handed = { ...run.handed, [nodeId]: true } - wrote = true + current = { ...current, handed: { ...current.handed, [nodeId]: true }, inputs: { ...(current.inputs ?? {}), [nodeId]: body.inputs } } + storeRun(item.id, current) + } + write() + return current + } + + async function releaseRun(workflowId, run) { + const ids = Object.values(run.ephemeral ?? {}) + delete run.ephemeral + delete run.handed + const lane = laneOf(workflowId) + lane.active = lane.active.filter(item => item.id !== run.id) + lane.last = run + if (run.status === 'done' || run.status === 'failed') { + lane.history = [...lane.history, { task: run.task, status: run.status, at: run.updatedAt }].slice(-20) } - if (wrote) write() + await releaseAll(ids, release, warn) + write() + } + + // The oldest queued task whose own workflow still has a slot, or null. + function takeNext() { + let best = null + for (const item of state.workflows) { + const lane = laneOf(item.id) + if (lane.queue.length === 0) continue + if (runningOf(lane).length >= maxOf(item)) continue + const task = lane.queue[0] + const at = Number(task.enqueuedAt) || 0 + const order = Number(task.queueSeq) || 0 + if (!best || at < best.at || (at === best.at && order < best.order)) best = { item, at, order } + } + if (!best) return null + const task = laneOf(best.item.id).queue.shift() + return { item: best.item, task: { id: task.id, title: task.title } } + } + + function noteUnstarted(item, error) { + push(item.id, [{ kind: 'system', tone: 'bad', text: error ?? 'The queued task could not start' }], now()) + write() + } + + // After a slot frees, start the oldest parked task on any workflow that + // still has room, and stop at the host cap. + function drainSync() { + let guard = 0 + while (globalRunning() < HOST_CAP && guard < 48) { + guard += 1 + const next = takeNext() + if (!next) return + const begun = beginRun(next.item, next.task) + if (begun.ok === false) { + noteUnstarted(next.item, begun.error) + return + } + } + } + + async function drainAll() { + if (typeof spawn !== 'function') { + drainSync() + return + } + let guard = 0 + while (globalRunning() < HOST_CAP && guard < 48) { + guard += 1 + const next = takeNext() + if (!next) return + const started = await startLive(next.item, next.task) + if (started?.ok === false) { + noteUnstarted(next.item, started.error) + return + } + } + } + + function rememberClaim(taskId, decision) { + state.claims[taskId] = decision + state.routes[taskId] = { key: `${decision.kind}:${decision.id}`, kind: decision.kind, id: decision.id, at: now() } + } + + function routeBot(body) { + const botId = cleanLine(body.botId, 80) + const bot = (bots() ?? []).find(item => item && item.id === botId && item.remote !== true) + if (!bot) return { ok: false, error: 'No such Bot' } + const taskId = cleanLine(body.taskId ?? body.id, 80) + const title = cleanLine(body.title, 300) + if (!taskId || !title) return { ok: false, error: 'A task needs an id and a title' } + rememberClaim(taskId, { kind: 'bot', id: bot.id }) + write() + return { ok: true, matched: true, decision: { kind: 'bot', id: bot.id }, ownerBotId: bot.id } + } + + function queuedOrRunning(workflowId, taskId) { + const lane = laneOf(workflowId) + return runningOf(lane).some(run => run.task?.id === taskId) || lane.queue.some(task => task.id === taskId) + } + + function beginRun(item, task, ephemeral) { + const holes = missingProviders(item) + if (holes.length > 0) return { ok: false, error: `Node ${holes[0].nodeId} input ${holes[0].name} has no upstream provider` } + delete state.demoOnTop?.[item.id] + let run = { ...blankRun(item, task, now(), []), live: true } + if (ephemeral) run.ephemeral = ephemeral + const at = now() + push(item.id, [ + { kind: 'time', text: '刚刚' }, + { kind: 'system', text: `来自 Tasks 看板:${task.id} ${task.title} → 分配给 Workflow「${item.name}」` }, + ], at) + const stepped = applyOp(item, run, { type: 'start' }) + run = stepped.run + laneOf(item.id).active.push(run) + push(item.id, stepped.lines, at) + write() + return { ok: true, run } + } + + async function startLive(item, task) { + let ephemeral + if (typeof spawn === 'function') { + const holes = missingProviders(item) + if (holes.length > 0) return { ok: false, error: `Node ${holes[0].nodeId} input ${holes[0].name} has no upstream provider` } + try { + ephemeral = await spawnAll(item.nodes, bots() ?? [], spawn) + } catch (error) { + await releaseAll(error.spawned ?? [], release, warn) + return refuse(error) + } + } + const begun = beginRun(item, task, ephemeral) + if (begun.ok === false) { + if (ephemeral) await releaseAll(Object.values(ephemeral), release, warn) + return begun + } + let run = begun.run + if (typeof spawn === 'function') { + try { + run = await handOff(item, run) + } catch (error) { + await releaseRun(item.id, { ...run, status: 'failed', updatedAt: now() }) + return refuse(error) + } + } + if (run.status === 'done' || run.status === 'failed') await releaseRun(item.id, run) + return present() + } + + function park(item, task) { + const lane = laneOf(item.id) + state.queueSeq = (state.queueSeq ?? 0) + 1 + lane.queue.push({ id: task.id, title: task.title, enqueuedAt: now(), queueSeq: state.queueSeq }) + push(item.id, [{ kind: 'system', text: `排队:${task.id} ${task.title}(前面 ${lane.queue.length - 1} 个,同时运行上限 ${maxOf(item)})` }], now()) + write() + } + + function hasRoom(item) { + return runningOf(laneOf(item.id)).length < maxOf(item) && globalRunning() < HOST_CAP + } + + // A non-empty queue is joined at the tail, then the oldest task starts. + // A newer assign never runs ahead of one already parked. + function enqueue(item, task) { + if (queuedOrRunning(item.id, task.id)) return { ...present(), already: true } + const lane = laneOf(item.id) + const behind = lane.queue.length > 0 + if (behind || !hasRoom(item)) { + park(item, task) + if (typeof spawn !== 'function') { + drainSync() + return present() + } + return drainAll().then(() => present()) + } + if (typeof spawn !== 'function') { + const begun = beginRun(item, task) + if (begun.ok === false) return begun + drainSync() + return present() + } + return startLive(item, task).then(async (started) => { + if (started?.ok === false) return started + await drainAll() + return present() + }) } - async function settle(workflowId) { + function runFor(workflowId, runId, nodeId) { + const lane = laneOf(workflowId) + if (runId) return lane.active.find(run => run.id === runId) ?? null + const live = lane.active.find(run => run.status === 'running' && (!nodeId || run.nodeStatus?.[nodeId] === 'running')) + if (live) return live + return state.demos[workflowId] ?? null + } + + function commitAdvance(workflowId, op) { const item = workflow(workflowId) - const run = item ? state.runs[workflowId] : undefined - if (!item || !run || typeof spawn !== 'function') return present() + if (!item) return { ok: false, error: 'No such workflow' } + const run = runFor(workflowId, op.runId, op.nodeId) + if (!run || run.status === 'idle') return { ok: false, error: 'This workflow has no running task' } + if (run.status === 'done' || run.status === 'failed') return { ok: false, error: 'This run is already finished' } try { - await handOff(item, run) - } catch (error) { - await releaseAll(Object.values(run.ephemeral ?? {}), release, warn) - delete run.ephemeral - delete run.handed - run.status = 'failed' - run.updatedAt = now() + let stepped = applyOp(item, run, op) + if (op.type === 'fail' && nodeById(item, op.nodeId)?.failOnce === true && stepped.run.status === 'running') { + const again = applyOp(item, stepped.run, { type: 'retry', nodeId: op.nodeId }) + stepped = { run: again.run, lines: [...stepped.lines, ...again.lines] } + } + storeRun(workflowId, stepped.run) + push(workflowId, stepped.lines, now()) write() + return { ok: true, runId: stepped.run.id } + } catch (error) { return refuse(error) } - if (run.status === 'done' || run.status === 'failed') { - await releaseAll(Object.values(run.ephemeral ?? {}), release, warn) - delete run.ephemeral - delete run.handed - write() + } + + async function afterStep(workflowId, runId) { + const item = workflow(workflowId) + const lane = laneOf(workflowId) + const run = lane.active.find(itemRun => itemRun.id === runId) + if (item && run && run.status === 'running' && typeof spawn === 'function') await handOff(item, run) + const latest = lane.active.find(itemRun => itemRun.id === runId) + if (latest && (latest.status === 'done' || latest.status === 'failed')) { + await releaseRun(workflowId, latest) + await drainAll() } return present() } @@ -303,6 +659,8 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no ...(Array.isArray(body.edges) ? { edges: body.edges } : { layers: body.layers }), }) if (!item.name) throw new Error('A workflow needs a name') + const cap = Number(body.maxConcurrent) + if (Number.isInteger(cap) && cap >= 1 && cap <= MAX_CONCURRENT) item.maxConcurrent = cap state.workflows.push(item) const memberNames = item.nodes.map(node => node.name).join('、') push(item.id, [{ kind: 'system', text: `Workflow「${item.name}」已创建 · 成员 ${memberNames}` }], now()) @@ -312,132 +670,145 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no return refuse(error) } }, - // A board task starts a run at the first layer and posts the claim line. - // With a spawn hook, each node gets an ephemeral Bot before the first step. + // A board task joins the workflow's FIFO queue. A free slot starts it. + // A live run is never replaced. `botId` routes the card to that Bot and + // does not start a run. assign(body = {}) { if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + if (body.botId) return routeBot(body) const workflowId = String(body.workflowId ?? '') const item = workflow(workflowId) if (!item) return { ok: false, error: 'No such workflow' } const task = { id: cleanLine(body.taskId ?? body.id, 80), title: cleanLine(body.title, 300) } if (!task.id || !task.title) return { ok: false, error: 'A task needs an id and a title' } - const go = (ephemeral) => mutate(workflowId, (row) => { - const previous = state.runs[row.id] - const history = previous ? [...(previous.history ?? []), { task: previous.task, status: previous.status, at: previous.updatedAt }].slice(-20) : [] - let run = blankRun(row, task, now(), history) - if (ephemeral) run.ephemeral = ephemeral - const at = now() - push(row.id, [ - { kind: 'time', text: '刚刚' }, - { kind: 'system', text: `来自 Tasks 看板:${task.id} ${task.title} → 分配给 Workflow「${row.name}」` }, - ], at) - const stepped = applyOp(row, run, { type: 'start' }) - run = stepped.run - push(row.id, stepped.lines, at) - state.runs[row.id] = run + if (typeof spawn !== 'function') return enqueue(item, task) + return hostSerial(() => enqueue(item, task)) + }, + setConcurrency(body = {}) { + const workflowId = String(body.workflowId ?? '') + const value = Number(body.maxConcurrent) + const saved = mutate(workflowId, (item) => { + if (!Number.isInteger(value) || value < 1 || value > MAX_CONCURRENT) throw new Error('maxConcurrent is an integer from 1 to 8') + item.maxConcurrent = value + }) + if (saved.ok === false) return saved + if (typeof spawn !== 'function') { + drainSync() + return present() + } + return hostSerial(async () => { + await drainAll() + return present() }) - if (typeof spawn !== 'function') return go(undefined) - return (async () => { - const previousIds = Object.values(state.runs[workflowId]?.ephemeral ?? {}) - let ephemeral - try { - ephemeral = await spawnAll(item.nodes, bots() ?? [], spawn) - } catch (error) { - await releaseAll(error.spawned ?? [], release, warn) - return refuse(error) - } - const saved = go(ephemeral) - if (saved.ok === false) { - await releaseAll(Object.values(ephemeral), release, warn) - return saved - } - await releaseAll(previousIds, release, warn) - return settle(workflowId) - })() }, advance(body = {}) { const workflowId = String(body.workflowId ?? '') const outcome = body.outcome === 'failed' ? 'fail' : 'complete' const nodeId = String(body.nodeId ?? '') - const step = () => mutate(workflowId, (item) => { - const run = state.runs[item.id] - if (!run || run.status === 'idle') throw new Error('This workflow has no running task') - if (run.status === 'done' || run.status === 'failed') throw new Error('This run is already finished') - const stepped = applyOp(item, run, { type: outcome, nodeId }) - state.runs[item.id] = stepped.run - push(item.id, stepped.lines, now()) - }) - if (typeof spawn !== 'function') return step() - return (async () => { + const step = () => commitAdvance(workflowId, { type: outcome, nodeId, text: body.text, outputs: body.outputs, runId: body.runId }) + if (typeof spawn !== 'function') { const saved = step() - if (saved.ok === false) return saved - return settle(workflowId) - })() + if (saved?.ok === false) return saved + const lane = laneOf(workflowId) + const run = lane.active.find(item => item.id === saved.runId) + if (run && (run.status === 'done' || run.status === 'failed')) { + lane.active = lane.active.filter(item => item.id !== run.id) + lane.last = run + lane.history = [...lane.history, { task: run.task, status: run.status, at: run.updatedAt }].slice(-20) + drainSync() + write() + } + return present() + } + return hostSerial(async () => { + const saved = step() + if (saved?.ok === false) return saved + return afterStep(workflowId, saved.runId) + }) + }, + // The Bot's reply is the step result. A node that is no longer running + // has already moved; a second report does not move it again. + stepResult(body = {}) { + const found = findEphemeral(String(body.botId ?? '')) + if (!found) return present() + if (found.run.nodeStatus?.[found.nodeId] !== 'running') return present() + const node = nodeById(found.item, found.nodeId) + const parsed = readStepResult(body.text ?? '', node) + const text = parsed.text || cleanLine(body.text, 2000) + return this.advance({ + workflowId: found.item.id, + runId: found.run.id, + nodeId: found.nodeId, + outcome: body.ok === false ? 'failed' : 'done', + text, + outputs: parsed.outputs, + }) }, + ownsBot(botId) { + return findEphemeral(botId) !== null + }, + // Replay the scripted demo. It never moves a live run. demoStep(body = {}) { const workflowId = String(body.workflowId ?? '') - const step = (ephemeral) => mutate(workflowId, (item) => { - let run = state.runs[item.id] + if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + const item = workflow(workflowId) + if (!item) return { ok: false, error: 'No such workflow' } + if (runningOf(laneOf(workflowId)).length > 0) return { ok: false, error: 'A live run is in progress' } + try { + let run = state.demos[item.id] if (!run) throw new Error('This workflow has no task') + state.demoOnTop ??= {} + state.demoOnTop[item.id] = true if (run.status === 'failed') throw new Error('This run is already finished') const op = nextDemoOp(item, run.nodeStatus, { failedOnce: run.failedOnce }) if (op.type === 'restart') { run = blankRun(item, run.task, now(), run.history) - if (ephemeral) run.ephemeral = ephemeral push(item.id, [{ kind: 'time', text: '重新演示' }], now()) - } else if (ephemeral && op.type === 'start') { - run = { ...run, ephemeral } } const stepped = applyOp(item, run, op.type === 'restart' ? { type: 'start' } : op) - state.runs[item.id] = stepped.run + state.demos[item.id] = stepped.run push(item.id, stepped.lines, now()) - }) - if (typeof spawn !== 'function') return step(undefined) - return (async () => { - if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } + write() + return present() + } catch (error) { + return refuse(error) + } + }, + reset(body = {}) { + const workflowId = String(body.workflowId ?? '') + const take = () => { const item = workflow(workflowId) if (!item) return { ok: false, error: 'No such workflow' } - const run = state.runs[item.id] - if (!run) return { ok: false, error: 'This workflow has no task' } - if (run.status === 'failed') return { ok: false, error: 'This run is already finished' } - const op = nextDemoOp(item, run.nodeStatus, { failedOnce: run.failedOnce }) - let ephemeral - let previousIds = [] - if (op.type === 'restart' || op.type === 'start') { - previousIds = Object.values(run.ephemeral ?? {}) - try { - ephemeral = await spawnAll(item.nodes, bots() ?? [], spawn) - } catch (error) { - await releaseAll(error.spawned ?? [], release, warn) - return refuse(error) - } + const lane = laneOf(workflowId) + const ids = lane.active.flatMap(run => Object.values(run.ephemeral ?? {})) + const live = lane.active[0] ?? lane.last + lane.active = [] + lane.queue = [] + if (live) { + const kept = { ...blankRun(item, live.task, now(), lane.history), id: live.id, startedAt: live.startedAt } + lane.last = kept + push(item.id, linesFor(item, live.task, { type: 'reset' }), now()) } - const saved = step(ephemeral) - if (saved.ok === false) { - if (ephemeral) await releaseAll(Object.values(ephemeral), release, warn) - return saved + const demo = state.demos[item.id] + if (demo) { + state.demos[item.id] = { ...blankRun(item, demo.task, now(), demo.history), id: demo.id, startedAt: demo.startedAt } + if (!live) push(item.id, linesFor(item, demo.task, { type: 'reset' }), now()) } - await releaseAll(previousIds, release, warn) - return settle(workflowId) - })() - }, - reset(body = {}) { - const workflowId = String(body.workflowId ?? '') - const step = () => mutate(workflowId, (item) => { - const run = state.runs[item.id] - if (!run) throw new Error('This workflow has no task') - const kept = { ...blankRun(item, run.task, now(), run.history), id: run.id, startedAt: run.startedAt } - state.runs[item.id] = kept - push(item.id, linesFor(item, run.task, { type: 'reset' }), now()) - }) - if (typeof spawn !== 'function') return step() - return (async () => { - const ids = Object.values(state.runs[workflowId]?.ephemeral ?? {}) - const saved = step() + if (!live && !demo) return { ok: false, error: 'This workflow has no task' } + write() + return { ok: true, ids } + } + if (typeof spawn !== 'function') { + const saved = take() + return saved.ok === false ? saved : present() + } + return hostSerial(async () => { + const saved = take() if (saved.ok === false) return saved - await releaseAll(ids, release, warn) + await releaseAll(saved.ids, release, warn) + await drainAll() return present() - })() + }) }, say(body = {}) { return mutate(String(body.workflowId ?? ''), (item) => { @@ -445,7 +816,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no if (!text) throw new Error('A message needs text') const at = now() const lines = [{ kind: 'user', text }] - const run = state.runs[item.id] + const run = primaryRun(item.id) const running = run ? orderedIds(item, run.nodeStatus, 'running')[0] : undefined if (running) { const node = nodeById(item, running) @@ -474,7 +845,7 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no item.nodes = next.nodes item.edges = next.edges item.layers = next.layers - const run = state.runs[item.id] + const run = primaryRun(item.id) if (run) { const ids = new Set(item.nodes.map(node => node.id)) const nodeStatus = {} @@ -511,62 +882,83 @@ export function createWorkflowService({ dir, persist = true, now = () => Date.no // Auto-assign an open board task. A miss leaves the task untouched. ingest(task = {}) { if (readOnly()) return { ok: false, code: 'read-only', error: 'The host is read-only' } - const decision = matchTask(task, { workflows: state.workflows, bots: bots() ?? [] }) - if (!decision) return { ok: true, matched: false } const taskId = cleanLine(task.id, 80) if (!taskId) return { ok: false, error: 'A task needs an id' } + const claimed = state.claims[taskId] + const decision = claimed?.kind === 'bot' || claimed?.kind === 'workflow' + ? claimed + : matchTask(task, { workflows: state.workflows, bots: bots() ?? [] }) + if (!decision) return { ok: true, matched: false } const key = `${decision.kind}:${decision.id}` const prev = state.routes[taskId] if (prev?.key === key) return { ok: true, matched: true, already: true, decision } - if (decision.kind === 'workflow') { - const run = state.runs[decision.id] - const live = run && run.task?.id === taskId && run.status === 'running' - if (live) { - state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } - write() - return { ok: true, matched: true, already: true, decision } - } - const title = cleanLine(task.title, 300) - if (!title) return { ok: false, error: 'A task needs a title' } - const assigned = this.assign({ workflowId: decision.id, taskId, title }) - const finish = (value) => { - if (value?.ok === false) return value - state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } - write() - return { ...present(), matched: true, already: false, decision } - } - if (typeof assigned?.then === 'function') return assigned.then(finish) - return finish(assigned) + if (decision.kind === 'bot') { + rememberClaim(taskId, decision) + write() + return { ok: true, matched: true, already: false, decision } } - state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } - write() - return { ok: true, matched: true, already: false, decision } + const demo = state.demos[decision.id] + if (demo && demo.task?.id === taskId && demo.status === 'running') { + state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } + write() + return { ok: true, matched: true, already: true, decision } + } + if (queuedOrRunning(decision.id, taskId)) { + state.routes[taskId] = { key, kind: decision.kind, id: decision.id, at: now() } + write() + return { ok: true, matched: true, already: true, decision } + } + const title = cleanLine(task.title, 300) + if (!title) return { ok: false, error: 'A task needs a title' } + const assigned = this.assign({ workflowId: decision.id, taskId, title }) + const finish = (value) => { + if (value?.ok === false) return value + rememberClaim(taskId, decision) + write() + return { ...present(), matched: true, already: false, decision } + } + if (typeof assigned?.then === 'function') return assigned.then(finish) + return finish(assigned) }, reap() { if (typeof release !== 'function') return Promise.resolve() const stale = [] - for (const run of Object.values(state.runs)) { - if (!run?.ephemeral) continue - // A run still marked running here was cut off with the host that ran it, - // and nothing resumes it: its instances are abandoned, not in progress. - // Release them and end the run, or they stay hidden with no way to remove - // them. A run that already ended keeps its own status (#1388). - const abandoned = run.status === 'running' - stale.push(...Object.values(run.ephemeral)) - delete run.ephemeral - delete run.handed - if (abandoned) { - run.status = 'failed' - run.updatedAt = now() + const abandoned = [] + for (const [workflowId, lane] of Object.entries(state.lanes ?? {})) { + const seen = new Set() + for (const run of [...(lane.active ?? []), lane.last].filter(Boolean)) { + if (!run?.ephemeral || seen.has(run)) continue + seen.add(run) + // A run still marked running here was cut off with the host that ran it, + // and nothing resumes it: its instances are abandoned, not in progress. + // Release them and end the run, or they stay hidden with no way to remove + // them. A run that already ended keeps its own status (#1388). + if (run.status === 'running') { + abandoned.push({ workflowId, run }) + continue + } + stale.push(...Object.values(run.ephemeral)) + delete run.ephemeral + delete run.handed } } - if (stale.length === 0) return Promise.resolve() + for (const { workflowId, run } of abandoned) { + stale.push(...Object.values(run.ephemeral)) + const ended = { ...run, status: 'failed', updatedAt: now() } + delete ended.ephemeral + delete ended.handed + const lane = laneOf(workflowId) + lane.active = lane.active.filter(item => item.id !== run.id) + lane.last = ended + lane.history = [...lane.history, { task: ended.task, status: ended.status, at: ended.updatedAt }].slice(-20) + } + if (stale.length === 0 && abandoned.length === 0) return Promise.resolve() return releaseAll(stale, release, warn).then(() => { write() }) }, dispose() { - const ids = Object.values(state.runs).flatMap(run => Object.values(run?.ephemeral ?? {})) - for (const run of Object.values(state.runs)) { - if (!run) continue + const runs = Object.values(state.lanes).flatMap(lane => [...(lane.active ?? []), lane.last].filter(Boolean)) + const ids = runs.flatMap(run => Object.values(run?.ephemeral ?? {})) + for (const run of runs) { delete run.ephemeral delete run.handed } @@ -585,6 +977,8 @@ function seat(nodes, roster) { const prompt = cleanPrompt(node.prompt) const model = cleanLine(node.model, 80) const tools = cleanTools(node.tools) + const inputs = cleanPorts(node.inputs, 'in') + const outputs = cleanPorts(node.outputs, 'out') return { id: cleanLine(node.id, 40).toLowerCase(), name: cleanLine(node.name || bot?.name, 80), @@ -593,6 +987,8 @@ function seat(nodes, roster) { ...(prompt ? { prompt } : {}), ...(model ? { model } : {}), ...(tools.length ? { tools } : {}), + ...(inputs.length ? { inputs } : {}), + ...(outputs.length ? { outputs } : {}), ...(botId ? { botId } : {}), ...(node.failOnce === true ? { failOnce: true } : {}), ...(Number.isFinite(Number(node.x)) ? { x: Number(node.x) } : {}), @@ -650,7 +1046,35 @@ function seed(now) { { kind: 'bot', botId: 'writer', step: '3/3', text: '周报已发布。' }, { kind: 'system', tone: 'ok', text: '✓ 运行完成' }, ]) - return { version: 1, workflows: [delivery, weekly], runs, logs, seq } + return { + version: 1, workflows: [delivery, weekly], runs: {}, demos: runs, lanes: {}, claims: {}, routes: {}, + logs, seq, laneFormat: true, + } +} + +function migrateRuns(state) { + state.lanes ??= {} + state.demos ??= {} + state.claims ??= {} + if (state.laneFormat === true) return + for (const [id, run] of Object.entries(state.runs ?? {})) { + if (!run || typeof run !== 'object' || state.lanes[id]) continue + const history = Array.isArray(run.history) ? run.history.filter(item => item?.status === 'done' || item?.status === 'failed') : [] + const bots = run.ephemeral && typeof run.ephemeral === 'object' ? Object.keys(run.ephemeral) : [] + // Only a run that still has its Bots occupies a slot. The seeded script + // has none, so it stays a demo and replay can move it. + if (bots.length > 0 && run.status === 'running') { + state.lanes[id] = { active: [{ ...run, live: true }], queue: [], last: null, history } + continue + } + const demo = { ...run } + delete demo.live + state.demos[id] = demo + const finished = run.status === 'done' || run.status === 'failed' + state.lanes[id] = { active: [], queue: [], last: finished ? { ...demo } : null, history } + } + state.runs = {} + state.laneFormat = true } function load(file, warn) { diff --git a/plugins/workflow/src/template.js b/plugins/workflow/src/template.js index fc0c6568..3826880e 100644 --- a/plugins/workflow/src/template.js +++ b/plugins/workflow/src/template.js @@ -5,26 +5,42 @@ import { chainLabel, normalizeGraph } from './graph.js' const planner = { id: 'planner', name: 'Planner', role: '需求拆解 / 排期', instructions: '读取任务卡片与上下文,拆成可执行子任务,指定验收标准,并把产出交给下游节点。', + inputs: [{ name: 'task', type: 'text', required: true }], + outputs: [{ name: 'plan', type: 'text' }], } const writer = { id: 'writer', name: 'Writer', role: '文案 / 文档', instructions: 'Draft posts, docs and release notes, keep them short, and lead with the conclusion.', + inputs: [{ name: 'plan', type: 'text', required: true }], + outputs: [{ name: 'copy', type: 'text' }], } const coder = { id: 'coder', name: 'Coder', role: '代码实现', instructions: '根据 Planner 的子任务和 Writer 的文案修改代码,提交 PR,并在 PR 描述中列出变更。', + tools: ['github', 'bash', 'read', 'edit'], + inputs: [{ name: 'copy', type: 'text', required: true }], + outputs: [{ name: 'patch', type: 'text' }], } const reviewer = { id: 'reviewer', name: 'Reviewer', role: '代码审查', instructions: '审查 Coder 的 PR:可读性、边界条件与回归风险。不通过则打回 Coder。', + tools: ['github'], + inputs: [{ name: 'patch', type: 'text', required: true }], + outputs: [{ name: 'review', type: 'text' }], } const tester = { id: 'tester', name: 'Tester', role: '自动化测试', failOnce: true, instructions: '运行单元、快照和 E2E。失败时自动重试 1 次,仍失败则停在本节点。', + tools: ['bash'], + inputs: [{ name: 'patch', type: 'text', required: true }], + outputs: [{ name: 'report', type: 'text' }], } const deployer = { id: 'deployer', name: 'Deployer', role: '发布上线', instructions: '在 Reviewer 与 Tester 都通过后合并,灰度发布,并回填任务卡片状态。', + tools: ['github'], + inputs: [{ name: 'review', type: 'text', required: true }, { name: 'report', type: 'text', required: true }], + outputs: [{ name: 'release', type: 'text' }], } export const DELIVERY_ID = 'delivery' @@ -54,9 +70,9 @@ export function weeklyWorkflow() { trigger: 'schedule', scheduleLabel: '周五 17:00', nodes: [ - { id: 'researcher', name: '研究员', role: '行业研究', instructions: '收集本周行业动态与竞品更新。' }, - { id: 'analyst', name: '数据分析师', role: '数据分析', instructions: '汇总核心指标,生成周对比。' }, - { id: 'writer', name: 'Writer', role: '成稿', instructions: '把研究数据和指标写成周报。' }, + { id: 'researcher', name: '研究员', role: '行业研究', instructions: '收集本周行业动态与竞品更新。', inputs: [{ name: 'task', type: 'text', required: true }], outputs: [{ name: 'notes', type: 'text' }] }, + { id: 'analyst', name: '数据分析师', role: '数据分析', instructions: '汇总核心指标,生成周对比。', inputs: [{ name: 'notes', type: 'text', required: true }], outputs: [{ name: 'metrics', type: 'text' }] }, + { id: 'writer', name: 'Writer', role: '成稿', instructions: '把研究数据和指标写成周报。', inputs: [{ name: 'metrics', type: 'text', required: true }], outputs: [{ name: 'report', type: 'text' }] }, ], layers: [['researcher'], ['analyst'], ['writer']], triggers: ['周报'], diff --git a/plugins/workflow/test/assign.test.js b/plugins/workflow/test/assign.test.js index 780fab64..728776b8 100644 --- a/plugins/workflow/test/assign.test.js +++ b/plugins/workflow/test/assign.test.js @@ -79,7 +79,7 @@ test('advance walks the parallel join: Deployer stays queued until both branches test('the demo step fails Tester once and still joins both branches', () => { const svc = service() - svc.assign({ workflowId: DELIVERY_ID, taskId: '#1280', title: '缩短交付组群 notice' }) + svc.reset({ workflowId: DELIVERY_ID }) const ops = [] for (let i = 0; i < 12 && delivery(svc.state()).run.status !== 'done'; i += 1) { const before = delivery(svc.state()).run.nodeStatus.tester @@ -187,13 +187,10 @@ test('a dragged graph persists and the reloaded run still highlights those nodes assert.equal(loaded.nodes.find(node => node.id === 'coder').x, coder.x + 48) assert.equal(loaded.run.nodeStatus.coder, 'running') assert.equal(loaded.run.nodeStatus.planner, 'done') - second.assign({ workflowId: DELIVERY_ID, taskId: '#9', title: 'Ship the note' }) - second.advance({ workflowId: DELIVERY_ID, nodeId: 'planner', outcome: 'done' }) - const opened = second.advance({ workflowId: DELIVERY_ID, nodeId: 'writer', outcome: 'done' }) - const run = delivery(opened).run - assert.equal(run.nodeStatus.coder, 'running') - assert.equal(run.nodeStatus.tester, 'running') - assert.equal(run.nodeStatus.deployer, 'queued') + const refused = second.assign({ workflowId: DELIVERY_ID, taskId: '#9', title: 'Ship the note' }) + assert.equal(refused.ok, false) + assert.match(refused.error, /patch/) + assert.equal(delivery(second.state()).run.nodeStatus.coder, 'running') } finally { rmSync(dir, { recursive: true, force: true }) } diff --git a/plugins/workflow/test/branch.test.js b/plugins/workflow/test/branch.test.js new file mode 100644 index 00000000..24c40756 --- /dev/null +++ b/plugins/workflow/test/branch.test.js @@ -0,0 +1,254 @@ +// Loops and conditions (#1402). A loop edge is the only cycle, and it stops +// at max or until. A when edge chooses a branch; the other is skipped. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { completeNode, connectNodes, freshStatus, normalizeGraph, startFrontier } from '../src/graph.js' +import { createWorkflowService } from '../src/service.js' +import { DELIVERY_ID, deliveryWorkflow } from '../src/template.js' + +function choiceGraph() { + return normalizeGraph({ + nodes: [ + { id: 'choose', name: 'Choose', outputs: [{ name: 'path' }] }, + { id: 'left', name: 'Left' }, + { id: 'right', name: 'Right' }, + { id: 'join', name: 'Join' }, + ], + edges: [ + { from: 'choose', to: 'left', when: { output: 'path', eq: 'left' } }, + { from: 'choose', to: 'right', when: { output: 'path', eq: 'right' } }, + { from: 'left', to: 'join' }, + { from: 'right', to: 'join' }, + ], + }) +} + +function loopGraph(until) { + return normalizeGraph({ + nodes: [ + { id: 'body', name: 'Body', outputs: [{ name: 'stop' }] }, + { id: 'next', name: 'Next' }, + ], + edges: [ + { from: 'body', to: 'body', loop: true, max: 3, ...(until ? { until } : {}) }, + { from: 'body', to: 'next' }, + ], + }) +} + +test('an if/else edge runs one branch and skips the other, then joins', () => { + const graph = choiceGraph() + assert.equal(graph.edges.find(edge => edge.to === 'left').when.eq, 'left') + let status = startFrontier(graph, freshStatus(graph)).status + assert.equal(status.choose, 'running') + let step = completeNode(graph, status, 'choose', { outputs: { path: 'left' } }) + assert.equal(step.status.left, 'running') + assert.equal(step.status.right, 'skipped') + assert.equal(step.status.join, 'queued') + step = completeNode(graph, step.status, 'left', { fired: step.fired, loops: step.loops }) + assert.equal(step.status.join, 'running') + assert.equal(step.status.right, 'skipped') + step = completeNode(graph, step.status, 'join', { fired: step.fired }) + assert.equal(step.finished, true) + assert.equal(step.status.join, 'done') +}) + +test('the other branch is taken when the output matches it', () => { + const graph = choiceGraph() + let status = startFrontier(graph, freshStatus(graph)).status + const step = completeNode(graph, status, 'choose', { outputs: { path: 'right' } }) + assert.equal(step.status.right, 'running') + assert.equal(step.status.left, 'skipped') +}) + +test('a loop restarts until the break output matches, and does not start the exit early', () => { + const graph = loopGraph({ output: 'stop', eq: 'yes' }) + assert.equal(graph.edges.find(edge => edge.loop).max, 3) + let status = startFrontier(graph, freshStatus(graph)).status + let step = completeNode(graph, status, 'body', { outputs: { stop: 'no' } }) + assert.equal(step.looped, true) + assert.equal(step.status.body, 'running') + assert.equal(step.status.next, 'queued') + step = completeNode(graph, step.status, 'body', { outputs: { stop: 'yes' }, loops: step.loops, fired: step.fired }) + assert.equal(step.looped, false) + assert.equal(step.status.body, 'done') + assert.equal(step.status.next, 'running') +}) + +test('a loop stops at max even when the break never matches', () => { + const graph = loopGraph({ output: 'stop', eq: 'yes' }) + let step = completeNode(graph, startFrontier(graph, freshStatus(graph)).status, 'body', { outputs: { stop: 'no' } }) + step = completeNode(graph, step.status, 'body', { outputs: { stop: 'no' }, loops: step.loops }) + assert.equal(step.status.next, 'queued') + step = completeNode(graph, step.status, 'body', { outputs: { stop: 'no' }, loops: step.loops }) + assert.equal(step.looped, false) + assert.equal(step.status.next, 'running') +}) + +test('a loop lets the next pass take the other branch', () => { + const graph = normalizeGraph({ + nodes: [ + { id: 'choose', name: 'Choose', outputs: [{ name: 'path' }] }, + { id: 'left', name: 'Left' }, + { id: 'right', name: 'Right' }, + ], + edges: [ + { from: 'choose', to: 'left', when: { output: 'path', eq: 'left' } }, + { from: 'choose', to: 'right', when: { output: 'path', eq: 'right' } }, + { from: 'left', to: 'choose', loop: true, max: 3 }, + ], + }) + let step = completeNode(graph, startFrontier(graph, freshStatus(graph)).status, 'choose', { outputs: { path: 'left' } }) + assert.equal(step.status.left, 'running') + assert.equal(step.status.right, 'skipped') + step = completeNode(graph, step.status, 'left', { fired: step.fired, loops: step.loops }) + assert.equal(step.looped, true) + assert.equal(step.status.choose, 'running') + assert.equal(step.status.right, 'queued') + step = completeNode(graph, step.status, 'choose', { outputs: { path: 'right' }, fired: step.fired, loops: step.loops }) + assert.equal(step.status.right, 'running') + assert.equal(step.status.left, 'skipped') +}) + +test('a loop edge is the only cycle; an unmarked back edge is still refused', () => { + const bare = normalizeGraph({ + nodes: [{ id: 'body', name: 'Body' }, { id: 'next', name: 'Next' }], + edges: [{ from: 'body', to: 'body', loop: true }, { from: 'body', to: 'next' }], + }) + assert.equal(bare.edges.find(edge => edge.loop).max, 3) + const delivery = normalizeGraph(deliveryWorkflow()) + assert.throws(() => connectNodes(delivery, 'deployer', 'planner'), /cycle/) +}) + +test('a multi-node loop restarts the body and leaves the exit queued', () => { + const graph = normalizeGraph({ + nodes: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + { id: 'next', name: 'Next' }, + ], + edges: [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'a', loop: true, max: 2 }, + { from: 'b', to: 'next' }, + ], + }) + let step = completeNode(graph, startFrontier(graph, freshStatus(graph)).status, 'a') + assert.equal(step.status.b, 'running') + step = completeNode(graph, step.status, 'b', { fired: step.fired, loops: step.loops }) + assert.equal(step.looped, true) + assert.equal(step.status.a, 'running') + assert.equal(step.status.b, 'queued') + assert.equal(step.status.next, 'queued') + step = completeNode(graph, step.status, 'a', { fired: step.fired, loops: step.loops }) + step = completeNode(graph, step.status, 'b', { fired: step.fired, loops: step.loops }) + assert.equal(step.looped, false) + assert.equal(step.status.next, 'running') +}) + +test('advance walks an if/else to done, and a loop clears the hand-off so the body is sent again', async () => { + const dispatched = [] + let seq = 0 + const svc = createWorkflowService({ + persist: false, + now: () => 1_700_000_000_000, + bots: () => [{ id: 'main', name: 'Chief', isMain: true, remote: false }], + spawn: async (spec) => { + seq += 1 + return `eph-${spec.nodeId}-${seq}` + }, + release: async () => {}, + dispatch: async (step) => { dispatched.push(step); return { ok: true } }, + }) + const defined = await svc.define({ + name: 'Branch', + nodes: [ + { id: 'choose', name: 'Choose', outputs: [{ name: 'path' }] }, + { id: 'left', name: 'Left' }, + { id: 'right', name: 'Right' }, + { id: 'join', name: 'Join' }, + ], + edges: [ + { from: 'choose', to: 'left', when: { output: 'path', eq: 'left' } }, + { from: 'choose', to: 'right', when: { output: 'path', eq: 'right' } }, + { from: 'left', to: 'join' }, + { from: 'right', to: 'join' }, + ], + }) + const branch = defined.workflows.find(item => item.name === 'Branch') + assert.equal(branch.edges.find(edge => edge.to === 'left').when.eq, 'left') + await svc.assign({ workflowId: branch.id, taskId: '#if', title: 'Pick a side' }) + const chooseBot = dispatched.find(step => step.nodeId === 'choose').botId + let state = await svc.stepResult({ botId: chooseBot, ok: true, text: 'OUTPUT path: left' }) + let run = state.workflows.find(item => item.id === branch.id).run + assert.equal(run.nodeStatus.left, 'running') + assert.equal(run.nodeStatus.right, 'skipped') + assert.equal(dispatched.some(step => step.nodeId === 'right'), false) + const leftBot = dispatched.find(step => step.nodeId === 'left').botId + state = await svc.stepResult({ botId: leftBot, ok: true, text: 'took the left' }) + run = state.workflows.find(item => item.id === branch.id).run + assert.equal(run.nodeStatus.join, 'running') + const joinBot = dispatched.find(step => step.nodeId === 'join').botId + state = await svc.stepResult({ botId: joinBot, ok: true, text: 'joined' }) + run = state.workflows.find(item => item.id === branch.id).run + assert.equal(run.status, 'done') + + const looped = await svc.define({ + name: 'Loop', + nodes: [ + { id: 'body', name: 'Body', outputs: [{ name: 'stop' }] }, + { id: 'next', name: 'Next' }, + ], + edges: [ + { from: 'body', to: 'body', loop: true, max: 3, until: { output: 'stop', eq: 'yes' } }, + { from: 'body', to: 'next' }, + ], + }) + const loop = looped.workflows.find(item => item.name === 'Loop') + await svc.assign({ workflowId: loop.id, taskId: '#loop', title: 'Repeat' }) + const bodySteps = () => dispatched.filter(step => step.nodeId === 'body') + assert.equal(bodySteps().length, 1) + state = await svc.stepResult({ botId: bodySteps()[0].botId, ok: true, text: 'OUTPUT stop: no' }) + run = state.workflows.find(item => item.id === loop.id).run + assert.equal(run.nodeStatus.body, 'running') + assert.equal(run.nodeStatus.next, 'queued') + assert.equal(bodySteps().length, 2) + state = await svc.stepResult({ botId: bodySteps()[0].botId, ok: true, text: 'OUTPUT stop: yes' }) + run = state.workflows.find(item => item.id === loop.id).run + assert.equal(run.nodeStatus.next, 'running') + assert.equal(dispatched.at(-1).nodeId, 'next') +}) + +test('replay shows the demo again after a live run has finished', () => { + const svc = createWorkflowService({ persist: false, now: () => 5 }) + const opened = svc.assign({ workflowId: DELIVERY_ID, taskId: '#live', title: 'A real task' }) + const row = opened.workflows.find(item => item.id === DELIVERY_ID) + assert.equal(row.run.live, true) + assert.equal(row.run.task.id, '#live') + let status = row.run.nodeStatus + const order = ['planner', 'writer', 'coder', 'reviewer', 'tester', 'deployer'] + let view = opened + for (const nodeId of order) { + if (status[nodeId] !== 'running') continue + view = svc.advance({ workflowId: DELIVERY_ID, nodeId, outcome: 'done' }) + status = view.workflows.find(item => item.id === DELIVERY_ID).run.nodeStatus + } + const finished = view.workflows.find(item => item.id === DELIVERY_ID) + assert.equal(finished.run.status, 'done') + assert.equal(finished.run.task.id, '#live') + assert.equal(finished.run.nodeStatus.deployer, 'done') + const replay = svc.demoStep({ workflowId: DELIVERY_ID }) + const shown = replay.workflows.find(item => item.id === DELIVERY_ID) + assert.equal(shown.run.task.id, '#1280') + assert.equal(shown.run.live, undefined) + assert.equal(shown.run.status, 'running') + assert.equal(shown.run.nodeStatus.coder, 'done') + assert.equal(shown.run.nodeStatus.reviewer, 'running') + assert.equal(shown.run.nodeStatus.deployer, 'queued') + const again = svc.demoStep({ workflowId: DELIVERY_ID }) + const moved = again.workflows.find(item => item.id === DELIVERY_ID) + assert.equal(moved.run.task.id, '#1280') + assert.equal(moved.run.nodeStatus.reviewer, 'running') + assert.equal(moved.run.nodeStatus.tester, 'failed') + assert.notEqual(moved.run.nodeStatus.deployer, 'done') +}) diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 5d4bcaea..f942de40 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -343,6 +343,32 @@ test('apply registers the sidebar section and the settings page', () => { off() }) +test('settings can add a loop edge and a condition edge', async () => { + const calls = [] + const graph = delivery() + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph] } + return { ok: true, workflows: [graph] } + } + const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + const pick = (label) => findAll(settings.tree, node => node.props?.['aria-label'] === label)[0] + pick('Kind').props.onChange({ target: { value: 'loop' } }) + pick('From node').props.onChange({ target: { value: 'coder' } }) + pick('To node').props.onChange({ target: { value: 'coder' } }) + pick('Max iterations').props.onChange({ target: { value: '3' } }) + pick('until').props.onChange({ target: { value: 'stop' } }) + pick('Equals').props.onChange({ target: { value: 'yes' } }) + button(settings.tree, 'Add link').props.onClick() + const looped = calls.filter(item => item.name === 'workflow/graph').at(-1) + const loop = looped.body.edges.find(edge => edge.from === 'coder' && edge.to === 'coder') + assert.equal(loop.loop, true) + assert.equal(loop.max, 3) + assert.deepEqual(loop.until, { output: 'stop', eq: 'yes' }) + settings.unmount() +}) + test('Chinese labels follow the host Tasks translation', () => { const prev = globalThis.__flybot__ globalThis.__flybot__ = { t: key => (key === 'Tasks' ? '任务' : key) } diff --git a/plugins/workflow/test/ephemeral.test.js b/plugins/workflow/test/ephemeral.test.js index 6d869bc8..af3a5511 100644 --- a/plugins/workflow/test/ephemeral.test.js +++ b/plugins/workflow/test/ephemeral.test.js @@ -180,6 +180,8 @@ 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, []) assert.match(created[0].systemPrompt, /Write the plan\./) assert.equal(created[0].name.startsWith('Planner '), true) assert.notEqual(created[0].name, 'Planner') @@ -191,6 +193,80 @@ test('apply wires spawn to a hidden ephemeral create and removes those Bots when assert.deepEqual(removed, ['bot-1']) }) +test('a Bot reply advances a live node; a 429 in the reply does not fail it', async () => { + let handle + const created = [] + const removed = [] + async function send(message) { return { action: 'allow', text: message.text } } + send.use = (spec) => { handle = spec.handle; return () => {} } + send.priority = { ROUTING: 10 } + const fb = { + bot: { + list: () => roster, + async create() { + const id = `bot-${created.length + 1}` + created.push(id) + return id + }, + async remove(id) { removed.push(id); return true }, + send, + }, + readOnly: () => false, + api: { handle() { return () => {} } }, + tools: { register() { return () => {} } }, + warn() {}, + } + const started = apply({ flybot: { scope: () => fb }, effect(fn) { fn() } }, { persist: false }) + const defined = await started.service.define({ + name: 'Lane', + nodes: [ + { id: 'plan', name: 'Planner', prompt: 'Plan.', outputs: [{ name: 'plan', type: 'text' }] }, + { id: 'code', name: 'Coder', prompt: 'Code.', inputs: [{ name: 'plan', type: 'text', required: true }], outputs: [{ name: 'patch', type: 'text' }] }, + ], + edges: [{ from: 'plan', to: 'code' }], + }) + const made = defined.workflows.find(item => item.name === 'Lane') + const opened = await started.service.runNamed({ workflow: 'Lane', brief: 'Ship the note' }) + const run = opened.workflows.find(item => item.id === made.id).run + assert.equal(typeof handle, 'function') + assert.equal(run.nodeStatus.plan, 'running') + handle({ from: run.ephemeral.plan, role: 'request', text: 'not a reply' }) + handle({ from: run.ephemeral.plan, role: 'reply', text: 'Noted a rate limit (429) in the log.\nOUTPUT plan: two lines' }) + const mid = await waitFor(() => { + const row = started.service.state().workflows.find(item => item.id === made.id) + return row.run?.nodeStatus?.code === 'running' ? row : null + }) + assert.equal(mid.run.nodeStatus.plan, 'done') + handle({ from: run.ephemeral.code, role: 'reply', text: 'could not answer your message' }) + const stopped = await waitFor(() => { + const row = started.service.state().workflows.find(item => item.id === made.id) + if (row.run?.status !== 'failed' || removed.length !== created.length) return null + return row + }) + assert.equal(stopped.run.nodeStatus.code, 'failed') + assert.equal(stopped.run.ephemeral, undefined) + assert.deepEqual(removed.sort(), created.sort()) + + const again = await started.service.runNamed({ workflow: 'Lane', brief: 'Ship another', task: '#1402b' }) + const second = again.workflows.find(item => item.id === made.id).run + assert.equal(second.status, 'running') + handle({ from: second.ephemeral.plan, role: 'reply', text: 'The model call failed.' }) + const failed = await waitFor(() => { + const row = started.service.state().workflows.find(item => item.id === made.id) + return row.run?.task?.id === '#1402b' && row.run?.status === 'failed' ? row : null + }) + assert.equal(failed.run.nodeStatus.plan, 'failed') +}) + +async function waitFor(read) { + for (let n = 0; n < 40; n += 1) { + const value = read() + if (value) return value + await new Promise(resolve => setImmediate(resolve)) + } + throw new Error('the reply did not move the run') +} + test('#1389: a step the host refuses is not marked as handed, and a later settle sends it again', async () => { let answer = { ok: false, code: 'no-sender' } const calls = [] @@ -248,6 +324,16 @@ function readStore(dir) { return JSON.parse(readFileSync(join(dir, 'workflows.json'), 'utf8')) } +// The lane store keeps a live run on `lanes[id].active` and the one that just +// ended on `lanes[id].last`. An older file still has `runs[id]`. +function persistedRun(dir, workflowId) { + const store = readStore(dir) + const lane = store.lanes?.[workflowId] + if (!lane) return store.runs?.[workflowId] ?? null + const active = (lane.active ?? []).find(run => run && (run.ephemeral || run.status === 'running')) + return active ?? lane.last ?? null +} + test('a load reaps the run a restart left running: its Bots go and the run ends failed (#1388)', async () => { const dir = mkdtempSync(join(tmpdir(), 'wf-reap-')) const released = [] @@ -263,7 +349,7 @@ test('a load reaps the run a restart left running: its Bots go and the run ends edges: [{ from: 'plan', to: 'code' }], }).workflows.find(item => item.name === 'Lane') await before.runNamed({ workflow: 'Lane', brief: 'Ship the note' }) - const saved = readStore(dir).runs[made.id] + const saved = persistedRun(dir, made.id) assert.equal(saved.status, 'running') assert.deepEqual(Object.values(saved.ephemeral).sort(), ['eph-code', 'eph-plan']) assert.equal(released.length, 0) @@ -278,7 +364,7 @@ test('a load reaps the run a restart left running: its Bots go and the run ends assert.equal(run.ephemeral, undefined) assert.equal(run.handed, undefined) assert.equal(run.updatedAt, clock) - const settled = readStore(dir).runs[made.id] + const settled = persistedRun(dir, made.id) assert.equal(settled.status, 'failed') assert.equal(settled.ephemeral, undefined) assert.equal('handed' in settled, false) @@ -307,7 +393,7 @@ test('a finished run stays done, and a run with no ephemeral Bots is not touched await svc.runNamed({ workflow: 'Quick', brief: 'One step' }) await svc.advance({ workflowId: quick.id, nodeId: 'only', outcome: 'done' }) released.length = 0 - const finished = readStore(dir).runs[quick.id] + const finished = persistedRun(dir, quick.id) assert.equal(finished.status, 'done') assert.equal(finished.ephemeral, undefined) const finishedAt = finished.updatedAt @@ -317,7 +403,8 @@ test('a finished run stays done, and a run with no ephemeral Bots is not touched const idle = svc.define({ name: 'Idle', nodes: [{ id: 'one', name: 'Planner', role: 'plan', prompt: 'Wait.' }], edges: [] }) .workflows.find(item => item.name === 'Idle') const store = readStore(dir) - store.runs[idle.id] = { + const lane = store.lanes[idle.id] ?? { active: [], queue: [], last: null, history: [] } + lane.active = [...(lane.active ?? []), { id: 'run-bare', workflowId: idle.id, task: null, @@ -329,7 +416,8 @@ test('a finished run stays done, and a run with no ephemeral Bots is not touched history: [], startedAt: 5, updatedAt: 6, - } + }] + store.lanes[idle.id] = lane const written = JSON.stringify(store) writeFileSync(join(dir, 'workflows.json'), written) @@ -362,7 +450,7 @@ test('the plugin load itself reaps a run the restart left running (#1388)', asyn edges: [], }).workflows.find(item => item.name === 'Lane') await before.runNamed({ workflow: 'Lane', brief: 'Ship the note' }) - assert.equal(readStore(dir).runs[made.id].status, 'running') + assert.equal(persistedRun(dir, made.id).status, 'running') // Loading the plugin is where the abandoned run is recognised. const fb = { diff --git a/plugins/workflow/test/queue.test.js b/plugins/workflow/test/queue.test.js new file mode 100644 index 00000000..b47b9dd8 --- /dev/null +++ b/plugins/workflow/test/queue.test.js @@ -0,0 +1,367 @@ +// #1402: a live run is not replaced. Tasks queue, a node stays running until +// the Bot replies, and a dispatch that fails is not marked handed. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { completeNode, freshStatus, normalizeGraph, startFrontier } from '../src/graph.js' +import { matchTask } from '../src/match.js' +import { createWorkflowService } from '../src/service.js' +import { DELIVERY_ID, WEEKLY_ID, deliveryWorkflow, fixtureTask, weeklyWorkflow } from '../src/template.js' + +const roster = [ + { id: 'main', name: 'Chief', role: 'lead', isMain: true, remote: false }, + { id: 'bot-coder', name: 'Coder', role: '实现', remote: false }, + { id: 'bot-friend', name: 'Remote', remote: true }, +] + +function harness(extra = {}) { + const spawned = [] + const released = [] + const dispatched = [] + let seq = 0 + let dispatchResult = extra.dispatchResult ?? (() => ({ ok: true })) + const svc = createWorkflowService({ + persist: false, + now: () => 1_700_000_000_000, + bots: () => roster, + spawn: async (spec) => { + if (extra.blockSpawn) await extra.blockSpawn() + const id = `eph-${++seq}` + spawned.push({ id, spec }) + if (extra.failOn === spec.nodeId) throw new Error('spawn failed') + return id + }, + release: async (id) => { released.push(id) }, + dispatch: async (step) => { + const result = await dispatchResult(step) + dispatched.push({ step, result }) + return result + }, + }) + return { svc, spawned, released, dispatched, setDispatch(fn) { dispatchResult = fn } } +} + +function delivery(state) { + return state.workflows.find(item => item.id === DELIVERY_ID) +} + +test('a second assign queues behind a live run and does not release its Bots', async () => { + const { svc, spawned, released } = harness() + svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 1 }) + const first = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1397', title: 'Fix the hand-off' }) + const opened = delivery(first) + assert.equal(opened.run.task.id, '#1397') + assert.equal(opened.run.status, 'running') + assert.equal(opened.run.live, true) + assert.equal(opened.activeCount, 1) + assert.equal(opened.queueDepth, 0) + const bots = spawned.map(item => item.id) + + const second = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1389', title: 'Do not drop the first run' }) + const queued = delivery(second) + assert.equal(queued.run.task.id, '#1397') + assert.equal(queued.run.status, 'running') + assert.equal(queued.queueDepth, 1) + assert.equal(queued.queue[0].id, '#1389') + assert.deepEqual(released, []) + assert.equal(spawned.length, bots.length) + assert.equal(queued.run.history.some(item => item.status === 'running'), false) + + const planner = opened.run.ephemeral.planner + const replied = await svc.stepResult({ botId: planner, ok: true, text: 'Split the notice.\nOUTPUT plan: two lines' }) + assert.equal(delivery(replied).run.task.id, '#1397') + const reply = delivery(replied).messages.at(-1).text + assert.match(reply, /Split the notice/) + assert.equal(reply.includes('已拆解'), false) +}) + +test('the queued task starts only after the live run finishes, in FIFO order', async () => { + const { svc } = harness() + svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 1 }) + await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1', title: 'First' }) + await svc.assign({ workflowId: DELIVERY_ID, taskId: '#2', title: 'Second' }) + await svc.assign({ workflowId: DELIVERY_ID, taskId: '#3', title: 'Third' }) + assert.deepEqual(delivery(svc.state()).queue.map(task => task.id), ['#2', '#3']) + + const walk = async (taskId) => { + for (let i = 0; i < 12 && delivery(svc.state()).run?.task?.id === taskId && delivery(svc.state()).run.status === 'running'; i += 1) { + const run = delivery(svc.state()).run + const nodeId = run.running[0] + const botId = run.ephemeral[nodeId] + const node = delivery(svc.state()).nodes.find(item => item.id === nodeId) + const line = (node?.outputs ?? []).map(port => `OUTPUT ${port.name}: ${port.name} for ${taskId}`).join('\n') + await svc.stepResult({ botId, ok: true, text: `${node?.name} did ${taskId}\n${line}` }) + } + } + await walk('#1') + assert.equal(delivery(svc.state()).run.task.id, '#2') + assert.equal(delivery(svc.state()).run.status, 'running') + assert.deepEqual(delivery(svc.state()).queue.map(task => task.id), ['#3']) + assert.equal(delivery(svc.state()).history.some(item => item.task.id === '#1' && item.status === 'running'), false) + assert.equal(delivery(svc.state()).history.some(item => item.task.id === '#1' && item.status === 'done'), true) + await walk('#2') + assert.equal(delivery(svc.state()).run.task.id, '#3') + await walk('#3') + assert.equal(delivery(svc.state()).run.status, 'done') + assert.equal(delivery(svc.state()).queueDepth, 0) +}) + +test('a node stays running until the Bot replies, and demo-step does not move a live run', async () => { + const { svc, dispatched } = harness() + const started = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#7', title: 'Wait for the Bot' }) + const run = delivery(started).run + assert.equal(run.nodeStatus.planner, 'running') + assert.equal(run.nodeStatus.writer, 'queued') + assert.equal(dispatched.length, 1) + assert.equal(dispatched[0].step.nodeId, 'planner') + assert.match(dispatched[0].step.inputs.task, /#7/) + + const demo = await svc.demoStep({ workflowId: DELIVERY_ID }) + assert.equal(demo.ok, false) + assert.equal(delivery(svc.state()).run.nodeStatus.planner, 'running') + assert.equal(delivery(svc.state()).run.nodeStatus.writer, 'queued') + + const writer = await svc.stepResult({ + botId: run.ephemeral.planner, + ok: true, + text: 'The plan is ready.\nOUTPUT plan: shorten the notice', + }) + const next = delivery(writer).run + assert.equal(next.nodeStatus.planner, 'done') + assert.equal(next.nodeStatus.writer, 'running') + assert.equal(dispatched.at(-1).step.nodeId, 'writer') + assert.equal(dispatched.at(-1).step.inputs.plan, 'shorten the notice') + assert.match(delivery(writer).messages.map(message => message.text).join('\n'), /The plan is ready/) +}) + +test('dispatch no-sender does not mark the node handed and the chat names it', async () => { + const { svc, dispatched, released } = harness({ dispatchResult: () => ({ ok: false, code: 'no-sender' }) }) + const started = await svc.assign({ workflowId: DELIVERY_ID, taskId: '#1389', title: 'No main Bot' }) + const run = delivery(started).run + assert.equal(run.status, 'running') + assert.equal(run.nodeStatus.planner, 'running') + assert.equal(run.handed, undefined) + assert.equal(dispatched.length, 1) + assert.match(delivery(started).messages.map(message => message.text).join('\n'), /no-sender/) + assert.deepEqual(released, []) +}) + +test('two assigns that overlap spawn one set per run and release none early', async () => { + let release + const gate = new Promise(resolve => { release = resolve }) + let entered = 0 + const { svc, spawned, released } = harness({ + blockSpawn: () => { + entered += 1 + return entered === 1 ? gate : undefined + }, + }) + svc.setConcurrency({ workflowId: DELIVERY_ID, maxConcurrent: 1 }) + const first = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1', title: 'First' }) + await new Promise(resolve => setImmediate(resolve)) + const second = svc.assign({ workflowId: DELIVERY_ID, taskId: '#2', title: 'Second' }) + release() + await first + await second + assert.equal(delivery(svc.state()).activeCount, 1) + assert.equal(delivery(svc.state()).queueDepth, 1) + assert.equal(spawned.length, delivery(svc.state()).nodes.length) + assert.deepEqual(released, []) +}) + +test('assigning to a Bot does not start a workflow, even when the title matches a trigger', async () => { + const { svc, spawned } = harness() + const routed = await svc.assign({ botId: 'bot-coder', taskId: '#51', title: 'notice for Coder' }) + assert.equal(routed.ok, true) + assert.equal(routed.decision.kind, 'bot') + assert.equal(routed.decision.id, 'bot-coder') + assert.equal(delivery(svc.state()).activeCount, 0) + assert.equal(spawned.length, 0) + assert.equal(delivery(svc.state()).run.task.id, '#1280') + + const remote = svc.assign({ botId: 'bot-friend', taskId: '#52', title: 'Nope' }) + assert.equal(remote.ok, false) + + const workflows = svc.state().workflows + assert.deepEqual( + matchTask({ id: '#9', title: 'notice', status: 'open', assignee: 'Coder' }, { workflows, bots: roster }), + { kind: 'bot', id: 'bot-coder' }, + ) + const ingested = svc.ingest({ id: '#9', title: 'notice', status: 'open', assignee: 'Coder' }) + assert.equal(ingested.decision.kind, 'bot') + assert.equal(delivery(svc.state()).activeCount, 0) +}) + +test('a required input with no upstream provider refuses the run', async () => { + const { svc, spawned } = harness() + const defined = svc.define({ + name: 'Broken', + nodes: [ + { id: 'draft', name: 'Draft', outputs: [{ name: 'note', type: 'text' }] }, + { id: 'edit', name: 'Edit', inputs: [{ name: 'plan', type: 'text', required: true }] }, + ], + edges: [{ from: 'draft', to: 'edit' }], + }) + const made = defined.workflows.find(item => item.name === 'Broken') + const refused = await svc.assign({ workflowId: made.id, taskId: '#4', title: 'Missing plan' }) + assert.equal(refused.ok, false) + assert.match(refused.error, /plan/) + assert.equal(spawned.length, 0) + assert.equal(defined.workflows.find(item => item.id === made.id).run, null) +}) + +test('outputs travel along edges and a node does not start without them', async () => { + const { svc, dispatched } = harness() + const defined = svc.define({ + name: 'Lane', + nodes: [ + { id: 'plan', name: 'Planner', outputs: [{ name: 'plan', type: 'text' }] }, + { id: 'write', name: 'Writer', inputs: [{ name: 'plan', type: 'text', required: true }], outputs: [{ name: 'copy', type: 'text' }] }, + { id: 'ship', name: 'Deployer', inputs: [{ name: 'copy', type: 'text', required: true }] }, + ], + edges: [{ from: 'plan', to: 'write' }, { from: 'write', to: 'ship' }], + }) + const made = defined.workflows.find(item => item.name === 'Lane') + const started = await svc.runNamed({ workflow: 'Lane', brief: 'Ship the note', task: '#8' }) + assert.equal(started.workflows.find(item => item.id === made.id).run.nodeStatus.plan, 'running') + assert.equal(started.workflows.find(item => item.id === made.id).run.nodeStatus.write, 'queued') + assert.equal(started.workflows.find(item => item.id === made.id).run.nodeStatus.ship, 'queued') + + const run = started.workflows.find(item => item.id === made.id).run + const mid = await svc.stepResult({ botId: run.ephemeral.plan, ok: true, text: 'OUTPUT plan: lead with the conclusion' }) + const after = mid.workflows.find(item => item.id === made.id).run + assert.equal(after.nodeStatus.write, 'running') + assert.equal(after.nodeStatus.ship, 'queued') + assert.equal(dispatched.at(-1).step.inputs.plan, 'lead with the conclusion') + + const done = await svc.stepResult({ botId: after.ephemeral.write, ok: false, text: 'The model call failed.' }) + const stopped = done.workflows.find(item => item.id === made.id).run + assert.equal(stopped.nodeStatus.write, 'failed') + assert.equal(stopped.nodeStatus.ship, 'queued') + assert.match(done.workflows.find(item => item.id === made.id).messages.map(message => message.text).join('\n'), /model call failed/) +}) + +test('an old store keeps the seeded script as a demo, so replay still advances', () => { + const dir = mkdtempSync(join(tmpdir(), 'flybot-wf-old-')) + try { + const deliveryGraph = normalizeGraph(deliveryWorkflow()) + const weeklyGraph = normalizeGraph(weeklyWorkflow()) + let nodeStatus = freshStatus(deliveryGraph) + for (const op of [{ type: 'start' }, { type: 'complete', nodeId: 'planner' }, { type: 'complete', nodeId: 'writer' }]) { + const result = op.type === 'start' ? startFrontier(deliveryGraph, nodeStatus) : completeNode(deliveryGraph, nodeStatus, op.nodeId) + nodeStatus = result.status + } + const task = fixtureTask() + const done = freshStatus(weeklyGraph) + for (const id of Object.keys(done)) done[id] = 'done' + writeFileSync(join(dir, 'workflows.json'), JSON.stringify({ + version: 1, + workflows: [ + deliveryGraph, + weeklyGraph, + { id: 'solo', name: 'Solo', nodes: [{ id: 'only', name: 'Only', x: 40, y: 40 }], edges: [], layers: [['only']] }, + ], + runs: { + [DELIVERY_ID]: { + id: 'run-old', workflowId: DELIVERY_ID, task, status: 'running', nodeStatus, + steps: 3, stepBudget: 8, failedOnce: {}, history: [], startedAt: 1, updatedAt: 1, + }, + [WEEKLY_ID]: { + id: 'run-week', workflowId: WEEKLY_ID, task: { id: 'W42', title: '周报' }, status: 'done', + nodeStatus: done, steps: 4, stepBudget: 4, + history: [{ task: { id: 'W42', title: '周报' }, status: 'done', at: 1 }], + startedAt: 1, updatedAt: 1, + }, + solo: { + id: 'run-live', workflowId: 'solo', task: { id: '#9', title: 'Live' }, status: 'running', + nodeStatus: { only: 'running' }, ephemeral: { only: 'bot-live' }, + steps: 1, stepBudget: 2, history: [], startedAt: 1, updatedAt: 1, + }, + }, + logs: { [DELIVERY_ID]: [], [WEEKLY_ID]: [], solo: [] }, + seq: 1, + })) + const svc = createWorkflowService({ dir, persist: true, now: () => 9, bots: () => [] }) + const opened = delivery(svc.state()) + assert.equal(opened.activeCount, 0) + assert.equal(opened.run.task.id, '#1280') + assert.equal(opened.run.live, undefined) + assert.equal(opened.run.nodeStatus.coder, 'running') + const stepped = svc.demoStep({ workflowId: DELIVERY_ID }) + assert.equal(stepped.ok, true) + const after = delivery(stepped) + assert.equal(after.activeCount, 0) + assert.equal(after.run.nodeStatus.coder, 'done') + const week = stepped.workflows.find(item => item.id === WEEKLY_ID) + assert.equal(week.activeCount, 0) + assert.equal(week.run.status, 'done') + assert.equal(week.run.nodeStatus.researcher, 'done') + const replay = svc.demoStep({ workflowId: WEEKLY_ID }) + assert.equal(replay.ok, true) + const replayed = replay.workflows.find(item => item.id === WEEKLY_ID) + assert.equal(replayed.activeCount, 0) + assert.equal(replayed.run.status, 'running') + assert.equal(replayed.run.nodeStatus.researcher, 'running') + assert.equal(replayed.run.nodeStatus.writer, 'queued') + const replayNext = svc.demoStep({ workflowId: WEEKLY_ID }) + assert.equal(replayNext.workflows.find(item => item.id === WEEKLY_ID).run.nodeStatus.analyst, 'running') + const solo = replay.workflows.find(item => item.id === 'solo') + assert.equal(solo.activeCount, 1) + const refused = svc.demoStep({ workflowId: 'solo' }) + assert.equal(refused.ok, false) + assert.match(refused.error, /A live run is in progress/) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('a task parked at the host cap starts when any run finishes, ahead of a newer assign', async () => { + let calls = 0 + let release + const gate = new Promise(resolve => { release = resolve }) + const { svc } = harness({ + blockSpawn: () => { + calls += 1 + if (calls === 6) return gate + return undefined + }, + }) + const ids = [] + for (let n = 0; n < 7; n += 1) { + const made = svc.define({ + name: `Cap ${n}`, + nodes: [{ id: 'only', name: 'Only', outputs: [{ name: 'out', type: 'text' }] }], + edges: [], + }).workflows.find(item => item.name === `Cap ${n}`) + ids.push(made.id) + } + for (let n = 0; n < 5; n += 1) { + const started = await svc.assign({ workflowId: ids[n], taskId: `#run${n}`, title: `Run ${n}` }) + assert.equal(started.workflows.find(item => item.id === ids[n]).activeCount, 1) + } + const sixth = svc.assign({ workflowId: ids[5], taskId: '#six', title: 'Six' }) + await new Promise(resolve => setImmediate(resolve)) + const parked = svc.assign({ workflowId: ids[6], taskId: '#parked', title: 'Parked' }) + release() + await sixth + const held = await parked + assert.equal(held.workflows.find(item => item.id === ids[5]).activeCount, 1) + const waiting = held.workflows.find(item => item.id === ids[6]) + assert.equal(waiting.activeCount, 0) + assert.deepEqual(waiting.queue.map(task => task.id), ['#parked']) + + const newer = await svc.assign({ workflowId: ids[6], taskId: '#newer', title: 'Newer' }) + const both = newer.workflows.find(item => item.id === ids[6]) + assert.equal(both.activeCount, 0) + assert.deepEqual(both.queue.map(task => task.id), ['#parked', '#newer']) + + const victim = newer.workflows.find(item => item.id === ids[0]).run + const finished = await svc.stepResult({ botId: victim.ephemeral.only, ok: true, text: 'OUTPUT out: done' }) + assert.equal(finished.workflows.find(item => item.id === ids[0]).activeCount, 0) + const woke = finished.workflows.find(item => item.id === ids[6]) + assert.equal(woke.activeCount, 1) + assert.equal(woke.run.task.id, '#parked') + assert.deepEqual(woke.queue.map(task => task.id), ['#newer']) +})