From 1d7c67f555027aefa2e7fbbc2ee95a4740cc0bab Mon Sep 17 00:00:00 2001 From: HamChowderr Date: Tue, 28 Jul 2026 02:05:12 -0700 Subject: [PATCH 1/3] spike(durable): prove the filesystem adapter runs Foreman durables offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foreman-02lu. Adds @zapier/zapier-durable 0.11.0 as a real @foreman/agents dependency — until now it was only a version STRING handed to Zapier's sandbox via AGED_DURABLE_DEPS, never imported here. scripts/durable-filesystem-spike.ts runs a Foreman-shaped durable end to end with no credentials, no network and no early-access allowlist: step -> human-approval gate -> suspend -> deliver -> resume -> done. What it establishes: - ctx.createCallback returns [promise, callbackUrl] directly in-process, so the __report_callback_url_* step humanApprovalGate() emits in lib/durable/source.ts is unnecessary on this path. That step only exists because getDurableRun will not expose the URL over the wire. - The repo-wide zod override (4.4.3) is accepted even though the package pins zod 4.2.1 exactly. check-dep-uniqueness only passes after a FULL lockfile regen; an incremental install leaves a second zod and fails postinstall. And one finding that contradicts the issue's own acceptance criteria: - FilesystemClient.callbackBaseUrl is a file:// URL. Measured file:///callbacks/. It is not HTTP-POSTable, so the /automations Approve/Deny action cannot POST to it. Delivery has to go through client.callback(token, payload). Filed as foreman-gk6k. - CallbackRequest is typed unknown and the payload IS the body; passing {payload:{...}} fails edge validation. The README only documents CLI delivery, so this is not covered upstream. No adapter wiring yet — deliberately out of spike scope, filed as foreman-2qbk. Zapier deploy path untouched. audit unchanged at 8 (3 low / 5 high); production-only still 3 low. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/export-state.json | 2 +- package-lock.json | 58 ++++++-- packages/agents/package.json | 1 + packages/agents/scripts/README.md | 16 +++ .../scripts/durable-filesystem-spike.ts | 125 ++++++++++++++++++ 5 files changed, 189 insertions(+), 13 deletions(-) create mode 100644 packages/agents/scripts/durable-filesystem-spike.ts diff --git a/.beads/export-state.json b/.beads/export-state.json index b2bbc1d..ac9497a 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":"ha2tmra6btmgnge1ldgbpmjmfch6mrj0","timestamp":"2026-07-28T02:04:10.206306-07:00","issues":454,"memories":0} \ No newline at end of file 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..ce06282 --- /dev/null +++ b/packages/agents/scripts/durable-filesystem-spike.ts @@ -0,0 +1,125 @@ +/** + * 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. + */ +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 }); + }); From f37ea8a515a9d0478b1628b6c0772cc73e6d0e50 Mon Sep 17 00:00:00 2001 From: HamChowderr Date: Tue, 28 Jul 2026 02:10:40 -0700 Subject: [PATCH 2/3] docs(zapier): record the filesystem-adapter findings for Zapier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing questions doc rather than starting a new artifact or a thread. Five new evidence-backed questions from the foreman-02lu spike: filesystem-adapter support status, callback-URL exposure on getDurableRun, whether client.callback is the intended delivery path for non-Zapier adapters, the undocumented raw-payload CallbackRequest shape, and the exact zod 4.2.1 pin. Also sharpens the standing question 2. It asks Zapier to expose the callback URL because an external orchestrator cannot obtain it; the spike shows ctx.createCallback returns it directly in-process, so the gap is in the wire protocol, not the SDK design. Notes that neither package has a public tracker — @zapier/zapier-durable publishes no repository/bugs/homepage, and the SDK's GitLab is behind Zapier SAML SSO — and asks where reports should go (foreman-iaed). Co-Authored-By: Claude Opus 5 (1M context) --- .beads/export-state.json | 2 +- .../zapier-durable-questions-for-engineers.md | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.beads/export-state.json b/.beads/export-state.json index ac9497a..c201737 100644 --- a/.beads/export-state.json +++ b/.beads/export-state.json @@ -1 +1 @@ -{"last_dolt_commit":"ha2tmra6btmgnge1ldgbpmjmfch6mrj0","timestamp":"2026-07-28T02:04:10.206306-07:00","issues":454,"memories":0} \ No newline at end of file +{"last_dolt_commit":"1mb5kfro1v58lkd6hutrml0ph83pf4h9","timestamp":"2026-07-28T02:10:22.3936787-07:00","issues":457,"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`: From 6ea318bc70b127520bc47e53cab3a94dcb2fd896 Mon Sep 17 00:00:00 2001 From: HamChowderr Date: Tue, 28 Jul 2026 02:16:11 -0700 Subject: [PATCH 3/3] docs(durable): record the pre-1.0 stability posture on the spike 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). Missed that on the first read. No code change needed: npm caret on a 0.x version allows patches only, so the existing ^0.11.0 already rejects 0.12.0 (verified with semver.satisfies). Recording the reasoning so the range is not 'loosened' later by someone assuming caret spans minors, and so the spike gets re-run on each bump. Co-Authored-By: Claude Opus 5 (1M context) --- .beads/export-state.json | 2 +- packages/agents/scripts/durable-filesystem-spike.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.beads/export-state.json b/.beads/export-state.json index c201737..e9d0100 100644 --- a/.beads/export-state.json +++ b/.beads/export-state.json @@ -1 +1 @@ -{"last_dolt_commit":"1mb5kfro1v58lkd6hutrml0ph83pf4h9","timestamp":"2026-07-28T02:10:22.3936787-07:00","issues":457,"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/packages/agents/scripts/durable-filesystem-spike.ts b/packages/agents/scripts/durable-filesystem-spike.ts index ce06282..c738c3a 100644 --- a/packages/agents/scripts/durable-filesystem-spike.ts +++ b/packages/agents/scripts/durable-filesystem-spike.ts @@ -12,6 +12,12 @@ * * 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";