feat(KB-136): add shared badge pub/sub for multi-instance deployments

- Add BadgePubSub interface with Redis and in-memory adapters

- Implement cross-instance badge snapshot delivery via Redis pub/sub

- Add message validation, echo loop prevention, and structured caching

- Support graceful fallback to in-memory mode when Redis unavailable

- Update ServerOptions for dependency injection and add documentation
This commit is contained in:
gsxdsm
2026-03-30 12:52:07 -07:00
parent fc0ce7cf95
commit 2dbcf2433e
14 changed files with 1341 additions and 23 deletions

View File

@@ -12,8 +12,10 @@ import { getTerminalService, type TerminalSession } from "./terminal-service.js"
import { WebSocketServer, type WebSocket } from "ws";
import { terminalSessionManager } from "./terminal.js";
import { getCurrentGitHubRepo } from "./github.js";
import { githubPoller, type TaskWatchInput } from "./github-poll.js";
import { WebSocketManager } from "./websocket.js";
import { githubPoller as globalGithubPoller, GitHubPollingService, type TaskWatchInput } from "./github-poll.js";
import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
import type { BadgePubSub } from "./badge-pubsub.js";
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -28,6 +30,10 @@ export interface ServerOptions {
authStorage?: AuthStorageLike;
/** Optional ModelRegistry instance for the models API — if not provided, the endpoint returns an empty list */
modelRegistry?: ModelRegistryLike;
/** Optional BadgePubSub adapter for cross-instance badge snapshot fan-out — if not provided, creates from env or falls back to in-memory */
badgePubSub?: BadgePubSub;
/** Optional GitHubPollingService instance for per-server poller isolation — if not provided, uses the global singleton */
githubPoller?: GitHubPollingService;
}
type DashboardExpressApp = ReturnType<typeof express> & {
@@ -350,17 +356,34 @@ export function setupBadgeWebSocket(
): void {
const dashboardApp = app as DashboardExpressApp;
const wsManager = new WebSocketManager();
const badgeSnapshots = new Map<string, string>();
// Structured badge snapshot cache for local subscriptions and pub/sub sync
// Maps taskId -> BadgeSnapshot with timestamp
const badgeSnapshots = new Map<string, BadgeSnapshot>();
// Server instance ID for pub/sub deduplication
const serverId = randomUUID();
// Use injected badgePubSub or create from environment
const badgePubSub = options?.badgePubSub ?? createBadgePubSub({ sourceId: serverId });
void badgePubSub.start();
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
const githubPoller = options?.githubPoller ?? globalGithubPoller;
githubPoller.configure({
store,
token: githubToken,
});
// Prime cache with existing tasks
void store.listTasks().then((tasks) => {
for (const task of tasks) {
badgeSnapshots.set(task.id, serializeBadgeSnapshot(task));
badgeSnapshots.set(task.id, {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
});
}
}).catch(() => {
// Best-effort cache prime only
@@ -426,31 +449,50 @@ export function setupBadgeWebSocket(
}
};
const broadcastBadgeSnapshot = (task: Task): void => {
wsManager.broadcastBadgeUpdate(task.id, {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
});
const broadcastBadgeSnapshot = (taskId: string, snapshot: BadgeSnapshot): void => {
wsManager.broadcastBadgeUpdate(taskId, snapshot);
};
const onTaskUpdated = (task: Task) => {
const nextSnapshot = serializeBadgeSnapshot(task);
const previousSnapshot = badgeSnapshots.get(task.id);
const nextSnapshot: BadgeSnapshot = {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
};
// Update local cache immediately
badgeSnapshots.set(task.id, nextSnapshot);
if (previousSnapshot === nextSnapshot) {
// Check if badge data actually changed
if (snapshotsEqual(previousSnapshot, nextSnapshot)) {
return;
}
// Always publish to shared bus (even if no local subscribers)
// This ensures other instances receive the update
const pubSubMessage: BadgePubSubMessage = {
sourceId: serverId,
taskId: task.id,
timestamp: nextSnapshot.timestamp,
prInfo: nextSnapshot.prInfo,
issueInfo: nextSnapshot.issueInfo,
};
void badgePubSub.publish(pubSubMessage);
// Broadcast to local websocket subscribers if any
if (wsManager.getSubscriptionCount(task.id) > 0) {
broadcastBadgeSnapshot(task);
broadcastBadgeSnapshot(task.id, nextSnapshot);
void syncPollerTask(task.id);
}
};
const onTaskCreated = (task: Task) => {
badgeSnapshots.set(task.id, serializeBadgeSnapshot(task));
badgeSnapshots.set(task.id, {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
});
};
const onTaskDeleted = (task: Task) => {
@@ -462,6 +504,23 @@ export function setupBadgeWebSocket(
store.on("task:created", onTaskCreated);
store.on("task:deleted", onTaskDeleted);
// Handle remote badge updates from other instances via pub/sub
badgePubSub.on("message", (message: BadgePubSubMessage) => {
// Update local cache with remote snapshot
const remoteSnapshot: BadgeSnapshot = {
prInfo: message.prInfo,
issueInfo: message.issueInfo,
timestamp: message.timestamp,
};
badgeSnapshots.set(message.taskId, remoteSnapshot);
// Rebroadcast to local websocket subscribers
// (No need to check for echo - pub/sub adapter already filtered our own messages)
if (wsManager.getSubscriptionCount(message.taskId) > 0) {
broadcastBadgeSnapshot(message.taskId, remoteSnapshot);
}
});
wsManager.on("client:connected", (_clientId, totalClients) => {
if (totalClients === 1) {
githubPoller.start();
@@ -480,6 +539,13 @@ export function setupBadgeWebSocket(
return;
}
// Send cached snapshot to late subscriber if available
// This ensures a client subscribing after a remote update still sees the latest state
const cachedSnapshot = badgeSnapshots.get(taskId);
if (cachedSnapshot) {
broadcastBadgeSnapshot(taskId, cachedSnapshot);
}
void syncPollerTask(taskId);
});
@@ -497,6 +563,7 @@ export function setupBadgeWebSocket(
}
wsManager.dispose();
void badgePubSub.dispose();
githubPoller.reset();
wss.close();
dashboardApp.terminalWsServer = null;
@@ -506,11 +573,24 @@ export function setupBadgeWebSocket(
});
}
function serializeBadgeSnapshot(task: Pick<Task, "id" | "prInfo" | "issueInfo">): string {
return JSON.stringify({
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
});
/** Compare two badge snapshots for equality */
function snapshotsEqual(a: BadgeSnapshot | undefined, b: BadgeSnapshot | undefined): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
// Compare prInfo
if (a.prInfo?.url !== b.prInfo?.url) return false;
if (a.prInfo?.status !== b.prInfo?.status) return false;
if (a.prInfo?.number !== b.prInfo?.number) return false;
if (a.prInfo?.title !== b.prInfo?.title) return false;
// Compare issueInfo
if (a.issueInfo?.url !== b.issueInfo?.url) return false;
if (a.issueInfo?.state !== b.issueInfo?.state) return false;
if (a.issueInfo?.number !== b.issueInfo?.number) return false;
if (a.issueInfo?.title !== b.issueInfo?.title) return false;
return true;
}
function resolveBadgeRepo(url: string, store: TaskStore): { owner: string; repo: string } | null {