Skip to content
Merged
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
40 changes: 22 additions & 18 deletions lib/sandbox/__tests__/createSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

import { createSandbox } from "../createSandbox";
import { Sandbox } from "@vercel/sandbox";
import { VercelSandbox } from "../vercel";

const mockSandbox = {
name: "sbx_test123",
status: "running",
sdkStatus: "running",
timeout: 1800000,
createdAt: new Date("2024-01-01T00:00:00Z"),
};

vi.mock("@vercel/sandbox", () => ({
Sandbox: {
vi.mock("../vercel", () => ({
VercelSandbox: {
create: vi.fn(() => Promise.resolve(mockSandbox)),
},
}));
Expand All @@ -28,40 +28,42 @@ describe("createSandbox", () => {
vi.clearAllMocks();
});

it("creates sandbox with default configuration when no params provided", async () => {
it("creates sandbox with default configuration when no config provided", async () => {
await createSandbox();

expect(Sandbox.create).toHaveBeenCalledWith({
resources: { vcpus: 4 },
expect(VercelSandbox.create).toHaveBeenCalledWith({
vcpus: 4,
timeout: 1800000,
runtime: "node22",
});
});

it("creates sandbox from snapshot when source is provided", async () => {
await createSandbox({ source: { type: "snapshot", snapshotId: "snap_abc123" } });
it("restores from snapshot when restoreSnapshotId is provided", async () => {
await createSandbox({ restoreSnapshotId: "snap_abc123" });

expect(Sandbox.create).toHaveBeenCalledWith({
source: { type: "snapshot", snapshotId: "snap_abc123" },
expect(VercelSandbox.create).toHaveBeenCalledWith({
vcpus: 4,
runtime: "node22",
timeout: 1800000,
restoreSnapshotId: "snap_abc123",
});
});

it("allows overriding default timeout", async () => {
await createSandbox({ timeout: 300000 });

expect(Sandbox.create).toHaveBeenCalledWith({
resources: { vcpus: 4 },
expect(VercelSandbox.create).toHaveBeenCalledWith({
vcpus: 4,
timeout: 300000,
runtime: "node22",
});
});

it("allows overriding default resources", async () => {
await createSandbox({ resources: { vcpus: 2 } });
it("allows overriding default vcpus", async () => {
await createSandbox({ vcpus: 2 });

expect(Sandbox.create).toHaveBeenCalledWith({
resources: { vcpus: 2 },
expect(VercelSandbox.create).toHaveBeenCalledWith({
vcpus: 2,
timeout: 1800000,
runtime: "node22",
});
Expand All @@ -84,7 +86,9 @@ describe("createSandbox", () => {
...mockSandbox,
stop: vi.fn(),
};
vi.mocked(Sandbox.create).mockResolvedValue(mockSandboxWithStop as unknown as Sandbox);
vi.mocked(VercelSandbox.create).mockResolvedValue(
mockSandboxWithStop as unknown as VercelSandbox,
);

await createSandbox();

Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/__tests__/createSandboxWithFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe("createSandboxWithFallback", () => {
const result = await createSandboxWithFallback("snap_abc");

expect(mockCreateSandbox).toHaveBeenCalledWith({
source: { type: "snapshot", snapshotId: "snap_abc" },
restoreSnapshotId: "snap_abc",
});
expect(result).toEqual({ ...mockCreateResult, fromSnapshot: true });
});
Expand Down
25 changes: 12 additions & 13 deletions lib/sandbox/__tests__/getActiveSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Sandbox } from "@vercel/sandbox";
import { VercelSandbox } from "@/lib/sandbox/vercel";

import { getActiveSandbox } from "../getActiveSandbox";

const mockSelectAccountSandboxes = vi.fn();

vi.mock("@vercel/sandbox", () => ({
Sandbox: {
get: vi.fn(),
vi.mock("@/lib/sandbox/vercel", () => ({
VercelSandbox: {
connect: vi.fn(),
},
}));

Expand All @@ -25,17 +25,16 @@ describe("getActiveSandbox", () => {

const mockSandbox = {
name: "sbx_123",
status: "running",
runCommand: vi.fn(),
sdkStatus: "running",
};
vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox);
vi.mocked(VercelSandbox.connect).mockResolvedValue(mockSandbox as unknown as VercelSandbox);

const result = await getActiveSandbox("acc_1");

expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({
accountIds: ["acc_1"],
});
expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" });
expect(VercelSandbox.connect).toHaveBeenCalledWith("sbx_123", {});
expect(result).toBe(mockSandbox);
});

Expand All @@ -45,7 +44,7 @@ describe("getActiveSandbox", () => {
const result = await getActiveSandbox("acc_1");

expect(result).toBeNull();
expect(Sandbox.get).not.toHaveBeenCalled();
expect(VercelSandbox.connect).not.toHaveBeenCalled();
});

it("returns null when sandbox is not running", async () => {
Expand All @@ -55,21 +54,21 @@ describe("getActiveSandbox", () => {

const mockSandbox = {
name: "sbx_stopped",
status: "stopped",
sdkStatus: "stopped",
};
vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox);
vi.mocked(VercelSandbox.connect).mockResolvedValue(mockSandbox as unknown as VercelSandbox);

const result = await getActiveSandbox("acc_1");

expect(result).toBeNull();
});

it("returns null when Sandbox.get throws", async () => {
it("returns null when VercelSandbox.connect throws", async () => {
mockSelectAccountSandboxes.mockResolvedValue([
{ sandbox_id: "sbx_expired", account_id: "acc_1" },
]);

vi.mocked(Sandbox.get).mockRejectedValue(new Error("Sandbox not found"));
vi.mocked(VercelSandbox.connect).mockRejectedValue(new Error("Sandbox not found"));

const result = await getActiveSandbox("acc_1");

Expand Down
6 changes: 3 additions & 3 deletions lib/sandbox/__tests__/processCreateSandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Sandbox } from "@vercel/sandbox";
import type { VercelSandbox } from "@/lib/sandbox/vercel";

import { processCreateSandbox } from "../processCreateSandbox";
import { createSandboxFromSnapshot } from "@/lib/sandbox/createSandboxFromSnapshot";
Expand All @@ -15,10 +15,10 @@ vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({

const mockSandbox = {
name: "sbx_123",
status: "running",
sdkStatus: "running",
timeout: 600000,
createdAt: new Date("2024-01-01T00:00:00.000Z"),
} as unknown as Sandbox;
} as unknown as VercelSandbox;

describe("processCreateSandbox", () => {
beforeEach(() => {
Expand Down
60 changes: 28 additions & 32 deletions lib/sandbox/createSandbox.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,57 @@
import ms from "ms";
import { Sandbox } from "@vercel/sandbox";
import { VercelSandbox, type VercelSandboxConfig } from "@/lib/sandbox/vercel";

export interface SandboxCreatedResponse {
sandboxId: Sandbox["name"];
sandboxStatus: Sandbox["status"];
timeout: Sandbox["timeout"];
sandboxId: VercelSandbox["name"];
sandboxStatus: string;
timeout: VercelSandbox["timeout"];
createdAt: string;
}

export interface SandboxCreateResult {
sandbox: Sandbox;
sandbox: VercelSandbox;
response: SandboxCreatedResponse;
}

/** Extract CreateSandboxParams from Sandbox.create method signature */
export type CreateSandboxParams = NonNullable<Parameters<typeof Sandbox.create>[0]>;
/** Parameters for the api-side createSandbox helper. Wraps the abstraction's
* VercelSandboxConfig so callers do not need to import it directly. */
export type CreateSandboxParams = VercelSandboxConfig;

const DEFAULT_TIMEOUT = ms("30m");
const DEFAULT_VCPUS = 4;
const DEFAULT_RUNTIME = "node22";
const DEFAULT_RUNTIME = "node22" as const;

/**
* Creates a Vercel Sandbox and returns its info.
* Creates a Vercel Sandbox via the open-agents abstraction and returns
* its info. The sandbox is left running so subsequent prompts can run
* against it.
*
* The sandbox is left running so that prompts can be executed via the prompt_sandbox tool.
* Accepts the same parameters as Sandbox.create from @vercel/sandbox.
* Note: VercelSandbox.create applies its own defaults for vcpus and
* runtime (vcpus=4, runtime="node22") regardless of source — those
* apply to the runtime resources of the new sandbox even when restoring
* from a snapshot. We pass our preferred defaults explicitly so api's
* intent is documented at the call site.
*
* @param params - Sandbox creation parameters (source, timeout, resources, runtime, ports)
* @returns The sandbox creation response
* @param config - VercelSandboxConfig (timeout, vcpus, runtime,
* restoreSnapshotId, source, ports, env, etc.)
* @returns The sandbox creation result (instance + response shape)
* @throws Error if sandbox creation fails
*/
export async function createSandbox(
params: CreateSandboxParams = {},
config: CreateSandboxParams = {},
): Promise<SandboxCreateResult> {
const hasSnapshotSource =
params.source && "type" in params.source && params.source.type === "snapshot";

// Pass params directly to SDK - it handles all the type variants
const sandbox = await Sandbox.create(
hasSnapshotSource
? {
...params,
timeout: params.timeout ?? DEFAULT_TIMEOUT,
}
: {
resources: { vcpus: DEFAULT_VCPUS },
timeout: params.timeout ?? DEFAULT_TIMEOUT,
runtime: DEFAULT_RUNTIME,
...params,
},
);
const sandbox = await VercelSandbox.create({
vcpus: DEFAULT_VCPUS,
runtime: DEFAULT_RUNTIME,
timeout: DEFAULT_TIMEOUT,
...config,
});

return {
sandbox,
response: {
sandboxId: sandbox.name,
sandboxStatus: sandbox.status,
sandboxStatus: sandbox.sdkStatus,
timeout: sandbox.timeout,
createdAt: sandbox.createdAt.toISOString(),
},
Expand Down
4 changes: 2 additions & 2 deletions lib/sandbox/createSandboxFromSnapshot.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Sandbox } from "@vercel/sandbox";
import type { VercelSandbox } from "@/lib/sandbox/vercel";
import { createSandboxWithFallback } from "@/lib/sandbox/createSandboxWithFallback";
import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId";
import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox";

export interface CreateSandboxFromSnapshotResult {
sandbox: Sandbox;
sandbox: VercelSandbox;
fromSnapshot: boolean;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/createSandboxWithFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export async function createSandboxWithFallback(
): Promise<SandboxWithFallbackResult> {
if (snapshotId) {
try {
const result = await createSandbox({ source: { type: "snapshot", snapshotId } });
const result = await createSandbox({ restoreSnapshotId: snapshotId });
return { ...result, fromSnapshot: true };
} catch (error) {
console.error("Snapshot sandbox creation failed, falling back to fresh sandbox:", error);
Expand Down
11 changes: 6 additions & 5 deletions lib/sandbox/getActiveSandbox.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Sandbox } from "@vercel/sandbox";
import { VercelSandbox } from "@/lib/sandbox/vercel";
import { selectAccountSandboxes } from "@/lib/supabase/account_sandboxes/selectAccountSandboxes";

/**
* Finds the most recent sandbox for an account and returns it if still running.
* Reconnects via the open-agents sandbox abstraction.
*
* @param accountId - The account ID to find an active sandbox for
* @returns The running Sandbox instance, or null if none found
* @returns The running VercelSandbox instance, or null if none found
*/
export async function getActiveSandbox(accountId: string): Promise<Sandbox | null> {
export async function getActiveSandbox(accountId: string): Promise<VercelSandbox | null> {
const sandboxes = await selectAccountSandboxes({
accountIds: [accountId],
});
Expand All @@ -19,9 +20,9 @@ export async function getActiveSandbox(accountId: string): Promise<Sandbox | nul
const mostRecent = sandboxes[0];

try {
const sandbox = await Sandbox.get({ name: mostRecent.sandbox_id });
const sandbox = await VercelSandbox.connect(mostRecent.sandbox_id, {});

if (sandbox.status === "running") {
if (sandbox.sdkStatus === "running") {
return sandbox;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/processCreateSandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function processCreateSandbox(

const result: SandboxCreatedResponse = {
sandboxId: sandbox.name,
sandboxStatus: sandbox.status,
sandboxStatus: sandbox.sdkStatus,
timeout: sandbox.timeout,
createdAt: sandbox.createdAt.toISOString(),
};
Expand Down
25 changes: 25 additions & 0 deletions lib/sandbox/vercel/sandbox/VercelSandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,31 @@ ${hostLine}${portLines}${runtimeEnvLine}`;
return "ready";
}

/**
* Get the raw SDK session status (e.g. "running", "pending", "stopped",
* "failed", "aborted", "snapshotting"). Distinct from the abstraction's
* normalized `status` getter — exposed for callers that need to surface
* the exact SDK lifecycle state in HTTP responses or status polling.
*/
get sdkStatus(): string {
this.refreshStateFromCurrentSession();
return this.session.status;
}

/**
* Timestamp when the underlying SDK sandbox was created. Sourced from
* the SDK session's createdAt field. The SDK populates this on create
* and connect, so it is always defined for any reachable instance —
* we throw rather than fabricate a fallback if the contract is broken.
*/
get createdAt(): Date {
this.refreshStateFromCurrentSession();
if (!this.session.createdAt) {
throw new Error("VercelSandbox session is missing createdAt — SDK contract violation");
}
return this.session.createdAt;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Get the current state for persistence.
* Returns state that can be passed to `connectSandbox()` to restore this sandbox.
Expand Down
Loading