Merge pull request #1536 from Runfusion/feature/workflow-mapping

feat(FN-6035): route execution through workflow primitives
This commit is contained in:
gsxdsm
2026-06-08 22:26:42 -07:00
committed by GitHub
26 changed files with 1974 additions and 177 deletions

View File

@@ -36,7 +36,7 @@ describe("builtin coding workflow ir", () => {
const seams = BUILTIN_CODING_WORKFLOW_IR.nodes
.map((node) => String(node.config?.seam ?? ""))
.filter((seam) => seam.length > 0);
expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"]));
expect(seams).toEqual(expect.arrayContaining(["execute", "workflow-step", "review", "merge"]));
expect(seams).not.toContain("triage");
});
@@ -66,13 +66,15 @@ describe("builtin coding workflow ir", () => {
it("places seam nodes in their columns", () => {
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("execute")?.column).toBe("in-progress");
expect(byId.get("workflow-step")?.column).toBe("in-progress");
expect(byId.get("review")?.column).toBe("in-review");
expect(byId.get("merge")?.column).toBe("in-review");
});
it("assigns descriptive names to execute/review/merge seam nodes", () => {
it("assigns descriptive names to execute/workflow-step/review/merge seam nodes", () => {
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("execute")?.config?.name).toBe("Execute");
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
expect(byId.get("review")?.config?.name).toBe("Review");
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
});
@@ -85,8 +87,10 @@ describe("builtin coding workflow ir", () => {
expect(config.maxRetries).toBeLessThanOrEqual(10);
const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n]));
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
expect(byId.get("review")?.config?.name).toBe("Review");
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
expect(byId.get("merge")?.config?.maxRetries).toBeUndefined();
});

View File

@@ -133,6 +133,7 @@ describe("built-in workflows", () => {
const byId = new Map(ir.nodes.map((node) => [node.id, node]));
expect(byId.get("execute")?.column).toBe("in-progress");
expect(byId.get("workflow-step")?.column).toBe("in-progress");
expect(byId.get("review")?.column).toBe("in-review");
expect(byId.get("merge")?.column).toBe("in-review");
expect(ir.settings).toEqual(BUILTIN_WORKFLOW_SETTINGS);
@@ -169,8 +170,10 @@ describe("built-in workflows", () => {
expect(executeConfig?.maxRetries).toBeLessThanOrEqual(10);
const byId = new Map(candidate.nodes.map((node) => [node.id, node]));
expect(byId.get("workflow-step")?.config?.name).toBe("Pre-merge workflow steps");
expect(byId.get("review")?.config?.name).toBe("Review");
expect(byId.get("merge")?.config?.name).toBe("Merge boundary");
expect(byId.get("workflow-step")?.config?.maxRetries).toBeUndefined();
expect(byId.get("review")?.config?.maxRetries).toBeUndefined();
expect(byId.get("merge")?.config?.maxRetries).toBeUndefined();
}

View File

@@ -145,7 +145,7 @@ describe("compileWorkflowToSteps (U2)", () => {
expect(err).toBeInstanceOf(WorkflowCompileError);
});
it("rejects seams that are out of the execute -> review -> merge order", () => {
it("rejects seams that are out of the planning -> execute -> workflow-step -> review -> merge order", () => {
const ir: WorkflowIr = {
version: "v1",
name: "misordered-seams",
@@ -163,7 +163,7 @@ describe("compileWorkflowToSteps (U2)", () => {
};
const err = validateLinearity(ir);
expect(err).toBeInstanceOf(WorkflowCompileError);
expect(err?.message).toMatch(/execute -> review -> merge order/);
expect(err?.message).toMatch(/planning -> execute -> workflow-step -> review -> merge order/);
});
it("rejects a graph with a duplicated seam role", () => {

View File

@@ -18,9 +18,11 @@ import { builtinPromptConfig } from "./builtin-workflow-prompts.js";
* done = complete
* archived = archived
*
* The seam nodes (execute/review/merge) are placed in their columns; the graph
* walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph
* executor continues to drive execute → review → merge unchanged.
* The lifecycle seam nodes are placed in their columns. Planning is explicit so
* the built-in workflow owns the specification phase rather than relying on
* triage code that runs outside the graph; workflow-step keeps the legacy
* pre-merge quality gate between implementation and review; execute/review/
* merge keep the same observable pipeline and failure routing.
*/
const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
version: "v2",
@@ -47,22 +49,40 @@ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = {
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{
id: "planning",
kind: "prompt",
column: "triage",
config: builtinPromptConfig("planning", "Plan / specify"),
},
{
id: "execute",
kind: "prompt",
column: "in-progress",
config: { ...builtinPromptConfig("execute", "Execute"), maxRetries: 2 },
},
{
id: "workflow-step",
kind: "prompt",
column: "in-progress",
config: builtinPromptConfig("workflow-step", "Pre-merge workflow steps"),
},
{ id: "review", kind: "prompt", column: "in-review", config: builtinPromptConfig("review", "Review") },
{ id: "merge", kind: "prompt", column: "in-review", config: builtinPromptConfig("merge", "Merge boundary") },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "execute" },
{ from: "execute", to: "review", condition: "success" },
{ from: "start", to: "planning" },
{ from: "planning", to: "execute", condition: "success" },
{ from: "execute", to: "workflow-step", condition: "success" },
{ from: "workflow-step", to: "review", condition: "success" },
{ from: "workflow-step", to: "end", condition: "outcome:remediation-scheduled" },
{ from: "workflow-step", to: "end", condition: "outcome:deferred-paused" },
{ from: "review", to: "merge", condition: "success" },
{ from: "merge", to: "end", condition: "success" },
{ from: "planning", to: "end", condition: "failure" },
{ from: "execute", to: "end", condition: "failure" },
{ from: "workflow-step", to: "end", condition: "failure" },
{ from: "review", to: "end", condition: "failure" },
{ from: "merge", to: "end", condition: "failure" },
],

View File

@@ -9,6 +9,7 @@ const BUILTIN_SEAM_PROMPTS: Record<string, string> = {
execute: DEFAULT_EXECUTOR_PROMPT,
planning: DEFAULT_TRIAGE_PROMPT,
"step-execute": DEFAULT_EXECUTOR_PROMPT,
"workflow-step": DEFAULT_REVIEWER_PROMPT,
review: DEFAULT_REVIEWER_PROMPT,
merge: DEFAULT_MERGER_PROMPT,
};

View File

@@ -16,8 +16,9 @@ export class WorkflowCompileError extends Error {
}
/** Seam anchor kinds, encoded on IR nodes as `config.seam`. These map to the
* fixed execute → review → merge pipeline and are not emitted as steps. */
const SEAM_NAMES = new Set(["execute", "review", "merge"]);
* fixed planning → execute → workflow-step → review → merge pipeline and are
* not emitted as steps. */
const SEAM_NAMES = new Set(["planning", "execute", "workflow-step", "review", "merge"]);
function seamOf(node: WorkflowIrNode): string | undefined {
const seam = node.config?.seam;
@@ -77,7 +78,8 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
const seam = seamOf(node);
if (seam) {
const failureEdges = outs.filter((edge) => edge.condition === "failure");
const mainEdges = outs.filter((edge) => edge.condition !== "failure");
const mainEdges = outs.filter((edge) => !edge.condition || edge.condition === "success");
const outcomeEdges = outs.filter((edge) => edge.condition?.startsWith("outcome:"));
if (mainEdges.length !== 1) {
return new WorkflowCompileError(`seam '${node.id}' must have exactly one success path`);
}
@@ -87,6 +89,10 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
if (failureEdges[0] && failureEdges[0].to !== endNode.id) {
return new WorkflowCompileError(`seam '${node.id}' failure edge must target the end node`);
}
const nonTerminalOutcomeEdge = outcomeEdges.find((edge) => edge.to !== endNode.id);
if (nonTerminalOutcomeEdge) {
return new WorkflowCompileError(`seam '${node.id}' outcome edge must target the end node`);
}
continue;
}
@@ -106,12 +112,12 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
}
// Reachability: the single main path must reach end and cover every node.
// While walking, enforce the canonical seam pipeline: each of execute/review/
// merge may appear at most once and only in that order. The compiler treats
// seams as a fixed execute → review → merge boundary (merge flips pre- to
// post-merge), so out-of-order or duplicate seams would compile inconsistently
// with the runtime contract.
const expectedSeamOrder = ["execute", "review", "merge"] as const;
// While walking, enforce the canonical seam pipeline: each of planning/
// execute/workflow-step/review/merge may appear at most once and only in that
// order. The compiler treats seams as a fixed lifecycle boundary (merge flips
// pre- to post-merge), so out-of-order or duplicate seams would compile
// inconsistently with the runtime contract.
const expectedSeamOrder = ["planning", "execute", "workflow-step", "review", "merge"] as const;
const seenSeams = new Set<string>();
let nextExpectedSeamIndex = 0;
const visited = new Set<string>();
@@ -131,7 +137,9 @@ export function validateLinearity(ir: WorkflowIr): WorkflowCompileError | null {
nextExpectedSeamIndex += 1;
}
if (expectedSeamOrder[nextExpectedSeamIndex] !== seam) {
return new WorkflowCompileError("seams must follow the execute -> review -> merge order");
return new WorkflowCompileError(
"seams must follow the planning -> execute -> workflow-step -> review -> merge order",
);
}
seenSeams.add(seam);
nextExpectedSeamIndex += 1;

View File

@@ -138,6 +138,7 @@ export const DEFAULT_WORKFLOW_COLUMN_IDS = [
function defaultColumnForNode(node: WorkflowIrNode): string {
const seam = node.config?.seam;
if (seam === "execute") return "in-progress";
if (seam === "workflow-step") return "in-progress";
if (seam === "review") return "in-review";
if (seam === "merge") return "in-review";
return "todo";