diff --git a/app/api/sessions/__tests__/route.test.ts b/app/api/sessions/__tests__/route.test.ts new file mode 100644 index 000000000..09170af70 --- /dev/null +++ b/app/api/sessions/__tests__/route.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { POST, OPTIONS } from "@/app/api/sessions/route"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; + +vi.mock("@/lib/sessions/createSessionHandler", () => ({ + createSessionHandler: vi.fn(), +})); + +describe("POST /api/sessions", () => { + it("delegates to createSessionHandler", async () => { + const expected = NextResponse.json({ ok: true }, { status: 200 }); + vi.mocked(createSessionHandler).mockResolvedValue(expected); + + const req = new NextRequest("http://localhost/api/sessions", { method: "POST" }); + const res = await POST(req); + + expect(createSessionHandler).toHaveBeenCalledWith(req); + expect(res).toBe(expected); + }); +}); + +describe("OPTIONS /api/sessions", () => { + it("returns 200 for CORS preflight", async () => { + const res = await OPTIONS(); + expect(res.status).toBe(200); + }); +}); diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts new file mode 100644 index 000000000..d4a458782 --- /dev/null +++ b/app/api/sessions/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * `POST /api/sessions` — create a session and an initial chat. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ session, chat }` on 200, or an error. + */ +export async function POST(request: NextRequest) { + return createSessionHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/__tests__/baseChatRow.ts b/lib/sessions/__tests__/baseChatRow.ts new file mode 100644 index 000000000..4c295ed49 --- /dev/null +++ b/lib/sessions/__tests__/baseChatRow.ts @@ -0,0 +1,19 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Returns a fully-populated `chats` row suitable for mocking + * `insertChat` in tests. Pass `overrides` to customize fields per case. + */ +export function baseChatRow(overrides: Partial> = {}): Tables<"chats"> { + return { + id: "chat_1", + session_id: "sess_1", + title: "New chat", + model_id: null, + active_stream_id: null, + last_assistant_message_at: null, + created_at: "2026-05-04T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", + ...overrides, + }; +} diff --git a/lib/sessions/__tests__/baseSessionRow.ts b/lib/sessions/__tests__/baseSessionRow.ts new file mode 100644 index 000000000..8c5fe8095 --- /dev/null +++ b/lib/sessions/__tests__/baseSessionRow.ts @@ -0,0 +1,39 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Returns a fully-populated `sessions` row suitable for mocking + * `insertSession` / `selectSession` in tests. Pass `overrides` to + * customize fields per case. + */ +export function baseSessionRow(overrides: Partial> = {}): Tables<"sessions"> { + return { + id: "sess_1", + account_id: "acc-uuid-1", + title: "Test session", + status: "running", + repo_owner: null, + repo_name: null, + branch: null, + clone_url: null, + is_new_branch: false, + global_skill_refs: [], + sandbox_state: { type: "vercel" }, + lifecycle_state: "provisioning", + lifecycle_version: 0, + last_activity_at: null, + sandbox_expires_at: null, + hibernate_after: null, + lifecycle_run_id: null, + lifecycle_error: null, + lines_added: 0, + lines_removed: 0, + snapshot_url: null, + snapshot_created_at: null, + snapshot_size_bytes: null, + cached_diff: null, + cached_diff_updated_at: null, + created_at: "2026-05-04T00:00:00.000Z", + updated_at: "2026-05-04T00:00:00.000Z", + ...overrides, + }; +} diff --git a/lib/sessions/__tests__/buildSessionInsertRow.test.ts b/lib/sessions/__tests__/buildSessionInsertRow.test.ts new file mode 100644 index 000000000..a786871f5 --- /dev/null +++ b/lib/sessions/__tests__/buildSessionInsertRow.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; + +describe("buildSessionInsertRow", () => { + it("returns sane defaults for an empty body", () => { + const row = buildSessionInsertRow({ body: {}, accountId: "acc-1", title: "Berlin" }); + expect(row.account_id).toBe("acc-1"); + expect(row.title).toBe("Berlin"); + expect(row.status).toBe("running"); + expect(row.lifecycle_state).toBe("provisioning"); + expect(row.lifecycle_version).toBe(0); + expect(row.sandbox_state).toEqual({ type: "vercel" }); + expect(row.branch).toBeNull(); + expect(row.clone_url).toBeNull(); + expect(row.id).toMatch(/^[0-9a-f-]{36}$/i); + }); + + it("forwards branch + clone fields verbatim", () => { + const row = buildSessionInsertRow({ + body: { + branch: "main", + cloneUrl: "https://github.com/recoupable/ai.git", + }, + accountId: "acc-1", + title: "Berlin", + }); + expect(row.branch).toBe("main"); + expect(row.clone_url).toBe("https://github.com/recoupable/ai.git"); + }); + + it("uses the provided sandboxType when set", () => { + const row = buildSessionInsertRow({ + body: { sandboxType: "vercel" }, + accountId: "acc-1", + title: "Berlin", + }); + expect(row.sandbox_state).toEqual({ type: "vercel" }); + }); +}); diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts new file mode 100644 index 000000000..038889a3d --- /dev/null +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; +import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({ + validateCreateSessionBody: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); +vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ + resolveSessionTitle: vi.fn(async () => "Anchorage"), +})); + +const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({ + body: overrides.body ?? {}, + auth: { + accountId: overrides.accountId ?? "acc-uuid-1", + orgId: null, + authToken: "key_test", + }, +}); + +describe("createSessionHandler — persistence", () => { + beforeEach(() => vi.clearAllMocks()); + + it("creates session and chat with defaults on empty body", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(200); + + const body = (await res.json()) as { session: { userId: string }; chat: { sessionId: string } }; + expect(body.session.userId).toBe("acc-uuid-1"); + expect(body.chat.sessionId).toBe("sess_1"); + + const insertArgs = vi.mocked(insertSession).mock.calls[0][0]; + expect(insertArgs.account_id).toBe("acc-uuid-1"); + expect(insertArgs.status).toBe("running"); + expect(insertArgs.lifecycle_state).toBe("provisioning"); + expect(insertArgs.sandbox_state).toEqual({ type: "vercel" }); + + const chatArgs = vi.mocked(insertChat).mock.calls[0][0]; + expect(chatArgs.session_id).toBe("sess_1"); + expect(chatArgs.title).toBe("New chat"); + }); + + it("forwards body title to resolveSessionTitle and writes the resolved title", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue( + okValidated({ body: { title: "Hello world" } }), + ); + vi.mocked(resolveSessionTitle).mockResolvedValueOnce("Hello world"); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ title: "Hello world" })); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({ title: "Hello world" })); + + expect(resolveSessionTitle).toHaveBeenCalledWith({ + providedTitle: "Hello world", + accountId: "acc-uuid-1", + }); + expect(vi.mocked(insertSession).mock.calls[0][0].title).toBe("Hello world"); + }); + + it("returns 500 when insertSession fails", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(null); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(500); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("rolls back the session and returns 500 when insertChat fails", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_rollback" })); + vi.mocked(insertChat).mockResolvedValue(null); + vi.mocked(deleteSessionById).mockResolvedValue(true); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(500); + expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback"); + }); + + it("logs an orphan-session error when rollback also fails", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" })); + vi.mocked(insertChat).mockResolvedValue(null); + vi.mocked(deleteSessionById).mockResolvedValue(false); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(500); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("orphaned session"), "sess_orphan"); + errSpy.mockRestore(); + }); +}); diff --git a/lib/sessions/__tests__/createSessionHandler.test.ts b/lib/sessions/__tests__/createSessionHandler.test.ts new file mode 100644 index 000000000..a239ccd82 --- /dev/null +++ b/lib/sessions/__tests__/createSessionHandler.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; + +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; +import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({ + validateCreateSessionBody: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); +vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ + resolveSessionTitle: vi.fn(async () => "Anchorage"), +})); + +describe("createSessionHandler — short-circuits on validation failure", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the NextResponse from validateCreateSessionBody as-is", async () => { + const failure = NextResponse.json({ status: "error", error: "bad" }, { status: 401 }); + vi.mocked(validateCreateSessionBody).mockResolvedValue(failure); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res).toBe(failure); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when validateCreateSessionBody rejects with 400", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue( + NextResponse.json({ status: "error", error: "Invalid sandbox type" }, { status: 400 }), + ); + + const res = await createSessionHandler(makeCreateSessionReq({ sandboxType: "wrong" })); + expect(res.status).toBe(400); + expect(insertSession).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/sessions/__tests__/getRandomCityName.test.ts b/lib/sessions/__tests__/getRandomCityName.test.ts new file mode 100644 index 000000000..a9d2e0754 --- /dev/null +++ b/lib/sessions/__tests__/getRandomCityName.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; +import { cityNames } from "@/lib/sessions/cityNames"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getRandomCityName", () => { + it("returns a city from the curated list when none are used", () => { + const result = getRandomCityName(new Set()); + expect(cityNames).toContain(result); + }); + + it("avoids cities that are already in use", () => { + const used = new Set(cityNames.slice(0, cityNames.length - 1)); + const result = getRandomCityName(used); + expect(result).toBe(cityNames[cityNames.length - 1]); + }); + + it("appends a numeric suffix once every city is used", () => { + const used = new Set(cityNames); + const result = getRandomCityName(used); + expect(result).toMatch(/ \d+$/); + const base = result.replace(/ \d+$/, ""); + expect(cityNames).toContain(base); + }); + + it("increments the suffix when the next number is also used", () => { + const baseCity = cityNames[0]; + const used = new Set([...cityNames, `${baseCity} 2`, `${baseCity} 3`]); + // Pin Math.random so the fallback always picks index 0 (= baseCity), + // making the suffix-increment behavior deterministic to assert. + vi.spyOn(Math, "random").mockReturnValue(0); + + const result = getRandomCityName(used); + + expect(result).toBe(`${baseCity} 4`); + }); +}); diff --git a/lib/sessions/__tests__/makeCreateSessionReq.ts b/lib/sessions/__tests__/makeCreateSessionReq.ts new file mode 100644 index 000000000..e7b368f68 --- /dev/null +++ b/lib/sessions/__tests__/makeCreateSessionReq.ts @@ -0,0 +1,19 @@ +import { NextRequest } from "next/server"; + +/** + * Builds a NextRequest pointing at `/api/sessions` with a JSON body + * and the standard test API key already attached. + * + * @param body - The body to send (object literal or raw string). + */ +export function makeCreateSessionReq(body: unknown): NextRequest { + const headers = new Headers({ + "Content-Type": "application/json", + "x-api-key": "key_test", + }); + return new NextRequest("http://localhost/api/sessions", { + method: "POST", + headers, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} diff --git a/lib/sessions/__tests__/resolveSessionTitle.test.ts b/lib/sessions/__tests__/resolveSessionTitle.test.ts new file mode 100644 index 000000000..07615db28 --- /dev/null +++ b/lib/sessions/__tests__/resolveSessionTitle.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; +import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; + +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/sessions/getRandomCityName", () => ({ + getRandomCityName: vi.fn(() => "Anchorage"), +})); + +describe("resolveSessionTitle", () => { + beforeEach(() => vi.clearAllMocks()); + + it("uses the provided title verbatim when present", async () => { + const result = await resolveSessionTitle({ providedTitle: "Hello", accountId: "acc-1" }); + expect(result).toBe("Hello"); + expect(selectSessions).not.toHaveBeenCalled(); + expect(getRandomCityName).not.toHaveBeenCalled(); + }); + + it("trims whitespace around a provided title", async () => { + const result = await resolveSessionTitle({ providedTitle: " Hi ", accountId: "acc-1" }); + expect(result).toBe("Hi"); + }); + + it("falls back to getRandomCityName when no title is provided", async () => { + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ title: "Berlin" }), + baseSessionRow({ id: "sess_2", title: "Paris" }), + ]); + + const result = await resolveSessionTitle({ accountId: "acc-1" }); + + expect(result).toBe("Anchorage"); + expect(selectSessions).toHaveBeenCalledWith({ accountId: "acc-1" }); + expect(vi.mocked(getRandomCityName).mock.calls[0][0]).toEqual(new Set(["Berlin", "Paris"])); + }); + + it("falls back to getRandomCityName when title is whitespace-only", async () => { + vi.mocked(selectSessions).mockResolvedValue([]); + + const result = await resolveSessionTitle({ providedTitle: " ", accountId: "acc-1" }); + + expect(result).toBe("Anchorage"); + expect(selectSessions).toHaveBeenCalledWith({ accountId: "acc-1" }); + }); +}); diff --git a/lib/sessions/__tests__/validateCreateSessionBody.test.ts b/lib/sessions/__tests__/validateCreateSessionBody.test.ts new file mode 100644 index 000000000..e5e863b73 --- /dev/null +++ b/lib/sessions/__tests__/validateCreateSessionBody.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "key" }; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +describe("validateCreateSessionBody", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ status: "error", error: "no auth" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const result = await validateCreateSessionBody(req({})); + expect(result).toBe(failure); + }); + + it("returns 400 when sandboxType is not 'vercel'", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req({ sandboxType: "wrong" })); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = (await result.json()) as { status: string; error: string }; + expect(body.error).toBe("Invalid sandbox type"); + } + }); + + it("returns body + auth on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req({ title: "Hello", sandboxType: "vercel" })); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.body).toEqual({ title: "Hello", sandboxType: "vercel" }); + expect(result.auth).toBe(okAuth); + } + }); + + it("treats malformed JSON as an empty body and accepts it", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req("{not valid")); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.body).toEqual({}); + } + }); +}); diff --git a/lib/sessions/buildSessionInsertRow.ts b/lib/sessions/buildSessionInsertRow.ts new file mode 100644 index 000000000..8c718f57f --- /dev/null +++ b/lib/sessions/buildSessionInsertRow.ts @@ -0,0 +1,37 @@ +import type { TablesInsert } from "@/types/database.types"; +import type { CreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { generateUUID } from "@/lib/uuid/generateUUID"; + +interface BuildSessionInsertRowInput { + body: CreateSessionBody; + accountId: string; + title: string; +} + +/** + * Normalizes a validated `POST /api/sessions` body plus a resolved + * title into a `sessions` insert row. Centralizes the default / + * null-coalescing rules so the handler can stay focused on HTTP and + * persistence flow. + * + * Title resolution is intentionally not done here — that lives in + * `resolveSessionTitle` so this function stays synchronous and pure. + * + * @param input - The validated body, owning account id, and resolved title. + * @returns A row ready to pass to `insertSession`. + */ +export function buildSessionInsertRow(input: BuildSessionInsertRowInput): TablesInsert<"sessions"> { + const { body, accountId, title } = input; + return { + id: generateUUID(), + account_id: accountId, + title, + status: "running", + branch: body.branch ?? null, + clone_url: body.cloneUrl ?? null, + global_skill_refs: [], + sandbox_state: { type: body.sandboxType ?? "vercel" }, + lifecycle_state: "provisioning", + lifecycle_version: 0, + }; +} diff --git a/lib/sessions/cityNames.ts b/lib/sessions/cityNames.ts new file mode 100644 index 000000000..92cd18a17 --- /dev/null +++ b/lib/sessions/cityNames.ts @@ -0,0 +1,198 @@ +/** + * Curated list of ~200 notable cities used as memorable, unique + * fallback titles for sessions that don't have a user-provided title. + * Ported verbatim from open-agents so generated titles look familiar + * to the existing frontend. + */ +export const cityNames: readonly string[] = [ + // Africa + "Abidjan", + "Accra", + "Addis Ababa", + "Algiers", + "Antananarivo", + "Cairo", + "Cape Town", + "Casablanca", + "Dakar", + "Dar es Salaam", + "Johannesburg", + "Kampala", + "Kigali", + "Kinshasa", + "Lagos", + "Luanda", + "Lusaka", + "Maputo", + "Marrakech", + "Nairobi", + "Tunis", + "Windhoek", + + // Asia + "Almaty", + "Amman", + "Baku", + "Bangkok", + "Beijing", + "Beirut", + "Bengaluru", + "Bishkek", + "Colombo", + "Dhaka", + "Dubai", + "Hanoi", + "Ho Chi Minh City", + "Hong Kong", + "Hyderabad", + "Islamabad", + "Istanbul", + "Jakarta", + "Jeddah", + "Jerusalem", + "Kabul", + "Karachi", + "Kathmandu", + "Kolkata", + "Kuala Lumpur", + "Kyoto", + "Manila", + "Mumbai", + "Muscat", + "New Delhi", + "Osaka", + "Phnom Penh", + "Riyadh", + "Seoul", + "Shanghai", + "Shenzhen", + "Singapore", + "Taipei", + "Tashkent", + "Tbilisi", + "Tehran", + "Tel Aviv", + "Thimphu", + "Tokyo", + "Ulaanbaatar", + "Vientiane", + "Yangon", + "Yerevan", + + // Europe + "Amsterdam", + "Athens", + "Barcelona", + "Belgrade", + "Berlin", + "Bern", + "Bordeaux", + "Bratislava", + "Brussels", + "Bucharest", + "Budapest", + "Copenhagen", + "Dublin", + "Edinburgh", + "Florence", + "Geneva", + "Hamburg", + "Helsinki", + "Kraków", + "Kyiv", + "Lisbon", + "Ljubljana", + "London", + "Lyon", + "Madrid", + "Marseille", + "Milan", + "Minsk", + "Monaco", + "Moscow", + "Munich", + "Naples", + "Oslo", + "Paris", + "Porto", + "Prague", + "Reykjavik", + "Riga", + "Rome", + "Sarajevo", + "Seville", + "Sofia", + "Stockholm", + "Tallinn", + "Valencia", + "Venice", + "Vienna", + "Vilnius", + "Warsaw", + "Zagreb", + "Zurich", + + // North America + "Anchorage", + "Atlanta", + "Austin", + "Boston", + "Calgary", + "Chicago", + "Denver", + "Detroit", + "Guadalajara", + "Havana", + "Honolulu", + "Houston", + "Kingston", + "Los Angeles", + "Mexico City", + "Miami", + "Minneapolis", + "Montreal", + "Nashville", + "New Orleans", + "New York", + "Ottawa", + "Philadelphia", + "Phoenix", + "Portland", + "San Diego", + "San Francisco", + "San Juan", + "Seattle", + "Toronto", + "Vancouver", + "Washington", + + // South America + "Bogotá", + "Brasília", + "Buenos Aires", + "Caracas", + "Cartagena", + "Cusco", + "Guayaquil", + "La Paz", + "Lima", + "Medellín", + "Montevideo", + "Quito", + "Recife", + "Rio de Janeiro", + "Santiago", + "São Paulo", + "Valparaíso", + + // Oceania + "Adelaide", + "Auckland", + "Brisbane", + "Christchurch", + "Fiji", + "Melbourne", + "Perth", + "Sydney", + "Wellington", +]; diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts new file mode 100644 index 000000000..d27b0b9e1 --- /dev/null +++ b/lib/sessions/createSessionHandler.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { generateUUID } from "@/lib/uuid/generateUUID"; +import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; +import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; + +const INITIAL_CHAT_TITLE = "New chat"; + +/** + * Handles `POST /api/sessions`. + * + * Authenticates, validates the request, resolves a final session + * title (provided > random city fallback), then creates a session + * row and an initial chat row. If the chat insert fails after the + * session row is persisted, the session is rolled back so callers + * never observe an orphaned session. + * + * @param request - The incoming request. + * @returns A NextResponse with `{ session, chat }` on 200, or an error. + */ +export async function createSessionHandler(request: NextRequest): Promise { + const validated = await validateCreateSessionBody(request); + if (validated instanceof NextResponse) { + return validated; + } + const { body, auth } = validated; + + const title = await resolveSessionTitle({ + providedTitle: body.title, + accountId: auth.accountId, + }); + + const sessionRow = await insertSession( + buildSessionInsertRow({ body, accountId: auth.accountId, title }), + ); + + if (!sessionRow) { + return failedToCreateSession(); + } + + const chatRow = await insertChat({ + id: generateUUID(), + session_id: sessionRow.id, + title: INITIAL_CHAT_TITLE, + }); + + if (!chatRow) { + const rolledBack = await deleteSessionById(sessionRow.id); + if (!rolledBack) { + console.error( + "[createSessionHandler] chat insert failed and session rollback failed — orphaned session:", + sessionRow.id, + ); + } + return failedToCreateSession(); + } + + return NextResponse.json( + { session: toSessionResponse(sessionRow), chat: toChatResponse(chatRow) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/failedToCreateSession.ts b/lib/sessions/failedToCreateSession.ts new file mode 100644 index 000000000..a9e010c04 --- /dev/null +++ b/lib/sessions/failedToCreateSession.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; + +/** + * 500 response shared by every internal failure on `POST /api/sessions`. + * Returns the standard `{status, error}` envelope with the generic + * `"Internal server error"` message — specific failure modes are logged + * server-side rather than leaked to clients. + */ +export function failedToCreateSession(): NextResponse { + return NextResponse.json( + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/getRandomCityName.ts b/lib/sessions/getRandomCityName.ts new file mode 100644 index 000000000..72238ba52 --- /dev/null +++ b/lib/sessions/getRandomCityName.ts @@ -0,0 +1,26 @@ +import { cityNames } from "@/lib/sessions/cityNames"; + +/** + * Picks a random city name not already in `usedNames`. When every + * curated city has been used at least once, falls back to suffixing + * with the smallest unused integer (e.g. `"Tokyo 2"`, `"Tokyo 3"`). + * + * Ported from open-agents so generated titles look familiar to the + * existing frontend after cutover. + * + * @param usedNames - Session titles the account already has. + * @returns A title not present in `usedNames`. + */ +export function getRandomCityName(usedNames: Set): string { + const available = cityNames.filter(city => !usedNames.has(city)); + if (available.length > 0) { + return available[Math.floor(Math.random() * available.length)]!; + } + + const base = cityNames[Math.floor(Math.random() * cityNames.length)]!; + let suffix = 2; + while (usedNames.has(`${base} ${suffix}`)) { + suffix++; + } + return `${base} ${suffix}`; +} diff --git a/lib/sessions/resolveSessionTitle.ts b/lib/sessions/resolveSessionTitle.ts new file mode 100644 index 000000000..1e2270b40 --- /dev/null +++ b/lib/sessions/resolveSessionTitle.ts @@ -0,0 +1,30 @@ +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; + +interface ResolveSessionTitleInput { + providedTitle?: string; + accountId: string; +} + +/** + * Resolves the final title for a new session. + * + * If the caller provided a non-blank title, returns it trimmed. + * Otherwise queries the account's existing session titles and picks + * a random city name that doesn't collide with them — mirroring the + * open-agents fallback so generated titles look familiar after + * frontend cutover. + * + * @param input - Provided title (optional) and the owning account id. + * @returns The resolved title. + */ +export async function resolveSessionTitle(input: ResolveSessionTitleInput): Promise { + const trimmed = input.providedTitle?.trim(); + if (trimmed) { + return trimmed; + } + + const rows = await selectSessions({ accountId: input.accountId }); + const usedTitles = rows.map(row => row.title); + return getRandomCityName(new Set(usedTitles)); +} diff --git a/lib/sessions/toChatResponse.ts b/lib/sessions/toChatResponse.ts new file mode 100644 index 000000000..2b89105aa --- /dev/null +++ b/lib/sessions/toChatResponse.ts @@ -0,0 +1,22 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Translates a Supabase `chats` row into the camelCase shape returned + * by the API. Mirrors `toSessionResponse` so wire format stays aligned + * with what open-agents' frontend already consumes. + * + * @param row - The Supabase chats row. + * @returns The camelCase chat payload for HTTP responses. + */ +export function toChatResponse(row: Tables<"chats">) { + return { + id: row.id, + sessionId: row.session_id, + title: row.title, + modelId: row.model_id, + activeStreamId: row.active_stream_id, + lastAssistantMessageAt: row.last_assistant_message_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/lib/sessions/toSessionResponse.ts b/lib/sessions/toSessionResponse.ts new file mode 100644 index 000000000..d7c08cbac --- /dev/null +++ b/lib/sessions/toSessionResponse.ts @@ -0,0 +1,43 @@ +import type { Tables } from "@/types/database.types"; + +/** + * Translates the snake_case Supabase row into the camelCase shape that + * open-agents' frontend expects, preserving its existing field names + * (e.g. `userId` for what is now `account_id`). This keeps the wire + * format identical so the open-agents frontend can cut over to api + * with zero frontend code changes. + * + * @param row - The Supabase sessions row. + * @returns The camelCase session payload for HTTP responses. + */ +export function toSessionResponse(row: Tables<"sessions">) { + return { + id: row.id, + userId: row.account_id, + title: row.title, + status: row.status, + repoOwner: row.repo_owner, + repoName: row.repo_name, + branch: row.branch, + cloneUrl: row.clone_url, + isNewBranch: row.is_new_branch, + globalSkillRefs: row.global_skill_refs, + sandboxState: row.sandbox_state, + lifecycleState: row.lifecycle_state, + lifecycleVersion: row.lifecycle_version, + lastActivityAt: row.last_activity_at, + sandboxExpiresAt: row.sandbox_expires_at, + hibernateAfter: row.hibernate_after, + lifecycleRunId: row.lifecycle_run_id, + lifecycleError: row.lifecycle_error, + linesAdded: row.lines_added, + linesRemoved: row.lines_removed, + snapshotUrl: row.snapshot_url, + snapshotCreatedAt: row.snapshot_created_at, + snapshotSizeBytes: row.snapshot_size_bytes, + cachedDiff: row.cached_diff, + cachedDiffUpdatedAt: row.cached_diff_updated_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/lib/sessions/validateCreateSessionBody.ts b/lib/sessions/validateCreateSessionBody.ts new file mode 100644 index 000000000..f794dfb07 --- /dev/null +++ b/lib/sessions/validateCreateSessionBody.ts @@ -0,0 +1,60 @@ +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 createSessionBodySchema = z.object({ + title: z.string().optional(), + branch: z.string().optional(), + cloneUrl: z.string().optional(), + sandboxType: z.literal("vercel", { error: "Invalid sandbox type" }).optional(), +}); + +export type CreateSessionBody = z.infer; + +export interface ValidatedCreateSessionRequest { + body: CreateSessionBody; + auth: AuthContext; +} + +/** + * Validates a `POST /api/sessions` 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 validateCreateSessionBody( + request: NextRequest, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rawBody = await safeParseJson(request); + const result = createSessionBodySchema.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 }; +} diff --git a/lib/supabase/chats/insertChat.ts b/lib/supabase/chats/insertChat.ts new file mode 100644 index 000000000..6b6102198 --- /dev/null +++ b/lib/supabase/chats/insertChat.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts a new row into the `chats` table and returns it. + * + * @param row - The chat row to insert. + * @returns The inserted row, or `null` if the insert failed. + */ +export async function insertChat(row: TablesInsert<"chats">): Promise | null> { + const { data, error } = await supabase.from("chats").insert(row).select().maybeSingle(); + + if (error) { + console.error("Error inserting chat:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/sessions/deleteSessionById.ts b/lib/supabase/sessions/deleteSessionById.ts new file mode 100644 index 000000000..731f75a34 --- /dev/null +++ b/lib/supabase/sessions/deleteSessionById.ts @@ -0,0 +1,20 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes the session with the given id. Used for rollback when a + * subsequent insert (e.g. the initial chat) fails after the session + * row was already persisted. + * + * @param id - The session id to delete. + * @returns `true` on success, `false` if the delete failed. + */ +export async function deleteSessionById(id: string): Promise { + const { error } = await supabase.from("sessions").delete().eq("id", id); + + if (error) { + console.error("Error deleting session:", error); + return false; + } + + return true; +} diff --git a/lib/supabase/sessions/insertSession.ts b/lib/supabase/sessions/insertSession.ts new file mode 100644 index 000000000..7ac6f7bf7 --- /dev/null +++ b/lib/supabase/sessions/insertSession.ts @@ -0,0 +1,21 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Inserts a new row into the `sessions` table and returns it. + * + * @param row - The session row to insert. + * @returns The inserted row, or `null` if the insert failed. + */ +export async function insertSession( + row: TablesInsert<"sessions">, +): Promise | null> { + const { data, error } = await supabase.from("sessions").insert(row).select().maybeSingle(); + + if (error) { + console.error("Error inserting session:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/sessions/selectSessions.ts b/lib/supabase/sessions/selectSessions.ts new file mode 100644 index 000000000..69477b9cb --- /dev/null +++ b/lib/supabase/sessions/selectSessions.ts @@ -0,0 +1,41 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +interface SelectSessionsFilter { + /** Optional id filter — when set, returns at most one row. */ + id?: string; + /** Optional account filter — when set, returns every session owned by the account. */ + accountId?: string; +} + +/** + * 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. + * + * 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. + */ +export async function selectSessions( + filter: SelectSessionsFilter = {}, +): Promise[]> { + 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); + + try { + const { data, error } = await query; + if (error) { + console.error("[selectSessions] error:", error); + return []; + } + return data ?? []; + } catch (e) { + console.error("[selectSessions] threw:", e); + return []; + } +} diff --git a/types/database.types.ts b/types/database.types.ts index 50b0a8ef6..a3c4b7876 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -298,23 +298,23 @@ export type Database = { Row: { account_id: string; created_at: string | null; - expires_at: string; + expires_at: string | null; github_repo: string | null; - snapshot_id: string; + snapshot_id: string | null; }; Insert: { account_id: string; created_at?: string | null; - expires_at: string; + expires_at?: string | null; github_repo?: string | null; - snapshot_id: string; + snapshot_id?: string | null; }; Update: { account_id?: string; created_at?: string | null; - expires_at?: string; + expires_at?: string | null; github_repo?: string | null; - snapshot_id?: string; + snapshot_id?: string | null; }; Relationships: [ { @@ -1079,6 +1079,118 @@ export type Database = { }; Relationships: []; }; + chat_messages: { + Row: { + chat_id: string; + created_at: string; + id: string; + parts: Json; + role: string; + }; + Insert: { + chat_id: string; + created_at?: string; + id: string; + parts: Json; + role: string; + }; + Update: { + chat_id?: string; + created_at?: string; + id?: string; + parts?: Json; + role?: string; + }; + Relationships: [ + { + foreignKeyName: "chat_messages_chat_id_fkey"; + columns: ["chat_id"]; + isOneToOne: false; + referencedRelation: "chats"; + referencedColumns: ["id"]; + }, + ]; + }; + chat_reads: { + Row: { + account_id: string; + chat_id: string; + created_at: string; + last_read_at: string; + updated_at: string; + }; + Insert: { + account_id: string; + chat_id: string; + created_at?: string; + last_read_at?: string; + updated_at?: string; + }; + Update: { + account_id?: string; + chat_id?: string; + created_at?: string; + last_read_at?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "chat_reads_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "chat_reads_chat_id_fkey"; + columns: ["chat_id"]; + isOneToOne: false; + referencedRelation: "chats"; + referencedColumns: ["id"]; + }, + ]; + }; + chats: { + Row: { + active_stream_id: string | null; + created_at: string; + id: string; + last_assistant_message_at: string | null; + model_id: string | null; + session_id: string; + title: string; + updated_at: string; + }; + Insert: { + active_stream_id?: string | null; + created_at?: string; + id: string; + last_assistant_message_at?: string | null; + model_id?: string | null; + session_id: string; + title: string; + updated_at?: string; + }; + Update: { + active_stream_id?: string | null; + created_at?: string; + id?: string; + last_assistant_message_at?: string | null; + model_id?: string | null; + session_id?: string; + title?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "chats_session_id_fkey"; + columns: ["session_id"]; + isOneToOne: false; + referencedRelation: "sessions"; + referencedColumns: ["id"]; + }, + ]; + }; config: { Row: { billing_provider: Database["public"]["Enums"]["billing_provider"]; @@ -2763,6 +2875,104 @@ export type Database = { }; Relationships: []; }; + sessions: { + Row: { + account_id: string; + branch: string | null; + cached_diff: Json | null; + cached_diff_updated_at: string | null; + clone_url: string | null; + created_at: string; + global_skill_refs: Json; + hibernate_after: string | null; + id: string; + is_new_branch: boolean; + last_activity_at: string | null; + lifecycle_error: string | null; + lifecycle_run_id: string | null; + lifecycle_state: string | null; + lifecycle_version: number; + lines_added: number | null; + lines_removed: number | null; + repo_name: string | null; + repo_owner: string | null; + sandbox_expires_at: string | null; + sandbox_state: Json | null; + snapshot_created_at: string | null; + snapshot_size_bytes: number | null; + snapshot_url: string | null; + status: string; + title: string; + updated_at: string; + }; + Insert: { + account_id: string; + branch?: string | null; + cached_diff?: Json | null; + cached_diff_updated_at?: string | null; + clone_url?: string | null; + created_at?: string; + global_skill_refs?: Json; + hibernate_after?: string | null; + id: string; + is_new_branch?: boolean; + last_activity_at?: string | null; + lifecycle_error?: string | null; + lifecycle_run_id?: string | null; + lifecycle_state?: string | null; + lifecycle_version?: number; + lines_added?: number | null; + lines_removed?: number | null; + repo_name?: string | null; + repo_owner?: string | null; + sandbox_expires_at?: string | null; + sandbox_state?: Json | null; + snapshot_created_at?: string | null; + snapshot_size_bytes?: number | null; + snapshot_url?: string | null; + status?: string; + title: string; + updated_at?: string; + }; + Update: { + account_id?: string; + branch?: string | null; + cached_diff?: Json | null; + cached_diff_updated_at?: string | null; + clone_url?: string | null; + created_at?: string; + global_skill_refs?: Json; + hibernate_after?: string | null; + id?: string; + is_new_branch?: boolean; + last_activity_at?: string | null; + lifecycle_error?: string | null; + lifecycle_run_id?: string | null; + lifecycle_state?: string | null; + lifecycle_version?: number; + lines_added?: number | null; + lines_removed?: number | null; + repo_name?: string | null; + repo_owner?: string | null; + sandbox_expires_at?: string | null; + sandbox_state?: Json | null; + snapshot_created_at?: string | null; + snapshot_size_bytes?: number | null; + snapshot_url?: string | null; + status?: string; + title?: string; + updated_at?: string; + }; + Relationships: [ + { + foreignKeyName: "sessions_account_id_fkey"; + columns: ["account_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; + }, + ]; + }; social_fans: { Row: { artist_social_id: string; @@ -3548,6 +3758,91 @@ export type Database = { }; Relationships: []; }; + workflow_run_steps: { + Row: { + created_at: string; + duration_ms: number; + finish_reason: string | null; + finished_at: string; + id: string; + raw_finish_reason: string | null; + started_at: string; + step_number: number; + workflow_run_id: string; + }; + Insert: { + created_at?: string; + duration_ms: number; + finish_reason?: string | null; + finished_at: string; + id: string; + raw_finish_reason?: string | null; + started_at: string; + step_number: number; + workflow_run_id: string; + }; + Update: { + created_at?: string; + duration_ms?: number; + finish_reason?: string | null; + finished_at?: string; + id?: string; + raw_finish_reason?: string | null; + started_at?: string; + step_number?: number; + workflow_run_id?: string; + }; + Relationships: [ + { + foreignKeyName: "workflow_run_steps_workflow_run_id_fkey"; + columns: ["workflow_run_id"]; + isOneToOne: false; + referencedRelation: "workflow_runs"; + referencedColumns: ["id"]; + }, + ]; + }; + workflow_runs: { + Row: { + chat_id: string; + created_at: string; + finished_at: string; + id: string; + model_id: string | null; + started_at: string; + status: string; + total_duration_ms: number; + }; + Insert: { + chat_id: string; + created_at?: string; + finished_at: string; + id: string; + model_id?: string | null; + started_at: string; + status: string; + total_duration_ms: number; + }; + Update: { + chat_id?: string; + created_at?: string; + finished_at?: string; + id?: string; + model_id?: string | null; + started_at?: string; + status?: string; + total_duration_ms?: number; + }; + Relationships: [ + { + foreignKeyName: "workflow_runs_chat_id_fkey"; + columns: ["chat_id"]; + isOneToOne: false; + referencedRelation: "chats"; + referencedColumns: ["id"]; + }, + ]; + }; youtube_tokens: { Row: { access_token: string;