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
9 changes: 9 additions & 0 deletions .changeset/preserve-imports-used-by-hoisted-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@workflow/swc-plugin": patch
---

Fix three bugs affecting nested step functions that get hoisted out of an enclosing function (workflows in any declaration form, plus regular factory-style functions returning objects with step methods):

1. Module-level imports referenced only by hoisted step bodies were stripped by dead-code elimination, causing a `ReferenceError` at runtime.
2. The step ID generated for nested anonymous steps inside a non-exported workflow declared as `const foo = async () => {}` or `const foo = async function() {}` was not namespaced under the workflow name in step mode, so it did not match the ID looked up by the workflow-mode proxy and caused a runtime "step not found" failure. Steps inside `async function foo()` workflows were already namespaced correctly; this brings the const-arrow and const-fn-expression forms into agreement.
3. The `__internal_workflows` manifest comment reported nested anonymous step IDs without the workflow-name prefix even though the runtime registration and proxy lookup used the prefixed form, so downstream tooling (e.g. builders consuming the manifest) saw the wrong step ID.
229 changes: 169 additions & 60 deletions packages/swc-plugin-workflow/transform/src/lib.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// https://github.com/vercel/workflow/issues/1365
import { MockLanguageModelV3 } from 'ai/test';
import { xai as xaiProvider } from '@ai-sdk/xai';
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//_anonymousStep9"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//mockModel/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//xai/_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//withForIn/_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//withDoWhile/_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//withShorthandProps/_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//withComputedKey/_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//mockTextModel/_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//withClassExpr/_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//withClassSuper/_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//withClassProp/_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//mockModelWrapped/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//configuredStep/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withOptionalChaining/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withSequenceExpr/_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//withTryCatch/_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//withThrow/_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//withSwitch/_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//withForOf/_anonymousStep9"}}}}*/;
var mockModel$_anonymousStep0 = async ()=>{
const { args } = function() {
var __wf_ctx = globalThis[Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE")], __wf_store = __wf_ctx && __wf_ctx.getStore();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// https://github.com/vercel/workflow/issues/1365
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//_anonymousStep9"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//mockModel/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//xai/_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//withForIn/_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//withDoWhile/_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//withShorthandProps/_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//withComputedKey/_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//mockTextModel/_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//withClassExpr/_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//withClassSuper/_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//withClassProp/_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//mockModelWrapped/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//configuredStep/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withOptionalChaining/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withSequenceExpr/_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//withTryCatch/_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//withThrow/_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//withSwitch/_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//withForOf/_anonymousStep9"}}}}*/;
// Bug 1: `new` expressions should have their arguments captured as closure vars
export function mockModel(...args) {
return globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//mockModel/_anonymousStep0", ()=>({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"}}}}*/;
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//withTsAs/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//withTsSatisfies/_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//withTsNonNull/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//withTsTypeAssertion/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withTsConstAssertion/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withGenericCall/_anonymousStep5"}}}}*/;
var withTsAs$_anonymousStep0 = async ()=>{
const { config } = function() {
var __wf_ctx = globalThis[Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE")], __wf_store = __wf_ctx && __wf_ctx.getStore();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"}}}}*/;
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//withTsAs/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//withTsSatisfies/_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//withTsNonNull/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//withTsTypeAssertion/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withTsConstAssertion/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withGenericCall/_anonymousStep5"}}}}*/;
// TypeScript expression wrappers should not prevent closure variable detection.
// The plugin must traverse through `as`, `satisfies`, `!`, type assertions,
// const assertions, and instantiation expressions to reach the inner expression.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from 'fs/promises';
/**__internal_workflows{"steps":{"input.js":{"myFactory/myStep":{"stepId":"step//./input//myFactory/myStep"}}}}*/;
var myFactory$myStep = async function() {
await fs.mkdir('test');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
/**__internal_workflows{"steps":{"input.js":{"Service#process":{"stepId":"step//./input//Service#process"},"helper":{"stepId":"step//./input//helper"}}},"classes":{"input.js":{"Service":{"classId":"class//./input//Service"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"Service#process":{"stepId":"step//./input//Service#process"},"helper":{"stepId":"step//./input//Service$process/helper"}}},"classes":{"input.js":{"Service":{"classId":"class//./input//Service"}}}}*/;
var Service$process$helper = async (x)=>x * 2;
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"workflows":{"input.js":{"outer":{"workflowId":"workflow//./input//outer"}}},"steps":{"input.js":{"step":{"stepId":"step//./input//step"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"outer":{"workflowId":"workflow//./input//outer"}}},"steps":{"input.js":{"step":{"stepId":"step//./input//outer/step"}}}}*/;
async function outer$step() {
return arguments[0];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"workflows":{"input.js":{"outer":{"workflowId":"workflow//./input//outer"}}},"steps":{"input.js":{"step":{"stepId":"step//./input//step"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"outer":{"workflowId":"workflow//./input//outer"}}},"steps":{"input.js":{"step":{"stepId":"step//./input//outer/step"}}}}*/;
// `arguments` inside a nested `function`-form step body must NOT be treated
// as a closure variable — it's a per-function intrinsic binding that
// reflects the positional args the runtime passes via `stepFn.apply(thisVal,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//letArrowStep"},"step":{"stepId":"step//./input//step"},"varArrowStep":{"stepId":"step//./input//varArrowStep"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//example/arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//example/letArrowStep"},"step":{"stepId":"step//./input//example/step"},"varArrowStep":{"stepId":"step//./input//example/varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//letArrowStep"},"step":{"stepId":"step//./input//step"},"varArrowStep":{"stepId":"step//./input//varArrowStep"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//example/arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//example/letArrowStep"},"step":{"stepId":"step//./input//example/step"},"varArrowStep":{"stepId":"step//./input//example/varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//example/step");
// Arrow function with const
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
import { db } from './db'; // step-mode: kept (used by hoisted step)
import { unused } from './unused'; // step-mode: stripped (truly unused)
import * as logger from './logger'; // step-mode: kept (used by hoisted step)
import { tool, z } from 'some-agent-lib'; // step-mode: stripped (only referenced by replaced workflow body); workflow-mode: kept

async function w() {
'use workflow';
const agent = new WorkflowAgent({
model: 'anthropic/claude-opus-4.5',
tools: () => ({
queryDatabase: tool({
description: 'Query the database',
inputSchema: z.object({ query: z.string() }),
execute: async (input) => {
'use step';
logger.info('querying', input.query);
return db.query(input.query);
},
}),
}),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
import { db } from './db'; // step-mode: kept (used by hoisted step)
import * as logger from './logger'; // step-mode: kept (used by hoisted step)
/**__internal_workflows{"workflows":{"input.js":{"w":{"workflowId":"workflow//./input//w"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//w/_anonymousStep0"}}}}*/;
var w$_anonymousStep0 = async (input)=>{
logger.info('querying', input.query);
return db.query(input.query);
};
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
Object.defineProperty(__wf_fn, "name", {
value: "w$_anonymousStep0",
configurable: true
});
})(w$_anonymousStep0, "step//./input//w/_anonymousStep0");
async function w() {
throw new Error("You attempted to execute workflow w function directly. To start a workflow, use start(w) from workflow/api");
}
w.workflowId = "workflow//./input//w";
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
import { tool, z } from 'some-agent-lib'; // step-mode: stripped (only referenced by replaced workflow body); workflow-mode: kept
/**__internal_workflows{"workflows":{"input.js":{"w":{"workflowId":"workflow//./input//w"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//w/_anonymousStep0"}}}}*/;
async function w() {
const agent = new WorkflowAgent({
model: 'anthropic/claude-opus-4.5',
tools: ()=>({
queryDatabase: tool({
description: 'Query the database',
inputSchema: z.object({
query: z.string()
}),
execute: globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//w/_anonymousStep0")
})
})
});
}
w.workflowId = "workflow//./input//w";
globalThis.__private_workflows.set("workflow//./input//w", w);
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/**__internal_workflows{"workflows":{"input.js":{"wflow":{"workflowId":"workflow//./input//wflow"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"f":{"stepId":"step//./input//f"},"fn":{"stepId":"step//./input//fn"},"namedStepWithClosureVars":{"stepId":"step//./input//namedStepWithClosureVars"}}}}*/;
import { gateway } from 'ai';
/**__internal_workflows{"workflows":{"input.js":{"wflow":{"workflowId":"workflow//./input//wflow"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//stepWrapperReturnArrowFunction/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//arrowWrapperReturnArrowFunction/_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//wflow/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//wflow/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//wflow/_anonymousStep4"},"f":{"stepId":"step//./input//arrowWrapperReturnNamedFunction/f"},"fn":{"stepId":"step//./input//arrowWrapperReturnNamedFunctionVar/fn"},"namedStepWithClosureVars":{"stepId":"step//./input//wflow/namedStepWithClosureVars"}}}}*/;
var stepWrapperReturnArrowFunctionVar$fn = async ()=>{
const { a, b, c } = function() {
var __wf_ctx = globalThis[Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE")], __wf_store = __wf_ctx && __wf_ctx.getStore();
Expand Down
Loading
Loading