Collapse built-in workflow merge-policy primitives back to the legacy merge seam until graph execution owns merge policy end-to-end. - Treat all merge-policy primitive node kinds as a synthetic legacy merge node during graph traversal. - Preserve shadow-stage parity by mapping the synthetic merge node into legacy stage transitions. - Add regression coverage for success, failure, pre-merge review failure, and alternate merge-region entry points. - Record a patch changeset for the published Fusion package. Files changed: .changeset/fn-6294-merge-region-collapse.md | 5 + .../workflow-graph-executor-handlers.test.ts | 13 ++- .../workflow-graph-merge-region-collapse.test.ts | 116 +++++++++++++++++++++ packages/engine/src/executor.ts | 14 ++- packages/engine/src/workflow-graph-executor.ts | 63 ++++++++++- 5 files changed, 203 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6294 Fusion-Task-Lineage: fe86b5e2-861f-448d-8db0-b0332e6ad76f
428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
|
|
import type { TaskDetail, WorkflowIr } from "@fusion/core";
|
|
|
|
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
|
|
|
const task = { id: "FN-5767" } as TaskDetail;
|
|
|
|
function settingsOn() {
|
|
return { experimentalFeatures: { workflowGraphExecutor: true } };
|
|
}
|
|
|
|
describe("WorkflowGraphExecutor traversal", () => {
|
|
it("walks linear graph", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "linear",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "end", condition: "success" },
|
|
],
|
|
};
|
|
const handler = vi.fn(async () => ({ outcome: "success" as const }));
|
|
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
|
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
expect(result.outcome).toBe("success");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("routes failure edges", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "failure-route",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "b", kind: "script" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "b", condition: "failure" },
|
|
{ from: "b", to: "end", condition: "success" },
|
|
],
|
|
};
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({ outcome: "failure" }),
|
|
script: async () => ({ outcome: "success" }),
|
|
},
|
|
});
|
|
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
expect(result.visitedNodeIds).toContain("b");
|
|
});
|
|
|
|
it("supports outcome:value conditions", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "outcome-value",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "left", kind: "script" },
|
|
{ id: "right", kind: "script" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "left", condition: "outcome:left" },
|
|
{ from: "a", to: "right", condition: "outcome:right" },
|
|
{ from: "left", to: "end" },
|
|
{ from: "right", to: "end" },
|
|
],
|
|
};
|
|
const script = vi.fn(async () => ({ outcome: "success" as const }));
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({ outcome: "success", value: "right" }),
|
|
script,
|
|
},
|
|
});
|
|
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
expect(result.visitedNodeIds).toContain("right");
|
|
expect(result.visitedNodeIds).not.toContain("left");
|
|
});
|
|
|
|
it("leaves outcome unchanged when outcome:value does not match any edge", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "outcome-miss",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "left", kind: "script" },
|
|
{ id: "right", kind: "script" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "left", condition: "outcome:left" },
|
|
{ from: "a", to: "right", condition: "outcome:right" },
|
|
],
|
|
};
|
|
|
|
const executor = new WorkflowGraphExecutor({ handlers: { prompt: async () => ({ outcome: "success", value: "miss" }) } });
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
expect(result.outcome).toBe("success");
|
|
expect(result.visitedNodeIds).not.toContain("left");
|
|
expect(result.visitedNodeIds).not.toContain("right");
|
|
});
|
|
|
|
it("publishes workflow node task projections for dispatcher and UI", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "projection",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "end", condition: "success" },
|
|
],
|
|
};
|
|
const publishTaskProjection = vi.fn();
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({
|
|
outcome: "success",
|
|
contextPatch: {
|
|
touchedFiles: ["./packages/engine/src/workflow-graph-executor.ts", "packages\\core\\src\\store.ts"],
|
|
filesChanged: 2,
|
|
summary: "workflow published task metadata",
|
|
},
|
|
}),
|
|
},
|
|
publishTaskProjection,
|
|
});
|
|
|
|
await executor.run(task, settingsOn(), ir);
|
|
|
|
expect(publishTaskProjection).toHaveBeenCalledWith(
|
|
task.id,
|
|
{
|
|
modifiedFiles: ["packages/core/src/store.ts", "packages/engine/src/workflow-graph-executor.ts"],
|
|
mergeDetails: { filesChanged: 2 },
|
|
summary: "workflow published task metadata",
|
|
},
|
|
{ nodeId: "a", nodeKind: "prompt" },
|
|
);
|
|
});
|
|
|
|
it("keeps projection writes to safe task metadata fields", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "safe-projection",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "end", condition: "success" },
|
|
],
|
|
};
|
|
const publishTaskProjection = vi.fn();
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({
|
|
outcome: "success",
|
|
contextPatch: {
|
|
modifiedFiles: ["src/index.ts"],
|
|
mergeDetails: {
|
|
commitSha: "engine-owned",
|
|
mergeConfirmed: true,
|
|
filesChanged: 3,
|
|
insertions: 12.8,
|
|
deletions: 1,
|
|
},
|
|
status: "done",
|
|
error: "bypass",
|
|
review: {},
|
|
reviewState: {},
|
|
workflowStepResults: [{}],
|
|
tokenUsage: {},
|
|
},
|
|
}),
|
|
},
|
|
publishTaskProjection,
|
|
});
|
|
|
|
await executor.run(task, settingsOn(), ir);
|
|
|
|
expect(publishTaskProjection).toHaveBeenCalledWith(
|
|
task.id,
|
|
{
|
|
modifiedFiles: ["src/index.ts"],
|
|
mergeDetails: { filesChanged: 3, insertions: 12, deletions: 1 },
|
|
},
|
|
{ nodeId: "a", nodeKind: "prompt" },
|
|
);
|
|
});
|
|
|
|
it("publishes projections from loop template nodes", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v2",
|
|
name: "loop-projection",
|
|
columns: [
|
|
{ id: "todo", name: "Todo", traits: [] },
|
|
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
|
],
|
|
nodes: [
|
|
{ id: "start", kind: "start", column: "todo" },
|
|
{
|
|
id: "loop",
|
|
kind: "loop",
|
|
column: "todo",
|
|
config: {
|
|
maxIterations: 1,
|
|
exitWhen: { type: "output-contains", value: "done" },
|
|
template: {
|
|
nodes: [{ id: "inner", kind: "prompt" }],
|
|
edges: [],
|
|
},
|
|
},
|
|
},
|
|
{ id: "end", kind: "end", column: "done" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "loop" },
|
|
{ from: "loop", to: "end", condition: "success" },
|
|
],
|
|
};
|
|
const publishTaskProjection = vi.fn();
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({
|
|
outcome: "success",
|
|
value: "done",
|
|
contextPatch: { modifiedFiles: ["src/from-loop.ts"] },
|
|
}),
|
|
},
|
|
publishTaskProjection,
|
|
});
|
|
|
|
await executor.run(task, settingsOn(), ir);
|
|
|
|
expect(publishTaskProjection).toHaveBeenCalledWith(
|
|
task.id,
|
|
{ modifiedFiles: ["src/from-loop.ts"] },
|
|
{ nodeId: "inner", nodeKind: "prompt" },
|
|
);
|
|
});
|
|
|
|
it("does not retry an already-executed node when projection publishing fails", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "projection-failure",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "end", condition: "failure" },
|
|
],
|
|
};
|
|
const handler = vi.fn(async () => ({
|
|
outcome: "success" as const,
|
|
contextPatch: { modifiedFiles: ["src/once.ts"] },
|
|
}));
|
|
const publishTaskProjection = vi.fn(async () => {
|
|
throw new Error("store unavailable");
|
|
});
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: { prompt: handler },
|
|
maxRetriesPerNode: 3,
|
|
publishTaskProjection,
|
|
});
|
|
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(publishTaskProjection).toHaveBeenCalledTimes(1);
|
|
expect(result.outcome).toBe("failure");
|
|
expect(result.context["node:a:projectionError"]).toBe("store unavailable");
|
|
});
|
|
|
|
it("does not fail the node when the deprecated touched-files hook fails", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "legacy-touched-files-failure",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "end", condition: "success" },
|
|
],
|
|
};
|
|
const publishTaskProjection = vi.fn();
|
|
const publishTouchedFiles = vi.fn(async () => {
|
|
throw new Error("legacy sink unavailable");
|
|
});
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({
|
|
outcome: "success",
|
|
contextPatch: { modifiedFiles: ["src/projected.ts"] },
|
|
}),
|
|
},
|
|
publishTaskProjection,
|
|
publishTouchedFiles,
|
|
});
|
|
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
|
|
expect(publishTaskProjection).toHaveBeenCalledTimes(1);
|
|
expect(publishTouchedFiles).toHaveBeenCalledTimes(1);
|
|
expect(result.outcome).toBe("success");
|
|
expect(result.context["node:a:projectionError"]).toBeUndefined();
|
|
});
|
|
|
|
it("caps retries and converts exceptions to failure", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "retry",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "end", condition: "failure" },
|
|
],
|
|
};
|
|
const handler = vi.fn(async () => {
|
|
throw new Error("boom");
|
|
});
|
|
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler }, maxRetriesPerNode: 3 });
|
|
|
|
const result = await executor.run(task, settingsOn(), ir);
|
|
expect(handler).toHaveBeenCalledTimes(3);
|
|
expect(result.outcome).toBe("failure");
|
|
});
|
|
|
|
it("fan-out executes deterministic sorted order", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "fanout",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "b", kind: "script" },
|
|
{ id: "c", kind: "script" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "c" },
|
|
{ from: "a", to: "b" },
|
|
{ from: "b", to: "end" },
|
|
{ from: "c", to: "end" },
|
|
],
|
|
};
|
|
const order: string[] = [];
|
|
const executor = new WorkflowGraphExecutor({
|
|
handlers: {
|
|
prompt: async () => ({ outcome: "success" }),
|
|
script: async (node) => {
|
|
order.push(node.id);
|
|
return { outcome: "success" };
|
|
},
|
|
},
|
|
});
|
|
await executor.run(task, settingsOn(), ir);
|
|
expect(order).toEqual(["b", "c"]);
|
|
});
|
|
|
|
it("builtin coding workflow ir exposes expected lifecycle and merge-policy nodes", () => {
|
|
expect(BUILTIN_CODING_WORKFLOW_IR.nodes.map((node) => node.id)).toEqual(
|
|
expect.arrayContaining([
|
|
"start",
|
|
"execute",
|
|
"review",
|
|
"merge-gate",
|
|
"branch-group-member-integration",
|
|
"branch-group-promotion",
|
|
"merge-attempt",
|
|
"end",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("rejects malformed cyclic graphs", async () => {
|
|
const ir: WorkflowIr = {
|
|
version: "v1",
|
|
name: "cycle",
|
|
nodes: [
|
|
{ id: "start", kind: "start" },
|
|
{ id: "a", kind: "prompt" },
|
|
{ id: "end", kind: "end" },
|
|
],
|
|
edges: [
|
|
{ from: "start", to: "a" },
|
|
{ from: "a", to: "a" },
|
|
],
|
|
};
|
|
const executor = new WorkflowGraphExecutor({ handlers: { prompt: async () => ({ outcome: "success" }) } });
|
|
|
|
await expect(executor.run(task, settingsOn(), ir)).rejects.toThrow("Cycle detected");
|
|
});
|
|
});
|