/** * Agent Sessions API * * POST /api/projects/[projectId]/agent/sessions * Create a new agent session and kick it off via vibn-agent-runner. * Body: { appName, appPath, task } * * GET /api/projects/[projectId]/agent/sessions * List all sessions for a project, newest first. */ import { NextResponse } from "next/server"; import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth"; import { query, queryOne } from "@/lib/db-postgres"; import { listWorkspaceApiKeys, mintWorkspaceApiKey, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth"; const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; // Verify the agent_sessions table is reachable. If it doesn't exist yet, // throw a descriptive error instead of a generic "Failed to create session". // Run POST /api/admin/migrate once to create the table. async function ensureTable() { await query( `SELECT 1 FROM agent_sessions LIMIT 0`, [] ); } // ── POST — create session ──────────────────────────────────────────────────── export async function POST( req: Request, { params }: { params: Promise<{ projectId: string }> } ) { try { const { projectId } = await params; // 1. Authenticate the Workspace API key or Browser Session const principal = await requireWorkspacePrincipal(req); if (principal instanceof NextResponse) return principal; // 2. Fetch user details from principal.userId const userRow = await queryOne<{ id: string; data: any }>( `SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`, [principal.userId] ); const email = userRow?.data?.email; if (!email) { return NextResponse.json({ error: "User email not found" }, { status: 404 }); } const body = await req.json(); const { appName, appPath, task } = body as { appName: string; appPath: string; task: string; }; if (!appName || appPath === undefined || !task?.trim()) { return NextResponse.json({ error: "appName, appPath and task are required" }, { status: 400 }); } await ensureTable(); // Verify ownership and fetch giteaRepo const owns = await query<{ id: string; data: Record }>( `SELECT p.id, p.data FROM fs_projects p JOIN fs_users u ON u.id = p.user_id WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`, [projectId, email] ); if (owns.length === 0) { return NextResponse.json({ error: "Project not found" }, { status: 404 }); } const giteaRepo = owns[0].data?.giteaRepo as string | undefined; // Find the Coolify UUID for this specific app so the runner can trigger a deploy interface AppEntry { name: string; coolifyServiceUuid?: string | null; } const apps = (owns[0].data?.apps ?? []) as AppEntry[]; const matchedApp = apps.find(a => a.name === appName); const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined; // Create the session row const rows = await query<{ id: string }>( `INSERT INTO agent_sessions (project_id, app_name, app_path, task, status, started_at) VALUES ($1::text, $2, $3, $4, 'running', now()) RETURNING id`, [projectId, appName, appPath, task.trim()] ); const sessionId = rows[0].id; const wsResult = await query<{ workspace_id: string }>( `SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1`, [projectId] ); if (!wsResult.length) { return NextResponse.json({ error: "Project not found" }, { status: 404 }); } const workspaceId = wsResult[0].workspace_id; // Grab or mint a default API key for the runner to use let mcpToken = ""; const keys = await listWorkspaceApiKeys(workspaceId); let defaultKey = keys.find((k: any) => k.name === 'default' && !k.revoked_at); if (!defaultKey) { const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: principal.userId, scopes: ['workspace:*'] }); mcpToken = minted.token; } else { const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id); if (revealed) mcpToken = revealed.token; else { const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: principal.userId, scopes: ['workspace:*'] }); mcpToken = minted.token; } } // Add VIBN_API_URL so the runner knows where to send MCP requests const vibnApiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; // Fire-and-forget: call agent-runner to start the execution loop. // autoApprove: true — agent commits + deploys automatically on completion. fetch(`${AGENT_RUNNER_URL}/agent/execute`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, projectId, appName, appPath, giteaRepo, task: task.trim(), autoApprove: true, coolifyAppUuid, mcpToken, vibnApiUrl }), }).catch(err => { // Agent runner may not be wired yet — log but don't fail console.warn("[agent] runner not reachable:", err.message); // Mark session as failed if runner unreachable query( `UPDATE agent_sessions SET status = 'failed', error = 'Agent runner not reachable', completed_at = now(), output = jsonb_build_array(jsonb_build_object( 'ts', now()::text, 'type', 'error', 'text', 'Agent runner service is not connected yet. Phase 2 implementation pending.' )) WHERE id = $1::uuid`, [sessionId] ).catch(() => {}); }); return NextResponse.json({ sessionId }, { status: 201 }); } catch (err) { console.error("[agent/sessions POST]", err); return NextResponse.json( { error: "Failed to create session", details: err instanceof Error ? err.message : String(err) }, { status: 500 } ); } } // ── GET — list sessions ────────────────────────────────────────────────────── export async function GET( req: Request, { params }: { params: Promise<{ projectId: string }> } ) { try { const { projectId } = await params; // 1. Authenticate the Workspace API key or Browser Session const principal = await requireWorkspacePrincipal(req); if (principal instanceof NextResponse) return principal; // 2. Fetch user details from principal.userId const userRow = await queryOne<{ id: string; data: any }>( `SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`, [principal.userId] ); const email = userRow?.data?.email; if (!email) { return NextResponse.json({ error: "User email not found" }, { status: 404 }); } await ensureTable(); const sessions = await query<{ id: string; app_name: string; task: string; status: string; created_at: string; started_at: string | null; completed_at: string | null; output: Array<{ ts: string; type: string; text: string }>; changed_files: Array<{ path: string; status: string }>; error: string | null; }>( `SELECT s.id, s.app_name, s.task, s.status, s.created_at, s.started_at, s.completed_at, s.output, s.changed_files, s.error 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.project_id::text = $1 AND u.data->>'email' = $2 ORDER BY s.created_at DESC LIMIT 50`, [projectId, email] ); return NextResponse.json({ sessions }); } catch (err) { console.error("[agent/sessions GET]", err); return NextResponse.json( { error: "Failed to list sessions", details: err instanceof Error ? err.message : String(err) }, { status: 500 } ); } }