import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; import { makeSelfHostAppsRuntime } from "./self-host-runtime"; import { makeInMemoryAppsStore, makeTestResolver } from "../testing"; import type { Bindings } from "./bindings"; const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect); // --------------------------------------------------------------------------- // Finding 5 regression: a suspended workflow must resume against the run's // PINNED snapshot + the bindings it started with, NOT the latest publish + empty // bindings. Before the fix, `signalWorkflow` loaded `requireDescriptor(scope)` // (the latest publish) and passed `{}` bindings, so a workflow republished // between start and signal ran DIFFERENT code with NO credentials on resume. // // The app has one tool `mark` (writes a `marks` row recording the version + the // connection it was bound to) and one workflow `flow` that waits for an event // then calls `mark`. We publish v1, start (it waits), publish a MODIFIED v2, // then signal. The observable proof: the row written carries v1's marker and // v1's binding, proving the ORIGINAL code + bindings ran. // --------------------------------------------------------------------------- const appFiles = (version: string): ReadonlyMap => new Map([ [ "tools/mark.ts", `import { defineTool, connection } from "executor:app"; import { z } from "zod"; export default defineTool({ description: "record a version marker", input: z.object({}), connections: { note: connection("notes") }, async handler(_args, { note }) { // A real method call on the bound client -> routes through the resolver, // which records the CONNECTION used (proves which binding was applied). The // returned version proves which code (pinned snapshot) ran. const echoed = await note.record({ version: "${version}" }); return { version: "${version}", echoed }; }, });`, ], [ "workflows/flow.ts", `import { defineWorkflow } from "executor:app"; export default defineWorkflow({ description: "wait then mark", async run(step) { await step.waitForEvent("go"); const out = await step.tool("mark", {}); return out; }, });`, ], ]); describe("workflow resume pins snapshot + bindings (Fix 5)", () => { it("resumes the ORIGINAL code + bindings after a republish + signal", async () => { const store = makeInMemoryAppsStore(); // The resolver records the connection each `notes.record` call resolved to, // so `resolver.calls` proves WHICH binding was applied on resume. const resolver = makeTestResolver({ notes: { record: () => ({ ok: true }) } }); const host = makeSelfHostAppsRuntime({ dataDir: mkdtempSync(join(tmpdir(), "apps-resume-")), store, resolver, inMemory: true, }); const { runtime } = host; // Publish v1 and start the workflow with a v1 binding; it waits. await run(runtime.publish({ scope: "s", files: appFiles("v1") })); const v1Bindings: Bindings = { note: { kind: "single", connection: "conn-v1" } }; const started = await run( runtime.startWorkflow({ scope: "s", workflow: "flow", input: {}, bindings: v1Bindings, runId: "resume-run", }), ); expect(started.status).toBe("waiting"); // Republish a MODIFIED v2 (different marker + would use different bindings). await run(runtime.publish({ scope: "s", files: appFiles("v2") })); // Signal to resume. This must use the run's pinned v1 snapshot + v1 bindings. const done = await run( runtime.signalWorkflow({ scope: "s", runId: "resume-run", event: "go", payload: {} }), ); expect(done.status).toBe("completed"); // The output is v1's marker (pinned snapshot ran, NOT the republished v2). expect((done.output as { version: string }).version).toBe("v1"); // The resolver was called with the v1 binding ("conn-v1"), proving the // STORED bindings (not empty defaults) were used on resume. If the fix // regressed, resume would use `{}` bindings -> the default same-named // connection ("notes"), never "conn-v1". const noteCalls = resolver.calls.filter((c) => c.integration === "notes"); expect(noteCalls.length).toBe(1); expect(noteCalls[0].connection).toBe("conn-v1"); await host.close(); }, 60_000); });