Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/icy-nights-bet.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@vercel/sandbox": patch
---

Support useworkflow serialization for sandboxes and commands
Add `"use step"` annotations to all API-calling methods for durable execution compatibility with the Workflow DevKit
59 changes: 0 additions & 59 deletions examples/workflow-code-runner/steps/sandbox.ts

This file was deleted.

48 changes: 29 additions & 19 deletions examples/workflow-code-runner/workflows/code-runner.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import { Sandbox, setSandboxCredentials } from "@vercel/sandbox";
import { FatalError } from "workflow";
import { createSandbox, execute, stopSandbox } from "@/steps/sandbox";
import { generateCode, fixCode } from "@/steps/ai";

// Set credentials at module scope so deserialized Sandbox instances
// can lazily create API clients in any step context.
setSandboxCredentials({
token: process.env.VERCEL_TOKEN!,
teamId: process.env.VERCEL_TEAM_ID!,
projectId: process.env.VERCEL_PROJECT_ID!,
});

const MAX_ATTEMPTS = 3;

export async function runCode(prompt: string) {
"use workflow";

// Step 1: Create a sandbox.
// The returned Sandbox instance is automatically serialized via
// WORKFLOW_SERIALIZE when it crosses the step boundary.
const sandbox = await createSandbox();
// Sandbox.create() has "use step" built in — this is a durable step.
const sandbox = await Sandbox.create({
resources: { vcpus: 1 },
timeout: 5 * 60 * 1000,
runtime: "node22",
});

try {
let code = await generateCode(prompt);
Expand All @@ -21,28 +31,28 @@ export async function runCode(prompt: string) {
code = await fixCode(prompt, code, lastError);
}

// The sandbox object survives the step boundary from createSandbox —
// it was rehydrated via WORKFLOW_DESERIALIZE, no Sandbox.get() needed.
const result = await execute(sandbox, code);

if (result.exitCode === 0) {
return {
code,
stdout: result.stdout,
stderr: result.stderr,
iterations: attempt,
};
// Each SDK method (writeFiles, runCommand, stdout, stderr)
// is annotated with "use step" — no wrapper functions needed.
await sandbox.writeFiles([
{ path: "script.js", content: Buffer.from(code) },
]);

const finished = await sandbox.runCommand("node", ["script.js"]);
const stdout = await finished.stdout();
const stderr = await finished.stderr();

if (finished.exitCode === 0) {
return { code, stdout, stderr, iterations: attempt };
}

lastError =
result.stderr || `Process exited with code ${result.exitCode}`;
lastError = stderr || `Process exited with code ${finished.exitCode}`;
console.log(`[Attempt ${attempt}] Failed:`, lastError);
}

throw new FatalError(
`Code failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastError}`,
);
} finally {
await stopSandbox(sandbox);
await sandbox.stop();
}
}
5 changes: 5 additions & 0 deletions packages/vercel-sandbox/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export class Command {
* @returns A {@link CommandFinished} instance with populated exit code.
*/
async wait(params?: { signal?: AbortSignal }) {
"use step";
params?.signal?.throwIfAborted();

const command = await this.client.getCommand({
Expand Down Expand Up @@ -286,6 +287,7 @@ export class Command {
stream: "stdout" | "stderr" | "both" = "both",
opts?: { signal?: AbortSignal }
) {
"use step";
const cached = await this.getCachedOutput(opts);
return cached[stream];
}
Expand All @@ -301,6 +303,7 @@ export class Command {
* @returns The standard output of the command.
*/
async stdout(opts?: { signal?: AbortSignal }) {
"use step";
return this.output("stdout", opts);
}

Expand All @@ -315,6 +318,7 @@ export class Command {
* @returns The standard error output of the command.
*/
async stderr(opts?: { signal?: AbortSignal }) {
"use step";
return this.output("stderr", opts);
}

Expand All @@ -327,6 +331,7 @@ export class Command {
* @returns Promise<void>.
*/
async kill(signal?: Signal, opts?: { abortSignal?: AbortSignal }) {
"use step";
await this.client.killCommand({
sandboxId: this.sandboxId,
commandId: this.cmd.id,
Expand Down
14 changes: 14 additions & 0 deletions packages/vercel-sandbox/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export class Sandbox {
Partial<Credentials> &
WithFetchOptions,
) {
"use step";
const credentials = await getCredentials(params);
const client = new APIClient({
teamId: credentials.teamId,
Expand Down Expand Up @@ -368,6 +369,7 @@ export class Sandbox {
> &
WithFetchOptions,
): Promise<Sandbox & AsyncDisposable> {
"use step";
const credentials = await getCredentials(params);
const client = new APIClient({
teamId: credentials.teamId,
Expand Down Expand Up @@ -406,6 +408,7 @@ export class Sandbox {
params: WithPrivate<GetSandboxParams | (GetSandboxParams & Credentials)> &
WithFetchOptions,
): Promise<Sandbox> {
"use step";
const credentials = await getCredentials(params);
const client = new APIClient({
teamId: credentials.teamId,
Expand Down Expand Up @@ -460,6 +463,7 @@ export class Sandbox {
cmdId: string,
opts?: { signal?: AbortSignal },
): Promise<Command> {
"use step";
const command = await this.client.getCommand({
sandboxId: this.sandbox.id,
cmdId,
Expand Down Expand Up @@ -511,6 +515,7 @@ export class Sandbox {
args?: string[],
opts?: { signal?: AbortSignal },
): Promise<Command | CommandFinished> {
"use step";
const params: RunCommandParams =
typeof commandOrParams === "string"
? { cmd: commandOrParams, args, signal: opts?.signal }
Expand Down Expand Up @@ -602,6 +607,7 @@ export class Sandbox {
* @param opts.signal - An AbortSignal to cancel the operation.
*/
async mkDir(path: string, opts?: { signal?: AbortSignal }): Promise<void> {
"use step";
await this.client.mkDir({
sandboxId: this.sandbox.id,
path: path,
Expand All @@ -621,6 +627,7 @@ export class Sandbox {
file: { path: string; cwd?: string },
opts?: { signal?: AbortSignal },
): Promise<NodeJS.ReadableStream | null> {
"use step";
return this.client.readFile({
sandboxId: this.sandbox.id,
path: file.path,
Expand All @@ -641,6 +648,7 @@ export class Sandbox {
file: { path: string; cwd?: string },
opts?: { signal?: AbortSignal },
): Promise<Buffer | null> {
"use step";
const stream = await this.client.readFile({
sandboxId: this.sandbox.id,
path: file.path,
Expand Down Expand Up @@ -670,6 +678,7 @@ export class Sandbox {
dst: { path: string; cwd?: string },
opts?: { mkdirRecursive?: boolean; signal?: AbortSignal },
): Promise<string | null> {
"use step";
if (!src?.path) {
throw new Error("downloadFile: source path is required");
}
Expand Down Expand Up @@ -723,6 +732,7 @@ export class Sandbox {
files: { path: string; content: Buffer; mode?: number }[],
opts?: { signal?: AbortSignal },
) {
"use step";
return this.client.writeFiles({
sandboxId: this.sandbox.id,
cwd: this.sandbox.cwd,
Expand Down Expand Up @@ -760,6 +770,7 @@ export class Sandbox {
signal?: AbortSignal;
blocking?: boolean;
}): Promise<SandboxSnapshot> {
"use step";
const response = await this.client.stopSandbox({
sandboxId: this.sandbox.id,
signal: opts?.signal,
Expand Down Expand Up @@ -804,6 +815,7 @@ export class Sandbox {
networkPolicy: NetworkPolicy,
opts?: { signal?: AbortSignal },
): Promise<NetworkPolicy> {
"use step";
const response = await this.client.updateNetworkPolicy({
sandboxId: this.sandbox.id,
networkPolicy: networkPolicy,
Expand Down Expand Up @@ -835,6 +847,7 @@ export class Sandbox {
duration: number,
opts?: { signal?: AbortSignal },
): Promise<void> {
"use step";
const response = await this.client.extendTimeout({
sandboxId: this.sandbox.id,
duration,
Expand All @@ -860,6 +873,7 @@ export class Sandbox {
expiration?: number;
signal?: AbortSignal;
}): Promise<Snapshot> {
"use step";
const response = await this.client.createSnapshot({
sandboxId: this.sandbox.id,
expiration: opts?.expiration,
Expand Down
3 changes: 3 additions & 0 deletions packages/vercel-sandbox/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class Snapshot {
Partial<Credentials> &
WithFetchOptions,
) {
"use step";
const credentials = await getCredentials(params);
const client = new APIClient({
teamId: credentials.teamId,
Expand All @@ -123,6 +124,7 @@ export class Snapshot {
static async get(
params: GetSnapshotParams | (GetSnapshotParams & Credentials),
): Promise<Snapshot> {
"use step";
const credentials = await getCredentials(params);
const client = new APIClient({
teamId: credentials.teamId,
Expand All @@ -148,6 +150,7 @@ export class Snapshot {
* @returns A promise that resolves once the snapshot has been deleted.
*/
async delete(opts?: { signal?: AbortSignal }): Promise<void> {
"use step";
const response = await this.client.deleteSnapshot({
snapshotId: this.snapshot.id,
signal: opts?.signal,
Expand Down
Loading