* fix(world-vercel,world-local): hold process-wide state on globalThis
Both packages are bundled into the host application's server build, and a
bundler keys module identity on (resource, layer) — Next.js alone builds
`instrument`, app-route, `ssr` and `edge` layers, so one process holds one
copy of each of these modules per layer. Every module-scope `const`/`let` in
them was therefore per-copy state wearing the costume of a process singleton.
vercel/workflow#3493 made `@workflow/world-vercel` bundled rather than
external and the events WebSocket transport regressed to HTTP for exactly
this reason: the queue consumer registered its channel in the `instrument`
copy's `Map` and the write path looked it up in the route copy's empty one. A
deterministic miss, for the life of the process. `@workflow/world-local` had
the same exposure all along — including `runFileLocks`, where a duplicated
mutex simply stops mutually excluding.
Add `globalSingleton()` to `@workflow/utils` (the primitive `@workflow/core`
already hand-rolls for its World cache) and route every mutable module-scope
binding in both worlds through it.
Regression cover, in three layers:
- `global-singleton.test.ts` pins the primitive's semantics.
- `ws-transport-module-copies.test.ts` imports the module twice in one
process and asserts a transport registered by one copy is found by the
other — it fails on a plain module-scope `Map`, which is the shipped bug.
- `scripts/lint/module-scope-state.mjs` fails the class: an AST rule banning
mutable module-scope state in these packages, with `// per-copy-ok: <why>`
as the deliberate escape. Wired into both packages' `vitest run src`, with
fixture self-tests so it cannot rot into a no-op.
* test(world-postgres): pin the module-scope-state rule for the postgres world
It is deduped today only because `getRuntimeRequire()` loads it — a property
of how it is loaded, not how it is written, and exactly what changed for
world-vercel in #3493. The package is already clean; this keeps it that way.
* docs(worlds): codify "a world must not hold mutable module state"
A world package is loaded one of two ways, and only one of them gives it a
single module instance: a runtime `require()` (deduped by Node) or the host's
bundler (one copy per layer). Which one you get is a property of how the world
is loaded, not of how it is written, and it changed under `world-vercel` in
#3493 — so the rule has to be "never rely on module scope", not "rely on it
until someone flips a config".
Written down in the four places someone can meet it:
- `docs/content/worlds/{v4,v5}/building-a-world.mdx` — a "Process-wide state"
section for custom-world authors, with the loading modes spelled out and a
nudge to prefer World-instance state over a global.
- `packages/world/README.md` — the same constraint on the contract package.
- `CLAUDE.md` — so the next contributor working in these packages sees it.
- `packages/core/src/runtime/world.ts` — at the two static imports, which is
where the difference between a bundled world and a required one originates.
The rule's own error message now teaches it too, rather than naming a helper.
Consolidates the guard while here: `@workflow/utils` owns the rule and its
fixture self-tests, and sweeps every *published* `packages/world-*` discovered
at runtime, so a world package added later is covered without anyone
remembering. Each world keeps a one-assertion mirror for locality.
* style: drop prose em dashes from this branch's new text
#3704 landed a repo-wide writing pass hours after this branch was written and
took `world-vercel/src` from 406 em dashes to 130 (`ws-transport.ts` alone went
35 to 1). This branch's docs section, README, comments and lint messages were
written before that and would have put 36 of them straight back into the files
that were just cleaned.
Rewritten sentence by sentence rather than by substitution: an em dash becomes a
colon, a comma, a full stop or a parenthetical depending on what it was doing.
Also fixes a real defect the sweep surfaced: `world-postgres`'s guard test was
generated through a shell heredoc and had literal backslash-backticks in its
doc comment.
* Update .changeset/world-module-scope-state.md
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* fix(core): build the entrypoint's queue handler from getWorld()
Adopted from #3666 by @MintedKenny, which implements #3665 and could not run
CI as a fork PR. One line of behavior: `workflowEntrypoint`'s lazy handler
init calls `getWorld()` rather than `getWorldHandlers()`.
`getWorldHandlers()` owns a second, build-time-safe cache, so calling it from
the runtime route built a *second* World in the same process. That costs a
stateful World duplicate resources on every instance — world-postgres eagerly
constructs a `pg.Pool` (default `max: 10`) and a nested world-local World in
`createWorld()`, so self-hosted users have been paying for two of each — and,
for a bundled world package, the two Worlds are built by two different module
copies, which is the mechanism behind the WS transport regression the rest of
this branch contains.
The public `getWorldHandlers()` and its separate build-time cache are
unchanged; only the runtime route stops using it.
Kept from the original: the regression test asserting the factory runs exactly
once, and the api-reference wording (re-applied over #3704's list punctuation).
Not taken: renaming the `workflow.route.get_world_handlers` span. It is a
distinct span from the per-request `workflow.route.get_world` at the top of the
flow route, and reusing that name would collide with it in traces and in
`runtime-trace-mode.test.ts`; a comment records why the name outlived the call.
Co-authored-by: Kenneth <kenneth@standardforensics.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: address AI review on the module-scope work
Two blocking findings, both real:
- **Cross-version state sharing** (`ws-transport.ts`). A process can hold two
*published versions* of `@workflow/world-vercel` (a transitive dependency
pinning an older `@workflow/core`, which depends on this package by exact
version). Both wrote to the same unversioned `Symbol.for` key, so one
version's write path could be handed a `WsEventsTransport` built by the
other's class and frame against a protocol it may not share — with no version
negotiation on the socket to catch it. `shapeVersion` cannot express this: the
container is stable, the hazard is its contents. The registry and the events
dispatcher recycler are now keyed by package version. The plain connection
pools stay unversioned; sharing those across copies is the point.
- **The documented pattern failed the rule this PR adds.** The custom-world docs
teach `store[StateKey] ??= …`, which the rule flagged as a field write. It now
recognizes state rooted at `globalThis`, following one alias hop, which is
also what `core/private.ts:23` and `next/src/index.ts:58` are already doing
correctly (core drops 26 findings to 22, next 7 to 6). The docs also now say
outright that `globalSingleton()` is the same thing, since AGENTS.md
prescribes it and the page did not mention it.
Rule precision, from the review's probes:
- `.mts`/`.cts` are scanned. `@workflow/world-testing` is authored in `.mts`, so
its entry in the sweep was passing vacuously — with the walk fixed it reports
a real finding, now annotated (it is a standalone `serve()` entry).
- Mutations in top-level statements no longer count. A table filled at module
evaluation is identical in every copy; divergence needs a later write.
- `static` class fields are collected, attributed to the class name.
- An *exported* binding initialized to an empty collection is a finding on its
own, which approximates the cross-file case the walk cannot resolve.
Six fixtures pin the new behavior. The rule's header now states what it does not
see, and AGENTS.md states where the sweep stops and why core is not gated yet.
Also tags `resetGlobalSingletonForTest` `@internal`.
* fix(lint): attribute a static-field write to the field, not the class
The static-field support added in the previous commit keyed `declared` on the
class name, so a class carrying more than one mutable static reported one
finding instead of one per field, and labelled the survivor with whichever
mutation was seen first. On a two-static fixture it reported
`static Registry.latch (`.set()`)`: the name of one field, the reason
belonging to the other, pointing the reader at the wrong line.
Key static fields `Class.field` and resolve a write to the same shape, via a
new `memberPath()` that takes the first two segments of a member chain and
tries that key before the bare root identifier. Two follow-ons fall out of
having the path:
- `this.field` inside a `static` member resolves to the class, which is the
ordinary way to write the mutation. `staticClassOf()` returns nothing for an
instance member, where `this` is an instance and the state is per-instance
rather than per-copy, and nothing inside a nested `function`, which rebinds
`this`.
- `state.count++` is now a finding, like the `state.count += 1` that
`assignment()` already reported.
Fixtures pin all four, including the instance-field case that must stay clean.
The four world packages still report zero, and the extracted `recordMutation()`
keeps the file at its previous two Biome complexity warnings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: make module duplication inert across every bundled package
`@workflow/core` is bundled into the host server build the same way the worlds
are, and always has been — the original repro measured three live copies in
every arm, including the pre-#3493 external one. One instance is not reachable:
layers cannot share a module, and core cannot be external because it *is*
workflow code (`runtime/start.ts:253` and nine methods in `runtime/run.ts` are
`'use step'`), so it must go through the SWC loader. The Next integration
already encodes that rule by removing workflow-bearing packages from
`serverExternalPackages`.
So the duplication stays and the hazard is removed instead, everywhere the
duplication can happen.
`@workflow/core` (22 findings to 0): warn-once latches in `constants.ts`,
`start.ts` and `telemetry.ts`; the source-map tracer cache; the VM script cache;
the QuickJS compiled-assets and baseline caches; the dev-server port cache (its
own comment already said "per process"); the text codecs; the zstd browser
decoder; and the `useStep` closure brand, where a function marked by one copy
was invisible to another.
The one with teeth was `step-single-flight.ts`: a per-copy map is not
single-flight. Two invocations reaching it through different layers would each
believe they were alone in the process and both run the step body, silently
degrading in-process dedup to the cross-process residual its own doc scopes out
to the ownership lease.
Also `@workflow/world` (a warn-once set, hand-rolled onto `globalThis` to keep
that package dependency-free), `@workflow/ai` (the lazy OTel API), and
`@workflow/nest` (bootstrap config in a module-level `let` and two static class
fields — configure one copy, read another, and the controller is unconfigured
for the life of the process).
Five sites are deliberately per-copy and now say why: state keyed on objects
that never cross copies (the barrier safety-net `WeakSet`, the QuickJS pending
byte `WeakMap`), the synchronously-scoped guest-code sink, and the OTel
diagnostic that reports what *this* copy sees.
The sweep now covers all of it. Packages with a single module graph stay out
(build-time code, the CLI, the o11y UI, the test runner) and AGENTS.md records
which and why.
Found while doing this: two static fields on one class collapsed into a single
entry in the rule, so `WorkflowModule.options` was invisible behind
`WorkflowModule.outDir`. Statics are now keyed `Class.field`.
* fix(world): suppress noAssignInExpressions on the globalThis idiom
The hand-rolled form trips Biome, as it does in `packages/core/src/private.ts`,
which carries the same suppression. Restructuring it into a helper function
instead would hide the state behind a call the module-scope rule cannot follow,
so the binding would stop being recognized as off-module and the package would
report a finding for correct code.
* fix: sweep every bundled package, and mark utils side-effect free
@shalabhc asked on review whether `@workflow/utils` needs this too. It does,
and so do three others: `utils`, `errors`, `serde` and `workflow` all end up in
the host application's server build and none were in the sweep. All four report
zero today, which is exactly the state `world-testing` appeared to be in before
the `.mts` walk was fixed and it turned out to have a real finding. Being clean
and being *checked* are different properties, and only the second one survives
the next contributor.
`sideEffects: false` on `@workflow/utils`: verified that every module in the
package only declares (no import-time work), so a bundler can now drop the
unused parts of the barrel instead of keeping all ~64 KB of it because three
packages import one 476-byte function.
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Co-authored-by: Kenneth <kenneth@standardforensics.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Peter Wielander <peter.wielander@vercel.com>
* docs: apply Vercel technical writing standards
Audit the complete documentation corpus, package READMEs, skills, and
source TSDoc/comments against the vercel-technical-writing skill and
style-rules.md. Normalize sentence-case headings without changing
published anchors, remove prose em dashes and filler wording, improve
active voice and self-contained phrasing, standardize product/brand
capitalization, American English, list punctuation, units, and code
fence languages, and preserve exact runtime strings/table placeholders.
All executable code is unchanged. Modified skills have their metadata
versions bumped.
* docs: extend writing audit to repository Markdown
Apply the same technical-writing rules to design documents, compiler
specifications, workbench guides, package changelogs, and the remaining
tracked Markdown outside the deployed docs corpus. Preserve historical
meaning, commands, output literals, table placeholders, and heading
anchors.
* docs: exclude generated package changelogs from audit
## Summary & Motivation
Adds `StreamExpiredError` to `@workflow/errors`, carrying the run, stream, and server-reported expiry timestamp from workflow-server's 410 `stream-expired` envelope. The reconnect loop rethrows it instead of retrying, since retention expiry is terminal and a retry budget would only convert it into a generic exhaustion error.
## Test Plan
Unit tests added for the 410 decoding path and the reconnect rethrow; typechecks pass across the touched packages.
## Summary & Motivation
A queue callback that reaches a deployment other than the one its run is pinned to derives the per-run encryption key from the wrong master key, so the delivery fails before user code runs and the run dies as a blank "exceeded max retries". The delivery is re-enqueued explicitly addressed to the run's own deployment — strictly better-targeted than the send that misrouted — and the run is failed with the new `DEPLOYMENT_MISMATCH` error code only once `WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES` (default 3) is spent. Gated on the new World capability `deploymentAffinity`, so worlds with synthetic or version-tagged deployment ids are unaffected.
## Test Plan
Unit tests added for the guard and both runtime paths; local vitest and typechecks pass.
* Fix Biome lint violations and add Biome CI check
Biome was not configured to respect .gitignore, so ~92% of the 13,355
reported diagnostics came from gitignored build artifacts. Enable VCS
integration (useIgnoreFile), apply safe auto-fixes across the repo, fix
the remaining mechanical errors by hand, downgrade judgment-call a11y /
dangerouslySetInnerHTML rules to warnings, and add a 'biome ci' job to
the Lint workflow so violations block PRs going forward.
* Use an empty changeset (no behavior change, no release needed)
Enforces the published per-run events limit, which was previously not enforced. The server supplies the limit on the run_started response (separate change); once a run's event log reaches it, the runtime throws MaxEventsExceededError at the top of the replay loop, and the existing terminal-error path records it as run_failed with a new MAX_EVENTS_EXCEEDED code — instead of letting a runaway workflow (e.g. an unbounded step loop) grow the event log without bound.
Adds a new client side WORKFLOW_MAX_EVENTS_OVERRIDE env var which can override the server side provided value (lower only).
* build: declare typescript (catalog:) in every package that runs tsc
Twenty packages invoke tsc in their build/typecheck scripts without
declaring a typescript dependency, resolving whatever tsc pnpm happens
to leave reachable. That broke locally after the TypeScript 6 upgrade
(#2700): base.json now uses the TS6-only 'types': ['*'] wildcard, and
worktrees carrying pre-upgrade node_modules/.bin/tsc shims (orphaned
typescript@5.9.3 bins that pnpm never refreshes for an undeclared
dependency) fail with TS2688 'Cannot find type definition file for *'.
Declaring 'typescript': 'catalog:' (the convention nest already
follows) makes pnpm own each package's tsc bin, so version upgrades
refresh the shims and this staleness class cannot recur. Packages
without tsc in their scripts are left unchanged.
Full pnpm build: 27/27 tasks green.
* Address review: drop duplicate zod devDep; regenerate lockfile minimally
- packages/world listed zod in both dependencies and devDependencies
(pre-existing on main, surfaced by the devDependencies sort) — keep
the runtime dependency only.
- Regenerate pnpm-lock.yaml from a pristine main baseline with
--lockfile-only (a clean-main run produces zero diff, so main has no
drift). Remaining non-typescript changes are mechanical consequences
of the change itself: typescript is an (optional) peer of several
tooling dependencies, so declaring it in 20 importers creates new
peer-resolution snapshot variants and prunes the now-orphaned old
ones; plus one radix-ui 1.6.1->1.6.2 refresh in docs caused by its
floating 'latest' specifier.
- Validated: pnpm install --frozen-lockfile succeeds; full build 27/27.
* fix(core,errors): classify SDK encryption failures as RUNTIME_ERROR
SDK-level AES-GCM encrypt/decrypt failures are never the user's fault,
but the run-failure classifier was tagging them as USER_ERROR because
the native Web Crypto OperationError (most commonly raised by
AESCipherJob.onDone on GCM auth-tag mismatch) does not match any
RUNTIME_ERROR_CHECKS entry.
Introduce a new RuntimeDecryptionError (subclass of WorkflowRuntimeError)
that the encryption module throws when subtle.encrypt/subtle.decrypt
fails, with the original DOMException as cause plus diagnostic context
(operation, byteLength, printable/hex format prefix of the input
header). classifyRunError now picks it up via RUNTIME_ERROR_CHECKS, so
these failures surface as RUNTIME_ERROR with a proper named class for
dashboards and triage.
* Trim changeset description to one sentence
* Trim historical-context comments
* docs: add runtime-decryption-failed troubleshooting page (v4 + v5)
* fix(core): round-trip RuntimeDecryptionError context, fix formatPrefix, propagate through serialization wrappers
Addresses review feedback on #2145:
- Add a RuntimeDecryptionError reducer/reviver (+ SerializableSpecial
entry + globalThis registration) so its `context` (operation,
byteLength, formatPrefix) survives the dehydrate/hydrate run-error
round trip instead of being dropped by the generic Error reducer.
- Stop capturing `formatPrefix` in the low-level encryption layer, which
only sees the stripped AES payload (nonce bytes), not the outer `encr`
marker. The serialization layer now attaches the real envelope prefix.
- Rethrow RuntimeDecryptionError unchanged from the serialize/dehydrate
catch blocks instead of reframing it as a SerializationError, so an
encryption failure during dehydration stays a RUNTIME_ERROR rather than
being misclassified as USER_ERROR.
* fix(core): enrich stream decrypt errors with envelope prefix + fix lint
- Mirror the catch/enrich/rethrow block from serialization/encryption.ts
around the stream-path aesGcmDecrypt() call so auth-tag failures on
encrypted stream frames also carry context.formatPrefix = 'encr'
(addresses review feedback). Add a tampered-frame test.
- Fix all auto-fixable Biome lint findings in the touched files
(template literals, useless try/catch wrappers, optional chaining,
non-null assertions).
* Expose conflicting run id on hook conflicts
* Mark hook conflict run id as future required
* Address hook conflict docs review
* Address hook conflict review comments
* Fix hook conflict docs typecheck
* Serialize run_failed/step_failed errors through serialization pipeline
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
* Update Next.js workbenches for new WorkflowRunFailedError.cause type
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.
* Update docs for WorkflowRunFailedError.cause: unknown
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.
* Expand test coverage for the run/step error serialization pipeline
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.
* Tighten error serialization changeset description
* Trim error serialization changeset to a single sentence
* Resolve FatalError/RetryableError revivers via cross-realm registry
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).
* Use `types.isNativeError` to remap workflow stacks across VM realms
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`.
* Sync CLI revivers with core + add toJSON shim for Error subclasses
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.
* Clarify parseErrorJson JSDoc to match its always-null return
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.
* Refine error-handler ergonomics on the step / run hot paths
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.
* Hydrate `event.eventData.error` in event listings
`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.
* Use `.is()` static checks in `classifyRunError` for cross-realm safety
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.
* Add e2e coverage for step throws of non-Error values
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.
* Note legacy postgres error-data loss in the run/step error changeset
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.
* Introduce structured context-violation errors + Ansi renderer
Phase 1: Add Ansi rendering helpers (frame, hint, note, help, code, inline)
to @workflow/errors, and a chalk mock for readable snapshot tests.
Phase 2: Add four context-violation error classes to @workflow/core
(NotInWorkflowContextError, NotInStepContextError,
NotInWorkflowOrStepContextError, UnavailableInWorkflowContextError)
and apply them to all twelve user-facing throw sites so errors now
include docs links and a structured "what/why/fix" frame.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Address review: tighten changeset, implement ansifyName, harden Ansi
- Tighten phase 1 changeset to a single sentence (per pranaygp review) and switch to double-quoted frontmatter (per Copilot + repo convention).
- Implement `ansifyName` to actually apply dim styling to workflow/ / step/ prefixes; add an `Ansi.dim` helper to `@workflow/errors` so callers don't need to import chalk directly.
- Remove the `void getWorkflowMetadata;` workaround in context-errors.ts by dropping the unused value import (we only needed the type and symbol).
- Render the plain-Error throw in `workflow/get-workflow-metadata.ts` with `Ansi.frame` + docs link so the VM path matches the structured-class styling from the sibling step path (still uses a plain Error to avoid the module-init cycle).
- Guard `buildUnderline` against zero-length markers so a stray empty token can't produce a negative `String.repeat` count.
* Structured runtime logger metadata + fold in replay-timeout logging
Adds a `.child()` and `.forRun(runId, workflowName)` child-logger API to
the structured logger so runtime/step code doesn't have to repeat
`workflowRunId`/`workflowName`/`stepId` on every call. Normalizes error
metadata to structured `errorName` / `errorMessage` / `errorStack` fields
instead of ad-hoc `error: err.message` strings, and adds comments to
silent catches that swallow expected idempotency conflicts.
Also folds in the pending changes from #1812 so that PR can be closed:
- Standardize the console prefix to `[workflow-sdk]`.
- Split the replay-timeout log into a warn-while-retrying vs.
error-when-giving-up, and surface the underlying error when we can't
mark a timed-out run as failed.
- Include the error stack in the "Fatal runtime error during workflow
setup" log and in the top-level user-code workflow error log so the
stack surfaces in flattened log drains.
- Drop the `[Workflows] "<runId>" - ` prefix from
`buildWorkflowSuspensionMessage` — the structured logger now attaches
run context.
Supersedes #1812.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Use double-quoted changeset frontmatter per repo convention
* Add SerializationError + apply to user-facing serialization sites
Phase 4 of friendlier errors: introduce a `SerializationError` class with
an optional `hint` and a docs link (workflow-sdk.dev/err/serialization-failed),
and adopt it at every user-facing serialization boundary in @workflow/core:
- Locked ReadableStream at a workflow boundary
- Unregistered class / missing `classId` / missing `WORKFLOW_DESERIALIZE`
- Attempting to return step functions to clients or call workflow functions
directly
- Webhook `respondWith()` called outside a step
- `dehydrate*` / `getSerializeStream` failures (workflow args/return, step
args/return, stream chunks)
Internal invariants (format prefix length checks, unknown format bytes,
missing `STREAM_NAME_SYMBOL`, encryption key/size guards, etc.) now throw
`WorkflowRuntimeError` instead of plain `Error` so the classifier and logger
treat them consistently.
`formatSerializationError` now returns `{ message, hint }` so the hint
fragment can be rendered with the standard SerializationError framing
instead of being baked into the message string.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Use double-quoted changeset frontmatter per repo convention
* Presentation-only user vs SDK error attribution
Add describeError() that derives attribution and class-aware hints from
existing error classes + RUN_ERROR_CODES — no event data changes. Wire into
step failures, max-delivery exhaustion, run failures, and fatal setup errors
so terminal logs include errorAttribution and a hint for known error types.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Address review: describeError accepts precomputed errorCode + instanceof
- `describeError(err, errorCode?)` now accepts an optional precomputed
`RunErrorCode`. `classifyRunError(err)` only narrows to USER_ERROR /
RUNTIME_ERROR, so the REPLAY_TIMEOUT and MAX_DELIVERIES_EXCEEDED branches
were previously unreachable from the step / run failure log sites.
Callers that know the failure category (runtime.ts for replay timeout and
max-deliveries exhaustion) now pass the code in.
- Context-violation checks use `instanceof` against the actual classes from
context-errors.ts instead of a name-string set. Type-safe + survives
class renames.
- Wire the new hints through to the REPLAY_TIMEOUT and MAX_DELIVERIES_EXCEEDED
log sites so those branches actually render a hint now.
- 3 new tests cover the reachable code paths + precomputed-code override.
- Changeset frontmatter switched to double quotes per repo convention.
* Cosmetic consistency pass on remaining bare throws
Internal invariants now use WorkflowRuntimeError so describeError attributes
them to the SDK: missing startedAt, VM generateKey, closure-vars outside
step context, ENOTSUP. defineHook().resume() formats schema validation
failures as a readable list instead of a JSON blob.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Use double-quoted changeset frontmatter per repo convention
* Data-driven describeRunError + expose via @workflow/core/describe-error
Observability renderers read persisted run_failed / step_failed event data,
not live Error instances. describeRunError takes { errorCode, errorName }
and returns the same { attribution, hint } shape as describeError, so the
CLI and web UI can derive user-vs-SDK framing from the event log directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Friendlier build-time errors: WorkflowBuildError class + applications
Add `WorkflowBuildError` class in `@workflow/errors` with optional `hint`
for an actionable next step, and apply it in `@workflow/builders` at
user-facing sites: failed esbuild phases, unresolved built-in steps, and
empty esbuild output now throw `WorkflowBuildError` with a hint pointing
at the likely fix. Runtime invariants remain plain `Error`.
* Polish friendlier-errors rendering: drop functionName leak, simplify docs link, redirect stack
- Drop the readonly `functionName` param-property on context-error classes so
util.inspect no longer prints a trailing `{ functionName: 'foo()' }` block.
- Replace the `DocLink` ("label: https://…") shape with a plain `DocsUrl`
template-literal type. Error output now renders a single clean line:
`docs: https://…` (new `Ansi.docs` helper) instead of the noisier
"note: Read more about foo(): https://…".
- Add throw helpers (`throwNotInWorkflowContext`, etc.) that call
`Error.captureStackTrace(err, stackStartFn)` on V8 engines so the top frame
of the thrown error points at the user's call site instead of at the gate
function inside the framework. Callers pass themselves as the boundary.
- Refactor `defineHook()` (both root and `/workflow`) to use named function
closures rather than `this.create`/`this.resume`, since the stack redirect
relies on a stable function identity that survives destructuring.
- Update context-errors.test.ts to snapshot the new `docs:` framing and to
add a regression test asserting the top stack frame is the user call site.
* Consolidate friendlier-errors stack: fix ANSI leak + non-retry semantics
Addresses PR review feedback across the 8-phase friendlier-errors stack and
fixes issues surfaced by manual testing (createHook() inside a step):
- ANSI no longer leaks into .message / .stack. Context-violation errors
now store plain text on .message and render the colored framed form
lazily via [util.inspect.custom] / toString(). Structured logs, log
drains, CBOR-serialized events, and JSON payloads no longer contain
raw \x1B[...m bytes.
- Context violations are now fatal. ContextViolationError sets
fatal = true; FatalError.is(err) recognizes any error with a
fatal: true own property. Calling createHook() from a step no longer
burns three retry attempts on a guaranteed-to-fail context violation.
- Ansi helpers moved to @workflow/errors/ansi subpath so imports from
@workflow/errors no longer pull chalk into consumers that only want
error classes (addresses reviewer VaguelySerious).
- Shared redirectStackToCaller helper in packages/core/src/capture-stack.ts,
used by both context-errors.ts and workflow/get-workflow-metadata.ts
(addresses Copilot review on #1849).
- Structured framed content: ContextViolationError now takes a structured
FramedContent (title segments + detail branches) and renders plain/pretty
from the same source of truth.
Tightens the eight existing phase changesets to 1-2 sentences each and adds
four new scoped changesets (errors-ansi-subpath, context-errors-plain-message,
context-errors-fatal, capture-stack-shared) for the followup fixes, so the
final changelog history stays readable.
* test: update step-handler mocks for scoped forRun() logger
The runtime logger now uses .forRun(runId, name, {stepId, stepName})
to attach scope context, so 409-handling log calls no longer repeat
{workflowRunId, stepId} in every metadata bag — those live on the
scoped logger instance. Update the mock to return itself from forRun()
and tighten assertions to check both the log args (errorName/errorMessage)
and the forRun() scope.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Mark SerializationError fatal + route dehydration through step-failure path
SerializationError now carries readonly fatal = true. Step-return
dehydration is wrapped inside the user-code try/catch so that the
resulting error flows through userCodeFailed → step_failed →
FatalError.is() short-circuit instead of bubbling up as HTTP 500 and
triggering a queue retry loop. Retrying a step that returned a non-POJO
is guaranteed to fail the same way, so this saves ~20s and 3 near-
identical error blocks per serialization failure.
* Add logging snapshot tests + manual-test artifacts
Snapshot tests lock in the exact shape of:
- describeError() payloads (attribution, errorCode, hint) for every
classification — plain Error, SerializationError, context-violation,
WorkflowRuntimeError, REPLAY_TIMEOUT, MAX_DELIVERIES_EXCEEDED.
- The scoped-logger call signature for the two canonical runtime
failure paths (fatal-bubble and hit-max-retries), so refactors of
forRun() / child() metadata merging can't silently change what users
see in their log drains.
SerializationError now also has a direct test for readonly fatal=true
+ FatalError.is() recognition.
pr-artifacts/ contains real log-output snapshots from running the
nextjs-turbopack workbench against five error scenarios. These are
reference material for reviewers and are flagged to be removed before
merge.
* Readable step-fatal logs: inline stack + friendly step/workflow names
The step-level fatal-error log used to embed the full stack trace inside
an `errorStack` string field in the metadata object, so util.inspect
rendered it as a quote-escaped, line-continuation blob when the log
hit the terminal — unreadable in practice. Move framing + stack into
the log *message* (matching the workflow-level log in runtime.ts) and
keep the metadata object compact with only the indexable structured
fields (`errorAttribution`, `errorName`, `errorMessage`, `hint`,
IDs). Log drains still get the same keys; humans now see a readable
stack trace.
Also introduce `formatStepName` / `formatWorkflowName` in
`@workflow/utils` that render machine names
(`step//./workflows/1_simple//add`) as `add (./workflows/1_simple)` in
log framings, using the existing `parseStepName` / `parseWorkflowName`
parsers. Applied to step-fatal, hit-max-retries, exceeded-max-retries,
and workflow-threw log sites.
Artifacts in pr-artifacts/ updated to show the new output shape, and
renamed .log → .md since they're Markdown and IDE previews are nicer
that way.
* Opinionated pretty formatter for runtime structured-log metadata
Replace util.inspect's default object dump (which quote-escapes
multi-line stacks and paragraph hints into a single-line JSON-y blob)
with a workflow-aware formatter that composes the entire log line
into a single string passed to console.error / console.warn.
Highlights of the new output:
- Per-run / per-step IDs render with their parsed friendly names so
users see `wrun_… · simple (./workflows/1_simple)` instead of just
the raw `workflowName: 'workflow//./workflows/1_simple//simple'`.
- Color-coded attribution badge (user error red / sdk error magenta)
paired with the error class in bold.
- Hints render as a paragraph under `hint:` rather than a backslash-
`\n`-escaped string.
- Drops redundant fields (errorStack always; errorMessage when it's
already in the parent message) to avoid double-printing.
- Unknown fields fall through as a sorted `key value` tail so we
never silently drop log information.
@workflow/errors/ansi gains bold/red/magenta helpers used by the
formatter. The web / web-shared packages don't consume stderr — they
read structured event payloads from the World event log — so this is
presentation-only at the runtime layer.
* ci(benchmarks): disable pnpm cache for getCommunityWorldsMatrix
The job never runs `pnpm install` (it just calls `node` against a
checked-in script), so the pnpm store path never exists. The post-job
`actions/setup-node@v4` cache-save then fails with `Path Validation
Error: Path(s) specified in the action for caching do(es) not exist`
and red-X's the entire job even though the matrix step succeeded.
The setup-workflow-dev composite already has a `cache-pnpm` opt-out
input for this exact case — wire it through here.
* Address PR review comments: inspect dedup, cause leak, retry-loop tests
- ContextViolationError: util.inspect(err) duplicated every framed detail
line because the stack-tail strip only sliced the first message line.
V8's Error.stack reads `Name: messageLine1\n messageLine2\n at ...`,
so for our multi-line `title\n╰▶ docs: …` messages every detail line
was getting prepended twice (once in the pretty form, once via the
unsliced message tail). Count the actual message lines and slice past
all of them. Repro test asserts `╰▶ docs:` appears exactly once.
- WorkflowError: stop assigning `cause: undefined` as an enumerable own
property when no cause is provided. Subclasses (every error in this PR)
inherit the parent constructor; the unconditional assignment polluted
`util.inspect(err)` output with `{ cause: undefined, … }` on every
no-cause instance. The `super(...)` call already conditionally sets
`.cause` non-enumerably when `options.cause` is provided.
- step-handler.test.ts: add a regression-gate suite that exercises the
fatal-vs-retryable retry-loop wiring directly. Asserts that an error
with `fatal: true` produces exactly one `step_failed` event with no
`step_retrying`, and that a non-fatal `Error` retries via
`step_retrying` on early attempts and emits `step_failed` once the
retry budget is exhausted. Catches the silent-regression case where
`fatal = true` is removed from a context-violation error class but
the `FatalError.is()` unit tests stay green.
* Consolidate changesets + remove pr-artifacts
Address review feedback to drastically shorten the changesets — fold
the 15 file-by-file entries into a single user-facing changeset for
@workflow/core / errors / builders / utils. Also drop the pr-artifacts/
folder (reviewer-only log captures, no longer needed).
* Polish runtime error logging: layout, stack trim, hint consolidation
Five user-driven fixes from manual smoke-testing of #1849:
1. Logger layout. composeLogLine() now puts the structured-fields block
(attribution badge, run/step IDs, error code) **between** the framing
line and the stack body, instead of after it where 30+ lines of stack
buried the most useful information. The framing stays at the top,
stack at the bottom, structured info readable at a glance.
2. Stack trim. Drops framework-internal frames (`node_modules/.pnpm/`,
`node:internal/`, Turbopack-bundled `node_modules__pnpm_*` chunks,
`_next_dist_*` chunks) and caps the surviving frame count at 6
so the stack stays compact even on heavy async wrappers. Suppressed
runs emit one summary line so users know the trim happened.
3. Wrapper-route noise. The nextjs-turbopack workbench's start route
was catching `WorkflowRunFailedError` rejection on
`Promise.race([readLoop(), run.returnValue])` and re-logging it via
`console.error('Error in workflow stream:', error)` plus
`controller.error(error)` — which then triggered Next.js's
`⨯ failed to pipe response` overlay. The SDK already logs the
failure cleanly upstream and the runId is on the response header, so
the wrapper now closes the SSE stream cleanly on
WorkflowRunFailedError.
4. Consistent framed `╰▶ hint:` / `╰▶ docs:` layout for all errors
that carry a hint or docs slug. WorkflowError, SerializationError,
and WorkflowBuildError now share one `appendFramedDetails` helper
matching the box-drawing structure that ContextViolationError
already used. Was: blank-line-separated `Learn more: <url>`. Now:
one tree, indistinguishable from context-violation rendering.
5. Drop the duplicate logger-side `hint` field. Hints now live on the
error message only — actionable hints get serialized into the event
log, rehydrated on the workflow side, and shown in observability
automatically. The previous logger-only hint duplicated stderr but
never made it past the step boundary.
Updated SerializationError hint to point at the foundations doc
("Ensure you're returning workflow serializable types. Check the
serialization docs to see what's serializable:
https://workflow-sdk.dev/docs/foundations/serialization") instead
of the hardcoded `(plain objects, arrays, primitives, …)` list,
which drifted out of sync as the supported types grew. Same hint
reuses for step args, workflow args/return, stream messages, and
any other site that goes through `formatSerializationError`.
Also retitled the retry summary `3 retries` → `3 max retries` since
"3 retries" next to "4 attempts" was ambiguous (already-happened vs.
budget).
* Trim error-card title + drop machine step name from persisted error
- ErrorStackBlock (web observability): show just the first non-empty
trimmed line of the error message in the card title with single-line
truncation. Multi-line messages (`Failed to serialize step return
value\n╰▶ hint: …`) were rendering the entire framed body in the
title, pushing the copy button off-screen and burying the
scannability of the headline. Full message stays in the body via
the stack (V8 prepends `Name: message` to `Error.stack`), so no
information is lost; hover-tooltip exposes the full title text.
- Persisted error message: drop the `Step "step//./.../foo"` machine
name from `Step failed after N retries: …` and `Step exceeded max
retries (…)` strings. Observability already attributes the event
to a specific step via the UI tree, and the CLI logger emits the
friendly `Step foo (./...) hit max retries` framing on its own
line. Embedding the raw `step//./...` machine name in the persisted
message text was duplicate noise.
* Update .changeset/friendlier-errors.md
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* Update .changeset/pretty-log-format.md
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* Update SerializationError snapshot tests for slug-less message
The class no longer attaches a slug-based `╰▶ docs:` line — the
foundations URL is embedded directly in the hint via the
`formatSerializationError` helper in @workflow/core. Update the test
expectations accordingly:
- bare-title case is now a single line (no docs link)
- hint case renders one `╰▶ hint: …` branch (no second branch)
* Update serialization.test.ts hint assertions for foundations URL
Four `should throw error for an unsupported type` cases were still
asserting on the old hardcoded type list. Update to the new hint
phrasing that points at the foundations doc, matching the change in
`formatSerializationError` (`packages/core/src/serialization/errors.ts`).
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
* [docs] Rename workflowdevkit references to workflowsdk
* [docs] Rename useworkflow.dev to workflow-sdk.dev
* [chore] Add changeset for domain rename
* [docs] Revert sitemap rewrite to useworkflow.dev (crawled-sitemap not yet available for new domain)
The previous pre-release versions (4.x.y-beta.N) caused two issues:
- semver.inc('4.0.0-beta.N', 'major') returns 4.0.0, not 5.0.0
- Pre-release numbers carried over (beta.61 -> beta.62 instead of beta.0)
Setting all versions to 4.0.0 (non-pre-release) ensures a clean major
bump to 5.0.0-beta.0. Also removes @workflow/swc-playground-wasm from
the changeset and pre.json since it is a private package.
* feat: enhance error handling for missing workflow functions
Slack-Thread: https://vercel.slack.com/archives/C09G3EQAL84/p1773856370214769?thread_ts=1773856370.214769&cid=C09G3EQAL84
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
* fix: update step not found handling to match FatalError pattern
Move step function validation after step_started and call step_failed directly if not found.
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
* changes
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
* feat: add StepNotRegisteredError and WorkflowNotRegisteredError semantic errors
Introduce dedicated error types for when step/workflow functions are not
registered in the current deployment, replacing generic WorkflowRuntimeError.
These are infrastructure errors (not user code errors) with proper error
slugs, docs pages, and a new FUNCTION_NOT_REGISTERED error code.
Step not found fails the step (like FatalError) so the workflow can handle
it gracefully. Workflow not found fails the run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address PR review comments
- Remove FUNCTION_NOT_REGISTERED error code, use RUNTIME_ERROR instead
- Use .is() instead of instanceof for WorkflowRuntimeError check in runtime.ts
- Remove non-working example from WorkflowNotRegisteredError docs (custom
errors not serialized yet)
- Update all references from FUNCTION_NOT_REGISTERED to RUNTIME_ERROR
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add e2e tests for step/workflow not registered errors and fix docs typecheck
E2E tests:
- WorkflowNotRegisteredError: start a run with a fake workflowId, verify
the run fails with RUNTIME_ERROR
- StepNotRegisteredError (caught): workflow catches the step failure,
verify workflow completes and step is marked failed
- StepNotRegisteredError (uncaught): verify the run fails when workflow
doesn't catch the error
Step not registered is tested by manually invoking useStep with a
non-existent step ID in the workflow VM — this is the same pattern the
SWC transform generates for real step calls.
Also fix docs typecheck by using declare/\@setup pattern instead of
\@skip-typecheck for code samples.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: cast globalThis to any for Symbol index access in e2e workflow
TypeScript's strict mode doesn't allow using a symbol to index
globalThis. Cast to any since this runs in the workflow VM where
the symbol is defined.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: classify WorkflowNotRegisteredError as RUNTIME_ERROR
The .is() check uses name-based matching, so WorkflowNotRegisteredError
(name='WorkflowNotRegisteredError') doesn't match WorkflowRuntimeError.is().
Add explicit check in classifyRunError so the error code is RUNTIME_ERROR
instead of USER_ERROR.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use instanceof for WorkflowRuntimeError checks, improve docs
Address PR review feedback:
1. Revert .is() checks back to instanceof WorkflowRuntimeError in
runtime.ts and classify-error.ts. instanceof catches all subclasses
(current and future), which is the correct behavior for these catch
blocks.
2. Remove duplicated try/catch example from step-not-registered-error
API reference (troubleshooting page already has it).
3. Add Callout in API reference docs clarifying that .is() works in
server-side Node.js code but not inside "use workflow" functions
where errors arrive deserialized from the event log.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* changes
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
---------
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com>
Co-authored-by: v0 <v0[bot]@users.noreply.github.com>
Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: export semantic error types and add API reference documentation
Add missing error exports (HookNotFoundError, EntityConflictError,
RunExpiredError, TooEarlyError, ThrottleError, RunNotSupportedError,
WorkflowWorldError) to workflow/internal/errors. Create new error
classes for world-level semantics. Tighten TSDoc comments on all
error classes. Add API reference docs for all error types.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use @setup declarations, workflow/errors import, and errors/ doc section
- Replace @skip-typecheck with proper `declare` + `// @setup` lines
so code samples are typechecked but setup lines hidden from readers
- Add `workflow/errors` export to package.json (public API, replaces
`workflow/internal/errors` in docs)
- Add `workflow/errors` path mapping in docs-typecheck type-checker
- Add HookConflictError to re-export list
- Move all error docs under api-reference/workflow/errors/ subdirectory
- Update all internal cross-references and links
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: move error docs to top-level workflow-errors section
- Move semantic error docs to api-reference/workflow-errors/ (matching
the workflow/errors import path, like workflow-api for workflow/api)
- Keep FatalError and RetryableError in api-reference/workflow/ since
they're imported from workflow, not workflow/errors
- Fix all cross-reference links
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: update HTTP debug logger JSDoc to clarify scope
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: make TooEarlyError.retryAfter a number (seconds) matching WorkflowWorldError
TooEarlyError.retryAfter is now seconds (number) instead of a Date,
consistent with ThrottleError and WorkflowWorldError. The conversion
from seconds to Date is done at the consumer site (step-handler) rather
than at construction time.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review feedback on docs accuracy
- WorkflowWorldError docs: add status, code, url, retryAfter properties
to TSDoc; clarify that .is() only matches direct instances (not
subclasses); use instanceof in catch-all example
- TooEarlyError/ThrottleError docs: mark retryAfter as optional (?)
to match actual type definitions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>