feat: handle runner execute failures and surface immediately to DB sessions

This commit is contained in:
2026-06-02 11:41:02 -07:00
parent b1625dac88
commit 0ce4facf8f

View File

@@ -11,25 +11,27 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth"; import { requireWorkspacePrincipal } from "@/lib/auth/workspace-auth";
import { query, queryOne } from "@/lib/db-postgres"; import { query, queryOne } from "@/lib/db-postgres";
import { listWorkspaceApiKeys, mintWorkspaceApiKey, revealWorkspaceApiKey } from "@/lib/auth/workspace-auth"; import {
listWorkspaceApiKeys,
mintWorkspaceApiKey,
revealWorkspaceApiKey,
} from "@/lib/auth/workspace-auth";
const AGENT_RUNNER_URL = process.env.AGENT_RUNNER_URL ?? "http://localhost:3333"; 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, // 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". // throw a descriptive error instead of a generic "Failed to create session".
// Run POST /api/admin/migrate once to create the table. // Run POST /api/admin/migrate once to create the table.
async function ensureTable() { async function ensureTable() {
await query( await query(`SELECT 1 FROM agent_sessions LIMIT 0`, []);
`SELECT 1 FROM agent_sessions LIMIT 0`,
[]
);
} }
// ── POST — create session ──────────────────────────────────────────────────── // ── POST — create session ────────────────────────────────────────────────────
export async function POST( export async function POST(
req: Request, req: Request,
{ params }: { params: Promise<{ projectId: string }> } { params }: { params: Promise<{ projectId: string }> },
) { ) {
try { try {
const { projectId } = await params; const { projectId } = await params;
@@ -41,11 +43,14 @@ export async function POST(
// 2. Fetch user details from principal.userId // 2. Fetch user details from principal.userId
const userRow = await queryOne<{ id: string; data: any }>( const userRow = await queryOne<{ id: string; data: any }>(
`SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`, `SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId] [principal.userId],
); );
const email = userRow?.data?.email; const email = userRow?.data?.email;
if (!email) { if (!email) {
return NextResponse.json({ error: "User email not found" }, { status: 404 }); return NextResponse.json(
{ error: "User email not found" },
{ status: 404 },
);
} }
const body = await req.json(); const body = await req.json();
@@ -56,7 +61,10 @@ export async function POST(
}; };
if (!appName || appPath === undefined || !task?.trim()) { if (!appName || appPath === undefined || !task?.trim()) {
return NextResponse.json({ error: "appName, appPath and task are required" }, { status: 400 }); return NextResponse.json(
{ error: "appName, appPath and task are required" },
{ status: 400 },
);
} }
await ensureTable(); await ensureTable();
@@ -66,7 +74,7 @@ export async function POST(
`SELECT p.id, p.data FROM fs_projects p `SELECT p.id, p.data FROM fs_projects p
JOIN fs_users u ON u.id = p.user_id JOIN fs_users u ON u.id = p.user_id
WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`, WHERE p.id::text = $1 AND u.data->>'email' = $2 LIMIT 1`,
[projectId, email] [projectId, email],
); );
if (owns.length === 0) { if (owns.length === 0) {
return NextResponse.json({ error: "Project not found" }, { status: 404 }); return NextResponse.json({ error: "Project not found" }, { status: 404 });
@@ -75,9 +83,12 @@ export async function POST(
const giteaRepo = owns[0].data?.giteaRepo as string | undefined; const giteaRepo = owns[0].data?.giteaRepo as string | undefined;
// Find the Coolify UUID for this specific app so the runner can trigger a deploy // Find the Coolify UUID for this specific app so the runner can trigger a deploy
interface AppEntry { name: string; coolifyServiceUuid?: string | null; } interface AppEntry {
name: string;
coolifyServiceUuid?: string | null;
}
const apps = (owns[0].data?.apps ?? []) as AppEntry[]; const apps = (owns[0].data?.apps ?? []) as AppEntry[];
const matchedApp = apps.find(a => a.name === appName); const matchedApp = apps.find((a) => a.name === appName);
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined; const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
// Create the session row // Create the session row
@@ -85,13 +96,13 @@ export async function POST(
`INSERT INTO agent_sessions (project_id, app_name, app_path, task, status, started_at) `INSERT INTO agent_sessions (project_id, app_name, app_path, task, status, started_at)
VALUES ($1::uuid, $2, $3, $4, 'running', now()) VALUES ($1::uuid, $2, $3, $4, 'running', now())
RETURNING id`, RETURNING id`,
[projectId, appName, appPath, task.trim()] [projectId, appName, appPath, task.trim()],
); );
const sessionId = rows[0].id; const sessionId = rows[0].id;
const wsResult = await query<{ workspace_id: string }>( const wsResult = await query<{ workspace_id: string }>(
`SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1`, `SELECT vibn_workspace_id as workspace_id FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId] [projectId],
); );
if (!wsResult.length) { if (!wsResult.length) {
return NextResponse.json({ error: "Project not found" }, { status: 404 }); return NextResponse.json({ error: "Project not found" }, { status: 404 });
@@ -101,22 +112,34 @@ export async function POST(
// Grab or mint a default API key for the runner to use // Grab or mint a default API key for the runner to use
let mcpToken = ""; let mcpToken = "";
const keys = await listWorkspaceApiKeys(workspaceId); const keys = await listWorkspaceApiKeys(workspaceId);
let defaultKey = keys.find((k: any) => k.name === 'default' && !k.revoked_at); let defaultKey = keys.find(
(k: any) => k.name === "default" && !k.revoked_at,
);
if (!defaultKey) { if (!defaultKey) {
const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: principal.userId, scopes: ['workspace:*'] }); const minted = await mintWorkspaceApiKey({
workspaceId,
name: "default",
createdBy: principal.userId,
scopes: ["workspace:*"],
});
mcpToken = minted.token; mcpToken = minted.token;
} else { } else {
const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id); const revealed = await revealWorkspaceApiKey(workspaceId, defaultKey.id);
if (revealed) mcpToken = revealed.token; if (revealed) mcpToken = revealed.token;
else { else {
const minted = await mintWorkspaceApiKey({ workspaceId, name: 'default', createdBy: principal.userId, scopes: ['workspace:*'] }); const minted = await mintWorkspaceApiKey({
workspaceId,
name: "default",
createdBy: principal.userId,
scopes: ["workspace:*"],
});
mcpToken = minted.token; mcpToken = minted.token;
} }
} }
// Add VIBN_API_URL so the runner knows where to send MCP requests // Add VIBN_API_URL so the runner knows where to send MCP requests
const vibnApiUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; const vibnApiUrl =
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
// Fire-and-forget: call agent-runner to start the execution loop. // Fire-and-forget: call agent-runner to start the execution loop.
// autoApprove: true — agent commits + deploys automatically on completion. // autoApprove: true — agent commits + deploys automatically on completion.
@@ -133,33 +156,43 @@ export async function POST(
autoApprove: true, autoApprove: true,
coolifyAppUuid, coolifyAppUuid,
mcpToken, mcpToken,
vibnApiUrl vibnApiUrl,
}), }),
}).catch(err => { })
// Agent runner may not be wired yet — log but don't fail .then(async (res) => {
console.warn("[agent] runner not reachable:", err.message); if (!res.ok) {
// Mark session as failed if runner unreachable const text = await res.text().catch(() => res.statusText);
query( throw new Error(`Runner returned ${res.status}: ${text}`);
`UPDATE agent_sessions }
})
.catch((err) => {
// Agent runner may not be wired yet — log but don't fail
console.warn("[agent] runner failed or not reachable:", err.message);
// Mark session as failed if runner unreachable
query(
`UPDATE agent_sessions
SET status = 'failed', SET status = 'failed',
error = 'Agent runner not reachable', error = $2,
completed_at = now(), completed_at = now(),
output = jsonb_build_array(jsonb_build_object( output = jsonb_build_array(jsonb_build_object(
'ts', now()::text, 'ts', now()::text,
'type', 'error', 'type', 'error',
'text', 'Agent runner service is not connected yet. Phase 2 implementation pending.' 'text', $2
)) ))
WHERE id = $1::uuid`, WHERE id = $1::uuid`,
[sessionId] [sessionId, `Agent runner failed: ${err.message}`],
).catch(() => {}); ).catch(() => {});
}); });
return NextResponse.json({ sessionId }, { status: 201 }); return NextResponse.json({ sessionId }, { status: 201 });
} catch (err) { } catch (err) {
console.error("[agent/sessions POST]", err); console.error("[agent/sessions POST]", err);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create session", details: err instanceof Error ? err.message : String(err) }, {
{ status: 500 } error: "Failed to create session",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
); );
} }
} }
@@ -168,7 +201,7 @@ export async function POST(
export async function GET( export async function GET(
req: Request, req: Request,
{ params }: { params: Promise<{ projectId: string }> } { params }: { params: Promise<{ projectId: string }> },
) { ) {
try { try {
const { projectId } = await params; const { projectId } = await params;
@@ -180,11 +213,14 @@ export async function GET(
// 2. Fetch user details from principal.userId // 2. Fetch user details from principal.userId
const userRow = await queryOne<{ id: string; data: any }>( const userRow = await queryOne<{ id: string; data: any }>(
`SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`, `SELECT id, data FROM fs_users WHERE id = $1 LIMIT 1`,
[principal.userId] [principal.userId],
); );
const email = userRow?.data?.email; const email = userRow?.data?.email;
if (!email) { if (!email) {
return NextResponse.json({ error: "User email not found" }, { status: 404 }); return NextResponse.json(
{ error: "User email not found" },
{ status: 404 },
);
} }
await ensureTable(); await ensureTable();
@@ -210,15 +246,18 @@ export async function GET(
WHERE s.project_id::text = $1 AND u.data->>'email' = $2 WHERE s.project_id::text = $1 AND u.data->>'email' = $2
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT 50`, LIMIT 50`,
[projectId, email] [projectId, email],
); );
return NextResponse.json({ sessions }); return NextResponse.json({ sessions });
} catch (err) { } catch (err) {
console.error("[agent/sessions GET]", err); console.error("[agent/sessions GET]", err);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to list sessions", details: err instanceof Error ? err.message : String(err) }, {
{ status: 500 } error: "Failed to list sessions",
details: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
); );
} }
} }