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 };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not swallow workflow errors; rethrow after logging so failed runs are recorded correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/workflows/buildOrgSnapshotWorkflow.ts, line 27:

<comment>Do not swallow workflow errors; rethrow after logging so failed runs are recorded correctly.</comment>

<file context>
@@ -0,0 +1,29 @@
+  } 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 };
+  }
+}
</file context>

Tip: Review your code locally with the cubic CLI to iterate faster.

}
Comment on lines +20 to +28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Vercel Workflow (workflow@4.2.0-beta.72), if a workflow catches an exception and returns a value instead of throwing, is the run considered failed and eligible for automatic retries?

💡 Result:

No. In Vercel Workflow, if you catch an exception and return a value instead of throwing a failure (i.e., you don’t let the step/workflow end in an error), the run/step is considered successful rather than failed, so it is not eligible for the automatic retry mechanism. What counts as retryable is tied to failures being surfaced as errors: by default, “errors thrown inside steps are retried” [1], and you can explicitly control retrying by throwing RetryableError (or stop retrying by throwing FatalError) [1][2]. If you catch the exception and simply return a value, you’re preventing the thrown error from occurring, so there’s no failure state to trigger retries. Notes: - If a step throws until it exhausts retries, the run is marked failed (and you can inspect the run status as “failed”) [3]. - Separately, retries may also occur due to certain infrastructure/runtime issues, but those still originate from an error path rather than a caught-and-returned success value [4].

Citations:


Rethrow errors to preserve run failure state and enable automatic retries.

Catching errors and returning { success: false } prevents Vercel Workflow from marking the run as failed, which disables automatic retries and obscures true failure conditions. Log the error, then rethrow it to surface the failure properly.

💡 Proposed fix
 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 };
+  throw error instanceof Error ? error : new Error(message);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/workflows/buildOrgSnapshotWorkflow.ts` around lines 20 - 28, The current
try/catch in buildOrgSnapshotWorkflow swallows errors and returns { success:
false } which prevents Vercel Workflow from marking the run as failed; instead,
after logging the error (preserve the existing console.error call that formats
message for input.sandboxName), rethrow the original error (or throw a new Error
with the message) so the workflow run fails and can be retried; update the catch
block around buildSnapshotStep so it no longer returns { success: false } but
logs then throws the error (or remove the catch entirely to let the error
bubble).

}
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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new snapshot-miss branch can repeatedly start duplicate build workflows for the same org while an earlier build is still in progress.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/sandbox/createSandboxHandler.ts, line 75:

<comment>The new snapshot-miss branch can repeatedly start duplicate build workflows for the same org while an earlier build is still in progress.</comment>

<file context>
@@ -67,6 +68,17 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
+  // 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,
</file context>

kickBuildOrgSnapshotWorkflow({
cloneUrl: body.repoUrl,
sandboxName: orgRepoName,
});
}
Comment on lines +75 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In workflow@4.2.0-beta.72, what is the recommended way to deduplicate start(...) calls for the same logical job (idempotency key / unique run key / concurrency controls)?

💡 Result:

In workflow@4.2.0-beta.72, the recommended way to deduplicate start(...) calls for the same logical job (idempotency key / unique run key) is to provide a custom runId in the StartOptions object. If a run with that runId already exists, start() returns the existing run instead of creating a duplicate.[1][2] Example: const run = await start(myWorkflow, args, { runId: `order-${orderId}` }); This ensures idempotency: repeated calls with the same runId return the same run object.[1][2] For concurrency controls, there is an ongoing discussion about a native lock primitive (distributed semaphore) to limit concurrent executions for the same key inside workflows, but it appears not yet implemented in beta.72 (no mention in release notes).[3][4] Use application-level checks (e.g., check run status before starting) or the custom runId for deduplication as primary mechanism.[5][6]

Citations:


Use deterministic runId to prevent duplicate org snapshot workflow kicks.

When parallel requests miss the snapshot check simultaneously, both can trigger identical builds. Implement idempotency by passing a deterministic runId based on the org identifier to the start() call in kickBuildOrgSnapshotWorkflow. This ensures that concurrent identical requests reuse the same workflow run instead of spawning duplicates:

const run = await start(myWorkflow, args, { 
  runId: `org-snapshot-${orgRepoName}` 
});

This prevents redundant clones, snapshot operations, and avoidable cost/rate pressure from concurrent requests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/sandbox/createSandboxHandler.ts` around lines 75 - 80, The current call
to kickBuildOrgSnapshotWorkflow can start duplicate workflows when parallel
requests race; update kickBuildOrgSnapshotWorkflow (and its internal call to
start) to pass a deterministic runId based on the org identifier (e.g., use
`orgRepoName`) so identical requests reuse the same workflow run; specifically,
modify the start(...) invocation inside kickBuildOrgSnapshotWorkflow to include
an options object with runId set to a stable string like
`org-snapshot-${orgRepoName}` derived from the same org identifier used in the
if block, ensuring idempotent workflow starts.


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