Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 222 additions & 1 deletion app/api/sessions/[sessionId]/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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">;
Expand All @@ -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(),
}));
Expand All @@ -18,12 +22,35 @@ 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<string, unknown>,
url = "https://example.com/api/sessions/sess_1",
): NextRequest {
return new NextRequest(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}

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",
Expand Down Expand Up @@ -78,6 +105,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",
Expand Down Expand Up @@ -161,3 +206,179 @@ 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 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",
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: "not-a-status" }), {
params: Promise.resolve({ sessionId: "sess_1" }),
});
expect(res.status).toBe(400);
const body = await res.json();
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",
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,
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",
});
});

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,
});
});
});
23 changes: 23 additions & 0 deletions app/api/sessions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -37,6 +38,28 @@ export async function GET(
return getSessionByIdHandler(request, sessionId);
}

/**
* PATCH /api/sessions/{sessionId}
*
* 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
* @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;
4 changes: 4 additions & 0 deletions app/workflows/clearLifecycleRunIdIfOwned.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export async function clearLifecycleRunIdIfOwned(sessionId: string, runId: strin
"use step";

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;

Expand Down
1 change: 1 addition & 0 deletions app/workflows/computeLifecycleWakeDecision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function computeLifecycleWakeDecision(
"use step";

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") {
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/createSandboxHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function createSandboxHandler(request: NextRequest): Promise<NextRe

let sessionRow: Tables<"sessions"> | null = null;
if (sessionId) {
const rows = await selectSessions({ id: sessionId });
const rows = (await selectSessions({ id: sessionId })) ?? [];
sessionRow = rows[0] ?? null;

if (!sessionRow) {
Expand Down
6 changes: 5 additions & 1 deletion lib/sandbox/evaluateSandboxLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function evaluateSandboxLifecycle(
reason: SandboxLifecycleReason,
): Promise<SandboxLifecycleEvaluationResult> {
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" };
Expand Down Expand Up @@ -70,7 +71,10 @@ 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);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/getSandboxStatusHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function getSandboxStatusHandler(request: NextRequest): Promise<Nex
);
}

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

if (!row) {
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/reclaimStaleLease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ import type { Tables } from "@/types/database.types";
*/
export async function reclaimStaleLease(sessionId: string): Promise<Tables<"sessions"> | null> {
await updateSession(sessionId, { lifecycle_run_id: null });
const rows = await selectSessions({ id: sessionId });
const rows = (await selectSessions({ id: sessionId })) ?? [];
return rows[0] ?? null;
}
2 changes: 1 addition & 1 deletion lib/sandbox/runKick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface RunKickInput {
* design.
*/
export async function runKick(input: RunKickInput): Promise<void> {
const rows = await selectSessions({ id: input.sessionId });
const rows = (await selectSessions({ id: input.sessionId })) ?? [];
const session = rows[0];
if (!session) return;

Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/validateSandboxReconnectRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion lib/sandbox/wasLifecycleTimingExtended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export async function wasLifecycleTimingExtended(
sessionId: string,
prior: Tables<"sessions">,
): Promise<boolean> {
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 =
Expand Down
Loading
Loading