Skip to content

feat(durable): adapter-aware decision delivery, scoped to the tenant workspace - #26

Merged
hamchowderr merged 2 commits into
mainfrom
feat/durable-callback-delivery
Jul 28, 2026
Merged

feat(durable): adapter-aware decision delivery, scoped to the tenant workspace#26
hamchowderr merged 2 commits into
mainfrom
feat/durable-callback-delivery

Conversation

@hamchowderr

Copy link
Copy Markdown
Owner

Approve/Deny assumed the callback was an HTTP endpoint, so it only ever worked on the Zapier adapter. lib/durable/delivery.ts is now the single seam — routes and service code call it and never touch resolveCallbackUrl/postCallback directly.

Builds on #25.

Behaviour

  • zapier — unchanged mechanics: resolve the reported URL, POST it, cancel via the SDK.
  • filesystem — no URL to POST to, so delivery goes through client.callback(token, payload). The token is read with getOperations(), a lease-free read; checkout() would take the runner's lease.
  • cancel — the adapter has no cancel primitive. A suspended run holds no lease, so take it and release as failed. If the runner is mid-tick the lease is refused and that's reported rather than half-cancelling.
  • DecisionResult.runStatus carries what the adapter actually reported, so callers persist the truth instead of assuming "cancelled" — a run can finish before a cancel lands.

cancelRunForUser went through the seam too; it had the same hardcoding.

Tenancy

State lives at <workspace_dir>/.durable, per tenant, matching the agent workspace layout. FilesystemClient is constructed with an explicit baseDir rather than createClient(), because createClient reads process-global config that can't be per-tenant on a server handling several workspaces at once. DurableCallOptions.client makes this per-call with no global state.

Approval gate (foreman-2qbk, part 2)

humanApprovalGate() emitted a __report_callback_url_* step solely to smuggle the URL past getDurableRun. That doesn't apply locally, so on the filesystem adapter it emits neither the step nor the URL binding. Zapier path byte-identical.

Two traps, found by measuring

  • configureDurable imported from @zapier/zapier-durable/node reports success via its own getConfig() while the runtime keeps writing to the default fsDir — the subpath and the root hold separate config state. Import from the root only.
  • The option key is fsDir. An unknown key (e.g. filesystem: {baseDir}) is silently ignored and falls back to ~/.config/zapier-sdk/durable. An earlier revision of these tests wrote into a real home directory because of it. There's now a regression test asserting nothing is written there.

Tests

Existing service tests now mock @/lib/durable/deploy instead of @/lib/durable, so the real seam runs on top of them and those assertions keep proving the Zapier path rather than a stub.

424 tests pass (19 new, including a real offline durable end-to-end), typecheck 0, lint 0.

Closes foreman-gk6k. foreman-2qbk stays open, blocked on foreman-3uje (sandboxed local execution).

🤖 Generated with Claude Code

hamchowderr and others added 2 commits July 28, 2026 07:41
…workspace

foreman-gk6k. Approve/Deny assumed the callback was an HTTP endpoint, so it
worked only on the Zapier adapter. lib/durable/delivery.ts is now the single
seam: routes and service code call deliverDecision and never touch
resolveCallbackUrl/postCallback directly.

- zapier: unchanged mechanics (resolve the reported URL, POST it, cancel via
  the SDK). The existing service tests now mock @/lib/durable/deploy instead
  of @/lib/durable, so the REAL seam runs on top of them and those assertions
  keep proving the Zapier path rather than a stub.
- filesystem: no URL to POST to, so delivery goes through the store's
  callback(token, payload). The token is read with getOperations(), a
  lease-free read -- checkout() would take the runner's lease.
- cancel: the adapter has no cancel primitive. A suspended run holds no lease,
  so take it and release as failed; if the runner IS mid-tick the lease is
  refused and that is reported instead of half-cancelling.
- DecisionResult carries runStatus so callers persist what the adapter really
  reported, not an assumed cancelled.

State lives at <workspace_dir>/.durable, per tenant, matching the agent
workspace layout. FilesystemClient is constructed with an explicit baseDir
rather than createClient(), because createClient reads PROCESS-GLOBAL config
that cannot be per-tenant on a server serving several workspaces at once.
cancelRunForUser went through the seam too -- it had the same hardcoding.

Two traps found by measurement and now guarded:

- configureDurable imported from @zapier/zapier-durable/node reports success
  via its own getConfig() while the runtime keeps writing to the default
  fsDir. The subpath and the root hold SEPARATE config state. Import from the
  root only.
- The option key is fsDir; an unknown key (e.g. filesystem: {baseDir}) is
  silently ignored and falls back to ~/.config/zapier-sdk/durable. An earlier
  revision of these tests wrote into the real home directory because of it.
  There is now a regression test asserting nothing is written there.

Filed foreman-1uz7: node:fs co-location with the workspace holds only while
the workspace FS is local, or while the durable runs inside the sandbox mount.

422 tests pass (17 new, incl. a real offline durable), typecheck 0, lint 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
foreman-2qbk (part 2). humanApprovalGate() emitted a __report_callback_url_*
step for one reason: getDurableRun will not expose the callback URL, so the
durable had to smuggle it out through a step result.

That does not apply locally. The run is in-process, so Foreman reads the token
straight off the execution's operations via findOpenLocalGate. On the
filesystem adapter the gate now emits neither the reporting step nor the URL
binding — an unused binding would be dead weight in generated source — while
the Zapier path is byte-identical to before.

Adapter comes from activeDurableAdapter() and is overridable per call, so
tests pin it explicitly instead of depending on ambient env.

424 tests pass, typecheck 0, lint 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 28, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
foreman Ready Ready Preview, Comment Jul 28, 2026 2:44pm

@supabase

supabase Bot commented Jul 28, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ihhdqzljgdnatuwzrlfm because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@hamchowderr
hamchowderr merged commit 741f491 into main Jul 28, 2026
9 checks passed
@hamchowderr
hamchowderr deleted the feat/durable-callback-delivery branch July 28, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant