workflow 5.0.0-beta.1 → 5.0.0-beta.10
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/README.md +4 -4
- package/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -2
- package/dist/api.d.ts +5 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +14 -2
- package/dist/astro.js +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/builtins.d.ts +17 -0
- package/dist/internal/builtins.d.ts.map +1 -1
- package/dist/internal/builtins.js +65 -1
- package/dist/internal/class-serialization.js +1 -1
- package/dist/internal/errors.js +1 -1
- package/dist/nest.js +1 -1
- package/dist/next.cjs +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -2
- package/dist/stdlib.js +1 -1
- package/dist/sveltekit.js +1 -1
- package/dist/typescript-plugin.cjs +1 -1
- package/dist/vite.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/ai/index.mdx +6 -5
- package/docs/ai/resumable-streams.mdx +1 -1
- package/docs/api-reference/vitest/index.mdx +28 -1
- package/docs/api-reference/workflow/create-webhook.mdx +37 -18
- package/docs/api-reference/workflow/experimental-set-attributes.mdx +63 -0
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
- package/docs/api-reference/workflow/index.mdx +3 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
- package/docs/api-reference/workflow-ai/index.mdx +0 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
- 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 +34 -2
- package/docs/changelog/attributes-mvp.mdx +368 -0
- package/docs/changelog/eager-processing.mdx +595 -0
- package/docs/changelog/index.mdx +2 -1
- package/docs/cookbook/advanced/child-workflows.mdx +313 -0
- package/docs/cookbook/advanced/meta.json +9 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +336 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +147 -0
- package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +154 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +255 -0
- package/docs/cookbook/agent-patterns/meta.json +4 -0
- package/docs/cookbook/common-patterns/batching.mdx +105 -0
- package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
- package/docs/cookbook/common-patterns/meta.json +15 -0
- package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
- package/docs/cookbook/common-patterns/saga.mdx +247 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +129 -0
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +111 -0
- package/docs/cookbook/index.mdx +38 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +383 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +307 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +520 -0
- package/docs/cookbook/meta.json +5 -0
- package/docs/deploying/building-a-world.mdx +1 -1
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +6 -4
- package/docs/deploying/world/vercel-world.mdx +3 -1
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
- package/docs/errors/hook-conflict.mdx +56 -4
- package/docs/errors/index.mdx +3 -0
- package/docs/errors/runtime-decryption-failed.mdx +77 -0
- package/docs/errors/start-invalid-workflow-function.mdx +1 -1
- package/docs/foundations/cancellation.mdx +460 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/index.mdx +3 -3
- package/docs/foundations/meta.json +3 -2
- package/docs/foundations/serialization.mdx +78 -42
- package/docs/foundations/starting-workflows.mdx +6 -2
- package/docs/foundations/versioning.mdx +263 -0
- package/docs/getting-started/astro.mdx +6 -0
- package/docs/getting-started/index.mdx +13 -7
- package/docs/getting-started/meta.json +3 -1
- 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/python.mdx +165 -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/meta.json +1 -0
- package/docs/migration-guides/index.mdx +34 -0
- package/docs/migration-guides/meta.json +9 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +358 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +304 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +313 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +328 -0
- package/docs/observability/attributes.mdx +66 -0
- package/docs/observability/index.mdx +9 -1
- package/docs/observability/meta.json +1 -1
- package/package.json +14 -13
- 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
|
|
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
|
package/docs/errors/index.mdx
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
157
|
-
|
|
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>
|