From a7ea90a14ef89247aefc400f64ab4136457c5c52 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 02:05:58 +0700 Subject: [PATCH 1/6] feat(api): add PATCH handler for updating session details - Implemented a new PATCH endpoint for updating a session's title or status, allowing optional fields to be modified. - Authenticates requests using a Privy Bearer token or x-api-key header. - Added comprehensive tests for the PATCH handler, covering scenarios such as unauthorized access, session not found, and successful updates. This enhancement improves the API's functionality by enabling users to modify session details dynamically. --- .../[sessionId]/__tests__/route.test.ts | 115 +++++++++++++++++- app/api/sessions/[sessionId]/route.ts | 21 ++++ lib/sessions/patchSessionByIdHandler.ts | 65 ++++++++++ lib/sessions/validatePatchSessionBody.ts | 58 +++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 lib/sessions/patchSessionByIdHandler.ts create mode 100644 lib/sessions/validatePatchSessionBody.ts diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 685a4a205..997737c3b 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import { GET, OPTIONS } from "../route"; +import { GET, PATCH, OPTIONS } from "../route"; import type { Tables } from "@/types/database.types"; type SessionRow = Tables<"sessions">; @@ -9,6 +9,10 @@ vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ selectSessions: vi.fn(), })); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ + updateSession: vi.fn(), +})); + vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); @@ -18,12 +22,24 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({ })); const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { updateSession } = await import("@/lib/supabase/sessions/updateSession"); const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); function makeReq(url = "https://example.com/api/sessions/sess_1"): NextRequest { return new NextRequest(url); } +function makePatchReq( + body: Record, + url = "https://example.com/api/sessions/sess_1", +): NextRequest { + return new NextRequest(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + const mockRow: SessionRow = { id: "sess_1", account_id: "acc-uuid-1", @@ -161,3 +177,100 @@ describe("GET /api/sessions/[sessionId]", () => { }); }); }); + +describe("PATCH /api/sessions/[sessionId]", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }), + ); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(401); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when session does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_missing" }), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + }); + + it("returns 403 when session is owned by a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-OTHER", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ + status: "error", + error: "Forbidden", + }); + }); + + it("returns 400 when status value is invalid", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + + const res = await PATCH(makePatchReq({ status: "completed" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + }); + + it("returns 200 with updated session on happy path", async () => { + const updatedRow: SessionRow = { + ...mockRow, + title: "Renamed session", + status: "archived", + }; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + vi.mocked(updateSession).mockResolvedValue(updatedRow); + + const res = await PATCH( + makePatchReq({ title: "Renamed session", status: "archived" }), + { params: Promise.resolve({ sessionId: "sess_1" }) }, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.session.title).toBe("Renamed session"); + expect(body.session.status).toBe("archived"); + expect(updateSession).toHaveBeenCalledWith("sess_1", { + title: "Renamed session", + status: "archived", + }); + }); +}); diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index dbc04994a..8560780aa 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { getSessionByIdHandler } from "@/lib/sessions/getSessionByIdHandler"; +import { patchSessionByIdHandler } from "@/lib/sessions/patchSessionByIdHandler"; /** * OPTIONS handler for CORS preflight requests. @@ -37,6 +38,26 @@ export async function GET( return getSessionByIdHandler(request, sessionId); } +/** + * PATCH /api/sessions/{sessionId} + * + * Updates a session's title (rename) or status (archive/unarchive). + * All fields are optional; omitted fields are left unchanged. + * Authenticates via Privy Bearer token or x-api-key header. + * + * @param request - The request object + * @param options - Route options containing the async params + * @param options.params - Route params containing the session id + * @returns A NextResponse with `{ session }` on 200, or an error. + */ +export async function PATCH( + request: NextRequest, + options: { params: Promise<{ sessionId: string }> }, +) { + const { sessionId } = await options.params; + return patchSessionByIdHandler(request, sessionId); +} + export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; export const revalidate = 0; diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts new file mode 100644 index 000000000..7837e4c94 --- /dev/null +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; + +/** + * Handles PATCH /api/sessions/{sessionId}. + * + * Updates a session's `title` (rename) or `status` (archive/unarchive). + * All fields are optional; omitted fields are left unchanged. + * Authenticates via Privy Bearer token or x-api-key header. + * Returns 404 if the session does not exist and 403 if it exists but + * is not owned by the authenticated account. + * + * @param request - The incoming request. + * @param sessionId - The id of the session to update. + * @returns A NextResponse with `{ session }` on 200, or an error. + */ +export async function patchSessionByIdHandler( + request: NextRequest, + sessionId: string, +): Promise { + const validated = await validatePatchSessionBody(request); + if (validated instanceof NextResponse) { + return validated; + } + + const { body, auth } = validated; + + const rows = await selectSessions({ id: sessionId }); + const row = rows[0] ?? null; + + if (!row) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (row.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const updated = await updateSession(sessionId, { + ...(body.title !== undefined && { title: body.title }), + ...(body.status !== undefined && { status: body.status }), + }); + + if (!updated) { + return NextResponse.json( + { status: "error", error: "Failed to update session" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { session: toSessionResponse(updated) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts new file mode 100644 index 000000000..d2200a123 --- /dev/null +++ b/lib/sessions/validatePatchSessionBody.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; + +export const patchSessionBodySchema = z.object({ + title: z.string().optional(), + status: z.enum(["running", "archived"]).optional(), +}); + +export type PatchSessionBody = z.infer; + +export interface ValidatedPatchSessionRequest { + body: PatchSessionBody; + auth: AuthContext; +} + +/** + * Validates a `PATCH /api/sessions/{sessionId}` request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Parses the JSON body (treating malformed JSON as an empty body) + * 3. Validates the body against the Zod schema + * + * Returns either a 4xx NextResponse describing the first failure, or + * the validated `{ body, auth }` ready for the handler to consume. + * + * @param request - The incoming request. + * @returns A NextResponse on validation failure, or the validated body + auth. + */ +export async function validatePatchSessionBody( + request: NextRequest, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rawBody = await safeParseJson(request); + const result = patchSessionBodySchema.safeParse(rawBody); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return { body: result.data, auth }; +} From 3670385b9103266aef6bd128cfaaf14a2be1a8e5 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 02:23:31 +0700 Subject: [PATCH 2/6] feat(tests): add error handling tests for session API endpoints - Implemented tests for the GET and PATCH session API endpoints to handle scenarios where the database returns an error, ensuring a 500 status response with appropriate error messages. - Updated the `selectSessions` function to return `null` on database errors, allowing for better error handling in the API responses. - Enhanced existing tests to verify that the API correctly responds to internal server errors, improving overall robustness and reliability of the session management functionality. --- .../[sessionId]/__tests__/route.test.ts | 43 +++++++++++++++++-- app/workflows/clearLifecycleRunIdIfOwned.ts | 2 +- app/workflows/computeLifecycleWakeDecision.ts | 2 +- lib/sandbox/createSandboxHandler.ts | 2 +- lib/sandbox/evaluateSandboxLifecycle.ts | 4 +- lib/sandbox/getSandboxStatusHandler.ts | 2 +- lib/sandbox/reclaimStaleLease.ts | 2 +- lib/sandbox/runKick.ts | 2 +- .../validateSandboxReconnectRequest.ts | 2 +- lib/sandbox/wasLifecycleTimingExtended.ts | 2 +- lib/sessions/getSessionByIdHandler.ts | 8 ++++ lib/sessions/patchSessionByIdHandler.ts | 10 ++++- lib/sessions/resolveSessionTitle.ts | 2 +- lib/supabase/sessions/selectSessions.ts | 16 ++++--- 14 files changed, 77 insertions(+), 22 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 997737c3b..8b696881f 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -94,6 +94,24 @@ describe("GET /api/sessions/[sessionId]", () => { expect(selectSessions).not.toHaveBeenCalled(); }); + it("returns 500 when the database returns an error", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue(null); + + const res = await GET(makeReq(), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + status: "error", + error: "Internal server error", + }); + }); + it("returns 404 when session does not exist", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc-uuid-1", @@ -195,6 +213,24 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(selectSessions).not.toHaveBeenCalled(); }); + it("returns 500 when the database returns an error", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue(null); + + const res = await PATCH(makePatchReq({ title: "New title" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + status: "error", + error: "Internal server error", + }); + }); + it("returns 404 when session does not exist", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc-uuid-1", @@ -260,10 +296,9 @@ describe("PATCH /api/sessions/[sessionId]", () => { vi.mocked(selectSessions).mockResolvedValue([mockRow]); vi.mocked(updateSession).mockResolvedValue(updatedRow); - const res = await PATCH( - makePatchReq({ title: "Renamed session", status: "archived" }), - { params: Promise.resolve({ sessionId: "sess_1" }) }, - ); + const res = await PATCH(makePatchReq({ title: "Renamed session", status: "archived" }), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); expect(res.status).toBe(200); const body = await res.json(); expect(body.session.title).toBe("Renamed session"); diff --git a/app/workflows/clearLifecycleRunIdIfOwned.ts b/app/workflows/clearLifecycleRunIdIfOwned.ts index df6f55228..c862a134b 100644 --- a/app/workflows/clearLifecycleRunIdIfOwned.ts +++ b/app/workflows/clearLifecycleRunIdIfOwned.ts @@ -14,7 +14,7 @@ import { updateSession } from "@/lib/supabase/sessions/updateSession"; export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: string): Promise { "use step"; - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const session = rows[0]; if (!session || session.lifecycle_run_id !== runId) return; diff --git a/app/workflows/computeLifecycleWakeDecision.ts b/app/workflows/computeLifecycleWakeDecision.ts index 9b084a770..ea10fc53c 100644 --- a/app/workflows/computeLifecycleWakeDecision.ts +++ b/app/workflows/computeLifecycleWakeDecision.ts @@ -26,7 +26,7 @@ export async function computeLifecycleWakeDecision( ): Promise { "use step"; - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const session = rows[0]; if (!session) return { shouldContinue: false, reason: "session-not-found" }; if (session.status === "archived" || session.lifecycle_state === "archived") { diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index 6d391000c..a4f5237b5 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -44,7 +44,7 @@ export async function createSandboxHandler(request: NextRequest): Promise | null = null; if (sessionId) { - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; sessionRow = rows[0] ?? null; if (!sessionRow) { diff --git a/lib/sandbox/evaluateSandboxLifecycle.ts b/lib/sandbox/evaluateSandboxLifecycle.ts index cc8e9cb5b..0c182901b 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -35,7 +35,7 @@ export async function evaluateSandboxLifecycle( sessionId: string, reason: SandboxLifecycleReason, ): Promise { - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const session = rows[0]; if (!session) return { action: "skipped", reason: "session-not-found" }; @@ -70,7 +70,7 @@ export async function evaluateSandboxLifecycle( } if (await wasLifecycleTimingExtended(sessionId, session)) { - const refreshed = (await selectSessions({ id: sessionId }))[0]; + const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0]; if (refreshed?.sandbox_state) { await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state); } diff --git a/lib/sandbox/getSandboxStatusHandler.ts b/lib/sandbox/getSandboxStatusHandler.ts index 7423cdf9f..3aff48954 100644 --- a/lib/sandbox/getSandboxStatusHandler.ts +++ b/lib/sandbox/getSandboxStatusHandler.ts @@ -38,7 +38,7 @@ export async function getSandboxStatusHandler(request: NextRequest): Promise | null> { await updateSession(sessionId, { lifecycle_run_id: null }); - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; return rows[0] ?? null; } diff --git a/lib/sandbox/runKick.ts b/lib/sandbox/runKick.ts index f5e1fa918..512223468 100644 --- a/lib/sandbox/runKick.ts +++ b/lib/sandbox/runKick.ts @@ -30,7 +30,7 @@ interface RunKickInput { * design. */ export async function runKick(input: RunKickInput): Promise { - const rows = await selectSessions({ id: input.sessionId }); + const rows = (await selectSessions({ id: input.sessionId })) ?? []; const session = rows[0]; if (!session) return; diff --git a/lib/sandbox/validateSandboxReconnectRequest.ts b/lib/sandbox/validateSandboxReconnectRequest.ts index 3d8d3cb79..e648c9a76 100644 --- a/lib/sandbox/validateSandboxReconnectRequest.ts +++ b/lib/sandbox/validateSandboxReconnectRequest.ts @@ -39,7 +39,7 @@ export async function validateSandboxReconnectRequest( ); } - const rows = await selectSessions({ id: sessionId }); + const rows = (await selectSessions({ id: sessionId })) ?? []; const row = rows[0]; if (!row) { diff --git a/lib/sandbox/wasLifecycleTimingExtended.ts b/lib/sandbox/wasLifecycleTimingExtended.ts index c3980a1df..d938d35a3 100644 --- a/lib/sandbox/wasLifecycleTimingExtended.ts +++ b/lib/sandbox/wasLifecycleTimingExtended.ts @@ -18,7 +18,7 @@ export async function wasLifecycleTimingExtended( sessionId: string, prior: Tables<"sessions">, ): Promise { - const refreshed = (await selectSessions({ id: sessionId }))[0]; + const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0]; if (!refreshed?.sandbox_state || !hasRuntimeSandboxState(refreshed.sandbox_state)) return false; const timingChanged = diff --git a/lib/sessions/getSessionByIdHandler.ts b/lib/sessions/getSessionByIdHandler.ts index 11a22f5a7..6fe515739 100644 --- a/lib/sessions/getSessionByIdHandler.ts +++ b/lib/sessions/getSessionByIdHandler.ts @@ -28,6 +28,14 @@ export async function getSessionByIdHandler( } const rows = await selectSessions({ id: sessionId }); + + if (rows === null) { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + const row = rows[0] ?? null; if (!row) { diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index 7837e4c94..fd73e4402 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -30,6 +30,14 @@ export async function patchSessionByIdHandler( const { body, auth } = validated; const rows = await selectSessions({ id: sessionId }); + + if (rows === null) { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + const row = rows[0] ?? null; if (!row) { @@ -53,7 +61,7 @@ export async function patchSessionByIdHandler( if (!updated) { return NextResponse.json( - { status: "error", error: "Failed to update session" }, + { status: "error", error: "Internal server error" }, { status: 500, headers: getCorsHeaders() }, ); } diff --git a/lib/sessions/resolveSessionTitle.ts b/lib/sessions/resolveSessionTitle.ts index 1e2270b40..4421c608e 100644 --- a/lib/sessions/resolveSessionTitle.ts +++ b/lib/sessions/resolveSessionTitle.ts @@ -24,7 +24,7 @@ export async function resolveSessionTitle(input: ResolveSessionTitleInput): Prom return trimmed; } - const rows = await selectSessions({ accountId: input.accountId }); + const rows = (await selectSessions({ accountId: input.accountId })) ?? []; const usedTitles = rows.map(row => row.title); return getRandomCityName(new Set(usedTitles)); } diff --git a/lib/supabase/sessions/selectSessions.ts b/lib/supabase/sessions/selectSessions.ts index 69477b9cb..228e39b78 100644 --- a/lib/supabase/sessions/selectSessions.ts +++ b/lib/supabase/sessions/selectSessions.ts @@ -10,19 +10,23 @@ interface SelectSessionsFilter { /** * General-purpose `sessions` reader. Pass any combination of filters - * to narrow the result set; an unset filter is ignored. Returns an - * empty array on Supabase error after logging. + * to narrow the result set; an unset filter is ignored. + * + * Returns `null` on Supabase error (DB unreachable / query failure) so + * callers can distinguish a transient backend failure from a legitimately + * empty result set. Returns `[]` when the query succeeds but matches no + * rows. * * Callers project to whatever shape they need (single row by id, * titles by account, etc.) — keeping this single function as the * sole entry point keeps `lib/supabase/sessions/` DRY. * * @param filter - Optional filters narrowing the query. - * @returns Matching rows, or `[]` on error / no match. + * @returns Matching rows, `[]` on no match, or `null` on DB error. */ export async function selectSessions( filter: SelectSessionsFilter = {}, -): Promise[]> { +): Promise[] | null> { let query = supabase.from("sessions").select("*"); if (filter.id) query = query.eq("id", filter.id); if (filter.accountId) query = query.eq("account_id", filter.accountId); @@ -31,11 +35,11 @@ export async function selectSessions( const { data, error } = await query; if (error) { console.error("[selectSessions] error:", error); - return []; + return null; } return data ?? []; } catch (e) { console.error("[selectSessions] threw:", e); - return []; + return null; } } From a1975b122327e2386765936fcf3f923a451753fe Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 03:04:36 +0700 Subject: [PATCH 3/6] fix(workflows): improve error handling for session queries - Updated the `selectSessions` function calls in `clearLifecycleRunIdIfOwned`, `computeLifecycleWakeDecision`, and `evaluateSandboxLifecycle` to handle potential database errors more gracefully. - Added console error logging in `clearLifecycleRunIdIfOwned` for better debugging. - Enhanced return values in `computeLifecycleWakeDecision` and `evaluateSandboxLifecycle` to provide clearer failure reasons when session queries fail. These changes enhance the robustness of session management by ensuring that errors are properly logged and handled. --- app/workflows/clearLifecycleRunIdIfOwned.ts | 6 +++++- app/workflows/computeLifecycleWakeDecision.ts | 3 ++- lib/sandbox/evaluateSandboxLifecycle.ts | 7 +++++-- lib/sessions/validatePatchSessionBody.ts | 2 +- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/workflows/clearLifecycleRunIdIfOwned.ts b/app/workflows/clearLifecycleRunIdIfOwned.ts index c862a134b..14677ee3f 100644 --- a/app/workflows/clearLifecycleRunIdIfOwned.ts +++ b/app/workflows/clearLifecycleRunIdIfOwned.ts @@ -14,7 +14,11 @@ import { updateSession } from "@/lib/supabase/sessions/updateSession"; export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: string): Promise { "use step"; - const rows = (await selectSessions({ id: sessionId })) ?? []; + const rows = await selectSessions({ id: sessionId }); + if (!rows) { + console.error("[clearLifecycleRunIdIfOwned] DB error fetching session", sessionId); + return; + } const session = rows[0]; if (!session || session.lifecycle_run_id !== runId) return; diff --git a/app/workflows/computeLifecycleWakeDecision.ts b/app/workflows/computeLifecycleWakeDecision.ts index ea10fc53c..6fc177057 100644 --- a/app/workflows/computeLifecycleWakeDecision.ts +++ b/app/workflows/computeLifecycleWakeDecision.ts @@ -26,7 +26,8 @@ export async function computeLifecycleWakeDecision( ): Promise { "use step"; - const rows = (await selectSessions({ id: sessionId })) ?? []; + const rows = await selectSessions({ id: sessionId }); + if (!rows) return { shouldContinue: false, reason: "db-error" }; const session = rows[0]; if (!session) return { shouldContinue: false, reason: "session-not-found" }; if (session.status === "archived" || session.lifecycle_state === "archived") { diff --git a/lib/sandbox/evaluateSandboxLifecycle.ts b/lib/sandbox/evaluateSandboxLifecycle.ts index 0c182901b..3ea4c1ae2 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -35,7 +35,8 @@ export async function evaluateSandboxLifecycle( sessionId: string, reason: SandboxLifecycleReason, ): Promise { - const rows = (await selectSessions({ id: sessionId })) ?? []; + const rows = await selectSessions({ id: sessionId }); + if (!rows) return { action: "failed", reason: "session-query-failed" }; const session = rows[0]; if (!session) return { action: "skipped", reason: "session-not-found" }; @@ -70,7 +71,9 @@ export async function evaluateSandboxLifecycle( } if (await wasLifecycleTimingExtended(sessionId, session)) { - const refreshed = ((await selectSessions({ id: sessionId })) ?? [])[0]; + const refreshedRows = await selectSessions({ id: sessionId }); + if (!refreshedRows) throw new Error("Failed to refresh session during lifecycle extension check"); + const refreshed = refreshedRows[0]; if (refreshed?.sandbox_state) { await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state); } diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts index d2200a123..c6d235cb3 100644 --- a/lib/sessions/validatePatchSessionBody.ts +++ b/lib/sessions/validatePatchSessionBody.ts @@ -44,7 +44,7 @@ export async function validatePatchSessionBody( return NextResponse.json( { status: "error", - missing_fields: firstError.path, + path: firstError.path, error: firstError.message, }, { From e068734fb00196fa38e20009f0c4028e6a8b2ce7 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 11 May 2026 03:06:51 +0700 Subject: [PATCH 4/6] fix(sandbox): improve error handling in evaluateSandboxLifecycle - Enhanced error handling in the `evaluateSandboxLifecycle` function by ensuring that an error is thrown with a clear message when session refresh fails during lifecycle extension checks. This change improves the robustness of session management by providing better feedback in case of failures. --- lib/sandbox/evaluateSandboxLifecycle.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/sandbox/evaluateSandboxLifecycle.ts b/lib/sandbox/evaluateSandboxLifecycle.ts index 3ea4c1ae2..0c34e2bad 100644 --- a/lib/sandbox/evaluateSandboxLifecycle.ts +++ b/lib/sandbox/evaluateSandboxLifecycle.ts @@ -72,7 +72,8 @@ export async function evaluateSandboxLifecycle( if (await wasLifecycleTimingExtended(sessionId, session)) { const refreshedRows = await selectSessions({ id: sessionId }); - if (!refreshedRows) throw new Error("Failed to refresh session during lifecycle extension check"); + if (!refreshedRows) + throw new Error("Failed to refresh session during lifecycle extension check"); const refreshed = refreshedRows[0]; if (refreshed?.sandbox_state) { await restoreActiveLifecycleState(sessionId, refreshed.sandbox_state); From ad443007e518d4d3b93f1bd0d13dc887ce8d2e7d Mon Sep 17 00:00:00 2001 From: john Date: Thu, 14 May 2026 06:41:03 +0700 Subject: [PATCH 5/6] feat(api): enhance PATCH /api/sessions/{sessionId} to support additional status values and line counters - Updated the API documentation to reflect the new optional fields for line counters and expanded status options (running, completed, failed, archived). - Modified the patchSessionByIdHandler to handle updates for linesAdded and linesRemoved, returning a 200 status when no updates are provided. - Added tests to ensure correct handling of new status values and line counter mappings in the session update process. --- .../[sessionId]/__tests__/route.test.ts | 46 ++++++++++++++++++- app/api/sessions/[sessionId]/route.ts | 6 ++- lib/sessions/patchSessionByIdHandler.ts | 21 +++++++-- lib/sessions/validatePatchSessionBody.ts | 4 +- 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 8b696881f..03b5c82a9 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -274,7 +274,7 @@ describe("PATCH /api/sessions/[sessionId]", () => { authToken: "tok", }); - const res = await PATCH(makePatchReq({ status: "completed" }), { + const res = await PATCH(makePatchReq({ status: "not-a-status" }), { params: Promise.resolve({ sessionId: "sess_1" }), }); expect(res.status).toBe(400); @@ -282,6 +282,23 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(body.status).toBe("error"); }); + it("returns 200 without calling updateSession when body has no updates", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + + const res = await PATCH(makePatchReq({}), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.session.id).toBe("sess_1"); + expect(updateSession).not.toHaveBeenCalled(); + }); + it("returns 200 with updated session on happy path", async () => { const updatedRow: SessionRow = { ...mockRow, @@ -308,4 +325,31 @@ describe("PATCH /api/sessions/[sessionId]", () => { status: "archived", }); }); + + it("accepts completed status and maps line counters to snake_case columns", async () => { + const updatedRow: SessionRow = { + ...mockRow, + status: "completed", + lines_added: 99, + lines_removed: 1, + }; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([mockRow]); + vi.mocked(updateSession).mockResolvedValue(updatedRow); + + const res = await PATCH( + makePatchReq({ status: "completed", linesAdded: 99, linesRemoved: 1 }), + { params: Promise.resolve({ sessionId: "sess_1" }) }, + ); + expect(res.status).toBe(200); + expect(updateSession).toHaveBeenCalledWith("sess_1", { + status: "completed", + lines_added: 99, + lines_removed: 1, + }); + }); }); diff --git a/app/api/sessions/[sessionId]/route.ts b/app/api/sessions/[sessionId]/route.ts index 8560780aa..40527e2ea 100644 --- a/app/api/sessions/[sessionId]/route.ts +++ b/app/api/sessions/[sessionId]/route.ts @@ -41,8 +41,10 @@ export async function GET( /** * PATCH /api/sessions/{sessionId} * - * Updates a session's title (rename) or status (archive/unarchive). - * All fields are optional; omitted fields are left unchanged. + * Updates a session's title, lifecycle `status` (including archive / + * unarchive), and optional line counters. `status` matches the merged + * docs: `running`, `completed`, `failed`, or `archived`. All body fields + * are optional; omitted fields are left unchanged. * Authenticates via Privy Bearer token or x-api-key header. * * @param request - The request object diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index fd73e4402..c93913667 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -8,8 +8,10 @@ import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; /** * Handles PATCH /api/sessions/{sessionId}. * - * Updates a session's `title` (rename) or `status` (archive/unarchive). - * All fields are optional; omitted fields are left unchanged. + * Updates a session's `title`, `status` (see DB CHECK / public docs: + * `running`, `completed`, `failed`, `archived`), and optional + * `linesAdded` / `linesRemoved` counters. All fields are optional; + * omitted fields are left unchanged. * Authenticates via Privy Bearer token or x-api-key header. * Returns 404 if the session does not exist and 403 if it exists but * is not owned by the authenticated account. @@ -54,10 +56,21 @@ export async function patchSessionByIdHandler( ); } - const updated = await updateSession(sessionId, { + const updates = { ...(body.title !== undefined && { title: body.title }), ...(body.status !== undefined && { status: body.status }), - }); + ...(body.linesAdded !== undefined && { lines_added: body.linesAdded }), + ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), + }; + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { session: toSessionResponse(row) }, + { status: 200, headers: getCorsHeaders() }, + ); + } + + const updated = await updateSession(sessionId, updates); if (!updated) { return NextResponse.json( diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts index c6d235cb3..2d2858607 100644 --- a/lib/sessions/validatePatchSessionBody.ts +++ b/lib/sessions/validatePatchSessionBody.ts @@ -7,7 +7,9 @@ import type { AuthContext } from "@/lib/auth/validateAuthContext"; export const patchSessionBodySchema = z.object({ title: z.string().optional(), - status: z.enum(["running", "archived"]).optional(), + status: z.enum(["running", "completed", "failed", "archived"]).optional(), + linesAdded: z.number().int().min(0).optional(), + linesRemoved: z.number().int().min(0).optional(), }); export type PatchSessionBody = z.infer; From 7717c574ff41acefaad401009905bd59e73712a6 Mon Sep 17 00:00:00 2001 From: john Date: Thu, 14 May 2026 07:00:16 +0700 Subject: [PATCH 6/6] feat(api): enhance PATCH /api/sessions/{sessionId} to handle malformed JSON body - Added a new utility function to read and validate the JSON body of PATCH requests, returning an empty object for empty bodies and a 400 error for malformed JSON. - Updated the test suite to include a case for handling malformed JSON, ensuring the API responds correctly with a 400 status and an appropriate error message. - Adjusted the validation logic in `validatePatchSessionBody` to utilize the new JSON reading function, improving error handling for invalid input. --- .../[sessionId]/__tests__/route.test.ts | 29 +++++++++++++++++++ lib/sessions/validatePatchSessionBody.ts | 28 ++++++++++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 03b5c82a9..87bbe4bcd 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -40,6 +40,17 @@ function makePatchReq( }); } +function makePatchReqRaw( + body: string, + url = "https://example.com/api/sessions/sess_1", +): NextRequest { + return new NextRequest(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body, + }); +} + const mockRow: SessionRow = { id: "sess_1", account_id: "acc-uuid-1", @@ -282,6 +293,24 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(body.status).toBe("error"); }); + it("returns 400 when JSON body is malformed", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: "acc-uuid-1", + orgId: null, + authToken: "tok", + }); + + const res = await PATCH(makePatchReqRaw("{not-json"), { + params: Promise.resolve({ sessionId: "sess_1" }), + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + status: "error", + error: "Invalid JSON body", + }); + expect(selectSessions).not.toHaveBeenCalled(); + }); + it("returns 200 without calling updateSession when body has no updates", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc-uuid-1", diff --git a/lib/sessions/validatePatchSessionBody.ts b/lib/sessions/validatePatchSessionBody.ts index 2d2858607..1de49ce68 100644 --- a/lib/sessions/validatePatchSessionBody.ts +++ b/lib/sessions/validatePatchSessionBody.ts @@ -1,10 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { AuthContext } from "@/lib/auth/validateAuthContext"; +/** + * Reads the PATCH body: empty/whitespace-only becomes `{}`. + * Non-empty text that is not valid JSON returns `null` (caller should 400). + */ +async function readPatchSessionJsonBody(request: NextRequest): Promise { + const text = await request.text(); + if (text.trim() === "") { + return {}; + } + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } +} + export const patchSessionBodySchema = z.object({ title: z.string().optional(), status: z.enum(["running", "completed", "failed", "archived"]).optional(), @@ -22,7 +37,7 @@ export interface ValidatedPatchSessionRequest { /** * Validates a `PATCH /api/sessions/{sessionId}` request end-to-end: * 1. Authenticates the caller via Privy Bearer / x-api-key - * 2. Parses the JSON body (treating malformed JSON as an empty body) + * 2. Parses the JSON body (empty body → `{}`; non-empty invalid JSON → 400) * 3. Validates the body against the Zod schema * * Returns either a 4xx NextResponse describing the first failure, or @@ -39,7 +54,14 @@ export async function validatePatchSessionBody( return auth; } - const rawBody = await safeParseJson(request); + const rawBody = await readPatchSessionJsonBody(request); + if (rawBody === null) { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + const result = patchSessionBodySchema.safeParse(rawBody); if (!result.success) { const firstError = result.error.issues[0];