Skip to content

feat(sandbox): port GET /api/sandbox/reconnect from open-agents#525

Merged
sweetmantech merged 3 commits into
testfrom
feat/sandbox-reconnect
May 7, 2026
Merged

feat(sandbox): port GET /api/sandbox/reconnect from open-agents#525
sweetmantech merged 3 commits into
testfrom
feat/sandbox-reconnect

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 7, 2026

Summary

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

Sequel to #522 (POST /api/sandbox + GET /api/sandbox/status). Reuses everything that PR established — validateAuthContext, selectSessions, hasRuntimeSandboxState, buildLifecycle, connectSandbox, updateSession.

Why this endpoint

GET /api/sandbox/status is DB-only. It can't tell you whether the recorded sandbox_state actually points at a sandbox that's still alive — only what the row says. On session re-entry (user closes the tab, comes back tomorrow) the chat UI needs to distinguish three cases:

  1. connected — sandbox is still reachable, resume immediately
  2. expired — DB says we have one but the runtime is gone — UI should offer resume-from-snapshot or fresh-create
  3. no_sandbox — never had one

/reconnect runs a quick sandbox.exec(\"pwd\") inside the sandbox to make that distinction. Without this endpoint, returning to a hibernated session leaves the UI stuck in a loading-state polling loop.

Architecture

Layer File Responsibility
Route shell app/api/sandbox/reconnect/route.ts thin GET delegation + OPTIONS
Handler lib/sandbox/getSandboxReconnectHandler.ts auth → ownership → no-runtime short-circuit → live probe → DB clear on expired

Behavior

  • 200 status=\"no_sandbox\" when sandbox_state lacks runtime metadata (hasRuntimeSandboxState returns false). Skips the probe entirely.
  • 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: \"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 {status, error} envelope as the existing endpoints.

TDD

Strict red → green. +10 new tests, all passing (suite: 2516 → 2526):

File Tests
lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts 9
app/api/sandbox/reconnect/__tests__/route.test.ts 1

Coverage: auth short-circuit, all 4xx response codes, no-runtime short-circuit (asserts probe is skipped), hasSnapshot derivation, connected (with expiresAt from sandbox handle), expired (with state-clear assertion on the session row), lifecycle envelope shape on every 200.

Verification

Check Result
pnpm test ✅ 2526 / 2526 pass
pnpm lint:check ✅ clean

Out of scope (deferred)

  • Transient vs unavailable error distinction. open-agents preserves runtime state on transient errors (a network blip is not the same as a dead sandbox). v1 treats every probe failure as expired, which is safer for the loading UX (a retry just creates a fresh sandbox under the deterministic sandboxName). Worth porting once we have a real signal this is happening in production.
  • Stale-state lifecycle workflow kick. open-agents' /status has the same gap — needs workflow infra in api.

Test plan

  • CI green (vitest + lint)
  • Preview: GET /api/sandbox/reconnect?sessionId=… with no auth returns 401
  • Preview: same call without sessionId returns 400
  • Preview: bogus sessionId returns 404
  • Preview: fresh session (no sandbox yet) returns 200 with status: \"no_sandbox\"
  • Preview: after POST /api/sandbox provisions, /reconnect returns 200 with status: \"connected\" and a real expiresAt

What follows

Once this lands and the test → main promote happens:

  • An open-agents PR pointing the UI's /api/sandbox/reconnect calls at RECOUPABLE_API_BASE_URL and deleting the local handler
  • The cutover for POST + GET /status + GET /reconnect becomes safe (third missing piece resolved)

🤖 Generated with Claude Code


Summary by cubic

Adds GET /api/sandbox/reconnect to probe sandbox liveness on session re-entry so the UI can resume or recreate reliably. Extracts shared helpers and fixes a next build type error.

  • New Features

    • Probes by running pwd; returns 200 with status: "connected" (with expiresAt), "expired" (clears sandbox_state, sets lifecycle_state: "hibernated"), or "no_sandbox".
    • Always includes hasSnapshot and a lifecycle envelope; 4xx for auth (401), missing sessionId (400), forbidden (403), and not-found (404).
    • Route in app/api/sandbox/reconnect/route.ts (with CORS OPTIONS); handler in lib/sandbox/getSandboxReconnectHandler.ts.
  • Refactors

    • Extracted lib/sandbox/noSandboxResponse.ts and lib/sandbox/validateSandboxReconnectRequest.ts (auth + sessionId + lookup + ownership).
    • Fixed next build type issue by casting row.sandbox_state via unknown before SandboxState.
    • Tests cover route delegation, validator paths, no_sandbox helper, and connected/expired flows.

Written for commit 8a1e660. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added sandbox session reconnect functionality to restore connections to previously established sandboxes
    • Automatic detection and validation of sandbox session status with expiration handling
    • Improved session lifecycle management with automatic cleanup of expired sandbox connections

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>
@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 5:25pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Review Change Stack

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 28 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: 335bf9d8-c3e3-4c6a-8696-c385285c0ccf

📥 Commits

Reviewing files that changed from the base of the PR and between c66f12f and 8a1e660.

⛔ Files ignored due to path filters (2)
  • lib/sandbox/__tests__/noSandboxResponse.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sandbox/__tests__/validateSandboxReconnectRequest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (3)
  • lib/sandbox/getSandboxReconnectHandler.ts
  • lib/sandbox/noSandboxResponse.ts
  • lib/sandbox/validateSandboxReconnectRequest.ts
📝 Walkthrough

Walkthrough

This PR introduces a new /api/sandbox/reconnect endpoint that probes the current status of a sandbox session. The handler authenticates, validates session ownership, checks runtime state, and either returns the current lifecycle or attempts a live connection probe with automatic hibernation recovery on timeout.

Changes

Sandbox Reconnect Endpoint

Layer / File(s) Summary
Data Shapes & Response Contract
lib/sandbox/getSandboxReconnectHandler.ts
Defines ReconnectBody interface with status ("no_sandbox" | "connected" | "expired"), hasSnapshot, optional expiresAt and lifecycle; introduces PROBE_TIMEOUT_MS constant (15 seconds).
Reconnect Handler & Probe Logic
lib/sandbox/getSandboxReconnectHandler.ts
Authenticates request, validates and fetches session, enforces account ownership. Branches on missing sandbox state to return no\_sandbox response; otherwise probes sandbox via connectSandbox().exec("pwd") within timeout. On probe failure, updates session runtime fields to hibernated state and returns expired response. Helper noSandboxResponse() constructs no\_sandbox payloads with lifecycle and snapshot presence.
Route Handlers & CORS
app/api/sandbox/reconnect/route.ts
Adds OPTIONS handler returning 204 with getCorsHeaders(); adds GET handler delegating to getSandboxReconnectHandler().
Route Runtime Configuration
app/api/sandbox/reconnect/route.ts
Sets dynamic = "force-dynamic", fetchCache = "force-no-store", and revalidate = 0 to disable caching and ensure live request processing.

Estimated Code Review Effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly Related PRs

  • recoupable/api#509: The reconnect handler's probe logic and sandbox connection/lifecycle API depend on refactored sandbox interaction patterns introduced in this PR.

Poem

🏠 A session calls home from the sandbox shore,
💫 "Are you still awake? Please reconnect, I implore."
⚡ With a pwd whisper and 15 seconds to spare,
🌊 Live or expired, the truth floats through the air—
🔄 And if silence replies, we'll rest in hibernare. 💤

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning DRY violation: hard-coded lifecycle object in expired path instead of reusing buildLifecycle(). SRP: 7 distinct responsibilities. KISS: inconsistent lifecycle handling. Reuse buildLifecycle() consistently. Extract auth/validation/query logic into separate helpers to reduce function responsibilities per SOLID principles.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sandbox-reconnect

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

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
lib/sandbox/getSandboxReconnectHandler.ts (3)

99-109: ⚡ Quick win

Inline lifecycle construction in the expired branch is a DRY risk.

The "no_sandbox" path calls buildLifecycle(row), but the "expired" branch hand-rolls the same shape. While ReturnType<typeof buildLifecycle> will cause a compile error if the shapes diverge today, any new semantically meaningful field added to buildLifecycle in the future won't automatically appear here.

A cleaner alternative is to fabricate an updated row and delegate to buildLifecycle:

♻️ Proposed refactor
-    const body: ReconnectBody = {
-      status: "expired",
-      hasSnapshot: !!row.snapshot_url,
-      lifecycle: {
-        serverTime: Date.now(),
-        state: "hibernated",
-        lastActivityAt: null,
-        hibernateAfter: null,
-        sandboxExpiresAt: null,
-      },
-    };
+    const expiredRow = {
+      ...row,
+      lifecycle_state: "hibernated" as const,
+      sandbox_state: null,
+      sandbox_expires_at: null,
+    };
+    const body: ReconnectBody = {
+      status: "expired",
+      hasSnapshot: !!row.snapshot_url,
+      lifecycle: buildLifecycle(expiredRow),
+    };
🤖 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/getSandboxReconnectHandler.ts` around lines 99 - 109, The expired
branch manually constructs a lifecycle object, duplicating the shape produced by
buildLifecycle and risking future drift; instead, create an updated row object
that reflects the expired state (e.g., set fields like state: "hibernated",
lastActivityAt: null, hibernateAfter: null, sandboxExpiresAt: null or adjust
snapshot_url as needed) and call buildLifecycle(updatedRow) to produce the
lifecycle used in the ReconnectBody (replace the inline lifecycle in the expired
branch with the result of buildLifecycle), ensuring you still set status:
"expired" and hasSnapshot: !!row.snapshot_url on the body.

42-112: ⚖️ Poor tradeoff

Handler exceeds the 50-line guideline for lib/ functions.

getSandboxReconnectHandler spans ~70 lines. The auth guard, ownership check, short-circuit, live probe, and expiry recovery are distinct responsibilities. Consider extracting the live-probe logic (lines 77–111) into a focused helper (e.g., probeSandboxConnection) to bring the main handler under the limit and improve unit-testability of each path.

As per coding guidelines: "Keep functions under 50 lines" and "Flag functions longer than 20 lines."

🤖 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/getSandboxReconnectHandler.ts` around lines 42 - 112,
getSandboxReconnectHandler is over the 50-line limit; extract the live-probe and
expiry-recovery logic into a new helper (e.g., probeSandboxConnection) so the
handler only performs auth/ownership checks and delegates probing. The helper
should accept the session row and sessionId, call
connectSandbox(row.sandbox_state as SandboxState), run sandbox.exec("pwd",
sandbox.workingDirectory, PROBE_TIMEOUT_MS), return the ReconnectBody on success
(using sandbox.expiresAt and buildLifecycle(row)), and on failure perform the
updateSession(...) rollback (setting sandbox_state/null, lifecycle_state
"hibernated", sandbox_expires_at null, hibernate_after null) and return the
expired ReconnectBody; then make getSandboxReconnectHandler call
probeSandboxConnection and return its NextResponse (or construct the JSON
response from the helper result), keeping references to connectSandbox,
PROBE_TIMEOUT_MS, updateSession, noSandboxResponse, selectSessions, and
buildLifecycle.

48-54: ⚡ Quick win

sessionId needs Zod schema validation.

The query param is validated only for null/absence. A Zod schema would catch malformed values (e.g., non-UUID strings) before they hit the database, consistent with the project's validation standards.

✨ Proposed Zod-based validation
+import { z } from "zod";
+
+const ReconnectQuerySchema = z.object({
+  sessionId: z.string().uuid("sessionId must be a valid UUID"),
+});

-  const sessionId = request.nextUrl.searchParams.get("sessionId");
-  if (!sessionId) {
+  const parseResult = ReconnectQuerySchema.safeParse({
+    sessionId: request.nextUrl.searchParams.get("sessionId"),
+  });
+  if (!parseResult.success) {
     return NextResponse.json(
-      { status: "error", error: "Missing sessionId" },
+      { status: "error", error: parseResult.error.issues[0].message },
       { status: 400, headers: getCorsHeaders() },
     );
   }
+  const { sessionId } = parseResult.data;

As per coding guidelines: "All API endpoints should use a validate function for input parsing using Zod for schema validation."

🤖 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/getSandboxReconnectHandler.ts` around lines 48 - 54, The handler
currently only checks for missing sessionId but lacks Zod validation; update the
request parsing in getSandboxReconnectHandler (where sessionId is read from
request.nextUrl.searchParams.get) to validate the query param with a Zod schema
(e.g., z.string().uuid() or the project's shared validate utility) before using
it; if validation fails return the same NextResponse.json error pattern with
status 400 and getCorsHeaders(), otherwise proceed with the validated sessionId
value.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@lib/sandbox/getSandboxReconnectHandler.ts`:
- Around line 92-97: The updateSession call is clearing the user's
hibernate_after preference (setting hibernate_after: null) which violates the
described expired-path contract and is inconsistent with the no_sandbox path
that preserves hibernateAfter via buildLifecycle(row); remove the
hibernate_after: null assignment (or explicitly preserve the existing value by
reading row.hibernate_after or using buildLifecycle(row) to compute lifecycle)
so expiry only clears sandbox_state and sets lifecycle_state to "hibernated"
without wiping user preferences in updateSession.

---

Nitpick comments:
In `@lib/sandbox/getSandboxReconnectHandler.ts`:
- Around line 99-109: The expired branch manually constructs a lifecycle object,
duplicating the shape produced by buildLifecycle and risking future drift;
instead, create an updated row object that reflects the expired state (e.g., set
fields like state: "hibernated", lastActivityAt: null, hibernateAfter: null,
sandboxExpiresAt: null or adjust snapshot_url as needed) and call
buildLifecycle(updatedRow) to produce the lifecycle used in the ReconnectBody
(replace the inline lifecycle in the expired branch with the result of
buildLifecycle), ensuring you still set status: "expired" and hasSnapshot:
!!row.snapshot_url on the body.
- Around line 42-112: getSandboxReconnectHandler is over the 50-line limit;
extract the live-probe and expiry-recovery logic into a new helper (e.g.,
probeSandboxConnection) so the handler only performs auth/ownership checks and
delegates probing. The helper should accept the session row and sessionId, call
connectSandbox(row.sandbox_state as SandboxState), run sandbox.exec("pwd",
sandbox.workingDirectory, PROBE_TIMEOUT_MS), return the ReconnectBody on success
(using sandbox.expiresAt and buildLifecycle(row)), and on failure perform the
updateSession(...) rollback (setting sandbox_state/null, lifecycle_state
"hibernated", sandbox_expires_at null, hibernate_after null) and return the
expired ReconnectBody; then make getSandboxReconnectHandler call
probeSandboxConnection and return its NextResponse (or construct the JSON
response from the helper result), keeping references to connectSandbox,
PROBE_TIMEOUT_MS, updateSession, noSandboxResponse, selectSessions, and
buildLifecycle.
- Around line 48-54: The handler currently only checks for missing sessionId but
lacks Zod validation; update the request parsing in getSandboxReconnectHandler
(where sessionId is read from request.nextUrl.searchParams.get) to validate the
query param with a Zod schema (e.g., z.string().uuid() or the project's shared
validate utility) before using it; if validation fails return the same
NextResponse.json error pattern with status 400 and getCorsHeaders(), otherwise
proceed with the validated sessionId value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e93c3680-dc46-4803-82b1-b012ca97e4aa

📥 Commits

Reviewing files that changed from the base of the PR and between ef4499e and c66f12f.

⛔ Files ignored due to path filters (2)
  • app/api/sandbox/reconnect/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (2)
  • app/api/sandbox/reconnect/route.ts
  • lib/sandbox/getSandboxReconnectHandler.ts

Comment thread lib/sandbox/getSandboxReconnectHandler.ts Outdated
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.

3 issues found across 4 files

Confidence score: 3/5

  • There is a moderate merge risk: lib/sandbox/getSandboxReconnectHandler.ts has a high-confidence API contract concern (severity 6/10) where a GET reconnect path mutates persisted session state, which can cause inconsistent behavior for clients expecting retrieval-only semantics.
  • The remaining findings are maintainability-focused (line-limit/style) in lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts and lib/sandbox/getSandboxReconnectHandler.ts; these are less likely to break runtime behavior but can make future changes and reviews harder.
  • Pay close attention to lib/sandbox/getSandboxReconnectHandler.ts, lib/sandbox/__tests__/getSandboxReconnectHandler.test.ts - resolve GET-side mutation semantics first, then split/trim oversized modules for maintainability.
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__/getSandboxReconnectHandler.test.ts">

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

Test file exceeds the repository's under-100-line limit and combines too many concerns in one module.</violation>
</file>

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

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

New file exceeds the custom under-100-lines limit.</violation>

<violation number="2" location="lib/sandbox/getSandboxReconnectHandler.ts:92">
P2: Custom agent: **API Design Consistency and Maintainability**

GET endpoint performs a persisted session mutation on the reconnect failure path, so this route is not retrieval-only as required by the API method rule.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant UI as "Chat UI (Browser)"
    participant Route as "GET /api/sandbox/reconnect"
    participant Handler as "getSandboxReconnectHandler"
    participant Auth as "validateAuthContext"
    participant DB as "Supabase (Sessions)"
    participant Runtime as "Sandbox Runtime (Vercel)"

    Note over UI,Route: Session re-entry / tab refocus

    UI->>Route: GET /api/sandbox/reconnect?sessionId=xxx
    Route->>Handler: Delegate request
    Handler->>Auth: Validate auth context
    Auth-->>Handler: { accountId, orgId, authToken }

    alt 401 - Auth fails
        Handler-->>Route: { status: "error", error: "Unauthorized" }
        Route-->>UI: 401
    else 400 - Missing sessionId
        Handler-->>Route: { status: "error", error: "Missing sessionId" }
        Route-->>UI: 400
    else Auth succeeds
        Handler->>DB: selectSessions({ id: sessionId })
        DB-->>Handler: Session row

        alt 404 - Session not found
            Handler-->>Route: { status: "error", error: "Session not found" }
            Route-->>UI: 404
        else 403 - Not owned by account
            Handler-->>Route: { status: "error", error: "Forbidden" }
            Route-->>UI: 403
        else Session found & owned

            alt hasRuntimeSandboxState(row.sandbox_state) == false
                Note over Handler: Short-circuit - no runtime metadata
                Handler-->>Route: { status: "no_sandbox", hasSnapshot, lifecycle }
                Route-->>UI: 200 no_sandbox
            else Runtime metadata exists
                Handler->>Runtime: connectSandbox(runtime state)
                Runtime-->>Handler: Sandbox handle

                alt Probe succeeds
                    Handler->>Runtime: sandbox.exec("pwd", ...timeout=15s)
                    Runtime-->>Handler: { success: true, stdout: "/workspace" }
                    Handler-->>Route: { status: "connected", hasSnapshot, expiresAt, lifecycle }
                    Route-->>UI: 200 connected
                else Probe failure
                    Note over Handler: Runtime unreachable - clear stale state
                    Handler->>DB: updateSession(sessionId, { sandbox_state: null, lifecycle_state: "hibernated", ... })
                    Handler-->>Route: { status: "expired", hasSnapshot, lifecycle }
                    Route-->>UI: 200 expired
                end
            end
        end
    end
Loading

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

@@ -0,0 +1,177 @@
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.

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

Test file exceeds the repository's under-100-line limit and combines too many concerns in one module.

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

<comment>Test file exceeds the repository's under-100-line limit and combines too many concerns in one module.</comment>

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

const message = error instanceof Error ? error.message : String(error);
console.warn(`[getSandboxReconnectHandler] probe failed for ${sessionId}: ${message}`);

await updateSession(sessionId, {
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: Custom agent: API Design Consistency and Maintainability

GET endpoint performs a persisted session mutation on the reconnect failure path, so this route is not retrieval-only as required by the API method rule.

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

<comment>GET endpoint performs a persisted session mutation on the reconnect failure path, so this route is not retrieval-only as required by the API method rule.</comment>

<file context>
@@ -0,0 +1,112 @@
+    const message = error instanceof Error ? error.message : String(error);
+    console.warn(`[getSandboxReconnectHandler] probe failed for ${sessionId}: ${message}`);
+
+    await updateSession(sessionId, {
+      sandbox_state: null,
+      lifecycle_state: "hibernated",
</file context>

Comment thread lib/sandbox/getSandboxReconnectHandler.ts
lifecycle: ReturnType<typeof buildLifecycle>;
}

function noSandboxResponse(row: Tables<"sessions">): NextResponse {
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.

SRP - new lib for noSandboxResponse.ts

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>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Pushed 23043f13 addressing your SRP comment on getSandboxReconnectHandler.ts:21.

Extracted noSandboxResponse to lib/sandbox/noSandboxResponse.ts with a 3-test unit file covering 200 status, hasSnapshot derivation from snapshot_url, and lifecycle envelope projection. Handler imports it now; the local ReconnectBody type narrowed to just the two outcomes the handler still builds inline (connected / expired).

TDD red → green confirmed; suite 2526 → 2529, lint clean.

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 3 files (changes from recent commits).

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

Comment on lines +34 to +66
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}

const sessionId = request.nextUrl.searchParams.get("sessionId");
if (!sessionId) {
return NextResponse.json(
{ status: "error", error: "Missing sessionId" },
{ status: 400, headers: getCorsHeaders() },
);
}

const rows = await selectSessions({ id: sessionId });
const row = rows[0];

if (!row) {
return NextResponse.json(
{ status: "error", error: "Session not found" },
{ status: 404, headers: getCorsHeaders() },
);
}

if (row.account_id !== auth.accountId) {
return NextResponse.json(
{ status: "error", error: "Forbidden" },
{ status: 403, headers: getCorsHeaders() },
);
}

if (!hasRuntimeSandboxState(row.sandbox_state)) {
return noSandboxResponse(row);
}
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.

SRP

  • actual: validate logic defined in the handler
  • required: new lib for the validate logic.

…iew 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>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Pushed 8a1e660d addressing your latest SRP comment + a build-blocking type fix.

SRP (r3203391529): Extracted the pre-flight validate logic (auth + sessionId-from-query + session lookup + ownership) from getSandboxReconnectHandler into lib/sandbox/validateSandboxReconnectRequest.ts. Mirrors validateCreateSandboxBody — returns either a 4xx NextResponse describing the first failure, or { row, auth } for the handler.

Type-fix bonus: the previous two preview deployments errored at the next build typecheck step:

Type 'Json[]' is not comparable to type 'SandboxState'.
  Property 'type' is missing in type 'Json[]' but required in type '{ type: "vercel"; }'.

vitest doesn't run tsc, so it didn't catch this. Fixed by casting through unknown (row.sandbox_state as unknown as SandboxState) — the hasRuntimeSandboxState gate above ensures the runtime shape is safe at the call site. Comment added at the call site explaining why.

TDD red → green throughout. 5 unit tests for the new validator. Existing handler tests pass unchanged (their vi.mock calls still intercept). Suite 2529 → 2534, lint clean. Preview should rebuild green now.

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 3 files (changes from recent commits).

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

@sweetmantech
Copy link
Copy Markdown
Contributor Author

Smoke test results — preview deployment

Ran end-to-end against `https://api-git-feat-sandbox-reconnect-recoup.vercel.app\` after the build fix landed in `8a1e660d`. All paths green.

✅ Negative paths

Test Expected Got
no auth 401 `401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}`
missing sessionId 400 `400 {"status":"error","error":"Missing sessionId"}`
bogus sessionId 404 `404 {"status":"error","error":"Session not found"}`
OPTIONS preflight 204 `204`

✅ Operational paths

no_sandbox — fresh session, no sandbox provisioned yet:

HTTP 200
{
  "status": "no_sandbox",
  "hasSnapshot": false,
  "lifecycle": { "serverTime": ..., "state": "provisioning", ... }
}

The `hasRuntimeSandboxState` gate correctly skips the live probe for the type-stub written by `POST /api/sessions`.

connected — after provisioning, runtime probe succeeds:

HTTP 200
{
  "status": "connected",
  "hasSnapshot": false,
  "expiresAt": 1778177756888,
  "lifecycle": {
    "serverTime": 1778175957909,
    "state": "active",
    "lastActivityAt": 1778175956634,
    "sandboxExpiresAt": 1778177756634
  }
}

Worth noting: top-level expiresAt (live-probed) is 254ms ahead of lifecycle.sandboxExpiresAt (DB-persisted at provision time) — exactly the divergence the docs spec described as possible when the sandbox extended itself between writes. Working as intended.

⚠️ Not tested live: expired path

Would require either waiting 30min for the sandbox to time out or manually destroying it via the Vercel API. Covered by unit tests (handler test asserts state-clear on probe failure). Comfortable shipping without a live verification of this path — it's the same DB-write code path as POST /api/sandbox' state writes, and the unit test mocks connectSandbox to throw.

Ready to merge whenever you give the word.

@sweetmantech sweetmantech merged commit 432b8ab into test May 7, 2026
6 checks passed
@sweetmantech sweetmantech deleted the feat/sandbox-reconnect branch May 7, 2026 17:52
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