Skip to content

Commit 0295cc6

Browse files
authored
docs: add suspend, resume & snapshot documentation (#754)
1 parent 7425502 commit 0295cc6

5 files changed

Lines changed: 266 additions & 0 deletions

File tree

EXAMPLES.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Runnable examples live in [`examples/`](./examples).
1010
- [Blueprint with Build Context](#blueprint-with-build-context)
1111
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
1212
- [Devbox Mounts (Agent, Code, Object)](#devbox-mounts)
13+
- [Devbox Snapshots (Suspend, Resume, Restore, Delete)](#devbox-snapshots)
1314
- [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel)
1415
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
1516
- [Secrets with Devbox and Agent Gateway](#secrets-with-devbox)
@@ -107,6 +108,37 @@ yarn test:examples
107108

108109
**Source:** [`examples/devbox-mounts.ts`](./examples/devbox-mounts.ts)
109110

111+
<a id="devbox-snapshots"></a>
112+
## Devbox Snapshots (Suspend, Resume, Restore, Delete)
113+
114+
**Use case:** Upload a file to a devbox, preserve it across suspend and resume, create a disk snapshot, restore multiple devboxes from that snapshot, mutate each copy independently, and delete the snapshot when finished.
115+
116+
**Tags:** `devbox`, `snapshot`, `suspend`, `resume`, `files`, `cleanup`
117+
118+
### Workflow
119+
- Create a source devbox
120+
- Upload a file and mutate it into a shared baseline
121+
- Suspend and resume the source devbox
122+
- Create a disk snapshot from the resumed devbox
123+
- Restore two additional devboxes from the same snapshot baseline
124+
- Mutate the same file differently in each devbox to prove isolation
125+
- Shutdown the devboxes and delete the snapshot
126+
127+
### Prerequisites
128+
- `RUNLOOP_API_KEY`
129+
130+
### Run
131+
```sh
132+
yarn tsn -T examples/devbox-snapshots.ts
133+
```
134+
135+
### Test
136+
```sh
137+
yarn test:examples
138+
```
139+
140+
**Source:** [`examples/devbox-snapshots.ts`](./examples/devbox-snapshots.ts)
141+
110142
<a id="devbox-tunnel"></a>
111143
## Devbox Tunnel (HTTP Server Access)
112144

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ Use direct `secrets` injection when code inside the devbox legitimately needs th
7373

7474
For mount patterns, see [`examples/devbox-mounts.ts`](./examples/devbox-mounts.ts). It shows when to use `agent_mount` for reusable agents like Claude Code, `code_mount` for Git repositories such as [`runloopai/rl-cli`](https://github.com/runloopai/rl-cli.git), and `object_mount` for blobs that should appear on the devbox at startup. The example also demonstrates agent gateway wiring for Anthropic credentials plus object TTL, `.tgz` compression, and extraction-on-mount behavior.
7575

76+
For snapshot workflows, see [`examples/devbox-snapshots.ts`](./examples/devbox-snapshots.ts). It uploads a file to a source devbox, shows `suspend()` plus `resume()`, takes a disk snapshot, restores multiple devboxes from the same snapshot baseline, mutates the file independently in each devbox, and deletes the snapshot after the demo completes.
77+
7678
## Agent Guidance
7779

7880
Detailed agent-specific instructions live in [`llms.txt`](./llms.txt). Consolidated recipes for frequent tasks are in [`EXAMPLES.md`](./EXAMPLES.md).

examples/devbox-snapshots.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#!/usr/bin/env -S npm run tsn -T
2+
3+
/**
4+
---
5+
title: Devbox Snapshots (Suspend, Resume, Restore, Delete)
6+
slug: devbox-snapshots
7+
use_case: Upload a file to a devbox, preserve it across suspend and resume, create a disk snapshot, restore multiple devboxes from that snapshot, mutate each copy independently, and delete the snapshot when finished.
8+
workflow:
9+
- Create a source devbox
10+
- Upload a file and mutate it into a shared baseline
11+
- Suspend and resume the source devbox
12+
- Create a disk snapshot from the resumed devbox
13+
- Restore two additional devboxes from the same snapshot baseline
14+
- Mutate the same file differently in each devbox to prove isolation
15+
- Shutdown the devboxes and delete the snapshot
16+
tags:
17+
- devbox
18+
- snapshot
19+
- suspend
20+
- resume
21+
- files
22+
- cleanup
23+
prerequisites:
24+
- RUNLOOP_API_KEY
25+
run: yarn tsn -T examples/devbox-snapshots.ts
26+
test: yarn test:examples
27+
---
28+
*/
29+
30+
import { RunloopSDK, toFile } from '@runloop/api-client';
31+
import { wrapRecipe, runAsCli } from './_harness';
32+
import type { Devbox, Snapshot } from '@runloop/api-client';
33+
import type { RecipeContext, RecipeOutput } from './types';
34+
35+
function uniqueName(prefix: string): string {
36+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
37+
}
38+
39+
const FILE_PATH = '/tmp/snapshot-demo.txt';
40+
41+
type FileReadableDevbox = {
42+
file: {
43+
read(params: { file_path: string }): Promise<string>;
44+
};
45+
};
46+
47+
async function readFileContents(devbox: FileReadableDevbox): Promise<string> {
48+
return devbox.file.read({ file_path: FILE_PATH });
49+
}
50+
51+
export async function recipe(ctx: RecipeContext): Promise<RecipeOutput> {
52+
const { cleanup } = ctx;
53+
54+
const sdk = new RunloopSDK({
55+
bearerToken: process.env['RUNLOOP_API_KEY'],
56+
});
57+
58+
const resourcesCreated: string[] = [];
59+
60+
let sourceDevbox: Devbox | undefined;
61+
let cloneA: Devbox | undefined;
62+
let cloneB: Devbox | undefined;
63+
let snapshot: Snapshot | undefined;
64+
65+
let sourceNeedsCleanup = false;
66+
let cloneANeedsCleanup = false;
67+
let cloneBNeedsCleanup = false;
68+
let snapshotNeedsCleanup = false;
69+
70+
// The harness runs cleanup in LIFO order, so register snapshot cleanup first
71+
// to ensure it runs after any live devboxes have been shut down.
72+
cleanup.add('snapshot:baseline', async () => {
73+
if (snapshotNeedsCleanup && snapshot) {
74+
await snapshot.delete();
75+
}
76+
});
77+
cleanup.add('devbox:source', async () => {
78+
if (sourceNeedsCleanup && sourceDevbox) {
79+
await sourceDevbox.shutdown();
80+
}
81+
});
82+
cleanup.add('devbox:clone-a', async () => {
83+
if (cloneANeedsCleanup && cloneA) {
84+
await cloneA.shutdown();
85+
}
86+
});
87+
cleanup.add('devbox:clone-b', async () => {
88+
if (cloneBNeedsCleanup && cloneB) {
89+
await cloneB.shutdown();
90+
}
91+
});
92+
93+
// Start from a single source devbox.
94+
sourceDevbox = await sdk.devbox.create({
95+
name: uniqueName('snapshot-source'),
96+
launch_parameters: {
97+
resource_size_request: 'X_SMALL',
98+
},
99+
});
100+
sourceNeedsCleanup = true;
101+
resourcesCreated.push(`devbox:${sourceDevbox.id}`);
102+
103+
const uploadedContents = 'uploaded-from-local-file';
104+
const baselineContents = 'baseline-after-upload-and-mutation';
105+
const sourceContents = 'source-devbox-after-isolated-mutation';
106+
const cloneAContents = 'clone-a-after-isolated-mutation';
107+
const cloneBContents = 'clone-b-after-isolated-mutation';
108+
109+
await sourceDevbox.file.upload({
110+
path: FILE_PATH,
111+
file: await toFile(Buffer.from(uploadedContents, 'utf8'), 'snapshot-demo.txt'),
112+
});
113+
const uploadedReadback = await readFileContents(sourceDevbox);
114+
115+
await sourceDevbox.file.write({
116+
file_path: FILE_PATH,
117+
contents: baselineContents,
118+
});
119+
120+
// suspend & resume:
121+
await sourceDevbox.suspend();
122+
const suspendedInfo = await sourceDevbox.awaitSuspended();
123+
const resumedInfo = await sourceDevbox.resume();
124+
const resumedReadback = await readFileContents(sourceDevbox);
125+
126+
snapshot = await sourceDevbox.snapshotDisk({
127+
name: uniqueName('snapshot-baseline'),
128+
commit_message: 'Capture the shared baseline after suspend and resume.',
129+
});
130+
snapshotNeedsCleanup = true;
131+
resourcesCreated.push(`snapshot:${snapshot.id}`);
132+
133+
// Restore two separate devboxes from the same baseline snapshot.
134+
cloneA = await snapshot.createDevbox({
135+
name: uniqueName('snapshot-clone-a'),
136+
launch_parameters: {
137+
resource_size_request: 'X_SMALL',
138+
},
139+
});
140+
cloneANeedsCleanup = true;
141+
resourcesCreated.push(`devbox:${cloneA.id}`);
142+
143+
cloneB = await sdk.devbox.createFromSnapshot(snapshot.id, {
144+
name: uniqueName('snapshot-clone-b'),
145+
launch_parameters: {
146+
resource_size_request: 'X_SMALL',
147+
},
148+
});
149+
cloneBNeedsCleanup = true;
150+
resourcesCreated.push(`devbox:${cloneB.id}`);
151+
152+
const cloneABaselineReadback = await readFileContents(cloneA);
153+
const cloneBBaselineReadback = await readFileContents(cloneB);
154+
155+
await sourceDevbox.file.write({ file_path: FILE_PATH, contents: sourceContents });
156+
await cloneA.file.write({ file_path: FILE_PATH, contents: cloneAContents });
157+
await cloneB.file.write({ file_path: FILE_PATH, contents: cloneBContents });
158+
159+
const sourceIsolatedReadback = await readFileContents(sourceDevbox);
160+
const cloneAIsolatedReadback = await readFileContents(cloneA);
161+
const cloneBIsolatedReadback = await readFileContents(cloneB);
162+
163+
await cloneB.shutdown();
164+
cloneBNeedsCleanup = false;
165+
await cloneA.shutdown();
166+
cloneANeedsCleanup = false;
167+
await sourceDevbox.shutdown();
168+
sourceNeedsCleanup = false;
169+
await snapshot.delete();
170+
snapshotNeedsCleanup = false;
171+
172+
return {
173+
resourcesCreated,
174+
checks: [
175+
{
176+
name: 'uploaded file is readable on the source devbox',
177+
passed: uploadedReadback === uploadedContents,
178+
details: uploadedReadback,
179+
},
180+
{
181+
name: 'suspend reaches the suspended state',
182+
passed: suspendedInfo.status === 'suspended',
183+
details: `status=${suspendedInfo.status}`,
184+
},
185+
{
186+
name: 'resume preserves the baseline file contents',
187+
passed: resumedInfo.status === 'running' && resumedReadback === baselineContents,
188+
details: `status=${resumedInfo.status}, contents=${resumedReadback}`,
189+
},
190+
{
191+
name: 'multiple devboxes can use the same snapshot baseline',
192+
passed: cloneABaselineReadback === baselineContents && cloneBBaselineReadback === baselineContents,
193+
details: `cloneA=${cloneABaselineReadback}, cloneB=${cloneBBaselineReadback}`,
194+
},
195+
{
196+
name: 'devboxes diverge after isolated mutations',
197+
passed:
198+
sourceIsolatedReadback === sourceContents &&
199+
cloneAIsolatedReadback === cloneAContents &&
200+
cloneBIsolatedReadback === cloneBContents,
201+
details: `source=${sourceIsolatedReadback}, cloneA=${cloneAIsolatedReadback}, cloneB=${cloneBIsolatedReadback}`,
202+
},
203+
{
204+
name: 'snapshot-backed devboxes stay isolated from one another',
205+
passed: new Set([sourceIsolatedReadback, cloneAIsolatedReadback, cloneBIsolatedReadback]).size === 3,
206+
details: `values=${JSON.stringify([sourceIsolatedReadback, cloneAIsolatedReadback, cloneBIsolatedReadback])}`,
207+
},
208+
{
209+
name: 'snapshot can be deleted after the demo finishes',
210+
passed: !snapshotNeedsCleanup,
211+
details: `deleted=${String(!snapshotNeedsCleanup)}`,
212+
},
213+
],
214+
};
215+
}
216+
217+
export const runDevboxSnapshotsExample = wrapRecipe({ recipe });
218+
219+
if (require.main === module) {
220+
void runAsCli(runDevboxSnapshotsExample);
221+
}

examples/registry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ExampleResult } from './types';
66
import { runBlueprintWithBuildContextExample } from './blueprint-with-build-context';
77
import { runDevboxFromBlueprintLifecycleExample } from './devbox-from-blueprint-lifecycle';
88
import { runDevboxMountsExample } from './devbox-mounts';
9+
import { runDevboxSnapshotsExample } from './devbox-snapshots';
910
import { runDevboxTunnelExample } from './devbox-tunnel';
1011
import { runMcpGithubToolsExample } from './mcp-github-tools';
1112
import { runSecretsWithDevboxExample } from './secrets-with-devbox';
@@ -40,6 +41,13 @@ export const exampleRegistry: ExampleRegistryEntry[] = [
4041
requiredEnv: ['RUNLOOP_API_KEY', 'ANTHROPIC_API_KEY'],
4142
run: runDevboxMountsExample,
4243
},
44+
{
45+
slug: 'devbox-snapshots',
46+
title: 'Devbox Snapshots (Suspend, Resume, Restore, Delete)',
47+
fileName: 'devbox-snapshots.ts',
48+
requiredEnv: ['RUNLOOP_API_KEY'],
49+
run: runDevboxSnapshotsExample,
50+
},
4351
{
4452
slug: 'devbox-tunnel',
4553
title: 'Devbox Tunnel (HTTP Server Access)',

llms.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
- [Devbox lifecycle example](examples/devbox-from-blueprint-lifecycle.ts): Create blueprint, launch devbox, run commands, cleanup
1313
- [Devbox mounts example](examples/devbox-mounts.ts): Show `agent_mount`, `code_mount`, and `object_mount` together, including Claude Code via Anthropic agent gateway, the `runloopai/rl-cli` repo, and object TTL/compression/extraction behavior
14+
- [Devbox snapshots example](examples/devbox-snapshots.ts): Upload a file, suspend and resume the source devbox, snapshot the shared baseline, restore multiple devboxes from it, mutate each copy independently, and delete the snapshot
1415
- [MCP GitHub example](examples/mcp-github-tools.ts): MCP Hub integration with Claude Code
1516
- [Secrets with Devbox example](examples/secrets-with-devbox.ts): Show direct secret injection for app data alongside agent gateway for upstream credentials that should stay off the devbox
1617

@@ -25,6 +26,8 @@
2526
- Prefer SDK methods (`sdk.devbox`, `sdk.blueprint`, etc.) for object-oriented patterns and convenience
2627
- For resources without SDK coverage (e.g., benchmarks), use `sdk.api.*` as a fallback
2728
- Use `sdk.devbox.create()` to create devboxes; they auto-await readiness
29+
- Use `devbox.suspend()` plus `devbox.awaitSuspended()` when you want to park a devbox and preserve its disk state, then use `devbox.resume()` to bring the same devbox back to `running`
30+
- Use `devbox.snapshotDisk()` to capture a reusable disk baseline and `snapshot.createDevbox()` or `sdk.devbox.createFromSnapshot(snapshot.id, ...)` to branch multiple devboxes from that same state
2831
- Use `devbox.cmd.exec('command')` for commands expected to return immediately (e.g., `echo`, `pwd`, `cat`)—blocks until completion, returns `ExecutionResult` with stdout/stderr
2932
- Use `devbox.cmd.execAsync('command')` for long-running or background processes (servers, watchers, builds)—returns immediately with `Execution` handle to check status, get result, or kill
3033
- Both `exec` and `execAsync` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output

0 commit comments

Comments
 (0)