Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/dynamic-workflow-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/core": patch
"workflow": patch
---

Add an experimental dynamic workflow source overload for `start()`.
27 changes: 27 additions & 0 deletions docs/content/docs/v5/api-reference/workflow-api/start.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
* All arguments must be [serializable](/docs/foundations/serialization).
* When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments.
* You can also start an experimental [dynamic workflow](/docs/foundations/dynamic-workflows) from a trusted source string by passing `options.dynamic`.

<Callout type="info">
If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function).
Expand Down Expand Up @@ -83,6 +84,32 @@ const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight]
}); // [!code highlight]
```

### With Dynamic Source

Pass a trusted JavaScript source string plus `dynamic.steps` when the workflow orchestration is generated at runtime.

```typescript
import { start } from "workflow/api";
import { fetchUser, sendEmail } from "./steps";

const source = `
async function workflow(input) {
"use workflow";

const user = await steps.fetchUser(input.userId);
await steps.sendEmail(user.email);

return { ok: true };
}
`;

const run = await start(source, [{ userId: "user_123" }], {
dynamic: {
steps: { fetchUser, sendEmail },
},
});
```

### Using `deploymentId: "latest"`

Set `deploymentId` to `"latest"` to automatically resolve the most recent deployment for the current environment. This is useful when you want to ensure a workflow run targets the latest deployed version of your application rather than the deployment that initiated the call. For when to use this and how it fits with default run pinning, see [Versioning](/docs/foundations/versioning).
Expand Down
170 changes: 170 additions & 0 deletions docs/content/docs/v5/foundations/dynamic-workflows.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
title: Dynamic Workflows
description: Start trusted workflow source strings that orchestrate already-registered steps.
type: conceptual
summary: Run workflow code generated at runtime without adding a new build-time workflow file.
prerequisites:
- /docs/foundations/workflows-and-steps
- /docs/foundations/starting-workflows
related:
- /docs/api-reference/workflow-api/start
- /docs/foundations/serialization
- /docs/foundations/versioning
---

Dynamic workflows let you start a workflow from a JavaScript source string instead of importing a workflow function that was discovered at build time. This is useful when the orchestration is generated at runtime, such as from a workflow builder UI or an LLM.

<Callout type="warning">
Dynamic workflows are experimental. Treat the source as trusted application code. The workflow VM enforces the usual deterministic workflow runtime, but it is not a security sandbox for untrusted JavaScript.
</Callout>

## How it works

Dynamic workflows reuse the normal Workflow SDK runtime. The dynamic source is compiled into per-run workflow VM code, stored on the run, and replayed from the event log just like a static workflow.

For the MVP, dynamic workflows can only orchestrate step functions that are already registered in the deployment. Pass those step references through `dynamic.steps`, then call them from the source through the injected `steps` object.

```typescript lineNumbers
import { start } from "workflow/api";
import { fetchUser, sendEmail } from "./steps";

const source = `
async function workflow(input) {
"use workflow";

const user = await steps.fetchUser(input.userId);
await steps.sendEmail(user.email);

return { ok: true };
}
`;

const run = await start(
source,
[{ userId: "user_123" }],
{
dynamic: {
steps: { fetchUser, sendEmail },
},
},
);
```

The generated workflow ID uses the form `workflow//dynamic/<source-hash>//<exportName>`. Workflow SDK derives the hash from the source and step references so the same dynamic workflow source gets a stable generated workflow name.

## Predefined runtime globals

Dynamic workflow source does not use imports. The runtime predefines these bindings inside the generated workflow VM code:

| Binding | Description |
| --- | --- |
| `steps` | A frozen object containing the step aliases passed through `dynamic.steps`. |
| `sleep` | The Workflow SDK sleep primitive for durable waits and timers. |
| `createHook` | The Workflow SDK hook primitive for durable external resume signals. |

The source also runs inside the normal deterministic workflow VM, so standard sandbox globals such as `Date`, `Math.random`, `crypto`, `URL`, `URLSearchParams`, `TextEncoder`, `TextDecoder`, `structuredClone`, `atob`, and `btoa` are available with the same determinism constraints as static workflows.

For the MVP, `createWebhook` and `getWritable` are not predefined in dynamic source.

```typescript lineNumbers
const source = `
async function workflow(input) {
"use workflow";

await sleep("15m");

const approval = createHook({ token: "approval-" + input.userId });
const result = await Promise.race([
approval,
sleep("1d").then(() => ({ approved: false, timedOut: true })),
]);

if (result.approved) {
await steps.sendEmail(input.email);
}

return result;
}
`;
```

## Generated with an LLM

Keep the step catalog explicit. The model should choose from steps you provide, and your application should review or validate the generated source before starting it.

```typescript lineNumbers
import { generateText } from "ai";
import { start } from "workflow/api";
import { createTicket, lookupCustomer, sendSlackMessage } from "./steps";

const stepCatalog = {
lookupCustomer,
createTicket,
sendSlackMessage,
};

const { text: source } = await generateText({
model: myModel,
system: `
Generate one JavaScript async function named workflow.
The first statement inside the function must be "use workflow".
Use only these predefined runtime globals: steps, sleep, createHook.
Only call step functions through the injected steps object.
Do not use import, export, TypeScript syntax, inline step functions, createWebhook, or getWritable.
`,
prompt: "When a customer reports a billing issue, look them up, create a ticket, and notify Slack.",
});

const run = await start(
source,
[{ customerId: "cus_123", issue: "Invoice looks wrong" }],
{
dynamic: {
steps: stepCatalog,
},
},
);
```

## Options

`dynamic.steps` is required. Each value can be an imported step function or an explicit `{ stepId }` reference.

```typescript lineNumbers
import { start } from "workflow/api";
import { fetchUser, sendEmail } from "./steps";

const source = `
async function workflow(input) {
"use workflow";
return await steps.fetchUser(input.userId);
}
`;
const input = { userId: "user_123" };

await start(source, [input], {
dynamic: {
steps: {
fetchUser,
sendEmail: { stepId: "step//./steps//sendEmail" },
},
exportName: "workflow",
},
});
```

`dynamic.exportName` defaults to `workflow`. Use it when your generated source defines a different async function name.

## Source storage

The current prototype stores generated workflow VM code inline with run metadata. Before broad release, dynamic workflow source/code should move to encrypted ref-backed storage so sensitive generated source is protected by the same run encryption model as workflow inputs and step data, and longer workflows do not have to fit inside run metadata storage limits.

## MVP limitations

- Dynamic source must be JavaScript and must fit within the 32 KB source limit.
- The source must define `async function workflow(...)` by default, or the function named by `dynamic.exportName`.
- The first statement in that function must be `"use workflow";`.
- `import` and `export` syntax are not supported.
- Inline `"use step"` functions are not supported.
- TypeScript syntax, runtime bundling, npm package imports, and custom class serialization registration are not supported.
- The current prototype stores generated workflow code inline in run metadata. Do not put secrets in generated source until source/code storage moves to encrypted refs.
3 changes: 3 additions & 0 deletions docs/content/docs/v5/foundations/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Workflow programming can be a slight shift from how you traditionally write real
<Card href="/docs/foundations/starting-workflows" title="Starting Workflows">
Trigger workflows and track their execution using the `start()` function.
</Card>
<Card href="/docs/foundations/dynamic-workflows" title="Dynamic Workflows">
Start trusted workflow source strings that orchestrate registered steps.
</Card>
<Card href="/docs/foundations/errors-and-retries" title="Errors & Retrying">
Types of errors and how retrying work in workflows.
</Card>
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/v5/foundations/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"workflows-and-steps",
"starting-workflows",
"dynamic-workflows",
"errors-and-retries",
"hooks",
"streaming",
Expand Down
93 changes: 92 additions & 1 deletion packages/core/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { workflowEntrypoint } from './runtime.js';
import {
dehydrateStepReturnValue,
dehydrateWorkflowArguments,
hydrateRunError,
hydrateWorkflowReturnValue,
} from './serialization.js';

vi.mock('@vercel/functions', () => ({
Expand Down Expand Up @@ -58,7 +60,7 @@ async function runWorkflowHandlerWithEvents(
{
requestId: 'req_test',
attempt: 1,
queueName: '__wkf_workflow_workflow',
queueName: `__wkf_workflow_${workflowRun.workflowName}`,
messageId: 'msg_test',
}
);
Expand Down Expand Up @@ -97,6 +99,95 @@ describe('workflowEntrypoint replay guards', () => {
`;globalThis.__private_workflows = new Map();
globalThis.__private_workflows.set(${JSON.stringify(workflowName)}, ${workflowName});`;

it('uses dynamic workflow code from the run executionContext when present', async () => {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
runId: 'wrun_runtime_dynamic',
workflowName: 'workflow//dynamic/test-run//workflow',
status: 'running',
input: await dehydrateWorkflowArguments(
['Ada'],
'wrun_runtime_dynamic',
undefined,
ops
),
executionContext: {
dynamicWorkflow: {
version: 1,
sourceHash: 'hash',
exportName: 'workflow',
workflowCode: `
async function workflow(name) {
return "hello " + name;
}
;globalThis.__private_workflows = new Map();
globalThis.__private_workflows.set("workflow//dynamic/test-run//workflow", workflow);
`,
},
},
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
startedAt: new Date('2024-01-01T00:00:00.000Z'),
deploymentId: 'test-deployment',
};

const events: Event[] = [
{
eventId: 'event-0',
runId: workflowRun.runId,
eventType: 'run_created',
eventData: {
input: workflowRun.input,
deploymentId: workflowRun.deploymentId,
workflowName: workflowRun.workflowName,
executionContext: workflowRun.executionContext,
},
createdAt: new Date('2024-01-01T00:00:00.000Z'),
specVersion: SPEC_VERSION_CURRENT,
},
{
eventId: 'event-1',
runId: workflowRun.runId,
eventType: 'run_started',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
specVersion: SPEC_VERSION_CURRENT,
},
];

const createdEvents = await runWorkflowHandlerWithEvents(
`function workflow() { throw new Error("static bundle should not run"); }${getWorkflowTransformCode('workflow')}`,
workflowRun,
events
);

const runCompleted = createdEvents.find(
(event: any) => event.eventType === 'run_completed'
) as any;
if (!runCompleted) {
const runFailed = createdEvents.find(
(event: any) => event.eventType === 'run_failed'
) as any;
if (runFailed) {
const error = await hydrateRunError(
runFailed.eventData.error,
workflowRun.runId,
undefined,
ops
);
throw error;
}
}
expect(runCompleted).toBeDefined();
expect(
await hydrateWorkflowReturnValue(
runCompleted.eventData.output,
workflowRun.runId,
undefined,
ops
)
).toBe('hello Ada');
});

it('records run_failed when a committed wait_completed targets the wrong wait', async () => {
const ops: Promise<any>[] = [];
const workflowRun: WorkflowRun = {
Expand Down
Loading