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

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