From 18f61fe95cbce0bd38ed5fa4d55779a91dce78e2 Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Sat, 7 Mar 2026 11:36:55 -0800 Subject: [PATCH] approve & commit flow + adaptive polling in Agent mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire Approve & commit button: shows commit message input, calls POST /api/.../sessions/[id]/approve which asks agent runner to git commit + push, then marks session as approved in DB - Adaptive polling: 500ms while session running, 5s when idle — output feels near-real-time without hammering the API - Auto-refresh session list when a session completes - Open in Theia links to theia.vibnai.com (escape hatch for manual edits) Made-with: Cursor --- .../project/[projectId]/build/page.tsx | 133 ++++++++++++++++-- .../sessions/[sessionId]/approve/route.ts | 133 ++++++++++++++++++ 2 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts diff --git a/app/[workspace]/project/[projectId]/build/page.tsx b/app/[workspace]/project/[projectId]/build/page.tsx index 3b87cb2..044eff6 100644 --- a/app/[workspace]/project/[projectId]/build/page.tsx +++ b/app/[workspace]/project/[projectId]/build/page.tsx @@ -274,6 +274,10 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName const [activeSession, setActiveSession] = useState(null); const [submitting, setSubmitting] = useState(false); const [loadingSessions, setLoadingSessions] = useState(true); + const [approving, setApproving] = useState(false); + const [approveMsg, setApproveMsg] = useState(""); + const [showApproveInput, setShowApproveInput] = useState(false); + const [approveResult, setApproveResult] = useState(null); const outputRef = useCallback((el: HTMLDivElement | null) => { if (el) el.scrollTop = el.scrollHeight; }, []); @@ -288,20 +292,32 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName .catch(() => setLoadingSessions(false)); }, [projectId, appName]); - // Poll active session + // Adaptive polling — 500ms while running, 5s when idle useEffect(() => { if (!activeSessionId) return; + let cancelled = false; const poll = async () => { - const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`); - const d = await r.json(); - if (d.session) setActiveSession(d.session); + try { + const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`); + const d = await r.json(); + if (!cancelled && d.session) { + setActiveSession(d.session); + // Refresh session list so status dots update in sidebar + if (!["running", "pending"].includes(d.session.status)) { + fetch(`/api/projects/${projectId}/agent/sessions`) + .then(r2 => r2.json()) + .then(d2 => { if (!cancelled) setSessions(d2.sessions ?? []); }) + .catch(() => {}); + } + } + } catch { /* network hiccup — ignore */ } }; + poll(); - const id = setInterval(() => { + const interval = setInterval(() => { poll(); - if (activeSession?.status && !["running", "pending"].includes(activeSession.status)) clearInterval(id); - }, 2000); - return () => clearInterval(id); + }, ["running", "pending"].includes(activeSession?.status ?? "") ? 500 : 5000); + return () => { cancelled = true; clearInterval(interval); }; }, [activeSessionId, projectId, activeSession?.status]); const handleRun = async () => { @@ -330,6 +346,36 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName setActiveSession(prev => prev ? { ...prev, status: "stopped" } : null); }; + const handleApprove = async () => { + if (!activeSessionId || !approveMsg.trim()) return; + setApproving(true); + setApproveResult(null); + try { + const r = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ commitMessage: approveMsg.trim() }), + }); + const d = await r.json() as { ok?: boolean; committed?: boolean; deployed?: boolean; message?: string; error?: string }; + if (d.ok) { + setApproveResult(d.deployed + ? `✓ Committed & deployment triggered — ${d.message}` + : `✓ Committed — ${d.message}`); + setShowApproveInput(false); + setApproveMsg(""); + // Refresh session + const s = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`).then(r2 => r2.json()); + if (s.session) setActiveSession(s.session); + } else { + setApproveResult(`✗ ${d.error ?? "Failed to commit"}`); + } + } catch (e) { + setApproveResult("✗ Network error — please try again"); + } finally { + setApproving(false); + } + }; + if (!appName) { return (
@@ -426,13 +472,70 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName ))}
{activeSession.status === "done" && ( -
- - +
+ {approveResult && ( +
+ {approveResult} +
+ )} + {showApproveInput ? ( +
+ setApproveMsg(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") handleApprove(); if (e.key === "Escape") setShowApproveInput(false); }} + placeholder="Commit message…" + style={{ + width: "100%", padding: "8px 11px", border: "1px solid #e8e4dc", + borderRadius: 6, fontSize: "0.78rem", fontFamily: "Outfit, sans-serif", + outline: "none", background: "#faf8f5", boxSizing: "border-box", + }} + /> +
+ + +
+
+ Enter to commit · Esc to cancel · Coolify will auto-deploy after push +
+
+ ) : ( +
+ + + Open in Theia → + +
+ )}
)}
diff --git a/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts b/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts new file mode 100644 index 0000000..9efb6a9 --- /dev/null +++ b/app/api/projects/[projectId]/agent/sessions/[sessionId]/approve/route.ts @@ -0,0 +1,133 @@ +/** + * POST /api/projects/[projectId]/agent/sessions/[sessionId]/approve + * + * Called by the frontend when the user clicks "Approve & commit". + * Verifies ownership, then asks the agent runner to git commit + push + * the changes it made in the workspace, and triggers a Coolify deploy. + * + * Body: { commitMessage: string } + */ +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"; +const COOLIFY_API_URL = process.env.COOLIFY_API_URL ?? ""; +const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? ""; + +interface AppEntry { + name: string; + path: string; + coolifyServiceUuid?: string | null; + domain?: string | null; +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ projectId: string; sessionId: string }> } +) { + try { + const { projectId, sessionId } = await params; + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json() as { commitMessage?: string }; + const commitMessage = body.commitMessage?.trim(); + if (!commitMessage) { + return NextResponse.json({ error: "commitMessage is required" }, { status: 400 }); + } + + // Verify ownership + fetch project data (giteaRepo, apps list) + const rows = await query<{ data: Record }>( + `SELECT 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 (rows.length === 0) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + const projectData = rows[0].data; + const giteaRepo = projectData?.giteaRepo as string | undefined; + if (!giteaRepo) { + return NextResponse.json({ error: "No Gitea repo linked to this project" }, { status: 400 }); + } + + // Find the session to get the appName (so we can find the right Coolify UUID) + const sessionRows = await query<{ app_name: string; status: string }>( + `SELECT app_name, status FROM agent_sessions WHERE id = $1 AND project_id = $2 LIMIT 1`, + [sessionId, projectId] + ); + if (sessionRows.length === 0) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + if (sessionRows[0].status !== "done") { + return NextResponse.json({ error: "Session must be in 'done' state to approve" }, { status: 400 }); + } + + const appName = sessionRows[0].app_name; + + // Find the matching Coolify UUID from project.data.apps[] + const apps: AppEntry[] = (projectData?.apps ?? []) as AppEntry[]; + const matchedApp = apps.find(a => a.name === appName); + const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined; + + // Call agent runner to commit + push + const approveRes = await fetch(`${AGENT_RUNNER_URL}/agent/approve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + giteaRepo, + commitMessage, + coolifyApiUrl: COOLIFY_API_URL, + coolifyApiToken: COOLIFY_API_TOKEN, + coolifyAppUuid, + }), + }); + + const approveData = await approveRes.json() as { + ok: boolean; + committed?: boolean; + deployed?: boolean; + message?: string; + error?: string; + }; + + if (!approveRes.ok || !approveData.ok) { + return NextResponse.json( + { error: approveData.error ?? "Agent runner returned an error" }, + { status: 500 } + ); + } + + // Mark session as approved in DB + await query( + `UPDATE agent_sessions + SET status = 'approved', completed_at = COALESCE(completed_at, now()), updated_at = now(), + output = output || $1::jsonb + WHERE id = $2`, + [ + JSON.stringify([{ + ts: new Date().toISOString(), + type: "done", + text: `✓ ${approveData.message ?? "Committed and pushed."}${approveData.deployed ? " Deployment triggered." : ""}`, + }]), + sessionId, + ] + ); + + return NextResponse.json({ + ok: true, + committed: approveData.committed, + deployed: approveData.deployed, + message: approveData.message, + }); + } catch (err) { + console.error("[agent/approve]", err); + return NextResponse.json({ error: "Failed to approve session" }, { status: 500 }); + } +}