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
28 changes: 28 additions & 0 deletions lib/sandbox/__tests__/clearSandboxResumeState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { clearSandboxResumeState } from "@/lib/sandbox/clearSandboxResumeState";

describe("clearSandboxResumeState", () => {
it("returns null when state is null or undefined", () => {
expect(clearSandboxResumeState(null)).toBeNull();
expect(clearSandboxResumeState(undefined)).toBeNull();
});

it("returns null when state is not an object", () => {
expect(clearSandboxResumeState("oops" as unknown)).toBeNull();
});

it("preserves only the type discriminator, dropping any resume handles", () => {
const result = clearSandboxResumeState({
type: "vercel",
sandboxName: "session-abc",
sandboxId: "sbx_xyz",
expiresAt: 12345,
});
expect(result).toEqual({ type: "vercel" });
});

it("falls back to type='vercel' when the input has no recognizable type", () => {
const result = clearSandboxResumeState({ sandboxName: "session-abc" });
expect(result).toEqual({ type: "vercel" });
});
});
24 changes: 24 additions & 0 deletions lib/sandbox/__tests__/clearUnavailableSandboxState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { clearUnavailableSandboxState } from "@/lib/sandbox/clearUnavailableSandboxState";

describe("clearUnavailableSandboxState", () => {
const stateWithResume = {
type: "vercel",
sandboxName: "session-abc",
expiresAt: 12345,
};

it("drops the resume handle when the error indicates the sandbox no longer exists", () => {
const result = clearUnavailableSandboxState(stateWithResume, "Sandbox not found");
expect(result).toEqual({ type: "vercel" });
});

it("keeps the resume handle when the error is generic unavailability", () => {
const result = clearUnavailableSandboxState(stateWithResume, "Sandbox is stopped");
expect(result).toEqual({ type: "vercel", sandboxName: "session-abc" });
});

it("returns null when input state is null", () => {
expect(clearUnavailableSandboxState(null, "any error")).toBeNull();
});
});
109 changes: 107 additions & 2 deletions lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ describe("getSandboxReconnectHandler", () => {
expect(body.expiresAt).toBe(expiresAt);
});

it("returns status='expired' and clears runtime state when the probe throws", async () => {
it("returns status='expired' and drops the resume handle on a 'sandbox not found' error", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "active" } as never,
]);
Expand All @@ -149,15 +149,120 @@ describe("getSandboxReconnectHandler", () => {
const body = await res.json();
expect(body.status).toBe("expired");
expect(body.expiresAt).toBeUndefined();
// not-found means even the resume handle is stale — sandbox_state
// collapses to just the type discriminator.
expect(updateSession).toHaveBeenCalledWith(
"sess-1",
expect.objectContaining({
sandbox_state: null,
sandbox_state: { type: "vercel" },
lifecycle_state: "hibernated",
}),
);
});

// Open-agents parity: only known "permanently unavailable" errors
// collapse the session to expired. A transient probe failure (e.g.
// 502 / connection reset) preserves the runtime state so the next
// reconnect attempt can succeed without forcing a full rebuild.
it("preserves runtime state and returns 'connected' on a transient probe error", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{
...baseRow,
sandbox_state: { ...RUNTIME_STATE, expiresAt: Date.now() + 1_000_000 },
lifecycle_state: "active",
} as never,
]);
vi.mocked(connectSandbox).mockRejectedValueOnce(new Error("Status code 502"));

const res = await getSandboxReconnectHandler(makeReq());

expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("connected");
expect(body.expiresAt).toBeGreaterThan(Date.now());
expect(updateSession).not.toHaveBeenCalled();
});

it("drops the runtime resume handle on a 'sandbox is stopped' error (preserves nothing)", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "active" } as never,
]);
vi.mocked(connectSandbox).mockRejectedValueOnce(new Error("Sandbox is stopped"));

const res = await getSandboxReconnectHandler(makeReq());

const body = await res.json();
expect(body.status).toBe("expired");
// 'stopped' is unavailable but not not-found — keep the resume handle
// so a future provision can pick it back up.
expect(updateSession).toHaveBeenCalledWith(
"sess-1",
expect.objectContaining({
sandbox_state: { type: "vercel", sandboxName: "session-sess-1" },
lifecycle_state: "hibernated",
}),
);
});

// Open-agents parity: a successful probe refreshes the row's
// `sandbox_expires_at` from the live SDK state so the FE timer
// matches reality.
it("refreshes sandbox_expires_at on successful probe", async () => {
const newExpiresAt = Date.now() + 1_800_000;
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "active" } as never,
]);
const sb = fakeAliveSandbox(newExpiresAt);
(sb as unknown as { getState: () => unknown }).getState = () => ({
type: "vercel",
sandboxName: "session-sess-1",
expiresAt: newExpiresAt,
});
vi.mocked(connectSandbox).mockResolvedValueOnce(sb as never);

await getSandboxReconnectHandler(makeReq());

expect(updateSession).toHaveBeenCalledWith(
"sess-1",
expect.objectContaining({
sandbox_expires_at: new Date(newExpiresAt).toISOString(),
}),
);
});

// Open-agents parity: when the lifecycle evaluator left the session
// in `failed` but the runtime probe succeeds, recover it back to
// `active` and clear the stale error.
it("recovers lifecycle_state 'failed' to 'active' on successful probe", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "failed" } as never,
]);
vi.mocked(connectSandbox).mockResolvedValueOnce(fakeAliveSandbox() as never);

await getSandboxReconnectHandler(makeReq());

expect(updateSession).toHaveBeenCalledWith(
"sess-1",
expect.objectContaining({
lifecycle_state: "active",
lifecycle_error: null,
}),
);
});

it("does NOT touch lifecycle_state on successful probe when it was already 'active'", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: RUNTIME_STATE, lifecycle_state: "active" } as never,
]);
vi.mocked(connectSandbox).mockResolvedValueOnce(fakeAliveSandbox() as never);

await getSandboxReconnectHandler(makeReq());

const updateArgs = vi.mocked(updateSession).mock.calls[0]?.[1] ?? {};
expect(updateArgs).not.toHaveProperty("lifecycle_state");
expect(updateArgs).not.toHaveProperty("lifecycle_error");
});

it("includes the lifecycle envelope on every 200 response", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{ ...baseRow, sandbox_state: { type: "vercel" } } as never,
Expand Down
99 changes: 99 additions & 0 deletions lib/sandbox/__tests__/getSandboxStatusHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getSandboxStatusHandler } from "@/lib/sandbox/getSandboxStatusHandler";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectSessions } from "@/lib/supabase/sessions/selectSessions";
import { updateSession } from "@/lib/supabase/sessions/updateSession";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
Expand All @@ -14,6 +15,9 @@ vi.mock("@/lib/auth/validateAuthContext", () => ({
vi.mock("@/lib/supabase/sessions/selectSessions", () => ({
selectSessions: vi.fn(),
}));
vi.mock("@/lib/supabase/sessions/updateSession", () => ({
updateSession: vi.fn(),
}));
vi.mock("@/lib/sandbox/kickSandboxLifecycleWorkflow", () => ({
kickSandboxLifecycleWorkflow: vi.fn(),
}));
Expand Down Expand Up @@ -176,4 +180,99 @@ describe("getSandboxStatusHandler", () => {
const body = await res.json();
expect(body.status).toBe("active");
});

// Open-agents parity: the lifecycle evaluator can leave a session in
// `lifecycle_state: "failed"` with an error message, but the runtime
// sandbox is still alive. The UI shouldn't surface that as "Paused" —
// the status read self-heals to `active` and reports the recovered
// value back to the client.
it("self-heals lifecycle_state from 'failed' to 'active' when runtime is alive", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{
...baseRow,
sandbox_state: {
type: "vercel",
sandboxName: "session-sess-1",
expiresAt: 4_102_444_800_000,
},
lifecycle_state: "failed",
lifecycle_error: "previous-eval-blew-up",
sandbox_expires_at: FAR_FUTURE,
} as any,
]);
vi.mocked(updateSession).mockResolvedValueOnce({
...baseRow,
sandbox_state: { type: "vercel", sandboxName: "session-sess-1" },
lifecycle_state: "active",
lifecycle_error: null,
sandbox_expires_at: FAR_FUTURE,
} as any);

const res = await getSandboxStatusHandler(makeReq());

expect(updateSession).toHaveBeenCalledWith(
"sess-1",
expect.objectContaining({
lifecycle_state: "active",
lifecycle_error: null,
}),
);
const body = await res.json();
expect(body.status).toBe("active");
expect(body.lifecycle.state).toBe("active");
});

it("does NOT self-heal lifecycle when runtime is gone (lifecycle stays 'failed')", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{
...baseRow,
sandbox_state: null,
lifecycle_state: "failed",
} as any,
]);

const res = await getSandboxStatusHandler(makeReq());

expect(updateSession).not.toHaveBeenCalled();
const body = await res.json();
expect(body.status).toBe("no_sandbox");
expect(body.lifecycle.state).toBe("failed");
});

// Open-agents parity: hasSnapshot must also recognize hibernated
// sessions that still carry a resumable `sandboxName`. This is what
// the UI needs to render a "Resume" affordance for paused sandboxes
// with no explicit `snapshot_url`.
it("reports hasSnapshot=true when lifecycle is 'hibernated' and sandbox_state is resumable", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{
...baseRow,
sandbox_state: { type: "vercel", sandboxName: "session-sess-1" },
lifecycle_state: "hibernated",
snapshot_url: null,
} as any,
]);

const res = await getSandboxStatusHandler(makeReq());

const body = await res.json();
expect(body.hasSnapshot).toBe(true);
});

it("reports hasSnapshot=false when lifecycle is 'active' and there's no snapshot_url (no resume affordance needed)", async () => {
vi.mocked(selectSessions).mockResolvedValue([
{
...baseRow,
sandbox_state: { type: "vercel", sandboxName: "session-sess-1" },
lifecycle_state: "active",
sandbox_expires_at: FAR_FUTURE,
snapshot_url: null,
} as any,
]);

const res = await getSandboxStatusHandler(makeReq());

const body = await res.json();
expect(body.hasSnapshot).toBe(false);
});
});
22 changes: 22 additions & 0 deletions lib/sandbox/__tests__/getStateExpiresAt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { getStateExpiresAt } from "@/lib/sandbox/getStateExpiresAt";

describe("getStateExpiresAt", () => {
it("returns the numeric expiresAt when present", () => {
expect(getStateExpiresAt({ type: "vercel", expiresAt: 4_102_444_800_000 })).toBe(
4_102_444_800_000,
);
});

it("returns undefined when expiresAt is not a number", () => {
expect(getStateExpiresAt({ type: "vercel", expiresAt: "soon" })).toBeUndefined();
expect(getStateExpiresAt({ type: "vercel" })).toBeUndefined();
});

it("returns undefined for null / undefined / non-object inputs", () => {
expect(getStateExpiresAt(null)).toBeUndefined();
expect(getStateExpiresAt(undefined)).toBeUndefined();
expect(getStateExpiresAt("nope")).toBeUndefined();
expect(getStateExpiresAt(42)).toBeUndefined();
});
});
20 changes: 20 additions & 0 deletions lib/sandbox/__tests__/isSandboxNotFoundError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { isSandboxNotFoundError } from "@/lib/sandbox/isSandboxNotFoundError";

describe("isSandboxNotFoundError", () => {
it.each([
"Got status code 404 from sandbox API",
"Sandbox not found",
"STATUS CODE 404",
"sandbox NOT FOUND in this region",
])("returns true for: %s", message => {
expect(isSandboxNotFoundError(message)).toBe(true);
});

it.each(["request timed out", "ECONNREFUSED", "Status code 500", "sandbox is stopped", ""])(
"returns false for: %s",
message => {
expect(isSandboxNotFoundError(message)).toBe(false);
},
);
});
26 changes: 26 additions & 0 deletions lib/sandbox/__tests__/isSandboxUnavailableError.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { isSandboxUnavailableError } from "@/lib/sandbox/isSandboxUnavailableError";

describe("isSandboxUnavailableError", () => {
it.each([
"Expected a stream of command data",
"Got status code 410",
"status code 404 from sandbox",
"Sandbox is stopped",
"Sandbox not found in region",
"Sandbox probe failed: unknown",
])("returns true for permanent-failure: %s", message => {
expect(isSandboxUnavailableError(message)).toBe(true);
});

it.each([
"ECONNRESET while reading sandbox stream",
"fetch failed",
"request timed out",
"Status code 502 (bad gateway)",
"Status code 503",
"",
])("returns false for transient: %s", message => {
expect(isSandboxUnavailableError(message)).toBe(false);
});
});
18 changes: 18 additions & 0 deletions lib/sandbox/clearSandboxResumeState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Strips *everything* from the persisted `sandbox_state` except the
* `type` discriminator. Used when the sandbox is gone-gone (404 /
* not-found) — even the durable resume handle is stale, so the next
* provision must start from scratch.
*
* Sister helper to `clearSandboxState`, which preserves the resume
* handle for cases where the sandbox can still be reconnected later.
*
* @param state - The current `sandbox_state` JSON value.
* @returns A minimal state with only `type`, or null when the input is null.
*/
export function clearSandboxResumeState(state: unknown): { type: string } | null {
if (!state || typeof state !== "object") return null;

const type = (state as { type?: unknown }).type;
return { type: typeof type === "string" ? type : "vercel" };
}
Loading
Loading