diff --git a/app/api/cron/ai-sessions/cleanup/route.ts b/app/api/cron/ai-sessions/cleanup/route.ts new file mode 100644 index 0000000..25fc335 --- /dev/null +++ b/app/api/cron/ai-sessions/cleanup/route.ts @@ -0,0 +1,28 @@ +import { timingSafeEqual } from "node:crypto"; + +import { deleteExpiredSessions } from "@/lib/ai/session-store"; + +/** + * Vercel Cron calls this daily (see `vercel.json`) with + * `Authorization: Bearer $CRON_SECRET`. The route is public in `proxy.ts`, so + * the secret is the only gate. + */ +function isAuthorized(request: Request): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) { + return false; + } + + const provided = Buffer.from(request.headers.get("authorization") ?? ""); + const expected = Buffer.from(`Bearer ${secret}`); + return provided.length === expected.length && timingSafeEqual(provided, expected); +} + +export async function GET(request: Request) { + if (!isAuthorized(request)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const deletedSessions = await deleteExpiredSessions(); + return Response.json({ deletedSessions }); +} diff --git a/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts new file mode 100644 index 0000000..5482e64 --- /dev/null +++ b/app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts @@ -0,0 +1,66 @@ +import { auth } from "@clerk/nextjs/server"; + +import { deleteSession, getSession } from "@/lib/ai/session-store"; +import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; + +type SessionRouteContext = { params: Promise<{ projectId: string; sessionId: string }> }; + +function unauthorizedResponse() { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +function forbiddenResponse() { + return Response.json({ error: "Forbidden" }, { status: 403 }); +} + +// Sessions are private to their creator, so another user's session id reads +// the same as one that never existed. +function notFoundResponse() { + return Response.json({ error: "Session not found" }, { status: 404 }); +} + +async function resolveScope(projectId: string) { + const { userId } = await auth(); + if (!userId) { + return { error: unauthorizedResponse() } as const; + } + + const { primaryEmail } = await getCurrentIdentity(); + const project = await getAccessibleProject(userId, primaryEmail, projectId); + if (!project) { + return { error: forbiddenResponse() } as const; + } + + return { scope: { projectId: project.id, userId } } as const; +} + +/** Returns one of the current user's sessions with its full transcript. */ +export async function GET(_request: Request, context: SessionRouteContext) { + const { projectId, sessionId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + const session = await getSession(result.scope, sessionId); + if (!session) { + return notFoundResponse(); + } + + return Response.json({ session }); +} + +export async function DELETE(_request: Request, context: SessionRouteContext) { + const { projectId, sessionId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + const deleted = await deleteSession(result.scope, sessionId); + if (!deleted) { + return notFoundResponse(); + } + + return new Response(null, { status: 204 }); +} diff --git a/app/api/projects/[projectId]/ai-sessions/route.ts b/app/api/projects/[projectId]/ai-sessions/route.ts new file mode 100644 index 0000000..2806dd5 --- /dev/null +++ b/app/api/projects/[projectId]/ai-sessions/route.ts @@ -0,0 +1,72 @@ +import { auth } from "@clerk/nextjs/server"; + +import { createSession, DEFAULT_SESSION_TITLE, listSessions } from "@/lib/ai/session-store"; +import { getAccessibleProject, getCurrentIdentity } from "@/lib/project-access"; + +function unauthorizedResponse() { + return Response.json({ error: "Unauthorized" }, { status: 401 }); +} + +function forbiddenResponse() { + return Response.json({ error: "Forbidden" }, { status: 403 }); +} + +async function resolveScope(projectId: string) { + const { userId } = await auth(); + if (!userId) { + return { error: unauthorizedResponse() } as const; + } + + const { primaryEmail } = await getCurrentIdentity(); + const project = await getAccessibleProject(userId, primaryEmail, projectId); + if (!project) { + return { error: forbiddenResponse() } as const; + } + + return { scope: { projectId: project.id, userId } } as const; +} + +/** Optional `title`; anything else in the body is ignored. */ +function parseTitle(value: unknown): string { + if (typeof value === "object" && value !== null && "title" in value && typeof value.title === "string") { + return value.title; + } + return DEFAULT_SESSION_TITLE; +} + +/** Lists the current user's unexpired AI sessions in this project, most recent first. */ +export async function GET( + _request: Request, + context: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + const sessions = await listSessions(result.scope); + return Response.json({ sessions }); +} + +/** Starts a new AI session for the current user in this project. */ +export async function POST( + request: Request, + context: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await context.params; + const result = await resolveScope(projectId); + if ("error" in result) { + return result.error; + } + + let body: unknown = null; + try { + body = await request.json(); + } catch { + // An empty body is allowed; the session gets the default title. + } + + const session = await createSession(result.scope, parseTitle(body)); + return Response.json({ session }, { status: 201 }); +} diff --git a/context/architecture-context.md b/context/architecture-context.md index 50654ef..44bb7b4 100644 --- a/context/architecture-context.md +++ b/context/architecture-context.md @@ -22,6 +22,11 @@ - **Database**: Stores project metadata, user ownership, collaborator links, and references to generated artifacts. - **File System / Blob Storage**: Stores large generated artifacts including Markdown technical specs and canvas snapshots. +- **AI sessions (database, short-lived)**: Design agent chats live in `AiSession` / `AiMessage` (small text + JSON payloads). + Sessions are private to the user who created them within a project, expire 7 days after last activity + (`expiresAt` slides forward on activity), are capped at 10 per user per project and 60 messages each, and are + removed by a daily Vercel Cron (`/api/cron/ai-sessions/cleanup`, `CRON_SECRET`). Reads ignore expired rows, so + expiry holds even before cleanup runs. Deleting a project cascades to its sessions. ## Auth and Access Model diff --git a/context/progress-tracker.md b/context/progress-tracker.md index c46861c..5549924 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -8,7 +8,9 @@ Update this file whenever the current phase, active feature, or implementation s ## Current Goal -- Implement the Specs tab (Generate Spec + download), which remains inert. +- Persistent, multi-turn AI design sessions (plan: storage → wire sessions → turn engine → UI cards → generation + quality → docs). Step 1 (storage) is done; next is step 2, wiring the existing generation to stored sessions. +- After that: the Specs tab (Generate Spec + automatic Markdown download), which remains inert. ## Completed @@ -1160,3 +1162,36 @@ Update this file whenever the current phase, active feature, or implementation s - `project-dialogs.tsx`: primary buttons show a `Loader2` spinner with "Creating…", "Saving…", "Deleting…" and `aria-busy`; name inputs are disabled while loading. - Type check and lint pass. Not checked in a browser. +- AI sessions, step 1: storage for persistent design agent chats (2026-09-15): + - Why: the AI sidebar kept its chat in React state only (`useDesignAgent` `useState`), so a reload lost the + transcript and any in-flight run, and every prompt reached the model with no history. + - `prisma/models/ai-session.prisma`: `AiSession` (projectId → `Project` cascade, userId, title, phase + `CLARIFYING|PLANNED|GENERATING|COMPLETE`, `brief` JSON, `clarifyRounds`, `lastActivityAt`, `expiresAt`) and + `AiMessage` (sessionId cascade, role `USER|ASSISTANT`, kind `TEXT|QUESTIONS|ANSWERS|PLAN|RESULT|ERROR`, `content`, + `payload` JSON, unique `runId`, status `PENDING|COMPLETE|FAILED`). `Project.aiSessions` back-relation. + Migration `20260915163917_add_ai_sessions` (additive only) applied. + - `types/ai-session.ts`: Prisma-free wire types (`AiSessionSummary`, `AiSessionDetail`, `AiMessageDto`) and value + lists for the enums, for client use in step 2. + - `lib/ai/session-store.ts`: limits (7-day sliding TTL, 10 sessions per user per project, 60 messages per + session, 4,000-char messages, 80-char titles), `toSessionTitle`, `listSessions`, `createSession` (one + transaction that trims to the cap, dropping least recently active and expired), `getSession` (with transcript), + `deleteSession`, `deleteExpiredSessions`. Every read filters `expiresAt > now`, scoped to project + user. + - Routes (Clerk auth + `getAccessibleProject`; sessions private to their creator, others' ids return 404): + - `GET/POST /api/projects/[projectId]/ai-sessions` — list mine / create (optional `title`, 201). + - `GET/DELETE /api/projects/[projectId]/ai-sessions/[sessionId]` — session + messages / delete (204). + - `GET /api/cron/ai-sessions/cleanup` — deletes expired sessions; requires `Authorization: Bearer $CRON_SECRET` + (constant-time compare, 401 when unset or wrong). `/api/cron(.*)` added to the public routes in `proxy.ts`. + - `vercel.json` (new): daily cron at 03:00 UTC. `CRON_SECRET` added to `.env.example`; it must also be set in + Vercel project env for the cron to authenticate. + - `architecture-context.md` storage model documents session retention. + - Not wired to the UI yet; `useDesignAgent` and `/api/ai/design` are unchanged. + - Validation checks: + - `pnpm typecheck` and `pnpm lint` passed (2 pre-existing warnings in vendored skill templates) + - A tsx script against the database confirmed: title normalisation, 7-day expiry, the 10-session cap deletes + the oldest rows, other users can't read/delete or count toward the cap, transcripts load, expired sessions + are hidden from get/list, cleanup deletes them with messages cascading, and project delete cascades. Test + rows were removed. + - Against the running dev server: signed-out requests to the session routes are stopped by Clerk (404 / 307 + to sign-in) before the handler; the cron route is reachable and returns 401 JSON with no or a wrong bearer. + - Not yet exercised: the session routes over HTTP with a signed-in user, and the cron route with the correct + secret (the dev server had no `CRON_SECRET`). diff --git a/lib/ai/session-store.ts b/lib/ai/session-store.ts new file mode 100644 index 0000000..700575f --- /dev/null +++ b/lib/ai/session-store.ts @@ -0,0 +1,188 @@ +import { prisma } from "@/lib/prisma"; +import type { AiSessionDetail, AiSessionSummary } from "@/types/ai-session"; + +// --------------------------------------------------------------------------- +// Retention and size limits +// +// Sessions are working memory for the design agent, not an archive. They +// expire a fixed time after the last activity, and each user keeps only a +// handful per project, so storage stays small. +// --------------------------------------------------------------------------- + +/** A session expires this long after its last activity. */ +export const AI_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** Sessions one user keeps per project. Creating one more drops the oldest. */ +export const MAX_SESSIONS_PER_USER_PROJECT = 10; + +/** Messages allowed in one session before the user must start a new one. */ +export const MAX_MESSAGES_PER_SESSION = 60; + +/** Longest message text accepted from the client. */ +export const MAX_MESSAGE_LENGTH = 4000; + +export const MAX_SESSION_TITLE_LENGTH = 80; + +export const DEFAULT_SESSION_TITLE = "New chat"; + +export function computeExpiresAt(from: Date = new Date()): Date { + return new Date(from.getTime() + AI_SESSION_TTL_MS); +} + +/** Collapses whitespace and truncates text into a session title. */ +export function toSessionTitle(text: string): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length === 0) { + return DEFAULT_SESSION_TITLE; + } + if (collapsed.length <= MAX_SESSION_TITLE_LENGTH) { + return collapsed; + } + return `${collapsed.slice(0, MAX_SESSION_TITLE_LENGTH - 1).trimEnd()}…`; +} + +// --------------------------------------------------------------------------- +// Queries +// +// Every query is scoped to project + user, and ignores expired sessions even +// before the cleanup cron has removed them. Callers must already have checked +// project access. +// --------------------------------------------------------------------------- + +const summarySelect = { + id: true, + title: true, + phase: true, + lastActivityAt: true, + expiresAt: true, + createdAt: true, +} as const; + +interface SessionScope { + projectId: string; + userId: string; +} + +function toSummary(session: { + id: string; + title: string; + phase: AiSessionSummary["phase"]; + lastActivityAt: Date; + expiresAt: Date; + createdAt: Date; +}): AiSessionSummary { + return { + id: session.id, + title: session.title, + phase: session.phase, + lastActivityAt: session.lastActivityAt.toISOString(), + expiresAt: session.expiresAt.toISOString(), + createdAt: session.createdAt.toISOString(), + }; +} + +export async function listSessions({ projectId, userId }: SessionScope): Promise { + const sessions = await prisma.aiSession.findMany({ + where: { projectId, userId, expiresAt: { gt: new Date() } }, + orderBy: { lastActivityAt: "desc" }, + take: MAX_SESSIONS_PER_USER_PROJECT, + select: summarySelect, + }); + + return sessions.map(toSummary); +} + +/** + * Creates a session and trims the user's sessions in this project down to + * {@link MAX_SESSIONS_PER_USER_PROJECT}, dropping the least recently active + * ones and any that have already expired. + */ +export async function createSession( + { projectId, userId }: SessionScope, + title: string = DEFAULT_SESSION_TITLE, +): Promise { + const now = new Date(); + + return prisma.$transaction(async (tx) => { + const kept = await tx.aiSession.findMany({ + where: { projectId, userId, expiresAt: { gt: now } }, + orderBy: { lastActivityAt: "desc" }, + take: MAX_SESSIONS_PER_USER_PROJECT - 1, + select: { id: true }, + }); + + await tx.aiSession.deleteMany({ + where: { projectId, userId, id: { notIn: kept.map((session) => session.id) } }, + }); + + const session = await tx.aiSession.create({ + data: { + projectId, + userId, + title: toSessionTitle(title), + lastActivityAt: now, + expiresAt: computeExpiresAt(now), + }, + select: summarySelect, + }); + + return toSummary(session); + }); +} + +export async function getSession( + { projectId, userId }: SessionScope, + sessionId: string, +): Promise { + const session = await prisma.aiSession.findFirst({ + where: { id: sessionId, projectId, userId, expiresAt: { gt: new Date() } }, + select: { + ...summarySelect, + brief: true, + clarifyRounds: true, + messages: { + orderBy: { createdAt: "asc" }, + select: { + id: true, + role: true, + kind: true, + content: true, + payload: true, + runId: true, + status: true, + createdAt: true, + }, + }, + }, + }); + + if (!session) { + return null; + } + + return { + ...toSummary(session), + brief: session.brief, + clarifyRounds: session.clarifyRounds, + messages: session.messages.map((message) => ({ + ...message, + createdAt: message.createdAt.toISOString(), + })), + }; +} + +/** Returns false when no matching session existed. */ +export async function deleteSession({ projectId, userId }: SessionScope, sessionId: string): Promise { + const { count } = await prisma.aiSession.deleteMany({ + where: { id: sessionId, projectId, userId }, + }); + return count > 0; +} + +/** Deletes every expired session across all projects. Messages cascade. */ +export async function deleteExpiredSessions(now: Date = new Date()): Promise { + const { count } = await prisma.aiSession.deleteMany({ + where: { expiresAt: { lte: now } }, + }); + return count; +} diff --git a/prisma/migrations/20260915163917_add_ai_sessions/migration.sql b/prisma/migrations/20260915163917_add_ai_sessions/migration.sql new file mode 100644 index 0000000..e5789ea --- /dev/null +++ b/prisma/migrations/20260915163917_add_ai_sessions/migration.sql @@ -0,0 +1,60 @@ +-- CreateEnum +CREATE TYPE "AiSessionPhase" AS ENUM ('CLARIFYING', 'PLANNED', 'GENERATING', 'COMPLETE'); + +-- CreateEnum +CREATE TYPE "AiMessageRole" AS ENUM ('USER', 'ASSISTANT'); + +-- CreateEnum +CREATE TYPE "AiMessageKind" AS ENUM ('TEXT', 'QUESTIONS', 'ANSWERS', 'PLAN', 'RESULT', 'ERROR'); + +-- CreateEnum +CREATE TYPE "AiMessageStatus" AS ENUM ('PENDING', 'COMPLETE', 'FAILED'); + +-- CreateTable +CREATE TABLE "AiSession" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "phase" "AiSessionPhase" NOT NULL DEFAULT 'CLARIFYING', + "brief" JSONB, + "clarifyRounds" INTEGER NOT NULL DEFAULT 0, + "lastActivityAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AiSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AiMessage" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "role" "AiMessageRole" NOT NULL, + "kind" "AiMessageKind" NOT NULL, + "content" TEXT NOT NULL, + "payload" JSONB, + "runId" TEXT, + "status" "AiMessageStatus" NOT NULL DEFAULT 'COMPLETE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AiMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AiSession_projectId_userId_lastActivityAt_idx" ON "AiSession"("projectId", "userId", "lastActivityAt"); + +-- CreateIndex +CREATE INDEX "AiSession_expiresAt_idx" ON "AiSession"("expiresAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "AiMessage_runId_key" ON "AiMessage"("runId"); + +-- CreateIndex +CREATE INDEX "AiMessage_sessionId_createdAt_idx" ON "AiMessage"("sessionId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "AiSession" ADD CONSTRAINT "AiSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AiMessage" ADD CONSTRAINT "AiMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AiSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/ai-session.prisma b/prisma/models/ai-session.prisma new file mode 100644 index 0000000..fec2f9b --- /dev/null +++ b/prisma/models/ai-session.prisma @@ -0,0 +1,67 @@ +// AI chat sessions for the design agent. Rows are short-lived: every session +// carries an `expiresAt` that slides forward on activity, and a daily cron +// deletes expired sessions (messages cascade). + +enum AiSessionPhase { + CLARIFYING + PLANNED + GENERATING + COMPLETE +} + +enum AiMessageRole { + USER + ASSISTANT +} + +enum AiMessageKind { + TEXT + QUESTIONS + ANSWERS + PLAN + RESULT + ERROR +} + +enum AiMessageStatus { + PENDING + COMPLETE + FAILED +} + +model AiSession { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + userId String + title String + phase AiSessionPhase @default(CLARIFYING) + // Running requirements summary the agent keeps between turns. + brief Json? + clarifyRounds Int @default(0) + lastActivityAt DateTime @default(now()) + expiresAt DateTime + createdAt DateTime @default(now()) + messages AiMessage[] + + @@index([projectId, userId, lastActivityAt]) + @@index([expiresAt]) +} + +model AiMessage { + id String @id @default(cuid()) + sessionId String + session AiSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + role AiMessageRole + kind AiMessageKind + // Display text, capped by the API. + content String + // Structured part of the message: questions, answers, plan, or result. + payload Json? + // Trigger.dev run producing this message, while it is PENDING. + runId String? @unique + status AiMessageStatus @default(COMPLETE) + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/prisma/models/project.prisma b/prisma/models/project.prisma index 6fdde90..0ceb925 100644 --- a/prisma/models/project.prisma +++ b/prisma/models/project.prisma @@ -13,6 +13,7 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt collaborators ProjectCollaborator[] + aiSessions AiSession[] @@index([ownerId]) @@index([createdAt]) diff --git a/proxy.ts b/proxy.ts index 844ca18..6888d28 100644 --- a/proxy.ts +++ b/proxy.ts @@ -23,6 +23,8 @@ const isPublicRoute = createRouteMatcher([ `${signUpPath}(.*)`, "/docs", "/docs(.*)", + // Invoked by Vercel Cron without a Clerk session; each handler checks CRON_SECRET. + "/api/cron(.*)", ]); export default clerkMiddleware( diff --git a/types/ai-session.ts b/types/ai-session.ts new file mode 100644 index 0000000..c9d1383 --- /dev/null +++ b/types/ai-session.ts @@ -0,0 +1,42 @@ +// Wire shapes for the AI session API. Kept free of Prisma imports so client +// code can use them. + +export const AI_SESSION_PHASES = ["CLARIFYING", "PLANNED", "GENERATING", "COMPLETE"] as const; +export type AiSessionPhase = (typeof AI_SESSION_PHASES)[number]; + +export const AI_MESSAGE_ROLES = ["USER", "ASSISTANT"] as const; +export type AiMessageRole = (typeof AI_MESSAGE_ROLES)[number]; + +export const AI_MESSAGE_KINDS = ["TEXT", "QUESTIONS", "ANSWERS", "PLAN", "RESULT", "ERROR"] as const; +export type AiMessageKind = (typeof AI_MESSAGE_KINDS)[number]; + +export const AI_MESSAGE_STATUSES = ["PENDING", "COMPLETE", "FAILED"] as const; +export type AiMessageStatus = (typeof AI_MESSAGE_STATUSES)[number]; + +/** Session as listed in the sidebar's session switcher. */ +export interface AiSessionSummary { + id: string; + title: string; + phase: AiSessionPhase; + lastActivityAt: string; + expiresAt: string; + createdAt: string; +} + +export interface AiMessageDto { + id: string; + role: AiMessageRole; + kind: AiMessageKind; + content: string; + payload: unknown; + runId: string | null; + status: AiMessageStatus; + createdAt: string; +} + +/** Full session with its transcript, oldest message first. */ +export interface AiSessionDetail extends AiSessionSummary { + brief: unknown; + clarifyRounds: number; + messages: AiMessageDto[]; +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..49e9965 --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/ai-sessions/cleanup", + "schedule": "0 3 * * *" + } + ] +}