feat: handle runner execute failures and surface immediately to DB sessions
This commit is contained in:
@@ -11,25 +11,27 @@
|
||||
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";
|
||||
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,
|
||||
// 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`,
|
||||
[]
|
||||
);
|
||||
await query(`SELECT 1 FROM agent_sessions LIMIT 0`, []);
|
||||
}
|
||||
|
||||
// ── POST — create session ────────────────────────────────────────────────────
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
@@ -41,11 +43,14 @@ export async function POST(
|
||||
// 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]
|
||||
[principal.userId],
|
||||
);
|
||||
const email = userRow?.data?.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();
|
||||
@@ -56,7 +61,10 @@ export async function POST(
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -66,7 +74,7 @@ export async function POST(
|
||||
`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]
|
||||
[projectId, email],
|
||||
);
|
||||
if (owns.length === 0) {
|
||||
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;
|
||||
|
||||
// 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 matchedApp = apps.find(a => a.name === appName);
|
||||
const matchedApp = apps.find((a) => a.name === appName);
|
||||
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
|
||||
|
||||
// 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)
|
||||
VALUES ($1::uuid, $2, $3, $4, 'running', now())
|
||||
RETURNING id`,
|
||||
[projectId, appName, appPath, task.trim()]
|
||||
[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]
|
||||
[projectId],
|
||||
);
|
||||
if (!wsResult.length) {
|
||||
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
|
||||
let mcpToken = "";
|
||||
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) {
|
||||
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;
|
||||
} 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:*'] });
|
||||
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";
|
||||
|
||||
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.
|
||||
@@ -133,33 +156,43 @@ export async function POST(
|
||||
autoApprove: true,
|
||||
coolifyAppUuid,
|
||||
mcpToken,
|
||||
vibnApiUrl
|
||||
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
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`Runner returned ${res.status}: ${text}`);
|
||||
}
|
||||
})
|
||||
.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',
|
||||
error = 'Agent runner not reachable',
|
||||
error = $2,
|
||||
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.'
|
||||
'text', $2
|
||||
))
|
||||
WHERE id = $1::uuid`,
|
||||
[sessionId]
|
||||
).catch(() => {});
|
||||
});
|
||||
[sessionId, `Agent runner failed: ${err.message}`],
|
||||
).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 }
|
||||
{
|
||||
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(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ projectId: string }> }
|
||||
{ params }: { params: Promise<{ projectId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { projectId } = await params;
|
||||
@@ -180,11 +213,14 @@ export async function GET(
|
||||
// 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]
|
||||
[principal.userId],
|
||||
);
|
||||
const email = userRow?.data?.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();
|
||||
@@ -210,15 +246,18 @@ export async function GET(
|
||||
WHERE s.project_id::text = $1 AND u.data->>'email' = $2
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT 50`,
|
||||
[projectId, email]
|
||||
[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 }
|
||||
{
|
||||
error: "Failed to list sessions",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user