diff --git a/.changeset/clean-doodles-learn.md b/.changeset/clean-doodles-learn.md new file mode 100644 index 000000000..68e04645e --- /dev/null +++ b/.changeset/clean-doodles-learn.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Set `stepId` property on function in `registerStepFunction` for serialization support diff --git a/.changeset/tasty-rules-stick.md b/.changeset/tasty-rules-stick.md new file mode 100644 index 000000000..9b0a339f9 --- /dev/null +++ b/.changeset/tasty-rules-stick.md @@ -0,0 +1,5 @@ +--- +"@workflow/swc-plugin": patch +--- + +Set `stepId` property on step functions in "client" mode for serialization support diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index e6889123f..c68bba6ab 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -1545,6 +1545,70 @@ describe('e2e', () => { } ); + test( + 'stepFunctionAsStartArgWorkflow - step function reference passed as start() argument', + { timeout: 120_000 }, + async () => { + // This test verifies that step function references can be: + // 1. Serialized in the client bundle (the SWC plugin sets stepId property on the function) + // 2. Passed as arguments to start() + // 3. Deserialized in the workflow bundle (using WORKFLOW_USE_STEP from globalThis) + // 4. Invoked from within a step function in the workflow + // + // In client mode, the SWC plugin sets the `stepId` property directly on step functions + // (e.g., `myStepFn.stepId = "step//..."`). This allows the serialization layer to detect + // step functions and serialize them by their stepId. + // + // The workflow receives a step function reference (add) and: + // 1. Calls stepFn(3, 5) directly -> 8 + // 2. Passes it to invokeStepFn(stepFn, 3, 5) -> stepFn(3, 5) = 8 + // 3. Calls stepFn(8, 8) -> 16 + + // Look up the stepId for the `add` function from 98_duplicate_case.ts + // This simulates what the SWC plugin does in client mode: setting stepId on the function + const manifest = await fetchManifest(); + const stepFile = Object.keys(manifest.steps).find((f) => + f.includes('98_duplicate_case') + ); + assert(stepFile, 'Could not find 98_duplicate_case in manifest steps'); + const addStepInfo = manifest.steps[stepFile]?.['add']; + assert(addStepInfo, 'Could not find "add" step in manifest'); + + // Create a function reference with stepId, mimicking what the SWC client transform does + const addStepRef = Object.assign(() => {}, { + stepId: addStepInfo.stepId, + }); + + const run = await start(await e2e('stepFunctionAsStartArgWorkflow'), [ + addStepRef, + 3, + 5, + ]); + const returnValue = await run.returnValue; + + // Verify the workflow result + // directResult: stepFn called directly from workflow code = add(3, 5) = 8 + // viaStepResult: stepFn called via invokeStepFn = add(3, 5) = 8 + // doubled: stepFn(8, 8) = 16 + expect(returnValue).toEqual({ + directResult: 8, + viaStepResult: 8, + doubled: 16, + }); + + // Verify the run completed successfully via CLI + const { json: runData } = await cliInspectJson( + `runs ${run.runId} --withData` + ); + expect(runData.status).toBe('completed'); + expect(runData.output).toEqual({ + directResult: 8, + viaStepResult: 8, + doubled: 16, + }); + } + ); + // ==================== PAGES ROUTER TESTS ==================== // Tests for Next.js Pages Router API endpoint (only runs for nextjs-turbopack and nextjs-webpack) const isNextJsApp = diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts index d893b459f..ee9738aa5 100644 --- a/packages/core/src/private.ts +++ b/packages/core/src/private.ts @@ -11,15 +11,18 @@ export type StepFunction< Result extends Serializable | unknown = unknown, > = ((...args: Args) => Promise) & { maxRetries?: number; + stepId?: string; }; const registeredSteps = new Map(); /** - * Register a step function to be served in the server bundle + * Register a step function to be served in the server bundle. + * Also sets the stepId property on the function for serialization support. */ export function registerStepFunction(stepId: string, stepFn: StepFunction) { registeredSteps.set(stepId, stepFn); + stepFn.stepId = stepId; } /** diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index 00dea2700..13db84908 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -1688,25 +1688,58 @@ describe('step function serialization', () => { }); it('should deserialize step function name through reviver', () => { - const stepName = 'testStep'; + const stepName = 'step//test//testStep'; const stepFn = async () => 42; // Register the step function registerStepFunction(stepName, stepFn); - // Get the reviver and test it directly - const revivers = getCommonRevivers(vmGlobalThis); - const result = revivers.StepFunction({ stepId: stepName }); + // Create a function with stepId property (like registerStepFunction does) + const fnWithStepId = async () => 42; + Object.defineProperty(fnWithStepId, 'stepId', { + value: stepName, + writable: false, + enumerable: false, + configurable: false, + }); + + // Serialize using workflow reducers (which handle StepFunction) + const dehydrated = dehydrateStepArguments([fnWithStepId], globalThis); - expect(result).toBe(stepFn); + // Hydrate it back using step revivers + const ops: Promise[] = []; + const hydrated = hydrateStepArguments( + dehydrated, + ops, + mockRunId, + globalThis + ); + + // The hydrated result should be the registered step function + expect(hydrated[0]).toBe(stepFn); }); it('should throw error when reviver cannot find registered step function', () => { - const revivers = getCommonRevivers(vmGlobalThis); + // Create a function with a non-existent stepId + const fnWithNonExistentStepId = async () => 42; + Object.defineProperty(fnWithNonExistentStepId, 'stepId', { + value: 'nonExistentStep', + writable: false, + enumerable: false, + configurable: false, + }); + + // Serialize the step function reference + const dehydrated = dehydrateStepArguments( + [fnWithNonExistentStepId], + globalThis + ); + // Hydrating should throw an error + const ops: Promise[] = []; let err: Error | undefined; try { - revivers.StepFunction({ stepId: 'nonExistentStep' }); + hydrateStepArguments(dehydrated, ops, mockRunId, globalThis); } catch (err_) { err = err_ as Error; } @@ -1849,6 +1882,97 @@ describe('step function serialization', () => { // Should return object with stepId expect(result).toEqual({ stepId: stepName }); }); + + it('should hydrate step function from workflow arguments using WORKFLOW_USE_STEP', () => { + // This tests the flow: client mode serializes step function with stepId, + // workflow mode deserializes it using WORKFLOW_USE_STEP from vmGlobalThis + const stepId = 'step//workflows/test.ts//addNumbers'; + + // Create a VM context like the workflow runner does + const { context, globalThis: vmGlobalThis } = createContext({ + seed: 'test', + fixedTimestamp: 1714857600000, + }); + + // Set up WORKFLOW_USE_STEP on the VM's globalThis (like workflow.ts does) + const mockUseStep = (id: string) => { + const fn = (...args: any[]) => { + // Return a promise that resolves with args (like useStep wrapper does) + return Promise.resolve({ calledWithStepId: id, args }); + }; + fn.stepId = id; + return fn; + }; + (vmGlobalThis as any)[Symbol.for('WORKFLOW_USE_STEP')] = mockUseStep; + + // Create a function with stepId (like SWC plugin does in client mode) + const clientStepFn = async (a: number, b: number) => a + b; + Object.defineProperty(clientStepFn, 'stepId', { + value: stepId, + writable: false, + enumerable: false, + configurable: false, + }); + + // Serialize from client side using external reducers + const ops: Promise[] = []; + const dehydrated = dehydrateWorkflowArguments( + [clientStepFn, 3, 5], + ops, + mockRunId, + globalThis + ); + + // Hydrate in workflow context using VM's globalThis + const hydrated = hydrateWorkflowArguments(dehydrated, vmGlobalThis); + + // Verify the hydrated result + expect(Array.isArray(hydrated)).toBe(true); + expect(hydrated).toHaveLength(3); + + const [hydratedStepFn, arg1, arg2] = hydrated; + + // The step function should be a function (from useStep wrapper) + expect(typeof hydratedStepFn).toBe('function'); + expect(arg1).toBe(3); + expect(arg2).toBe(5); + + // The hydrated function should have stepId + expect(hydratedStepFn.stepId).toBe(stepId); + }); + + it('should throw error when WORKFLOW_USE_STEP is not set on globalThis', () => { + const stepId = 'step//workflows/test.ts//missingUseStep'; + + // Create a VM context WITHOUT setting up WORKFLOW_USE_STEP + const { context, globalThis: vmGlobalThis } = createContext({ + seed: 'test', + fixedTimestamp: 1714857600000, + }); + + // Create a function with stepId + const clientStepFn = async (a: number, b: number) => a + b; + Object.defineProperty(clientStepFn, 'stepId', { + value: stepId, + writable: false, + enumerable: false, + configurable: false, + }); + + // Serialize from client side + const ops: Promise[] = []; + const dehydrated = dehydrateWorkflowArguments( + [clientStepFn], + ops, + mockRunId, + globalThis + ); + + // Hydrating should throw because WORKFLOW_USE_STEP is not set + expect(() => hydrateWorkflowArguments(dehydrated, vmGlobalThis)).toThrow( + 'WORKFLOW_USE_STEP not found on global object' + ); + }); }); describe('custom class serialization', () => { diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index fb4ce0b28..bd41a3684 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -893,59 +893,6 @@ export function getCommonRevivers(global: Record = globalThis) { return deserialize.call(cls, data); }, Set: (value) => new global.Set(value), - StepFunction: (value) => { - const stepId = value.stepId; - const closureVars = value.closureVars; - - const stepFn = getStepFunction(stepId); - if (!stepFn) { - throw new Error( - `Step function "${stepId}" not found. Make sure the step function is registered.` - ); - } - - // If closure variables were serialized, return a wrapper function - // that sets up AsyncLocalStorage context when invoked - if (closureVars) { - const wrappedStepFn = ((...args: any[]) => { - // Get the current context from AsyncLocalStorage - const currentContext = contextStorage.getStore(); - - if (!currentContext) { - throw new Error( - 'Cannot call step function with closure variables outside step context' - ); - } - - // Create a new context with the closure variables merged in - const newContext = { - ...currentContext, - closureVars, - }; - - // Run the step function with the new context that includes closure vars - return contextStorage.run(newContext, () => stepFn(...args)); - }) as any; - - // Copy properties from original step function - Object.defineProperty(wrappedStepFn, 'name', { - value: stepFn.name, - }); - Object.defineProperty(wrappedStepFn, 'stepId', { - value: stepId, - writable: false, - enumerable: false, - configurable: false, - }); - if (stepFn.maxRetries !== undefined) { - wrappedStepFn.maxRetries = stepFn.maxRetries; - } - - return wrappedStepFn; - } - - return stepFn; - }, URL: (value) => new global.URL(value), URLSearchParams: (value) => new global.URLSearchParams(value === '.' ? '' : value), @@ -984,6 +931,13 @@ export function getExternalRevivers( return { ...getCommonRevivers(global), + // StepFunction should not be returned from workflows to clients + StepFunction: () => { + throw new Error( + 'Step functions cannot be deserialized in client context. Step functions should not be returned from workflows.' + ); + }, + Request: (value) => { return new global.Request(value.url, { method: value.method, @@ -1088,8 +1042,37 @@ export function getExternalRevivers( export function getWorkflowRevivers( global: Record = globalThis ): Revivers { + // Get the useStep function from the VM's globalThis + // This is set up by the workflow runner in workflow.ts + // Use Symbol.for directly to access the symbol on the global object + const useStep = (global as any)[Symbol.for('WORKFLOW_USE_STEP')] as + | (( + stepId: string, + closureVarsFn?: () => Record + ) => (...args: unknown[]) => Promise) + | undefined; + return { ...getCommonRevivers(global), + // StepFunction reviver for workflow context - returns useStep wrapper + // This allows step functions passed as arguments to start() to be called directly + // from workflow code, just like step functions defined in the same file + StepFunction: (value) => { + const stepId = value.stepId; + const closureVars = value.closureVars; + + if (!useStep) { + throw new Error( + 'WORKFLOW_USE_STEP not found on global object. Step functions cannot be deserialized outside workflow context.' + ); + } + + if (closureVars) { + // For step functions with closure variables, create a wrapper that provides them + return useStep(stepId, () => closureVars); + } + return useStep(stepId); + }, Request: (value) => { Object.setPrototypeOf(value, global.Request.prototype); const responseWritable = value.responseWritable; @@ -1160,6 +1143,62 @@ function getStepRevivers( return { ...getCommonRevivers(global), + // StepFunction reviver for step context - returns raw step function + // with closure variable support via AsyncLocalStorage + StepFunction: (value) => { + const stepId = value.stepId; + const closureVars = value.closureVars; + + const stepFn = getStepFunction(stepId); + if (!stepFn) { + throw new Error( + `Step function "${stepId}" not found. Make sure the step function is registered.` + ); + } + + // If closure variables were serialized, return a wrapper function + // that sets up AsyncLocalStorage context when invoked + if (closureVars) { + const wrappedStepFn = ((...args: any[]) => { + // Get the current context from AsyncLocalStorage + const currentContext = contextStorage.getStore(); + + if (!currentContext) { + throw new Error( + 'Cannot call step function with closure variables outside step context' + ); + } + + // Create a new context with the closure variables merged in + const newContext = { + ...currentContext, + closureVars, + }; + + // Run the step function with the new context that includes closure vars + return contextStorage.run(newContext, () => stepFn(...args)); + }) as any; + + // Copy properties from original step function + Object.defineProperty(wrappedStepFn, 'name', { + value: stepFn.name, + }); + Object.defineProperty(wrappedStepFn, 'stepId', { + value: stepId, + writable: false, + enumerable: false, + configurable: false, + }); + if (stepFn.maxRetries !== undefined) { + wrappedStepFn.maxRetries = stepFn.maxRetries; + } + + return wrappedStepFn; + } + + return stepFn; + }, + Request: (value) => { const responseWritable = value.responseWritable; const request = new global.Request(value.url, { diff --git a/packages/swc-plugin-workflow/spec.md b/packages/swc-plugin-workflow/spec.md index d28db0aa1..4b80b43ad 100644 --- a/packages/swc-plugin-workflow/spec.md +++ b/packages/swc-plugin-workflow/spec.md @@ -208,7 +208,7 @@ Output (Step Mode): ```javascript import { registerStepFunction } from "workflow/internal/private"; import { agent } from "experimental-agent"; -/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//input.js//vade/tools/VercelRequest/execute"}}}}*/; +/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/; var vade$tools$VercelRequest$execute = async function(input, ctx) { return 1 + 1; }; @@ -219,7 +219,7 @@ export const vade = agent({ } } }); -registerStepFunction("step//input.js//vade/tools/VercelRequest/execute", vade$tools$VercelRequest$execute); +registerStepFunction("step//./input//vade/tools/VercelRequest/execute", vade$tools$VercelRequest$execute); ``` Note: Step functions are hoisted as regular function expressions (not arrow functions) to preserve `this` binding when called with `.call()` or `.apply()`. This applies even when the original step function was defined as an arrow function. @@ -227,16 +227,35 @@ Note: Step functions are hoisted as regular function expressions (not arrow func Output (Workflow Mode): ```javascript import { agent } from "experimental-agent"; -/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//input.js//vade/tools/VercelRequest/execute"}}}}*/; +/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/; export const vade = agent({ tools: { VercelRequest: { - execute: globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//vade/tools/VercelRequest/execute") + execute: globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//vade/tools/VercelRequest/execute") } } }); ``` +Output (Client Mode): +```javascript +import { agent } from "experimental-agent"; +/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/; +var vade$tools$VercelRequest$execute = async function(input, ctx) { + return 1 + 1; +}; +export const vade = agent({ + tools: { + VercelRequest: { + execute: vade$tools$VercelRequest$execute + } + } +}); +vade$tools$VercelRequest$execute.stepId = "step//./input//vade/tools/VercelRequest/execute"; +``` + +Note: In client mode, nested object property step functions are hoisted and have `stepId` set directly (no `registerStepFunction` call). The original call site is replaced with a reference to the hoisted variable, same as step mode. + Note: The step ID includes the full path through nested objects (`vade/tools/VercelRequest/execute`), while the hoisted variable name uses `$` as the separator (`vade$tools$VercelRequest$execute`) to create a valid JavaScript identifier. #### Shorthand Method Syntax @@ -263,7 +282,7 @@ Output (Step Mode): ```javascript import { registerStepFunction } from "workflow/internal/private"; import { agent } from "experimental-agent"; -/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//input.js//vade/tools/VercelRequest/execute"}}}}*/; +/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/; var vade$tools$VercelRequest$execute = async function(input, { experimental_context }) { return 1 + 1; }; @@ -274,7 +293,7 @@ export const vade = agent({ } } }); -registerStepFunction("step//input.js//vade/tools/VercelRequest/execute", vade$tools$VercelRequest$execute); +registerStepFunction("step//./input//vade/tools/VercelRequest/execute", vade$tools$VercelRequest$execute); ``` Note: Shorthand methods are hoisted as regular function expressions (not arrow functions) to preserve `this` binding when called with `.call()` or `.apply()`. Closure variables are handled the same way as other step functions. @@ -468,7 +487,11 @@ globalThis.__private_workflows.set("workflow//./input//myWorkflow", myWorkflow); ## Client Mode -In client mode, step function bodies are preserved as-is (allowing local testing/execution). Workflow functions throw an error and have `workflowId` attached for use with `start()`. +In client mode, step function bodies are preserved as-is (allowing local testing/execution), and step functions have their `stepId` property set so they can be properly serialized when passed across boundaries (e.g., as arguments to `start()` or returned from other step functions). Workflow functions throw an error and have `workflowId` attached for use with `start()`. + +Unlike step mode, client mode does **not** import `registerStepFunction` from `workflow/internal/private` because that module contains server-side dependencies. Instead, the `stepId` property is set directly on the function, similar to how `workflowId` is set on workflow functions. + +Note: Step functions nested inside other functions (whether workflow functions or regular functions) do NOT get `stepId` assignments in client mode because they are not accessible at module level. ### Step Functions @@ -486,6 +509,7 @@ Output: export async function add(a, b) { return a + b; } +add.stepId = "step//./input//add"; ``` ### Workflow Functions diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs index b63f10233..0974fc22f 100644 --- a/packages/swc-plugin-workflow/transform/src/lib.rs +++ b/packages/swc-plugin-workflow/transform/src/lib.rs @@ -323,6 +323,8 @@ pub struct StepTransform { workflow_exports_to_expand: Vec<(String, Expr, swc_core::common::Span)>, // Track workflow functions that need workflowId property in client mode workflow_functions_needing_id: Vec<(String, swc_core::common::Span)>, + // Track step functions that need stepId property in client mode + step_functions_needing_id: Vec<(String, swc_core::common::Span)>, // Track step function exports that need to be converted to const declarations in workflow mode step_exports_to_convert: Vec<(String, String, swc_core::common::Span)>, // (fn_name, step_id, span) // Track default exports that need to be replaced with expressions @@ -1031,7 +1033,9 @@ impl StepTransform { return; } TransformMode::Client => { - // In client mode, just remove the directive and keep the function + // In client mode for nested step functions, just remove directive + // WITHOUT registering - the function will be undefined since the + // workflow body is replaced with throw Error self.remove_use_step_directive(&mut fn_decl.function.body); return; } @@ -1043,6 +1047,13 @@ impl StepTransform { self.create_registration_call(&fn_name, fn_decl.function.span); stmt.visit_mut_children_with(self); } + TransformMode::Client => { + // In client mode, track for stepId assignment instead of registration + self.remove_use_step_directive(&mut fn_decl.function.body); + self.step_functions_needing_id + .push((fn_name.clone(), fn_decl.function.span)); + stmt.visit_mut_children_with(self); + } TransformMode::Workflow => { self.remove_use_step_directive(&mut fn_decl.function.body); if let Some(body) = &mut fn_decl.function.body { @@ -1073,10 +1084,6 @@ impl StepTransform { })]; } } - TransformMode::Client => { - self.remove_use_step_directive(&mut fn_decl.function.body); - stmt.visit_mut_children_with(self); - } } } } @@ -1088,9 +1095,10 @@ impl StepTransform { match self.mode { TransformMode::Step => { // First visit children to process nested step functions + // This must happen BEFORE replacing the body so nested steps are hoisted stmt.visit_mut_children_with(self); - // After step hoisting, re-extract fn_decl and replace workflow body with throw error + // After processing nested steps, re-extract fn_decl and replace workflow body with throw error if let Stmt::Decl(Decl::Fn(fn_decl)) = stmt { self.remove_use_workflow_directive(&mut fn_decl.function.body); if let Some(body) = &mut fn_decl.function.body { @@ -1130,6 +1138,8 @@ impl StepTransform { stmt.visit_mut_children_with(self); } TransformMode::Client => { + // In client mode, don't visit children - nested steps inside workflows + // are unreachable since the workflow body is replaced with throw error self.remove_use_workflow_directive(&mut fn_decl.function.body); if let Some(body) = &mut fn_decl.function.body { let error_msg = format!( @@ -1161,7 +1171,6 @@ impl StepTransform { } self.workflow_functions_needing_id .push((fn_name.clone(), fn_span)); - stmt.visit_mut_children_with(self); } } } @@ -1234,6 +1243,7 @@ impl StepTransform { current_parent_function_name: None, workflow_exports_to_expand: Vec::new(), workflow_functions_needing_id: Vec::new(), + step_functions_needing_id: Vec::new(), step_exports_to_convert: Vec::new(), default_exports_to_replace: Vec::new(), default_workflow_exports: Vec::new(), @@ -1745,7 +1755,39 @@ impl StepTransform { )); } TransformMode::Client => { - // In client mode, just remove the directive (already done above) + // In client mode, replace method with key-value property referencing the hoisted variable + // (same as step mode) so the stepId property is accessible + let safe_parent_name = parent_var_name.replace('/', "$"); + let hoist_var_name = if let Some(ref workflow_name) = + self.current_workflow_function_name + { + format!( + "{}${}${}", + workflow_name, safe_parent_name, prop_key + ) + } else { + format!("{}${}", safe_parent_name, prop_key) + }; + let step_id = self.create_object_property_id( + parent_var_name, + &prop_key, + false, + self.current_workflow_function_name.as_deref(), + ); + // Replace the method with a key-value property referencing the hoisted function + *boxed_prop = Box::new(Prop::KeyValue(KeyValueProp { + key: method_prop.key.clone(), + value: Box::new(Expr::Ident(Ident::new( + hoist_var_name.into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + })); + self.object_property_workflow_conversions.push(( + parent_var_name.to_string(), + prop_key, + step_id, + )); } } } @@ -1805,7 +1847,26 @@ impl StepTransform { )); } TransformMode::Client => { - // In client mode, just remove the directive + // In client mode, replace with reference to hoisted variable + // (same as step mode) so the stepId property is accessible + let safe_parent_name = parent_var_name.replace('/', "$"); + let hoist_var_name = + if let Some(ref workflow_name) = self.current_workflow_function_name { + format!("{}${}${}", workflow_name, safe_parent_name, prop_key) + } else { + format!("{}${}", safe_parent_name, prop_key) + }; + *kv_prop.value = Expr::Ident(Ident::new( + hoist_var_name.into(), + DUMMY_SP, + SyntaxContext::empty(), + )); + // Track for metadata + self.object_property_workflow_conversions.push(( + parent_var_name.to_string(), + prop_key.to_string(), + step_id, + )); } } } @@ -2781,6 +2842,39 @@ impl StepTransform { }) } + // Create a statement that adds stepId property to a function (client mode) + // Creates: functionName.stepId = "stepId" + fn create_step_id_assignment(&self, fn_name: &str, span: swc_core::common::Span) -> Stmt { + let step_id = self.create_id(Some(fn_name), span, false); + self.create_step_id_assignment_with_id(fn_name, &step_id) + } + + // Create a statement that adds stepId property to a function with a pre-computed step_id (client mode) + // Creates: functionName.stepId = "stepId" + fn create_step_id_assignment_with_id(&self, fn_name: &str, step_id: &str) -> Stmt { + Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Assign(AssignExpr { + span: DUMMY_SP, + op: AssignOp::Assign, + left: AssignTarget::Simple(SimpleAssignTarget::Member(MemberExpr { + span: DUMMY_SP, + obj: Box::new(Expr::Ident(Ident::new( + fn_name.into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + prop: MemberProp::Ident(IdentName::new("stepId".into(), DUMMY_SP)), + })), + right: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: step_id.into(), + raw: None, + }))), + })), + }) + } + // Create a workflow registration call for workflow mode: // globalThis.__private_workflows.set("workflowId", functionName); fn create_workflow_registration(&self, fn_name: &str, span: swc_core::common::Span) -> Stmt { @@ -3584,8 +3678,9 @@ impl VisitMut for StepTransform { } } TransformMode::Client => { - // In client mode, we still need class serialization registration - // so that classes can be serialized when passed to start(workflow) + // In client mode, we use stepId property assignments instead of registerStepFunction + // for step functions, so no need to import registerStepFunction. + // Class serialization registration is still needed. let needs_class_serialization = !self.classes_needing_serialization.is_empty(); if needs_class_serialization { @@ -3599,8 +3694,8 @@ impl VisitMut for StepTransform { module.body.insert(0, import); } - // Add hoisted object property functions and registration calls at the end for step mode - if matches!(self.mode, TransformMode::Step) { + // Add hoisted object property functions and registration calls at the end for step mode or client mode + if matches!(self.mode, TransformMode::Step | TransformMode::Client) { // Calculate insertion position once before any hoisting let initial_insert_pos = module .body @@ -3732,46 +3827,55 @@ impl VisitMut for StepTransform { module.body.insert(current_insert_pos, hoisted_decl); current_insert_pos += 1; - // Create a registration call with parent workflow name in the step ID + // Create a registration call or stepId assignment with parent workflow name in the step ID let step_fn_name = if parent_workflow_name.is_empty() { fn_name.clone() } else { format!("{}/{}", parent_workflow_name, fn_name) }; let step_id = self.create_id(Some(&step_fn_name), span, false); - let registration_call = Stmt::Expr(ExprStmt { - span: DUMMY_SP, - expr: Box::new(Expr::Call(CallExpr { + + if self.mode == TransformMode::Client { + // In client mode, use stepId property assignment instead of registerStepFunction + let step_id_assignment = + self.create_step_id_assignment_with_id(&hoisted_name, &step_id); + self.registration_calls.push(step_id_assignment); + } else { + // In step mode, use registerStepFunction + let registration_call = Stmt::Expr(ExprStmt { span: DUMMY_SP, - ctxt: SyntaxContext::empty(), - callee: Callee::Expr(Box::new(Expr::Ident(Ident::new( - "registerStepFunction".into(), - DUMMY_SP, - SyntaxContext::empty(), - )))), - args: vec![ - ExprOrSpread { - spread: None, - expr: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: step_id.into(), - raw: None, - }))), - }, - ExprOrSpread { - spread: None, - expr: Box::new(Expr::Ident(Ident::new( - hoisted_name.into(), - DUMMY_SP, - SyntaxContext::empty(), - ))), - }, - ], - type_args: None, - })), - }); + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + callee: Callee::Expr(Box::new(Expr::Ident(Ident::new( + "registerStepFunction".into(), + DUMMY_SP, + SyntaxContext::empty(), + )))), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: step_id.into(), + raw: None, + }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(Ident::new( + hoisted_name.into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + }, + ], + type_args: None, + })), + }); - self.registration_calls.push(registration_call); + self.registration_calls.push(registration_call); + } } // Then process object property step functions (they typically appear later) @@ -3832,40 +3936,47 @@ impl VisitMut for StepTransform { module.body.insert(current_insert_pos, hoisted_decl); current_insert_pos += 1; - // Create a registration call - let registration_call = Stmt::Expr(ExprStmt { - span: DUMMY_SP, - expr: Box::new(Expr::Call(CallExpr { + if self.mode == TransformMode::Client { + // In client mode, use stepId property assignment instead of registerStepFunction + let step_id_assignment = + self.create_step_id_assignment_with_id(&hoist_var_name, &step_id); + self.registration_calls.push(step_id_assignment); + } else { + // In step mode, use registerStepFunction + let registration_call = Stmt::Expr(ExprStmt { span: DUMMY_SP, - ctxt: SyntaxContext::empty(), - callee: Callee::Expr(Box::new(Expr::Ident(Ident::new( - "registerStepFunction".into(), - DUMMY_SP, - SyntaxContext::empty(), - )))), - args: vec![ - ExprOrSpread { - spread: None, - expr: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: step_id.into(), - raw: None, - }))), - }, - ExprOrSpread { - spread: None, - expr: Box::new(Expr::Ident(Ident::new( - hoist_var_name.into(), - DUMMY_SP, - SyntaxContext::empty(), - ))), - }, - ], - type_args: None, - })), - }); + expr: Box::new(Expr::Call(CallExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + callee: Callee::Expr(Box::new(Expr::Ident(Ident::new( + "registerStepFunction".into(), + DUMMY_SP, + SyntaxContext::empty(), + )))), + args: vec![ + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: step_id.into(), + raw: None, + }))), + }, + ExprOrSpread { + spread: None, + expr: Box::new(Expr::Ident(Ident::new( + hoist_var_name.into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + }, + ], + type_args: None, + })), + }); - self.registration_calls.push(registration_call); + self.registration_calls.push(registration_call); + } } for call in self.registration_calls.drain(..) { @@ -5118,6 +5229,116 @@ impl VisitMut for StepTransform { // Clear the workflow_functions_needing_id since we've already processed them self.workflow_functions_needing_id.clear(); + // In client mode, add stepId property assignments for step functions + if self.mode == TransformMode::Client && !self.step_functions_needing_id.is_empty() { + let step_functions: Vec<_> = self.step_functions_needing_id.drain(..).collect(); + let mut items_to_insert: Vec<(usize, ModuleItem)> = Vec::new(); + + for (i, item) in items.iter().enumerate() { + match item { + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => { + // Exported step functions + match &export_decl.decl { + Decl::Fn(fn_decl) => { + let fn_name = fn_decl.ident.sym.to_string(); + if step_functions.iter().any(|(name, _)| name == &fn_name) { + items_to_insert.push(( + i + 1, + ModuleItem::Stmt(self.create_step_id_assignment( + &fn_name, + fn_decl.function.span, + )), + )); + } + } + Decl::Var(var_decl) => { + for declarator in &var_decl.decls { + if let Pat::Ident(binding) = &declarator.name { + let name = binding.id.sym.to_string(); + if step_functions.iter().any(|(n, _)| n == &name) { + if let Some(init) = &declarator.init { + let span = match &**init { + Expr::Fn(fn_expr) => fn_expr.function.span, + Expr::Arrow(arrow_expr) => arrow_expr.span, + _ => declarator.span, + }; + items_to_insert.push(( + i + 1, + ModuleItem::Stmt( + self.create_step_id_assignment(&name, span), + ), + )); + } + } + } + } + } + _ => {} + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(default_decl)) => { + // Default exported step function + if let DefaultDecl::Fn(fn_expr) = &default_decl.decl { + if let Some(ident) = &fn_expr.ident { + let fn_name = ident.sym.to_string(); + if step_functions.iter().any(|(name, _)| name == &fn_name) { + items_to_insert.push(( + i + 1, + ModuleItem::Stmt(self.create_step_id_assignment( + &fn_name, + fn_expr.function.span, + )), + )); + } + } + } + } + ModuleItem::Stmt(Stmt::Decl(Decl::Fn(fn_decl))) => { + // Non-exported function declaration + let fn_name = fn_decl.ident.sym.to_string(); + if step_functions.iter().any(|(name, _)| name == &fn_name) { + items_to_insert.push(( + i + 1, + ModuleItem::Stmt( + self.create_step_id_assignment(&fn_name, fn_decl.function.span), + ), + )); + } + } + ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) => { + // Non-exported variable declaration + for declarator in &var_decl.decls { + if let Pat::Ident(binding) = &declarator.name { + let name = binding.id.sym.to_string(); + if step_functions.iter().any(|(n, _)| n == &name) { + if let Some(init) = &declarator.init { + let span = match &**init { + Expr::Fn(fn_expr) => fn_expr.function.span, + Expr::Arrow(arrow_expr) => arrow_expr.span, + _ => declarator.span, + }; + items_to_insert.push(( + i + 1, + ModuleItem::Stmt( + self.create_step_id_assignment(&name, span), + ), + )); + } + } + } + } + } + _ => {} + } + } + + // Insert items in reverse order to maintain correct indices + items_to_insert.sort_by(|a, b| b.0.cmp(&a.0)); + for (pos, item) in items_to_insert { + items.insert(pos, item); + } + } + // In workflow mode, convert step functions to const declarations // (Must be after visit_mut_children_with so step_function_names is populated) if self.mode == TransformMode::Workflow { @@ -5306,14 +5527,20 @@ impl VisitMut for StepTransform { self.remove_use_step_directive(&mut fn_decl.function.body); self.create_registration_call(&fn_name, fn_decl.function.span); } + TransformMode::Client => { + self.remove_use_step_directive(&mut fn_decl.function.body); + // Only set stepId for module-level step functions in client mode + // Nested step functions are unreachable (their containing function + // bodies are not hoisted to module level) + if self.in_module_level { + self.step_functions_needing_id + .push((fn_name.clone(), fn_decl.function.span)); + } + } TransformMode::Workflow => { // For workflow mode, we need to replace the entire declaration // This will be handled at a higher level } - TransformMode::Client => { - // Step functions are completely removed in client mode - // This will be handled at a higher level - } } } } else if self.has_workflow_directive(&fn_decl.function, false) { @@ -5413,6 +5640,12 @@ impl VisitMut for StepTransform { self.create_registration_call(&fn_name, fn_decl.function.span); export_decl.visit_mut_children_with(self); } + TransformMode::Client => { + self.remove_use_step_directive(&mut fn_decl.function.body); + self.step_functions_needing_id + .push((fn_name.clone(), fn_decl.function.span)); + export_decl.visit_mut_children_with(self); + } TransformMode::Workflow => { // Collect for later conversion in visit_mut_module_items self.remove_use_step_directive(&mut fn_decl.function.body); @@ -5424,11 +5657,6 @@ impl VisitMut for StepTransform { fn_decl.function.span, )); } - TransformMode::Client => { - // In client mode, just remove the directive and keep the function as-is - self.remove_use_step_directive(&mut fn_decl.function.body); - export_decl.visit_mut_children_with(self); - } } } } else if is_workflow_function { @@ -5452,52 +5680,47 @@ impl VisitMut for StepTransform { self.remove_use_workflow_directive(&mut fn_decl.function.body); } TransformMode::Client => { - // Only replace with throw if function has inline directive - // Functions with only file-level directive keep original body - let has_inline_directive = - self.has_use_workflow_directive(&fn_decl.function.body); - + // In client mode, don't visit children - nested steps inside workflows + // are unreachable since the workflow body is replaced with throw error self.remove_use_workflow_directive(&mut fn_decl.function.body); - - if has_inline_directive { - // Replace with error throw for inline workflow directives - if let Some(body) = &mut fn_decl.function.body { - let error_msg = format!( - "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api", - fn_name, fn_name - ); - let error_expr = Expr::New(NewExpr { - span: DUMMY_SP, - ctxt: SyntaxContext::empty(), - callee: Box::new(Expr::Ident(Ident::new( - "Error".into(), - DUMMY_SP, - SyntaxContext::empty(), - ))), - args: Some(vec![ExprOrSpread { - spread: None, - expr: Box::new(Expr::Lit(Lit::Str(Str { - span: DUMMY_SP, - value: error_msg.into(), - raw: None, - }))), - }]), - type_args: None, - }); - body.stmts = vec![Stmt::Throw(ThrowStmt { - span: DUMMY_SP, - arg: Box::new(error_expr), - })]; - } + if let Some(body) = &mut fn_decl.function.body { + let error_msg = format!( + "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api", + fn_name, fn_name + ); + let error_expr = Expr::New(NewExpr { + span: DUMMY_SP, + ctxt: SyntaxContext::empty(), + callee: Box::new(Expr::Ident(Ident::new( + "Error".into(), + DUMMY_SP, + SyntaxContext::empty(), + ))), + args: Some(vec![ExprOrSpread { + spread: None, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: error_msg.into(), + raw: None, + }))), + }]), + type_args: None, + }); + body.stmts = vec![Stmt::Throw(ThrowStmt { + span: DUMMY_SP, + arg: Box::new(error_expr), + })]; } - self.workflow_functions_needing_id .push((fn_name.clone(), fn_decl.function.span)); } } } - // Visit children for workflow functions OUTSIDE the match to avoid borrow issues - export_decl.visit_mut_children_with(self); + // Visit children for workflow functions in Step and Workflow modes + // (Client mode already handled above - no children to visit) + if !matches!(self.mode, TransformMode::Client) || !is_workflow_function { + export_decl.visit_mut_children_with(self); + } // After visiting, process the function again for cleanup and Step mode transformation if let Decl::Fn(fn_decl) = &mut export_decl.decl { @@ -5589,6 +5812,15 @@ impl VisitMut for StepTransform { fn_expr.function.span, ); } + TransformMode::Client => { + self.remove_use_step_directive( + &mut fn_expr.function.body, + ); + self.step_functions_needing_id.push(( + name.clone(), + fn_expr.function.span, + )); + } TransformMode::Workflow => { // Replace the function expression with an initializer call self.remove_use_step_directive( @@ -5604,12 +5836,6 @@ impl VisitMut for StepTransform { self.create_step_initializer(&step_id), ); } - TransformMode::Client => { - // In client mode, just remove the directive and keep the function as-is - self.remove_use_step_directive( - &mut fn_expr.function.body, - ); - } } } } else if self @@ -5753,6 +5979,13 @@ impl VisitMut for StepTransform { arrow_expr.span, ); } + TransformMode::Client => { + self.remove_use_step_directive_arrow( + &mut arrow_expr.body, + ); + self.step_functions_needing_id + .push((name.clone(), arrow_expr.span)); + } TransformMode::Workflow => { // Replace the arrow function with an initializer call self.remove_use_step_directive_arrow( @@ -5768,12 +6001,6 @@ impl VisitMut for StepTransform { self.create_step_initializer(&step_id), ); } - TransformMode::Client => { - // In client mode, just remove the directive and keep the function as-is - self.remove_use_step_directive_arrow( - &mut arrow_expr.body, - ); - } } } } else if self.has_workflow_directive_arrow(arrow_expr, true) { @@ -5991,6 +6218,16 @@ impl VisitMut for StepTransform { fn_expr.function.span, ); } + TransformMode::Client => { + self.remove_use_step_directive( + &mut fn_expr.function.body, + ); + // Only set stepId for module-level step functions + if self.in_module_level { + self.step_functions_needing_id + .push((name.clone(), fn_expr.function.span)); + } + } TransformMode::Workflow => { // Keep the function expression but replace its body with a proxy call self.remove_use_step_directive( @@ -6033,12 +6270,6 @@ impl VisitMut for StepTransform { })]; } } - TransformMode::Client => { - // In client mode, just remove the directive and keep the function as-is - self.remove_use_step_directive( - &mut fn_expr.function.body, - ); - } } } } else if has_workflow { @@ -6277,7 +6508,9 @@ impl VisitMut for StepTransform { )); } TransformMode::Client => { - // In client mode, just remove the directive and keep the function + // In client mode for nested step functions, just remove directive + // WITHOUT registering - the function will be undefined since it's + // locally scoped within another function self.remove_use_step_directive_arrow( &mut arrow_expr.body, ); @@ -6295,6 +6528,13 @@ impl VisitMut for StepTransform { arrow_expr.span, ); } + TransformMode::Client => { + self.remove_use_step_directive_arrow( + &mut arrow_expr.body, + ); + self.step_functions_needing_id + .push((name.clone(), arrow_expr.span)); + } TransformMode::Workflow => { // Keep the arrow function but replace its body with a proxy call self.remove_use_step_directive_arrow( @@ -6333,12 +6573,6 @@ impl VisitMut for StepTransform { Box::new(proxy_call), )); } - TransformMode::Client => { - // In client mode, just remove the directive and keep the function as-is - self.remove_use_step_directive_arrow( - &mut arrow_expr.body, - ); - } } } } @@ -7330,6 +7564,11 @@ impl VisitMut for StepTransform { self.remove_use_step_directive(&mut fn_expr.function.body); self.create_registration_call(&fn_name, fn_expr.function.span); } + TransformMode::Client => { + self.remove_use_step_directive(&mut fn_expr.function.body); + self.step_functions_needing_id + .push((fn_name.clone(), fn_expr.function.span)); + } TransformMode::Workflow => { // Replace function body with step proxy self.remove_use_step_directive(&mut fn_expr.function.body); @@ -7365,10 +7604,6 @@ impl VisitMut for StepTransform { })]; } } - TransformMode::Client => { - // Transform step function body to use step run call - self.remove_use_step_directive(&mut fn_expr.function.body); - } } } } diff --git a/packages/swc-plugin-workflow/transform/tests/errors/invalid-exports/output-client.js b/packages/swc-plugin-workflow/transform/tests/errors/invalid-exports/output-client.js index 7d4fc7322..79d6596ad 100644 --- a/packages/swc-plugin-workflow/transform/tests/errors/invalid-exports/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/errors/invalid-exports/output-client.js @@ -12,3 +12,4 @@ export * from './other'; export async function validStep() { return 'allowed'; } +validStep.stepId = "step//./input//validStep"; diff --git a/packages/swc-plugin-workflow/transform/tests/errors/misplaced-function-directive/output-client.js b/packages/swc-plugin-workflow/transform/tests/errors/misplaced-function-directive/output-client.js index afc843eef..8125bdf01 100644 --- a/packages/swc-plugin-workflow/transform/tests/errors/misplaced-function-directive/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/errors/misplaced-function-directive/output-client.js @@ -5,6 +5,7 @@ export async function badStep() { 'use step'; return x; } +badStep.stepId = "step//./input//badStep"; export const badWorkflow = async ()=>{ console.log('hello'); // Error: directive must be at the top of function diff --git a/packages/swc-plugin-workflow/transform/tests/errors/non-async-functions/output-client.js b/packages/swc-plugin-workflow/transform/tests/errors/non-async-functions/output-client.js index 3d845a82b..85304d26d 100644 --- a/packages/swc-plugin-workflow/transform/tests/errors/non-async-functions/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/errors/non-async-functions/output-client.js @@ -13,6 +13,7 @@ export const syncWorkflow = ()=>{ export async function validStep() { return 42; } +validStep.stepId = "step//./input//validStep"; export const validWorkflow = async ()=>{ throw new Error("You attempted to execute workflow validWorkflow function directly. To start a workflow, use start(validWorkflow) from workflow/api"); }; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step-shorthand-method/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step-shorthand-method/output-client.js index b83d6440c..8654e0295 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step-shorthand-method/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step-shorthand-method/output-client.js @@ -1,10 +1,13 @@ import { agent } from "experimental-agent"; +/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/; +var vade$tools$VercelRequest$execute = async function(input, { experimental_context }) { + return 1 + 1; +}; export const vade = agent({ tools: { VercelRequest: { - async execute (input, { experimental_context }) { - return 1 + 1; - } + execute: vade$tools$VercelRequest$execute } } }); +vade$tools$VercelRequest$execute.stepId = "step//./input//vade/tools/VercelRequest/execute"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step/output-client.js index df7da5b12..8654e0295 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/agent-with-tool-step/output-client.js @@ -1,10 +1,13 @@ import { agent } from "experimental-agent"; +/**__internal_workflows{"steps":{"input.js":{"vade/tools/VercelRequest/execute":{"stepId":"step//./input//vade/tools/VercelRequest/execute"}}}}*/; +var vade$tools$VercelRequest$execute = async function(input, { experimental_context }) { + return 1 + 1; +}; export const vade = agent({ tools: { VercelRequest: { - execute: async (input, { experimental_context })=>{ - return 1 + 1; - } + execute: vade$tools$VercelRequest$execute } } }); +vade$tools$VercelRequest$execute.stepId = "step//./input//vade/tools/VercelRequest/execute"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/deeply-nested-step/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/deeply-nested-step/output-client.js index 8b7c2fa9c..945a1dec7 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/deeply-nested-step/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/deeply-nested-step/output-client.js @@ -1,13 +1,16 @@ import { createConfig } from "some-library"; +/**__internal_workflows{"steps":{"input.js":{"config/level1/level2/level3/myStep":{"stepId":"step//./input//config/level1/level2/level3/myStep"}}}}*/; +var config$level1$level2$level3$myStep = async function(input) { + return input * 2; +}; // Test deeply nested step functions (4 levels deep) export const config = createConfig({ level1: { level2: { level3: { - myStep: async (input)=>{ - return input * 2; - } + myStep: config$level1$level2$level3$myStep } } } }); +config$level1$level2$level3$myStep.stepId = "step//./input//config/level1/level2/level3/myStep"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/destructuring/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/destructuring/output-client.js index 05e3d0384..4dd0e3e5c 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/destructuring/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/destructuring/output-client.js @@ -2,15 +2,19 @@ export async function destructure({ a, b }) { return a + b; } +destructure.stepId = "step//./input//destructure"; export async function process_array([first, second]) { return first + second; } +process_array.stepId = "step//./input//process_array"; export async function nested_destructure({ user: { name, age } }) { return `${name} is ${age} years old`; } +nested_destructure.stepId = "step//./input//nested_destructure"; export async function with_defaults({ x = 10, y = 20 }) { return x + y; } +with_defaults.stepId = "step//./input//with_defaults"; export async function with_rest({ a, b, ...rest }) { return { a, @@ -18,6 +22,7 @@ export async function with_rest({ a, b, ...rest }) { rest }; } +with_rest.stepId = "step//./input//with_rest"; export async function multiple({ a, b }, { c, d }) { return { a, @@ -26,6 +31,7 @@ export async function multiple({ a, b }, { c, d }) { d }; } +multiple.stepId = "step//./input//multiple"; export async function rest_top_level(a, b, ...rest) { return { a, @@ -33,3 +39,4 @@ export async function rest_top_level(a, b, ...rest) { rest }; } +rest_top_level.stepId = "step//./input//rest_top_level"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/factory-with-step-method/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/factory-with-step-method/output-client.js index 9bbd17614..04764550d 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/factory-with-step-method/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/factory-with-step-method/output-client.js @@ -1,7 +1,9 @@ -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'); +}; const myFactory = ()=>({ - myStep: async ()=>{ - await fs.mkdir('test'); - } + myStep: myFactory$myStep }); export default myFactory; +myFactory$myStep.stepId = "step//./input//myFactory/myStep"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/let-arrow-step-function/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/let-arrow-step-function/output-client.js index 5f890c227..ec179fad3 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/let-arrow-step-function/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/let-arrow-step-function/output-client.js @@ -2,9 +2,12 @@ let stepArrow = async ()=>{ return 1; }; +stepArrow.stepId = "step//./input//stepArrow"; export let exportedStepArrow = async ()=>{ return 2; }; +exportedStepArrow.stepId = "step//./input//exportedStepArrow"; export async function normalStep() { return 3; } +normalStep.stepId = "step//./input//normalStep"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/mixed-functions/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/mixed-functions/output-client.js index 6956ca0f3..35a83ac93 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/mixed-functions/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/mixed-functions/output-client.js @@ -2,9 +2,11 @@ export async function stepFunction(a, b) { return a + b; } +stepFunction.stepId = "step//./input//stepFunction"; async function stepFunctionWithoutExport(a, b) { return a - b; } +stepFunctionWithoutExport.stepId = "step//./input//stepFunctionWithoutExport"; export async function workflowFunction(a, b) { throw new Error("You attempted to execute workflow workflowFunction function directly. To start a workflow, use start(workflowFunction) from workflow/api"); } diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/module-level-step/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/module-level-step/output-client.js index fa15a97a2..22c14d105 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/module-level-step/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/module-level-step/output-client.js @@ -2,6 +2,8 @@ export async function step(input) { return input.foo; } +step.stepId = "step//./input//step"; export const stepArrow = async (input)=>{ return input.bar; }; +stepArrow.stepId = "step//./input//stepArrow"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/module-level-workflow/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/module-level-workflow/output-client.js index 0d1ed8300..8fbadfce9 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/module-level-workflow/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/module-level-workflow/output-client.js @@ -1,6 +1,6 @@ /**__internal_workflows{"workflows":{"input.js":{"arrowWorkflow":{"workflowId":"workflow//./input//arrowWorkflow"},"workflow":{"workflowId":"workflow//./input//workflow"}}}}*/; export async function workflow(input) { - return input.foo; + throw new Error("You attempted to execute workflow workflow function directly. To start a workflow, use start(workflow) from workflow/api"); } workflow.workflowId = "workflow//./input//workflow"; export const arrowWorkflow = async (input)=>{ diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/multiple-const-step-functions/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/multiple-const-step-functions/output-client.js index 46fd0498f..0e9a27320 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/multiple-const-step-functions/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/multiple-const-step-functions/output-client.js @@ -4,19 +4,25 @@ const fn1 = async ()=>{ }, fn2 = async ()=>{ return 2; }; +fn2.stepId = "step//./input//fn2"; +fn1.stepId = "step//./input//fn1"; export const fn3 = async ()=>{ return 3; }, fn4 = async ()=>{ return 4; }; +fn4.stepId = "step//./input//fn4"; +fn3.stepId = "step//./input//fn3"; // Test case: regular function BEFORE step function in same declaration // This verifies that processing doesn't skip the step function const regularArrow = ()=>1, stepAfterRegular = async ()=>{ return 5; }; +stepAfterRegular.stepId = "step//./input//stepAfterRegular"; // Test case: regular function expression BEFORE step function const regularFn = function() { return 2; }, stepAfterRegularFn = async function() { return 6; }; +stepAfterRegularFn.stepId = "step//./input//stepAfterRegularFn"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/object-property-step/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/object-property-step/output-client.js index 762f80346..5707b1aaa 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/object-property-step/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/object-property-step/output-client.js @@ -1,34 +1,41 @@ import * as z from 'zod'; import { tool } from 'ai'; +/**__internal_workflows{"steps":{"input.js":{"timeTool/execute":{"stepId":"step//./input//timeTool/execute"},"weatherTool/execute":{"stepId":"step//./input//weatherTool/execute"},"weatherTool2/execute":{"stepId":"step//./input//weatherTool2/execute"}}}}*/; +var weatherTool$execute = async function({ location }) { + return { + location, + temperature: 72 + Math.floor(Math.random() * 21) - 10 + }; +}; +var timeTool$execute = async function timeToolImpl() { + return { + time: new Date().toISOString() + }; +}; +var weatherTool2$execute = async function({ location }) { + return { + location, + temperature: 72 + Math.floor(Math.random() * 21) - 10 + }; +}; export const weatherTool = tool({ description: 'Get the weather in a location', inputSchema: z.object({ location: z.string().describe('The location to get the weather for') }), - execute: async ({ location })=>{ - return { - location, - temperature: 72 + Math.floor(Math.random() * 21) - 10 - }; - } + execute: weatherTool$execute }); export const timeTool = tool({ description: 'Get the current time', - execute: async function timeToolImpl() { - return { - time: new Date().toISOString() - }; - } + execute: timeTool$execute }); export const weatherTool2 = tool({ description: 'Get the weather in a location', inputSchema: z.object({ location: z.string().describe('The location to get the weather for') }), - async execute ({ location }) { - return { - location, - temperature: 72 + Math.floor(Math.random() * 21) - 10 - }; - } + execute: weatherTool2$execute }); +weatherTool$execute.stepId = "step//./input//weatherTool/execute"; +timeTool$execute.stepId = "step//./input//timeTool/execute"; +weatherTool2$execute.stepId = "step//./input//weatherTool2/execute"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/separate-export-statement/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/separate-export-statement/output-client.js index f9dd05462..bb063c4e5 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/separate-export-statement/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/separate-export-statement/output-client.js @@ -2,6 +2,7 @@ async function stepFunction(a, b) { return a + b; } +stepFunction.stepId = "step//./input//stepFunction"; async function workflowFunction(a, b) { throw new Error("You attempted to execute workflow workflowFunction function directly. To start a workflow, use start(workflowFunction) from workflow/api"); } diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/single-step/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/single-step/output-client.js index 2b073c1d5..deb5f9dec 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/single-step/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/single-step/output-client.js @@ -2,3 +2,4 @@ export async function add(a, b) { return a + b; } +add.stepId = "step//./input//add"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/step-arrow-function/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/step-arrow-function/output-client.js index 7136f7cf7..815fc648d 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/step-arrow-function/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/step-arrow-function/output-client.js @@ -2,3 +2,4 @@ export const multiply = async (a, b)=>{ return a * b; }; +multiply.stepId = "step//./input//multiply"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/step-with-imports/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/step-with-imports/output-client.js index 88c68d1af..7f5ad78bc 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/step-with-imports/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/step-with-imports/output-client.js @@ -8,6 +8,7 @@ export async function processData(data) { localFunction(); return defaultExport(transformed); } +processData.stepId = "step//./input//processData"; export function normalFunction() { // since this function is exported we can't remove it useful.doSomething(); diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/step-with-this-arguments-super/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/step-with-this-arguments-super/output-client.js index e39dc81b1..23f1a0b45 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/step-with-this-arguments-super/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/step-with-this-arguments-super/output-client.js @@ -4,10 +4,12 @@ export async function stepWithThis() { // `this` is allowed in step functions return this.value; } +stepWithThis.stepId = "step//./input//stepWithThis"; export async function stepWithArguments() { // `arguments` is allowed in step functions return arguments[0]; } +stepWithArguments.stepId = "step//./input//stepWithArguments"; class TestClass extends BaseClass { async stepMethod() { // `super` is allowed in step functions diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/unused-exports/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/unused-exports/output-client.js index b4f3218c8..0c5423a94 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/unused-exports/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/unused-exports/output-client.js @@ -13,6 +13,7 @@ export function formatData(data) { export async function processData(input) { return helper(input); } +processData.stepId = "step//./input//processData"; // This is used internally function internalHelper(value) { return value * 2; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/unused-variables-and-types/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/unused-variables-and-types/output-client.js index 20ae55194..70b2abaeb 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/unused-variables-and-types/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/unused-variables-and-types/output-client.js @@ -12,6 +12,7 @@ export const sendRecipientEmail = async ({ recipientEmail, cardImage, cardText, html }); }; +sendRecipientEmail.stepId = "step//./input//sendRecipientEmail"; export function normalFunction() { return 'this stays because it is exported'; } diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/using-declaration-step/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/using-declaration-step/output-client.js index db2400bec..a14cb00f0 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/using-declaration-step/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/using-declaration-step/output-client.js @@ -23,3 +23,4 @@ export async function testStep() { env.stack.pop(); } } +testStep.stepId = "step//./input//testStep"; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/var-named-step-function/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/var-named-step-function/output-client.js index 9fe3b1787..20b8a8cd8 100644 --- a/packages/swc-plugin-workflow/transform/tests/fixture/var-named-step-function/output-client.js +++ b/packages/swc-plugin-workflow/transform/tests/fixture/var-named-step-function/output-client.js @@ -2,6 +2,8 @@ async function namedStep() { return 1; } +namedStep.stepId = "step//./input//namedStep"; export async function exportedNamedStep() { return 2; } +exportedNamedStep.stepId = "step//./input//exportedNamedStep"; diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index 0c2f36240..e8854f25a 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -1228,3 +1228,50 @@ export async function instanceMethodStepWorkflow(initialValue: number) { added2, // 100 + 50 = 150 }; } + +////////////////////////////////////////////////////////// +// Step Function Reference as start() Argument E2E Test +////////////////////////////////////////////////////////// + +/** + * A step function that invokes a step function reference passed to it. + * This is called from within the workflow to execute the passed step function. + */ +async function invokeStepFn( + stepFn: (a: number, b: number) => Promise, + x: number, + y: number +): Promise { + 'use step'; + // Call the step function reference that was passed in + return await stepFn(x, y); +} + +/** + * Workflow that receives a step function reference as an argument from start(). + * This tests that: + * 1. Step function references can be serialized in the client bundle (via stepId property) + * 2. The serialized step function can be deserialized in the workflow bundle + * 3. The deserialized step function can be invoked DIRECTLY from workflow code + * 4. The deserialized step function can also be invoked from within another step + */ +export async function stepFunctionAsStartArgWorkflow( + stepFn: (a: number, b: number) => Promise, + x: number, + y: number +): Promise<{ directResult: number; viaStepResult: number; doubled: number }> { + 'use workflow'; + + // CRITICAL TEST: Call the passed step function DIRECTLY from workflow code + // This tests that the deserialized step function has the useStep wrapper, + // allowing it to be scheduled as a proper step (not executed inline) + const directResult = await stepFn(x, y); + + // Also test invoking via another step (this already worked before) + const viaStepResult = await invokeStepFn(stepFn, x, y); + + // Do another operation to verify the workflow continues normally + const doubled = await stepFn(directResult, directResult); + + return { directResult, viaStepResult, doubled }; +}