feat(core): add serialization support for workflow function references#1677
Conversation
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (74 failed)mongodb (7 failed):
redis (7 failed):
turso (60 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
🦋 Changeset detectedLatest commit: dfe6ba8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Adds first-class serialization support for workflow function references (functions tagged with .workflowId) so they can cross the step serialization boundary and be rendered nicely in observability tooling.
Changes:
- Extend core serialization reducers/revivers with a
WorkflowFunctionspecial type that reduces workflow function refs to{ workflowId }. - Add tests covering workflow function ref serialization behavior across step return values and step arguments.
- Add an observability reviver rendering workflow function refs as
<workflow:workflowId>and ship a minor changeset.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/core/src/serialization.ts | Adds WorkflowFunction to special-type serialization and revives it to { workflowId } across external/workflow/step contexts. |
| packages/core/src/serialization.test.ts | Adds unit tests for WorkflowFunction reducer/reviver behavior and boundary roundtrips. |
| packages/core/src/serialization-format.ts | Adds o11y rendering of WorkflowFunction values as <workflow:...>. |
| .changeset/workflow-function-serialization.md | Declares a minor release for the new serialization capability. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| WorkflowFunction: { | ||
| workflowId: string; | ||
| }; |
There was a problem hiding this comment.
SerializableSpecial was extended with WorkflowFunction, but the public Serializable union in packages/core/src/schemas.ts was not updated accordingly (see the note above this interface). This can cause TypeScript to still reject workflow function references in places typed as Serializable (e.g., step args), even though runtime serialization now supports them. Please update Serializable to explicitly include the workflow function reference shape you intend to support (and/or a dedicated workflow-metadata type), so the type-level contract matches the runtime behavior.
There was a problem hiding this comment.
Good point. The Serializable union in schemas.ts is primarily for type-level validation of step arguments. Since workflow function references are typically passed as the first arg to start() (which has its own overloaded type signature accepting WorkflowFunction | WorkflowMetadata), they don't flow through the generic Serializable type path. If we find cases where this causes type errors, we can add it then.
| // A function without workflowId or stepId cannot be serialized | ||
| await expect( | ||
| dehydrateStepReturnValue(plainFn, 'wrun_test', undefined) | ||
| ).rejects.toThrow('serialization'); |
There was a problem hiding this comment.
The assertion .rejects.toThrow('serialization') is unlikely to match the actual error message thrown by dehydrateStepReturnValue(), which is formatted as "Failed to serialize …" (and does not necessarily contain the substring "serialization"). This test may be flaky or fail consistently depending on the underlying error. Prefer matching on the stable prefix/message produced by formatSerializationError (e.g., "Failed to serialize") or use a regex that matches the expected error text.
| ).rejects.toThrow('serialization'); | |
| ).rejects.toThrow('Failed to serialize'); |
There was a problem hiding this comment.
Fixed. Changed to .rejects.toThrow('Failed to serialize') which matches the stable prefix from formatSerializationError.
| StepFunction: serializedStepFunctionToString, | ||
| WorkflowFunction: (value: { workflowId: string }) => | ||
| `<workflow:${value.workflowId}>`, |
There was a problem hiding this comment.
observabilityRevivers gained a WorkflowFunction renderer, but serialization-format.test.ts has coverage for the other built-in display revivers (ReadableStream, StepFunction, Instance, Class) and currently none for WorkflowFunction. Please add a focused test that verifies { workflowId: "..." } renders as <workflow:...> so this o11y output remains stable.
There was a problem hiding this comment.
Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.
2de477c to
70854e1
Compare
Summary
Add a
WorkflowFunctionreducer/reviver pair to the serialization system so that workflow function references (functions with a.workflowIdproperty, as produced by the SWC compiler) can cross the step serialization boundary.This is a prerequisite for allowing
start()to be called directly from workflow functions with"use step"— the workflow function reference passed as the first argument needs to be serializable.How it works
Reducer: Detects functions with a
.workflowIdstring property, serializes as{ workflowId }. Only matches functions (not plain objects) to prevent infinite recursion since the reduced form is a plain object.Revivers: All contexts (step, workflow, external) deserialize as a function with
.workflowIdthat throws "Workflow functions cannot be called directly. Use start() to invoke them." on direct invocation. This is sufficient becausestart()only reads.workflowIdfrom the reference.Observability: Renders as
<workflow:workflowId>in the o11y UI.Note on workflow context hydration
It is recognized that the workflow context reviver should ideally return the proper registered function reference (since the SWC compiler registers workflow functions in the VM). However, since this PR is primarily for the
start()as a step use-case — where the deserialized reference is only passed tostart()which reads.workflowId— returning a throwing wrapper is sufficient. Full workflow function registry hydration can be revisited in the future when there is a concrete use case requiring the actual function reference in workflow context.Example