feat(agent): event timeline API, SSE stream, Coolify DDL, env template
- Add agent_session_events table + GET/POST events + SSE stream routes - Build Agent tab: hydrate from events + EventSource while running - entrypoint: create agent_sessions + agent_session_events on container start - .env.example for AGENT_RUNNER_URL, AGENT_RUNNER_SECRET, DATABASE_URL Made-with: Cursor
This commit is contained in:
@@ -236,6 +236,33 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
};
|
||||
const FILE_STATUS_COLORS: Record<string, string> = { added: "#2e7d32", modified: "#d4a04a", deleted: "#c62828" };
|
||||
|
||||
/** Maps persisted / SSE agent event row to terminal line (output.* mirrors legacy outputLine types). */
|
||||
interface AgentLogLine {
|
||||
seq?: number;
|
||||
ts: string;
|
||||
type: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface StreamEventRow {
|
||||
seq: number;
|
||||
ts: string;
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function streamRowToLine(e: StreamEventRow): AgentLogLine {
|
||||
const text =
|
||||
typeof e.payload?.text === "string"
|
||||
? e.payload.text
|
||||
: "";
|
||||
if (e.type.startsWith("output.")) {
|
||||
const sub = e.type.slice("output.".length);
|
||||
return { seq: e.seq, ts: e.ts, type: sub, text: text || e.type };
|
||||
}
|
||||
return { seq: e.seq, ts: e.ts, type: "info", text: text || e.type };
|
||||
}
|
||||
|
||||
function elapsed(start: string | null): string {
|
||||
if (!start) return "";
|
||||
const s = Math.floor((Date.now() - new Date(start).getTime()) / 1000);
|
||||
@@ -249,6 +276,9 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
|
||||
const [sessions, setSessions] = useState<AgentSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [activeSession, setActiveSession] = useState<AgentSession | null>(null);
|
||||
const [eventLog, setEventLog] = useState<AgentLogLine[]>([]);
|
||||
const maxStreamSeqRef = useRef(0);
|
||||
const pollOutputRef = useRef<AgentSession["output"]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||
const [approving, setApproving] = useState(false);
|
||||
@@ -262,6 +292,95 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pollOutputRef.current = activeSession?.output ?? [];
|
||||
}, [activeSession?.output]);
|
||||
|
||||
// Load historical events + live SSE tail (replaces tight polling for log lines when events exist)
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !appName) return;
|
||||
|
||||
let cancelled = false;
|
||||
let es: EventSource | null = null;
|
||||
|
||||
(async () => {
|
||||
setEventLog([]);
|
||||
maxStreamSeqRef.current = 0;
|
||||
|
||||
try {
|
||||
const evRes = await fetch(
|
||||
`/api/projects/${projectId}/agent/sessions/${activeSessionId}/events?afterSeq=0`
|
||||
);
|
||||
if (evRes.ok) {
|
||||
const d = (await evRes.json()) as { events?: StreamEventRow[]; maxSeq?: number };
|
||||
if (!cancelled) {
|
||||
const mapped = (d.events ?? []).map(streamRowToLine);
|
||||
setEventLog(mapped);
|
||||
maxStreamSeqRef.current = typeof d.maxSeq === "number" ? d.maxSeq : 0;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* migration not applied or network */
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
let status = "";
|
||||
try {
|
||||
const sRes = await fetch(`/api/projects/${projectId}/agent/sessions/${activeSessionId}`);
|
||||
const sJson = (await sRes.json()) as { session?: { status: string } };
|
||||
status = sJson.session?.status ?? "";
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== "running" && status !== "pending") return;
|
||||
|
||||
const url = `/api/projects/${projectId}/agent/sessions/${activeSessionId}/events/stream?afterSeq=${maxStreamSeqRef.current}`;
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onmessage = (msg: MessageEvent<string>) => {
|
||||
try {
|
||||
const data = JSON.parse(msg.data) as Record<string, unknown>;
|
||||
if (data.type === "_heartbeat") return;
|
||||
if (data.type === "_stream.end" || data.type === "_stream.error") {
|
||||
es?.close();
|
||||
return;
|
||||
}
|
||||
if (typeof data.seq !== "number") return;
|
||||
|
||||
maxStreamSeqRef.current = data.seq as number;
|
||||
const line = streamRowToLine(data as StreamEventRow);
|
||||
|
||||
setEventLog((prev) => {
|
||||
if (prev.some((p) => p.seq === line.seq)) return prev;
|
||||
if (prev.length === 0 && pollOutputRef.current.length > 0) {
|
||||
const seed = pollOutputRef.current.map((l, i) => ({
|
||||
ts: l.ts,
|
||||
type: l.type,
|
||||
text: l.text,
|
||||
seq: -(i + 1),
|
||||
}));
|
||||
return [...seed, line];
|
||||
}
|
||||
return [...prev, line];
|
||||
});
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
es?.close();
|
||||
};
|
||||
}, [activeSessionId, projectId, appName]);
|
||||
|
||||
// Load session list — auto-select the most recent active or last session
|
||||
useEffect(() => {
|
||||
if (!appName) return;
|
||||
@@ -447,22 +566,26 @@ function AgentMode({ projectId, appName, appPath }: { projectId: string; appName
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output stream */}
|
||||
{/* Output stream — prefer persisted event timeline + SSE when available */}
|
||||
<div ref={outputRef} style={{ flex: 1, overflow: "auto", background: "#1a1a1a", padding: "16px 20px", fontFamily: "IBM Plex Mono, monospace", fontSize: "0.72rem", lineHeight: 1.6 }}>
|
||||
{activeSession.output.length === 0 && (
|
||||
<span style={{ color: "#555" }}>Starting agent…</span>
|
||||
)}
|
||||
{activeSession.output.map((line, i) => {
|
||||
const color = line.type === "error" ? "#f87171" : line.type === "stderr" ? "#fb923c"
|
||||
: line.type === "info" ? "#60a5fa" : line.type === "step" ? "#a78bfa" : "#d4d4d4";
|
||||
const prefix = line.type === "step" ? "▶ " : line.type === "error" ? "✗ "
|
||||
: line.type === "info" ? "→ " : " ";
|
||||
return (
|
||||
<div key={i} style={{ color, marginBottom: 1 }}>
|
||||
{prefix}{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
const displayLines: AgentLogLine[] =
|
||||
eventLog.length > 0 ? eventLog : activeSession.output.map((l) => ({ ts: l.ts, type: l.type, text: l.text }));
|
||||
if (displayLines.length === 0) {
|
||||
return <span style={{ color: "#555" }}>Starting agent…</span>;
|
||||
}
|
||||
return displayLines.map((line, i) => {
|
||||
const color = line.type === "error" ? "#f87171" : line.type === "stderr" ? "#fb923c"
|
||||
: line.type === "info" ? "#60a5fa" : line.type === "step" ? "#a78bfa" : line.type === "done" ? "#86efac" : "#d4d4d4";
|
||||
const prefix = line.type === "step" ? "▶ " : line.type === "error" ? "✗ "
|
||||
: line.type === "info" ? "→ " : line.type === "done" ? "✓ " : " ";
|
||||
return (
|
||||
<div key={line.seq ?? `o-${i}`} style={{ color, marginBottom: 1 }}>
|
||||
{prefix}{line.text}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{["running", "pending"].includes(activeSession.status) && (
|
||||
<span style={{ color: "#555", animation: "pulse 1.5s infinite" }}>▌</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user