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..a166cddaf 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -75,3 +75,11 @@ 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. + +- 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/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/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/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..ff8fb1e78 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,13 @@ 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 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() for (const node of seated) { @@ -1458,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 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/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 + } +}) 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') +})