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);
});