- Sessions route now reads giteaRepo from project.data and forwards it to /agent/execute so the runner can clone/update the correct repo - PATCH route now validates x-agent-runner-secret header to prevent unauthorized session output injection Made-with: Cursor
174 lines
6.0 KiB
TypeScript
174 lines
6.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 { 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";
|
|
|
|
// Ensure the agent_sessions table exists (idempotent).
|
|
async function ensureTable() {
|
|
await query(`
|
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
project_id UUID NOT NULL,
|
|
app_name TEXT NOT NULL,
|
|
app_path TEXT NOT NULL,
|
|
task TEXT NOT NULL,
|
|
plan JSONB,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
output JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
changed_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
error TEXT,
|
|
started_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS agent_sessions_project_idx
|
|
ON agent_sessions(project_id, created_at DESC);
|
|
`, []);
|
|
}
|
|
|
|
// ── 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 = $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;
|
|
|
|
// 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, $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
|
|
// The agent runner is responsible for updating the session row as it works.
|
|
fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
sessionId,
|
|
projectId,
|
|
appName,
|
|
appPath,
|
|
giteaRepo, // e.g. "mark/sportsy" — agent runner uses this to clone/update the repo
|
|
task: task.trim(),
|
|
}),
|
|
}).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`,
|
|
[sessionId]
|
|
).catch(() => {});
|
|
});
|
|
|
|
return NextResponse.json({ sessionId }, { status: 201 });
|
|
} catch (err) {
|
|
console.error("[agent/sessions POST]", err);
|
|
return NextResponse.json({ error: "Failed to create session" }, { 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 = s.project_id
|
|
JOIN fs_users u ON u.id = p.user_id
|
|
WHERE s.project_id = $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" }, { status: 500 });
|
|
}
|
|
}
|