Merge remote-tracking branch 'origin/main' into latest-1717
# Conflicts: # packages/engine/src/executor.ts
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BUILTIN_CODING_WORKFLOW_IR } from "@fusion/core";
|
||||
import type { TaskDetail } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-15:10:
|
||||
Built-in-level execution coverage for U6: the coding workflow now expresses the
|
||||
pre-merge browser-verification step as an `optional-group` (default OFF). This is
|
||||
the dead-toggle / two-task divergence guard at the BUILT-IN level (not just the
|
||||
generic construct): two coding tasks identical except `enabledWorkflowSteps` must
|
||||
diverge — the one including the group id runs the browser-verification prompt node
|
||||
pre-merge; the sibling runs NONE and still reaches review. Real executor runs (not
|
||||
traversal-only) so a mock-masked dead path cannot pass.
|
||||
|
||||
The inner template node id is `browser-verification-step` (distinct from the group
|
||||
id `browser-verification` per the U1 template-node-id collision rule), and its
|
||||
materialized visited id is `browser-verification::browser-verification-step`.
|
||||
*/
|
||||
|
||||
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
const GROUP_ID = "browser-verification";
|
||||
const INNER_STEP_VISITED_ID = "browser-verification::browser-verification-step";
|
||||
|
||||
function codingTask(enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
return {
|
||||
id: "FN-CODING",
|
||||
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
|
||||
} as unknown as TaskDetail;
|
||||
}
|
||||
|
||||
/** Count how many times the inner browser-verification prompt node ran. A prompt
|
||||
* handler keyed on the inner template node id; everything else succeeds. */
|
||||
function makeExecutor(onInnerStep: () => void) {
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") onInnerStep();
|
||||
return { outcome: "success" };
|
||||
});
|
||||
return new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
}
|
||||
|
||||
describe("builtin coding browser-verification optional-group (U6)", () => {
|
||||
it("two-task divergence: the enabled task runs browser-verification pre-merge; the disabled task does not", async () => {
|
||||
// Enabled.
|
||||
let enabledRuns = 0;
|
||||
const enabledResult = await makeExecutor(() => {
|
||||
enabledRuns++;
|
||||
}).run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// Disabled (no enabledWorkflowSteps).
|
||||
let disabledRuns = 0;
|
||||
const disabledResult = await makeExecutor(() => {
|
||||
disabledRuns++;
|
||||
}).run(codingTask(), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
// The browser-verification step ran exactly once when enabled, never when off.
|
||||
expect(enabledRuns).toBe(1);
|
||||
expect(disabledRuns).toBe(0);
|
||||
|
||||
// Enabled: the inner template node is visited pre-merge (before review).
|
||||
expect(enabledResult.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
|
||||
const innerIdx = enabledResult.visitedNodeIds.indexOf(INNER_STEP_VISITED_ID);
|
||||
const reviewIdxEnabled = enabledResult.visitedNodeIds.indexOf("review");
|
||||
const executeIdxEnabled = enabledResult.visitedNodeIds.indexOf("execute");
|
||||
expect(executeIdxEnabled).toBeLessThan(innerIdx);
|
||||
expect(innerIdx).toBeLessThan(reviewIdxEnabled);
|
||||
|
||||
// Disabled: the group node is traversed (bypassed) but its body never runs;
|
||||
// both tasks reach the same downstream review node.
|
||||
expect(disabledResult.visitedNodeIds).toContain(GROUP_ID);
|
||||
expect(disabledResult.visitedNodeIds).not.toContain(INNER_STEP_VISITED_ID);
|
||||
expect(disabledResult.visitedNodeIds).toContain("review");
|
||||
expect(enabledResult.visitedNodeIds).toContain("review");
|
||||
});
|
||||
|
||||
it("a browser-verification failure surfaces as the group's outcome and routes its failure edge to end", async () => {
|
||||
// The inner step fails → the group's failure edge (browser-verification → end)
|
||||
// fires, so review is never reached.
|
||||
const prompt = vi.fn<WorkflowNodeHandler>(async (node) => {
|
||||
if (node.id === "browser-verification-step") return { outcome: "failure", value: "verify-failed" };
|
||||
return { outcome: "success" };
|
||||
});
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt } });
|
||||
|
||||
const result = await executor.run(codingTask([GROUP_ID]), settingsOn(), BUILTIN_CODING_WORKFLOW_IR);
|
||||
|
||||
expect(result.context[`node:${GROUP_ID}:outcome`]).toBe("failure");
|
||||
expect(result.visitedNodeIds).toContain(INNER_STEP_VISITED_ID);
|
||||
// The group's only two outgoing edges are `success → review` and
|
||||
// `failure → end`; the inner-step failure routes the failure edge, so review
|
||||
// is skipped. (`end` is a terminal node the executor does not record in
|
||||
// visitedNodeIds, so the routing is asserted via the group's failure outcome
|
||||
// above + review being unreachable here.)
|
||||
expect(result.visitedNodeIds).not.toContain("review");
|
||||
});
|
||||
});
|
||||
@@ -131,7 +131,11 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("graph executor with builtin:coding selection skips the workflow-step seam in fast mode", async () => {
|
||||
// U6: the coding built-in's pre-merge browser-verification optional-group is
|
||||
// default-OFF (the task sets no enabledWorkflowSteps), so it is bypassed — its
|
||||
// group node is visited but its body never runs and runWorkflowSteps is not
|
||||
// called. Fast mode is irrelevant to a bypassed group; the seam is simply gone.
|
||||
it("graph executor with builtin:coding selection bypasses the disabled browser-verification group", async () => {
|
||||
const { executor } = makeExecutorForTask(task({ executionMode: "fast", worktree: "/tmp/wt" }));
|
||||
const runWorkflowSteps = vi.spyOn(executor as any, "runWorkflowSteps").mockResolvedValue(workflowResult());
|
||||
const seams = {
|
||||
@@ -154,7 +158,9 @@ describe("fast mode workflow/runtime invariants", () => {
|
||||
const result = await runner.run(task({ id: "FN-6226", executionMode: "fast" }), { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(runWorkflowSteps).not.toHaveBeenCalled();
|
||||
expect(seams.review).toHaveBeenCalledTimes(1);
|
||||
expect(seams.merge).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type WorkflowIr,
|
||||
} from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor } from "../workflow-graph-executor.js";
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeResult } from "../workflow-graph-executor.js";
|
||||
import {
|
||||
FOREACH_ACTIVE_CONTEXT_KEY,
|
||||
type ForeachActiveContext,
|
||||
@@ -81,13 +81,17 @@ function makeFakeStore(steps: TaskStep[]) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a TaskDetail with N pending steps. */
|
||||
function taskWithSteps(n: number): TaskDetail {
|
||||
/** Build a TaskDetail with N pending steps and an optional enabled-group set. */
|
||||
function taskWithSteps(n: number, enabledWorkflowSteps?: string[]): TaskDetail {
|
||||
const steps: TaskStep[] = Array.from({ length: n }, (_, i) => ({
|
||||
name: `Step ${i + 1}`,
|
||||
status: "pending" as const,
|
||||
}));
|
||||
return { id: "FN-STEPWISE", steps } as unknown as TaskDetail;
|
||||
return {
|
||||
id: "FN-STEPWISE",
|
||||
steps,
|
||||
...(enabledWorkflowSteps ? { enabledWorkflowSteps } : {}),
|
||||
} as unknown as TaskDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,9 +161,15 @@ async function runStepwiseGraph(
|
||||
onReset?: (active: ForeachActiveContext) => void;
|
||||
captureResetResult?: (ok: boolean, reason?: string) => void;
|
||||
workflowStep?: WorkflowLegacySeams["workflowStep"];
|
||||
// U6: ids of optional-group nodes enabled for this task (e.g.
|
||||
// "browser-verification"); seeds task.enabledWorkflowSteps.
|
||||
enabledWorkflowSteps?: string[];
|
||||
// U6: handler for non-seam custom nodes — the browser-verification
|
||||
// optional-group's inner prompt node runs through this.
|
||||
runCustomNode?: (nodeId: string) => Promise<WorkflowNodeResult>;
|
||||
} = {},
|
||||
): Promise<{ trajectory: TrajectoryEntry[]; outcome: string; result: Awaited<ReturnType<WorkflowGraphExecutor["run"]>> }> {
|
||||
const task = taskWithSteps(stepCount);
|
||||
const task = taskWithSteps(stepCount, opts.enabledWorkflowSteps);
|
||||
const fake = makeFakeStore(task.steps as TaskStep[]);
|
||||
const reviewCursor = new Map<number, number>();
|
||||
|
||||
@@ -211,6 +221,10 @@ async function runStepwiseGraph(
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
seams,
|
||||
signal: opts.signal,
|
||||
// U6: non-seam custom nodes (the browser-verification optional-group's inner
|
||||
// prompt node) route here. Default: success no-op.
|
||||
runCustomNode: async (node) =>
|
||||
opts.runCustomNode ? opts.runCustomNode(node.id) : { outcome: "success" },
|
||||
getTaskSteps: () => task.steps as TaskStep[],
|
||||
// parse-steps reads PROMPT.md; produce headings matching the step count so the
|
||||
// real builtin chain runs end-to-end. writeSteps is a no-op (steps pre-set).
|
||||
@@ -578,44 +592,57 @@ describe("stepwise workflow parity (U7 / KTD-9)", () => {
|
||||
expect(result.visitedNodeIds).toContain("merge");
|
||||
});
|
||||
|
||||
// ── Pre-merge workflow-step seam (optional-step execution, R1) ─────────────
|
||||
// ── Pre-merge browser-verification optional-group (U6, R-3 run-once) ────────
|
||||
|
||||
it("runs the pre-merge workflow-step seam exactly once after the foreach (enabled steps execute)", async () => {
|
||||
// This is the dead-toggle guard: without a workflow-step seam node on the
|
||||
// success path, a stepwise task's enabledWorkflowSteps (e.g. browser
|
||||
// verification) would never run. Wire a workflowStep spy and assert the graph
|
||||
// invokes it once, between the foreach and review.
|
||||
let workflowStepCalls = 0;
|
||||
const BROWSER_VERIFICATION_STEP_VISITED_ID = "browser-verification::browser-verification-step";
|
||||
|
||||
it("runs the pre-merge browser-verification optional-group EXACTLY ONCE after the foreach when enabled", async () => {
|
||||
// R-3 run-once guarantee + dead-toggle guard: the optional-group sits on the
|
||||
// post-foreach success path. A stepwise task whose enabledWorkflowSteps
|
||||
// includes the group id runs the inner browser-verification prompt node ONCE
|
||||
// after all step instances complete — never per step-instance.
|
||||
let browserVerificationCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(
|
||||
3,
|
||||
[["APPROVE"], ["APPROVE"], ["APPROVE"]],
|
||||
{
|
||||
workflowStep: async () => {
|
||||
workflowStepCalls++;
|
||||
enabledWorkflowSteps: ["browser-verification"],
|
||||
runCustomNode: async (nodeId) => {
|
||||
if (nodeId === "browser-verification-step") browserVerificationCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(outcome).toBe("success");
|
||||
// The seam ran ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(workflowStepCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
// Ordering: all step instances complete before the workflow-step seam, which
|
||||
// ONCE post-foreach — not per step-instance (3 steps here).
|
||||
expect(browserVerificationCalls).toBe(1);
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
// Ordering: all step instances complete before the group's inner step, which
|
||||
// precedes review.
|
||||
const seamIdx = result.visitedNodeIds.indexOf("workflow-step");
|
||||
const groupStepIdx = result.visitedNodeIds.indexOf(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
const reviewIdx = result.visitedNodeIds.indexOf("review");
|
||||
const lastStepIdx = result.visitedNodeIds.map((id) => id.startsWith("steps#")).lastIndexOf(true);
|
||||
expect(lastStepIdx).toBeLessThan(seamIdx);
|
||||
expect(seamIdx).toBeLessThan(reviewIdx);
|
||||
expect(lastStepIdx).toBeLessThan(groupStepIdx);
|
||||
expect(groupStepIdx).toBeLessThan(reviewIdx);
|
||||
});
|
||||
|
||||
it("treats the workflow-step seam as a no-op pass-through when no steps are enabled", async () => {
|
||||
// No workflowStep seam wired → the handler skips to success and routes to
|
||||
// review, leaving the trajectory identical to the pre-seam behavior.
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]]);
|
||||
it("bypasses the browser-verification optional-group (inert) when it is not enabled", async () => {
|
||||
// Disabled (no enabledWorkflowSteps): the group node is traversed but its
|
||||
// template body never runs — the inner prompt node is not visited and the
|
||||
// custom-node runner is never invoked for it. Routes straight to review.
|
||||
let browserVerificationCalls = 0;
|
||||
const { outcome, result } = await runStepwiseGraph(2, [["APPROVE"], ["APPROVE"]], {
|
||||
runCustomNode: async (nodeId) => {
|
||||
if (nodeId === "browser-verification-step") browserVerificationCalls++;
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
expect(outcome).toBe("success");
|
||||
expect(result.visitedNodeIds).toContain("workflow-step");
|
||||
expect(browserVerificationCalls).toBe(0);
|
||||
expect(result.visitedNodeIds).toContain("browser-verification");
|
||||
expect(result.visitedNodeIds).not.toContain(BROWSER_VERIFICATION_STEP_VISITED_ID);
|
||||
expect(result.visitedNodeIds).toContain("review");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PARITY SUBJECT (test-file ownership, U7 / KTD-9):
|
||||
// This suite owns DEFAULT-WORKFLOW BYTE-IDENTITY parity — it proves the graph
|
||||
// executor reproduces the workflow-native planning → execute → workflow-step
|
||||
// → review → merge seam
|
||||
// sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// executor reproduces the workflow-native planning → execute → review → merge
|
||||
// seam sequence exactly (the parity ORACLE per KTD-1). It deliberately does NOT
|
||||
// cover per-step / updateStep-trajectory parity.
|
||||
//
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-15:10 (U6): the legacy `workflow-step`
|
||||
// seam was retired from the coding built-in; pre-merge browser-verification is
|
||||
// now a default-OFF `optional-group`. With no `enabledWorkflowSteps` on the task
|
||||
// the group is BYPASSED, so the lifecycle seam sequence is planning → execute →
|
||||
// review → merge (no workflow-step seam).
|
||||
//
|
||||
// The stepwise per-step trajectory + merge-blocker-window parity (legacy
|
||||
// step-session path vs the stepwise foreach graph) is owned by the sibling
|
||||
// suite `stepwise-workflow-parity.test.ts`. Keep the two concerns separate.
|
||||
@@ -41,9 +46,7 @@ function runLegacy(seams: WorkflowLegacySeams) {
|
||||
const execute = await seams.execute(task, {});
|
||||
events.push(`execute:${execute.outcome}`);
|
||||
if (execute.outcome !== "success") return events;
|
||||
const workflowStep = await seams.workflowStep?.(task, {}) ?? { outcome: "success" as const };
|
||||
events.push(`workflow-step:${workflowStep.outcome}`);
|
||||
if (workflowStep.outcome !== "success") return events;
|
||||
// U6: no workflow-step seam — browser-verification is a bypassed optional-group.
|
||||
const review = await seams.review(task, {});
|
||||
events.push(`review:${review.outcome}`);
|
||||
if (review.outcome !== "success") return events;
|
||||
@@ -98,7 +101,7 @@ describe("WorkflowGraphExecutor interpreter-parity", () => {
|
||||
const executor = new WorkflowGraphExecutor({ seams });
|
||||
const result = await executor.run(task, { experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "workflow-step:success", "review:success", "merge:failure"]);
|
||||
expect(legacyEvents).toEqual(["planning:success", "execute:success", "review:success", "merge:failure"]);
|
||||
});
|
||||
|
||||
it("preserves autoMerge:false terminal in-review semantics via review failure", async () => {
|
||||
@@ -198,11 +201,11 @@ describe("column-agent feature is invisible when unbound (U7 / R9)", () => {
|
||||
// Bind the invariant to actual executor behavior (PR #1432 review): the
|
||||
// observation below derives from the run-captured seam sequence, so seam
|
||||
// drift fails here instead of being masked by a hard-coded literal.
|
||||
expect(stages).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(stages).toEqual(["planning", "execute", "review", "merge"]);
|
||||
|
||||
// Legacy authoritative observation: a clean run that lands in `done`/merged.
|
||||
const legacyObs = buildWorkflowObservation({
|
||||
stageTransitions: ["triage", "planning", "execute", "workflow-step", "review", "merge"],
|
||||
stageTransitions: ["triage", "planning", "execute", "review", "merge"],
|
||||
terminalColumn: "done",
|
||||
terminalStatus: "done",
|
||||
reviewVerdict: "approve",
|
||||
|
||||
@@ -27,9 +27,14 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(executeCalls).toBe(2);
|
||||
expect(result.context["node:execute:outcome"]).toBe("success");
|
||||
// U6: the legacy `workflow-step` seam is gone; the pre-merge browser-verification
|
||||
// optional-group is bypassed here (task has no enabledWorkflowSteps), so its
|
||||
// group node is visited but its template body is not.
|
||||
expect(result.visitedNodeIds).toEqual(
|
||||
expect.arrayContaining(["execute", "workflow-step", "review", "merge"]),
|
||||
expect.arrayContaining(["execute", "browser-verification", "review", "merge"]),
|
||||
);
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification::browser-verification-step");
|
||||
});
|
||||
|
||||
it("exhausts execute node retries and routes failure to end", async () => {
|
||||
@@ -52,7 +57,7 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(BUILTIN_CODING_WORKFLOW_IR.edges).toContainEqual({ from: "execute", to: "end", condition: "failure" });
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute"]);
|
||||
expect(result.visitedNodeIds).not.toContain("workflow-step");
|
||||
expect(result.visitedNodeIds).not.toContain("browser-verification");
|
||||
});
|
||||
|
||||
it("does not retry when the execute node returns a clean failure outcome", async () => {
|
||||
@@ -95,7 +100,9 @@ describe("WorkflowGraphExecutor built-in coding workflow retries", () => {
|
||||
expect(result.context["node:review:value"]).toBe("exception");
|
||||
expect(result.context["node:review:error"]).toBe("review seam failed");
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]);
|
||||
// U6: with browser-verification disabled (bypassed), the group node sits
|
||||
// between execute and review where the workflow-step seam used to.
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
|
||||
});
|
||||
|
||||
it("respects a per-node maxRetries override", async () => {
|
||||
|
||||
@@ -19,6 +19,10 @@ const mergeRegionEntries: Array<{ id: string; kind: WorkflowIrNodeKind }> = [
|
||||
];
|
||||
const rawMergeRegionNodeIds = mergeRegionEntries.map((entry) => entry.id);
|
||||
|
||||
// U6: the task carries no `enabledWorkflowSteps`, so the pre-merge
|
||||
// browser-verification optional-group is BYPASSED — its node is visited but its
|
||||
// body never runs. The legacy `workflowStep` seam is retained here only for shape
|
||||
// (it is no longer reached by the migrated coding IR).
|
||||
function createSeams(overrides: Partial<WorkflowLegacySeams> = {}): WorkflowLegacySeams {
|
||||
return {
|
||||
planning: async () => ({ outcome: "success" }),
|
||||
@@ -62,7 +66,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(merge).toHaveBeenCalledOnce();
|
||||
expect(calls).toEqual(["merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
expect(result.context["node:merge:outcome"]).toBe("success");
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
});
|
||||
@@ -75,7 +79,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(merge).toHaveBeenCalledOnce();
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
expect(result.context["node:merge:outcome"]).toBe("failure");
|
||||
expect(result.context["node:merge:value"]).toBe("FileScopeViolationError");
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
@@ -94,7 +98,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(merge).not.toHaveBeenCalled();
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review"]);
|
||||
expect(result.visitedNodeIds).not.toContain("merge");
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
});
|
||||
@@ -109,7 +113,7 @@ describe("WorkflowGraphExecutor merge-region collapse", () => {
|
||||
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(merge).toHaveBeenCalledOnce();
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
expectNoRawMergeRegionVisits(result.visitedNodeIds);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { TaskDetail, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js";
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
Execution-level coverage for the run-once/bypass dispatch (U2). The contract that
|
||||
guards the dead-toggle failure mode is the TWO-TASK DIVERGENCE test: two tasks
|
||||
identical except `enabledWorkflowSteps` must diverge — the enabled one runs the
|
||||
template's nodes, the disabled one runs NONE and still reaches the same downstream
|
||||
node. These are real executor runs (not traversal-only) so a mock-masked dead path
|
||||
cannot pass.
|
||||
*/
|
||||
|
||||
const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } });
|
||||
|
||||
/** A graph with one `optional-group` between `before` and `after`. The group's
|
||||
* template runs a single `optstep` prompt when the group is enabled. */
|
||||
function optionalGroupIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional-group-test",
|
||||
columns: [{ id: "work", name: "Work", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "before", kind: "prompt", config: { prompt: "before" } },
|
||||
{
|
||||
id: "group",
|
||||
kind: "optional-group",
|
||||
config: {
|
||||
name: "Browser verification",
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [{ id: "optstep", kind: "prompt", config: { prompt: "verify" } }],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "after", kind: "prompt", config: { prompt: "after" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "before" },
|
||||
{ from: "before", to: "group" },
|
||||
{ from: "group", to: "after", condition: "success" },
|
||||
{ from: "after", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** A graph with a two-node template so we can prove a single pass walks all
|
||||
* template nodes once (not per-step, not looped). */
|
||||
function multiNodeGroupIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v2",
|
||||
name: "optional-group-multi",
|
||||
columns: [{ id: "work", name: "Work", traits: [] }],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{
|
||||
id: "group",
|
||||
kind: "optional-group",
|
||||
config: {
|
||||
defaultOn: false,
|
||||
template: {
|
||||
nodes: [
|
||||
{ id: "a", kind: "prompt", config: { prompt: "a" } },
|
||||
{ id: "b", kind: "gate", config: { prompt: "b" } },
|
||||
],
|
||||
edges: [{ from: "a", to: "b" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "after", kind: "prompt", config: { prompt: "after" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "group" },
|
||||
{ from: "group", to: "after", condition: "success" },
|
||||
{ from: "after", to: "end" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function taskWith(enabled: string[] | undefined): TaskDetail {
|
||||
return { id: "FN-OG", enabledWorkflowSteps: enabled } as TaskDetail;
|
||||
}
|
||||
|
||||
describe("WorkflowGraphExecutor optional-group", () => {
|
||||
it("two-task divergence: only the task whose enabledWorkflowSteps includes the group id runs the template; the sibling runs none and both reach downstream", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
|
||||
const enabledCalls: string[] = [];
|
||||
const enabledExecutor = new WorkflowGraphExecutor({
|
||||
handlers: {
|
||||
prompt: async (node) => {
|
||||
enabledCalls.push(node.id);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
});
|
||||
const enabledResult = await enabledExecutor.run(taskWith(["group"]), settingsOn(), ir);
|
||||
|
||||
const disabledCalls: string[] = [];
|
||||
const disabledExecutor = new WorkflowGraphExecutor({
|
||||
handlers: {
|
||||
prompt: async (node) => {
|
||||
disabledCalls.push(node.id);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
},
|
||||
});
|
||||
const disabledResult = await disabledExecutor.run(taskWith([]), settingsOn(), ir);
|
||||
|
||||
// Enabled task executed the template node; disabled did not.
|
||||
expect(enabledCalls).toContain("optstep");
|
||||
expect(disabledCalls).not.toContain("optstep");
|
||||
|
||||
// The materialized template id is recorded only for the enabled run.
|
||||
expect(enabledResult.visitedNodeIds).toContain("group::optstep");
|
||||
expect(disabledResult.visitedNodeIds).not.toContain("group::optstep");
|
||||
|
||||
// Both still reach the same downstream node.
|
||||
expect(enabledCalls).toContain("after");
|
||||
expect(disabledCalls).toContain("after");
|
||||
expect(enabledResult.visitedNodeIds).toContain("after");
|
||||
expect(disabledResult.visitedNodeIds).toContain("after");
|
||||
|
||||
expect(enabledResult.outcome).toBe("success");
|
||||
expect(disabledResult.outcome).toBe("success");
|
||||
});
|
||||
|
||||
it("runs an enabled group's template exactly once (single pass, not per-step/looped)", async () => {
|
||||
const runTemplate = vi.fn<WorkflowNodeHandler>(async () => ({ outcome: "success" }));
|
||||
const executor = new WorkflowGraphExecutor({
|
||||
handlers: { prompt: runTemplate, gate: runTemplate },
|
||||
});
|
||||
|
||||
const result = await executor.run(taskWith(["group"]), settingsOn(), multiNodeGroupIr());
|
||||
|
||||
// Each template node ran exactly once; plus the downstream `after`.
|
||||
const templateRuns = runTemplate.mock.calls
|
||||
.map(([node]) => node.id)
|
||||
.filter((id) => id === "a" || id === "b");
|
||||
expect(templateRuns).toEqual(["a", "b"]);
|
||||
|
||||
expect(result.visitedNodeIds.filter((id) => id === "group::a")).toHaveLength(1);
|
||||
expect(result.visitedNodeIds.filter((id) => id === "group::b")).toHaveLength(1);
|
||||
expect(result.context["node:group:outcome"]).toBe("success");
|
||||
expect(result.outcome).toBe("success");
|
||||
});
|
||||
|
||||
it("disabled group is inert: downstream outcome/context identical to the group not being there", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
const handler: WorkflowNodeHandler = async () => ({ outcome: "success" });
|
||||
|
||||
// Run with the group disabled.
|
||||
const withGroup = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
const disabledResult = await withGroup.run(taskWith([]), settingsOn(), ir);
|
||||
|
||||
// Reference graph: identical but with the group node removed (before → after).
|
||||
const refIr: WorkflowIr = {
|
||||
...ir,
|
||||
nodes: ir.nodes.filter((n) => n.id !== "group"),
|
||||
edges: [
|
||||
{ from: "start", to: "before" },
|
||||
{ from: "before", to: "after" },
|
||||
{ from: "after", to: "end" },
|
||||
],
|
||||
};
|
||||
const refExecutor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
const refResult = await refExecutor.run(taskWith([]), settingsOn(), refIr);
|
||||
|
||||
expect(disabledResult.outcome).toBe(refResult.outcome);
|
||||
// Downstream node outcome is identical in both graphs.
|
||||
expect(disabledResult.context["node:after:outcome"]).toBe(refResult.context["node:after:outcome"]);
|
||||
expect(disabledResult.context["node:before:outcome"]).toBe(refResult.context["node:before:outcome"]);
|
||||
// No template node executed.
|
||||
expect(disabledResult.visitedNodeIds).not.toContain("group::optstep");
|
||||
});
|
||||
|
||||
it("a template-node failure inside an enabled group surfaces as the group's outcome and routes its outcome: edge", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
// Route the group's failure value to a dedicated recovery node.
|
||||
ir.nodes.push({ id: "recover", kind: "prompt", config: { prompt: "recover" } });
|
||||
ir.edges.push({ from: "group", to: "recover", condition: "outcome:boom" });
|
||||
|
||||
const calls: string[] = [];
|
||||
const handler: WorkflowNodeHandler = async (node) => {
|
||||
calls.push(node.id);
|
||||
if (node.id === "optstep") return { outcome: "failure", value: "boom" };
|
||||
return { outcome: "success" };
|
||||
};
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
|
||||
const result = await executor.run(taskWith(["group"]), settingsOn(), ir);
|
||||
|
||||
// The group's outcome reflects the template failure.
|
||||
expect(result.context["node:group:outcome"]).toBe("failure");
|
||||
expect(result.context["node:group:value"]).toBe("boom");
|
||||
// The outcome: edge routed to recover, NOT the success edge to `after`.
|
||||
expect(calls).toContain("recover");
|
||||
expect(calls).not.toContain("after");
|
||||
});
|
||||
|
||||
it("treats a stale/unknown enabled id as not-enabled (group bypassed, no crash)", async () => {
|
||||
const ir = optionalGroupIr();
|
||||
const calls: string[] = [];
|
||||
const handler: WorkflowNodeHandler = async (node) => {
|
||||
calls.push(node.id);
|
||||
return { outcome: "success" };
|
||||
};
|
||||
const executor = new WorkflowGraphExecutor({ handlers: { prompt: handler } });
|
||||
|
||||
// enabledWorkflowSteps references a since-removed group id, not "group".
|
||||
const result = await executor.run(taskWith(["stale-group-id"]), settingsOn(), ir);
|
||||
|
||||
expect(calls).not.toContain("optstep");
|
||||
expect(calls).toContain("after");
|
||||
expect(result.outcome).toBe("success");
|
||||
});
|
||||
});
|
||||
@@ -245,7 +245,9 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
|
||||
const result = await runner.run(task, flagOn);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "execute", "workflow-step", "review", "merge"]);
|
||||
// U6: the coding built-in no longer carries a `workflow-step` seam; its
|
||||
// pre-merge browser-verification optional-group is default-OFF and bypassed.
|
||||
expect(calls).toEqual(["planning", "execute", "review", "merge"]);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(getWorkflowDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -229,7 +229,9 @@ describe("WorkflowTaskRuntime", () => {
|
||||
const result = await runtime.run(attachmentTask, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
|
||||
// U6: the coding built-in no longer runs a `workflow-step` seam; the pre-merge
|
||||
// browser-verification optional-group is default-OFF and bypassed (no call).
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
|
||||
expect(observed.executedTasks).toHaveLength(1);
|
||||
expect(observed.executedTasks[0]?.attachments).toEqual(attachments);
|
||||
});
|
||||
@@ -293,31 +295,52 @@ describe("WorkflowTaskRuntime", () => {
|
||||
const result = await runtime.run(task, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step", "review", "merge"]);
|
||||
// U6: no `workflow-step` seam; the bypassed browser-verification group node
|
||||
// sits between execute and review in the visited sequence.
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "review", "merge"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "browser-verification", "review", "merge"]);
|
||||
});
|
||||
|
||||
it("stops the built-in workflow before review when workflow-step remediation is scheduled", async () => {
|
||||
it("runs the pre-merge browser-verification optional-group once when enabled, before review", async () => {
|
||||
// U6: replaces the prior workflow-step-remediation test. With the group ENABLED
|
||||
// (task.enabledWorkflowSteps includes "browser-verification"), the inner
|
||||
// browser-verification-step prompt node runs once pre-merge, recorded as a
|
||||
// custom-node call; the group then routes success → review → merge.
|
||||
const calls: string[] = [];
|
||||
const runtime = new WorkflowTaskRuntime({
|
||||
store: {
|
||||
getTaskWorkflowSelection: () => undefined,
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
},
|
||||
primitives: recordingPrimitives(calls, {
|
||||
workflowStep: { outcome: "success", value: "remediation-scheduled" },
|
||||
}),
|
||||
primitives: recordingPrimitives(calls),
|
||||
runCustomNode: async (node) => {
|
||||
calls.push(`custom:${node.id}`);
|
||||
return { outcome: "success" };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.run(task, flagOff);
|
||||
const enabledTask = { ...task, enabledWorkflowSteps: ["browser-verification"] } as TaskDetail;
|
||||
const result = await runtime.run(enabledTask, flagOff);
|
||||
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["planning", "prepare-worktree", "execute", "workflow-step"]);
|
||||
expect(result.visitedNodeIds).toEqual(["start", "planning", "execute", "workflow-step"]);
|
||||
expect(calls).toEqual([
|
||||
"planning",
|
||||
"prepare-worktree",
|
||||
"execute",
|
||||
"custom:browser-verification-step",
|
||||
"review",
|
||||
"merge",
|
||||
]);
|
||||
expect(result.visitedNodeIds).toEqual([
|
||||
"start",
|
||||
"planning",
|
||||
"execute",
|
||||
// The group container node, then its inner template step (run once).
|
||||
"browser-verification",
|
||||
"browser-verification::browser-verification-step",
|
||||
"review",
|
||||
"merge",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails selected workflow lookup misses instead of running the built-in workflow", async () => {
|
||||
|
||||
@@ -176,9 +176,13 @@ export {
|
||||
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
|
||||
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
|
||||
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
|
||||
// FNXC:MergerUnification 2026-06-22-00:00: @deprecated must sit on aiMergeTask's own
|
||||
// export so IDE/type-aware tooling flags only aiMergeTask, not the helpers it shares with
|
||||
// runAiMerge (those are NOT deprecated). A single @deprecated on the multi-member block
|
||||
// would mark every symbol below as deprecated.
|
||||
/** @deprecated Use runAiMerge — aiMergeTask is the soft-deprecated legacy path. */
|
||||
export { aiMergeTask } from "./merger.js";
|
||||
export {
|
||||
aiMergeTask,
|
||||
listAutostashOrphans,
|
||||
applyAutostashBySha,
|
||||
dropAutostashBySha,
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type ForeachEnvironment,
|
||||
type WorkflowStepInstancePersistence,
|
||||
} from "./workflow-graph-foreach.js";
|
||||
import { runLoop } from "./workflow-graph-loop.js";
|
||||
import { runLoop, runOptionalGroup } from "./workflow-graph-loop.js";
|
||||
|
||||
export type WorkflowNodeOutcome = "success" | "failure";
|
||||
|
||||
@@ -473,6 +473,52 @@ export class WorkflowGraphExecutor {
|
||||
return await traverseChildren(node, result);
|
||||
}
|
||||
|
||||
if (node.kind === "optional-group") {
|
||||
/*
|
||||
* FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
* Run-once-or-bypass dispatch. The enable decision is read from the
|
||||
* per-task `enabledWorkflowSteps` facet, keyed by THIS group node's id
|
||||
* (KTD-2). Enabled → walk the template subgraph EXACTLY ONCE via
|
||||
* `runOptionalGroup` (single pass, no iteration/rework). Disabled →
|
||||
* pass through: traverse the group's children with a synthetic
|
||||
* success result WITHOUT executing any template node, so a disabled
|
||||
* group is byte-inert vs the group not being there. Two tasks
|
||||
* identical except `enabledWorkflowSteps` therefore diverge here:
|
||||
* the enabled one runs the body, the disabled one runs none and
|
||||
* still reaches the same downstream node.
|
||||
*/
|
||||
const enabled = task.enabledWorkflowSteps?.includes(node.id) ?? false;
|
||||
if (!enabled) {
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-21-16:30: record the group's own
|
||||
// outcome on bypass too (mirrors the enabled path + every other node
|
||||
// kind), so a downstream node reading `node:<id>:outcome` from context
|
||||
// sees "success" rather than undefined — disabled is fully inert, not
|
||||
// just edge-routing-inert.
|
||||
context[`node:${node.id}:outcome`] = "success";
|
||||
// FNXC:WorkflowOptionalGroup 2026-06-22-09:00: route a disabled group
|
||||
// as a plain success with NO distinguishing value — a non-empty value
|
||||
// could let an `outcome:*` edge preempt the success edge in
|
||||
// traverseChildren, breaking the "disabled == node absent" inertness
|
||||
// invariant. (Code review: CodeRabbit.)
|
||||
return await traverseChildren(node, { outcome: "success" });
|
||||
}
|
||||
const groupResult = await runOptionalGroup(node, {
|
||||
context,
|
||||
runTemplateNode: (tNode, sig, contextOverride) =>
|
||||
this.executeNodeWithRetries(tNode, task, settings, contextOverride ?? context, ir, sig),
|
||||
shouldTraverseEdge: (edge, src) => this.shouldTraverseEdge(edge, src),
|
||||
signal: this.deps.signal,
|
||||
});
|
||||
visitedNodeIds.push(...groupResult.visitedNodeIds);
|
||||
const result: WorkflowNodeResult = {
|
||||
outcome: groupResult.outcome,
|
||||
value: groupResult.value,
|
||||
};
|
||||
context[`node:${node.id}:outcome`] = result.outcome;
|
||||
if (result.value !== undefined) context[`node:${node.id}:value`] = result.value;
|
||||
return await traverseChildren(node, result);
|
||||
}
|
||||
|
||||
const result = await this.executeNodeWithRetries(node, task, settings, context, ir);
|
||||
if (result.contextPatch) Object.assign(context, result.contextPatch);
|
||||
context[`node:${node.id}:outcome`] = result.outcome;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig } from "@fusion/core";
|
||||
import type { WorkflowIrEdge, WorkflowIrNode, WorkflowLoopConfig, WorkflowOptionalGroupConfig } from "@fusion/core";
|
||||
import { WorkflowIrError } from "@fusion/core";
|
||||
|
||||
import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js";
|
||||
@@ -208,3 +208,87 @@ export async function runLoop(
|
||||
};
|
||||
return { outcome: "failure", value: "loop-iteration-exhausted", visitedNodeIds };
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowOptionalGroup 2026-06-21-14:05:
|
||||
An enabled `optional-group` runs its `template` subgraph EXACTLY ONCE (single pass — no iteration, no rework budget; rework edges are validation-forbidden inside the template). This reuses the loop's template-walk primitives (`buildOutgoing`, `findTemplateEntry`, `shouldTraverseEdge`) but caps the walk at one pass. The disabled/bypass decision lives in the executor branch (read from per-task `enabledWorkflowSteps`); this helper only runs the body when enabled.
|
||||
A template-node failure surfaces as the group's outcome so the group's `failure`/`outcome:` edges route, mirroring `runLoop`'s node-failure short-circuit.
|
||||
*/
|
||||
export interface OptionalGroupEnvironment {
|
||||
context: Record<string, unknown>;
|
||||
runTemplateNode: (
|
||||
node: WorkflowIrNode,
|
||||
signal?: AbortSignal,
|
||||
contextOverride?: Record<string, unknown>,
|
||||
) => Promise<WorkflowNodeResult>;
|
||||
shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface OptionalGroupRunResult {
|
||||
outcome: WorkflowNodeOutcome;
|
||||
value?: string;
|
||||
visitedNodeIds: string[];
|
||||
}
|
||||
|
||||
function resolveOptionalGroupTemplate(
|
||||
node: WorkflowIrNode,
|
||||
): { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] } {
|
||||
const cfg = (node.config ?? {}) as Partial<WorkflowOptionalGroupConfig>;
|
||||
if (!cfg.template || !Array.isArray(cfg.template.nodes) || !Array.isArray(cfg.template.edges)) {
|
||||
throw new WorkflowIrError(`optional-group node '${node.id}' has no template subgraph`);
|
||||
}
|
||||
return cfg.template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk an enabled optional-group's template subgraph once. Mirrors a single
|
||||
* loop iteration: entry → follow matching edges → stop at the template exit (no
|
||||
* outgoing matching edge). Materialized visited ids use a `<groupId>::<templateNodeId>`
|
||||
* scheme so they are distinguishable from top-level ids and parseable back to the
|
||||
* template node. The group's own outcome is the last template node's outcome.
|
||||
*/
|
||||
export async function runOptionalGroup(
|
||||
groupNode: WorkflowIrNode,
|
||||
env: OptionalGroupEnvironment,
|
||||
): Promise<OptionalGroupRunResult> {
|
||||
const template = resolveOptionalGroupTemplate(groupNode);
|
||||
const templateById = new Map(template.nodes.map((n) => [n.id, n]));
|
||||
const outgoing = buildOutgoing(template.edges);
|
||||
const entry = findTemplateEntry(template.nodes, template.edges, groupNode.id);
|
||||
const visitedNodeIds: string[] = [];
|
||||
|
||||
const groupContext: Record<string, unknown> = { ...env.context };
|
||||
let current: WorkflowIrNode | undefined = entry;
|
||||
let lastResult: WorkflowNodeResult = { outcome: "success" };
|
||||
|
||||
while (current) {
|
||||
if (env.signal?.aborted) {
|
||||
return { outcome: "failure", value: "aborted", visitedNodeIds };
|
||||
}
|
||||
|
||||
const materializedId = `${groupNode.id}::${current.id}`;
|
||||
visitedNodeIds.push(materializedId);
|
||||
lastResult = await env.runTemplateNode(current, env.signal, groupContext);
|
||||
if (lastResult.contextPatch) Object.assign(groupContext, lastResult.contextPatch);
|
||||
groupContext[`node:${current.id}:outcome`] = lastResult.outcome;
|
||||
if (lastResult.value !== undefined) groupContext[`node:${current.id}:value`] = lastResult.value;
|
||||
|
||||
if (lastResult.outcome === "failure") {
|
||||
// Publish accumulated template context, then surface the failure as the
|
||||
// group's outcome so its failure/outcome: edges route.
|
||||
Object.assign(env.context, groupContext);
|
||||
return { outcome: "failure", value: lastResult.value, visitedNodeIds };
|
||||
}
|
||||
|
||||
const edges: WorkflowIrEdge[] = outgoing.get(current.id) ?? [];
|
||||
const matching: WorkflowIrEdge[] = edges.filter((edge: WorkflowIrEdge) =>
|
||||
env.shouldTraverseEdge(edge, lastResult),
|
||||
);
|
||||
current = matching.length > 0 ? templateById.get(matching[0].to) : undefined;
|
||||
}
|
||||
|
||||
// Single pass complete: publish the template's context onto the shared context.
|
||||
Object.assign(env.context, groupContext);
|
||||
return { outcome: lastResult.outcome, value: lastResult.value, visitedNodeIds };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user