workflow — Engineering Performance
18 engineers all time · Oct 2025 – Sep 2026 · built 2026-09-30 · GitHub
Performance snapshot
Today's rolling 90-day reading for workflow, compared with the start of the series. Pick a window to move that comparison point.
Avg. perf / dev / mo
+123.5%
2.13 → 4.76 ETV
Active engineers
+11.1%
9.0 → 10.0
Features
−24.0pp
47.4% → 23.4%
vs. Vercel
0.95x
1.4x → 0.95x · −5% below
workflow vs. Vercel
Per-engineer ETV for workflow against Vercel as a whole. Both lines are 90-day rolling averages scaled to a 30-day month, so they share one axis and can be read against each other at any point. Pick a window to zoom the chart to it.
Performance Composition
Each month's output split by type of work: Features (new value), Maintenance (sustaining systems), Tests, Docs, and Fixes (rework). The yellow line is output per engineer, so when it rises each engineer is delivering more, whatever the team size did. Unit: Engineering Throughput Value (ETV).
Engineering capacity
Effective engineers behind workflow, against its pre-AI baseline. Each subject has its own: workflow's is 2.13 ETV / dev / mo, its first reading in Q4 2025. Per-engineer ETV divided by that gives a capacity multiple, and that multiple applied to the engineers active in the trailing 90 days turns it into engineer-equivalents. The line is the real headcount, so the gap between line and area is what the leverage is worth. Because each baseline is its own, every subject opens at 1.0x on its first day: multiples measure improvement and are not comparable between subjects.
Knowledge concentration
How dependent is this repo on a small number of engineers? Higher top-1 share = higher key-person risk.
Nathan Rajlich owns 29.5 % of commits.
Behind the numbers
Written summary of the work completed each month.
No monthly reports available yet.
Top engineers
Most impactful commits
Top 10 by ETV in the all-time window.
- 8.6ETVdocs: apply Vercel technical writing standards (#3704) * 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 auditNathan Rajlich · e1e64e3d · 2026-08-21
- 5.1ETVAdd dynamic workflows, backed by encrypted ref storage (#2062) * feat: dynamic workflows, backed by encrypted ref storage Rebase of the dynamic-workflow-source prototype onto main, reworked to use the real server-side storage path instead of stashing generated code in plaintext run metadata. `start()` now accepts workflow source as a string, for orchestration whose shape is only known after you deploy — a workflow-builder UI, a customer-defined automation, an AI-generated plan over a fixed catalog of registered steps: await start(source, [{ userId }], { dynamic: { steps: { fetchUser } } }); The source is validated, compiled to workflow VM code, and stored with the run through the same pipeline as the run's input: compressed, then encrypted with the run's key. On worlds with a blob-ref layer it lands behind a ref — inline for a small definition, object storage for a large one (see the matching workflow-server PR). Replay hydrates it and evaluates *it* instead of the deployment's bundle, so a run always executes the exact code it started on. What changed from the prototype: - Generated code no longer rides `executionContext.dynamicWorkflow`. Only small plaintext metadata does — version, source hash, export name, and the alias-to-step-id map — which is what lets a run be identified as dynamic, and its step allowlist audited, without decrypting anything. It also has to fit the 2 KB execution-context budget, which real generated code does not. - The code rides `run_created` (and the queue message, for resilient start and the turbo path, which synthesizes the run snapshot rather than reading it) as a serialized payload, or as a ref for definitions too large to send inline. - `start()` verifies the created run came back carrying its code. A backend without dynamic-source storage drops the field rather than rejecting it, so without this the run would look fine and fail much later as an unregistered-workflow error on a queue delivery. - Compilation moved to its own module. The workflow id is derived from the source *and* its step bindings, so the same definition always gets the same durable id, and arbitrary source cannot claim a static workflow's. Docs: new "Dynamic Workflows" page. The v5 "How it works" section is renamed "Advanced" to fit it — the section explains the build-time execution model, and dynamic source is the escape hatch from it. Permanent redirects cover every `/v5/docs/how-it-works/*` slug; v4 keeps its own section at the unversioned path, so those are deliberately untouched until v5 becomes the default. Tests: 35 unit tests over compilation, id derivation and validation; 4 type-level tests pinning the overload resolution; 7 e2e tests over the real round trip (generated code executing against registered steps, stable ids, encrypted-at-rest storage, replay across a suspension, the deferred ref path, the step allowlist failing a run, and validation refusing to create one). The e2e file is added to `test:e2e`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix(web-shared): render a dynamic run's workflow code in the run detail view The run detail sidebar keys its display map off `WorkflowRun`'s fields via an exhaustive `Record<AttributeKey, ...>`, so adding `dynamicWorkflowCode` to the run schema broke the build until it had a renderer — which is the right outcome: a payload field with no renderer would otherwise be silently dropped from the one view that exists to show it. It gets the same locked-by-default treatment as input and output, which is what the RFC asks for: generated orchestration names internal step ids, prompts and business rules, so reading it goes through the existing decrypt flow rather than being displayed by default. Ordered after input/output — the code a dynamic run actually executed is the most useful thing on its detail view, and absent on every other run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: point @workflow/core's packed docs at the renamed Advanced section `prepack` copies the v5 docs tree into the published package, and it named the section by its old path, so `pnpm pack` — and with it the tarballs preview deployment — failed on a missing directory rather than on anything about the docs themselves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * docs: make the dynamic-workflow approval sample self-contained The docs type-checker checks each code block on its own, so a sample that leaned on the imports of the block above it did not compile — and neither would a reader who copied it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * docs: use Run.status in the dynamic-workflow example There is no readStatus(); status is a promise-returning getter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): skip dynamic-workflow tests on a backend without source storage Two fixes the first Vercel-prod run surfaced. The unsupported-backend path threw instead of skipping, so the whole dynamic suite came back red against production — which does not have the server-side storage deployed yet. It now issues a real vitest skip, the same mechanism the conformance gate uses: the gap is the backend's and the suite cannot close it, and these tests go live unchanged once the server ships. The validation test also called `getWorld()` without awaiting it, then read `.runs.list` off a promise. That assertion is gone rather than fixed: the run listing is served from an eventually-consistent store in e2e, so comparing it before and after would be a flake, and the ordering it was checking — that validation happens before any write — is pinned deterministically by the unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): read run records through the World, and skip the suspension case Three problems the first full e2e matrix surfaced, all in the test file. The assertions on stored state went through `getRun()`, which returns a Run handle whose fields are promise-returning getters over a small public surface — so `workflowName` compared two promises, and `executionContext` / `dynamicWorkflowCode` read as undefined. They now read the persisted record through `world.runs.get(runId, { resolveData: 'all' })`, which is what the rest of the suite does for storage assertions. The suspension test is skipped and left in place with the observation written down: it timed out waiting on `returnValue` on `world-local` while every non-suspending dynamic test passed on the same world, so dynamic execution works and something about the resume delivery does not. That delivery takes the read-back path in `resolveWorkflowCodeForRun` that no passing test covers. Not root-caused, and it should be before this ships beyond experiment — deleting the test would hide the finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix(world-local): keep a dynamic run's code across status transitions world-local rebuilds the run record field by field on every status change, so anything not named in those four literals is dropped the first time the run moves — which for a dynamic run meant losing the workflow code the moment it went `running`, leaving a run nothing could replay. Same failure mode the neighbouring `encryptionPublicKey` lines were added to avoid, and the reason the suspension e2e test hung rather than failed: the resume delivery is the only path that reads the code back off the record, and it found nothing, so the delivery threw and was redelivered until the test timed out. That test is re-enabled here — it is what caught this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): skip the stored-code assertion when the backend persisted nothing Two of the twenty-seven Vercel apps got past `start()`'s fail-fast check and then failed on the stored bytes instead of skipping. That is accurate, not noise: the check reads the created run off `run_created`'s own response, and a resilient start has no response to read — so a short dynamic run can still finish, because turbo executes it from the queue message inside one invocation, while the backend stored nothing. This assertion is the thing that notices. It is the same "no dynamic-source storage" fact the other tests skip on, reached one step later, so it skips with that stated instead of failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): apply the stored-code skip to both assertion sites The deferred-ref test asserts stored bytes too, and only the first site got the skip — so it failed on the two apps whose runs happened to take the resilient-start path. Both now go through one helper, so a third site cannot drift from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * chore: re-run CI The previous Vercel e2e matrix hit a backend-wide slowdown — every Vercel-facing job failed on 60s timeouts in pre-existing tests (promiseRaceStressTestWorkflow, closureVariableWorkflow, the abort suite) while all 84 local-world jobs passed. Empty commit to get a clean read, since re-running the failed jobs directly is not permitted for this token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> * test(e2e): emit the dynamic runs' IDs as a CI artifact The dynamic tests track every run they start, but never wrote the sidecar that carries the IDs out — each e2e file runs in its own vitest worker with its own copy of the collector, and only `e2e.test.ts` was writing one. So the run IDs were collected in memory and thrown away, which is precisely what you want when you ask what a dynamic run looks like in production. Written under `e2e-dynamic-runs-<app>-vercel.json` rather than the shared per-app metadata name, which the last writer would otherwise clobber, and added to the four artifact upload lists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): generate the dynamic workflows inside the deployment The dynamic e2e tests called `start(source, …)` from the vitest runner. That runs, but it is not the shape an app uses, and it skipped the most important part of the feature: `dynamic.steps` has to be given *imported* step functions, so the `.stepId` the build-time transform stamped on them is what binds generated source to a registered step. The runner holds no handle on those functions, so it had to look step ids up in the deployed manifest and pass `{ stepId }` — the escape hatch, not the documented path. The workflows are now generated inside the deployment, by fixtures in `workflows/99_e2e.ts` (one file, symlinked into every workbench app). Each fixture assembles source at runtime and starts it from a step with `{ steps: { add } }`, which is how a builder UI or an LLM-generated plan actually reaches `start()`. The tests start the static fixture, take the dynamic child's run id back, and assert on the child — so the same tests exercise Vercel, local dev/prod and Postgres with no per-world branching. Fixtures added: a plain generated workflow, one whose source suspends (the resume delivery is the only path that reads the stored code back), and one whose source calls a step it was not given. `getStepMetadata` goes with them — it existed only for the manifest lookup this replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: accept imported step functions in dynamic.steps `DynamicWorkflowStepReference` was `{ readonly stepId: string }`, so the call the docs advertise — `steps: { fetchUser, sendEmail }` with real imports — did not typecheck. The build-time transform stamps `.stepId` on step functions at runtime and nothing adds it to their declared type, so the only thing the type actually accepted was the `{ stepId }` escape hatch. Moving the e2e fixtures into the deployment is what surfaced this: app code is typechecked by the app's build, and the runner had only ever passed the escape hatch. The function arm is typed as any function, with the existing runtime check in `resolveStepId` as the real gate — it already reads `.stepId` off functions and fails with a message naming the alias. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: name the created run when its dynamic code was not stored The error said the code was not stored but not which run it was talking about — and `start()` writes `run_created` *before* reading back what the backend kept, so on this path a real dynamic run does exist and only its replayability is missing. Naming it is what makes it inspectable. The e2e skip path now pulls that id out and tracks it, so the run-ID sidecar reports the run even when the test skips. Without it the ids of dynamic runs created against a backend that cannot store them were simply lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): capture the child's run id, not the wrapping parent's The error reaches the runner wrapped in the parent fixture's failure, which names the parent, so a loose `wrun_` match took that instead — every run id in the sidecar was the parent's, recorded twice. Anchored to the exact wording of the message `start()` raises about the run it created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * test(e2e): label the dynamic run in the run-id sidecar The sidecar lists two bare ids per test — the parent fixture's and the dynamic child's — with nothing saying which is which. The dynamic one is the whole reason the artifact exists, so it is now tagged `[dynamic run]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude <noreply@anthropic.com> * fix: stop the module-syntax check rejecting identifiers that start with import/export `import` was allowed to be followed directly by an identifier character, so a line starting with `importData = …` was rejected as module syntax. The keyword now has to be followed by whitespace or a module-syntax token. `export{` and `export *` without a space are caught as well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(dynamic-workflows): purge stored code, refuse Replay Run, parse source up front, hydrate code for o11y - $retention: 0 purges clear dynamicWorkflowCode on world-local and world-postgres alongside the other run payloads. - Replay Run refuses a dynamic run instead of creating one with no code behind it. - compileDynamicWorkflow parses the generated code, so TypeScript syntax, a reserved-word function name, or an unbalanced brace fail at start() rather than on every delivery; inline "use step" is rejected as the docs already state. - The stored code payload is real devalue under its devl prefix, so the generic hydrators (CLI inspect, web UI) can read it; hydration field lists in core, web-shared, and the CLI include dynamicWorkflowCode. - hydrateDynamicWorkflowCode normalizes a corrupt payload to SerializationError. - Changeset covers @workflow/cli and @workflow/web-shared; bump is minor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(dynamic-workflows): preflight runtime and backend support Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(dynamic-workflows): keep source out of local events Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(web): disable replay for dynamic runs Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): qualify source storage guarantees Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * test(core): exercise dynamic source compression Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): qualify changeset storage wording Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(web): guard Replay while run identity loads Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): clarify cross-deployment key preflight Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * feat(core): mark dynamic start options experimental Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world): reject ambiguous dynamic code storage Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): isolate dynamic workflow compilation Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): explain runtime surface Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): validate top-level dynamic workflows Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): validate generated dynamic wrapper Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): isolate dynamic source bindings Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): pin dynamic syntax and preserve aliases Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(dynamic-workflows): validate queue names before start writes Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): gate dynamic starts on deployment opt-in and same deployment Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): fail dynamic deliveries that cannot safely execute stored code Require the deployment opt-in, a marker that derives the run's workflow name, and encr-only stored code when the run has key material. Each refusal records run_failed instead of redelivering, and executing stored code is recorded on the delivery span and in one log line. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(core): validate execution context size for dynamic starts only Static starts no longer serialize or reject their execution context before creation. world-vercel throws a WorkflowRuntimeError that names the dynamic step bindings as the cause. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world-vercel): read a missing capabilities route as no capabilities A 404 from /v2/capabilities maps to an empty set so dynamic starts fail closed with the backend-support error; other failures still propagate. The dynamic E2E suite skips when the deployment has not opted in or its backend does not advertise dynamic-source storage. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world): omit dynamic workflow code for resolveData none WorkflowRunWithoutData excludes dynamicWorkflowCode, and the local, Postgres, and Vercel Worlds strip it from get, getMany, and list. world-local list now filters through filterRunData. Replay reads the code back with resolveData all when a snapshot lacks it. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * docs(dynamic-workflows): document opt-in, same-deployment scope, and security model State that dynamic source runs with the deployment's full privileges and that steps is not a boundary, drop the customer-authored and AI-generated framing, and document the WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS opt-in, same-deployment-only starts, encr-only stored code, plaintext storage in Local and Postgres, the plaintext step map, and the 2,048-byte marker limit. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * refactor(core): drop the redundant dynamic runtime-version check from start Dynamic starts are same-deployment only and require the opt-in up front, so the start-local target dynamic version could only echo this process's own constant. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * ci: run dynamic workflow E2E on local and Postgres lanes The local dev, local prod, and Postgres servers opt in with WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1, and WORKFLOW_E2E_EXPECT_DYNAMIC_WORKFLOWS=1 makes the suite fail rather than skip on an opt-in refusal there. Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world-vercel): skip decompressing dynamic workflow code on resolveData none reads Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> * fix(world-vercel): classify corrupt dynamic workflow code as a contract error Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> --------- Signed-off-by: Alex Langenfeld <alex.langenfeld@vercel.com> Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Pranay Prakash <1797812+pranaygp@users.noreply.github.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com> Co-authored-by: Alex Langenfeld <alex.langenfeld@vercel.com>Pranay Prakash · 20e8440f · 2026-09-29
- 4.0ETVWorld-side incrementing event ID (specVersion 6) (#3389)Peter Wielander · 6786db99 · 2026-08-11
- 3.8ETVfeat(hooks): `createHook({ experimental_force: true })` takes a held token over (#4193) * feat(hooks): createHook({ experimental_force: true }) takes a held token over A run can now register a hook token another active run holds. The World disposes the previous owner's hook in that run's own event log (`hook_disposed{forceClaimedBy}`), re-points the token, and returns the new hook with `claimedFrom`; the claimer's runtime then wakes the previous owner from that record. A run awaiting a hook whose token was taken over gets the new `HookForceClaimedError` naming the claimer, after draining the payloads that landed before the takeover. Co-authored-by: Peter Wielander <mittgfu@gmail.com>Pranay Prakash · 4f524386 · 2026-09-23
- 2.8ETV[nest] Harden the NestJS integration for production (#3695) Co-authored-by: Karthik Kalyanaraman <karthik.kalyanaraman@vercel.com>Peter Wielander · 5a05f409 · 2026-09-16
- 2.7ETV[builders][web-shared] Improvements to o11y and fixes to graph generation code path (#1031) * [workflow o11y] rebase on latest main and keep targeted UI/builders changes Rebase the branch intent onto latest main by preserving web-shared UI refactors and builders base-builder updates while taking main for hydration and data-fetching behavior elsewhere. Co-authored-by: Cursor <cursoragent@cursor.com> * [workflow o11y] align web-shared hydration revivers with main Revert the hydration reviver delta for URL, URLSearchParams, and Headers so web-shared matches main behavior while keeping the targeted UI/builders-only scope on this branch. Co-authored-by: Cursor <cursoragent@cursor.com> * [workflow o11y] restore PR #1017 detail-panel decoupling Bring the web-shared trace/detail panel files back in sync with main so PR #1017 behavior is preserved and not regressed on this branch. Co-authored-by: Cursor <cursoragent@cursor.com> * [workflow o11y] restore PR #1018 react-inspector sidebar updates Bring web-shared o11y rendering files back in sync with main so ObjectInspector-based sidebar rendering and related UI behavior from PR #1018 remain intact on this branch. Co-authored-by: Cursor <cursoragent@cursor.com> * [workflow o11y] keep custom viewers and apply inspector rendering Preserve the branch-specific event list and stream viewer UX while applying react-inspector rendering to payload/chunk data so complex hydrated values render correctly without reverting custom UI behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * [workflow o11y] trace viewer UX improvements and Geist alignment - Add live tick animation, context menu, and cancel run support from PR #984 - Decouple side panel styling to use Geist design tokens (inline styles) - Fix sleep span detail panel showing events instead of wait entity attributes - Fix stream viewer flickering by removing unstable object deps - Remove Chunks/Output toggle from stream viewer, show only chunks - Add resolve hook modal, wake-up sleep, and cancel run plumbing - Add loading skeleton for stream viewer Co-authored-by: Cursor <cursoragent@cursor.com> * Bug fixes * Bug fixes * Bug fixes * Bug fixes * Bug fixes * Bug fixes * Bug fixes * Bug fixes * Bug fixes * Bug fixes --------- Co-authored-by: Cursor <cursoragent@cursor.com>Karthik Kalyan · 1c115734 · 2026-02-13
- 2.7ETVWorkflows graph extractor (#455) --------- Signed-off-by: Karthik Kalyanaraman <karthik@scale3labs.com>Karthik Kalyanaraman · e3f03904 · 2025-12-27
- 2.6ETVfix(swc-plugin): register class expressions via an IIFE instead of by name (#3971) * fix(swc-plugin): register class expressions via an IIFE and reject unnameable classes Class expressions with "use step" methods or custom serialization were registered by module-level statements referencing the class by name. When no module-scope binding could be resolved the plugin fell back to a placeholder `AnonymousClass` identifier, which is a guaranteed ReferenceError at module evaluation (vercel/workflow#3929). Other shapes were silently wrong as well: `var A = class {}, B = class {}` registered A's steps under B, `X = class {}` assignments and classes nested inside functions emitted unresolvable references. Class expressions are now wrapped in a single IIFE that receives the class, performs every registration recorded for it, and returns it, so the registration no longer depends on a name being in scope. The class name is still needed for step/class IDs and is derived from the assigned variable, the class's own identifier, or the property key it is assigned to (`exports.Foo = class {}`, `{ Foo: class {} }`). When none is available, or the class is declared inside a function, the plugin emits a compile error instead of broken code. Class declarations keep their existing module-level output; the emitters were factored so both paths share the same statement builders. * fix(swc-plugin): generate names for anonymous class expressions instead of erroring With registration happening inside the IIFE, an anonymous class expression in a position that provides no name (`foo(class { ... })`, an array element, a conditional branch) only needs a name for its step/class IDs. Generate a deterministic `AnonymousClass<N>`, counting only anonymous classes that have something to register, instead of rejecting them. Classes declared inside a function remain an error. Dead-code elimination now keeps module-level declarations whose initializer contains a wrapped class expression: evaluating the initializer is what registers the class, and the binding may be otherwise unreferenced.Nathan Rajlich · ae5ee5ba · 2026-09-08
- 2.6ETVfix(world-vercel,world-local): hold process-wide state on globalThis (#3728) * 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>Pranay Prakash · f7715854 · 2026-08-21
- 2.4ETVFriendlier workflow errors (consolidated) (#1849) * 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>Pranay Prakash · 1203dae7 · 2026-05-04