Skip to content

feat(sessions): port POST /api/sessions from open-agents#515

Merged
sweetmantech merged 13 commits into
testfrom
feat/post-create-session
May 4, 2026
Merged

feat(sessions): port POST /api/sessions from open-agents#515
sweetmantech merged 13 commits into
testfrom
feat/post-create-session

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 4, 2026

Summary

Implements POST /api/sessions per the docs spec merged in recoupable/docs#186 + #187.

Creates a session row and an initial chat row. Rolls back the session if chat insert fails so callers never observe an orphaned session.

Stacked on PR #514

This PR depends on #514 (feat/port-get-session-route) for types/database.types.ts (regenerated to include sessions + chats) and lib/sessions/toSessionResponse.ts. Base is feat/port-get-session-route; once #514 lands in test, I'll flip the base.

Architecture

Following api conventions from CLAUDE.md:

  • Route shell at app/api/sessions/route.ts delegates to createSessionHandler
  • Validation via Zod (lib/sessions/validateCreateSessionBody.ts) returns the standard {status, missing_fields, error} 400 shape
  • Supabase ops live in lib/supabase/sessions/ and lib/supabase/chats/ (one exported function per file)
  • Auth via validateAuthContext (Privy Bearer or x-api-key)

TDD

Wrote tests first; verified red; implemented to green:

  • 9 handler tests in lib/sessions/__tests__/createSessionHandler.test.ts
    • 401 unauth
    • 400 (sandboxType, repoOwner, repoName invalid)
    • 200 happy path: response shape + insert payload assertions
    • 200 with isNewBranch=true generates branch
    • 200 with provided title
    • 500 when insertSession fails (no chat insert attempted)
    • 500 with rollback when insertChat fails (deleteSessionById invoked)
  • 1 thin route shell test

Out of scope

auto_commit_push_override, auto_create_pr_override, pr_number, pr_status — these columns don't exist on api's sessions table yet. Docs spec was trimmed in #187 to match. A follow-up database migration can add them later.

Test plan

  • CI green
  • Preview deployment: POST /api/sessions with x-api-key returns 200 + {session, chat}
  • Same call without auth returns 401
  • POST /api/sessions with {"sandboxType": "wrong"} returns 400
  • After this lands, run end-to-end test: POST a session, then GET it via PR #514's endpoint to verify the full lifecycle

🤖 Generated with Claude Code


Summary by cubic

Adds POST /api/sessions to create a session and its first chat with auth, minimal validation, random-city title fallback, and rollback on failure (logs the orphaned id if rollback also fails). Also adds an OPTIONS preflight and disables caching; removes branch auto-generation and repo owner/name inputs.

  • New Features

    • Implements POST /api/sessions returning { session, chat } (camelCase via toSessionResponse and toChatResponse).
    • Auth via validateAuthContext (Privy Bearer or x-api-key); 401 on failure. Body allows optional title, branch, cloneUrl, sandboxType=vercel; title resolves to trimmed input or a random city avoiding account collisions.
    • Adds OPTIONS handler with CORS and sets dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0.
  • Refactors

    • Centralizes request validation in validateCreateSessionBody (auth + tolerant JSON parse + Zod); returns either a 4xx response or { body, auth }.
    • Replaces readers with selectSessions({ id?, accountId? }); resolveSessionTitle uses it with defensive try/catch.
    • Extracts buildSessionInsertRow; standardizes 500 via failedToCreateSession; logs orphaned session id if chat insert and rollback both fail.
    • Removes branch auto-generation and repo owner/name inputs; scopes this PR to POST only.

Written for commit cb091b0. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added session and chat creation API endpoint with full workflow support
    • Implemented automatic session naming using city-based fallback titles when custom titles are not provided
    • Added sandbox type configuration support for session initialization
    • Included comprehensive error handling with graceful rollback for failed operations
    • Enabled CORS support for cross-origin requests

sweetmantech and others added 4 commits May 4, 2026 12:32
…Phase 2.4 — first route)

First route in the route-by-route cutover plan. Strategy: open-agents
frontend stays unchanged in shape; api ports each route it calls in
priority order (simplest first), and the open-agents frontend gets
cut over to api one route at a time.

Why this route first:
- Pure DB read (single-row select by id) — no agent runner, no Vercel
  Workflow, no sandbox runtime
- Hits sessions table already migrated in database PR #20
- Frontend usage: agents-frontend hits /api/sessions/{id} on session
  detail page navigation
- Smallest possible blast radius for proving the cutover pattern

Files added:
  lib/supabase/sessions/selectSession.ts  Single-row helper + SessionRow
                                          type (hand-typed; database.types.ts
                                          regen pending — flagged in code
                                          comment)
  app/api/sessions/[sessionId]/route.ts   GET handler matching open-agents
                                          response shape exactly (camelCase
                                          fields, "userId" preserved on the
                                          wire even though stored as
                                          account_id internally)
  app/api/sessions/[sessionId]/__tests__/route.test.ts (5 tests)

Auth: validateAuthContext (Privy Bearer or x-api-key). Response codes
match open-agents: 200 happy path, 401 no auth, 403 not owner, 404 not
found.

Wire-format translation: snake_case Supabase row -> camelCase response,
with account_id surfaced as userId so the existing open-agents frontend
fetches with zero code changes. Translation lives at the route boundary
(toSessionResponse) where it is easy to remove once chat absorbs this
UI and we can switch to schema-natural naming.

Verification:
- pnpm lint:check: clean
- pnpm test: 2379/2379 pass (5 new for this route)

Up next:
- Cutover step (separate PR in open-agents): point the frontend at
  api's URL for this single route. Validate end-to-end before porting
  the next route.
- Next routes in priority order (still pure DB, no agent/workflow):
  GET /api/sessions (list with unread — needs Postgres RPC for the
  multi-table aggregation), GET /api/sessions/[id]/chats, GET
  /api/sessions/[id]/chats/[chatId].

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

Three review comments on PR #514:

1. SRP: extract toSessionResponse to its own file
   was: defined inline in app/api/sessions/[sessionId]/route.ts
   now: lib/sessions/toSessionResponse.ts (one exported fn per file)

2. SRP: add a handler function (mirroring api convention)
   was: GET handler logic inline in route.ts
   now: lib/sessions/getSessionByIdHandler.ts contains all the auth +
        ownership + DB lookup + response logic; route.ts is a thin
        shell that awaits options.params and delegates. Matches the
        pattern used by every other api route (e.g. socials/[id]/scrape,
        artists/[id]/...).

3. DRY: use existing db schema type
   was: hand-typed SessionRow interface in selectSession.ts
   now: Tables<\"sessions\"> from types/database.types.ts (regenerated
        via npx supabase gen types typescript --project-id ...
        --schema public)

The types regen also resolved the preview-build failure
(\"Type instantiation is excessively deep and possibly infinite\") on
the .from(\"sessions\") call — Supabase's type inference was choking
because the table was unknown to the generic.

Files added:
  lib/sessions/toSessionResponse.ts
  lib/sessions/getSessionByIdHandler.ts

Files modified:
  app/api/sessions/[sessionId]/route.ts        thin shell now
  app/api/sessions/[sessionId]/__tests__/
    route.test.ts                              type alias updated
  lib/supabase/sessions/selectSession.ts       Tables<\"sessions\">
  types/database.types.ts                      Supabase regen

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2379/2379 pass (no test changes; same 5 route tests)
  - tsc compile clean (the local pnpm build progresses past compile
    into page-data collection where it fails on missing local env
    vars — Vercel preview will have those set, so the preview rebuild
    should now succeed)

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

The 401 returned by validateAuthContext shaped like
{status:"error", error:"..."} but 404/403 from this handler returned
{error:"..."} only. Same endpoint, two error shapes — inconsistent for
clients. Align all error responses on the validateAuthContext shape.

Tests now assert the full error body, not just the status code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the POST /api/sessions contract documented in
recoupable/docs PR #186 + #187. Creates a session row and an
initial chat row; rolls back the session if chat insert fails so
callers never observe an orphaned session.

Auth: validateAuthContext (Privy Bearer or x-api-key).
Validation: Zod schema + GitHub repo segment regex. Body is
optional — empty body creates a session with sensible defaults
(status=running, lifecycle_state=provisioning, sandbox_state.type=
vercel, title="New session").

Out of scope (will follow once database catches up):
  auto_commit_push_override, auto_create_pr_override, pr_number,
  pr_status — these columns don't yet exist on api's sessions
  table, so the docs spec was trimmed accordingly in docs PR #187.

TDD: 9 handler tests cover 401, 400 (sandboxType / repoOwner /
repoName), 200 happy path, branch generation, title pass-through,
500 (insertSession failure), and 500-with-rollback (insertChat
failure). Plus 1 thin test on the route shell.

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

vercel Bot commented May 4, 2026

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

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

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Warning

Rate limit exceeded

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

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ 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: ac7e4e73-55cc-441a-a62a-4986b7640db2

📥 Commits

Reviewing files that changed from the base of the PR and between 4adb35d and cb091b0.

⛔ Files ignored due to path filters (5)
  • lib/sessions/__tests__/createSessionHandler.persistence.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/createSessionHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/getRandomCityName.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/resolveSessionTitle.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/validateCreateSessionBody.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (4)
  • lib/sessions/createSessionHandler.ts
  • lib/sessions/resolveSessionTitle.ts
  • lib/sessions/validateCreateSessionBody.ts
  • lib/supabase/sessions/selectSessions.ts
📝 Walkthrough

Walkthrough

A new POST /api/sessions API route is introduced to create a session with an initial chat. The endpoint validates the request, resolves a title (user-provided or auto-generated from a curated city list), inserts the session and chat rows into Supabase, and returns both records with CORS headers.

Changes

Session & Chat Creation Flow

Layer / File(s) Summary
Request Validation
lib/sessions/validateCreateSessionBody.ts
Zod schema validates optional title, branch, cloneUrl, and sandboxType fields; returns 400 error response with field path and message on failure.
Title Fallback System
lib/sessions/cityNames.ts, lib/sessions/getRandomCityName.ts, lib/sessions/resolveSessionTitle.ts
Curated city names list; random selection excludes previously used titles for the account; returns user-provided title if present, otherwise generates collision-free name.
Row Builders
lib/sessions/buildSessionInsertRow.ts
Constructs a fully-formed sessions table row with UUID, lifecycle initialization, normalized fields from request body, and sandbox state defaults.
Database Operations
lib/supabase/sessions/insertSession.ts, lib/supabase/sessions/deleteSessionById.ts, lib/supabase/sessions/selectSessionTitlesByAccountId.ts, lib/supabase/chats/insertChat.ts
Encapsulate Supabase CRUD for sessions (insert, delete, select) and chat insertion; log errors and return null/false on failure.
Response Transformation
lib/sessions/toSessionResponse.ts, lib/sessions/toChatResponse.ts
Map snake\_case database columns to camelCase HTTP response payloads for both session and chat records.
Error Handling
lib/sessions/failedToCreateSession.ts
Consistent 500 error response with CORS headers; used when session or chat insertion fails.
Handler & Route
lib/sessions/createSessionHandler.ts, app/api/sessions/route.ts
createSessionHandler orchestrates the end-to-end flow: validate, resolve title, insert session and chat, rollback session on chat failure; OPTIONS and POST handlers in route with dynamic/no-store cache config.

Sequence Diagram

sequenceDiagram
    actor Client
    participant Route as API Route<br/>/api/sessions
    participant Handler as createSessionHandler
    participant Auth as Auth Validator
    participant Validator as Request Validator
    participant TitleResolver as Title Resolver
    participant SessionDB as Session DB Ops
    participant ChatDB as Chat DB Ops
    participant Transform as Response Transform

    Client->>Route: POST /api/sessions + body
    Route->>Handler: delegate to handler
    Handler->>Auth: validateAuthContext()
    Auth-->>Handler: accountId or error response
    Handler->>Validator: validateCreateSessionBody()
    Validator-->>Handler: CreateSessionBody or error response
    Handler->>TitleResolver: resolveSessionTitle(title, accountId)
    TitleResolver->>SessionDB: selectSessionTitlesByAccountId()
    SessionDB-->>TitleResolver: existing titles[]
    TitleResolver-->>Handler: resolved title
    Handler->>SessionDB: insertSession(buildSessionInsertRow(...))
    SessionDB-->>Handler: sessionRow or null
    Handler->>ChatDB: insertChat(newChatRow with sessionId)
    ChatDB-->>Handler: chatRow or null
    alt chat insert failed
        Handler->>SessionDB: deleteSessionById(sessionRow.id)
        SessionDB-->>Handler: true
        Handler-->>Route: 500 error response
    else chat insert succeeded
        Handler->>Transform: toSessionResponse(sessionRow)
        Transform-->>Handler: session payload
        Handler->>Transform: toChatResponse(chatRow)
        Transform-->>Handler: chat payload
        Handler-->>Route: 200 JSON {session, chat}
    end
    Route-->>Client: response + CORS headers
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🌍 From cities far, a session springs,
With chats in tow and titles bright,
One POST request, two rows of things,
And fallback names when users don't write. ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning Good SRP design but 4 unaddressed review issues: raw sessionId in logs (security), missing try-catch for DB failure, unchecked rollback, and deprecated Zod syntax. Wrap DB call in try-catch, redact IDs in logs, check rollback result, use { error } instead of { message } in Zod schema.
✅ 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/post-create-session

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.

Match the convention from app/api/sessions/[sessionId]/route.ts:
- OPTIONS handler returning 200 + CORS headers (preflight)
- dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0

POST routes that mutate DB shouldn't be cached, and browsers issuing
preflight checks (POST with JSON body + custom auth headers) need
OPTIONS to respond.

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.

3 issues found across 12 files

Confidence score: 3/5

  • There is some merge risk because two validation helpers (lib/github/isValidGitHubRepoName.ts and lib/github/isValidGitHubRepoOwner.ts) are too permissive, which can let invalid GitHub owner/repo inputs pass and cause user-facing failures in downstream GitHub path handling.
  • The most impactful issue is behavior correctness (not just style): accepting .git suffixes, dot-only names, and unsupported owner characters (. / _) can produce invalid repository targets at runtime.
  • The test-file length issue in lib/sessions/__tests__/createSessionHandler.test.ts is mainly maintainability-related and is less likely to break functionality by itself.
  • Pay close attention to lib/github/isValidGitHubRepoName.ts and lib/github/isValidGitHubRepoOwner.ts - tighten validation rules to match actual GitHub login/repository naming constraints.
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/github/isValidGitHubRepoName.ts">

<violation number="1" location="lib/github/isValidGitHubRepoName.ts:9">
P2: `repoName` validation is too permissive: it accepts `.git`-suffixed and reserved dot-only names that are not valid GitHub repository path names.</violation>
</file>

<file name="lib/github/isValidGitHubRepoOwner.ts">

<violation number="1" location="lib/github/isValidGitHubRepoOwner.ts:1">
P2: Owner validation is too permissive and accepts characters GitHub owner logins do not allow (for example `.` and `_`).</violation>
</file>

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

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

Test file exceeds the repository’s 100-line limit and should be split into smaller files.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Client (Frontend/API Consumer)
    participant Route as POST /api/sessions (route.ts)
    participant Handler as createSessionHandler
    participant Auth as validateAuthContext
    participant Validator as validateCreateSessionBody (Zod)
    participant Branch as generateSessionBranchName
    participant SessionDB as sessions (Supabase)
    participant ChatDB as chats (Supabase)
    participant Response as toSessionResponse / toChatResponse

    Note over Client,Response: NEW: POST /api/sessions flow

    Client->>Route: POST /api/sessions (body: JSON)
    Route->>Handler: Delegates to handler

    Handler->>Auth: validateAuthContext(request)
    alt Auth fails (401)
        Auth-->>Handler: NextResponse 401
        Handler-->>Client: 401 Unauthorized
    else Auth succeeds
        Auth-->>Handler: { accountId, orgId, authToken }
    end

    Handler->>Handler: safeParseJson(request)
    Handler->>Validator: validateCreateSessionBody(body)
    alt Validation fails (400)
        Validator-->>Handler: NextResponse 400 (missing_fields, error)
        Handler-->>Client: 400 Bad Request
    else Validation succeeds
        Validator-->>Handler: validated body
    end

    opt isNewBranch === true
        Handler->>Branch: generateSessionBranchName()
        Branch-->>Handler: "ag/<8-hex-chars>"
    end

    Handler->>Handler: generateUUID() for sessionId

    Handler->>SessionDB: insertSession({ id, account_id, ... })
    alt Insert fails (500)
        SessionDB-->>Handler: null
        Handler-->>Client: 500 Failed to create session
    else Insert succeeds
        SessionDB-->>Handler: sessionRow
    end

    Handler->>ChatDB: insertChat({ id, session_id, title: "New chat" })
    alt Insert succeeds
        ChatDB-->>Handler: chatRow
        Handler->>Response: toSessionResponse(sessionRow)
        Handler->>Response: toChatResponse(chatRow)
        Response-->>Handler: CamelCase response payloads
        Handler-->>Client: 200 { session, chat }
    else Insert fails (500 with rollback)
        ChatDB-->>Handler: null
        Handler->>SessionDB: deleteSessionById(sessionRow.id)
        SessionDB-->>Handler: true/false
        Handler-->>Client: 500 Failed to create session
    end
Loading

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

Comment thread lib/github/isValidGitHubRepoName.ts Outdated
Comment thread lib/github/isValidGitHubRepoOwner.ts Outdated
Comment thread lib/sessions/__tests__/createSessionHandler.test.ts
Comment thread lib/sessions/createSessionHandler.ts Outdated
Comment on lines +26 to +38
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}

const body = await safeParseJson(request);
const validated = validateCreateSessionBody(body);
if (validated instanceof NextResponse) {
return validated;
}

const branch = validated.isNewBranch ? generateSessionBranchName() : (validated.branch ?? null);
const sessionId = generateUUID();
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 - make a validator function for this logic similar to other endpoints.

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 - why are branch names needed? I thought all commits were on 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.

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

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

- SRP: extract insert-row construction to lib/sessions/buildSessionInsertRow.ts
- YAGNI: drop generateSessionBranchName + isNewBranch handling (sessions
  commit to whatever branch the client provides; auto-generation was
  speculative)
- Tighten isValidGitHubRepoOwner: GitHub's actual rules are alphanumeric
  + hyphen only (no `_` or `.`), 1-39 chars, no leading/trailing or
  consecutive hyphens
- Tighten isValidGitHubRepoName: reject reserved `.` and `..`, reject
  `.git` suffix, cap at 100 chars
- Add unit tests for both validators (15 cases) and for the new
  buildSessionInsertRow (4 cases)
- Split createSessionHandler tests into auth/validation + persistence
  files; share fixtures via createSessionHandlerFixtures.ts. All test
  files now under 100 lines.

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

Addressed the review feedback:

  • SRP (commit 93da2ac3) — extracted insert-row construction into lib/sessions/buildSessionInsertRow.ts so the handler only owns HTTP + persistence flow. New unit tests cover defaults, title trimming, whitespace fallback, and pass-through.
  • YAGNI — deleted generateSessionBranchName and dropped isNewBranch from the validate schema. Sessions now commit to whatever branch the client supplies; the column defaults to false so we just don't write to it.
  • Owner regex — tightened to GitHub's actual login rules: [a-zA-Z0-9-]+, 1–39 chars, no leading/trailing/consecutive hyphens, no _ or .. New unit test covers all rejection paths.
  • Repo name regex — tightened: reject . and .., reject .git suffix (case-insensitive), cap at 100 chars.
  • Test file size — split createSessionHandler.test.ts into two focused files (auth/validation + persistence) sharing fixtures via createSessionHandlerFixtures.ts. All test files now under 100 lines.

127 tests pass, 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.

2 issues found across 12 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/sessions/createSessionHandler.ts">

<violation number="1" location="lib/sessions/createSessionHandler.ts:18">
P2: Use a generic 500 message (`"Internal server error"`) instead of `"Failed to create session"` for internal failures.

(Based on your team's feedback about standardizing 500 responses to a hardcoded internal-error message.) [FEEDBACK_USED]</violation>
</file>

<file name="lib/sessions/__tests__/createSessionHandlerFixtures.ts">

<violation number="1" location="lib/sessions/__tests__/createSessionHandlerFixtures.ts:12">
P2: Custom agent: **Module should export a single primary function whose name matches the filename**

Module exports multiple top-level functions instead of a single primary export matching the filename.</violation>
</file>

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

Comment thread lib/sessions/createSessionHandler.ts Outdated
Comment thread lib/sessions/__tests__/createSessionHandlerFixtures.ts Outdated
@sweetmantech
Copy link
Copy Markdown
Contributor Author

Preview deployment test results

Tested against https://api-git-feat-post-create-session-recoup.vercel.app (commit 93da2ac3).

API key obtained via the documented signup flow:

curl -s -X POST "$PREVIEW/api/agents/signup" \
  -H 'Content-Type: application/json' \
  -d '{"email":"agent+post-create-session-test-'$(date +%s)'@recoupable.com"}'
# → { "account_id": "2db5cda8-8b1c-4c6d-87e9-ed8f10a9757a", "api_key": "recoup_sk_…" }

T1 — 401 no auth

POST /api/sessions
Content-Type: application/json

{}
HTTP/2 401
{"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}

✅ matches validateAuthContext envelope.

T2 — 400 invalid sandboxType

POST /api/sessions
x-api-key: recoup_sk_…

{"sandboxType":"wrong"}
HTTP/2 400
{"status":"error","missing_fields":["sandboxType"],"error":"Invalid sandbox type"}

✅ Zod rejects, error envelope matches api convention.

T3 — 400 underscore in repoOwner (tightened regex)

{"repoOwner":"snake_case"}
HTTP/2 400
{"status":"error","missing_fields":["repoOwner"],"error":"Invalid repository owner"}

✅ The pre-fix regex /^[.\w-]+$/ accepted this; tightened regex correctly rejects.

T4 — 400 dot in repoOwner (tightened regex)

{"repoOwner":"dot.case"}
HTTP/2 400
{"status":"error","missing_fields":["repoOwner"],"error":"Invalid repository owner"}

✅ same — tightened owner regex rejects ..

T5 — 400 .git suffix on repoName (tightened regex)

{"repoName":"repo.git"}
HTTP/2 400
{"status":"error","missing_fields":["repoName"],"error":"Invalid repository name"}

✅ pre-fix accepted; tightened version rejects .git suffix per GitHub's actual rules.

T6 — 400 reserved .. repoName (tightened regex)

{"repoName":".."}
HTTP/2 400
{"status":"error","missing_fields":["repoName"],"error":"Invalid repository name"}

✅ pre-fix accepted; tightened version rejects.

T7 — 200 happy path, empty body

POST /api/sessions
x-api-key: recoup_sk_…
Content-Type: application/json

{}
HTTP/2 200
{
  "session": {
    "id": "1af9a98e-c730-4484-875b-a7fa9bc18bf5",
    "userId": "2db5cda8-8b1c-4c6d-87e9-ed8f10a9757a",
    "title": "New session",
    "status": "running",
    "repoOwner": null, "repoName": null, "branch": null, "cloneUrl": null,
    "isNewBranch": false,
    "globalSkillRefs": [],
    "sandboxState": { "type": "vercel" },
    "lifecycleState": "provisioning",
    "lifecycleVersion": 0,
    "lastActivityAt": null, "sandboxExpiresAt": null, "hibernateAfter": null,
    "lifecycleRunId": null, "lifecycleError": null,
    "linesAdded": 0, "linesRemoved": 0,
    "snapshotUrl": null, "snapshotCreatedAt": null, "snapshotSizeBytes": null,
    "cachedDiff": null, "cachedDiffUpdatedAt": null,
    "createdAt": "2026-05-04T22:30:07.068391+00:00",
    "updatedAt": "2026-05-04T22:30:07.068391+00:00"
  },
  "chat": {
    "id": "77ee1e2f-f549-43c0-95b2-fcb3bed66f26",
    "sessionId": "1af9a98e-c730-4484-875b-a7fa9bc18bf5",
    "title": "New chat",
    "modelId": "anthropic/claude-haiku-4.5",
    "activeStreamId": null,
    "lastAssistantMessageAt": null,
    "createdAt": "2026-05-04T22:30:07.132005+00:00",
    "updatedAt": "2026-05-04T22:30:07.132005+00:00"
  }
}

Verified end-to-end:

  • session.userId resolved from validateAuthContext (matches account_id of the API key's account)
  • Session defaults applied as documented: status=running, lifecycleState=provisioning, sandbox_state.type=vercel, title="New session"
  • chat.session_id = session.id (insert ordering correct, no orphan possible)
  • chat.title = "New chat", chat.modelId = "anthropic/claude-haiku-4.5" (DB default — handler does not set it)
  • All timestamps populated by Supabase (no last_activity_at / sandbox_expires_at / etc., as expected)

Test artifact

One session row remains in the preview's DB (id=1af9a98e-…, account_id=2db5cda8-…). No DELETE /api/sessions/[id] endpoint exists yet to clean up via API; can be removed via Supabase admin if needed.

Unit test suite (local)

127/127 passing, lint clean.

Comment thread lib/github/isValidGitHubRepoOwner.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.

YAGNI - Owner should always be recoupable. Why is this lib needed?

Comment thread lib/sessions/createSessionHandler.ts Outdated

const INITIAL_CHAT_TITLE = "New chat";

function failedToCreateSession(): 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 failedToCreateSession.

- 500 message: "Failed to create session" → "Internal server error"
  (per cubic.dev standardized 500 envelope feedback)
- SRP: extract failedToCreateSession to lib/sessions/failedToCreateSession.ts
- YAGNI: drop repoOwner from request body and remove
  isValidGitHubRepoOwner helper entirely (recoupable is the only
  owner; no need to validate)
- YAGNI: drop repoName from request body and remove
  isValidGitHubRepoName helper (repo identity is derived server-side
  from the authenticated account, not accepted from user input)
- Single-export per file: split createSessionHandlerFixtures.ts into
  makeCreateSessionReq.ts, baseSessionRow.ts, baseChatRow.ts.
  okAuth constant inlined where used.

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

Real open-agents request vs api preview — head-to-head

Per request, captured the exact request the open-agents UI sends when a user creates a new session, then replayed it against this PR's preview deployment.

What the open-agents UI actually does

POST /api/sessions/personal HTTP/2
Authorization: Bearer <privy>
Content-Length: 0

Note: empty body, and the path is /api/sessions/personal, not /api/sessions — the UI flow uses a separate per-account-bootstrap endpoint that we have not built yet.

Side-by-side response shapes (same empty body)

Field open-agents POST /api/sessions/personal api preview POST /api/sessions
session.id "YpiwAqlVkeQoFdTZ_uqpJ" (nanoid) "b55da159-2d2a-4def-…" (UUID v4)
session.userId "1WynZUAgzeNuOrxq7J7ot" (nanoid) "2db5cda8-…" (UUID, recoup account_id)
session.title "Vienna" (random city fallback) "New session" (hardcoded default)
session.repoOwner "recoupable" (auto-derived from account) null
session.repoName "-ed60e112-727a-4616-9a30-ed39dab1d0d7" (auto-derived from account id) null
session.cloneUrl "https://github.com/recoupable/-ed60e112-…" (derived) null
session.autoCommitPushOverride false absent (trimmed per docs PR #187)
session.autoCreatePrOverride false absent (trimmed per docs PR #187)
session.prNumber / prStatus null / null absent (trimmed per docs PR #187)
chat.modelId "openai/gpt-5.4" "anthropic/claude-haiku-4.5" (DB default)
Top-level { session, chat, bootstrapPrompt } { session, chat }

What this means

This PR's POST /api/sessions matches the docs spec we merged, but it does not yet serve the open-agents frontend cutover — the UI calls a different endpoint with substantially different semantics. To actually replace /api/sessions/personal, we'd need to either:

  1. Add POST /api/sessions/personal separately, with:
    • Auto-derive repoOwner = "recoupable" + repoName from accountId (needs an account → repo mapping)
    • cloneUrl derived from owner+name
    • Random-city title fallback when none provided
    • bootstrapPrompt in the response
  2. Fold all of that into POST /api/sessions and rename / repurpose this endpoint — at which point the trimmed docs spec becomes wrong again.

Either way: this PR's current scope (generic POST /api/sessions with empty-body → minimal default session) is a real but distinct capability from what the frontend actually needs.

Two test artifacts left in the preview DB

Sessions 1af9a98e-… and b55da159-… (account 2db5cda8-…).

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

Requires human review: New API endpoint with database writes, rollback logic, and auth — significant feature addition requiring human review to verify correctness and production safety.

@sweetmantech
Copy link
Copy Markdown
Contributor Author

Real POST /api/sessions captured from open-agents (clone-from-URL flow)

Triggered the actual frontend code path via Chrome DevTools MCP — same body shape createBlankSession builds.

Request sent

{
  "cloneUrl": "https://github.com/recoupable/api.git",
  "isNewBranch": false,
  "sandboxType": "vercel",
  "autoCommitPush": false,
  "autoCreatePr": false
}

Open-agents response (200)

{
  "session": {
    "id": "d3U5esFUIKmO_ZqY948mU",
    "userId": "1WynZUAgzeNuOrxq7J7ot",
    "title": "Anchorage",
    "status": "running",
    "repoOwner": null,
    "repoName": null,
    "branch": null,
    "cloneUrl": "https://github.com/recoupable/api.git",
    "isNewBranch": false,
    "autoCommitPushOverride": false,
    "autoCreatePrOverride": false,
    "globalSkillRefs": [],
    "sandboxState": { "type": "vercel" },
    "lifecycleState": "provisioning",
    "lifecycleVersion": 0,
    "linesAdded": 0, "linesRemoved": 0,
    "prNumber": null, "prStatus": null
  },
  "chat": {
    "id": "eFD1TkdH0fE76J8t5tEPC",
    "sessionId": "d3U5esFUIKmO_ZqY948mU",
    "title": "New chat",
    "modelId": "openai/gpt-5.4"
  }
}

(No bootstrapPrompt — that field is only on /personal. Confirmed.)

Differences vs this PR's preview (same body, both endpoints POST /api/sessions)

Field open-agents prod api preview
session.title (no body title) "Anchorage" (random city fallback) "New session" (hardcoded)
session.cloneUrl passed through ✓ passed through ✓
session.repoOwner / repoName null null
session.autoCommitPushOverride false (from input) absent (removed in docs PR #187)
session.autoCreatePrOverride false (from input) absent (removed in docs PR #187)
session.prNumber / prStatus null / null absent (removed in docs PR #187)
chat.modelId "openai/gpt-5.4" "anthropic/claude-haiku-4.5" (DB default)
ID format nanoid UUID v4

What this means for the cutover

Three input fields the real frontend sends today hit our preview and get silently stripped by Zod:

  • isNewBranch — accepted-and-ignored, DB column already has default false
  • autoCommitPush — accepted-and-ignored, DB column doesn't exist
  • autoCreatePr — accepted-and-ignored, DB column doesn't exist

To actually cut the frontend over to api against this endpoint we'd need:

  1. Database migration adding auto_commit_push_override, auto_create_pr_override (and pr_number, pr_status for completeness)
  2. Restore those fields in docs (reverse the over-aggressive trim from feat: add CodeRabbit config with reviews for test branch #187)
  3. Restore them here in the request schema + persist to the new columns
  4. Title fallback: port the random-city helper or pick something deterministic

@sweetmantech
Copy link
Copy Markdown
Contributor Author

UI-driven capture: head-to-head with this PR's preview

Re-ran the comparison properly. Clicked an actual org button in the open-agents UI to fire POST /api/sessions, captured the request via Chrome DevTools MCP, then replayed the byte-identical body against this PR's preview.

Identical request body sent to both endpoints

{
  "cloneUrl": "https://github.com/recoupable/org-delete-me-4c1f308f-9dd0-465d-97d2-4444ca1eaf4f",
  "isNewBranch": false,
  "sandboxType": "vercel",
  "autoCommitPush": true,
  "autoCreatePr": true
}

Open-agents production response (sandbox.recoupable.com/api/sessions)

{
  "session": {
    "id": "AstOUnR7blX1Dg4yBI4ry",
    "userId": "CHsKBglsX6elABy03yAYb",
    "title": "Philadelphia",
    "status": "running",
    "repoOwner": null, "repoName": null, "branch": null,
    "cloneUrl": "https://github.com/recoupable/org-delete-me-4c1f308f-9dd0-465d-97d2-4444ca1eaf4f",
    "isNewBranch": false,
    "autoCommitPushOverride": true,
    "autoCreatePrOverride": true,
    "globalSkillRefs": [],
    "sandboxState": { "type": "vercel" },
    "lifecycleState": "provisioning",
    "lifecycleVersion": 0,
    "linesAdded": 0, "linesRemoved": 0,
    "prNumber": null, "prStatus": null
  },
  "chat": {
    "id": "7FQ22wCcmDg8GI9xggK0y",
    "sessionId": "AstOUnR7blX1Dg4yBI4ry",
    "title": "New chat",
    "modelId": "openai/gpt-5.5"
  }
}

This PR's preview response (api-git-feat-post-create-session-recoup.vercel.app/api/sessions)

{
  "session": {
    "id": "01aab72d-1d1d-4214-9e37-6b9722d5cdc6",
    "userId": "2db5cda8-8b1c-4c6d-87e9-ed8f10a9757a",
    "title": "New session",
    "status": "running",
    "repoOwner": null, "repoName": null, "branch": null,
    "cloneUrl": "https://github.com/recoupable/org-delete-me-4c1f308f-9dd0-465d-97d2-4444ca1eaf4f",
    "isNewBranch": false,
    "globalSkillRefs": [],
    "sandboxState": { "type": "vercel" },
    "lifecycleState": "provisioning",
    "lifecycleVersion": 0,
    "lastActivityAt": null, "sandboxExpiresAt": null, "hibernateAfter": null,
    "lifecycleRunId": null, "lifecycleError": null,
    "linesAdded": 0, "linesRemoved": 0,
    "snapshotUrl": null, "snapshotCreatedAt": null, "snapshotSizeBytes": null,
    "cachedDiff": null, "cachedDiffUpdatedAt": null
  },
  "chat": {
    "id": "0d0afe2d-1586-451e-b390-c75544d2b00a",
    "sessionId": "01aab72d-1d1d-4214-9e37-6b9722d5cdc6",
    "title": "New chat",
    "modelId": "anthropic/claude-haiku-4.5"
  }
}

Field-by-field divergence (same input)

Field open-agents this PR Cause
session.id nanoid "AstOUnR7blX1Dg4yBI4ry" UUID v4 "01aab72d-…" We use generateUUID().
session.userId nanoid (open-agents user pk) UUID (recoup account_id) Different identity systems.
session.title "Philadelphia" (random city) "New session" (hardcoded) We didn't port the random-city helper.
session.cloneUrl passed through ✓ passed through ✓ matches
session.autoCommitPushOverride true (echoed from input) absent Removed in docs PR #187; Zod silently strips.
session.autoCreatePrOverride true (echoed from input) absent Same.
session.prNumber / prStatus null / null absent Removed in docs PR #187.
chat.modelId "openai/gpt-5.5" (account preference) "anthropic/claude-haiku-4.5" (DB default) We don't read account preferences.

How the request was triggered (UI path)

  1. Logged into sandbox.recoupable.com as an account belonging to multiple orgs (so the org-selector UI shows instead of auto-creating personal).
  2. Clicked the "delete me" org button on /sessions.
  3. Captured reqid=703 via Chrome DevTools MCP list_network_requests + get_network_request. That's the request body shown above.
  4. Replayed the captured body verbatim against this PR's preview with an x-api-key (issued via POST /api/agents/signup).

Conclusion

This PR's preview accepts the request and returns the right top-level structure ({session, chat}), but it's:

  • Missing 4 response fields the frontend reads: autoCommitPushOverride, autoCreatePrOverride, prNumber, prStatus
  • Silently stripping 2 input fields with nowhere to land: autoCommitPush, autoCreatePr (no DB columns)

Cutover blockers:

  1. Database migration adding auto_commit_push_override, auto_create_pr_override, pr_number, pr_status columns
  2. Revert the over-aggressive trim from docs PR feat: add CodeRabbit config with reviews for test branch #187 (restore those 4 response fields + 2 input fields in the OpenAPI spec)
  3. Restore in this PR's request schema + persist to the new columns
  4. Optional: port getRandomCityName so default titles match "Philadelphia"-style instead of "New session"

Generated session titles now match the open-agents UX — names like
"Anchorage", "Vienna", "Philadelphia" — instead of every untitled
session being called "New session". Closes a wire-shape gap with
open-agents production identified by the head-to-head test on PR.

Pieces:
- lib/sessions/cityNames.ts: ~200-city curated list (verbatim port)
- lib/sessions/getRandomCityName.ts: pick a city not in `usedNames`,
  numeric-suffix fallback when the curated list is exhausted
- lib/supabase/sessions/selectSessionTitlesByAccountId.ts: Supabase
  helper for collision avoidance
- lib/sessions/resolveSessionTitle.ts: orchestrates provided title
  (trimmed) > random city fallback. Async. Kept separate from the
  insert-row builder so that stays synchronous + pure.
- buildSessionInsertRow now takes `title` as a parameter
- createSessionHandler awaits resolveSessionTitle before building the
  row

TDD: 4 tests for getRandomCityName, 4 for resolveSessionTitle. Handler
tests updated to mock resolveSessionTitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech sweetmantech changed the base branch from feat/port-get-session-route to test May 4, 2026 22:56
The GET endpoint + handler + tests live in PR #514 and were
inadvertently brought in when this branch was rebased after #514's
work. This PR is scoped to POST only; GET ships in #514.

Shared infrastructure stays (types/database.types.ts regen +
lib/sessions/toSessionResponse.ts) — both are required by the POST
handler too. When either #514 or this PR merges to test first, the
other will see those files already present and resolve cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread lib/sessions/createSessionHandler.ts Outdated
Comment on lines +31 to +34
const auth = await validateAuthContext(request);
if (auth instanceof NextResponse) {
return auth;
}
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: validateAuthContext called in the handler
  • required: move validateAuthContext to inside of validateCreateSessionBody

Comment thread lib/sessions/createSessionHandler.ts Outdated
return auth;
}

const body = await safeParseJson(request);
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: safeParseJson called in the handler
  • required: move safeParseJson to inside of validateCreateSessionBody

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.

DRY

  • actual: 2 supabase libs to query select on sessions.
  • required: 1, general selectSessions lib which can be called in both usages.

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 14 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/sessions/__tests__/getRandomCityName.test.ts">

<violation number="1" location="lib/sessions/__tests__/getRandomCityName.test.ts:29">
P2: This test can pass without verifying the suffix behavior. Add a failing assertion after the loop so the spec fails when the expected suffixed city is never generated.</violation>
</file>

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

Comment thread lib/sessions/__tests__/getRandomCityName.test.ts Outdated
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: 4

🧹 Nitpick comments (4)
lib/supabase/sessions/deleteSessionById.ts (1)

11-20: ⚡ Quick win

Return a typed DB result instead of boolean.

For Supabase data helpers, boolean loses useful context and diverges from the typed result convention. Returning a typed session row (or null) gives callers better rollback/error handling semantics and keeps contracts consistent.

As per coding guidelines, "lib/supabase/**/*.ts: Return typed results using Tables<"table_name">."

🤖 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/supabase/sessions/deleteSessionById.ts` around lines 11 - 20, The
function deleteSessionById currently returns a plain boolean; change it to
return the typed deleted session row (or null) using the Tables<"sessions"> type
so callers get the deleted record and consistent typing. Update the signature of
deleteSessionById to return Promise<Tables<"sessions"> | null>, call
supabase.from("sessions").delete().eq("id", id).select().single() (or select()
and grab first element) and on success return the deleted row (data) or null if
none; on error log and return null. Ensure the function references the
Tables<"sessions"> type in its return annotation and uses the supabase
delete().select() result rather than a boolean.
lib/sessions/cityNames.ts (1)

7-198: ⚡ Quick win

Align this module with the file-function export rule.

This file currently exports only a constant; repo rules require a primary exported function with filename alignment. Please move this list behind a function export in a function-named file (or colocate with the consumer if that better fits module boundaries).

As per coding guidelines, "**/*.{js,ts,tsx,jsx}: File-function name match: each file must export ONE primary function, and the file name must match that function's name."

🤖 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/sessions/cityNames.ts` around lines 7 - 198, The module currently only
exports the readonly constant cityNames which violates the file-function export
rule; wrap the array behind a single primary exported function whose name
matches the file (e.g., export function cityNames() or export default function
cityNames()) and keep the existing array as an internal const (e.g., const
CITY_NAMES = [...]) returned by that function; update any consumers to call the
function (cityNames()) instead of importing the constant or move the array into
the module that already exports a primary function to satisfy the rule.
lib/supabase/sessions/selectSessionTitlesByAccountId.ts (1)

11-23: ⚡ Quick win

Use a table-derived return type for titles.

Promise<string[]> works today, but it decouples this helper from schema typing. Returning an array typed from Tables<"sessions">["title"] keeps compile-time protection if the column type changes.

As per coding guidelines, "lib/supabase/**/*.ts: Return typed results using Tables<"table_name">."

🤖 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/supabase/sessions/selectSessionTitlesByAccountId.ts` around lines 11 -
23, Update selectSessionTitlesByAccountId to return the column-typed array
rather than a raw string[]: change the declared return type Promise<string[]> to
Promise<Array<Tables<"sessions">["title"]>> (or the equivalent using
Tables<"sessions">["title"][]), adjust the supabase query typing if needed so
the data is inferred as Array<Pick<Tables<"sessions">, "title">> or similar, and
ensure the final return maps rows to the typed title values while preserving the
same runtime behavior; reference the function name
selectSessionTitlesByAccountId and the type Tables<"sessions">["title"] when
making the change.
lib/sessions/toSessionResponse.ts (1)

16-16: 💤 Low value

userId violates the account terminology convention.

The field name userId conflicts with the project's naming guideline ("Use 'account' terminology, never 'entity' or 'user'"). Even though this is intentional for open-agents wire-format compatibility (as the JSDoc explains), the divergence should be tracked — either by adding an explicit // wire-compat: open-agents expects "userId" inline annotation, or by filing a follow-up to align the frontend field name with accountId once the cutover window opens.

As per coding guidelines: "Use 'account' terminology, never 'entity' or 'user'".

🤖 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/sessions/toSessionResponse.ts` at line 16, The assignment of userId in
toSessionResponse (userId: row.account_id) violates the project-wide "account"
terminology; add an inline annotation to explicitly document the intentional
divergence for open-agents wire-format compatibility (e.g., add a comment like
// wire-compat: open-agents expects "userId" next to the userId field in
toSessionResponse) and also create/file a follow-up task to migrate the frontend
to accountId when the cutover window opens so the divergence is tracked and can
be removed later.
🤖 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/sessions/createSessionHandler.ts`:
- Around line 61-64: When chatRow is missing the rollback call to
deleteSessionById(sessionRow.id) currently ignores its Promise<boolean> result;
change it to capture the result (e.g., const deleted = await
deleteSessionById(sessionRow.id)) and if deleted is false, emit an observability
signal (log an error including sessionRow.id and context and/or report to
telemetry/Sentry) before returning failedToCreateSession() so failed deletions
are visible and diagnosable.

In `@lib/sessions/resolveSessionTitle.ts`:
- Around line 27-28: Wrap the call to selectSessionTitlesByAccountId inside a
try-catch in resolveSessionTitle (or wherever the call is made) so transient DB
errors are swallowed: on success use the returned titles, on any thrown error
log or warn with context and proceed with an empty Set to pass into
getRandomCityName; this preserves deduplication when the DB is healthy but falls
back to non-deduplicated city-name generation on DB failures.

In `@lib/sessions/validateCreateSessionBody.ts`:
- Line 9: Update the Zod literal schema for sandboxType to use the Zod v4 error
param: replace the deprecated { message: "Invalid sandbox type" } with { error:
"Invalid sandbox type" } in the z.literal(...) call (the symbol to change is
sandboxType defined via z.literal). Ensure any similar uses of { message: ... }
in validateCreateSessionBody.ts are migrated to { error: ... } as well.

In `@lib/supabase/sessions/selectSession.ts`:
- Around line 18-20: In selectSession, avoid logging the raw sessionId on error;
change the error handling where it currently consoles the sessionId and error
(the block using sessionId and error) to log a redacted or truncated form of
sessionId (e.g., first N chars + ellipsis) and emit structured metadata instead
of embedding the full identifier in the message—include the redactedId and the
error object separately (or use a request/correlation id if available) so the
call site still has useful context without exposing the full resource
identifier.

---

Nitpick comments:
In `@lib/sessions/cityNames.ts`:
- Around line 7-198: The module currently only exports the readonly constant
cityNames which violates the file-function export rule; wrap the array behind a
single primary exported function whose name matches the file (e.g., export
function cityNames() or export default function cityNames()) and keep the
existing array as an internal const (e.g., const CITY_NAMES = [...]) returned by
that function; update any consumers to call the function (cityNames()) instead
of importing the constant or move the array into the module that already exports
a primary function to satisfy the rule.

In `@lib/sessions/toSessionResponse.ts`:
- Line 16: The assignment of userId in toSessionResponse (userId:
row.account_id) violates the project-wide "account" terminology; add an inline
annotation to explicitly document the intentional divergence for open-agents
wire-format compatibility (e.g., add a comment like // wire-compat: open-agents
expects "userId" next to the userId field in toSessionResponse) and also
create/file a follow-up task to migrate the frontend to accountId when the
cutover window opens so the divergence is tracked and can be removed later.

In `@lib/supabase/sessions/deleteSessionById.ts`:
- Around line 11-20: The function deleteSessionById currently returns a plain
boolean; change it to return the typed deleted session row (or null) using the
Tables<"sessions"> type so callers get the deleted record and consistent typing.
Update the signature of deleteSessionById to return Promise<Tables<"sessions"> |
null>, call supabase.from("sessions").delete().eq("id", id).select().single()
(or select() and grab first element) and on success return the deleted row
(data) or null if none; on error log and return null. Ensure the function
references the Tables<"sessions"> type in its return annotation and uses the
supabase delete().select() result rather than a boolean.

In `@lib/supabase/sessions/selectSessionTitlesByAccountId.ts`:
- Around line 11-23: Update selectSessionTitlesByAccountId to return the
column-typed array rather than a raw string[]: change the declared return type
Promise<string[]> to Promise<Array<Tables<"sessions">["title"]>> (or the
equivalent using Tables<"sessions">["title"][]), adjust the supabase query
typing if needed so the data is inferred as Array<Pick<Tables<"sessions">,
"title">> or similar, and ensure the final return maps rows to the typed title
values while preserving the same runtime behavior; reference the function name
selectSessionTitlesByAccountId and the type Tables<"sessions">["title"] when
making the change.
🪄 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: dfaa31ac-aff6-42d6-ae97-e5ab18e833fd

📥 Commits

Reviewing files that changed from the base of the PR and between 97724c4 and 4adb35d.

⛔ Files ignored due to path filters (10)
  • app/api/sessions/__tests__/route.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by app/**
  • lib/sessions/__tests__/baseChatRow.ts is excluded by !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/baseSessionRow.ts is excluded by !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/buildSessionInsertRow.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/createSessionHandler.persistence.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/createSessionHandler.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/getRandomCityName.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/makeCreateSessionReq.ts is excluded by !**/__tests__/** and included by lib/**
  • lib/sessions/__tests__/resolveSessionTitle.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • types/database.types.ts is excluded by none and included by none
📒 Files selected for processing (15)
  • app/api/sessions/route.ts
  • lib/sessions/buildSessionInsertRow.ts
  • lib/sessions/cityNames.ts
  • lib/sessions/createSessionHandler.ts
  • lib/sessions/failedToCreateSession.ts
  • lib/sessions/getRandomCityName.ts
  • lib/sessions/resolveSessionTitle.ts
  • lib/sessions/toChatResponse.ts
  • lib/sessions/toSessionResponse.ts
  • lib/sessions/validateCreateSessionBody.ts
  • lib/supabase/chats/insertChat.ts
  • lib/supabase/sessions/deleteSessionById.ts
  • lib/supabase/sessions/insertSession.ts
  • lib/supabase/sessions/selectSession.ts
  • lib/supabase/sessions/selectSessionTitlesByAccountId.ts

Comment thread lib/sessions/createSessionHandler.ts
Comment thread lib/sessions/resolveSessionTitle.ts Outdated
Comment on lines +27 to +28
const usedTitles = await selectSessionTitlesByAccountId(input.accountId);
return getRandomCityName(new Set(usedTitles));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

A transient DB failure in title-lookup crashes the entire session creation request.

selectSessionTitlesByAccountId is not wrapped in a try-catch. If it throws (e.g., DB timeout, Supabase outage), the exception propagates through createSessionHandler — which also has no try-catch — and the caller gets a raw 500, even though the session could still be created with a degraded (non-deduplicated) city-name fallback.

The fix is to swallow the error and fall back to an empty set, preserving the happy path while still avoiding title collisions when the DB is healthy.

🛡️ Proposed fix: graceful degradation
-  const usedTitles = await selectSessionTitlesByAccountId(input.accountId);
-  return getRandomCityName(new Set(usedTitles));
+  let usedTitles: string[] = [];
+  try {
+    usedTitles = await selectSessionTitlesByAccountId(input.accountId);
+  } catch {
+    // Non-critical: fall back to an empty exclusion set so session creation
+    // can still proceed with a random city name (may collide, but won't block).
+  }
+  return getRandomCityName(new Set(usedTitles));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const usedTitles = await selectSessionTitlesByAccountId(input.accountId);
return getRandomCityName(new Set(usedTitles));
let usedTitles: string[] = [];
try {
usedTitles = await selectSessionTitlesByAccountId(input.accountId);
} catch {
// Non-critical: fall back to an empty exclusion set so session creation
// can still proceed with a random city name (may collide, but won't block).
}
return getRandomCityName(new Set(usedTitles));
🤖 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/sessions/resolveSessionTitle.ts` around lines 27 - 28, Wrap the call to
selectSessionTitlesByAccountId inside a try-catch in resolveSessionTitle (or
wherever the call is made) so transient DB errors are swallowed: on success use
the returned titles, on any thrown error log or warn with context and proceed
with an empty Set to pass into getRandomCityName; this preserves deduplication
when the DB is healthy but falls back to non-deduplicated city-name generation
on DB failures.

Comment thread lib/sessions/validateCreateSessionBody.ts Outdated
Comment thread lib/supabase/sessions/selectSession.ts Outdated
Two reviewer asks rolled into one commit:

SRP — validateCreateSessionBody now owns the full validation flow.
The handler used to call safeParseJson, validateAuthContext, and the
Zod body schema separately; that was three places to short-circuit
and three places to duplicate the error envelope. Folded them into
validateCreateSessionBody so the handler does one call → success or
NextResponse error. Returns { body, auth } on success.

DRY — replaced lib/supabase/sessions/selectSession.ts and
selectSessionTitlesByAccountId.ts with a single
selectSessions({ id?, accountId? }) that supports both call sites.
resolveSessionTitle now derives titles from the general fetch.

Tests:
- New validateCreateSessionBody.test.ts covers auth-failure / 400 /
  success / malformed-JSON tolerance (4 cases)
- Handler tests now mock validateCreateSessionBody (single mock
  surface instead of three)
- resolveSessionTitle tests mock selectSessions

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

Addressed the latest review:

SRP — handler now does one validation call. Folded safeParseJson and validateAuthContext into validateCreateSessionBody. The function now takes a NextRequest, returns either a 4xx NextResponse or { body, auth }. Handler is correspondingly thinner — one call, one short-circuit, then straight into title resolution + persistence.

DRY — single supabase reader for sessions. Replaced selectSession.ts (single-row by id) and selectSessionTitlesByAccountId.ts (titles by account) with one selectSessions({ id?, accountId? }) returning rows. resolveSessionTitle now projects to titles from the general fetch.

Tests:

  • New validateCreateSessionBody.test.ts — 4 cases (auth-failure / 400 / success / malformed-JSON tolerance)
  • Handler tests now mock the single validateCreateSessionBody instead of three separate functions
  • resolveSessionTitle.test.ts mocks selectSessions instead of the deleted helper

23 unit tests pass, lint clean.

Note: deleting selectSession.ts here means PR #514's getSessionByIdHandler will need to switch its import to selectSessions({ id }).then(rows => rows[0] ?? null) when it rebases — flagging so neither of us forgets.

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

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Four small fixes from the latest round:

1. Zod v4 migration: { message } → { error } on the sandboxType
   literal. v4 unified the error customization API; { message } is
   deprecated.

2. Orphan rollback observability: when insertChat fails AND the
   session-rollback delete also fails, log the session id so ops
   can detect orphaned rows. New persistence test asserts the log.

3. Defensive try/catch in selectSessions so a thrown exception
   (network-level rejection, not a Supabase {error} return) doesn't
   bubble up and 500 the entire session-creation flow.

4. Deterministic test for getRandomCityName suffix-increment: pin
   Math.random instead of looping until the random pick lands on
   baseCity. Previous test could pass without ever asserting if the
   loop cap was hit.

Skipped: cubic-dev-ai's note about logging raw sessionId in
selectSession.ts — that file was deleted earlier in this PR.

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

Addressed the latest automated review (commit c7dff7c):

  • Zod v4 migration ✓ — { message: "Invalid sandbox type" }{ error: "Invalid sandbox type" } on the sandboxType literal. v4 unified the error customization API.
  • Orphan-session observability ✓ — when both insertChat and the session-rollback deleteSessionById fail, now logs [createSessionHandler] chat insert failed and session rollback failed — orphaned session: <id> so ops can find the leak. New test in createSessionHandler.persistence.test.ts asserts the log fires.
  • selectSessions defensive try/catch ✓ — handles thrown exceptions (network-level rejection) as a fall-through to [] so a transient outage during title-lookup doesn't propagate up and 500 the whole create flow.
  • getRandomCityName suffix-increment test ✓ — pinned Math.random to make the test deterministic. Previous loop could hit its cap without ever asserting.

Skipped: cubic-dev-ai's "don't log raw sessionId in selectSession.ts" — that file was deleted earlier in this PR (replaced by selectSessions).

24 unit tests pass, 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 5 files (changes from recent commits).

Requires human review: This PR adds a new API endpoint with database writes, auth, and business logic for session/chat creation. It's a significant feature (1394 lines) with non-trivial impact on data integrity and core API

sweetmantech and others added 2 commits May 4, 2026 18:19
The new orphan-session test had a line that exceeded prettier's wrap
width. Auto-format fixed it; format-check now 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 1 file (changes from recent commits).

Requires human review: New API endpoint with database writes, rollback logic, and multiple new files. Even with thorough tests, this is a non-trivial feature that could impact data integrity and requires human review to vet

@sweetmantech sweetmantech merged commit 4c17ee7 into test May 4, 2026
6 checks passed
@sweetmantech sweetmantech deleted the feat/post-create-session branch May 4, 2026 23:25
sweetmantech added a commit that referenced this pull request May 4, 2026
* refactor(sandbox): callers use open-agents abstraction (Phase 2.2) (#509)

* refactor(sandbox): callers use open-agents abstraction (Phase 2.2)

Replaces direct @vercel/sandbox SDK calls with the open-agents sandbox
abstraction layer (inlined in Phase 2.1) for sandbox lifecycle (create
+ reconnect). HTTP response shapes preserved exactly.

Per the agreed Option B (hybrid): only the lifecycle creator helpers
get refactored. installClaudeCode / runClaudeCode / getSandboxStatus
stay on the SDK directly because the abstraction does not cover their
needs (sudo, stdout/stderr streaming, simple status reads). Those
two install/run files are also dead orphans (defined but never called)
and will be removed entirely after the full migration.

Production refactor:
  createSandbox.ts            Sandbox.create(...) -> VercelSandbox.create(...)
                              Input: VercelSandboxConfig (was SDK params)
                              Snapshot trigger: restoreSnapshotId field
                                (was source: { type: "snapshot", ... })
                              Returns VercelSandbox (was SDK Sandbox)
  createSandboxWithFallback.ts cascade — passes restoreSnapshotId to createSandbox
  createSandboxFromSnapshot.ts type cascade only (Sandbox -> VercelSandbox)
  getActiveSandbox.ts         Sandbox.get({name}) -> VercelSandbox.connect(name, {})
                              Status check: sandbox.status -> sandbox.sdkStatus
  getOrCreateSandbox.ts       no code change — type cascades automatically
  processCreateSandbox.ts     reads sandbox.sdkStatus instead of sandbox.status
                              defensive nullish on createdAt

Abstraction extension:
  vercel/sandbox/VercelSandbox.ts adds two readonly getters following
  the existing host/environmentDetails/expiresAt pattern:
    get sdkStatus(): string  — raw SDK session status (running/pending/
                                stopped/failed/aborted/snapshotting),
                                distinct from the abstraction's normalized
                                status getter
    get createdAt(): Date | undefined  — SDK session.createdAt

  These give api callers what they need to construct the existing
  HTTP response shape without breaking the abstraction's interface.

Tests updated:
  createSandbox.test.ts            mocks VercelSandbox.create instead of
                                    Sandbox.create; mock object uses
                                    sdkStatus instead of status
  createSandboxWithFallback.test.ts asserts restoreSnapshotId pass-through
  getActiveSandbox.test.ts         mocks VercelSandbox.connect; sdkStatus
                                    on mock objects
  processCreateSandbox.test.ts     mockSandbox uses sdkStatus

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2391/2391 pass
  - HTTP response shape unchanged: same fields, same enum values for
    sandboxStatus (sourced from the SDK now via sdkStatus, was directly
    via SDK Sandbox.status before — identical strings either way)

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

* fix: address PR #509 review feedback

Three real issues from CodeRabbit + cubic:

1. createdAt staleness (CodeRabbit minor)
   The new `createdAt` getter on VercelSandbox skipped the
   `refreshStateFromCurrentSession()` step that `sdkStatus` uses, so
   readers right after a reconnect could see stale session metadata.
   Add the refresh.

2. Fabricated createdAt (cubic P2)
   Both createSandbox.ts and processCreateSandbox.ts had a
   `?? new Date().toISOString()` fallback that fabricated creation
   metadata when sandbox.createdAt was missing. The SDK guarantees
   createdAt is populated for any reachable instance, so the fallback
   was both wrong (fabricates data) and unnecessary.

   Tighten the getter to return `Date` (not `Date | undefined`) and
   throw with an explicit "SDK contract violation" message if the
   field is missing — fail-fast surfaces a real contract bug instead
   of silently lying.

   Drop the `?? new Date()` fallbacks at both call sites.

3. Misleading snapshot-restore branching (CodeRabbit major)
   createSandbox.ts had two paths — a "snapshot" branch that omitted
   DEFAULT_VCPUS/DEFAULT_RUNTIME (intent: let snapshot dictate), and
   a "fresh" branch that applied defaults. But VercelSandbox.create
   internally defaults vcpus=4 and runtime="node22" regardless, so
   the omission was a no-op — the abstraction always forwarded those
   to the SDK.

   Drop the misleading branching. Document the actual behavior at
   the top of createSandbox: "VercelSandbox.create applies its own
   defaults regardless of source — those apply to the runtime
   resources of the new sandbox even when restoring from a snapshot."

   Updated the snapshot-restore test to assert the actual call shape
   (vcpus + runtime + timeout + restoreSnapshotId) instead of just
   the original SDK-style truncated args.

Verification:
- pnpm lint:check: clean
- pnpm test: 2391/2391 pass

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

---------

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

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3) (#512)

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3)

installClaudeCode and runClaudeCode were defined but never imported
anywhere in api production code — confirmed by grep on main:

  $ grep -rn "installClaudeCode\b\|runClaudeCode\b" lib/ app/
  lib/sandbox/installClaudeCode.ts:9: export async function installClaudeCode(...)
  lib/sandbox/runClaudeCode.ts:10:    export async function runClaudeCode(...)

Both files were skipped during the Phase 2.2 abstraction refactor
(per the agreed Option B — they used SDK features the abstraction
doesn't expose: sudo, stdout/stderr streaming, batched writes). With
the broader migration moving to Vercel Workflow + open-agents' agent
package for sandbox bootstrap, these orphans have no path to being
called again.

Removed:
  lib/sandbox/installClaudeCode.ts                (32 lines)
  lib/sandbox/runClaudeCode.ts                    (29 lines)
  lib/sandbox/__tests__/installClaudeCode.test.ts (4 tests)
  lib/sandbox/__tests__/runClaudeCode.test.ts     (6 tests)

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2381/2381 pass (was 2391 — net -10 tests from the
    two deleted test files)

Note: getOrCreateSandbox.ts also has zero importers per the audit
and is similarly dead, but is intentionally NOT deleted in this PR
since it was not explicitly flagged as orphan in the migration plan.
Worth a separate follow-up decision.

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

* chore(sandbox): also delete getOrCreateSandbox + getActiveSandbox (YAGNI)

Cascade audit found two more truly-dead helpers per YAGNI:

  getOrCreateSandbox.ts    0 importers (self-only references)
  getActiveSandbox.ts      only called by getOrCreateSandbox — orphan
                            once that goes

Removed:
  lib/sandbox/getOrCreateSandbox.ts                (39 lines)
  lib/sandbox/getActiveSandbox.ts                  (33 lines)
  lib/sandbox/__tests__/getOrCreateSandbox.test.ts (3 tests)
  lib/sandbox/__tests__/getActiveSandbox.test.ts   (4 tests)

Live consumers of related helpers preserved:
  - createSandboxFromSnapshot still used by processCreateSandbox
  - selectAccountSandboxes still used by aggregateAccountSandboxStats,
    buildGetSandboxesParams, getSandboxesHandler, validateGetSandboxesRequest

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2374/2374 pass (was 2381 — net -7 from the two deleted
    test files; -3 from getOrCreateSandbox.test.ts + -4 from
    getActiveSandbox.test.ts)

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

---------

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

* feat(sessions): port POST /api/sessions from open-agents (#515)

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (Phase 2.4 — first route)

First route in the route-by-route cutover plan. Strategy: open-agents
frontend stays unchanged in shape; api ports each route it calls in
priority order (simplest first), and the open-agents frontend gets
cut over to api one route at a time.

Why this route first:
- Pure DB read (single-row select by id) — no agent runner, no Vercel
  Workflow, no sandbox runtime
- Hits sessions table already migrated in database PR #20
- Frontend usage: agents-frontend hits /api/sessions/{id} on session
  detail page navigation
- Smallest possible blast radius for proving the cutover pattern

Files added:
  lib/supabase/sessions/selectSession.ts  Single-row helper + SessionRow
                                          type (hand-typed; database.types.ts
                                          regen pending — flagged in code
                                          comment)
  app/api/sessions/[sessionId]/route.ts   GET handler matching open-agents
                                          response shape exactly (camelCase
                                          fields, "userId" preserved on the
                                          wire even though stored as
                                          account_id internally)
  app/api/sessions/[sessionId]/__tests__/route.test.ts (5 tests)

Auth: validateAuthContext (Privy Bearer or x-api-key). Response codes
match open-agents: 200 happy path, 401 no auth, 403 not owner, 404 not
found.

Wire-format translation: snake_case Supabase row -> camelCase response,
with account_id surfaced as userId so the existing open-agents frontend
fetches with zero code changes. Translation lives at the route boundary
(toSessionResponse) where it is easy to remove once chat absorbs this
UI and we can switch to schema-natural naming.

Verification:
- pnpm lint:check: clean
- pnpm test: 2379/2379 pass (5 new for this route)

Up next:
- Cutover step (separate PR in open-agents): point the frontend at
  api's URL for this single route. Validate end-to-end before porting
  the next route.
- Next routes in priority order (still pure DB, no agent/workflow):
  GET /api/sessions (list with unread — needs Postgres RPC for the
  multi-table aggregation), GET /api/sessions/[id]/chats, GET
  /api/sessions/[id]/chats/[chatId].

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

* fix: address PR review — SRP splits + use Tables<\"sessions\"> from regen'd types

Three review comments on PR #514:

1. SRP: extract toSessionResponse to its own file
   was: defined inline in app/api/sessions/[sessionId]/route.ts
   now: lib/sessions/toSessionResponse.ts (one exported fn per file)

2. SRP: add a handler function (mirroring api convention)
   was: GET handler logic inline in route.ts
   now: lib/sessions/getSessionByIdHandler.ts contains all the auth +
        ownership + DB lookup + response logic; route.ts is a thin
        shell that awaits options.params and delegates. Matches the
        pattern used by every other api route (e.g. socials/[id]/scrape,
        artists/[id]/...).

3. DRY: use existing db schema type
   was: hand-typed SessionRow interface in selectSession.ts
   now: Tables<\"sessions\"> from types/database.types.ts (regenerated
        via npx supabase gen types typescript --project-id ...
        --schema public)

The types regen also resolved the preview-build failure
(\"Type instantiation is excessively deep and possibly infinite\") on
the .from(\"sessions\") call — Supabase's type inference was choking
because the table was unknown to the generic.

Files added:
  lib/sessions/toSessionResponse.ts
  lib/sessions/getSessionByIdHandler.ts

Files modified:
  app/api/sessions/[sessionId]/route.ts        thin shell now
  app/api/sessions/[sessionId]/__tests__/
    route.test.ts                              type alias updated
  lib/supabase/sessions/selectSession.ts       Tables<\"sessions\">
  types/database.types.ts                      Supabase regen

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2379/2379 pass (no test changes; same 5 route tests)
  - tsc compile clean (the local pnpm build progresses past compile
    into page-data collection where it fails on missing local env
    vars — Vercel preview will have those set, so the preview rebuild
    should now succeed)

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

* fix(sessions): make 404/403 errors emit status:"error" for shape consistency

The 401 returned by validateAuthContext shaped like
{status:"error", error:"..."} but 404/403 from this handler returned
{error:"..."} only. Same endpoint, two error shapes — inconsistent for
clients. Align all error responses on the validateAuthContext shape.

Tests now assert the full error body, not just the status code.

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

* feat(sessions): port POST /api/sessions from open-agents

Implements the POST /api/sessions contract documented in
recoupable/docs PR #186 + #187. Creates a session row and an
initial chat row; rolls back the session if chat insert fails so
callers never observe an orphaned session.

Auth: validateAuthContext (Privy Bearer or x-api-key).
Validation: Zod schema + GitHub repo segment regex. Body is
optional — empty body creates a session with sensible defaults
(status=running, lifecycle_state=provisioning, sandbox_state.type=
vercel, title="New session").

Out of scope (will follow once database catches up):
  auto_commit_push_override, auto_create_pr_override, pr_number,
  pr_status — these columns don't yet exist on api's sessions
  table, so the docs spec was trimmed accordingly in docs PR #187.

TDD: 9 handler tests cover 401, 400 (sandboxType / repoOwner /
repoName), 200 happy path, branch generation, title pass-through,
500 (insertSession failure), and 500-with-rollback (insertChat
failure). Plus 1 thin test on the route shell.

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

* feat(sessions): add OPTIONS handler + cache directives to POST route

Match the convention from app/api/sessions/[sessionId]/route.ts:
- OPTIONS handler returning 200 + CORS headers (preflight)
- dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0

POST routes that mutate DB shouldn't be cached, and browsers issuing
preflight checks (POST with JSON body + custom auth headers) need
OPTIONS to respond.

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

* fix(sessions): address PR review feedback

- SRP: extract insert-row construction to lib/sessions/buildSessionInsertRow.ts
- YAGNI: drop generateSessionBranchName + isNewBranch handling (sessions
  commit to whatever branch the client provides; auto-generation was
  speculative)
- Tighten isValidGitHubRepoOwner: GitHub's actual rules are alphanumeric
  + hyphen only (no `_` or `.`), 1-39 chars, no leading/trailing or
  consecutive hyphens
- Tighten isValidGitHubRepoName: reject reserved `.` and `..`, reject
  `.git` suffix, cap at 100 chars
- Add unit tests for both validators (15 cases) and for the new
  buildSessionInsertRow (4 cases)
- Split createSessionHandler tests into auth/validation + persistence
  files; share fixtures via createSessionHandlerFixtures.ts. All test
  files now under 100 lines.

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

* fix(sessions): address second round of PR review

- 500 message: "Failed to create session" → "Internal server error"
  (per cubic.dev standardized 500 envelope feedback)
- SRP: extract failedToCreateSession to lib/sessions/failedToCreateSession.ts
- YAGNI: drop repoOwner from request body and remove
  isValidGitHubRepoOwner helper entirely (recoupable is the only
  owner; no need to validate)
- YAGNI: drop repoName from request body and remove
  isValidGitHubRepoName helper (repo identity is derived server-side
  from the authenticated account, not accepted from user input)
- Single-export per file: split createSessionHandlerFixtures.ts into
  makeCreateSessionReq.ts, baseSessionRow.ts, baseChatRow.ts.
  okAuth constant inlined where used.

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

* feat(sessions): port random-city title fallback from open-agents

Generated session titles now match the open-agents UX — names like
"Anchorage", "Vienna", "Philadelphia" — instead of every untitled
session being called "New session". Closes a wire-shape gap with
open-agents production identified by the head-to-head test on PR.

Pieces:
- lib/sessions/cityNames.ts: ~200-city curated list (verbatim port)
- lib/sessions/getRandomCityName.ts: pick a city not in `usedNames`,
  numeric-suffix fallback when the curated list is exhausted
- lib/supabase/sessions/selectSessionTitlesByAccountId.ts: Supabase
  helper for collision avoidance
- lib/sessions/resolveSessionTitle.ts: orchestrates provided title
  (trimmed) > random city fallback. Async. Kept separate from the
  insert-row builder so that stays synchronous + pure.
- buildSessionInsertRow now takes `title` as a parameter
- createSessionHandler awaits resolveSessionTitle before building the
  row

TDD: 4 tests for getRandomCityName, 4 for resolveSessionTitle. Handler
tests updated to mock resolveSessionTitle.

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

* chore: remove GET-only files (scope this PR to POST)

The GET endpoint + handler + tests live in PR #514 and were
inadvertently brought in when this branch was rebased after #514's
work. This PR is scoped to POST only; GET ships in #514.

Shared infrastructure stays (types/database.types.ts regen +
lib/sessions/toSessionResponse.ts) — both are required by the POST
handler too. When either #514 or this PR merges to test first, the
other will see those files already present and resolve cleanly.

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

* fix(sessions): consolidate request validation + DRY supabase select

Two reviewer asks rolled into one commit:

SRP — validateCreateSessionBody now owns the full validation flow.
The handler used to call safeParseJson, validateAuthContext, and the
Zod body schema separately; that was three places to short-circuit
and three places to duplicate the error envelope. Folded them into
validateCreateSessionBody so the handler does one call → success or
NextResponse error. Returns { body, auth } on success.

DRY — replaced lib/supabase/sessions/selectSession.ts and
selectSessionTitlesByAccountId.ts with a single
selectSessions({ id?, accountId? }) that supports both call sites.
resolveSessionTitle now derives titles from the general fetch.

Tests:
- New validateCreateSessionBody.test.ts covers auth-failure / 400 /
  success / malformed-JSON tolerance (4 cases)
- Handler tests now mock validateCreateSessionBody (single mock
  surface instead of three)
- resolveSessionTitle tests mock selectSessions

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

* fix(sessions): address automated review feedback

Four small fixes from the latest round:

1. Zod v4 migration: { message } → { error } on the sandboxType
   literal. v4 unified the error customization API; { message } is
   deprecated.

2. Orphan rollback observability: when insertChat fails AND the
   session-rollback delete also fails, log the session id so ops
   can detect orphaned rows. New persistence test asserts the log.

3. Defensive try/catch in selectSessions so a thrown exception
   (network-level rejection, not a Supabase {error} return) doesn't
   bubble up and 500 the entire session-creation flow.

4. Deterministic test for getRandomCityName suffix-increment: pin
   Math.random instead of looping until the random pick lands on
   baseCity. Previous test could pass without ever asserting if the
   loop cap was hit.

Skipped: cubic-dev-ai's note about logging raw sessionId in
selectSession.ts — that file was deleted earlier in this PR.

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

* chore: prettier format fix on persistence test

The new orphan-session test had a line that exceeded prettier's wrap
width. Auto-format fixed it; format-check now 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 4, 2026
Rebased onto current main (which now has the POST endpoint + shared
infra from PR #515). Three pieces of GET-specific work:

- app/api/sessions/[sessionId]/route.ts: thin shell delegating to the
  handler, plus OPTIONS for CORS preflight + cache directives
- lib/sessions/getSessionByIdHandler.ts: validates auth via
  validateAuthContext, reads via selectSessions({id}), enforces
  ownership (403 if account_id mismatch), 404 if missing
- app/api/sessions/[sessionId]/__tests__/route.test.ts: 5 cases —
  401 / 404 / 403 / 200 happy path / OPTIONS smoke

Uses the new general selectSessions({id}) reader rather than the
deleted single-purpose selectSession helper. All other shared infra
(types, toSessionResponse) is already on main from #515.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 5, 2026
…514)

Rebased onto current main (which now has the POST endpoint + shared
infra from PR #515). Three pieces of GET-specific work:

- app/api/sessions/[sessionId]/route.ts: thin shell delegating to the
  handler, plus OPTIONS for CORS preflight + cache directives
- lib/sessions/getSessionByIdHandler.ts: validates auth via
  validateAuthContext, reads via selectSessions({id}), enforces
  ownership (403 if account_id mismatch), 404 if missing
- app/api/sessions/[sessionId]/__tests__/route.test.ts: 5 cases —
  401 / 404 / 403 / 200 happy path / OPTIONS smoke

Uses the new general selectSessions({id}) reader rather than the
deleted single-purpose selectSession helper. All other shared infra
(types, toSessionResponse) is already on main from #515.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 5, 2026
* refactor(sandbox): callers use open-agents abstraction (Phase 2.2) (#509)

* refactor(sandbox): callers use open-agents abstraction (Phase 2.2)

Replaces direct @vercel/sandbox SDK calls with the open-agents sandbox
abstraction layer (inlined in Phase 2.1) for sandbox lifecycle (create
+ reconnect). HTTP response shapes preserved exactly.

Per the agreed Option B (hybrid): only the lifecycle creator helpers
get refactored. installClaudeCode / runClaudeCode / getSandboxStatus
stay on the SDK directly because the abstraction does not cover their
needs (sudo, stdout/stderr streaming, simple status reads). Those
two install/run files are also dead orphans (defined but never called)
and will be removed entirely after the full migration.

Production refactor:
  createSandbox.ts            Sandbox.create(...) -> VercelSandbox.create(...)
                              Input: VercelSandboxConfig (was SDK params)
                              Snapshot trigger: restoreSnapshotId field
                                (was source: { type: "snapshot", ... })
                              Returns VercelSandbox (was SDK Sandbox)
  createSandboxWithFallback.ts cascade — passes restoreSnapshotId to createSandbox
  createSandboxFromSnapshot.ts type cascade only (Sandbox -> VercelSandbox)
  getActiveSandbox.ts         Sandbox.get({name}) -> VercelSandbox.connect(name, {})
                              Status check: sandbox.status -> sandbox.sdkStatus
  getOrCreateSandbox.ts       no code change — type cascades automatically
  processCreateSandbox.ts     reads sandbox.sdkStatus instead of sandbox.status
                              defensive nullish on createdAt

Abstraction extension:
  vercel/sandbox/VercelSandbox.ts adds two readonly getters following
  the existing host/environmentDetails/expiresAt pattern:
    get sdkStatus(): string  — raw SDK session status (running/pending/
                                stopped/failed/aborted/snapshotting),
                                distinct from the abstraction's normalized
                                status getter
    get createdAt(): Date | undefined  — SDK session.createdAt

  These give api callers what they need to construct the existing
  HTTP response shape without breaking the abstraction's interface.

Tests updated:
  createSandbox.test.ts            mocks VercelSandbox.create instead of
                                    Sandbox.create; mock object uses
                                    sdkStatus instead of status
  createSandboxWithFallback.test.ts asserts restoreSnapshotId pass-through
  getActiveSandbox.test.ts         mocks VercelSandbox.connect; sdkStatus
                                    on mock objects
  processCreateSandbox.test.ts     mockSandbox uses sdkStatus

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2391/2391 pass
  - HTTP response shape unchanged: same fields, same enum values for
    sandboxStatus (sourced from the SDK now via sdkStatus, was directly
    via SDK Sandbox.status before — identical strings either way)

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

* fix: address PR #509 review feedback

Three real issues from CodeRabbit + cubic:

1. createdAt staleness (CodeRabbit minor)
   The new `createdAt` getter on VercelSandbox skipped the
   `refreshStateFromCurrentSession()` step that `sdkStatus` uses, so
   readers right after a reconnect could see stale session metadata.
   Add the refresh.

2. Fabricated createdAt (cubic P2)
   Both createSandbox.ts and processCreateSandbox.ts had a
   `?? new Date().toISOString()` fallback that fabricated creation
   metadata when sandbox.createdAt was missing. The SDK guarantees
   createdAt is populated for any reachable instance, so the fallback
   was both wrong (fabricates data) and unnecessary.

   Tighten the getter to return `Date` (not `Date | undefined`) and
   throw with an explicit "SDK contract violation" message if the
   field is missing — fail-fast surfaces a real contract bug instead
   of silently lying.

   Drop the `?? new Date()` fallbacks at both call sites.

3. Misleading snapshot-restore branching (CodeRabbit major)
   createSandbox.ts had two paths — a "snapshot" branch that omitted
   DEFAULT_VCPUS/DEFAULT_RUNTIME (intent: let snapshot dictate), and
   a "fresh" branch that applied defaults. But VercelSandbox.create
   internally defaults vcpus=4 and runtime="node22" regardless, so
   the omission was a no-op — the abstraction always forwarded those
   to the SDK.

   Drop the misleading branching. Document the actual behavior at
   the top of createSandbox: "VercelSandbox.create applies its own
   defaults regardless of source — those apply to the runtime
   resources of the new sandbox even when restoring from a snapshot."

   Updated the snapshot-restore test to assert the actual call shape
   (vcpus + runtime + timeout + restoreSnapshotId) instead of just
   the original SDK-style truncated args.

Verification:
- pnpm lint:check: clean
- pnpm test: 2391/2391 pass

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

---------

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

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3) (#512)

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3)

installClaudeCode and runClaudeCode were defined but never imported
anywhere in api production code — confirmed by grep on main:

  $ grep -rn "installClaudeCode\b\|runClaudeCode\b" lib/ app/
  lib/sandbox/installClaudeCode.ts:9: export async function installClaudeCode(...)
  lib/sandbox/runClaudeCode.ts:10:    export async function runClaudeCode(...)

Both files were skipped during the Phase 2.2 abstraction refactor
(per the agreed Option B — they used SDK features the abstraction
doesn't expose: sudo, stdout/stderr streaming, batched writes). With
the broader migration moving to Vercel Workflow + open-agents' agent
package for sandbox bootstrap, these orphans have no path to being
called again.

Removed:
  lib/sandbox/installClaudeCode.ts                (32 lines)
  lib/sandbox/runClaudeCode.ts                    (29 lines)
  lib/sandbox/__tests__/installClaudeCode.test.ts (4 tests)
  lib/sandbox/__tests__/runClaudeCode.test.ts     (6 tests)

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2381/2381 pass (was 2391 — net -10 tests from the
    two deleted test files)

Note: getOrCreateSandbox.ts also has zero importers per the audit
and is similarly dead, but is intentionally NOT deleted in this PR
since it was not explicitly flagged as orphan in the migration plan.
Worth a separate follow-up decision.

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

* chore(sandbox): also delete getOrCreateSandbox + getActiveSandbox (YAGNI)

Cascade audit found two more truly-dead helpers per YAGNI:

  getOrCreateSandbox.ts    0 importers (self-only references)
  getActiveSandbox.ts      only called by getOrCreateSandbox — orphan
                            once that goes

Removed:
  lib/sandbox/getOrCreateSandbox.ts                (39 lines)
  lib/sandbox/getActiveSandbox.ts                  (33 lines)
  lib/sandbox/__tests__/getOrCreateSandbox.test.ts (3 tests)
  lib/sandbox/__tests__/getActiveSandbox.test.ts   (4 tests)

Live consumers of related helpers preserved:
  - createSandboxFromSnapshot still used by processCreateSandbox
  - selectAccountSandboxes still used by aggregateAccountSandboxStats,
    buildGetSandboxesParams, getSandboxesHandler, validateGetSandboxesRequest

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2374/2374 pass (was 2381 — net -7 from the two deleted
    test files; -3 from getOrCreateSandbox.test.ts + -4 from
    getActiveSandbox.test.ts)

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

---------

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

* feat(sessions): port POST /api/sessions from open-agents (#515)

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (Phase 2.4 — first route)

First route in the route-by-route cutover plan. Strategy: open-agents
frontend stays unchanged in shape; api ports each route it calls in
priority order (simplest first), and the open-agents frontend gets
cut over to api one route at a time.

Why this route first:
- Pure DB read (single-row select by id) — no agent runner, no Vercel
  Workflow, no sandbox runtime
- Hits sessions table already migrated in database PR #20
- Frontend usage: agents-frontend hits /api/sessions/{id} on session
  detail page navigation
- Smallest possible blast radius for proving the cutover pattern

Files added:
  lib/supabase/sessions/selectSession.ts  Single-row helper + SessionRow
                                          type (hand-typed; database.types.ts
                                          regen pending — flagged in code
                                          comment)
  app/api/sessions/[sessionId]/route.ts   GET handler matching open-agents
                                          response shape exactly (camelCase
                                          fields, "userId" preserved on the
                                          wire even though stored as
                                          account_id internally)
  app/api/sessions/[sessionId]/__tests__/route.test.ts (5 tests)

Auth: validateAuthContext (Privy Bearer or x-api-key). Response codes
match open-agents: 200 happy path, 401 no auth, 403 not owner, 404 not
found.

Wire-format translation: snake_case Supabase row -> camelCase response,
with account_id surfaced as userId so the existing open-agents frontend
fetches with zero code changes. Translation lives at the route boundary
(toSessionResponse) where it is easy to remove once chat absorbs this
UI and we can switch to schema-natural naming.

Verification:
- pnpm lint:check: clean
- pnpm test: 2379/2379 pass (5 new for this route)

Up next:
- Cutover step (separate PR in open-agents): point the frontend at
  api's URL for this single route. Validate end-to-end before porting
  the next route.
- Next routes in priority order (still pure DB, no agent/workflow):
  GET /api/sessions (list with unread — needs Postgres RPC for the
  multi-table aggregation), GET /api/sessions/[id]/chats, GET
  /api/sessions/[id]/chats/[chatId].

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

* fix: address PR review — SRP splits + use Tables<\"sessions\"> from regen'd types

Three review comments on PR #514:

1. SRP: extract toSessionResponse to its own file
   was: defined inline in app/api/sessions/[sessionId]/route.ts
   now: lib/sessions/toSessionResponse.ts (one exported fn per file)

2. SRP: add a handler function (mirroring api convention)
   was: GET handler logic inline in route.ts
   now: lib/sessions/getSessionByIdHandler.ts contains all the auth +
        ownership + DB lookup + response logic; route.ts is a thin
        shell that awaits options.params and delegates. Matches the
        pattern used by every other api route (e.g. socials/[id]/scrape,
        artists/[id]/...).

3. DRY: use existing db schema type
   was: hand-typed SessionRow interface in selectSession.ts
   now: Tables<\"sessions\"> from types/database.types.ts (regenerated
        via npx supabase gen types typescript --project-id ...
        --schema public)

The types regen also resolved the preview-build failure
(\"Type instantiation is excessively deep and possibly infinite\") on
the .from(\"sessions\") call — Supabase's type inference was choking
because the table was unknown to the generic.

Files added:
  lib/sessions/toSessionResponse.ts
  lib/sessions/getSessionByIdHandler.ts

Files modified:
  app/api/sessions/[sessionId]/route.ts        thin shell now
  app/api/sessions/[sessionId]/__tests__/
    route.test.ts                              type alias updated
  lib/supabase/sessions/selectSession.ts       Tables<\"sessions\">
  types/database.types.ts                      Supabase regen

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2379/2379 pass (no test changes; same 5 route tests)
  - tsc compile clean (the local pnpm build progresses past compile
    into page-data collection where it fails on missing local env
    vars — Vercel preview will have those set, so the preview rebuild
    should now succeed)

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

* fix(sessions): make 404/403 errors emit status:"error" for shape consistency

The 401 returned by validateAuthContext shaped like
{status:"error", error:"..."} but 404/403 from this handler returned
{error:"..."} only. Same endpoint, two error shapes — inconsistent for
clients. Align all error responses on the validateAuthContext shape.

Tests now assert the full error body, not just the status code.

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

* feat(sessions): port POST /api/sessions from open-agents

Implements the POST /api/sessions contract documented in
recoupable/docs PR #186 + #187. Creates a session row and an
initial chat row; rolls back the session if chat insert fails so
callers never observe an orphaned session.

Auth: validateAuthContext (Privy Bearer or x-api-key).
Validation: Zod schema + GitHub repo segment regex. Body is
optional — empty body creates a session with sensible defaults
(status=running, lifecycle_state=provisioning, sandbox_state.type=
vercel, title="New session").

Out of scope (will follow once database catches up):
  auto_commit_push_override, auto_create_pr_override, pr_number,
  pr_status — these columns don't yet exist on api's sessions
  table, so the docs spec was trimmed accordingly in docs PR #187.

TDD: 9 handler tests cover 401, 400 (sandboxType / repoOwner /
repoName), 200 happy path, branch generation, title pass-through,
500 (insertSession failure), and 500-with-rollback (insertChat
failure). Plus 1 thin test on the route shell.

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

* feat(sessions): add OPTIONS handler + cache directives to POST route

Match the convention from app/api/sessions/[sessionId]/route.ts:
- OPTIONS handler returning 200 + CORS headers (preflight)
- dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0

POST routes that mutate DB shouldn't be cached, and browsers issuing
preflight checks (POST with JSON body + custom auth headers) need
OPTIONS to respond.

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

* fix(sessions): address PR review feedback

- SRP: extract insert-row construction to lib/sessions/buildSessionInsertRow.ts
- YAGNI: drop generateSessionBranchName + isNewBranch handling (sessions
  commit to whatever branch the client provides; auto-generation was
  speculative)
- Tighten isValidGitHubRepoOwner: GitHub's actual rules are alphanumeric
  + hyphen only (no `_` or `.`), 1-39 chars, no leading/trailing or
  consecutive hyphens
- Tighten isValidGitHubRepoName: reject reserved `.` and `..`, reject
  `.git` suffix, cap at 100 chars
- Add unit tests for both validators (15 cases) and for the new
  buildSessionInsertRow (4 cases)
- Split createSessionHandler tests into auth/validation + persistence
  files; share fixtures via createSessionHandlerFixtures.ts. All test
  files now under 100 lines.

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

* fix(sessions): address second round of PR review

- 500 message: "Failed to create session" → "Internal server error"
  (per cubic.dev standardized 500 envelope feedback)
- SRP: extract failedToCreateSession to lib/sessions/failedToCreateSession.ts
- YAGNI: drop repoOwner from request body and remove
  isValidGitHubRepoOwner helper entirely (recoupable is the only
  owner; no need to validate)
- YAGNI: drop repoName from request body and remove
  isValidGitHubRepoName helper (repo identity is derived server-side
  from the authenticated account, not accepted from user input)
- Single-export per file: split createSessionHandlerFixtures.ts into
  makeCreateSessionReq.ts, baseSessionRow.ts, baseChatRow.ts.
  okAuth constant inlined where used.

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

* feat(sessions): port random-city title fallback from open-agents

Generated session titles now match the open-agents UX — names like
"Anchorage", "Vienna", "Philadelphia" — instead of every untitled
session being called "New session". Closes a wire-shape gap with
open-agents production identified by the head-to-head test on PR.

Pieces:
- lib/sessions/cityNames.ts: ~200-city curated list (verbatim port)
- lib/sessions/getRandomCityName.ts: pick a city not in `usedNames`,
  numeric-suffix fallback when the curated list is exhausted
- lib/supabase/sessions/selectSessionTitlesByAccountId.ts: Supabase
  helper for collision avoidance
- lib/sessions/resolveSessionTitle.ts: orchestrates provided title
  (trimmed) > random city fallback. Async. Kept separate from the
  insert-row builder so that stays synchronous + pure.
- buildSessionInsertRow now takes `title` as a parameter
- createSessionHandler awaits resolveSessionTitle before building the
  row

TDD: 4 tests for getRandomCityName, 4 for resolveSessionTitle. Handler
tests updated to mock resolveSessionTitle.

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

* chore: remove GET-only files (scope this PR to POST)

The GET endpoint + handler + tests live in PR #514 and were
inadvertently brought in when this branch was rebased after #514's
work. This PR is scoped to POST only; GET ships in #514.

Shared infrastructure stays (types/database.types.ts regen +
lib/sessions/toSessionResponse.ts) — both are required by the POST
handler too. When either #514 or this PR merges to test first, the
other will see those files already present and resolve cleanly.

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

* fix(sessions): consolidate request validation + DRY supabase select

Two reviewer asks rolled into one commit:

SRP — validateCreateSessionBody now owns the full validation flow.
The handler used to call safeParseJson, validateAuthContext, and the
Zod body schema separately; that was three places to short-circuit
and three places to duplicate the error envelope. Folded them into
validateCreateSessionBody so the handler does one call → success or
NextResponse error. Returns { body, auth } on success.

DRY — replaced lib/supabase/sessions/selectSession.ts and
selectSessionTitlesByAccountId.ts with a single
selectSessions({ id?, accountId? }) that supports both call sites.
resolveSessionTitle now derives titles from the general fetch.

Tests:
- New validateCreateSessionBody.test.ts covers auth-failure / 400 /
  success / malformed-JSON tolerance (4 cases)
- Handler tests now mock validateCreateSessionBody (single mock
  surface instead of three)
- resolveSessionTitle tests mock selectSessions

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

* fix(sessions): address automated review feedback

Four small fixes from the latest round:

1. Zod v4 migration: { message } → { error } on the sandboxType
   literal. v4 unified the error customization API; { message } is
   deprecated.

2. Orphan rollback observability: when insertChat fails AND the
   session-rollback delete also fails, log the session id so ops
   can detect orphaned rows. New persistence test asserts the log.

3. Defensive try/catch in selectSessions so a thrown exception
   (network-level rejection, not a Supabase {error} return) doesn't
   bubble up and 500 the entire session-creation flow.

4. Deterministic test for getRandomCityName suffix-increment: pin
   Math.random instead of looping until the random pick lands on
   baseCity. Previous test could pass without ever asserting if the
   loop cap was hit.

Skipped: cubic-dev-ai's note about logging raw sessionId in
selectSession.ts — that file was deleted earlier in this PR.

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

* chore: prettier format fix on persistence test

The new orphan-session test had a line that exceeded prettier's wrap
width. Auto-format fixed it; format-check now 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>

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (#514)

Rebased onto current main (which now has the POST endpoint + shared
infra from PR #515). Three pieces of GET-specific work:

- app/api/sessions/[sessionId]/route.ts: thin shell delegating to the
  handler, plus OPTIONS for CORS preflight + cache directives
- lib/sessions/getSessionByIdHandler.ts: validates auth via
  validateAuthContext, reads via selectSessions({id}), enforces
  ownership (403 if account_id mismatch), 404 if missing
- app/api/sessions/[sessionId]/__tests__/route.test.ts: 5 cases —
  401 / 404 / 403 / 200 happy path / OPTIONS smoke

Uses the new general selectSessions({id}) reader rather than the
deleted single-purpose selectSession helper. All other shared infra
(types, toSessionResponse) is already on main from #515.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 5, 2026
* refactor(sandbox): callers use open-agents abstraction (Phase 2.2) (#509)

* refactor(sandbox): callers use open-agents abstraction (Phase 2.2)

Replaces direct @vercel/sandbox SDK calls with the open-agents sandbox
abstraction layer (inlined in Phase 2.1) for sandbox lifecycle (create
+ reconnect). HTTP response shapes preserved exactly.

Per the agreed Option B (hybrid): only the lifecycle creator helpers
get refactored. installClaudeCode / runClaudeCode / getSandboxStatus
stay on the SDK directly because the abstraction does not cover their
needs (sudo, stdout/stderr streaming, simple status reads). Those
two install/run files are also dead orphans (defined but never called)
and will be removed entirely after the full migration.

Production refactor:
  createSandbox.ts            Sandbox.create(...) -> VercelSandbox.create(...)
                              Input: VercelSandboxConfig (was SDK params)
                              Snapshot trigger: restoreSnapshotId field
                                (was source: { type: "snapshot", ... })
                              Returns VercelSandbox (was SDK Sandbox)
  createSandboxWithFallback.ts cascade — passes restoreSnapshotId to createSandbox
  createSandboxFromSnapshot.ts type cascade only (Sandbox -> VercelSandbox)
  getActiveSandbox.ts         Sandbox.get({name}) -> VercelSandbox.connect(name, {})
                              Status check: sandbox.status -> sandbox.sdkStatus
  getOrCreateSandbox.ts       no code change — type cascades automatically
  processCreateSandbox.ts     reads sandbox.sdkStatus instead of sandbox.status
                              defensive nullish on createdAt

Abstraction extension:
  vercel/sandbox/VercelSandbox.ts adds two readonly getters following
  the existing host/environmentDetails/expiresAt pattern:
    get sdkStatus(): string  — raw SDK session status (running/pending/
                                stopped/failed/aborted/snapshotting),
                                distinct from the abstraction's normalized
                                status getter
    get createdAt(): Date | undefined  — SDK session.createdAt

  These give api callers what they need to construct the existing
  HTTP response shape without breaking the abstraction's interface.

Tests updated:
  createSandbox.test.ts            mocks VercelSandbox.create instead of
                                    Sandbox.create; mock object uses
                                    sdkStatus instead of status
  createSandboxWithFallback.test.ts asserts restoreSnapshotId pass-through
  getActiveSandbox.test.ts         mocks VercelSandbox.connect; sdkStatus
                                    on mock objects
  processCreateSandbox.test.ts     mockSandbox uses sdkStatus

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2391/2391 pass
  - HTTP response shape unchanged: same fields, same enum values for
    sandboxStatus (sourced from the SDK now via sdkStatus, was directly
    via SDK Sandbox.status before — identical strings either way)

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

* fix: address PR #509 review feedback

Three real issues from CodeRabbit + cubic:

1. createdAt staleness (CodeRabbit minor)
   The new `createdAt` getter on VercelSandbox skipped the
   `refreshStateFromCurrentSession()` step that `sdkStatus` uses, so
   readers right after a reconnect could see stale session metadata.
   Add the refresh.

2. Fabricated createdAt (cubic P2)
   Both createSandbox.ts and processCreateSandbox.ts had a
   `?? new Date().toISOString()` fallback that fabricated creation
   metadata when sandbox.createdAt was missing. The SDK guarantees
   createdAt is populated for any reachable instance, so the fallback
   was both wrong (fabricates data) and unnecessary.

   Tighten the getter to return `Date` (not `Date | undefined`) and
   throw with an explicit "SDK contract violation" message if the
   field is missing — fail-fast surfaces a real contract bug instead
   of silently lying.

   Drop the `?? new Date()` fallbacks at both call sites.

3. Misleading snapshot-restore branching (CodeRabbit major)
   createSandbox.ts had two paths — a "snapshot" branch that omitted
   DEFAULT_VCPUS/DEFAULT_RUNTIME (intent: let snapshot dictate), and
   a "fresh" branch that applied defaults. But VercelSandbox.create
   internally defaults vcpus=4 and runtime="node22" regardless, so
   the omission was a no-op — the abstraction always forwarded those
   to the SDK.

   Drop the misleading branching. Document the actual behavior at
   the top of createSandbox: "VercelSandbox.create applies its own
   defaults regardless of source — those apply to the runtime
   resources of the new sandbox even when restoring from a snapshot."

   Updated the snapshot-restore test to assert the actual call shape
   (vcpus + runtime + timeout + restoreSnapshotId) instead of just
   the original SDK-style truncated args.

Verification:
- pnpm lint:check: clean
- pnpm test: 2391/2391 pass

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

---------

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

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3) (#512)

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3)

installClaudeCode and runClaudeCode were defined but never imported
anywhere in api production code — confirmed by grep on main:

  $ grep -rn "installClaudeCode\b\|runClaudeCode\b" lib/ app/
  lib/sandbox/installClaudeCode.ts:9: export async function installClaudeCode(...)
  lib/sandbox/runClaudeCode.ts:10:    export async function runClaudeCode(...)

Both files were skipped during the Phase 2.2 abstraction refactor
(per the agreed Option B — they used SDK features the abstraction
doesn't expose: sudo, stdout/stderr streaming, batched writes). With
the broader migration moving to Vercel Workflow + open-agents' agent
package for sandbox bootstrap, these orphans have no path to being
called again.

Removed:
  lib/sandbox/installClaudeCode.ts                (32 lines)
  lib/sandbox/runClaudeCode.ts                    (29 lines)
  lib/sandbox/__tests__/installClaudeCode.test.ts (4 tests)
  lib/sandbox/__tests__/runClaudeCode.test.ts     (6 tests)

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2381/2381 pass (was 2391 — net -10 tests from the
    two deleted test files)

Note: getOrCreateSandbox.ts also has zero importers per the audit
and is similarly dead, but is intentionally NOT deleted in this PR
since it was not explicitly flagged as orphan in the migration plan.
Worth a separate follow-up decision.

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

* chore(sandbox): also delete getOrCreateSandbox + getActiveSandbox (YAGNI)

Cascade audit found two more truly-dead helpers per YAGNI:

  getOrCreateSandbox.ts    0 importers (self-only references)
  getActiveSandbox.ts      only called by getOrCreateSandbox — orphan
                            once that goes

Removed:
  lib/sandbox/getOrCreateSandbox.ts                (39 lines)
  lib/sandbox/getActiveSandbox.ts                  (33 lines)
  lib/sandbox/__tests__/getOrCreateSandbox.test.ts (3 tests)
  lib/sandbox/__tests__/getActiveSandbox.test.ts   (4 tests)

Live consumers of related helpers preserved:
  - createSandboxFromSnapshot still used by processCreateSandbox
  - selectAccountSandboxes still used by aggregateAccountSandboxStats,
    buildGetSandboxesParams, getSandboxesHandler, validateGetSandboxesRequest

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2374/2374 pass (was 2381 — net -7 from the two deleted
    test files; -3 from getOrCreateSandbox.test.ts + -4 from
    getActiveSandbox.test.ts)

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

---------

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

* feat(sessions): port POST /api/sessions from open-agents (#515)

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (Phase 2.4 — first route)

First route in the route-by-route cutover plan. Strategy: open-agents
frontend stays unchanged in shape; api ports each route it calls in
priority order (simplest first), and the open-agents frontend gets
cut over to api one route at a time.

Why this route first:
- Pure DB read (single-row select by id) — no agent runner, no Vercel
  Workflow, no sandbox runtime
- Hits sessions table already migrated in database PR #20
- Frontend usage: agents-frontend hits /api/sessions/{id} on session
  detail page navigation
- Smallest possible blast radius for proving the cutover pattern

Files added:
  lib/supabase/sessions/selectSession.ts  Single-row helper + SessionRow
                                          type (hand-typed; database.types.ts
                                          regen pending — flagged in code
                                          comment)
  app/api/sessions/[sessionId]/route.ts   GET handler matching open-agents
                                          response shape exactly (camelCase
                                          fields, "userId" preserved on the
                                          wire even though stored as
                                          account_id internally)
  app/api/sessions/[sessionId]/__tests__/route.test.ts (5 tests)

Auth: validateAuthContext (Privy Bearer or x-api-key). Response codes
match open-agents: 200 happy path, 401 no auth, 403 not owner, 404 not
found.

Wire-format translation: snake_case Supabase row -> camelCase response,
with account_id surfaced as userId so the existing open-agents frontend
fetches with zero code changes. Translation lives at the route boundary
(toSessionResponse) where it is easy to remove once chat absorbs this
UI and we can switch to schema-natural naming.

Verification:
- pnpm lint:check: clean
- pnpm test: 2379/2379 pass (5 new for this route)

Up next:
- Cutover step (separate PR in open-agents): point the frontend at
  api's URL for this single route. Validate end-to-end before porting
  the next route.
- Next routes in priority order (still pure DB, no agent/workflow):
  GET /api/sessions (list with unread — needs Postgres RPC for the
  multi-table aggregation), GET /api/sessions/[id]/chats, GET
  /api/sessions/[id]/chats/[chatId].

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

* fix: address PR review — SRP splits + use Tables<\"sessions\"> from regen'd types

Three review comments on PR #514:

1. SRP: extract toSessionResponse to its own file
   was: defined inline in app/api/sessions/[sessionId]/route.ts
   now: lib/sessions/toSessionResponse.ts (one exported fn per file)

2. SRP: add a handler function (mirroring api convention)
   was: GET handler logic inline in route.ts
   now: lib/sessions/getSessionByIdHandler.ts contains all the auth +
        ownership + DB lookup + response logic; route.ts is a thin
        shell that awaits options.params and delegates. Matches the
        pattern used by every other api route (e.g. socials/[id]/scrape,
        artists/[id]/...).

3. DRY: use existing db schema type
   was: hand-typed SessionRow interface in selectSession.ts
   now: Tables<\"sessions\"> from types/database.types.ts (regenerated
        via npx supabase gen types typescript --project-id ...
        --schema public)

The types regen also resolved the preview-build failure
(\"Type instantiation is excessively deep and possibly infinite\") on
the .from(\"sessions\") call — Supabase's type inference was choking
because the table was unknown to the generic.

Files added:
  lib/sessions/toSessionResponse.ts
  lib/sessions/getSessionByIdHandler.ts

Files modified:
  app/api/sessions/[sessionId]/route.ts        thin shell now
  app/api/sessions/[sessionId]/__tests__/
    route.test.ts                              type alias updated
  lib/supabase/sessions/selectSession.ts       Tables<\"sessions\">
  types/database.types.ts                      Supabase regen

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2379/2379 pass (no test changes; same 5 route tests)
  - tsc compile clean (the local pnpm build progresses past compile
    into page-data collection where it fails on missing local env
    vars — Vercel preview will have those set, so the preview rebuild
    should now succeed)

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

* fix(sessions): make 404/403 errors emit status:"error" for shape consistency

The 401 returned by validateAuthContext shaped like
{status:"error", error:"..."} but 404/403 from this handler returned
{error:"..."} only. Same endpoint, two error shapes — inconsistent for
clients. Align all error responses on the validateAuthContext shape.

Tests now assert the full error body, not just the status code.

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

* feat(sessions): port POST /api/sessions from open-agents

Implements the POST /api/sessions contract documented in
recoupable/docs PR #186 + #187. Creates a session row and an
initial chat row; rolls back the session if chat insert fails so
callers never observe an orphaned session.

Auth: validateAuthContext (Privy Bearer or x-api-key).
Validation: Zod schema + GitHub repo segment regex. Body is
optional — empty body creates a session with sensible defaults
(status=running, lifecycle_state=provisioning, sandbox_state.type=
vercel, title="New session").

Out of scope (will follow once database catches up):
  auto_commit_push_override, auto_create_pr_override, pr_number,
  pr_status — these columns don't yet exist on api's sessions
  table, so the docs spec was trimmed accordingly in docs PR #187.

TDD: 9 handler tests cover 401, 400 (sandboxType / repoOwner /
repoName), 200 happy path, branch generation, title pass-through,
500 (insertSession failure), and 500-with-rollback (insertChat
failure). Plus 1 thin test on the route shell.

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

* feat(sessions): add OPTIONS handler + cache directives to POST route

Match the convention from app/api/sessions/[sessionId]/route.ts:
- OPTIONS handler returning 200 + CORS headers (preflight)
- dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0

POST routes that mutate DB shouldn't be cached, and browsers issuing
preflight checks (POST with JSON body + custom auth headers) need
OPTIONS to respond.

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

* fix(sessions): address PR review feedback

- SRP: extract insert-row construction to lib/sessions/buildSessionInsertRow.ts
- YAGNI: drop generateSessionBranchName + isNewBranch handling (sessions
  commit to whatever branch the client provides; auto-generation was
  speculative)
- Tighten isValidGitHubRepoOwner: GitHub's actual rules are alphanumeric
  + hyphen only (no `_` or `.`), 1-39 chars, no leading/trailing or
  consecutive hyphens
- Tighten isValidGitHubRepoName: reject reserved `.` and `..`, reject
  `.git` suffix, cap at 100 chars
- Add unit tests for both validators (15 cases) and for the new
  buildSessionInsertRow (4 cases)
- Split createSessionHandler tests into auth/validation + persistence
  files; share fixtures via createSessionHandlerFixtures.ts. All test
  files now under 100 lines.

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

* fix(sessions): address second round of PR review

- 500 message: "Failed to create session" → "Internal server error"
  (per cubic.dev standardized 500 envelope feedback)
- SRP: extract failedToCreateSession to lib/sessions/failedToCreateSession.ts
- YAGNI: drop repoOwner from request body and remove
  isValidGitHubRepoOwner helper entirely (recoupable is the only
  owner; no need to validate)
- YAGNI: drop repoName from request body and remove
  isValidGitHubRepoName helper (repo identity is derived server-side
  from the authenticated account, not accepted from user input)
- Single-export per file: split createSessionHandlerFixtures.ts into
  makeCreateSessionReq.ts, baseSessionRow.ts, baseChatRow.ts.
  okAuth constant inlined where used.

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

* feat(sessions): port random-city title fallback from open-agents

Generated session titles now match the open-agents UX — names like
"Anchorage", "Vienna", "Philadelphia" — instead of every untitled
session being called "New session". Closes a wire-shape gap with
open-agents production identified by the head-to-head test on PR.

Pieces:
- lib/sessions/cityNames.ts: ~200-city curated list (verbatim port)
- lib/sessions/getRandomCityName.ts: pick a city not in `usedNames`,
  numeric-suffix fallback when the curated list is exhausted
- lib/supabase/sessions/selectSessionTitlesByAccountId.ts: Supabase
  helper for collision avoidance
- lib/sessions/resolveSessionTitle.ts: orchestrates provided title
  (trimmed) > random city fallback. Async. Kept separate from the
  insert-row builder so that stays synchronous + pure.
- buildSessionInsertRow now takes `title` as a parameter
- createSessionHandler awaits resolveSessionTitle before building the
  row

TDD: 4 tests for getRandomCityName, 4 for resolveSessionTitle. Handler
tests updated to mock resolveSessionTitle.

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

* chore: remove GET-only files (scope this PR to POST)

The GET endpoint + handler + tests live in PR #514 and were
inadvertently brought in when this branch was rebased after #514's
work. This PR is scoped to POST only; GET ships in #514.

Shared infrastructure stays (types/database.types.ts regen +
lib/sessions/toSessionResponse.ts) — both are required by the POST
handler too. When either #514 or this PR merges to test first, the
other will see those files already present and resolve cleanly.

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

* fix(sessions): consolidate request validation + DRY supabase select

Two reviewer asks rolled into one commit:

SRP — validateCreateSessionBody now owns the full validation flow.
The handler used to call safeParseJson, validateAuthContext, and the
Zod body schema separately; that was three places to short-circuit
and three places to duplicate the error envelope. Folded them into
validateCreateSessionBody so the handler does one call → success or
NextResponse error. Returns { body, auth } on success.

DRY — replaced lib/supabase/sessions/selectSession.ts and
selectSessionTitlesByAccountId.ts with a single
selectSessions({ id?, accountId? }) that supports both call sites.
resolveSessionTitle now derives titles from the general fetch.

Tests:
- New validateCreateSessionBody.test.ts covers auth-failure / 400 /
  success / malformed-JSON tolerance (4 cases)
- Handler tests now mock validateCreateSessionBody (single mock
  surface instead of three)
- resolveSessionTitle tests mock selectSessions

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

* fix(sessions): address automated review feedback

Four small fixes from the latest round:

1. Zod v4 migration: { message } → { error } on the sandboxType
   literal. v4 unified the error customization API; { message } is
   deprecated.

2. Orphan rollback observability: when insertChat fails AND the
   session-rollback delete also fails, log the session id so ops
   can detect orphaned rows. New persistence test asserts the log.

3. Defensive try/catch in selectSessions so a thrown exception
   (network-level rejection, not a Supabase {error} return) doesn't
   bubble up and 500 the entire session-creation flow.

4. Deterministic test for getRandomCityName suffix-increment: pin
   Math.random instead of looping until the random pick lands on
   baseCity. Previous test could pass without ever asserting if the
   loop cap was hit.

Skipped: cubic-dev-ai's note about logging raw sessionId in
selectSession.ts — that file was deleted earlier in this PR.

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

* chore: prettier format fix on persistence test

The new orphan-session test had a line that exceeded prettier's wrap
width. Auto-format fixed it; format-check now 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>

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (#514)

Rebased onto current main (which now has the POST endpoint + shared
infra from PR #515). Three pieces of GET-specific work:

- app/api/sessions/[sessionId]/route.ts: thin shell delegating to the
  handler, plus OPTIONS for CORS preflight + cache directives
- lib/sessions/getSessionByIdHandler.ts: validates auth via
  validateAuthContext, reads via selectSessions({id}), enforces
  ownership (403 if account_id mismatch), 404 if missing
- app/api/sessions/[sessionId]/__tests__/route.test.ts: 5 cases —
  401 / 404 / 403 / 200 happy path / OPTIONS smoke

Uses the new general selectSessions({id}) reader rather than the
deleted single-purpose selectSession helper. All other shared infra
(types, toSessionResponse) is already on main from #515.

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

* feat(ai/models): enrich response with context_window + cost (#518)

* feat(ai/models): enrich response with context_window + cost from models.dev

api's GET /api/ai/models previously returned just the gateway entries.
Open-agents' frontend depends on two extra fields per model that come
from the public models.dev catalog:

  - context_window (integer) — gates model selection in the picker
  - cost ({input, output}) — per-million-token pricing for display

Adds three pure helpers (TDD'd individually) plus a small refactor of
the existing fetcher to merge metadata in:

  - lib/ai/parseModelsDevMetadata.ts: tolerant unknown→Map parser
  - lib/ai/fetchModelsDevMetadata.ts: 750ms-bounded fetch with full
    error swallowing (metadata is best-effort, must never gate the
    underlying gateway response)
  - lib/ai/enrichGatewayModel.ts: pure, non-mutating merge

getAvailableModels now fetches gateway + metadata in parallel and
maps each non-embed model through enrichGatewayModel. If models.dev
is unreachable the response is identical to today (gateway models
unenriched).

Documented in recoupable/docs#188 (merged). Unblocks the eventual
open-agents frontend cutover for the model picker.

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

* fix(ai): extract isRecord into its own lib (SRP)

Per PR feedback: each file should export one primary function.
Pulled isRecord out of parseModelsDevMetadata.ts into
lib/ai/isRecord.ts so the parser file is single-purpose.

Also includes the typecheck fix for enrichGatewayModel — the
`[key: string]: unknown` index signature on its generic constraint
was rejecting `GatewayLanguageModelEntry` and breaking the Vercel
build.

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

---------

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 7, 2026
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>
sweetmantech added a commit that referenced this pull request May 7, 2026
…open-agents (#522)

* 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

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

* 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

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

* 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)

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

* 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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