@workflow/core 5.0.0-beta.2 → 5.0.0-beta.21

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 (251) hide show
  1. package/dist/attribute-changes.d.ts +7 -0
  2. package/dist/attribute-changes.d.ts.map +1 -0
  3. package/dist/attribute-changes.js +25 -0
  4. package/dist/capabilities.d.ts +22 -1
  5. package/dist/capabilities.d.ts.map +1 -1
  6. package/dist/capabilities.js +41 -4
  7. package/dist/capture-stack.d.ts +16 -0
  8. package/dist/capture-stack.d.ts.map +1 -0
  9. package/dist/capture-stack.js +21 -0
  10. package/dist/class-serialization.d.ts +32 -0
  11. package/dist/class-serialization.d.ts.map +1 -1
  12. package/dist/class-serialization.js +37 -1
  13. package/dist/classify-error.d.ts +11 -3
  14. package/dist/classify-error.d.ts.map +1 -1
  15. package/dist/classify-error.js +59 -6
  16. package/dist/context-errors.d.ts +27 -0
  17. package/dist/context-errors.d.ts.map +1 -0
  18. package/dist/context-errors.js +101 -0
  19. package/dist/context-violation-error.d.ts +97 -0
  20. package/dist/context-violation-error.d.ts.map +1 -0
  21. package/dist/context-violation-error.js +147 -0
  22. package/dist/create-hook.d.ts +40 -1
  23. package/dist/create-hook.d.ts.map +1 -1
  24. package/dist/create-hook.js +4 -3
  25. package/dist/define-hook.d.ts.map +1 -1
  26. package/dist/define-hook.js +20 -5
  27. package/dist/describe-error.d.ts +70 -0
  28. package/dist/describe-error.d.ts.map +1 -0
  29. package/dist/describe-error.js +165 -0
  30. package/dist/encryption.d.ts +22 -1
  31. package/dist/encryption.d.ts.map +1 -1
  32. package/dist/encryption.js +72 -27
  33. package/dist/events-consumer.d.ts +24 -0
  34. package/dist/events-consumer.d.ts.map +1 -1
  35. package/dist/events-consumer.js +79 -16
  36. package/dist/flushable-stream.d.ts +10 -10
  37. package/dist/flushable-stream.d.ts.map +1 -1
  38. package/dist/flushable-stream.js +18 -13
  39. package/dist/global.d.ts +15 -1
  40. package/dist/global.d.ts.map +1 -1
  41. package/dist/global.js +21 -3
  42. package/dist/index.d.ts +1 -0
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +2 -1
  45. package/dist/log-format.d.ts +25 -0
  46. package/dist/log-format.d.ts.map +1 -0
  47. package/dist/log-format.js +250 -0
  48. package/dist/logger.d.ts +28 -30
  49. package/dist/logger.d.ts.map +1 -1
  50. package/dist/logger.js +77 -31
  51. package/dist/private.d.ts +107 -2
  52. package/dist/private.d.ts.map +1 -1
  53. package/dist/private.js +80 -3
  54. package/dist/runtime/constants.d.ts +121 -0
  55. package/dist/runtime/constants.d.ts.map +1 -1
  56. package/dist/runtime/constants.js +206 -8
  57. package/dist/runtime/get-port-lazy.d.ts +25 -0
  58. package/dist/runtime/get-port-lazy.d.ts.map +1 -0
  59. package/dist/runtime/get-port-lazy.js +92 -0
  60. package/dist/runtime/get-world-lazy.d.ts +32 -0
  61. package/dist/runtime/get-world-lazy.d.ts.map +1 -0
  62. package/dist/runtime/get-world-lazy.js +56 -0
  63. package/dist/runtime/helpers.d.ts +50 -7
  64. package/dist/runtime/helpers.d.ts.map +1 -1
  65. package/dist/runtime/helpers.js +149 -31
  66. package/dist/runtime/replay-budget.d.ts +98 -0
  67. package/dist/runtime/replay-budget.d.ts.map +1 -0
  68. package/dist/runtime/replay-budget.js +191 -0
  69. package/dist/runtime/resume-hook.d.ts.map +1 -1
  70. package/dist/runtime/resume-hook.js +39 -23
  71. package/dist/runtime/run-id-time.d.ts +10 -0
  72. package/dist/runtime/run-id-time.d.ts.map +1 -0
  73. package/dist/runtime/run-id-time.js +31 -0
  74. package/dist/runtime/run.d.ts.map +1 -1
  75. package/dist/runtime/run.js +44 -9
  76. package/dist/runtime/runs.d.ts.map +1 -1
  77. package/dist/runtime/runs.js +4 -1
  78. package/dist/runtime/start.d.ts +30 -1
  79. package/dist/runtime/start.d.ts.map +1 -1
  80. package/dist/runtime/start.js +138 -20
  81. package/dist/runtime/step-executor.d.ts +108 -0
  82. package/dist/runtime/step-executor.d.ts.map +1 -0
  83. package/dist/runtime/step-executor.js +715 -0
  84. package/dist/runtime/step-handler.d.ts.map +1 -1
  85. package/dist/runtime/step-handler.js +715 -556
  86. package/dist/runtime/suspension-handler.d.ts +74 -7
  87. package/dist/runtime/suspension-handler.d.ts.map +1 -1
  88. package/dist/runtime/suspension-handler.js +308 -116
  89. package/dist/runtime/wait-continuation.d.ts +84 -0
  90. package/dist/runtime/wait-continuation.d.ts.map +1 -0
  91. package/dist/runtime/wait-continuation.js +92 -0
  92. package/dist/runtime/wait-until.d.ts +18 -0
  93. package/dist/runtime/wait-until.d.ts.map +1 -0
  94. package/dist/runtime/wait-until.js +42 -0
  95. package/dist/runtime/world-init.d.ts +75 -0
  96. package/dist/runtime/world-init.d.ts.map +1 -0
  97. package/dist/runtime/world-init.js +75 -0
  98. package/dist/runtime/world.d.ts.map +1 -1
  99. package/dist/runtime/world.js +47 -18
  100. package/dist/runtime.d.ts +11 -8
  101. package/dist/runtime.d.ts.map +1 -1
  102. package/dist/runtime.js +1395 -319
  103. package/dist/schemas.d.ts +1 -1
  104. package/dist/schemas.d.ts.map +1 -1
  105. package/dist/schemas.js +1 -1
  106. package/dist/serialization/client.d.ts +17 -0
  107. package/dist/serialization/client.d.ts.map +1 -0
  108. package/dist/serialization/client.js +48 -0
  109. package/dist/serialization/codec-devalue.d.ts +14 -0
  110. package/dist/serialization/codec-devalue.d.ts.map +1 -0
  111. package/dist/serialization/codec-devalue.js +98 -0
  112. package/dist/serialization/codec.d.ts +106 -0
  113. package/dist/serialization/codec.d.ts.map +1 -0
  114. package/dist/serialization/codec.js +17 -0
  115. package/dist/serialization/compression.d.ts +104 -0
  116. package/dist/serialization/compression.d.ts.map +1 -0
  117. package/dist/serialization/compression.js +260 -0
  118. package/dist/serialization/encryption.d.ts +33 -0
  119. package/dist/serialization/encryption.d.ts.map +1 -0
  120. package/dist/serialization/encryption.js +70 -0
  121. package/dist/serialization/errors.d.ts +34 -0
  122. package/dist/serialization/errors.d.ts.map +1 -0
  123. package/dist/serialization/errors.js +59 -0
  124. package/dist/serialization/format.d.ts +60 -0
  125. package/dist/serialization/format.d.ts.map +1 -0
  126. package/dist/serialization/format.js +97 -0
  127. package/dist/serialization/index.d.ts +19 -0
  128. package/dist/serialization/index.d.ts.map +1 -0
  129. package/dist/serialization/index.js +22 -0
  130. package/dist/serialization/reducers/class.d.ts +11 -0
  131. package/dist/serialization/reducers/class.d.ts.map +1 -0
  132. package/dist/serialization/reducers/class.js +68 -0
  133. package/dist/serialization/reducers/common.d.ts +16 -0
  134. package/dist/serialization/reducers/common.d.ts.map +1 -0
  135. package/dist/serialization/reducers/common.js +417 -0
  136. package/dist/serialization/reducers/step-function.d.ts +35 -0
  137. package/dist/serialization/reducers/step-function.d.ts.map +1 -0
  138. package/dist/serialization/reducers/step-function.js +88 -0
  139. package/dist/serialization/step.d.ts +17 -0
  140. package/dist/serialization/step.d.ts.map +1 -0
  141. package/dist/serialization/step.js +48 -0
  142. package/dist/serialization/types.d.ts +253 -0
  143. package/dist/serialization/types.d.ts.map +1 -0
  144. package/dist/serialization/types.js +25 -0
  145. package/dist/serialization/workflow.d.ts +29 -0
  146. package/dist/serialization/workflow.d.ts.map +1 -0
  147. package/dist/serialization/workflow.js +54 -0
  148. package/dist/serialization-format.d.ts +15 -0
  149. package/dist/serialization-format.d.ts.map +1 -1
  150. package/dist/serialization-format.js +209 -11
  151. package/dist/serialization.d.ts +313 -214
  152. package/dist/serialization.d.ts.map +1 -1
  153. package/dist/serialization.js +1430 -658
  154. package/dist/set-attributes.d.ts +13 -0
  155. package/dist/set-attributes.d.ts.map +1 -0
  156. package/dist/set-attributes.js +42 -0
  157. package/dist/sleep.d.ts.map +1 -1
  158. package/dist/sleep.js +3 -2
  159. package/dist/source-map.d.ts +5 -0
  160. package/dist/source-map.d.ts.map +1 -1
  161. package/dist/source-map.js +102 -10
  162. package/dist/step/context-storage.d.ts +43 -0
  163. package/dist/step/context-storage.d.ts.map +1 -1
  164. package/dist/step/context-storage.js +1 -1
  165. package/dist/step/get-closure-vars.d.ts.map +1 -1
  166. package/dist/step/get-closure-vars.js +3 -2
  167. package/dist/step/get-step-metadata.d.ts.map +1 -1
  168. package/dist/step/get-step-metadata.js +3 -2
  169. package/dist/step/get-workflow-metadata.d.ts.map +1 -1
  170. package/dist/step/get-workflow-metadata.js +3 -2
  171. package/dist/step/writable-stream.d.ts.map +1 -1
  172. package/dist/step/writable-stream.js +49 -5
  173. package/dist/step-hydration-cache.d.ts +148 -0
  174. package/dist/step-hydration-cache.d.ts.map +1 -0
  175. package/dist/step-hydration-cache.js +171 -0
  176. package/dist/step.d.ts.map +1 -1
  177. package/dist/step.js +115 -21
  178. package/dist/symbols.d.ts +28 -0
  179. package/dist/symbols.d.ts.map +1 -1
  180. package/dist/symbols.js +29 -1
  181. package/dist/telemetry/semantic-conventions.d.ts +30 -2
  182. package/dist/telemetry/semantic-conventions.d.ts.map +1 -1
  183. package/dist/telemetry/semantic-conventions.js +21 -1
  184. package/dist/telemetry.d.ts +60 -0
  185. package/dist/telemetry.d.ts.map +1 -1
  186. package/dist/telemetry.js +82 -4
  187. package/dist/types.d.ts +6 -0
  188. package/dist/types.d.ts.map +1 -1
  189. package/dist/types.js +23 -1
  190. package/dist/util.d.ts +16 -6
  191. package/dist/util.d.ts.map +1 -1
  192. package/dist/util.js +25 -16
  193. package/dist/version.d.ts +1 -1
  194. package/dist/version.d.ts.map +1 -1
  195. package/dist/version.js +2 -2
  196. package/dist/vm/index.d.ts.map +1 -1
  197. package/dist/vm/index.js +3 -2
  198. package/dist/vm/script-cache.d.ts +28 -0
  199. package/dist/vm/script-cache.d.ts.map +1 -0
  200. package/dist/vm/script-cache.js +140 -0
  201. package/dist/workflow/abort-controller.d.ts +65 -0
  202. package/dist/workflow/abort-controller.d.ts.map +1 -0
  203. package/dist/workflow/abort-controller.js +277 -0
  204. package/dist/workflow/attribute-dispatcher.d.ts +6 -0
  205. package/dist/workflow/attribute-dispatcher.d.ts.map +1 -0
  206. package/dist/workflow/attribute-dispatcher.js +48 -0
  207. package/dist/workflow/create-hook.d.ts.map +1 -1
  208. package/dist/workflow/create-hook.js +23 -2
  209. package/dist/workflow/define-hook.d.ts +1 -1
  210. package/dist/workflow/define-hook.d.ts.map +1 -1
  211. package/dist/workflow/define-hook.js +8 -4
  212. package/dist/workflow/get-workflow-metadata.d.ts.map +1 -1
  213. package/dist/workflow/get-workflow-metadata.js +14 -3
  214. package/dist/workflow/hook.d.ts.map +1 -1
  215. package/dist/workflow/hook.js +207 -33
  216. package/dist/workflow/index.d.ts +1 -0
  217. package/dist/workflow/index.d.ts.map +1 -1
  218. package/dist/workflow/index.js +5 -3
  219. package/dist/workflow/set-attributes.d.ts +73 -0
  220. package/dist/workflow/set-attributes.d.ts.map +1 -0
  221. package/dist/workflow/set-attributes.js +65 -0
  222. package/dist/workflow/sleep.d.ts.map +1 -1
  223. package/dist/workflow/sleep.js +45 -8
  224. package/dist/workflow/world-init-stub.d.ts +15 -0
  225. package/dist/workflow/world-init-stub.d.ts.map +1 -0
  226. package/dist/workflow/world-init-stub.js +15 -0
  227. package/dist/workflow.d.ts +10 -1
  228. package/dist/workflow.d.ts.map +1 -1
  229. package/dist/workflow.js +168 -49
  230. package/docs/api-reference/create-hook.mdx +38 -0
  231. package/docs/api-reference/create-webhook.mdx +1 -0
  232. package/docs/api-reference/experimental-set-attributes.mdx +65 -0
  233. package/docs/api-reference/fetch.mdx +5 -0
  234. package/docs/api-reference/index.mdx +3 -0
  235. package/docs/foundations/cancellation.mdx +459 -0
  236. package/docs/foundations/errors-and-retries.mdx +7 -3
  237. package/docs/foundations/hooks.mdx +29 -0
  238. package/docs/foundations/idempotency.mdx +236 -11
  239. package/docs/foundations/index.mdx +3 -3
  240. package/docs/foundations/meta.json +3 -2
  241. package/docs/foundations/serialization.mdx +78 -42
  242. package/docs/foundations/starting-workflows.mdx +6 -2
  243. package/docs/foundations/streaming.mdx +14 -23
  244. package/docs/foundations/versioning.mdx +263 -0
  245. package/docs/how-it-works/cancellation.mdx +287 -0
  246. package/docs/how-it-works/code-transform.mdx +2 -2
  247. package/docs/how-it-works/encryption.mdx +2 -2
  248. package/docs/how-it-works/event-sourcing.mdx +2 -2
  249. package/docs/how-it-works/meta.json +2 -1
  250. package/package.json +22 -10
  251. package/docs/foundations/common-patterns.mdx +0 -265
@@ -8,6 +8,7 @@ prerequisites:
8
8
  related:
9
9
  - /docs/api-reference/workflow/define-hook
10
10
  - /docs/api-reference/workflow/create-webhook
11
+ - /docs/foundations/idempotency
11
12
  ---
12
13
 
13
14
  Creates a low-level hook primitive that can be used to resume a workflow run with arbitrary payloads.
@@ -65,6 +66,8 @@ export default Hook;`}
65
66
 
66
67
  The returned `Hook` object also implements `AsyncIterable<T>`, which allows you to iterate over incoming payloads using `for await...of` syntax.
67
68
 
69
+ Use `hook.getConflict()` to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run) if another active hook already owns the same token.
70
+
68
71
  ## Examples
69
72
 
70
73
  ### Basic Usage
@@ -112,6 +115,40 @@ export async function slackBotWorkflow(channelId: string) {
112
115
  }
113
116
  ```
114
117
 
118
+ ### Detecting Token Conflicts
119
+
120
+ Use `hook.getConflict()` when the workflow needs to claim a hook token before doing other work, but does not need a payload yet:
121
+
122
+ ```typescript lineNumbers
123
+ import { createHook } from "workflow";
124
+
125
+ declare function chargeOrder(orderId: string): Promise<void>; // @setup
126
+
127
+ async function processOrder(orderId: string) {
128
+ "use workflow";
129
+
130
+ using hook = createHook({ // [!code highlight]
131
+ token: `order:${orderId}` // [!code highlight]
132
+ }); // [!code highlight]
133
+
134
+ const conflict = await hook.getConflict(); // [!code highlight]
135
+ if (conflict) { // [!code highlight]
136
+ // Another active workflow run already owns this token.
137
+ return { dedupedTo: conflict.runId };
138
+ }
139
+
140
+ await chargeOrder(orderId);
141
+ }
142
+ ```
143
+
144
+ Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration — to receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`.
145
+
146
+ On a conflict, the resolved value is a `Run` handle for the run that currently owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context.
147
+
148
+ <Callout type="info">
149
+ Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
150
+ </Callout>
151
+
115
152
  ### Waiting for Multiple Payloads
116
153
 
117
154
  You can also wait for multiple payloads by using the `for await...of` syntax.
@@ -195,3 +232,4 @@ This is equivalent to manually calling `dispose()` but ensures the hook is alway
195
232
  - [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper
196
233
  - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload
197
234
  - [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Higher-level HTTP webhook abstraction
235
+ - [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts
@@ -55,6 +55,7 @@ The returned `Webhook` object has:
55
55
 
56
56
  - `url`: The HTTP endpoint URL that external systems can call
57
57
  - `token`: The unique token identifying this webhook
58
+ - `getConflict()`: A promise that resolves with the conflicting run if another active hook already owns this token, or `null` once the webhook endpoint has been registered
58
59
  - Implements `AsyncIterable<T>` for handling multiple requests, where `T` is `Request` (default) or `RequestWithResponse` (manual mode)
59
60
 
60
61
  When using `createWebhook({ respondWith: 'manual' })`, the resolved request type is `RequestWithResponse`, which extends the standard `Request` interface with a `respondWith(response: Response): Promise<void>` method for sending custom responses back to the caller.
@@ -0,0 +1,65 @@
1
+ ---
2
+ title: experimental_setAttributes
3
+ description: Attach string metadata to workflow run for observability.
4
+ type: reference
5
+ summary: Use experimental_setAttributes inside a workflow or step function to set run attributes.
6
+ prerequisites:
7
+ - /docs/foundations/workflows-and-steps
8
+ related:
9
+ - /docs/observability/attributes
10
+ - /docs/api-reference/workflow/fatal-error
11
+ ---
12
+
13
+ Attaches string metadata to the current workflow run.
14
+
15
+ <Callout>
16
+ This API is experimental and may change before the stable attributes API is released.
17
+ </Callout>
18
+
19
+ ```typescript lineNumbers
20
+ import { experimental_setAttributes } from "workflow"
21
+
22
+ export async function orderWorkflow(orderId: string) {
23
+ "use workflow"
24
+
25
+ await experimental_setAttributes({
26
+ phase: "received",
27
+ orderId,
28
+ })
29
+ }
30
+ ```
31
+
32
+ ## API Signature
33
+
34
+ ### Parameters
35
+
36
+ <TSDoc
37
+ definition={`
38
+ import { experimental_setAttributes } from "workflow";
39
+ export default experimental_setAttributes;`}
40
+ showSections={['parameters']}
41
+ />
42
+
43
+ ## Usage
44
+
45
+ Call `experimental_setAttributes` from a `"use workflow"` function or a `"use step"` function. Calling it from plain application code is not supported because there is no active workflow run.
46
+
47
+ Attribute values must be strings. Pass `undefined` to remove an attribute:
48
+
49
+ ```typescript lineNumbers
50
+ import { experimental_setAttributes } from "workflow"
51
+
52
+ export async function cleanupAttributes() {
53
+ "use workflow"
54
+
55
+ await experimental_setAttributes({ staleKey: undefined })
56
+ }
57
+ ```
58
+
59
+ Attribute keys must be 1-256 characters, values must be strings up to 256 bytes, and each run can have up to 64 attributes. Keys that start with `$` are reserved for framework and library code.
60
+
61
+ Validation errors throw [`FatalError`](/docs/api-reference/workflow/fatal-error) and fail the run before an attribute write is attempted.
62
+
63
+ Calls from both workflow and step bodies append a native `attr_set` event, which the World materializes onto `run.attributes`. Workflow-originated events record a workflow writer; step-originated events record the originating step ID and attempt.
64
+
65
+ Native attributes require spec version 4 or later. Step-body storage errors throw from `experimental_setAttributes`; catch them inside the step if the write should be best-effort. Workflow-body writes are committed when the workflow suspends: transient storage errors are retried with the suspension, while a write the World rejects as invalid — such as exceeding the per-run attribute cap across multiple calls — fails the run with the validation error.
@@ -7,12 +7,17 @@ prerequisites:
7
7
  - /docs/foundations/workflows-and-steps
8
8
  related:
9
9
  - /docs/errors/fetch-in-workflow
10
+ - /docs/foundations/idempotency
10
11
  ---
11
12
 
12
13
  Makes HTTP requests from within a workflow. This is a special step function that wraps the standard `fetch` API, automatically handling serialization and providing retry semantics.
13
14
 
14
15
  This is useful when you need to call external APIs or services from within your workflow.
15
16
 
17
+ <Callout type="warn">
18
+ Because workflow `fetch()` has retry semantics, use idempotency keys when the request mutates an external system, such as creating a charge, sending an email, or enqueueing work. See [Idempotency](/docs/foundations/idempotency).
19
+ </Callout>
20
+
16
21
  <Callout>
17
22
  `fetch` is a *special* type of step function provided and should be called directly inside workflow functions.
18
23
  </Callout>
@@ -47,6 +47,9 @@ Workflow SDK contains the following functions you can use inside your workflow f
47
47
  <Card href="/docs/api-reference/workflow/get-writable" title="getWritable()">
48
48
  Access the current workflow run's default stream.
49
49
  </Card>
50
+ <Card href="/docs/api-reference/workflow/experimental-set-attributes" title="experimental_setAttributes()">
51
+ Attach experimental string metadata to the current workflow run.
52
+ </Card>
50
53
  </Cards>
51
54
 
52
55
  ## Error Classes
@@ -0,0 +1,459 @@
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/hooks
10
+ - /docs/how-it-works/cancellation
11
+ ---
12
+
13
+ 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.
14
+
15
+ ## AbortSignal
16
+
17
+ `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.
18
+
19
+ ```typescript lineNumbers
20
+ import { sleep } from "workflow";
21
+
22
+ export async function cancellableWorkflow() {
23
+ "use workflow";
24
+
25
+ const controller = new AbortController(); // [!code highlight]
26
+
27
+ const result = await Promise.race([
28
+ longRunningStep(controller.signal), // [!code highlight]
29
+ sleep("30s").then(() => "timeout" as const),
30
+ ]);
31
+
32
+ if (result === "timeout") {
33
+ controller.abort(); // [!code highlight]
34
+ return { status: "timed out" };
35
+ }
36
+
37
+ return { status: "completed", result };
38
+ }
39
+
40
+ async function longRunningStep(signal: AbortSignal) {
41
+ "use step";
42
+
43
+ const response = await fetch("https://api.example.com/slow-operation", {
44
+ signal, // [!code highlight]
45
+ });
46
+
47
+ return response.json();
48
+ }
49
+ ```
50
+
51
+ No special imports, no wrapper functions — just the standard `AbortController` API.
52
+
53
+ <Callout type="info">
54
+ 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.
55
+ </Callout>
56
+
57
+ <Callout type="info">
58
+ To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/docs/how-it-works/cancellation).
59
+ </Callout>
60
+
61
+ ### Timeout with Cancellation
62
+
63
+ Race a step against a timeout, and cancel the step if the timeout wins:
64
+
65
+ ```typescript lineNumbers
66
+ import { sleep } from "workflow";
67
+
68
+ export async function fetchWithTimeout(url: string) {
69
+ "use workflow";
70
+
71
+ const controller = new AbortController();
72
+
73
+ const result = await Promise.race([
74
+ fetchUrl(url, controller.signal),
75
+ sleep("10s").then(() => null),
76
+ ]);
77
+
78
+ if (result === null) {
79
+ controller.abort(); // [!code highlight]
80
+ throw new Error(`Request to ${url} timed out after 10s`);
81
+ }
82
+
83
+ return result;
84
+ }
85
+
86
+ async function fetchUrl(url: string, signal: AbortSignal) {
87
+ "use step";
88
+ const response = await fetch(url, { signal });
89
+ return response.json();
90
+ }
91
+ ```
92
+
93
+ ### Cancelling Parallel Work
94
+
95
+ When racing multiple steps, cancel the losers:
96
+
97
+ ```typescript lineNumbers
98
+ export async function firstResponder(urls: string[]) {
99
+ "use workflow";
100
+
101
+ const controller = new AbortController();
102
+
103
+ const result = await Promise.race( // [!code highlight]
104
+ urls.map((url) => fetchUrl(url, controller.signal)) // [!code highlight]
105
+ ); // [!code highlight]
106
+
107
+ controller.abort(); // Cancel remaining fetches // [!code highlight]
108
+
109
+ return result;
110
+ }
111
+
112
+ async function fetchUrl(url: string, signal: AbortSignal) {
113
+ "use step";
114
+ const response = await fetch(url, { signal });
115
+ return { url, data: await response.json() };
116
+ }
117
+ ```
118
+
119
+ ### Passing Signal Through a Pipeline
120
+
121
+ Pass the same signal to a chain of steps. Aborting cancels whichever step is currently running:
122
+
123
+ ```typescript lineNumbers
124
+ declare function splitIntoChunks(data: ArrayBuffer): ArrayBuffer[]; // @setup
125
+ declare function processChunk(chunk: ArrayBuffer): Promise<Uint8Array>; // @setup
126
+
127
+ export async function pipelineWorkflow(dataUrl: string) {
128
+ "use workflow";
129
+
130
+ const controller = new AbortController();
131
+
132
+ try {
133
+ const raw = await downloadData(dataUrl, controller.signal);
134
+ const transformed = await transformData(raw, controller.signal);
135
+ const result = await uploadData(transformed, controller.signal);
136
+ return result;
137
+ } catch (err) {
138
+ if (err instanceof Error && err.name === "AbortError") {
139
+ return { status: "cancelled" };
140
+ }
141
+ throw err;
142
+ }
143
+ }
144
+
145
+ async function downloadData(url: string, signal: AbortSignal) {
146
+ "use step";
147
+ const response = await fetch(url, { signal });
148
+ return response.arrayBuffer();
149
+ }
150
+
151
+ async function transformData(data: ArrayBuffer, signal: AbortSignal) {
152
+ "use step";
153
+
154
+ signal.throwIfAborted(); // [!code highlight]
155
+
156
+ const chunks = splitIntoChunks(data);
157
+ const results = [];
158
+
159
+ for (const chunk of chunks) {
160
+ signal.throwIfAborted(); // [!code highlight]
161
+ results.push(await processChunk(chunk));
162
+ }
163
+
164
+ return Buffer.concat(results);
165
+ }
166
+
167
+ async function uploadData(data: ArrayBuffer, signal: AbortSignal) {
168
+ "use step";
169
+ await fetch("https://storage.example.com/upload", {
170
+ method: "POST",
171
+ body: data,
172
+ signal,
173
+ });
174
+ return { status: "uploaded" };
175
+ }
176
+ ```
177
+
178
+ ### Step-Initiated Abort
179
+
180
+ 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:
181
+
182
+ ```typescript lineNumbers
183
+ export async function processWithQuotaCheck(userId: string, dataUrl: string) {
184
+ "use workflow";
185
+
186
+ const controller = new AbortController();
187
+
188
+ // Run the work and a quota monitor in parallel
189
+ const [result] = await Promise.all([ // [!code highlight]
190
+ processData(dataUrl, controller.signal), // [!code highlight]
191
+ monitorQuota(userId, controller), // [!code highlight]
192
+ ]); // [!code highlight]
193
+
194
+ return result;
195
+ }
196
+
197
+ async function processData(url: string, signal: AbortSignal) {
198
+ "use step";
199
+ const response = await fetch(url, { signal });
200
+ const data = await response.arrayBuffer();
201
+ // ... expensive processing ...
202
+ return { processed: true };
203
+ }
204
+
205
+ async function monitorQuota(userId: string, controller: AbortController) {
206
+ "use step";
207
+
208
+ // Poll quota status while the other step is running
209
+ while (!controller.signal.aborted) {
210
+ const quota = await fetch(`https://api.example.com/quota/${userId}`);
211
+ const { exceeded } = await quota.json();
212
+
213
+ if (exceeded) {
214
+ controller.abort("Quota exceeded"); // Cancels processData // [!code highlight]
215
+ return;
216
+ }
217
+
218
+ await new Promise((resolve) => setTimeout(resolve, 5000));
219
+ }
220
+ }
221
+ ```
222
+
223
+ ### User-Triggered Cancellation with Hooks
224
+
225
+ Combine hooks with abort controllers to let users cancel in-flight work from an external API:
226
+
227
+ ```typescript lineNumbers
228
+ import { createHook } from "workflow";
229
+
230
+ export async function userCancellableWorkflow(jobId: string) {
231
+ "use workflow";
232
+
233
+ using cancelHook = createHook<{ reason: string }>({
234
+ token: `cancel:${jobId}`,
235
+ });
236
+
237
+ const controller = new AbortController();
238
+ const workPromise = doExpensiveWork(controller.signal);
239
+
240
+ const result = await Promise.race([ // [!code highlight]
241
+ workPromise.then((data) => ({ status: "completed", data })),
242
+ cancelHook.then((payload) => { // [!code highlight]
243
+ controller.abort(); // [!code highlight]
244
+ return { status: "cancelled", reason: payload.reason };
245
+ }),
246
+ ]);
247
+
248
+ return result;
249
+ }
250
+
251
+ async function doExpensiveWork(signal: AbortSignal) {
252
+ "use step";
253
+ const response = await fetch("https://api.example.com/expensive", { signal });
254
+ return response.json();
255
+ }
256
+ ```
257
+
258
+ ```typescript title="app/api/cancel/route.ts" lineNumbers
259
+ import { resumeHook } from "workflow/api";
260
+
261
+ export async function POST(request: Request) {
262
+ const { jobId, reason } = await request.json();
263
+
264
+ await resumeHook(`cancel:${jobId}`, { reason });
265
+ return Response.json({ cancelled: true });
266
+ }
267
+ ```
268
+
269
+ ### How Steps Handle Abort
270
+
271
+ When an `AbortSignal` is aborted, the behavior depends on how the step uses it:
272
+
273
+ | Usage | Behavior on Abort |
274
+ |-------|-------------------|
275
+ | `fetch(url, { signal })` | Request is cancelled, throws `AbortError` |
276
+ | `signal.throwIfAborted()` | Throws the abort reason |
277
+ | `signal.aborted` check | Returns `true`, step can exit gracefully |
278
+ | `signal.addEventListener('abort', fn)` | Callback fires, step can clean up |
279
+ | Ignored | Step runs to completion (abort is cooperative) |
280
+
281
+ ### Abort Errors Skip Retries
282
+
283
+ 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.
284
+
285
+ 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`.
286
+
287
+ ```typescript lineNumbers
288
+ import { sleep } from "workflow";
289
+
290
+ export async function workflow() {
291
+ "use workflow";
292
+ const controller = new AbortController();
293
+
294
+ try {
295
+ const result = await Promise.race([
296
+ cancellableStep(controller.signal),
297
+ sleep("5s").then(() => null),
298
+ ]);
299
+ if (result === null) controller.abort();
300
+ return result;
301
+ } catch (err) {
302
+ // AbortError arrives as FatalError — no retries attempted // [!code highlight]
303
+ return { status: "cancelled" };
304
+ }
305
+ }
306
+
307
+ async function cancellableStep(signal: AbortSignal) {
308
+ "use step";
309
+ // If this throws AbortError, it's automatically wrapped in FatalError
310
+ const response = await fetch("https://api.example.com/slow", { signal });
311
+ return response.json();
312
+ }
313
+ ```
314
+
315
+ ### Passing AbortSignal as Workflow Input
316
+
317
+ You can pass an `AbortSignal` from external code into a workflow via `start()`:
318
+
319
+ {/* @skip-typecheck: myWorkflow is not declared, this is a conceptual snippet */}
320
+ ```typescript lineNumbers
321
+ import { start } from "workflow/api";
322
+
323
+ export async function POST(request: Request) {
324
+ const controller = new AbortController();
325
+ const run = await start(myWorkflow, [controller.signal]); // [!code highlight]
326
+
327
+ // Later, cancel from external code
328
+ controller.abort(); // [!code highlight]
329
+ }
330
+ ```
331
+
332
+ 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).
333
+
334
+ <Callout type="info">
335
+ 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.
336
+ </Callout>
337
+
338
+ ## Run Cancellation
339
+
340
+ 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.
341
+
342
+ ```typescript title="app/api/cancel-run/route.ts" lineNumbers
343
+ import { getRun } from "workflow/api";
344
+
345
+ export async function POST(request: Request) {
346
+ const { runId } = await request.json();
347
+
348
+ const run = getRun(runId);
349
+ await run.cancel(); // [!code highlight]
350
+
351
+ return Response.json({ cancelled: true });
352
+ }
353
+ ```
354
+
355
+ <Callout type="info">
356
+ 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.
357
+ </Callout>
358
+
359
+ When a run is cancelled:
360
+ - The workflow stops at its next suspension point (step call, hook await, or sleep)
361
+ - A `run_cancelled` event is recorded in the [event log](/docs/how-it-works/event-sourcing)
362
+ - All associated hooks are disposed and their tokens released
363
+ - Streams are closed
364
+
365
+ <Callout type="info">
366
+ 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`.
367
+ </Callout>
368
+
369
+ ## AbortSignal vs. Run Cancellation
370
+
371
+ | | AbortSignal | Run Cancellation |
372
+ |---|---|---|
373
+ | **Scope** | Individual operations within a step | Entire workflow run |
374
+ | **Triggered by** | Your code (`controller.abort()`) | External API (`run.cancel()`) |
375
+ | **Cooperative** | Yes — steps must check the signal | No — workflow stops at the next suspension point |
376
+ | **Granularity** | Can target specific steps or operations | All-or-nothing |
377
+ | **In-flight steps** | Aborted immediately if using the signal | Run to completion |
378
+
379
+ Use `AbortSignal` when you need fine-grained, in-flight cancellation of specific operations. Use run cancellation when you want to stop the entire workflow.
380
+
381
+ ## Best Practices
382
+
383
+ **Use `throwIfAborted()` before expensive work.** This throws the signal's abort reason if the signal is already aborted, preventing wasted compute:
384
+
385
+ ```typescript lineNumbers
386
+ async function expensiveStep(signal: AbortSignal) {
387
+ "use step";
388
+ signal.throwIfAborted(); // [!code highlight]
389
+ // ... expensive work ...
390
+ }
391
+ ```
392
+
393
+ **Handle abort errors in the workflow.** Abort errors arrive as `FatalError` (no retries) and can be caught with a standard try/catch:
394
+
395
+ ```typescript lineNumbers
396
+ declare function cancellableStep(signal: AbortSignal): Promise<void>; // @setup
397
+ import { FatalError } from "workflow";
398
+
399
+ export async function workflow() {
400
+ "use workflow";
401
+ const controller = new AbortController();
402
+
403
+ try {
404
+ await cancellableStep(controller.signal);
405
+ } catch (err) {
406
+ if (FatalError.is(err)) { // [!code highlight]
407
+ return { status: "cancelled" };
408
+ }
409
+ throw err;
410
+ }
411
+ }
412
+ ```
413
+
414
+ **Use `AbortSignal.any()` to combine signals:**
415
+
416
+ ```typescript lineNumbers
417
+ async function stepWithMultipleSignals(
418
+ userSignal: AbortSignal,
419
+ timeoutSignal: AbortSignal
420
+ ) {
421
+ "use step";
422
+
423
+ const combined = AbortSignal.any([userSignal, timeoutSignal]); // [!code highlight]
424
+ const response = await fetch("https://api.example.com/data", {
425
+ signal: combined,
426
+ });
427
+ return response.json();
428
+ }
429
+ ```
430
+
431
+ **Abort after a race:**
432
+
433
+ ```typescript lineNumbers
434
+ declare function stepA(signal: AbortSignal): Promise<string>; // @setup
435
+ declare function stepB(signal: AbortSignal): Promise<string>; // @setup
436
+
437
+ export async function workflow() {
438
+ "use workflow";
439
+ const controller = new AbortController();
440
+
441
+ const winner = await Promise.race([
442
+ stepA(controller.signal),
443
+ stepB(controller.signal),
444
+ ]);
445
+
446
+ controller.abort(); // Clean up whichever step is still running // [!code highlight]
447
+ return winner;
448
+ }
449
+ ```
450
+
451
+ This is safe even if both steps have already completed — aborting a finished operation is a no-op.
452
+
453
+ ## Related Documentation
454
+
455
+ - [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals
456
+ - [Serialization](/docs/foundations/serialization) — Understanding serializable types
457
+ - [Cookbook](/v5/cookbook) — Timeout, race, and other reliability patterns
458
+ - [Hooks](/docs/foundations/hooks) — Pausing workflows for external events
459
+ - [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
  ```
@@ -85,6 +85,35 @@ The key points:
85
85
  - You need the hook's `token` to resume it
86
86
  - The workflow will resume execution right where it left off
87
87
 
88
+ ### Checking for Token Conflicts
89
+
90
+ Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` for that:
91
+
92
+ ```typescript lineNumbers
93
+ import { createHook } from "workflow";
94
+
95
+ declare function processOrder(orderId: string): Promise<void>; // @setup
96
+
97
+ export async function orderWorkflow(orderId: string) {
98
+ "use workflow";
99
+
100
+ using hook = createHook({
101
+ token: `order:${orderId}`
102
+ });
103
+
104
+ const conflict = await hook.getConflict(); // [!code highlight]
105
+ if (conflict) { // [!code highlight]
106
+ // Another active run already owns this token.
107
+ return { dedupedTo: conflict.runId };
108
+ }
109
+
110
+ // The hook token is registered and reserved here.
111
+ await processOrder(orderId);
112
+ }
113
+ ```
114
+
115
+ Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies.
116
+
88
117
  ### Custom Tokens for Deterministic Hooks
89
118
 
90
119
  By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events.