## Summary
Replace the custom `JSON.stringify`-based data renderer with [`react-inspector`](https://www.npmjs.com/package/react-inspector)'s `ObjectInspector` component for proper display of all JavaScript types in the o11y sidebar panel.
## Before
Data was rendered via `JSON.stringify(value, null, 2)` in a `<pre>` block. Non-plain-object types like `Map`, `Set`, `URLSearchParams`, `Date`, `Error`, `RegExp`, and typed arrays all rendered as `{}` or lost their type information.
## After
Uses `react-inspector` (the same rendering engine as Chrome DevTools) to display data as expandable tree views with proper type labels:
- `URLSearchParams {size: 12}` with entries visible
- `Map(3)` / `Set(5)` with contents
- `Date "2026-02-11T..."`
- `Error {message: "...", stack: "..."}`
- `Uint8Array(32)` with byte values
## Implementation
- Replace `JsonBlock` function with `DataInspector` component using `ObjectInspector`
- Custom theme with transparent background and design system CSS variables (`--ds-gray-*`, `--font-mono`)
- Dark/light mode support via `useDarkMode` hook
- `StreamRef` and `ClassInstanceRef` objects still render as custom components (clickable stream links and styled class cards)
- Expand level defaults to 2 for a good overview without overwhelming
## Dependencies
- Added `react-inspector@9` to `@workflow/web-shared`
## Summary
Render the `EntityDetailPanel` (step/hook/event detail sidebar) outside the trace viewer's `TraceViewerContextProvider`, so hydrated data no longer passes through the web worker's `postMessage` structured clone boundary.
## Problem
The trace viewer uses a web worker for span layout calculations, sending data via `postMessage` which requires structured cloning. After moving hydration client-side, the hydrated data (containing `URLSearchParams`, `Headers`, and other non-structurally-cloneable types) was embedded in trace span attributes and sent to the worker, causing `DOMException: URLSearchParams object could not be cloned`.
## Solution
Render the detail panel as a **sibling** to the trace timeline instead of nested inside the context:
```
Before: After:
TraceViewerContext [flex row]
└─ Timeline ├─ TraceViewerContext
└─ Panel (inside worker) │ └─ Timeline (worker)
└─ Panel (direct props)
```
- `WorkflowTraceViewer`: flex layout with timeline + panel as siblings
- `SelectionBridge`: lightweight component inside context that reads the selected span and passes it up via callback
- `EntityDetailPanel`: no longer imports `useTraceViewer()`, receives selected span info via `selectedSpan` prop
- Panel has independent width/resize state (no longer coupled to the trace viewer context)
- Strip `input`/`output`/`error` from trace span attributes to keep worker data lightweight
## Also fixes
- Stale `customPanelComponent` bug — the panel was stored in context state during initialization and never updated, causing data to be lost on re-renders
## Summary
Split the serialization/deserialization logic into environment-specific layers so data hydration can happen client-side in the browser. This is a prerequisite for e2e encryption where decryption keys are only available in the browser.
## Architecture
### Layer 1: `@workflow/core/serialization-format` (new, browser-safe)
- Format prefix encoding/decoding (`devl`, future `encr`, etc.)
- Generic `hydrateData()` dispatch — handles Uint8Array (v2 binary), legacy arrays (v1), and plain values
- `hydrateResourceIO(resource, revivers)` resource-type dispatcher (step/hook/event/workflow field mapping)
- `ClassInstanceRef` (plain data class, no `node:util` dependency)
- `StreamRef`, type guards (`isStreamRef`, `isStreamId`, `isClassInstanceRef`), utility functions (`extractStreamIds`, `truncateId`)
- Shared `observabilityRevivers` for stream/class/step display overrides
- 36 unit tests covering all of the above
### Layer 2: Environment-specific revivers
- **`@workflow/web-shared`** (`lib/hydration.ts`) — browser-safe revivers using `atob()` for base64, real `URLSearchParams`/`Headers`/`URL` instances, `ClassInstanceRef` for UI rendering
- **`@workflow/cli`** (`lib/inspect/hydration.ts`) — Node.js revivers using `Buffer.from()` for base64, `CLIClassInstanceRef` with `util.inspect.custom` for CLI output
Each module exports a pre-bound `hydrateResourceIO(resource)` that uses its environment's revivers.
### Removed: `@workflow/core/observability`
- Deleted `observability.ts` and `observability.test.ts` entirely (no remaining consumers)
- Removed `"./observability"` export from `@workflow/core/package.json`
- Removed the `workflow` package's `internal/observability.ts` re-export
- All functionality has been split between `serialization-format.ts` (shared types/utilities) and the environment-specific hydration modules
## Web package changes
- Server passes raw world data through without hydration (CBOR preserves `Uint8Array`)
- Client calls `hydrateResourceIO` from `@workflow/web-shared` after receiving CBOR-decoded data
- No Vite `node:*` stubs needed since `@workflow/core/serialization-format` is browser-safe
- Optimized: event hydration finds the matching event before hydrating (instead of hydrating all)
- Reduced server log noise from handled API errors (4xx errors no longer logged)
## Packages affected
- `@workflow/core` — new `serialization-format` export (with tests), removed `observability` export
- `@workflow/web-shared` — new `lib/hydration.ts` with browser-safe revivers
- `@workflow/cli` — new `lib/inspect/hydration.ts` with Node.js revivers, updated `output.ts` import
- `@workflow/web` — client-side hydration, removed server-side hydration
- `workflow` — removed `internal/observability.ts` re-export
## Summary
- Replace Next.js App Router with React Router v7.13.0 framework mode (Vite-based), eliminating the large `next` dependency from the web, CLI, and workflow metapackages
- Serve the web UI in-process from the CLI via Express instead of spawning `next start` as a child process
- Switch RPC transport from JSON to CBOR to preserve binary data types across the wire
- Replace `nuqs` URL state management with React Router's `useSearchParams`
- Replace Next.js server actions with an RPC resource route (`/api/rpc`) and a thin CBOR-based client
## Motivation
The `next` package is ~300MB installed and was the single largest dependency in the monorepo. It also required spawning a separate child process from the CLI to run the o11y web server, adding complexity around process lifecycle management, port readiness polling, and environment variable forwarding.
With React Router framework mode, the web package builds to a standard Express-compatible server bundle that the CLI can import and serve directly in its own process.
## What changed
**Framework swap (`@workflow/web`):**
- `next.config.ts` / `postcss.config.mjs` → `react-router.config.ts` / `vite.config.ts`
- `src/` directory → `app/` directory (React Router convention)
- `src/app/layout.tsx` + `layout-client.tsx` → `app/root.tsx`
- `src/app/page.tsx` → `app/routes/home.tsx`
- `src/app/run/[runId]/page.tsx` → `app/routes/run-detail.tsx`
- Path alias `@/` → `~/`
- Removed all `'use client'` / `'use server'` directives
**Data transport:**
- Server actions → RPC resource route at `/api/rpc` with CBOR encoding
- CBOR preserves `Uint8Array` and other binary types natively (no base64 overhead)
- Stream reading → dedicated `/api/stream/:streamId` resource route
**URL state:**
- `nuqs` (`useQueryState`) → `useSearchParams` from `react-router`
**Fonts:**
- `next/font/google` → Geist `.woff2` files referenced directly from `node_modules/geist` via `@font-face` in CSS
**CLI integration (`@workflow/cli`):**
- `import('@workflow/web/server').then(m => m.startServer(port))`
- No child process, no readiness polling, no cleanup handlers
**Radix UI compatibility:**
- `onSubmit` preventDefault on `AlertDialogContent` and `SheetContent` to prevent Radix's internal `<form method="dialog">` from triggering React Router route actions
- Catch-all action on root route for any stray POSTs
## Dependencies removed
- `next`, `swr`, `nuqs`, `@tailwindcss/postcss`
## Dependencies added
- `react-router` / `@react-router/dev` / `@react-router/node` / `@react-router/express` (all `7.13.0`)
- `express`, `vite`, `@tailwindcss/vite`, `cbor-x`, `isbot`, `cross-env`
- `geist` (devDep)
Stream chunks were serialized as raw devalue text without the 'devl'
format prefix, unlike step inputs/outputs. This meant stream data
couldn't go through the same hydration/deserialization pipeline.
Core changes (serialization.ts):
- getSerializeStream: each chunk is now individually wrapped with
encodeWithFormatPrefix('devl', payload) — same as step data
- getDeserializeStream: handles both format-prefixed chunks (current)
and legacy newline-delimited text (backwards compatible)
World-local changes (streamer.ts):
- Stream chunk files now use .bin extension instead of .json, since
the content is binary (1-byte eof flag + serialized chunk data)
- Add listFilesByExtension() utility to fs.ts for generic file listing
* chore: add changeset
* world-local: add backwards compat for legacy .json chunk files
When reading stream chunks, list both .bin (current) and .json
(legacy) files so streams written before the extension change
continue to work.
* core: add format prefix and length-prefix framing to stream chunks
Stream chunks were serialized as raw devalue text without the 'devl'
format prefix or any framing, unlike step inputs/outputs.
Core changes (serialization.ts):
- getSerializeStream: each chunk is now wrapped with
encodeWithFormatPrefix('devl', payload) inside a length-prefixed
frame: [4-byte big-endian length][format-prefixed payload]
- getDeserializeStream: handles both length-prefixed framed chunks
(current) and legacy newline-delimited text (backwards compatible)
- Added 6 unit tests covering framing, round-trip, concatenation,
splitting, and legacy fallback
World-local changes (streamer.ts):
- Stream chunk files now use .bin extension instead of .json
- readFromStream lists both .bin and .json files for backwards compat
- Updated streamer.test.ts to expect .bin extension
Split changesets: core (format + framing) and world-local (.bin extension)
When upstream bundlers like tsup/esbuild transform class declarations into anonymous class expressions (e.g. `var Foo = class { ... }`), the SWC plugin now re-inserts the binding name into the class expression (e.g. `var Foo = class Foo { ... }`). This preserves the class `.name` property through subsequent bundling passes, preventing runtime failures in downstream bundlers like Nitro's Rollup bundler that rely on the class expression name for serialization class registration.
## Summary
- Generates `runId` client-side in `start()` using ULID (`wrun_${ulid()}`), instead of deferring to the server
- Passes client-generated `runId` to `events.create()` and verifies the server accepted it
- Simplifies `runId` types from `string | Promise<string>` to `string` throughout the serialization layer
- Removes the `withResolvers<string>()` / deferred runId pattern that was needed when `runId` was server-generated
- Updates `Streamer` interface and `WorkflowServerWritableStream` to use plain `string` runId
- Updates `Storage.events.create()` first overload to accept `string | null` instead of just `null`
This is a prerequisite for E2E encryption, where we need the `runId` before serialization so it can be used as part of the encryption context.
## Test plan
All existing tests pass. Updated `start.test.ts` to expect client-generated runId, removed `writable-stream.test.ts` promise runId test (no longer applicable).
Fixed a bug where step functions nested multiple levels deep in an object weren't being properly transformed.
### What changed?
- Enhanced the SWC plugin to recursively process nested objects to find step functions
- Added support for step functions defined inside deeply nested object properties, including function call arguments
- Implemented proper path handling for nested step IDs, using compound paths (e.g., `vade/tools/VercelRequest/execute`)
- Fixed variable name generation by replacing slashes with `$` to create valid JavaScript identifiers
- Updated documentation with examples of nested object property step functions
- Added test cases for deeply nested step functions and agent tool steps
### How to test?
Test with code that has step functions nested multiple levels deep in objects:
```javascript
export const config = createConfig({
level1: {
level2: {
level3: {
myStep: async (input) => {
"use step";
return input * 2;
},
},
},
},
});
```
Or with agent tools:
```javascript
export const vade = agent({
tools: {
VercelRequest: {
execute: async (input, ctx) => {
"use step";
return 1 + 1;
},
},
},
});
```
### Why make this change?
This fix ensures that step functions can be properly identified and transformed regardless of how deeply they are nested in object structures. This is particularly important for complex configurations like agent tools where step functions might be several levels deep in the object hierarchy.
## Summary
Refactors the E2E tests to call `start()` from `workflow/api` directly instead of going through the `/api/trigger` HTTP endpoint in each workbench app. This removes a layer of indirection — the tests now use the same API that users would use to start workflows programmatically.
### Before
```ts
const run = await triggerWorkflow('addTenWorkflow', [123]);
const returnValue = await getWorkflowReturnValue(run.runId);
```
- `triggerWorkflow()` sent an HTTP POST to `/api/trigger` on the workbench app
- The workbench app looked up the workflow function, called `start()`, and returned the run ID
- `getWorkflowReturnValue()` polled `GET /api/trigger?runId=...` until the workflow completed
### After
```ts
const run = await start(await e2e('addTenWorkflow'), [123]);
const returnValue = await run.returnValue;
```
- `e2e()` / `getWorkflowMetadata()` fetches the manifest from `/.well-known/workflow/v1/manifest.json` to look up the correct `workflowId`
- `start()` is called directly from the test process via the configured World
- `run.returnValue` polls for completion via the World (no HTTP polling endpoint needed)
### Changes
**`packages/core/e2e/e2e.test.ts`**
- Removed `triggerWorkflow()` and `getWorkflowReturnValue()` helpers
- Added `fetchManifest()` to fetch and cache the workflow manifest from the deployment
- Added `getWorkflowMetadata(file, fn)` to look up `{ workflowId }` from the manifest
- Added `e2e(fn)` shorthand for the common case of `workflows/99_e2e.ts`
- All tests call `start()` and `run.returnValue` directly
- Error tests use `.catch()` to inspect `WorkflowRunFailedError`
- Output stream tests use `run.getReadable()` directly (skipped on local world where cross-process streaming isn't supported)
- `beforeAll` configures the local World with the correct data directory and base URL
- Pages Router tests use `startWorkflowViaHttp()` to specifically validate the HTTP trigger path
**Workbench apps (hono, express, fastify, nest)**
- Removed `/api/trigger` route handlers
- Kept `/api/hook`, `/api/test-direct-step-call`, `/api/test-health-check` endpoints
- Re-added `_workflows.js` side-effect import for hono/express/fastify to maintain Nitro's HMR dependency graph
**Deleted trigger-only route files** from: nextjs-turbopack, nextjs-webpack, vite, sveltekit, astro, nuxt, nitro-v2, nitro-v3, example
**`.github/workflows/tests.yml`**
- Added `WORKFLOW_PUBLIC_MANIFEST: '1'` to all E2E test jobs
### Dependencies
Stacked on #963 which adds `WORKFLOW_PUBLIC_MANIFEST` support to all framework builders.
* Refactor web-shared to separate UI components from server concerns
- Move UI components into src/components/ subdirectory
- Move server actions and API client to @workflow/web
- Add world-actions/ module with shared helpers (recreateRun, cancelRun, etc.)
- Add runtime subpath exports to @workflow/core
- Components now accept callbacks as props for data fetching
* add support for streams
* update lock file
* Migrate helpers from web-shared to core
* Migrate helpers from web-shared to core
* Migrate helpers from web-shared to core
* Migrate helpers from web-shared to core
* Fix claude's comments
* Fix server actions bug for web
* fix copilot bugs
## Summary
When `WORKFLOW_PUBLIC_MANIFEST=1` is set, each framework builder exposes the workflow manifest at `/.well-known/workflow/v1/manifest.json` via the most appropriate mechanism for the framework:
- **Next.js**: Copies manifest to `public/.well-known/workflow/v1/manifest.json` (served as a static file)
- **SvelteKit**: Copies manifest to `static/.well-known/workflow/v1/manifest.json` (served as a static file)
- **Vercel Build Output API** (example workbench): Copies manifest to `.vercel/output/static/.well-known/workflow/v1/manifest.json` (served as a static file)
- **Nitro** (vite, hono, express, fastify, nuxt, nitro-v2, nitro-v3): Registers a virtual handler that reads and serves the manifest JSON
- **Astro**: Generates a `manifest.json.js` page route that returns the manifest JSON
- **NestJS**: Adds a `@Get('manifest.json')` endpoint on the `WorkflowController` (gated by the env var at runtime)
### Other changes
- `BaseBuilder.createManifest()` now returns the manifest JSON string so framework builders can use it
- Added `shouldExposePublicManifest` getter to `BaseBuilder`
- Fixed Astro `LocalBuilder` which was missing the `createManifest()` call that all other framework builders have
- Removed unused `public/` directory and static file copy from the example workbench build script
- Added `WORKFLOW_PUBLIC_MANIFEST` to `turbo.json` build task env so Vercel builds can access it
- Added generated manifest paths to `.gitignore`
* Parallelize async operations in step handler for performance
- Parallelize getPort(), getSpanKind(), and world.steps.get() calls
- Start step_started event creation while hydrating arguments (CPU work)
- Parallelize step_completed event with trace serialization
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix race condition: await step_started before hydration
Reverts Optimization 1 to fix a race condition where hydrateStepArguments()
could throw before stepStartedPromise was awaited, causing stale step.attempt
in the catch handler and potentially allowing extra retries.
Optimizations 0 and 2 are preserved as they don't have this issue.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Skip initial world.steps.get() in step handler for performance
Eliminate the world.steps.get() HTTP call by calling step_started first
and relying on server-side validation. This saves 50-80ms per step
execution by removing one HTTP round-trip.
The server (workflow-server) now validates:
- Step not in terminal state (returns 409)
- retryAfter timestamp reached (returns 425 with Retry-After header)
- Workflow still active (returns 410 if completed)
Changes:
- Remove world.steps.get() from initial Promise.all
- Call step_started first to get step entity and validate state
- Handle 409 (terminal state) by re-queueing workflow
- Handle 425 (retryAfter not reached) by returning timeout
- Handle 410 (workflow gone) as no-op
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add retryAfter validation to local and postgres worlds
Add server-side retryAfter validation to match workflow-server behavior:
- Check retryAfter timestamp before allowing step_started
- Return HTTP 425 with retryAfter timestamp in response meta
- Clear retryAfter field when step starts successfully
This ensures consistent behavior across all world implementations
and allows the step-handler optimization to work correctly.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix step terminal state HTTP status code to 409 (Conflict)
Aligns local and postgres worlds with workflow-server, which returns
409 via InvalidOperationStateError for step in terminal state.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix world-vercel telemetry to use parent application's tracer
The world-vercel package was creating spans under a separate
'workflow-world-vercel' service name, causing HTTP spans for
workflow-server API calls (step_started, step_completed) to be
filtered out when viewing traces for the main application service.
Now uses the same 'workflow' tracer name as @workflow/core to
ensure all spans are reported under the parent application's service.
* Update /demo command to include OTEL tracing with Jaeger
- Start Jaeger container for local trace visualization
- Configure OTEL exporter environment variables for dev server
- Open Jaeger UI automatically
- Add documentation about available trace attributes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add OTEL instrumentation to world-local and improve trace consistency
- Add telemetry.ts and instrumentObject.ts to world-local for tracing parity
with world-vercel (world.runs, world.steps, world.events, world.hooks spans)
- Change workflow span name from uppercase "WORKFLOW" to lowercase "workflow"
for consistency with step spans and OTEL naming conventions
- Add step.execute child span to trace actual user step function execution
separately from step handler infrastructure
These changes enable local development to have the same observability as
production deployments, making performance analysis and debugging easier.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Use uppercase WORKFLOW and STEP span names for consistency with HTTP spans
HTTP spans use uppercase methods like "GET /path" and "POST /path".
Following the same convention, workflow and step spans now use:
- WORKFLOW <workflow-name>
- STEP <step-name>
Child spans (workflow.run, workflow.loadEvents, step.execute, world.events.create)
remain lowercase as they represent internal operations, not top-level entries.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add W3C trace context headers to step queue messages
Include traceparent and tracestate headers when queueing step
execution messages. This enables automatic trace propagation
by Vercel's infrastructure, potentially linking step invocation
spans to the parent workflow trace.
The trace carrier is now serialized once and included in both:
- Payload: for manual context restoration in step handler
- Headers: for automatic HTTP-based trace propagation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Improve OTEL tracing with service attribution and enhanced instrumentation
- Add peer.service attributes for workflow-server and VQS for Datadog service maps
- Rename queueMessage span to queue.publish for consistency
- Add step.hydrate, step.dehydrate, and workflow.replay spans
- Include event type in world.events.create span names (e.g., "world.events.create step_started")
- Add span.recordException() for errors with category classification (fatal/retryable/transient)
- Add span events for milestones: retry.scheduled, step.skipped, step.delayed
- Add HTTP semantic conventions with peer.service for world-vercel HTTP calls
- Add baggage propagation for workflow context (run_id, workflow_name)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Update changesets to reflect all PR changes
- step-handler-parallelization.md: Add race condition fix and 409 status code fix
- world-vercel-telemetry-tracer.md: Add peer.service and event type in span names
- otel-tracing-improvements.md: New changeset for comprehensive OTEL improvements
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Use span name for rpc.method to show event type in Datadog resource
Datadog derives the resource name from rpc.method attribute. Updated to
use the full span name (which includes event type) instead of just the
method name, so Datadog shows "world.events.create step_started" instead
of "world.events.create workflow-server".
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Use lazy ref resolution for events where client discards response data
Skip expensive S3 ref resolution (~200-460ms) for event types where
the client doesn't use the response entity data (step_created,
step_completed, step_failed, run_completed, etc). Only resolve refs
for run_created, run_started, and step_started where the client
reads the response.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update changeset to include lazy ref resolution optimization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address PR review comments: module scope const, comment cleanup, status code audit
- Move eventsNeedingResolve Set to module scope (PR review #2)
- Rewrite changelog-style OPTIMIZATION comments as current-state docs (PR review #4)
- Fix run terminal state errors: 410 → 409 in world-local and world-postgres to
match workflow-server's InvalidOperationStateError (409) (PR review #3)
- Fix remaining step terminal state 410 → 409 in world-postgres fallback paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: pass class as this context to custom serializer/deserializer methods
When calling WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE static methods,
use .call(ctor, value) and .call(cls, data) respectively to ensure the
class is passed as 'this' context. This is necessary for serializers that
access static properties or methods via 'this'.
Adds test case to verify this behavior.
* fix: address PR review feedback
- Fix test to actually use `this` instead of `Counter` directly, which
properly validates the fix
- Standardize variable naming: rename `ctor` to `cls` in the Instance
reducer for consistency with the reviver
* Add changeset
* fix: use this in test serde functions with biome-ignore
The test now properly uses `this.serializedCount` and `this.deserializedCount`
to validate the fix. Added biome-ignore comments to prevent the linter from
auto-fixing `this` back to the class name.
* Add x-workflow-run-id header to queue messages
Pass the workflow run ID as a custom header when enqueueing messages, enabling better observability and correlation with VQS server logs.
- Add headers field to QueueOptions interface in @workflow/world
- Pass headers through to VQS client in @workflow/world-vercel
- Include x-workflow-run-id header in all queueMessage calls
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Upgrade @vercel/queue to 0.0.0-alpha.36
This version includes the headers support needed for passing x-workflow-run-id to VQS.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Pass custom headers through world-local queue implementation
Custom headers are now spread first in the fetch headers so that system
headers (x-vqs-*, content-type) take precedence and cannot be overridden.
Also adds TODO comment in world-postgres noting headers are not propagated.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* Bump Next.js and React in workbenches
- Next.js: 16.0.10 → 16.1.6 in nextjs-turbopack, nextjs-webpack, swc-playground workbenches
- Next.js: 16.0.10 → 16.1.6 in @workflow/next devDependencies (for type compatibility)
- React/React-DOM: ^19.0.0/19.2.1 → 19.2.4
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add changeset
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Fixed a bug in module specifier resolution and added support for package subpath exports in workflow IDs.
### What changed?
- Fixed a caching bug in the module specifier resolution system that could cause incorrect IDs
- Added support for subpath exports in package IDs (e.g., `workflow/internal/builtins@4.0.0`)
- Improved module resolution by passing absolute file paths to the SWC transform
- Enhanced manifest merging to properly combine results from both workflow and step bundles
- Updated builders to return and merge manifests from both workflow and step bundles
### How to test?
1. Build a project that uses subpath exports in packages
2. Verify that workflow IDs correctly include the subpath (e.g., `workflow/internal/builtins@4.0.0`)
3. Test with a project that has multiple builds to ensure module specifier caching works correctly
### Why make this change?
This change addresses an issue where the module specifier cache could return incorrect results, leading to inconsistent workflow IDs. It also adds support for packages with multiple entry points through subpath exports, ensuring that steps with the same name in different subpaths don't collide. This improves the reliability of cross-bundle references and makes the system more robust when working with complex package structures.
* Consolidate console logging to structured logger utility
Replace all console.log, console.warn, and console.error calls in
packages/core/src with the structured logger utility from logger.ts.
This provides consistent, filterable logging with OpenTelemetry
integration.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add changeset
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Improve logging: fix levels and ensure visibility
- Logger now outputs error/warn to console (always visible, not just when DEBUG enabled)
- Downgrade warn → info for expected states (OTel unavailable, workflow/step already completed, idempotency cases)
- Downgrade error → warn for transient failures (step invoked erroneously, error with retry pending)
- Remove verbose debug logs from hot paths (events-consumer)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
## Summary
This PR changes how the SWC compiler generates IDs for workflows, steps, and classes. Instead of using raw file paths, IDs are now based on **Node.js module specifiers** when the file belongs to a package (either in `node_modules` or a workspace package).
## Motivation
Previously, IDs were generated using file paths like `step//src/jobs/order.ts//fetchData`. This caused several issues:
1. **Package exports conditions**: When a package uses conditional exports (e.g., `"workflow"` vs `"default"` conditions in `package.json`), the same import specifier can resolve to different files. Using file paths meant IDs could differ based on which export condition was used.
2. **Cross-bundle consistency**: Classes serialized in one bundle couldn't be deserialized in another if the file paths differed.
3. **Version tracking**: No way to include package versions in IDs for cache invalidation.
## Changes
### New ID Format
IDs now use the format `{type}//{modulePath}//{identifier}` where `modulePath` is either:
- A **module specifier** like `point@0.0.1` or `@myorg/shared@1.2.3` for package files
- A **relative path** prefixed with `./` like `./src/jobs/order` for local app files
Examples:
- `step//workflow@4.0.1-beta.50//fetch` (SDK step)
- `step//./workflows/order//processOrder` (local step)
- `class//point@0.0.1//Point` (package class)
- `class//./src/models/User//User` (local class)
### New Module Specifier Resolution
Added `packages/builders/src/module-specifier.ts` which:
- Detects if a file is in `node_modules` or a workspace package
- Finds the nearest `package.json` and extracts name/version
- Returns the module specifier for the SWC plugin to use
### SWC Plugin Changes
- Added `moduleSpecifier` option to plugin config
- Updated `naming.rs` to support both module specifiers and relative paths
- Added `get_module_path()` helper that uses specifier when available, falls back to `./filename` format
### Special Cases
- **Builtin functions** (`__builtin_*`): Continue to use just the function name as the ID for stable, version-independent lookup from the workflow VM runtime.
## Testing
- Updated all 125+ SWC plugin test fixtures to use new ID format
- Added tests for module specifier resolution
- Added tests for Windows path normalization in naming
## Breaking Changes
This is technically a breaking change for any persisted workflow runs that reference the old ID format. However, since IDs are internal implementation details and not user-facing, this should not affect end users.
## Files Changed
- `packages/builders/src/module-specifier.ts` - **NEW**: Module specifier resolution logic
- `packages/builders/src/apply-swc-transform.ts` - Pass module specifier to SWC plugin
- `packages/builders/src/base-builder.ts` - Use `getImportPath` for virtual entry imports
- `packages/swc-plugin-workflow/transform/src/lib.rs` - Accept and use module specifier
- `packages/swc-plugin-workflow/transform/src/naming.rs` - New ID formatting with module paths
- `packages/swc-plugin-workflow/spec.md` - Updated documentation
- `packages/core/e2e/e2e.test.ts` - Updated test assertions for new ID format
* Add 50, 100, 500 concurrent step benchmarks
Enable Promise.all and Promise.race benchmarks for 50, 100, and 500
concurrent steps (previously 100+ were skipped).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add 50, 100, 500 sequential step benchmarks
Extends the sequential step benchmarks to test workflows with 50, 100,
and 500 sequential steps in addition to the existing 10 step test.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add full/quick benchmark suite toggle for CI
- Add BENCHMARK_FULL_SUITE env var to control which benchmarks run
- Quick suite (default for PRs): 10, 25, 50 step benchmarks
- Full suite (main branch, manual dispatch): adds 100, 500 step benchmarks
- Add workflow_dispatch input to manually trigger full suite from GitHub UI
- Skip 100+ sequential and concurrent step benchmarks by default
This keeps PR benchmarks fast while allowing full stress testing on demand.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Add 25 sequential steps benchmark
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Fix benchmark API calls to use binary format
PR #853 changed the workflow trigger API to expect binary data
(application/octet-stream) instead of JSON. The e2e tests were updated
but the benchmark file was missed, causing all benchmarks to fail
silently since Jan 28.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Only run full benchmark suite on manual dispatch
Remove automatic full suite on main branch pushes - only run full suite
when manually triggered with full_suite=true.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Added support for `"use step"` directive in class instance methods, allowing instance methods to be used as workflow steps.
### What changed?
- Modified the SWC plugin to recognize and transform instance methods with the "use step" directive
- Added registration logic for instance method steps using `ClassName.prototype.methodName`
- Implemented proper serialization of class instances to preserve the `this` context across workflow/step boundaries
- Added comprehensive end-to-end tests for instance method steps
- Updated error handling to allow "use step" in instance methods while still preventing "use workflow" in instance methods
### How to test?
The PR includes a new end-to-end test `instanceMethodStepWorkflow` that demonstrates the functionality:
1. Run the e2e tests to verify the new instance method step functionality
2. The test creates a `Counter` class with instance methods marked as steps
3. It verifies that the instance methods can be called as steps with proper serialization of the `this` context
4. It also verifies that multiple instances of the same class can be used independently
### Why make this change?
This change enables a more natural object-oriented programming model when working with workflows. Previously, only static methods, standalone functions, and object methods could be marked as steps. Now, developers can create classes with instance methods that are steps, allowing for better encapsulation and more intuitive code organization. This is particularly useful for complex workflows that need to maintain state across multiple step invocations.
This PR ensures that all classes with custom serialization are automatically included in all bundle contexts (step, workflow, client) to ensure proper serialization/deserialization when crossing execution boundaries:
- Classes defined in any context can now be properly serialized when passing data between:
- Client → Workflow (when starting workflows)
- Workflow → Step (when calling steps)
- Step → Workflow (when returning step results)
- Workflow → Client (when returning workflow results)
- The build system now automatically discovers all files containing serializable classes and includes them in each bundle, regardless of where the class is originally defined.
- No manual configuration is required - cross-registration happens automatically during the build process.
Fixed class serialization/deserialization to properly respect VM context boundaries, ensuring workflow code only accesses classes registered in its own global context.
### What changed?
- Modified `getSerializationClass()` to no longer fall back to checking `globalThis` when a class isn't found in the provided global context
- Updated the serialization tests to properly simulate how classes are registered and accessed in both host and VM contexts
- Added VM-specific class registration in tests to better match production behavior where workflow code runs in isolation
### How to test?
- Run the updated serialization tests which now properly simulate the VM boundary
- Verify that class serialization/deserialization works correctly when classes are registered in their respective contexts
- Confirm that classes registered in one context aren't accessible from another context
### Why make this change?
This change ensures proper isolation between the host environment and workflow VM contexts, matching production serverless behavior where workflow code runs in isolation. Previously, the code could leak class registrations between contexts, potentially causing unexpected behavior or security issues. This fix maintains proper boundaries between execution contexts.
Fixed class ID generation for class expressions bound to variables in the SWC plugin.
### What changed?
- Added support for correctly handling class expressions with binding names that differ from internal class names
- Updated the SWC plugin to use the variable name (binding name) for registration instead of the internal class name
- Added test cases for class expressions with different binding names and anonymous class expressions
- Updated documentation to explain how class expressions with binding names are handled
### How to test?
Test with code patterns like:
```javascript
var Bash = class _Bash {
// Class implementation with serialization methods
};
```
Verify that the generated code correctly uses the binding name "Bash" for registration rather than the internal name "_Bash".
### Why make this change?
When a class expression is assigned to a variable, the internal class name is only accessible inside the class body, not at module level. Using the binding name ensures that the registration call references a symbol that's actually in scope at module level, preventing runtime errors when the code attempts to register the class for serialization.
Added automatic discovery for custom classes with workflow serialization, allowing serialization classes to be defined in separate files without requiring explicit directives.
### What changed?
- Added detection for files containing custom class serialization patterns:
- Files importing from `@workflow/serde`
- Files using `Symbol.for('workflow-serialize')` or `Symbol.for('workflow-deserialize')`
- Created shared utilities in `transform-utils.ts` for consistent pattern detection across all build tools
- Updated Next.js, Nitro, Rollup, and Vite plugins to use the new detection patterns
- Added exclusion logic to prevent re-processing of generated workflow files
- Added tests to verify the pattern detection works correctly
- Updated documentation in the SWC plugin spec to explain the new discovery mechanism
### How to test?
1. Create a class with custom serialization in a separate file:
```js
// models/point.js
export class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
static [Symbol.for('workflow-serialize')](instance) {
return { x: instance.x, y: instance.y };
}
static [Symbol.for('workflow-deserialize')](data) {
return new Point(data.x, data.y);
}
}
```
1. Import and use this class in a workflow or step file:
```js
'use workflow';
import { Point } from '../models/point';
export function myWorkflow() {
const point = new Point(10, 20);
return point;
}
```
1. Verify the class is properly serialized when passed between client and server
### Why make this change?
Previously, files containing custom serialization classes needed to include a `'use step'` directive to be discovered and transformed, even if they weren't actual step functions. This was unintuitive and could lead to confusion.
This change allows for a more natural code organization pattern where model classes with serialization can be defined in their own files without requiring directives. The build system will automatically discover and transform these files to ensure the serialization works correctly when the classes are used in workflows or steps.
* Fix bundling behavior for dynamic imports and server-only, client-only imports
* Generate changeset
* Filter tsconfig to only pull in code from within the current working directory(workflow dir)
* Rewrite server-only and client-only
* add additional pseudo directives
* upmerge
* fix failing unit test