diff --git a/.beads/export-state.json b/.beads/export-state.json index b2bbc1d..e9d0100 100644 --- a/.beads/export-state.json +++ b/.beads/export-state.json @@ -1 +1 @@ -{"last_dolt_commit":"c7bb5qcv1ctkhf392dl3d1f974oogh1u","timestamp":"2026-07-28T00:44:28.7090854-07:00","issues":454,"memories":0} \ No newline at end of file +{"last_dolt_commit":"fb1t0tec2l8cs1b4jr63mqbir0gi8ttb","timestamp":"2026-07-28T02:16:02.5366637-07:00","issues":456,"memories":0} \ No newline at end of file diff --git a/docs/zapier-durable-questions-for-engineers.md b/docs/zapier-durable-questions-for-engineers.md index 9527df5..b517ff5 100644 --- a/docs/zapier-durable-questions-for-engineers.md +++ b/docs/zapier-durable-questions-for-engineers.md @@ -1,5 +1,56 @@ # Durable Execution โ€” Questions for Zapier Engineers +> ## ๐Ÿงช UPDATE 2026-07-28 (`@zapier/zapier-durable@0.11.0`) โ€” filesystem adapter runs our durables offline; five new questions +> +> Following the direct steer from your team (2026-07-27) that `@zapier/zapier-durable` +> ships three adapters and that the **filesystem** adapter is the recommended path for +> running durables outside Zapier, we ran it. **It works.** +> +> Repro (no credentials, no network, no early-access allowlist): +> `packages/agents/scripts/durable-filesystem-spike.ts` โ€” a Foreman-shaped durable +> (`ctx.step` โ†’ `ctx.createCallback` approval gate โ†’ suspend โ†’ deliver โ†’ resume โ†’ done) +> completes in-process against `FilesystemClient`. +> +> **What this changes about question 2 below.** Q2 asks you to expose the callback URL +> because an external orchestrator can't obtain it. In-process, `ctx.createCallback` +> **does** return `[promise, callbackUrl]` directly โ€” so the limitation is in the *wire +> protocol* (`getDurableRun`), not in the SDK's design. Our `__report_callback_url_*` +> self-report step exists purely to work around the wire gap, and it is unnecessary on +> the filesystem adapter. +> +> **New, evidence-backed questions:** +> +> 1. **Is the filesystem adapter supported for production self-hosting, or is it a +> dev/test convenience?** Your team described custom adapters as "supported but +> undocumented" and the filesystem adapter as "not officially supported yet." We are +> considering running end-user automations on it, so we need to know which it is +> before we depend on it. +> 2. **Will `getDurableRun` expose the callback URL (or gain a resume-by-token +> endpoint)?** Restating Q2 with the new evidence above: the SDK already has the URL +> locally; only the remote read path withholds it. +> 3. **Is `client.callback(token, payload)` the intended programmatic delivery path for +> non-Zapier adapters?** On the filesystem adapter, `callbackBaseUrl` is a `file://` +> URL โ€” we measured `file:///callbacks/` โ€” so `callbackUrl` is **not** +> POST-able and the only delivery route we found is the adapter client (or the +> `durable-callback` CLI). If so, is `callbackUrl` better understood as an opaque +> **token carrier** whose last path segment is the real identifier, rather than a URL? +> A host that treats it as a URL works on the Zapier adapter and silently breaks on +> filesystem. +> 4. **`CallbackRequest` is typed `unknown`, and the payload is the raw body.** Passing +> `{ payload: {...} }` fails edge validation with +> `validation_failed` / `additionalProperties`. The README documents only CLI +> delivery, so there is no worked example of the programmatic shape. Worth one line +> in the README. +> 5. **Why is `zod` pinned to exactly `4.2.1` rather than a range?** An exact pin in +> `dependencies` forces every consumer that already pins zod (we are on `4.4.3` +> repo-wide) into an override. `4.4.3` works fine in our end-to-end run, so a +> `^4.2.1` range would remove the friction. +> +> **Housekeeping:** `@zapier/zapier-durable` publishes no `repository`, `bugs`, or +> `homepage` field, and `gitlab.com/zapier/zapier-sdk` is behind Zapier SAML SSO โ€” so +> there is no public tracker we can file any of this against. **Where would you like +> bug reports for these packages to go?** + > ## โœ… UPDATE 2026-07-05 (SDK 0.81.0) โ€” durables now WORK for accounts on Zapier's early-access allowlist (apply for access; not GA); questions narrowed > > The two original blockers below are **RESOLVED** on `@zapier/zapier-sdk@0.81.0`: diff --git a/package-lock.json b/package-lock.json index 8a4066e..dca62b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9266,6 +9266,18 @@ "zod": "^4.0.0" } }, + "node_modules/@zapier/zapier-durable": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@zapier/zapier-durable/-/zapier-durable-0.11.0.tgz", + "integrity": "sha512-v0L42w5NpUokdlrYaqpvt2ylRSX0kJfX4cy/A6J+Ivm+qCP79HpbN6Xr7G9m6KzyOL7G/Azk2BS/99ypCWddVw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "ajv": "^8.17.1", + "fzstd": "0.1.1", + "openapi-fetch": "^0.15.0", + "zod": "4.2.1" + } + }, "node_modules/@zapier/zapier-sdk": { "version": "0.91.0", "resolved": "https://registry.npmjs.org/@zapier/zapier-sdk/-/zapier-sdk-0.91.0.tgz", @@ -13903,6 +13915,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fzstd": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", + "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", + "license": "MIT" + }, "node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", @@ -16432,6 +16450,23 @@ "node": ">=14.14" } }, + "node_modules/mastra/node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/mastra/node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "dev": true, + "license": "MIT" + }, "node_modules/mastra/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -18468,26 +18503,24 @@ } }, "node_modules/openapi-fetch": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", - "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", - "dev": true, + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.2.tgz", + "integrity": "sha512-rdYTzUmSsJevmNqg7fwUVGuKc2Gfb9h6ph74EVPkPfIGJaZTfqdIbJahtbJ3qg1LKinln30hqZniLnKpH0RJBg==", "license": "MIT", "dependencies": { - "openapi-typescript-helpers": "^0.1.0" + "openapi-typescript-helpers": "^0.0.15" } }, "node_modules/openapi-typescript-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", - "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", - "dev": true, + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", "license": "MIT" }, "node_modules/openapi3-ts": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.0.tgz", - "integrity": "sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.6.1.tgz", + "integrity": "sha512-XW9MOldkhoICNeXVzzmXzmOW5G73ppOEGmh7fLCqHjgfdEYCGGN+00MlVCeUZgovjjfC56j9tvtDt1zGabNjjA==", "license": "MIT", "dependencies": { "yaml": "^2.9.0" @@ -23602,6 +23635,7 @@ "@mastra/voice-openai": "0.13.0", "@opentelemetry/api": "^1.9.1", "@supabase/supabase-js": "^2.105.4", + "@zapier/zapier-durable": "^0.11.0", "@zapier/zapier-sdk": "^0.91.0", "ai": "^7.0.37", "async-mutex": "^0.5.0", diff --git a/packages/agents/package.json b/packages/agents/package.json index 44a3d8e..7aa0568 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -65,6 +65,7 @@ "@mastra/voice-openai": "0.13.0", "@opentelemetry/api": "^1.9.1", "@supabase/supabase-js": "^2.105.4", + "@zapier/zapier-durable": "^0.11.0", "@zapier/zapier-sdk": "^0.91.0", "ai": "^7.0.37", "async-mutex": "^0.5.0", diff --git a/packages/agents/scripts/README.md b/packages/agents/scripts/README.md index 467f913..45b0f70 100644 --- a/packages/agents/scripts/README.md +++ b/packages/agents/scripts/README.md @@ -87,6 +87,22 @@ Run with `npx tsx --env-file=.env.local scripts/.ts`. > These probes hit a real Zapier account and may create/delete cloud resources. > They self-clean, but run them against a non-sensitive test account. +### Offline durable spike + +`durable-filesystem-spike.ts` is the exception โ€” it needs **no credentials, no +network, and no Zapier early-access allowlist**. It runs a Foreman-shaped durable +(step โ†’ human-approval gate โ†’ resume) entirely in-process on the +`@zapier/zapier-durable` filesystem adapter, into a temp dir it cleans up. + +```bash +npx tsx scripts/durable-filesystem-spike.ts +``` + +It prints the measured `callbackUrl`. Note that on this adapter it is a `file://` +URL and therefore **not** HTTP-POSTable โ€” delivery goes through +`client.callback(token, payload)`, where `token` is the last path segment. See +`foreman-02lu` for the full finding. + ## Root scripts (`/scripts`) | Command | Purpose | diff --git a/packages/agents/scripts/durable-filesystem-spike.ts b/packages/agents/scripts/durable-filesystem-spike.ts new file mode 100644 index 0000000..c738c3a --- /dev/null +++ b/packages/agents/scripts/durable-filesystem-spike.ts @@ -0,0 +1,131 @@ +/** + * Spike (foreman-02lu): run a Foreman-shaped durable with a human-approval gate + * on the FILESYSTEM adapter โ€” no Zapier early-access allowlist, no network. + * + * What this answers: + * 1. Does `@zapier/zapier-durable` run in-process on the filesystem adapter? + * 2. Does it work with the repo-wide `zod` override (4.4.3) even though the + * package pins zod 4.2.1 exactly? + * 3. What does `ctx.createCallback` hand back as `callbackUrl` locally โ€” is it + * something Foreman's /automations Approve/Deny could POST to, or does + * delivery have to go through the adapter client? + * + * Run: npx tsx scripts/durable-filesystem-spike.ts + * No credentials required. + * + * STABILITY: the package README states it is pre-1.0 and that MINOR versions are + * breaking until 1.0, and it ships one roughly every 1-2 weeks (0.5.2 -> 0.11.0 + * between 2026-06-02 and 2026-07-27). The `^0.11.0` range is deliberate: npm + * caret on a 0.x version allows patches only (`^0.11.0` rejects 0.12.0), so it + * pins us below the next breaking minor. Re-run this spike on every minor bump. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { configureDurable, createClient, defineDurable, getConfig } from "@zapier/zapier-durable"; +import { z } from "zod"; + +const stateDir = mkdtempSync(join(tmpdir(), "foreman-durable-spike-")); + +function log(label: string, value?: unknown) { + if (value === undefined) console.log(`\n${label}`); + else console.log(` ${label}`, typeof value === "string" ? value : JSON.stringify(value)); +} + +async function main() { + // 1. Select the adapter explicitly rather than relying on the documented + // default, so this proves the env-selected path Foreman would ship. + process.env.ZAPIER_DURABLE_ADAPTER = "filesystem"; + process.env.ZAPIER_DURABLE_FS_DIR = stateDir; + configureDurable({ adapter: "filesystem", filesystem: { baseDir: stateDir } }); + + log("[1] config"); + const cfg = getConfig(); + log("adapter:", cfg.adapter); + log("stateDir:", stateDir); + + const client = createClient(); + log("client:", client.constructor.name); + log("callbackBaseUrl:", client.callbackBaseUrl); + + // 2. A durable shaped like Foreman's: one step, then a human-approval gate. + // payloadSchema exercises zod โ€” the version the override forced on it. + let observedCallbackUrl: string | undefined; + + const approvalDurable = defineDurable({ + name: "foreman-spike-approval", + description: "One step, then wait for a human decision.", + inputSchema: z.object({ subject: z.string() }), + run: async (ctx, input) => { + const prepared = await ctx.step("prepare", async () => ({ + subject: input.subject, + preparedAt: "fixed-for-determinism", + })); + + const [approval, callbackUrl] = await ctx.createCallback({ + name: "human-approval", + payloadSchema: z.object({ approved: z.boolean(), note: z.string().optional() }), + }); + + // THE POINT OF THE SPIKE: in-process the URL is right here, no + // `__report_callback_url_*` step needed to smuggle it out. + observedCallbackUrl = callbackUrl; + + const decision = await approval; + return { subject: prepared.subject, approved: decision.approved, note: decision.note }; + }, + }); + + // 3. First tick โ€” should suspend at the gate. + log("[2] first tick (expect suspend at the approval gate)"); + const first = await approvalDurable({ subject: "ship it" }); + log("done:", first.done); + log("executionId:", first.executionId ?? "(none)"); + log("callbackUrl:", observedCallbackUrl ?? "(never observed)"); + + if (first.done) + throw new Error("expected the durable to suspend at the callback, but it finished"); + if (!observedCallbackUrl) throw new Error("ctx.createCallback did not surface a callbackUrl"); + if (!first.executionId) throw new Error("no executionId returned; cannot resume"); + + // 4. Deliver the decision. The token is the last URL segment; the adapter + // client is the delivery path (there is no HTTP server locally). + const token = observedCallbackUrl.split("/").pop() as string; + log("[3] delivering approval via the adapter client"); + log("token:", token); + // `CallbackRequest` is `unknown` โ€” the payload IS the body, not `{ payload }`. + // Wrapping it fails edge validation against the gate's payloadSchema. + const delivered = await client.callback(token, { approved: true, note: "spike" }); + log("callback response:", delivered); + if ("error" in delivered) throw new Error(`callback delivery rejected: ${delivered.error}`); + + // 5. Resume and confirm the journal replayed to completion. + log("[4] resume"); + const second = await approvalDurable(first.executionId); + log("done:", second.done); + log("result:", second.result); + log("error:", second.error?.message ?? "(none)"); + + if (!second.done) throw new Error("durable did not complete after callback delivery"); + const result = second.result as { approved?: boolean; subject?: string; note?: string }; + if (result?.approved !== true) throw new Error(`expected approved=true, got ${result?.approved}`); + + log("[5] VERDICT"); + log("filesystem adapter ran end-to-end:", true); + log("zod override (4.4.3) accepted by payloadSchema:", true); + log("callbackUrl available in-process:", observedCallbackUrl); + log("callbackUrl is HTTP-POSTable:", /^https?:\/\//.test(observedCallbackUrl)); +} + +main() + .then(() => { + console.log("\nPASS"); + process.exitCode = 0; + }) + .catch((err) => { + console.error("\nFAIL:", err); + process.exitCode = 1; + }) + .finally(() => { + rmSync(stateDir, { recursive: true, force: true }); + });