workflow 5.0.0-beta.56 → 5.0.0-beta.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +1 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1 -1
- package/docs/advanced/dynamic-workflows.mdx +224 -0
- package/docs/api-reference/workflow/create-hook.mdx +30 -1
- package/docs/api-reference/workflow/create-webhook.mdx +1 -1
- package/docs/api-reference/workflow/define-hook.mdx +2 -0
- package/docs/api-reference/workflow-api/register-lifecycle-hooks.mdx +6 -4
- package/docs/api-reference/workflow-api/start.mdx +49 -0
- package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +1 -1
- package/docs/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +7 -0
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +7 -0
- package/docs/api-reference/workflow-runtime/health-check.mdx +1 -0
- package/docs/api-reference/workflow-runtime/world/storage.mdx +3 -1
- package/docs/changelog/batched-event-writes.mdx +2 -2
- package/docs/configuration/runtime-tuning.mdx +13 -3
- package/docs/configuration/worlds.mdx +7 -7
- package/docs/cookbook/advanced/child-workflows.mdx +3 -1
- package/docs/cookbook/advanced/upgrading-workflows.mdx +1 -1
- package/docs/cookbook/common-patterns/batching.mdx +2 -0
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +2 -2
- package/docs/errors/hook-conflict.mdx +23 -0
- package/docs/errors/index.mdx +21 -0
- package/docs/foundations/errors-and-retries.mdx +3 -1
- package/docs/foundations/idempotency.mdx +44 -21
- package/docs/foundations/serialization.mdx +1 -0
- package/docs/foundations/starting-workflows.mdx +2 -0
- package/docs/how-it-works/event-sourcing.mdx +7 -1
- package/docs/meta.json +1 -0
- package/docs/observability/lifecycle-hooks.mdx +6 -4
- package/docs/whats-new.mdx +4 -1
- package/docs/worlds/building-a-world.mdx +538 -0
- package/docs/worlds/local.mdx +129 -0
- package/docs/worlds/meta.json +10 -0
- package/docs/worlds/postgres.mdx +424 -0
- package/docs/worlds/upgrading-to-v5.mdx +162 -0
- package/docs/worlds/vercel.mdx +345 -0
- package/package.json +11 -11
package/dist/api.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export type { CancelRunOptions, Event, StopSleepOptions, StopSleepResult, Workfl
|
|
|
3
3
|
export { type RunCompletedHookParams, type RunFailedHookParams, registerLifecycleHooks, type WorkflowLifecycleHooks, } from '@workflow/core/runtime/lifecycle-hooks';
|
|
4
4
|
export { getHookByToken, type Hook, type ResumedHook, resumeHook, resumeWebhook, } from '@workflow/core/runtime/resume-hook';
|
|
5
5
|
export { getRun, Run, type WorkflowReadableStream, type WorkflowReadableStreamOptions, type WorkflowRunWritableStreamOptions, } from '@workflow/core/runtime/run';
|
|
6
|
-
export { type StartOptions, start, } from '@workflow/core/runtime/start';
|
|
6
|
+
export { type DynamicStartOptions, type DynamicWorkflowOptions, type DynamicWorkflowStepReference, type StartOptions, start, } from '@workflow/core/runtime/start';
|
|
7
7
|
//# sourceMappingURL=api.d.ts.map
|
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAOA,OAAO,mCAAmC,CAAC;AAE3C,YAAY,EACV,gBAAgB,EAChB,KAAK,EACL,gBAAgB,EAChB,eAAe,EACf,WAAW,GACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,sBAAsB,EACtB,KAAK,sBAAsB,GAC5B,MAAM,wCAAwC,CAAC;AAChD,OAAO,EACL,cAAc,EACd,KAAK,IAAI,EACT,KAAK,WAAW,EAChB,UAAU,EACV,aAAa,GACd,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACL,MAAM,EACN,GAAG,EACH,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,KAAK,gCAAgC,GACtC,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,GACN,MAAM,8BAA8B,CAAC"}
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAOA,OAAO,mCAAmC,CAAC;AAE3C,YAAY,EACV,gBAAgB,EAChB,KAAK,EACL,gBAAgB,EAChB,eAAe,EACf,WAAW,GACZ,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,KAAK,sBAAsB,EAC3B,KAAK,mBAAmB,EACxB,sBAAsB,EACtB,KAAK,sBAAsB,GAC5B,MAAM,wCAAwC,CAAC;AAChD,OAAO,EACL,cAAc,EACd,KAAK,IAAI,EACT,KAAK,WAAW,EAChB,UAAU,EACV,aAAa,GACd,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACL,MAAM,EACN,GAAG,EACH,KAAK,sBAAsB,EAC3B,KAAK,6BAA6B,EAClC,KAAK,gCAAgC,GACtC,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,4BAA4B,EACjC,KAAK,YAAY,EACjB,KAAK,GACN,MAAM,8BAA8B,CAAC"}
|
package/dist/api.js
CHANGED
|
@@ -10,4 +10,4 @@ export { registerLifecycleHooks, } from '@workflow/core/runtime/lifecycle-hooks'
|
|
|
10
10
|
export { getHookByToken, resumeHook, resumeWebhook, } from '@workflow/core/runtime/resume-hook';
|
|
11
11
|
export { getRun, Run, } from '@workflow/core/runtime/run';
|
|
12
12
|
export { start, } from '@workflow/core/runtime/start';
|
|
13
|
-
//# sourceMappingURL=data:application/json;base64,
|
|
13
|
+
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXBpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL2FwaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxxRUFBcUU7QUFDckUseUVBQXlFO0FBQ3pFLDJFQUEyRTtBQUMzRSw0RUFBNEU7QUFDNUUsNkVBQTZFO0FBQzdFLG1CQUFtQjtBQUNuQix5RUFBeUU7QUFDekUsT0FBTyxtQ0FBbUMsQ0FBQztBQVMzQyxPQUFPLEVBR0wsc0JBQXNCLEdBRXZCLE1BQU0sd0NBQXdDLENBQUM7QUFDaEQsT0FBTyxFQUNMLGNBQWMsRUFHZCxVQUFVLEVBQ1YsYUFBYSxHQUNkLE1BQU0sb0NBQW9DLENBQUM7QUFDNUMsT0FBTyxFQUNMLE1BQU0sRUFDTixHQUFHLEdBSUosTUFBTSw0QkFBNEIsQ0FBQztBQUNwQyxPQUFPLEVBS0wsS0FBSyxHQUNOLE1BQU0sOEJBQThCLENBQUMiLCJzb3VyY2VzQ29udGVudCI6WyIvLyBTaWRlLWVmZmVjdCBpbXBvcnQ6IGVuc3VyZSBgd29ybGQudHNgIGlzIGxvYWRlZCBzbyBpdHMgbW9kdWxlLWxvYWRcbi8vIGBnbG9iYWxUaGlzW0dldFdvcmxkRm5LZXldID8/PSBnZXRXb3JsZGAgcmVnaXN0cmF0aW9uIGZpcmVzIGJlZm9yZSBhbnlcbi8vIGhvc3Qgcm91dGUgcmVhY2hlcyBgZ2V0V29ybGRMYXp5KClgLiBXaXRob3V0IHRoaXMsIHdlYnBhY2svdHVyYm9wYWNrIGNhblxuLy8gdHJlZS1zaGFrZSBgd29ybGQudHNgIG91dCBvZiByb3V0ZXMgdGhhdCBvbmx5IHVzZSBgc3RhcnRgLiBSZXNvbHZlZCB0byBhblxuLy8gZW1wdHkgc3R1YiB2aWEgdGhlIGB3b3JrZmxvd2AgZXhwb3J0IGNvbmRpdGlvbiBpbiBWTS9zdGVwIGJ1bmRsZXMsIHNvIHRoaXNcbi8vIHN0YXlzIGhvc3Qtb25seS5cbi8vIFNlZSBgQHdvcmtmbG93L2NvcmUvc3JjL3J1bnRpbWUvd29ybGQtaW5pdC50c2AgZm9yIHRoZSBmdWxsIHJhdGlvbmFsZS5cbmltcG9ydCAnQHdvcmtmbG93L2NvcmUvcnVudGltZS93b3JsZC1pbml0JztcblxuZXhwb3J0IHR5cGUge1xuICBDYW5jZWxSdW5PcHRpb25zLFxuICBFdmVudCxcbiAgU3RvcFNsZWVwT3B0aW9ucyxcbiAgU3RvcFNsZWVwUmVzdWx0LFxuICBXb3JrZmxvd1J1bixcbn0gZnJvbSAnQHdvcmtmbG93L2NvcmUvcnVudGltZSc7XG5leHBvcnQge1xuICB0eXBlIFJ1bkNvbXBsZXRlZEhvb2tQYXJhbXMsXG4gIHR5cGUgUnVuRmFpbGVkSG9va1BhcmFtcyxcbiAgcmVnaXN0ZXJMaWZlY3ljbGVIb29rcyxcbiAgdHlwZSBXb3JrZmxvd0xpZmVjeWNsZUhvb2tzLFxufSBmcm9tICdAd29ya2Zsb3cvY29yZS9ydW50aW1lL2xpZmVjeWNsZS1ob29rcyc7XG5leHBvcnQge1xuICBnZXRIb29rQnlUb2tlbixcbiAgdHlwZSBIb29rLFxuICB0eXBlIFJlc3VtZWRIb29rLFxuICByZXN1bWVIb29rLFxuICByZXN1bWVXZWJob29rLFxufSBmcm9tICdAd29ya2Zsb3cvY29yZS9ydW50aW1lL3Jlc3VtZS1ob29rJztcbmV4cG9ydCB7XG4gIGdldFJ1bixcbiAgUnVuLFxuICB0eXBlIFdvcmtmbG93UmVhZGFibGVTdHJlYW0sXG4gIHR5cGUgV29ya2Zsb3dSZWFkYWJsZVN0cmVhbU9wdGlvbnMsXG4gIHR5cGUgV29ya2Zsb3dSdW5Xcml0YWJsZVN0cmVhbU9wdGlvbnMsXG59IGZyb20gJ0B3b3JrZmxvdy9jb3JlL3J1bnRpbWUvcnVuJztcbmV4cG9ydCB7XG4gIHR5cGUgRHluYW1pY1N0YXJ0T3B0aW9ucyxcbiAgdHlwZSBEeW5hbWljV29ya2Zsb3dPcHRpb25zLFxuICB0eXBlIER5bmFtaWNXb3JrZmxvd1N0ZXBSZWZlcmVuY2UsXG4gIHR5cGUgU3RhcnRPcHRpb25zLFxuICBzdGFydCxcbn0gZnJvbSAnQHdvcmtmbG93L2NvcmUvcnVudGltZS9zdGFydCc7XG4iXX0=
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Dynamic Workflows
|
|
3
|
+
description: Start a workflow run from source code that was not part of your build.
|
|
4
|
+
type: conceptual
|
|
5
|
+
summary: Pass workflow source to start() to run orchestration whose shape is only known after deployment.
|
|
6
|
+
prerequisites:
|
|
7
|
+
- /docs/foundations/starting-workflows
|
|
8
|
+
- /docs/how-it-works/code-transform
|
|
9
|
+
related:
|
|
10
|
+
- /docs/api-reference/workflow-api/start
|
|
11
|
+
- /docs/how-it-works/encryption
|
|
12
|
+
- /docs/configuration/runtime-tuning
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<Callout type="warning">
|
|
16
|
+
Dynamic workflows are **experimental** and **off by default**. The API may change without a major version bump. A deployment must opt in with `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`, dynamic runs can only start on the current deployment, and the World must support dynamic-source storage. See [Enabling dynamic workflows](#enabling-dynamic-workflows) and [World support](#world-support).
|
|
17
|
+
</Callout>
|
|
18
|
+
|
|
19
|
+
<Callout type="error">
|
|
20
|
+
Dynamic source runs with the **full privileges of your deployment's functions**. It can read every environment variable, use the network and the filesystem, and call any step in the deployment. `experimental_dynamic.steps` is not a security boundary. Only pass source you would merge into your codebase. See [Security](#security).
|
|
21
|
+
</Callout>
|
|
22
|
+
|
|
23
|
+
Normally a workflow function is compiled into your build: the [code transform](/docs/how-it-works/code-transform) rewrites every `"use workflow"` function, the build bundles them, and `start()` names one by importing it.
|
|
24
|
+
|
|
25
|
+
A dynamic workflow skips that. You hand `start()` a string of JavaScript, and it runs — no build, no deploy:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { start } from 'workflow/api';
|
|
29
|
+
import { fetchUser, sendEmail } from './steps';
|
|
30
|
+
|
|
31
|
+
const run = await start(
|
|
32
|
+
`
|
|
33
|
+
async function workflow(input) {
|
|
34
|
+
"use workflow";
|
|
35
|
+
|
|
36
|
+
const user = await steps.fetchUser(input.userId);
|
|
37
|
+
await steps.sendEmail(user.email);
|
|
38
|
+
|
|
39
|
+
return { ok: true };
|
|
40
|
+
}
|
|
41
|
+
`,
|
|
42
|
+
[{ userId: 'user_123' }],
|
|
43
|
+
{
|
|
44
|
+
experimental_dynamic: {
|
|
45
|
+
steps: { fetchUser, sendEmail },
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
console.log(await run.status); // 'running'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Only the *orchestration* is dynamic. Every step the source calls was deployed with your app, and `experimental_dynamic.steps` names the ones it calls by alias. That map does not stop source from reaching other steps; see [Security](#security). There is no way to define a new step from source.
|
|
54
|
+
|
|
55
|
+
## When to use this
|
|
56
|
+
|
|
57
|
+
Reach for dynamic workflows when the **shape** of the orchestration is only known after you deploy, and the source comes from code you trust as much as your own:
|
|
58
|
+
|
|
59
|
+
- **Orchestration your application assembles** from reviewed templates, over a fixed set of deployed steps.
|
|
60
|
+
- **Experiments** — try a new composition of existing steps without shipping a build.
|
|
61
|
+
|
|
62
|
+
Dynamic workflows are not a way to run code written by your end users or generated by a model from their input. That source would run with your deployment's privileges; see [Security](#security).
|
|
63
|
+
|
|
64
|
+
If your workflows are known at build time, use a normal workflow function. It has better types, better errors, no source validation, and no size limits.
|
|
65
|
+
|
|
66
|
+
## Enabling dynamic workflows
|
|
67
|
+
|
|
68
|
+
Dynamic workflows are off unless the deployment sets:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Only `1` or `true` (case-insensitive) enables them; any other value, or no value, leaves them off. The runtime reads the variable where workflows execute, when it needs it, so set it on the deployment or dev server rather than at build time. It controls three things:
|
|
75
|
+
|
|
76
|
+
- **Starting.** `start()` with source throws before it contacts the World, creates a run, or enqueues anything unless this process has opted in.
|
|
77
|
+
- **Delivery.** When a dynamic run reaches a deployment that has not opted in, the runtime does not execute its stored code. It fails the run with a `RUNTIME_ERROR` rather than retrying it.
|
|
78
|
+
- **Health check.** A deployment advertises dynamic support in its [health check](/docs/api-reference/workflow-runtime/health-check) only when it has opted in.
|
|
79
|
+
|
|
80
|
+
### Same deployment only
|
|
81
|
+
|
|
82
|
+
A dynamic run must execute on the deployment that started it. `start()` rejects a dynamic start whose target differs from the current deployment. That includes an explicit `deploymentId` for another deployment, `deploymentId: 'latest'` when it resolves to a different deployment, and any concrete target when the current deployment cannot be determined. The rejection happens before any capability check, key lookup, upload, run creation, or queue message.
|
|
83
|
+
|
|
84
|
+
## What the source can use
|
|
85
|
+
|
|
86
|
+
Dynamic source has no imports. Instead, the generated code predefines a small runtime surface:
|
|
87
|
+
|
|
88
|
+
| Binding | What it is |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `steps` | Frozen object of the aliases you passed in `experimental_dynamic.steps`. Calling one dispatches that registered step. |
|
|
91
|
+
| `sleep` | The [durable sleep](/docs/api-reference/workflow/sleep) primitive. |
|
|
92
|
+
| `createHook` | The [hook](/docs/foundations/hooks) primitive, for waiting on an external signal. |
|
|
93
|
+
|
|
94
|
+
The source also runs inside the normal deterministic workflow VM, so the usual [workflow globals](/docs/api-reference/workflow-globals) — `Date`, `Math.random`, `crypto`, `URL`, `TextEncoder`, `structuredClone`, and the rest — are available with the same determinism guarantees as a static workflow.
|
|
95
|
+
|
|
96
|
+
Dynamic source exposes only the small set of primitives injected by its generated wrapper. `createWebhook()` also needs the static workflow module's URL and metadata helper, and `getWritable()` needs its workflow-stream helper, so neither is currently injected into dynamic source. Use `createHook()` with server-side `resumeHook()`, and perform streaming through registered steps or a statically compiled workflow.
|
|
97
|
+
|
|
98
|
+
Here is a longer example using a timer and a hook to wait for an approval:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { start } from 'workflow/api';
|
|
102
|
+
import { sendEmail } from './steps';
|
|
103
|
+
|
|
104
|
+
const run = await start(
|
|
105
|
+
`
|
|
106
|
+
async function workflow(input) {
|
|
107
|
+
"use workflow";
|
|
108
|
+
|
|
109
|
+
await sleep("15m");
|
|
110
|
+
|
|
111
|
+
const approval = createHook({ token: input.approvalToken });
|
|
112
|
+
const result = await Promise.race([
|
|
113
|
+
approval,
|
|
114
|
+
sleep("1d").then(() => ({ approved: false, timedOut: true })),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
if (result.approved) {
|
|
118
|
+
await steps.sendEmail(input.email);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
`,
|
|
124
|
+
[{
|
|
125
|
+
userId: 'user_123',
|
|
126
|
+
email: 'ada@example.com',
|
|
127
|
+
approvalToken: 'approval-req_01J...',
|
|
128
|
+
}],
|
|
129
|
+
{
|
|
130
|
+
experimental_dynamic: {
|
|
131
|
+
steps: { sendEmail },
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Supply a unique, deterministic approval token from the caller. The workflow must recreate the same token during replay, while the external service needs that token to call `resumeHook()`; do not use a tenant or user ID alone when concurrent runs can overlap.
|
|
138
|
+
|
|
139
|
+
## Rules for the source
|
|
140
|
+
|
|
141
|
+
`start()` validates the source before it writes anything, so a definition that could never run fails at the call site rather than on a queue delivery:
|
|
142
|
+
|
|
143
|
+
- It must declare `async function workflow(...)`. Pass `experimental_dynamic.exportName` to use a different name; export names may contain letters, digits, and `_`, and cannot start with a digit.
|
|
144
|
+
- The function's first statement must be the `"use workflow"` directive.
|
|
145
|
+
- No `import` or `export`. Reach steps through `steps`, not through modules.
|
|
146
|
+
- JavaScript only — no TypeScript syntax, no npm dependencies, no bundling.
|
|
147
|
+
- No inline `"use step"` functions. Steps come from `experimental_dynamic.steps`.
|
|
148
|
+
- At most 128 KB of source.
|
|
149
|
+
- On Vercel, the run's execution context is limited to 2,048 bytes of JSON, and the `dynamicWorkflow` metadata below counts against it. That leaves room for roughly 30 step aliases, depending on how long the aliases and step IDs are. A start that exceeds it fails before anything is written.
|
|
150
|
+
|
|
151
|
+
Everything a static workflow must obey still applies: the body has to be [deterministic](/docs/foundations/workflows-and-steps), and any side effect belongs in a step.
|
|
152
|
+
|
|
153
|
+
## Workflow IDs
|
|
154
|
+
|
|
155
|
+
You do not choose the workflow ID. It is derived from the source and its step bindings:
|
|
156
|
+
|
|
157
|
+
```
|
|
158
|
+
workflow//dynamic/<source-hash>//<exportName>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Two consequences worth knowing:
|
|
162
|
+
|
|
163
|
+
- **The same definition always gets the same ID.** Runs of one generated workflow group together in [observability](/docs/observability) and share a queue topic, even across processes.
|
|
164
|
+
- **A caller cannot claim an ID.** Because the hash covers the source *and* the step bindings, arbitrary source cannot be made to run under a static workflow's name — or under another definition's.
|
|
165
|
+
|
|
166
|
+
Changing the source, or pointing an alias at a different step, produces a different workflow.
|
|
167
|
+
|
|
168
|
+
## How the code is stored
|
|
169
|
+
|
|
170
|
+
A dynamic run's workflow function is not in your deployment's bundle, so the run carries its own compiled workflow code — and replaying the run means replaying *that* code, not whatever your deployment contains now.
|
|
171
|
+
|
|
172
|
+
That code uses the same serialization path as workflow inputs. It is compressed when the run protocol supports compression and compression is worthwhile, and encrypted when the World supplies run key material (see [Encryption](/docs/how-it-works/encryption)). Vercel's supported configuration provides encrypted storage; the Local and Postgres Worlds store it in plaintext. Retention and deletion apply whether the stored bytes are plaintext or ciphertext.
|
|
173
|
+
|
|
174
|
+
When a run has key material, or was started with encryption, a delivery only executes code stored in the run's symmetric `encr` envelope. It refuses plaintext and sealed (`encp`) payloads and fails the run. Encryption keeps the code confidential; it does not prove who wrote it. See [Security](#security).
|
|
175
|
+
|
|
176
|
+
On Vercel, durable workflow code storage is ref-backed on the run. The definition's size changes only how those bytes reach the backend:
|
|
177
|
+
|
|
178
|
+
- **Small definitions** (the overwhelming majority) ride inline in the `run_created` request frame. The backend materializes those bytes into the run's ref-backed storage, with no upload request from `start()`.
|
|
179
|
+
- **Larger definitions** are uploaded first, and `run_created` carries the resulting reference. This costs one extra request at `start()`.
|
|
180
|
+
|
|
181
|
+
Both paths are transparent — there is nothing to configure. Here, “inline” describes request transport, not a second durable storage shape.
|
|
182
|
+
|
|
183
|
+
Alongside the serialized code, the run records small plaintext metadata on `executionContext.dynamicWorkflow`: the source hash, the export name, and the alias-to-step-ID map. That is what lets a run be identified as dynamic without decoding the source. It is plaintext even when the code is encrypted, so anyone who can read the run can see which step IDs it was given and the aliases they were given under.
|
|
184
|
+
|
|
185
|
+
## World support
|
|
186
|
+
|
|
187
|
+
Dynamic workflows need a World that can store the run's workflow code.
|
|
188
|
+
|
|
189
|
+
| World | Support |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| [Vercel](/worlds/vercel) | Encrypted, ref-backed storage (small definitions transported inline; large definitions uploaded first). If dynamic-source storage is not enabled for the project, the backend rejects the run's creation and `start()` throws. |
|
|
192
|
+
| [Local](/worlds/local) | Stored in plaintext on the run record in the local filesystem store. |
|
|
193
|
+
| [Postgres](/worlds/postgres) | Stored in plaintext on the run row. |
|
|
194
|
+
| Others | Supported when the World declares [`capabilities.dynamicWorkflowCode`](/worlds/building-a-world). |
|
|
195
|
+
|
|
196
|
+
After the opt-in and same-deployment checks, `start()` fails a dynamic start on a World that does not declare `capabilities.dynamicWorkflowCode`. On Vercel, `start()` then validates the final execution context against the 2,048-byte limit. All of this happens before serializing or uploading code, creating an event, or publishing a queue message.
|
|
197
|
+
|
|
198
|
+
## Security
|
|
199
|
+
|
|
200
|
+
<Callout type="warning">
|
|
201
|
+
Dynamic source is **trusted application code** with the full privileges of your deployment's functions. The workflow VM is a determinism sandbox, not a security sandbox. Code in it can reach the host process: it can read every environment variable, use the network and the filesystem, and call any step registered in the deployment with any arguments.
|
|
202
|
+
</Callout>
|
|
203
|
+
|
|
204
|
+
- **`steps` is not a boundary.** The `steps` object contains only the aliases you passed and is frozen, so ordinary code that calls `steps.somethingElse()` fails the run instead of dispatching a step it was not given. Code that is trying to reach other steps, or the host, can.
|
|
205
|
+
- **Only start source you would merge.** Do not build source from end-user input, and do not run model output generated from untrusted input. Either one gives whoever controls that input your deployment's privileges.
|
|
206
|
+
- **Opting in is a deployment decision.** A deployment that sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS` executes stored code for any dynamic run it receives. Anyone who can start a workflow on it can run code with its privileges.
|
|
207
|
+
- **Encryption gives confidentiality only.** Where the World encrypts stored code, it cannot be read at rest without the run's key, and a delivery refuses code that is not encrypted with that key. Anyone who can obtain the run's key can still write valid code, so encryption does not replace the opt-in.
|
|
208
|
+
- **Plaintext Worlds turn storage write access into code execution.** The Local and Postgres Worlds store the code in plaintext. On an opted-in deployment, anyone who can write to the Postgres database or the local data directory can make every worker execute code of their choosing.
|
|
209
|
+
- **The step map is readable.** `executionContext.dynamicWorkflow.steps` stores the alias-to-step-ID map in plaintext, so anyone with read access to the run sees the step IDs the source was given.
|
|
210
|
+
|
|
211
|
+
Treat dynamic source the way you would treat code in a pull request: written or reviewed by someone you trust with the deployment.
|
|
212
|
+
|
|
213
|
+
## Limitations
|
|
214
|
+
|
|
215
|
+
- Experimental — the API may change without a major version bump.
|
|
216
|
+
- Off unless the deployment sets `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`.
|
|
217
|
+
- Same-deployment starts only.
|
|
218
|
+
- JavaScript only. No TypeScript syntax, npm dependencies, or bundling.
|
|
219
|
+
- Steps must already be registered in the deployment; no runtime step registration.
|
|
220
|
+
- No inline `"use step"` functions, `createWebhook`, or `getWritable`.
|
|
221
|
+
- No caller-provided workflow IDs.
|
|
222
|
+
- Parser-based validation checks JavaScript syntax and the required source/wrapper shape without executing it. It does not validate behavior, determinism, or intent.
|
|
223
|
+
- On Vercel, roughly 30 step aliases fit the 2,048-byte execution-context limit.
|
|
224
|
+
- Requires a World with dynamic-source storage.
|
|
@@ -143,6 +143,35 @@ async function processOrder(orderId: string) {
|
|
|
143
143
|
|
|
144
144
|
Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration. To receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`.
|
|
145
145
|
|
|
146
|
+
### Registering a hook before a step uses it
|
|
147
|
+
|
|
148
|
+
A hook's registration is committed alongside everything else the workflow started before it suspended, not ahead of it. When a workflow creates a hook and calls a step without awaiting anything in between, the step can start running before the hook is registered, and it can run even if the registration turns out to conflict. That matters in two cases:
|
|
149
|
+
|
|
150
|
+
- The step hands the token to something that may call `resumeHook()` right away, which throws `HookNotFoundError` until the hook exists.
|
|
151
|
+
- The hook guards against duplicate runs. A run that only learns of the conflict after calling the step, for example by awaiting the hook and letting `HookConflictError` end the run, may already have started that step.
|
|
152
|
+
|
|
153
|
+
In either case, await `hook.getConflict()` before calling the step:
|
|
154
|
+
|
|
155
|
+
```typescript lineNumbers
|
|
156
|
+
import { createHook } from "workflow";
|
|
157
|
+
|
|
158
|
+
declare function requestApproval(token: string): Promise<void>; // @setup
|
|
159
|
+
|
|
160
|
+
async function approvalWorkflow() {
|
|
161
|
+
"use workflow";
|
|
162
|
+
|
|
163
|
+
using hook = createHook<{ approved: boolean }>();
|
|
164
|
+
await hook.getConflict(); // [!code highlight]
|
|
165
|
+
|
|
166
|
+
// The hook is registered, so an approver that resumes it immediately
|
|
167
|
+
// finds it.
|
|
168
|
+
await requestApproval(hook.token);
|
|
169
|
+
|
|
170
|
+
const { approved } = await hook;
|
|
171
|
+
return approved;
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
146
175
|
On a conflict, the resolved value is a `Run` handle for the run that owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context.
|
|
147
176
|
|
|
148
177
|
<Callout type="info">
|
|
@@ -222,7 +251,7 @@ With `experimental_force`, this run always ends up owning the token:
|
|
|
222
251
|
- Any number of runs forcing the same token at the same time converge on a single owner. The takeovers form a chain: each run that loses the token gets `HookForceClaimedError`, exactly one run ends up owning it, and none of them can get stuck. Which run wins among simultaneous claimers is not defined; if the order matters, start them in order.
|
|
223
252
|
- A finished run that still holds the token under [`experimental_minRetention`](#keep-a-token-unavailable-after-the-run-ends) is taken over silently, since there is nothing left to wake. A run can also take over a token held by its own earlier Hook.
|
|
224
253
|
|
|
225
|
-
The takeover is durable. If either run's compute fails partway through, the next request for the token completes it, so the token never ends up held by nobody or by both runs. The previous owner's wake is durable too: the new owner
|
|
254
|
+
The takeover is durable. If either run's compute fails partway through, the next request for the token completes it, so the token never ends up held by nobody or by both runs. The previous owner's wake is durable too: if the new owner's compute fails between registering the Hook and waking the previous owner, the new owner's next invocation republishes the wake, whatever else the new owner has recorded since (a step it started alongside the Hook, for example). Every invocation of the new owner within 24 hours of the takeover republishes it under the same idempotency key, which collapses the repeats into one wake; a repeat that does get through only replays the previous owner, which finds nothing new.
|
|
226
255
|
|
|
227
256
|
<Callout type="info">
|
|
228
257
|
A token can only be taken from a run whose runtime understands being taken from. Runs started at a Workflow spec version below 8, which includes every run started by an older SDK release, a Python SDK run, or a deployment with `WORKFLOW_SEALED_LOG=0`, would never learn that their Hook was disposed. The World declines to take their token and the forced Hook rejects with the ordinary [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error) instead, exactly as if `experimental_force` had not been set. Finished runs holding a retained token are taken over at any version.
|
|
@@ -55,7 +55,7 @@ The returned `Webhook` object has:
|
|
|
55
55
|
|
|
56
56
|
- `url`: The HTTP endpoint URL that external systems can call
|
|
57
57
|
- `token`: The unique token identifying this webhook
|
|
58
|
-
- `getConflict()`: A promise that resolves with the conflicting run if another active hook already owns this token, or `null` once the webhook endpoint has been registered
|
|
58
|
+
- `getConflict()`: A promise that resolves with the conflicting run if another active hook already owns this token, or `null` once the webhook endpoint has been registered. The endpoint is registered alongside the steps the workflow starts at the same time, not ahead of them, so await `getConflict()` before a step that hands `url` to a caller who may request it right away. See [Registering a hook before a step uses it](/docs/api-reference/workflow/create-hook#registering-a-hook-before-a-step-uses-it).
|
|
59
59
|
- Implements `AsyncIterable<T>` for handling multiple requests, where `T` is `Request` (default) or `RequestWithResponse` (manual mode)
|
|
60
60
|
|
|
61
61
|
When using `createWebhook({ respondWith: 'manual' })`, the resolved request type is `RequestWithResponse`, which extends the standard `Request` interface with a `respondWith(response: Response): Promise<void>` method for sending custom responses back to the caller.
|
|
@@ -211,6 +211,8 @@ export async function slackBotWorkflow(channelId: string) {
|
|
|
211
211
|
}
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
`create()` accepts the same options as `createHook()`. If a newer run should replace one that still holds the token, pass [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) to take the token over instead of getting [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error).
|
|
215
|
+
|
|
214
216
|
## Related functions
|
|
215
217
|
|
|
216
218
|
- [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow.
|
|
@@ -49,11 +49,11 @@ showSections={["parameters"]}
|
|
|
49
49
|
|
|
50
50
|
### Returns
|
|
51
51
|
|
|
52
|
-
Returns a function that unregisters these hooks.
|
|
52
|
+
Returns a function that unregisters these hooks. Registrations are not deduplicated. Register each hook set once per process, and unregister the previous hooks before registering again during hot reload or module re-evaluation.
|
|
53
53
|
|
|
54
54
|
## Handlers
|
|
55
55
|
|
|
56
|
-
Both handlers receive a `workflowName` string and a lazily hydrated [`Run`](/docs/api-reference/workflow-api/get-run) instance. Use the `workflowName` parameter to filter without a backend read; `run.runId` also requires no read. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. Lazy access defers those reads rather than eliminating them.
|
|
56
|
+
Both handlers receive a `workflowName` string and a lazily hydrated [`Run`](/docs/api-reference/workflow-api/get-run) instance. Use the `workflowName` parameter to filter without a backend read; `run.runId` also requires no read. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. In particular, `workflowName` is a string, while `run.workflowName` is a `Promise<string>`. Lazy access defers those reads rather than eliminating them.
|
|
57
57
|
|
|
58
58
|
### `onRunCompleted`
|
|
59
59
|
|
|
@@ -72,9 +72,11 @@ Invoked when a workflow run fails terminally (after any retries).
|
|
|
72
72
|
| --- | --- | --- |
|
|
73
73
|
| `params.run` | `Run` | The failed run. |
|
|
74
74
|
| `params.workflowName` | `string` | The machine-readable workflow identifier, such as `workflow//./src/workflows/order//processOrder`. Available without a backend read. |
|
|
75
|
-
| `params.error` | `WorkflowRunFailedError` | The failure
|
|
75
|
+
| `params.error` | `WorkflowRunFailedError` | The persisted failure hydrated for reporting: `error.errorCode` carries the classification (e.g. `USER_ERROR`) and `error.cause` is the hydrated thrown value. |
|
|
76
76
|
|
|
77
|
-
`error.cause`
|
|
77
|
+
Unlike `run.returnValue`, `error.cause` defers readable stream I/O until consumption and revives abort signals as persisted snapshots without live subscriptions. Writable streams retain their normal forwarding pipe and lock-polling setup during hydration. If hydration fails, the cause is a generic `Error`, matching `run.returnValue`'s fallback. In `onRunFailed`, `run.returnValue` rejects because the run failed. Use `error.cause` to inspect or report the thrown value instead.
|
|
78
|
+
|
|
79
|
+
The invocation's `waitUntil` scope includes background stream operations from the hydrated cause, even after a handler returns or throws. Close or release stream reader and writer locks when finished so that work can settle. Await other asynchronous reporting work in your handler to keep it in the same lifetime scope.
|
|
78
80
|
|
|
79
81
|
## Behavior
|
|
80
82
|
|
|
@@ -7,6 +7,7 @@ prerequisites:
|
|
|
7
7
|
- /docs/foundations/starting-workflows
|
|
8
8
|
related:
|
|
9
9
|
- /docs/foundations/idempotency
|
|
10
|
+
- /docs/advanced/dynamic-workflows
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
Start/enqueue a new workflow run.
|
|
@@ -59,6 +60,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
|
|
|
59
60
|
* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered. Handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict, it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
60
61
|
* All arguments must be [serializable](/docs/foundations/serialization).
|
|
61
62
|
* When you provide `deploymentId`, the argument types and return type become `unknown` because the workflow function's types may differ across deployments.
|
|
63
|
+
* When `deploymentId` names a deployment other than the caller's, the run is stamped with the spec version the *target* deployment reports on its capability probe (capped at the caller's own), since the target is what executes it. `start()` waits up to 10 seconds for the first probe to a deployment and returns as soon as the target answers; later starts to the same deployment reuse the answer. If the target does not answer in time, the run is stamped with spec version 6, the lowest version a v5 runtime executes; a target on an older major version (such as `stable`) cannot execute such a run, so it logs a warning. In either case, the `attributes` and `experimental_retention` checks below apply to the stamped version, and fail naming the target deployment.
|
|
62
64
|
* `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name.
|
|
63
65
|
* `region` pins the new run to a specific region on Worlds with a regional dimension. The [Vercel World](/worlds/vercel#explicit-region-selection) then serves the run's storage, queue dispatch, and streams from that region. When you omit `region`, the run is pinned to the region where it was created. Worlds without regions ignore the option.
|
|
64
66
|
* `experimental_retention` asks the World to delete the run's user data as soon as the run completes or fails, instead of keeping it for the World's default window. `0` requests immediate deletion; `'default'` is identical to omitting the option. These are the only two values accepted — the value is a duration and zero is the only one implemented, and its unit is not yet decided. Recorded as the reserved `$retention` attribute, so it needs a World implementing spec version 4 or later. Retention is enforced by the World, not the SDK: the first-party Worlds implement it and a World that does not keeps the data. Note that `await run.returnValue` on a run started with `experimental_retention: 0` usually throws [`RunExpiredError`](/docs/errors/run-expired) rather than resolving, because the deletion races the read. See [Data retention](/docs/observability/retention).
|
|
@@ -152,3 +154,50 @@ The returned `Run` object is fully functional inside a workflow. Each property a
|
|
|
152
154
|
<Callout type="warn">
|
|
153
155
|
`returnValue` polls the child run every second and holds the polling step's worker slot open for as long as the child takes to finish. For long-running children, spawn without awaiting `returnValue` and have the child resume a [hook](/docs/foundations/hooks) when it completes. See the [`startAndWait()` pattern](/cookbook/advanced/child-workflows).
|
|
154
156
|
</Callout>
|
|
157
|
+
|
|
158
|
+
### Dynamic Workflow Source
|
|
159
|
+
|
|
160
|
+
<Callout type="warning">
|
|
161
|
+
Experimental and off by default. The API may change without a major version bump. The deployment must set `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS=1`, and dynamic runs can only start on the current deployment.
|
|
162
|
+
</Callout>
|
|
163
|
+
|
|
164
|
+
`start()` also accepts a string of workflow **source** instead of an imported function, for orchestration whose shape is only known after you deploy. The source is compiled and stored with the run through the same serialization path as other run payloads, including encryption when the World supplies run key material.
|
|
165
|
+
|
|
166
|
+
Dynamic source runs with the full privileges of your deployment's functions: it can read environment variables, use the network and filesystem, and call any step in the deployment. Only pass source you trust as much as your own code.
|
|
167
|
+
|
|
168
|
+
`experimental_dynamic.steps` maps the aliases the source may call to steps that are already registered in your deployment. It keeps ordinary source from calling a step by a name it was not given, but it is not a security boundary. There is no way to define a new step from source.
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
import { start } from "workflow/api";
|
|
172
|
+
import { fetchUser, sendEmail } from "./steps";
|
|
173
|
+
|
|
174
|
+
const run = await start(
|
|
175
|
+
`
|
|
176
|
+
async function workflow(input) {
|
|
177
|
+
"use workflow";
|
|
178
|
+
|
|
179
|
+
const user = await steps.fetchUser(input.userId);
|
|
180
|
+
await steps.sendEmail(user.email);
|
|
181
|
+
|
|
182
|
+
return { ok: true };
|
|
183
|
+
}
|
|
184
|
+
`,
|
|
185
|
+
[{ userId: "user_123" }],
|
|
186
|
+
{
|
|
187
|
+
experimental_dynamic: { // [!code highlight]
|
|
188
|
+
steps: { fetchUser, sendEmail }, // [!code highlight]
|
|
189
|
+
}, // [!code highlight]
|
|
190
|
+
}
|
|
191
|
+
);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
| Option | Type | Description |
|
|
195
|
+
| --- | --- | --- |
|
|
196
|
+
| `experimental_dynamic.steps` | `Record<string, StepFunction \| { stepId: string }>` | Required. Registered steps the source may call, keyed by the alias it calls them under. |
|
|
197
|
+
| `experimental_dynamic.exportName` | `string` | Name of the async workflow function in the source. Use letters, digits, and `_`, not starting with a digit. Defaults to `"workflow"`. |
|
|
198
|
+
|
|
199
|
+
The return type is `Run<unknown>`: the source's shape is only known to whatever generated it, so there is nothing to infer. The workflow ID is derived from the source and its step bindings — it cannot be supplied.
|
|
200
|
+
|
|
201
|
+
A dynamic start throws a `WorkflowRuntimeError`, before anything is written, when the deployment has not opted in, when `deploymentId` targets another deployment (including `'latest'` resolving to one), or when the World's backend does not support dynamic-source storage.
|
|
202
|
+
|
|
203
|
+
See [Dynamic Workflows](/docs/advanced/dynamic-workflows) for the opt-in, the source rules and limits, the predefined runtime bindings (`steps`, `sleep`, `createHook`), how the code is stored, and the security model.
|
|
@@ -9,7 +9,7 @@ related:
|
|
|
9
9
|
- /docs/errors/hook-conflict
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies.
|
|
12
|
+
`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. To take the token over instead, create the hook with [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds).
|
|
13
13
|
|
|
14
14
|
```typescript lineNumbers
|
|
15
15
|
import { HookConflictError } from "workflow/errors"
|
|
@@ -12,6 +12,8 @@ related:
|
|
|
12
12
|
|
|
13
13
|
You can check for cancellation before awaiting by inspecting `run.status`.
|
|
14
14
|
|
|
15
|
+
A canceled run is terminal, so this error is non-retryable (`fatal: true`). Inside a workflow, `await run.returnValue` runs as a step, and that step fails on its first attempt instead of spending its retry budget re-reading a run that cannot change. Errors from *failing to read* the run, such as a transport blip, stay retryable.
|
|
16
|
+
|
|
15
17
|
```typescript lineNumbers
|
|
16
18
|
import { WorkflowRunCancelledError } from "workflow/errors"
|
|
17
19
|
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup
|
|
@@ -34,6 +36,11 @@ definition={`
|
|
|
34
36
|
interface WorkflowRunCancelledError {
|
|
35
37
|
/** The ID of the canceled run. */
|
|
36
38
|
runId: string;
|
|
39
|
+
/**
|
|
40
|
+
* Always \`true\`. A canceled run is terminal, so a step that reads one is
|
|
41
|
+
* not retried.
|
|
42
|
+
*/
|
|
43
|
+
fatal: true;
|
|
37
44
|
/** The error message. */
|
|
38
45
|
message: string;
|
|
39
46
|
}
|
|
@@ -13,6 +13,8 @@ related:
|
|
|
13
13
|
|
|
14
14
|
The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown`, so narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property.
|
|
15
15
|
|
|
16
|
+
A failed run is terminal, so this error is non-retryable (`fatal: true`). Inside a workflow, `await run.returnValue` runs as a step, and that step fails on its first attempt instead of spending its retry budget re-reading a run that cannot change: the remote failure reaches the caller immediately, and the caller catches a `WorkflowRunFailedError` rather than a retry-exhaustion wrapper. Errors from *failing to read* the run, such as a transport blip, stay retryable.
|
|
17
|
+
|
|
16
18
|
```typescript lineNumbers
|
|
17
19
|
import { WorkflowRunFailedError } from "workflow/errors"
|
|
18
20
|
declare const run: { status: Promise<string>; returnValue: Promise<any> }; // @setup
|
|
@@ -50,6 +52,11 @@ interface WorkflowRunFailedError {
|
|
|
50
52
|
cause: unknown;
|
|
51
53
|
/** The high-level error category (e.g. \`USER_ERROR\`, \`RUNTIME_ERROR\`). */
|
|
52
54
|
errorCode?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Always \`true\`. A failed run is terminal, so a step that reads one is not
|
|
57
|
+
* retried.
|
|
58
|
+
*/
|
|
59
|
+
fatal: true;
|
|
53
60
|
/** The error message. */
|
|
54
61
|
message: string;
|
|
55
62
|
}
|
|
@@ -48,3 +48,4 @@ Returns a `Promise<HealthCheckResult>`:
|
|
|
48
48
|
| `latencyMs` | `number \| undefined` | Round-trip latency when the check succeeded |
|
|
49
49
|
| `specVersion` | `number \| undefined` | Workflow spec version of the responding deployment |
|
|
50
50
|
| `workflowCoreVersion` | `string \| undefined` | `@workflow/core` version of the responding deployment |
|
|
51
|
+
| `dynamicWorkflowVersion` | `number \| undefined` | Dynamic-workflow runtime version of the responding deployment. Present only when it has opted in with `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS`; see [Dynamic Workflows](/docs/advanced/dynamic-workflows) |
|
|
@@ -23,6 +23,7 @@ keywords:
|
|
|
23
23
|
- Event
|
|
24
24
|
- cursor pagination
|
|
25
25
|
- resolveData
|
|
26
|
+
- skip-step-inputs
|
|
26
27
|
- run_cancelled
|
|
27
28
|
- correlation ID
|
|
28
29
|
- parseStepName
|
|
@@ -94,7 +95,7 @@ const result = await world.events.list({ runId, pagination: { cursor } }); // [!
|
|
|
94
95
|
| `params.pagination.cursor` | `string` | Cursor for the next page |
|
|
95
96
|
| `params.pagination.limit` | `number` | Maximum events to return. When omitted, returns every remaining event up to the World's event ceiling. |
|
|
96
97
|
| `params.pagination.sortOrder` | `"asc" \| "desc"` | Event order |
|
|
97
|
-
| `params.resolveData` | `"all" \| "none"` | Include or omit event payload data |
|
|
98
|
+
| `params.resolveData` | `"all" \| "none" \| "skip-step-inputs"` | Include or omit event payload data. `"skip-step-inputs"` is `"all"` without the `input` of `step_created` and `step_started` events, which replay does not read. |
|
|
98
99
|
|
|
99
100
|
**Returns:** `{ data: Event[], cursor: string | null, hasMore: boolean }`
|
|
100
101
|
|
|
@@ -359,6 +360,7 @@ const result = await world.hooks.list({ // [!code highlight]
|
|
|
359
360
|
| `environment` | `string` | Deployment environment |
|
|
360
361
|
| `metadata` | `object` | Custom metadata attached to the hook |
|
|
361
362
|
| `isWebhook` | `boolean` | Whether this is a webhook-style hook |
|
|
363
|
+
| `claimedFrom` | `{ runId: string; hookId: string } \| undefined` | Set when this hook took its token from another run with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). Names that run and its hook |
|
|
362
364
|
|
|
363
365
|
---
|
|
364
366
|
|
|
@@ -62,11 +62,11 @@ The contract:
|
|
|
62
62
|
|
|
63
63
|
## The runtime integration (suspension fan-out fold)
|
|
64
64
|
|
|
65
|
-
**On by default.** The suspension handler folds a **clean fan-out** (the suspension's eager `step_created` and `wait_created` writes) into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes
|
|
65
|
+
**On by default.** The suspension handler folds a **clean fan-out** (the suspension's eager `step_created` and `wait_created` writes) into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes and no resilient step dispatch; everything else keeps the single-event path byte-for-byte. A suspension that also creates or disposes hooks still folds: hook writes are not batchable, so they go through the single-event path **concurrently** with the fold rather than ahead of it.
|
|
66
66
|
|
|
67
67
|
**Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged.
|
|
68
68
|
|
|
69
|
-
**Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.
|
|
69
|
+
**Pre-claimed inline pairs.** When the fold engages with at least two inline steps, the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The pairs commit in a chunk of their own, ahead of the plain `step_created` and `wait_created` chunks, so the write the inline bodies wait for carries only two rows per inline step (a small transaction that commits faster than a full 32-event chunk) while the plain creates commit concurrently beside it. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and the sibling chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step keeps the optimistic lazy-start path (one row, whose claim overlaps the body) even when eager creates batch beside it: the pairs share no round trip with those creates, so only two or more inline steps make a pair chunk worth the trade. The exception is a lone inline step in a suspension that creates a hook: the runtime never starts a body before its claim settles while a hook is being created, and a lazy claim could only be sent after the hook write committed, so the step's pair is folded instead and its claim commits concurrently with the hook write. A plain partition of exactly one `step_created` or `wait_created` beside the pairs is written through the ordinary single path rather than a one-row batch, and its queue message still waits for that write.
|
|
70
70
|
|
|
71
71
|
Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would. A batch carrying a `step_started` (that is, any batch with inline pairs) is **not** retried in-process on a transport blip: a pair's `409` cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation.
|
|
72
72
|
|
|
@@ -73,7 +73,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
73
73
|
- Default: `25000`
|
|
74
74
|
- Positive-integer event limit reported by the Local World and enforced by the runtime as `MAX_EVENTS_EXCEEDED`.
|
|
75
75
|
- The Local and Postgres Worlds also use it as the maximum number of events returned when `events.list()` is called without a limit. If more events exist, the response includes `hasMore: true` and a continuation cursor.
|
|
76
|
-
- The Vercel World receives its event limit from the service; this environment variable does not override that service-owned value.
|
|
76
|
+
- The Vercel World receives its event limit from the service; this environment variable does not override that service-owned value. See [Vercel World limits](/worlds/vercel#per-run-limits).
|
|
77
77
|
- Invalid or non-positive values fall back to the default.
|
|
78
78
|
|
|
79
79
|
### `WORKFLOW_REPLAY_DIVERGENCE_MAX_RETRIES`
|
|
@@ -129,6 +129,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
129
129
|
- New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap.
|
|
130
130
|
- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow or advancing the deterministic clock. Its timestamp belongs to whichever reader sealed it, not to the run.
|
|
131
131
|
- Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems.
|
|
132
|
+
- Setting `0` also stamps new runs below spec version 8, so another run can't take their hook tokens over with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). A forced hook in another run gets `HookConflictError` instead. Runs created with `0` can still take tokens over from others.
|
|
132
133
|
- Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting.
|
|
133
134
|
- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That includes every runtime on this release train, but a runtime that pins its own accepted spec range separately, such as the Python runtime, has to catch up before it can read these runs. Switch this off in an environment where it has not.
|
|
134
135
|
- Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec.
|
|
@@ -236,6 +237,15 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
236
237
|
- A bundle whose module scope consumes randomness, reads the clock, or replaces a serialization intrinsic cannot be snapshotted safely. The runtime detects these cases when preparing the snapshot and falls back to per-invocation evaluation.
|
|
237
238
|
- Set `0` or `false` to always evaluate the bundle per invocation.
|
|
238
239
|
|
|
240
|
+
## Dynamic workflows
|
|
241
|
+
|
|
242
|
+
### `WORKFLOW_EXPERIMENTAL_DYNAMIC_WORKFLOWS`
|
|
243
|
+
|
|
244
|
+
- Default: disabled
|
|
245
|
+
- Set `1` or `true` (case-insensitive) to let this deployment start [dynamic workflows](/docs/advanced/dynamic-workflows), execute their stored code on delivery, and advertise dynamic support in its health check. Any other value leaves it disabled.
|
|
246
|
+
- Dynamic source runs with the full privileges of the deployment's functions. Enable it only on deployments whose dynamic source you trust as much as your own code.
|
|
247
|
+
- When disabled, `start()` with source throws before writing anything, and a delivered dynamic run fails instead of executing.
|
|
248
|
+
|
|
239
249
|
## Compression and tracing
|
|
240
250
|
|
|
241
251
|
### `WORKFLOW_DISABLE_COMPRESSION`
|
|
@@ -280,7 +290,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL
|
|
|
280
290
|
|
|
281
291
|
Node's own modules do less than the client they replace, so enabling this drops the per-call-site tuning the Worlds configure:
|
|
282
292
|
|
|
283
|
-
- Event-log requests lose HTTP/2, so concurrent reads and writes no longer share one connection, and the enlarged HTTP/2 receive windows no longer apply. This is the largest difference, and it slows down replays that read a big event log. It does not apply to event writes on the [WebSocket events transport](/docs/configuration/worlds#workflow_events_transport), which
|
|
293
|
+
- Event-log requests lose HTTP/2, so concurrent reads and writes no longer share one connection, and the enlarged HTTP/2 receive windows no longer apply. This is the largest difference, and it slows down replays that read a big event log. It does not apply to event writes on the opt-in [WebSocket events transport](/docs/configuration/worlds#workflow_events_transport), which takes neither transport.
|
|
284
294
|
- Requests lose their transport-level retry. Failures still surface to the layers above, which retry event writes and redeliver queue messages, so nothing is silently dropped, but a failure that a same-connection retry would have hidden now costs a full redelivery.
|
|
285
295
|
- Stream close loses its retry of retriable server errors. A transient failure at close can leave a stream marked closing until the run expires, where it would previously have resolved on the retry.
|
|
286
296
|
|
|
@@ -386,4 +396,4 @@ These variables are primarily for tests, debugging, or unusual deployments.
|
|
|
386
396
|
- Default: unset
|
|
387
397
|
- Lowers the per-run event ceiling supplied by the World. A run whose event log reaches the ceiling fails with `MAX_EVENTS_EXCEEDED`, which stops a runaway loop from growing its log without bound.
|
|
388
398
|
- Clamp-down only: it never raises the World's limit, and it applies even when the World supplies none. With no World limit and no override, nothing is enforced.
|
|
389
|
-
- The Local and Vercel Worlds both supply a limit; the Local World defaults to 25,000 and is configurable with [`WORKFLOW_MAX_EVENTS`](/docs/configuration/worlds#workflow_max_events).
|
|
399
|
+
- The Local and Vercel Worlds both supply a limit; the Local World defaults to 25,000 and is configurable with [`WORKFLOW_MAX_EVENTS`](/docs/configuration/worlds#workflow_max_events). The Vercel World's ceiling is documented under [Workflow run limits](https://vercel.com/docs/workflows/pricing#workflow-run-limits).
|