diff --git a/lib/recoupable/__tests__/extractOrgRepoName.test.ts b/lib/recoupable/__tests__/extractOrgRepoName.test.ts new file mode 100644 index 00000000..8f5b2c9a --- /dev/null +++ b/lib/recoupable/__tests__/extractOrgRepoName.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; + +describe("extractOrgRepoName", () => { + it("extracts the repo name from a recoupable org clone URL", () => { + expect(extractOrgRepoName("https://github.com/recoupable/org-rostrum-pacific-abc123")).toBe( + "org-rostrum-pacific-abc123", + ); + }); + + it("strips a trailing .git suffix", () => { + expect(extractOrgRepoName("https://github.com/recoupable/myorg.git")).toBe("myorg"); + }); + + it("strips a trailing slash", () => { + expect(extractOrgRepoName("https://github.com/recoupable/myorg/")).toBe("myorg"); + }); + + it("returns null for non-recoupable orgs", () => { + expect(extractOrgRepoName("https://github.com/someoneelse/repo")).toBeNull(); + }); + + it("returns null for nested paths beyond the repo segment", () => { + expect(extractOrgRepoName("https://github.com/recoupable/repo/blob/main/x")).toBeNull(); + }); + + it("returns null for non-GitHub URLs", () => { + expect(extractOrgRepoName("https://gitlab.com/recoupable/repo")).toBeNull(); + expect(extractOrgRepoName("not-a-url")).toBeNull(); + }); + + it("returns null for the org root with no repo", () => { + expect(extractOrgRepoName("https://github.com/recoupable/")).toBeNull(); + expect(extractOrgRepoName("https://github.com/recoupable")).toBeNull(); + }); +}); diff --git a/lib/recoupable/extractOrgRepoName.ts b/lib/recoupable/extractOrgRepoName.ts new file mode 100644 index 00000000..a033fdb6 --- /dev/null +++ b/lib/recoupable/extractOrgRepoName.ts @@ -0,0 +1,19 @@ +const ORG_REPO_URL_PATTERN = /^https:\/\/github\.com\/recoupable\/([^/]+?)(?:\.git)?\/?$/; + +/** + * Extracts the repo name from a Recoupable org clone URL. The repo + * name is used as a `sandboxName` to look up per-org base snapshots + * built by the build-org-snapshot workflow — finding one warm-boots + * the sandbox in seconds instead of paying the ~75s full-clone path. + * + * Example: `https://github.com/recoupable/org-rostrum-pacific-` + * → `org-rostrum-pacific-` + * + * @param cloneUrl - The repo URL the caller wants to clone. + * @returns The repo name when the URL is under the recoupable org, + * otherwise null. Non-recoupable repos skip the snapshot lookup. + */ +export function extractOrgRepoName(cloneUrl: string): string | null { + const match = cloneUrl.match(ORG_REPO_URL_PATTERN); + return match?.[1] ?? null; +} diff --git a/lib/sandbox/__tests__/createSandboxHandler.test.ts b/lib/sandbox/__tests__/createSandboxHandler.test.ts index d257f9b3..dbc49eff 100644 --- a/lib/sandbox/__tests__/createSandboxHandler.test.ts +++ b/lib/sandbox/__tests__/createSandboxHandler.test.ts @@ -7,6 +7,7 @@ import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { connectSandbox } from "@/lib/sandbox/factory"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; +import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), @@ -29,6 +30,9 @@ vi.mock("@/lib/github/getServiceGithubToken", () => ({ vi.mock("@/lib/sandbox/installSessionGlobalSkills", () => ({ installSessionGlobalSkills: vi.fn(async () => undefined), })); +vi.mock("@/lib/sandbox/findOrgSnapshot", () => ({ + findOrgSnapshot: vi.fn(async () => null), +})); const ACCOUNT_ID = "acc-1"; @@ -167,6 +171,65 @@ describe("createSandboxHandler", () => { expect(res.status).toBe(200); }); + it("looks up an org snapshot and plumbs its id into baseSnapshotId when the repo is a recoupable org repo", async () => { + vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({ + body: { + repoUrl: "https://github.com/recoupable/org-acme-xyz", + sessionId: "sess-1", + }, + auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" }, + }); + vi.mocked(findOrgSnapshot).mockResolvedValueOnce("snap_abc123"); + + await createSandboxHandler(makeReq()); + + expect(findOrgSnapshot).toHaveBeenCalledWith("org-acme-xyz"); + const arg = vi.mocked(connectSandbox).mock.calls[0]?.[0]; + if (!arg || !("options" in arg)) throw new Error("expected new-API config shape"); + if (!("state" in arg)) throw new Error("expected new-API state shape"); + expect(arg.options?.baseSnapshotId).toBe("snap_abc123"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((arg.state as any).source.prebuilt).toBe(true); + }); + + it("skips the snapshot lookup entirely for non-recoupable repos", async () => { + vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({ + body: { + repoUrl: "https://github.com/someoneelse/repo", + sessionId: "sess-1", + }, + auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" }, + }); + + await createSandboxHandler(makeReq()); + + expect(findOrgSnapshot).not.toHaveBeenCalled(); + const arg = vi.mocked(connectSandbox).mock.calls[0]?.[0]; + if (!arg || !("options" in arg)) throw new Error("expected new-API config shape"); + expect(arg.options?.baseSnapshotId).toBeUndefined(); + }); + + it("does not pass baseSnapshotId when the org snapshot lookup misses", async () => { + vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({ + body: { + repoUrl: "https://github.com/recoupable/org-no-snap-yet", + sessionId: "sess-1", + }, + auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" }, + }); + vi.mocked(findOrgSnapshot).mockResolvedValueOnce(null); + + await createSandboxHandler(makeReq()); + + expect(findOrgSnapshot).toHaveBeenCalledWith("org-no-snap-yet"); + const arg = vi.mocked(connectSandbox).mock.calls[0]?.[0]; + if (!arg || !("options" in arg)) throw new Error("expected new-API config shape"); + if (!("state" in arg)) throw new Error("expected new-API state shape"); + expect(arg.options?.baseSnapshotId).toBeUndefined(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((arg.state as any).source.prebuilt).toBe(false); + }); + it("does not attempt skill installation when no sessionId is provided", async () => { vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({ body: { repoUrl: "https://github.com/o/r" }, diff --git a/lib/sandbox/__tests__/findOrgSnapshot.test.ts b/lib/sandbox/__tests__/findOrgSnapshot.test.ts new file mode 100644 index 00000000..bd992310 --- /dev/null +++ b/lib/sandbox/__tests__/findOrgSnapshot.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; +import { Snapshot } from "@vercel/sandbox"; + +vi.mock("@vercel/sandbox", () => ({ + Snapshot: { list: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("findOrgSnapshot", () => { + it("returns the id of the most recent created snapshot", async () => { + vi.mocked(Snapshot.list).mockResolvedValue({ + snapshots: [ + { id: "snap_A", status: "creating" }, + { id: "snap_B", status: "created" }, + { id: "snap_C", status: "created" }, + ], + } as never); + + const id = await findOrgSnapshot("org-x"); + // Returns the FIRST created snapshot in the list (sortOrder desc means first = newest). + expect(id).toBe("snap_B"); + }); + + it("calls Snapshot.list with the supplied name and a desc sort order", async () => { + vi.mocked(Snapshot.list).mockResolvedValue({ snapshots: [] } as never); + + await findOrgSnapshot("org-y"); + + expect(Snapshot.list).toHaveBeenCalledWith( + expect.objectContaining({ name: "org-y", sortOrder: "desc" }), + ); + }); + + it("returns null when no snapshots are in the 'created' state", async () => { + vi.mocked(Snapshot.list).mockResolvedValue({ + snapshots: [{ id: "snap_pending", status: "creating" }], + } as never); + + expect(await findOrgSnapshot("org-z")).toBeNull(); + }); + + it("returns null when the API returns no snapshots", async () => { + vi.mocked(Snapshot.list).mockResolvedValue({ snapshots: [] } as never); + expect(await findOrgSnapshot("org-empty")).toBeNull(); + }); + + it("returns null when Snapshot.list throws", async () => { + vi.mocked(Snapshot.list).mockRejectedValue(new Error("vercel api down")); + expect(await findOrgSnapshot("org-err")).toBeNull(); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 00891e4c..01e5f499 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -4,8 +4,10 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { connectSandbox } from "@/lib/sandbox/factory"; +import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; import { installSessionGlobalSkills } from "@/lib/sandbox/installSessionGlobalSkills"; +import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; import type { Json, Tables } from "@/types/database.types"; @@ -57,6 +59,14 @@ export async function createSandboxHandler(request: NextRequest): Promise { + try { + const result = await Snapshot.list({ + name: sandboxName, + sortOrder: "desc", + limit: 5, + }); + const ready = result.snapshots.find(s => s.status === "created"); + console.log( + `[findOrgSnapshot] '${sandboxName}' → ${ready ? `hit ${ready.id}` : "miss"} (${result.snapshots.length} total snapshots returned)`, + ); + return ready?.id ?? null; + } catch (error) { + console.error(`[findOrgSnapshot] failed to list snapshots for '${sandboxName}':`, error); + return null; + } +}