workflow 5.0.0-beta.1 → 5.0.0-beta.11

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.
Files changed (113) hide show
  1. package/README.md +4 -4
  2. package/dist/api-workflow.d.ts +1 -1
  3. package/dist/api-workflow.d.ts.map +1 -1
  4. package/dist/api-workflow.js +2 -2
  5. package/dist/api.d.ts +5 -1
  6. package/dist/api.d.ts.map +1 -1
  7. package/dist/api.js +14 -2
  8. package/dist/astro.js +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/internal/builtins.d.ts +17 -0
  11. package/dist/internal/builtins.d.ts.map +1 -1
  12. package/dist/internal/builtins.js +65 -1
  13. package/dist/internal/class-serialization.js +1 -1
  14. package/dist/internal/errors.js +1 -1
  15. package/dist/nest.js +1 -1
  16. package/dist/next.cjs +1 -1
  17. package/dist/nitro.js +1 -1
  18. package/dist/nuxt.js +1 -1
  19. package/dist/observability.d.ts +1 -1
  20. package/dist/observability.js +2 -2
  21. package/dist/runtime.d.ts +1 -1
  22. package/dist/runtime.d.ts.map +1 -1
  23. package/dist/runtime.js +2 -2
  24. package/dist/stdlib.js +1 -1
  25. package/dist/sveltekit.js +1 -1
  26. package/dist/typescript-plugin.cjs +1 -1
  27. package/dist/vite.js +1 -1
  28. package/dist/workflow.js +1 -1
  29. package/docs/ai/index.mdx +6 -5
  30. package/docs/ai/resumable-streams.mdx +1 -1
  31. package/docs/api-reference/vitest/index.mdx +28 -1
  32. package/docs/api-reference/workflow/create-webhook.mdx +37 -18
  33. package/docs/api-reference/workflow/experimental-set-attributes.mdx +63 -0
  34. package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
  35. package/docs/api-reference/workflow/index.mdx +3 -0
  36. package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
  37. package/docs/api-reference/workflow-ai/index.mdx +0 -5
  38. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
  39. package/docs/api-reference/workflow-api/start.mdx +5 -4
  40. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
  41. package/docs/api-reference/workflow-next/with-workflow.mdx +34 -2
  42. package/docs/changelog/attributes-mvp.mdx +368 -0
  43. package/docs/changelog/eager-processing.mdx +595 -0
  44. package/docs/changelog/index.mdx +2 -1
  45. package/docs/cookbook/advanced/child-workflows.mdx +313 -0
  46. package/docs/cookbook/advanced/meta.json +9 -0
  47. package/docs/cookbook/advanced/publishing-libraries.mdx +336 -0
  48. package/docs/cookbook/advanced/serializable-steps.mdx +147 -0
  49. package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
  50. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
  51. package/docs/cookbook/agent-patterns/durable-agent.mdx +154 -0
  52. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +255 -0
  53. package/docs/cookbook/agent-patterns/meta.json +4 -0
  54. package/docs/cookbook/common-patterns/batching.mdx +105 -0
  55. package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
  56. package/docs/cookbook/common-patterns/meta.json +15 -0
  57. package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
  58. package/docs/cookbook/common-patterns/saga.mdx +247 -0
  59. package/docs/cookbook/common-patterns/scheduling.mdx +129 -0
  60. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
  61. package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
  62. package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
  63. package/docs/cookbook/common-patterns/workflow-composition.mdx +111 -0
  64. package/docs/cookbook/index.mdx +38 -0
  65. package/docs/cookbook/integrations/ai-sdk.mdx +383 -0
  66. package/docs/cookbook/integrations/chat-sdk.mdx +307 -0
  67. package/docs/cookbook/integrations/meta.json +4 -0
  68. package/docs/cookbook/integrations/sandbox.mdx +520 -0
  69. package/docs/cookbook/meta.json +5 -0
  70. package/docs/deploying/building-a-world.mdx +1 -1
  71. package/docs/deploying/world/local-world.mdx +1 -1
  72. package/docs/deploying/world/postgres-world.mdx +6 -4
  73. package/docs/deploying/world/vercel-world.mdx +3 -1
  74. package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
  75. package/docs/errors/hook-conflict.mdx +56 -4
  76. package/docs/errors/index.mdx +3 -0
  77. package/docs/errors/runtime-decryption-failed.mdx +77 -0
  78. package/docs/errors/start-invalid-workflow-function.mdx +1 -1
  79. package/docs/foundations/cancellation.mdx +460 -0
  80. package/docs/foundations/errors-and-retries.mdx +7 -3
  81. package/docs/foundations/index.mdx +3 -3
  82. package/docs/foundations/meta.json +3 -2
  83. package/docs/foundations/serialization.mdx +78 -42
  84. package/docs/foundations/starting-workflows.mdx +6 -2
  85. package/docs/foundations/versioning.mdx +263 -0
  86. package/docs/getting-started/astro.mdx +6 -0
  87. package/docs/getting-started/index.mdx +13 -7
  88. package/docs/getting-started/meta.json +3 -1
  89. package/docs/getting-started/nestjs.mdx +8 -0
  90. package/docs/getting-started/next.mdx +5 -3
  91. package/docs/getting-started/nitro.mdx +22 -0
  92. package/docs/getting-started/python.mdx +165 -0
  93. package/docs/getting-started/sveltekit.mdx +6 -0
  94. package/docs/getting-started/tanstack-start.mdx +241 -0
  95. package/docs/how-it-works/cancellation.mdx +287 -0
  96. package/docs/how-it-works/code-transform.mdx +2 -2
  97. package/docs/how-it-works/event-sourcing.mdx +2 -2
  98. package/docs/how-it-works/meta.json +2 -1
  99. package/docs/internal/index.mdx +19 -0
  100. package/docs/internal/meta.json +5 -0
  101. package/docs/internal/serializable-abort-controller.mdx +148 -0
  102. package/docs/meta.json +1 -0
  103. package/docs/migration-guides/index.mdx +34 -0
  104. package/docs/migration-guides/meta.json +9 -0
  105. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +358 -0
  106. package/docs/migration-guides/migrating-from-inngest.mdx +304 -0
  107. package/docs/migration-guides/migrating-from-temporal.mdx +313 -0
  108. package/docs/migration-guides/migrating-from-trigger-dev.mdx +328 -0
  109. package/docs/observability/attributes.mdx +66 -0
  110. package/docs/observability/index.mdx +9 -1
  111. package/docs/observability/meta.json +1 -1
  112. package/package.json +14 -13
  113. package/docs/foundations/common-patterns.mdx +0 -265
@@ -73,9 +73,9 @@ export async function processPayment() {
73
73
  }
74
74
  ```
75
75
 
76
- ## Handling Hook Conflicts in Your Workflow
76
+ ## Handling Hook Conflicts
77
77
 
78
- When a hook conflict occurs, awaiting the hook will throw a `HookConflictError`. You can catch this error to handle the conflict gracefully:
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
- return { success: false, reason: "duplicate-processing" };
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 and handle duplicate processing attempts instead of letting the workflow fail.
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
@@ -43,6 +43,9 @@ Fix common mistakes when creating and executing workflows in the **Workflow SDK*
43
43
  <Card href="/docs/errors/workflow-not-registered" title="workflow-not-registered">
44
44
  Resolve workflow not registered errors caused by deployment mismatches.
45
45
  </Card>
46
+ <Card href="/docs/errors/runtime-decryption-failed" title="runtime-decryption-failed">
47
+ Resolve runtime decryption failures from the SDK's encryption layer.
48
+ </Card>
46
49
  </Cards>
47
50
 
48
51
  ## Learn More
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: runtime-decryption-failed
3
+ description: The SDK's built-in AES-GCM encryption layer failed to encrypt or decrypt a workflow payload.
4
+ type: troubleshooting
5
+ summary: Resolve runtime decryption failures caused by ciphertext corruption, key mismatch, or malformed envelopes.
6
+ prerequisites:
7
+ - /docs/foundations/workflows-and-steps
8
+ related:
9
+ - /docs/foundations/errors-and-retries
10
+ ---
11
+
12
+ This error occurs when the Workflow SDK's built-in AES-GCM encryption layer fails while encrypting or decrypting a workflow payload. The SDK encrypts step inputs, step outputs, hook payloads, and other event-log data with a per-run AES-256 key whenever encryption is configured for the deployment.
13
+
14
+ This is an **internal SDK failure** — your workflow code never invokes the encryption primitives directly. When this surfaces, it means the ciphertext, nonce, or auth tag the SDK tried to verify is not the bytes that were originally produced. The run is failed with the `RUNTIME_ERROR` classification.
15
+
16
+ ## Error Message
17
+
18
+ ```
19
+ AES-256-GCM decryption failed: The operation failed for an operation-specific reason
20
+ ```
21
+
22
+ The underlying cause is a native Web Crypto [`OperationError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#operationerror) — most commonly raised by `AESCipherJob.onDone` in Node's `node:internal/crypto/util` module when the GCM authentication tag does not verify.
23
+
24
+ The thrown `RuntimeDecryptionError` carries a small `context` object with diagnostic fields to help triangulate the source:
25
+
26
+ - `operation` — `'encrypt'` or `'decrypt'`
27
+ - `byteLength` — total byte length of the payload at the failure site
28
+ - `formatPrefix` — the first 4 bytes of the input (`'encr'` for a well-formed encrypted envelope, otherwise a hex dump)
29
+
30
+ ## Why This Happens
31
+
32
+ Common causes, in rough order of likelihood:
33
+
34
+ 1. **Ciphertext mutation or truncation in transit.** The encrypted payload reached the SDK with bytes that differ from what storage holds. Possible sources include a truncated HTTP response from a workflow-server ref endpoint, an edge-cache miss returning a partial 200, or a proxy drop during streaming. A truncated body whose first 4 bytes happen to still spell `encr` produces the exact "auth tag mismatch" symptom.
35
+ 2. **Key resolution mismatch.** The key used to decrypt is not the key that was used to encrypt — e.g. the run's `deploymentId` was not threaded through key resolution and the SDK fell back to the wrong deployment's key material.
36
+ 3. **Malformed encrypted envelope.** The envelope is too short to contain the GCM nonce (12 bytes) and auth tag (16 bytes), so decryption is rejected before it begins.
37
+
38
+ ## What To Do
39
+
40
+ This error indicates an SDK or infrastructure problem — not a bug in your workflow code. Your workflow code does not need to change.
41
+
42
+ ### 1. Upgrade to the latest `workflow` package
43
+
44
+ The underlying issue may have already been identified and fixed:
45
+
46
+ ```bash
47
+ npm install workflow@latest
48
+ ```
49
+
50
+ ### 2. Retry the failed run
51
+
52
+ Since this is a fatal error, the run is automatically marked as `failed`. You can re-run it using the **Re-run** button in the Workflow Dashboard.
53
+
54
+ ### 3. Report the issue
55
+
56
+ If the error persists after upgrading, please [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate. Include:
57
+
58
+ - The version of the `workflow` package you are using
59
+ - The run ID(s) of the affected workflow run(s)
60
+ - The full error message, including the `context` fields (`operation`, `byteLength`, `formatPrefix`)
61
+ - Whether the affected workflows make heavy use of large step inputs/outputs (which may indicate the failure is on the lazy-loaded ref read path)
62
+
63
+ ## This Error Cannot Be Caught
64
+
65
+ Like other `WorkflowRuntimeError` subclasses, a runtime decryption failure is **not catchable** inside your workflow function. The runtime cannot safely continue executing user code when an event-log payload can't be verified, so the entire run fails immediately and is marked as `failed`.
66
+
67
+ To handle this programmatically from outside the workflow, check the run status:
68
+
69
+ ```typescript lineNumbers
70
+ import { getRun } from "workflow/api";
71
+
72
+ const run = getRun("wrun_abc123");
73
+ const status = await run.status;
74
+ if (status === "failed") {
75
+ console.error("Run failed");
76
+ }
77
+ ```
@@ -123,7 +123,7 @@ Before calling `start()`:
123
123
  1. Confirm the function includes `"use workflow"` as its first statement.
124
124
  2. Confirm your framework integration is enabled (for Next.js, wrap `next.config.ts` with [`withWorkflow()`](/docs/api-reference/workflow-next/with-workflow)).
125
125
  3. Pass the imported workflow function directly to `start()`, not a wrapper callback.
126
- 4. Keep the function in a file that goes through Workflow DevKit's transform step.
126
+ 4. Keep the function in a file that goes through Workflow SDK's transform step.
127
127
 
128
128
  ## Related
129
129
 
@@ -0,0 +1,460 @@
1
+ ---
2
+ title: Cancellation
3
+ description: Cancel long-running steps cooperatively using AbortSignal, or cancel entire workflow runs.
4
+ type: conceptual
5
+ summary: Cancel in-flight work with AbortSignal or stop entire workflow runs.
6
+ prerequisites:
7
+ - /docs/foundations/workflows-and-steps
8
+ related:
9
+ - /docs/foundations/common-patterns
10
+ - /docs/foundations/hooks
11
+ - /docs/how-it-works/cancellation
12
+ ---
13
+
14
+ Workflow DevKit supports two cancellation mechanisms: **AbortSignal** for fine-grained, cooperative cancellation of individual operations, and **run cancellation** for stopping an entire workflow. This guide covers both.
15
+
16
+ ## AbortSignal
17
+
18
+ `AbortController` and `AbortSignal` work across workflow and step boundaries. Create an `AbortController` with `new AbortController()` in a workflow function, pass its signal to steps, and call `abort()` — using the standard [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API you already know.
19
+
20
+ ```typescript lineNumbers
21
+ import { sleep } from "workflow";
22
+
23
+ export async function cancellableWorkflow() {
24
+ "use workflow";
25
+
26
+ const controller = new AbortController(); // [!code highlight]
27
+
28
+ const result = await Promise.race([
29
+ longRunningStep(controller.signal), // [!code highlight]
30
+ sleep("30s").then(() => "timeout" as const),
31
+ ]);
32
+
33
+ if (result === "timeout") {
34
+ controller.abort(); // [!code highlight]
35
+ return { status: "timed out" };
36
+ }
37
+
38
+ return { status: "completed", result };
39
+ }
40
+
41
+ async function longRunningStep(signal: AbortSignal) {
42
+ "use step";
43
+
44
+ const response = await fetch("https://api.example.com/slow-operation", {
45
+ signal, // [!code highlight]
46
+ });
47
+
48
+ return response.json();
49
+ }
50
+ ```
51
+
52
+ No special imports, no wrapper functions — just the standard `AbortController` API.
53
+
54
+ <Callout type="info">
55
+ Cancellation is **cooperative**. Aborting a signal doesn't forcefully kill a step — it's up to the step's code to check `signal.aborted` or pass the signal to APIs like `fetch` that respect it. If a step ignores the signal, it runs to completion.
56
+ </Callout>
57
+
58
+ <Callout type="info">
59
+ To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/docs/how-it-works/cancellation).
60
+ </Callout>
61
+
62
+ ### Timeout with Cancellation
63
+
64
+ Race a step against a timeout, and cancel the step if the timeout wins:
65
+
66
+ ```typescript lineNumbers
67
+ import { sleep } from "workflow";
68
+
69
+ export async function fetchWithTimeout(url: string) {
70
+ "use workflow";
71
+
72
+ const controller = new AbortController();
73
+
74
+ const result = await Promise.race([
75
+ fetchUrl(url, controller.signal),
76
+ sleep("10s").then(() => null),
77
+ ]);
78
+
79
+ if (result === null) {
80
+ controller.abort(); // [!code highlight]
81
+ throw new Error(`Request to ${url} timed out after 10s`);
82
+ }
83
+
84
+ return result;
85
+ }
86
+
87
+ async function fetchUrl(url: string, signal: AbortSignal) {
88
+ "use step";
89
+ const response = await fetch(url, { signal });
90
+ return response.json();
91
+ }
92
+ ```
93
+
94
+ ### Cancelling Parallel Work
95
+
96
+ When racing multiple steps, cancel the losers:
97
+
98
+ ```typescript lineNumbers
99
+ export async function firstResponder(urls: string[]) {
100
+ "use workflow";
101
+
102
+ const controller = new AbortController();
103
+
104
+ const result = await Promise.race( // [!code highlight]
105
+ urls.map((url) => fetchUrl(url, controller.signal)) // [!code highlight]
106
+ ); // [!code highlight]
107
+
108
+ controller.abort(); // Cancel remaining fetches // [!code highlight]
109
+
110
+ return result;
111
+ }
112
+
113
+ async function fetchUrl(url: string, signal: AbortSignal) {
114
+ "use step";
115
+ const response = await fetch(url, { signal });
116
+ return { url, data: await response.json() };
117
+ }
118
+ ```
119
+
120
+ ### Passing Signal Through a Pipeline
121
+
122
+ Pass the same signal to a chain of steps. Aborting cancels whichever step is currently running:
123
+
124
+ ```typescript lineNumbers
125
+ declare function splitIntoChunks(data: ArrayBuffer): ArrayBuffer[]; // @setup
126
+ declare function processChunk(chunk: ArrayBuffer): Promise<Uint8Array>; // @setup
127
+
128
+ export async function pipelineWorkflow(dataUrl: string) {
129
+ "use workflow";
130
+
131
+ const controller = new AbortController();
132
+
133
+ try {
134
+ const raw = await downloadData(dataUrl, controller.signal);
135
+ const transformed = await transformData(raw, controller.signal);
136
+ const result = await uploadData(transformed, controller.signal);
137
+ return result;
138
+ } catch (err) {
139
+ if (err instanceof Error && err.name === "AbortError") {
140
+ return { status: "cancelled" };
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ async function downloadData(url: string, signal: AbortSignal) {
147
+ "use step";
148
+ const response = await fetch(url, { signal });
149
+ return response.arrayBuffer();
150
+ }
151
+
152
+ async function transformData(data: ArrayBuffer, signal: AbortSignal) {
153
+ "use step";
154
+
155
+ signal.throwIfAborted(); // [!code highlight]
156
+
157
+ const chunks = splitIntoChunks(data);
158
+ const results = [];
159
+
160
+ for (const chunk of chunks) {
161
+ signal.throwIfAborted(); // [!code highlight]
162
+ results.push(await processChunk(chunk));
163
+ }
164
+
165
+ return Buffer.concat(results);
166
+ }
167
+
168
+ async function uploadData(data: ArrayBuffer, signal: AbortSignal) {
169
+ "use step";
170
+ await fetch("https://storage.example.com/upload", {
171
+ method: "POST",
172
+ body: data,
173
+ signal,
174
+ });
175
+ return { status: "uploaded" };
176
+ }
177
+ ```
178
+
179
+ ### Step-Initiated Abort
180
+
181
+ A step can receive the full `AbortController` and call `abort()` to cancel parallel work. This is useful for watchdog/monitor patterns where one step observes an external condition and cancels other in-flight steps:
182
+
183
+ ```typescript lineNumbers
184
+ export async function processWithQuotaCheck(userId: string, dataUrl: string) {
185
+ "use workflow";
186
+
187
+ const controller = new AbortController();
188
+
189
+ // Run the work and a quota monitor in parallel
190
+ const [result] = await Promise.all([ // [!code highlight]
191
+ processData(dataUrl, controller.signal), // [!code highlight]
192
+ monitorQuota(userId, controller), // [!code highlight]
193
+ ]); // [!code highlight]
194
+
195
+ return result;
196
+ }
197
+
198
+ async function processData(url: string, signal: AbortSignal) {
199
+ "use step";
200
+ const response = await fetch(url, { signal });
201
+ const data = await response.arrayBuffer();
202
+ // ... expensive processing ...
203
+ return { processed: true };
204
+ }
205
+
206
+ async function monitorQuota(userId: string, controller: AbortController) {
207
+ "use step";
208
+
209
+ // Poll quota status while the other step is running
210
+ while (!controller.signal.aborted) {
211
+ const quota = await fetch(`https://api.example.com/quota/${userId}`);
212
+ const { exceeded } = await quota.json();
213
+
214
+ if (exceeded) {
215
+ controller.abort("Quota exceeded"); // Cancels processData // [!code highlight]
216
+ return;
217
+ }
218
+
219
+ await new Promise((resolve) => setTimeout(resolve, 5000));
220
+ }
221
+ }
222
+ ```
223
+
224
+ ### User-Triggered Cancellation with Hooks
225
+
226
+ Combine hooks with abort controllers to let users cancel in-flight work from an external API:
227
+
228
+ ```typescript lineNumbers
229
+ import { createHook } from "workflow";
230
+
231
+ export async function userCancellableWorkflow(jobId: string) {
232
+ "use workflow";
233
+
234
+ using cancelHook = createHook<{ reason: string }>({
235
+ token: `cancel:${jobId}`,
236
+ });
237
+
238
+ const controller = new AbortController();
239
+ const workPromise = doExpensiveWork(controller.signal);
240
+
241
+ const result = await Promise.race([ // [!code highlight]
242
+ workPromise.then((data) => ({ status: "completed", data })),
243
+ cancelHook.then((payload) => { // [!code highlight]
244
+ controller.abort(); // [!code highlight]
245
+ return { status: "cancelled", reason: payload.reason };
246
+ }),
247
+ ]);
248
+
249
+ return result;
250
+ }
251
+
252
+ async function doExpensiveWork(signal: AbortSignal) {
253
+ "use step";
254
+ const response = await fetch("https://api.example.com/expensive", { signal });
255
+ return response.json();
256
+ }
257
+ ```
258
+
259
+ ```typescript title="app/api/cancel/route.ts" lineNumbers
260
+ import { resumeHook } from "workflow/api";
261
+
262
+ export async function POST(request: Request) {
263
+ const { jobId, reason } = await request.json();
264
+
265
+ await resumeHook(`cancel:${jobId}`, { reason });
266
+ return Response.json({ cancelled: true });
267
+ }
268
+ ```
269
+
270
+ ### How Steps Handle Abort
271
+
272
+ When an `AbortSignal` is aborted, the behavior depends on how the step uses it:
273
+
274
+ | Usage | Behavior on Abort |
275
+ |-------|-------------------|
276
+ | `fetch(url, { signal })` | Request is cancelled, throws `AbortError` |
277
+ | `signal.throwIfAborted()` | Throws the abort reason |
278
+ | `signal.aborted` check | Returns `true`, step can exit gracefully |
279
+ | `signal.addEventListener('abort', fn)` | Callback fires, step can clean up |
280
+ | Ignored | Step runs to completion (abort is cooperative) |
281
+
282
+ ### Abort Errors Skip Retries
283
+
284
+ When a step throws due to an abort (e.g., `fetch` throws `AbortError`, or `signal.throwIfAborted()` throws), the error is automatically wrapped in a `FatalError`. This means the step **skips retries** and the error bubbles up to the workflow immediately.
285
+
286
+ This is the correct behavior because an abort is an intentional cancellation — retrying the step would just result in another abort. You don't need to manually wrap abort errors in `FatalError`.
287
+
288
+ ```typescript lineNumbers
289
+ import { sleep } from "workflow";
290
+
291
+ export async function workflow() {
292
+ "use workflow";
293
+ const controller = new AbortController();
294
+
295
+ try {
296
+ const result = await Promise.race([
297
+ cancellableStep(controller.signal),
298
+ sleep("5s").then(() => null),
299
+ ]);
300
+ if (result === null) controller.abort();
301
+ return result;
302
+ } catch (err) {
303
+ // AbortError arrives as FatalError — no retries attempted // [!code highlight]
304
+ return { status: "cancelled" };
305
+ }
306
+ }
307
+
308
+ async function cancellableStep(signal: AbortSignal) {
309
+ "use step";
310
+ // If this throws AbortError, it's automatically wrapped in FatalError
311
+ const response = await fetch("https://api.example.com/slow", { signal });
312
+ return response.json();
313
+ }
314
+ ```
315
+
316
+ ### Passing AbortSignal as Workflow Input
317
+
318
+ You can pass an `AbortSignal` from external code into a workflow via `start()`:
319
+
320
+ {/* @skip-typecheck: myWorkflow is not declared, this is a conceptual snippet */}
321
+ ```typescript lineNumbers
322
+ import { start } from "workflow/api";
323
+
324
+ export async function POST(request: Request) {
325
+ const controller = new AbortController();
326
+ const run = await start(myWorkflow, [controller.signal]); // [!code highlight]
327
+
328
+ // Later, cancel from external code
329
+ controller.abort(); // [!code highlight]
330
+ }
331
+ ```
332
+
333
+ When the signal is serialized at the `start()` boundary, an event listener is attached to the external signal that writes the cancellation packet to the backing stream. This means the external `abort()` propagates into the workflow — but only while the originating process is still alive (same constraint as passing a `ReadableStream` as input).
334
+
335
+ <Callout type="info">
336
+ For reliable external cancellation that works regardless of process lifetime, prefer the [User-Triggered Cancellation with Hooks](#user-triggered-cancellation-with-hooks) pattern. Hooks are durable and don't depend on the caller's process staying alive.
337
+ </Callout>
338
+
339
+ ## Run Cancellation
340
+
341
+ Run cancellation stops an entire workflow at the next suspension point. Unlike `AbortSignal`, it is not cooperative — the workflow does not continue executing after cancellation.
342
+
343
+ ```typescript title="app/api/cancel-run/route.ts" lineNumbers
344
+ import { getRun } from "workflow/api";
345
+
346
+ export async function POST(request: Request) {
347
+ const { runId } = await request.json();
348
+
349
+ const run = getRun(runId);
350
+ await run.cancel(); // [!code highlight]
351
+
352
+ return Response.json({ cancelled: true });
353
+ }
354
+ ```
355
+
356
+ <Callout type="info">
357
+ Calling `run.cancel()` is the same action as clicking the **Cancel** button on a run in the observability UI — both produce identical `run_cancelled` events in the event log.
358
+ </Callout>
359
+
360
+ When a run is cancelled:
361
+ - The workflow stops at its next suspension point (step call, hook await, or sleep)
362
+ - A `run_cancelled` event is recorded in the [event log](/docs/how-it-works/event-sourcing)
363
+ - All associated hooks are disposed and their tokens released
364
+ - Streams are closed
365
+
366
+ <Callout type="info">
367
+ Run cancellation does **not** automatically abort any outstanding `AbortSignal`s. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use `AbortSignal`.
368
+ </Callout>
369
+
370
+ ## AbortSignal vs. Run Cancellation
371
+
372
+ | | AbortSignal | Run Cancellation |
373
+ |---|---|---|
374
+ | **Scope** | Individual operations within a step | Entire workflow run |
375
+ | **Triggered by** | Your code (`controller.abort()`) | External API (`run.cancel()`) |
376
+ | **Cooperative** | Yes — steps must check the signal | No — workflow stops at the next suspension point |
377
+ | **Granularity** | Can target specific steps or operations | All-or-nothing |
378
+ | **In-flight steps** | Aborted immediately if using the signal | Run to completion |
379
+
380
+ Use `AbortSignal` when you need fine-grained, in-flight cancellation of specific operations. Use run cancellation when you want to stop the entire workflow.
381
+
382
+ ## Best Practices
383
+
384
+ **Use `throwIfAborted()` before expensive work.** This throws the signal's abort reason if the signal is already aborted, preventing wasted compute:
385
+
386
+ ```typescript lineNumbers
387
+ async function expensiveStep(signal: AbortSignal) {
388
+ "use step";
389
+ signal.throwIfAborted(); // [!code highlight]
390
+ // ... expensive work ...
391
+ }
392
+ ```
393
+
394
+ **Handle abort errors in the workflow.** Abort errors arrive as `FatalError` (no retries) and can be caught with a standard try/catch:
395
+
396
+ ```typescript lineNumbers
397
+ declare function cancellableStep(signal: AbortSignal): Promise<void>; // @setup
398
+ import { FatalError } from "workflow";
399
+
400
+ export async function workflow() {
401
+ "use workflow";
402
+ const controller = new AbortController();
403
+
404
+ try {
405
+ await cancellableStep(controller.signal);
406
+ } catch (err) {
407
+ if (FatalError.is(err)) { // [!code highlight]
408
+ return { status: "cancelled" };
409
+ }
410
+ throw err;
411
+ }
412
+ }
413
+ ```
414
+
415
+ **Use `AbortSignal.any()` to combine signals:**
416
+
417
+ ```typescript lineNumbers
418
+ async function stepWithMultipleSignals(
419
+ userSignal: AbortSignal,
420
+ timeoutSignal: AbortSignal
421
+ ) {
422
+ "use step";
423
+
424
+ const combined = AbortSignal.any([userSignal, timeoutSignal]); // [!code highlight]
425
+ const response = await fetch("https://api.example.com/data", {
426
+ signal: combined,
427
+ });
428
+ return response.json();
429
+ }
430
+ ```
431
+
432
+ **Abort after a race:**
433
+
434
+ ```typescript lineNumbers
435
+ declare function stepA(signal: AbortSignal): Promise<string>; // @setup
436
+ declare function stepB(signal: AbortSignal): Promise<string>; // @setup
437
+
438
+ export async function workflow() {
439
+ "use workflow";
440
+ const controller = new AbortController();
441
+
442
+ const winner = await Promise.race([
443
+ stepA(controller.signal),
444
+ stepB(controller.signal),
445
+ ]);
446
+
447
+ controller.abort(); // Clean up whichever step is still running // [!code highlight]
448
+ return winner;
449
+ }
450
+ ```
451
+
452
+ This is safe even if both steps have already completed — aborting a finished operation is a no-op.
453
+
454
+ ## Related Documentation
455
+
456
+ - [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
457
+ - [Serialization](/docs/foundations/serialization) — Understanding serializable types
458
+ - [Common Patterns](/docs/foundations/common-patterns) — Timeout and race patterns
459
+ - [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
460
+ - [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures
@@ -141,7 +141,7 @@ callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts)
141
141
 
142
142
  ## Error Codes
143
143
 
144
- When a workflow run fails, the error may include a `code` that classifies the failure. You can access it programmatically via the `Run` class:
144
+ When a workflow run fails, the error includes an `errorCode` that classifies the failure, alongside the original thrown value (preserved as `cause`):
145
145
 
146
146
  ```typescript lineNumbers
147
147
  import { WorkflowRunFailedError } from "@workflow/errors";
@@ -153,8 +153,12 @@ try {
153
153
  const result = await run.returnValue;
154
154
  } catch (err) {
155
155
  if (WorkflowRunFailedError.is(err)) {
156
- console.log(err.cause.code); // "USER_ERROR", "RUNTIME_ERROR", or undefined
157
- console.log(err.cause.message); // The error message
156
+ console.log(err.errorCode); // "USER_ERROR", "RUNTIME_ERROR", or undefined
157
+ // `cause` is the original thrown value, hydrated through the workflow
158
+ // serialization pipeline. It can be any thrown value, so check shape.
159
+ if (err.cause instanceof Error) {
160
+ console.log(err.cause.message); // The error message
161
+ }
158
162
  }
159
163
  }
160
164
  ```
@@ -17,9 +17,6 @@ Workflow programming can be a slight shift from how you traditionally write real
17
17
  <Card href="/docs/foundations/starting-workflows" title="Starting Workflows">
18
18
  Trigger workflows and track their execution using the `start()` function.
19
19
  </Card>
20
- <Card href="/docs/foundations/common-patterns" title="Common Patterns">
21
- Common patterns useful in workflows.
22
- </Card>
23
20
  <Card href="/docs/foundations/errors-and-retries" title="Errors & Retrying">
24
21
  Types of errors and how retrying work in workflows.
25
22
  </Card>
@@ -35,4 +32,7 @@ Workflow programming can be a slight shift from how you traditionally write real
35
32
  <Card href="/docs/foundations/idempotency" title="Idempotency">
36
33
  Prevent duplicate side effects when retrying operations.
37
34
  </Card>
35
+ <Card href="/docs/foundations/versioning" title="Versioning">
36
+ Understand how runs stay pinned to deployments and when to opt in to newer code.
37
+ </Card>
38
38
  </Cards>