import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import type { WorkflowBindings, WorkflowRunner } from "./workflow-runner"; // --------------------------------------------------------------------------- // WorkflowRunner conformance suite. Runs against the interface with DATA, never // a closure: each test seeds a workflow SOURCE into the store, then drives the // run by (snapshotId, entryPath). The author body runs inside the sandbox via // the WorkflowDriver, exactly as it does in production. Covers: // - step memoization (a step body runs exactly once across replays) // - sleep suspends then resumes past the sleep // - waitForEvent suspends; signal delivers and resumes // - step.tool routes through the bindings and journals // - retry semantics (RetryableError leaves the run re-drivable) // The real SIGKILL kill test lives in its own file (needs a child process). // --------------------------------------------------------------------------- const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); /** The seam a conformance harness provides: a runner plus a way to publish a * named workflow source and get back the (snapshotId, entryPath) to start it, * and the bindings the run reaches out through. */ export interface WorkflowConformanceHarness { readonly runner: WorkflowRunner; /** Publish a workflow's author source; returns the DATA `start` needs. */ readonly publish: ( name: string, source: string, ) => Promise<{ snapshotId: string; entryPath: string }>; } export const workflowRunnerConformance = ( name: string, makeHarness: (bindings: WorkflowBindings) => WorkflowConformanceHarness, ): void => { describe(`WorkflowRunner conformance: ${name}`, () => { it("memoizes a step: replay does not re-execute it", async () => { let sideEffects = 0; const bindings: WorkflowBindings = { // A step.tool call stands in for the observable side effect: it must run // exactly once even though the body replays after the sleep. runTool: async () => { sideEffects++; return 10; }, notify: async () => {}, }; const { runner, publish } = makeHarness(bindings); const src = `import { defineWorkflow } from "executor:app"; export default defineWorkflow({ async run(step) { const a = await step.tool("side-effect", {}); await step.sleep("nap", 1); const b = await step.do("b", async () => a + 5); return { a, b }; }, });`; const { snapshotId, entryPath } = await publish("memo", src); const started = await run( runner.start( { scope: "s", workflow: "memo", snapshotId, entryPath, input: {}, runId: "r1" }, bindings, ), ); expect(started.status).toBe("sleeping"); expect(sideEffects).toBe(1); const resumed = await run(runner.resume("r1", bindings)); expect(resumed.status).toBe("completed"); expect(resumed.output).toEqual({ a: 10, b: 15 }); // The tool ran exactly once despite the resume. expect(sideEffects).toBe(1); await run(runner.close()); }); it("suspends on waitForEvent and resumes on signal with the payload", async () => { const bindings: WorkflowBindings = { runTool: async () => ({}), notify: async () => {} }; const { runner, publish } = makeHarness(bindings); const src = `import { defineWorkflow } from "executor:app"; export default defineWorkflow({ async run(step) { const approval = await step.waitForEvent("approval"); return { approved: approval.ok }; }, });`; const { snapshotId, entryPath } = await publish("wait", src); const started = await run( runner.start( { scope: "s", workflow: "wait", snapshotId, entryPath, input: {}, runId: "rw" }, bindings, ), ); expect(started.status).toBe("waiting"); const done = await run(runner.signal("rw", "approval", { ok: true }, bindings)); expect(done.status).toBe("completed"); expect(done.output).toEqual({ approved: true }); await run(runner.close()); }); it("runs step.tool through the bindings and journals it", async () => { const calls: { address: string; args: unknown }[] = []; const bindings: WorkflowBindings = { runTool: async (address, args) => { calls.push({ address, args }); return { synced: 3 }; }, notify: async () => {}, }; const { runner, publish } = makeHarness(bindings); const src = `import { defineWorkflow } from "executor:app"; export default defineWorkflow({ async run(step) { const r = await step.tool("issues-sync", {}); return { synced: r.synced }; }, });`; const { snapshotId, entryPath } = await publish("tool", src); const done = await run( runner.start( { scope: "s", workflow: "tool", snapshotId, entryPath, input: {}, runId: "rt" }, bindings, ), ); expect(done.status).toBe("completed"); expect(done.output).toEqual({ synced: 3 }); expect(calls).toEqual([{ address: "issues-sync", args: {} }]); const steps = await run(runner.listSteps("rt")); expect(steps.some((s) => s.name === "tool:issues-sync" && s.status === "completed")).toBe( true, ); await run(runner.close()); }); it("leaves the run re-drivable on RetryableError", async () => { // The bound "attempt" tool throws a RetryableError on the first drive and // succeeds on the second. A retryable step error is NOT journaled, so the // resume re-runs it; the memoized "stable" step replays without re-running. let attempts = 0; let stableRuns = 0; const bindings: WorkflowBindings = { runTool: async (address) => { if (address === "attempt") { attempts++; if (attempts < 2) { // oxlint-disable-next-line executor/no-error-constructor -- test boundary: retryable failure fixture must be an Error-like rejected value const e = new Error("flaky") as Error & { retryable?: boolean }; e.retryable = true; // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: Promise-shaped workflow binding rejects to exercise retry behavior throw e; } return attempts; } return null; }, notify: async () => {}, }; const { runner, publish } = makeHarness(bindings); const src = `import { defineWorkflow } from "executor:app"; export default defineWorkflow({ async run(step) { await step.do("stable", async () => { globalThis.__markStable && globalThis.__markStable(); return "ok"; }); const attempt = await step.tool("attempt", {}); return { attempts: attempt }; }, });`; // The sandbox can't touch our closure; count "stable" re-runs via the // journal instead. We assert it stays journaled (see listSteps below). void stableRuns; const { snapshotId, entryPath } = await publish("retry", src); const first = await run( runner.start( { scope: "s", workflow: "retry", snapshotId, entryPath, input: {}, runId: "rr" }, bindings, ), ); expect(first.status).toBe("running"); const second = await run(runner.resume("rr", bindings)); expect(second.status).toBe("completed"); expect(second.output).toEqual({ attempts: 2 }); // "stable" was journaled once and never re-recorded across the retry. const steps = await run(runner.listSteps("rr")); expect(steps.filter((s) => s.name === "stable").length).toBe(1); await run(runner.close()); }); }); };