workflow 5.0.0-beta.4 → 5.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +5 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +14 -2
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -2
- package/docs/ai/index.mdx +6 -5
- package/docs/api-reference/vitest/index.mdx +28 -1
- package/docs/api-reference/workflow-api/start.mdx +5 -4
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
- package/docs/api-reference/workflow-next/with-workflow.mdx +32 -0
- package/docs/changelog/eager-processing.mdx +595 -0
- package/docs/changelog/index.mdx +2 -1
- package/docs/cookbook/advanced/child-workflows.mdx +13 -24
- package/docs/cookbook/advanced/meta.json +1 -6
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +29 -78
- package/docs/cookbook/agent-patterns/durable-agent.mdx +4 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +4 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +1 -1
- package/docs/cookbook/common-patterns/workflow-composition.mdx +7 -14
- package/docs/cookbook/index.mdx +0 -1
- package/docs/cookbook/integrations/ai-sdk.mdx +4 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +4 -0
- package/docs/deploying/building-a-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +5 -3
- package/docs/deploying/world/vercel-world.mdx +2 -0
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
- package/docs/errors/hook-conflict.mdx +56 -4
- package/docs/foundations/cancellation.mdx +460 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/index.mdx +3 -0
- package/docs/foundations/meta.json +3 -1
- package/docs/foundations/serialization.mdx +77 -41
- package/docs/foundations/starting-workflows.mdx +5 -1
- package/docs/foundations/versioning.mdx +263 -0
- package/docs/getting-started/astro.mdx +6 -0
- package/docs/getting-started/index.mdx +6 -7
- package/docs/getting-started/meta.json +1 -0
- package/docs/getting-started/nestjs.mdx +8 -0
- package/docs/getting-started/next.mdx +5 -3
- package/docs/getting-started/nitro.mdx +22 -0
- package/docs/getting-started/sveltekit.mdx +6 -0
- package/docs/getting-started/tanstack-start.mdx +241 -0
- package/docs/how-it-works/cancellation.mdx +287 -0
- package/docs/how-it-works/code-transform.mdx +2 -2
- package/docs/how-it-works/event-sourcing.mdx +2 -2
- package/docs/how-it-works/meta.json +2 -1
- package/docs/internal/index.mdx +19 -0
- package/docs/internal/meta.json +5 -0
- package/docs/internal/serializable-abort-controller.mdx +148 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +7 -12
- package/docs/migration-guides/migrating-from-inngest.mdx +7 -17
- package/docs/migration-guides/migrating-from-temporal.mdx +5 -10
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +8 -17
- package/package.json +13 -12
- package/docs/cookbook/advanced/distributed-abort-controller.mdx +0 -318
package/docs/changelog/index.mdx
CHANGED
|
@@ -12,4 +12,5 @@ Stay up to date with the latest changes to Workflow SDK.
|
|
|
12
12
|
|
|
13
13
|
## 2026
|
|
14
14
|
|
|
15
|
-
-
|
|
15
|
+
- [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) - March 2026
|
|
16
|
+
- [Serializable AbortController and AbortSignal](/docs/changelog/serializable-abort-controller) — March 12, 2026
|
|
@@ -22,7 +22,7 @@ For simpler cases where steps share a single event log, use [direct await compos
|
|
|
22
22
|
|
|
23
23
|
The core pattern has three parts:
|
|
24
24
|
|
|
25
|
-
1. A
|
|
25
|
+
1. A parent workflow that calls `start()` to spawn child workflows and records their run IDs
|
|
26
26
|
2. A **polling loop** in the parent workflow that checks child status with `getRun()`
|
|
27
27
|
3. A **step** that retrieves the child's return value once it completes
|
|
28
28
|
|
|
@@ -67,7 +67,11 @@ export async function processDocumentBatch(documentIds: string[]) {
|
|
|
67
67
|
"use workflow";
|
|
68
68
|
|
|
69
69
|
// Spawn a child workflow for each document
|
|
70
|
-
const runIds =
|
|
70
|
+
const runIds: string[] = [];
|
|
71
|
+
for (const docId of documentIds) {
|
|
72
|
+
const run = await start(processDocument, [docId]); // [!code highlight]
|
|
73
|
+
runIds.push(run.runId);
|
|
74
|
+
}
|
|
71
75
|
|
|
72
76
|
// Poll until all children complete
|
|
73
77
|
await pollUntilComplete(runIds);
|
|
@@ -77,19 +81,6 @@ export async function processDocumentBatch(documentIds: string[]) {
|
|
|
77
81
|
|
|
78
82
|
return { processed: results.length, results };
|
|
79
83
|
}
|
|
80
|
-
|
|
81
|
-
async function spawnChildren(
|
|
82
|
-
documentIds: string[]
|
|
83
|
-
): Promise<string[]> {
|
|
84
|
-
"use step"; // [!code highlight]
|
|
85
|
-
|
|
86
|
-
const runIds: string[] = [];
|
|
87
|
-
for (const docId of documentIds) {
|
|
88
|
-
const run = await start(processDocument, [docId]); // [!code highlight]
|
|
89
|
-
runIds.push(run.runId);
|
|
90
|
-
}
|
|
91
|
-
return runIds;
|
|
92
|
-
}
|
|
93
84
|
```
|
|
94
85
|
|
|
95
86
|
### Polling loop
|
|
@@ -163,7 +154,7 @@ async function collectResults(
|
|
|
163
154
|
|
|
164
155
|
## Fan-out pattern: chunked spawning
|
|
165
156
|
|
|
166
|
-
When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system.
|
|
157
|
+
When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system. Start one chunk at a time from the parent workflow.
|
|
167
158
|
|
|
168
159
|
```typescript
|
|
169
160
|
import { start } from "workflow/api";
|
|
@@ -179,7 +170,7 @@ export async function largeReportBatch(reportConfigs: Array<{ id: string; query:
|
|
|
179
170
|
const allRunIds: string[] = [];
|
|
180
171
|
for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) {
|
|
181
172
|
const chunk = reportConfigs.slice(i, i + CHUNK_SIZE);
|
|
182
|
-
const runIds = await
|
|
173
|
+
const runIds = await startReportChunk(chunk); // [!code highlight]
|
|
183
174
|
allRunIds.push(...runIds);
|
|
184
175
|
}
|
|
185
176
|
|
|
@@ -190,14 +181,12 @@ export async function largeReportBatch(reportConfigs: Array<{ id: string; query:
|
|
|
190
181
|
return { total: results.length, results };
|
|
191
182
|
}
|
|
192
183
|
|
|
193
|
-
async function
|
|
184
|
+
async function startReportChunk(
|
|
194
185
|
configs: Array<{ id: string; query: string }>
|
|
195
186
|
): Promise<string[]> {
|
|
196
|
-
"use step";
|
|
197
|
-
|
|
198
187
|
const runIds: string[] = [];
|
|
199
188
|
for (const config of configs) {
|
|
200
|
-
const run = await start(generateReport, [config.id, config.query]);
|
|
189
|
+
const run = await start(generateReport, [config.id, config.query]); // [!code highlight]
|
|
201
190
|
runIds.push(run.runId);
|
|
202
191
|
}
|
|
203
192
|
return runIds;
|
|
@@ -356,12 +345,12 @@ async function pollWithRetries(
|
|
|
356
345
|
|
|
357
346
|
## Tips
|
|
358
347
|
|
|
359
|
-
- **`start()`
|
|
348
|
+
- **`start()` can be called directly from a workflow function in v5.** It records a step-backed boundary in the parent event log and returns a serializable `Run`.
|
|
360
349
|
- **`getRun()` must also be called from a step.** The polling loop lives in the workflow, but the actual status check is a step.
|
|
361
350
|
- **Set a max iteration count on polling loops** to prevent runaway workflows. Calculate the count from your expected max duration and poll interval.
|
|
362
|
-
- **Use chunked spawning for large batches.**
|
|
351
|
+
- **Use chunked spawning for large batches.** Starting 500 children at once can create a large burst of work. Break it into chunks of 10-50.
|
|
363
352
|
- **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent only sees the child's final status.
|
|
364
|
-
- **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations.
|
|
353
|
+
- **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations.
|
|
365
354
|
|
|
366
355
|
## Key APIs
|
|
367
356
|
|
|
@@ -1,70 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Agent Cancellation
|
|
3
|
-
description: Cancel a running agent from the outside —
|
|
3
|
+
description: Cancel a running agent from the outside using AbortSignal — a hook fires the abort, the agent step bails out of the model stream, and the client gets a clean stop notification.
|
|
4
4
|
type: guide
|
|
5
|
-
summary:
|
|
5
|
+
summary: Cancel a running agent cooperatively with AbortController. A stop hook fires controller.abort(), the signal propagates into the agent step to cancel the model stream, and a data-stopped part is emitted to streaming clients before the workflow returns.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback.
|
|
8
|
+
Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## Pattern
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
* **Admin cancellation** — stop an agent from a different process or API
|
|
14
|
-
* **Timeout fallback** — combine with `sleep()` to auto-stop after a deadline
|
|
15
|
-
|
|
16
|
-
## Choosing an approach
|
|
17
|
-
|
|
18
|
-
Pick the option that matches what your endpoint needs to deliver to the caller:
|
|
19
|
-
|
|
20
|
-
* **Hard Cancellation** — terminates the run immediately with no opportunity for cleanup or client notification. A single line of code, but the workflow throws `WorkflowRunCancelledError` and any streaming clients see an abrupt connection close.
|
|
21
|
-
* **Stop Signal** — the workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a real result.
|
|
22
|
-
|
|
23
|
-
The trade-offs at a glance:
|
|
24
|
-
|
|
25
|
-
| | Hard Cancellation | Stop Signal |
|
|
26
|
-
| --- | --- | --- |
|
|
27
|
-
| Mechanism | `getRun(runId).cancel()` | Hook + `Promise.race` |
|
|
28
|
-
| Speed to terminate | Immediate | At the next `await` boundary in the workflow |
|
|
29
|
-
| Runs `finally` / cleanup | No | Yes |
|
|
30
|
-
| Final stream notification | No (abrupt close) | Yes (`data-stopped` part) |
|
|
31
|
-
| `run.returnValue` | Throws `WorkflowRunCancelledError` | Returns the workflow's result |
|
|
32
|
-
| Code complexity | One line | Hook + race + signal step |
|
|
33
|
-
| Best for | Stuck or unresponsive runs, forced termination | User-facing stop, admin cancel, timeouts |
|
|
34
|
-
|
|
35
|
-
## Hard Cancellation
|
|
36
|
-
|
|
37
|
-
Call `.cancel()` on a run to terminate it immediately:
|
|
38
|
-
|
|
39
|
-
```typescript lineNumbers
|
|
40
|
-
import { getRun } from "workflow/api";
|
|
41
|
-
|
|
42
|
-
export async function POST(
|
|
43
|
-
_request: Request,
|
|
44
|
-
{ params }: { params: Promise<{ runId: string }> }
|
|
45
|
-
) {
|
|
46
|
-
const { runId } = await params;
|
|
47
|
-
await getRun(runId).cancel(); // [!code highlight]
|
|
48
|
-
return Response.json({ success: true });
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
This is an abrupt termination — the run is stopped mid-step with no opportunity to exit cleanly:
|
|
53
|
-
|
|
54
|
-
* **No cleanup runs** — `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped
|
|
55
|
-
* **No final notification to the client** — the writable closes abruptly, so a streaming UI just sees the connection drop with no `data-stopped` part to render a clean ending
|
|
56
|
-
* **`run.returnValue` throws** — anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload
|
|
57
|
-
* **Underlying step keeps running** — same caveat as the Stop Signal pattern below: the model stream or HTTP call inside the current step continues to completion in the background
|
|
58
|
-
|
|
59
|
-
Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else — chat stop buttons, admin "stop" actions, timeout fallbacks — you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result.
|
|
60
|
-
|
|
61
|
-
## Stop Signal
|
|
62
|
-
|
|
63
|
-
<Callout type="warn">
|
|
64
|
-
**Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes — tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
|
|
65
|
-
</Callout>
|
|
66
|
-
|
|
67
|
-
### Example
|
|
12
|
+
Create an `AbortController` in the workflow and race the agent (passing its signal) against a stop hook. When the hook fires, `controller.abort()` is called — the signal propagates into the agent step and cancels the underlying model stream. Before returning, a `data-stopped` part is written to the stream so any streaming clients can render a clean end state.
|
|
68
13
|
|
|
69
14
|
```typescript lineNumbers
|
|
70
15
|
import { DurableAgent } from "@workflow/ai/agent";
|
|
@@ -88,7 +33,7 @@ async function analyzeData({ topic }: { topic: string }) {
|
|
|
88
33
|
return { summary: `Analysis of ${topic}: significant developments found.`, confidence: 0.85 };
|
|
89
34
|
}
|
|
90
35
|
|
|
91
|
-
async function emitStopSignal(details: { reason?: string }) {
|
|
36
|
+
async function emitStopSignal(details: { reason?: string }) {
|
|
92
37
|
"use step";
|
|
93
38
|
const writer = getWritable<UIMessageChunk>().getWriter();
|
|
94
39
|
try {
|
|
@@ -102,7 +47,8 @@ export async function stoppableAgent(messages: ModelMessage[]) {
|
|
|
102
47
|
"use workflow";
|
|
103
48
|
|
|
104
49
|
const { workflowRunId } = getWorkflowMetadata();
|
|
105
|
-
const
|
|
50
|
+
const controller = new AbortController(); // [!code highlight]
|
|
51
|
+
const hook = stopHook.create({ token: `stop:${workflowRunId}` });
|
|
106
52
|
|
|
107
53
|
const agent = new DurableAgent({
|
|
108
54
|
model: "anthropic/claude-haiku-4.5",
|
|
@@ -121,15 +67,23 @@ export async function stoppableAgent(messages: ModelMessage[]) {
|
|
|
121
67
|
},
|
|
122
68
|
});
|
|
123
69
|
|
|
124
|
-
const result = await Promise.race([
|
|
70
|
+
const result = await Promise.race([
|
|
125
71
|
agent
|
|
126
|
-
.stream({
|
|
72
|
+
.stream({
|
|
73
|
+
messages,
|
|
74
|
+
writable: getWritable<UIMessageChunk>(),
|
|
75
|
+
abortSignal: controller.signal, // [!code highlight]
|
|
76
|
+
maxSteps: 15,
|
|
77
|
+
})
|
|
127
78
|
.then((r) => ({ type: "complete" as const, messages: r.messages })),
|
|
128
|
-
hook.then(({ reason }) =>
|
|
79
|
+
hook.then(({ reason }) => {
|
|
80
|
+
controller.abort(reason); // [!code highlight]
|
|
81
|
+
return { type: "stopped" as const, reason };
|
|
82
|
+
}),
|
|
129
83
|
]);
|
|
130
84
|
|
|
131
85
|
if (result.type === "stopped") {
|
|
132
|
-
await emitStopSignal({ reason: result.reason });
|
|
86
|
+
await emitStopSignal({ reason: result.reason });
|
|
133
87
|
}
|
|
134
88
|
|
|
135
89
|
return result;
|
|
@@ -148,7 +102,7 @@ export async function POST(
|
|
|
148
102
|
const { runId } = await params;
|
|
149
103
|
const { reason } = await request.json();
|
|
150
104
|
|
|
151
|
-
await stopHook.resume(`stop:${runId}`, {
|
|
105
|
+
await stopHook.resume(`stop:${runId}`, {
|
|
152
106
|
reason: reason || "User requested stop",
|
|
153
107
|
});
|
|
154
108
|
|
|
@@ -180,13 +134,12 @@ export function StopButton({ runId }: { runId: string }) {
|
|
|
180
134
|
|
|
181
135
|
## How it works
|
|
182
136
|
|
|
183
|
-
1.
|
|
184
|
-
2.
|
|
185
|
-
3.
|
|
186
|
-
4.
|
|
187
|
-
5.
|
|
188
|
-
|
|
189
|
-
This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — race a long-running operation against a hook signal.
|
|
137
|
+
1. An `AbortController` is created at the start of the workflow
|
|
138
|
+
2. A hook is created with token `stop:${workflowRunId}`
|
|
139
|
+
3. `Promise.race` runs the agent stream and the stop hook concurrently
|
|
140
|
+
4. The agent receives `controller.signal` — when aborted, the underlying model stream is cancelled
|
|
141
|
+
5. When the stop API resumes the hook, `controller.abort()` is called — the race resolves and the workflow exits
|
|
142
|
+
6. `emitStopSignal` writes a `data-stopped` part to the stream so the client renders a clean stop state
|
|
190
143
|
|
|
191
144
|
## Adapting this
|
|
192
145
|
|
|
@@ -194,12 +147,10 @@ This is the same pattern used by the [Distributed Abort Controller](/cookbook/ad
|
|
|
194
147
|
* **Audit logging** — include a `reason` field in the stop schema to record who stopped and why
|
|
195
148
|
* **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID
|
|
196
149
|
* **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop
|
|
197
|
-
* **Hard Cancellation as a fallback** — wire your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume errors with `not found` / `expired` (for example, the hook was already consumed). This guarantees the run is terminated even when the Stop Signal path is unavailable.
|
|
198
150
|
|
|
199
151
|
## Key APIs
|
|
200
152
|
|
|
201
153
|
* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal
|
|
202
154
|
* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
|
|
203
|
-
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream
|
|
204
|
-
* [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — the agent that
|
|
205
|
-
* [`getRun()`](/docs/api-reference/workflow-api/get-run) — entry point for Hard Cancellation: `getRun(runId).cancel()`
|
|
155
|
+
* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream output and the stop notification to the client
|
|
156
|
+
* [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — the agent that respects the abort signal via its `signal` option
|
|
@@ -14,6 +14,10 @@ Use this pattern to make any AI SDK agent durable. The agent becomes a workflow,
|
|
|
14
14
|
- Long-running agent sessions where losing progress is unacceptable
|
|
15
15
|
- Agents that need per-step observability in the workflow event log
|
|
16
16
|
|
|
17
|
+
<Callout type="info">
|
|
18
|
+
A durable agent run stays on the deployment that started it. For multi-turn agents that should pick up newer code between turns, see [Versioning](/docs/foundations/versioning) for patterns that start the next turn or next session run with `deploymentId: "latest"`.
|
|
19
|
+
</Callout>
|
|
20
|
+
|
|
17
21
|
## Pattern
|
|
18
22
|
|
|
19
23
|
Replace `Agent` with `DurableAgent`, wrap the function in `"use workflow"`, mark each tool with `"use step"`, and stream output through `getWritable()`.
|
|
@@ -7,6 +7,10 @@ summary: Schedule future actions with durable sleep that survives cold starts, a
|
|
|
7
7
|
|
|
8
8
|
Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences.
|
|
9
9
|
|
|
10
|
+
<Callout type="info">
|
|
11
|
+
Scheduled workflows are still pinned to the deployment that started them. If you are building recurring or indefinitely running schedules that should adopt newer code over time, see [Versioning](/docs/foundations/versioning) for the explicit `deploymentId: "latest"` continuation pattern.
|
|
12
|
+
</Callout>
|
|
13
|
+
|
|
10
14
|
## When to use this
|
|
11
15
|
|
|
12
16
|
- Sending emails on a schedule (drip campaigns, onboarding sequences, reminders)
|
|
@@ -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.
|
|
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 — see the [Cancellation Guide](/docs/foundations/cancellation) for patterns.
|
|
84
84
|
</Callout>
|
|
85
85
|
|
|
86
86
|
## Adapting to your use case
|
|
@@ -53,7 +53,7 @@ The parent waits for the child to finish before continuing. Both functions share
|
|
|
53
53
|
|
|
54
54
|
### Background spawn via `start()`
|
|
55
55
|
|
|
56
|
-
To run a child workflow independently without blocking the parent, call [`start()`](/docs/api-reference/workflow-api/start) from
|
|
56
|
+
To run a child workflow independently without blocking the parent, call [`start()`](/docs/api-reference/workflow-api/start) from the parent workflow. This launches the child as a separate workflow run with its own `runId`.
|
|
57
57
|
|
|
58
58
|
```typescript lineNumbers
|
|
59
59
|
import { start } from "workflow/api";
|
|
@@ -62,37 +62,30 @@ declare function generateReport(reportId: string): Promise<void>; // @setup
|
|
|
62
62
|
declare function fulfillOrder(orderId: string): Promise<{ id: string }>; // @setup
|
|
63
63
|
declare function sendConfirmation(orderId: string): Promise<void>; // @setup
|
|
64
64
|
|
|
65
|
-
async function triggerReportGeneration(reportId: string) {
|
|
66
|
-
"use step"; // [!code highlight]
|
|
67
|
-
|
|
68
|
-
const run = await start(generateReport, [reportId]); // [!code highlight]
|
|
69
|
-
return run.runId;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
65
|
export async function processOrder(orderId: string) {
|
|
73
66
|
"use workflow";
|
|
74
67
|
|
|
75
68
|
const order = await fulfillOrder(orderId);
|
|
76
69
|
|
|
77
|
-
const
|
|
70
|
+
const reportRun = await start(generateReport, [orderId]); // [!code highlight]
|
|
78
71
|
|
|
79
72
|
await sendConfirmation(orderId);
|
|
80
73
|
|
|
81
|
-
return { orderId, reportRunId };
|
|
74
|
+
return { orderId, reportRunId: reportRun.runId };
|
|
82
75
|
}
|
|
83
76
|
```
|
|
84
77
|
|
|
85
78
|
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)).
|
|
86
79
|
|
|
87
80
|
<Callout type="info">
|
|
88
|
-
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. This is currently a Vercel-specific feature. 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.
|
|
81
|
+
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.
|
|
89
82
|
</Callout>
|
|
90
83
|
|
|
91
84
|
## How it works
|
|
92
85
|
|
|
93
86
|
1. **Direct await flattens.** When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline — the child's steps emit into the parent's event log and share the parent's run ID.
|
|
94
87
|
2. **`start()` mints a new run.** The child gets its own `runId`, its own event log, and its own retry boundary. The parent only sees the `runId` returned by `start()`.
|
|
95
|
-
3. **`start()`
|
|
88
|
+
3. **`start()` can run inside workflows.** In v5, `start()` is step-backed, so it can be called directly from a workflow function and still records a deterministic step boundary in the event log.
|
|
96
89
|
|
|
97
90
|
## Choosing between the two modes
|
|
98
91
|
|
|
@@ -106,9 +99,9 @@ If you want the child workflow to run on the latest deployment rather than the c
|
|
|
106
99
|
|
|
107
100
|
## Adapting to your use case
|
|
108
101
|
|
|
109
|
-
- **Spawn many children at once** — call `start()` in a loop
|
|
102
|
+
- **Spawn many children at once** — call `start()` in a loop from the workflow. For more advanced fan-out (chunking, polling, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe.
|
|
110
103
|
- **Wait for a background child to finish** — combine `start()` with `getRun()` polling. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the full polling loop.
|
|
111
|
-
- **Pass results back from background children** —
|
|
104
|
+
- **Pass results back from background children** — `start()` returns the `runId`; later, a poll step uses `getRun(runId).returnValue` to fetch the final result.
|
|
112
105
|
|
|
113
106
|
## Key APIs
|
|
114
107
|
|
package/docs/cookbook/index.mdx
CHANGED
|
@@ -33,6 +33,5 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
|
|
|
33
33
|
## Advanced
|
|
34
34
|
|
|
35
35
|
- [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent
|
|
36
|
-
- [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks
|
|
37
36
|
- [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary
|
|
38
37
|
- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions
|
|
@@ -32,6 +32,10 @@ Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text)
|
|
|
32
32
|
|
|
33
33
|
One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run.
|
|
34
34
|
|
|
35
|
+
<Callout type="info">
|
|
36
|
+
Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see [Versioning](/docs/foundations/versioning) for the child-run continuation pattern.
|
|
37
|
+
</Callout>
|
|
38
|
+
|
|
35
39
|
<Tabs items={['Workflow', 'API Route', 'Client']}>
|
|
36
40
|
|
|
37
41
|
<Tab value="Workflow">
|
|
@@ -20,6 +20,10 @@ Workflow SDK complements it by making bot **sessions** durable. Each conversatio
|
|
|
20
20
|
- Survives deploys, cold starts, and crashes — the session picks up from the last step on replay
|
|
21
21
|
- Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running
|
|
22
22
|
|
|
23
|
+
<Callout type="info">
|
|
24
|
+
One thread mapped to one workflow run also means the thread stays on the deployment that started it. For channels where each message should use newer code, see [Versioning](/docs/foundations/versioning) for explicit child-run and handoff patterns using `deploymentId: "latest"`.
|
|
25
|
+
</Callout>
|
|
26
|
+
|
|
23
27
|
The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev.
|
|
24
28
|
|
|
25
29
|
## How It Fits Together
|
|
@@ -21,6 +21,10 @@ A sandbox alone gets you an isolated VM. A workflow around it gets you a **durab
|
|
|
21
21
|
- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure.
|
|
22
22
|
- **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy.
|
|
23
23
|
|
|
24
|
+
<Callout type="info">
|
|
25
|
+
An effectively unbounded sandbox session is still one workflow run, so it stays on the deployment that started it. If the controller or agent code should upgrade over time, use an explicit version boundary and pass the serialized state or stream handles forward. See [Versioning](/docs/foundations/versioning).
|
|
26
|
+
</Callout>
|
|
27
|
+
|
|
24
28
|
## Use Case: Coding Agents
|
|
25
29
|
|
|
26
30
|
This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return.
|
|
@@ -93,7 +93,7 @@ interface Storage {
|
|
|
93
93
|
|
|
94
94
|
**Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.
|
|
95
95
|
|
|
96
|
-
**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead.
|
|
96
|
+
**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead and include the active hook owner's run ID as `eventData.conflictingRunId`.
|
|
97
97
|
|
|
98
98
|
**Automatic Hook Disposal:** When a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`), automatically dispose of all associated hooks to release tokens for reuse.
|
|
99
99
|
|
|
@@ -161,7 +161,9 @@ Prefix for graphile-worker queue job names. Useful when sharing a database betwe
|
|
|
161
161
|
|
|
162
162
|
### `WORKFLOW_POSTGRES_WORKER_CONCURRENCY`
|
|
163
163
|
|
|
164
|
-
Number of concurrent workers polling for jobs. Default: `
|
|
164
|
+
Number of concurrent workers polling for jobs. Default: `50`.
|
|
165
|
+
|
|
166
|
+
This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates — if you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` e2e test (fib(6), ~24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting.
|
|
165
167
|
|
|
166
168
|
### `WORKFLOW_POSTGRES_MAX_POOL_SIZE`
|
|
167
169
|
|
|
@@ -179,8 +181,8 @@ import { createWorld } from "@workflow/world-postgres";
|
|
|
179
181
|
const world = createWorld({
|
|
180
182
|
connectionString: "postgres://user:password@host:5432/database",
|
|
181
183
|
jobPrefix: "myapp_",
|
|
182
|
-
queueConcurrency:
|
|
183
|
-
maxPoolSize:
|
|
184
|
+
queueConcurrency: 50,
|
|
185
|
+
maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE
|
|
184
186
|
});
|
|
185
187
|
```
|
|
186
188
|
|
|
@@ -139,6 +139,8 @@ On Vercel, workflow runs are pegged to the deployment that started them. This me
|
|
|
139
139
|
|
|
140
140
|
This ensures long-running workflows complete reliably without being affected by subsequent deployments.
|
|
141
141
|
|
|
142
|
+
For the full model, including rerunning on latest and explicit upgrade boundaries, see [Versioning](/docs/foundations/versioning).
|
|
143
|
+
|
|
142
144
|
## Security
|
|
143
145
|
|
|
144
146
|
### Consumer function security
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: abort-signal-timeout-in-workflow
|
|
3
|
+
description: AbortSignal.timeout() cannot be used inside workflow functions because it relies on real timers which break deterministic replay.
|
|
4
|
+
type: troubleshooting
|
|
5
|
+
summary: Use sleep() with AbortController instead of AbortSignal.timeout() in workflow functions.
|
|
6
|
+
prerequisites:
|
|
7
|
+
- /docs/foundations/workflows-and-steps
|
|
8
|
+
related:
|
|
9
|
+
- /docs/foundations/cancellation
|
|
10
|
+
- /docs/api-reference/workflow/sleep
|
|
11
|
+
- /docs/errors/timeout-in-workflow
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Error
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
AbortSignal.timeout() is not supported in workflow functions.
|
|
18
|
+
Use sleep() with an AbortController instead.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Why This Happens
|
|
22
|
+
|
|
23
|
+
`AbortSignal.timeout()` creates a signal that aborts after a real-time delay using an internal timer. Workflow functions must be [deterministic](/docs/foundations/workflows-and-steps) to support replay — they run the same code multiple times during the workflow's lifecycle, using the [event log](/docs/how-it-works/event-sourcing) to resume execution to the correct point.
|
|
24
|
+
|
|
25
|
+
Real-time timers break this determinism because:
|
|
26
|
+
- On the first execution, the timer might fire after 10 seconds
|
|
27
|
+
- On replay, the timer would fire again, but the event log may have already advanced past that point
|
|
28
|
+
- The timer's behavior depends on wall-clock time, which varies between executions
|
|
29
|
+
|
|
30
|
+
## How to Fix
|
|
31
|
+
|
|
32
|
+
Use [`sleep()`](/docs/api-reference/workflow/sleep) with an `AbortController` to create a deterministic timeout that cancels in-flight work:
|
|
33
|
+
|
|
34
|
+
**Before (incorrect):**
|
|
35
|
+
|
|
36
|
+
{/* @skip-typecheck: intentionally incorrect example */}
|
|
37
|
+
```typescript lineNumbers
|
|
38
|
+
export async function workflow() {
|
|
39
|
+
"use workflow";
|
|
40
|
+
|
|
41
|
+
// This will throw an error
|
|
42
|
+
const signal = AbortSignal.timeout(10_000); // [!code highlight]
|
|
43
|
+
const result = await fetchData(signal);
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**After (correct):**
|
|
49
|
+
|
|
50
|
+
```typescript lineNumbers
|
|
51
|
+
import { sleep } from "workflow";
|
|
52
|
+
|
|
53
|
+
export async function workflow() {
|
|
54
|
+
"use workflow";
|
|
55
|
+
|
|
56
|
+
const controller = new AbortController(); // [!code highlight]
|
|
57
|
+
void sleep("10s").then(() => controller.abort()); // [!code highlight]
|
|
58
|
+
|
|
59
|
+
return await fetchData(controller.signal);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchData(signal: AbortSignal) {
|
|
63
|
+
"use step";
|
|
64
|
+
const response = await fetch("https://api.example.com/data", { signal });
|
|
65
|
+
return response.json();
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The `sleep()` + `AbortController` pattern is the durable equivalent of `AbortSignal.timeout()`. The sleep is recorded in the event log, so it replays deterministically. If `fetchData` finishes within 10 seconds you get the response; if not, the timer fires `controller.abort()`, `fetch` rejects with an `AbortError`, and the step's failure propagates to the workflow as a `FatalError` (no retries — abort is intentional cancellation).
|
|
70
|
+
|
|
71
|
+
<Callout type="info">
|
|
72
|
+
`AbortSignal.timeout()` works normally inside step functions, since steps have full Node.js runtime access and are not replayed.
|
|
73
|
+
</Callout>
|
|
74
|
+
|
|
75
|
+
## Related
|
|
76
|
+
|
|
77
|
+
- [Cancellation](/docs/foundations/cancellation) — Patterns for cancelling in-flight work
|
|
78
|
+
- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) — Durable sleep primitive
|
|
79
|
+
- [Workflows and Steps](/docs/foundations/workflows-and-steps) — Why workflow functions must be deterministic
|
|
80
|
+
- [`setTimeout` in Workflow](/docs/errors/timeout-in-workflow) — Similar restriction on `setTimeout`
|
|
@@ -73,9 +73,9 @@ export async function processPayment() {
|
|
|
73
73
|
}
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
## Handling Hook Conflicts
|
|
76
|
+
## Handling Hook Conflicts
|
|
77
77
|
|
|
78
|
-
When a hook conflict occurs, awaiting the hook will throw a `HookConflictError`.
|
|
78
|
+
When a hook conflict occurs, awaiting the hook will throw a `HookConflictError`. The error exposes the token that conflicted and, for current worlds, the run ID that currently owns it. `conflictingRunId` remains optional for compatibility with older persisted events and world implementations, so guard it before delegating:
|
|
79
79
|
|
|
80
80
|
```typescript lineNumbers
|
|
81
81
|
import { createHook } from "workflow";
|
|
@@ -93,14 +93,64 @@ export async function processPayment(orderId: string) {
|
|
|
93
93
|
if (HookConflictError.is(error)) { // [!code highlight]
|
|
94
94
|
// Another workflow is already processing this order
|
|
95
95
|
console.log(`Conflicting token: ${error.token}`);
|
|
96
|
-
|
|
96
|
+
if (error.conflictingRunId) {
|
|
97
|
+
console.log(`Active run: ${error.conflictingRunId}`);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
reason: "duplicate-processing",
|
|
102
|
+
token: error.token,
|
|
103
|
+
runId: error.conflictingRunId
|
|
104
|
+
};
|
|
97
105
|
}
|
|
98
106
|
throw error; // Re-throw other errors
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
109
|
```
|
|
102
110
|
|
|
103
|
-
This pattern is useful when you want to detect
|
|
111
|
+
This pattern is useful when you want to detect duplicate processing inside the workflow. Runtime APIs such as `resumeHook()` and `getRun()` must be called outside workflow functions, for example from an API route or in a step.
|
|
112
|
+
|
|
113
|
+
### Delegate to the Active Run
|
|
114
|
+
|
|
115
|
+
In idempotency flows, a conflict means another active run already owns the hook token. You can return the duplicate-processing payload from the workflow, resume the active hook to deliver the payload to the existing run, then use `getRun(result.runId)` to wait for, stream, or cancel the active run:
|
|
116
|
+
|
|
117
|
+
```typescript lineNumbers
|
|
118
|
+
import { getRun, resumeHook, start } from "workflow/api";
|
|
119
|
+
import { processPayment } from "@/workflows/process-payment";
|
|
120
|
+
|
|
121
|
+
type ProcessPaymentResult =
|
|
122
|
+
| { success: true; payment: unknown }
|
|
123
|
+
| {
|
|
124
|
+
success: false;
|
|
125
|
+
reason: "duplicate-processing";
|
|
126
|
+
token: string;
|
|
127
|
+
runId?: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export async function POST(request: Request) {
|
|
131
|
+
const { orderId, payment } = await request.json();
|
|
132
|
+
const run = await start(processPayment, [orderId]);
|
|
133
|
+
const result = (await run.returnValue) as ProcessPaymentResult;
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
result.success === false &&
|
|
137
|
+
result.reason === "duplicate-processing" &&
|
|
138
|
+
result.runId
|
|
139
|
+
) {
|
|
140
|
+
await resumeHook(result.token, payment); // [!code highlight]
|
|
141
|
+
const activeRun = getRun(result.runId); // [!code highlight]
|
|
142
|
+
|
|
143
|
+
return Response.json({
|
|
144
|
+
delegatedToRunId: activeRun.runId,
|
|
145
|
+
result: await activeRun.returnValue
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Response.json(result);
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
If the caller needs live output instead of the final result, return `activeRun.getReadable()` from the same branch. If the duplicate request should replace the active work, call `await activeRun.cancel()` after inspecting the run.
|
|
104
154
|
|
|
105
155
|
## When Hook Tokens Are Released
|
|
106
156
|
|
|
@@ -122,4 +172,6 @@ After a workflow completes, its hook tokens become available for reuse by other
|
|
|
122
172
|
## Related
|
|
123
173
|
|
|
124
174
|
- [Hooks](/docs/foundations/hooks) - Learn more about using hooks in workflows
|
|
175
|
+
- [getRun](/docs/api-reference/workflow-api/get-run) - Retrieve or control the active run
|
|
176
|
+
- [resumeHook](/docs/api-reference/workflow-api/resume-hook) - Deliver data to the active hook
|
|
125
177
|
- [createWebhook](/docs/api-reference/workflow/create-webhook) - Alternative for fixed webhook URLs
|