From 085ca0703010284078926ebb9c603c79a0bbe131 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Apr 2026 11:34:04 -0500 Subject: [PATCH 1/6] fix: skip expired snapshots and fallback to fresh sandbox on failure Accounts with expired Vercel snapshots caused Sandbox.create() to throw "Status code 400 is not ok" with no recovery path. Changes: - Extract getValidSnapshotId to its own lib file (SRP) - Both processCreateSandbox and createSandboxFromSnapshot use it - Add fallback: if snapshot creation fails, create a fresh sandbox - DRY: processCreateSandbox uses createSandboxWithFallback helper so createSandbox({}) is only called once - Add tests for getValidSnapshotId (5 tests) - Add snapshot expiry and fallback tests to existing test files Co-Authored-By: Claude Opus 4.6 (1M context) --- .../createSandboxFromSnapshot.test.ts | 56 +++++++++--- .../createSandboxPostHandler.test.ts | 91 +++++++++++++++++++ .../__tests__/getValidSnapshotId.test.ts | 65 +++++++++++++ lib/sandbox/createSandboxFromSnapshot.ts | 34 +++++-- lib/sandbox/getValidSnapshotId.ts | 19 ++++ lib/sandbox/processCreateSandbox.ts | 30 ++++-- 6 files changed, 264 insertions(+), 31 deletions(-) create mode 100644 lib/sandbox/__tests__/getValidSnapshotId.test.ts create mode 100644 lib/sandbox/getValidSnapshotId.ts diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index 887d136c..7c4930a7 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -3,7 +3,7 @@ import type { Sandbox } from "@vercel/sandbox"; import { createSandboxFromSnapshot } from "../createSandboxFromSnapshot"; -const mockSelectAccountSnapshots = vi.fn(); +const mockGetValidSnapshotId = vi.fn(); const mockInsertAccountSandbox = vi.fn(); const mockCreateSandbox = vi.fn(); @@ -11,8 +11,8 @@ vi.mock("@/lib/sandbox/createSandbox", () => ({ createSandbox: (...args: unknown[]) => mockCreateSandbox(...args), })); -vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ - selectAccountSnapshots: (...args: unknown[]) => mockSelectAccountSnapshots(...args), +vi.mock("@/lib/sandbox/getValidSnapshotId", () => ({ + getValidSnapshotId: (...args: unknown[]) => mockGetValidSnapshotId(...args), })); vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ @@ -44,9 +44,7 @@ describe("createSandboxFromSnapshot", () => { }); it("creates from snapshot when available", async () => { - mockSelectAccountSnapshots.mockResolvedValue([ - { snapshot_id: "snap_abc", account_id: "acc_1" }, - ]); + mockGetValidSnapshotId.mockResolvedValue("snap_abc"); await createSandboxFromSnapshot("acc_1"); @@ -56,7 +54,7 @@ describe("createSandboxFromSnapshot", () => { }); it("creates fresh sandbox when no snapshot exists", async () => { - mockSelectAccountSnapshots.mockResolvedValue([]); + mockGetValidSnapshotId.mockResolvedValue(undefined); await createSandboxFromSnapshot("acc_1"); @@ -64,7 +62,7 @@ describe("createSandboxFromSnapshot", () => { }); it("inserts account_sandbox record", async () => { - mockSelectAccountSnapshots.mockResolvedValue([]); + mockGetValidSnapshotId.mockResolvedValue(undefined); await createSandboxFromSnapshot("acc_1"); @@ -75,9 +73,7 @@ describe("createSandboxFromSnapshot", () => { }); it("returns { sandbox, fromSnapshot: true } when snapshot exists", async () => { - mockSelectAccountSnapshots.mockResolvedValue([ - { snapshot_id: "snap_abc", account_id: "acc_1" }, - ]); + mockGetValidSnapshotId.mockResolvedValue("snap_abc"); const result = await createSandboxFromSnapshot("acc_1"); @@ -85,10 +81,46 @@ describe("createSandboxFromSnapshot", () => { }); it("returns { sandbox, fromSnapshot: false } when no snapshot", async () => { - mockSelectAccountSnapshots.mockResolvedValue([]); + mockGetValidSnapshotId.mockResolvedValue(undefined); const result = await createSandboxFromSnapshot("acc_1"); expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false }); }); + + it("skips expired snapshot (getValidSnapshotId returns undefined)", async () => { + mockGetValidSnapshotId.mockResolvedValue(undefined); + + const result = await createSandboxFromSnapshot("acc_1"); + + expect(mockCreateSandbox).toHaveBeenCalledWith({}); + expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false }); + }); + + it("falls back to fresh sandbox when snapshot creation fails", async () => { + mockGetValidSnapshotId.mockResolvedValue("snap_bad"); + + const freshSandbox = { + sandboxId: "sbx_fresh", + status: "running", + runCommand: vi.fn(), + } as unknown as Sandbox; + + mockCreateSandbox + .mockRejectedValueOnce(new Error("Status code 400 is not ok")) + .mockResolvedValueOnce({ + sandbox: freshSandbox, + response: { + sandboxId: "sbx_fresh", + sandboxStatus: "running", + timeout: 1800000, + createdAt: "2024-01-01T00:00:00.000Z", + }, + }); + + const result = await createSandboxFromSnapshot("acc_1"); + + expect(mockCreateSandbox).toHaveBeenCalledTimes(2); + expect(result).toEqual({ sandbox: freshSandbox, fromSnapshot: false }); + }); }); diff --git a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts index 0e1cfc00..aca65c6b 100644 --- a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts @@ -361,6 +361,97 @@ describe("createSandboxPostHandler", () => { expect(triggerPromptSandbox).not.toHaveBeenCalled(); }); + it("skips expired snapshot and creates fresh sandbox", async () => { + const pastDate = new Date(Date.now() - 86400000).toISOString(); + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + id: "snap_record_123", + account_id: "acc_123", + snapshot_id: "snap_expired", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: pastDate, + }, + ]); + vi.mocked(createSandbox).mockResolvedValue({ + sandbox: {} as never, + response: { + sandboxId: "sbx_fresh", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }, + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_fresh", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + const response = await createSandboxPostHandler(request); + + expect(response.status).toBe(200); + expect(createSandbox).toHaveBeenCalledWith({}); + expect(createSandbox).not.toHaveBeenCalledWith( + expect.objectContaining({ source: expect.anything() }), + ); + }); + + it("falls back to fresh sandbox when snapshot creation fails", async () => { + const futureDate = new Date(Date.now() + 86400000).toISOString(); + vi.mocked(validateSandboxBody).mockResolvedValue({ + accountId: "acc_123", + orgId: null, + authToken: "token", + }); + vi.mocked(selectAccountSnapshots).mockResolvedValue([ + { + id: "snap_record_123", + account_id: "acc_123", + snapshot_id: "snap_bad", + created_at: "2024-01-01T00:00:00.000Z", + expires_at: futureDate, + }, + ]); + vi.mocked(createSandbox) + .mockRejectedValueOnce(new Error("Status code 400 is not ok")) + .mockResolvedValueOnce({ + sandbox: {} as never, + response: { + sandboxId: "sbx_fallback", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + }, + }); + vi.mocked(insertAccountSandbox).mockResolvedValue({ + data: { + id: "record_123", + account_id: "acc_123", + sandbox_id: "sbx_fallback", + created_at: "2024-01-01T00:00:00.000Z", + }, + error: null, + }); + + const request = createMockRequest(); + const response = await createSandboxPostHandler(request); + + expect(response.status).toBe(200); + expect(createSandbox).toHaveBeenCalledTimes(2); + const json = await response.json(); + expect(json.sandboxes[0].sandboxId).toBe("sbx_fallback"); + }); + it("returns 200 without runId when triggerPromptSandbox throws", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", diff --git a/lib/sandbox/__tests__/getValidSnapshotId.test.ts b/lib/sandbox/__tests__/getValidSnapshotId.test.ts new file mode 100644 index 00000000..0e84c85e --- /dev/null +++ b/lib/sandbox/__tests__/getValidSnapshotId.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getValidSnapshotId } from "../getValidSnapshotId"; + +const mockSelectAccountSnapshots = vi.fn(); + +vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ + selectAccountSnapshots: (...args: unknown[]) => mockSelectAccountSnapshots(...args), +})); + +describe("getValidSnapshotId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns snapshot_id when snapshot exists and is not expired", async () => { + const futureDate = new Date(Date.now() + 86400000).toISOString(); + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: "snap_abc", account_id: "acc_1", expires_at: futureDate }, + ]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBe("snap_abc"); + }); + + it("returns snapshot_id when snapshot has no expires_at", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: "snap_abc", account_id: "acc_1", expires_at: null }, + ]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBe("snap_abc"); + }); + + it("returns undefined when snapshot is expired", async () => { + const pastDate = new Date(Date.now() - 86400000).toISOString(); + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: "snap_expired", account_id: "acc_1", expires_at: pastDate }, + ]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when no snapshots exist", async () => { + mockSelectAccountSnapshots.mockResolvedValue([]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when snapshot has no snapshot_id", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: null, account_id: "acc_1", expires_at: null }, + ]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBeUndefined(); + }); +}); diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 98310a1f..b8d09b9e 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -1,6 +1,6 @@ import type { Sandbox } from "@vercel/sandbox"; import { createSandbox } from "@/lib/sandbox/createSandbox"; -import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; export interface CreateSandboxFromSnapshotResult { @@ -9,8 +9,8 @@ export interface CreateSandboxFromSnapshotResult { } /** - * Creates a new sandbox from the account's latest snapshot (or fresh if none) - * and records it in the database. + * Creates a new sandbox from the account's latest valid snapshot, + * falling back to a fresh sandbox if the snapshot is expired or fails. * * @param accountId - The account ID to create a sandbox for * @returns The created Sandbox instance and whether it was created from a snapshot @@ -18,17 +18,31 @@ export interface CreateSandboxFromSnapshotResult { export async function createSandboxFromSnapshot( accountId: string, ): Promise { - const snapshots = await selectAccountSnapshots(accountId); - const snapshotId = snapshots[0]?.snapshot_id; + const snapshotId = await getValidSnapshotId(accountId); - const { sandbox, response } = await createSandbox( - snapshotId ? { source: { type: "snapshot", snapshotId } } : {}, - ); + let sandbox: Sandbox; + let fromSnapshot = false; + + if (snapshotId) { + try { + const result = await createSandbox({ + source: { type: "snapshot", snapshotId }, + }); + sandbox = result.sandbox; + fromSnapshot = true; + } catch { + const result = await createSandbox({}); + sandbox = result.sandbox; + } + } else { + const result = await createSandbox({}); + sandbox = result.sandbox; + } await insertAccountSandbox({ account_id: accountId, - sandbox_id: response.sandboxId, + sandbox_id: sandbox.sandboxId, }); - return { sandbox, fromSnapshot: !!snapshotId }; + return { sandbox, fromSnapshot }; } diff --git a/lib/sandbox/getValidSnapshotId.ts b/lib/sandbox/getValidSnapshotId.ts new file mode 100644 index 00000000..28526dc3 --- /dev/null +++ b/lib/sandbox/getValidSnapshotId.ts @@ -0,0 +1,19 @@ +import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; + +/** + * Returns a valid (non-expired) snapshot ID for the account, or undefined. + * + * @param accountId - The account to look up + * @returns The snapshot ID if it exists and has not expired + */ +export async function getValidSnapshotId(accountId: string): Promise { + const accountSnapshots = await selectAccountSnapshots(accountId); + const snapshot = accountSnapshots[0]; + if (!snapshot?.snapshot_id) return undefined; + + if (snapshot.expires_at && new Date(snapshot.expires_at) < new Date()) { + return undefined; + } + + return snapshot.snapshot_id; +} diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index be568c11..56d5c026 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -1,6 +1,6 @@ import { createSandbox, type SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; +import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; -import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; type ProcessCreateSandboxInput = { @@ -9,6 +9,24 @@ type ProcessCreateSandboxInput = { }; type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string }; +/** + * Attempts to create a sandbox from the given snapshot, falling back to a fresh sandbox on failure. + * If no snapshotId is provided, creates a fresh sandbox directly. + * + * @param snapshotId - Optional snapshot ID to restore from + * @returns The sandbox creation response + */ +async function createSandboxWithFallback(snapshotId: string | undefined) { + if (snapshotId) { + try { + return (await createSandbox({ source: { type: "snapshot", snapshotId } })).response; + } catch { + // Snapshot invalid or expired on Vercel's side — fall through to fresh + } + } + return (await createSandbox({})).response; +} + /** * Shared domain logic for creating a sandbox and optionally running a prompt. * Used by both POST /api/sandboxes handler and the prompt_sandbox MCP tool. @@ -21,14 +39,8 @@ export async function processCreateSandbox( ): Promise { const { accountId, prompt } = input; - // Get account's most recent snapshot if available - const accountSnapshots = await selectAccountSnapshots(accountId); - const snapshotId = accountSnapshots[0]?.snapshot_id; - - // Create sandbox (from snapshot if valid, otherwise fresh) - const { response: result } = await createSandbox( - snapshotId ? { source: { type: "snapshot", snapshotId } } : {}, - ); + const snapshotId = await getValidSnapshotId(accountId); + const result = await createSandboxWithFallback(snapshotId); await insertAccountSandbox({ account_id: accountId, From 0f978f0945a4e3a2e09e9ff2297d8801baeef8f0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Apr 2026 11:51:00 -0500 Subject: [PATCH 2/6] refactor: DRY createSandbox({}) in createSandboxFromSnapshot Single createSandbox({}) call path instead of duplicating it in both the catch block and the else branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/sandbox/createSandboxFromSnapshot.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index b8d09b9e..56775473 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -25,18 +25,15 @@ export async function createSandboxFromSnapshot( if (snapshotId) { try { - const result = await createSandbox({ - source: { type: "snapshot", snapshotId }, - }); - sandbox = result.sandbox; + sandbox = (await createSandbox({ source: { type: "snapshot", snapshotId } })).sandbox; fromSnapshot = true; } catch { - const result = await createSandbox({}); - sandbox = result.sandbox; + // Snapshot invalid or expired on Vercel's side — fall through to fresh } - } else { - const result = await createSandbox({}); - sandbox = result.sandbox; + } + + if (!fromSnapshot) { + sandbox = (await createSandbox({})).sandbox; } await insertAccountSandbox({ From d8f633f63811660ceb52b7eb0a4a4fded27b2a9f Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Apr 2026 11:53:05 -0500 Subject: [PATCH 3/6] refactor: extract createSandboxWithFallback to own lib file, add error logging - Extract createSandboxWithFallback to lib/sandbox/createSandboxWithFallback.ts (SRP) - processCreateSandbox and createSandboxFromSnapshot use it / follow same pattern - Log snapshot creation errors before falling back to fresh sandbox - Add 4 unit tests for createSandboxWithFallback Co-Authored-By: Claude Opus 4.6 (1M context) --- .../createSandboxWithFallback.test.ts | 70 +++++++++++++++++++ lib/sandbox/createSandboxFromSnapshot.ts | 11 +-- lib/sandbox/createSandboxWithFallback.ts | 24 +++++++ lib/sandbox/processCreateSandbox.ts | 21 +----- 4 files changed, 103 insertions(+), 23 deletions(-) create mode 100644 lib/sandbox/__tests__/createSandboxWithFallback.test.ts create mode 100644 lib/sandbox/createSandboxWithFallback.ts diff --git a/lib/sandbox/__tests__/createSandboxWithFallback.test.ts b/lib/sandbox/__tests__/createSandboxWithFallback.test.ts new file mode 100644 index 00000000..67a49715 --- /dev/null +++ b/lib/sandbox/__tests__/createSandboxWithFallback.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createSandboxWithFallback } from "../createSandboxWithFallback"; + +const mockCreateSandbox = vi.fn(); + +vi.mock("@/lib/sandbox/createSandbox", () => ({ + createSandbox: (...args: unknown[]) => mockCreateSandbox(...args), +})); + +const mockResponse = { + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 1800000, + createdAt: "2024-01-01T00:00:00.000Z", +}; + +describe("createSandboxWithFallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreateSandbox.mockResolvedValue({ + sandbox: {}, + response: mockResponse, + }); + }); + + it("creates from snapshot when snapshotId is provided", async () => { + const result = await createSandboxWithFallback("snap_abc"); + + expect(mockCreateSandbox).toHaveBeenCalledWith({ + source: { type: "snapshot", snapshotId: "snap_abc" }, + }); + expect(result).toEqual(mockResponse); + }); + + it("creates fresh sandbox when snapshotId is undefined", async () => { + const result = await createSandboxWithFallback(undefined); + + expect(mockCreateSandbox).toHaveBeenCalledWith({}); + expect(result).toEqual(mockResponse); + }); + + it("falls back to fresh sandbox when snapshot creation fails", async () => { + const freshResponse = { ...mockResponse, sandboxId: "sbx_fresh" }; + mockCreateSandbox + .mockRejectedValueOnce(new Error("Status code 400 is not ok")) + .mockResolvedValueOnce({ sandbox: {}, response: freshResponse }); + + const result = await createSandboxWithFallback("snap_bad"); + + expect(mockCreateSandbox).toHaveBeenCalledTimes(2); + expect(result).toEqual(freshResponse); + }); + + it("logs error when snapshot creation fails", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const snapshotError = new Error("Status code 400 is not ok"); + mockCreateSandbox + .mockRejectedValueOnce(snapshotError) + .mockResolvedValueOnce({ sandbox: {}, response: mockResponse }); + + await createSandboxWithFallback("snap_bad"); + + expect(consoleSpy).toHaveBeenCalledWith( + "Snapshot sandbox creation failed, falling back to fresh sandbox:", + snapshotError, + ); + consoleSpy.mockRestore(); + }); +}); diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 56775473..cf4e5bfe 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -27,8 +27,11 @@ export async function createSandboxFromSnapshot( try { sandbox = (await createSandbox({ source: { type: "snapshot", snapshotId } })).sandbox; fromSnapshot = true; - } catch { - // Snapshot invalid or expired on Vercel's side — fall through to fresh + } catch (error) { + console.error( + "Snapshot sandbox creation failed, falling back to fresh sandbox:", + error, + ); } } @@ -38,8 +41,8 @@ export async function createSandboxFromSnapshot( await insertAccountSandbox({ account_id: accountId, - sandbox_id: sandbox.sandboxId, + sandbox_id: sandbox!.sandboxId, }); - return { sandbox, fromSnapshot }; + return { sandbox: sandbox!, fromSnapshot }; } diff --git a/lib/sandbox/createSandboxWithFallback.ts b/lib/sandbox/createSandboxWithFallback.ts new file mode 100644 index 00000000..b9f9ca74 --- /dev/null +++ b/lib/sandbox/createSandboxWithFallback.ts @@ -0,0 +1,24 @@ +import { createSandbox, type SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; + +/** + * Attempts to create a sandbox from the given snapshot, falling back to a fresh sandbox on failure. + * If no snapshotId is provided, creates a fresh sandbox directly. + * + * @param snapshotId - Optional snapshot ID to restore from + * @returns The sandbox creation response + */ +export async function createSandboxWithFallback( + snapshotId: string | undefined, +): Promise { + if (snapshotId) { + try { + return (await createSandbox({ source: { type: "snapshot", snapshotId } })).response; + } catch (error) { + console.error( + "Snapshot sandbox creation failed, falling back to fresh sandbox:", + error, + ); + } + } + return (await createSandbox({})).response; +} diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index 56d5c026..c8bb15a9 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -1,7 +1,8 @@ -import { createSandbox, type SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; +import { createSandboxWithFallback } from "@/lib/sandbox/createSandboxWithFallback"; import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; +import type { SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; type ProcessCreateSandboxInput = { accountId: string; @@ -9,24 +10,6 @@ type ProcessCreateSandboxInput = { }; type ProcessCreateSandboxResult = SandboxCreatedResponse & { runId?: string }; -/** - * Attempts to create a sandbox from the given snapshot, falling back to a fresh sandbox on failure. - * If no snapshotId is provided, creates a fresh sandbox directly. - * - * @param snapshotId - Optional snapshot ID to restore from - * @returns The sandbox creation response - */ -async function createSandboxWithFallback(snapshotId: string | undefined) { - if (snapshotId) { - try { - return (await createSandbox({ source: { type: "snapshot", snapshotId } })).response; - } catch { - // Snapshot invalid or expired on Vercel's side — fall through to fresh - } - } - return (await createSandbox({})).response; -} - /** * Shared domain logic for creating a sandbox and optionally running a prompt. * Used by both POST /api/sandboxes handler and the prompt_sandbox MCP tool. From 9141cc4cc105cb57bb78f12370b61006c1726c80 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Apr 2026 11:57:17 -0500 Subject: [PATCH 4/6] refactor: DRY createSandboxFromSnapshot via shared createSandboxWithFallback - createSandboxFromSnapshot now delegates to createSandboxWithFallback instead of duplicating snapshot/fresh fallback logic - createSandboxWithFallback returns full SandboxCreateResult + fromSnapshot - Update tests to match new delegation pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- .../createSandboxFromSnapshot.test.ts | 68 +++++++++---------- .../createSandboxWithFallback.test.ts | 21 +++--- lib/sandbox/createSandboxFromSnapshot.ts | 26 ++----- lib/sandbox/createSandboxWithFallback.ts | 19 +++--- lib/sandbox/processCreateSandbox.ts | 2 +- 5 files changed, 59 insertions(+), 77 deletions(-) diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index 7c4930a7..d7a97507 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -5,10 +5,10 @@ import { createSandboxFromSnapshot } from "../createSandboxFromSnapshot"; const mockGetValidSnapshotId = vi.fn(); const mockInsertAccountSandbox = vi.fn(); -const mockCreateSandbox = vi.fn(); +const mockCreateSandboxWithFallback = vi.fn(); -vi.mock("@/lib/sandbox/createSandbox", () => ({ - createSandbox: (...args: unknown[]) => mockCreateSandbox(...args), +vi.mock("@/lib/sandbox/createSandboxWithFallback", () => ({ + createSandboxWithFallback: (...args: unknown[]) => mockCreateSandboxWithFallback(...args), })); vi.mock("@/lib/sandbox/getValidSnapshotId", () => ({ @@ -28,7 +28,7 @@ describe("createSandboxFromSnapshot", () => { beforeEach(() => { vi.clearAllMocks(); - mockCreateSandbox.mockResolvedValue({ + mockCreateSandboxWithFallback.mockResolvedValue({ sandbox: mockSandbox, response: { sandboxId: "sbx_new", @@ -36,6 +36,7 @@ describe("createSandboxFromSnapshot", () => { timeout: 1800000, createdAt: "2024-01-01T00:00:00.000Z", }, + fromSnapshot: false, }); mockInsertAccountSandbox.mockResolvedValue({ data: { account_id: "acc_1", sandbox_id: "sbx_new" }, @@ -43,22 +44,20 @@ describe("createSandboxFromSnapshot", () => { }); }); - it("creates from snapshot when available", async () => { + it("passes snapshotId to createSandboxWithFallback", async () => { mockGetValidSnapshotId.mockResolvedValue("snap_abc"); await createSandboxFromSnapshot("acc_1"); - expect(mockCreateSandbox).toHaveBeenCalledWith({ - source: { type: "snapshot", snapshotId: "snap_abc" }, - }); + expect(mockCreateSandboxWithFallback).toHaveBeenCalledWith("snap_abc"); }); - it("creates fresh sandbox when no snapshot exists", async () => { + it("passes undefined when no snapshot exists", async () => { mockGetValidSnapshotId.mockResolvedValue(undefined); await createSandboxFromSnapshot("acc_1"); - expect(mockCreateSandbox).toHaveBeenCalledWith({}); + expect(mockCreateSandboxWithFallback).toHaveBeenCalledWith(undefined); }); it("inserts account_sandbox record", async () => { @@ -72,8 +71,18 @@ describe("createSandboxFromSnapshot", () => { }); }); - it("returns { sandbox, fromSnapshot: true } when snapshot exists", async () => { + it("returns { sandbox, fromSnapshot: true } when snapshot used", async () => { mockGetValidSnapshotId.mockResolvedValue("snap_abc"); + mockCreateSandboxWithFallback.mockResolvedValue({ + sandbox: mockSandbox, + response: { + sandboxId: "sbx_new", + sandboxStatus: "running", + timeout: 1800000, + createdAt: "2024-01-01T00:00:00.000Z", + }, + fromSnapshot: true, + }); const result = await createSandboxFromSnapshot("acc_1"); @@ -88,39 +97,30 @@ describe("createSandboxFromSnapshot", () => { expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false }); }); - it("skips expired snapshot (getValidSnapshotId returns undefined)", async () => { + it("returns { sandbox, fromSnapshot: false } for expired snapshot", async () => { mockGetValidSnapshotId.mockResolvedValue(undefined); const result = await createSandboxFromSnapshot("acc_1"); - expect(mockCreateSandbox).toHaveBeenCalledWith({}); + expect(mockCreateSandboxWithFallback).toHaveBeenCalledWith(undefined); expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false }); }); - it("falls back to fresh sandbox when snapshot creation fails", async () => { + it("returns { sandbox, fromSnapshot: false } when snapshot creation fails", async () => { mockGetValidSnapshotId.mockResolvedValue("snap_bad"); - - const freshSandbox = { - sandboxId: "sbx_fresh", - status: "running", - runCommand: vi.fn(), - } as unknown as Sandbox; - - mockCreateSandbox - .mockRejectedValueOnce(new Error("Status code 400 is not ok")) - .mockResolvedValueOnce({ - sandbox: freshSandbox, - response: { - sandboxId: "sbx_fresh", - sandboxStatus: "running", - timeout: 1800000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); + mockCreateSandboxWithFallback.mockResolvedValue({ + sandbox: mockSandbox, + response: { + sandboxId: "sbx_new", + sandboxStatus: "running", + timeout: 1800000, + createdAt: "2024-01-01T00:00:00.000Z", + }, + fromSnapshot: false, + }); const result = await createSandboxFromSnapshot("acc_1"); - expect(mockCreateSandbox).toHaveBeenCalledTimes(2); - expect(result).toEqual({ sandbox: freshSandbox, fromSnapshot: false }); + expect(result).toEqual({ sandbox: mockSandbox, fromSnapshot: false }); }); }); diff --git a/lib/sandbox/__tests__/createSandboxWithFallback.test.ts b/lib/sandbox/__tests__/createSandboxWithFallback.test.ts index 67a49715..3af6d1fe 100644 --- a/lib/sandbox/__tests__/createSandboxWithFallback.test.ts +++ b/lib/sandbox/__tests__/createSandboxWithFallback.test.ts @@ -8,20 +8,19 @@ vi.mock("@/lib/sandbox/createSandbox", () => ({ createSandbox: (...args: unknown[]) => mockCreateSandbox(...args), })); +const mockSandbox = { sandboxId: "sbx_123", status: "running" }; const mockResponse = { sandboxId: "sbx_123", sandboxStatus: "running", timeout: 1800000, createdAt: "2024-01-01T00:00:00.000Z", }; +const mockCreateResult = { sandbox: mockSandbox, response: mockResponse }; describe("createSandboxWithFallback", () => { beforeEach(() => { vi.clearAllMocks(); - mockCreateSandbox.mockResolvedValue({ - sandbox: {}, - response: mockResponse, - }); + mockCreateSandbox.mockResolvedValue(mockCreateResult); }); it("creates from snapshot when snapshotId is provided", async () => { @@ -30,34 +29,34 @@ describe("createSandboxWithFallback", () => { expect(mockCreateSandbox).toHaveBeenCalledWith({ source: { type: "snapshot", snapshotId: "snap_abc" }, }); - expect(result).toEqual(mockResponse); + expect(result).toEqual({ ...mockCreateResult, fromSnapshot: true }); }); it("creates fresh sandbox when snapshotId is undefined", async () => { const result = await createSandboxWithFallback(undefined); expect(mockCreateSandbox).toHaveBeenCalledWith({}); - expect(result).toEqual(mockResponse); + expect(result).toEqual({ ...mockCreateResult, fromSnapshot: false }); }); it("falls back to fresh sandbox when snapshot creation fails", async () => { + const freshSandbox = { sandboxId: "sbx_fresh", status: "running" }; const freshResponse = { ...mockResponse, sandboxId: "sbx_fresh" }; + const freshResult = { sandbox: freshSandbox, response: freshResponse }; mockCreateSandbox .mockRejectedValueOnce(new Error("Status code 400 is not ok")) - .mockResolvedValueOnce({ sandbox: {}, response: freshResponse }); + .mockResolvedValueOnce(freshResult); const result = await createSandboxWithFallback("snap_bad"); expect(mockCreateSandbox).toHaveBeenCalledTimes(2); - expect(result).toEqual(freshResponse); + expect(result).toEqual({ ...freshResult, fromSnapshot: false }); }); it("logs error when snapshot creation fails", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const snapshotError = new Error("Status code 400 is not ok"); - mockCreateSandbox - .mockRejectedValueOnce(snapshotError) - .mockResolvedValueOnce({ sandbox: {}, response: mockResponse }); + mockCreateSandbox.mockRejectedValueOnce(snapshotError).mockResolvedValueOnce(mockCreateResult); await createSandboxWithFallback("snap_bad"); diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index cf4e5bfe..1b6da57a 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -1,5 +1,5 @@ import type { Sandbox } from "@vercel/sandbox"; -import { createSandbox } from "@/lib/sandbox/createSandbox"; +import { createSandboxWithFallback } from "@/lib/sandbox/createSandboxWithFallback"; import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId"; import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; @@ -19,30 +19,12 @@ export async function createSandboxFromSnapshot( accountId: string, ): Promise { const snapshotId = await getValidSnapshotId(accountId); - - let sandbox: Sandbox; - let fromSnapshot = false; - - if (snapshotId) { - try { - sandbox = (await createSandbox({ source: { type: "snapshot", snapshotId } })).sandbox; - fromSnapshot = true; - } catch (error) { - console.error( - "Snapshot sandbox creation failed, falling back to fresh sandbox:", - error, - ); - } - } - - if (!fromSnapshot) { - sandbox = (await createSandbox({})).sandbox; - } + const { sandbox, fromSnapshot } = await createSandboxWithFallback(snapshotId); await insertAccountSandbox({ account_id: accountId, - sandbox_id: sandbox!.sandboxId, + sandbox_id: sandbox.sandboxId, }); - return { sandbox: sandbox!, fromSnapshot }; + return { sandbox, fromSnapshot }; } diff --git a/lib/sandbox/createSandboxWithFallback.ts b/lib/sandbox/createSandboxWithFallback.ts index b9f9ca74..93014ac4 100644 --- a/lib/sandbox/createSandboxWithFallback.ts +++ b/lib/sandbox/createSandboxWithFallback.ts @@ -1,24 +1,25 @@ -import { createSandbox, type SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; +import { createSandbox, type SandboxCreateResult } from "@/lib/sandbox/createSandbox"; + +export type SandboxWithFallbackResult = SandboxCreateResult & { fromSnapshot: boolean }; /** * Attempts to create a sandbox from the given snapshot, falling back to a fresh sandbox on failure. * If no snapshotId is provided, creates a fresh sandbox directly. * * @param snapshotId - Optional snapshot ID to restore from - * @returns The sandbox creation response + * @returns The sandbox creation result with fromSnapshot flag */ export async function createSandboxWithFallback( snapshotId: string | undefined, -): Promise { +): Promise { if (snapshotId) { try { - return (await createSandbox({ source: { type: "snapshot", snapshotId } })).response; + const result = await createSandbox({ source: { type: "snapshot", snapshotId } }); + return { ...result, fromSnapshot: true }; } catch (error) { - console.error( - "Snapshot sandbox creation failed, falling back to fresh sandbox:", - error, - ); + console.error("Snapshot sandbox creation failed, falling back to fresh sandbox:", error); } } - return (await createSandbox({})).response; + const result = await createSandbox({}); + return { ...result, fromSnapshot: false }; } diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index c8bb15a9..8eafa649 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -23,7 +23,7 @@ export async function processCreateSandbox( const { accountId, prompt } = input; const snapshotId = await getValidSnapshotId(accountId); - const result = await createSandboxWithFallback(snapshotId); + const { response: result } = await createSandboxWithFallback(snapshotId); await insertAccountSandbox({ account_id: accountId, From 98de74266740c49526f9f3449fa351e37328da5e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Apr 2026 12:04:39 -0500 Subject: [PATCH 5/6] refactor: consolidate processCreateSandbox to use createSandboxFromSnapshot processCreateSandbox now delegates sandbox creation to createSandboxFromSnapshot instead of calling getValidSnapshotId, createSandboxWithFallback, and insertAccountSandbox directly. This eliminates the duplicated snapshot lookup + fallback + DB insert logic between the two functions. processCreateSandbox adds only the prompt-triggering behavior on top. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../createSandboxPostHandler.test.ts | 420 ++---------------- .../__tests__/processCreateSandbox.test.ts | 187 +------- lib/sandbox/processCreateSandbox.ts | 19 +- 3 files changed, 62 insertions(+), 564 deletions(-) diff --git a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts index aca65c6b..03aafaaf 100644 --- a/lib/sandbox/__tests__/createSandboxPostHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxPostHandler.test.ts @@ -4,29 +4,14 @@ import { NextResponse } from "next/server"; import { createSandboxPostHandler } from "../createSandboxPostHandler"; import { validateSandboxBody } from "@/lib/sandbox/validateSandboxBody"; -import { createSandbox } from "@/lib/sandbox/createSandbox"; -import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; -import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; -import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; +import { processCreateSandbox } from "@/lib/sandbox/processCreateSandbox"; vi.mock("@/lib/sandbox/validateSandboxBody", () => ({ validateSandboxBody: vi.fn(), })); -vi.mock("@/lib/sandbox/createSandbox", () => ({ - createSandbox: vi.fn(), -})); - -vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ - insertAccountSandbox: vi.fn(), -})); - -vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ - triggerPromptSandbox: vi.fn(), -})); - -vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ - selectAccountSnapshots: vi.fn(), +vi.mock("@/lib/sandbox/processCreateSandbox", () => ({ + processCreateSandbox: vi.fn(), })); /** @@ -56,34 +41,17 @@ describe("createSandboxPostHandler", () => { expect(response.status).toBe(401); }); - it("returns runId when prompt is provided", async () => { + it("returns 200 with sandbox result when no prompt", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - prompt: "create a hello world page", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, }); - vi.mocked(triggerPromptSandbox).mockResolvedValue({ - id: "run_abc123", + vi.mocked(processCreateSandbox).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", }); const request = createMockRequest(); @@ -99,181 +67,66 @@ describe("createSandboxPostHandler", () => { sandboxStatus: "running", timeout: 600000, createdAt: "2024-01-01T00:00:00.000Z", - runId: "run_abc123", }, ], }); }); - it("calls createSandbox with snapshotId when account has snapshot", async () => { + it("returns runId when prompt is provided", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - prompt: "say hello", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([ - { - id: "snap_record_123", - account_id: "acc_123", - snapshot_id: "snap_xyz", - created_at: "2024-01-01T00:00:00.000Z", - }, - ]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_456", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_456", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, + prompt: "create a hello world page", }); - vi.mocked(triggerPromptSandbox).mockResolvedValue({ - id: "run_def456", + vi.mocked(processCreateSandbox).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", + runId: "run_abc123", }); const request = createMockRequest(); - await createSandboxPostHandler(request); + const response = await createSandboxPostHandler(request); - expect(createSandbox).toHaveBeenCalledWith({ - source: { type: "snapshot", snapshotId: "snap_xyz" }, - }); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.sandboxes[0].runId).toBe("run_abc123"); }); - it("calls createSandbox with empty params when account has no snapshot", async () => { + it("passes validated input to processCreateSandbox", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", prompt: "say hello", }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_456", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_456", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - vi.mocked(triggerPromptSandbox).mockResolvedValue({ - id: "run_def456", + vi.mocked(processCreateSandbox).mockResolvedValue({ + sandboxId: "sbx_123", + sandboxStatus: "running", + timeout: 600000, + createdAt: "2024-01-01T00:00:00.000Z", }); const request = createMockRequest(); await createSandboxPostHandler(request); - expect(createSandbox).toHaveBeenCalledWith({}); - }); - - it("calls insertAccountSandbox with correct account_id and sandbox_id", async () => { - vi.mocked(validateSandboxBody).mockResolvedValue({ + expect(processCreateSandbox).toHaveBeenCalledWith({ accountId: "acc_123", orgId: null, authToken: "token", prompt: "say hello", }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_456", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_456", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - vi.mocked(triggerPromptSandbox).mockResolvedValue({ - id: "run_def456", - }); - - const request = createMockRequest(); - await createSandboxPostHandler(request); - - expect(insertAccountSandbox).toHaveBeenCalledWith({ - account_id: "acc_123", - sandbox_id: "sbx_456", - }); - }); - - it("calls triggerPromptSandbox with prompt, sandboxId, and accountId", async () => { - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - prompt: "create a hello world page", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_789", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_789", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - vi.mocked(triggerPromptSandbox).mockResolvedValue({ - id: "run_ghi789", - }); - - const request = createMockRequest(); - await createSandboxPostHandler(request); - - expect(triggerPromptSandbox).toHaveBeenCalledWith({ - prompt: "create a hello world page", - sandboxId: "sbx_789", - accountId: "acc_123", - }); }); - it("returns 400 with error status when createSandbox throws", async () => { + it("returns 400 when processCreateSandbox throws", async () => { vi.mocked(validateSandboxBody).mockResolvedValue({ accountId: "acc_123", orgId: null, authToken: "token", - prompt: "say hello", }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed")); + vi.mocked(processCreateSandbox).mockRejectedValue(new Error("Sandbox creation failed")); const request = createMockRequest(); const response = await createSandboxPostHandler(request); @@ -285,217 +138,4 @@ describe("createSandboxPostHandler", () => { error: "Sandbox creation failed", }); }); - - it("returns 400 with error status when insertAccountSandbox throws", async () => { - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - prompt: "say hello", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockRejectedValue(new Error("Database insert failed")); - - const request = createMockRequest(); - const response = await createSandboxPostHandler(request); - - expect(response.status).toBe(400); - const json = await response.json(); - expect(json).toEqual({ - status: "error", - error: "Database insert failed", - }); - }); - - it("returns 200 without runId when no prompt is provided", async () => { - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - - const request = createMockRequest(); - const response = await createSandboxPostHandler(request); - - expect(response.status).toBe(200); - const json = await response.json(); - expect(json).toEqual({ - status: "success", - sandboxes: [ - { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - ], - }); - expect(triggerPromptSandbox).not.toHaveBeenCalled(); - }); - - it("skips expired snapshot and creates fresh sandbox", async () => { - const pastDate = new Date(Date.now() - 86400000).toISOString(); - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([ - { - id: "snap_record_123", - account_id: "acc_123", - snapshot_id: "snap_expired", - created_at: "2024-01-01T00:00:00.000Z", - expires_at: pastDate, - }, - ]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_fresh", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_fresh", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - - const request = createMockRequest(); - const response = await createSandboxPostHandler(request); - - expect(response.status).toBe(200); - expect(createSandbox).toHaveBeenCalledWith({}); - expect(createSandbox).not.toHaveBeenCalledWith( - expect.objectContaining({ source: expect.anything() }), - ); - }); - - it("falls back to fresh sandbox when snapshot creation fails", async () => { - const futureDate = new Date(Date.now() + 86400000).toISOString(); - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([ - { - id: "snap_record_123", - account_id: "acc_123", - snapshot_id: "snap_bad", - created_at: "2024-01-01T00:00:00.000Z", - expires_at: futureDate, - }, - ]); - vi.mocked(createSandbox) - .mockRejectedValueOnce(new Error("Status code 400 is not ok")) - .mockResolvedValueOnce({ - sandbox: {} as never, - response: { - sandboxId: "sbx_fallback", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_fallback", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - - const request = createMockRequest(); - const response = await createSandboxPostHandler(request); - - expect(response.status).toBe(200); - expect(createSandbox).toHaveBeenCalledTimes(2); - const json = await response.json(); - expect(json.sandboxes[0].sandboxId).toBe("sbx_fallback"); - }); - - it("returns 200 without runId when triggerPromptSandbox throws", async () => { - vi.mocked(validateSandboxBody).mockResolvedValue({ - accountId: "acc_123", - orgId: null, - authToken: "token", - prompt: "say hello", - }); - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - vi.mocked(triggerPromptSandbox).mockRejectedValue(new Error("Task trigger failed")); - - const request = createMockRequest(); - const response = await createSandboxPostHandler(request); - - // Sandbox was created successfully, so return 200 even if prompt trigger fails - expect(response.status).toBe(200); - const json = await response.json(); - expect(json).toEqual({ - status: "success", - sandboxes: [ - { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - ], - }); - }); }); diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index f70b40c2..5f8b33e2 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -1,53 +1,41 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Sandbox } from "@vercel/sandbox"; import { processCreateSandbox } from "../processCreateSandbox"; -import { createSandbox } from "@/lib/sandbox/createSandbox"; -import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; +import { createSandboxFromSnapshot } from "@/lib/sandbox/createSandboxFromSnapshot"; import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; -import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots"; -vi.mock("@/lib/sandbox/createSandbox", () => ({ - createSandbox: vi.fn(), -})); - -vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ - insertAccountSandbox: vi.fn(), +vi.mock("@/lib/sandbox/createSandboxFromSnapshot", () => ({ + createSandboxFromSnapshot: vi.fn(), })); vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ triggerPromptSandbox: vi.fn(), })); -vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({ - selectAccountSnapshots: vi.fn(), -})); +const mockSandbox = { + sandboxId: "sbx_123", + status: "running", + timeout: 600000, + createdAt: new Date("2024-01-01T00:00:00.000Z"), +} as unknown as Sandbox; describe("processCreateSandbox", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(createSandboxFromSnapshot).mockResolvedValue({ + sandbox: mockSandbox, + fromSnapshot: false, + }); }); - it("creates sandbox without prompt and returns result without runId", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); + it("delegates to createSandboxFromSnapshot", async () => { + await processCreateSandbox({ accountId: "acc_123" }); + + expect(createSandboxFromSnapshot).toHaveBeenCalledWith("acc_123"); + }); + it("returns serializable response without runId when no prompt", async () => { const result = await processCreateSandbox({ accountId: "acc_123" }); expect(result).toEqual({ @@ -59,26 +47,7 @@ describe("processCreateSandbox", () => { expect(triggerPromptSandbox).not.toHaveBeenCalled(); }); - it("creates sandbox with prompt and returns result with runId", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); + it("returns result with runId when prompt is provided", async () => { vi.mocked(triggerPromptSandbox).mockResolvedValue({ id: "run_prompt123", }); @@ -102,99 +71,8 @@ describe("processCreateSandbox", () => { }); }); - it("uses snapshot when account has one", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([ - { - id: "snap_record_123", - account_id: "acc_123", - snapshot_id: "snap_xyz", - created_at: "2024-01-01T00:00:00.000Z", - }, - ]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_456", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_456", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - - await processCreateSandbox({ accountId: "acc_123" }); - - expect(createSandbox).toHaveBeenCalledWith({ - source: { type: "snapshot", snapshotId: "snap_xyz" }, - }); - }); - - it("calls createSandbox with empty params when no snapshot", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_456", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_456", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - - await processCreateSandbox({ accountId: "acc_123" }); - - expect(createSandbox).toHaveBeenCalledWith({}); - }); - - it("inserts account_sandbox record", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_789", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_789", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); - - await processCreateSandbox({ accountId: "acc_123" }); - - expect(insertAccountSandbox).toHaveBeenCalledWith({ - account_id: "acc_123", - sandbox_id: "sbx_789", - }); - }); - - it("throws when createSandbox fails", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockRejectedValue(new Error("Sandbox creation failed")); + it("throws when createSandboxFromSnapshot fails", async () => { + vi.mocked(createSandboxFromSnapshot).mockRejectedValue(new Error("Sandbox creation failed")); await expect(processCreateSandbox({ accountId: "acc_123" })).rejects.toThrow( "Sandbox creation failed", @@ -202,25 +80,6 @@ describe("processCreateSandbox", () => { }); it("returns result without runId when triggerPromptSandbox fails", async () => { - vi.mocked(selectAccountSnapshots).mockResolvedValue([]); - vi.mocked(createSandbox).mockResolvedValue({ - sandbox: {} as never, - response: { - sandboxId: "sbx_123", - sandboxStatus: "running", - timeout: 600000, - createdAt: "2024-01-01T00:00:00.000Z", - }, - }); - vi.mocked(insertAccountSandbox).mockResolvedValue({ - data: { - id: "record_123", - account_id: "acc_123", - sandbox_id: "sbx_123", - created_at: "2024-01-01T00:00:00.000Z", - }, - error: null, - }); vi.mocked(triggerPromptSandbox).mockRejectedValue(new Error("Task trigger failed")); const result = await processCreateSandbox({ diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index 8eafa649..0ce50ed6 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -1,6 +1,4 @@ -import { createSandboxWithFallback } from "@/lib/sandbox/createSandboxWithFallback"; -import { getValidSnapshotId } from "@/lib/sandbox/getValidSnapshotId"; -import { insertAccountSandbox } from "@/lib/supabase/account_sandboxes/insertAccountSandbox"; +import { createSandboxFromSnapshot } from "@/lib/sandbox/createSandboxFromSnapshot"; import { triggerPromptSandbox } from "@/lib/trigger/triggerPromptSandbox"; import type { SandboxCreatedResponse } from "@/lib/sandbox/createSandbox"; @@ -22,13 +20,14 @@ export async function processCreateSandbox( ): Promise { const { accountId, prompt } = input; - const snapshotId = await getValidSnapshotId(accountId); - const { response: result } = await createSandboxWithFallback(snapshotId); + const { sandbox } = await createSandboxFromSnapshot(accountId); - await insertAccountSandbox({ - account_id: accountId, - sandbox_id: result.sandboxId, - }); + const result: SandboxCreatedResponse = { + sandboxId: sandbox.sandboxId, + sandboxStatus: sandbox.status, + timeout: sandbox.timeout, + createdAt: sandbox.createdAt.toISOString(), + }; // Trigger the prompt execution task if a prompt was provided let runId: string | undefined; @@ -36,7 +35,7 @@ export async function processCreateSandbox( try { const handle = await triggerPromptSandbox({ prompt, - sandboxId: result.sandboxId, + sandboxId: sandbox.sandboxId, accountId, }); runId = handle.id; From 936b0487360876bce53935e3b8672e16500fbae7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Tue, 7 Apr 2026 12:54:05 -0500 Subject: [PATCH 6/6] fix: treat exact-expiry and unparsable timestamps as invalid snapshots Use <= instead of < so snapshots expiring exactly now are treated as expired. Add NaN check so unparsable expires_at values don't slip through as valid. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__tests__/getValidSnapshotId.test.ts | 21 +++++++++++++++++++ lib/sandbox/getValidSnapshotId.ts | 7 +++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/sandbox/__tests__/getValidSnapshotId.test.ts b/lib/sandbox/__tests__/getValidSnapshotId.test.ts index 0e84c85e..2ee3ad63 100644 --- a/lib/sandbox/__tests__/getValidSnapshotId.test.ts +++ b/lib/sandbox/__tests__/getValidSnapshotId.test.ts @@ -53,6 +53,27 @@ describe("getValidSnapshotId", () => { expect(result).toBeUndefined(); }); + it("returns undefined when expires_at equals now", async () => { + const now = new Date().toISOString(); + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: "snap_edge", account_id: "acc_1", expires_at: now }, + ]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when expires_at is unparsable", async () => { + mockSelectAccountSnapshots.mockResolvedValue([ + { snapshot_id: "snap_bad", account_id: "acc_1", expires_at: "not-a-date" }, + ]); + + const result = await getValidSnapshotId("acc_1"); + + expect(result).toBeUndefined(); + }); + it("returns undefined when snapshot has no snapshot_id", async () => { mockSelectAccountSnapshots.mockResolvedValue([ { snapshot_id: null, account_id: "acc_1", expires_at: null }, diff --git a/lib/sandbox/getValidSnapshotId.ts b/lib/sandbox/getValidSnapshotId.ts index 28526dc3..17d7a6d5 100644 --- a/lib/sandbox/getValidSnapshotId.ts +++ b/lib/sandbox/getValidSnapshotId.ts @@ -11,8 +11,11 @@ export async function getValidSnapshotId(accountId: string): Promise