225 lines
8.0 KiB
TypeScript
225 lines
8.0 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 { 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<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, 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 }
|
|
);
|
|
}
|
|
}
|