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
28 changes: 28 additions & 0 deletions app/api/cron/ai-sessions/cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
66 changes: 66 additions & 0 deletions app/api/projects/[projectId]/ai-sessions/[sessionId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
72 changes: 72 additions & 0 deletions app/api/projects/[projectId]/ai-sessions/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
5 changes: 5 additions & 0 deletions context/architecture-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 36 additions & 1 deletion context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`).
Loading
Loading