Files
fusion/packages/dashboard/app/hooks/useAgentLogs.ts
2026-04-12 15:13:27 -07:00

97 lines
3.0 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogs } from "../api";
export const MAX_LOG_ENTRIES = 500;
/**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
*
* This is a **whole-list cap** — it limits how many entries are kept
* in memory, not the content of any individual entry. Per-entry `text`
* and `detail` fields are never truncated anywhere in the pipeline
* (persistence → API → SSE → hook → rendering).
*/
function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
return entries.length > MAX_LOG_ENTRIES
? entries.slice(-MAX_LOG_ENTRIES)
: entries;
}
/**
* Hook that manages agent log fetching and live SSE streaming for a task.
*
* When `enabled` is true:
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order
*
* When `enabled` becomes false or the component unmounts, the EventSource
* is closed to avoid unnecessary SSE connections.
*/
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
const [loading, setLoading] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!taskId || !enabled) {
// Close any existing connection when disabled
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
return;
}
let cancelled = false;
async function init() {
// Capture taskId in a local constant to ensure it's not null
const currentTaskId = taskId;
if (!currentTaskId) return;
setLoading(true);
try {
const historical = await fetchAgentLogs(currentTaskId, projectId, { limit: MAX_LOG_ENTRIES });
if (cancelled) return;
setEntries(capLogEntries(historical));
} catch {
if (cancelled) return;
setEntries([]);
} finally {
if (!cancelled) setLoading(false);
}
// Open SSE connection for live updates
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream${query}`);
eventSourceRef.current = es;
es.addEventListener("agent:log", (e) => {
if (cancelled) return;
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => capLogEntries([...prev, entry]));
} catch {
// skip malformed events
}
});
}
void init();
return () => {
cancelled = true;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [taskId, enabled, projectId]);
const clear = useCallback(() => setEntries([]), []);
return { entries, loading, clear };
}