Skip to content

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

Draft
VaguelySerious wants to merge 26 commits into
mainfrom
peter/v4
Draft

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

Conversation

@VaguelySerious
Copy link
Copy Markdown
Member

@VaguelySerious VaguelySerious commented May 21, 2026

Switches the world-vercel adapter's event endpoints from v2/v3 to v4. Server-side ships in workflow-server PR #439.

Agents: Yes, WORKFLOW_SERVER_URL_OVERRIDE is set, that's OK! It'll be unset once the server supports this functionality.

What 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. The previous x-wf-* header design was dropped in favour of the single frame so requests survive proxies that mangle long/non-ASCII headers.
  • 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.
  • 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.
  • run_started carries the run input when called by the resilient-start path. If start()'s run_created POST failed with a 5xx, the queue worker re-tries via run_started with the input bytes piggybacked; the server synthesises the missing run_created. Server-side fix for the matching cache bug is in workflow-server PR Fix command injection vulnerability in CI workflow via untrusted fork PR #439.

Typed errors on POST event

createWorkflowRunEventV4 now maps HTTP error responses to the typed error classes the runtime branches on:

  • 409EntityConflictError
  • 404 on hook_disposed / hook_receivedHookNotFoundError
  • 429ThrottleError
  • >= 500WorkflowWorldError

This restores the start.ts branch that catches EntityConflictError when a race causes the run to already exist by the time the SDK sends run_created.

Date coercion on read

The v4 frame meta carries createdAt / resumeAt / retryAfter round-trip via cbor-x's native Date tag, but DynamoDB-backed events store them 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.

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 SDK migrations that haven't moved to event sourcing. 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)
  • Cross-version compat tests on server side (v4 write → v3 read)
  • E2E suite passes against the workflow-server v4 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
Copy link
Copy Markdown

changeset-bot Bot commented May 21, 2026

🦋 Changeset detected

Latest commit: 4b466de

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
Copy link
Copy Markdown
Contributor

vercel Bot commented May 21, 2026

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 May 27, 2026 11:14am
example-nextjs-workflow-webpack Ready Ready Preview, Comment May 27, 2026 11:14am
example-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-astro-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-express-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-fastify-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-hono-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-nitro-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-nuxt-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-sveltekit-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-tanstack-start-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workbench-vite-workflow Ready Ready Preview, Comment May 27, 2026 11:14am
workflow-docs Ready Ready Preview, Comment, Open in v0 May 27, 2026 11:14am
workflow-swc-playground Ready Ready Preview, Comment May 27, 2026 11:14am
workflow-tarballs Ready Ready Preview, Comment May 27, 2026 11:14am
workflow-web Ready Ready Preview, Comment May 27, 2026 11:14am

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1107 115 219 1441
✅ 💻 Local Development 1615 0 219 1834
✅ 📦 Local Production 1615 0 219 1834
✅ 🐘 Local Postgres 1615 0 219 1834
✅ 🪟 Windows 131 0 0 131
✅ 📋 Other 741 0 176 917
Total 6824 115 1052 7991

❌ Failed Tests

▲ Vercel Production (115 failed)

nextjs-webpack (115 failed):

  • DurableAgent e2e core basic text response
  • DurableAgent e2e core single tool call
  • DurableAgent e2e core multiple sequential tool calls
  • DurableAgent e2e core tool error recovery
  • DurableAgent e2e onStepFinish fires constructor + stream callbacks in order with step data
  • DurableAgent e2e onFinish fires constructor + stream callbacks in order with event data
  • DurableAgent e2e provider tools provider tool identity preserved across step boundaries
  • DurableAgent e2e provider tools mixed provider and function tools
  • DurableAgent e2e instructions string instructions are passed to the model
  • DurableAgent e2e timeout completes within timeout
  • DurableAgent e2e experimental_onStart (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e experimental_onStepStart (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e experimental_onToolCallStart (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e experimental_onToolCallFinish (GAP) completes but callbacks are not called (GAP)
  • DurableAgent e2e prepareCall (GAP) completes but prepareCall is not applied (GAP)
  • DurableAgent e2e prepareStep on constructor agent-level prepareStep is called for each LLM step
  • DurableAgent e2e prepareStep on constructor stream-level prepareStep overrides constructor-level
  • DurableAgent e2e multimodal tool results passes through LanguageModelV3ToolResultOutput from tools
  • DurableAgent e2e tool approval (GAP) completes but needsApproval is not checked (GAP)
  • addTenWorkflow | wrun_01KSMJ5D27E3CGQ8NCSV9KTPMB | 🔍 observability
  • addTenWorkflow | wrun_01KSMJ5D27E3CGQ8NCSV9KTPMB | 🔍 observability
  • wellKnownAgentWorkflow (.well-known/agent) | wrun_01KSN5BAAA9MG32D3Y2A8GJRXP | 🔍 observability
  • promiseAllWorkflow | wrun_01KSMJ5K1KY8WQDWGZTQS29QS4 | 🔍 observability
  • promiseRaceWorkflow | wrun_01KSMJ5RD3N0AFGPHGTH01E7JW | 🔍 observability
  • promiseAnyWorkflow | wrun_01KSMJ64FQ4TK2JH1P19R3RFEK | 🔍 observability
  • importedStepOnlyWorkflow | wrun_01KSN5C11GS98DDJR9K92B2G28 | 🔍 observability
  • readableStreamWorkflow | wrun_01KSMJ6C0NQVZK55E210MB563H | 🔍 observability
  • hookWorkflow | wrun_01KSMJ6NM3244NBPPFYFSP6MB0 | 🔍 observability
  • hookWorkflow is not resumable via public webhook endpoint | wrun_01KSMJ6VVW27PQ6CJJY9EX3X31 | 🔍 observability
  • webhookWorkflow | wrun_01KSMJ6ZNZB6VZTKVERGXGPZDE | 🔍 observability
  • sleepingWorkflow | wrun_01KSMJ7545GJP22WRT49NPMMM2 | 🔍 observability
  • parallelSleepWorkflow | wrun_01KSMJ7HRBCPP57CDG5JGNMJWP | 🔍 observability
  • sleepWinsRaceWorkflow | wrun_01KSMJ7NXWBEJS4WZC72PV40V5 | 🔍 observability
  • stepWinsRaceWorkflow | wrun_01KSMJ7S2Q9HHWX1PYMKHJSJD9 | 🔍 observability
  • nullByteWorkflow | wrun_01KSMJ7WAB1GJHMJN218609VEN | 🔍 observability
  • workflowAndStepMetadataWorkflow | wrun_01KSMJ7Y7NK3Z9QFKJPE00S13Q | 🔍 observability
  • outputStreamWorkflow no startIndex (reads all chunks)
  • outputStreamWorkflow positive startIndex (skips first chunk)
  • outputStreamWorkflow negative startIndex (reads from end)
  • outputStreamWorkflow - getTailIndex and getChunks getTailIndex returns correct index after stream completes
  • outputStreamWorkflow - getTailIndex and getChunks getChunks returns same content as reading the stream
  • outputStreamInsideStepWorkflow - getWritable() called inside step functions | wrun_01KSMJ9Z9156PH5VPDP366MR51 | 🔍 observability
  • utf8StreamWorkflow | wrun_01KSMJAC6CHFXV55TBNSJ5MZ91 | 🔍 observability
  • writableForwardedFromWorkflowWorkflow | wrun_01KSMJAJ6NXPZ6D5GJM3B4FR1P | 🔍 observability
  • writableForwardedFromStepWorkflow | wrun_01KSMJANKVQ09N3ZFJ2EJV8J20 | 🔍 observability
  • fetchWorkflow | wrun_01KSMJAQVG4G9DVD26MCX0G6D1 | 🔍 observability
  • promiseRaceStressTestWorkflow | wrun_01KSMJASPE4Y6XCQXADYMGEB10 | 🔍 observability
  • error handling error propagation step errors basic step error preserves message and stack trace
  • error handling error propagation step errors cross-file step error preserves message and function names in stack
  • error handling retry behavior regular Error retries until success
  • error handling retry behavior RetryableError respects custom retryAfter delay
  • error handling retry behavior maxRetries=0 disables retries
  • error handling catchability FatalError can be caught and detected with FatalError.is()
  • error handling catchability step throw round-trips FatalError with cause chain to workflow catch
  • error handling catchability step throw of a non-Error value preserves it as cause on the wrapping FatalError
  • error handling not registered StepNotRegisteredError fails the step but workflow can catch it
  • error handling not registered StepNotRegisteredError fails the run when not caught in workflow
  • hookCleanupTestWorkflow - hook token reuse after workflow completion | wrun_01KSMJEEGHEYC276NXBKZ5MYFQ | 🔍 observability
  • concurrent hook token conflict - two workflows cannot use the same hook token simultaneously | wrun_01KSMJERYC9E1CV0RGZ3AEGEM2 | 🔍 observability
  • hookDisposeTestWorkflow - hook token reuse after explicit disposal while workflow still running | wrun_01KSMJF6XX3YTRC4KEE0HBPJ97 | 🔍 observability
  • stepFunctionPassingWorkflow - step function references can be passed as arguments (without closure vars) | wrun_01KSMJFP4XHWAFPBRSXT00BSM8 | 🔍 observability
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument | wrun_01KSMJFYP33W64719JGRFZDRGN | 🔍 observability
  • closureVariableWorkflow - nested step functions with closure variables | wrun_01KSMJG3Q8PJBQJ036HNWC26XM | 🔍 observability
  • spawnWorkflowFromStepWorkflow - spawning a child workflow using start() inside a step | wrun_01KSMJG5NR8BJE3EEE42CQ3WFE | 🔍 observability
  • runClassSerializationWorkflow - Run instances serialize across workflow/step boundaries | wrun_01KSMJGFQ4F7SMRS27KD9XGB6R | 🔍 observability
  • startFromWorkflow - calling start() directly inside a workflow function with hook communication | wrun_01KSMJGR3AV8VPY6X1V3ESFBYY | 🔍 observability
  • fibonacciWorkflow - recursive workflow composition via start() | wrun_01KSMJGTYX2D2VTR4ZMYEQ1QJ9 | 🔍 observability
  • pathsAliasWorkflow - TypeScript path aliases resolve correctly | wrun_01KSMJH96JZCW8A185K4A8TEG3 | 🔍 observability
  • Calculator.calculate - static workflow method using static step methods from another class | wrun_01KSMJHE0G2VNYYGEHB224QRFS | 🔍 observability
  • AllInOneService.processNumber - static workflow method using sibling static step methods | wrun_01KSMJHJVM5K6M6QQDMWFJH0W2 | 🔍 observability
  • ChainableService.processWithThis - static step methods using this to reference the class | wrun_01KSMJHQN14G34QM670YFEJX51 | 🔍 observability
  • thisSerializationWorkflow - step function invoked with .call() and .apply() | wrun_01KSMJHWJC0PBE3HMXEGH9CHX1 | 🔍 observability
  • customSerializationWorkflow - custom class serialization with WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE | wrun_01KSMJJ2FNYM19X4HQ2Z0H3299 | 🔍 observability
  • instanceMethodStepWorkflow - instance methods with "use step" directive | wrun_01KSMJJ8CCPATC18GWGV007XD7 | 🔍 observability
  • crossContextSerdeWorkflow - classes defined in step code are deserializable in workflow context | wrun_01KSMJJH8XNRGTEZ8SB0343V6J | 🔍 observability
  • errorSubclassRoundTripWorkflow - first-class Error subclasses survive every serialization boundary | wrun_01KSMJJQSXHG9EHA5PHFSH2QVW | 🔍 observability
  • stepFunctionAsStartArgWorkflow - step function reference passed as start() argument | wrun_01KSMJJSZQ1HA2ZN9HAAB3KJHJ | 🔍 observability
  • pages router addTenWorkflow via pages router
  • pages router promiseAllWorkflow via pages router
  • pages router sleepingWorkflow via pages router
  • hookWithSleepWorkflow - hook payloads delivered correctly with concurrent sleep | wrun_01KSMJKKRGPWX654GAKK5MZW2W | 🔍 observability
  • hookWithSleepFinalStepWorkflow - step only on final payload | wrun_01KSMJM28MBKAAYC34VC8HE0RK | 🔍 observability
  • sleepInLoopWorkflow - sleep inside loop with steps actually delays each iteration | wrun_01KSMJMJA1TBDZNR9SQ6DP5DX5 | 🔍 observability
  • sleepWithSequentialStepsWorkflow - sequential steps work with concurrent sleep (control) | wrun_01KSMJMVYDAS33T7YEQRE44NXD | 🔍 observability
  • AbortController abortTimeoutWorkflow: timeout cancels long-running step
  • AbortController abortParallelWorkflow: abort cancels all parallel steps
  • AbortController abortFromStepWorkflow: step abort cancels an in-flight sibling step
  • AbortController abortAlreadyAbortedWorkflow: pre-aborted signal seen by step
  • AbortController abortReasonWorkflow: abort reason preserved across boundaries
  • AbortController abortAfterCompletionWorkflow: abort after step completes is a no-op
  • AbortController abortViaHookWorkflow: external hook triggers abort on in-flight step
  • AbortController abortExternalSignalWorkflow: signal passed as workflow input
  • AbortController abortExternalSignalInFlightWorkflow: external abort fires mid-flight, propagates to nested steps
  • AbortController abortAnyInStepWorkflow: AbortSignal.any inside a step composes deserialized signals
  • AbortController abortSurvivesReplayWorkflow: controller state consistent across replay
  • AbortController abortThrowIfAbortedWorkflow: throwIfAborted causes FatalError, no retries
  • AbortController abortReasonTypesWorkflow: various abort reason types propagate correctly
  • AbortController abortFetchUncaughtWorkflow: uncaught fetch AbortError is FatalError, no retries
  • AbortController abortFetchInFlightWorkflow: aborting cancels an in-flight fetch
  • AbortController abortVoidSleepTimeoutWorkflow: documented void sleep().then(abort) pattern works
  • AbortController abortDeterministicBranchWorkflow: if-check takes same path on first-run and replay
  • AbortController abortListenerWorkflow: signal.addEventListener fires on the deserialized step signal
  • AbortController abortThrowIfAbortedMidFlightWorkflow: throwIfAborted in a polling loop bails when abort fires
  • AbortController abortDeterministicBranchFromStepWorkflow: branches stay consistent when abort comes from a step
  • AbortController abortHookOrderingWorkflow [listener-first-abort-first]: addEventListener → hook.then → abort() → resumeHook
  • AbortController abortHookOrderingWorkflow [listener-first-hook-first]: addEventListener → hook.then → resumeHook → abort()
  • AbortController abortHookOrderingWorkflow [hook-first-abort-first]: hook.then → addEventListener → abort() → resumeHook
  • AbortController abortHookOrderingWorkflow [hook-first-hook-first]: hook.then → addEventListener → resumeHook → abort()
  • importMetaUrlWorkflow - import.meta.url is available in step bundles | wrun_01KSMJTE7MCVFVYQJ1B2AAAY8N | 🔍 observability
  • metadataFromHelperWorkflow - getWorkflowMetadata/getStepMetadata work from module-level helper (#1577) | wrun_01KSMJTG3G68EWTRC79THWD7N1 | 🔍 observability
  • resilient start: addTenWorkflow completes when run_created returns 500 | wrun_01KSMJTHZ4QJH4V0XZ0Z3HPADK | 🔍 observability
  • getterStepWorkflow - getter functions with "use step" directive | wrun_01KSMJTN3SWPHSVCRKEE0R8DEF | 🔍 observability
  • distributedAbortController - manual abort triggers signal | wrun_01KSMJTV61EFHQV4TSD93HQ9TD | 🔍 observability
  • distributedAbortController - TTL expiration triggers signal | wrun_01KSMJTXS76PMN6V9Y7DA7Y5H0 | 🔍 observability
  • distributedAbortController - reconnect to existing controller | wrun_01KSMJV2B17A5V0P2Y46YGXYSP | 🔍 observability

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 105 0 26
✅ example 105 0 26
✅ express 105 0 26
✅ fastify 105 0 26
✅ hono 105 0 26
✅ nextjs-turbopack 129 0 2
❌ nextjs-webpack 14 115 2
✅ nitro 105 0 26
✅ nuxt 105 0 26
✅ sveltekit 124 0 7
✅ vite 105 0 26
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 106 0 25
✅ express-stable 106 0 25
✅ fastify-stable 106 0 25
✅ hono-stable 106 0 25
✅ nextjs-turbopack-canary 112 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 131 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 131 0 0
✅ nextjs-webpack-canary 112 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 131 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 131 0 0
✅ nitro-stable 106 0 25
✅ nuxt-stable 106 0 25
✅ sveltekit-stable 125 0 6
✅ vite-stable 106 0 25
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 106 0 25
✅ express-stable 106 0 25
✅ fastify-stable 106 0 25
✅ hono-stable 106 0 25
✅ nextjs-turbopack-canary 112 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 131 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 131 0 0
✅ nextjs-webpack-canary 112 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 131 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 131 0 0
✅ nitro-stable 106 0 25
✅ nuxt-stable 106 0 25
✅ sveltekit-stable 125 0 6
✅ vite-stable 106 0 25
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 106 0 25
✅ express-stable 106 0 25
✅ fastify-stable 106 0 25
✅ hono-stable 106 0 25
✅ nextjs-turbopack-canary 112 0 19
✅ nextjs-turbopack-stable-lazy-discovery-disabled 131 0 0
✅ nextjs-turbopack-stable-lazy-discovery-enabled 131 0 0
✅ nextjs-webpack-canary 112 0 19
✅ nextjs-webpack-stable-lazy-discovery-disabled 131 0 0
✅ nextjs-webpack-stable-lazy-discovery-enabled 131 0 0
✅ nitro-stable 106 0 25
✅ nuxt-stable 106 0 25
✅ sveltekit-stable 125 0 6
✅ vite-stable 106 0 25
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 131 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 106 0 25
✅ e2e-local-dev-tanstack-start- 106 0 25
✅ e2e-local-postgres-nest-stable 106 0 25
✅ e2e-local-postgres-tanstack-start- 106 0 25
✅ e2e-local-prod-nest-stable 106 0 25
✅ e2e-local-prod-tanstack-start- 106 0 25
✅ e2e-vercel-prod-tanstack-start 105 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
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

📊 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 🥇 Nitro 0.029s (-31.6% 🟢) 1.005s (~) 0.976s 10 1.00x
💻 Local Express 0.030s (-32.7% 🟢) 1.006s (~) 0.976s 10 1.01x
🐘 Postgres Express 0.044s (-23.3% 🟢) 1.012s (~) 0.967s 10 1.51x
💻 Local Next.js (Turbopack) 0.048s 1.006s 0.958s 10 1.62x
🐘 Postgres Nitro 0.050s (-48.0% 🟢) 1.011s (-3.1%) 0.962s 10 1.68x
🐘 Postgres Next.js (Turbopack) 0.052s 1.011s 0.959s 10 1.75x
workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.071s (-4.8%) 2.007s (~) 0.936s 10 1.00x
💻 Local Nitro 1.073s (-5.2% 🟢) 2.006s (~) 0.934s 10 1.00x
🐘 Postgres Nitro 1.081s (-5.2% 🟢) 2.009s (~) 0.928s 10 1.01x
🐘 Postgres Express 1.084s (-5.4% 🟢) 2.009s (~) 0.925s 10 1.01x
🐘 Postgres Next.js (Turbopack) 1.109s 2.008s 0.899s 10 1.04x
💻 Local Next.js (Turbopack) 1.117s 2.006s 0.890s 10 1.04x
workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 10.391s (-5.1% 🟢) 11.022s (~) 0.631s 3 1.00x
🐘 Postgres Nitro 10.410s (-4.2%) 11.014s (~) 0.604s 3 1.00x
💻 Local Express 10.415s (-4.6%) 11.021s (~) 0.606s 3 1.00x
🐘 Postgres Express 10.423s (-4.9%) 11.014s (~) 0.591s 3 1.00x
🐘 Postgres Next.js (Turbopack) 10.577s 11.020s 0.443s 3 1.02x
💻 Local Next.js (Turbopack) 10.700s 11.020s 0.320s 3 1.03x
workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 13.444s (-10.7% 🟢) 14.026s (-12.5% 🟢) 0.582s 5 1.00x
🐘 Postgres Nitro 13.459s (-7.8% 🟢) 14.017s (-6.7% 🟢) 0.558s 5 1.00x
🐘 Postgres Express 13.487s (-7.5% 🟢) 14.018s (-6.7% 🟢) 0.531s 5 1.00x
💻 Local Express 13.488s (-9.9% 🟢) 14.026s (-6.7% 🟢) 0.538s 5 1.00x
🐘 Postgres Next.js (Turbopack) 13.811s 14.024s 0.213s 5 1.03x
💻 Local Next.js (Turbopack) 14.142s 15.029s 0.887s 4 1.05x
workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Nitro 11.786s (-29.8% 🟢) 12.023s (-29.4% 🟢) 0.237s 8 1.00x
💻 Local Express 11.908s (-28.3% 🟢) 12.148s (-28.7% 🟢) 0.240s 8 1.01x
🐘 Postgres Nitro 11.914s (-14.7% 🟢) 12.266s (-14.3% 🟢) 0.352s 8 1.01x
🐘 Postgres Express 11.936s (-14.8% 🟢) 12.140s (-16.8% 🟢) 0.203s 8 1.01x
🐘 Postgres Next.js (Turbopack) 12.591s 13.018s 0.427s 7 1.07x
💻 Local Next.js (Turbopack) 13.043s 13.741s 0.699s 7 1.11x
Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.136s (-9.9% 🟢) 2.007s (~) 0.871s 15 1.00x
🐘 Postgres Nitro 1.143s (-10.3% 🟢) 2.007s (~) 0.864s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.176s 2.007s 0.831s 15 1.04x
💻 Local Express 1.181s (-20.7% 🟢) 2.005s (~) 0.824s 15 1.04x
💻 Local Next.js (Turbopack) 1.256s 2.006s 0.750s 15 1.11x
💻 Local Nitro 1.564s (-4.1%) 2.392s (+15.3% 🔺) 0.828s 13 1.38x
Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.191s (-49.5% 🟢) 2.007s (-33.3% 🟢) 0.816s 15 1.00x
🐘 Postgres Nitro 1.203s (-48.8% 🟢) 2.007s (-33.3% 🟢) 0.804s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.299s 2.006s 0.708s 15 1.09x
💻 Local Nitro 1.696s (-46.1% 🟢) 2.005s (-48.4% 🟢) 0.309s 15 1.42x
💻 Local Express 1.717s (-41.9% 🟢) 2.005s (-41.9% 🟢) 0.288s 15 1.44x
💻 Local Next.js (Turbopack) 1.818s 2.073s 0.255s 15 1.53x
Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.278s (-63.3% 🟢) 2.008s (-49.9% 🟢) 0.729s 15 1.00x
🐘 Postgres Nitro 1.337s (-61.6% 🟢) 2.007s (-49.9% 🟢) 0.670s 15 1.05x
🐘 Postgres Next.js (Turbopack) 1.545s 2.007s 0.462s 15 1.21x
💻 Local Nitro 4.426s (-47.0% 🟢) 5.012s (-44.4% 🟢) 0.585s 7 3.46x
💻 Local Express 4.839s (-42.0% 🟢) 5.346s (-40.8% 🟢) 0.508s 6 3.79x
💻 Local Next.js (Turbopack) 5.430s 6.013s 0.584s 5 4.25x
Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.140s (-9.3% 🟢) 2.009s (~) 0.869s 15 1.00x
🐘 Postgres Nitro 1.147s (-8.7% 🟢) 2.009s (~) 0.862s 15 1.01x
🐘 Postgres Next.js (Turbopack) 1.172s 2.008s 0.836s 15 1.03x
💻 Local Express 1.327s (-29.9% 🟢) 2.006s (-15.1% 🟢) 0.679s 15 1.16x
💻 Local Next.js (Turbopack) 1.350s 2.006s 0.656s 15 1.18x
💻 Local Nitro 1.422s (-23.8% 🟢) 2.006s (-14.3% 🟢) 0.584s 15 1.25x
Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.172s (-50.0% 🟢) 2.008s (-33.3% 🟢) 0.836s 15 1.00x
🐘 Postgres Nitro 1.200s (-48.7% 🟢) 2.007s (-33.3% 🟢) 0.808s 15 1.02x
🐘 Postgres Next.js (Turbopack) 1.302s 2.008s 0.706s 15 1.11x
💻 Local Nitro 1.936s (-36.8% 🟢) 2.392s (-38.5% 🟢) 0.456s 13 1.65x
💻 Local Express 1.969s (-37.1% 🟢) 2.392s (-36.4% 🟢) 0.423s 13 1.68x
💻 Local Next.js (Turbopack) 2.029s 2.674s 0.645s 12 1.73x
Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.273s (-63.6% 🟢) 2.008s (-49.9% 🟢) 0.735s 15 1.00x
🐘 Postgres Nitro 1.313s (-62.3% 🟢) 2.008s (-49.9% 🟢) 0.695s 15 1.03x
🐘 Postgres Next.js (Turbopack) 1.503s 2.008s 0.505s 15 1.18x
💻 Local Nitro 5.003s (-45.3% 🟢) 5.513s (-45.0% 🟢) 0.510s 6 3.93x
💻 Local Express 5.451s (-38.1% 🟢) 6.215s (-33.0% 🟢) 0.764s 5 4.28x
💻 Local Next.js (Turbopack) 5.980s 6.414s 0.434s 5 4.70x
workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.434s (-48.2% 🟢) 1.006s (-1.7%) 0.571s 60 1.00x
🐘 Postgres Nitro 0.447s (-45.5% 🟢) 1.006s (~) 0.559s 60 1.03x
💻 Local Express 0.474s (-51.8% 🟢) 1.004s (-6.7% 🟢) 0.530s 60 1.09x
💻 Local Nitro 0.474s (-51.6% 🟢) 1.004s (-8.2% 🟢) 0.529s 60 1.09x
🐘 Postgres Next.js (Turbopack) 0.519s 1.006s 0.487s 60 1.19x
💻 Local Next.js (Turbopack) 0.716s 1.005s 0.289s 60 1.65x
workflow with 25 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.047s (-47.1% 🟢) 1.482s (-34.4% 🟢) 0.435s 61 1.00x
🐘 Postgres Nitro 1.057s (-45.1% 🟢) 1.614s (-23.2% 🟢) 0.556s 56 1.01x
💻 Local Express 1.168s (-61.3% 🟢) 2.006s (-44.1% 🟢) 0.838s 45 1.12x
💻 Local Nitro 1.179s (-61.2% 🟢) 2.005s (-46.6% 🟢) 0.826s 45 1.13x
🐘 Postgres Next.js (Turbopack) 1.261s 2.007s 0.745s 45 1.21x
💻 Local Next.js (Turbopack) 1.792s 2.006s 0.214s 45 1.71x
workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.972s (-50.6% 🟢) 2.266s (-48.1% 🟢) 0.294s 54 1.00x
🐘 Postgres Nitro 2.092s (-49.0% 🟢) 2.582s (-43.9% 🟢) 0.490s 47 1.06x
🐘 Postgres Next.js (Turbopack) 2.509s 3.008s 0.499s 40 1.27x
💻 Local Express 2.650s (-71.2% 🟢) 3.007s (-70.0% 🟢) 0.357s 40 1.34x
💻 Local Nitro 2.694s (-71.0% 🟢) 3.007s (-70.0% 🟢) 0.313s 40 1.37x
💻 Local Next.js (Turbopack) 3.826s 4.041s 0.216s 30 1.94x
workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.158s (-44.2% 🟢) 1.005s (~) 0.847s 60 1.00x
🐘 Postgres Nitro 0.186s (-34.4% 🟢) 1.006s (~) 0.820s 60 1.18x
🐘 Postgres Next.js (Turbopack) 0.204s 1.006s 0.802s 60 1.29x
💻 Local Nitro 0.372s (-38.5% 🟢) 1.004s (-1.7%) 0.632s 60 2.36x
💻 Local Express 0.382s (-31.9% 🟢) 1.004s (~) 0.623s 60 2.42x
💻 Local Next.js (Turbopack) 0.566s 1.022s 0.456s 59 3.59x
workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.276s (-45.8% 🟢) 1.006s (~) 0.729s 90 1.00x
🐘 Postgres Nitro 0.322s (-35.2% 🟢) 1.006s (~) 0.685s 90 1.16x
🐘 Postgres Next.js (Turbopack) 0.397s 1.006s 0.609s 90 1.44x
💻 Local Express 2.113s (-15.9% 🟢) 2.796s (-7.1% 🟢) 0.684s 33 7.64x
💻 Local Nitro 2.167s (-14.6% 🟢) 2.796s (-7.1% 🟢) 0.629s 33 7.84x
💻 Local Next.js (Turbopack) 2.265s 3.009s 0.744s 30 8.19x
workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.548s (-33.0% 🟢) 1.005s (-1.2%) 0.457s 120 1.00x
🐘 Postgres Nitro 0.628s (-20.5% 🟢) 1.006s (~) 0.378s 120 1.15x
🐘 Postgres Next.js (Turbopack) 0.803s 1.006s 0.202s 120 1.46x
💻 Local Express 9.804s (-12.4% 🟢) 10.443s (-12.5% 🟢) 0.640s 12 17.87x
💻 Local Nitro 9.909s (-11.5% 🟢) 10.448s (-10.4% 🟢) 0.539s 12 18.07x
💻 Local Next.js (Turbopack) 11.122s 11.849s 0.727s 11 20.28x
Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.128s (+450.1% 🔺) 2.002s (+100.4% 🔺) 0.001s (-25.0% 🟢) 2.010s (+98.7% 🔺) 0.882s 10 1.00x
💻 Local Express 1.131s (+467.9% 🔺) 2.005s (+99.6% 🔺) 0.012s (-3.3%) 2.019s (+98.3% 🔺) 0.889s 10 1.00x
💻 Local Nitro 1.132s (+429.7% 🔺) 2.004s (+99.5% 🔺) 0.011s (-14.4% 🟢) 2.017s (+98.0% 🔺) 0.885s 10 1.00x
🐘 Postgres Nitro 1.154s (+462.9% 🔺) 1.999s (+100.0% 🔺) 0.001s (-13.3% 🟢) 2.011s (+98.8% 🔺) 0.857s 10 1.02x
🐘 Postgres Next.js (Turbopack) 1.156s 2.001s 0.001s 2.009s 0.853s 10 1.02x
💻 Local Next.js (Turbopack) 1.176s 2.004s 0.013s 2.021s 0.845s 10 1.04x
stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.492s (+136.8% 🔺) 2.006s (+99.4% 🔺) 0.003s (-13.0% 🟢) 2.023s (+97.7% 🔺) 0.531s 30 1.00x
💻 Local Express 1.528s (+101.8% 🔺) 2.011s (+95.5% 🔺) 0.010s (+2.3%) 2.023s (+94.5% 🔺) 0.495s 30 1.02x
🐘 Postgres Nitro 1.542s (+147.1% 🔺) 2.007s (+99.3% 🔺) 0.004s (-8.1% 🟢) 2.027s (+98.3% 🔺) 0.485s 30 1.03x
🐘 Postgres Next.js (Turbopack) 1.637s 2.009s 0.004s 2.024s 0.387s 30 1.10x
💻 Local Next.js (Turbopack) 1.849s 2.010s 0.011s 2.191s 0.342s 30 1.24x
💻 Local Nitro 1.922s (+129.1% 🔺) 2.011s (+98.8% 🔺) 0.009s (-6.5% 🟢) 2.423s (+117.1% 🔺) 0.501s 25 1.29x
10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 0.618s (-35.7% 🟢) 1.052s (-17.7% 🟢) 0.000s (-19.3% 🟢) 1.059s (-18.9% 🟢) 0.442s 57 1.00x
🐘 Postgres Nitro 0.678s (-30.0% 🟢) 1.068s (-14.4% 🟢) 0.000s (+110.5% 🔺) 1.078s (-14.3% 🟢) 0.400s 57 1.10x
🐘 Postgres Next.js (Turbopack) 0.725s 1.035s 0.000s 1.045s 0.320s 58 1.17x
💻 Local Express 1.332s (+8.7% 🔺) 2.015s (~) 0.000s (-40.0% 🟢) 2.017s (~) 0.685s 30 2.16x
💻 Local Nitro 1.361s (+11.3% 🔺) 2.015s (~) 0.000s (+33.3% 🔺) 2.018s (~) 0.657s 30 2.20x
💻 Local Next.js (Turbopack) 1.459s 2.014s 0.000s 2.017s 0.557s 30 2.36x
fan-out fan-in 10 streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Express 1.213s (-31.5% 🟢) 1.848s (-15.2% 🟢) 0.000s (NaN%) 1.862s (-15.3% 🟢) 0.649s 33 1.00x
🐘 Postgres Nitro 1.375s (-23.3% 🟢) 2.066s (-3.5%) 0.000s (-3.4%) 2.079s (-4.4%) 0.705s 29 1.13x
🐘 Postgres Next.js (Turbopack) 1.392s 2.141s 0.000s 2.150s 0.758s 29 1.15x
💻 Local Next.js (Turbopack) 2.933s 3.418s 0.001s 3.422s 0.489s 18 2.42x
💻 Local Express 3.087s (-11.0% 🟢) 3.776s (-6.4% 🟢) 0.001s (-37.5% 🟢) 3.781s (-6.3% 🟢) 0.694s 16 2.54x
💻 Local Nitro 3.100s (-8.5% 🟢) 3.903s (-3.2%) 0.000s (-53.1% 🟢) 3.906s (-3.2%) 0.805s 16 2.56x

Summary

Fastest Framework by World

Winner determined by most benchmark wins

World 🥇 Fastest Framework Wins
💻 Local Express 11/21
🐘 Postgres Express 17/21
Fastest World by Framework

Winner determined by most benchmark wins

Framework 🥇 Fastest World Wins
Express 🐘 Postgres 17/21
Next.js (Turbopack) 🐘 Postgres 20/21
Nitro 🐘 Postgres 15/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


Some benchmark jobs failed:

  • Local: success
  • Postgres: success
  • Vercel: failure

Check the workflow run for details.

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>
…CBOR

The matching server-side change
(workflow-server PR #439, commit 5d79cf1) now returns:

  - GET single event: full event entity CBOR-encoded in the body
    (resolves refs server-side and bakes payload into eventData).
  - LIST events: each frame's meta is the full event entity (CBOR),
    payload stays as a RefDescriptor in eventData[field], resolved
    bytes ride in the frame body.

This commit threads that through the world-vercel adapter:

  - events-v4.ts:
    * getEventV4 returns DecodedV4Event (CBOR-decode of response
      body). Drop the parseEventMetaFromHeaders / readHeader-driven
      reconstruction.
    * ListedEventV4 carries `{ event: DecodedV4Event, body:
      Uint8Array }` — the full entity plus the resolved payload bytes
      to splice in.

  - events.ts:
    * buildEventFromV4 takes (decoded entity, payload bytes) and
      splices the bytes into eventData[payloadField] for the LIST
      path. For the GET path the server already baked the bytes in,
      so buildEventFromV4 is called with an empty body.
    * CBOR-decode the payload bytes back into the original JS value
      on read. Matches the unconditional CBOR-encode on write
      (Uint8Array round-trips via cbor-x's binary type).

Why this matters: the workflow runtime's replay path reads arbitrary
fields off eventData (executionContext, hookToken, isWebhook,
resumeAt, error shape, …). The previous cherry-picked-metadata shape
dropped those fields, which is why every E2E Vercel Prod test on
this branch was getting stuck after run_started with no further
events — the runtime's invocation of the workflow function on the
workbench deployment couldn't reconstruct state correctly.

Companion to workflow-server PR #439 commit 5d79cf1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread .changeset/v4-events-client.md Outdated
@@ -0,0 +1,5 @@
---
"@workflow/world-vercel": major
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.

Suggested change
"@workflow/world-vercel": major
"@workflow/world-vercel": minor

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.

Applied — both the bump type and the description are updated in 3d03e5d (changeset edit in this push).

Comment thread .changeset/v4-events-client.md Outdated
"@workflow/world-vercel": major
---

Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged; the underlying wire calls swap to v4. `listEventsByCorrelationId` is not yet implemented on v4 and now throws — callers should fetch hooks directly via `storage.hooks.getByToken`. Requires workflow-server with v4 routes mounted.
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.

Suggested change
Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged; the underlying wire calls swap to v4. `listEventsByCorrelationId` is not yet implemented on v4 and now throws — callers should fetch hooks directly via `storage.hooks.getByToken`. Requires workflow-server with v4 routes mounted.
New internal API format: separately encode event metadata from user payloads. Eliminates the need for calling separate endpoints for ref resolution, which improves performance on longer runs.

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.

Applied — both the bump type and the description are updated in 3d03e5d (changeset edit in this push).

VaguelySerious and others added 3 commits May 25, 2026 10:49
Companion to workflow-server PR #439 commit 2a55acd, which switched
GET /api/v4/runs/:runId/events/:eventId from a CBOR-entity body to a
single v4 frame (same wire shape as one LIST frame).

  - getEventV4 now returns `{ event: DecodedV4Event, body: Uint8Array }`
    by reading exactly one frame off the response body via
    `decodeFrames` — same reader the LIST path uses. No content-type
    branching, no separate CBOR decode path.

  - getEvent (the storage adapter wrapper) passes both pieces to
    `buildEventFromV4`, which splices the CBOR-decoded body into
    `eventData[payloadField]`. Same path LIST already uses, so no
    GET-specific shape exists anymore.

  - Drop the special-case fallback in `buildEventFromV4` that used to
    re-decode an in-eventData Uint8Array — only one input shape now.

Net effect: server-side memory for GET single-event is now bounded by
S3 chunk size (~64 KB) instead of full payload size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to workflow-server PR #439 commit 5036b56, which added
`GET /api/v4/events?correlationId=...`. The SDK adapter's
`storage.events.listByCorrelationId` no longer throws.

Implementation:

  - New `getEventsByCorrelationIdV4` wire helper alongside
    `getWorkflowRunEventsV4`. Both share a small
    `consumeListFrameStream(url, config, opName)` that drives the
    same frame-stream-to-page conversion — only the URL differs.

  - `events.ts`'s `getWorkflowRunEvents` dispatches between the two
    based on whether params carry `runId` or `correlationId`. Same
    return shape on either path.

Changeset updated to drop the "not yet implemented" caveat.

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

Mirrors the server-side change in workflow-server: the POST request body
is now one length-prefixed frame containing CBOR-encoded metadata plus
the opaque payload. Eliminates the V4_HEADERS constant, the
percent-encoding for non-ASCII values, the base64-CBOR encoding for
executionContext, and the implicit 32 KB cap on Vercel header size.
The response side still uses x-wf-event-id/run-id/created-at headers
for callers that want eventId without decoding the CBOR body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread .changeset/v4-events-client.md Outdated
"@workflow/world-vercel": major
---

Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. GET single event and LIST events use the same `application/vnd.workflow.v4-frames` binary frame stream; `listEventsByCorrelationId` is wired through too. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged. Requires workflow-server with v4 routes mounted.
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.

Suggested change
Switch the world-vercel adapter's event endpoints from the v2/v3 wire format to v4. Event metadata now rides in `x-wf-*` HTTP headers and payloads stream end-to-end as opaque bytes — no server-side CBOR parse on writes, and no per-event `/refs` round-trip on list responses. POST event response carries the materialized EventResult as a CBOR body. GET single event and LIST events use the same `application/vnd.workflow.v4-frames` binary frame stream; `listEventsByCorrelationId` is wired through too. Public `createWorkflowRunEvent` / `getEvent` / `getWorkflowRunEvents` signatures are unchanged. Requires workflow-server with v4 routes mounted.
New internal API format: separately encode event metadata from user payloads. Eliminates the need for calling separate endpoints for ref resolution, which improves performance especially on longer runs.

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.

Applied — both the bump type and the description are updated in 3d03e5d (changeset edit in this push).

VaguelySerious and others added 18 commits May 25, 2026 12:23
Payload fields (input / output / result / error / payload / metadata)
reach world-vercel after the runtime has already serialized them via
dehydrateRunError / dehydrateStepReturnValue / dehydrateStepArguments —
they're Uint8Arrays carrying a devalue blob with a format prefix.
splitEventDataForV4 was running them through cbor-x.encode again, so
the wire bytes ended up as cbor(Uint8Array). On reads through
runs.get (which goes through v2 and just returns the raw stored bytes),
the consumer saw the CBOR wrapping and hydrateRunError couldn't parse
the format prefix — every failed workflow run surfaced as
"Failed to hydrate workflow run error".

Pass the bytes through unchanged on write and read; symmetric with
world-local and the v2/v3 wire format. Throw on non-Uint8Array to flag
non-runtime callers loudly instead of silently double-wrapping.

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

Hook-emitting runtimes set eventData.token (matches the world contract
in packages/world/src/events.ts). splitEventDataForV4 was looking for
eventData.hookToken instead, so the frame meta arrived without a token
and the server's hook materialization failed validation. The v4 wire
name (meta.hookToken) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cbor-x encodes Date natively (CBOR tag 1) and the server (post-23c79b9)
decodes it back to a Date for the materialization service. Stop
pre-flattening to an ISO string — that was a workaround for the original
header-based v4 contract and now leaves the runtime with a string in
eventData.resumeAt after replay, blowing up on .getTime().

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

DynamoDB's eventData column stringifies Date values on write — even when
the v4 frame meta originally carried them as native Dates — so the v4
GET/LIST response delivers eventData.resumeAt back as an ISO string.
The runtime expects a Date (wait_created.resumeAt → .getTime() in the
replay loop) and crashed every sleeping workflow with
"resumeAt.getTime is not a function".

Run the assembled event through EventSchema.safeParse, which applies
the per-event-type z.coerce.date() that mirror the v2/v3 read path
(see packages/world-vercel/src/events.ts on main). safeParse so a
mid-rollout event with an unknown shape still passes through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Tests run (26447355995) on commit e278374 got stuck in a
"queued" state for 20+ minutes — likely related to the same GitHub
account/token suspension that blocked the vercel/wait-for-deployment
step earlier today. `gh run rerun --failed` and even
`POST /actions/runs/.../cancel` were rejected. Force a fresh push so a
new workflow run is created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous CI retrigger commits e699861 and the subsequent main
merge 512ce2a didn't spawn workflow runs because GitHub Actions was
degraded throughout that window (incident gnftqj9htp0g, mitigated
2026-05-26T13:01Z). Push an empty commit now that the platform is
healthy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The E2E suite is failing with "Failed to hydrate step error: Invalid
format prefix" on every step_failed / run_failed replay even after the
CBOR-double-wrap fix. The bytes arriving at hydrateStepError have a
prefix that doesn't match /^[a-z0-9]{4}$/, which means they were never
the devalue blob dehydrateStepError emitted — something on the read
path is reshaping them.

Add a temporary console.warn in buildEventFromV4 that dumps the first
16 bytes (hex) and the failed prefix string when the body bytes for a
step_failed / run_failed event don't look like a valid format prefix.
The output lands in the workbench Vercel logs alongside the workflow
replay. Revert once we identify the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous diagnostic that only logged bad-prefix cases never fired in
workbench Vercel logs. Either the bytes are already valid here (and
the failure is later in the pipeline) or buildEventFromV4 isn't being
hit at all for the failing replays. Log every step_failed / run_failed
event so we can tell which scenario we're in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause for the step-error hydration failure was found server-side
(workflow-server@899193a — handleStepFailed / handleStepRetrying now
recognize pre-built RemoteRef instances and pass them through). Drop
the temporary console.warn that was logging every step_failed /
run_failed event's payload prefix.

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

Matches the server-side change in workflow-server@961988e. When
run_created POST fails (e.g. simulated 500 in the resilient-start
e2e), the runtime re-issues run_started with the run-input bag
attached to eventData (input + deploymentId + workflowName +
executionContext). Without listing run_started here we'd silently
drop the input bytes during splitEventDataForV4, leaving the server's
"run_started arrived before run_created" fallback with no input to
backfill from.

For a normal run_started call (no eventData) the new entry is a no-op —
the eventData lookup misses and no payload is built.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createWorkflowRunEventV4 / getEventV4 / getWorkflowRunEventsV4 were
throwing plain \`Error\` on non-2xx — meaning runtime callers that
specifically branch on EntityConflictError / RunExpiredError /
ThrottleError saw an opaque error and treated it as fatal. The
runClassSerializationWorkflow e2e regressed once run_started carried
input on the resilient-start path: queue + run_started races against
run_created sometimes win, and the parallel run_created POST in
start() then 409s. start() catches EntityConflictError on that path
and continues — but only if the error has the right type.

Mirror the v3 makeRequest mapping (utils.ts): 409 → EntityConflictError,
410 → RunExpiredError, 425 → TooEarlyError, 429 → ThrottleError,
everything else → WorkflowWorldError with the original status.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-add the SDK-side run_started: 'input' entry to PAYLOAD_FIELD_BY_EVENT_TYPE
(server already has it from 961988e). Previous attempt regressed
~6 unrelated workflows with "Invalid input" / devalue parse errors,
but I never identified the root cause — too generic of a message.

Add a temporary console.warn in workflow.ts that dumps workflowRun.input
shape + first 16 bytes right before hydrateWorkflowArguments, so the
workbench Vercel logs surface exactly which bytes are arriving when
the hydration fails. Wraps the hydrate in try/catch to also log the
exception path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…9/429/5xx

start.ts catches EntityConflictError on the run_created POST so a
resilient-start race against start.ts's parallel POST can ride through
cleanly. With plain Error, the 409 was treated as fatal and any test
that hit the race (e.g. outputStreamWorkflow, thisSerializationWorkflow,
wellKnownAgentWorkflow) failed with "Workflow run already exists".

Only convert the codes start.ts / runtime actually branch on
(409, 429, 5xx). Keep other statuses as plain Error to avoid widening
behavior changes at other catch sites — the previous broader mapping
attempt (7ba5a10) coincided with unrelated workflow regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keep the typed-errors mapping from createWorkflowRunEventV4 — start.ts
catches EntityConflictError correctly so the 409 race against parallel
run_created POSTs rides through. Drop the run_started:'input' SDK entry
and the workflow.ts hydration diagnostic.

The "resilient start: addTenWorkflow completes when run_created returns
500" test remains broken without the input piggyback. Fixing it cleanly
without regressing other workflows needs a deeper investigation than
the diagnostic logging surfaced — the runs that fail with "Invalid
input" never reached hydrateWorkflowArguments, so the bad deserialize
is somewhere later in the workflow VM (likely hydrateStepReturnValue
on a step that wraps a child workflow's return value).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `run_started: 'input'` back to PAYLOAD_FIELD_BY_EVENT_TYPE so the
SDK ships the input bytes on the wire when the queue worker re-tries
run creation via the run_started fallback path.

The prior attempt to enable this regressed the resilient-start test
itself with a generic "Invalid input" error inside the workflow VM.
Root cause was a server-side cache bug (workflow-server baa1a35):
RefTracker.create was caching the ref instance rather than the raw
bytes, so resolveRefs returned a RemoteRef descriptor where the runtime
expected a Uint8Array, and hydrateWorkflowArguments fell through to the
legacy devalue path which fails on non-Uint8Array input. Fixed on the
server; this commit re-engages the client side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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