workflow 5.0.0-beta.55 → 5.0.0-beta.57

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 (40) hide show
  1. package/dist/internal/errors.d.ts +1 -1
  2. package/dist/internal/errors.d.ts.map +1 -1
  3. package/dist/internal/errors.js +2 -2
  4. package/docs/api-reference/workflow/create-hook.mdx +77 -0
  5. package/docs/api-reference/workflow/create-webhook.mdx +1 -1
  6. package/docs/api-reference/workflow/define-hook.mdx +2 -0
  7. package/docs/api-reference/workflow-api/get-hook-by-token.mdx +2 -0
  8. package/docs/api-reference/workflow-api/register-lifecycle-hooks.mdx +6 -4
  9. package/docs/api-reference/workflow-api/start.mdx +1 -0
  10. package/docs/api-reference/workflow-errors/hook-conflict-error.mdx +1 -1
  11. package/docs/api-reference/workflow-errors/hook-force-claimed-error.mdx +70 -0
  12. package/docs/api-reference/workflow-errors/index.mdx +3 -0
  13. package/docs/api-reference/workflow-errors/meta.json +1 -0
  14. package/docs/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +7 -0
  15. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +7 -0
  16. package/docs/api-reference/workflow-runtime/world/storage.mdx +3 -1
  17. package/docs/changelog/batched-event-writes.mdx +2 -2
  18. package/docs/configuration/runtime-tuning.mdx +11 -2
  19. package/docs/configuration/worlds.mdx +5 -5
  20. package/docs/cookbook/advanced/child-workflows.mdx +3 -1
  21. package/docs/cookbook/advanced/upgrading-workflows.mdx +1 -1
  22. package/docs/cookbook/common-patterns/batching.mdx +2 -0
  23. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +2 -2
  24. package/docs/errors/hook-conflict.mdx +23 -0
  25. package/docs/errors/hook-force-claimed.mdx +96 -0
  26. package/docs/errors/index.mdx +21 -0
  27. package/docs/foundations/errors-and-retries.mdx +3 -1
  28. package/docs/foundations/hooks.mdx +34 -0
  29. package/docs/foundations/idempotency.mdx +44 -21
  30. package/docs/foundations/starting-workflows.mdx +2 -0
  31. package/docs/how-it-works/event-sourcing.mdx +7 -1
  32. package/docs/observability/lifecycle-hooks.mdx +6 -4
  33. package/docs/whats-new.mdx +4 -1
  34. package/docs/worlds/building-a-world.mdx +537 -0
  35. package/docs/worlds/local.mdx +129 -0
  36. package/docs/worlds/meta.json +10 -0
  37. package/docs/worlds/postgres.mdx +424 -0
  38. package/docs/worlds/upgrading-to-v5.mdx +162 -0
  39. package/docs/worlds/vercel.mdx +345 -0
  40. package/package.json +11 -11
@@ -0,0 +1,96 @@
1
+ ---
2
+ title: hook-force-claimed
3
+ description: Another workflow run took this hook's token over with experimental_force.
4
+ type: troubleshooting
5
+ summary: Handle HookForceClaimedError by letting the replaced run wrap up while the new owner receives the token's payloads.
6
+ prerequisites:
7
+ - /docs/foundations/hooks
8
+ related:
9
+ - /docs/api-reference/workflow/create-hook
10
+ - /docs/api-reference/workflow-errors/hook-force-claimed-error
11
+ ---
12
+
13
+ <CopyPrompt
14
+ text="Handle hook-force-claimed. Find every `createHook({ token })` whose token can be taken over by another run created with `experimental_force: true`. Wrap the `await hook` / `for await...of` in a try/catch, check `HookForceClaimedError.is(error)` from `workflow/errors`, and make the replaced run finish cleanly: persist or return anything it still owes (using `error.claimedByRunId` if the new owner should be told), then exit instead of retrying the await. If the run itself should be the one taking over, add `experimental_force: true` to its `createHook()` call and make sure the token is explicit. Verify that starting a second run with the same token wakes the first, that its await rejects with HookForceClaimedError, and that resumeHook() payloads sent after the takeover reach the second run."
15
+ />
16
+
17
+ This error is thrown to a workflow run that was waiting on a hook when another run created a hook with the same token and [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds). The token moved to that run; this run's hook is disposed and will not receive anything further.
18
+
19
+ ## Error message
20
+
21
+ ```text
22
+ Hook token "<token>" was force-claimed by another workflow (run "<runId>")
23
+ ```
24
+
25
+ ## Why this happens
26
+
27
+ A hook token is owned by one active run at a time. Normally a second run asking for a held token gets [`HookConflictError`](/docs/errors/hook-conflict). With `experimental_force`, the second run takes the token instead:
28
+
29
+ 1. The holder's hook is disposed, and a `hook_disposed` event naming the new owner is written to the holder's event log.
30
+ 2. The holder is woken. If it was awaiting the hook, that promise rejects with `HookForceClaimedError`. Payloads that arrived before the takeover are still delivered first; a `for await...of` loop drains them and then throws.
31
+ 3. Every `resumeHook()` for the token from then on reaches the new owner, including a delivery that was already in flight. Senders are never told the token moved. The one exception is a `resumeWebhook()` request caught inside the handoff window: its body can be sent only once, so it fails with a retryable error instead of being redirected, and the sender's retry reaches the new owner.
32
+
33
+ This is expected behavior, not a failure of the replaced run. It usually means a newer run for the same subject (a channel, a conversation, a device) has started and is meant to take over.
34
+
35
+ ## Handling the takeover
36
+
37
+ Catch the error where the hook is awaited and let the run finish. The error tells you which run replaced this one:
38
+
39
+ ```typescript lineNumbers
40
+ import { createHook } from "workflow";
41
+ import { HookForceClaimedError } from "workflow/errors";
42
+
43
+ declare function processMessage(message: { text: string }): Promise<void>; // @setup
44
+ declare function flushDraft(channelId: string): Promise<void>; // @setup
45
+
46
+ export async function channelWorkflow(channelId: string) {
47
+ "use workflow";
48
+
49
+ const hook = createHook<{ text: string }>({
50
+ token: `channel:${channelId}`,
51
+ experimental_force: true,
52
+ });
53
+
54
+ try {
55
+ for await (const message of hook) {
56
+ await processMessage(message);
57
+ }
58
+ } catch (error) {
59
+ if (HookForceClaimedError.is(error)) { // [!code highlight]
60
+ // A newer run owns the channel now. Hand off and stop.
61
+ await flushDraft(channelId);
62
+ return { replacedBy: error.claimedByRunId };
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+ ```
68
+
69
+ Anything the replaced run still needs to publish should happen in this branch. Do not create another hook with the same token here unless this run really should take the token back: runs forcing the same token converge on whichever registered last, and every other one receives this error again.
70
+
71
+ ## Several runs forcing the same token
72
+
73
+ Any number of runs can force the same token at once without leaving the token or the runs in a bad state. The takeovers chain: each run that loses the token receives `HookForceClaimedError` naming the run that took it, exactly one run ends up owning the token, and a run that crashes halfway through its own takeover is completed by the next request for the token. Which of several simultaneous claimers wins is not defined, so start them in order if the order matters. If a run should fall back rather than keep fighting for the token, catch `HookForceClaimedError` and exit instead of forcing again.
74
+
75
+ ## Runs that cannot be taken from
76
+
77
+ A run that was started at a Workflow spec version below 8 (an older SDK release, a Python SDK run, or a deployment with `WORKFLOW_SEALED_LOG=0`) would never learn that its hook was disposed. The World declines to take its token: the forced hook rejects with the ordinary [`HookConflictError`](/docs/errors/hook-conflict), whose `conflictingRunId` names that run, and the run keeps receiving its payloads. Handle it like any other conflict, for example by [delegating to the active run](/docs/errors/hook-conflict#delegate-to-the-active-run). A finished run holding a retained token is taken over at any version.
78
+
79
+ ## Deciding who takes over
80
+
81
+ - **The newest run should win.** Create the hook with `experimental_force: true` in the workflow that starts on each new deployment, restart, or session. Older runs get `HookForceClaimedError` and exit.
82
+ - **The first run should win.** Do not use `experimental_force`. Later runs get `HookConflictError` and can [delegate to the active run](/docs/errors/hook-conflict#delegate-to-the-active-run).
83
+ - **Both runs should keep working.** Give them different tokens.
84
+
85
+ ## When it is thrown
86
+
87
+ - To `await hook` and to `for await...of` once buffered payloads are drained.
88
+ - On every later `await` of the same hook. `hook.getConflict()` resolves with `null`: the hook was registered, it just no longer holds the token.
89
+ - Never to a run that has already finished. A token retained after the run ended with `experimental_minRetention` is taken over silently.
90
+
91
+ ## Related
92
+
93
+ - [Hooks](/docs/foundations/hooks) - Taking over a token from another run
94
+ - [createHook](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) - The `experimental_force` option
95
+ - [HookForceClaimedError](/docs/api-reference/workflow-errors/hook-force-claimed-error) - Error reference
96
+ - [hook-conflict](/docs/errors/hook-conflict) - The default behavior without `experimental_force`
@@ -9,6 +9,27 @@ related:
9
9
 
10
10
  Fix common mistakes when creating and executing workflows in the **Workflow SDK**.
11
11
 
12
+ ## Error codes
13
+
14
+ When a workflow run fails, its `errorCode` identifies the failure category. You can read it from [`WorkflowRunFailedError`](/docs/api-reference/workflow-errors/workflow-run-failed-error), the Workflow CLI's `error.code` field, or the `workflow.error.code` OpenTelemetry span attribute.
15
+
16
+ | Code | Description |
17
+ | --- | --- |
18
+ | `USER_ERROR` | An error thrown by workflow or step code, including an unhandled step failure or `FatalError`. |
19
+ | `RUNTIME_ERROR` | The Workflow runtime encountered an internal error, such as missing runtime data or an invariant failure. Persistent occurrences should be [reported](https://github.com/vercel/workflow/issues). |
20
+ | [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log) | The run's event log cannot be replayed because it contains orphaned or mismatched events, a gap, or an unreadable stored payload. |
21
+ | [`REPLAY_DIVERGENCE`](/docs/errors/replay-divergence) | One replay could not consume the event log deterministically. The runtime automatically retries before treating repeated divergence as a corrupted event log. |
22
+ | `MAX_DELIVERIES_EXCEEDED` | The run exceeded the maximum number of queue deliveries, usually because a persistent failure kept causing redelivery. |
23
+ | `MAX_EVENTS_EXCEEDED` | The run reached the World's per-run event limit. Split unbounded work into child workflows before reaching the limit. |
24
+ | `REPLAY_TIMEOUT` | Workflow replay exceeded the configured duration limit. This measures workflow execution and event-log replay between step boundaries, not time spent inside step functions. |
25
+ | `STREAM_ERROR` | Workflow stream infrastructure failed while reading or writing data. This is an SDK or backend failure rather than an error in workflow code. |
26
+ | `WORLD_CONTRACT_ERROR` | A World returned data that violated the SDK contract and could not be retried safely. This usually indicates a World implementation bug. |
27
+ | [`DEPLOYMENT_MISMATCH`](/docs/errors/deployment-mismatch) | A run was delivered to a deployment other than the one it is pinned to, and automatic re-routing did not recover it. |
28
+
29
+ For guidance on catching failures, retry behavior, and inspecting `WorkflowRunFailedError`, see [Errors and retries](/docs/foundations/errors-and-retries).
30
+
31
+ ## Troubleshooting guides
32
+
12
33
  <AutoCards />
13
34
 
14
35
  ## Learn more
@@ -201,12 +201,14 @@ try {
201
201
  | Code | Meaning |
202
202
  | --- | --- |
203
203
  | `USER_ERROR` | An error thrown in your workflow or step code (including propagated step failures like `FatalError`) |
204
- | `MAX_EVENTS_EXCEEDED` | The run reached the World's per-run event ceiling (25,000 on the Local and Vercel Worlds). Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows); see [Limits](/docs/configuration/runtime-tuning#limits) |
204
+ | `MAX_EVENTS_EXCEEDED` | The run reached the World's per-run event ceiling (for the Vercel World, see [Workflow run limits](https://vercel.com/docs/workflows/pricing#workflow-run-limits)). Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows) well before the ceiling; see [Vercel World limits](/worlds/vercel#per-run-limits) and [Limits](/docs/configuration/runtime-tuning#limits) |
205
205
  | `MAX_DELIVERIES_EXCEEDED` | The run exceeded the maximum number of queue deliveries |
206
206
  | `REPLAY_TIMEOUT` | A workflow replay exceeded the maximum allowed duration |
207
207
  | `REPLAY_DIVERGENCE` | A replay could not consume the event log deterministically, usually because of non-deterministic workflow code. |
208
208
  | `CORRUPTED_EVENT_LOG` | The event log cannot be replayed: it contains orphaned or mismatched events, or one of its stored payloads is no longer readable from the World's storage. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) |
209
+ | `STREAM_ERROR` | Workflow stream infrastructure failed while reading or writing data. This is an SDK or backend failure rather than an error in workflow code |
209
210
  | `WORLD_CONTRACT_ERROR` | A World response violated the SDK contract; points at a World implementation bug |
211
+ | `DEPLOYMENT_MISMATCH` | The run was delivered to a deployment other than the one it is pinned to, and automatic re-routing did not recover it |
210
212
  | `RUNTIME_ERROR` | An internal runtime error. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) |
211
213
 
212
214
  <Callout type="info">
@@ -240,6 +240,40 @@ hook.dispose(); // Manually release the token
240
240
  After disposal, the hook will no longer receive events and the async iterator will stop yielding values.
241
241
  </Callout>
242
242
 
243
+ ### Taking over a token from another run
244
+
245
+ Disposing early only works when the run holding the token cooperates. When the newest run should own a token regardless, such as a redeployed bot that must replace the run still waiting on a channel, pass `experimental_force` and let the runtime perform the handoff:
246
+
247
+ ```typescript lineNumbers
248
+ import { createHook } from "workflow";
249
+ import { HookForceClaimedError } from "workflow/errors";
250
+
251
+ declare function processMessage(message: { text: string }): Promise<void>; // @setup
252
+
253
+ export async function channelWorkflow(channelId: string) {
254
+ "use workflow";
255
+
256
+ const hook = createHook<{ text: string }>({
257
+ token: `channel:${channelId}`,
258
+ experimental_force: true, // [!code highlight]
259
+ });
260
+
261
+ try {
262
+ for await (const message of hook) {
263
+ await processMessage(message);
264
+ }
265
+ } catch (error) {
266
+ if (HookForceClaimedError.is(error)) { // [!code highlight]
267
+ // A newer run for this channel took the token. Wrap up and exit.
268
+ return { replacedBy: error.claimedByRunId };
269
+ }
270
+ throw error;
271
+ }
272
+ }
273
+ ```
274
+
275
+ The run that held the token is woken and its `await hook` (or `for await...of`) rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error) once it has drained the payloads it received before the takeover. Every `resumeHook()` for the token from then on reaches the new run, including one that was already in flight, so senders never notice the handoff. Several runs forcing the same token at once chain in the same way and always end with exactly one owner. A run started at a spec version below 8 cannot be taken from; the forced hook then gets an ordinary `HookConflictError`. See [`createHook()`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) for the full semantics.
276
+
243
277
  ## Understanding webhooks
244
278
 
245
279
  Hooks require you to manually handle HTTP requests and route them to workflows. **Webhooks** provide a higher-level abstraction built on top of hooks that:
@@ -61,7 +61,7 @@ Because [hooks](/docs/foundations/hooks) already ensure globally unique active t
61
61
 
62
62
  Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with a `Run` handle for the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload.
63
63
 
64
- The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook; awaiting `getConflict()` suspends the workflow to commit the registration.
64
+ The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook; awaiting `getConflict()` suspends the workflow to commit the registration. Check it before calling any step: steps the workflow calls before it suspends are started alongside the hook's registration, so a duplicate run that only learns of the conflict later (for example, by awaiting the hook and letting `HookConflictError` end the run) may already have started them. See [Registering a hook before a step uses it](/docs/api-reference/workflow/create-hook#registering-a-hook-before-a-step-uses-it).
65
65
 
66
66
  ```typescript lineNumbers
67
67
  import { createHook } from "workflow";
@@ -242,7 +242,7 @@ export async function processOrder(orderId: string, confirmed: boolean) {
242
242
  }
243
243
  ```
244
244
 
245
- **Supersede the owner.** Without minimum retention, cancel the active run, then claim the released token. The retry loop covers the window where cancellation cleanup has not propagated yet:
245
+ **Supersede the owner.** Create the Hook with [`experimental_force: true`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) so the newest run takes the token over from the active owner. This also works for a finished run holding the token under `experimental_minRetention`:
246
246
 
247
247
  ```typescript lineNumbers
248
248
  import { createHook } from "workflow";
@@ -253,31 +253,54 @@ declare function chargeOrder(orderId: string): Promise<void>; // @setup
253
253
  export async function processOrderNewestWins(orderId: string) {
254
254
  "use workflow";
255
255
 
256
- const token = `order:${orderId}`;
257
-
258
- for (let attempt = 0; attempt < 3; attempt++) {
259
- using request = createHook<OrderRequest>({ token });
260
-
261
- const conflict = await request.getConflict();
262
- if (!conflict) {
263
- // Token claimed: this run is now the owner.
264
- const { confirmed } = await request;
265
- if (confirmed) {
266
- await chargeOrder(orderId);
267
- }
268
- return { status: "processed" as const };
269
- }
256
+ using request = createHook<OrderRequest>({
257
+ token: `order:${orderId}`,
258
+ experimental_force: true, // [!code highlight]
259
+ });
270
260
 
271
- await conflict.cancel(); // [!code highlight]
261
+ // This run now owns the token.
262
+ const { confirmed } = await request;
263
+ if (confirmed) {
264
+ await chargeOrder(orderId);
272
265
  }
266
+ return { status: "processed" as const };
267
+ }
268
+ ```
269
+
270
+ The previous owner's `await request` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), and every `resumeHook()` for the token reaches the new run from then on. Any run of this workflow can be superseded by a later one, so catch the error and exit cleanly:
271
+
272
+ ```typescript lineNumbers
273
+ import { createHook } from "workflow";
274
+ import { HookForceClaimedError } from "workflow/errors";
273
275
 
274
- throw new Error(`Could not claim ${token} after canceling the owner`);
276
+ type OrderRequest = { confirmed: boolean };
277
+ declare function chargeOrder(orderId: string): Promise<void>; // @setup
278
+
279
+ export async function processOrderNewestWins(orderId: string) {
280
+ "use workflow";
281
+
282
+ using request = createHook<OrderRequest>({
283
+ token: `order:${orderId}`,
284
+ experimental_force: true,
285
+ });
286
+
287
+ try {
288
+ const { confirmed } = await request;
289
+ if (confirmed) {
290
+ await chargeOrder(orderId);
291
+ }
292
+ return { status: "processed" as const };
293
+ } catch (error) {
294
+ if (HookForceClaimedError.is(error)) { // [!code highlight]
295
+ // A newer run for this order owns the token now. Stop here.
296
+ return { status: "superseded" as const, runId: error.claimedByRunId }; // [!code highlight]
297
+ }
298
+ throw error;
299
+ }
275
300
  }
276
301
  ```
277
302
 
278
- <Callout type="warn">
279
- This pattern does not work with `experimental_minRetention`: canceling the old run does not make its token available early.
280
- </Callout>
303
+ A run started at a Workflow spec version below 8, including runs started by older SDK releases, can't be taken from. In that case the forced Hook rejects with `HookConflictError`, as it would without `experimental_force`.
281
304
 
282
305
  If duplicate requests should only reuse the active run without sending data, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) as an advisory pre-check before calling `start()`. The workflow should still check `hook.getConflict()`, because the lookup and `start()` are not atomic.
283
306
 
@@ -102,6 +102,8 @@ export async function parentWorkflow(inputValue: number) {
102
102
 
103
103
  When you call `start()` inside a workflow function, it automatically executes through an internal step to maintain deterministic replay. The returned `Run` object works as it does outside workflows. Properties such as `.runId`, `.status`, and `.returnValue`, and methods such as `.cancel()`, are all available. Each property access or method call executes as a separate step.
104
104
 
105
+ If the child run fails or is canceled, `await childRun.returnValue` throws [`WorkflowRunFailedError`](/docs/api-reference/workflow-errors/workflow-run-failed-error) or [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) on the first attempt.
106
+
105
107
  <Callout type="info">
106
108
  Inside workflow functions, each `Run` property access (e.g., `run.status`, `run.returnValue`) triggers a workflow step. This means each access is recorded in the event log and replayed deterministically.
107
109
  </Callout>
@@ -188,7 +188,7 @@ Events are categorized by the entity type they affect. Each event contains metad
188
188
  | `hook_created` | Creates a new hook in `active` state. Contains the hook token and optional metadata. |
189
189
  | `hook_conflict` | Records that hook creation failed because another run owns the token. Contains the token and, for current worlds, the owner's run ID. The hook is not created: `hook.getConflict()` resolves with the conflicting run, and awaiting the hook payload rejects with a `HookConflictError`. |
190
190
  | `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads. |
191
- | `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. |
191
+ | `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. When another run takes the token over with [`experimental_force`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds), the event names the new owner in `forceClaimedBy`, and the token moves to that run instead of being released. |
192
192
 
193
193
  ### Wait events
194
194
 
@@ -203,6 +203,12 @@ Events are categorized by the entity type they affect. Each event contains metad
203
203
  |-------|-------------|
204
204
  | `noop` | Seals an abandoned log position (`specVersion` 7 and above). Only the backend writes this event; the create endpoints reject it. See [Sealed positions](#sealed-positions-noop-events). |
205
205
 
206
+ ### How fast a log grows
207
+
208
+ Counting the events above is how you size a run. A step that succeeds on the first attempt contributes three events; a retry adds another `step_started`, preceded by a `step_retrying` when the World emits one; a sleep contributes two, and a hook at least two.
209
+
210
+ A run's log is capped — the [Vercel World](/worlds/vercel#per-run-limits) fails anything past its ceiling with `MAX_EVENTS_EXCEEDED`. Replay reads the whole log, so a run gets slower as it grows and is worth splitting well before it fails: split into [child workflows](/cookbook/advanced/child-workflows) once a run would grow past a few thousand events.
211
+
206
212
  ## Terminal states
207
213
 
208
214
  Terminal states represent the end of an entity's lifecycle. Once an entity reaches a terminal state, no further events can transition it to another state.
@@ -39,21 +39,23 @@ export async function register() {
39
39
 
40
40
  Keep the dynamic `workflow/api` import inside the `NEXT_RUNTIME === "nodejs"` guard. Next.js also compiles `instrumentation.ts` for the Edge runtime. A top-level static import pulls Node.js-only dependencies into that compilation and breaks webpack Edge builds, even if the registration call is guarded.
41
41
 
42
- `registerLifecycleHooks` returns an unregister function. You can register multiple hook sets, and handlers run in registration order.
42
+ `registerLifecycleHooks` returns an unregister function. You can register multiple hook sets, and handlers run in registration order. Registrations are not deduplicated: register each hook set once per process, and call its unregister function before registering it again during hot reload or module re-evaluation. Otherwise, repeated registrations invoke the same handler multiple times for each transition.
43
43
 
44
44
  ## Handler parameters
45
45
 
46
46
  Both handlers receive a `workflowName` string and the [`Run`](/docs/api-reference/workflow-api/get-run) instance for the transitioned run. `workflowName` is the machine-readable workflow identifier, such as `workflow//./src/workflows/order//processOrder`. Use this parameter to filter runs without a backend read. `run.runId` is also available without a read.
47
47
 
48
- The `Run` instance hydrates lazily. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. Lazy access defers those reads; it does not make them free.
48
+ The `Run` instance hydrates lazily. Accessors such as `run.workflowName`, `run.status`, and `run.returnValue` still fetch from the backend when used. In particular, `workflowName` is a string, while `run.workflowName` is a `Promise<string>`. Lazy access defers those reads; it does not make them free.
49
49
 
50
- `onRunFailed` additionally receives the failure as a `WorkflowRunFailedError`, the same shape `run.returnValue` rejects with:
50
+ `onRunFailed` additionally receives a `WorkflowRunFailedError` hydrated for reporting. Unlike `run.returnValue`, it defers readable stream I/O and uses persisted abort snapshots:
51
51
 
52
52
  - `error.errorCode`: the failure classification (`USER_ERROR`, `RUNTIME_ERROR`, `MAX_DELIVERIES_EXCEEDED`, and more). See [error codes](/docs/errors) for the full list.
53
- - `error.cause`: the thrown value hydrated from the persisted error data, with registered Error subclass identity, message, stack, and cause chain preserved. Streamed values load lazily when consumed, and abort signals reflect their persisted state without live subscriptions. If hydration fails, the cause is a generic `Error`, matching `run.returnValue`'s fallback. Any JavaScript value can be thrown, so this is typed `unknown`.
53
+ - `error.cause`: the thrown value hydrated from the persisted error data, with registered Error subclass identity, message, stack, and cause chain preserved. Readable streams load lazily when consumed, and abort signals reflect their persisted state without live subscriptions. Writable streams retain their normal forwarding pipe and lock-polling setup during hydration. If hydration fails, the cause is a generic `Error`, matching `run.returnValue`'s fallback. Any JavaScript value can be thrown, so this is typed `unknown`.
54
54
 
55
55
  In `onRunFailed`, `run.returnValue` rejects because the run failed. Use `error.cause` to inspect or report the thrown value instead of awaiting `run.returnValue`.
56
56
 
57
+ The invocation's `waitUntil` scope includes background stream operations from the hydrated cause, even after a handler returns or throws. Close or release stream reader and writer locks when finished so that work can settle. Await other asynchronous reporting work in your handler, such as `Sentry.flush()` below, to keep it in the same lifetime scope.
58
+
57
59
  ## Reporting failed runs to Sentry
58
60
 
59
61
  This example reports failures for workflows named `processOrder`. Remove the filter to report failures from all workflows.
@@ -35,6 +35,8 @@ The largest change in v5 has no API surface: the runtime does far less work per
35
35
 
36
36
  **The workflow VM is kept alive across inline steps.** Within one invocation, a step- or attribute-driven suspension keeps the live VM and hydrated state, including when hooks or waits are open or created at the same boundary, so the next iteration appends only the newly written events instead of rebuilding the sandbox and replaying the whole log. Step inputs made of plain data or standard built-ins keep this fast path; see [`WORKFLOW_RETAINED_VM`](/docs/configuration/runtime-tuning#workflow_retained_vm).
37
37
 
38
+ **Replay no longer downloads recorded step inputs.** Replay recomputes step arguments by re-running workflow code, so it reads the event log without the `input` of each step. For workflows that pass growing state into their steps, this removes the part of the replay transfer that grew quadratically with the run. Worlds opt in through [`resolveData: 'skip-step-inputs'`](/docs/api-reference/workflow-runtime/world/storage#eventslist), and Vercel Workflows supports it.
39
+
38
40
  **Resuming a hook takes one round trip instead of two.** `resumeHook()` writes the `hook_received` event and dispatches the queue message concurrently, with a `(runId, resumeId)` dedup constraint keeping the two writers converging on exactly one event. See [Resilient hook resumption](/docs/changelog/resilient-resume).
39
41
 
40
42
  **Payloads are compressed** before they are encrypted and sent to the API. Repetitive payloads compress heavily; AI token streams average around 80% smaller. That is less stored data and less to move over the network.
@@ -141,6 +143,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and
141
143
  ### Also new in 5.0
142
144
 
143
145
  - **`start()` from inside a workflow.** Spawn a child run or hand off to a new run directly in a workflow function, without wrapping it in a step. See [Starting workflows](/docs/foundations/starting-workflows).
146
+ - **Hook token takeover.** Pass `experimental_force: true` to [`createHook()`](/docs/api-reference/workflow/create-hook#take-over-a-token-another-run-holds) when the newest run should own a token another run still holds. The previous owner is woken and its `await hook` rejects with [`HookForceClaimedError`](/docs/api-reference/workflow-errors/hook-force-claimed-error), while `resumeHook()` callers, including one already in flight, are routed to the new owner without noticing. Supported by all three first-party Worlds.
144
147
  - **Stronger hook coordination.** `hook.getConflict()` resolves with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run) rather than a bare `{ runId }`, so a duplicate can `await conflict.status`, `await conflict.returnValue`, or `await conflict.cancel()` directly. See [Run idempotency](/docs/foundations/idempotency#run-idempotency).
145
148
  - **Support for more frameworks.** [React Router](/docs/getting-started/react-router) (v7 and v8, via Nitro) and [NestJS](/docs/getting-started/nestjs) are now supported.
146
149
  - **A misrouted delivery no longer fails a run.** Runs are pinned to the deployment that created them. A delivery that arrives at a different deployment is now re-routed to the pinned one with backoff instead of failing, and only gives up with the new [`DEPLOYMENT_MISMATCH`](/docs/errors/deployment-mismatch) error once the recovery budget is spent. Nothing executes on the wrong deployment while this happens. In 4.x the same situation surfaced as an unexplained decryption failure.
@@ -165,7 +168,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and
165
168
  | Default trace mode is `linked` | Update dashboards that assume one trace per run, or set `WORKFLOW_TRACE_MODE=continuous`. |
166
169
  | The event-creation precondition guard is gone | `WORKFLOW_PRECONDITION_GUARD` no longer exists, and no World in the SDK rejects a write for a stale snapshot. Remove the variable if you set it. A replay that is behind now learns what it missed from the write it makes next instead of from a rejection, and [`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error) remains only for a custom World that would still rather refuse. |
167
170
  | Event IDs are slot numbers, not ULIDs | An event ID is now its 1-based position in the run's log (`evnt_00000000000000000000000042`). It is unique only within a run, so pair it with the `runId` as a key, and it carries no timestamp: decoding one yields the Unix epoch rather than a creation time, so read `createdAt` off the event instead. Other entity IDs are unchanged. See [Event IDs](/docs/how-it-works/event-sourcing#event-ids). |
168
- | A per-run event limit is enforced | The World supplies the ceiling, which is 25,000 events on the Local and Vercel Worlds. A run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows). As a fallback, you can tune the ceiling. See [Limits](/docs/configuration/runtime-tuning#limits). |
171
+ | A per-run event limit is enforced | The World supplies the ceiling; for the Vercel World see [Workflow run limits](https://vercel.com/docs/workflows/pricing#workflow-run-limits). A run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows). [`WORKFLOW_MAX_EVENTS`](/docs/configuration/runtime-tuning#workflow_max_events) tunes the ceiling on the Local and Postgres Worlds; the Vercel World's is service-owned and the variable does not override it. |
169
172
  | Stream writes flush the first chunk immediately | The leading-edge flush window defaults to `0` instead of 10ms. Restore a window with `streamFlushIntervalMs` or `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`. |
170
173
  | The workflow sandbox is stricter about nondeterminism | `WeakRef`, `FinalizationRegistry`, `Atomics.waitAsync`, and async `WebAssembly` compilation are no longer available inside workflow functions, and `crypto.subtle.digest` computes synchronously (same results, deterministic timing). Move code that needs them into a step. |
171
174
  | `Date()` without `new` returns a string inside workflow functions | This matches the language spec, and 4.x returned a `Date` object. Use `new Date()` where you need the object. Subclassing `Date` now works, so libraries like `TZDate` keep their identity across the sandbox boundary. |