workflow 5.0.0-beta.4 → 5.0.0-beta.5

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 (35) hide show
  1. package/dist/api.d.ts +5 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +14 -2
  4. package/dist/runtime.d.ts +1 -1
  5. package/dist/runtime.d.ts.map +1 -1
  6. package/dist/runtime.js +2 -2
  7. package/docs/api-reference/vitest/index.mdx +28 -1
  8. package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
  9. package/docs/api-reference/workflow-next/with-workflow.mdx +32 -0
  10. package/docs/changelog/eager-processing.mdx +595 -0
  11. package/docs/changelog/index.mdx +2 -1
  12. package/docs/cookbook/advanced/meta.json +1 -6
  13. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +29 -78
  14. package/docs/cookbook/common-patterns/timeouts.mdx +1 -1
  15. package/docs/cookbook/index.mdx +0 -1
  16. package/docs/deploying/world/postgres-world.mdx +5 -3
  17. package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
  18. package/docs/foundations/cancellation.mdx +460 -0
  19. package/docs/foundations/errors-and-retries.mdx +7 -3
  20. package/docs/foundations/meta.json +1 -0
  21. package/docs/foundations/serialization.mdx +77 -41
  22. package/docs/getting-started/astro.mdx +6 -0
  23. package/docs/getting-started/index.mdx +6 -7
  24. package/docs/getting-started/meta.json +1 -0
  25. package/docs/getting-started/nestjs.mdx +8 -0
  26. package/docs/getting-started/nitro.mdx +22 -0
  27. package/docs/getting-started/sveltekit.mdx +6 -0
  28. package/docs/getting-started/tanstack-start.mdx +241 -0
  29. package/docs/how-it-works/cancellation.mdx +287 -0
  30. package/docs/how-it-works/meta.json +2 -1
  31. package/docs/internal/index.mdx +19 -0
  32. package/docs/internal/meta.json +5 -0
  33. package/docs/internal/serializable-abort-controller.mdx +148 -0
  34. package/package.json +13 -12
  35. package/docs/cookbook/advanced/distributed-abort-controller.mdx +0 -318
@@ -12,4 +12,5 @@ Stay up to date with the latest changes to Workflow SDK.
12
12
 
13
13
  ## 2026
14
14
 
15
- - TBD
15
+ - [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) - March 2026
16
+ - [Serializable AbortController and AbortSignal](/docs/changelog/serializable-abort-controller) — March 12, 2026
@@ -1,9 +1,4 @@
1
1
  {
2
2
  "title": "Advanced",
3
- "pages": [
4
- "child-workflows",
5
- "distributed-abort-controller",
6
- "serializable-steps",
7
- "publishing-libraries"
8
- ]
3
+ "pages": ["child-workflows", "serializable-steps", "publishing-libraries"]
9
4
  }
@@ -1,70 +1,15 @@
1
1
  ---
2
2
  title: Agent Cancellation
3
- description: Cancel a running agent from the outside — either immediately via run.cancel() or gracefully via a stop signal hook.
3
+ description: Cancel a running agent from the outside using AbortSignal a hook fires the abort, the agent step bails out of the model stream, and the client gets a clean stop notification.
4
4
  type: guide
5
- summary: Two patterns for cancelling a running agent Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook + Promise.race for a clean exit with cleanup and final stream notification.
5
+ summary: Cancel a running agent cooperatively with AbortController. A stop hook fires controller.abort(), the signal propagates into the agent step to cancel the model stream, and a data-stopped part is emitted to streaming clients before the workflow returns.
6
6
  ---
7
7
 
8
- Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback. Two patterns are available depending on whether you need the agent to exit cleanly or just need the run to stop: **Hard Cancellation** via `getRun(runId).cancel()` for immediate forced termination, or **Stop Signal** via a hook + `Promise.race` for a graceful exit that runs cleanup and notifies streaming clients before returning.
8
+ Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback.
9
9
 
10
- ## When to use this
10
+ ## Pattern
11
11
 
12
- * **Chat stop buttons**let users cancel a long-running agent from the browser
13
- * **Admin cancellation** — stop an agent from a different process or API
14
- * **Timeout fallback** — combine with `sleep()` to auto-stop after a deadline
15
-
16
- ## Choosing an approach
17
-
18
- Pick the option that matches what your endpoint needs to deliver to the caller:
19
-
20
- * **Hard Cancellation** — terminates the run immediately with no opportunity for cleanup or client notification. A single line of code, but the workflow throws `WorkflowRunCancelledError` and any streaming clients see an abrupt connection close.
21
- * **Stop Signal** — the workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a real result.
22
-
23
- The trade-offs at a glance:
24
-
25
- | | Hard Cancellation | Stop Signal |
26
- | --- | --- | --- |
27
- | Mechanism | `getRun(runId).cancel()` | Hook + `Promise.race` |
28
- | Speed to terminate | Immediate | At the next `await` boundary in the workflow |
29
- | Runs `finally` / cleanup | No | Yes |
30
- | Final stream notification | No (abrupt close) | Yes (`data-stopped` part) |
31
- | `run.returnValue` | Throws `WorkflowRunCancelledError` | Returns the workflow's result |
32
- | Code complexity | One line | Hook + race + signal step |
33
- | Best for | Stuck or unresponsive runs, forced termination | User-facing stop, admin cancel, timeouts |
34
-
35
- ## Hard Cancellation
36
-
37
- Call `.cancel()` on a run to terminate it immediately:
38
-
39
- ```typescript lineNumbers
40
- import { getRun } from "workflow/api";
41
-
42
- export async function POST(
43
- _request: Request,
44
- { params }: { params: Promise<{ runId: string }> }
45
- ) {
46
- const { runId } = await params;
47
- await getRun(runId).cancel(); // [!code highlight]
48
- return Response.json({ success: true });
49
- }
50
- ```
51
-
52
- This is an abrupt termination — the run is stopped mid-step with no opportunity to exit cleanly:
53
-
54
- * **No cleanup runs** — `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped
55
- * **No final notification to the client** — the writable closes abruptly, so a streaming UI just sees the connection drop with no `data-stopped` part to render a clean ending
56
- * **`run.returnValue` throws** — anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload
57
- * **Underlying step keeps running** — same caveat as the Stop Signal pattern below: the model stream or HTTP call inside the current step continues to completion in the background
58
-
59
- Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else — chat stop buttons, admin "stop" actions, timeout fallbacks — you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result.
60
-
61
- ## Stop Signal
62
-
63
- <Callout type="warn">
64
- **Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes — tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
65
- </Callout>
66
-
67
- ### Example
12
+ Create an `AbortController` in the workflow and race the agent (passing its signal) against a stop hook. When the hook fires, `controller.abort()` is called the signal propagates into the agent step and cancels the underlying model stream. Before returning, a `data-stopped` part is written to the stream so any streaming clients can render a clean end state.
68
13
 
69
14
  ```typescript lineNumbers
70
15
  import { DurableAgent } from "@workflow/ai/agent";
@@ -88,7 +33,7 @@ async function analyzeData({ topic }: { topic: string }) {
88
33
  return { summary: `Analysis of ${topic}: significant developments found.`, confidence: 0.85 };
89
34
  }
90
35
 
91
- async function emitStopSignal(details: { reason?: string }) { // [!code highlight]
36
+ async function emitStopSignal(details: { reason?: string }) {
92
37
  "use step";
93
38
  const writer = getWritable<UIMessageChunk>().getWriter();
94
39
  try {
@@ -102,7 +47,8 @@ export async function stoppableAgent(messages: ModelMessage[]) {
102
47
  "use workflow";
103
48
 
104
49
  const { workflowRunId } = getWorkflowMetadata();
105
- const hook = stopHook.create({ token: `stop:${workflowRunId}` }); // [!code highlight]
50
+ const controller = new AbortController(); // [!code highlight]
51
+ const hook = stopHook.create({ token: `stop:${workflowRunId}` });
106
52
 
107
53
  const agent = new DurableAgent({
108
54
  model: "anthropic/claude-haiku-4.5",
@@ -121,15 +67,23 @@ export async function stoppableAgent(messages: ModelMessage[]) {
121
67
  },
122
68
  });
123
69
 
124
- const result = await Promise.race([ // [!code highlight]
70
+ const result = await Promise.race([
125
71
  agent
126
- .stream({ messages, writable: getWritable<UIMessageChunk>(), maxSteps: 15 })
72
+ .stream({
73
+ messages,
74
+ writable: getWritable<UIMessageChunk>(),
75
+ abortSignal: controller.signal, // [!code highlight]
76
+ maxSteps: 15,
77
+ })
127
78
  .then((r) => ({ type: "complete" as const, messages: r.messages })),
128
- hook.then(({ reason }) => ({ type: "stopped" as const, reason })), // [!code highlight]
79
+ hook.then(({ reason }) => {
80
+ controller.abort(reason); // [!code highlight]
81
+ return { type: "stopped" as const, reason };
82
+ }),
129
83
  ]);
130
84
 
131
85
  if (result.type === "stopped") {
132
- await emitStopSignal({ reason: result.reason }); // [!code highlight]
86
+ await emitStopSignal({ reason: result.reason });
133
87
  }
134
88
 
135
89
  return result;
@@ -148,7 +102,7 @@ export async function POST(
148
102
  const { runId } = await params;
149
103
  const { reason } = await request.json();
150
104
 
151
- await stopHook.resume(`stop:${runId}`, { // [!code highlight]
105
+ await stopHook.resume(`stop:${runId}`, {
152
106
  reason: reason || "User requested stop",
153
107
  });
154
108
 
@@ -180,13 +134,12 @@ export function StopButton({ runId }: { runId: string }) {
180
134
 
181
135
  ## How it works
182
136
 
183
- 1. A hook is created with token `stop:${workflowRunId}` when the workflow starts
184
- 2. `Promise.race` runs the agent stream and the stop hook concurrently
185
- 3. When the stop API resumes the hook, the race resolves immediately — the workflow exits
186
- 4. Before returning, `emitStopSignal` writes a `data-stopped` part to the stream so the client knows the agent was stopped (not just disconnected)
187
- 5. The client detects `data-stopped` and updates the UI accordingly
188
-
189
- This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — race a long-running operation against a hook signal.
137
+ 1. An `AbortController` is created at the start of the workflow
138
+ 2. A hook is created with token `stop:${workflowRunId}`
139
+ 3. `Promise.race` runs the agent stream and the stop hook concurrently
140
+ 4. The agent receives `controller.signal` when aborted, the underlying model stream is cancelled
141
+ 5. When the stop API resumes the hook, `controller.abort()` is called the race resolves and the workflow exits
142
+ 6. `emitStopSignal` writes a `data-stopped` part to the stream so the client renders a clean stop state
190
143
 
191
144
  ## Adapting this
192
145
 
@@ -194,12 +147,10 @@ This is the same pattern used by the [Distributed Abort Controller](/cookbook/ad
194
147
  * **Audit logging** — include a `reason` field in the stop schema to record who stopped and why
195
148
  * **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID
196
149
  * **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop
197
- * **Hard Cancellation as a fallback** — wire your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume errors with `not found` / `expired` (for example, the hook was already consumed). This guarantees the run is terminated even when the Stop Signal path is unavailable.
198
150
 
199
151
  ## Key APIs
200
152
 
201
153
  * [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal
202
154
  * [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens
203
- * [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream a stop notification to the client
204
- * [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — the agent that gets raced against the stop hook
205
- * [`getRun()`](/docs/api-reference/workflow-api/get-run) — entry point for Hard Cancellation: `getRun(runId).cancel()`
155
+ * [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream output and the stop notification to the client
156
+ * [`DurableAgent`](/docs/api-reference/workflow-ai/durable-agent) — the agent that respects the abort signal via its `signal` option
@@ -80,7 +80,7 @@ export async function waitForApproval(requestId: string) {
80
80
  4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows.
81
81
 
82
82
  <Callout type="warn">
83
- **The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. For hard cancellation across processes, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller).
83
+ **The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Pass an `AbortSignal` into the step to cancel it cooperatively — see the [Cancellation Guide](/docs/foundations/cancellation) for patterns.
84
84
  </Callout>
85
85
 
86
86
  ## Adapting to your use case
@@ -33,6 +33,5 @@ A curated collection of workflow patterns with clean, copy-paste code examples f
33
33
  ## Advanced
34
34
 
35
35
  - [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent
36
- - [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks
37
36
  - [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary
38
37
  - [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions
@@ -161,7 +161,9 @@ Prefix for graphile-worker queue job names. Useful when sharing a database betwe
161
161
 
162
162
  ### `WORKFLOW_POSTGRES_WORKER_CONCURRENCY`
163
163
 
164
- Number of concurrent workers polling for jobs. Default: `10`
164
+ Number of concurrent workers polling for jobs. Default: `50`.
165
+
166
+ This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates — if you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` e2e test (fib(6), ~24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting.
165
167
 
166
168
  ### `WORKFLOW_POSTGRES_MAX_POOL_SIZE`
167
169
 
@@ -179,8 +181,8 @@ import { createWorld } from "@workflow/world-postgres";
179
181
  const world = createWorld({
180
182
  connectionString: "postgres://user:password@host:5432/database",
181
183
  jobPrefix: "myapp_",
182
- queueConcurrency: 20,
183
- maxPoolSize: 20, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE
184
+ queueConcurrency: 50,
185
+ maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE
184
186
  });
185
187
  ```
186
188
 
@@ -0,0 +1,80 @@
1
+ ---
2
+ title: abort-signal-timeout-in-workflow
3
+ description: AbortSignal.timeout() cannot be used inside workflow functions because it relies on real timers which break deterministic replay.
4
+ type: troubleshooting
5
+ summary: Use sleep() with AbortController instead of AbortSignal.timeout() in workflow functions.
6
+ prerequisites:
7
+ - /docs/foundations/workflows-and-steps
8
+ related:
9
+ - /docs/foundations/cancellation
10
+ - /docs/api-reference/workflow/sleep
11
+ - /docs/errors/timeout-in-workflow
12
+ ---
13
+
14
+ ## Error
15
+
16
+ ```
17
+ AbortSignal.timeout() is not supported in workflow functions.
18
+ Use sleep() with an AbortController instead.
19
+ ```
20
+
21
+ ## Why This Happens
22
+
23
+ `AbortSignal.timeout()` creates a signal that aborts after a real-time delay using an internal timer. Workflow functions must be [deterministic](/docs/foundations/workflows-and-steps) to support replay — they run the same code multiple times during the workflow's lifecycle, using the [event log](/docs/how-it-works/event-sourcing) to resume execution to the correct point.
24
+
25
+ Real-time timers break this determinism because:
26
+ - On the first execution, the timer might fire after 10 seconds
27
+ - On replay, the timer would fire again, but the event log may have already advanced past that point
28
+ - The timer's behavior depends on wall-clock time, which varies between executions
29
+
30
+ ## How to Fix
31
+
32
+ Use [`sleep()`](/docs/api-reference/workflow/sleep) with an `AbortController` to create a deterministic timeout that cancels in-flight work:
33
+
34
+ **Before (incorrect):**
35
+
36
+ {/* @skip-typecheck: intentionally incorrect example */}
37
+ ```typescript lineNumbers
38
+ export async function workflow() {
39
+ "use workflow";
40
+
41
+ // This will throw an error
42
+ const signal = AbortSignal.timeout(10_000); // [!code highlight]
43
+ const result = await fetchData(signal);
44
+ return result;
45
+ }
46
+ ```
47
+
48
+ **After (correct):**
49
+
50
+ ```typescript lineNumbers
51
+ import { sleep } from "workflow";
52
+
53
+ export async function workflow() {
54
+ "use workflow";
55
+
56
+ const controller = new AbortController(); // [!code highlight]
57
+ void sleep("10s").then(() => controller.abort()); // [!code highlight]
58
+
59
+ return await fetchData(controller.signal);
60
+ }
61
+
62
+ async function fetchData(signal: AbortSignal) {
63
+ "use step";
64
+ const response = await fetch("https://api.example.com/data", { signal });
65
+ return response.json();
66
+ }
67
+ ```
68
+
69
+ The `sleep()` + `AbortController` pattern is the durable equivalent of `AbortSignal.timeout()`. The sleep is recorded in the event log, so it replays deterministically. If `fetchData` finishes within 10 seconds you get the response; if not, the timer fires `controller.abort()`, `fetch` rejects with an `AbortError`, and the step's failure propagates to the workflow as a `FatalError` (no retries — abort is intentional cancellation).
70
+
71
+ <Callout type="info">
72
+ `AbortSignal.timeout()` works normally inside step functions, since steps have full Node.js runtime access and are not replayed.
73
+ </Callout>
74
+
75
+ ## Related
76
+
77
+ - [Cancellation](/docs/foundations/cancellation) — Patterns for cancelling in-flight work
78
+ - [`sleep()` API Reference](/docs/api-reference/workflow/sleep) — Durable sleep primitive
79
+ - [Workflows and Steps](/docs/foundations/workflows-and-steps) — Why workflow functions must be deterministic
80
+ - [`setTimeout` in Workflow](/docs/errors/timeout-in-workflow) — Similar restriction on `setTimeout`