FN-7192: Document custom workflow capacity dispatch
Clarify the custom workflow migration boundary and lock dispatch behavior with tests. - Document that pure-v1 custom workflows keep trait-less default columns for rollback compatibility. - Show the v2 hold(capacity) and wip traits required for custom workflow capacity dispatch. - Add core and engine regression coverage for pure-v1 stranding and authored-v2 release behavior. Files changed: docs/workflow-editor.md | 34 +++++ docs/workflow-steps.md | 40 ++++++ .../__tests__/custom-v1-workflow-dispatch.test.ts | 154 +++++++++++++++++++++ packages/engine/src/__tests__/hold-release.test.ts | 81 ++++++++++- 4 files changed, 306 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7192 Fusion-Task-Lineage: ff7af258-c0cb-4b81-8919-9e076c3ac1df Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -74,6 +74,40 @@ The editor prevents ordinary cycles while connecting nodes. If a graph branches
|
||||
|
||||
The **Columns** panel edits workflow-defined swimlanes. A column has an id, name, ordered position, and composable traits. The panel can add, rename, reorder, and remove columns for custom workflows; built-ins show the same data read-only.
|
||||
|
||||
<!--
|
||||
FNXC:Workflows 2026-06-28-09:45:
|
||||
FN-7190/FN-7192 made the workflow-columns dispatch contract explicit: `runHoldReleaseSweep` only releases cards resting in a hold column, so custom workflows that queue work in `todo` must author a v2 `hold(capacity)` todo column instead of relying on pure-v1 default columns.
|
||||
-->
|
||||
|
||||
For task dispatch, the queue column that holds ready work must carry a `hold` trait with `release: "capacity"`, and the downstream active column must carry a `wip` trait. Pure-v1 custom graphs still upgrade to the legacy default column ids with empty trait sets for rollback compatibility, so a task selecting that workflow can remain in `todo` until the workflow is migrated to v2 columns. When authoring or migrating a custom coding workflow, mirror this minimum shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v2",
|
||||
"columns": [
|
||||
{
|
||||
"id": "todo",
|
||||
"name": "todo",
|
||||
"traits": [
|
||||
{ "trait": "hold", "config": { "release": "capacity" } },
|
||||
{ "trait": "reset-on-entry" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "in-progress",
|
||||
"name": "in-progress",
|
||||
"traits": [
|
||||
{ "trait": "wip", "config": { "limit": "settings.maxConcurrent" } },
|
||||
{ "trait": "abort-on-exit" },
|
||||
{ "trait": "timing" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Copying a selectable built-in workflow is the easiest way to inherit the full canonical trait set (`todo`, `in-progress`, `in-review`, `done`, `archived`) before customizing nodes or prompts.
|
||||
|
||||
When column-agent support is enabled by the required experimental features, a column can also assign a permanent agent with one of two modes:
|
||||
|
||||
- **defer:** use the column agent only when the work has no more specific agent/model setting.
|
||||
|
||||
@@ -69,6 +69,46 @@ Skill-backed prompt/gate nodes run through the same workflow-step session builde
|
||||
|
||||
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.
|
||||
|
||||
<!--
|
||||
FNXC:Workflows 2026-06-28-09:50:
|
||||
Pure-v1 custom graphs remain rollback-compatible by upgrading to trait-less default columns. Capacity dispatch after the workflow-columns cutover is therefore an explicit v2 authoring requirement, not an implicit v1 upgrade side effect.
|
||||
-->
|
||||
|
||||
#### Capacity dispatch for custom workflows
|
||||
|
||||
After the workflow-columns cutover, the only automatic queued-work dispatcher is the engine's hold/release sweep. It releases a task from `todo` only when that column resolves as a `hold` column and its hold config uses `release: "capacity"`; it then moves the card to the nearest downstream `wip` column with available capacity.
|
||||
|
||||
Pure-v1 custom workflow definitions (`start` / `prompt` / `script` / `gate` / `end` nodes with default columns) still parse and upgrade by synthesizing the legacy column ids with empty trait sets. That shape is intentional for FN-5769 / issue #1405 rollback compatibility: it can be downgraded back to v1 for older binaries. The tradeoff is that a pure-v1 custom workflow's `todo` column is not a hold column, so tasks can sit in `todo` instead of dispatching to `in-progress`.
|
||||
|
||||
For capacity-dispatched custom workflows, author or migrate the workflow as IR v2 and give `todo` and `in-progress` the canonical dispatch traits (the same minimum used by the built-in coding workflow):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v2",
|
||||
"columns": [
|
||||
{
|
||||
"id": "todo",
|
||||
"name": "todo",
|
||||
"traits": [
|
||||
{ "trait": "hold", "config": { "release": "capacity" } },
|
||||
{ "trait": "reset-on-entry" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "in-progress",
|
||||
"name": "in-progress",
|
||||
"traits": [
|
||||
{ "trait": "wip", "config": { "limit": "settings.maxConcurrent" } },
|
||||
{ "trait": "abort-on-exit" },
|
||||
{ "trait": "timing" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If you need the full lifecycle behavior, duplicate `builtin:coding` (or another selectable built-in) and edit the copy so `in-review`, `done`, and `archived` keep their merge/review/completion traits. FN-7190 keeps selectable built-ins on canonical traits; FN-7192 documents and tests the custom-v1 migration boundary.
|
||||
|
||||
### Workflow graph integrity validation
|
||||
|
||||
<!--
|
||||
|
||||
154
packages/core/src/__tests__/custom-v1-workflow-dispatch.test.ts
Normal file
154
packages/core/src/__tests__/custom-v1-workflow-dispatch.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore, type WorkflowIr, type WorkflowIrV1, type WorkflowIrV2 } from "../index.js";
|
||||
import { resolveColumnFlags } from "../trait-registry.js";
|
||||
import { downgradeIrToV1IfPure, parseWorkflowIr } from "../workflow-ir.js";
|
||||
import { resolveWorkflowIrForTask } from "../workflow-ir-resolver.js";
|
||||
import { stepsToWorkflowIr } from "../workflow-steps-to-ir.js";
|
||||
|
||||
const pureV1CustomWorkflow = (): WorkflowIrV1 => ({
|
||||
version: "v1",
|
||||
name: "pure-v1-custom",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
],
|
||||
});
|
||||
|
||||
const authoredV2CapacityWorkflow = (): WorkflowIrV2 => ({
|
||||
version: "v2",
|
||||
name: "authored-v2-capacity-workflow",
|
||||
columns: [
|
||||
{ id: "todo", name: "todo", traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }] },
|
||||
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limit: "settings.maxConcurrent" } }, { trait: "abort-on-exit" }, { trait: "timing" }] },
|
||||
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", prompt: "Do the work" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
],
|
||||
});
|
||||
|
||||
function todoColumn(ir: WorkflowIr) {
|
||||
if (ir.version !== "v2") throw new Error("expected upgraded v2 IR");
|
||||
const column = ir.columns.find((candidate) => candidate.id === "todo");
|
||||
if (!column) throw new Error("expected todo column");
|
||||
return column;
|
||||
}
|
||||
|
||||
function inProgressColumn(ir: WorkflowIr) {
|
||||
if (ir.version !== "v2") throw new Error("expected v2 IR");
|
||||
const column = ir.columns.find((candidate) => candidate.id === "in-progress");
|
||||
if (!column) throw new Error("expected in-progress column");
|
||||
return column;
|
||||
}
|
||||
|
||||
function setSelection(store: TaskStore, taskId: string, workflowId: string): void {
|
||||
const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db;
|
||||
db.prepare(
|
||||
`INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt)
|
||||
VALUES (?, ?, '[]', ?)
|
||||
ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`,
|
||||
).run(taskId, workflowId, new Date().toISOString());
|
||||
}
|
||||
|
||||
function rawStoredWorkflowIr(store: TaskStore, workflowId: string): unknown {
|
||||
const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => { ir: string } | undefined } } }).db;
|
||||
const row = db.prepare("SELECT ir FROM workflows WHERE id = ?").get(workflowId);
|
||||
if (!row) throw new Error(`missing workflow row ${workflowId}`);
|
||||
return JSON.parse(row.ir);
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:Workflows 2026-06-28-08:45:
|
||||
* Pure-v1 custom workflows intentionally upgrade through synthesizeDefaultColumns(), whose columns are placement-only and trait-less for FN-5769/#1405 rollback compatibility. Capacity-dispatched custom workflows must author v2 columns with todo hold(capacity); the engine test suite asserts that documented remedy performs the actual sweep release.
|
||||
*/
|
||||
describe("custom v1 workflow dispatch characterization", () => {
|
||||
let rootDir = "";
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "fn7192-custom-v1-workflow-"));
|
||||
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
|
||||
await store.init();
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try { store?.close(); } catch { /* ignore */ }
|
||||
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("documents that pure-v1 custom workflows resolve to a trait-less todo column", async () => {
|
||||
const definition = await store.createWorkflowDefinition({
|
||||
name: "pure v1 custom",
|
||||
ir: pureV1CustomWorkflow(),
|
||||
});
|
||||
const task = await store.createTask({ description: "uses pure v1 custom workflow" });
|
||||
setSelection(store, task.id, definition.id);
|
||||
|
||||
const resolved = await resolveWorkflowIrForTask(store, task.id);
|
||||
const todo = todoColumn(resolved);
|
||||
|
||||
expect(todo.traits).toEqual([]);
|
||||
expect(resolveColumnFlags(todo).hold).not.toBe(true);
|
||||
});
|
||||
|
||||
it("proves the documented v2 remedy authors hold(capacity) on todo and wip capacity downstream", () => {
|
||||
const resolved = parseWorkflowIr(authoredV2CapacityWorkflow());
|
||||
|
||||
const todo = todoColumn(resolved);
|
||||
expect(todo.traits).toEqual(
|
||||
expect.arrayContaining([{ trait: "hold", config: { release: "capacity" } }]),
|
||||
);
|
||||
expect(resolveColumnFlags(todo).hold).toBe(true);
|
||||
|
||||
const inProgress = inProgressColumn(resolved);
|
||||
expect(resolveColumnFlags(inProgress).countsTowardWip).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps pure-v1 round-trip compatibility for v1 inputs and step-derived pure-v1 graphs", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
|
||||
|
||||
const fromRawV1 = await store.createWorkflowDefinition({
|
||||
name: "persisted raw v1",
|
||||
ir: pureV1CustomWorkflow(),
|
||||
});
|
||||
const storedRawV1 = rawStoredWorkflowIr(store, fromRawV1.id) as { version?: string };
|
||||
expect(storedRawV1.version).toBe("v1");
|
||||
|
||||
const fromSteps = stepsToWorkflowIr([
|
||||
{
|
||||
name: "Plan",
|
||||
mode: "prompt",
|
||||
prompt: "Plan the work",
|
||||
gateMode: "advisory",
|
||||
},
|
||||
], "step-derived pure v1");
|
||||
expect(fromSteps.version).toBe("v2");
|
||||
expect(downgradeIrToV1IfPure(fromSteps).version).toBe("v1");
|
||||
|
||||
const stepDerivedDefinition = await store.createWorkflowDefinition({
|
||||
name: "persisted step-derived v1",
|
||||
ir: fromSteps,
|
||||
});
|
||||
const storedFromSteps = rawStoredWorkflowIr(store, stepDerivedDefinition.id) as { version?: string };
|
||||
expect(storedFromSteps.version).toBe("v1");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { TaskStore, type Task, type WorkflowIr } from "@fusion/core";
|
||||
import { getBuiltinWorkflow, resolveColumnFlags, TaskStore, type Task, type WorkflowIr } from "@fusion/core";
|
||||
import {
|
||||
runHoldReleaseSweep,
|
||||
promoteHeldTask,
|
||||
@@ -70,6 +70,46 @@ const LINEAR_BUILTIN_WORKFLOW_IDS = [
|
||||
"builtin:design",
|
||||
] as const;
|
||||
|
||||
function pureV1CustomWorkflowIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name: "pure-v1-custom",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "execute", kind: "prompt", config: { seam: "execute", prompt: "Do the work" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
],
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
function authoredV2CapacityWorkflowIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "authored-v2-capacity-workflow",
|
||||
columns: [
|
||||
{ id: "todo", name: "todo", traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }] },
|
||||
{ id: "in-progress", name: "in-progress", traits: [{ trait: "wip", config: { limit: "settings.maxConcurrent" } }, { trait: "abort-on-exit" }, { trait: "timing" }] },
|
||||
{ id: "in-review", name: "in-review", traits: [{ trait: "merge-blocker" }, { trait: "human-review" }] },
|
||||
{ id: "done", name: "done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "todo" },
|
||||
{ id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute", prompt: "Do the work" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "execute", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "success" },
|
||||
{ from: "execute", to: "end", condition: "failure" },
|
||||
],
|
||||
} as WorkflowIr;
|
||||
}
|
||||
|
||||
describe("hold-release sweep (U6)", () => {
|
||||
let rootDir = "";
|
||||
let store: TaskStore;
|
||||
@@ -107,8 +147,16 @@ describe("hold-release sweep (U6)", () => {
|
||||
const defaultWorkflowTask = await seedTodoCard();
|
||||
const selectedTasks: string[] = [];
|
||||
|
||||
// Pre-fix, linear() synthesized trait-less default columns, so isHeldTask()
|
||||
// returned false here and these selected tasks were silently skipped forever.
|
||||
for (const workflowId of LINEAR_BUILTIN_WORKFLOW_IDS) {
|
||||
const builtin = getBuiltinWorkflow(workflowId);
|
||||
if (!builtin?.ir || builtin.ir.version !== "v2") throw new Error(`missing v2 built-in ${workflowId}`);
|
||||
const todo = builtin.ir.columns.find((column) => column.id === "todo");
|
||||
expect(todo).toBeDefined();
|
||||
expect(resolveColumnFlags(todo!).hold).toBe(true);
|
||||
}
|
||||
|
||||
// Pre-FN-7190, linear() synthesized trait-less default columns, so isHeldTask()
|
||||
// returned false here and these selected built-in tasks were silently skipped forever.
|
||||
for (const workflowId of LINEAR_BUILTIN_WORKFLOW_IDS) {
|
||||
const task = await store.createTask({ description: `card ${workflowId}` });
|
||||
setSelection(store, task.id, workflowId);
|
||||
@@ -124,6 +172,33 @@ describe("hold-release sweep (U6)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("documents custom v1 stranding and proves the authored v2 capacity-column remedy", async () => {
|
||||
await store.updateSettings({ maxConcurrent: 10 } as Parameters<typeof store.updateSettings>[0]);
|
||||
const defaultWorkflowTask = await seedTodoCard();
|
||||
|
||||
const v1Def = await store.createWorkflowDefinition({ name: "pure v1 custom", ir: pureV1CustomWorkflowIr() });
|
||||
const v1Task = await store.createTask({ description: "pure-v1 custom card" });
|
||||
setSelection(store, v1Task.id, v1Def.id);
|
||||
setColumn(store, v1Task.id, "todo");
|
||||
|
||||
const v2Def = await store.createWorkflowDefinition({ name: "authored v2 capacity", ir: authoredV2CapacityWorkflowIr() });
|
||||
const v2Task = await store.createTask({ description: "authored-v2 custom card" });
|
||||
setSelection(store, v2Task.id, v2Def.id);
|
||||
setColumn(store, v2Task.id, "todo");
|
||||
|
||||
/*
|
||||
* FNXC:Workflows 2026-06-28-09:17:
|
||||
* The migration contract documented in docs/workflow-editor.md is behavioral: pure-v1 custom workflows stay rollback-compatible and are not held, while authored-v2 workflows that put hold(capacity) on todo are released by the sole post-cutover dispatcher.
|
||||
*/
|
||||
const result = await runHoldReleaseSweep(store, noReserveDeps);
|
||||
|
||||
expect(result.released).toEqual(expect.arrayContaining([defaultWorkflowTask, v2Task.id]));
|
||||
expect(result.released).not.toContain(v1Task.id);
|
||||
expect((await store.getTask(defaultWorkflowTask))?.column).toBe("in-progress");
|
||||
expect((await store.getTask(v2Task.id))?.column).toBe("in-progress");
|
||||
expect((await store.getTask(v1Task.id))?.column).toBe("todo");
|
||||
});
|
||||
|
||||
it("ignores stale workflowColumns=false and still releases held default-workflow cards", async () => {
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } });
|
||||
const id = await seedTodoCard();
|
||||
|
||||
Reference in New Issue
Block a user