@workflow/core 5.0.0-beta.35 → 5.0.0-beta.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +13 -1
- package/dist/classify-error.d.ts.map +1 -1
- package/dist/classify-error.js +5 -2
- package/dist/create-hook.d.ts +41 -22
- package/dist/create-hook.d.ts.map +1 -1
- package/dist/create-hook.js +1 -1
- package/dist/describe-error.d.ts.map +1 -1
- package/dist/describe-error.js +12 -1
- package/dist/encryption.d.ts +15 -2
- package/dist/encryption.d.ts.map +1 -1
- package/dist/encryption.js +28 -7
- package/dist/flushable-stream.d.ts +55 -0
- package/dist/flushable-stream.d.ts.map +1 -1
- package/dist/flushable-stream.js +106 -4
- package/dist/global.d.ts +2 -0
- package/dist/global.d.ts.map +1 -1
- package/dist/global.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/log-format.js +3 -3
- package/dist/private.d.ts +82 -50
- package/dist/private.d.ts.map +1 -1
- package/dist/private.js +149 -22
- package/dist/replay-payload-cache.d.ts +57 -0
- package/dist/replay-payload-cache.d.ts.map +1 -0
- package/dist/replay-payload-cache.js +143 -0
- package/dist/runtime/constants.d.ts +11 -0
- package/dist/runtime/constants.d.ts.map +1 -1
- package/dist/runtime/constants.js +26 -1
- package/dist/runtime/count-step-started-events.d.ts +52 -0
- package/dist/runtime/count-step-started-events.d.ts.map +1 -0
- package/dist/runtime/count-step-started-events.js +72 -0
- package/dist/runtime/get-port-lazy.js +4 -4
- package/dist/runtime/helpers.d.ts +47 -16
- package/dist/runtime/helpers.d.ts.map +1 -1
- package/dist/runtime/helpers.js +97 -28
- package/dist/runtime/resume-hook.d.ts +6 -3
- package/dist/runtime/resume-hook.d.ts.map +1 -1
- package/dist/runtime/resume-hook.js +180 -58
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +3 -3
- package/dist/runtime/runs.d.ts.map +1 -1
- package/dist/runtime/runs.js +5 -3
- package/dist/runtime/start.d.ts.map +1 -1
- package/dist/runtime/start.js +65 -18
- package/dist/runtime/step-executor.d.ts +48 -8
- package/dist/runtime/step-executor.d.ts.map +1 -1
- package/dist/runtime/step-executor.js +90 -20
- package/dist/runtime/suspension-handler.js +3 -3
- package/dist/runtime/world.d.ts +2 -7
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js +3 -10
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +398 -184
- package/dist/sealed-box.d.ts +167 -0
- package/dist/sealed-box.d.ts.map +1 -0
- package/dist/sealed-box.js +571 -0
- package/dist/serialization/client.d.ts +3 -3
- package/dist/serialization/client.d.ts.map +1 -1
- package/dist/serialization/client.js +1 -1
- package/dist/serialization/codec.d.ts +6 -4
- package/dist/serialization/codec.d.ts.map +1 -1
- package/dist/serialization/codec.js +1 -1
- package/dist/serialization/encryption.d.ts +118 -17
- package/dist/serialization/encryption.d.ts.map +1 -1
- package/dist/serialization/encryption.js +131 -15
- package/dist/serialization/step.d.ts +4 -4
- package/dist/serialization/step.d.ts.map +1 -1
- package/dist/serialization/step.js +2 -2
- package/dist/serialization/types.d.ts +20 -0
- package/dist/serialization/types.d.ts.map +1 -1
- package/dist/serialization/types.js +11 -1
- package/dist/serialization-format.d.ts +44 -4
- package/dist/serialization-format.d.ts.map +1 -1
- package/dist/serialization-format.js +66 -9
- package/dist/serialization.d.ts +61 -21
- package/dist/serialization.d.ts.map +1 -1
- package/dist/serialization.js +556 -200
- package/dist/set-attributes.d.ts +2 -7
- package/dist/set-attributes.d.ts.map +1 -1
- package/dist/set-attributes.js +1 -6
- package/dist/step/context-storage.d.ts +2 -2
- package/dist/step/context-storage.d.ts.map +1 -1
- package/dist/step/context-storage.js +2 -2
- package/dist/step/writable-stream.d.ts.map +1 -1
- package/dist/step/writable-stream.js +14 -3
- package/dist/step.d.ts.map +1 -1
- package/dist/step.js +87 -22
- package/dist/symbols.d.ts +26 -0
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +27 -1
- package/dist/telemetry/semantic-conventions.d.ts +4 -4
- package/dist/telemetry/semantic-conventions.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/dist/vm/index.d.ts.map +1 -1
- package/dist/vm/index.js +76 -9
- package/dist/workflow/abort-controller.d.ts +1 -1
- package/dist/workflow/abort-controller.d.ts.map +1 -1
- package/dist/workflow/abort-controller.js +38 -3
- package/dist/workflow/hook.d.ts.map +1 -1
- package/dist/workflow/hook.js +85 -30
- package/dist/workflow/index.d.ts +1 -1
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +2 -2
- package/dist/workflow/set-attributes.d.ts +0 -9
- package/dist/workflow/set-attributes.d.ts.map +1 -1
- package/dist/workflow/set-attributes.js +1 -6
- package/dist/workflow/sleep.d.ts.map +1 -1
- package/dist/workflow/sleep.js +21 -9
- package/dist/workflow.d.ts +14 -11
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +37 -92
- package/docs/api-reference/create-hook.mdx +43 -2
- package/docs/api-reference/define-hook.mdx +26 -24
- package/docs/api-reference/fatal-error.mdx +29 -7
- package/docs/api-reference/fetch.mdx +3 -4
- package/docs/api-reference/set-attributes.mdx +0 -4
- package/docs/api-reference/sleep.mdx +1 -1
- package/docs/foundations/hooks.mdx +1 -1
- package/docs/foundations/idempotency.mdx +16 -9
- package/docs/how-it-works/cancellation.mdx +2 -2
- package/docs/how-it-works/code-transform.mdx +19 -15
- package/docs/how-it-works/encryption.mdx +3 -3
- package/docs/how-it-works/event-sourcing.mdx +6 -6
- package/docs/how-it-works/framework-integrations.mdx +96 -342
- package/package.json +6 -6
- package/dist/runtime/step-handler.d.ts +0 -2
- package/dist/runtime/step-handler.d.ts.map +0 -1
- package/dist/runtime/step-handler.js +0 -839
- package/dist/step-hydration-cache.d.ts +0 -148
- package/dist/step-hydration-cache.d.ts.map +0 -1
- package/dist/step-hydration-cache.js +0 -171
|
@@ -66,7 +66,7 @@ export default Hook;`}
|
|
|
66
66
|
|
|
67
67
|
The returned `Hook` object also implements `AsyncIterable<T>`, which allows you to iterate over incoming payloads using `for await...of` syntax.
|
|
68
68
|
|
|
69
|
-
Use `hook.getConflict()` to check whether the hook token is already claimed by another
|
|
69
|
+
Use `hook.getConflict()` to check whether the hook token is already claimed by another hook, including one kept reserved after its run ends, 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).
|
|
70
70
|
|
|
71
71
|
## Examples
|
|
72
72
|
|
|
@@ -143,12 +143,53 @@ async function processOrder(orderId: string) {
|
|
|
143
143
|
|
|
144
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
145
|
|
|
146
|
-
On a conflict, the resolved value is a `Run` handle for the run that
|
|
146
|
+
On a conflict, the resolved value is a `Run` handle for the run that 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
147
|
|
|
148
148
|
<Callout type="info">
|
|
149
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
150
|
</Callout>
|
|
151
151
|
|
|
152
|
+
### Keep a Token Unavailable After the Run Ends
|
|
153
|
+
|
|
154
|
+
By default, another Hook can use the token after its workflow ends. Set `experimental_minRetention` to keep the token unavailable for at least a specific time after `createHook()` runs:
|
|
155
|
+
|
|
156
|
+
```typescript lineNumbers
|
|
157
|
+
import { createHook } from "workflow";
|
|
158
|
+
|
|
159
|
+
declare function processOwnedOrder(orderId: string): Promise<void>; // @setup
|
|
160
|
+
|
|
161
|
+
export async function processOrder(orderId: string) {
|
|
162
|
+
"use workflow";
|
|
163
|
+
|
|
164
|
+
const hook = createHook({ // [!code highlight]
|
|
165
|
+
token: `order:${orderId}`, // [!code highlight]
|
|
166
|
+
experimental_minRetention: "30d", // [!code highlight]
|
|
167
|
+
}); // [!code highlight]
|
|
168
|
+
|
|
169
|
+
const conflict = await hook.getConflict();
|
|
170
|
+
if (conflict) {
|
|
171
|
+
return { status: "duplicate" as const, runId: conflict.runId };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await processOwnedOrder(orderId);
|
|
175
|
+
return { status: "processed" as const };
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`experimental_minRetention` accepts the same values as [`sleep()`](/docs/api-reference/workflow/sleep): a duration string such as `"30d"`, a number of milliseconds, or an absolute `Date`. Durations start when `createHook()` runs.
|
|
180
|
+
|
|
181
|
+
The Hook remains active until the workflow ends, even if the configured time passes first. Another Hook can use the token only after both the workflow has ended and the configured time has passed. For example, `"30d"` keeps the token unavailable for 29 more days if the workflow ends after 1 day. A workflow that runs for more than 30 days releases the token when it ends.
|
|
182
|
+
|
|
183
|
+
After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) can still find the Hook until retention ends, but the Hook cannot be resumed.
|
|
184
|
+
|
|
185
|
+
<Callout type="warn">
|
|
186
|
+
`using` auto-disposes the Hook at scope exit, which releases the token immediately and defeats `experimental_minRetention`. Declare retained Hooks with `const` and let the runtime clean them up when the run ends.
|
|
187
|
+
</Callout>
|
|
188
|
+
|
|
189
|
+
<Callout type="warn">
|
|
190
|
+
This option is experimental. If the configured World does not support it, the workflow fails when registering the Hook. `createWebhook()` does not accept this option.
|
|
191
|
+
</Callout>
|
|
192
|
+
|
|
152
193
|
### Waiting for Multiple Payloads
|
|
153
194
|
|
|
154
195
|
You can also wait for multiple payloads by using the `for await...of` syntax.
|
|
@@ -46,24 +46,21 @@ showSections={['parameters']}
|
|
|
46
46
|
|
|
47
47
|
### Returns
|
|
48
48
|
|
|
49
|
+
`defineHook()` returns a `TypedHook<TInput, TOutput>`:
|
|
50
|
+
|
|
49
51
|
<TSDoc
|
|
50
52
|
definition={`
|
|
51
|
-
interface
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
create: (options?: HookOptions) => Hook<T>;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
* Resumes a hook by sending a payload with the defined type.
|
|
61
|
-
*/
|
|
62
|
-
resume: (token: string, payload: T) => Promise<HookEntity | null>;
|
|
53
|
+
interface TypedHook<TInput, TOutput> {
|
|
54
|
+
/** Creates the hook. Call inside a "use workflow" function. */
|
|
55
|
+
create(options?: HookOptions): Hook<TOutput>;
|
|
56
|
+
/** Resumes the hook from runtime code. Resolves to the resumed hook; throws HookNotFoundError if the token does not match an active hook. */
|
|
57
|
+
resume(token: string, payload: TInput): Promise<HookEntity>;
|
|
63
58
|
}
|
|
64
|
-
export default
|
|
59
|
+
export default TypedHook;`}
|
|
65
60
|
/>
|
|
66
61
|
|
|
62
|
+
`create()` is called inside a `"use workflow"` function to create the hook; `resume()` is called from runtime code (an API route or server action). When a `schema` is provided, `resume()` accepts the raw input type (`TInput`) and the workflow receives the validated and possibly transformed output type (`TOutput`); without a schema, `TOutput` defaults to `TInput`. `resume()` throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error) if the token does not match an active hook — it does not return `null`.
|
|
63
|
+
|
|
67
64
|
## Examples
|
|
68
65
|
|
|
69
66
|
### Basic Type-Safe Hook Definition
|
|
@@ -93,24 +90,29 @@ export async function workflowWithApproval() {
|
|
|
93
90
|
|
|
94
91
|
### Resuming with Type Safety
|
|
95
92
|
|
|
96
|
-
Hooks can be resumed using the same defined hook and a token. By using the same hook, you can ensure that the payload matches the defined type when resuming a hook.
|
|
93
|
+
Hooks can be resumed using the same defined hook and a token. By using the same hook, you can ensure that the payload matches the defined type when resuming a hook. `resume()` resolves to the resumed hook and throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error) if the token does not match an active hook.
|
|
97
94
|
|
|
98
95
|
```typescript lineNumbers
|
|
96
|
+
import { HookNotFoundError } from "workflow/errors";
|
|
97
|
+
|
|
99
98
|
// Use the same defined hook to resume
|
|
100
99
|
export async function POST(request: Request) {
|
|
101
100
|
const { token, approved, comment } = await request.json();
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return Response.json({
|
|
102
|
+
try {
|
|
103
|
+
// Type-safe resumption - TypeScript ensures the payload matches
|
|
104
|
+
const hook = await approvalHook.resume(token, { // [!code highlight]
|
|
105
|
+
approved, // [!code highlight]
|
|
106
|
+
comment, // [!code highlight]
|
|
107
|
+
}); // [!code highlight]
|
|
108
|
+
|
|
109
|
+
return Response.json({ success: true, runId: hook.runId });
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (HookNotFoundError.is(error)) { // [!code highlight]
|
|
112
|
+
return Response.json({ error: "Hook not found" }, { status: 404 });
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
111
115
|
}
|
|
112
|
-
|
|
113
|
-
return Response.json({ success: true, runId: result.runId });
|
|
114
116
|
}
|
|
115
117
|
```
|
|
116
118
|
|
|
@@ -29,16 +29,38 @@ async function fallibleStep() {
|
|
|
29
29
|
|
|
30
30
|
## API Signature
|
|
31
31
|
|
|
32
|
-
###
|
|
32
|
+
### Constructor
|
|
33
|
+
|
|
34
|
+
`new FatalError(message)`
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Description |
|
|
37
|
+
| --------- | -------- | ------------------ |
|
|
38
|
+
| `message` | `string` | The error message. |
|
|
39
|
+
|
|
40
|
+
### Properties
|
|
33
41
|
|
|
34
42
|
<TSDoc
|
|
35
43
|
definition={`
|
|
36
|
-
interface
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
* The error message.
|
|
40
|
-
*/
|
|
44
|
+
interface FatalError {
|
|
45
|
+
/** The error message. */
|
|
41
46
|
message: string;
|
|
47
|
+
/** Always true. Marks the error as non-retryable. */
|
|
48
|
+
fatal: boolean;
|
|
42
49
|
}
|
|
43
|
-
export default
|
|
50
|
+
export default FatalError;`}
|
|
44
51
|
/>
|
|
52
|
+
|
|
53
|
+
### Static Methods
|
|
54
|
+
|
|
55
|
+
#### `FatalError.is(value)`
|
|
56
|
+
|
|
57
|
+
Type-safe check for `FatalError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts.
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import { FatalError } from "workflow"
|
|
61
|
+
declare const error: unknown; // @setup
|
|
62
|
+
|
|
63
|
+
if (FatalError.is(error)) {
|
|
64
|
+
// error is typed as FatalError
|
|
65
|
+
}
|
|
66
|
+
```
|
|
@@ -93,7 +93,7 @@ This API is provided as a convenience to easily use `fetch` in workflow, but oft
|
|
|
93
93
|
|
|
94
94
|
### Customizing Fetch Behavior
|
|
95
95
|
|
|
96
|
-
Here's an example of a custom fetch wrapper that provides more sophisticated error handling with custom retry logic:
|
|
96
|
+
Here's an example of a custom fetch wrapper that provides more sophisticated error handling with custom retry logic. Call `globalThis.fetch` inside your own `"use step"` function — calling the workflow `fetch` imported from `workflow` would nest a step inside a step:
|
|
97
97
|
|
|
98
98
|
```typescript lineNumbers
|
|
99
99
|
import { FatalError, RetryableError } from "workflow"
|
|
@@ -104,7 +104,7 @@ export async function customFetch(
|
|
|
104
104
|
) {
|
|
105
105
|
"use step"
|
|
106
106
|
|
|
107
|
-
const response = await fetch(url, init)
|
|
107
|
+
const response = await globalThis.fetch(url, init)
|
|
108
108
|
|
|
109
109
|
// Handle client errors (4xx) - don't retry
|
|
110
110
|
if (response.status >= 400 && response.status < 500) {
|
|
@@ -145,7 +145,6 @@ export async function customFetch(
|
|
|
145
145
|
|
|
146
146
|
This example demonstrates:
|
|
147
147
|
|
|
148
|
-
- Setting custom `maxRetries` to 5 retries (6 total attempts including the initial attempt).
|
|
149
148
|
- Throwing [`FatalError`](/docs/api-reference/workflow/fatal-error) for client errors (400-499) to prevent retries.
|
|
150
149
|
- Handling 429 rate limiting by reading the `Retry-After` header and using [`RetryableError`](/docs/api-reference/workflow/retryable-error).
|
|
151
|
-
- Allowing automatic retries for server errors (5xx)
|
|
150
|
+
- Allowing automatic retries for server errors (5xx) by throwing a plain `Error`.
|
|
@@ -59,7 +59,3 @@ Validation errors throw [`FatalError`](/docs/api-reference/workflow/fatal-error)
|
|
|
59
59
|
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.
|
|
60
60
|
|
|
61
61
|
Native attributes require spec version 4 or later. Step-body storage errors throw from `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.
|
|
62
|
-
|
|
63
|
-
<Callout>
|
|
64
|
-
This function was previously exported as `experimental_setAttributes`. The old name still works as a deprecated alias — update imports to `setAttributes`.
|
|
65
|
-
</Callout>
|
|
@@ -14,7 +14,7 @@ Suspends a workflow for a specified duration or until an end date without consum
|
|
|
14
14
|
This is useful when you want to resume a workflow after some duration or date.
|
|
15
15
|
|
|
16
16
|
<Callout>
|
|
17
|
-
`sleep` is a
|
|
17
|
+
`sleep` is a built-in workflow runtime function (backed by a timer event in the event log, not a step) and should be called directly inside workflow functions.
|
|
18
18
|
</Callout>
|
|
19
19
|
|
|
20
20
|
```typescript lineNumbers
|
|
@@ -112,7 +112,7 @@ export async function orderWorkflow(orderId: string) {
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
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
|
|
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 (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
116
|
|
|
117
117
|
### Custom Tokens for Deterministic Hooks
|
|
118
118
|
|
|
@@ -96,7 +96,7 @@ export async function processOrder(orderId: string): Promise<OrderResult> {
|
|
|
96
96
|
}
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
The runtime creates the hook atomically. At most one
|
|
99
|
+
The runtime creates the hook atomically. At most one hook can own `order:${orderId}`, so duplicate workflow runs converge on one owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner — see [conflict-handling strategies](#conflict-handling-strategies) below.
|
|
100
100
|
|
|
101
101
|
Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook:
|
|
102
102
|
|
|
@@ -149,7 +149,7 @@ export async function POST(request: Request) {
|
|
|
149
149
|
This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`.
|
|
150
150
|
</Callout>
|
|
151
151
|
|
|
152
|
-
This
|
|
152
|
+
This coordinates active runs by default: the token becomes available when its workflow ends. Set `experimental_minRetention` to keep it unavailable to late duplicates. After the workflow ends, the Hook can still be found with `getHookByToken()` until retention ends, but it cannot be resumed. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values.
|
|
153
153
|
|
|
154
154
|
### Conflict-handling strategies
|
|
155
155
|
|
|
@@ -182,7 +182,7 @@ export async function processOrder(orderId: string) {
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
**Inspect the owner before deciding.**
|
|
185
|
+
**Inspect the owner before deciding.** Reuse a completed owner's result, but reject other duplicates:
|
|
186
186
|
|
|
187
187
|
```typescript lineNumbers
|
|
188
188
|
import { createHook } from "workflow";
|
|
@@ -200,17 +200,17 @@ export async function processOrder(orderId: string) {
|
|
|
200
200
|
const conflict = await request.getConflict();
|
|
201
201
|
if (conflict) {
|
|
202
202
|
const status = await conflict.status; // [!code highlight]
|
|
203
|
-
if (status === "
|
|
204
|
-
return
|
|
203
|
+
if (status === "completed") {
|
|
204
|
+
return await conflict.returnValue;
|
|
205
205
|
}
|
|
206
|
-
|
|
206
|
+
return { status: "duplicate" as const, runId: conflict.runId };
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
return await processOwnedOrder(orderId);
|
|
210
210
|
}
|
|
211
211
|
```
|
|
212
212
|
|
|
213
|
-
**Signal the owner instead of doing the work.**
|
|
213
|
+
**Signal the owner instead of doing the work.** A conflict can refer to a finished run when `experimental_minRetention` is set, so check its status before sending data to its Hook:
|
|
214
214
|
|
|
215
215
|
```typescript lineNumbers
|
|
216
216
|
import { createHook } from "workflow";
|
|
@@ -230,16 +230,19 @@ export async function processOrder(orderId: string, confirmed: boolean) {
|
|
|
230
230
|
using request = createHook<OrderRequest>({ token });
|
|
231
231
|
|
|
232
232
|
const conflict = await request.getConflict();
|
|
233
|
-
if (conflict) {
|
|
233
|
+
if (conflict && ["pending", "running"].includes(await conflict.status)) {
|
|
234
234
|
await forwardToOwner(token, { confirmed }); // [!code highlight]
|
|
235
235
|
return { status: "forwarded" as const, runId: conflict.runId };
|
|
236
236
|
}
|
|
237
|
+
if (conflict) {
|
|
238
|
+
return { status: "duplicate" as const, runId: conflict.runId };
|
|
239
|
+
}
|
|
237
240
|
|
|
238
241
|
// ... own the token and do the work
|
|
239
242
|
}
|
|
240
243
|
```
|
|
241
244
|
|
|
242
|
-
**Supersede the owner.**
|
|
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:
|
|
243
246
|
|
|
244
247
|
```typescript lineNumbers
|
|
245
248
|
import { createHook } from "workflow";
|
|
@@ -272,6 +275,10 @@ export async function processOrderNewestWins(orderId: string) {
|
|
|
272
275
|
}
|
|
273
276
|
```
|
|
274
277
|
|
|
278
|
+
<Callout type="warn">
|
|
279
|
+
This pattern does not work with `experimental_minRetention`: cancelling the old run does not make its token available early.
|
|
280
|
+
</Callout>
|
|
281
|
+
|
|
275
282
|
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.
|
|
276
283
|
|
|
277
284
|
Because this pattern uses hooks for idempotency, duplicate requests can also inject additional data and steer the existing run. The route example above uses `resumeHook()` for that: if the hook already exists, the duplicate request resumes the active workflow; if the hook is not registered yet, the route starts the workflow and retries `resumeHook()` so the payload is not dropped.
|
|
@@ -176,12 +176,12 @@ The step's `ops` array is awaited via `waitUntil(Promise.all(ops))` after the st
|
|
|
176
176
|
|
|
177
177
|
### Abort Errors Are Wrapped in FatalError
|
|
178
178
|
|
|
179
|
-
When a step throws due to an abort — whether from `fetch` throwing `AbortError`, `signal.throwIfAborted()`, or any other abort-induced error — the step
|
|
179
|
+
When a step throws due to an abort — whether from `fetch` throwing `AbortError`, `signal.throwIfAborted()`, or any other abort-induced error — the step executor wraps the error in `FatalError` before recording it in the event log. This ensures:
|
|
180
180
|
|
|
181
181
|
- **No retries**: An abort is intentional cancellation, not a transient failure. Retrying would just abort again.
|
|
182
182
|
- **Immediate propagation**: The error bubbles up to the workflow as a `FatalError`, which the workflow can catch with `FatalError.is(err)`.
|
|
183
183
|
|
|
184
|
-
The wrapping happens
|
|
184
|
+
The wrapping happens in `runtime/step-executor.ts` during error hydration. When the step's thrown error is an `AbortError` (checked via `err.name === 'AbortError'`), it is treated as fatal regardless of the step's `maxRetries` configuration.
|
|
185
185
|
|
|
186
186
|
### abort() in the Workflow
|
|
187
187
|
|
|
@@ -53,18 +53,20 @@ flowchart LR
|
|
|
53
53
|
A["Source Code<br/>with directives"] --> B["Step Mode"]
|
|
54
54
|
A --> C["Workflow Mode"]
|
|
55
55
|
A --> D["Client Mode"]
|
|
56
|
-
B --> E["
|
|
57
|
-
C --> F["
|
|
56
|
+
B --> E["Step registration bundle"]
|
|
57
|
+
C --> F["Workflow bundle"]
|
|
58
|
+
E --> H["Combined flow handler"]
|
|
59
|
+
F --> H
|
|
58
60
|
D --> G["Your App Code<br/>(Enables `start`)"]
|
|
59
61
|
```
|
|
60
62
|
|
|
61
63
|
### Comparison Table
|
|
62
64
|
|
|
63
|
-
| Mode | Used In | Purpose |
|
|
64
|
-
|
|
65
|
-
| Step | Build time |
|
|
66
|
-
| Workflow | Build time | Bundles workflow orchestrators | `.well-known/workflow/v1/flow`
|
|
67
|
-
| Client | Build/Runtime | Provides workflow IDs and types to `start`
|
|
65
|
+
| Mode | Used In | Purpose | Runtime role | Required? |
|
|
66
|
+
|----------|------------|--------------------------------|--------------|-----------|
|
|
67
|
+
| Step | Build time | Registers executable step functions | Imported by the combined flow handler | Yes |
|
|
68
|
+
| Workflow | Build time | Bundles workflow orchestrators | Executed by `.well-known/workflow/v1/flow` | Yes |
|
|
69
|
+
| Client | Build/Runtime | Provides workflow IDs and types to `start` | Your application code | Optional* |
|
|
68
70
|
|
|
69
71
|
\* Client mode is **recommended** for better developer experience—it provides automatic ID generation and type safety. Without it, you must manually construct workflow IDs or use the build manifest.
|
|
70
72
|
|
|
@@ -73,7 +75,7 @@ flowchart LR
|
|
|
73
75
|
<Tabs items={["Step Mode", "Workflow Mode", "Client Mode"]}>
|
|
74
76
|
<Tab value="Step Mode">
|
|
75
77
|
|
|
76
|
-
**Step Mode** creates the
|
|
78
|
+
**Step Mode** creates a registration bundle that the combined flow handler imports. It is not an HTTP route.
|
|
77
79
|
|
|
78
80
|
**Input:**
|
|
79
81
|
|
|
@@ -211,7 +213,7 @@ The IDs are generated exactly like in workflow mode to ensure they can be direct
|
|
|
211
213
|
|
|
212
214
|
## Generated Files
|
|
213
215
|
|
|
214
|
-
When you build your application, the Workflow SDK generates
|
|
216
|
+
When you build your application, the Workflow SDK generates a combined flow handler, an internal step registration bundle, and a webhook handler. Exact filenames vary by framework.
|
|
215
217
|
|
|
216
218
|
### `flow.js`
|
|
217
219
|
|
|
@@ -245,15 +247,17 @@ Most invalid patterns cause **build-time errors**, catching issues before deploy
|
|
|
245
247
|
**Why a VM?** Workflow functions must be deterministic to support replay. The VM sandbox prevents accidental use of non-deterministic APIs or side effects. All side effects should be performed in [step functions](/docs/foundations/workflows-and-steps#step-functions) instead.
|
|
246
248
|
</Callout>
|
|
247
249
|
|
|
248
|
-
### `
|
|
250
|
+
### `__step_registrations.js`
|
|
249
251
|
|
|
250
|
-
Contains all step functions transformed in **step mode**.
|
|
252
|
+
Contains all step functions transformed in **step mode**. The combined flow handler imports this module for its registration side effects.
|
|
251
253
|
|
|
252
254
|
**What it does:**
|
|
253
255
|
|
|
254
|
-
-
|
|
255
|
-
-
|
|
256
|
-
-
|
|
256
|
+
- Adds step functions to the runtime step registry
|
|
257
|
+
- Keeps step bodies in the full host runtime
|
|
258
|
+
- Makes registered steps available when a flow queue message includes `stepId` and `stepName`
|
|
259
|
+
|
|
260
|
+
This module must not be exposed as an HTTP endpoint.
|
|
257
261
|
|
|
258
262
|
### `webhook.js`
|
|
259
263
|
|
|
@@ -336,7 +340,7 @@ These transformations are framework-agnostic—they output standard JavaScript t
|
|
|
336
340
|
|
|
337
341
|
If you need to debug transformation issues, you can inspect the generated files:
|
|
338
342
|
|
|
339
|
-
1. **
|
|
343
|
+
1. **Inspect the generated output**: Check the combined flow handler, step registration bundle, webhook handler, and emitted debug files.
|
|
340
344
|
2. **Check build logs**: Most frameworks log transformation activity during builds
|
|
341
345
|
3. **Verify directives**: Ensure `"use workflow"` and `"use step"` are the first statements in functions
|
|
342
346
|
4. **Check file locations**: Transformations only apply to files in configured source directories
|
|
@@ -7,7 +7,7 @@ prerequisites:
|
|
|
7
7
|
- /docs/how-it-works/event-sourcing
|
|
8
8
|
related:
|
|
9
9
|
- /docs/observability
|
|
10
|
-
- /
|
|
10
|
+
- /worlds/vercel
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
<Callout>
|
|
@@ -38,7 +38,7 @@ Metadata such as workflow names, step names, entity IDs, timestamps, and lifecyc
|
|
|
38
38
|
|
|
39
39
|
Each workflow run is encrypted with its own unique key, provided by the `World` implementation via `getEncryptionKeyForRun()`. How the key is generated and stored is up to the `World`.
|
|
40
40
|
|
|
41
|
-
For example, the [Vercel World](/
|
|
41
|
+
For example, the [Vercel World](/worlds/vercel) provides unique keys per run and execution environment, ensuring that a given run can only decrypt data from that run itself.
|
|
42
42
|
|
|
43
43
|
### Encryption Algorithm
|
|
44
44
|
|
|
@@ -127,4 +127,4 @@ async function lookupRunKey(
|
|
|
127
127
|
}
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
The [Vercel World](/
|
|
130
|
+
The [Vercel World](/worlds/vercel) implementation uses HKDF derivation from a deployment-scoped key, but any consistent key management scheme will work.
|
|
@@ -121,15 +121,15 @@ flowchart TD
|
|
|
121
121
|
|
|
122
122
|
**Hook states:**
|
|
123
123
|
|
|
124
|
-
- `active`: Ready to receive payloads
|
|
125
|
-
- `disposed`: No longer accepting payloads
|
|
124
|
+
- `active`: Ready to receive payloads
|
|
125
|
+
- `disposed`: No longer accepting payloads
|
|
126
126
|
- `conflicted`: Hook creation failed because the token is already in use by another workflow
|
|
127
127
|
|
|
128
|
-
Unlike other entities, hooks don't have a `status` field—the states above are conceptual.
|
|
128
|
+
Unlike other entities, hooks don't have a `status` field—the states above are conceptual. When a `hook_disposed` event is created, the hook record is removed rather than updated.
|
|
129
129
|
|
|
130
|
-
While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token
|
|
130
|
+
While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token reserved by another run — either by an active hook or by `experimental_minRetention` after its run ended — a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that owns it, though older persisted events or world implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details.
|
|
131
131
|
|
|
132
|
-
When a
|
|
132
|
+
When a workflow ends, its Hooks can no longer be resumed. They are normally removed and their tokens become available again. With `experimental_minRetention`, a Hook remains readable and its token remains unavailable until retention ends. A `hook_disposed` event removes the Hook and makes its token available immediately.
|
|
133
133
|
|
|
134
134
|
See [Hooks & Webhooks](/docs/foundations/hooks) for more on how hooks and webhooks work.
|
|
135
135
|
|
|
@@ -188,7 +188,7 @@ Events are categorized by the entity type they affect. Each event contains metad
|
|
|
188
188
|
| Event | Description |
|
|
189
189
|
|-------|-------------|
|
|
190
190
|
| `hook_created` | Creates a new hook in `active` state. Contains the hook token and optional metadata. |
|
|
191
|
-
| `hook_conflict` | Records that hook creation failed because
|
|
191
|
+
| `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`. |
|
|
192
192
|
| `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads. |
|
|
193
193
|
| `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. |
|
|
194
194
|
|