workflow 5.0.0-beta.1 → 5.0.0-beta.3

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 (72) hide show
  1. package/README.md +4 -4
  2. package/dist/api-workflow.d.ts +1 -1
  3. package/dist/api-workflow.d.ts.map +1 -1
  4. package/dist/api-workflow.js +2 -2
  5. package/dist/api.js +1 -1
  6. package/dist/astro.js +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/internal/builtins.js +1 -1
  9. package/dist/internal/class-serialization.js +1 -1
  10. package/dist/internal/errors.js +1 -1
  11. package/dist/nest.js +1 -1
  12. package/dist/next.cjs +1 -1
  13. package/dist/nitro.js +1 -1
  14. package/dist/nuxt.js +1 -1
  15. package/dist/observability.js +1 -1
  16. package/dist/runtime.js +1 -1
  17. package/dist/stdlib.js +1 -1
  18. package/dist/sveltekit.js +1 -1
  19. package/dist/typescript-plugin.cjs +1 -1
  20. package/dist/vite.js +1 -1
  21. package/dist/workflow.js +1 -1
  22. package/docs/ai/resumable-streams.mdx +1 -1
  23. package/docs/api-reference/workflow/create-webhook.mdx +37 -18
  24. package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
  25. package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
  26. package/docs/api-reference/workflow-ai/index.mdx +0 -5
  27. package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
  28. package/docs/cookbook/advanced/child-workflows.mdx +372 -0
  29. package/docs/cookbook/advanced/distributed-abort-controller.mdx +318 -0
  30. package/docs/cookbook/advanced/meta.json +9 -0
  31. package/docs/cookbook/advanced/publishing-libraries.mdx +336 -0
  32. package/docs/cookbook/advanced/serializable-steps.mdx +147 -0
  33. package/docs/cookbook/agent-patterns/agent-cancellation.mdx +205 -0
  34. package/docs/cookbook/agent-patterns/durable-agent.mdx +150 -0
  35. package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +255 -0
  36. package/docs/cookbook/agent-patterns/meta.json +4 -0
  37. package/docs/cookbook/common-patterns/batching.mdx +105 -0
  38. package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
  39. package/docs/cookbook/common-patterns/meta.json +15 -0
  40. package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
  41. package/docs/cookbook/common-patterns/saga.mdx +247 -0
  42. package/docs/cookbook/common-patterns/scheduling.mdx +125 -0
  43. package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
  44. package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
  45. package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
  46. package/docs/cookbook/common-patterns/workflow-composition.mdx +118 -0
  47. package/docs/cookbook/index.mdx +38 -0
  48. package/docs/cookbook/integrations/ai-sdk.mdx +360 -0
  49. package/docs/cookbook/integrations/chat-sdk.mdx +303 -0
  50. package/docs/cookbook/integrations/meta.json +4 -0
  51. package/docs/cookbook/integrations/sandbox.mdx +516 -0
  52. package/docs/cookbook/meta.json +5 -0
  53. package/docs/deploying/world/local-world.mdx +1 -1
  54. package/docs/deploying/world/postgres-world.mdx +1 -1
  55. package/docs/deploying/world/vercel-world.mdx +1 -1
  56. package/docs/errors/start-invalid-workflow-function.mdx +1 -1
  57. package/docs/foundations/index.mdx +0 -3
  58. package/docs/foundations/meta.json +0 -1
  59. package/docs/foundations/serialization.mdx +1 -1
  60. package/docs/foundations/starting-workflows.mdx +1 -1
  61. package/docs/getting-started/index.mdx +8 -1
  62. package/docs/getting-started/meta.json +2 -1
  63. package/docs/getting-started/python.mdx +165 -0
  64. package/docs/meta.json +1 -0
  65. package/docs/migration-guides/index.mdx +34 -0
  66. package/docs/migration-guides/meta.json +9 -0
  67. package/docs/migration-guides/migrating-from-aws-step-functions.mdx +363 -0
  68. package/docs/migration-guides/migrating-from-inngest.mdx +314 -0
  69. package/docs/migration-guides/migrating-from-temporal.mdx +318 -0
  70. package/docs/migration-guides/migrating-from-trigger-dev.mdx +337 -0
  71. package/package.json +13 -13
  72. package/docs/foundations/common-patterns.mdx +0 -265
@@ -0,0 +1,372 @@
1
+ ---
2
+ title: Child Workflows
3
+ description: Spawn child workflows from a parent and poll their progress for batch processing, report generation, and other multi-workflow orchestration scenarios.
4
+ type: guide
5
+ summary: Orchestrate independent child workflows from a parent workflow using start(), sleep(), and getRun() to fan out work with isolated failure boundaries.
6
+ ---
7
+
8
+ Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings.
9
+
10
+ ## When to use child workflows
11
+
12
+ Child workflows are the right choice when:
13
+
14
+ - **Work units are independent.** Each child can run without knowing about the others (e.g., processing individual documents, generating separate reports).
15
+ - **You need isolated failure boundaries.** A failing child should not abort unrelated work. The parent decides how to handle failures.
16
+ - **You want massive fan-out.** Spawning 50 or 500 children is practical because each runs on its own infrastructure.
17
+ - **You need per-item observability.** Each child workflow has its own run ID, status, and event log for monitoring.
18
+
19
+ For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead.
20
+
21
+ ## Basic pattern: spawn and poll
22
+
23
+ The core pattern has three parts:
24
+
25
+ 1. A **step** that calls `start()` to spawn a child workflow and returns the run ID
26
+ 2. A **polling loop** in the parent workflow that checks child status with `getRun()`
27
+ 3. A **step** that retrieves the child's return value once it completes
28
+
29
+ ```typescript
30
+ import { sleep } from "workflow";
31
+ import { getRun, start } from "workflow/api";
32
+
33
+ declare function pollUntilComplete(runIds: string[]): Promise<void>; // @setup
34
+ declare function collectResults(runIds: string[]): Promise<Array<{ documentId: string; summary: string }>>; // @setup
35
+
36
+ // Child workflow -- processes a single document
37
+ export async function processDocument(documentId: string) {
38
+ "use workflow";
39
+
40
+ const content = await fetchDocument(documentId);
41
+ const analysis = await analyzeContent(content);
42
+ const summary = await generateSummary(analysis);
43
+
44
+ return { documentId, summary };
45
+ }
46
+
47
+ async function fetchDocument(documentId: string): Promise<string> {
48
+ "use step";
49
+ const res = await fetch(`https://docs.example.com/api/${documentId}`);
50
+ return res.text();
51
+ }
52
+
53
+ async function analyzeContent(content: string): Promise<string> {
54
+ "use step";
55
+ // Call analysis API
56
+ return `analysis of ${content.length} chars`;
57
+ }
58
+
59
+ async function generateSummary(analysis: string): Promise<string> {
60
+ "use step";
61
+ // Generate summary from analysis
62
+ return `Summary: ${analysis}`;
63
+ }
64
+
65
+ // Parent workflow -- orchestrates document processing
66
+ export async function processDocumentBatch(documentIds: string[]) {
67
+ "use workflow";
68
+
69
+ // Spawn a child workflow for each document
70
+ const runIds = await spawnChildren(documentIds);
71
+
72
+ // Poll until all children complete
73
+ await pollUntilComplete(runIds);
74
+
75
+ // Collect results
76
+ const results = await collectResults(runIds);
77
+
78
+ return { processed: results.length, results };
79
+ }
80
+
81
+ async function spawnChildren(
82
+ documentIds: string[]
83
+ ): Promise<string[]> {
84
+ "use step"; // [!code highlight]
85
+
86
+ const runIds: string[] = [];
87
+ for (const docId of documentIds) {
88
+ const run = await start(processDocument, [docId]); // [!code highlight]
89
+ runIds.push(run.runId);
90
+ }
91
+ return runIds;
92
+ }
93
+ ```
94
+
95
+ ### Polling loop
96
+
97
+ The parent workflow polls child statuses in a loop, sleeping between checks. This is durable -- if the parent replays, the sleep and status checks replay from the event log.
98
+
99
+ ```typescript
100
+ import { sleep } from "workflow";
101
+ import { getRun } from "workflow/api";
102
+
103
+ const POLL_INTERVAL = "30s";
104
+ const MAX_POLL_ITERATIONS = 120; // 60 minutes at 30s intervals
105
+
106
+ async function pollUntilComplete(runIds: string[]): Promise<void> {
107
+ let iteration = 0;
108
+
109
+ while (iteration < MAX_POLL_ITERATIONS) {
110
+ const status = await checkStatuses(runIds); // [!code highlight]
111
+
112
+ if (status.running === 0) {
113
+ if (status.failed > 0) {
114
+ throw new Error(
115
+ `${status.failed} of ${runIds.length} children failed`
116
+ );
117
+ }
118
+ return; // All completed successfully
119
+ }
120
+
121
+ iteration += 1;
122
+ await sleep(POLL_INTERVAL); // [!code highlight]
123
+ }
124
+
125
+ throw new Error("Timed out waiting for children to complete");
126
+ }
127
+
128
+ async function checkStatuses(
129
+ runIds: string[]
130
+ ): Promise<{ running: number; completed: number; failed: number }> {
131
+ "use step"; // [!code highlight]
132
+
133
+ let running = 0;
134
+ let completed = 0;
135
+ let failed = 0;
136
+
137
+ for (const runId of runIds) {
138
+ const run = getRun(runId); // [!code highlight]
139
+ const status = await run.status; // [!code highlight]
140
+
141
+ if (status === "completed") completed += 1;
142
+ else if (status === "failed" || status === "cancelled") failed += 1;
143
+ else running += 1; // pending, running
144
+ }
145
+
146
+ return { running, completed, failed };
147
+ }
148
+
149
+ async function collectResults(
150
+ runIds: string[]
151
+ ): Promise<Array<{ documentId: string; summary: string }>> {
152
+ "use step";
153
+
154
+ const results = [];
155
+ for (const runId of runIds) {
156
+ const run = getRun(runId);
157
+ const value = await run.returnValue;
158
+ results.push(value as { documentId: string; summary: string });
159
+ }
160
+ return results;
161
+ }
162
+ ```
163
+
164
+ ## Fan-out pattern: chunked spawning
165
+
166
+ When spawning hundreds of children, batch the `start()` calls to avoid overwhelming the system. Use multiple spawn steps, each launching a chunk of children.
167
+
168
+ ```typescript
169
+ import { start } from "workflow/api";
170
+
171
+ declare function pollUntilComplete(runIds: string[]): Promise<void>; // @setup
172
+
173
+ const CHUNK_SIZE = 10;
174
+
175
+ export async function largeReportBatch(reportConfigs: Array<{ id: string; query: string }>) {
176
+ "use workflow";
177
+
178
+ // Spawn children in chunks
179
+ const allRunIds: string[] = [];
180
+ for (let i = 0; i < reportConfigs.length; i += CHUNK_SIZE) {
181
+ const chunk = reportConfigs.slice(i, i + CHUNK_SIZE);
182
+ const runIds = await spawnReportChunk(chunk); // [!code highlight]
183
+ allRunIds.push(...runIds);
184
+ }
185
+
186
+ // Poll until all complete
187
+ await pollUntilComplete(allRunIds);
188
+
189
+ const results = await collectReportResults(allRunIds);
190
+ return { total: results.length, results };
191
+ }
192
+
193
+ async function spawnReportChunk(
194
+ configs: Array<{ id: string; query: string }>
195
+ ): Promise<string[]> {
196
+ "use step";
197
+
198
+ const runIds: string[] = [];
199
+ for (const config of configs) {
200
+ const run = await start(generateReport, [config.id, config.query]);
201
+ runIds.push(run.runId);
202
+ }
203
+ return runIds;
204
+ }
205
+
206
+ async function generateReport(reportId: string, query: string) {
207
+ "use workflow";
208
+
209
+ const data = await queryDatabase(reportId, query);
210
+ const formatted = await formatReport(reportId, data);
211
+ return { reportId, formatted };
212
+ }
213
+
214
+ declare function queryDatabase(reportId: string, query: string): Promise<string>; // @setup
215
+ declare function formatReport(reportId: string, data: string): Promise<string>; // @setup
216
+
217
+ declare function collectReportResults(
218
+ runIds: string[]
219
+ ): Promise<Array<{ reportId: string; formatted: string }>>; // @setup
220
+ ```
221
+
222
+ ## Error handling
223
+
224
+ ### Tolerating partial failures
225
+
226
+ Not every batch requires 100% success. Use `allowFailures` logic to let the parent continue when some children fail, while still surfacing the failures.
227
+
228
+ ```typescript
229
+ import { sleep } from "workflow";
230
+ import { getRun } from "workflow/api";
231
+
232
+ const POLL_INTERVAL = "30s";
233
+ const MAX_POLL_ITERATIONS = 120;
234
+
235
+ async function pollWithPartialFailures(
236
+ runIds: string[],
237
+ maxFailureRate: number
238
+ ): Promise<{ completed: string[]; failed: string[] }> {
239
+ let iteration = 0;
240
+ const completedIds: string[] = [];
241
+ const failedIds: string[] = [];
242
+
243
+ while (iteration < MAX_POLL_ITERATIONS) {
244
+ const status = await checkDetailedStatuses(runIds);
245
+
246
+ completedIds.length = 0;
247
+ failedIds.length = 0;
248
+
249
+ for (const entry of status) {
250
+ if (entry.status === "completed") completedIds.push(entry.runId);
251
+ else if (entry.status === "failed" || entry.status === "cancelled")
252
+ failedIds.push(entry.runId);
253
+ }
254
+
255
+ const active = runIds.length - completedIds.length - failedIds.length;
256
+
257
+ // Check if failure rate exceeds threshold
258
+ const failureRate = failedIds.length / Math.max(1, runIds.length); // [!code highlight]
259
+ if (failureRate > maxFailureRate) { // [!code highlight]
260
+ throw new Error( // [!code highlight]
261
+ `Failure rate ${(failureRate * 100).toFixed(1)}% exceeds ` + // [!code highlight]
262
+ `threshold of ${(maxFailureRate * 100).toFixed(1)}%` // [!code highlight]
263
+ ); // [!code highlight]
264
+ } // [!code highlight]
265
+
266
+ if (active === 0) {
267
+ return { completed: completedIds, failed: failedIds };
268
+ }
269
+
270
+ iteration += 1;
271
+ await sleep(POLL_INTERVAL);
272
+ }
273
+
274
+ throw new Error("Timed out waiting for children");
275
+ }
276
+
277
+ async function checkDetailedStatuses(
278
+ runIds: string[]
279
+ ): Promise<Array<{ runId: string; status: string }>> {
280
+ "use step";
281
+
282
+ const statuses = [];
283
+ for (const runId of runIds) {
284
+ const run = getRun(runId);
285
+ const status = await run.status;
286
+ statuses.push({ runId, status });
287
+ }
288
+ return statuses;
289
+ }
290
+ ```
291
+
292
+ ### Retrying failed children
293
+
294
+ When a child fails, the parent can spawn a replacement and continue polling. Track restart counts to prevent infinite retry loops.
295
+
296
+ ```typescript
297
+ import { sleep } from "workflow";
298
+
299
+ declare function checkDetailedStatuses(runIds: string[]): Promise<Array<{ runId: string; status: string }>>; // @setup
300
+
301
+ const POLL_INTERVAL = "30s";
302
+ const MAX_POLL_ITERATIONS = 120;
303
+
304
+ async function pollWithRetries(
305
+ initialRunIds: string[],
306
+ maxRestartsPerChild: number,
307
+ spawnReplacement: (index: number) => Promise<string>
308
+ ): Promise<void> {
309
+ const activeRuns = new Map<number, string>();
310
+ const restartCounts = new Map<number, number>();
311
+
312
+ initialRunIds.forEach((runId, index) => activeRuns.set(index, runId));
313
+
314
+ let iteration = 0;
315
+
316
+ while (iteration < MAX_POLL_ITERATIONS) {
317
+ const statuses = await checkDetailedStatuses(
318
+ Array.from(activeRuns.values())
319
+ );
320
+ const statusByRunId = new Map(
321
+ statuses.map((s) => [s.runId, s.status])
322
+ );
323
+
324
+ for (const [index, runId] of activeRuns.entries()) {
325
+ const status = statusByRunId.get(runId) ?? "running";
326
+
327
+ if (status === "completed") {
328
+ activeRuns.delete(index);
329
+ continue;
330
+ }
331
+
332
+ if (status === "failed" || status === "cancelled") {
333
+ const restarts = (restartCounts.get(index) ?? 0) + 1; // [!code highlight]
334
+ restartCounts.set(index, restarts); // [!code highlight]
335
+
336
+ if (restarts > maxRestartsPerChild) { // [!code highlight]
337
+ throw new Error( // [!code highlight]
338
+ `Child ${index} exceeded restart limit (${maxRestartsPerChild})` // [!code highlight]
339
+ ); // [!code highlight]
340
+ } // [!code highlight]
341
+
342
+ const newRunId = await spawnReplacement(index); // [!code highlight]
343
+ activeRuns.set(index, newRunId); // [!code highlight]
344
+ }
345
+ }
346
+
347
+ if (activeRuns.size === 0) return;
348
+
349
+ iteration += 1;
350
+ await sleep(POLL_INTERVAL);
351
+ }
352
+
353
+ throw new Error("Timed out waiting for children");
354
+ }
355
+ ```
356
+
357
+ ## Tips
358
+
359
+ - **`start()` must be called from a step**, not directly from a workflow function. Wrap it in a `"use step"` function.
360
+ - **`getRun()` must also be called from a step.** The polling loop lives in the workflow, but the actual status check is a step.
361
+ - **Set a max iteration count on polling loops** to prevent runaway workflows. Calculate the count from your expected max duration and poll interval.
362
+ - **Use chunked spawning for large batches.** Spawning 500 children in a single step can time out. Break it into chunks of 10-50.
363
+ - **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent only sees the child's final status.
364
+ - **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations.
365
+
366
+ ## Key APIs
367
+
368
+ - [`start()`](/docs/api-reference/workflow-api/start) -- spawn a new workflow run and get its run ID
369
+ - [`getRun()`](/docs/api-reference/workflow-api/get-run) -- retrieve a workflow run's status and return value
370
+ - [`sleep()`](/docs/api-reference/workflow/sleep) -- durably pause between polling iterations
371
+ - [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function
372
+ - [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access
@@ -0,0 +1,318 @@
1
+ ---
2
+ title: Distributed Abort Controller
3
+ description: A distributed AbortController that uses durable workflows for cross-process cancellation signaling.
4
+ type: guide
5
+ summary: Build a distributed abort controller that uses workflow streams and hooks to propagate cancellation signals across process boundaries.
6
+ ---
7
+
8
+ Use this pattern when you need an `AbortController`-like interface that works across distributed systems. The controller uses a durable workflow to coordinate cancellation — calling `.abort()` on one machine triggers the `.signal` on any other machine.
9
+
10
+ ## When to use this
11
+
12
+ - **Cross-process cancellation** — Cancel a long-running operation from a different server, worker, or edge function
13
+ - **Durable cancellation** — The abort signal persists even if the process that created it crashes
14
+ - **UI stop buttons** — Let users cancel operations running on the server from the browser
15
+ - **Timeout coordination** — The built-in TTL auto-expires stale controllers
16
+
17
+ ## Pattern
18
+
19
+ The `DistributedAbortController` class encapsulates a workflow that:
20
+ 1. Accepts a user-provided unique ID (like a chat ID or task ID)
21
+ 2. Creates or reconnects to an existing workflow using that ID
22
+ 3. Waits for a hook signal OR TTL expiration
23
+ 4. Writes a cancellation message to the run's stream when triggered
24
+
25
+ ### Core Implementation
26
+
27
+ ```typescript lineNumbers
28
+ import { defineHook, getWritable, sleep } from "workflow";
29
+ import { start, getRun, getHookByToken } from "workflow/api";
30
+
31
+ // Default TTL: 24 hours
32
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
33
+ // Default grace period: 1 hour (keeps hook alive after abort for late subscribers)
34
+ const DEFAULT_GRACE_MS = 60 * 60 * 1000;
35
+
36
+ // Hook to trigger the abort signal
37
+ export const abortHook = defineHook<{ reason?: string }>();
38
+
39
+ // The abort message written to the stream
40
+ export type AbortMessage = {
41
+ type: "abort";
42
+ reason?: string;
43
+ expired?: boolean;
44
+ };
45
+
46
+ // Helper to create a consistent hook token from the user ID
47
+ function getAbortToken(id: string): string {
48
+ return `abort:${id}`;
49
+ }
50
+
51
+ // Step function that writes the abort message to the stream
52
+ async function writeAbortSignal(reason?: string, expired?: boolean) {
53
+ "use step";
54
+
55
+ const writable = getWritable<AbortMessage>();
56
+ const writer = writable.getWriter();
57
+ try {
58
+ await writer.write({ type: "abort", reason, expired });
59
+ } finally {
60
+ writer.releaseLock();
61
+ }
62
+ await writable.close();
63
+ }
64
+
65
+ // Workflow that waits for abort or TTL expiration
66
+ export async function abortControllerWorkflow(
67
+ id: string,
68
+ ttlMs: number,
69
+ graceMs: number
70
+ ) {
71
+ "use workflow";
72
+
73
+ const startTime = Date.now();
74
+ const hook = abortHook.create({ token: getAbortToken(id) });
75
+
76
+ // Race: manual abort OR TTL expiration // [!code highlight]
77
+ const result = await Promise.race([
78
+ hook.then((payload) => ({
79
+ reason: payload.reason,
80
+ expired: false,
81
+ })),
82
+ sleep(`${ttlMs}ms`).then(() => ({
83
+ reason: "Controller expired",
84
+ expired: true,
85
+ })),
86
+ ]);
87
+
88
+ await writeAbortSignal(result.reason, result.expired);
89
+
90
+ // Only sleep through grace period on TTL expiration (keeps hook alive for late subscribers). // [!code highlight]
91
+ // Manual aborts complete immediately.
92
+ if (result.expired) {
93
+ const elapsed = Date.now() - startTime;
94
+ const remainingTime = graceMs - (elapsed - ttlMs);
95
+ if (remainingTime > 0) {
96
+ await sleep(`${remainingTime}ms`); // [!code highlight]
97
+ }
98
+ }
99
+
100
+ return { aborted: true, reason: result.reason, expired: result.expired };
101
+ }
102
+
103
+ /**
104
+ * A distributed abort controller that works across process boundaries.
105
+ * Uses a semantically meaningful ID (like a chat ID or task ID) to coordinate.
106
+ */
107
+ export class DistributedAbortController {
108
+ private id: string;
109
+ readonly runId: string;
110
+
111
+ private constructor(id: string, runId: string) {
112
+ this.id = id;
113
+ this.runId = runId;
114
+ }
115
+
116
+ /**
117
+ * Creates or reconnects to a distributed abort controller.
118
+ * If a controller with this ID already exists, reconnects to it.
119
+ * Otherwise, starts a new workflow.
120
+ *
121
+ * @param id - A unique, semantically meaningful ID (e.g., "chat:123")
122
+ * @param options.ttlMs - Time-to-live in ms (default: 24 hours)
123
+ * @param options.graceMs - Grace period after abort (default: 1 hour)
124
+ */
125
+ static async create( // [!code highlight]
126
+ id: string,
127
+ options: { ttlMs?: number; graceMs?: number } = {}
128
+ ): Promise<DistributedAbortController> {
129
+ const { ttlMs = DEFAULT_TTL_MS, graceMs = DEFAULT_GRACE_MS } = options;
130
+ const token = getAbortToken(id);
131
+
132
+ // Try to find an existing run with this hook token
133
+ const existingHook = await getHookByToken(token).catch(() => null); // [!code highlight]
134
+
135
+ if (existingHook) {
136
+ // Reconnect to existing controller
137
+ return new DistributedAbortController(id, existingHook.runId);
138
+ }
139
+
140
+ // Create a new workflow
141
+ const run = await start(abortControllerWorkflow, [id, ttlMs, graceMs]); // [!code highlight]
142
+ return new DistributedAbortController(id, run.runId);
143
+ }
144
+
145
+ /**
146
+ * Triggers the abort signal.
147
+ * Idempotent: safe to call multiple times or after the workflow has completed.
148
+ */
149
+ async abort(reason?: string): Promise<void> { // [!code highlight]
150
+ try {
151
+ await abortHook.resume(getAbortToken(this.id), { reason });
152
+ } catch (error) {
153
+ const msg = error instanceof Error ? error.message.toLowerCase() : '';
154
+ if (msg.includes('not found') || msg.includes('expired')) {
155
+ return;
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Returns an AbortSignal that fires when abort() is called or TTL expires.
163
+ * The signal fires with a reason indicating what triggered it.
164
+ */
165
+ get signal(): AbortSignal { // [!code highlight]
166
+ const run = getRun<{ aborted: boolean; reason?: string; expired?: boolean }>(this.runId);
167
+ const controller = new AbortController();
168
+ const readable = run.getReadable<AbortMessage>();
169
+
170
+ (async () => {
171
+ const reader = readable.getReader();
172
+ try {
173
+ while (true) {
174
+ const { done, value } = await reader.read();
175
+ if (done) break;
176
+ if (value.type === "abort") {
177
+ const reason = value.expired
178
+ ? `${value.reason} (expired)`
179
+ : value.reason;
180
+ controller.abort(reason);
181
+ break;
182
+ }
183
+ }
184
+ } catch (error) {
185
+ if (!controller.signal.aborted) {
186
+ controller.abort(
187
+ error instanceof Error ? error.message : "Stream read failed"
188
+ );
189
+ }
190
+ } finally {
191
+ reader.releaseLock();
192
+ }
193
+ })();
194
+
195
+ return controller.signal;
196
+ }
197
+ }
198
+ ```
199
+
200
+ ### Usage: Single Process
201
+
202
+ ```typescript lineNumbers
203
+ import { DistributedAbortController } from "./distributed-abort-controller";
204
+
205
+ // Create a controller with a meaningful ID
206
+ const controller = await DistributedAbortController.create("chat:user-123");
207
+
208
+ // Get the signal and use it with fetch
209
+ const signal = controller.signal;
210
+ const response = await fetch("https://api.example.com/long-operation", {
211
+ signal,
212
+ });
213
+
214
+ // Later: abort the operation
215
+ await controller.abort("User cancelled");
216
+ ```
217
+
218
+ ### Usage: Cross-Process Coordination
219
+
220
+ ```typescript lineNumbers
221
+ import { DistributedAbortController } from "./distributed-abort-controller";
222
+
223
+ // Process A: Create the controller
224
+ const controller = await DistributedAbortController.create("task:build-123");
225
+ // start long operation using controller.signal...
226
+
227
+ // Process B: Reconnect and abort (no run ID sharing needed!)
228
+ const sameController = await DistributedAbortController.create("task:build-123"); // [!code highlight]
229
+ await sameController.abort("Cancelled by admin");
230
+
231
+ // Process C: Reconnect and listen
232
+ const anotherRef = await DistributedAbortController.create("task:build-123");
233
+ anotherRef.signal.addEventListener("abort", (e) => {
234
+ console.log("Task was cancelled:", (e.target as AbortSignal).reason);
235
+ });
236
+ ```
237
+
238
+ ### Custom TTL
239
+
240
+ ```typescript lineNumbers
241
+ import { DistributedAbortController } from "./distributed-abort-controller";
242
+
243
+ // Short-lived controller for a quick operation (5 minutes)
244
+ const shortLived = await DistributedAbortController.create("quick-task", {
245
+ ttlMs: 5 * 60 * 1000,
246
+ });
247
+
248
+ // Long-lived controller for batch jobs (7 days)
249
+ const longLived = await DistributedAbortController.create("batch-job", {
250
+ ttlMs: 7 * 24 * 60 * 60 * 1000,
251
+ });
252
+
253
+ // When TTL expires, the signal fires with expired reason
254
+ shortLived.signal.addEventListener("abort", (e) => {
255
+ const reason = (e.target as AbortSignal).reason;
256
+ if (reason?.includes("expired")) {
257
+ console.log("Controller expired, cleaning up...");
258
+ }
259
+ });
260
+ ```
261
+
262
+ ### API Route for Remote Abort
263
+
264
+ ```typescript lineNumbers
265
+ import { DistributedAbortController } from "@/lib/distributed-abort-controller";
266
+
267
+ export async function POST(
268
+ request: Request,
269
+ { params }: { params: Promise<{ id: string }> }
270
+ ) {
271
+ const { id } = await params;
272
+ const { reason } = await request.json();
273
+
274
+ const controller = await DistributedAbortController.create(id);
275
+ await controller.abort(reason || "Cancelled via API");
276
+
277
+ return Response.json({ success: true });
278
+ }
279
+ ```
280
+
281
+ ### Client Cancel Button
282
+
283
+ ```tsx lineNumbers
284
+ "use client";
285
+
286
+ export function CancelButton({ taskId }: { taskId: string }) {
287
+ const handleCancel = async () => {
288
+ await fetch(`/api/abort/${taskId}`, {
289
+ method: "POST",
290
+ headers: { "Content-Type": "application/json" },
291
+ body: JSON.stringify({ reason: "User clicked cancel" }),
292
+ });
293
+ };
294
+
295
+ return (
296
+ <button type="button" onClick={handleCancel}>
297
+ Cancel Operation
298
+ </button>
299
+ );
300
+ }
301
+ ```
302
+
303
+ ## Tips
304
+
305
+ - **Use semantic IDs** — Use meaningful IDs like `chat:123` or `task:abc` instead of random UUIDs
306
+ - **Create is idempotent** — Calling `create()` with the same ID reconnects to the existing controller
307
+ - **TTL auto-cleanup** — Workflows self-terminate after TTL expires; no manual cleanup needed
308
+ - **Signal is a getter** — Each access to `.signal` creates a new listener; cache it if needed
309
+ - **One-shot** — Once aborted or expired, the workflow completes; create a new controller for new operations
310
+
311
+ ## Key APIs
312
+
313
+ - [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the abort trigger
314
+ - [`getWritable()`](/docs/api-reference/workflow/get-writable) — write abort messages to the stream
315
+ - [`sleep()`](/docs/api-reference/workflow/sleep) — TTL timer for auto-expiration
316
+ - [`start()`](/docs/api-reference/workflow-api/start) — start the abort controller workflow
317
+ - [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) — find existing run by hook token
318
+ - [`getRun()`](/docs/api-reference/workflow-api/get-run) — reconnect to the workflow's readable stream
@@ -0,0 +1,9 @@
1
+ {
2
+ "title": "Advanced",
3
+ "pages": [
4
+ "child-workflows",
5
+ "distributed-abort-controller",
6
+ "serializable-steps",
7
+ "publishing-libraries"
8
+ ]
9
+ }