feat(FN-1764): merge fusion/fn-1764

This commit is contained in:
gsxdsm
2026-04-14 11:29:16 -07:00
parent 81b662b526
commit c7252a07f1
7 changed files with 708 additions and 196 deletions

View File

@@ -21,6 +21,12 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
/**
* Hook that manages agent log fetching and live SSE streaming for a task.
*
* Features project-context isolation to prevent cross-project log bleed:
* - Treats `{projectId, taskId}` as a context key
* - Clears entries immediately on context change (project or task switch)
* - Rejects late fetch responses from previous contexts
* - Rejects stale SSE events from previous EventSource instances
*
* 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
@@ -32,7 +38,46 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
const [loading, setLoading] = useState(false);
// Refs for state that needs to survive re-renders
const eventSourceRef = useRef<EventSource | null>(null);
const cancelledRef = useRef(false);
// Track the project context version to detect stale SSE events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
const projectContextVersionRef = useRef(0);
// Track previous values to detect context changes
const previousTaskIdRef = useRef<string | null>(taskId);
const previousProjectIdRef = useRef<string | undefined>(projectId);
const previousEnabledRef = useRef(enabled);
// Track request version to reject stale fetch completions
const requestVersionRef = useRef(0);
// Detect context changes and clear state immediately
const contextChanged =
previousTaskIdRef.current !== taskId ||
previousProjectIdRef.current !== projectId ||
previousEnabledRef.current !== enabled;
if (contextChanged) {
previousTaskIdRef.current = taskId;
previousProjectIdRef.current = projectId;
previousEnabledRef.current = enabled;
projectContextVersionRef.current++;
cancelledRef.current = true;
// Clear entries immediately on context change to prevent stale data visibility
setEntries([]);
setLoading(false);
// Close existing EventSource
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
useEffect(() => {
if (!taskId || !enabled) {
@@ -44,32 +89,57 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
return;
}
let cancelled = false;
// Capture context version at effect start - stale SSE events will be rejected
const contextVersionAtStart = projectContextVersionRef.current;
const requestVersion = ++requestVersionRef.current;
cancelledRef.current = false;
// Capture taskId and projectId at effect start for comparison
const currentTaskId = taskId;
const currentProjectId = projectId;
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;
const historical = await fetchAgentLogs(currentTaskId, currentProjectId, { limit: MAX_LOG_ENTRIES });
// Reject stale response: check context version and request version
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart ||
requestVersionRef.current !== requestVersion) {
return;
}
setEntries(capLogEntries(historical));
} catch {
if (cancelled) return;
// Reject stale error: check context version and request version
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart ||
requestVersionRef.current !== requestVersion) {
return;
}
setEntries([]);
} finally {
if (!cancelled) setLoading(false);
// Only update loading state if not cancelled and not stale
if (!cancelledRef.current &&
projectContextVersionRef.current === contextVersionAtStart &&
requestVersionRef.current === requestVersion) {
setLoading(false);
}
}
// Open SSE connection for live updates
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const query = currentProjectId ? `?projectId=${encodeURIComponent(currentProjectId)}` : "";
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream${query}`);
eventSourceRef.current = es;
es.addEventListener("agent:log", (e) => {
if (cancelled) return;
// Reject events from stale contexts (project/task switch)
if (cancelledRef.current ||
projectContextVersionRef.current !== contextVersionAtStart) {
return;
}
try {
const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => capLogEntries([...prev, entry]));
@@ -82,7 +152,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
void init();
return () => {
cancelled = true;
cancelledRef.current = true;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;