- Add agent_session_events table + GET/POST events + SSE stream routes - Build Agent tab: hydrate from events + EventSource while running - entrypoint: create agent_sessions + agent_session_events on container start - .env.example for AGENT_RUNNER_URL, AGENT_RUNNER_SECRET, DATABASE_URL Made-with: Cursor
143 lines
4.4 KiB
TypeScript
143 lines
4.4 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
}
|
|
|
|
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<AgentSessionEventRow>(
|
|
`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<string, unknown>;
|
|
}>;
|
|
};
|
|
|
|
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();
|
|
}
|
|
}
|