Merge pull request #1536 from Runfusion/feature/workflow-mapping
feat(FN-6035): route execution through workflow primitives
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user