Skip to content

Serialize run_failed/step_failed errors through serialization pipeline#1851

Open
TooTallNate wants to merge 15 commits intomainfrom
nrajlich/step-error-serialization
Open

Serialize run_failed/step_failed errors through serialization pipeline#1851
TooTallNate wants to merge 15 commits intomainfrom
nrajlich/step-error-serialization

Conversation

@TooTallNate
Copy link
Copy Markdown
Member

@TooTallNate TooTallNate commented Apr 24, 2026

Summary

Switch run_failed, step_failed, and step_retrying events to persist the full thrown value through the workflow serialization pipeline (as SerializedData / Uint8Array) instead of a lossy { message, stack, code } StructuredError shape. Consumers hydrate via hydrateRunError / hydrateStepError to reconstruct the original thrown value — preserving Error subclass identity, cause chains, and custom properties (e.g. FatalError.fatal, RetryableError.retryAfter).

Breaking changes

  • WorkflowRun.error and Step.error are now SerializedData (Uint8Array) instead of { message, stack?, code? }. Consumers must hydrate via hydrateRunError / hydrateStepError.
  • WorkflowRun gains a top-level errorCode field carrying the previous error.code value as plaintext metadata.
  • WorkflowRunFailedError.cause is now unknown (the hydrated thrown value with its original type identity preserved) instead of a synthesized Error. A new errorCode property exposes the error classification.
  • Event payload for step_failed, step_retrying, and run_failed now contains error: SerializedData.
  • Adds @workflow/world-postgres migration 0010_add_error_code.sql (new error_code column on workflow.workflow_runs).
  • Legacy pre-pipeline records in the deprecated error text column are surfaced as undefined on read (they cannot be hydrated into the original thrown value).

Stack

Notes

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 24, 2026

🦋 Changeset detected

Latest commit: 4963546

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

This PR includes changesets to release 22 packages
Name Type
@workflow/core Major
@workflow/errors Major
@workflow/world Major
@workflow/world-local Major
@workflow/world-postgres Major
@workflow/world-vercel Major
@workflow/builders Patch
@workflow/cli Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/vitest Patch
@workflow/web-shared Patch
@workflow/web Patch
workflow Major
@workflow/world-testing Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch
@workflow/nuxt Patch
@workflow/ai Major

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 Apr 24, 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 1, 2026 11:40pm
example-nextjs-workflow-webpack Ready Ready Preview, Comment May 1, 2026 11:40pm
example-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-astro-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-express-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-fastify-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-hono-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-nitro-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-nuxt-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-sveltekit-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workbench-vite-workflow Ready Ready Preview, Comment May 1, 2026 11:40pm
workflow-docs Ready Ready Preview, Comment, Open in v0 May 1, 2026 11:40pm
workflow-swc-playground Ready Ready Preview, Comment May 1, 2026 11:40pm
workflow-web Ready Ready Preview, Comment May 1, 2026 11:40pm

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 24, 2026

🧪 E2E Test Results

Some tests failed

Summary

Passed Failed Skipped Total
❌ ▲ Vercel Production 1038 6 67 1111
✅ 💻 Local Development 1126 0 86 1212
✅ 📦 Local Production 1126 0 86 1212
✅ 🐘 Local Postgres 1126 0 86 1212
✅ 🪟 Windows 101 0 0 101
✅ 📋 Other 285 0 18 303
Total 4802 6 343 5151

❌ Failed Tests

▲ Vercel Production (6 failed)

example (1 failed):

express (1 failed):

fastify (1 failed):

hono (1 failed):

nextjs-webpack (1 failed):

nuxt (1 failed):

Details by Category

❌ ▲ Vercel Production
App Passed Failed Skipped
✅ astro 94 0 7
❌ example 93 1 7
❌ express 93 1 7
❌ fastify 93 1 7
❌ hono 93 1 7
✅ nextjs-turbopack 99 0 2
❌ nextjs-webpack 98 1 2
✅ nitro 94 0 7
❌ nuxt 93 1 7
✅ sveltekit 94 0 7
✅ vite 94 0 7
✅ 💻 Local Development
App Passed Failed Skipped
✅ astro-stable 95 0 6
✅ express-stable 95 0 6
✅ fastify-stable 95 0 6
✅ hono-stable 95 0 6
✅ nextjs-turbopack-canary 82 0 19
✅ nextjs-turbopack-stable 101 0 0
✅ nextjs-webpack-canary 82 0 19
✅ nextjs-webpack-stable 101 0 0
✅ nitro-stable 95 0 6
✅ nuxt-stable 95 0 6
✅ sveltekit-stable 95 0 6
✅ vite-stable 95 0 6
✅ 📦 Local Production
App Passed Failed Skipped
✅ astro-stable 95 0 6
✅ express-stable 95 0 6
✅ fastify-stable 95 0 6
✅ hono-stable 95 0 6
✅ nextjs-turbopack-canary 82 0 19
✅ nextjs-turbopack-stable 101 0 0
✅ nextjs-webpack-canary 82 0 19
✅ nextjs-webpack-stable 101 0 0
✅ nitro-stable 95 0 6
✅ nuxt-stable 95 0 6
✅ sveltekit-stable 95 0 6
✅ vite-stable 95 0 6
✅ 🐘 Local Postgres
App Passed Failed Skipped
✅ astro-stable 95 0 6
✅ express-stable 95 0 6
✅ fastify-stable 95 0 6
✅ hono-stable 95 0 6
✅ nextjs-turbopack-canary 82 0 19
✅ nextjs-turbopack-stable 101 0 0
✅ nextjs-webpack-canary 82 0 19
✅ nextjs-webpack-stable 101 0 0
✅ nitro-stable 95 0 6
✅ nuxt-stable 95 0 6
✅ sveltekit-stable 95 0 6
✅ vite-stable 95 0 6
✅ 🪟 Windows
App Passed Failed Skipped
✅ nextjs-turbopack 101 0 0
✅ 📋 Other
App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 95 0 6
✅ e2e-local-postgres-nest-stable 95 0 6
✅ e2e-local-prod-nest-stable 95 0 6

📋 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.

Copy link
Copy Markdown
Contributor

@vercel vercel Bot left a comment

Choose a reason for hiding this comment

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

Additional Suggestion:

TypeScript build fails because WorkflowRunFailedError.cause was changed to unknown but consumer code accesses .message, .stack, and .code on it without type narrowing.

Fix on Vercel

Switch run_failed, step_failed, and step_retrying events to persist
the full thrown value via the workflow serialization pipeline (as
SerializedData / Uint8Array) instead of a lossy { message, stack, code }
StructuredError shape. Consumers hydrate via hydrateRunError /
hydrateStepError to reconstruct the original thrown value, preserving
Error subclass identity, cause chains, and custom properties.

- WorkflowRun.error and Step.error are now SerializedData
- WorkflowRun gains a top-level errorCode plaintext field
- WorkflowRunFailedError.cause is now the hydrated thrown value
- Adds world-postgres migration 0010_add_error_code.sql
- Legacy pre-pipeline errorJson records surface as undefined on read
cause is now `unknown` (the hydrated thrown value) rather than
`Error & { code }`. Defensively extract Error-shaped fields when the
hydrated value is an Error, otherwise round-trip the raw value, and
expose the new `errorCode` classification field.
The hydrated `cause` is now `unknown` (the original thrown value
through the serialization pipeline) and the error classification has
moved to the top-level `errorCode` property. Update the two affected
docs pages and the `TSDoc` interface to reflect the new shape, and
narrow `cause` with `instanceof Error` before accessing fields.
Unit tests:
- 19 new dehydrate/hydrate{Step,Run}Error round-trip tests covering
  FatalError, plain Error, built-in Error subclasses, non-Error thrown
  values (string, plain object), cause chains, encryption round-trip,
  the binary format prefix contract, and the unserializable / unknown-
  format error paths.
- 5 new tests for Run.returnValue when the run is failed: hydrated
  FatalError + cause as cause, plain Error preservation, non-Error
  thrown values surfaced verbatim, cross-class cause chains, and the
  hydration-failure fallback that still surfaces errorCode.

E2E tests (new, in 99_e2e.ts + e2e.test.ts):
- Step throw → workflow catch round-trips a FatalError with a TypeError
  cause chain, asserting class identity, fatal marker, and cause name +
  message all survive the step_failed event pipeline.
- Workflow throw → run_failed reaches  status with the new
  top-level errorCode metadata exposed (cause-shape coverage lives at
  the unit level, since the SWC plugin's class registration is not
  invoked in the plain-Node e2e runner).
- Workflow throw of a non-Error value round-trips that value verbatim
  as WorkflowRunFailedError.cause.

Adjustments to existing assertions:
- error.cause is now ; tests narrow with
  and use the new top-level  field instead of .
- step.error / run.error from CLI --withData are now hydrated payloads:
  unregistered class instances surface as Instance refs whose
  carries the original message + stack.

Observability hydration:
- hydrateStepIO / hydrateWorkflowIO in serialization-format.ts now
  hydrate the  field via hydrateData, so the CLI and web UI
  continue to surface readable run/step error messages and stacks.
When a workflow runs in a Node `vm` context, its bundled
`@workflow/errors` is a different module instance than the host's
import (separate prototype chains, separate class identity). Calling
`new FatalError(...)` from the host-side reviver produces a
host-realm instance that fails `err instanceof FatalError` checks
in the workflow code — even when the serialized payload was correctly
tagged via the dedicated `FatalError` reducer.

Surfaced by the local-prod e2e "step throw round-trips FatalError"
test on Next.js Turbopack: each route gets its own bundled chunk, so
the flow handler's `@workflow/errors` and the workflow VM bundle's
`@workflow/errors` are two distinct copies of the same module.

Fix:

- Each bundled copy of `@workflow/errors` self-registers its
  `FatalError` and `RetryableError` classes on `globalThis` via
  `Symbol.for("@workflow/errors//FatalError")` /
  `Symbol.for("@workflow/errors//RetryableError")`. First load wins
  per realm; the descriptor is non-writable / non-configurable to make
  accidental clobbering loud.

- The revivers in `@workflow/core`'s common reducers module read the
  consumer's `globalThis` (passed in as `global`) to pick up the
  realm-local class, falling back to the host-imported class when no
  registration is present (e.g. in the CLI / test runner).
The runtime's run-failure path computes a source-map-remapped stack
and then assigns it back onto the thrown value via `if (err
instanceof Error) err.stack = errorStack`. Workflows run inside a
Node `vm` context, so a workflow-thrown error is an instance of the
VM realm's `Error` — `instanceof` against the host realm's
`Error` returns `false`, the assignment is skipped, and the
serialized `run_failed` event carries the un-remapped (bundled-line-
number) stack instead of the source-mapped one.

Switch the gate to `types.isNativeError`, which uses V8's internal
type tag and works across realms — same approach already in place
for the serialization reducers.

Caught by the local-prod e2e "nested function calls preserve message
and stack trace" and "cross-file imports preserve message and stack
trace" tests, which assert that the persisted run-error stack
contains `99_e2e.ts` / `helpers.ts`.
Two issues with the CLI's hand-rolled reviver list:

1. It hadn't been updated for the new first-class Error subclass
   reducers (`TypeError`, `RangeError`, `FatalError`, `RetryableError`,
   etc.). devalue throws "Unknown type X" when it encounters a
   reduced value with no matching reviver, and `hydrateResourceIO`
   swallows that error and surfaces the raw `Uint8Array` payload —
   so `step.error` / `run.error` showed up as raw byte dumps in
   `workflow inspect` output.

2. Even with all the right revivers, `Error.prototype`'s `message`
   / `stack` / `cause` are non-enumerable, so `JSON.stringify`
   (used by `workflow inspect --json`) drops them — leaving the
   subclass-specific enumerable fields (e.g. `FatalError.fatal`)
   visible but the actual error data missing.

Fix:

- Build the CLI reviver set on top of `getCommonRevivers()` from
  `@workflow/core` so the CLI stays in sync with the runtime's
  reducer set automatically. New core reducers/revivers will Just
  Work without any CLI-side change.

- Wrap each Error reviver from the common set with a thin shim that
  attaches a non-enumerable `toJSON` method to the produced
  `Error` instance. `JSON.stringify` calls `toJSON` and gets a
  full object (`name` + `message` + `stack` + `cause` + any
  enumerable subclass fields like `fatal` / `retryAfter` /
  `errors`); `util.inspect` ignores `toJSON` and renders the
  canonical `Error: msg\\n at ...` format. Best of both worlds for
  CLI output without compromising the runtime hydration path.

Caught by the local-prod e2e "basic step error preserves" and
"cross-file step error preserves" tests, which read
`failedStep.error.message` / `.stack` from the CLI's JSON output.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 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.042s (-2.1%) 1.006s (~) 0.964s 10 1.00x
💻 Local Next.js (Turbopack) 0.048s 1.005s 0.957s 10 1.13x
💻 Local Express 0.049s (+11.1% 🔺) 1.004s (~) 0.955s 10 1.17x
🐘 Postgres Next.js (Turbopack) 0.056s 1.010s 0.954s 10 1.32x
🐘 Postgres Nitro 0.064s (-32.5% 🟢) 1.011s (-3.1%) 0.947s 10 1.52x
🐘 Postgres Express 0.071s (+22.9% 🔺) 1.013s (~) 0.942s 10 1.69x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 0.221s (-46.1% 🟢) 2.063s (-17.8% 🟢) 1.842s 10 1.00x
▲ Vercel Next.js (Turbopack) 0.282s (+12.2% 🔺) 2.039s (-12.6% 🟢) 1.757s 10 1.28x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 1 step

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 1.103s (-2.0%) 2.005s (~) 0.902s 10 1.00x
💻 Local Next.js (Turbopack) 1.110s 2.005s 0.895s 10 1.01x
💻 Local Nitro 1.126s (~) 2.007s (~) 0.881s 10 1.02x
🐘 Postgres Next.js (Turbopack) 1.138s 2.009s 0.871s 10 1.03x
🐘 Postgres Nitro 1.143s (~) 2.010s (~) 0.866s 10 1.04x
🐘 Postgres Express 1.180s (+2.9%) 2.012s (~) 0.832s 10 1.07x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 1.955s (-3.9%) 3.896s (+1.7%) 1.941s 10 1.00x
▲ Vercel Nitro 2.000s (-48.6% 🟢) 3.435s (-41.9% 🟢) 1.435s 10 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 10.760s (-1.5%) 11.021s (~) 0.261s 3 1.00x
💻 Local Next.js (Turbopack) 10.782s 11.021s 0.239s 3 1.00x
🐘 Postgres Next.js (Turbopack) 10.864s 11.020s 0.156s 3 1.01x
💻 Local Nitro 10.928s (~) 11.024s (~) 0.096s 3 1.02x
🐘 Postgres Nitro 10.968s (+0.9%) 11.352s (+2.9%) 0.383s 3 1.02x
🐘 Postgres Express 11.132s (+1.5%) 12.030s (+9.1% 🔺) 0.899s 3 1.03x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 16.804s (-29.2% 🟢) 18.689s (-25.6% 🟢) 1.885s 2 1.00x
▲ Vercel Next.js (Turbopack) 17.153s (-1.0%) 18.590s (-4.2%) 1.437s 2 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 14.438s 15.024s 0.587s 4 1.00x
💻 Local Express 14.481s (-3.3%) 15.028s (~) 0.547s 4 1.00x
💻 Local Next.js (Turbopack) 14.566s 15.028s 0.462s 4 1.01x
🐘 Postgres Nitro 14.584s (~) 15.024s (~) 0.440s 4 1.01x
🐘 Postgres Express 14.909s (+2.2%) 15.032s (~) 0.122s 4 1.03x
💻 Local Nitro 15.003s (~) 15.280s (-4.7%) 0.277s 4 1.04x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 31.140s (-40.8% 🟢) 32.705s (-40.1% 🟢) 1.565s 2 1.00x
▲ Vercel Nitro 31.285s (-51.5% 🟢) 33.186s (-50.2% 🟢) 1.901s 2 1.00x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 50 sequential steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 13.784s 14.021s 0.238s 7 1.00x
🐘 Postgres Nitro 14.017s (~) 14.594s (+2.0%) 0.578s 7 1.02x
🐘 Postgres Express 14.864s (+6.1% 🔺) 15.193s (+4.1%) 0.329s 6 1.08x
💻 Local Express 15.151s (-8.7% 🟢) 15.859s (-6.9% 🟢) 0.709s 6 1.10x
💻 Local Next.js (Turbopack) 15.764s 16.029s 0.266s 6 1.14x
💻 Local Nitro 16.793s (~) 17.030s (~) 0.238s 6 1.22x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 68.242s (-83.9% 🟢) 69.420s (-83.6% 🟢) 1.178s 2 1.00x
▲ Vercel Next.js (Turbopack) 70.638s (-82.1% 🟢) 72.322s (-81.7% 🟢) 1.684s 2 1.04x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.232s 2.010s 0.778s 15 1.00x
🐘 Postgres Nitro 1.269s (~) 2.010s (~) 0.741s 15 1.03x
🐘 Postgres Express 1.308s (+3.8%) 2.013s (~) 0.705s 15 1.06x
💻 Local Express 1.395s (-6.3% 🟢) 2.005s (~) 0.610s 15 1.13x
💻 Local Nitro 1.523s (-6.7% 🟢) 2.006s (-3.3%) 0.484s 15 1.24x
💻 Local Next.js (Turbopack) 1.538s 2.005s 0.467s 15 1.25x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 2.453s (-27.8% 🟢) 3.892s (-21.1% 🟢) 1.439s 8 1.00x
▲ Vercel Nitro 2.536s (-10.0% 🟢) 4.027s (-6.8% 🟢) 1.491s 8 1.03x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.all with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.384s (+1.4%) 3.012s (~) 0.628s 10 1.00x
🐘 Postgres Next.js (Turbopack) 2.405s 3.009s 0.604s 10 1.01x
🐘 Postgres Express 2.420s (+2.5%) 3.013s (~) 0.593s 10 1.02x
💻 Local Express 2.577s (-12.7% 🟢) 3.107s (-10.0% 🟢) 0.531s 10 1.08x
💻 Local Next.js (Turbopack) 2.685s 3.108s 0.423s 10 1.13x
💻 Local Nitro 2.984s (-5.1% 🟢) 3.454s (-11.1% 🟢) 0.470s 9 1.25x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 40.838s (+907.8% 🔺) 42.369s (+615.6% 🔺) 1.531s 8 1.00x
▲ Vercel Next.js (Turbopack) 54.514s (+667.8% 🔺) 56.109s (+530.1% 🔺) 1.596s 6 1.33x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.all with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.494s (~) 4.012s (~) 0.518s 8 1.00x
🐘 Postgres Express 3.582s (+2.7%) 4.016s (~) 0.434s 8 1.03x
🐘 Postgres Next.js (Turbopack) 3.648s 4.011s 0.363s 8 1.04x
💻 Local Express 6.023s (-27.8% 🟢) 6.818s (-24.5% 🟢) 0.795s 5 1.72x
💻 Local Next.js (Turbopack) 7.120s 8.019s 0.898s 4 2.04x
💻 Local Nitro 8.483s (+1.6%) 9.020s (~) 0.538s 4 2.43x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.871s (-18.5% 🟢) 4.843s (-12.5% 🟢) 1.971s 7 1.00x
▲ Vercel Next.js (Turbopack) 4.334s (-51.4% 🟢) 6.104s (-44.3% 🟢) 1.770s 5 1.51x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 10 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 1.221s 2.009s 0.788s 15 1.00x
🐘 Postgres Nitro 1.261s (~) 2.009s (~) 0.748s 15 1.03x
🐘 Postgres Express 1.307s (+4.0%) 2.010s (~) 0.703s 15 1.07x
💻 Local Express 1.400s (-26.1% 🟢) 2.006s (-15.2% 🟢) 0.606s 15 1.15x
💻 Local Next.js (Turbopack) 1.495s 2.005s 0.510s 15 1.22x
💻 Local Nitro 1.544s (-17.2% 🟢) 2.006s (-14.3% 🟢) 0.462s 15 1.26x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 1.980s (-32.4% 🟢) 3.678s (-20.8% 🟢) 1.697s 9 1.00x
▲ Vercel Nitro 2.015s (-18.1% 🟢) 3.465s (-16.9% 🟢) 1.450s 9 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Promise.race with 25 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 2.338s (~) 3.009s (~) 0.671s 10 1.00x
🐘 Postgres Express 2.403s (+2.6%) 3.013s (~) 0.609s 10 1.03x
🐘 Postgres Next.js (Turbopack) 2.428s 3.008s 0.580s 10 1.04x
💻 Local Express 2.551s (-18.6% 🟢) 3.007s (-20.1% 🟢) 0.456s 10 1.09x
💻 Local Next.js (Turbopack) 2.746s 3.108s 0.362s 10 1.17x
💻 Local Nitro 3.032s (-1.1%) 3.885s (~) 0.853s 8 1.30x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 2.318s (-28.3% 🟢) 3.877s (-23.6% 🟢) 1.559s 8 1.00x
▲ Vercel Next.js (Turbopack) 2.382s (-24.2% 🟢) 3.927s (-13.2% 🟢) 1.544s 8 1.03x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

Promise.race with 50 concurrent steps

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Nitro 3.482s (~) 4.011s (~) 0.529s 8 1.00x
🐘 Postgres Express 3.575s (+2.2%) 4.015s (~) 0.440s 8 1.03x
🐘 Postgres Next.js (Turbopack) 3.651s 4.011s 0.359s 8 1.05x
💻 Local Express 6.493s (-26.2% 🟢) 7.013s (-24.4% 🟢) 0.520s 5 1.86x
💻 Local Next.js (Turbopack) 8.048s 8.774s 0.726s 4 2.31x
💻 Local Nitro 8.458s (-7.5% 🟢) 9.023s (-10.0% 🟢) 0.565s 4 2.43x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 3.564s (-47.3% 🟢) 5.556s (-35.0% 🟢) 1.992s 6 1.00x
▲ Vercel Nitro 3.822s (-25.0% 🟢) 5.590s (-18.0% 🟢) 1.768s 6 1.07x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.781s 1.006s 0.225s 60 1.00x
🐘 Postgres Nitro 0.830s (+1.2%) 1.007s (~) 0.177s 60 1.06x
💻 Local Next.js (Turbopack) 0.843s 1.021s 0.179s 59 1.08x
💻 Local Express 0.910s (-7.5% 🟢) 1.181s (+9.7% 🔺) 0.271s 51 1.16x
🐘 Postgres Express 0.966s (+15.2% 🔺) 1.233s (+20.5% 🔺) 0.267s 49 1.24x
💻 Local Nitro 0.997s (+1.6%) 1.309s (+19.7% 🔺) 0.313s 46 1.28x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 8.839s (-59.9% 🟢) 10.311s (-57.1% 🟢) 1.472s 6 1.00x
▲ Vercel Next.js (Turbopack) 9.005s (-37.9% 🟢) 10.621s (-34.0% 🟢) 1.616s 6 1.02x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: 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 🥇 Next.js (Turbopack) 1.885s 2.076s 0.191s 44 1.00x
🐘 Postgres Nitro 2.010s (+4.3%) 2.403s (+14.4% 🔺) 0.393s 38 1.07x
🐘 Postgres Express 2.329s (+17.8% 🔺) 3.011s (+33.3% 🔺) 0.682s 30 1.24x
💻 Local Express 2.483s (-17.7% 🟢) 3.007s (-16.1% 🟢) 0.523s 30 1.32x
💻 Local Next.js (Turbopack) 2.696s 3.041s 0.345s 30 1.43x
💻 Local Nitro 3.028s (~) 3.689s (-1.9%) 0.660s 25 1.61x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 28.729s (-42.3% 🟢) 30.184s (-41.6% 🟢) 1.455s 4 1.00x
▲ Vercel Nitro 28.889s (-26.8% 🟢) 30.371s (-26.5% 🟢) 1.483s 3 1.01x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 50 sequential data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 3.881s 4.182s 0.301s 29 1.00x
🐘 Postgres Nitro 3.978s (-3.1%) 4.367s (-5.1% 🟢) 0.390s 28 1.02x
🐘 Postgres Express 4.845s (+21.4% 🔺) 5.181s (+18.6% 🔺) 0.337s 24 1.25x
💻 Local Express 7.404s (-19.6% 🟢) 8.015s (-20.0% 🟢) 0.611s 15 1.91x
💻 Local Next.js (Turbopack) 8.437s 9.017s 0.580s 14 2.17x
💻 Local Nitro 9.194s (-1.1%) 9.787s (-2.3%) 0.593s 13 2.37x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 76.504s (-28.6% 🟢) 78.288s (-28.1% 🟢) 1.783s 2 1.00x
▲ Vercel Nitro 384.764s (+297.0% 🔺) 386.570s (+292.7% 🔺) 1.806s 1 5.03x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

workflow with 10 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.252s 1.007s 0.755s 60 1.00x
🐘 Postgres Nitro 0.282s (-0.5%) 1.007s (~) 0.725s 60 1.12x
🐘 Postgres Express 0.332s (+17.7% 🔺) 1.009s (~) 0.677s 60 1.32x
💻 Local Express 0.527s (-6.0% 🟢) 1.024s (+1.9%) 0.497s 59 2.09x
💻 Local Next.js (Turbopack) 0.528s 1.004s 0.476s 60 2.09x
💻 Local Nitro 0.571s (-5.6% 🟢) 1.004s (-1.7%) 0.434s 60 2.26x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.649s (-0.7%) 3.418s (+2.0%) 1.769s 18 1.00x
▲ Vercel Next.js (Turbopack) 1.673s (-17.3% 🟢) 3.340s (-12.0% 🟢) 1.666s 18 1.01x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 25 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.479s 1.006s 0.527s 90 1.00x
🐘 Postgres Nitro 0.515s (+3.7%) 1.007s (~) 0.492s 90 1.07x
🐘 Postgres Express 0.588s (+15.3% 🔺) 1.008s (~) 0.420s 90 1.23x
💻 Local Express 2.152s (-14.4% 🟢) 2.766s (-8.1% 🟢) 0.615s 33 4.49x
💻 Local Next.js (Turbopack) 2.502s 3.042s 0.540s 30 5.22x
💻 Local Nitro 2.506s (-1.3%) 3.010s (~) 0.504s 30 5.23x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.031s (-6.1% 🟢) 4.641s (-3.8%) 1.610s 20 1.00x
▲ Vercel Next.js (Turbopack) 3.599s (+1.8%) 5.090s (-2.0%) 1.491s 19 1.19x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

workflow with 50 concurrent data payload steps (10KB)

💻 Local Development

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.764s 1.006s 0.242s 120 1.00x
🐘 Postgres Nitro 0.817s (+3.4%) 1.009s (~) 0.192s 119 1.07x
🐘 Postgres Express 0.998s (+21.9% 🔺) 1.448s (+42.3% 🔺) 0.450s 83 1.31x
💻 Local Express 8.702s (-22.2% 🟢) 9.166s (-23.2% 🟢) 0.464s 14 11.38x
💻 Local Next.js (Turbopack) 10.336s 10.775s 0.439s 12 13.52x
💻 Local Nitro 10.955s (-2.1%) 11.572s (-0.8%) 0.617s 11 14.33x

▲ Production (Vercel)

World Framework Workflow Time Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 6.960s (-32.6% 🟢) 8.648s (-29.6% 🟢) 1.688s 14 1.00x
▲ Vercel Nitro 66.494s (+761.0% 🔺) 67.942s (+622.8% 🔺) 1.448s 5 9.55x
▲ Vercel Express ⚠️ missing - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Stream Benchmarks (includes TTFB metrics)
workflow with stream

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
💻 Local 🥇 Express 0.166s (-16.5% 🟢) 1.003s (~) 0.008s (-33.9% 🟢) 1.013s (~) 0.847s 10 1.00x
💻 Local Next.js (Turbopack) 0.174s 1.003s 0.011s 1.017s 0.843s 10 1.05x
🐘 Postgres Next.js (Turbopack) 0.190s 1.001s 0.001s 1.010s 0.820s 10 1.14x
💻 Local Nitro 0.202s (-5.6% 🟢) 1.004s (~) 0.013s (~) 1.019s (~) 0.817s 10 1.21x
🐘 Postgres Nitro 0.210s (+2.5%) 1.000s (~) 0.002s (~) 1.010s (~) 0.800s 10 1.26x
🐘 Postgres Express 0.236s (+15.2% 🔺) 0.995s (~) 0.002s (+6.2% 🔺) 1.011s (~) 0.775s 10 1.42x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 1.737s (-54.7% 🟢) 3.044s (-42.3% 🟢) 1.964s (+164.6% 🔺) 5.437s (-16.1% 🟢) 3.700s 10 1.00x
▲ Vercel Next.js (Turbopack) 1.852s (-73.0% 🟢) 3.204s (-63.0% 🟢) 1.747s (+176.5% 🔺) 5.400s (-44.8% 🟢) 3.548s 10 1.07x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

stream pipeline with 5 transform steps (1MB)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.612s 1.009s 0.006s 1.024s 0.413s 59 1.00x
🐘 Postgres Nitro 0.637s (+2.0%) 1.007s (~) 0.004s (-5.3% 🟢) 1.024s (~) 0.387s 59 1.04x
💻 Local Next.js (Turbopack) 0.652s 1.011s 0.009s 1.023s 0.371s 59 1.07x
💻 Local Nitro 0.752s (-10.3% 🟢) 1.012s (~) 0.010s (+5.8% 🔺) 1.024s (-8.2% 🟢) 0.272s 59 1.23x
🐘 Postgres Express 0.752s (+19.4% 🔺) 1.045s (+3.8%) 0.006s (+53.0% 🔺) 1.063s (+3.9%) 0.311s 57 1.23x
💻 Local Express 0.767s (+1.2%) 1.054s (+2.4%) 0.015s (+63.6% 🔺) 1.167s (+12.3% 🔺) 0.401s 52 1.25x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.401s (-74.0% 🟢) 5.657s (-69.0% 🟢) 0.197s (-6.6% 🟢) 6.478s (-65.8% 🟢) 2.078s 10 1.00x
▲ Vercel Nitro 4.446s (-84.9% 🟢) 6.259s (-79.7% 🟢) 0.189s (+68.4% 🔺) 6.832s (-78.5% 🟢) 2.387s 9 1.01x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

10 parallel streams (1MB each)

💻 Local Development

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
🐘 Postgres 🥇 Next.js (Turbopack) 0.918s 1.091s 0.000s 1.107s 0.188s 55 1.00x
🐘 Postgres Nitro 0.964s (-0.5%) 1.272s (+1.9%) 0.000s (+2.1%) 1.285s (+2.2%) 0.321s 47 1.05x
🐘 Postgres Express 1.141s (+18.7% 🔺) 1.845s (+44.4% 🔺) 0.000s (-30.3% 🟢) 1.862s (+42.5% 🔺) 0.721s 33 1.24x
💻 Local Nitro 1.225s (~) 2.021s (~) 0.000s (+233.3% 🔺) 2.023s (~) 0.798s 30 1.33x
💻 Local Next.js (Turbopack) 1.226s 2.020s 0.000s 2.022s 0.797s 30 1.33x
💻 Local Express 1.307s (+6.7% 🔺) 1.682s (-16.8% 🟢) 0.000s (-36.4% 🟢) 1.835s (-9.3% 🟢) 0.528s 33 1.42x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Nitro 3.093s (+1.4%) 4.441s (+1.1%) 0.000s (-100.0% 🟢) 4.809s (~) 1.715s 13 1.00x
▲ Vercel Next.js (Turbopack) 3.242s (-68.2% 🟢) 4.537s (-60.6% 🟢) 0.000s (NaN%) 5.084s (-57.8% 🟢) 1.841s 12 1.05x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Nitro | Next.js (Turbopack)

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.803s (+0.7%) 2.221s (+3.7%) 0.000s (-100.0% 🟢) 2.233s (+2.7%) 0.430s 27 1.00x
🐘 Postgres Next.js (Turbopack) 1.835s 2.181s 0.000s 2.188s 0.352s 28 1.02x
🐘 Postgres Express 2.141s (+20.8% 🔺) 2.541s (+16.7% 🔺) 0.000s (NaN%) 2.555s (+16.2% 🔺) 0.414s 24 1.19x
💻 Local Express 2.902s (-16.3% 🟢) 3.416s (-15.3% 🟢) 0.000s (-65.3% 🟢) 3.418s (-15.3% 🟢) 0.516s 18 1.61x
💻 Local Nitro 3.477s (+2.7%) 3.908s (-3.1%) 0.001s (+64.1% 🔺) 3.912s (-3.1%) 0.434s 16 1.93x
💻 Local Next.js (Turbopack) 3.578s 4.032s 0.001s 4.036s 0.458s 15 1.98x

▲ Production (Vercel)

World Framework Workflow Time TTFB Slurp Wall Time Overhead Samples vs Fastest
▲ Vercel 🥇 Next.js (Turbopack) 4.316s (-23.2% 🟢) 5.633s (-19.3% 🟢) 0.000s (-100.0% 🟢) 6.114s (-18.9% 🟢) 1.798s 10 1.00x
▲ Vercel Nitro 6.043s (+47.6% 🔺) 7.342s (+36.6% 🔺) 0.000s (-100.0% 🟢) 7.719s (+33.2% 🔺) 1.675s 8 1.40x
▲ Vercel Express ⚠️ missing - - - - -

🔍 Observability: Next.js (Turbopack) | Nitro

Summary

Fastest Framework by World

Winner determined by most benchmark wins

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

Winner determined by most benchmark wins

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

📋 View full workflow run

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the workflow failure event model so run_failed, step_failed, and step_retrying persist the full thrown value through the existing serialization pipeline (SerializedData / Uint8Array) rather than a lossy { message, stack, code } shape, enabling consumers to hydrate back to the original thrown value (including Error subclass identity, cause chains, and custom properties).

Changes:

  • Persist run/step failure payloads as opaque SerializedData and add errorCode as plaintext metadata for workflow runs.
  • Add dedicated dehydrate{Step,Run}Error / hydrate{Step,Run}Error helpers and wire them into step/workflow handlers and consumer APIs (Run.returnValue, step promise rejection).
  • Update storage backends (local + Postgres + Vercel world), CLI/web UI hydration, docs, and tests; add Postgres migration for workflow_runs.error_code.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
workbench/nextjs-webpack/pages/api/trigger-pages.ts Adjusts workbench API response to handle WorkflowRunFailedError.cause as unknown and surfaces errorCode.
workbench/nextjs-turbopack/pages/api/trigger-pages.ts Same as above for turbopack workbench app.
workbench/example/workflows/99_e2e.ts Adds e2e workflows that validate round-tripping thrown values (FatalError + cause chains; non-Error throws).
packages/world/src/steps.ts Switches Step.error to SerializedData and updates schema/docs accordingly.
packages/world/src/runs.ts Switches WorkflowRun.error to SerializedData and adds top-level errorCode.
packages/world/src/events.ts Updates step_failed/step_retrying/run_failed event payload schemas to carry serialized error (+ errorCode for runs).
packages/world-vercel/src/utils.ts Makes error (de)serialization helpers pass-through for SerializedData wire format.
packages/world-vercel/src/steps.ts Updates step wire schema + deserializer to pass through serialized error payloads.
packages/world-vercel/src/runs.ts Updates run wire schema to accept serialized error payloads and separate errorCode.
packages/world-vercel/src/events.ts Updates event result transformation docs/behavior for serialized error fields.
packages/world-postgres/test/storage.test.ts Updates Postgres storage tests to assert opaque Uint8Array persistence and legacy error handling.
packages/world-postgres/src/storage.ts Updates Postgres storage read/write paths for serialized errors and run errorCode.
packages/world-postgres/src/drizzle/schema.ts Changes CBOR error column typing to SerializedData and adds error_code column.
packages/world-postgres/src/drizzle/migrations/meta/_journal.json Registers the new Postgres migration.
packages/world-postgres/src/drizzle/migrations/0010_add_error_code.sql Adds error_code column to workflow.workflow_runs.
packages/world-local/src/storage/events-storage.ts Updates local world event application to store serialized errors verbatim (+ errorCode).
packages/world-local/src/storage.test.ts Updates local world storage tests for new error payload shape and stripping behavior.
packages/web-shared/src/components/sidebar/attribute-panel.tsx Adds UI display handling for the new errorCode attribute.
packages/errors/src/index.ts Updates WorkflowRunFailedError to use cause: unknown and add errorCode; registers FatalError/RetryableError on globalThis for cross-realm identity.
packages/core/src/step.ts Hydrates step failure payloads via hydrateStepError and rejects with the original thrown value.
packages/core/src/step.test.ts Updates tests to use error de/rehydration pipeline and validate subclass preservation.
packages/core/src/serialization/reducers/common.ts Updates FatalError/RetryableError revivers to resolve constructors via globalThis Symbol registry when available.
packages/core/src/serialization.ts Adds dehydrate{Step,Run}Error / hydrate{Step,Run}Error helpers and integrates with format-prefix + optional encryption.
packages/core/src/serialization.test.ts Adds unit tests covering round-trips for step/run error helpers, subclasses, causes, and encryption.
packages/core/src/serialization-format.ts Extends observability hydration to hydrate error fields on step/run resources.
packages/core/src/runtime/step-handler.ts Writes step_failed/step_retrying errors via dehydrateStepError (encrypted when configured).
packages/core/src/runtime/step-handler.test.ts Updates mocked serialization + assertions to account for binary error payload.
packages/core/src/runtime/runs.test.ts Adds tests ensuring Run.returnValue throws WorkflowRunFailedError with hydrated cause + errorCode.
packages/core/src/runtime/run.ts Hydrates run errors via hydrateRunError before throwing WorkflowRunFailedError.
packages/core/src/runtime.ts Writes run_failed errors via dehydrateRunError and preserves remapped stacks on the serialized thrown value.
packages/core/src/async-deserialization-ordering.test.ts Updates ordering tests to use serialized step errors.
packages/core/e2e/e2e.test.ts Updates/extends e2e coverage for new errorCode + hydrated causes across step/run failures.
packages/cli/src/lib/inspect/hydration.ts Uses core common revivers and adds Error.toJSON shims so --json output includes name/message/stack/cause.
docs/content/docs/foundations/errors-and-retries.mdx Updates docs to reference errorCode and cause: unknown narrowing.
docs/content/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx Updates API reference for WorkflowRunFailedError’s new cause and errorCode.
.changeset/run-step-error-hydration.md Declares major-version breaking changes across affected packages.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/world-postgres/src/storage.ts Outdated
Comment thread packages/core/src/serialization.ts
Comment thread packages/core/src/serialization.ts
The previous JSDoc described preserving legacy values "for best-effort
hydration" which contradicted the implementation, where legacy errors
are intentionally surfaced as absent (the pre-pipeline shapes can't be
hydrated by the new error revivers). Rewrite the comment so the contract
matches behavior. Also rename the now-unused parameter to `_errorJson`
to reflect that the function ignores it.

Caught by a code review on #1851.
Copy link
Copy Markdown
Contributor

@karthikscale3 karthikscale3 left a comment

Choose a reason for hiding this comment

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

AI review: Five flagged items from my analysis of this PR, posted as inline comments below.

Comment thread packages/core/src/runtime/step-handler.ts Outdated
Comment thread packages/core/src/runtime.ts Outdated
Comment thread packages/core/src/runtime/step-handler.ts Outdated
Comment thread packages/world-postgres/src/storage.ts
Comment thread packages/core/src/serialization.ts
Three review-driven adjustments that all touch the queue handlers and
their interaction with the error serialization pipeline:

1. Memoize the per-run encryption key fetch. The step handler used to
   eagerly fetch + import the key at the top of every step delivery so
   the value would be in scope for every potential dehydrateStepError
   path. That pessimized step-started early-return cases (the fetch
   happens unconditionally even when the step never reaches user code)
   and required duplicating the same boilerplate at four call sites in
   runtime.ts. Introduce `memoizeEncryptionKey(world, run)` in
   runtime/helpers.ts that returns a lazy, single-fetch accessor;
   step-handler / runtime call sites use `await getEncryptionKey()`
   instead. The first caller pays the fetch cost, subsequent callers
   await the cached promise, and steps that fail before any
   encryption-aware work happens skip the fetch entirely.

2. Preserve the prior attempt's serialized error as the cause on the
   defensive max-retries-exceeded `step_failed` re-invocation guard.
   The existing comment explicitly opted out of cause attachment, but
   the symmetric post-failure path below already does this and the
   reviewer is right that consumers shouldn't have to walk the
   step_retrying event history to recover the underlying error. Best-
   effort: if hydration of the prior `step.error` throws, fall back
   to a FatalError without cause rather than letting the event write
   itself fail.

3. Document the intentional `unflatten` throw in
   `hydrateStepError` / `hydrateRunError` for non-Uint8Array input.
   SDK version is pinned per workflow run via skew protection so the
   non-binary branch is dead in production; if a misshapen value
   reaches it, surfacing the throw via the surrounding o11y try/catch
   is more debuggable than masking it. Add a comment so future
   reviewers don't reach for a defensive fallback.

A standalone `falls back to plaintext` suggestion on the run_failed
key fetch was rejected: when encryption is configured we should fail
loudly rather than silently emit plaintext error data. The queue's
redelivery semantics will retry the key fetch; persistent KMS outages
get logged with the existing "persistent error preventing the run from
being terminated" message rather than a security regression.
`hydrateEventData` enumerated the per-event fields that need
hydration (`result`, `input`, `output`, `metadata`, `payload`)
but omitted the new `error` field on `step_failed`,
`step_retrying`, and `run_failed` events. Without this branch,
o11y tools that list events (e.g. `workflow inspect events`) surface
the raw `Uint8Array` payload instead of a hydrated
`{ name, message, stack, … }` object even though the entity-level
`Run.error` / `Step.error` paths already hydrate.

Mirrors the existing per-field branches; the `try/catch` leaves the
field un-hydrated on parse failure rather than failing the whole
event view. Adds a unit test.
Workflows execute inside a separate `vm` realm: the
`WorkflowRuntimeError` class bundled into the workflow code and the
host-imported one are distinct constructors, so an
`err instanceof WorkflowRuntimeError` check on a VM-thrown error
returns `false` and we'd misclassify genuine runtime errors (corrupted
event log, missing timestamps, workflow/step not registered) as user
errors.

Switch to each subclass's `.is()` static (a name-based duck check that
works across realms). Since `WorkflowRuntimeError.is` only matches its
own concrete name, enumerate every concrete subclass we want to
recognize (`StepNotRegisteredError`, `WorkflowNotRegisteredError`)
in a `RUNTIME_ERROR_CHECKS` table; keep that table in sync with the
class hierarchy in `@workflow/errors`.

Existing `classify-error.test.ts` already covers `WorkflowRuntimeError`
and `WorkflowNotRegisteredError` cases — both still pass.
We had `errorWorkflowThrowNonErrorValue` (workflow body throws a plain
object — round-trips verbatim as `WorkflowRunFailedError.cause`) but
no symmetric coverage for the step-throw side. Step-throw goes through
a different code path: non-Error values aren't recognized as
`FatalError` (no `name === 'FatalError'`) nor `RetryableError`,
so they take the transient retry path. After max retries the runtime
wraps the original thrown value as `cause` on a fresh `FatalError`
which the workflow's catch block then sees.

Add a workflow that throws a recognizable plain object from a step
with `maxRetries = 0` (so we exhaust on first attempt and avoid a
long test wait) and a workflow that asserts the wrapped FatalError
shape: `isFatal`, `instanceof FatalError`, message includes the
original object's serialized form, `cause` is the original non-Error
object verbatim with structure preserved.

Documents the current retry-then-wrap behavior so any future change
to "non-Error throws skip retries" semantics has to update the test.
Pre-upgrade failed runs that wrote into world-postgres's deprecated
`error` text column can't be hydrated through the new pipeline (the
shape is incompatible with the new revivers). The new runtime
intentionally surfaces them as `error: undefined` on read; the
original payload is still readable directly from the `errorJson`
column for manual inspection. Add a one-sentence note to the
changeset's migration text so consumers upgrading don't get blindsided
by suddenly-empty error fields on historical runs.
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.

3 participants