Add support for calling start() inside workflow functions#1133
Add support for calling start() inside workflow functions#1133
Conversation
Enable `start()` to work in workflow context by routing through an internal step (`__workflow_start`), reusing existing step infrastructure with no new event types or server changes needed. Co-Authored-By: Claude Opus 4.6 <[email protected]>
🦋 Changeset detectedLatest commit: d832df7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Enable start() (from workflow/api) to be invoked directly inside "use workflow" functions by routing the call through an internal built-in step, keeping workflow execution deterministic.
Changes:
- Injected a workflow-context
startimplementation into the workflow VM via a newWORKFLOW_STARTsymbol andcreateStart(ctx)factory. - Registered a built-in
__workflow_startstep in the step handler that calls the real runtimestart()and returns the spawned runId. - Added an e2e scenario and updated docs/skill content to document workflow-context semantics (
{ runId }only).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| workbench/example/workflows/99_e2e.ts | Adds example parent→child workflow start from workflow context with hook signaling. |
| skills/workflow/SKILL.md | Documents that start() works in workflow functions and returns { runId } there. |
| packages/core/src/workflow/start.ts | Implements createStart(ctx) that proxies start() through __workflow_start. |
| packages/core/src/workflow.ts | Injects WORKFLOW_START into the workflow VM global to enable delegation. |
| packages/core/src/symbols.ts | Adds the WORKFLOW_START symbol. |
| packages/core/src/runtime/step-handler.ts | Registers the built-in __workflow_start step for workflow-context start(). |
| packages/core/src/runtime/start.ts | Delegates to injected workflow start when running inside the workflow VM. |
| packages/core/e2e/e2e.test.ts | Adds e2e coverage for starting a child workflow from a workflow function. |
| docs/content/docs/foundations/starting-workflows.mdx | Adds docs section describing start() usage inside workflow functions. |
| docs/content/docs/foundations/common-patterns.mdx | Updates “background execution” guidance to use start() directly in workflows. |
| docs/content/docs/api-reference/workflow-api/start.mdx | Documents workflow-context start() behavior and return shape. |
| .changeset/start-in-workflow.md | Declares a patch release for the new capability. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/core/src/runtime/start.ts
Outdated
| // When called inside a workflow function, delegate to the injected start implementation | ||
| // which routes through an internal step for deterministic replay. | ||
| const workflowStartFn = (globalThis as any)[WORKFLOW_START]; | ||
| if (workflowStartFn) { |
There was a problem hiding this comment.
workflowStartFn is checked for truthiness but not for being callable. If anything sets globalThis[WORKFLOW_START] to a non-function value, this will throw a confusing "is not a function" error and bypass the normal runtime implementation. Suggest guarding with typeof workflowStartFn === 'function' before delegating (and otherwise falling back to the normal runtime path).
| if (workflowStartFn) { | |
| if (typeof workflowStartFn === 'function') { |
There was a problem hiding this comment.
Good catch — addressed in d832df7. Using typeof workflowStartFn === 'function' now.
packages/core/src/workflow/start.ts
Outdated
| // Parse overloaded args/options (same pattern as real start) | ||
| let args: Serializable[] = []; | ||
| let opts: Serializable = (options ?? {}) as Serializable; | ||
| if (Array.isArray(argsOrOptions)) { | ||
| args = argsOrOptions as Serializable[]; | ||
| } else if (typeof argsOrOptions === 'object' && argsOrOptions !== null) { | ||
| opts = argsOrOptions as Serializable; | ||
| } | ||
|
|
There was a problem hiding this comment.
createStart() forwards options through step argument serialization (opts: Serializable). StartOptions includes world?: World, which won’t be serializable and (worse) could deserialize to an invalid shape and then break start() inside the step handler. Consider explicitly validating/sanitizing the allowed options in workflow context (e.g., disallow world, and only permit serializable fields like deploymentId / specVersion) and throw a clear error if unsupported options are provided.
| // Parse overloaded args/options (same pattern as real start) | |
| let args: Serializable[] = []; | |
| let opts: Serializable = (options ?? {}) as Serializable; | |
| if (Array.isArray(argsOrOptions)) { | |
| args = argsOrOptions as Serializable[]; | |
| } else if (typeof argsOrOptions === 'object' && argsOrOptions !== null) { | |
| opts = argsOrOptions as Serializable; | |
| } | |
| // Parse overloaded args/options (same pattern as real start), | |
| // but explicitly validate/sanitize options to ensure they are serializable | |
| // and do not include unsupported fields like `world`. | |
| let args: Serializable[] = []; | |
| let rawOpts: Record<string, unknown> = | |
| (options as Record<string, unknown>) ?? {}; | |
| if (Array.isArray(argsOrOptions)) { | |
| args = argsOrOptions as Serializable[]; | |
| } else if (typeof argsOrOptions === 'object' && argsOrOptions !== null) { | |
| rawOpts = argsOrOptions as Record<string, unknown>; | |
| } | |
| // Only allow a limited set of workflow options in this context. | |
| const allowedOptionKeys = new Set(['deploymentId', 'specVersion']); | |
| const sanitizedOpts: Record<string, Serializable> = {}; | |
| for (const [key, value] of Object.entries(rawOpts)) { | |
| if (!allowedOptionKeys.has(key)) { | |
| throw new Error( | |
| `Unsupported option '${key}' passed to 'start' in workflow context. ` + | |
| `Only 'deploymentId' and 'specVersion' are allowed.` | |
| ); | |
| } | |
| sanitizedOpts[key] = value as Serializable; | |
| } | |
| const opts: Serializable = sanitizedOpts as Serializable; |
There was a problem hiding this comment.
Addressed in d832df7. Options are now validated against an allowlist (deploymentId, specVersion). Unsupported options like world throw a clear error.
| export function createStart(ctx: WorkflowOrchestratorContext) { | ||
| const internalStartStep = createUseStep(ctx)< | ||
| [string, Serializable[], Serializable], | ||
| string | ||
| >('__workflow_start'); |
There was a problem hiding this comment.
There are dedicated unit tests for other workflow-context factories like createSleep and createCreateHook, but this new createStart implementation has no unit test coverage. Adding a focused unit test that asserts start() inside workflow context enqueues a __workflow_start step invocation (and returns { runId } on step_completed) would help prevent regressions without relying solely on e2e.
There was a problem hiding this comment.
Addressed in d832df7. Added 6 unit tests in src/workflow/start.test.ts covering: step completion → { runId }, suspension when no events, invalid workflow rejection, unsupported options rejection, valid options passthrough, and options parsing from second argument.
| options | ||
| ); | ||
| return run.runId; | ||
| } |
There was a problem hiding this comment.
__workflow_start executes start() inside a regular step, which is subject to step retries (DEFAULT_STEP_MAX_RETRIES). Because start() generates a new runId on each attempt, a retry after a partial failure can spawn multiple child workflow runs (orphaning earlier ones). Consider making this built-in step non-retriable (e.g., set maxRetries = 0/1) and/or adding an idempotency mechanism so the same child runId is reused across retries.
| } | |
| }, | |
| { maxRetries: 0 } |
There was a problem hiding this comment.
Good point — addressed in d832df7. Set maxRetries = 0 on the __workflow_start step since each retry would generate a new runId and orphan earlier child runs.
| export function start<TArgs extends unknown[], TResult>( | ||
| workflow: WorkflowFunction<TArgs, TResult> | WorkflowMetadata, | ||
| args: TArgs, | ||
| options?: StartOptions | ||
| ): Promise<Run<TResult>>; | ||
|
|
||
| export function start<TResult>( | ||
| workflow: WorkflowFunction<[], TResult> | WorkflowMetadata, | ||
| options?: StartOptions | ||
| ): Promise<Run<TResult>>; | ||
|
|
||
| export async function start<TArgs extends unknown[], TResult>( | ||
| workflow: WorkflowFunction<TArgs, TResult> | WorkflowMetadata, | ||
| argsOrOptions?: TArgs | StartOptions, | ||
| options?: StartOptions | ||
| ) { | ||
| // When called inside a workflow function, delegate to the injected start implementation | ||
| // which routes through an internal step for deterministic replay. | ||
| const workflowStartFn = (globalThis as any)[WORKFLOW_START]; | ||
| if (workflowStartFn) { | ||
| return workflowStartFn(workflow, argsOrOptions, options); | ||
| } |
There was a problem hiding this comment.
The function overloads declare start() always returns Promise<Run<TResult>>, but when WORKFLOW_START is present you return the injected workflow implementation (which returns { runId } per the PR). This makes the public API type-unsafe in workflow context (code will typecheck against Run methods that are missing at runtime). Please update the typings to reflect the workflow-context return value (e.g., a union return type or a separate workflow-only overload/type) so TS users can’t accidentally call Run APIs.
There was a problem hiding this comment.
Valid concern. The runId property does exist on Run so the most common usage pattern works at runtime. We're planning a follow-up to make Run fully serializable with instance methods that work as steps in workflow context, which will resolve this type mismatch properly.
| With background execution, the parent workflow continues immediately after starting the child. The child workflow runs independently with its own event log and can be monitored separately using the returned `runId`. | ||
|
|
||
| <Callout type="info"> | ||
| Inside workflow functions, `start()` returns an object with a `runId` property. For full `Run` capabilities (like `.returnValue` or `.cancel()`), use `getRun(runId)` inside a step function. |
There was a problem hiding this comment.
actually we should add Run to the serialization layer so that we can serialize and deserialize it correctly. then update the docs and examples everywhere so that you can just use run like normal in workflow.
any instance methods of the Run class that can't run inside the workflow vm should also have "use step" in the function definitions so that they work inside workflow and defer to step when run there (and then noop inside steps)
- Use typeof check instead of truthiness for WORKFLOW_START symbol - Validate start() options in workflow context (reject unsupported options like world) - Set maxRetries=0 on __workflow_start step to prevent orphaned child runs - Add unit tests for createStart factory (6 tests) Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary
start()fromworkflow/apito work directly inside"use workflow"functions__workflow_start) reusing existing step infrastructure — no new event types, no server/world changes{ runId }in workflow context (not fullRunobject; usegetRun(runId)in a step for full access)Implementation
WORKFLOW_STARTsymbol andcreateStart(ctx)factory that creates a step proxy__workflow_startbuilt-in step in the step handler that calls realstart()start()detects workflow context via symbol check and delegates accordinglyTest plan
pnpm test)DEPLOYMENT_URL="http://localhost:3000" APP_NAME="nextjs-turbopack" pnpm vitest run packages/core/e2e/e2e.test.ts -t "startFromWorkflow"-t "spawnWorkflowFromStepWorkflow"🤖 Generated with Claude Code