Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(core): add serialization support for workflow function references by TooTallNate · Pull Request #1677 · vercel/workflow · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/workflow-function-serialization.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": minor
---

Add serialization support for workflow function references
9 changes: 9 additions & 0 deletions packages/core/src/serialization-format.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,3 +710,12 @@ describe('encrypted data handling', () => {
});
});
});

describe('observabilityRevivers', () => {
it('should render WorkflowFunction as <workflow:workflowId>', () => {
const result = observabilityRevivers.WorkflowFunction({
workflowId: 'workflow//./workflows/example//myWorkflow',
});
expect(result).toBe('<workflow:workflow//./workflows/example//myWorkflow>');
});
});
2 changes: 2 additions & 0 deletions packages/core/src/serialization-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,8 @@ export const observabilityRevivers: Revivers = {
WritableStream: streamToStreamRef,
TransformStream: streamToStreamRef,
StepFunction: serializedStepFunctionToString,
WorkflowFunction: (value: { workflowId: string }) =>
`<workflow:${value.workflowId}>`,
Comment on lines 375 to +377

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

Added. New test in serialization-format.test.ts verifies observabilityRevivers.WorkflowFunction({ workflowId: '...' }) renders as <workflow:...>.

Instance: serializedInstanceToRef,
Class: serializedClassToString,
};
Expand Down
114 changes: 114 additions & 0 deletions packages/core/src/serialization.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4422,3 +4422,117 @@ describe('isEncrypted', () => {
expect(isEncrypted(new Uint8Array(2))).toBe(false);
});
});

describe('WorkflowFunction serialization', () => {
it('should serialize a function with workflowId and hydrate as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_myWorkflow' }
);
const dehydrated = await dehydrateStepReturnValue(
workflowFn,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
// Deserialized as a function with .workflowId that throws on direct call
expect(typeof hydrated).toBe('function');
expect((hydrated as any).workflowId).toBe('wf_myWorkflow');
expect(() => (hydrated as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should roundtrip through step arguments as a function with workflowId', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_argTest' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(typeof hydrated[0]).toBe('function');
expect((hydrated[0] as any).workflowId).toBe('wf_argTest');
expect(() => (hydrated[0] as any)()).toThrow(
'Workflow functions cannot be called directly'
);
});

it('should not match plain objects with workflowId (only functions)', async () => {
const workflowMeta = { workflowId: 'wf_otherWorkflow' };
const dehydrated = await dehydrateStepReturnValue(
workflowMeta,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ workflowId: 'wf_otherWorkflow' });
});

it('should roundtrip workflow function reference through step arguments', async () => {
const workflowFn = Object.assign(
async () => {
/* workflow body */
},
{ workflowId: 'wf_childWorkflow' }
);
const dehydrated = await dehydrateStepArguments(
[workflowFn, [42]],
'wrun_test',
undefined
);
const hydrated = await hydrateStepArguments(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated[0]).toHaveProperty('workflowId', 'wf_childWorkflow');
expect(hydrated[1]).toEqual([42]);
});

it('should not match objects without workflowId', async () => {
const plainObj = { name: 'test', value: 42 };
const dehydrated = await dehydrateStepReturnValue(
plainObj,
'wrun_test',
undefined
);
const hydrated = await hydrateStepReturnValue(
dehydrated,
'wrun_test',
undefined
);
expect(hydrated).toEqual({ name: 'test', value: 42 });
});

it('should not match functions without workflowId', async () => {
const plainFn = Object.assign(
async () => {
/* some function */
},
{ someOtherProp: 'test' }
);
await expect(
dehydrateStepReturnValue(plainFn, 'wrun_test', undefined)
).rejects.toThrow('Failed to serialize');
});
});
44 changes: 44 additions & 0 deletions packages/core/src/serialization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -653,6 +653,9 @@ export interface SerializableSpecial {
closureVars?: Record<string, any>;
};
URL: string;
WorkflowFunction: {
workflowId: string;
};
Comment on lines +656 to +658

CopilotAIApr 9, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

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.

URLSearchParams: string;
Uint8Array: string; // base64 string
Uint8ClampedArray: string; // base64 string
Expand DownExpand Up@@ -834,6 +837,16 @@ function getCommonReducers(global: Record<string, any> = globalThis) {
return { stepId };
},
URL: (value) => value instanceof global.URL && value.href,
WorkflowFunction: (value) => {
// Only match function references with a workflowId property (set by
// the SWC compiler on workflow functions). Plain { workflowId } objects
// are NOT matched — this prevents infinite recursion since the reduced
// form { workflowId } is a plain object, not a function.
if (typeof value !== 'function') return false;
const workflowId = (value as any).workflowId;
if (typeof workflowId !== 'string') return false;
return { workflowId };
},
URLSearchParams: (value) => {
if (!(value instanceof global.URLSearchParams)) return false;

Expand DownExpand Up@@ -1190,6 +1203,16 @@ export function getExternalRevivers(
);
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
return new global.Request(value.url, {
method: value.method,
Expand DownExpand Up@@ -1342,6 +1365,17 @@ export function getWorkflowRevivers(
}
return value;
},
// Workflow function reviver for workflow context — returns a function-like
// object with .workflowId that mimics what the SWC compiler produces,
WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),
Response: (value) => {
Object.setPrototypeOf(value, global.Response.prototype);
return value;
Expand DownExpand Up@@ -1455,6 +1489,16 @@ function getStepRevivers(
return stepFn;
},

WorkflowFunction: (value) =>
Object.assign(
() => {
throw new Error(
'Workflow functions cannot be called directly. Use start() to invoke them.'
);
},
{ workflowId: value.workflowId }
),

Request: (value) => {
const responseWritable = value.responseWritable;
const request = new global.Request(value.url, {
Expand Down
Loading