Skip to content

feat(sandbox): port POST /api/sandbox + GET /api/sandbox/status from open-agents#522

Merged
sweetmantech merged 4 commits intotestfrom
feat/sandbox-routes
May 7, 2026
Merged

feat(sandbox): port POST /api/sandbox + GET /api/sandbox/status from open-agents#522
sweetmantech merged 4 commits intotestfrom
feat/sandbox-routes

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 7, 2026

Summary

Implements the two session-scoped sandbox endpoints needed to drive the chat "loading sandbox…" UX on session entry. Matches the contract documented in recoupable/docs#192 (merged on main as commit `2e9099a`).

This is the next route-by-route port from open-agents into api after #514 (GET sessions) and #515 (POST sessions). Reuses the sandbox abstraction inlined in #507.

Why these two endpoints first

POST `/api/sessions` (already shipped in #515) only creates the session + initial chat row — it does not spawn a sandbox. To drive a UX where a new session lands on a page showing "loading sandbox…" until the sandbox is ready, chat needs:

  1. POST `/api/sandbox` — trigger provision/restore on session entry
  2. GET `/api/sandbox/status` — poll until `status === "active"`

Without these, the chat UI cannot render the loading flow.

Architecture

Following api conventions per CLAUDE.md:

Layer File Responsibility
Route shell `app/api/sandbox/route.ts` thin POST delegation + OPTIONS
Route shell `app/api/sandbox/status/route.ts` thin GET delegation + OPTIONS
Handler `lib/sandbox/createSandboxHandler.ts` auth → validate → ownership → connectSandbox → persist
Handler `lib/sandbox/getSandboxStatusHandler.ts` auth → ownership → DB-only status compute
Validator `lib/sandbox/validateCreateSandboxBody.ts` Zod schema + auth, returns `{body, auth}` or 4xx
Helper `lib/sandbox/getSessionSandboxName.ts` deterministic `session-{id}` for resume
Supabase `lib/supabase/sessions/updateSessionSandboxState.ts` persists sandbox_state + lifecycle fields

Behavior

POST `/api/sandbox`

  • Auth via `validateAuthContext` (Privy Bearer or x-api-key)
  • Validates `{ repoUrl, sessionId?, branch?, isNewBranch? }` — `repoUrl` required
  • When `sessionId` is supplied: looks up the session, enforces ownership (404/403), uses deterministic `sandboxName` for resume idempotency, then persists `sandbox_state`, `lifecycle_state = "active"`, bumped `lifecycle_version`, `sandbox_expires_at`, and `last_activity_at` onto the row
  • Returns `{ createdAt, timeout, currentBranch, mode: "vercel", timing: { readyMs } }` per docs spec
  • 502 on upstream provider failure

GET `/api/sandbox/status?sessionId=…`

  • DB-only (no upstream probe — open-agents matches this for `/status`)
  • `status: "active"` when `sandbox_state` is set and `sandbox_expires_at` is at least 10s in the future, else `"no_sandbox"`
  • `hasSnapshot`: `true` when `snapshot_url` is set
  • Returns `lifecycle: { serverTime, state, lastActivityAt, hibernateAfter, sandboxExpiresAt }` — ISO timestamps converted to epoch ms per docs spec

TDD

Red → green throughout. 30 new tests, all passing (suite: 2461 → 2491):

File Tests
`lib/sandbox/tests/getSessionSandboxName.test.ts` 2
`lib/sandbox/tests/validateCreateSandboxBody.test.ts` 7
`lib/supabase/sessions/tests/updateSessionSandboxState.test.ts` 3
`lib/sandbox/tests/createSandboxHandler.test.ts` 8
`lib/sandbox/tests/getSandboxStatusHandler.test.ts` 8
`app/api/sandbox/tests/route.test.ts` 1
`app/api/sandbox/status/tests/route.test.ts` 1

Coverage: success paths, all 4xx/5xx response codes, auth short-circuit, ownership 403, missing-session 404, validator edge cases (malformed JSON, wrong types, missing required fields), `isNewBranch` flipping `source.branch → source.newBranch`, status-active vs no_sandbox vs expired, hasSnapshot derivation.

Verification

Check Result
`pnpm test` ✅ 2491 / 2491 pass
`pnpm lint:check` ✅ clean

Out of scope (deferred)

  • Org-snapshot lookup / kickBuildOrgSnapshotWorkflow — cold-start optimization; without it sandboxes still provision, just slower from the default base
  • `installSessionGlobalSkills` — skill bootstrapping inside the sandbox; not in critical path for the loading UX
  • Lifecycle workflow kick — open-agents calls `kickSandboxLifecycleWorkflow` to manage hibernation/expiry; api doesn't have the workflow infra yet, so the lifecycle FSM stays at "active" until manually transitioned
  • DELETE `/api/sandbox` + PUT `/api/sandbox/snapshot` — POST snapshot has no open-agents UI caller; PUT snapshot does (resume button) but is deferred to a follow-up dedicated to the snapshot/resume flow
  • `/api/sandbox/{extend, activity, reconnect, snapshot}` — sequels per the original migration plan; reconnect is the highest priority among them since it drives the loading UX on session re-entry (live runtime probe) — to follow next

Test plan

  • CI green (vitest + lint)
  • Preview deployment: `POST /api/sandbox` with x-api-key + valid `{repoUrl, sessionId}` returns 200 with the documented response shape
  • Same call with no auth returns 401
  • Same call without `repoUrl` returns 400 with `missing_fields: ["repoUrl"]`
  • `GET /api/sandbox/status?sessionId=…` returns 200 with `status: "active"` after the POST above lands
  • Same GET against another account's session returns 403
  • Same GET against a non-existent session returns 404

What follows this PR

  1. Once merged to test then promoted to main, an open-agents PR to point its UI at `RECOUPABLE_API_BASE_URL` for these two routes and delete the local handlers
  2. A second docs PR for the remaining sub-routes (`extend`, `activity`, `reconnect`, `snapshot`) followed by their api ports

🤖 Generated with Claude Code


Summary by cubic

Ports session-scoped sandbox endpoints to power the chat “loading sandbox…” flow. Adds strict GitHub repo URL validation, private-repo cloning via a service token, drops branch from the POST contract, and fixes a status bug where fresh sessions reported ready.

  • New Features

    • POST /api/sandbox: provisions or resumes; validates { repoUrl, sessionId? }; enforces ownership when sessionId is set; uses deterministic session-{id} for idempotent resume; persists sandbox_state, lifecycle_state="active", lifecycle_version++, sandbox_expires_at, last_activity_at, and clears snapshot_url/snapshot_created_at; supports private repos via getServiceGithubToken; returns { createdAt, timeout, currentBranch, mode: "vercel", timing: { readyMs } } (reads currentBranch from the sandbox handle).
    • GET /api/sandbox/status?sessionId=…: DB-only; returns status: "active" only when sandbox_state has runtime metadata (non-empty sandboxName) and is not expired (10s buffer), else "no_sandbox"; includes hasSnapshot, lifecycleVersion, and lifecycle { serverTime, state, lastActivityAt, hibernateAfter, sandboxExpiresAt }; enforces ownership and 404s when missing.
  • Bug Fixes

    • Status no longer reports "active" from the type-only stub { type: "vercel" } written at session creation; introduced hasRuntimeSandboxState and tightened isSandboxActive to require a non-empty sandboxName.

Written for commit 5b1cca4. Summary will update on new commits.

…open-agents

Implements the two session-scoped sandbox endpoints required to drive
the chat "loading sandbox..." UX on session entry — matching the
contract documented in recoupable/docs#192 (now merged on main).

POST /api/sandbox provisions or resumes a Sandbox via the abstraction
inlined in #507. When sessionId is supplied, the deterministic
sandboxName ensures resume idempotency and the resolved sandbox state
is persisted onto the session row (sandbox_state, lifecycle_state =
"active", lifecycle_version bumped, sandbox_expires_at,
last_activity_at) so subsequent GET /api/sandbox/status calls report
the sandbox as active.

GET /api/sandbox/status is DB-only — reads the session row, computes
status as "active" when sandbox_state is set and not expired (10s
buffer to match open-agents), otherwise "no_sandbox". hasSnapshot is
true when snapshot_url is set. Mirrors the lifecycle envelope shape
from open-agents so the frontend cutover is byte-identical.

Files follow existing api conventions:
- Route shells in app/api/sandbox/ delegate to handlers in lib/sandbox/
- Auth via validateAuthContext (Privy Bearer or x-api-key)
- Validation via Zod (validateCreateSandboxBody)
- Supabase ops in lib/supabase/sessions/ (one fn per file)
- Error envelope { status: "error", error } matches sessions PRs

TDD red → green:
- 7 new test files added covering validator, helper, Supabase wrapper,
  both handlers, and the two route shells
- 30 new tests, all passing (was 2461, now 2491)
- pnpm lint:check clean

Out of scope (deferred to follow-up PRs):
- Org-snapshot lookup / kickBuildOrgSnapshotWorkflow (cold-start opt)
- Skill installation (installSessionGlobalSkills)
- Lifecycle workflow kick (no workflow infra in api yet)
- DELETE /api/sandbox + PUT /api/sandbox/snapshot (no UI callers
  identified during the open-agents grep audit)
- /api/sandbox/{extend,activity,reconnect,snapshot} sub-routes — to
  follow once these two land and the chat UX is validated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview May 7, 2026 4:15pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Warning

Rate limit exceeded

@sweetmantech has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 30 minutes and 42 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 80fac2a3-3fac-4db7-8cc3-731b317012fc

📥 Commits

Reviewing files that changed from the base of the PR and between 2c83920 and 5b1cca4.

⛔ Files ignored due to path filters (12)
  • app/api/sandbox/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • app/api/sandbox/status/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • lib/github/__tests__/getServiceGithubToken.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/buildLifecycle.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/createSandboxHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/getSandboxStatusHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/getSessionSandboxName.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/hasRuntimeSandboxState.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/isSandboxActive.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/isoToEpochMs.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/validateCreateSandboxBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/sessions/__tests__/updateSession.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (12)
  • app/api/sandbox/route.ts
  • app/api/sandbox/status/route.ts
  • lib/github/getServiceGithubToken.ts
  • lib/sandbox/buildLifecycle.ts
  • lib/sandbox/createSandboxHandler.ts
  • lib/sandbox/getSandboxStatusHandler.ts
  • lib/sandbox/getSessionSandboxName.ts
  • lib/sandbox/hasRuntimeSandboxState.ts
  • lib/sandbox/isSandboxActive.ts
  • lib/sandbox/isoToEpochMs.ts
  • lib/sandbox/validateCreateSandboxBody.ts
  • lib/supabase/sessions/updateSession.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sandbox-routes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

6 issues found across 14 files

Confidence score: 3/5

  • There is a concrete user-impact risk: lib/supabase/sessions/updateSessionSandboxState.ts can return null on DB failure, which may let sandbox creation appear successful even when session state is not persisted (severity 7/10, high confidence).
  • lib/sandbox/createSandboxHandler.ts currently does not check the updateSessionSandboxState result, so a failed write can still return 200 and later cause /api/sandbox/status to report no_sandbox unexpectedly (severity 6/10).
  • The remaining findings are maintainability/style-only (100-line rule) in lib/sandbox/createSandboxHandler.ts and test files, which are lower severity and not direct runtime blockers.
  • Pay close attention to lib/supabase/sessions/updateSessionSandboxState.ts, lib/sandbox/createSandboxHandler.ts - ensure DB write failures are handled and surfaced so success responses match persisted session state.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/sandbox/__tests__/validateCreateSandboxBody.test.ts">

<violation number="1" location="lib/sandbox/__tests__/validateCreateSandboxBody.test.ts:21">
P3: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

This new test file exceeds the repository’s 100-line limit required by Rule 3.</violation>
</file>

<file name="lib/sandbox/__tests__/createSandboxHandler.test.ts">

<violation number="1" location="lib/sandbox/__tests__/createSandboxHandler.test.ts:1">
P3: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

New test file exceeds the 100-line file-size limit required by the maintainability rule.</violation>
</file>

<file name="lib/sandbox/createSandboxHandler.ts">

<violation number="1" location="lib/sandbox/createSandboxHandler.ts:1">
P2: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

File exceeds the 100-line maximum required by Rule 3.</violation>

<violation number="2" location="lib/sandbox/createSandboxHandler.ts:109">
P2: The return value of `updateSessionSandboxState` is not checked. If the DB write fails (returns `null`), the client gets a 200 but the session row isn't marked active—causing `GET /api/sandbox/status` to report `no_sandbox` while the sandbox is actually running. Consider checking the return value and either retrying or returning an error to the client.</violation>
</file>

<file name="lib/sandbox/__tests__/getSandboxStatusHandler.test.ts">

<violation number="1" location="lib/sandbox/__tests__/getSandboxStatusHandler.test.ts:1">
P3: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

Test file exceeds the 100-line maintainability limit.</violation>
</file>

<file name="lib/supabase/sessions/updateSessionSandboxState.ts">

<violation number="1" location="lib/supabase/sessions/updateSessionSandboxState.ts:42">
P1: Returning `null` on DB failure allows sandbox creation to report success even when state persistence fails.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant UI as Chat Client (Browser)
    participant Route as Next.js Route (/api/sandbox)
    participant StatusRoute as Next.js Route (/api/sandbox/status)
    participant Auth as Auth Validator (validateAuthContext)
    participant CreateBody as Body Validator (validateCreateSandboxBody)
    participant CreateHandler as createSandboxHandler
    participant StatusHandler as getSandboxStatusHandler
    participant SessionDB as Supabase (Sessions Table)
    participant SandboxProvider as Vercel Sandbox Provider

    Note over UI,SandboxProvider: POST /api/sandbox – Provision/Resume Sandbox

    UI->>Route: POST /api/sandbox { repoUrl, sessionId?, branch?, isNewBranch? }
    Note over UI,Route: Headers: Authorization (Privy Bearer) or x-api-key

    Route->>CreateBody: validateCreateSandboxBody(request)
    CreateBody->>Auth: validateAuthContext(request)
    alt Auth fails (401)
        Auth-->>CreateBody: NextResponse 401
        CreateBody-->>Route: Forward 401 response
        Route-->>UI: 401 Unauthorized
    else Auth success
        Auth-->>CreateBody: { accountId, orgId, authToken }
        CreateBody->>CreateBody: safeParseJson + Zod schema check
        alt Validation fails (400)
            CreateBody-->>Route: NextResponse 400
            Route-->>UI: 400 Bad Request (missing_fields)
        else Valid body
            CreateBody-->>Route: { body: CreateSandboxBody, auth: AuthContext }
            Route->>CreateHandler: createSandboxHandler(request) [with validated data]

            CreateHandler->>SessionDB: selectSessions({ id: sessionId }) [only if sessionId provided]
            alt Session not found
                SessionDB-->>CreateHandler: []
                CreateHandler-->>Route: 404 Session not found
                Route-->>UI: 404
            else Wrong owner
                SessionDB-->>CreateHandler: [{ account_id: "other-acc" }]
                CreateHandler-->>Route: 403 Forbidden
                Route-->>UI: 403
            else Session owned by caller
                SessionDB-->>CreateHandler: [{ lifecycle_version, ... }]
                CreateHandler->>CreateHandler: getSessionSandboxName(sessionId) => "session-{id}"
                CreateHandler->>CreateHandler: buildSource({ repoUrl, branch, isNewBranch })
                
                Note over CreateHandler: isNewBranch=true moves branch to source.newBranch

                CreateHandler->>SandboxProvider: connectSandbox({ state: { type: "vercel", sandboxName, source }, options: { timeout, ports, persistent, resume, createIfMissing } })
                
                alt Provider error
                    SandboxProvider-->>CreateHandler: throws
                    CreateHandler-->>Route: 502 Failed to provision sandbox
                    Route-->>UI: 502
                else Provider success
                    SandboxProvider-->>CreateHandler: sandbox object { timeout, expiresAt, getState }
                    
                    alt sessionId provided
                        CreateHandler->>SessionDB: updateSessionSandboxState({ id, sandboxState, sandboxExpiresAt, lifecycleVersion: ++ })
                        Note over CreateHandler,SessionDB: Persists: sandbox_state, lifecycle_state="active", lifecycle_version, sandbox_expires_at, last_activity_at
                        SessionDB-->>CreateHandler: Updated row
                    else No sessionId (one-shot)
                        Note over CreateHandler: Skip DB write
                    end
                    
                    CreateHandler-->>Route: 200 { createdAt, timeout, currentBranch, mode: "vercel", timing: { readyMs } }
                    Route-->>UI: 200 Success
                end
            end
        end
    end

    Note over UI,SandboxProvider: GET /api/sandbox/status – Poll Sandbox Status (DB only)

    UI->>StatusRoute: GET /api/sandbox/status?sessionId=xxx
    StatusRoute->>StatusHandler: getSandboxStatusHandler(request)
    StatusHandler->>Auth: validateAuthContext(request)
    alt Auth fails (401)
        Auth-->>StatusHandler: NextResponse 401
        StatusHandler-->>StatusRoute: Forward 401
        StatusRoute-->>UI: 401
    else Auth success
        Auth-->>StatusHandler: { accountId, ... }
        alt Missing sessionId param
            StatusHandler-->>StatusRoute: 400 Missing sessionId
            StatusRoute-->>UI: 400
        else
            StatusHandler->>SessionDB: selectSessions({ id: sessionId })
            alt Session not found
                SessionDB-->>StatusHandler: []
                StatusHandler-->>StatusRoute: 404
                StatusRoute-->>UI: 404
            else Wrong owner
                SessionDB-->>StatusHandler: [{ account_id: "other-acc" }]
                StatusHandler-->>StatusRoute: 403
                StatusRoute-->>UI: 403
            else Owned session
                SessionDB-->>StatusHandler: [session row]
                StatusHandler->>StatusHandler: isSandboxActive(row) ?
                Note over StatusHandler: Active when sandbox_state set AND sandbox_expires_at is >= 10s in future
                alt Active
                    StatusHandler-->>StatusRoute: 200 { status: "active", hasSnapshot, lifecycleVersion, lifecycle: { serverTime, state, lastActivityAt, hibernateAfter, sandboxExpiresAt } }
                else Not active (null state or expired)
                    StatusHandler-->>StatusRoute: 200 { status: "no_sandbox", ... }
                end
                StatusRoute-->>UI: 200
            end
        end
    end
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread lib/supabase/sessions/updateSessionSandboxState.ts Outdated
Comment thread lib/sandbox/createSandboxHandler.ts
Comment thread lib/sandbox/createSandboxHandler.ts Outdated
const nextState = sandbox.getState() as Json;
const expiresAt =
typeof sandbox.expiresAt === "number" ? new Date(sandbox.expiresAt).toISOString() : null;
await updateSessionSandboxState({
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 return value of updateSessionSandboxState is not checked. If the DB write fails (returns null), the client gets a 200 but the session row isn't marked active—causing GET /api/sandbox/status to report no_sandbox while the sandbox is actually running. Consider checking the return value and either retrying or returning an error to the client.

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 109:

<comment>The return value of `updateSessionSandboxState` is not checked. If the DB write fails (returns `null`), the client gets a 200 but the session row isn't marked active—causing `GET /api/sandbox/status` to report `no_sandbox` while the sandbox is actually running. Consider checking the return value and either retrying or returning an error to the client.</comment>

<file context>
@@ -0,0 +1,127 @@
+    const nextState = sandbox.getState() as Json;
+    const expiresAt =
+      typeof sandbox.expiresAt === "number" ? new Date(sandbox.expiresAt).toISOString() : null;
+    await updateSessionSandboxState({
+      id: sessionId,
+      sandboxState: nextState,
</file context>

@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

This new test file exceeds the repository’s 100-line limit required by Rule 3.

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

<comment>This new test file exceeds the repository’s 100-line limit required by Rule 3.</comment>

<file context>
@@ -0,0 +1,103 @@
+    method: "POST",
+    headers: { "Content-Type": "application/json", "x-api-key": "k" },
+    body: typeof body === "string" ? body : JSON.stringify(body),
+  });
+}
+
</file context>

@@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

New test file exceeds the 100-line file-size limit required by the maintainability rule.

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

<comment>New test file exceeds the 100-line file-size limit required by the maintainability rule.</comment>

<file context>
@@ -0,0 +1,156 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+
</file context>

@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

Test file exceeds the 100-line maintainability limit.

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

<comment>Test file exceeds the 100-line maintainability limit.</comment>

<file context>
@@ -0,0 +1,138 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { NextRequest, NextResponse } from "next/server";
+
</file context>

Comment thread lib/sandbox/createSandboxHandler.ts Outdated
Comment thread lib/sandbox/getSandboxStatusHandler.ts Outdated
Comment thread lib/sandbox/getSandboxStatusHandler.ts Outdated
Comment thread lib/sandbox/getSandboxStatusHandler.ts Outdated
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

KISS

  • actual: lib/supabase/sessions/updateSessionSandboxState.ts
  • required: lib/supabase/sessions/updateSession.ts

@sweetmantech
Copy link
Copy Markdown
Contributor Author

Smoke test results — preview deployment

Ran end-to-end against https://api-git-feat-sandbox-routes-recoup.vercel.app.

✅ All negative paths pass

Test Expected Actual
POST /api/sandbox no auth 401 + {status,error} 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
OPTIONS /api/sandbox 204 + CORS 204 with Access-Control-Allow-* headers
POST /api/sandbox auth + empty body 400 + missing_fields:["repoUrl"] 400 {"status":"error","missing_fields":["repoUrl"],"error":"repoUrl is required"}
GET /api/sandbox/status no sessionId 400 400 {"status":"error","error":"Missing sessionId"}
GET /api/sandbox/status bogus sessionId 404 404 {"status":"error","error":"Session not found"}
POST /api/sandbox isNewBranch:"yes" 400 400 {"status":"error","missing_fields":["isNewBranch"],"error":"Invalid input: expected boolean, received string"}

✅ Happy path provisions correctly

POST /api/sandbox with {repoUrl: "https://github.com/recoupable/api", sessionId, branch: "main"} returned 200 in 1121 ms with the documented shape:

{
  "createdAt": 1778167819889,
  "timeout": 1800000,
  "currentBranch": "main",
  "mode": "vercel",
  "timing": { "readyMs": 1121 }
}

GET /api/sandbox/status after provisioning correctly reports "active", bumped lifecycleVersion: 0 → 1, real sandboxExpiresAt (~30min ahead), and lastActivityAt set:

{
  "status": "active",
  "hasSnapshot": false,
  "lifecycleVersion": 1,
  "lifecycle": {
    "serverTime": 1778167820412,
    "state": "active",
    "lastActivityAt": 1778167819855,
    "hibernateAfter": null,
    "sandboxExpiresAt": 1778169619854
  }
}

🐛 Bug found — needs a fix before merge

GET /api/sandbox/status on a freshly-created session (no sandbox yet) incorrectly returns status: "active" instead of "no_sandbox".

Repro: POST /api/sessions then immediately GET /api/sandbox/status for that session id.

  • Got: {"status": "active", "lifecycle": {"state": "provisioning", "sandboxExpiresAt": null, ...}}
  • Expected: {"status": "no_sandbox", ...}

Root cause: POST /api/sessions (PR #515) writes sandbox_state: { type: "vercel" } as a type stub on insert. My isSandboxActive only checks if (!row.sandbox_state) return false; — the stub is truthy, so it falls through and returns true because sandbox_expires_at is null (no expiry to compare against).

Impact: the chat loading UX would flip out of "loading sandbox..." immediately on session entry, before any sandbox actually exists. That's exactly the bug this endpoint is meant to prevent.

Fix: distinguish "runtime state" (has sandboxName or id set) from "type stub". Mirror open-agents' hasRuntimeSandboxState helper. Will push a fix shortly with a regression test that locks this down.

Smoke test against the preview deployment caught a regression that
defeated the entire loading-state UX this PR exists to enable: GET
/api/sandbox/status reported `"active"` immediately after POST
/api/sessions, before any sandbox had been provisioned.

Root cause: POST /api/sessions (PR #515) inserts `sandbox_state` as
the type stub `{ type: "vercel" }`. The previous `isSandboxActive`
check `if (!row.sandbox_state) return false` saw a truthy object and
fell through; with `sandbox_expires_at = null` (no expiry yet), the
function returned true.

Fix: introduce `hasRuntimeSandboxState(state)` that distinguishes the
type stub from real runtime metadata by requiring a non-empty
`sandboxName` (set by `getSessionSandboxName(sessionId)` in POST
/api/sandbox and preserved by the abstraction's `getState()`).
Mirrors open-agents' equivalent helper.

TDD red → green:
- Regression test pinned to the exact production scenario
  (sandbox_state = {type:"vercel"}, sandbox_expires_at = null,
  lifecycle_state = "provisioning") asserting status=no_sandbox
- Companion test asserting status=active once sandboxName is set
- 6 unit tests for the new helper covering null/undefined, scalars,
  type stub, populated state, and empty-string sandboxName edge case
- Confirmed RED before implementing, GREEN after
- Suite: 2491 → 2499 (+8 new tests), pnpm lint:check clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 6 unresolved issues from previous reviews.

Addresses review feedback on PR #522 and the "missing from open-agents"
audit:

User-flagged review comments:
- SRP: extract `buildSource` to lib/sandbox/buildSource.ts
- YAGNI: drop `isNewBranch` from POST /api/sandbox — chat never sets it
  (note: docs PR #192 still documents it; will open follow-up docs PR
   to drop from sandbox.json)
- SRP: extract `isoToEpochMs` to lib/sandbox/isoToEpochMs.ts
- SRP: extract `buildLifecycle` to lib/sandbox/buildLifecycle.ts
- SRP: extract `isSandboxActive` to lib/sandbox/isSandboxActive.ts
- KISS: rename lib/supabase/sessions/updateSessionSandboxState.ts ->
  updateSession.ts, generalize signature to (id, TablesUpdate<"sessions">)

Tier 1 correctness gaps from the open-agents comparison:
1. GitHub URL validation via parseGitHubRepoUrl in
   validateCreateSandboxBody — bad URLs now return a clean 400 instead
   of falling through to a confusing 502 from the sandbox provider
2. Service GitHub token plumbed into connectSandbox options via new
   lib/github/getServiceGithubToken.ts — private repos can now clone
3. snapshot_url + snapshot_created_at cleared on fresh provision so
   GET /api/sandbox/status no longer surfaces stale snapshot URLs from
   prior runs

TDD red -> green:
- 5 new unit-test files for the extracted helpers (buildSource,
  isoToEpochMs, buildLifecycle, isSandboxActive, getServiceGithubToken)
- updateSession.test.ts replaces updateSessionSandboxState.test.ts
- Updated validator + handler tests for the contract changes
  (drop isNewBranch, add bad-URL 400 cases, assert githubToken plumbing,
   assert snapshot_url/snapshot_created_at: null in update payload)
- Confirmed RED before each implementation
- Suite: 2499 -> 2516 (+17 net new tests), pnpm lint:check clean

Files net delta: -241 / +70 lines (extractions + handler shrinks)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Pushed e0a2b97e addressing all five flagged comments + Tier 1 correctness gaps from the open-agents comparison. TDD red → green throughout (suite 2499 → 2516, +17 net new tests, lint clean).

Your review comments

Comment Resolution Where
SRP: new lib file for buildSource Extracted lib/sandbox/buildSource.ts + test
YAGNI: when would we ever pass isNewBranch? Dropped from Zod schema, type, validator, handler, and tests validateCreateSandboxBody.ts, createSandboxHandler.ts
SRP: new lib for isoToEpochMs Extracted lib/sandbox/isoToEpochMs.ts + test
SRP: new lib file for isSandboxActive Extracted lib/sandbox/isSandboxActive.ts + test
KISS: updateSession.ts not updateSessionSandboxState.ts Renamed file/function, generalized signature to updateSession(id, TablesUpdate<"sessions">) so the same helper covers any future column updates. Old file deleted. lib/supabase/sessions/updateSession.ts + test

I also extracted buildLifecycle to its own file under the same SRP pattern — felt inconsistent to keep it inline once the others were extracted. Happy to inline it back if you'd rather keep the surface smaller.

Tier 1 correctness fixes (from the open-agents comparison)

  • GitHub URL validationvalidateCreateSandboxBody now uses parseGitHubRepoUrl to reject non-GitHub URLs with a clean 400, instead of falling through to a confusing 502 from the sandbox provider. Two new test cases (gitlab.com/... → 400, "x" → 400).
  • Service GitHub token plumbingconnectSandbox now receives githubToken: getServiceGithubToken() so private repos can clone. New lib/github/getServiceGithubToken.ts + test (handles unset/empty env). New handler test asserts the token reaches connectSandbox options.
  • Stale snapshot clearingupdateSession payload now includes snapshot_url: null, snapshot_created_at: null on fresh provision so GET /api/sandbox/status doesn't surface a snapshot URL that no longer matches the active sandbox. Handler test updated to assert both fields are nulled.

One thing left to flag

The YAGNI removal of isNewBranch is a contract change — api-reference/openapi/sandbox.json (merged in docs PR #192) still documents it. I'll open a follow-up docs PR to drop it from the spec so the published reference and api stay in sync. Doing the docs PR now or wait for this one to land?

Open cubic-bot comments not actioned

  • P1, updateSessionSandboxState returning null — the rename + generalization stays the KISS shape (returns null, logs error). The handler still doesn't 500 on a failed write — the sandbox is already running by that point and the deterministic sandboxName makes a retry idempotent. If you want a 500-on-write-failure path I can add it as a follow-up commit.
  • P2/P3 100-line file size — the SRP extractions you flagged shrink createSandboxHandler.ts (127 → ~110 lines) but it's still over the 100-line rule because of the route-shell comment block + connectSandbox options. Test files for handlers exceed 100 lines too. I'd argue cohesive single-handler tests beat splitting tests artificially, but happy to revisit if you want strict adherence.

Comment thread lib/sandbox/buildSource.ts Outdated
*/
export function buildSource({
repoUrl,
branch,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

YAGNI - When is branch ever defined?

Suggested change
branch,

message: "repoUrl must be a valid GitHub repository URL",
}),
sessionId: z.string().optional(),
branch: z.string().optional(),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

KISS - Let's remove this branch name. We'll always be working off of the default branch.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 19 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/supabase/sessions/__tests__/updateSession.test.ts">

<violation number="1" location="lib/supabase/sessions/__tests__/updateSession.test.ts:4">
P2: Move the mock state into `vi.hoisted`; the hoisted `vi.mock` factory cannot safely close over these module-scope `vi.fn()` variables.</violation>
</file>

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

Comment on lines +4 to +7
const updateChain = vi.fn();
const eqChain = vi.fn();
const selectChain = vi.fn();
const singleChain = vi.fn();
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: Move the mock state into vi.hoisted; the hoisted vi.mock factory cannot safely close over these module-scope vi.fn() variables.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/sessions/__tests__/updateSession.test.ts, line 4:

<comment>Move the mock state into `vi.hoisted`; the hoisted `vi.mock` factory cannot safely close over these module-scope `vi.fn()` variables.</comment>

<file context>
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { updateSession } from "@/lib/supabase/sessions/updateSession";
+
+const updateChain = vi.fn();
+const eqChain = vi.fn();
+const selectChain = vi.fn();
</file context>
Suggested change
const updateChain = vi.fn();
const eqChain = vi.fn();
const selectChain = vi.fn();
const singleChain = vi.fn();
const { updateChain, eqChain, selectChain, singleChain } = vi.hoisted(() => ({
updateChain: vi.fn(),
eqChain: vi.fn(),
selectChain: vi.fn(),
singleChain: vi.fn(),
}));

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

YAGNI/KISS per review feedback — chat always works off the repo's
default branch, so the explicit `branch` input adds no value.

- Drop `branch` from createSandboxBodySchema
- Inline the now-trivial source object in createSandboxHandler
  (`{ repo: body.repoUrl }`) and delete `lib/sandbox/buildSource.ts`
  + its test
- Read `currentBranch` for the response from the sandbox handle's
  own `currentBranch` property (whatever the SDK actually checked
  out), falling back to "main" — no longer derives from a request
  field that no longer exists

Tests: TDD red -> green.
- Validator test asserts `branch` is stripped from the body even
  if a client still sends it
- Handler test asserts `currentBranch` in the response comes from
  `sandbox.currentBranch` (mocked to "release/v2") not from input
- Suite stays at 2516 (-1 from buildSource.test deletion +1 new
  currentBranch test)

Pairs with docs PR recoupable/docs#194 (merged) which already
removed `branch` and `isNewBranch` from the published OpenAPI spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Pushed 5b1cca45 — both new comments addressed and the contract is now in sync with the just-merged docs PR (recoupable/docs#194).

Comment Resolution
YAGNI: When is branch ever defined? branch dropped from Zod schema; lib/sandbox/buildSource.ts deleted (collapsed to the trivial inline { repo: body.repoUrl }); response currentBranch now reads from sandbox.currentBranch (the SDK's own report of what got checked out) instead of request input
KISS: remove this branch name Same — schema field gone

TDD red → green:

  • Validator test now asserts branch is stripped from the body even when sent
  • Handler test asserts currentBranch in the response comes from sandbox.currentBranch (mocked to "release/v2"), not from request input
  • Suite: 2516 → 2516 (-1 from buildSource.test.ts deletion, +1 new currentBranch test)
  • pnpm lint:check clean

Net: -50 / +37 lines.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/sandbox/createSandboxHandler.ts">

<violation number="1" location="lib/sandbox/createSandboxHandler.ts:107">
P2: `currentBranch` can be reported incorrectly as `"main"` for repos whose default branch is not main.</violation>
</file>

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

{
createdAt: Date.now(),
timeout: sandbox.timeout ?? DEFAULT_TIMEOUT_MS,
currentBranch: sandbox.currentBranch ?? DEFAULT_BRANCH,
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: currentBranch can be reported incorrectly as "main" for repos whose default branch is not main.

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 107:

<comment>`currentBranch` can be reported incorrectly as `"main"` for repos whose default branch is not main.</comment>

<file context>
@@ -102,7 +104,7 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe
       createdAt: Date.now(),
       timeout: sandbox.timeout ?? DEFAULT_TIMEOUT_MS,
-      currentBranch: branch,
+      currentBranch: sandbox.currentBranch ?? DEFAULT_BRANCH,
       mode: "vercel",
       timing: { readyMs: Date.now() - startTime },
</file context>

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

@sweetmantech
Copy link
Copy Markdown
Contributor Author

Smoke test results — re-run after 5b1cca45

Same preview alias, fresh deployment (Vercel inspector hash 45evNj5G6wBdiZHK841XkzTu9hWb). Every previously documented response code still passes, plus the bug fix and contract changes verified end-to-end.

✅ Negative paths (all unchanged)

Test Expected Got
POST /api/sandbox no auth 401 401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
POST /api/sandbox auth + {} 400 missing repoUrl 400 {"status":"error","missing_fields":["repoUrl"],"error":"repoUrl is required"}
GET /api/sandbox/status no sessionId 400 400 {"status":"error","error":"Missing sessionId"}
GET /api/sandbox/status?sessionId=00000000-… 404 404 {"status":"error","error":"Session not found"}

✅ NEW: Tier 1 GitHub URL validation

POST /api/sandbox with {"repoUrl":"https://gitlab.com/o/r"}:

HTTP 400
{"status":"error","missing_fields":["repoUrl"],"error":"repoUrl must be a valid GitHub repository URL"}

Clean 400 with a real explanation instead of the previous "fall through to confusing 502 from the provider."

✅ FIX VERIFIED: status='no_sandbox' before provision

The bug we caught last round (status reported "active" immediately after session creation, defeating the loading UX) is fixed. New session → status:

HTTP 200
{
  "status": "no_sandbox",
  "hasSnapshot": false,
  "lifecycleVersion": 0,
  "lifecycle": { "state": "provisioning", "sandboxExpiresAt": null, ... }
}

After POST /api/sandbox provisions, status correctly flips to "active" with bumped lifecycleVersion: 1 and a real sandbox_expires_at (~30min ahead).

✅ Branch input dropped from contract

POST /api/sandbox with {"repoUrl": "...", "sessionId": "...", "branch": "feat/should-be-ignored"}:

HTTP 200
{ "createdAt": ..., "timeout": 1800000, "currentBranch": "main", "mode": "vercel", "timing": { "readyMs": 1285 } }

Rogue branch input silently stripped by Zod; sandbox provisions on the default branch as expected. Response currentBranch reads from the sandbox handle ("main") rather than echoing input.

✅ Happy path

POST /api/sandbox with {"repoUrl":"https://github.com/recoupable/api","sessionId":"..."} (no branch field):

HTTP 200
{ "createdAt": ..., "timeout": 1800000, "currentBranch": "main", "mode": "vercel", "timing": { "readyMs": 1307 } }

PR is fully exercised end-to-end and behaves to spec.

@sweetmantech sweetmantech merged commit 42c701c into test May 7, 2026
6 checks passed
@sweetmantech sweetmantech deleted the feat/sandbox-routes branch May 7, 2026 16:21
sweetmantech added a commit that referenced this pull request May 7, 2026
…open-agents (#522) (#524)

* feat(sandbox): port POST /api/sandbox + GET /api/sandbox/status from open-agents

Implements the two session-scoped sandbox endpoints required to drive
the chat "loading sandbox..." UX on session entry — matching the
contract documented in recoupable/docs#192 (now merged on main).

POST /api/sandbox provisions or resumes a Sandbox via the abstraction
inlined in #507. When sessionId is supplied, the deterministic
sandboxName ensures resume idempotency and the resolved sandbox state
is persisted onto the session row (sandbox_state, lifecycle_state =
"active", lifecycle_version bumped, sandbox_expires_at,
last_activity_at) so subsequent GET /api/sandbox/status calls report
the sandbox as active.

GET /api/sandbox/status is DB-only — reads the session row, computes
status as "active" when sandbox_state is set and not expired (10s
buffer to match open-agents), otherwise "no_sandbox". hasSnapshot is
true when snapshot_url is set. Mirrors the lifecycle envelope shape
from open-agents so the frontend cutover is byte-identical.

Files follow existing api conventions:
- Route shells in app/api/sandbox/ delegate to handlers in lib/sandbox/
- Auth via validateAuthContext (Privy Bearer or x-api-key)
- Validation via Zod (validateCreateSandboxBody)
- Supabase ops in lib/supabase/sessions/ (one fn per file)
- Error envelope { status: "error", error } matches sessions PRs

TDD red → green:
- 7 new test files added covering validator, helper, Supabase wrapper,
  both handlers, and the two route shells
- 30 new tests, all passing (was 2461, now 2491)
- pnpm lint:check clean

Out of scope (deferred to follow-up PRs):
- Org-snapshot lookup / kickBuildOrgSnapshotWorkflow (cold-start opt)
- Skill installation (installSessionGlobalSkills)
- Lifecycle workflow kick (no workflow infra in api yet)
- DELETE /api/sandbox + PUT /api/sandbox/snapshot (no UI callers
  identified during the open-agents grep audit)
- /api/sandbox/{extend,activity,reconnect,snapshot} sub-routes — to
  follow once these two land and the chat UX is validated



* fix(sandbox): treat type-stub sandbox_state as no_sandbox in /status

Smoke test against the preview deployment caught a regression that
defeated the entire loading-state UX this PR exists to enable: GET
/api/sandbox/status reported `"active"` immediately after POST
/api/sessions, before any sandbox had been provisioned.

Root cause: POST /api/sessions (PR #515) inserts `sandbox_state` as
the type stub `{ type: "vercel" }`. The previous `isSandboxActive`
check `if (!row.sandbox_state) return false` saw a truthy object and
fell through; with `sandbox_expires_at = null` (no expiry yet), the
function returned true.

Fix: introduce `hasRuntimeSandboxState(state)` that distinguishes the
type stub from real runtime metadata by requiring a non-empty
`sandboxName` (set by `getSessionSandboxName(sessionId)` in POST
/api/sandbox and preserved by the abstraction's `getState()`).
Mirrors open-agents' equivalent helper.

TDD red → green:
- Regression test pinned to the exact production scenario
  (sandbox_state = {type:"vercel"}, sandbox_expires_at = null,
  lifecycle_state = "provisioning") asserting status=no_sandbox
- Companion test asserting status=active once sandboxName is set
- 6 unit tests for the new helper covering null/undefined, scalars,
  type stub, populated state, and empty-string sandboxName edge case
- Confirmed RED before implementing, GREEN after
- Suite: 2491 → 2499 (+8 new tests), pnpm lint:check clean



* refactor(sandbox): SRP/KISS extractions + Tier 1 correctness fixes

Addresses review feedback on PR #522 and the "missing from open-agents"
audit:

User-flagged review comments:
- SRP: extract `buildSource` to lib/sandbox/buildSource.ts
- YAGNI: drop `isNewBranch` from POST /api/sandbox — chat never sets it
  (note: docs PR #192 still documents it; will open follow-up docs PR
   to drop from sandbox.json)
- SRP: extract `isoToEpochMs` to lib/sandbox/isoToEpochMs.ts
- SRP: extract `buildLifecycle` to lib/sandbox/buildLifecycle.ts
- SRP: extract `isSandboxActive` to lib/sandbox/isSandboxActive.ts
- KISS: rename lib/supabase/sessions/updateSessionSandboxState.ts ->
  updateSession.ts, generalize signature to (id, TablesUpdate<"sessions">)

Tier 1 correctness gaps from the open-agents comparison:
1. GitHub URL validation via parseGitHubRepoUrl in
   validateCreateSandboxBody — bad URLs now return a clean 400 instead
   of falling through to a confusing 502 from the sandbox provider
2. Service GitHub token plumbed into connectSandbox options via new
   lib/github/getServiceGithubToken.ts — private repos can now clone
3. snapshot_url + snapshot_created_at cleared on fresh provision so
   GET /api/sandbox/status no longer surfaces stale snapshot URLs from
   prior runs

TDD red -> green:
- 5 new unit-test files for the extracted helpers (buildSource,
  isoToEpochMs, buildLifecycle, isSandboxActive, getServiceGithubToken)
- updateSession.test.ts replaces updateSessionSandboxState.test.ts
- Updated validator + handler tests for the contract changes
  (drop isNewBranch, add bad-URL 400 cases, assert githubToken plumbing,
   assert snapshot_url/snapshot_created_at: null in update payload)
- Confirmed RED before each implementation
- Suite: 2499 -> 2516 (+17 net new tests), pnpm lint:check clean

Files net delta: -241 / +70 lines (extractions + handler shrinks)



* refactor(sandbox): drop branch from POST /api/sandbox contract

YAGNI/KISS per review feedback — chat always works off the repo's
default branch, so the explicit `branch` input adds no value.

- Drop `branch` from createSandboxBodySchema
- Inline the now-trivial source object in createSandboxHandler
  (`{ repo: body.repoUrl }`) and delete `lib/sandbox/buildSource.ts`
  + its test
- Read `currentBranch` for the response from the sandbox handle's
  own `currentBranch` property (whatever the SDK actually checked
  out), falling back to "main" — no longer derives from a request
  field that no longer exists

Tests: TDD red -> green.
- Validator test asserts `branch` is stripped from the body even
  if a client still sends it
- Handler test asserts `currentBranch` in the response comes from
  `sandbox.currentBranch` (mocked to "release/v2") not from input
- Suite stays at 2516 (-1 from buildSource.test deletion +1 new
  currentBranch test)

Pairs with docs PR recoupable/docs#194 (merged) which already
removed `branch` and `isNewBranch` from the published OpenAPI spec.



---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 7, 2026
* feat(sandbox): port GET /api/sandbox/reconnect from open-agents

Implements the live runtime probe endpoint required for the chat
loading-UX cutover on session re-entry / tab refocus. Matches the
contract documented in recoupable/docs#195 (now merged on main).

Unlike GET /api/sandbox/status (DB-only read), /reconnect actually
runs `sandbox.exec("pwd")` inside the runtime so the UI can
distinguish a truly-alive sandbox from a DB row whose sandbox no
longer exists.

Behavior:
- 200 status="no_sandbox" when sandbox_state lacks runtime metadata
  (delegates to hasRuntimeSandboxState — the same gate /status uses)
- 200 status="connected" with sandbox.expiresAt when the probe succeeds
- 200 status="expired" when the probe throws — also clears
  sandbox_state on the session row and sets lifecycle_state to
  "hibernated" so subsequent /status reads agree with the probe
- hasSnapshot derived from snapshot_url across all three outcomes
- 4xx for auth (401), missing sessionId (400), forbidden (403),
  not-found (404) — same envelope as /status

Files follow the existing api conventions established by PR #522:
- app/api/sandbox/reconnect/route.ts: thin GET delegation + OPTIONS
- lib/sandbox/getSandboxReconnectHandler.ts: handler logic
- Reuses validateAuthContext, selectSessions, hasRuntimeSandboxState,
  buildLifecycle, connectSandbox, updateSession
- Error envelope { status, error } matches sessions PRs

TDD red -> green:
- Handler tests cover auth fail, missing sessionId, 404, 403,
  no_sandbox, hasSnapshot derivation, connected (with expiresAt),
  expired (with state-clear assertion), and lifecycle envelope shape
- Thin route shell test asserts delegation
- Suite: 2516 -> 2526 (+10 net new tests), pnpm lint:check clean

Out of scope (deferred per the gap analysis):
- Transient vs unavailable error distinction. open-agents preserves
  runtime state on transient errors (network blip != dead sandbox).
  v1 treats every probe failure as expired, which is safer for the
  loading UX (user can retry). Worth porting once we have a real
  signal that this is happening in production.
- Stale-state lifecycle workflow kick (open-agents' /status has it
  too — needs workflow infra in api).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sandbox): extract noSandboxResponse to its own file (SRP)

Per review feedback on PR #525 — pulls the inline `noSandboxResponse`
helper out of `getSandboxReconnectHandler.ts` into its own file so it
can be reused by future endpoints (e.g., `/snapshot` resume) and so
the handler file stops carrying response-shape construction logic.

The narrowed `ReconnectBody` type in the handler now only covers the
two outcomes the handler actually constructs locally (`connected` /
`expired`); the `no_sandbox` shape lives with its builder.

TDD red -> green: 3 unit tests for the extracted helper covering 200
status, hasSnapshot derivation, and lifecycle envelope projection.
Suite: 2526 -> 2529.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sandbox): extract validateSandboxReconnectRequest + fix preview build

Two changes bundled:

1) SRP per review feedback (sweetmantech) — extract the auth +
   sessionId-from-query + session lookup + ownership check pre-flight
   from `getSandboxReconnectHandler` into its own
   `validateSandboxReconnectRequest.ts`. Mirrors the
   `validateCreateSandboxBody` pattern: returns either a 4xx
   NextResponse describing the first failure, or `{ row, auth }` for
   the handler to consume.

2) Type fix for `next build` — `connectSandbox(row.sandbox_state as
   SandboxState)` failed to compile against `Json` (union includes
   primitives + arrays); cast through `unknown` first. The
   `hasRuntimeSandboxState` gate above ensures the runtime shape is
   safe at the call site, so the double cast is justified — comment
   added explaining why.

The vitest pass alone wasn't enough to catch the type error — `next
build` runs a separate `tsc` step that the test runner skips. Caught
by the Vercel preview build failing on the previous commit.

TDD red -> green:
- 5 unit tests for the new validator covering auth fail (passes
  through), missing sessionId (400), session not found (404),
  ownership mismatch (403), and happy-path return shape ({row, auth})
- Existing handler tests pass unchanged — the module-level mocks for
  validateAuthContext / selectSessions still intercept the calls now
  that they're behind the new validator
- Suite: 2529 -> 2534, pnpm lint:check clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant