Skip to content

Commit b1802e4

Browse files
V48 Gate 3 (implementation-only): Vercel Sandbox v2 ephemeral deposit boxes
Force persistent:false for one-shot harness creates (v2 defaults to persistent/Snapshot Storage), assign unique names, and stop+delete ephemeral sandboxes after deposit synthesis.
1 parent d9c8f06 commit b1802e4

6 files changed

Lines changed: 225 additions & 25 deletions

File tree

packages/pipeline-hosts/src/__tests__/vercel-sandbox-host.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { buildAssetPackSandboxHarness } from '../asset-pack-harness';
2-
import { VercelSandboxPipelineHost } from '../vercel-sandbox-host';
2+
import {
3+
normalizeCreateOptions,
4+
VercelSandboxPipelineHost,
5+
} from '../vercel-sandbox-host';
36
import type {
47
PipelineHarnessFile,
58
PipelineHarnessHostEvent,
@@ -10,10 +13,12 @@ import type {
1013

1114
class FakeSandbox {
1215
sandboxId = 'sbx_test';
16+
name?: string;
1317
status = 'running';
1418
readonly writtenFiles: PipelineHarnessFile[] = [];
1519
readonly commands: { cmd: string; args: string[] }[] = [];
1620
stopped = false;
21+
deleted = false;
1722

1823
async writeFiles(files: PipelineHarnessFile[]): Promise<void> {
1924
this.writtenFiles.push(...files);
@@ -42,6 +47,11 @@ class FakeSandbox {
4247
this.stopped = true;
4348
this.status = 'stopped';
4449
}
50+
51+
async delete(): Promise<void> {
52+
this.deleted = true;
53+
this.status = 'deleted';
54+
}
4555
}
4656

4757
class DetachedFakeSandbox extends FakeSandbox {
@@ -191,8 +201,14 @@ describe('VercelSandboxPipelineHost', () => {
191201
'command-started',
192202
'command-completed',
193203
'artifacts-read',
204+
// Ephemeral harnesses stop then delete (v2: avoid Snapshot Storage linger).
205+
'sandbox-deleted',
194206
'sandbox-stopped',
195207
]);
208+
expect(events.find((e) => e.type === 'sandbox-created')).toMatchObject({
209+
persistent: false,
210+
name: expect.any(String),
211+
});
196212
expect(events.find((event) => event.type === 'command-completed')).toMatchObject({
197213
stdoutLength: expect.any(Number),
198214
stderrLength: expect.any(Number),
@@ -300,7 +316,13 @@ describe('VercelSandboxPipelineHost', () => {
300316
token: 'test-token',
301317
teamId: 'team_test',
302318
projectId: 'prj_test',
319+
// v2 default is persistent=true; Bitcode harnesses force false.
320+
persistent: false,
303321
});
322+
expect(typeof createOptions[0].name).toBe('string');
323+
expect(createOptions[0].name!.length).toBeGreaterThan(8);
324+
expect(fakeSandbox.stopped).toBe(true);
325+
expect(fakeSandbox.deleted).toBe(true);
304326
} finally {
305327
restoreEnv('VERCEL_TOKEN', previous.VERCEL_TOKEN);
306328
restoreEnv('VERCEL_TEAM_ID', previous.VERCEL_TEAM_ID);
@@ -309,6 +331,36 @@ describe('VercelSandboxPipelineHost', () => {
309331
}
310332
});
311333

334+
it('normalizeCreateOptions forces ephemeral unless persistent is explicitly true', () => {
335+
expect(normalizeCreateOptions({}).persistent).toBe(false);
336+
expect(normalizeCreateOptions({ persistent: false }).persistent).toBe(false);
337+
expect(normalizeCreateOptions({ persistent: true, name: 'keep-me' })).toMatchObject({
338+
persistent: true,
339+
name: 'keep-me',
340+
});
341+
const named = normalizeCreateOptions({ name: ' ' });
342+
expect(named.persistent).toBe(false);
343+
expect(named.name).toMatch(/^bitcode-harness-/);
344+
});
345+
346+
it('deposit harness createOptions are non-persistent with a unique name', () => {
347+
const plan = buildAssetPackSandboxHarness({
348+
mode: 'asset_pack_pipeline',
349+
synthesizeMode: 'deposit',
350+
persistent: false,
351+
read: { id: 'read-1', prompt: 'n/a' },
352+
deposit: { id: 'deposit-demo' },
353+
sourceRevision: {
354+
repositoryFullName: 'engineeredsoftware/demo',
355+
branch: 'main',
356+
commit: 'abc',
357+
},
358+
source: { type: 'git', url: 'https://github.com/engineeredsoftware/demo.git', revision: 'abc' },
359+
});
360+
expect(plan.createOptions.persistent).toBe(false);
361+
expect(plan.createOptions.name).toMatch(/^bitcode-deposit-/);
362+
});
363+
312364
it('bounds sandbox creation so auth/API hangs are observable', async () => {
313365
const factory: SandboxFactory = {
314366
create: async () => new Promise(() => undefined),

packages/pipeline-hosts/src/asset-pack-harness.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@ const SANDBOX_WORKING_DIRECTORY = '/vercel/sandbox' as const;
3131
const DEFAULT_LONG_TIMEOUT_MS = 45 * 60 * 1000;
3232
const SANDBOX_PNPM_VERSION = '10.33.0';
3333

34+
/**
35+
* Unique name per harness create (Vercel project-scoped). Even non-persistent
36+
* sandboxes take a name for dashboard/log correlation; names are not reused.
37+
*/
38+
export function buildEphemeralSandboxName(
39+
synthesizeMode: 'deposit' | 'read',
40+
depositId?: string | null,
41+
): string {
42+
const slug = String(depositId || 'harness')
43+
.toLowerCase()
44+
.replace(/[^a-z0-9]+/g, '-')
45+
.replace(/^-+|-+$/g, '')
46+
.slice(0, 40);
47+
const stamp = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
48+
return `bitcode-${synthesizeMode}-${slug || 'run'}-${stamp}`.slice(0, 96);
49+
}
50+
3451
export interface BuildAssetPackSandboxHarnessOptions {
3552
mode?: PipelineHarnessMode;
3653
read: PipelineReadRequest;
@@ -54,10 +71,13 @@ export interface BuildAssetPackSandboxHarnessOptions {
5471
demandContext?: string[];
5572
};
5673
/**
57-
* When false, create a non-persistent (ephemeral) sandbox — no snapshot on stop.
58-
* Deposit one-shot synthesis defaults to false when synthesizeMode is deposit.
74+
* Vercel Sandbox v2 defaults to persistent (auto-snapshot + Snapshot Storage
75+
* billing). Bitcode harness runs are one-shot — default `false` unless a
76+
* caller explicitly opts into a long-lived named workspace.
5977
*/
6078
persistent?: boolean;
79+
/** Optional stable name (unique per Vercel project). Auto-generated when omitted. */
80+
sandboxName?: string;
6181
}
6282

6383
export function buildAssetPackSandboxHarness(
@@ -111,20 +131,23 @@ export function buildAssetPackSandboxHarness(
111131
sourceOverlayPatch !== null
112132
);
113133

134+
// Vercel Sandbox v2: persistence is ON by default. Never leave `persistent`
135+
// undefined for harness creates — that would silently bill Snapshot Storage
136+
// for one-shot deposit/read synthesis. Opt-in only when the caller sets true.
137+
const persistent = options.persistent === true;
138+
const sandboxName =
139+
(typeof options.sandboxName === 'string' && options.sandboxName.trim()) ||
140+
buildEphemeralSandboxName(options.synthesizeMode ?? 'read', options.deposit?.id);
141+
114142
return {
115143
capabilities: VERCEL_SANDBOX_HOST_CAPABILITIES,
116144
createOptions: {
117145
runtime: options.runtime ?? VERCEL_SANDBOX_HOST_CAPABILITIES.defaultRuntime,
118146
timeout: options.timeoutMs ?? DEFAULT_LONG_TIMEOUT_MS,
119147
networkPolicy: options.networkPolicy ?? 'allow-all',
120148
source: options.source,
121-
// Deposit one-shots are ephemeral; read/QA harness may opt into persistence.
122-
persistent:
123-
typeof options.persistent === 'boolean'
124-
? options.persistent
125-
: options.synthesizeMode === 'deposit'
126-
? false
127-
: undefined,
149+
persistent,
150+
name: sandboxName,
128151
},
129152
manifest,
130153
files: [

packages/pipeline-hosts/src/types.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,32 @@ export interface SandboxCreateOptions {
170170
projectId?: string;
171171
token?: string;
172172
/**
173-
* When false, the sandbox is ephemeral (no snapshot storage on stop).
174-
* Deposit one-shot synthesis should prefer non-persistent boxes.
173+
* Vercel Sandbox v2: persistence is DEFAULT (auto-snapshot on stop, billed
174+
* Snapshot Storage). Bitcode pipeline harnesses are one-shot CI-style work —
175+
* always pass `false` unless a caller explicitly opts into a long-lived
176+
* named workspace. See Vercel docs: Persistent sandboxes / Opt out.
175177
*/
176178
persistent?: boolean;
177-
/** Optional stable name for Sandbox.get / resume (unique per project). */
179+
/**
180+
* Unique name within the Vercel project (v2 primary identity; v1 used
181+
* sandboxId). Ephemeral deposit runs still set a unique name for logs/
182+
* dashboard correlation even when `persistent: false`.
183+
*/
178184
name?: string;
185+
/**
186+
* Optional TTL for automatic snapshots when persistent (ms from last use).
187+
* Only relevant when `persistent: true`.
188+
*/
189+
snapshotExpiration?: number;
190+
/**
191+
* Retention for persistent sandboxes (keep N most recent snapshots).
192+
* Only relevant when `persistent: true`.
193+
*/
194+
keepLastSnapshots?: {
195+
count: number;
196+
expiration?: number;
197+
deleteEvicted?: boolean;
198+
};
179199
}
180200

181201
export interface PipelineHarnessFile {
@@ -238,7 +258,10 @@ export interface SandboxRunCommandObject {
238258
}
239259

240260
export interface SandboxSession {
261+
/** v1 identity; still present on many SDK builds. */
241262
sandboxId?: string;
263+
/** v2 primary identity (unique per project). */
264+
name?: string;
242265
status?: string;
243266
writeFiles(files: PipelineHarnessFile[]): Promise<void>;
244267
runCommand(
@@ -248,19 +271,32 @@ export interface SandboxSession {
248271
): Promise<SandboxCommandResult>;
249272
runCommand(params: SandboxRunCommandObject): Promise<SandboxCommandResult>;
250273
readFileToBuffer(file: { path: string; cwd?: string }): Promise<Buffer | null>;
274+
/**
275+
* End the current session. Persistent sandboxes auto-snapshot; non-persistent
276+
* discard the filesystem. Does not permanently remove the sandbox entity.
277+
*/
251278
stop?(opts?: { blocking?: boolean }): Promise<unknown>;
279+
/** Permanent remove (sandbox + snapshots + sessions). Prefer after ephemeral stop. */
280+
delete?(): Promise<unknown>;
252281
snapshot?(opts?: { expiration?: number }): Promise<{ snapshotId: string }>;
282+
update?(opts: Record<string, unknown>): Promise<unknown>;
253283
}
254284

255285
export interface SandboxFactory {
256286
create(options: SandboxCreateOptions): Promise<SandboxSession>;
257-
/** Optional: resume/stop an existing sandbox by id or name (SDK-dependent). */
287+
/** Resume/retrieve by name (v2) or sandboxId (v1). */
258288
get?(options: {
259289
sandboxId?: string;
260290
name?: string;
261291
teamId?: string;
262292
projectId?: string;
263293
token?: string;
294+
resume?: boolean;
295+
}): Promise<SandboxSession>;
296+
getOrCreate?(options: SandboxCreateOptions & {
297+
onCreate?: (sandbox: SandboxSession) => void | Promise<void>;
298+
onResume?: (sandbox: SandboxSession) => void | Promise<void>;
299+
resume?: boolean;
264300
}): Promise<SandboxSession>;
265301
}
266302

@@ -275,14 +311,24 @@ export type PipelineHarnessHostEvent =
275311
type: 'sandbox-created';
276312
timestamp: string;
277313
sandboxId?: string;
314+
/** v2 name when available. */
315+
name?: string;
316+
persistent?: boolean;
278317
status?: string;
279318
}
280319
| {
281320
type: 'sandbox-cancelled';
282321
timestamp: string;
283322
sandboxId?: string;
323+
name?: string;
284324
reason?: string;
285325
}
326+
| {
327+
type: 'sandbox-deleted';
328+
timestamp: string;
329+
sandboxId?: string;
330+
name?: string;
331+
}
286332
| {
287333
type: 'harness-files-written';
288334
timestamp: string;

0 commit comments

Comments
 (0)