/** * GET /api/projects/[projectId]/agent/sessions/[sessionId]/events?afterSeq=0 * List persisted agent events for replay (user session auth). * * POST /api/projects/[projectId]/agent/sessions/[sessionId]/events * Batch append from vibn-agent-runner (x-agent-runner-secret). */ import { NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth/authOptions"; import { query, getPool } from "@/lib/db-postgres"; export interface AgentSessionEventRow { seq: number; ts: string; type: string; payload: Record; } export async function GET( req: Request, { params }: { params: Promise<{ projectId: string; sessionId: string }> } ) { try { const { projectId, sessionId } = await params; const session = await getServerSession(authOptions); if (!session?.user?.email) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0); const rows = await query( `SELECT e.seq, e.ts::text, e.type, e.payload FROM agent_session_events e JOIN agent_sessions s ON s.id = e.session_id JOIN fs_projects p ON p.id::text = s.project_id::text JOIN fs_users u ON u.id = p.user_id WHERE e.session_id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3 AND e.seq > $4 ORDER BY e.seq ASC LIMIT 2000`, [sessionId, projectId, session.user.email, afterSeq] ); const maxSeq = rows.length ? rows[rows.length - 1].seq : afterSeq; return NextResponse.json({ events: rows, maxSeq }); } catch (err) { console.error("[agent/sessions/.../events GET]", err); return NextResponse.json({ error: "Failed to list events" }, { status: 500 }); } } type IngestBody = { events: Array<{ clientEventId: string; ts: string; type: string; payload?: Record; }>; }; export async function POST( req: Request, { params }: { params: Promise<{ projectId: string; sessionId: string }> } ) { const secret = process.env.AGENT_RUNNER_SECRET ?? ""; const incomingSecret = req.headers.get("x-agent-runner-secret") ?? ""; if (secret && incomingSecret !== secret) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const { projectId, sessionId } = await params; let body: IngestBody; try { body = (await req.json()) as IngestBody; } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } if (!body.events?.length) { return NextResponse.json({ ok: true, inserted: 0 }); } const pool = getPool(); const client = await pool.connect(); try { const exists = await client.query<{ n: string }>( `SELECT 1 AS n FROM agent_sessions WHERE id = $1::uuid AND project_id::text = $2 LIMIT 1`, [sessionId, projectId] ); if (exists.rowCount === 0) { return NextResponse.json({ error: "Session not found" }, { status: 404 }); } await client.query("BEGIN"); await client.query("SELECT pg_advisory_xact_lock(hashtext($1::text))", [sessionId]); let inserted = 0; for (const ev of body.events) { if (!ev.clientEventId || !ev.type || !ev.ts) continue; const maxRes = await client.query<{ m: string }>( `SELECT COALESCE(MAX(seq), 0)::text AS m FROM agent_session_events WHERE session_id = $1::uuid`, [sessionId] ); const nextSeq = Number(maxRes.rows[0].m) + 1; const ins = await client.query( `INSERT INTO agent_session_events (session_id, project_id, seq, ts, type, payload, client_event_id) VALUES ($1::uuid, $2, $3, $4::timestamptz, $5, $6::jsonb, $7::uuid) ON CONFLICT (client_event_id) DO NOTHING`, [ sessionId, projectId, nextSeq, ev.ts, ev.type, JSON.stringify(ev.payload ?? {}), ev.clientEventId, ] ); if (ins.rowCount) inserted += ins.rowCount; } await client.query("COMMIT"); return NextResponse.json({ ok: true, inserted }); } catch (err) { try { await client.query("ROLLBACK"); } catch { /* ignore */ } console.error("[agent/sessions/.../events POST]", err); return NextResponse.json({ error: "Failed to ingest events" }, { status: 500 }); } finally { client.release(); } }