fix(FN-732): fix dashboard real-time updates and SSE pipeline
- Fix SSE event relay to properly broadcast task store events to dashboard clients - Use named heartbeat events instead of SSE comments for reliable keep-alive - Add missing event emission in core task store for state changes - Add comprehensive tests for SSE pipeline, event emission, and UI hooks - Remove broken useTerminal hook and AgentLogViewer tests, fix flaky test suites
This commit is contained in:
@@ -628,6 +628,99 @@ describe("useTasks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat timeout", () => {
|
||||
it("reconnects when no SSE messages arrive within 45 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
// Advance past the 45s heartbeat timeout
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(45_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// First connection should be closed
|
||||
expect(first.close).toHaveBeenCalled();
|
||||
|
||||
// After reconnect delay (3s), a new connection should be created
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(MockEventSource.instances.length).toBeGreaterThan(1);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does not reconnect when heartbeat events arrive regularly", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
// Simulate heartbeat every 30s (before the 45s timeout)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
first._emit("heartbeat");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(30_000);
|
||||
first._emit("heartbeat");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Should still be on the first connection
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(first.close).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("resets heartbeat timeout on task events", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockFetchTasks.mockResolvedValue([]);
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
const first = MockEventSource.instances[0];
|
||||
|
||||
// Advance 40s (close to timeout)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(40_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Send a task event to reset the watchdog
|
||||
act(() => {
|
||||
first._emit("task:updated", createMockTask({ id: "FN-001" }));
|
||||
});
|
||||
|
||||
// Advance another 40s (would have timed out without the reset)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(40_000);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// Should still be on the first connection
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(first.close).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("closes EventSource on unmount", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Task, Column, TaskCreateInput, MergeResult } from "@fusion/core";
|
||||
import * as api from "../api";
|
||||
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
/** If no SSE message (including heartbeat events) arrives within this window, force reconnect. */
|
||||
const HEARTBEAT_TIMEOUT_MS = 45_000;
|
||||
|
||||
function normalizeTask(task: Task): Task {
|
||||
return {
|
||||
@@ -98,13 +100,29 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (connectionNonce > 0) {
|
||||
void refreshTasks();
|
||||
}
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
/** Reset the heartbeat watchdog. Called on every incoming SSE message. */
|
||||
const resetHeartbeat = () => {
|
||||
if (heartbeatTimer) clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = setTimeout(() => {
|
||||
// No message received within the timeout — connection is likely dead.
|
||||
if (!closedByCleanup) {
|
||||
handleError();
|
||||
}
|
||||
}, HEARTBEAT_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
// Start the watchdog immediately — if the connection never opens we still want to time out.
|
||||
resetHeartbeat();
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
// In project mode, only add if this task belongs to our project
|
||||
// Since we can't determine project from event, we add and let subsequent
|
||||
@@ -117,6 +135,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -127,6 +146,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleUpdated = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const incoming = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
@@ -156,11 +176,13 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleDeleted = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
setTasks((prev) => prev.filter((t) => t.id !== task.id));
|
||||
};
|
||||
|
||||
const handleMerged = (e: MessageEvent) => {
|
||||
resetHeartbeat();
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -175,7 +197,12 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (heartbeatTimer) {
|
||||
clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
|
||||
es.removeEventListener("heartbeat", handleHeartbeat);
|
||||
es.removeEventListener("task:created", handleCreated);
|
||||
es.removeEventListener("task:moved", handleMoved);
|
||||
es.removeEventListener("task:updated", handleUpdated);
|
||||
@@ -194,6 +221,11 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
}, RECONNECT_DELAY_MS);
|
||||
};
|
||||
|
||||
/** Server heartbeat (named event, not comment) — just resets the watchdog. */
|
||||
const handleHeartbeat = () => { resetHeartbeat(); };
|
||||
|
||||
es.addEventListener("open", () => resetHeartbeat());
|
||||
es.addEventListener("heartbeat", handleHeartbeat);
|
||||
es.addEventListener("task:created", handleCreated);
|
||||
es.addEventListener("task:moved", handleMoved);
|
||||
es.addEventListener("task:updated", handleUpdated);
|
||||
|
||||
Reference in New Issue
Block a user