workflow 5.0.0-beta.15 → 5.0.0-beta.16
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-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/testing/index.mdx +2 -2
- package/package.json +10 -10
|
@@ -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>
|
|
@@ -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
|
|
@@ -5,6 +5,7 @@ type: guide
|
|
|
5
5
|
summary: Compose workflows two ways — direct await flattens the child into the parent's event log, while background spawn via start() runs the child as an independent run.
|
|
6
6
|
related:
|
|
7
7
|
- /cookbook/advanced/child-workflows
|
|
8
|
+
- /cookbook/common-patterns/idempotency
|
|
8
9
|
- /docs/api-reference/workflow-api/start
|
|
9
10
|
- /docs/api-reference/workflow-api/get-run
|
|
10
11
|
---
|
|
@@ -77,6 +78,10 @@ export async function processOrder(orderId: string) {
|
|
|
77
78
|
|
|
78
79
|
The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId` (e.g., via [`getRun()`](/docs/api-reference/workflow-api/get-run)).
|
|
79
80
|
|
|
81
|
+
<Callout type="info">
|
|
82
|
+
Each background spawn creates a separate run. If duplicate requests must route to one active child workflow, have the child create a deterministic hook token from the business key and use that hook as the idempotency point. If concurrent starts race, the losing child can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the child can point callers at it. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
83
|
+
</Callout>
|
|
84
|
+
|
|
80
85
|
<Callout type="info">
|
|
81
86
|
If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures.
|
|
82
87
|
</Callout>
|
|
@@ -109,3 +114,4 @@ If you want the child workflow to run on the latest deployment rather than the c
|
|
|
109
114
|
- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access
|
|
110
115
|
- [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run
|
|
111
116
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value
|
|
117
|
+
- [Idempotency](/docs/foundations/idempotency) — deduplicate step side effects and workflow starts
|
package/docs/cookbook/index.mdx
CHANGED
|
@@ -21,7 +21,7 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
|
|
|
21
21
|
- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff
|
|
22
22
|
- [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead
|
|
23
23
|
- [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep
|
|
24
|
-
- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects
|
|
24
|
+
- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects and duplicate starts are safe to retry
|
|
25
25
|
- [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably
|
|
26
26
|
|
|
27
27
|
## Integrations
|
|
@@ -130,6 +130,10 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) {
|
|
|
130
130
|
|
|
131
131
|
One endpoint handles first turn, follow-ups, and the `/done` exit. The client sends `runId` in the body to distinguish first vs follow-up.
|
|
132
132
|
|
|
133
|
+
<Callout type="info">
|
|
134
|
+
The first turn calls `start()` and then returns the `runId`. If your client or platform can retry that first request before it receives and stores the `runId`, use an atomic conversation or request key before starting the workflow. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
135
|
+
</Callout>
|
|
136
|
+
|
|
133
137
|
```typescript title="app/api/support/route.ts" lineNumbers
|
|
134
138
|
import type { UIMessage, UIMessageChunk } from "ai";
|
|
135
139
|
import { convertToModelMessages, createUIMessageStreamResponse } from "ai";
|
|
@@ -350,6 +354,10 @@ In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather
|
|
|
350
354
|
|
|
351
355
|
Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow.
|
|
352
356
|
|
|
357
|
+
### Make the first turn idempotent when needed
|
|
358
|
+
|
|
359
|
+
This example stores the `runId` after the first response. For strict one-session-per-thread behavior, use a deterministic hook token derived from the thread ID or conversation ID and route retries through the active hook. See [Idempotency](/docs/foundations/idempotency).
|
|
360
|
+
|
|
353
361
|
## streamText vs DurableAgent
|
|
354
362
|
|
|
355
363
|
| | `streamText()` (this pattern) | `DurableAgent` |
|
|
@@ -376,8 +384,9 @@ Use `DurableAgent` for most agent use cases. Use `streamText` when you need the
|
|
|
376
384
|
|
|
377
385
|
**Workflow SDK**
|
|
378
386
|
|
|
379
|
-
* [`"use step"`](/docs/
|
|
387
|
+
* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit
|
|
380
388
|
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages
|
|
381
389
|
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output
|
|
382
390
|
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams
|
|
383
391
|
* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns
|
|
392
|
+
* [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first turns and side effects
|
|
@@ -176,6 +176,10 @@ export type ChatTurnPayload = {
|
|
|
176
176
|
|
|
177
177
|
Handlers live outside the workflow file so adapter dependencies don't leak in. They decide whether to start a new workflow or resume an existing one, then store the `runId` in thread state:
|
|
178
178
|
|
|
179
|
+
<Callout type="info">
|
|
180
|
+
If the platform can deliver the same first message concurrently, use a deterministic hook token derived from the thread ID so duplicate handlers route to the active chat session hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
181
|
+
</Callout>
|
|
182
|
+
|
|
179
183
|
```typescript title="lib/chat-session-handlers.ts" lineNumbers
|
|
180
184
|
import type { Message, Thread } from "chat";
|
|
181
185
|
import { getRun, resumeHook, start } from "workflow/api";
|
|
@@ -289,6 +293,10 @@ Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend
|
|
|
289
293
|
|
|
290
294
|
A workflow run ends but its `runId` is still cached in thread state. The next message calls `resumeHook` on a dead run and throws `not found` / `expired`. Gate on `getRun(runId).exists` before resuming, or catch the error and fall through to `startSession`. Either way the user's message must not be dropped.
|
|
291
295
|
|
|
296
|
+
### Make first-message routing atomic
|
|
297
|
+
|
|
298
|
+
Thread state is also the idempotency boundary for starting sessions. Back it with a state adapter or database operation that can atomically claim the thread before `startSession()` runs when duplicate sessions would be harmful.
|
|
299
|
+
|
|
292
300
|
### Keep the hook outside the loop
|
|
293
301
|
|
|
294
302
|
One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused every iteration. Creating a new hook with the same token throws `HookConflictError`. This is the same rule as the [AI SDK](/docs/cookbook/integrations/ai-sdk) and [Sandbox](/docs/cookbook/integrations/sandbox) session patterns.
|
|
@@ -305,3 +313,4 @@ Slack wants a 200 within 3 seconds. The webhook handler returns immediately afte
|
|
|
305
313
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s.
|
|
306
314
|
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow.
|
|
307
315
|
- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions.
|
|
316
|
+
- [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first messages and side effects.
|
|
@@ -300,6 +300,10 @@ export async function sandboxSessionWorkflow() {
|
|
|
300
300
|
|
|
301
301
|
Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
|
|
302
302
|
|
|
303
|
+
<Callout type="info">
|
|
304
|
+
This example starts a fresh sandbox session when no `runId` is provided. If your product needs one sandbox session per user, project, or task, use a deterministic hook token derived from that session key and route retries through the active hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
|
|
305
|
+
</Callout>
|
|
306
|
+
|
|
303
307
|
```typescript title="app/api/sandbox/start/route.ts" lineNumbers
|
|
304
308
|
import { start, getRun } from "workflow/api";
|
|
305
309
|
import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
|
|
@@ -505,6 +509,10 @@ Stream closure must happen inside a `"use step"` function. Calling `writable.clo
|
|
|
505
509
|
|
|
506
510
|
Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.
|
|
507
511
|
|
|
512
|
+
### Decide whether `/start` should be idempotent
|
|
513
|
+
|
|
514
|
+
The sample treats a missing or stale `runId` as a request for a new session. For one-session-per-resource behavior, use a durable resource key, such as `projectId` or `taskId`, to claim or retrieve the run before starting a new one.
|
|
515
|
+
|
|
508
516
|
### Keep the hook outside the loop
|
|
509
517
|
|
|
510
518
|
Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
|
|
@@ -518,3 +526,4 @@ Each iteration's `hook.then(...)` attaches a listener to the same hook instance.
|
|
|
518
526
|
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
|
|
519
527
|
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
|
|
520
528
|
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
|
|
529
|
+
- [Idempotency](/docs/foundations/idempotency) — choose when `/start` should reuse an existing run
|
|
@@ -4,7 +4,7 @@ description: A step function is not registered in the current deployment.
|
|
|
4
4
|
type: troubleshooting
|
|
5
5
|
summary: Resolve step not registered errors caused by build issues.
|
|
6
6
|
prerequisites:
|
|
7
|
-
- /docs/foundations/steps
|
|
7
|
+
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
9
|
- /docs/errors/workflow-not-registered
|
|
10
10
|
- /docs/api-reference/workflow-errors/step-not-registered-error
|
|
@@ -6,7 +6,6 @@ summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
|
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
|
-
- /docs/foundations/common-patterns
|
|
10
9
|
- /docs/foundations/hooks
|
|
11
10
|
- /docs/how-it-works/cancellation
|
|
12
11
|
---
|
|
@@ -455,6 +454,6 @@ This is safe even if both steps have already completed — aborting a finished o
|
|
|
455
454
|
|
|
456
455
|
- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
|
|
457
456
|
- [Serialization](/docs/foundations/serialization) — Understanding serializable types
|
|
458
|
-
- [
|
|
457
|
+
- [Cookbook](/v5/cookbook) — Timeout, race, and other reliability patterns
|
|
459
458
|
- [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
|
|
460
459
|
- [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
|
|
@@ -112,7 +112,7 @@ export async function orderWorkflow(orderId: string) {
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [
|
|
115
|
+
Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.
|
|
116
116
|
|
|
117
117
|
### Custom Tokens for Deterministic Hooks
|
|
118
118
|
|
|
@@ -1,23 +1,27 @@
|
|
|
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: conceptual
|
|
5
|
-
summary:
|
|
5
|
+
summary: Use step IDs for retry-safe external calls, and route duplicate workflow-start requests through deterministic hook tokens.
|
|
6
6
|
prerequisites:
|
|
7
7
|
- /docs/foundations/workflows-and-steps
|
|
8
8
|
related:
|
|
9
9
|
- /docs/foundations/errors-and-retries
|
|
10
|
+
- /docs/foundations/starting-workflows
|
|
11
|
+
- /docs/foundations/hooks
|
|
10
12
|
---
|
|
11
13
|
|
|
12
|
-
Idempotency is a property of an operation that ensures
|
|
14
|
+
Idempotency is a property of an operation that ensures repeated attempts have the same effect as a single attempt.
|
|
15
|
+
|
|
16
|
+
In Workflow, idempotency shows up in two related places: step idempotency makes external calls safe when a step retries, and run idempotency coordinates duplicate requests that try to start the same workflow.
|
|
17
|
+
|
|
18
|
+
## Step Idempotency
|
|
13
19
|
|
|
14
20
|
In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds.
|
|
15
21
|
Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again.
|
|
16
22
|
|
|
17
23
|
To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests.
|
|
18
24
|
|
|
19
|
-
## The core pattern: use the step ID as your idempotency key
|
|
20
|
-
|
|
21
25
|
Every step invocation has a stable `stepId` that stays the same across retries.
|
|
22
26
|
Use it as the idempotency key when calling third-party APIs.
|
|
23
27
|
|
|
@@ -27,7 +31,7 @@ import { getStepMetadata } from "workflow";
|
|
|
27
31
|
async function chargeUser(userId: string, amount: number) {
|
|
28
32
|
"use step";
|
|
29
33
|
|
|
30
|
-
const { stepId } = getStepMetadata();
|
|
34
|
+
const { stepId } = getStepMetadata(); // [!code highlight]
|
|
31
35
|
|
|
32
36
|
// Example: Stripe-style idempotency key
|
|
33
37
|
// This guarantees only one charge is created even if the step retries
|
|
@@ -49,14 +53,235 @@ Why this works:
|
|
|
49
53
|
- **Stable across retries**: `stepId` does not change between attempts.
|
|
50
54
|
- **Globally unique per step**: Fulfills the uniqueness requirement for an idempotency key.
|
|
51
55
|
|
|
52
|
-
##
|
|
56
|
+
## Run idempotency
|
|
57
|
+
|
|
58
|
+
Step idempotency protects side effects **inside** a workflow run. Run idempotency answers a different question: if the same API request is sent twice, should it create one workflow run or two?
|
|
59
|
+
|
|
60
|
+
Because [hooks](/docs/foundations/hooks) already ensure globally unique active tokens, Workflow can use the same mechanism to coordinate duplicate requests while a run is active.
|
|
61
|
+
|
|
62
|
+
Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with a `Run` handle for the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload.
|
|
63
|
+
|
|
64
|
+
The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration.
|
|
65
|
+
|
|
66
|
+
```typescript lineNumbers
|
|
67
|
+
import { createHook } from "workflow";
|
|
68
|
+
|
|
69
|
+
type OrderRequest = { confirmed: boolean };
|
|
70
|
+
type OrderResult =
|
|
71
|
+
| { status: "processed" | "cancelled" }
|
|
72
|
+
| { status: "duplicate"; runId: string };
|
|
73
|
+
declare function chargeOrder(orderId: string): Promise<void>; // @setup
|
|
74
|
+
|
|
75
|
+
export async function processOrder(orderId: string): Promise<OrderResult> {
|
|
76
|
+
"use workflow";
|
|
77
|
+
|
|
78
|
+
using request = createHook<OrderRequest>({ // [!code highlight]
|
|
79
|
+
token: `order:${orderId}`, // [!code highlight]
|
|
80
|
+
}); // [!code highlight]
|
|
81
|
+
|
|
82
|
+
const conflict = await request.getConflict(); // [!code highlight]
|
|
83
|
+
if (conflict) { // [!code highlight]
|
|
84
|
+
// Another active run already owns this order's token. // [!code highlight]
|
|
85
|
+
return { status: "duplicate" as const, runId: conflict.runId }; // [!code highlight]
|
|
86
|
+
} // [!code highlight]
|
|
87
|
+
|
|
88
|
+
const { confirmed } = await request;
|
|
89
|
+
|
|
90
|
+
if (!confirmed) {
|
|
91
|
+
return { status: "cancelled" as const };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await chargeOrder(orderId);
|
|
95
|
+
return { status: "processed" as const };
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The runtime creates the hook atomically. At most one active hook can own `order:${orderId}`, so duplicate workflow runs converge on one active owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner — see [conflict-handling strategies](#conflict-handling-strategies) below.
|
|
100
|
+
|
|
101
|
+
Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook:
|
|
102
|
+
|
|
103
|
+
```typescript lineNumbers
|
|
104
|
+
import { resumeHook, start } from "workflow/api";
|
|
105
|
+
import { HookNotFoundError } from "workflow/errors";
|
|
106
|
+
import { processOrder } from "./workflows/process-order";
|
|
107
|
+
|
|
108
|
+
type OrderRequest = { confirmed: boolean };
|
|
109
|
+
|
|
110
|
+
async function resumeOrder(token: string, payload: OrderRequest) {
|
|
111
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
112
|
+
try {
|
|
113
|
+
return await resumeHook(token, payload); // [!code highlight]
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
116
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error("Order workflow did not register its hook in time");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function POST(request: Request) {
|
|
124
|
+
const { orderId, confirmed } = await request.json();
|
|
125
|
+
const token = `order:${orderId}`;
|
|
126
|
+
const payload = { confirmed };
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const hook = await resumeHook(token, payload); // [!code highlight]
|
|
130
|
+
return Response.json({ runId: hook.runId, reused: true });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!HookNotFoundError.is(error)) throw error;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const run = await start(processOrder, [orderId]); // [!code highlight]
|
|
136
|
+
const resumed = await resumeOrder(token, payload);
|
|
137
|
+
|
|
138
|
+
// A concurrent request's run may have won the race between `start()` // [!code highlight]
|
|
139
|
+
// and hook registration. The resume always reaches the actual active // [!code highlight]
|
|
140
|
+
// owner, so compare run IDs instead of waiting for this run to finish. // [!code highlight]
|
|
141
|
+
return Response.json({ // [!code highlight]
|
|
142
|
+
runId: resumed.runId, // [!code highlight]
|
|
143
|
+
reused: resumed.runId !== run.runId, // [!code highlight]
|
|
144
|
+
}); // [!code highlight]
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
<Callout type="warn">
|
|
149
|
+
This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`.
|
|
150
|
+
</Callout>
|
|
151
|
+
|
|
152
|
+
This is active-run coordination. When the workflow completes and disposes the hook, the token can be used again. If a duplicate request after completion must return the original result instead of starting fresh work, persist that completed result under the same domain key.
|
|
153
|
+
|
|
154
|
+
### Conflict-handling strategies
|
|
155
|
+
|
|
156
|
+
Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy — typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting `Run` itself, and the policy is ordinary code — including policies that inspect state before deciding, which static configuration can't express.
|
|
157
|
+
|
|
158
|
+
The example above implements **reject the duplicate**: return the owner's `runId` and let the caller decide. Other common strategies:
|
|
159
|
+
|
|
160
|
+
**Adopt the owner's result.** Wait for the active run to finish and return its result, so callers cannot tell which run did the work:
|
|
161
|
+
|
|
162
|
+
```typescript lineNumbers
|
|
163
|
+
import { createHook } from "workflow";
|
|
164
|
+
|
|
165
|
+
type OrderRequest = { confirmed: boolean };
|
|
166
|
+
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup
|
|
167
|
+
|
|
168
|
+
export async function processOrder(orderId: string) {
|
|
169
|
+
"use workflow";
|
|
170
|
+
|
|
171
|
+
using request = createHook<OrderRequest>({
|
|
172
|
+
token: `order:${orderId}`,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const conflict = await request.getConflict();
|
|
176
|
+
if (conflict) {
|
|
177
|
+
// Callers get the same result regardless of which run did the work.
|
|
178
|
+
return await conflict.returnValue; // [!code highlight]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return await processOwnedOrder(orderId);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Inspect the owner before deciding.** Branch on the owner's live state:
|
|
186
|
+
|
|
187
|
+
```typescript lineNumbers
|
|
188
|
+
import { createHook } from "workflow";
|
|
189
|
+
|
|
190
|
+
type OrderRequest = { confirmed: boolean };
|
|
191
|
+
declare function processOwnedOrder(orderId: string): Promise<{ status: string }>; // @setup
|
|
192
|
+
|
|
193
|
+
export async function processOrder(orderId: string) {
|
|
194
|
+
"use workflow";
|
|
195
|
+
|
|
196
|
+
using request = createHook<OrderRequest>({
|
|
197
|
+
token: `order:${orderId}`,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const conflict = await request.getConflict();
|
|
201
|
+
if (conflict) {
|
|
202
|
+
const status = await conflict.status; // [!code highlight]
|
|
203
|
+
if (status === "running") {
|
|
204
|
+
return { status: "duplicate" as const, runId: conflict.runId };
|
|
205
|
+
}
|
|
206
|
+
// Owner already reached a terminal state; its hook will be released.
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return await processOwnedOrder(orderId);
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
**Signal the owner instead of doing the work.** The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step:
|
|
214
|
+
|
|
215
|
+
```typescript lineNumbers
|
|
216
|
+
import { createHook } from "workflow";
|
|
217
|
+
import { resumeHook } from "workflow/api";
|
|
218
|
+
|
|
219
|
+
type OrderRequest = { confirmed: boolean };
|
|
220
|
+
|
|
221
|
+
async function forwardToOwner(token: string, payload: OrderRequest) {
|
|
222
|
+
"use step";
|
|
223
|
+
await resumeHook(token, payload); // [!code highlight]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function processOrder(orderId: string, confirmed: boolean) {
|
|
227
|
+
"use workflow";
|
|
228
|
+
|
|
229
|
+
const token = `order:${orderId}`;
|
|
230
|
+
using request = createHook<OrderRequest>({ token });
|
|
231
|
+
|
|
232
|
+
const conflict = await request.getConflict();
|
|
233
|
+
if (conflict) {
|
|
234
|
+
await forwardToOwner(token, { confirmed }); // [!code highlight]
|
|
235
|
+
return { status: "forwarded" as const, runId: conflict.runId };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ... own the token and do the work
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
**Supersede the owner.** Newest-wins: cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet:
|
|
243
|
+
|
|
244
|
+
```typescript lineNumbers
|
|
245
|
+
import { createHook } from "workflow";
|
|
246
|
+
|
|
247
|
+
type OrderRequest = { confirmed: boolean };
|
|
248
|
+
declare function chargeOrder(orderId: string): Promise<void>; // @setup
|
|
249
|
+
|
|
250
|
+
export async function processOrderNewestWins(orderId: string) {
|
|
251
|
+
"use workflow";
|
|
252
|
+
|
|
253
|
+
const token = `order:${orderId}`;
|
|
254
|
+
|
|
255
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
256
|
+
using request = createHook<OrderRequest>({ token });
|
|
257
|
+
|
|
258
|
+
const conflict = await request.getConflict();
|
|
259
|
+
if (!conflict) {
|
|
260
|
+
// Token claimed — this run is now the owner.
|
|
261
|
+
const { confirmed } = await request;
|
|
262
|
+
if (confirmed) {
|
|
263
|
+
await chargeOrder(orderId);
|
|
264
|
+
}
|
|
265
|
+
return { status: "processed" as const };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await conflict.cancel(); // [!code highlight]
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
throw new Error(`Could not claim ${token} after cancelling the owner`);
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
If duplicate requests should only reuse the active run without sending data, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) as an advisory pre-check before calling `start()`. The workflow should still check `hook.getConflict()`, because the lookup and `start()` are not atomic.
|
|
53
276
|
|
|
54
|
-
|
|
55
|
-
- **Prefer `stepId` as your key**; it is stable across retries and unique per step.
|
|
56
|
-
- **Keep keys deterministic**; avoid including timestamps or attempt counters.
|
|
57
|
-
- **Handle 409/conflict responses** gracefully; treat them as success if the prior attempt completed.
|
|
277
|
+
Because this pattern uses hooks for idempotency, duplicate requests can also inject additional data and steer the existing run. The route example above uses `resumeHook()` for that: if the hook already exists, the duplicate request resumes the active workflow; if the hook is not registered yet, the route starts the workflow and retries `resumeHook()` so the payload is not dropped.
|
|
58
278
|
|
|
59
279
|
## Related docs
|
|
60
280
|
|
|
61
281
|
- Learn about retries in [Errors & Retrying](/docs/foundations/errors-and-retries)
|
|
62
282
|
- API reference: [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata)
|
|
283
|
+
- API reference: [`createHook()`](/docs/api-reference/workflow/create-hook)
|
|
284
|
+
- API reference: [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token)
|
|
285
|
+
- API reference: [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook)
|
|
286
|
+
- API reference: [`start()`](/docs/api-reference/workflow-api/start)
|
|
287
|
+
- Learn about deterministic hook tokens in [Hooks](/docs/foundations/hooks)
|
|
@@ -295,7 +295,7 @@ Remove the Worker process, `@temporalio/*` dependencies, and the Temporal Server
|
|
|
295
295
|
- **Event history archival.** Temporal archives histories to S3/GCS for long-term retention. Workflow SDK event logs are durable, but retention depends on the integration you are using. For example, see [Vercel Workflow Storage Retention](https://vercel.com/docs/workflows/pricing#storage-retention) for Vercel.
|
|
296
296
|
- **Per-activity timeouts (`startToCloseTimeout`, `scheduleToCloseTimeout`, `heartbeatTimeout`).** Implement deadlines inside the step with `AbortSignal.timeout(ms)`, or wrap the call in `Promise.race(step(), sleep(...))` from the workflow.
|
|
297
297
|
- **Rich retry policy (`initialInterval`, `backoffCoefficient`, `maximumInterval`, `nonRetryableErrorTypes`).** Only `maxRetries` is configurable. Classify retryability with `RetryableError`/`FatalError`; control delay between attempts via `new RetryableError(msg, { retryAfter: '5s' })`.
|
|
298
|
-
- **Workers + task queues.** Managed deployments replace workers; self-hosted deployments still need a `World` implementation (see [/docs/deploying/world](/docs/deploying/world)).
|
|
298
|
+
- **Workers + task queues.** Managed deployments replace workers; self-hosted deployments still need a `World` implementation (see [/docs/deploying/building-a-world](/docs/deploying/building-a-world)).
|
|
299
299
|
|
|
300
300
|
## Quick-start checklist
|
|
301
301
|
|
|
@@ -62,6 +62,20 @@ export async function cleanupAttributes() {
|
|
|
62
62
|
|
|
63
63
|
Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.
|
|
64
64
|
|
|
65
|
+
## Viewing attributes
|
|
66
|
+
|
|
67
|
+
The run details panel in the observability UI shows the run's current attributes as key-value rows. Reserved `$`-prefixed keys are marked with a badge and sorted after user keys:
|
|
68
|
+
|
|
69
|
+

|
|
70
|
+
|
|
71
|
+
Each `experimental_setAttributes` call appears on the trace timeline as a diamond marker at the moment the attributes were written:
|
|
72
|
+
|
|
73
|
+

|
|
74
|
+
|
|
75
|
+
Expanding an `attr_set` event — in the run sidebar or the Events tab — shows the changed keys, removed keys, and whether the write came from the workflow body or a step (with the attempt number):
|
|
76
|
+
|
|
77
|
+

|
|
78
|
+
|
|
65
79
|
## Experimental Behavior
|
|
66
80
|
|
|
67
81
|
While attributes are experimental:
|
package/docs/testing/index.mdx
CHANGED
|
@@ -109,7 +109,7 @@ That's it. The plugin automatically:
|
|
|
109
109
|
|
|
110
110
|
1. Transforms `"use workflow"` and `"use step"` directives via SWC
|
|
111
111
|
2. Builds workflow and step bundles before tests run
|
|
112
|
-
3. Sets up an in-process workflow runtime using a fresh [Local World](/
|
|
112
|
+
3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker — all workflow data is cleared automatically between test files for full isolation
|
|
113
113
|
|
|
114
114
|
<Callout type="info">
|
|
115
115
|
Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above.
|
|
@@ -117,7 +117,7 @@ Use a separate Vitest configuration and a distinct file naming convention (e.g.
|
|
|
117
117
|
|
|
118
118
|
### Writing Integration Tests
|
|
119
119
|
|
|
120
|
-
Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#
|
|
120
|
+
Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returns) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails):
|
|
121
121
|
|
|
122
122
|
```typescript title="workflows/calculate.integration.test.ts" lineNumbers
|
|
123
123
|
import { describe, it, expect } from "vitest";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workflow",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
3
|
+
"version": "5.0.0-beta.16",
|
|
4
4
|
"description": "Workflow SDK - Build durable, resilient, and observable workflows",
|
|
5
5
|
"main": "dist/typescript-plugin.cjs",
|
|
6
6
|
"type": "module",
|
|
@@ -57,18 +57,18 @@
|
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"ms": "2.1.3",
|
|
60
|
-
"@workflow/astro": "5.0.0-beta.
|
|
61
|
-
"@workflow/cli": "5.0.0-beta.
|
|
62
|
-
"@workflow/core": "5.0.0-beta.
|
|
60
|
+
"@workflow/astro": "5.0.0-beta.16",
|
|
61
|
+
"@workflow/cli": "5.0.0-beta.16",
|
|
62
|
+
"@workflow/core": "5.0.0-beta.16",
|
|
63
63
|
"@workflow/errors": "5.0.0-beta.7",
|
|
64
64
|
"@workflow/typescript-plugin": "5.0.0-beta.4",
|
|
65
65
|
"@workflow/utils": "5.0.0-beta.3",
|
|
66
|
-
"@workflow/next": "5.0.0-beta.
|
|
67
|
-
"@workflow/nest": "5.0.0-beta.
|
|
68
|
-
"@workflow/nitro": "5.0.0-beta.
|
|
69
|
-
"@workflow/nuxt": "5.0.0-beta.
|
|
70
|
-
"@workflow/sveltekit": "5.0.0-beta.
|
|
71
|
-
"@workflow/rollup": "5.0.0-beta.
|
|
66
|
+
"@workflow/next": "5.0.0-beta.16",
|
|
67
|
+
"@workflow/nest": "5.0.0-beta.16",
|
|
68
|
+
"@workflow/nitro": "5.0.0-beta.16",
|
|
69
|
+
"@workflow/nuxt": "5.0.0-beta.16",
|
|
70
|
+
"@workflow/sveltekit": "5.0.0-beta.16",
|
|
71
|
+
"@workflow/rollup": "5.0.0-beta.16"
|
|
72
72
|
},
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@types/ms": "2.1.0",
|