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
29 changes: 29 additions & 0 deletions app/workflows/buildOrgSnapshotWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { buildSnapshotStep, type BuildOrgSnapshotInput } from "@/app/workflows/buildSnapshotStep";

/**
* Vercel Workflow that provisions a per-org base snapshot for warm-boot
* of future session sandboxes. Kicked fire-and-forget from
* `createSandboxHandler` when a recoupable org URL is requested but
* no `created` snapshot exists yet.
*
* Single step today (provision + clone + snapshot via `refreshBaseSnapshot`),
* wrapped here for the durable execution semantics — failures retry up
* to 3× automatically, the run is observable in the Vercel dashboard,
* and the request that kicked the workflow is fully decoupled from
* its lifetime.
*/
export async function buildOrgSnapshotWorkflow(input: BuildOrgSnapshotInput) {
"use workflow";

console.log(`[build-org-snapshot] workflow:start name='${input.sandboxName}'`);

try {
const snapshotId = await buildSnapshotStep(input);
console.log(`[build-org-snapshot] Built ${snapshotId} for '${input.sandboxName}'`);
return { success: true as const, snapshotId };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[build-org-snapshot] Failed for '${input.sandboxName}':`, message);
return { success: false as const, error: message };
}
}
49 changes: 49 additions & 0 deletions app/workflows/buildSnapshotStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { refreshBaseSnapshot } from "@/lib/sandbox/abstraction";
import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken";
import { DEFAULT_SANDBOX_BASE_SNAPSHOT_ID } from "@/lib/sandbox/defaultBaseSnapshotId";
import { shellEscape } from "@/lib/sandbox/shellEscape";

const BUILD_SANDBOX_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const BUILD_COMMAND_TIMEOUT_MS = 8 * 60 * 1000; // 8 minutes — leaves buffer under sandbox timeout

export interface BuildOrgSnapshotInput {
cloneUrl: string;
sandboxName: string;
}

/**
* Single step of `buildOrgSnapshotWorkflow`. Provisions a sandbox from
* the recoup base snapshot, runs `git clone --depth=1 <cloneUrl> .`
* inside it, and snapshots the result. Returns the new snapshot id.
*
* The cloneUrl is shell-escaped before interpolation: the validator
* upstream of this workflow already rejects anything that doesn't
* match `^https:\/\/github\.com\/recoupable\/...`, but defense-in-depth
* — never trust the validator to also be a shell-quoter.
*
* Logging deliberately omits `cloneUrl` to avoid surfacing any token
* embedded as `https://user:token@github.com/...`. The `sandboxName`
* is the regex-extracted repo name only, so it's safe to log.
*/
export async function buildSnapshotStep(input: BuildOrgSnapshotInput): Promise<string> {
"use step";

console.log(`[build-org-snapshot] step:start name='${input.sandboxName}'`);

const githubToken = getServiceGithubToken() ?? undefined;
if (!githubToken) {
throw new Error("[build-org-snapshot] GITHUB_TOKEN is not set; cannot clone org repo");
}

const result = await refreshBaseSnapshot({
baseSnapshotId: DEFAULT_SANDBOX_BASE_SNAPSHOT_ID,
sandboxName: input.sandboxName,
sandboxTimeoutMs: BUILD_SANDBOX_TIMEOUT_MS,
commandTimeoutMs: BUILD_COMMAND_TIMEOUT_MS,
githubToken,
commands: [`git clone --depth=1 ${shellEscape(input.cloneUrl)} .`],
log: message => console.log(`[build-org-snapshot] ${message}`),
});

return result.snapshotId;
}
55 changes: 53 additions & 2 deletions lib/sandbox/__tests__/createSandboxHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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";
import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }),
Expand All @@ -33,6 +34,9 @@ vi.mock("@/lib/sandbox/installSessionGlobalSkills", () => ({
vi.mock("@/lib/sandbox/findOrgSnapshot", () => ({
findOrgSnapshot: vi.fn(async () => null),
}));
vi.mock("@/lib/sandbox/kickBuildOrgSnapshotWorkflow", () => ({
kickBuildOrgSnapshotWorkflow: vi.fn(),
}));

const ACCOUNT_ID = "acc-1";

Expand Down Expand Up @@ -188,7 +192,7 @@ describe("createSandboxHandler", () => {
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);
});

Expand Down Expand Up @@ -226,10 +230,57 @@ describe("createSandboxHandler", () => {
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("kicks the build-org-snapshot workflow on a recoupable miss so the next session warm-boots", 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(kickBuildOrgSnapshotWorkflow).toHaveBeenCalledWith({
cloneUrl: "https://github.com/recoupable/org-no-snap-yet",
sandboxName: "org-no-snap-yet",
});
});

it("does not kick the build workflow when an org snapshot already exists (hit case)", async () => {
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
body: {
repoUrl: "https://github.com/recoupable/org-already-snapped",
sessionId: "sess-1",
},
auth: { accountId: ACCOUNT_ID, orgId: null, authToken: "k" },
});
vi.mocked(findOrgSnapshot).mockResolvedValueOnce("snap_existing");

await createSandboxHandler(makeReq());

expect(kickBuildOrgSnapshotWorkflow).not.toHaveBeenCalled();
});

it("does not kick the build workflow 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(kickBuildOrgSnapshotWorkflow).not.toHaveBeenCalled();
});

it("does not attempt skill installation when no sessionId is provided", async () => {
vi.mocked(validateCreateSandboxBody).mockResolvedValueOnce({
body: { repoUrl: "https://github.com/o/r" },
Expand Down
12 changes: 12 additions & 0 deletions lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshotWorkflow";
import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName";
import { updateSession } from "@/lib/supabase/sessions/updateSession";
import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken";
Expand Down Expand Up @@ -67,6 +68,17 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
const orgRepoName = extractOrgRepoName(body.repoUrl);
const orgSnapshotId = orgRepoName ? await findOrgSnapshot(orgRepoName) : null;

// Miss: kick a background workflow to build a snapshot for this org so
// the *next* session warm-boots from it. This request still pays the
// full-clone cold-start path — the workflow runs durably outside the
// request lifecycle.
if (orgRepoName && !orgSnapshotId) {
kickBuildOrgSnapshotWorkflow({
cloneUrl: body.repoUrl,
sandboxName: orgRepoName,
});
}

const startTime = Date.now();

let sandbox;
Expand Down
28 changes: 28 additions & 0 deletions lib/sandbox/defaultBaseSnapshotId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Base snapshot used by `buildOrgSnapshotWorkflow` to bootstrap a fresh
* sandbox before cloning an org repo into it. Lets the workflow skip
* provisioning a bare image and start from one with the standard
* Recoup tooling already installed, so the subsequent `git clone` is
* the only meaningful work.
*
* Override at deploy time via `VERCEL_SANDBOX_BASE_SNAPSHOT_ID` to
* roll forward to a newer base. The hardcoded fallback is the
* snapshot that lives in the Recoup Vercel team.
*
* Current snapshot includes:
* - jq (dnf install -y jq)
* - bun (curl -fsSL https://bun.sh/install | sudo BUN_INSTALL=/usr/local bash)
* - agent-browser (sudo npm install -g agent-browser)
* - code-server (curl -fsSL https://code-server.dev/install.sh | sudo sh)
*
* To refresh: provision a clean sandbox with the @vercel/sandbox SDK,
* run the install commands above (plus any new ones), snapshot it via
* `vercel sandbox snapshot <id> --stop`, and update the constant
* below with the new id.
*
* Tooling note: chromium is intentionally NOT in this base — Amazon
* Linux 2023's default repo doesn't carry it, and `agent-browser`
* fetches a managed Playwright browser on first use anyway.
*/
export const DEFAULT_SANDBOX_BASE_SNAPSHOT_ID =
process.env.VERCEL_SANDBOX_BASE_SNAPSHOT_ID ?? "snap_RgVtpDO4y1BJHQiUbptMwS3Rt2EQ";
40 changes: 40 additions & 0 deletions lib/sandbox/kickBuildOrgSnapshotWorkflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { start } from "workflow/api";
import { buildOrgSnapshotWorkflow } from "@/app/workflows/buildOrgSnapshotWorkflow";

interface KickBuildOrgSnapshotInput {
cloneUrl: string;
sandboxName: string;
}

/**
* Fire-and-forget kick of `buildOrgSnapshotWorkflow`. Used by
* `createSandboxHandler` when a recoupable org repo is requested but
* no `created` snapshot exists yet — the next session for the same
* org will warm-boot from the snapshot this build produces.
*
* Failures are logged but never surfaced. The current request always
* falls back to the slow full-clone path; what we're protecting is
* that *future* requests don't have to.
*
* Logging omits `cloneUrl` to avoid surfacing any embedded credential
* (e.g. `https://user:token@github.com/...`) — the `sandboxName` is
* already the regex-extracted repo name only, which uniquely
* identifies the org for observability without exposing tokens.
*
* @param input - The repo URL to clone and the sandbox name to use
* (which becomes the snapshot's name and the lookup key for
* `findOrgSnapshot`).
*/
export function kickBuildOrgSnapshotWorkflow(input: KickBuildOrgSnapshotInput) {
void start(buildOrgSnapshotWorkflow, [input]).then(
run =>
console.log(
`[build-org-snapshot] Started workflow run ${run.runId} for '${input.sandboxName}'`,
),
error =>
console.error(
`[build-org-snapshot] Failed to start workflow for '${input.sandboxName}':`,
error,
),
);
}
3 changes: 2 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withWorkflow } from "workflow/next";

const nextConfig: NextConfig = {
env: {
Expand All @@ -24,4 +25,4 @@ const nextConfig: NextConfig = {
},
};

export default nextConfig;
export default withWorkflow(nextConfig);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"sharp": "^0.34.5",
"stripe": "^17.4.0",
"viem": "^2.21.26",
"workflow": "^4.2.4",
"x402-fetch": "^0.7.3",
"x402-next": "^0.7.3",
"zod": "^4.1.13",
Expand Down
Loading
Loading