From c5dcf24157894c61fdd5ff7475d00cc531d560f9 Mon Sep 17 00:00:00 2001 From: Rainier Potgieter Date: Wed, 27 May 2026 09:04:14 +0200 Subject: [PATCH] refactor(langgraph): reflect CompiledStep.executionPlan on node metadata #65 added CompiledStep.executionPlan (ExecutionPlan | null). The LangGraph adapter built nodes without it, so the serialized state graph carried no fan-out/fan-in. Consumer side of #65, parallel-execution analog of the verification gap. Reflect compiledStep.executionPlan onto StateGraphNode.metadata.executionPlan, read from the compiled artifact (the adapter already holds the CompiledStep in buildGraphDefinition), gated by includeMetadata like retryPolicy and omitted when the step has no plan. A LangGraph builder can construct parallel/conditional edges from this without reaching back into the un-compiled spec. Consume-and-reflect only; no concurrent scheduler. Loop-closing test wraps compileWorkflow to attach an executionPlan to the compiled step while the source spec declares no parallel structure, proving the adapter reflects the compiled artifact and not the spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/execution-plan-source.test.ts | 56 +++++++++++++++++++ adapters/langgraph/src/adapter.ts | 7 +++ adapters/langgraph/src/types.ts | 5 ++ 3 files changed, 68 insertions(+) create mode 100644 adapters/langgraph/src/__tests__/execution-plan-source.test.ts 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; }; }