feat(agent): event timeline API, SSE stream, Coolify DDL, env template
- 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
This commit is contained in:
@@ -90,6 +90,20 @@ export async function POST(req: NextRequest) {
|
||||
`CREATE INDEX IF NOT EXISTS agent_sessions_project_idx ON agent_sessions (project_id, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS agent_sessions_status_idx ON agent_sessions (status)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS agent_session_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
client_event_id UUID UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(session_id, seq)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx ON agent_session_events (session_id, seq)`,
|
||||
|
||||
// NextAuth / Prisma tables
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* GET /api/projects/.../agent/sessions/.../events/stream?afterSeq=0
|
||||
* Server-Sent Events: tail agent_session_events while the session is active.
|
||||
*/
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth/authOptions";
|
||||
import { query, queryOne } from "@/lib/db-postgres";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
/** Long-lived SSE — raise if your host defaults to a shorter limit (e.g. Vercel). */
|
||||
export const maxDuration = 300;
|
||||
|
||||
const TERMINAL = new Set(["done", "approved", "failed", "stopped"]);
|
||||
|
||||
export async function GET(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string; sessionId: string }> }
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId, sessionId } = await params;
|
||||
let afterSeq = Math.max(0, parseInt(new URL(req.url).searchParams.get("afterSeq") ?? "0", 10) || 0);
|
||||
|
||||
const allowed = await queryOne<{ n: string }>(
|
||||
`SELECT 1 AS n FROM agent_sessions s
|
||||
JOIN fs_projects p ON p.id::text = s.project_id::text
|
||||
JOIN fs_users u ON u.id = p.user_id
|
||||
WHERE s.id = $1::uuid AND s.project_id::text = $2 AND u.data->>'email' = $3
|
||||
LIMIT 1`,
|
||||
[sessionId, projectId, session.user.email]
|
||||
);
|
||||
if (!allowed) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const signal = req.signal;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (obj: object) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||
};
|
||||
|
||||
let idleAfterTerminal = 0;
|
||||
let lastHeartbeat = Date.now();
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const rows = await query<{ seq: number; ts: string; type: string; payload: Record<string, unknown> }>(
|
||||
`SELECT e.seq, e.ts::text, e.type, e.payload
|
||||
FROM agent_session_events e
|
||||
WHERE e.session_id = $1::uuid AND e.seq > $2
|
||||
ORDER BY e.seq ASC
|
||||
LIMIT 200`,
|
||||
[sessionId, afterSeq]
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
afterSeq = row.seq;
|
||||
send({ seq: row.seq, ts: row.ts, type: row.type, payload: row.payload });
|
||||
}
|
||||
|
||||
const st = await queryOne<{ status: string }>(
|
||||
`SELECT status FROM agent_sessions WHERE id = $1::uuid LIMIT 1`,
|
||||
[sessionId]
|
||||
);
|
||||
const status = st?.status ?? "";
|
||||
const terminal = TERMINAL.has(status);
|
||||
|
||||
if (rows.length === 0) {
|
||||
if (terminal) {
|
||||
idleAfterTerminal++;
|
||||
if (idleAfterTerminal >= 3) {
|
||||
send({ type: "_stream.end", seq: afterSeq });
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
idleAfterTerminal = 0;
|
||||
}
|
||||
} else {
|
||||
idleAfterTerminal = 0;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastHeartbeat > 20000) {
|
||||
send({ type: "_heartbeat", t: now });
|
||||
lastHeartbeat = now;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 750));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[events/stream]", e);
|
||||
try {
|
||||
send({ type: "_stream.error", message: "stream failed" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream; charset=utf-8",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,13 @@ export async function POST(
|
||||
);
|
||||
const giteaRepo = proj[0]?.data?.giteaRepo as string | undefined;
|
||||
|
||||
// Clear persisted event timeline so SSE / replay matches the new run (no-op if table missing)
|
||||
try {
|
||||
await query(`DELETE FROM agent_session_events WHERE session_id = $1::uuid`, [sessionId]);
|
||||
} catch {
|
||||
/* table may not exist until admin migrate */
|
||||
}
|
||||
|
||||
// Reset the session row so the frontend shows it as running again
|
||||
await query(
|
||||
`UPDATE agent_sessions
|
||||
|
||||
Reference in New Issue
Block a user