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:
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user