Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions adapters/langgraph/src/__tests__/execution-plan-source.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("@logic-md/core")>();
return {
...actual,
compileWorkflow: (spec: LogicSpec, context: Parameters<typeof actual.compileWorkflow>[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",
});
});
});
7 changes: 7 additions & 0 deletions adapters/langgraph/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
});
Expand Down
5 changes: 5 additions & 0 deletions adapters/langgraph/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
};
}

Expand Down