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:
gsxdsm
2026-04-02 19:34:35 -07:00
parent 24d04ca63a
commit 74c0f8fdc2
7 changed files with 537 additions and 112 deletions

View File

@@ -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([]);

View File

@@ -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);

View File

@@ -0,0 +1,178 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Response, Request } from "express";
import { createSSE, getActiveSSEConnections } from "../sse.js";
/** Minimal mock TaskStore — just needs EventEmitter behaviour. */
function createMockStore() {
const emitter = new EventEmitter();
emitter.setMaxListeners(50);
return emitter as any;
}
/** Create a mock Express response with a writeable buffer. */
function createMockResponse() {
const chunks: string[] = [];
const res = {
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn((data: string) => {
chunks.push(data);
return true;
}),
writableEnded: false,
destroyed: false,
} as unknown as Response;
return { res, chunks };
}
/** Create a mock Express request that can fire 'close'. */
function createMockRequest() {
const emitter = new EventEmitter();
return emitter as unknown as Request;
}
describe("createSSE", () => {
let store: ReturnType<typeof createMockStore>;
beforeEach(() => {
store = createMockStore();
});
it("writes initial connected comment", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
expect(chunks[0]).toBe(": connected\n\n");
});
it("relays task:created events as SSE messages", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
const task = { id: "FN-001", description: "test" };
store.emit("task:created", task);
const sseMsg = chunks.find((c) => c.includes("task:created"));
expect(sseMsg).toBeDefined();
expect(sseMsg).toContain(JSON.stringify(task));
});
it("relays task:moved events as SSE messages", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
const data = { task: { id: "FN-001" }, from: "triage", to: "todo" };
store.emit("task:moved", data);
const sseMsg = chunks.find((c) => c.includes("task:moved"));
expect(sseMsg).toBeDefined();
expect(sseMsg).toContain(JSON.stringify(data));
});
it("relays task:updated events as SSE messages", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
const task = { id: "FN-001", title: "Updated" };
store.emit("task:updated", task);
const sseMsg = chunks.find((c) => c.includes("task:updated"));
expect(sseMsg).toBeDefined();
});
it("relays task:deleted events as SSE messages", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
const task = { id: "FN-001" };
store.emit("task:deleted", task);
const sseMsg = chunks.find((c) => c.includes("task:deleted"));
expect(sseMsg).toBeDefined();
});
it("relays task:merged events as SSE messages", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
const result = { task: { id: "FN-001" }, success: true };
store.emit("task:merged", result);
const sseMsg = chunks.find((c) => c.includes("task:merged"));
expect(sseMsg).toBeDefined();
});
it("cleans up listeners when client disconnects", () => {
const req = createMockRequest();
const { res } = createMockResponse();
createSSE(store)(req, res);
const before = store.listenerCount("task:created");
expect(before).toBe(1);
// Simulate client disconnect
req.emit("close");
expect(store.listenerCount("task:created")).toBe(0);
expect(store.listenerCount("task:moved")).toBe(0);
expect(store.listenerCount("task:updated")).toBe(0);
expect(store.listenerCount("task:deleted")).toBe(0);
expect(store.listenerCount("task:merged")).toBe(0);
});
it("stops writing when response is destroyed", () => {
const req = createMockRequest();
const { res, chunks } = createMockResponse();
createSSE(store)(req, res);
// Mark response as destroyed
(res as any).destroyed = true;
const initialCount = chunks.length;
store.emit("task:created", { id: "FN-001" });
// No new chunks should be written
expect(chunks.length).toBe(initialCount);
});
it("stops writing and cleans up when res.write throws", () => {
const req = createMockRequest();
const { res } = createMockResponse();
createSSE(store)(req, res);
// Make write throw on next call
(res.write as any).mockImplementation(() => {
throw new Error("Socket closed");
});
// This should not throw — the error is caught internally
expect(() => store.emit("task:created", { id: "FN-001" })).not.toThrow();
// Listeners should be cleaned up
expect(store.listenerCount("task:created")).toBe(0);
});
it("tracks active connection count", () => {
const req1 = createMockRequest();
const { res: res1 } = createMockResponse();
const req2 = createMockRequest();
const { res: res2 } = createMockResponse();
const initial = getActiveSSEConnections();
createSSE(store)(req1, res1);
expect(getActiveSSEConnections()).toBe(initial + 1);
createSSE(store)(req2, res2);
expect(getActiveSSEConnections()).toBe(initial + 2);
req1.emit("close");
expect(getActiveSSEConnections()).toBe(initial + 1);
req2.emit("close");
expect(getActiveSSEConnections()).toBe(initial);
});
});

View File

@@ -8,6 +8,21 @@ export function getActiveSSEConnections(): number {
return activeConnections;
}
/**
* Safely write to an SSE response stream.
* Returns `true` if the write succeeded, `false` if the connection is dead.
* On failure the caller should clean up event listeners.
*/
function safeWrite(res: Response, data: string): boolean {
try {
if (res.writableEnded || res.destroyed) return false;
res.write(data);
return true;
} catch {
return false;
}
}
export function createSSE(store: TaskStore, missionStore?: MissionStore) {
return (_req: Request, res: Response) => {
res.setHeader("Content-Type", "text/event-stream");
@@ -21,95 +36,11 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore) {
// Send initial heartbeat
res.write(": connected\n\n");
const onCreated = (task: any) => {
res.write(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
};
const onMoved = (data: any) => {
res.write(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
};
const onUpdated = (task: any) => {
res.write(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
};
const onDeleted = (task: any) => {
res.write(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
};
const onMerged = (result: any) => {
res.write(`event: task:merged\ndata: ${JSON.stringify(result)}\n\n`);
};
store.on("task:created", onCreated);
store.on("task:moved", onMoved);
store.on("task:updated", onUpdated);
store.on("task:deleted", onDeleted);
store.on("task:merged", onMerged);
// Mission store event listeners (only wired up when missionStore is provided)
const onMissionCreated = (data: any) => {
res.write(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMissionUpdated = (data: any) => {
res.write(`event: mission:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMissionDeleted = (data: any) => {
res.write(`event: mission:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMilestoneCreated = (data: any) => {
res.write(`event: milestone:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMilestoneUpdated = (data: any) => {
res.write(`event: milestone:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMilestoneDeleted = (data: any) => {
res.write(`event: milestone:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceCreated = (data: any) => {
res.write(`event: slice:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceUpdated = (data: any) => {
res.write(`event: slice:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceDeleted = (data: any) => {
res.write(`event: slice:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceActivated = (data: any) => {
res.write(`event: slice:activated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureCreated = (data: any) => {
res.write(`event: feature:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureUpdated = (data: any) => {
res.write(`event: feature:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureDeleted = (data: any) => {
res.write(`event: feature:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureLinked = (data: any) => {
res.write(`event: feature:linked\ndata: ${JSON.stringify(data)}\n\n`);
};
if (missionStore) {
missionStore.on("mission:created", onMissionCreated);
missionStore.on("mission:updated", onMissionUpdated);
missionStore.on("mission:deleted", onMissionDeleted);
missionStore.on("milestone:created", onMilestoneCreated);
missionStore.on("milestone:updated", onMilestoneUpdated);
missionStore.on("milestone:deleted", onMilestoneDeleted);
missionStore.on("slice:created", onSliceCreated);
missionStore.on("slice:updated", onSliceUpdated);
missionStore.on("slice:deleted", onSliceDeleted);
missionStore.on("slice:activated", onSliceActivated);
missionStore.on("feature:created", onFeatureCreated);
missionStore.on("feature:updated", onFeatureUpdated);
missionStore.on("feature:deleted", onFeatureDeleted);
missionStore.on("feature:linked", onFeatureLinked);
}
// Heartbeat every 30s to keep connection alive
const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n");
}, 30_000);
_req.on("close", () => {
/** Detach all listeners and clean up. Idempotent. */
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
activeConnections--;
clearInterval(heartbeat);
store.off("task:created", onCreated);
@@ -133,6 +64,104 @@ export function createSSE(store: TaskStore, missionStore?: MissionStore) {
missionStore.off("feature:deleted", onFeatureDeleted);
missionStore.off("feature:linked", onFeatureLinked);
}
});
};
/** Write an SSE message; clean up on failure. */
const send = (data: string) => {
if (!safeWrite(res, data)) cleanup();
};
const onCreated = (task: any) => {
send(`event: task:created\ndata: ${JSON.stringify(task)}\n\n`);
};
const onMoved = (data: any) => {
send(`event: task:moved\ndata: ${JSON.stringify(data)}\n\n`);
};
const onUpdated = (task: any) => {
send(`event: task:updated\ndata: ${JSON.stringify(task)}\n\n`);
};
const onDeleted = (task: any) => {
send(`event: task:deleted\ndata: ${JSON.stringify(task)}\n\n`);
};
const onMerged = (result: any) => {
send(`event: task:merged\ndata: ${JSON.stringify(result)}\n\n`);
};
store.on("task:created", onCreated);
store.on("task:moved", onMoved);
store.on("task:updated", onUpdated);
store.on("task:deleted", onDeleted);
store.on("task:merged", onMerged);
// Mission store event listeners (only wired up when missionStore is provided)
const onMissionCreated = (data: any) => {
send(`event: mission:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMissionUpdated = (data: any) => {
send(`event: mission:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMissionDeleted = (data: any) => {
send(`event: mission:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMilestoneCreated = (data: any) => {
send(`event: milestone:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMilestoneUpdated = (data: any) => {
send(`event: milestone:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onMilestoneDeleted = (data: any) => {
send(`event: milestone:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceCreated = (data: any) => {
send(`event: slice:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceUpdated = (data: any) => {
send(`event: slice:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceDeleted = (data: any) => {
send(`event: slice:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onSliceActivated = (data: any) => {
send(`event: slice:activated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureCreated = (data: any) => {
send(`event: feature:created\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureUpdated = (data: any) => {
send(`event: feature:updated\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureDeleted = (data: any) => {
send(`event: feature:deleted\ndata: ${JSON.stringify(data)}\n\n`);
};
const onFeatureLinked = (data: any) => {
send(`event: feature:linked\ndata: ${JSON.stringify(data)}\n\n`);
};
if (missionStore) {
missionStore.on("mission:created", onMissionCreated);
missionStore.on("mission:updated", onMissionUpdated);
missionStore.on("mission:deleted", onMissionDeleted);
missionStore.on("milestone:created", onMilestoneCreated);
missionStore.on("milestone:updated", onMilestoneUpdated);
missionStore.on("milestone:deleted", onMilestoneDeleted);
missionStore.on("slice:created", onSliceCreated);
missionStore.on("slice:updated", onSliceUpdated);
missionStore.on("slice:deleted", onSliceDeleted);
missionStore.on("slice:activated", onSliceActivated);
missionStore.on("feature:created", onFeatureCreated);
missionStore.on("feature:updated", onFeatureUpdated);
missionStore.on("feature:deleted", onFeatureDeleted);
missionStore.on("feature:linked", onFeatureLinked);
}
// Heartbeat every 30s to keep connection alive.
// Sent as a named event so the client's EventSource can detect it
// (SSE comments starting with ":" are silently consumed and never
// fire event listeners in the browser).
const heartbeat = setInterval(() => {
send("event: heartbeat\ndata: \n\n");
}, 30_000);
_req.on("close", cleanup);
};
}