feat: auto-approve UI + session status approved

- sessions POST: look up coolifyServiceUuid, pass autoApprove:true to runner
- sessions PATCH: approved added to terminal statuses (sets completed_at)
- build/page.tsx: approved status, STATUS_COLORS/LABELS for "Shipped",
  auto-committed UI in changed files panel, bottom bar for approved state
- Architecture doc: fully updated with current state

Made-with: Cursor
This commit is contained in:
2026-03-07 13:17:33 -08:00
parent 7b228ebad2
commit fc59333383
3 changed files with 69 additions and 13 deletions

View File

@@ -242,7 +242,7 @@ interface AgentSession {
id: string; id: string;
app_name: string; app_name: string;
task: string; task: string;
status: "pending" | "running" | "done" | "failed" | "stopped"; status: "pending" | "running" | "done" | "approved" | "failed" | "stopped";
output: Array<{ ts: string; type: string; text: string }>; output: Array<{ ts: string; type: string; text: string }>;
changed_files: Array<{ path: string; status: string }>; changed_files: Array<{ path: string; status: string }>;
error: string | null; error: string | null;
@@ -252,10 +252,12 @@ interface AgentSession {
} }
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
running: "#3d5afe", done: "#2e7d32", failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a", running: "#3d5afe", done: "#2e7d32", approved: "#1b5e20",
failed: "#c62828", stopped: "#a09a90", pending: "#d4a04a",
}; };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
running: "Running", done: "Done", failed: "Failed", stopped: "Stopped", pending: "Starting…", running: "Running", done: "Done", approved: "Shipped",
failed: "Failed", stopped: "Stopped", pending: "Starting…",
}; };
const FILE_STATUS_COLORS: Record<string, string> = { added: "#2e7d32", modified: "#d4a04a", deleted: "#c62828" }; const FILE_STATUS_COLORS: Record<string, string> = { added: "#2e7d32", modified: "#d4a04a", deleted: "#c62828" };
@@ -295,9 +297,9 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
const list: AgentSession[] = d.sessions ?? []; const list: AgentSession[] = d.sessions ?? [];
setSessions(list); setSessions(list);
setLoadingSessions(false); setLoadingSessions(false);
// Auto-select: prefer running/pending, then the most recent // Auto-select: prefer live session, then the most recent
if (list.length > 0 && !activeSessionId) { if (list.length > 0 && !activeSessionId) {
const live = list.find(s => s.status === "running" || s.status === "pending"); const live = list.find(s => ["running", "pending"].includes(s.status));
const pick = live ?? list[0]; const pick = live ?? list[0];
setActiveSessionId(pick.id); setActiveSessionId(pick.id);
setActiveSession(pick); setActiveSession(pick);
@@ -324,13 +326,13 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
.catch(() => {}); .catch(() => {});
} }
} }
} catch { /* network hiccup — ignore */ } } catch { /* network hiccup — ignore */ }
}; };
poll(); poll();
const interval = setInterval(() => { const isLive = ["running", "pending"].includes(activeSession?.status ?? "");
poll(); const interval = setInterval(poll, isLive ? 500 : 5000);
}, ["running", "pending"].includes(activeSession?.status ?? "") ? 500 : 5000);
return () => { cancelled = true; clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
}, [activeSessionId, projectId, activeSession?.status]); }, [activeSessionId, projectId, activeSession?.status]);
@@ -554,6 +556,11 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
</div> </div>
))} ))}
</div> </div>
{activeSession.status === "approved" && (
<div style={{ marginTop: 8, fontSize: "0.73rem", color: "#1b5e20", fontFamily: "Outfit, sans-serif" }}>
Auto-committed these changes are live.
</div>
)}
{activeSession.status === "done" && ( {activeSession.status === "done" && (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
{approveResult && ( {approveResult && (
@@ -648,6 +655,47 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
); );
} }
// Session auto-committed — show confirmation, offer next task
if (st === "approved") {
return (
<div style={{ borderTop: "1px solid #e8e4dc", background: "#fff", padding: "12px 16px", flexShrink: 0 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ fontSize: "0.75rem", color: "#1b5e20", fontFamily: "Outfit, sans-serif", flex: 1 }}>
Shipped changes committed and deployment triggered automatically.
</span>
<button onClick={() => setShowFollowUp(true)}
style={{ padding: "7px 14px", background: "#f0ece4", color: "#1a1a1a", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
+ Follow up
</button>
<button onClick={() => { setActiveSession(null); setActiveSessionId(null); setTask(""); }}
style={{ padding: "7px 14px", background: "transparent", color: "#a09a90", border: "1px solid #e8e4dc", borderRadius: 7, fontSize: "0.75rem", cursor: "pointer", fontFamily: "Outfit, sans-serif", whiteSpace: "nowrap" }}>
New task
</button>
</div>
{showFollowUp && (
<div style={{ display: "flex", gap: 8, alignItems: "flex-end", marginTop: 10 }}>
<textarea
autoFocus
value={task}
onChange={e => setTask(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { handleRun(); setShowFollowUp(false); }
if (e.key === "Escape") { setShowFollowUp(false); setTask(""); }
}}
placeholder="What should the agent build next?"
rows={2}
style={{ flex: 1, resize: "none", border: "1px solid #e8e4dc", borderRadius: 7, padding: "8px 11px", fontSize: "0.78rem", fontFamily: "Outfit, sans-serif", outline: "none", background: "#faf8f5" }}
/>
<button onClick={() => { handleRun(); setShowFollowUp(false); }} disabled={submitting || !task.trim()}
style={{ padding: "9px 14px", background: task.trim() ? "#1a1a1a" : "#e8e4dc", color: task.trim() ? "#fff" : "#a09a90", border: "none", borderRadius: 7, fontSize: "0.75rem", fontWeight: 600, cursor: task.trim() ? "pointer" : "default", fontFamily: "Outfit, sans-serif" }}>
{submitting ? "Starting…" : "Run"}
</button>
</div>
)}
</div>
);
}
// Session completed successfully — offer follow-up or new task // Session completed successfully — offer follow-up or new task
if (st === "done") { if (st === "done") {
return ( return (

View File

@@ -88,7 +88,7 @@ export async function PATCH(
if (body.status) { if (body.status) {
updates.push(`status = $${idx++}`); updates.push(`status = $${idx++}`);
values.push(body.status); values.push(body.status);
if (body.status === "done" || body.status === "failed" || body.status === "stopped") { if (["done", "approved", "failed", "stopped"].includes(body.status)) {
updates.push(`completed_at = now()`); updates.push(`completed_at = now()`);
} }
} }

View File

@@ -64,17 +64,23 @@ 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
interface AppEntry { name: string; coolifyServiceUuid?: string | null; }
const apps = (owns[0].data?.apps ?? []) as AppEntry[];
const matchedApp = apps.find(a => a.name === appName);
const coolifyAppUuid = matchedApp?.coolifyServiceUuid ?? undefined;
// Create the session row // Create the session row
const rows = await query<{ id: string }>( const rows = await query<{ id: string }>(
`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::text, $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;
// Fire-and-forget: call agent-runner to start the execution loop // Fire-and-forget: call agent-runner to start the execution loop.
// The agent runner is responsible for updating the session row as it works. // autoApprove: true — agent commits + deploys automatically on completion.
fetch(`${AGENT_RUNNER_URL}/agent/execute`, { fetch(`${AGENT_RUNNER_URL}/agent/execute`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -83,8 +89,10 @@ export async function POST(
projectId, projectId,
appName, appName,
appPath, appPath,
giteaRepo, // e.g. "mark/sportsy" — agent runner uses this to clone/update the repo giteaRepo,
task: task.trim(), task: task.trim(),
autoApprove: true,
coolifyAppUuid,
}), }),
}).catch(err => { }).catch(err => {
// Agent runner may not be wired yet — log but don't fail // Agent runner may not be wired yet — log but don't fail