Files
fusion/packages/engine/src/__tests__/workflow-graph-foreach.test.ts
gsxdsm 38d7f1c436 FN-6087: skip completed foreach steps on resume
Prevent shared-isolation foreach resumes from re-running terminal task steps.

- skip foreach step instances whose persisted status is already done or skipped before launching step-execute
- log resume skips so restart behavior is auditable during shared-isolation foreach runs
- add regression coverage for all-done resumes, mixed done/skipped resumes, and in-progress step replays

Files changed:
 .../src/__tests__/workflow-graph-foreach.test.ts   | 85 ++++++++++++++++++++++
 packages/engine/src/workflow-graph-foreach.ts      | 13 ++++
 2 files changed, 98 insertions(+)

Fusion-Task-Id: FN-6087

Fusion-Task-Lineage: 56805a5c-a1ea-41bc-a758-1afed83c6bb7
2026-06-09 09:00:46 -07:00

659 lines
26 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { TaskDetail, TaskStep, WorkflowIr, WorkflowIrNode } from "@fusion/core";
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
import {
FOREACH_ACTIVE_CONTEXT_KEY,
type ForeachActiveContext,
type WorkflowLegacySeams,
} from "../workflow-node-handlers.js";
import type { WorkflowStepInstanceState } from "../workflow-graph-foreach.js";
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
/** Build a TaskDetail with a fixed step list. */
function taskWithSteps(n: number): TaskDetail {
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
name: `Step ${i + 1}`,
status: "pending" as const,
}));
return { id: "FN-FOREACH", steps } as unknown as TaskDetail;
}
/** Build a TaskDetail with explicit persisted step statuses. */
function taskWithStepStatuses(statuses: TaskStep["status"][]): TaskDetail {
const steps: TaskStep[] = statuses.map((status, i) => ({
name: `Step ${i + 1}`,
status,
}));
return { id: "FN-FOREACH", steps } as unknown as TaskDetail;
}
/**
* Build a graph: start → foreach → end. The foreach template is provided inline.
* Extra edges from the foreach node (e.g. outcome:rework-exhausted) are appended.
*/
function foreachIr(
template: { nodes: WorkflowIrNode[]; edges: WorkflowIr["edges"] },
opts: {
config?: Record<string, unknown>;
extraNodes?: WorkflowIrNode[];
foreachEdges?: WorkflowIr["edges"];
} = {},
): WorkflowIr {
return {
version: "v2",
name: "foreach-test",
columns: [{ id: "work", name: "Work", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{
id: "fe",
kind: "foreach",
config: { source: "task-steps", template, ...(opts.config ?? {}) },
},
{ id: "end", kind: "end" },
...(opts.extraNodes ?? []),
],
edges: [
{ from: "start", to: "fe" },
{ from: "fe", to: "end", condition: "success" },
...(opts.foreachEdges ?? []),
],
};
}
/** A single-node template: one step-execute prompt. */
function singleExecuteTemplate() {
return {
nodes: [{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } }],
edges: [],
};
}
describe("WorkflowGraphExecutor foreach (U3)", () => {
it("3-step expansion runs instances in step order, all 3 template-node instances", async () => {
const order: string[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
order.push(`exec#${active.stepIndex}`);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(3), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
expect(order).toEqual(["exec#0", "exec#1", "exec#2"]);
// Instance ids are materialized deterministically.
expect(result.visitedNodeIds).toEqual(
expect.arrayContaining(["fe#0:exec", "fe#1:exec", "fe#2:exec"]),
);
// The foreach itself is visited and routes its success edge to end (end is
// intentionally not pushed to visited — same posture as other tail edges).
expect(result.visitedNodeIds).toContain("fe");
});
it("zero steps → foreach traverses its success edge without running any instance", async () => {
const exec = vi.fn(async () => ({ outcome: "success" as const }));
const seams = baseSeams({ stepExecute: exec });
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(0), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
expect(exec).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toContain("fe");
expect(result.visitedNodeIds.some((id) => id.startsWith("fe#"))).toBe(false);
});
it("resume skips all instances when every step is already done", async () => {
const exec = vi.fn(async () => ({ outcome: "success" as const, value: "step-done" }));
const seams = baseSeams({ stepExecute: exec });
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithStepStatuses(["done", "done", "done"]),
settingsOn(),
foreachIr(singleExecuteTemplate()),
);
expect(result.outcome).toBe("success");
expect(exec).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toContain("fe");
expect(result.visitedNodeIds.some((id) => id.startsWith("fe#"))).toBe(false);
});
it("resume skips done instances and runs the first pending step", async () => {
const executedStepIndexes: number[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
executedStepIndexes.push(active.stepIndex);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithStepStatuses(["done", "done", "done", "pending"]),
settingsOn(),
foreachIr(singleExecuteTemplate()),
);
expect(result.outcome).toBe("success");
expect(executedStepIndexes).toEqual([3]);
});
it("resume skips mixed done and skipped instances and runs pending steps only", async () => {
const executedStepIndexes: number[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
executedStepIndexes.push(active.stepIndex);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithStepStatuses(["done", "skipped", "done", "pending"]),
settingsOn(),
foreachIr(singleExecuteTemplate()),
);
expect(result.outcome).toBe("success");
expect(executedStepIndexes).toEqual([3]);
});
it("resume re-runs in-progress steps instead of treating them as terminal", async () => {
const executedStepIndexes: number[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
executedStepIndexes.push(active.stepIndex);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithStepStatuses(["done", "done", "in-progress", "pending"]),
settingsOn(),
foreachIr(singleExecuteTemplate()),
);
expect(result.outcome).toBe("success");
expect(executedStepIndexes).toEqual([2, 3]);
});
it("revise-style rework loops twice then completes (custom node routes a rework edge)", async () => {
// Template: exec → review. review routes a rework edge back to exec for the
// first 2 passes, then approves (success edge → exit).
let reviewCalls = 0;
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
// rework loop back to exec when review says "revise"
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
// success/approve exits (no outgoing edge → template exit)
],
};
const reviewHandler: WorkflowNodeHandler = async () => {
reviewCalls += 1;
if (reviewCalls <= 2) return { outcome: "success", value: "revise" };
return { outcome: "success", value: "approve" };
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(template));
expect(result.outcome).toBe("success");
expect(reviewCalls).toBe(3); // 2 revises + 1 approve
});
it("rework exhaustion routes the outcome:rework-exhausted edge", async () => {
// review always says revise → budget (2) exhausts → foreach emits
// rework-exhausted, routed to a hold node.
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
const reviewHandler: WorkflowNodeHandler = async () => ({ outcome: "success", value: "revise" });
const holdHandler = vi.fn(async () => ({ outcome: "success" as const }));
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: {
prompt: makePromptRouter(seams, { review: reviewHandler }),
hold: holdHandler,
},
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(template, {
config: { maxReworkCycles: 2 },
extraNodes: [{ id: "exhausted-hold", kind: "hold" }],
foreachEdges: [
{ from: "fe", to: "exhausted-hold", condition: "outcome:rework-exhausted" },
{ from: "exhausted-hold", to: "end", condition: "success" },
],
}),
);
expect(holdHandler).toHaveBeenCalledTimes(1);
expect(result.visitedNodeIds).toContain("exhausted-hold");
});
it("rework exhaustion with NO routed edge falls back to failure", async () => {
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
const reviewHandler: WorkflowNodeHandler = async () => ({ outcome: "success", value: "revise" });
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(template, { config: { maxReworkCycles: 1 } }),
);
expect(result.outcome).toBe("failure");
});
it("rework budget is per-instance, not shared across instances", async () => {
// 2 steps, budget 1 each. Each instance reworks exactly once then approves.
// If the budget were shared, the second instance would exhaust on its first
// rework. Per-instance, both succeed.
const reviewCallsByStep = new Map<number, number>();
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
const reviewHandler: WorkflowNodeHandler = async (_node, ctx) => {
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
const n = (reviewCallsByStep.get(active.stepIndex) ?? 0) + 1;
reviewCallsByStep.set(active.stepIndex, n);
if (n === 1) return { outcome: "success", value: "revise" }; // 1 rework per step
return { outcome: "success", value: "approve" };
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(
taskWithSteps(2),
settingsOn(),
foreachIr(template, { config: { maxReworkCycles: 1 } }),
);
expect(result.outcome).toBe("success");
expect(reviewCallsByStep.get(0)).toBe(2);
expect(reviewCallsByStep.get(1)).toBe(2);
});
it("a non-rework cycle outside an active instance still throws (recursive detector untouched)", async () => {
// Top-level graph with a plain cycle (no rework kind) — the recursive walk's
// inStack detector must still throw.
const ir: WorkflowIr = {
version: "v2",
name: "cycle",
columns: [{ id: "w", name: "W", traits: [] }],
nodes: [
{ id: "start", kind: "start" },
{ id: "a", kind: "prompt", config: {} },
{ id: "b", kind: "prompt", config: {} },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "a" },
{ from: "a", to: "b", condition: "success" },
{ from: "b", to: "a", condition: "success" }, // non-rework cycle
],
};
const executor = new WorkflowGraphExecutor({
handlers: { prompt: async () => ({ outcome: "success" as const }) },
});
await expect(executor.run(taskWithSteps(0), settingsOn(), ir)).rejects.toThrow(/Cycle detected/);
});
it("abort mid-instance stops cleanly (signal honored between nodes)", async () => {
const controller = new AbortController();
const seen: string[] = [];
// Template: exec → second. exec aborts the controller; `second` must not run
// (abort is checked at the top of the loop before the next node).
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "second", kind: "prompt" as const, config: {} },
],
edges: [{ from: "exec", to: "second", condition: "success" }],
};
const secondHandler: WorkflowNodeHandler = async () => {
seen.push("second");
return { outcome: "success" };
};
const seams = baseSeams({
stepExecute: async () => {
seen.push("exec");
controller.abort();
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { second: secondHandler }) },
signal: controller.signal,
});
const result = await executor.run(taskWithSteps(2), settingsOn(), foreachIr(template));
expect(result.outcome).toBe("failure");
expect(seen).toEqual(["exec"]); // second never ran; instance 1 never started
});
it("foreach:active context is visible to template handlers and absent outside instances", async () => {
const insideValues: Array<number | undefined> = [];
let outsideAfter: unknown = "unset";
// Template node records the active stepIndex; a tail node after the foreach
// asserts the key was cleared.
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext | undefined;
insideValues.push(active?.stepIndex);
return { outcome: "success", value: "step-done" };
},
});
const tailHandler: WorkflowNodeHandler = async (_node, ctx) => {
outsideAfter = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY];
return { outcome: "success" };
};
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { tail: tailHandler }) },
});
const ir = foreachIr(singleExecuteTemplate(), {
extraNodes: [{ id: "tail", kind: "prompt", config: {} }],
foreachEdges: [
{ from: "fe", to: "tail", condition: "success" },
{ from: "tail", to: "end", condition: "success" },
],
});
// Remove the direct fe→end edge so fe→tail is the only success route.
ir.edges = ir.edges.filter((e) => !(e.from === "fe" && e.to === "end"));
const result = await executor.run(taskWithSteps(2), settingsOn(), ir);
expect(result.outcome).toBe("success");
expect(insideValues).toEqual([0, 1]);
expect(outsideAfter).toBeUndefined(); // cleared on instance exit
});
it("step-execute seam is invoked with the correct stepIndex and captured baseline flows into context", async () => {
const captured: Array<{ stepIndex: number; baseline?: string }> = [];
// step-execute sets a baseline; a following review node reads it from the
// active context to prove the capture threads forward within the instance.
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [{ from: "exec", to: "review", condition: "success" }],
};
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
active.baselineSha = `sha-for-${active.stepIndex}`;
active.checkpointId = `ckpt-${active.stepIndex}`;
return {
outcome: "success",
value: "step-done",
contextPatch: { [FOREACH_ACTIVE_CONTEXT_KEY]: active },
};
},
});
const reviewHandler: WorkflowNodeHandler = async (_node, ctx) => {
const active = ctx.context[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
captured.push({ stepIndex: active.stepIndex, baseline: active.baselineSha });
return { outcome: "success" };
};
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
});
const result = await executor.run(taskWithSteps(2), settingsOn(), foreachIr(template));
expect(result.outcome).toBe("success");
expect(captured).toEqual([
{ stepIndex: 0, baseline: "sha-for-0" },
{ stepIndex: 1, baseline: "sha-for-1" },
]);
});
it("step-execute with no seam wired fails closed (does not silently succeed)", async () => {
// No stepExecute seam provided → step-execute node fails with a clear value.
const seams = baseSeams({});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("failure");
});
it("parallel mode (now worktree isolation, U10) fails cleanly without isolation wiring", async () => {
// U10: parallel mode defaults to worktree isolation. Without the worktree /
// integration deps wired, the foreach fails with a routable value rather than
// running shared-mode physics (which would be an unguardable concurrent-write race).
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({ seams });
const result = await executor.run(
taskWithSteps(2),
settingsOn(),
foreachIr(singleExecuteTemplate(), { config: { mode: "parallel", concurrency: 2 } }),
);
expect(result.outcome).toBe("failure");
expect(result.context["node:fe:value"]).toBe("worktree-isolation-unwired");
});
it("getTaskSteps dep is used to read a fresh count when injected", async () => {
const exec = vi.fn(async () => ({ outcome: "success" as const, value: "step-done" }));
const seams = baseSeams({ stepExecute: exec });
// task.steps is empty, but the injected accessor returns 2 steps.
const executor = new WorkflowGraphExecutor({
seams,
getTaskSteps: () => [
{ name: "fresh-1", status: "pending" },
{ name: "fresh-2", status: "pending" },
],
});
const result = await executor.run(taskWithSteps(0), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
expect(exec).toHaveBeenCalledTimes(2);
});
it("step instance persistence hook is called at start/completion/rework (no-op default safe)", async () => {
const saved: WorkflowStepInstanceState[] = [];
const template = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "prompt" as const, config: {} },
],
edges: [
{ from: "exec", to: "review", condition: "success" },
{ from: "review", to: "exec", condition: "outcome:revise", kind: "rework" as const },
],
};
let reviewCalls = 0;
const reviewHandler: WorkflowNodeHandler = async () => {
reviewCalls += 1;
return reviewCalls === 1
? { outcome: "success", value: "revise" }
: { outcome: "success", value: "approve" };
};
const seams = baseSeams({
stepExecute: async () => ({ outcome: "success", value: "step-done" }),
});
const executor = new WorkflowGraphExecutor({
seams,
handlers: { prompt: makePromptRouter(seams, { review: reviewHandler }) },
stepInstancePersistence: {
saveInstanceState: (s) => {
saved.push({ ...s });
},
},
});
const result = await executor.run(
taskWithSteps(1),
settingsOn(),
foreachIr(template, { config: { maxReworkCycles: 2 } }),
);
expect(result.outcome).toBe("success");
// in-progress at start, a rework in-progress bump, and a final completed.
expect(saved.some((s) => s.status === "in-progress" && s.reworkCount === 0)).toBe(true);
expect(saved.some((s) => s.status === "in-progress" && s.reworkCount === 1)).toBe(true);
expect(saved.some((s) => s.status === "completed")).toBe(true);
expect(saved.every((s) => s.pinnedStepCount === 1)).toBe(true);
});
// ── U6: projection discipline ──────────────────────────────────────────────
it("projection-first ordering: step projection writes precede the completed instance row", async () => {
// The merge-blocker race (KTD-7) is closed by ordering: the step projection
// (updateStep) must be observable BEFORE the instance row flips to completed.
// We interleave both into one event log: the stepExecute seam stands in for
// the projection write; the persistence hook records the row status.
const events: string[] = [];
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
events.push(`projection:done#${active.stepIndex}`);
return { outcome: "success", value: "step-done" };
},
});
const executor = new WorkflowGraphExecutor({
seams,
stepInstancePersistence: {
saveInstanceState: (s) => {
events.push(`row:${s.status}#${s.stepIndex}`);
},
},
});
const result = await executor.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(result.outcome).toBe("success");
const projectionIdx = events.indexOf("projection:done#0");
const completedIdx = events.indexOf("row:completed#0");
expect(projectionIdx).toBeGreaterThanOrEqual(0);
expect(completedIdx).toBeGreaterThanOrEqual(0);
// Projection (done) is observable before the instance row flips to completed.
expect(projectionIdx).toBeLessThan(completedIdx);
});
it("sets deferDoneToReview on the active instance when the template has a step-review node", async () => {
// U6/KTD-4: with a step-review node present, step-execute must NOT mark the
// step done (markDoneOnSuccess:false) — the active context flags this so the
// step-execute seam can pass the flag to runTaskStep.
let observedDefer: boolean | undefined;
let observedNoReviewDefer: boolean | undefined;
const seams = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
observedDefer = active.deferDoneToReview;
return { outcome: "success", value: "step-done" };
},
stepReview: async () => ({ verdict: "APPROVE" as const }),
});
const reviewTemplate = {
nodes: [
{ id: "exec", kind: "prompt" as const, config: { seam: "step-execute" } },
{ id: "review", kind: "step-review" as const, config: { type: "code" } },
],
edges: [{ from: "exec", to: "review", condition: "success" }],
};
const executor = new WorkflowGraphExecutor({ seams });
await executor.run(taskWithSteps(1), settingsOn(), foreachIr(reviewTemplate));
expect(observedDefer).toBe(true);
// Without a step-review node, deferDoneToReview is false (step-execute is the
// done authority).
const seamsNoReview = baseSeams({
stepExecute: async (_t, ctx) => {
const active = ctx[FOREACH_ACTIVE_CONTEXT_KEY] as ForeachActiveContext;
observedNoReviewDefer = active.deferDoneToReview;
return { outcome: "success", value: "step-done" };
},
});
const executor2 = new WorkflowGraphExecutor({ seams: seamsNoReview });
await executor2.run(taskWithSteps(1), settingsOn(), foreachIr(singleExecuteTemplate()));
expect(observedNoReviewDefer).toBe(false);
});
});
// ── helpers ───────────────────────────────────────────────────────────────
/** Base no-op seams with an optional override (stepExecute etc.). */
function baseSeams(overrides: Partial<WorkflowLegacySeams>): WorkflowLegacySeams {
const ok = async () => ({ outcome: "success" as const });
return {
planning: ok,
execute: ok,
review: ok,
merge: ok,
schedule: ok,
...overrides,
};
}
/**
* A prompt handler that dispatches: step-execute seam → seams.stepExecute;
* otherwise to a per-node-id custom handler map (review/tail/second/etc.).
*/
function makePromptRouter(
seams: WorkflowLegacySeams,
byId: Record<string, WorkflowNodeHandler>,
): WorkflowNodeHandler {
return async (node, ctx) => {
if (node.config?.seam === "step-execute") {
if (!seams.stepExecute) return { outcome: "failure", value: "step-execute-unwired" };
return seams.stepExecute(ctx.task, ctx.context);
}
const handler = byId[node.id];
if (handler) return handler(node, ctx);
return { outcome: "success" };
};
}