111 lines · 4.5 KB · TypeScript
RawDownload
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 = <A, E>(effect: Effect.Effect<A, E>): Promise<A> => 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<string, string> =>  new Map<string, string>([    [      "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);});