Skip to content

[world-vercel] Switch event endpoints to v4 wire format#2055

Merged
VaguelySerious merged 41 commits into
mainfrom
peter/v4
Jun 14, 2026
Merged

[world-vercel] Switch event endpoints to v4 wire format#2055
VaguelySerious merged 41 commits into
mainfrom
peter/v4

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented May 21, 2026

Copy link
Copy Markdown
Member

Switches the world-vercel adapter's event endpoints from v2/v3 to v4

Changes

The adapter's createWorkflowRunEvent / getEvent / getWorkflowRunEvents keep their public signatures and the EventResult / Event / PaginatedResponse<Event> shapes the workflow runtime consumes. What changes is what's on the wire under those calls:

  • POST event body is a single length-prefixed frame: [u32_be meta_len][cbor meta][u32_be body_len][bytes]. The CBOR meta block carries structured event metadata (eventType, deploymentId, executionContext, …); the body is the opaque user payload. POSTs target the /v4/runs/:runId/events/:eventType alias — the trailing segment is an observability hint (access logs / traces) and the frame meta stays authoritative.
  • User payloads stream end-to-end as opaque bytes. The runtime calls dehydrateRunInput / dehydrateStepReturnValue / etc. before invoking events.create, and the bytes pass through unchanged on both write and read paths — no double-CBOR-wrap.
  • POST event response is a CBOR-encoded EventResult. For event types the runtime reads immediately (run_created, run_started, step_started), the server's remoteRefBehavior=resolve lands the materialized entity in the same response so the runtime doesn't need a follow-up runs.get. The returned event is stripped per the caller's resolveData, same as v3.
  • GET single event uses the same binary-frame format as LIST (one frame, no sentinel).
  • GET list events is the v4 binary-frame stream (application/vnd.workflow.v4-frames). One frame per event with CBOR metadata + raw payload bytes inline, followed by a sentinel {_end:1, next?:cursor} frame. The per-event /refs round-trip used by the v3 client is gone. A stream that ends without the sentinel throws (truncation guard) — the read is idempotent and safe to retry.
  • Date coercion on read: The v4 frame meta carries createdAt / resumeAt / retryAfter round-trip via cbor-x's native Date tag, but events read back from the backing store arrive as ISO strings. The SDK runs each event through EventSchema.safeParse after building it from a frame so per-event-type z.coerce.date() lifts the strings back into Date instances — the runtime calls .getTime() on these and would otherwise crash. A parse failure on a known event type leaves a debug-level breadcrumb instead of failing silently.

Behavioral notes

  • No protocol fallback. This adapter version is all-in on the v4 event routes; there is no v3 degradation path. That is a deliberate trade: workflow deployments are pinned to the SDK version they shipped with, and the backend keeps serving v2/v3 to older SDKs. It does mean every deployment shipped with this version requires a backend that serves the v4 routes — the v4 route surface is a permanent compatibility commitment.
  • resolveData: 'none' listings still transfer payload bytes. The v4 list stream always carries resolved payload bytes in the frame body; the client strips them after transfer. The old lazy-refs path returned descriptors only, so metadata-only listings paid no payload bandwidth. Accepted trade for eliminating the per-event resolution round-trip; a wire-level body-less listing flag is a possible follow-up for metadata-only consumers.
  • The payload/meta split is now the eventData wire contract. v3 serialized the whole eventData object; on v4, a new field added to an event schema must also be added to the client's split logic and accepted by the backend's parser, or it is silently dropped. Documented with a warning comment at the map.

What goes away

  • packages/world-vercel/src/refs.ts — deleted. The /refs hydration path is no longer used.
  • hydrateEventRefs / collectPendingRefs / eventDataRefFieldMap and the wire schemas (EventResultResolveWireSchema, EventResultLazyWireSchema, EventWithRefsSchema) — deleted from events.ts.
  • The lazy-refs branching in createWorkflowRunEvent — the server still respects remoteRefBehavior (passed in the frame meta for eventsNeedingResolve types) and bakes the resolution decision into its CBOR response, so the SDK has nothing to do.

What stays

  • v1Compat path in createWorkflowRunEvent — still uses /v1 endpoints for legacy (pre-event-sourcing) runs, including the catch-all legacy POST for hook_received (resumeHook) and wait_completed (wakeUpRun). v4 doesn't cover these.
  • validateUlidTimestamp on run_created, the HookNotFoundError translation on hook 404s, and the stripEventDataRefs path for resolveData='none'.
  • events-v4.ts is an internal helper module — not re-exported from the package's public API.

Test plan

  • Unit tests for the v4 frame encoder/decoder (packages/world-vercel/src/frames.test.ts)
  • Unit tests for the v4 HTTP client: typed-error contract, POST alias round-trip, truncation guard (events-v4.test.ts)
  • Unit tests for the adapter layer: v1Compat legacy fallback, resolveData stripping on create (events.test.ts)
  • Cross-version compat tests on server side (v4 write → v3 read)
  • E2E suite passes against the v4 backend preview deployment
  • Resilient-start E2E test passes (resilient start: addTenWorkflow completes when run_created returns 500)

Mirrors the v4 server-side handlers landing in workflow-server. The
v4 wire format moves event metadata into x-wf-* request/response
headers and treats payloads as opaque user-data bytes (streamed
end-to-end). The SDK passes Uint8Array bytes through unchanged at
this layer; higher-level world-vercel adapter glue handles CBOR.

Adds:
  - packages/world-vercel/src/frames.ts: encoder + async-iterable
    decoder for the length-prefixed binary frame format used by the
    v4 list-events response.
  - packages/world-vercel/src/events-v4.ts: three new functions:
    * createWorkflowRunEventV4 — POST with x-wf-* headers + payload
      bytes, returns event/run ids and timestamp from response
      headers.
    * getEventV4 — GET single event, returns metadata + body bytes.
    * getWorkflowRunEventsV4 — GET list, parses frame stream, returns
      events + pagination cursor.
  - V4_HEADERS exported as the canonical name map; mirrors the
    server-side constant.

V4 client characteristics:
  - Required runId in URL for run_created too (no /runs/null/events
    shortcut; the runId is part of the S3 key the server allocates).
    Higher-level callers generate the ULID client-side.
  - Payload bytes flow through without CBOR encode/decode on this
    layer. Callers CBOR-encode for parity with v3 if they want.
  - Pagination cursor surfaces in the LIST response — eliminates the
    per-large-payload /refs round-trip used by v2/v3.

Tests (10 new in src/frames.test.ts, no new e2e):
  - Canonical wire layout round-trip.
  - Multi-frame round-trip with pagination cursor.
  - Decoder survives 1-byte chunk delivery (matching spike B's chunk-
    boundary robustness requirement).
  - 64 KB body split across many small chunks.
  - Bodies containing 0xff padding don't mis-frame.
  - Back-to-back frames in a single chunk.
  - Truncated stream raises.
  - Meta CBOR types (numbers, booleans, arrays) preserved.

The world-vercel adapter still defaults to the v3 path; v4 is exposed
for direct callers and a follow-up PR will switch the adapter over
once the matching server-side PR is on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented May 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9bd6543

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@workflow/world-vercel Minor
@workflow/cli Patch
@workflow/core Patch
@workflow/web Patch
workflow Patch
@workflow/world-testing Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview, Comment Jun 14, 2026 9:38am
example-nextjs-workflow-webpack Ready Ready Preview, Comment Jun 14, 2026 9:38am
example-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-astro-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-express-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-fastify-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-hono-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-nitro-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-nuxt-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-sveltekit-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-tanstack-start-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workbench-vite-workflow Ready Ready Preview, Comment Jun 14, 2026 9:38am
workflow-docs Ready Ready Preview, Comment, Open in v0 Jun 14, 2026 9:38am
workflow-swc-playground Ready Ready Preview, Comment Jun 14, 2026 9:38am
workflow-tarballs Ready Ready Preview, Comment Jun 14, 2026 9:38am
workflow-web Ready Ready Preview, Comment Jun 14, 2026 9:38am

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1441 1 219 1661
✅ 💻 Local Development 1895 0 219 2114
✅ 📦 Local Production 1895 0 219 2114
✅ 🐘 Local Postgres 1881 0 233 2114
✅ 🪟 Windows 151 0 0 151
✅ 📋 Other 879 0 178 1057
Total 8142 1 1068 9211

❌ Failed Tests

▲ Vercel Production (1 failed)

astro (1 failed):

  • AbortController abortFromStepWorkflow: step abort cancels an in-flight sibling step

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
❌ astro 124 1 26
✅ example 125 0 26
✅ express 125 0 26
✅ fastify 125 0 26
✅ hono 125 0 26
✅ nextjs-turbopack 149 0 2
✅ nextjs-webpack 149 0 2
✅ nitro 125 0 26
✅ nuxt 125 0 26
✅ sveltekit 144 0 7
✅ vite 125 0 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 126 0 25
✅ express-stable 126 0 25
✅ fastify-stable 126 0 25
✅ hono-stable 126 0 25
✅ nextjs-turbopack-canary 132 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 0
✅ nextjs-webpack-canary 132 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 0
✅ nitro-stable 126 0 25
✅ nuxt-stable 126 0 25
✅ sveltekit-stable 145 0 6
✅ vite-stable 126 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 126 0 25
✅ express-stable 126 0 25
✅ fastify-stable 126 0 25
✅ hono-stable 126 0 25
✅ nextjs-turbopack-canary 132 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 151 0 0
✅ nextjs-webpack-canary 132 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 151 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 151 0 0
✅ nitro-stable 126 0 25
✅ nuxt-stable 126 0 25
✅ sveltekit-stable 145 0 6
✅ vite-stable 126 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 125 0 26
✅ express-stable 125 0 26
✅ fastify-stable 125 0 26
✅ hono-stable 125 0 26
✅ nextjs-turbopack-canary 131 0 20
✅ nextjs-turbopack-stable-lazy-discovery-disabled 150 0 1
✅ nextjs-turbopack-stable-lazy-discovery-enabled 150 0 1
✅ nextjs-webpack-canary 131 0 20
✅ nextjs-webpack-stable-lazy-discovery-disabled 150 0 1
✅ nextjs-webpack-stable-lazy-discovery-enabled 150 0 1
✅ nitro-stable 125 0 26
✅ nuxt-stable 125 0 26
✅ sveltekit-stable 144 0 7
✅ vite-stable 125 0 26
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 151 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 126 0 25
✅ e2e-local-dev-tanstack-start- 126 0 25
✅ e2e-local-postgres-nest-stable 125 0 26
✅ e2e-local-postgres-tanstack-start- 125 0 26
✅ e2e-local-prod-nest-stable 126 0 25
✅ e2e-local-prod-tanstack-start- 126 0 25
✅ e2e-vercel-prod-tanstack-start 125 0 26

📋 View full workflow run


Some E2E test jobs failed:

  • Vercel Prod: failure
  • Local Dev: success
  • Local Prod: success
  • Local Postgres: success
  • Windows: success

Check the workflow run for details.

@github-actions

github-actions Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

📈 Comparing against baseline from main branch. Green 🟢 = faster, Red 🔺 = slower.

workflow with no steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 0.038s (-37.5% 🟢) 1.004s (~) 0.966s 10 1.00x
💻 Local Express 0.046s (+7.7% 🔺) 1.007s (~) 0.961s 10 1.19x
💻 Local Nitro 0.046s (-3.1%) 1.007s (~) 0.961s 10 1.20x
🐘 Postgres Express 0.059s (-2.8%) 1.012s (~) 0.953s 10 1.53x
🐘 Postgres Nitro 0.064s (-1.4%) 1.013s (~) 0.950s 10 1.65x
🐘 Postgres Next.js (Turbopack) 0.069s (+2.8%) 1.012s (~) 0.943s 10 1.78x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 0.266s (-16.8% 🟢) 2.490s (+8.8% 🔺) 2.224s 10 1.00x
▲ Vercel Next.js (Turbopack) 0.284s (-29.3% 🟢) 2.600s (+2.1%) 2.316s 10 1.07x
▲ Vercel Nitro 0.288s (+9.8% 🔺) 2.484s (+5.3% 🔺) 2.196s 10 1.08x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.092s (-4.8%) 2.004s (~) 0.912s 10 1.00x
💻 Local Express 1.096s (~) 2.006s (~) 0.909s 10 1.00x
💻 Local Nitro 1.102s (~) 2.007s (~) 0.905s 10 1.01x
🐘 Postgres Nitro 1.111s (~) 2.008s (~) 0.897s 10 1.02x
🐘 Postgres Express 1.117s (+0.8%) 2.011s (~) 0.894s 10 1.02x
🐘 Postgres Next.js (Turbopack) 1.146s (-0.8%) 2.011s (~) 0.865s 10 1.05x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 1.692s (+2.7%) 3.488s (-9.0% 🟢) 1.795s 10 1.00x
▲ Vercel Express 1.763s (+5.4% 🔺) 4.045s (+10.1% 🔺) 2.281s 10 1.04x
▲ Vercel Nitro 1.857s (+13.3% 🔺) 3.924s (+3.3%) 2.067s 10 1.10x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.530s (~) 11.021s (~) 0.492s 3 1.00x
💻 Local Nitro 10.565s (~) 11.024s (~) 0.459s 3 1.00x
🐘 Postgres Nitro 10.578s (~) 11.017s (~) 0.440s 3 1.00x
💻 Local Next.js (Turbopack) 10.610s (-1.6%) 11.019s (~) 0.409s 3 1.01x
🐘 Postgres Express 10.619s (+0.6%) 11.026s (~) 0.407s 3 1.01x
🐘 Postgres Next.js (Turbopack) 10.829s (-1.0%) 11.019s (-2.9%) 0.189s 3 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 14.792s (+4.6%) 16.866s (+8.4% 🔺) 2.074s 2 1.00x
▲ Vercel Next.js (Turbopack) 14.992s (-1.9%) 16.509s (-4.9%) 1.517s 2 1.01x
▲ Vercel Nitro 15.616s (-37.0% 🟢) 17.400s (-33.4% 🟢) 1.784s 2 1.06x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 13.743s (~) 14.026s (~) 0.283s 5 1.00x
🐘 Postgres Nitro 13.779s (-0.7%) 14.021s (-1.4%) 0.243s 5 1.00x
💻 Local Nitro 13.808s (~) 14.028s (~) 0.219s 5 1.00x
💻 Local Next.js (Turbopack) 13.812s (-4.9%) 14.025s (-6.7% 🟢) 0.213s 5 1.01x
🐘 Postgres Express 13.833s (~) 14.022s (~) 0.189s 5 1.01x
🐘 Postgres Next.js (Turbopack) 14.424s (-2.3%) 15.018s (-1.7%) 0.594s 4 1.05x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 22.067s (-2.0%) 24.176s (-0.9%) 2.109s 3 1.00x
▲ Vercel Next.js (Turbopack) 22.212s (+6.5% 🔺) 23.932s (+5.2% 🔺) 1.719s 3 1.01x
▲ Vercel Nitro 22.399s (+6.3% 🔺) 24.425s (+6.4% 🔺) 2.027s 3 1.02x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 12.468s (~) 13.024s (~) 0.556s 7 1.00x
💻 Local Nitro 12.609s (~) 13.025s (~) 0.415s 7 1.01x
🐘 Postgres Nitro 12.619s (+0.8%) 13.021s (~) 0.402s 7 1.01x
💻 Local Next.js (Turbopack) 12.637s (-7.2% 🟢) 13.022s (-7.2% 🟢) 0.385s 7 1.01x
🐘 Postgres Express 12.760s (+2.4%) 13.163s (+1.1%) 0.403s 7 1.02x
🐘 Postgres Next.js (Turbopack) 13.991s (+0.7%) 14.446s (+1.0%) 0.455s 7 1.12x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 30.916s (-18.9% 🟢) 33.673s (-14.9% 🟢) 2.758s 3 1.00x
▲ Vercel Nitro 31.350s (+4.4%) 33.788s (+4.6%) 2.438s 3 1.01x
▲ Vercel Next.js (Turbopack) 31.592s (+3.9%) 33.717s (+3.6%) 2.125s 3 1.02x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.171s (-5.0%) 2.006s (~) 0.835s 15 1.00x
💻 Local Nitro 1.201s (+1.0%) 2.007s (~) 0.806s 15 1.03x
🐘 Postgres Nitro 1.229s (+1.8%) 2.008s (~) 0.779s 15 1.05x
💻 Local Next.js (Turbopack) 1.236s (-5.6% 🟢) 2.005s (~) 0.769s 15 1.06x
🐘 Postgres Express 1.240s (+2.7%) 2.008s (~) 0.769s 15 1.06x
🐘 Postgres Next.js (Turbopack) 1.288s (+1.2%) 2.007s (~) 0.719s 15 1.10x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 2.312s (-49.3% 🟢) 3.905s (-35.0% 🟢) 1.593s 8 1.00x
▲ Vercel Nitro 2.628s (+7.9% 🔺) 4.336s (+7.2% 🔺) 1.709s 7 1.14x
▲ Vercel Next.js (Turbopack) 2.860s (+7.6% 🔺) 4.039s (-7.8% 🟢) 1.180s 8 1.24x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.365s (-1.4%) 2.392s (~) 1.027s 13 1.00x
🐘 Postgres Express 1.445s (+6.3% 🔺) 2.393s (~) 0.948s 13 1.06x
💻 Local Next.js (Turbopack) 1.505s (-15.0% 🟢) 2.016s (~) 0.511s 15 1.10x
🐘 Postgres Next.js (Turbopack) 1.563s (-0.7%) 2.224s (-3.0%) 0.661s 14 1.14x
💻 Local Express 1.654s (+1.9%) 2.006s (~) 0.352s 15 1.21x
💻 Local Nitro 2.060s (+5.0% 🔺) 2.510s (+8.4% 🔺) 0.450s 12 1.51x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 3.208s (-10.8% 🟢) 5.348s (+5.0% 🔺) 2.140s 6 1.00x
▲ Vercel Nitro 3.404s (-5.4% 🟢) 5.504s (+1.0%) 2.100s 6 1.06x
▲ Vercel Next.js (Turbopack) 4.154s (+33.8% 🔺) 5.550s (+7.1% 🔺) 1.396s 6 1.30x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.565s (-2.7%) 4.016s (-3.1%) 2.451s 8 1.00x
🐘 Postgres Express 1.579s (~) 3.889s (-3.1%) 2.311s 8 1.01x
💻 Local Next.js (Turbopack) 2.880s (-40.6% 🟢) 3.341s (-37.5% 🟢) 0.460s 9 1.84x
🐘 Postgres Next.js (Turbopack) 3.652s (+11.5% 🔺) 4.588s (+14.2% 🔺) 0.936s 7 2.33x
💻 Local Express 4.313s (+1.8%) 4.871s (+3.1%) 0.558s 7 2.76x
💻 Local Nitro 5.741s (+4.3%) 6.415s (+6.7% 🔺) 0.674s 5 3.67x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.812s (-7.4% 🟢) 6.523s (-6.7% 🟢) 1.711s 5 1.00x
▲ Vercel Express 4.834s (+15.0% 🔺) 7.057s (+16.5% 🔺) 2.223s 5 1.00x
▲ Vercel Nitro 5.425s (+19.5% 🔺) 7.782s (+23.6% 🔺) 2.357s 4 1.13x

🔍 Observability: Next.js (Turbopack) | Express | Nitro

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.207s (-0.8%) 2.008s (~) 0.801s 15 1.00x
🐘 Postgres Express 1.210s (+0.8%) 2.008s (~) 0.798s 15 1.00x
💻 Local Next.js (Turbopack) 1.261s (-8.7% 🟢) 2.005s (~) 0.743s 15 1.05x
🐘 Postgres Next.js (Turbopack) 1.292s (+0.5%) 2.007s (~) 0.714s 15 1.07x
💻 Local Express 1.403s (-7.2% 🟢) 2.007s (~) 0.604s 15 1.16x
💻 Local Nitro 1.545s (-6.7% 🟢) 2.007s (~) 0.462s 15 1.28x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.236s (-24.0% 🟢) 3.854s (-20.8% 🟢) 1.618s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.274s (-34.4% 🟢) 3.591s (-29.2% 🟢) 1.317s 9 1.02x
▲ Vercel Express 2.539s (+4.9%) 4.483s (+7.9% 🔺) 1.943s 7 1.14x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.360s (+0.6%) 2.222s (-14.3% 🟢) 0.862s 14 1.00x
🐘 Postgres Express 1.440s (+1.9%) 2.152s (-7.1% 🟢) 0.712s 14 1.06x
🐘 Postgres Next.js (Turbopack) 1.515s (-6.5% 🟢) 2.316s (+0.9%) 0.801s 13 1.11x
💻 Local Next.js (Turbopack) 1.631s (-21.3% 🟢) 2.071s (-26.7% 🟢) 0.441s 15 1.20x
💻 Local Express 1.733s (-0.7%) 2.006s (-6.7% 🟢) 0.273s 15 1.27x
💻 Local Nitro 2.208s (+3.5%) 2.592s (~) 0.385s 12 1.62x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.764s (+5.2% 🔺) 4.875s (+20.3% 🔺) 2.111s 7 1.00x
▲ Vercel Next.js (Turbopack) 2.823s (+7.7% 🔺) 4.405s (+2.2%) 1.581s 7 1.02x
▲ Vercel Express 2.971s (-1.5%) 4.938s (+10.8% 🔺) 1.967s 7 1.07x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.684s (+4.5%) 3.886s (+3.3%) 2.202s 8 1.00x
🐘 Postgres Express 1.905s (+21.4% 🔺) 4.873s (+21.5% 🔺) 2.968s 7 1.13x
💻 Local Next.js (Turbopack) 3.100s (-36.9% 🟢) 3.759s (-27.5% 🟢) 0.659s 8 1.84x
🐘 Postgres Next.js (Turbopack) 3.430s (+25.5% 🔺) 4.301s (+24.4% 🔺) 0.871s 7 2.04x
💻 Local Express 4.776s (+1.9%) 5.346s (+3.2%) 0.571s 6 2.84x
💻 Local Nitro 5.777s (-1.3%) 6.416s (~) 0.639s 5 3.43x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 4.276s (-4.4%) 6.331s (+5.1% 🔺) 2.055s 5 1.00x
▲ Vercel Next.js (Turbopack) 4.896s (+38.6% 🔺) 7.056s (+29.9% 🔺) 2.160s 5 1.15x
▲ Vercel Express 5.133s (+48.5% 🔺) 6.827s (+33.6% 🔺) 1.694s 5 1.20x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.588s (+2.2%) 1.006s (-1.7%) 0.418s 60 1.00x
💻 Local Next.js (Turbopack) 0.601s (-31.6% 🟢) 1.004s (-3.4%) 0.402s 60 1.02x
💻 Local Express 0.605s (-3.1%) 1.022s (~) 0.416s 59 1.03x
🐘 Postgres Express 0.619s (+10.0% 🔺) 1.023s (+1.7%) 0.404s 59 1.05x
💻 Local Nitro 0.649s (+3.3%) 1.022s (+1.7%) 0.373s 59 1.10x
🐘 Postgres Next.js (Turbopack) 0.841s (-0.9%) 1.023s (-3.3%) 0.183s 59 1.43x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.483s (-0.6%) 7.219s (~) 1.735s 9 1.00x
▲ Vercel Nitro 5.770s (+12.7% 🔺) 7.712s (+13.0% 🔺) 1.942s 8 1.05x
▲ Vercel Next.js (Turbopack) 5.871s (-4.2%) 7.624s (-2.2%) 1.753s 8 1.07x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.385s (+2.2%) 2.007s (~) 0.623s 45 1.00x
🐘 Postgres Express 1.434s (+5.8% 🔺) 2.030s (~) 0.596s 45 1.04x
💻 Local Express 1.513s (+1.3%) 2.029s (+1.2%) 0.516s 45 1.09x
💻 Local Next.js (Turbopack) 1.542s (-28.6% 🟢) 2.005s (-32.6% 🟢) 0.463s 45 1.11x
💻 Local Nitro 1.548s (-0.7%) 2.007s (~) 0.458s 45 1.12x
🐘 Postgres Next.js (Turbopack) 1.951s (-2.1%) 2.100s (-12.6% 🟢) 0.149s 43 1.41x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 13.796s (-9.8% 🟢) 15.934s (-7.8% 🟢) 2.138s 6 1.00x
▲ Vercel Nitro 13.805s (-5.1% 🟢) 16.126s (-1.9%) 2.321s 6 1.00x
▲ Vercel Next.js (Turbopack) 14.543s (-11.5% 🟢) 16.047s (-13.4% 🟢) 1.503s 6 1.05x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.726s (+1.0%) 3.111s (+0.8%) 0.386s 39 1.00x
🐘 Postgres Express 2.881s (+5.8% 🔺) 3.194s (+2.6%) 0.313s 38 1.06x
💻 Local Express 3.228s (+1.3%) 3.978s (~) 0.750s 31 1.18x
💻 Local Nitro 3.371s (~) 4.010s (~) 0.638s 30 1.24x
💻 Local Next.js (Turbopack) 3.400s (-21.1% 🟢) 4.008s (-20.0% 🟢) 0.607s 31 1.25x
🐘 Postgres Next.js (Turbopack) 3.895s (+1.0%) 4.076s (+0.8%) 0.182s 30 1.43x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 28.122s (+4.2%) 30.970s (+7.1% 🔺) 2.849s 4 1.00x
▲ Vercel Express 28.352s (+6.5% 🔺) 31.180s (+9.3% 🔺) 2.828s 4 1.01x
▲ Vercel Next.js (Turbopack) 30.214s (+1.4%) 32.971s (+3.2%) 2.757s 4 1.07x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.245s (+1.2%) 1.006s (~) 0.761s 60 1.00x
🐘 Postgres Express 0.271s (+11.1% 🔺) 1.007s (~) 0.736s 60 1.10x
🐘 Postgres Next.js (Turbopack) 0.299s (+1.3%) 1.006s (~) 0.707s 60 1.22x
💻 Local Next.js (Turbopack) 0.394s (-33.6% 🟢) 1.004s (-3.4%) 0.611s 60 1.61x
💻 Local Express 0.405s (+2.0%) 1.005s (~) 0.599s 60 1.65x
💻 Local Nitro 0.420s (+1.0%) 1.005s (~) 0.585s 60 1.71x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 1.657s (-19.1% 🟢) 3.835s (+0.6%) 2.178s 16 1.00x
▲ Vercel Next.js (Turbopack) 1.883s (-7.3% 🟢) 3.541s (-9.5% 🟢) 1.658s 18 1.14x
▲ Vercel Nitro 1.975s (+5.3% 🔺) 4.016s (+10.2% 🔺) 2.041s 15 1.19x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.410s (+1.6%) 1.028s (+1.1%) 0.619s 88 1.00x
🐘 Postgres Express 0.450s (+13.0% 🔺) 1.030s (+1.2%) 0.579s 88 1.10x
🐘 Postgres Next.js (Turbopack) 0.613s (-7.8% 🟢) 1.191s (-5.0%) 0.578s 76 1.50x
💻 Local Next.js (Turbopack) 1.682s (-27.3% 🟢) 2.174s (-30.9% 🟢) 0.493s 42 4.10x
💻 Local Express 2.040s (-0.5%) 2.564s (-1.7%) 0.524s 36 4.98x
💻 Local Nitro 2.125s (-3.0%) 2.715s (-4.8%) 0.591s 34 5.19x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.848s (~) 4.953s (-0.7%) 2.105s 19 1.00x
▲ Vercel Express 3.088s (+18.7% 🔺) 5.572s (+35.0% 🔺) 2.484s 17 1.08x
▲ Vercel Next.js (Turbopack) 3.534s (+7.6% 🔺) 5.217s (-1.5%) 1.683s 18 1.24x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.826s (+5.3% 🔺) 1.402s (~) 0.577s 86 1.00x
🐘 Postgres Express 0.919s (+21.1% 🔺) 1.690s (+20.5% 🔺) 0.770s 72 1.11x
🐘 Postgres Next.js (Turbopack) 2.944s (-5.5% 🟢) 3.919s (-2.4%) 0.975s 31 3.57x
💻 Local Next.js (Turbopack) 6.970s (-33.6% 🟢) 7.706s (-32.4% 🟢) 0.736s 16 8.44x
💻 Local Express 8.784s (-1.2%) 9.333s (-1.6%) 0.549s 13 10.64x
💻 Local Nitro 9.806s (-1.2%) 10.359s (-0.8%) 0.553s 12 11.87x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 5.358s (-31.8% 🟢) 7.617s (-19.8% 🟢) 2.259s 16 1.00x
▲ Vercel Nitro 5.890s (-4.2%) 8.271s (+4.8%) 2.381s 15 1.10x
▲ Vercel Next.js (Turbopack) 6.712s (-26.9% 🟢) 8.953s (-21.0% 🟢) 2.242s 14 1.25x

🔍 Observability: Express | Nitro | Next.js (Turbopack)

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.145s (-6.0% 🟢) 2.002s (~) 0.005s (-49.5% 🟢) 2.011s (~) 0.866s 10 1.00x
💻 Local Express 1.154s (~) 2.005s (~) 0.011s (+7.1% 🔺) 2.018s (~) 0.864s 10 1.01x
🐘 Postgres Nitro 1.172s (~) 1.996s (~) 0.001s (+40.0% 🔺) 2.011s (~) 0.838s 10 1.02x
💻 Local Nitro 1.175s (~) 2.005s (~) 0.012s (-3.2%) 2.019s (~) 0.844s 10 1.03x
🐘 Postgres Express 1.184s (+2.0%) 1.998s (~) 0.001s (~) 2.011s (~) 0.827s 10 1.03x
🐘 Postgres Next.js (Turbopack) 1.230s (~) 2.002s (~) 0.001s (+7.7% 🔺) 2.011s (~) 0.781s 10 1.07x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.297s (-4.4%) 3.424s (-2.5%) 0.788s (-41.3% 🟢) 4.648s (-12.5% 🟢) 2.351s 10 1.00x
▲ Vercel Next.js (Turbopack) 2.358s (+4.0%) 3.189s (-12.2% 🟢) 1.306s (+45.1% 🔺) 5.126s (+1.2%) 2.768s 10 1.03x
▲ Vercel Express 2.446s (+8.3% 🔺) 3.977s (+14.3% 🔺) 1.200s (+15.9% 🔺) 5.643s (+14.2% 🔺) 3.197s 10 1.06x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Next.js (Turbopack) 1.497s (-13.2% 🟢) 2.006s (~) 0.008s (-27.8% 🟢) 2.017s (~) 0.519s 30 1.00x
💻 Local Express 1.562s (-1.0%) 2.010s (~) 0.012s (+0.8%) 2.024s (~) 0.462s 30 1.04x
🐘 Postgres Nitro 1.592s (+0.7%) 2.002s (~) 0.005s (-4.4%) 2.028s (~) 0.436s 30 1.06x
💻 Local Nitro 1.597s (~) 2.010s (~) 0.013s (+5.6% 🔺) 2.025s (~) 0.428s 30 1.07x
🐘 Postgres Express 1.699s (+8.3% 🔺) 2.007s (~) 0.006s (+5.7% 🔺) 2.030s (~) 0.331s 30 1.13x
🐘 Postgres Next.js (Turbopack) 1.769s (-1.0%) 2.010s (~) 0.005s (-10.2% 🟢) 2.025s (~) 0.256s 30 1.18x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 6.043s (~) 7.293s (~) 0.204s (-23.6% 🟢) 7.992s (-2.2%) 1.949s 8 1.00x
▲ Vercel Express 6.294s (-0.6%) 7.641s (+2.2%) 0.362s (-32.3% 🟢) 8.479s (~) 2.185s 8 1.04x
▲ Vercel Next.js (Turbopack) 6.602s (-16.9% 🟢) 7.883s (-15.7% 🟢) 0.342s (-1.3%) 8.781s (-15.1% 🟢) 2.179s 8 1.09x

🔍 Observability: Nitro | Express | Next.js (Turbopack)

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 0.800s (+7.4% 🔺) 1.045s (~) 0.000s (+300.0% 🔺) 1.088s (+1.8%) 0.288s 57 1.00x
🐘 Postgres Express 0.844s (+10.2% 🔺) 1.127s (+7.4% 🔺) 0.000s (-45.2% 🟢) 1.163s (+9.8% 🔺) 0.319s 52 1.06x
🐘 Postgres Next.js (Turbopack) 1.014s (-3.2%) 1.464s (-4.0%) 0.000s (NaN%) 1.472s (-4.0%) 0.459s 41 1.27x
💻 Local Next.js (Turbopack) 1.056s (-26.9% 🟢) 1.915s (-4.8%) 0.000s (-37.5% 🟢) 1.918s (-4.9%) 0.861s 32 1.32x
💻 Local Express 1.370s (-1.0%) 2.014s (~) 0.000s (+71.4% 🔺) 2.016s (~) 0.646s 30 1.71x
💻 Local Nitro 1.403s (-1.2%) 2.014s (~) 0.001s (+21.4% 🔺) 2.016s (~) 0.613s 30 1.75x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.932s (-11.3% 🟢) 4.466s (-8.3% 🟢) 0.001s (+546.2% 🔺) 4.916s (-8.6% 🟢) 1.984s 13 1.00x
▲ Vercel Next.js (Turbopack) 3.063s (-29.3% 🟢) 4.337s (-25.2% 🟢) 0.000s (+Infinity% 🔺) 4.742s (-24.9% 🟢) 1.680s 13 1.04x
▲ Vercel Express 3.138s (+2.5%) 4.784s (+14.2% 🔺) 0.000s (+Infinity% 🔺) 5.272s (+13.9% 🔺) 2.133s 12 1.07x

🔍 Observability: Nitro | Next.js (Turbopack) | Express

fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 1.672s (-3.1%) 2.216s (-1.8%) 0.000s (+Infinity% 🔺) 2.231s (-1.6%) 0.559s 27 1.00x
🐘 Postgres Express 1.728s (+4.8%) 2.173s (+1.5%) 0.000s (+Infinity% 🔺) 2.188s (~) 0.459s 28 1.03x
💻 Local Next.js (Turbopack) 1.994s (-31.9% 🟢) 2.454s (-31.0% 🟢) 0.001s (-21.5% 🟢) 2.461s (-30.8% 🟢) 0.468s 25 1.19x
🐘 Postgres Next.js (Turbopack) 2.153s (-1.2%) 2.544s (-2.6%) 0.000s (-100.0% 🟢) 2.553s (-2.9%) 0.399s 24 1.29x
💻 Local Express 3.051s (~) 3.837s (+2.9%) 0.001s (-46.9% 🟢) 3.840s (+2.9%) 0.789s 16 1.82x
💻 Local Nitro 3.196s (+1.1%) 3.966s (+3.3%) 0.000s (-36.4% 🟢) 3.969s (+3.3%) 0.773s 16 1.91x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Express 4.629s (-28.5% 🟢) 6.504s (-15.1% 🟢) 0.000s (-70.4% 🟢) 7.083s (-13.8% 🟢) 2.453s 9 1.00x
▲ Vercel Next.js (Turbopack) 4.660s (-29.3% 🟢) 6.200s (-25.5% 🟢) 0.000s (NaN%) 6.653s (-24.9% 🟢) 1.993s 10 1.01x
▲ Vercel Nitro 4.661s (-15.3% 🟢) 6.470s (-8.7% 🟢) 0.001s (+522.2% 🔺) 7.045s (-7.0% 🟢) 2.384s 9 1.01x

🔍 Observability: Express | Next.js (Turbopack) | Nitro

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Next.js (Turbopack) 15/21
🐘 Postgres Nitro 20/21
▲ Vercel Express 11/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 12/21
Next.js (Turbopack) 💻 Local 16/21
Nitro 🐘 Postgres 16/21
Column Definitions
  • Workflow Time: Runtime reported by workflow (completedAt - createdAt) - primary metric
  • TTFB: Time to First Byte - time from workflow start until first stream byte received (stream benchmarks only)
  • Slurp: Time from first byte to complete stream consumption (stream benchmarks only)
  • Wall Time: Total testbench time (trigger workflow + poll for result)
  • Overhead: Testbench overhead (Wall Time - Workflow Time)
  • Samples: Number of benchmark iterations run
  • vs Fastest: How much slower compared to the fastest configuration for this benchmark

Worlds:

  • 💻 Local: In-memory filesystem world (local development)
  • 🐘 Postgres: PostgreSQL database world (local development)
  • ▲ Vercel: Vercel production/preview deployment
  • 🌐 Turso: Community world (local development)
  • 🌐 MongoDB: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Jazz: Community world (local development)
  • 🌐 Redis: Community world (local development)
  • 🌐 Redis + BullMQ: Community world (local development)
  • 🌐 Cloudflare: Community world (local development)
  • 🌐 MySQL: Community world (local development)
  • 🌐 Azure: Community world (local development)
  • 🌐 NATS JetStream: Community world (local development)
  • 🌐 Upstash: Community world (local development)

📋 View full workflow run

Sets WORKFLOW_SERVER_URL_OVERRIDE in
packages/world-vercel/src/utils.ts to
https://workflow-server-git-peter-v4.vercel.sh so that e2e tests
running off this SDK branch exercise the v4-enabled workflow-server
preview instead of production.

The override is the inline mechanism documented at the constant —
when set, it wins over both the default
(https://vercel-workflow.com) and the VERCEL_WORKFLOW_SERVER_URL
env var. The same pattern is used in v4 testing on the workflow-
server side: CI rewrites this string on PR branches. Reset to ''
before merging to main.

Companion to vercel/workflow-server#439.

Updates four tests in utils.test.ts that previously assumed the
override is empty. Each affected assertion gets a comment noting
what the expectation looks like on main; flipping back to the main
behavior is a one-line edit per test when the override is reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VaguelySerious and others added 2 commits June 10, 2026 11:24
…ped-error parity

- Feed undici response bodies into decodeFrames as AsyncIterables instead
  of converting via dynamic import('node:stream').Readable.toWeb — the
  dynamic import resolves to an empty namespace in Next.js webpack server
  bundles and crashed every events.list call (E2E Vercel Prod nextjs-webpack).
- Restore the v3 makeRequest typed-error contract on all v4 endpoints:
  409 EntityConflictError, 410 RunExpiredError, 425 TooEarlyError(retryAfter),
  429 ThrottleError(retryAfter), else WorkflowWorldError with status/code.
  The previous mapping returned plain Errors for 404/410/425, which broke
  the hook 404 -> HookNotFoundError translation, terminal-run handling, and
  step retry pacing.
- Honor config.dispatcher on v4 calls (was silently ignored).
- Drop the redundant setAuthHeader: getHttpConfig already sets Authorization
  and degrades gracefully outside a Vercel OIDC context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The queue enforces the retry delay, but the backend also persists the
timestamp on the step entity for premature-delivery pacing and
observability. The v4 split was silently dropping it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review: no blocking issues.

Three correctness regressions found during review were fixed in d4d5663 and 7a798a2 (webpack-incompatible node:stream dynamic import in the frame-read path, loss of the v3 typed-error contract for 404/410/425/retryAfter, and step_retrying.retryAfter being dropped from the wire). Remaining findings below are forward-compatibility notes and nits.

data as unknown as { eventData?: Record<string, unknown> }
).eventData ?? {}) as Record<string, unknown>;
const payloadField = PAYLOAD_FIELD_BY_EVENT_TYPE[data.eventType];
const meta: SplitEventData['meta'] = {};

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

Forward-compat: this allowlist is now the wire contract for eventData. v3 serialized the entire eventData object, so adding a field to an event schema in @workflow/world was automatically transmitted. On v4, any new field must be added here (and accepted by the backend) or it is silently dropped — step_retrying.retryAfter was exactly this failure mode (fixed in 7a798a2), and it produced no type error, no test failure, and no runtime warning.

Suggestion for a follow-up: a contract test that walks the AnyEventRequest union from @workflow/world and asserts every eventData key is either the event type's payload field or covered by this meta split, so the next schema addition fails loudly instead of silently.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Documented in 65a7234 — the map now carries a warning comment marking it (plus the meta allowlist) as the eventData wire contract, with step_retrying.retryAfter as the cautionary tale. The walk-the-AnyEventRequest-union contract test is a good idea; deferring to a follow-up since it needs a representative fixture per event type.

Comment thread packages/world-vercel/src/events-v4.ts Outdated
input.payload ?? new Uint8Array(0)
);

const url = `${baseUrl}/v4/runs/${encodeURIComponent(input.runId)}/events`;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

No protocol fallback. The adapter is all-in on the v4 endpoints: if they are ever unavailable (backend rollback or partial deploy), every event operation throws WorkflowWorldError(404) with no v3 degradation path. That is presumably a deliberate trade (skew protection pins deployments to their SDK, and the backend keeps v2/v3 routes for older SDKs), but it makes the v4 route surface a permanent compatibility commitment for every deployment shipped with this version. Worth stating explicitly in the PR description / release notes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Now stated explicitly in the PR description under "Behavioral notes": no v3 degradation path, deployments are pinned to their SDK, and the v4 route surface becomes a permanent compatibility commitment for deployments shipped with this version.


// For resolveData='none', strip eventData entirely. Reuse the world-
// side helper so behavior stays in sync with other backends.
return resolveData === 'none' ? stripEventDataRefs(event, 'none') : event;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

resolveData: 'none' still downloads every payload. The v4 list stream always carries resolved payload bytes in the frame body, and the client strips them here after the transfer. The v3 lazy-ref path returned descriptors only, so metadata-only listings (observability/CLI polling) paid no payload bandwidth. For runs with large step I/O this is a regression in transfer volume on the busiest read path. Consider a follow-up wire-level flag for body-less listing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Acknowledged in the PR description under "Behavioral notes" — accepted trade for eliminating the per-event resolution round-trip. A wire-level body-less listing flag is the right follow-up for metadata-only consumers (observability/CLI polling).

Comment thread packages/world-vercel/src/events.ts Outdated
// back into Date instances — the workflow runtime calls .getTime() on
// these and would otherwise crash. safeParse: pass the event through
// unchanged if it doesn't match a known shape (legacy / mid-rollout).
const parsed = EventSchema.safeParse(raw);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

safeParse falling back to the raw event is the right call for unknown/future event types, but it also masks parse failures on known types — e.g. a future regression in date coercion would pass through silently here and only surface later as a .getTime() crash inside the runtime. Consider logging (debug-level) when parsing fails for an eventType that is in the known set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Done in 65a7234buildEventFromV4 now emits a console.debug breadcrumb when EventSchema.safeParse fails for an eventType that is in the known set, so a coercion regression surfaces at the parse site instead of as a .getTime() crash downstream.

};
data: events,
cursor: result.next ?? null,
hasMore: Boolean(result.next),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

hasMore: Boolean(result.next) — key-paginated backends can return a cursor even when the next page turns out empty, so consumers may see hasMore: true followed by an empty page. v3 returned the server's explicit hasMore. Harmless for the runtime (it loops until the cursor is exhausted), but worth knowing for paginating UI consumers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(AI) Keeping as-is for now: the runtime (the dominant consumer of this path) loops until the cursor is exhausted, so a trailing empty page is harmless there. Flagged for paginating UI consumers if one shows up.

@TooTallNate TooTallNate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment — strong implementation; two behavioral divergences from main to resolve before merge

I read the full diff, built @workflow/world-vercel, and ran the package test suite locally (135 tests pass, including the new frame and v4-client suites). The core of this change — the framed wire protocol, the codec, and the typed-error parity — is excellent, careful work. I have two correctness concerns that I believe are still open (both originally surfaced by the automated review and, as far as I can tell, not yet resolved in code), plus a few smaller notes.

What's very good

  • The frame codec (frames.ts) is robust. refill()/take() handle arbitrary chunk boundaries — splits mid-u32-prefix and mid-CBOR-meta — and the test suite proves it with 1-byte chunking, a 64 KB body across 37-byte chunks, bodies full of 0xff bytes that could fool a length-scanning parser, and back-to-back frames in one chunk. The buffer.slice() (not subarray()) at the body yield is correct and commented — the yielded body owns its bytes.
  • Webpack-safety is handled deliberately. Feeding undici's response body straight into decodeFrames as an AsyncIterable (rather than going through Readable.toWeb) is the right call, and there's a dedicated regression test that drives the decoder from an async generator of Buffer chunks, mirroring how undici delivers in production. The comments explaining why not to convert via node:stream will save the next person a bad afternoon.
  • Typed-error parity is exhaustively tested. throwForErrorResponse preserves the 409→EntityConflict, 410→RunExpired, 425→TooEarly(+retryAfter), 429→Throttle(+retryAfter), else→WorkflowWorldError(status) contract that the runtime branches on, and events-v4.test.ts asserts each one plus the non-JSON-body message path. This is the part most likely to cause silent control-flow regressions if it drifted, so I'm glad it's nailed down.
  • The deletion of refs.ts is clean — I grepped for every exported symbol (resolveRefDescriptors, hydrateEventRefs, collectPendingRefs, eventDataRefFieldMap, RefWithRunId) and found no remaining references anywhere in packages/.
  • The changeset is correctly scoped (@workflow/world-vercel: minor); the only non-world-vercel file changes in the diff are version-bump churn from a merged release commit and will evaporate on rebase.

Concern 1 (blocking): v1Compat now throws for hook_received / wait_completed on legacy runs

createWorkflowRunEventInner only handles run_created and run_cancelled under v1Compat, and throws for everything else:

throw new Error(
  `world-vercel: v1Compat=true is only supported for run_created ` +
    `and run_cancelled, not ${data.eventType}`
);

On main, the v1Compat branch has a catch-all fallback that POSTs any other event type to the legacy /v1/runs/:id/events endpoint. The runtime still relies on that fallback for legacy (spec-version-1) runs:

  • packages/core/src/runtime/resume-hook.ts:160 calls world.events.create(hook.runId, { eventType: 'hook_received', … }, { v1Compat }) where v1Compat = isLegacySpecVersion(hook.specVersion).
  • packages/core/src/runtime/runs.ts:197 (wakeUpRun) calls world.events.create(runId, { eventType: 'wait_completed', … }, { v1Compat: compatMode }) for legacy runs.

For a legacy run, both now hit the throw instead of the legacy POST that main performs. A webhook delivered to — or a wait completing on — a pre-event-sourcing run would fail. run_cancelled (via cancelRun) is covered, but these two aren't.

Either restore the legacy event POST path for non-lifecycle event types under v1Compat, or update those callers so they don't route legacy hook_received/wait_completed through this adapter. If the position is that spec-version-1 runs can't reach a v5 SDK in practice and this path is intentionally dead, that's a legitimate call — but it should be stated explicitly (and ideally the throw message should say so), because today it's a silent behavioral narrowing vs main.

Concern 2 (should-fix): createWorkflowRunEvent ignores resolveData for the returned event

On main, the create path returns event: stripEventAndLegacyRefs(wireResult.event, resolveData), so a caller passing resolveData: 'none' doesn't get payload fields back. The v4 path returns body.event verbatim:

return {
  event: body.event as Event | undefined,};

This diverges from the Storage contract and can return input/result/error payload bytes to a caller that explicitly asked for none. In practice the eventsNeedingResolve types read run/step rather than the returned event, so the live blast radius may be zero today — but it's a contract divergence that's cheap to close (thread resolveData through and strip, mirroring buildEventFromV4's resolveData === 'none' handling). At minimum worth a comment if it's deliberately not stripped.

Smaller notes (non-blocking)

  • resolveData: 'none' still transfers full payloads. The list stream always carries resolved payload bytes in the frame body, and the client strips them after transfer. The previous lazy path returned descriptors only, so metadata-only listings (observability / CLI polling) paid no payload bandwidth. For runs with large step I/O this is a transfer-volume regression — probably an acceptable trade for eliminating the per-event resolution round-trip, but worth acknowledging in the PR description since it's a real change in egress characteristics.
  • safeParse fallback can mask date-coercion regressions on known types. In buildEventFromV4, falling back to the raw event when EventSchema.safeParse fails is the right behavior for unknown/future event types, but for a known type a future coercion regression would pass through silently here and only surface downstream as a .getTime() crash in the runtime. A debug-level log when parsing fails for an event type that's in the known set would turn a confusing runtime crash into an obvious breadcrumb.
  • No protocol fallback if the v4 endpoints are ever unavailable (every op throws WorkflowWorldError(404) with no degradation path). Presumably deliberate given deployment/SDK pinning, but it does mean a partial backend rollback is not survivable from the SDK side. Flagging for awareness, not asking for a change.
  • The forward-compat shape of PAYLOAD_FIELD_BY_EVENT_TYPE / the meta allowlist is now effectively the wire contract for eventData: any new event field must be added to the split logic (and accepted by the backend) or it's silently dropped. step_retrying.retryAfter was exactly that failure mode and is fixed here — but it's worth a code comment at the allowlist making explicit that this is now a contract surface, so the next field-adder doesn't rediscover it the hard way.

CI

The red nextjs-webpack jobs (Local Postgres / Local Prod / Local Dev, "stable lazyDiscovery disabled") are pre-existing on main — the latest main Tests run fails the same three jobs with the identical root cause (Dynamic require of "stream" is not supportedFailed to collect page data for /.well-known/workflow/v1/flow during next build). This PR doesn't introduce or widen that: undici is already statically imported into the world-vercel bundle on main via http-client.ts, so adding import { request } from 'undici' to events-v4.ts doesn't change the bundled dependency surface. Not a blocker for this PR, but it does mean nextjs-webpack build E2E can't currently vouch for this change end-to-end — worth getting that baseline green separately.

Bottom line

The protocol implementation and its tests are great. I'd like to see Concern 1 resolved (or explicitly declared dead-path with a clear message) and Concern 2 addressed or consciously waived before this merges, since both are silent divergences from main's behavior on the legacy/resolveData paths. Happy to re-review quickly once those are settled.

VaguelySerious and others added 2 commits June 10, 2026 20:51
…ata on create

- v1Compat with hook_received / wait_completed (legacy spec-version-1
  runs) again POSTs to the legacy v1 events endpoint instead of
  throwing — resumeHook and wakeUpRun still rely on this path.
- The event returned from createWorkflowRunEvent is stripped per the
  caller's resolveData, restoring the v3 Storage contract.
- Debug-level breadcrumb when EventSchema parsing fails for a known
  event type (the raw fallback is meant for unknown/future types).
- Document that the v4 payload/meta split is the eventData wire
  contract: new schema fields must be added on both sides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… truncated list streams

- POSTs now use the aliased route with the event type as the trailing
  path segment so it is visible in backend access logs / traces without
  decoding the frame; the frame meta stays authoritative.
- LIST responses that end without the {_end: 1} sentinel frame now
  throw instead of returning a silently-truncated page with
  hasMore=false — the read is idempotent and safe to retry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@VaguelySerious

Copy link
Copy Markdown
Member Author

(AI) @TooTallNate thanks for the thorough review — both concerns are addressed in this push:

  • Concern 1 (v1Compat legacy fallback): fixed in 65a7234. The catch-all is restored: under v1Compat, anything other than run_created/run_cancelled POSTs to /v1/runs/:id/events exactly as main does, so hook_received (resumeHook) and wait_completed (wakeUpRun) keep working for legacy spec-version-1 runs. Both are locked in with tests in events.test.ts.
  • Concern 2 (resolveData on create): fixed in 65a7234. The returned event is stripped via stripEventDataRefs(event, resolveData), restoring the Storage contract; test asserts 'none' strips payload fields and keeps entity metadata.

Smaller notes, same push: safeParse now leaves a debug breadcrumb when parsing fails for a known event type, the payload/meta split carries a contract-surface warning comment, and the PR description gained a "Behavioral notes" section stating the no-fallback commitment and the resolveData: 'none' transfer-volume change explicitly.

Also in 9bc87e3: event POSTs now use the backend's new /events/:eventType alias so the event type is visible in access logs/traces without decoding the frame (frame meta stays authoritative), and LIST reads now throw if the stream ends without the {_end: 1} sentinel instead of returning a silently-truncated page.

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff and cross-checked it against the server side (workflow-server origin/main). Verified: the /events/:eventType POST alias and meta.retryAfter parsing are both merged server-side (#504, #505); throwForErrorResponse preserves the full v3 typed-error mapping the runtime branches on; the splitEventDataForV4 meta allowlist covers every field of every user-creatable event schema in @workflow/world (the only uncovered field is hook_conflict.conflictingRunId, which is world-created and only travels the read path, where the frame meta carries full eventData anyway); run/step entities in the POST response carry real Dates over CBOR (electrodb getters convert before encode), so skipping zod there is safe; the truncation sentinel guard and webpack-safe stream reads look right and are well tested; no remaining imports of the deleted refs.ts; unit tests pass locally (141/141).

One medium (latent, not currently reachable) finding about date coercion on the POST response events array, plus two small nits — all inline. Previously-raised items from earlier rounds (hasMore derivation, resolveData:'none' bandwidth, no-fallback commitment, wire-contract allowlist) are addressed or consciously accepted, so I haven't re-raised them.

Comment thread packages/world-vercel/src/events.ts Outdated
cursor: wireResult.cursor,
hasMore: wireResult.hasMore,
hook: body.hook as EventResult['hook'],
events: body.events as EventResult['events'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium (latent): body.events — and body.event above — skip the EventSchema date coercion that this PR adds for the GET/LIST path.

On v3 the POST response was parsed through EventResultResolveWireSchema, whose events: z.array(EventSchema) coerced nested eventData dates. Here they're cast verbatim from CBOR. The preloaded-events optimization on run_started is fed server-side from a queryByRunId Dynamo read (workflow-server lib/data/events.ts, the non-resilient-start path), and Dynamo-stored eventData date fields come back as ISO strings — the electrodb eventData attribute's set converts Date → toISOString() and there is no inverse getter. That's exactly the situation buildEventFromV4 runs EventSchema.safeParse for.

It's unreachable today only because the server populates events solely on the first successful start (the alreadyRunning branch deliberately returns no events), and a first-start history contains only run_created/run_started, which have no eventData date fields. But the consumer is preloadedEvents in runtime.ts, which replay reads verbatim — including now >= (e.eventData.resumeAt as Date).getTime() (runtime.ts:803). If the TTFB optimization is ever extended to re-enqueues (the sleep-resume case, where a wait_created is guaranteed present), this becomes a replay crash with no SDK change to flag it.

Suggest mapping each entry of body.events (and body.event) through the same EventSchema.safeParse coercion used in buildEventFromV4, so create-response events and listed events are guaranteed the same shape.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 349a750 — extracted the EventSchema coercion into coerceEventDates (shared with buildEventFromV4) and mapped both body.event and body.events through it, so the POST response and the GET/LIST path now return identically-coerced events. Test added: a run_started response whose preloaded wait_created event carries ISO-string createdAt/resumeAt comes back with real Dates.

events: wireResult.events,
cursor: wireResult.cursor,
hasMore: wireResult.hasMore,
hook: body.hook as EventResult['hook'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: body.wait is dropped. The v4 server explicitly includes wait: result.wait in the CBOR response body, and CreateEventV4Result['body'] declares it — but it doesn't make it into the returned EventResult, which does have a wait?: Wait field (for wait_created/wait_completed). v3 had the same gap (its wire schemas had no wait), so this is parity-preserving, but since the v4 response now carries it, threading it through is a one-liner: wait: body.wait as EventResult['wait'],. Fine as a follow-up if you want this PR to stay strictly wire-format-only.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 349a750wait: body.wait as EventResult['wait'] is now threaded through, with a test asserting the wait entity survives a wait_created create.

Comment thread packages/world-vercel/src/events-v4.ts Outdated
config: APIConfig | undefined,
opName: string
): Promise<ListEventsV4Result> {
const { headers } = await getHttpConfig(config);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: each LIST call resolves getHttpConfig twice — once in getWorkflowRunEventsV4 / getEventsByCorrelationIdV4 (for baseUrl) and again here (for headers). Each invocation can hit getVercelOidcToken(). Consider resolving once at the call site and passing { baseUrl, headers } into consumeListFrameStream, which also removes the (theoretical) window where the two calls could disagree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 349a750consumeListFrameStream now takes the caller's resolved headers, so each LIST call resolves getHttpConfig (and the OIDC token) exactly once.

pranaygp and others added 3 commits June 11, 2026 14:23
…le auth resolve on LIST

Address review feedback on the v4 events client:

- Run the POST response's `event` and preloaded `events` (run_started
  TTFB optimization) through the same EventSchema date coercion the
  GET/LIST path uses. These can be read back from DynamoDB server-side,
  where nested eventData dates (wait_created.resumeAt,
  step_retrying.retryAfter) are ISO strings — v3 coerced them via its
  zod wire schemas, and the runtime calls .getTime() on them during
  replay. Extracted the coercion into coerceEventDates, shared with
  buildEventFromV4.
- Thread `body.wait` through to EventResult.wait — the v4 server
  includes the wait entity in the CBOR response body.
- Resolve getHttpConfig once per LIST call and pass headers into
  consumeListFrameStream instead of resolving twice (baseUrl at the
  call site, headers inside).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* origin/main:
  [core] V2: unify wait+step queue dispatch in suspension processing (#1925)
  fix(world-local,world-postgres): make duplicate hook_created idempotent (#2295)
  docs(observability): remove MVP implementation detail bullet (#2367)
  fix: settle aborted parallel steps before completing abortParallelWorkflow (#2244)
  Add native v4 workflow attribute events (#2226)
  Version Packages (beta) (#2326)
  [ci] Fix flaky windows unit tests (#2359)
  Capture Vercel runtime logs when e2e Vercel Prod lanes fail (#2356)
  Fix e2e failure reporting under vitest 4 and preserve fetch error causes (#2355)
  [core] Fix process crash from rejected waitUntil promises (#2336)
  [core] Remove duplicate `waitUntil` for suspension handler async operations (#2345)
  Prevent local tests from hanging (#2338)
  feat(core): add optional namespace for queue topic prefix (#2305)
  [web-shared] Show precise durations in the new trace viewer (#2335)
  Validate unique workflow step IDs at build time (#2018)
  Move run attributes into their own detail card (#2327)
  [core] Forward-port stream reconnect to getReadable level (#2318)
  [docs] Add "Step executed multiple times" error page (#2310)
  Fix flickering on the detail panel when navigating the trace viewer (#2325)
The merge from main brings native workflow attributes (attr_set events,
initial attributes on run_created / resilient run_started). Their
eventData fields are structured metadata, so they ride in the v4 frame
meta: splitEventDataForV4 now passes through `attributes`, `changes`,
`writer`, and `allowReservedAttributes`, and the wire client accepts
them on CreateEventV4Input. Without this the v4 split silently dropped
them — the exact wire-contract hazard documented on
PAYLOAD_FIELD_BY_EVENT_TYPE.

Server counterpart: vercel/workflow-server#516 (parses the same fields
out of the v4 meta). The attributes e2e tests on Vercel lanes stay red
until that deploys.

splitEventDataForV4 is now exported for unit tests, which lock in the
attribute-field coverage of the meta allowlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pranaygp

Copy link
Copy Markdown
Contributor

Status update after taking over the branch:

  • Merged main into peter/v4 (clean merge). This fixes the nextjs-webpack e2e build failures from the last few runs — they were stale-branch skew: the old merge-base's packages/core/src/util.ts still imported @vercel/functions, whose current release pulls in ws and breaks esbuild-bundled webpack routes with Dynamic require of "stream" is not supported. Main had already removed that import.
  • Implemented the review findings: POST-response event/events now go through the same EventSchema date coercion as the GET/LIST path (shared coerceEventDates helper), EventResult.wait is threaded through, and LIST calls resolve getHttpConfig once.
  • New since the merge: native run attributes needed v4 wire support. Main's attributes feature (Add native v4 workflow attribute events #2226) sends attr_set (changes/writer/allowReservedAttributes) and initial attributes on run_created/run_started through events.create. The v4 meta allowlist dropped all of them — the exact hazard the warning comment on PAYLOAD_FIELD_BY_EVENT_TYPE describes. The SDK side now carries them in the frame meta (with unit tests locking in the allowlist coverage).

⚠️ Merge dependency: the server must parse those meta fields, which it doesn't yet — that's vercel/workflow-server#516 (unit + integration tested). Until #516 merges and deploys to production, the experimental_setAttributes e2e tests on the Vercel prod lanes here will fail (local lanes are unaffected). Everything else should be green. Once #516 is deployed, re-run CI here and this PR is ready for final review.

🤖 Generated with Claude Code

@pranaygp pranaygp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. CI is fully green (111 pass / 9 skip, including all 12 E2E Vercel Prod lanes), the server-side dependency (workflow-server#516, which parses the native-attribute fields out of the v4 frame meta) is merged and deployed, and every prior review thread is resolved.

Disclosure: I co-authored part of this branch (the POST-response date coercion + shared coerceEventDates, the wait passthrough, single getHttpConfig resolve per LIST, and the native run-attribute wire fields on both SDK and server). Reviewed the full diff including those additions; 146 world-vercel unit tests pass and typecheck is clean locally. Since I'm a contributor, a second independent pass is still welcome, but I see nothing blocking.

VaguelySerious and others added 2 commits June 14, 2026 10:29
Remove specific backend storage technology names (S3, the backing store)
and internal server file paths from the v4 event-path comments and error
messages, keeping only the general wire contract. Open-source code should
describe behavior, not Vercel backend internals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/world-vercel/src/events-v4.ts Outdated
The v4 wire split routes each eventData field to either the frame body
(payload) or the frame meta. Previously a field added to a @workflow/world
event schema that wasn't routed here was silently dropped on the wire with
no type error, test failure, or warning (step_retrying.retryAfter hit this).

Add a compile-time exhaustiveness guard: EventDataField is derived from the
CreateEventSchema discriminated union (via AnyEventRequest), and
assertEventDataWireContractExhaustive fails the build — naming the field —
if any schema field is routed to neither the payload map nor the meta
allowlist, or if an allowlisted meta field no longer exists in the schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread packages/world-vercel/src/events-v4.ts Outdated
Comment thread packages/world-vercel/src/events-v4.ts Outdated
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
@VaguelySerious

Copy link
Copy Markdown
Member Author

Mostly polished comments, and added some more typeguards so that we don't accidentally forget to add a field to the v4 wire format

@github-actions

Copy link
Copy Markdown
Contributor

Backport PR opened against stable: #2414. Merge conflicts were resolved by AI — please review carefully. (backport job run)

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.

4 participants