From 90d963f59a4d736bffec432e780855e35b11b9ae Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 31 Oct 2026 00:53:55 +0800 Subject: [PATCH 1/3] Bind a workflow run to its Project (#1856) A workflow record gains projectId and a run is bound to that Project when it starts, so a Project board can name the run that is doing its work. The binding goes through one seam the plugin cannot fake: fb.projects.bindRun writes state.projectRuns with the same bindProject the project-bind endpoint uses, and a host without the seam, or an unknown Project, leaves the run unbound rather than failing it. saveGraph validates the value (unknown refuses the save and writes nothing, null clears, omitted keeps), and assign() lets the call's own projectId win over the workflow's. Docs-not-needed: docs/subsystems/workflow.md and docs/subsystems/core-host.md are updated in this commit. --- changelog.d/1856-workflow-project.added.md | 1 + core/src/host/hooks.js | 15 +++++ core/test/project-run-seam.test.js | 49 ++++++++++++++ docs/subsystems/core-host.md | 4 ++ docs/subsystems/workflow.md | 6 ++ patches/ds-bot/files/src/host/hooks.js | 15 +++++ .../files/test/project-run-seam.test.js | 49 ++++++++++++++ plugins/workflow/index.js | 4 ++ plugins/workflow/src/service.js | 44 ++++++++++++- plugins/workflow/test/assign.test.js | 66 +++++++++++++++++++ .../workflow/test/save-graph-project.test.js | 51 ++++++++++++++ 11 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1856-workflow-project.added.md create mode 100644 core/test/project-run-seam.test.js create mode 100644 patches/ds-bot/files/test/project-run-seam.test.js create mode 100644 plugins/workflow/test/save-graph-project.test.js diff --git a/changelog.d/1856-workflow-project.added.md b/changelog.d/1856-workflow-project.added.md new file mode 100644 index 000000000..7e93bedc7 --- /dev/null +++ b/changelog.d/1856-workflow-project.added.md @@ -0,0 +1 @@ +We worked a workflow (a workflow now belongs to a Project: its definition carries projectId, the run is bound when it starts, and Settings shows the Project) — [#1856](https://github.com/ThinkFlowLab/FlyBot/issues/1856). diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index d36c72b8d..2d8b9e021 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1349,6 +1349,21 @@ export function install(rt) { return rt.taskCounters?.(query) ?? { roles: {}, runs: {} } }, }, + // #1856: a workflow run belongs to a Project, and the plugin cannot write host state. A host + // without this seam answers `undefined` and never throws: the run stays unbound and keeps today + // behavior. The write goes through the same `bindProject` the endpoint uses, so a run bound + // from an assign and a run bound from Settings cannot disagree. + projects: { + read(id) { + return typeof rt.readProject === "function" ? rt.readProject(String(id ?? '')) ?? undefined : undefined + }, + async bindRun(runId, projectId) { + if (typeof rt.bindProject !== "function") return undefined + const project = rt.bindProject({ kind: 'run', id: String(runId ?? ''), projectId }) + await rt.save?.() + return project ?? undefined + }, + }, tools, api, outcomeOf: exchangeId => outcomes.get(exchangeId), diff --git a/core/test/project-run-seam.test.js b/core/test/project-run-seam.test.js new file mode 100644 index 000000000..ae82467b6 --- /dev/null +++ b/core/test/project-run-seam.test.js @@ -0,0 +1,49 @@ +// #1856: a workflow run belongs to a Project, but the plugin cannot write host state. `bindRun` +// is that one seam: it writes `state.projectRuns` through the same `bindProject` the endpoint +// uses, refuses an unknown Project, and a host without it answers `undefined` instead of throwing. +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 { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const seed = () => ({ + version: 1, + mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start(t) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-run-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) + const rt = createRuntime(fakeContext(), { home }) + t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 })) + await rt.load() + return rt +} + +test('#1856: bindRun writes the run binding the board reads', async (t) => { + const rt = await start(t) + const project = rt.createProject({ name: 'FlyBot 开发' }) + const bound = await rt.flybot.projects.bindRun('run-abc', project.id) + assert.equal(bound.id, project.id) + assert.equal(rt.state.projectRuns['run-abc'], project.id) + assert.equal(rt.flybot.projects.read(project.id).name, 'FlyBot 开发') +}) + +test('#1856: bindRun refuses an unknown Project and writes nothing', async (t) => { + const rt = await start(t) + await assert.rejects(() => rt.flybot.projects.bindRun('run-abc', 'project-missing'), /Unknown Project project-missing/) + assert.equal(rt.state.projectRuns['run-abc'], undefined) +}) + +test('#1856: a host without the seam answers undefined instead of throwing', async (t) => { + const rt = await start(t) + rt.readProject = undefined + rt.bindProject = undefined + assert.equal(rt.flybot.projects.read('project-1'), undefined) + assert.equal(await rt.flybot.projects.bindRun('run-abc', 'project-1'), undefined) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index d7d1e27ab..4ac34bc95 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -769,3 +769,7 @@ the host side only, so the tier stays T2 and `core/src/shared/` is untouched. ## Project metrics (#1829) `core/src/host/project-metrics.js` answers the Project board's seven numbers: PRs opened and merged today, issues opened and closed today, and the seven-day medians of PR merge, issue close and task completion durations. The four day counts compare `created_at` / `merged_at` / `closed_at` against the reader's own time zone (`Intl.DateTimeFormat`), not the host clock, and the local task half reads `createdAt` / `completedAt` from `state.tasks`. The numbers ride on the existing `project-board` endpoint (`store.js` carries the `metrics` section beside the rows), which passes the host secret store's `GITHUB_TOKEN` and the reader's time zone from the request, requires the Project, and answers `ok: false` with `null` values when the credential is missing or a read fails — the client paints `—`, never `0`. One five-minute in-process cache keys on repository, time zone and window, so a hit spends no source call; a failed read is not cached. `runningOf` projects a claimed card into the running table's row (who, claim age, source, last activity). + +## The Project run seam (#1856) + +`fb.projects` is the one seam a workflow run's Project binding goes through. `read(id)` answers the Project record or `undefined`; `bindRun(runId, projectId)` writes `state.projectRuns[runId]` through `bindProject({ kind: 'run', … })` — the path the `project-bind` endpoint already uses — and saves. An unknown Project throws the store's own refusal, so the caller (the workflow plugin) treats it as a refused binding and keeps the run. A host without either function answers `undefined` and never throws, which is what an older plugin instance sees. diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 775cf4293..acd978f45 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -75,3 +75,9 @@ Core never reads `$DSH_HOME/bot/flybot-workflow`. The plugin registers `flybot.t A connector a Bot switched off is not reopened by a Workflow, whichever surface started the run. A run records the Bots whose switches cap it, both in `cappedBy`: the Bot whose `run_workflow` call started it, and the Bot that saved the graph (`authoredBy` on the workflow record, set by `define_workflow` and by any save that names a Bot). A run Bot carries no `createdBy` (#1582): the roster reads that field as "a Bot made this Bot" and would start the run Bot on its maker's model, so the caller travels in `cappedBy` only — a ceiling, not a parent. Every node's run Bot carries `cappedBy` — the caller, else the graph's author — and `core/src/host/connector-access.js` intersects the node's `tools` with those switches, so a node that names a connector its caller or author turned off is refused with `turned off for ; the step's tools list cannot turn it back on`, and the tool is masked out of the run Bot's list too. A run nobody's tool call started (the board's `ingest`, the canvas, the `workflow/*` API) has no caller and is capped by the graph's author alone; a save with no Bot behind it records no author, which is the owner's own edit and leaves no ceiling; a graph saved before this has none either. `visibleTo` on `run_workflow`/`define_workflow` is unchanged: the ceiling is the fix, not limiting who may define a Workflow. - **Strict order (#1729).** A graph may carry `strict: true` (normalized to a boolean on the record). A strict graph starts one node at a time in declared order: `startFrontier` keeps only the first ready node running and leaves its ready siblings `queued`, so no parallel fan-out happens even when two nodes share a rank. They start when the node before them finishes, in list order. The default is unchanged — a graph without the flag still fans out — and the release-shaped delivery template passes unchanged. A second start against the persisted status after a crash between a claim and its result starts nothing that is already running and reaches no downstream node early, so no second downstream task is minted. The strict release graph (build → test → publish) and its refusal of a node whose required input was never produced are the regression (#1730). Tests: `plugins/workflow/test/strict-order.test.js`, `plugins/workflow/test/strict-release-dag.test.js`. - **Lease and delivery reads go through the host (#1729).** The plugin reads a card's lease with `fb.tasks.lease(taskId)` (`{ holder, takenAt, expiresAt, live, heldBy }`, or `undefined`) and the per-role/per-run delivery and resource counts with `fb.tasks.counters({ role, run })`. Both are host reads of the existing task rows (`rt.taskLease`, `rt.taskCounters`); the plugin keeps no copy, so an expired lease looks expired to both sides at once. + +## A workflow belongs to a Project (#1856) + +A workflow record carries `projectId`. `saveGraph` is the only writer: a non-empty value must name an existing Project (`fb.projects.read`, which reads the host's `readProject`) or the whole save is refused and nothing is written; `null` clears it; omitting the field keeps what is stored. + +A run is bound at creation, through `fb.projects.bindRun(runId, projectId)` — the seam beside `fb.tasks.onProjectProvider`. `assign()` resolves the Project once (the call's own `projectId` wins over the workflow's) and carries it with the task, so a parked run binds when it actually starts. The binding goes through the host's `bindProject({ kind: 'run', … })`, the same path the `project-bind` endpoint uses, so the Project board and Settings cannot disagree. An unknown Project, or a host without the seam, leaves the run unbound and logs once; it never stops the run from starting. diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index d36c72b8d..2d8b9e021 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1349,6 +1349,21 @@ export function install(rt) { return rt.taskCounters?.(query) ?? { roles: {}, runs: {} } }, }, + // #1856: a workflow run belongs to a Project, and the plugin cannot write host state. A host + // without this seam answers `undefined` and never throws: the run stays unbound and keeps today + // behavior. The write goes through the same `bindProject` the endpoint uses, so a run bound + // from an assign and a run bound from Settings cannot disagree. + projects: { + read(id) { + return typeof rt.readProject === "function" ? rt.readProject(String(id ?? '')) ?? undefined : undefined + }, + async bindRun(runId, projectId) { + if (typeof rt.bindProject !== "function") return undefined + const project = rt.bindProject({ kind: 'run', id: String(runId ?? ''), projectId }) + await rt.save?.() + return project ?? undefined + }, + }, tools, api, outcomeOf: exchangeId => outcomes.get(exchangeId), diff --git a/patches/ds-bot/files/test/project-run-seam.test.js b/patches/ds-bot/files/test/project-run-seam.test.js new file mode 100644 index 000000000..ae82467b6 --- /dev/null +++ b/patches/ds-bot/files/test/project-run-seam.test.js @@ -0,0 +1,49 @@ +// #1856: a workflow run belongs to a Project, but the plugin cannot write host state. `bindRun` +// is that one seam: it writes `state.projectRuns` through the same `bindProject` the endpoint +// uses, refuses an unknown Project, and a host without it answers `undefined` instead of throwing. +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 { createRuntime } from '../index.js' +import { fakeContext } from './fake-ctx.js' + +const seed = () => ({ + version: 1, + mainBotIds: ['a'], + bots: { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 } }, + rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, +}) + +async function start(t) { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-run-')) + writeFileSync(join(home, 'state.json'), JSON.stringify(seed())) + const rt = createRuntime(fakeContext(), { home }) + t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 })) + await rt.load() + return rt +} + +test('#1856: bindRun writes the run binding the board reads', async (t) => { + const rt = await start(t) + const project = rt.createProject({ name: 'FlyBot 开发' }) + const bound = await rt.flybot.projects.bindRun('run-abc', project.id) + assert.equal(bound.id, project.id) + assert.equal(rt.state.projectRuns['run-abc'], project.id) + assert.equal(rt.flybot.projects.read(project.id).name, 'FlyBot 开发') +}) + +test('#1856: bindRun refuses an unknown Project and writes nothing', async (t) => { + const rt = await start(t) + await assert.rejects(() => rt.flybot.projects.bindRun('run-abc', 'project-missing'), /Unknown Project project-missing/) + assert.equal(rt.state.projectRuns['run-abc'], undefined) +}) + +test('#1856: a host without the seam answers undefined instead of throwing', async (t) => { + const rt = await start(t) + rt.readProject = undefined + rt.bindProject = undefined + assert.equal(rt.flybot.projects.read('project-1'), undefined) + assert.equal(await rt.flybot.projects.bindRun('run-abc', 'project-1'), undefined) +}) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index a90fabd9d..53398d782 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -96,6 +96,10 @@ export function apply(ctx, config = {}) { readOnly: () => fb.readOnly?.() === true, warn: (...args) => fb.warn?.(...args), tasksWired, + // #1856: the host owns the Project record and the run binding; a host without the seam answers + // undefined and the service leaves the run unbound. + readProject: id => fb.projects?.read?.(id), + bindRun: (runId, projectId) => fb.projects?.bindRun?.(runId, projectId), ...lifecycle, }) void service.reap?.().catch(error => fb.warn?.('flybot-workflow: reap failed: %s', error)) diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index e44db96a9..fea496c50 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -153,6 +153,9 @@ function blankRun(workflow, task, now, history = []) { export function createWorkflowService({ dir, persist = true, now = () => Date.now(), bots = () => [], readOnly = () => false, warn = () => {}, tasksWired = false, spawn, release, dispatch, + // #1856: the host's Project seam. `readProject(id)` answers the record or null; `bindRun(runId, + // projectId)` binds a run at creation. A host without either leaves the run unbound. + readProject, bindRun, setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = handle => clearTimeout(handle), } = {}) { const file = dir ? join(dir, FILE) : null @@ -332,6 +335,8 @@ export function createWorkflowService({ trigger: item.trigger ?? 'manual', scheduleLabel: item.scheduleLabel ?? '', model: item.model ?? '', + // #1856: unowned is `null`, never a missing key, so the list and the editor agree. + projectId: item.projectId ?? null, nodes: item.nodes.map(node => publicNode(node, roster)), edges: (item.edges ?? []).map(publicEdge), layers: item.layers.map(layer => [...layer]), @@ -733,7 +738,7 @@ export function createWorkflowService({ } if (!best) return null const task = laneOf(best.item.id).queue.shift() - return { item: best.item, task: { id: task.id, title: task.title, ...(task.callerBotId ? { callerBotId: task.callerBotId } : {}) } } + return { item: best.item, task: { id: task.id, title: task.title, ...(task.callerBotId ? { callerBotId: task.callerBotId } : {}), ...(task.projectId ? { projectId: task.projectId } : {}) } } } function noteUnstarted(item, error) { @@ -805,6 +810,24 @@ export function createWorkflowService({ return runningOf(lane).some(run => run.task?.id === taskId) || lane.queue.some(task => task.id === taskId) } + // #1856: a run belongs to the Project its workflow names, or to the one the assign call named. + // A host without the seam, and a refused binding, leave the run unbound — the run still starts. + function bindRunProject(item, task, run) { + const projectId = cleanLine(task?.projectId ?? item?.projectId ?? '', 120) + if (projectId === '') return + if (typeof bindRun !== 'function') { + warnOnce('project-seam', 'flybot-workflow: this host has no project seam, so a run stays unbound') + return + } + const onError = error => warnOnce(`project-refused:${run.id}`, `flybot-workflow: run ${run.id} was refused Project ${projectId}: ${String(error?.message ?? error).slice(0, 200)}`) + try { + const bound = bindRun(run.id, projectId) + if (bound && typeof bound.then === 'function') bound.catch(onError) + } catch (error) { + onError(error) + } + } + 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` } @@ -823,6 +846,7 @@ export function createWorkflowService({ ], at) const stepped = applyOp(item, run, { type: 'start' }) run = stepped.run + bindRunProject(item, task, run) laneOf(item.id).active.push(run) push(item.id, stepped.lines, at) syncTimers(item, run) @@ -876,6 +900,8 @@ export function createWorkflowService({ queueSeq: state.queueSeq, // #1544: a parked run starts later without its caller in hand; keep it, or the ceiling is lost. ...(task.callerBotId ? { callerBotId: task.callerBotId } : {}), + // #1856: a parked run keeps the Project it was assigned with. + ...(task.projectId ? { projectId: task.projectId } : {}), }) const ahead = lane.queue.length - 1 push(item.id, [{ @@ -1115,6 +1141,11 @@ export function createWorkflowService({ // and the API pass none, and the graph's author is the ceiling there. const callerBotId = cleanLine(body.callerBotId, 80) if (callerBotId) task.callerBotId = callerBotId + // #1856: the call's own Project wins over the workflow's; the run is bound when it is + // created, which for a parked task is later, so the choice travels with the task. + const wanted = body.projectId !== undefined ? body.projectId : item.projectId + const projectId = cleanLine(wanted ?? '', 120) + if (projectId !== '') task.projectId = projectId if (typeof spawn !== 'function') return enqueue(item, task) return hostSerial(() => enqueue(item, task)) }, @@ -1417,6 +1448,17 @@ export function createWorkflowService({ const authoredBy = cleanLine(body.callerBotId ?? body.authoredBy, 80) || undefined if (authoredBy) item.authoredBy = authoredBy else delete item.authoredBy + // #1856: `null` clears, omitted keeps, and a name must resolve — an unknown Project + // refuses the whole save, so nothing half-written is left behind. + if (body.projectId !== undefined) { + const wantedId = body.projectId === null ? '' : cleanLine(body.projectId, 120) + if (wantedId === '') delete item.projectId + else { + const project = typeof readProject === 'function' ? readProject(wantedId) : null + if (!project || typeof project !== 'object') throw new Error(`Unknown Project ${wantedId}`) + item.projectId = wantedId + } + } const seated = seat(body.nodes, bots() ?? []) const seenBots = new Set() for (const node of seated) { diff --git a/plugins/workflow/test/assign.test.js b/plugins/workflow/test/assign.test.js index 7c865a6ad..1aca52d45 100644 --- a/plugins/workflow/test/assign.test.js +++ b/plugins/workflow/test/assign.test.js @@ -463,3 +463,69 @@ test('delete cleans up runs, logs, and routes', () => { assert.equal(deleted.ok, true) assert.ok(!deleted.workflows.some(item => item.id === laneId)) }) + +// #1856: a run belongs to the Project its workflow names, and the assign call may name another. +// The binding lands when the run is created, which for a parked task is later than the assign — +// so the choice travels with the task — and a refusal never keeps the run from starting. +function projectService() { + const bound = [] + const refusals = [] + const svc = createWorkflowService({ + persist: false, + now: () => 1_700_000_000_000, + bots: () => roster, + warn: message => refusals.push(message), + readProject: id => (id === 'project-1fe64499' || id === 'project-2' ? { id, name: 'FlyBot 开发' } : null), + bindRun: (runId, projectId) => { + if (projectId === 'project-unknown') throw new Error(`Unknown Project ${projectId}`) + bound.push({ runId, projectId }) + return { id: projectId } + }, + }) + return { svc, bound, refusals } +} + +function ownProject(svc, workflowId, projectId) { + const saved = svc.saveGraph({ workflowId, ...graphOf(svc, workflowId), projectId }) + assert.equal(saved.ok, true, JSON.stringify(saved)) +} + +function graphOf(svc, workflowId) { + const item = svc.state().workflows.find(workflow => workflow.id === workflowId) + return { nodes: item.nodes, edges: item.edges } +} + +test('#1856: a workflow carrying a Project binds the run it starts', () => { + const { svc, bound } = projectService() + ownProject(svc, DELIVERY_ID, 'project-1fe64499') + const state = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1856a', title: 'Bind at creation' }) + assert.equal(state.ok, true, JSON.stringify(state)) + const run = delivery(state).run + assert.deepEqual(bound, [{ runId: run.id, projectId: 'project-1fe64499' }]) +}) + +test('#1856: the Project named by the assign call wins over the workflow\'s own', () => { + const { svc, bound } = projectService() + ownProject(svc, DELIVERY_ID, 'project-1fe64499') + const state = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1856b', title: 'Call wins', projectId: 'project-2' }) + assert.equal(state.ok, true, JSON.stringify(state)) + assert.deepEqual(bound, [{ runId: delivery(state).run.id, projectId: 'project-2' }]) +}) + +test('#1856: a workflow with no Project binds nothing and behaves as before', () => { + const { svc, bound, refusals } = projectService() + const state = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1856c', title: 'Unowned' }) + assert.equal(state.ok, true, JSON.stringify(state)) + assert.deepEqual(bound, []) + assert.deepEqual(refusals, []) + assert.equal(delivery(state).run.status, 'running') +}) + +test('#1856: a refused binding still starts the run', () => { + const { svc, bound, refusals } = projectService() + const state = svc.assign({ workflowId: DELIVERY_ID, taskId: '#1856d', title: 'Refused', projectId: 'project-unknown' }) + assert.equal(state.ok, true, JSON.stringify(state)) + assert.equal(delivery(state).run.status, 'running', 'the run started despite the refused binding') + assert.deepEqual(bound, []) + assert.ok(refusals.some(message => /refused Project project-unknown/.test(message)), refusals.join(' | ')) +}) diff --git a/plugins/workflow/test/save-graph-project.test.js b/plugins/workflow/test/save-graph-project.test.js new file mode 100644 index 000000000..abdec0611 --- /dev/null +++ b/plugins/workflow/test/save-graph-project.test.js @@ -0,0 +1,51 @@ +// #1856: a workflow record carries `projectId`. The save is the only door that writes it, so it +// is where the three cases are decided: an unknown Project refuses the save and writes nothing, +// `null` clears the binding, and a save that omits the field keeps what is stored. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createWorkflowService } from '../src/service.js' + +const known = { 'project-1fe64499': { id: 'project-1fe64499', name: 'FlyBot 开发' } } + +function harness() { + return createWorkflowService({ + persist: false, + now: () => 1_700_000_000_000, + bots: () => [{ id: 'bot-coder', name: 'Coder', role: '实现', remote: false }], + readProject: id => known[id] ?? null, + }) +} + +async function define(svc) { + const state = await svc.define({ name: 'ProjectProbe', nodes: [{ id: 'a', name: 'A', prompt: 'Do A.' }], edges: [] }) + const item = state.workflows.find(workflow => workflow.name === 'ProjectProbe') + return { workflowId: item.id, nodes: item.nodes, edges: item.edges } +} + +const stored = (svc, workflowId) => svc.state().workflows.find(workflow => workflow.id === workflowId) + +test('#1856: an unknown Project is refused and nothing is written', async () => { + const svc = harness() + const graph = await define(svc) + const saved = svc.saveGraph({ ...graph, projectId: 'project-missing' }) + assert.equal(saved.ok, false, JSON.stringify(saved)) + assert.match(String(saved.error), /Unknown Project project-missing/) + assert.equal(stored(svc, graph.workflowId).projectId ?? null, null, 'the record kept no binding') +}) + +test('#1856: projectId null clears the binding', async () => { + const svc = harness() + const graph = await define(svc) + assert.equal(svc.saveGraph({ ...graph, projectId: 'project-1fe64499' }).ok, true) + assert.equal(stored(svc, graph.workflowId).projectId, 'project-1fe64499') + assert.equal(svc.saveGraph({ ...graph, projectId: null }).ok, true) + assert.equal(stored(svc, graph.workflowId).projectId, null) +}) + +test('#1856: a save that omits projectId keeps the stored binding', async () => { + const svc = harness() + const graph = await define(svc) + assert.equal(svc.saveGraph({ ...graph, projectId: 'project-1fe64499' }).ok, true) + assert.equal(svc.saveGraph({ ...graph }).ok, true) + assert.equal(stored(svc, graph.workflowId).projectId, 'project-1fe64499') +}) From acfbe93b3b3bfea7269d04ec78a41c384395b9a6 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 31 Oct 2026 00:54:55 +0800 Subject: [PATCH 2/3] Apply the Project binding only after the graph validated (#1856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A save that throws must leave neither the record nor the lane half-changed, so the value is checked before the graph is validated and written after it. Docs-not-needed: no — docs/subsystems/workflow.md and docs/subsystems/core-host.md are updated in the parent commit of this branch. --- plugins/workflow/src/service.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/workflow/src/service.js b/plugins/workflow/src/service.js index fea496c50..ff8fb1e78 100644 --- a/plugins/workflow/src/service.js +++ b/plugins/workflow/src/service.js @@ -1449,15 +1449,11 @@ export function createWorkflowService({ if (authoredBy) item.authoredBy = authoredBy else delete item.authoredBy // #1856: `null` clears, omitted keeps, and a name must resolve — an unknown Project - // refuses the whole save, so nothing half-written is left behind. - if (body.projectId !== undefined) { - const wantedId = body.projectId === null ? '' : cleanLine(body.projectId, 120) - if (wantedId === '') delete item.projectId - else { - const project = typeof readProject === 'function' ? readProject(wantedId) : null - if (!project || typeof project !== 'object') throw new Error(`Unknown Project ${wantedId}`) - item.projectId = wantedId - } + // refuses the whole save before anything is touched, so a failed edit writes nothing. + const projectId = body.projectId === undefined ? undefined : body.projectId === null ? null : cleanLine(body.projectId, 120) + if (projectId !== undefined && projectId !== null) { + const project = typeof readProject === "function" ? readProject(projectId) : null + if (!project || typeof project !== "object") throw new Error(`Unknown Project ${projectId}`) } const seated = seat(body.nodes, bots() ?? []) const seenBots = new Set() @@ -1500,6 +1496,10 @@ export function createWorkflowService({ item.nodes = next.nodes item.edges = next.edges item.layers = next.layers + // Applied only once the graph validated and the runs settled, so a save that throws leaves + // neither the record nor the lane half-changed (#1856). + if (projectId === null) delete item.projectId + else if (projectId !== undefined) item.projectId = projectId for (const { run, names } of endedRuns) { lane.active = lane.active.filter(itemRun => itemRun.id !== run.id) lane.last = run From c8362074093ae4363e92f9dbf40ba10f73832ca5 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 31 Oct 2026 01:03:56 +0800 Subject: [PATCH 3/3] Show a workflow's Project in the sidebar and in Settings (#1856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar row names the Project a workflow belongs to and reads Unassigned (未归属) when it has none; one Project filter above the list narrows it to a single Project or to the unowned rows. The graph editor carries a Project picker fed by the host projects endpoint, and it saves through the same workflow/graph call that carries the nodes and edges, sending null when the binding is cleared. Docs-not-needed: no — docs/subsystems/workflow.md is updated in this commit. --- docs/subsystems/workflow.md | 2 + plugins/workflow/client.js | 67 ++++++++++++++++++++++- plugins/workflow/test/client.test.js | 79 ++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index acd978f45..a166cddaf 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -80,4 +80,6 @@ A connector a Bot switched off is not reopened by a Workflow, whichever surface A workflow record carries `projectId`. `saveGraph` is the only writer: a non-empty value must name an existing Project (`fb.projects.read`, which reads the host's `readProject`) or the whole save is refused and nothing is written; `null` clears it; omitting the field keeps what is stored. +- The Settings page names the Project on every sidebar row (a workflow with none reads `Unassigned`, zh 未归属), offers one Project filter above the list (`All Projects` plus each Project plus the unassigned rows), and carries a Project picker in the graph editor; the picker saves through the same `workflow/graph` call that carries the nodes and edges, and an empty choice sends `null`. The picker is fed by the host `projects` endpoint, so it lists what the core store holds and never a second copy. + A run is bound at creation, through `fb.projects.bindRun(runId, projectId)` — the seam beside `fb.tasks.onProjectProvider`. `assign()` resolves the Project once (the call's own `projectId` wins over the workflow's) and carries it with the task, so a parked run binds when it actually starts. The binding goes through the host's `bindProject({ kind: 'run', … })`, the same path the `project-bind` endpoint uses, so the Project board and Settings cannot disagree. An unknown Project, or a host without the seam, leaves the run unbound and logs once; it never stops the run from starting. diff --git a/plugins/workflow/client.js b/plugins/workflow/client.js index 97e73b55e..10c31c611 100644 --- a/plugins/workflow/client.js +++ b/plugins/workflow/client.js @@ -22,6 +22,11 @@ window.__ModuleLoader__.load({ const ZH = { Workflows: 'Workflows', New: '新建', + Project: '项目', + Unassigned: '未归属', + 'All Projects': '全部项目', + 'Filter by Project': '按项目筛选', + 'No workflows in this Project': '这个项目下还没有 Workflow', 'Hand a task to a Workflow': '把任务交给 Workflow', 'A task can go to a Workflow, not only to one Bot. The Workflow then runs each Bot in order, or side by side.': '任务不再只能交给单个 Bot —— 交给 Workflow 后,会按固定流程依次(或并行)交给各个 Bot 执行。', 'Pick a task card': '从 Tasks 看板选择任务卡片', @@ -1501,6 +1506,7 @@ window.__ModuleLoader__.load({ const [error, setError] = useState('') const [loading, setLoading] = useState(true) const [attempt, setAttempt] = useState(0) + const [projects, setProjects] = useState([]) useEffect(() => { let gone = false @@ -1523,6 +1529,16 @@ window.__ModuleLoader__.load({ return undefined }, []) + useEffect(() => { + const host = globalThis.__flybot__?.host + if (typeof host !== 'function') return undefined + let gone = false + void host('projects', {}).then(listing => { + if (!gone && Array.isArray(listing?.projects)) setProjects(listing.projects) + }).catch(() => {}) + return () => { gone = true } + }, []) + const workflows = pack?.workflows ?? [] const current = workflows.find(item => item.id === workflowId) ?? workflows[0] const nodes = current?.nodes ?? [] @@ -1541,6 +1557,16 @@ window.__ModuleLoader__.load({ const updateNode = (nodeId, updates) => { save(nodes.map(item => item.id === nodeId ? { ...item, ...updates } : item), edges) } + + // The binding is stored with the graph it belongs to: one save carries the + // nodes, the edges and the Project, and null clears it. + const setProject = (projectId) => { + if (!current) return + void settledCall(call, 'workflow/graph', { workflowId: current.id, nodes, edges, projectId }, 'Could not save this workflow').then(value => { + if (value?.ok === false) { setError(value.error ?? tr('Could not save this workflow')); return } + if (value?.workflows) { setPack(value); setError('') } + }) + } const [edgeForm, setEdgeForm] = useState({ from: '', to: '', kind: 'forward', max: 5, until: { output: '', eq: '' } }) const [toolsDraft, setToolsDraft] = useState(null) @@ -1594,6 +1620,15 @@ window.__ModuleLoader__.load({ value: current.id, onChange: (event) => { setWorkflowId(event.target.value); setSelectedNode(null) }, }, workflows.map(item => h('option', { key: item.id, value: item.id }, item.name)))), + h('label', { className: 'wf-field' }, + h('span', null, tr('Project')), + h('select', { + 'aria-label': tr('Project'), + value: current.projectId ?? '', + onChange: (event) => setProject(event.target.value || null), + }, + h('option', { value: '' }, tr('Unassigned')), + projects.map(project => h('option', { key: project.id, value: project.id }, project.name)))), h('label', { className: 'wf-field' }, h('span', null, tr('Model')), h('select', { @@ -1847,6 +1882,8 @@ window.__ModuleLoader__.load({ const [rowMenu, setRowMenu] = useState(null) const [renaming, setRenaming] = useState(null) const [deleting, setDeleting] = useState(null) + const [projects, setProjects] = useState([]) + const [projectFilter, setProjectFilter] = useState('') const stageLeft = useConversationInset() const stageCall = (name, body, fallback) => settledCall(call, name, body, fallback) @@ -1870,6 +1907,12 @@ window.__ModuleLoader__.load({ void refresh().then(async () => { const host = globalThis.__flybot__?.host if (gone || typeof host !== 'function') return + try { + const listing = await host('projects', {}) + if (!gone && Array.isArray(listing?.projects)) setProjects(listing.projects) + } catch (loadError) { + void loadError + } try { const rows = await host('tasks', {}) if (!Array.isArray(rows) || gone) return @@ -1920,6 +1963,10 @@ window.__ModuleLoader__.load({ const workflows = pack?.workflows ?? [] const open = workflows.find(item => item.id === openId) ?? null + const projectName = (id) => projects.find(project => project.id === id)?.name ?? '' + const shown = projectFilter === '' + ? workflows + : workflows.filter(item => (projectFilter === 'none' ? !item.projectId : item.projectId === projectFilter)) useEffect(() => { if (!open) { setFocusedRunId(null); return } @@ -2059,10 +2106,21 @@ window.__ModuleLoader__.load({ h('div', { className: 'wf-sec' }, h('span', null, tr('Workflows')), h('button', { type: 'button', className: 'wf-new', onClick: () => setCreating(true) }, tr('New'))), + workflows.length > 0 ? h('label', { className: 'wf-filter' }, + h('span', null, tr('Filter by Project')), + h('select', { + 'aria-label': tr('Filter by Project'), + value: projectFilter, + onChange: (event) => setProjectFilter(event.target.value), + }, + h('option', { value: '' }, tr('All Projects')), + projects.map(project => h('option', { key: project.id, value: project.id }, project.name)), + h('option', { value: 'none' }, tr('Unassigned')))) : null, pack?.tasksWired === false ? h('div', { className: 'wf-wiring', role: 'status' }, tr('This host does not pass task writes to Workflows, so a card on the board cannot start a run.')) : null, workflows.length === 0 && !readError ? h('div', { className: 'wf-muted' }, tr('No workflows yet.')) : null, - workflows.map(item => h('button', { + workflows.length > 0 && shown.length === 0 ? h('div', { className: 'wf-muted' }, tr('No workflows in this Project')) : null, + shown.map(item => h('button', { key: item.id, type: 'button', className: `wf-row ${openId === item.id ? 'on' : ''}`, onClick: () => { setOpenId(item.id) }, onContextMenu: (event) => { @@ -2073,7 +2131,8 @@ window.__ModuleLoader__.load({ }, h('span', { className: 'wf-ico' }, h(MiniFlowchart, { workflow: item, compact: true })), h('span', { className: 'wf-txt' }, - h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow')), + h('span', { className: 'wf-t1' }, item.name, h('span', { className: 'wf-badge' }, 'Workflow'), + h('span', { className: `wf-proj${projectName(item.projectId) ? '' : ' none'}` }, projectName(item.projectId) || tr('Unassigned'))), h('span', { className: 'wf-t2' }, sidebarPreview(item))), (item.activeCount ?? 0) > 0 ? h('span', { className: 'wf-live', 'aria-hidden': true }) : null)), h('button', { type: 'button', className: 'wf-row wf-assign-row', onClick: () => { setAssignOpen(true); void loadTasks() } }, @@ -2173,6 +2232,10 @@ window.__ModuleLoader__.load({ const CSS = ` .wf-sec{display:flex;align-items:center;justify-content:space-between;padding:12px 8px 4px;font-size:11px;color:var(--bt-ink-3,#9a9a9a)} .wf-new{color:var(--bt-accent,#4d6bfe);background:none;border:0;cursor:pointer;font:inherit;font-size:11px} +.wf-filter{display:flex;align-items:center;gap:8px;padding:0 8px 4px;font-size:11px;color:var(--bt-ink-3,#9a9a9a)} +.wf-filter select{flex:1;min-width:0;font:inherit;font-size:11px;background:transparent;color:inherit;border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:6px;padding:2px 4px} +.wf-proj{font-size:9.5px;padding:0 5px;border-radius:4px;background:var(--bt-tag-bg,rgba(20,20,20,.06));color:var(--bt-tag-ink,rgba(20,20,20,.58));line-height:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px} +.wf-proj.none{opacity:.7;font-style:italic} .wf-row{display:flex;align-items:center;gap:10px;width:100%;padding:8px;border:0;border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer} .wf-row.on,.wf-row:hover{background:var(--bt-hover,rgba(119,119,119,.09))} .wf-txt{min-width:0;flex:1} diff --git a/plugins/workflow/test/client.test.js b/plugins/workflow/test/client.test.js index 8ae782331..97c4e2459 100644 --- a/plugins/workflow/test/client.test.js +++ b/plugins/workflow/test/client.test.js @@ -2921,3 +2921,82 @@ test('the drawer Edit plane encloses the delivery parallel pair in a frame its l assert.ok(labelMax >= LABEL_ONE_LINE, `the label is capped at ${labelMax}px, so 'Parallel branches · Merge after all pass' wraps`) view.unmount() }) + +// #1856: a Workflow belongs to a Project. The sidebar row names it, a workflow +// with none says so, and one filter narrows the list to a single Project. +test('#1856: the sidebar names each workflow Project and filters to one Project', async () => { + const alone = delivery() + alone.projectId = 'proj-atlas' + const loose = { ...delivery(), id: 'loose', name: 'Loose flow' } + const call = async (name) => { + if (name === 'workflow/state') return { workflows: [alone, loose], templates: [] } + return { ok: true, workflows: [alone, loose] } + } + const prevHost = globalThis.__flybot__ + globalThis.__flybot__ = { + t: key => (key === 'Tasks' ? '任务' : key), + host: async (channel) => (channel === 'projects' ? { projects: [{ id: 'proj-atlas', name: 'Atlas' }] } : []), + } + try { + const view = mount(client.Section, { roster: { bots: [] }, call, actions: {} }) + await settle() + const row = (name) => findAll(view.tree, node => node.type === 'button' && String(node.props?.className ?? '').includes('wf-row') && textOf(node).includes(name))[0] + assert.match(textOf(row('发布交付流水线')), /Atlas/, 'the row names the Project the workflow belongs to') + assert.match(textOf(row('Loose flow')), /未归属/, 'a workflow with no Project says so') + + const filter = () => findAll(view.tree, node => node.props?.['aria-label'] === '按项目筛选')[0] + assert.ok(filter(), 'one Project filter sits above the list') + filter().props.onChange({ target: { value: 'proj-atlas' } }) + await settle() + assert.ok(row('发布交付流水线'), 'the chosen Project keeps its own workflow') + assert.equal(row('Loose flow'), undefined, 'another Project is filtered out') + filter().props.onChange({ target: { value: 'none' } }) + await settle() + assert.ok(row('Loose flow'), 'unowned workflows are a filter of their own') + assert.equal(row('发布交付流水线'), undefined) + view.unmount() + } finally { + globalThis.__flybot__ = prevHost + } +}) + +// #1856: the editor binds the graph to a Project through the same save that +// carries the nodes and edges, and an empty choice clears it. +test('#1856: the settings picker binds a workflow to a Project and clears it', async () => { + const graph = delivery() + const calls = [] + const call = async (name, body) => { + calls.push({ name, body }) + if (name === 'workflow/state') return { workflows: [graph] } + if (name === 'workflow/graph') { graph.projectId = body.projectId; return { ok: true, workflows: [{ ...graph }] } } + return { ok: true, workflows: [graph] } + } + const prevHost = globalThis.__flybot__ + globalThis.__flybot__ = { + host: async (channel) => (channel === 'projects' ? { projects: [{ id: 'proj-atlas', name: 'Atlas' }] } : []), + } + try { + const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } }) + await settle() + const picker = () => findAll(settings.tree, node => node.type === 'select' && node.props?.['aria-label'] === 'Project')[0] + assert.ok(picker(), 'the editor carries a Project picker') + assert.equal(picker().props.value, '', 'a workflow with no Project shows the unassigned choice') + assert.deepEqual(picker().children.map(textOf), ['Unassigned', 'Atlas']) + + picker().props.onChange({ target: { value: 'proj-atlas' } }) + await settle() + const bound = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.equal(bound.body.projectId, 'proj-atlas') + assert.equal(bound.body.workflowId, 'delivery') + assert.ok(Array.isArray(bound.body.nodes) && bound.body.nodes.length > 0, 'the save still carries the graph') + assert.equal(picker().props.value, 'proj-atlas', 'the picker repaints from the saved store') + + picker().props.onChange({ target: { value: '' } }) + await settle() + const cleared = calls.filter(item => item.name === 'workflow/graph').at(-1) + assert.equal(cleared.body.projectId, null, 'an empty choice clears the binding') + settings.unmount() + } finally { + globalThis.__flybot__ = prevHost + } +})