diff --git a/adapters/langgraph/src/__tests__/execution-plan-source.test.ts b/adapters/langgraph/src/__tests__/execution-plan-source.test.ts new file mode 100644 index 0000000..9812d72 --- /dev/null +++ b/adapters/langgraph/src/__tests__/execution-plan-source.test.ts @@ -0,0 +1,56 @@ +import type { LogicSpec } from "@logic-md/core"; +import { describe, expect, it, vi } from "vitest"; + +// ============================================================================= +// Loop-closing test for issue #75 (adapter side) +// +// Proves the LangGraph adapter builds its node-level execution plan (fan-out / +// fan-in) from CompiledStep.executionPlan and not from the un-compiled spec's +// execution fields. To make the two sources observably diverge -- which the +// deterministic compiler never does on its own -- we wrap compileWorkflow and +// attach an executionPlan to the compiled step while the source spec declares +// no parallel structure. An adapter that still read the spec would emit no +// execution plan; one that reads the compiled field reflects the injected +// fan-out and fan-in onto the node metadata. +// ============================================================================= + +vi.mock("@logic-md/core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + compileWorkflow: (spec: LogicSpec, context: Parameters[1]) => { + const compiled = actual.compileWorkflow(spec, context); + for (const step of compiled.steps) { + step.executionPlan = { + mode: "parallel", + parallelSteps: ["from", "compiled"], + join: "majority", + joinTimeout: "90s", + }; + } + return compiled; + }, + }; +}); + +const { toStateGraphFromSpec } = await import("../adapter.js"); + +describe("adapter reflects the execution plan from the compiled artifact, not the spec", () => { + it("surfaces the compiled execution plan on node metadata when the spec declares none", () => { + const spec: LogicSpec = { + spec_version: "1.0", + name: "test-spec", + // No execution / parallel_steps / join authored on the spec step. + steps: { step1: { instructions: "Do something." } }, + }; + + const graph = toStateGraphFromSpec(spec); + + expect(graph.nodes[0]?.metadata.executionPlan).toEqual({ + mode: "parallel", + parallelSteps: ["from", "compiled"], + join: "majority", + joinTimeout: "90s", + }); + }); +}); diff --git a/adapters/langgraph/src/adapter.ts b/adapters/langgraph/src/adapter.ts index 8614c0a..df7745b 100644 --- a/adapters/langgraph/src/adapter.ts +++ b/adapters/langgraph/src/adapter.ts @@ -206,6 +206,13 @@ function buildGraphDefinition( options?.includeMetadata !== false && compiledStep.retryPolicy ? compiledStep.retryPolicy : undefined, + // Fan-out/fan-in read from the compiled artifact, not the + // un-compiled spec, so the graph carries the parallel plan + // without reaching back into the source (issue #75). + executionPlan: + options?.includeMetadata !== false && compiledStep.executionPlan + ? compiledStep.executionPlan + : undefined, }, }; }); diff --git a/adapters/langgraph/src/types.ts b/adapters/langgraph/src/types.ts index 1e3eda1..52e616b 100644 --- a/adapters/langgraph/src/types.ts +++ b/adapters/langgraph/src/types.ts @@ -13,6 +13,8 @@ * Edges represent data flow dependencies between steps. */ +import type { ExecutionPlan } from "@logic-md/core"; + /** * A single node in the state graph, representing a compiled step. */ @@ -58,6 +60,9 @@ export interface StateGraphNode { maximumInterval: string; nonRetryableErrors: string[]; }; + + /** Parallel execution plan (fan-out/fan-in) reflected from the compiled step */ + executionPlan?: ExecutionPlan; }; }