workflow 5.0.0-beta.1 → 5.0.0-beta.11
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/README.md +4 -4
- package/dist/api-workflow.d.ts +1 -1
- package/dist/api-workflow.d.ts.map +1 -1
- package/dist/api-workflow.js +2 -2
- package/dist/api.d.ts +5 -1
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +14 -2
- package/dist/astro.js +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/builtins.d.ts +17 -0
- package/dist/internal/builtins.d.ts.map +1 -1
- package/dist/internal/builtins.js +65 -1
- package/dist/internal/class-serialization.js +1 -1
- package/dist/internal/errors.js +1 -1
- package/dist/nest.js +1 -1
- package/dist/next.cjs +1 -1
- package/dist/nitro.js +1 -1
- package/dist/nuxt.js +1 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +2 -2
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +2 -2
- package/dist/stdlib.js +1 -1
- package/dist/sveltekit.js +1 -1
- package/dist/typescript-plugin.cjs +1 -1
- package/dist/vite.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/ai/index.mdx +6 -5
- package/docs/ai/resumable-streams.mdx +1 -1
- package/docs/api-reference/vitest/index.mdx +28 -1
- package/docs/api-reference/workflow/create-webhook.mdx +37 -18
- package/docs/api-reference/workflow/experimental-set-attributes.mdx +63 -0
- package/docs/api-reference/workflow/get-workflow-metadata.mdx +34 -0
- package/docs/api-reference/workflow/index.mdx +3 -0
- package/docs/api-reference/workflow-ai/durable-agent.mdx +0 -4
- package/docs/api-reference/workflow-ai/index.mdx +0 -5
- package/docs/api-reference/workflow-ai/workflow-chat-transport.mdx +0 -4
- package/docs/api-reference/workflow-api/start.mdx +5 -4
- package/docs/api-reference/workflow-errors/workflow-run-failed-error.mdx +16 -6
- package/docs/api-reference/workflow-next/with-workflow.mdx +34 -2
- package/docs/changelog/attributes-mvp.mdx +368 -0
- package/docs/changelog/eager-processing.mdx +595 -0
- package/docs/changelog/index.mdx +2 -1
- package/docs/cookbook/advanced/child-workflows.mdx +313 -0
- package/docs/cookbook/advanced/meta.json +9 -0
- package/docs/cookbook/advanced/publishing-libraries.mdx +336 -0
- package/docs/cookbook/advanced/serializable-steps.mdx +147 -0
- package/docs/cookbook/advanced/upgrading-workflows.mdx +195 -0
- package/docs/cookbook/agent-patterns/agent-cancellation.mdx +156 -0
- package/docs/cookbook/agent-patterns/durable-agent.mdx +154 -0
- package/docs/cookbook/agent-patterns/human-in-the-loop.mdx +255 -0
- package/docs/cookbook/agent-patterns/meta.json +4 -0
- package/docs/cookbook/common-patterns/batching.mdx +105 -0
- package/docs/cookbook/common-patterns/idempotency.mdx +107 -0
- package/docs/cookbook/common-patterns/meta.json +15 -0
- package/docs/cookbook/common-patterns/rate-limiting.mdx +228 -0
- package/docs/cookbook/common-patterns/saga.mdx +247 -0
- package/docs/cookbook/common-patterns/scheduling.mdx +129 -0
- package/docs/cookbook/common-patterns/sequential-and-parallel.mdx +155 -0
- package/docs/cookbook/common-patterns/timeouts.mdx +99 -0
- package/docs/cookbook/common-patterns/webhooks.mdx +185 -0
- package/docs/cookbook/common-patterns/workflow-composition.mdx +111 -0
- package/docs/cookbook/index.mdx +38 -0
- package/docs/cookbook/integrations/ai-sdk.mdx +383 -0
- package/docs/cookbook/integrations/chat-sdk.mdx +307 -0
- package/docs/cookbook/integrations/meta.json +4 -0
- package/docs/cookbook/integrations/sandbox.mdx +520 -0
- package/docs/cookbook/meta.json +5 -0
- package/docs/deploying/building-a-world.mdx +1 -1
- package/docs/deploying/world/local-world.mdx +1 -1
- package/docs/deploying/world/postgres-world.mdx +6 -4
- package/docs/deploying/world/vercel-world.mdx +3 -1
- package/docs/errors/abort-signal-timeout-in-workflow.mdx +80 -0
- package/docs/errors/hook-conflict.mdx +56 -4
- package/docs/errors/index.mdx +3 -0
- package/docs/errors/runtime-decryption-failed.mdx +77 -0
- package/docs/errors/start-invalid-workflow-function.mdx +1 -1
- package/docs/foundations/cancellation.mdx +460 -0
- package/docs/foundations/errors-and-retries.mdx +7 -3
- package/docs/foundations/index.mdx +3 -3
- package/docs/foundations/meta.json +3 -2
- package/docs/foundations/serialization.mdx +78 -42
- package/docs/foundations/starting-workflows.mdx +6 -2
- package/docs/foundations/versioning.mdx +263 -0
- package/docs/getting-started/astro.mdx +6 -0
- package/docs/getting-started/index.mdx +13 -7
- package/docs/getting-started/meta.json +3 -1
- package/docs/getting-started/nestjs.mdx +8 -0
- package/docs/getting-started/next.mdx +5 -3
- package/docs/getting-started/nitro.mdx +22 -0
- package/docs/getting-started/python.mdx +165 -0
- package/docs/getting-started/sveltekit.mdx +6 -0
- package/docs/getting-started/tanstack-start.mdx +241 -0
- package/docs/how-it-works/cancellation.mdx +287 -0
- package/docs/how-it-works/code-transform.mdx +2 -2
- package/docs/how-it-works/event-sourcing.mdx +2 -2
- package/docs/how-it-works/meta.json +2 -1
- package/docs/internal/index.mdx +19 -0
- package/docs/internal/meta.json +5 -0
- package/docs/internal/serializable-abort-controller.mdx +148 -0
- package/docs/meta.json +1 -0
- package/docs/migration-guides/index.mdx +34 -0
- package/docs/migration-guides/meta.json +9 -0
- package/docs/migration-guides/migrating-from-aws-step-functions.mdx +358 -0
- package/docs/migration-guides/migrating-from-inngest.mdx +304 -0
- package/docs/migration-guides/migrating-from-temporal.mdx +313 -0
- package/docs/migration-guides/migrating-from-trigger-dev.mdx +328 -0
- package/docs/observability/attributes.mdx +66 -0
- package/docs/observability/index.mdx +9 -1
- package/docs/observability/meta.json +1 -1
- package/package.json +14 -13
- package/docs/foundations/common-patterns.mdx +0 -265
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Sandbox
|
|
3
|
+
description: Model one Vercel Sandbox per workflow run — durable, idle-efficient, and not bound by the 5-hour sandbox hard cap.
|
|
4
|
+
type: guide
|
|
5
|
+
summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by runId — so one logical session can run effectively forever.
|
|
6
|
+
related:
|
|
7
|
+
- /docs/ai/defining-tools
|
|
8
|
+
- /docs/foundations/errors-and-retries
|
|
9
|
+
- /docs/cookbook/common-patterns/scheduling
|
|
10
|
+
- /docs/cookbook/agent-patterns/durable-agent
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK — the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function.
|
|
14
|
+
|
|
15
|
+
## Why Workflow + Sandbox
|
|
16
|
+
|
|
17
|
+
A sandbox alone gets you an isolated VM. A workflow around it gets you a **durable controller** for that VM's entire lifetime:
|
|
18
|
+
|
|
19
|
+
- **One workflow run = one sandbox session.** The `runId` is the only state you need to persist on the client. Close the tab, come back a week later, POST the same `runId` and you're back in the same session.
|
|
20
|
+
- **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer — when idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. Next command → spin a new sandbox from the snapshot with filesystem, installed packages, and git history intact.
|
|
21
|
+
- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure.
|
|
22
|
+
- **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy.
|
|
23
|
+
|
|
24
|
+
<Callout type="info">
|
|
25
|
+
An effectively unbounded sandbox session is still one workflow run, so it stays on the deployment that started it. If the controller or agent code should upgrade over time, use an explicit version boundary and pass the serialized state or stream handles forward. See [Versioning](/docs/foundations/versioning).
|
|
26
|
+
</Callout>
|
|
27
|
+
|
|
28
|
+
## Use Case: Coding Agents
|
|
29
|
+
|
|
30
|
+
This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return.
|
|
31
|
+
|
|
32
|
+
Most coding-agent workloads look like this:
|
|
33
|
+
|
|
34
|
+
- User sends a task → agent plans, reads files, runs shell commands, commits.
|
|
35
|
+
- User walks away mid-run → agent keeps going, eventually goes idle waiting for input.
|
|
36
|
+
- User comes back days later → same branch, same filesystem, same conversation history.
|
|
37
|
+
|
|
38
|
+
Without durable workflows you'd need a separate state store for the agent loop, a separate job queue for retries, a separate scheduler for idle cleanup, and bespoke reconnection logic. With the pattern below, all of it is one file.
|
|
39
|
+
|
|
40
|
+
## Quickstart: One-shot Pipeline
|
|
41
|
+
|
|
42
|
+
Before the full session pattern, the simplest shape. Each sandbox method is an implicit step, so the event log records every command and the workflow replays from the last completed call on restart.
|
|
43
|
+
|
|
44
|
+
```typescript title="workflows/sandbox-pipeline.ts" lineNumbers
|
|
45
|
+
import { Sandbox } from "@vercel/sandbox";
|
|
46
|
+
|
|
47
|
+
export async function sandboxPipeline(input: { commands: string[] }) {
|
|
48
|
+
"use workflow";
|
|
49
|
+
|
|
50
|
+
const sandbox = await Sandbox.create({ runtime: "node22" }); // [!code highlight]
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const command of input.commands) {
|
|
55
|
+
const result = await sandbox.runCommand({ // [!code highlight]
|
|
56
|
+
cmd: "bash",
|
|
57
|
+
args: ["-c", command],
|
|
58
|
+
});
|
|
59
|
+
results.push({
|
|
60
|
+
command,
|
|
61
|
+
exitCode: result.exitCode,
|
|
62
|
+
stdout: await result.stdout(),
|
|
63
|
+
stderr: await result.stderr(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return { status: "completed", results };
|
|
67
|
+
} finally {
|
|
68
|
+
await sandbox.stop(); // [!code highlight]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Session Pattern: Persistent Sandbox Beyond the Hard Cap
|
|
74
|
+
|
|
75
|
+
One workflow run owns a sandbox for its whole lifetime. The workflow's loop does two jobs simultaneously:
|
|
76
|
+
|
|
77
|
+
1. **Command pipeline** — await a hook, run the next user command, stream output, loop.
|
|
78
|
+
2. **Sandbox lifecycle** — race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap).
|
|
79
|
+
|
|
80
|
+
When the timer wins:
|
|
81
|
+
|
|
82
|
+
- **Idle** → `sandbox.snapshot()` and wait indefinitely for the next command. No compute while asleep.
|
|
83
|
+
- **Near sandbox hard cap** → `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session appears continuous; the underlying VM just rotated.
|
|
84
|
+
|
|
85
|
+
The only way out is an explicit `/destroy` command.
|
|
86
|
+
|
|
87
|
+
<Tabs items={['Workflow', 'API Routes', 'Client']}>
|
|
88
|
+
|
|
89
|
+
<Tab value="Workflow">
|
|
90
|
+
|
|
91
|
+
```typescript title="workflows/sandbox-session.ts" lineNumbers
|
|
92
|
+
import { defineHook, sleep, getWritable, getWorkflowMetadata } from "workflow";
|
|
93
|
+
import { Sandbox, type Snapshot } from "@vercel/sandbox";
|
|
94
|
+
import { z } from "zod";
|
|
95
|
+
|
|
96
|
+
export const commandHook = defineHook({ // [!code highlight]
|
|
97
|
+
schema: z.object({ command: z.string() }),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const RUNTIME = "node22";
|
|
101
|
+
const HIBERNATE_AFTER_MS = 30 * 60_000; // 30 min idle → hibernate
|
|
102
|
+
const SANDBOX_TIMEOUT_MS = 5 * 60 * 60_000; // sandbox hard cap (5h)
|
|
103
|
+
const REFRESH_SAFETY_MS = 5 * 60_000; // refresh 5 min before the cap
|
|
104
|
+
|
|
105
|
+
export type SandboxEvent =
|
|
106
|
+
| {
|
|
107
|
+
type: "created";
|
|
108
|
+
sandboxId: string;
|
|
109
|
+
runtime: string;
|
|
110
|
+
startedAt: number;
|
|
111
|
+
sandboxExpiresAt: number;
|
|
112
|
+
hibernateAfterMs: number;
|
|
113
|
+
}
|
|
114
|
+
| {
|
|
115
|
+
type: "status";
|
|
116
|
+
state:
|
|
117
|
+
| "active"
|
|
118
|
+
| "hibernating"
|
|
119
|
+
| "hibernated"
|
|
120
|
+
| "resuming"
|
|
121
|
+
| "refreshing"
|
|
122
|
+
| "destroyed";
|
|
123
|
+
at: number;
|
|
124
|
+
sandboxId?: string;
|
|
125
|
+
sandboxExpiresAt?: number;
|
|
126
|
+
snapshotId?: string;
|
|
127
|
+
}
|
|
128
|
+
| { type: "activity"; at: number }
|
|
129
|
+
| { type: "command_start"; id: string; command: string; at: number }
|
|
130
|
+
| { type: "command_output"; id: string; stream: "stdout" | "stderr"; data: string }
|
|
131
|
+
| { type: "command_end"; id: string; exitCode: number | null; durationMs: number }
|
|
132
|
+
| { type: "result"; status: "destroyed"; durationMs: number };
|
|
133
|
+
|
|
134
|
+
async function emit(event: SandboxEvent) {
|
|
135
|
+
"use step";
|
|
136
|
+
const writer = getWritable<SandboxEvent>().getWriter();
|
|
137
|
+
try {
|
|
138
|
+
await writer.write(event);
|
|
139
|
+
} finally {
|
|
140
|
+
writer.releaseLock();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runCommandAndStream(sandbox: Sandbox, id: string, command: string) {
|
|
145
|
+
"use step";
|
|
146
|
+
const writer = getWritable<SandboxEvent>().getWriter();
|
|
147
|
+
const startedAt = Date.now();
|
|
148
|
+
try {
|
|
149
|
+
await writer.write({ type: "command_start", id, command, at: startedAt });
|
|
150
|
+
const result = await sandbox.runCommand({ cmd: "bash", args: ["-c", command] });
|
|
151
|
+
const stdout = await result.stdout();
|
|
152
|
+
if (stdout) await writer.write({ type: "command_output", id, stream: "stdout", data: stdout });
|
|
153
|
+
const stderr = await result.stderr();
|
|
154
|
+
if (stderr) await writer.write({ type: "command_output", id, stream: "stderr", data: stderr });
|
|
155
|
+
await writer.write({
|
|
156
|
+
type: "command_end", id,
|
|
157
|
+
exitCode: result.exitCode,
|
|
158
|
+
durationMs: Date.now() - startedAt,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
writer.releaseLock();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function sandboxSessionWorkflow() {
|
|
166
|
+
"use workflow";
|
|
167
|
+
|
|
168
|
+
const { workflowRunId } = getWorkflowMetadata();
|
|
169
|
+
// Create the hook once, outside the loop — reusing the same token from inside // [!code highlight]
|
|
170
|
+
// the loop would throw HookConflictError. // [!code highlight]
|
|
171
|
+
const hook = commandHook.create({ token: workflowRunId });
|
|
172
|
+
|
|
173
|
+
const startedAt = Date.now();
|
|
174
|
+
|
|
175
|
+
let sandbox: Sandbox = await Sandbox.create({
|
|
176
|
+
runtime: RUNTIME,
|
|
177
|
+
timeout: SANDBOX_TIMEOUT_MS,
|
|
178
|
+
});
|
|
179
|
+
let sandboxCreatedAt = Date.now();
|
|
180
|
+
let sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
|
|
181
|
+
|
|
182
|
+
await emit({
|
|
183
|
+
type: "created",
|
|
184
|
+
sandboxId: sandbox.sandboxId,
|
|
185
|
+
runtime: RUNTIME,
|
|
186
|
+
startedAt,
|
|
187
|
+
sandboxExpiresAt,
|
|
188
|
+
hibernateAfterMs: HIBERNATE_AFTER_MS,
|
|
189
|
+
});
|
|
190
|
+
await emit({
|
|
191
|
+
type: "status", state: "active", at: Date.now(),
|
|
192
|
+
sandboxId: sandbox.sandboxId, sandboxExpiresAt,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
let snapshot: Snapshot | null = null;
|
|
196
|
+
let hibernated = false;
|
|
197
|
+
let lastActivityAt = startedAt;
|
|
198
|
+
let counter = 0;
|
|
199
|
+
let destroyed = false;
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
while (!destroyed) {
|
|
203
|
+
if (hibernated && snapshot) {
|
|
204
|
+
// While hibernated, the VM is already stopped. Just wait for the next
|
|
205
|
+
// command — no idle timer, no compute cost.
|
|
206
|
+
const payload = await hook;
|
|
207
|
+
if (payload.command === "/destroy") { destroyed = true; break; }
|
|
208
|
+
|
|
209
|
+
await emit({ type: "status", state: "resuming", at: Date.now() });
|
|
210
|
+
sandbox = await Sandbox.create({ // [!code highlight]
|
|
211
|
+
source: { type: "snapshot", snapshotId: snapshot.snapshotId }, // [!code highlight]
|
|
212
|
+
timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
|
|
213
|
+
});
|
|
214
|
+
sandboxCreatedAt = Date.now();
|
|
215
|
+
sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
|
|
216
|
+
hibernated = false;
|
|
217
|
+
snapshot = null;
|
|
218
|
+
await emit({
|
|
219
|
+
type: "status", state: "active", at: Date.now(),
|
|
220
|
+
sandboxId: sandbox.sandboxId, sandboxExpiresAt,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
counter += 1;
|
|
224
|
+
await runCommandAndStream(sandbox, `cmd-${counter}`, payload.command);
|
|
225
|
+
lastActivityAt = Date.now();
|
|
226
|
+
await emit({ type: "activity", at: lastActivityAt });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Active — wake at whichever comes first: idle-deadline or refresh-deadline.
|
|
231
|
+
const idleDeadline = lastActivityAt + HIBERNATE_AFTER_MS;
|
|
232
|
+
const refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS;
|
|
233
|
+
const wakeAt = Math.min(idleDeadline, refreshDeadline);
|
|
234
|
+
const sleepMs = Math.max(0, wakeAt - Date.now());
|
|
235
|
+
|
|
236
|
+
const outcome = await Promise.race([ // [!code highlight]
|
|
237
|
+
hook.then((p) => ({ type: "command" as const, command: p.command })),
|
|
238
|
+
sleep(`${sleepMs}ms`).then(() => ({ type: "timer" as const })),
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
if (outcome.type === "timer") {
|
|
242
|
+
const nearExpiry = Date.now() >= refreshDeadline;
|
|
243
|
+
|
|
244
|
+
if (nearExpiry) {
|
|
245
|
+
// Proactive refresh — snapshot and immediately recreate so the
|
|
246
|
+
// session outlives the sandbox hard cap.
|
|
247
|
+
await emit({ type: "status", state: "refreshing", at: Date.now() });
|
|
248
|
+
const snap = await sandbox.snapshot(); // [!code highlight]
|
|
249
|
+
sandbox = await Sandbox.create({ // [!code highlight]
|
|
250
|
+
source: { type: "snapshot", snapshotId: snap.snapshotId }, // [!code highlight]
|
|
251
|
+
timeout: SANDBOX_TIMEOUT_MS, // [!code highlight]
|
|
252
|
+
});
|
|
253
|
+
sandboxCreatedAt = Date.now();
|
|
254
|
+
sandboxExpiresAt = sandboxCreatedAt + SANDBOX_TIMEOUT_MS;
|
|
255
|
+
await emit({
|
|
256
|
+
type: "status", state: "active", at: Date.now(),
|
|
257
|
+
sandboxId: sandbox.sandboxId, sandboxExpiresAt,
|
|
258
|
+
snapshotId: snap.snapshotId,
|
|
259
|
+
});
|
|
260
|
+
lastActivityAt = Date.now();
|
|
261
|
+
} else {
|
|
262
|
+
// Idle — snapshot and hibernate indefinitely.
|
|
263
|
+
await emit({ type: "status", state: "hibernating", at: Date.now() });
|
|
264
|
+
snapshot = await sandbox.snapshot(); // [!code highlight]
|
|
265
|
+
hibernated = true;
|
|
266
|
+
await emit({
|
|
267
|
+
type: "status", state: "hibernated", at: Date.now(),
|
|
268
|
+
snapshotId: snapshot.snapshotId,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (outcome.command === "/destroy") { destroyed = true; break; }
|
|
275
|
+
|
|
276
|
+
counter += 1;
|
|
277
|
+
await runCommandAndStream(sandbox, `cmd-${counter}`, outcome.command);
|
|
278
|
+
lastActivityAt = Date.now();
|
|
279
|
+
await emit({ type: "activity", at: lastActivityAt });
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
if (!hibernated) {
|
|
283
|
+
try {
|
|
284
|
+
if (sandbox.status === "running") await sandbox.stop();
|
|
285
|
+
} catch { /* best-effort */ }
|
|
286
|
+
}
|
|
287
|
+
await emit({ type: "status", state: "destroyed", at: Date.now() });
|
|
288
|
+
await emit({
|
|
289
|
+
type: "result",
|
|
290
|
+
status: "destroyed",
|
|
291
|
+
durationMs: Date.now() - startedAt,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
</Tab>
|
|
298
|
+
|
|
299
|
+
<Tab value="API Routes">
|
|
300
|
+
|
|
301
|
+
Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream.
|
|
302
|
+
|
|
303
|
+
```typescript title="app/api/sandbox/start/route.ts" lineNumbers
|
|
304
|
+
import { start, getRun } from "workflow/api";
|
|
305
|
+
import { sandboxSessionWorkflow } from "@/workflows/sandbox-session";
|
|
306
|
+
|
|
307
|
+
export async function POST(req: Request) {
|
|
308
|
+
let body: { runId?: string } = {};
|
|
309
|
+
try {
|
|
310
|
+
const text = await req.text();
|
|
311
|
+
if (text) body = JSON.parse(text);
|
|
312
|
+
} catch { /* ignore malformed body */ }
|
|
313
|
+
|
|
314
|
+
// Reconnect path: if the client sends a known runId, stream the durable
|
|
315
|
+
// event log from the beginning so the UI can rehydrate.
|
|
316
|
+
if (body.runId) {
|
|
317
|
+
const run = getRun(body.runId);
|
|
318
|
+
if (await run.exists) { // [!code highlight]
|
|
319
|
+
const readable = run.getReadable({ startIndex: 0 }); // [!code highlight]
|
|
320
|
+
return new Response(readable.pipeThrough(ndjson()), {
|
|
321
|
+
headers: {
|
|
322
|
+
"Content-Type": "application/x-ndjson",
|
|
323
|
+
"x-workflow-run-id": body.runId,
|
|
324
|
+
"x-workflow-reconnected": "true",
|
|
325
|
+
"Cache-Control": "no-cache, no-transform",
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
// Stale runId — fall through to start fresh.
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const run = await start(sandboxSessionWorkflow, []);
|
|
333
|
+
return new Response(run.readable.pipeThrough(ndjson()), {
|
|
334
|
+
headers: {
|
|
335
|
+
"Content-Type": "application/x-ndjson",
|
|
336
|
+
"x-workflow-run-id": run.runId,
|
|
337
|
+
"Cache-Control": "no-cache, no-transform",
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function ndjson<T>() {
|
|
343
|
+
return new TransformStream<T, string>({
|
|
344
|
+
transform(chunk, controller) {
|
|
345
|
+
controller.enqueue(JSON.stringify(chunk) + "\n");
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
```typescript title="app/api/sandbox/command/route.ts" lineNumbers
|
|
352
|
+
import { commandHook } from "@/workflows/sandbox-session";
|
|
353
|
+
|
|
354
|
+
export async function POST(req: Request) {
|
|
355
|
+
const { runId, command } = (await req.json()) as { runId?: string; command?: string };
|
|
356
|
+
|
|
357
|
+
if (!runId || typeof command !== "string") {
|
|
358
|
+
return Response.json({ error: "runId and command are required" }, { status: 400 });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await commandHook.resume(runId, { command }); // [!code highlight]
|
|
363
|
+
return Response.json({ ok: true });
|
|
364
|
+
} catch (error) {
|
|
365
|
+
const msg = error instanceof Error ? error.message.toLowerCase() : "";
|
|
366
|
+
if (msg.includes("not found") || msg.includes("expired")) {
|
|
367
|
+
return Response.json({ ok: false, note: "session expired" }, { status: 410 });
|
|
368
|
+
}
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
</Tab>
|
|
375
|
+
|
|
376
|
+
<Tab value="Client">
|
|
377
|
+
|
|
378
|
+
On mount, if a `runId` is stashed in `localStorage`, reconnect to the existing run. Otherwise start fresh. Commands are POSTed to `/command` — output lands on the `/start` stream.
|
|
379
|
+
|
|
380
|
+
```tsx title="components/sandbox-runner.tsx" lineNumbers
|
|
381
|
+
"use client";
|
|
382
|
+
|
|
383
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
384
|
+
import type { SandboxEvent } from "@/workflows/sandbox-session";
|
|
385
|
+
|
|
386
|
+
const RUN_ID_KEY = "sandbox.runId";
|
|
387
|
+
|
|
388
|
+
export function SandboxRunner() {
|
|
389
|
+
const [events, setEvents] = useState<SandboxEvent[]>([]);
|
|
390
|
+
const runIdRef = useRef<string | null>(null);
|
|
391
|
+
const didReconnectRef = useRef(false);
|
|
392
|
+
|
|
393
|
+
const consume = useCallback(async (res: Response) => {
|
|
394
|
+
if (!res.ok || !res.body) return;
|
|
395
|
+
runIdRef.current = res.headers.get("x-workflow-run-id");
|
|
396
|
+
if (runIdRef.current) {
|
|
397
|
+
localStorage.setItem(RUN_ID_KEY, runIdRef.current); // [!code highlight]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const reader = res.body.getReader();
|
|
401
|
+
const decoder = new TextDecoder();
|
|
402
|
+
let buffer = "";
|
|
403
|
+
|
|
404
|
+
while (true) {
|
|
405
|
+
const { done, value } = await reader.read();
|
|
406
|
+
if (done) break;
|
|
407
|
+
buffer += decoder.decode(value, { stream: true });
|
|
408
|
+
const lines = buffer.split("\n");
|
|
409
|
+
buffer = lines.pop() ?? "";
|
|
410
|
+
for (const line of lines) {
|
|
411
|
+
if (!line.trim()) continue;
|
|
412
|
+
try {
|
|
413
|
+
setEvents((prev) => [...prev, JSON.parse(line) as SandboxEvent]);
|
|
414
|
+
} catch { /* malformed line */ }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}, []);
|
|
418
|
+
|
|
419
|
+
const openStream = useCallback(
|
|
420
|
+
async (runId?: string) => {
|
|
421
|
+
setEvents([]);
|
|
422
|
+
const res = await fetch("/api/sandbox/start", {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: runId ? { "Content-Type": "application/json" } : undefined,
|
|
425
|
+
body: runId ? JSON.stringify({ runId }) : undefined,
|
|
426
|
+
});
|
|
427
|
+
await consume(res);
|
|
428
|
+
},
|
|
429
|
+
[consume]
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// Auto-reconnect on mount if a runId is stashed.
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (didReconnectRef.current) return;
|
|
435
|
+
didReconnectRef.current = true;
|
|
436
|
+
const stored = localStorage.getItem(RUN_ID_KEY);
|
|
437
|
+
if (stored) openStream(stored); // [!code highlight]
|
|
438
|
+
}, [openStream]);
|
|
439
|
+
|
|
440
|
+
const start = useCallback(() => {
|
|
441
|
+
localStorage.removeItem(RUN_ID_KEY);
|
|
442
|
+
runIdRef.current = null;
|
|
443
|
+
openStream();
|
|
444
|
+
}, [openStream]);
|
|
445
|
+
|
|
446
|
+
const sendCommand = useCallback(async (command: string) => {
|
|
447
|
+
if (!runIdRef.current) return;
|
|
448
|
+
const res = await fetch("/api/sandbox/command", {
|
|
449
|
+
method: "POST",
|
|
450
|
+
headers: { "Content-Type": "application/json" },
|
|
451
|
+
body: JSON.stringify({ runId: runIdRef.current, command }),
|
|
452
|
+
});
|
|
453
|
+
if (res.status === 410) localStorage.removeItem(RUN_ID_KEY);
|
|
454
|
+
}, []);
|
|
455
|
+
|
|
456
|
+
const destroy = useCallback(async () => {
|
|
457
|
+
await sendCommand("/destroy");
|
|
458
|
+
localStorage.removeItem(RUN_ID_KEY);
|
|
459
|
+
}, [sendCommand]);
|
|
460
|
+
|
|
461
|
+
// Render events as a terminal-style log. Drive UI state from `status` events
|
|
462
|
+
// (active / hibernating / hibernated / resuming / refreshing / destroyed).
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
</Tab>
|
|
468
|
+
|
|
469
|
+
</Tabs>
|
|
470
|
+
|
|
471
|
+
## How It Works
|
|
472
|
+
|
|
473
|
+
1. **One workflow = one session.** The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember.
|
|
474
|
+
2. **Hook created once.** `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`.
|
|
475
|
+
3. **Two timer branches.** The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone — no timer, no compute.
|
|
476
|
+
4. **Proactive refresh.** `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS`. Hitting this triggers a snapshot + immediate new sandbox from that snapshot, rolling over the hard cap without user intervention.
|
|
477
|
+
5. **`sandbox.snapshot()` stops the VM.** It's documented as part of the snapshot process — don't call `stop()` separately.
|
|
478
|
+
6. **Resume = new sandbox.** `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a fresh VM from the snapshot. The new sandbox has a different `sandboxId`; filesystem, installed packages, and git history are preserved.
|
|
479
|
+
7. **Reconnect by runId.** `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, who rebuilds UI state from the replay.
|
|
480
|
+
8. **Exit only on `/destroy`.** The workflow loop has no hard deadline of its own. Individual sandboxes time out; the session doesn't.
|
|
481
|
+
|
|
482
|
+
## Pitfalls
|
|
483
|
+
|
|
484
|
+
### `sandbox.stop()` is terminal
|
|
485
|
+
|
|
486
|
+
A stopped sandbox cannot be restarted — you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later.
|
|
487
|
+
|
|
488
|
+
### `snapshot()` already stops the VM
|
|
489
|
+
|
|
490
|
+
Calling `stop()` after `snapshot()` either errors or is a no-op depending on timing. Snapshot takes care of it.
|
|
491
|
+
|
|
492
|
+
### New `sandboxId` after resume and refresh
|
|
493
|
+
|
|
494
|
+
Both `resuming` (idle → command) and `refreshing` (near-hard-cap rotation) create a new sandbox with a new `sandboxId`. Emit it on the subsequent `status: "active"` event and have the UI read from there, not from the initial `created` event.
|
|
495
|
+
|
|
496
|
+
### Keep the refresh margin generous
|
|
497
|
+
|
|
498
|
+
`snapshot()` + `Sandbox.create({ source })` takes real time (typically tens of seconds). If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; 5 minutes is comfortable.
|
|
499
|
+
|
|
500
|
+
### Don't call `writable.close()` inside a workflow function
|
|
501
|
+
|
|
502
|
+
Stream closure must happen inside a `"use step"` function. Calling `writable.close()` directly in the workflow body throws `Not supported in workflow functions`. The runtime closes the underlying writable when the workflow returns.
|
|
503
|
+
|
|
504
|
+
### Handle stale `runId` gracefully
|
|
505
|
+
|
|
506
|
+
Clients can hold `runId`s from long-gone workflow runs (localStorage, back button, server restart). Gate the reconnect path on `run.exists` and fall through to starting fresh. On `hook.resume`, catch `not found` / `expired` and return 410 so the client clears its state.
|
|
507
|
+
|
|
508
|
+
### Keep the hook outside the loop
|
|
509
|
+
|
|
510
|
+
Each iteration's `hook.then(...)` attaches a listener to the same hook instance. Creating a new hook per iteration with the same token throws `HookConflictError`. One hook, one token (`workflowRunId`), reused every iteration.
|
|
511
|
+
|
|
512
|
+
## Key APIs
|
|
513
|
+
|
|
514
|
+
- [`Sandbox.create`](https://vercel.com/docs/sandbox) — provision a VM (runtime, source, timeout)
|
|
515
|
+
- [`sandbox.runCommand`](https://vercel.com/docs/sandbox) — execute a command; implicit step
|
|
516
|
+
- [`sandbox.snapshot`](https://vercel.com/docs/sandbox) — save state and stop the VM; returns `Snapshot`
|
|
517
|
+
- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for user commands
|
|
518
|
+
- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh
|
|
519
|
+
- [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection
|
|
520
|
+
- [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream
|
|
@@ -93,7 +93,7 @@ interface Storage {
|
|
|
93
93
|
|
|
94
94
|
**Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`.
|
|
95
95
|
|
|
96
|
-
**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead.
|
|
96
|
+
**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead and include the active hook owner's run ID as `eventData.conflictingRunId`.
|
|
97
97
|
|
|
98
98
|
**Automatic Hook Disposal:** When a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`), automatically dispose of all associated hooks to release tokens for reuse.
|
|
99
99
|
|
|
@@ -141,7 +141,7 @@ If `WORKFLOW_POSTGRES_URL` is not set, the CLI defaults to `postgres://world:wor
|
|
|
141
141
|
|
|
142
142
|
Learn more in the [Observability](/docs/observability) documentation.
|
|
143
143
|
|
|
144
|
-
## Testing &
|
|
144
|
+
## Testing & Compatibility
|
|
145
145
|
|
|
146
146
|
<WorldTestingPerformance />
|
|
147
147
|
|
|
@@ -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: `
|
|
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:
|
|
183
|
-
maxPoolSize:
|
|
184
|
+
queueConcurrency: 50,
|
|
185
|
+
maxPoolSize: 52, // overrides WORKFLOW_POSTGRES_MAX_POOL_SIZE
|
|
184
186
|
});
|
|
185
187
|
```
|
|
186
188
|
|
|
@@ -83,7 +83,7 @@ npx workflow inspect runs \
|
|
|
83
83
|
|
|
84
84
|
Learn more in the [Observability](/docs/observability) documentation.
|
|
85
85
|
|
|
86
|
-
## Testing &
|
|
86
|
+
## Testing & Compatibility
|
|
87
87
|
|
|
88
88
|
<WorldTestingPerformance />
|
|
89
89
|
|
|
@@ -139,6 +139,8 @@ On Vercel, workflow runs are pegged to the deployment that started them. This me
|
|
|
139
139
|
|
|
140
140
|
This ensures long-running workflows complete reliably without being affected by subsequent deployments.
|
|
141
141
|
|
|
142
|
+
For the full model, including rerunning on latest and explicit upgrade boundaries, see [Versioning](/docs/foundations/versioning).
|
|
143
|
+
|
|
142
144
|
## Security
|
|
143
145
|
|
|
144
146
|
### Consumer function security
|
|
@@ -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`
|