workflow 5.0.0-beta.15 → 5.0.0-beta.17
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/docs/api-reference/vitest/index.mdx +1 -1
- package/docs/api-reference/workflow/create-hook.mdx +7 -1
- package/docs/api-reference/workflow/fetch.mdx +5 -0
- package/docs/api-reference/workflow-api/get-hook-by-token.mdx +7 -0
- package/docs/api-reference/workflow-api/get-run.mdx +6 -0
- package/docs/api-reference/workflow-api/resume-hook.mdx +57 -0
- package/docs/api-reference/workflow-api/start.mdx +4 -1
- package/docs/api-reference/workflow-errors/index.mdx +85 -0
- package/docs/api-reference/workflow-next/with-workflow.mdx +2 -2
- package/docs/api-reference/workflow-runtime/world/storage.mdx +6 -0
- package/docs/changelog/index.mdx +1 -1
- package/docs/cookbook/advanced/child-workflows.mdx +3 -1
- package/docs/cookbook/advanced/publishing-libraries.mdx +13 -12
- package/docs/cookbook/advanced/serializable-steps.mdx +3 -3
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +2 -2
- package/docs/cookbook/common-patterns/idempotency.mdx +36 -52
- package/docs/cookbook/common-patterns/rate-limiting.mdx +1 -1
- package/docs/cookbook/common-patterns/saga.mdx +2 -2
- package/docs/cookbook/common-patterns/scheduling.mdx +4 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +2 -1
- package/docs/cookbook/common-patterns/workflow-composition.mdx +6 -0
- package/docs/cookbook/index.mdx +1 -1
- package/docs/cookbook/integrations/ai-sdk.mdx +10 -1
- package/docs/cookbook/integrations/chat-sdk.mdx +9 -0
- package/docs/cookbook/integrations/sandbox.mdx +9 -0
- package/docs/errors/step-not-registered.mdx +1 -1
- package/docs/foundations/cancellation.mdx +1 -2
- package/docs/foundations/hooks.mdx +1 -1
- package/docs/foundations/idempotency.mdx +236 -11
- package/docs/migration-guides/migrating-from-temporal.mdx +1 -1
- package/docs/observability/attributes.mdx +14 -0
- package/docs/observability/index.mdx +3 -0
- package/docs/observability/meta.json +4 -1
- package/docs/observability/tracing.mdx +106 -0
- package/docs/testing/index.mdx +2 -2
- package/package.json +12 -12
|
@@ -69,7 +69,7 @@ export async function setup() {
|
|
|
69
69
|
|
|
70
70
|
### `setupWorkflowTests()`
|
|
71
71
|
|
|
72
|
-
Sets up an in-process workflow runtime in each test worker. Imports pre-built bundles, creates a [Local World](/
|
|
72
|
+
Sets up an in-process workflow runtime in each test worker. Imports pre-built bundles, creates a [Local World](/worlds/local) instance with direct handlers, and sets it as the global world. Clears all workflow data on each invocation for full test isolation.
|
|
73
73
|
|
|
74
74
|
Called automatically by the `workflow()` plugin in `setupFiles`. Use directly only for [manual setup](/docs/testing#manual-setup).
|
|
75
75
|
|
|
@@ -8,6 +8,7 @@ prerequisites:
|
|
|
8
8
|
related:
|
|
9
9
|
- /docs/api-reference/workflow/define-hook
|
|
10
10
|
- /docs/api-reference/workflow/create-webhook
|
|
11
|
+
- /docs/foundations/idempotency
|
|
11
12
|
---
|
|
12
13
|
|
|
13
14
|
Creates a low-level hook primitive that can be used to resume a workflow run with arbitrary payloads.
|
|
@@ -142,7 +143,11 @@ async function processOrder(orderId: string) {
|
|
|
142
143
|
|
|
143
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`.
|
|
144
145
|
|
|
145
|
-
On a conflict, the resolved value is a `Run` handle for the run that currently 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 [
|
|
146
|
+
On a conflict, the resolved value is a `Run` handle for the run that currently 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
|
+
|
|
148
|
+
<Callout type="info">
|
|
149
|
+
Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
150
|
+
</Callout>
|
|
146
151
|
|
|
147
152
|
### Waiting for Multiple Payloads
|
|
148
153
|
|
|
@@ -227,3 +232,4 @@ This is equivalent to manually calling `dispose()` but ensures the hook is alway
|
|
|
227
232
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper
|
|
228
233
|
- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload
|
|
229
234
|
- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Higher-level HTTP webhook abstraction
|
|
235
|
+
- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts
|
|
@@ -7,12 +7,17 @@ prerequisites:
|
|
|
7
7
|
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
9
|
- /docs/errors/fetch-in-workflow
|
|
10
|
+
- /docs/foundations/idempotency
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
Makes HTTP requests from within a workflow. This is a special step function that wraps the standard `fetch` API, automatically handling serialization and providing retry semantics.
|
|
13
14
|
|
|
14
15
|
This is useful when you need to call external APIs or services from within your workflow.
|
|
15
16
|
|
|
17
|
+
<Callout type="warn">
|
|
18
|
+
Because workflow `fetch()` has retry semantics, use idempotency keys when the request mutates an external system, such as creating a charge, sending an email, or enqueueing work. See [Idempotency](/docs/foundations/idempotency).
|
|
19
|
+
</Callout>
|
|
20
|
+
|
|
16
21
|
<Callout>
|
|
17
22
|
`fetch` is a *special* type of step function provided and should be called directly inside workflow functions.
|
|
18
23
|
</Callout>
|
|
@@ -5,6 +5,8 @@ type: reference
|
|
|
5
5
|
summary: Use getHookByToken to look up a hook's metadata and associated workflow run before resuming it.
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/hooks
|
|
8
|
+
related:
|
|
9
|
+
- /docs/foundations/idempotency
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
Retrieves a hook by its unique token, returning the associated workflow run information and any metadata that was set when the hook was created. This function is useful for inspecting hook details before deciding whether to resume a workflow.
|
|
@@ -13,6 +15,10 @@ Retrieves a hook by its unique token, returning the associated workflow run info
|
|
|
13
15
|
`getHookByToken` is a runtime function that must be called from outside a workflow function.
|
|
14
16
|
</Callout>
|
|
15
17
|
|
|
18
|
+
<Callout type="info">
|
|
19
|
+
Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the 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 route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
20
|
+
</Callout>
|
|
21
|
+
|
|
16
22
|
```typescript lineNumbers
|
|
17
23
|
import { getHookByToken } from "workflow/api";
|
|
18
24
|
|
|
@@ -178,3 +184,4 @@ export async function POST(request: Request) {
|
|
|
178
184
|
- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload.
|
|
179
185
|
- [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
|
|
180
186
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper.
|
|
187
|
+
- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts.
|
|
@@ -5,12 +5,18 @@ type: reference
|
|
|
5
5
|
summary: Use getRun to check a workflow run's status and metadata without blocking on completion.
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/starting-workflows
|
|
8
|
+
related:
|
|
9
|
+
- /docs/foundations/idempotency
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
Retrieves the workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion, making it ideal for status checking and monitoring.
|
|
11
13
|
|
|
12
14
|
Use this function when you need to check workflow status, get timing information, or access workflow metadata without blocking on workflow completion.
|
|
13
15
|
|
|
16
|
+
<Callout type="info">
|
|
17
|
+
`getRun()` retrieves a run when you already have its `runId`. It does not look up runs by a business key. For retried requests that should route to one active workflow, use a deterministic hook token and [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). After a hook conflict, `HookConflictError.conflictingRunId` can be passed to `getRun()` to inspect, stream, or return the active owner. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
18
|
+
</Callout>
|
|
19
|
+
|
|
14
20
|
```typescript lineNumbers
|
|
15
21
|
import { getRun } from "workflow/api";
|
|
16
22
|
|
|
@@ -7,6 +7,7 @@ prerequisites:
|
|
|
7
7
|
- /docs/foundations/hooks
|
|
8
8
|
related:
|
|
9
9
|
- /docs/api-reference/workflow-api/resume-webhook
|
|
10
|
+
- /docs/foundations/idempotency
|
|
10
11
|
---
|
|
11
12
|
|
|
12
13
|
Resumes a workflow run by sending a payload to a hook identified by its token.
|
|
@@ -155,8 +156,64 @@ export async function POST(request: Request) {
|
|
|
155
156
|
}
|
|
156
157
|
```
|
|
157
158
|
|
|
159
|
+
### Resume or Start
|
|
160
|
+
|
|
161
|
+
A common endpoint shape is "resume or start": one route that resumes the active workflow run for a business key if one exists, or starts a new run otherwise. This comes up when the workflow uses a deterministic hook token as its idempotency key — for example, one active run per order or conversation.
|
|
162
|
+
|
|
163
|
+
`resumeHook()` is the resume half of that flow. Try it first; if it throws `HookNotFoundError`, no active run owns the token yet, so start the workflow. One subtlety: `start()` returns before the new run executes and registers its hook, so you cannot resume immediately after starting. Retry the resume until the hook is registered — if you drop the payload and only start the workflow, the data from this request is lost.
|
|
164
|
+
|
|
165
|
+
```typescript lineNumbers
|
|
166
|
+
import { resumeHook, start } from "workflow/api";
|
|
167
|
+
import { HookNotFoundError } from "workflow/errors";
|
|
168
|
+
import { processOrder } from "./workflows/process-order";
|
|
169
|
+
|
|
170
|
+
type OrderRequest = { confirmed: boolean };
|
|
171
|
+
|
|
172
|
+
async function resumeWithRetry(token: string, payload: OrderRequest) {
|
|
173
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
174
|
+
try {
|
|
175
|
+
return await resumeHook(token, payload); // [!code highlight]
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
178
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw new Error("Workflow did not register its hook in time");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function POST(request: Request) {
|
|
186
|
+
const { orderId, confirmed } = await request.json();
|
|
187
|
+
const token = `order:${orderId}`;
|
|
188
|
+
const payload = { confirmed };
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// An active run already owns this token: resume it.
|
|
192
|
+
const hook = await resumeHook(token, payload); // [!code highlight]
|
|
193
|
+
return Response.json({ runId: hook.runId, reused: true });
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// No hook yet: start a new run, then retry the resume so this
|
|
199
|
+
// request's payload still reaches the workflow.
|
|
200
|
+
const run = await start(processOrder, [orderId]); // [!code highlight]
|
|
201
|
+
const resumed = await resumeWithRetry(token, payload);
|
|
202
|
+
|
|
203
|
+
// A concurrent request can win the race between `start()` and hook
|
|
204
|
+
// registration; the resume always reaches the actual active owner.
|
|
205
|
+
return Response.json({
|
|
206
|
+
runId: resumed.runId,
|
|
207
|
+
reused: resumed.runId !== run.runId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how the workflow claims the token with `hook.getConflict()` and how concurrent starts converge on one active owner.
|
|
213
|
+
|
|
158
214
|
## Related Functions
|
|
159
215
|
|
|
160
216
|
- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) - Get hook details before resuming.
|
|
161
217
|
- [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow.
|
|
162
218
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper.
|
|
219
|
+
- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts.
|
|
@@ -5,6 +5,8 @@ type: reference
|
|
|
5
5
|
summary: Use start to programmatically enqueue a new workflow run.
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/starting-workflows
|
|
8
|
+
related:
|
|
9
|
+
- /docs/foundations/idempotency
|
|
8
10
|
---
|
|
9
11
|
|
|
10
12
|
Start/enqueue a new workflow run.
|
|
@@ -54,9 +56,10 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-
|
|
|
54
56
|
* In v5, `start()` can also be called directly from a workflow function to spawn a child run or continue work in a new run. See [Workflow Composition](/cookbook/common-patterns/workflow-composition) and [Versioning](/docs/foundations/versioning).
|
|
55
57
|
* This is different from calling workflow functions directly, which is the typical pattern in Next.js applications.
|
|
56
58
|
* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete.
|
|
59
|
+
* 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).
|
|
57
60
|
* All arguments must be [serializable](/docs/foundations/serialization).
|
|
58
61
|
* 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.
|
|
59
|
-
* `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later.
|
|
62
|
+
* `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 [`experimental_setAttributes`](/docs/api-reference/workflow/experimental-set-attributes) option of the same name.
|
|
60
63
|
|
|
61
64
|
<Callout type="info">
|
|
62
65
|
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).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "workflow/errors"
|
|
3
|
+
description: Semantic error types thrown by the Workflow SDK and its storage backends.
|
|
4
|
+
type: overview
|
|
5
|
+
summary: Explore the error classes exported from workflow/errors for handling workflow failures.
|
|
6
|
+
related:
|
|
7
|
+
- /docs/foundations/errors-and-retries
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
API reference for the error classes exported from the `workflow/errors` package.
|
|
11
|
+
|
|
12
|
+
All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow-error), so you can catch any SDK error with a single `instanceof` check, or narrow to a specific class for fine-grained handling.
|
|
13
|
+
|
|
14
|
+
## Base Classes
|
|
15
|
+
|
|
16
|
+
<Cards>
|
|
17
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-error" title="WorkflowError">
|
|
18
|
+
Base class for all workflow error types.
|
|
19
|
+
</Card>
|
|
20
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-world-error" title="WorkflowWorldError">
|
|
21
|
+
Base error for failures from workflow storage backends.
|
|
22
|
+
</Card>
|
|
23
|
+
</Cards>
|
|
24
|
+
|
|
25
|
+
## Registration Errors
|
|
26
|
+
|
|
27
|
+
<Cards>
|
|
28
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-not-registered-error" title="WorkflowNotRegisteredError">
|
|
29
|
+
Thrown when a workflow function is not registered in the current deployment.
|
|
30
|
+
</Card>
|
|
31
|
+
<Card href="/docs/api-reference/workflow-errors/step-not-registered-error" title="StepNotRegisteredError">
|
|
32
|
+
Thrown when a step function is not registered in the current deployment.
|
|
33
|
+
</Card>
|
|
34
|
+
</Cards>
|
|
35
|
+
|
|
36
|
+
## Run Errors
|
|
37
|
+
|
|
38
|
+
<Cards>
|
|
39
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-run-not-found-error" title="WorkflowRunNotFoundError">
|
|
40
|
+
Thrown when operating on a workflow run that does not exist.
|
|
41
|
+
</Card>
|
|
42
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-run-failed-error" title="WorkflowRunFailedError">
|
|
43
|
+
Thrown when awaiting the return value of a failed workflow run.
|
|
44
|
+
</Card>
|
|
45
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-run-cancelled-error" title="WorkflowRunCancelledError">
|
|
46
|
+
Thrown when awaiting the return value of a cancelled workflow run.
|
|
47
|
+
</Card>
|
|
48
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-run-not-completed-error" title="WorkflowRunNotCompletedError">
|
|
49
|
+
Thrown when requesting the result of a workflow run that has not completed yet.
|
|
50
|
+
</Card>
|
|
51
|
+
<Card href="/docs/api-reference/workflow-errors/workflow-runtime-error" title="WorkflowRuntimeError">
|
|
52
|
+
Thrown when the workflow runtime encounters an execution error, such as serialization failures or timeouts.
|
|
53
|
+
</Card>
|
|
54
|
+
<Card href="/docs/api-reference/workflow-errors/run-expired-error" title="RunExpiredError">
|
|
55
|
+
Thrown when a workflow run has expired and can no longer be operated on.
|
|
56
|
+
</Card>
|
|
57
|
+
<Card href="/docs/api-reference/workflow-errors/run-not-supported-error" title="RunNotSupportedError">
|
|
58
|
+
Thrown when a workflow run requires a newer workflow spec version than the installed SDK supports.
|
|
59
|
+
</Card>
|
|
60
|
+
</Cards>
|
|
61
|
+
|
|
62
|
+
## Hook Errors
|
|
63
|
+
|
|
64
|
+
<Cards>
|
|
65
|
+
<Card href="/docs/api-reference/workflow-errors/hook-not-found-error" title="HookNotFoundError">
|
|
66
|
+
Thrown when resuming a hook that does not exist.
|
|
67
|
+
</Card>
|
|
68
|
+
<Card href="/docs/api-reference/workflow-errors/hook-conflict-error" title="HookConflictError">
|
|
69
|
+
Thrown when creating a hook with a token that is already in use by another workflow run.
|
|
70
|
+
</Card>
|
|
71
|
+
</Cards>
|
|
72
|
+
|
|
73
|
+
## Backend Errors
|
|
74
|
+
|
|
75
|
+
<Cards>
|
|
76
|
+
<Card href="/docs/api-reference/workflow-errors/throttle-error" title="ThrottleError">
|
|
77
|
+
Thrown when a request is rate-limited by the workflow backend.
|
|
78
|
+
</Card>
|
|
79
|
+
<Card href="/docs/api-reference/workflow-errors/entity-conflict-error" title="EntityConflictError">
|
|
80
|
+
Thrown when a storage operation conflicts with the current entity state.
|
|
81
|
+
</Card>
|
|
82
|
+
<Card href="/docs/api-reference/workflow-errors/too-early-error" title="TooEarlyError">
|
|
83
|
+
Thrown when a request is made before the system is ready to process it.
|
|
84
|
+
</Card>
|
|
85
|
+
</Cards>
|
|
@@ -31,8 +31,8 @@ export default withWorkflow(nextConfig, workflowConfig); // [!code highlight]
|
|
|
31
31
|
If a package in `serverExternalPackages` contains workflow code (`"use step"`,
|
|
32
32
|
`"use workflow"`, or serialization classes), `withWorkflow()` automatically
|
|
33
33
|
removes it from `serverExternalPackages` for the current build and prints a
|
|
34
|
-
warning.
|
|
35
|
-
|
|
34
|
+
warning. Workflow still compiles the package so its directives are transformed.
|
|
35
|
+
Remove that package from `serverExternalPackages` in your
|
|
36
36
|
`next.config` to silence the warning.
|
|
37
37
|
</Callout>
|
|
38
38
|
|
|
@@ -259,6 +259,12 @@ const hook = await world.hooks.get(hookId); // [!code highlight]
|
|
|
259
259
|
|
|
260
260
|
Look up a hook by its token. Useful in webhook resume flows where you receive a token in the callback URL.
|
|
261
261
|
|
|
262
|
+
<Callout type="info">
|
|
263
|
+
For runtime application code, prefer [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). Use `world.hooks.getByToken()` when you are working directly with the World storage interface for custom tooling, admin views, or low-level integrations.
|
|
264
|
+
|
|
265
|
+
Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first — on a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
266
|
+
</Callout>
|
|
267
|
+
|
|
262
268
|
```typescript lineNumbers
|
|
263
269
|
const hook = await world.hooks.getByToken(token); // [!code highlight]
|
|
264
270
|
```
|
package/docs/changelog/index.mdx
CHANGED
|
@@ -13,4 +13,4 @@ Stay up to date with the latest changes to Workflow SDK.
|
|
|
13
13
|
## 2026
|
|
14
14
|
|
|
15
15
|
- [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) - March 2026
|
|
16
|
-
-
|
|
16
|
+
- Serializable AbortController and AbortSignal — March 12, 2026
|
|
@@ -3,6 +3,8 @@ title: Child Workflows
|
|
|
3
3
|
description: Spawn child workflows from a parent and wait for completion via hook resume.
|
|
4
4
|
type: guide
|
|
5
5
|
summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait() — the child resumes the parent's hook when done instead of polling getRun().status.
|
|
6
|
+
related:
|
|
7
|
+
- /docs/api-reference/workflow-api/start
|
|
6
8
|
---
|
|
7
9
|
|
|
8
10
|
Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings.
|
|
@@ -145,7 +147,7 @@ Polling with `getRun().status` in a `sleep()` loop works, but hook resume is pre
|
|
|
145
147
|
- **Zero compute while waiting** — the parent suspends on the hook instead of waking every poll interval
|
|
146
148
|
- **Immediate wake-up** — the parent resumes as soon as the child finishes, not on the next poll tick
|
|
147
149
|
- **Typed payloads** — the child sends `{ status, value | error }` directly; no separate `returnValue` fetch step
|
|
148
|
-
- **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/changelog/eager-processing))
|
|
150
|
+
- **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/docs/changelog/eager-processing))
|
|
149
151
|
|
|
150
152
|
When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works — pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`.
|
|
151
153
|
|
|
@@ -270,14 +270,14 @@ Declare `workflow` as an **optional** peer so consumers without the runtime aren
|
|
|
270
270
|
|
|
271
271
|
### Runtime detection
|
|
272
272
|
|
|
273
|
-
Wrap a dynamic `import("workflow")` in try/catch. If either the module isn't installed *or* `
|
|
273
|
+
Wrap a dynamic `import("workflow")` in try/catch. If either the module isn't installed *or* `getStepMetadata()` throws (call site isn't inside a workflow step), fall through to the standalone path.
|
|
274
274
|
|
|
275
275
|
```typescript lineNumbers
|
|
276
|
-
async function
|
|
276
|
+
async function getWorkflowStepId(): Promise<string | null> { // [!code highlight]
|
|
277
277
|
try {
|
|
278
278
|
const wf = await import("workflow");
|
|
279
|
-
const {
|
|
280
|
-
return
|
|
279
|
+
const { stepId } = wf.getStepMetadata();
|
|
280
|
+
return stepId;
|
|
281
281
|
} catch {
|
|
282
282
|
return null;
|
|
283
283
|
}
|
|
@@ -286,13 +286,14 @@ async function getWorkflowRunId(): Promise<string | null> { // [!code highlight]
|
|
|
286
286
|
|
|
287
287
|
### A concrete use case: replay-safe idempotency keys
|
|
288
288
|
|
|
289
|
-
A payments utility that uses the workflow
|
|
289
|
+
A payments utility that uses the current workflow step ID as a Stripe idempotency key when available, and a fresh UUID otherwise:
|
|
290
290
|
|
|
291
|
-
{/* @skip-typecheck - depends on getWorkflowRunId defined in the previous block */}
|
|
292
291
|
```typescript lineNumbers
|
|
292
|
+
declare function getWorkflowStepId(): Promise<string | null>; // @setup (defined in the previous block)
|
|
293
|
+
|
|
293
294
|
export async function processPayment(amount: number, currency: string) {
|
|
294
|
-
const
|
|
295
|
-
const idempotencyKey =
|
|
295
|
+
const stepId = await getWorkflowStepId();
|
|
296
|
+
const idempotencyKey = stepId ? `payment:${stepId}` : crypto.randomUUID(); // [!code highlight]
|
|
296
297
|
|
|
297
298
|
const res = await fetch("https://api.stripe.com/v1/charges", {
|
|
298
299
|
method: "POST",
|
|
@@ -306,7 +307,7 @@ export async function processPayment(amount: number, currency: string) {
|
|
|
306
307
|
}
|
|
307
308
|
```
|
|
308
309
|
|
|
309
|
-
When called from inside a workflow
|
|
310
|
+
When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated. For more patterns, see [Idempotency](/docs/foundations/idempotency).
|
|
310
311
|
|
|
311
312
|
### In production
|
|
312
313
|
|
|
@@ -330,7 +331,7 @@ Before publishing a workflow library:
|
|
|
330
331
|
|
|
331
332
|
## Key APIs
|
|
332
333
|
|
|
333
|
-
- [`"use workflow"`](/docs/
|
|
334
|
-
- [`"use step"`](/docs/
|
|
335
|
-
- [`start`](/docs/api-reference/workflow/start) — starts a workflow run
|
|
334
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
|
|
335
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks functions for durable execution
|
|
336
|
+
- [`start`](/docs/api-reference/workflow-api/start) — starts a workflow run
|
|
336
337
|
- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — runtime detection and run ID access
|
|
@@ -6,7 +6,7 @@ summary: Return a callback from a step to defer construction of a non-owned clas
|
|
|
6
6
|
related:
|
|
7
7
|
- /docs/foundations/serialization
|
|
8
8
|
- /docs/foundations/serialization#custom-class-serialization
|
|
9
|
-
- /docs/
|
|
9
|
+
- /docs/foundations/workflows-and-steps#step-functions
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
<Callout>
|
|
@@ -141,7 +141,7 @@ async function uploadFile(
|
|
|
141
141
|
|
|
142
142
|
## Key APIs
|
|
143
143
|
|
|
144
|
-
- [`"use step"`](/docs/
|
|
145
|
-
- [`"use workflow"`](/docs/
|
|
144
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks a function for extraction and serialization
|
|
145
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
|
|
146
146
|
- [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — accepts a model factory for durable AI agent streaming
|
|
147
147
|
- [Custom class serialization](/docs/foundations/serialization#custom-class-serialization) — the companion pattern for classes you own (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`)
|
|
@@ -247,8 +247,8 @@ const approvalResult = messages
|
|
|
247
247
|
|
|
248
248
|
## Key APIs
|
|
249
249
|
|
|
250
|
-
- [`"use workflow"`](/docs/
|
|
251
|
-
- [`"use step"`](/docs/
|
|
250
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function
|
|
251
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — declares step functions with retries
|
|
252
252
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation
|
|
253
253
|
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry
|
|
254
254
|
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps
|
|
@@ -1,45 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Idempotency
|
|
3
|
-
description:
|
|
3
|
+
description: Make step retries safe and coordinate duplicate workflow starts with hook tokens.
|
|
4
4
|
type: guide
|
|
5
|
-
summary: Use step IDs
|
|
5
|
+
summary: Use step IDs for retry-safe external calls, and use deterministic hook tokens when duplicate requests must route to one active workflow.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts.
|
|
9
9
|
|
|
10
10
|
## When to use this
|
|
11
11
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
- Creating records in external systems where duplicates are harmful
|
|
15
|
-
- Any step that has side effects in systems you don't control
|
|
12
|
+
- A step charges a payment, sends an email, enqueues work, or creates an external record.
|
|
13
|
+
- A route may receive duplicate requests that should map to one active workflow run.
|
|
16
14
|
|
|
17
|
-
##
|
|
15
|
+
## Step idempotency
|
|
18
16
|
|
|
19
17
|
Every step has a unique, deterministic `stepId` available via `getStepMetadata()`. Pass this as the idempotency key to external APIs:
|
|
20
18
|
|
|
21
19
|
```typescript
|
|
22
20
|
import { getStepMetadata } from "workflow";
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
declare function sendReceipt(customerId: string, chargeId: string): Promise<void>; // @setup
|
|
26
|
-
|
|
27
|
-
export async function chargeCustomer(customerId: string, amount: number) {
|
|
28
|
-
"use workflow";
|
|
29
|
-
|
|
30
|
-
const charge = await createCharge(customerId, amount);
|
|
31
|
-
await sendReceipt(customerId, charge.id);
|
|
32
|
-
|
|
33
|
-
return { customerId, chargeId: charge.id, status: "completed" };
|
|
34
|
-
}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
### Step function with idempotency key
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
import { getStepMetadata } from "workflow";
|
|
41
|
-
|
|
42
|
-
async function createCharge(
|
|
22
|
+
export async function createCharge(
|
|
43
23
|
customerId: string,
|
|
44
24
|
amount: number
|
|
45
25
|
): Promise<{ id: string }> {
|
|
@@ -69,39 +49,43 @@ async function createCharge(
|
|
|
69
49
|
|
|
70
50
|
return charge.json();
|
|
71
51
|
}
|
|
72
|
-
|
|
73
|
-
async function sendReceipt(customerId: string, chargeId: string): Promise<void> {
|
|
74
|
-
"use step";
|
|
75
|
-
|
|
76
|
-
const { stepId } = getStepMetadata();
|
|
77
|
-
|
|
78
|
-
await fetch("https://api.example.com/receipts", {
|
|
79
|
-
method: "POST",
|
|
80
|
-
headers: { "Idempotency-Key": stepId },
|
|
81
|
-
body: JSON.stringify({ customerId, chargeId }),
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
52
|
```
|
|
85
53
|
|
|
86
|
-
|
|
54
|
+
See [Step Idempotency](/docs/foundations/idempotency#step-idempotency) for why `stepId` is stable across retries and how to think about external API conflicts.
|
|
87
55
|
|
|
88
|
-
|
|
56
|
+
## Run idempotency
|
|
89
57
|
|
|
90
|
-
-
|
|
91
|
-
- **Don't use check-then-act patterns** like "read a flag, then write if not set" -- another run could read the same flag between your read and write.
|
|
58
|
+
For duplicate workflow-start requests, derive a hook token from your domain key. You can avoid obvious duplicate starts by checking whether an active hook already owns that token before calling `start()`:
|
|
92
59
|
|
|
93
|
-
|
|
60
|
+
```typescript
|
|
61
|
+
import { getHookByToken, start } from "workflow/api";
|
|
62
|
+
import { HookNotFoundError } from "workflow/errors";
|
|
63
|
+
import { processOrder } from "./workflows/process-order";
|
|
64
|
+
|
|
65
|
+
export async function POST(request: Request) {
|
|
66
|
+
const { orderId } = await request.json();
|
|
67
|
+
const token = `order:${orderId}`;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const hook = await getHookByToken(token); // [!code highlight]
|
|
71
|
+
return Response.json({ runId: hook.runId, reused: true });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
74
|
+
}
|
|
94
75
|
|
|
95
|
-
|
|
76
|
+
const run = await start(processOrder, [orderId]); // [!code highlight]
|
|
77
|
+
return Response.json({ runId: run.runId, reused: false });
|
|
78
|
+
}
|
|
79
|
+
```
|
|
96
80
|
|
|
97
|
-
-
|
|
98
|
-
- **Always provide idempotency keys for non-idempotent external calls.** Even if you think a step won't be retried, cold-start replay will re-execute it.
|
|
99
|
-
- **Handle 409/conflict as success.** If an external API returns "already processed," treat that as a successful result, not an error.
|
|
100
|
-
- **Make your own APIs idempotent** where possible. Accept an idempotency key and return the cached result on duplicate requests.
|
|
81
|
+
The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration.
|
|
101
82
|
|
|
102
83
|
## Key APIs
|
|
103
84
|
|
|
104
|
-
- [`"use workflow"`](/docs/
|
|
105
|
-
- [`"use step"`](/docs/
|
|
106
|
-
- [`getStepMetadata()`](/docs/api-reference/
|
|
85
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
|
|
86
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
|
|
87
|
+
- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys
|
|
88
|
+
- [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token
|
|
89
|
+
- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token
|
|
90
|
+
- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data
|
|
107
91
|
- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run
|
|
@@ -224,5 +224,5 @@ export async function downloadWithRetry(url: string) {
|
|
|
224
224
|
- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access
|
|
225
225
|
- [`RetryableError`](/docs/api-reference/workflow/retryable-error) -- signals the runtime to retry after a delay
|
|
226
226
|
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- signals a permanent failure, skipping retries
|
|
227
|
-
- [`getStepMetadata()`](/docs/api-reference/
|
|
227
|
+
- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the current attempt number and step ID
|
|
228
228
|
- [`sleep()`](/docs/api-reference/workflow/sleep) -- durable pause for circuit breaker cooldowns
|
|
@@ -241,7 +241,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number)
|
|
|
241
241
|
|
|
242
242
|
## Key APIs
|
|
243
243
|
|
|
244
|
-
- [`"use workflow"`](/docs/
|
|
245
|
-
- [`"use step"`](/docs/
|
|
244
|
+
- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function
|
|
245
|
+
- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access
|
|
246
246
|
- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation
|
|
247
247
|
- [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates
|
|
@@ -106,6 +106,10 @@ export async function POST(req: Request) {
|
|
|
106
106
|
3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first.
|
|
107
107
|
4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token.
|
|
108
108
|
|
|
109
|
+
<Callout type="info">
|
|
110
|
+
Deterministic hook tokens can also serve as the idempotency point for scheduled runs. If duplicate schedule starts would send duplicate campaigns or reminders, create a hook with a token derived from the campaign key near the beginning of the workflow and route retries through that hook. If two scheduled starts race, the duplicate run can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the duplicate can defer to it. See [Idempotency](/docs/foundations/idempotency).
|
|
111
|
+
</Callout>
|
|
112
|
+
|
|
109
113
|
## Adapting to your use case
|
|
110
114
|
|
|
111
115
|
- **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times.
|
|
@@ -80,7 +80,7 @@ export async function waitForApproval(requestId: string) {
|
|
|
80
80
|
4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows.
|
|
81
81
|
|
|
82
82
|
<Callout type="warn">
|
|
83
|
-
**The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Pass an `AbortSignal` into the step to cancel it cooperatively
|
|
83
|
+
**The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Pass an `AbortSignal` into the step to cancel it cooperatively, and use idempotency keys for non-idempotent side effects. See the [Cancellation Guide](/docs/foundations/cancellation) and [Idempotency](/docs/foundations/idempotency) for patterns.
|
|
84
84
|
</Callout>
|
|
85
85
|
|
|
86
86
|
## Adapting to your use case
|
|
@@ -96,4 +96,5 @@ export async function waitForApproval(requestId: string) {
|
|
|
96
96
|
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost)
|
|
97
97
|
- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — create a webhook URL the workflow can race against
|
|
98
98
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — typed hook for in-process cancellation
|
|
99
|
+
- [Idempotency](/docs/foundations/idempotency) — protect side effects that may keep running after a timeout
|
|
99
100
|
- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — race operations against deadlines
|