Files
vibn-frontend/app/api/projects/[projectId]/agent/sessions/route.ts
Mark Henderson fc59333383 feat: auto-approve UI + session status approved
- sessions POST: look up coolifyServiceUuid, pass autoApprove:true to runner
- sessions PATCH: approved added to terminal statuses (sets completed_at)
- build/page.tsx: approved status, STATUS_COLORS/LABELS for "Shipped",
  auto-committed UI in changed files panel, bottom bar for approved state
- Architecture doc: fully updated with current state

Made-with: Cursor
2026-03-07 13:17:33 -08:00

174 lines
5.9 KiB
TypeScript

/**
* 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 { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/authOptions";
import { query } from "@/lib/db-postgres";
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;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { appName, appPath, task } = body as {
appName: string;
appPath: string;
task: string;
};
if (!appName || !appPath || !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<string, unknown> }>(
`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, session.user.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;
// 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,
}),
}).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;
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
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, session.user.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 }
);
}
}