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 { 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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user