feat(KB-063): add realtime GitHub badge updates

- Add a focused GitHub badge poller with shared rate-limit, freshness, and cleanup handling
- Wire a dedicated badge websocket server and routes to stream PR and issue badge snapshots
- Add a shared useBadgeWebSocket hook and update TaskCard to subscribe only when visible while preserving partial badge state
- Cover polling, websocket, and task card flows with tests and document the realtime badge channel
This commit is contained in:
gsxdsm
2026-03-30 03:19:51 -07:00
parent bce16bd817
commit 89b2899935
21 changed files with 3176 additions and 199 deletions

View File

@@ -1,8 +1,9 @@
import express from "express";
import { randomUUID } from "node:crypto";
import { join, dirname } from "node:path";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { TaskStore, MergeResult } from "@kb/core";
import type { Task, TaskStore, MergeResult } from "@kb/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
@@ -10,6 +11,9 @@ import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
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";
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -26,12 +30,19 @@ export interface ServerOptions {
modelRegistry?: ModelRegistryLike;
}
type DashboardExpressApp = ReturnType<typeof express> & {
terminalWsServer?: WebSocketServer | null;
badgeWsServer?: WebSocketServer | null;
badgeWsManager?: WebSocketManager | null;
__kbWebSocketsAttached?: boolean;
};
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
const app = express();
app.use(express.json());
// Initialize terminal service with project root
const terminalService = getTerminalService(store.getRootDir());
getTerminalService(store.getRootDir());
// Serve built React app
// Resolution order:
@@ -95,7 +106,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
res.write(": connected\n\n");
const session = terminalSessionManager.getSession(sessionId);
// If session doesn't exist, send error and close
if (!session) {
res.write(`event: terminal:error\ndata: ${JSON.stringify({ message: "Session not found" })}\n\n`);
@@ -119,7 +130,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
// Listen for new output
const onOutput = (event: import("./terminal.js").TerminalOutputEvent) => {
if (event.sessionId !== sessionId) return;
if (event.type === "exit") {
res.write(`event: terminal:exit\ndata: ${JSON.stringify({ exitCode: event.exitCode })}\n\n`);
res.end();
@@ -152,10 +163,26 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
res.sendFile(join(clientDir, "index.html"));
});
// Store WebSocket server reference for external mounting
(app as ReturnType<typeof express> & { wsServer?: WebSocketServer }).wsServer = null as unknown as WebSocketServer;
const dashboardApp = app as DashboardExpressApp;
dashboardApp.terminalWsServer = null;
dashboardApp.badgeWsServer = null;
dashboardApp.badgeWsManager = null;
dashboardApp.__kbWebSocketsAttached = false;
return app;
const originalListen = dashboardApp.listen.bind(dashboardApp);
dashboardApp.listen = ((...args: Parameters<typeof dashboardApp.listen>) => {
const server = originalListen(...args);
if (!dashboardApp.__kbWebSocketsAttached) {
dashboardApp.__kbWebSocketsAttached = true;
setupTerminalWebSocket(dashboardApp, server);
setupBadgeWebSocket(dashboardApp, server, store, options);
}
return server;
}) as typeof dashboardApp.listen;
return dashboardApp;
}
/**
@@ -164,17 +191,25 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
*/
export function setupTerminalWebSocket(
app: ReturnType<typeof express>,
server: import("http").Server
server: import("http").Server,
): void {
const terminalService = getTerminalService();
const wss = new WebSocketServer({
server,
path: "/api/terminal/ws",
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== "/api/terminal/ws") {
return;
}
wss.handleUpgrade(req, socket, head, (upgraded) => {
wss.emit("connection", upgraded, req);
});
});
// Store reference on app for access
(app as ReturnType<typeof express> & { wsServer?: WebSocketServer }).wsServer = wss;
(app as DashboardExpressApp).terminalWsServer = wss;
wss.on("connection", (ws: WebSocket, req) => {
// Parse query params from URL
@@ -253,7 +288,7 @@ export function setupTerminalWebSocket(
ws.on("message", (message: Buffer) => {
try {
const msg = JSON.parse(message.toString());
switch (msg.type) {
case "input":
if (typeof msg.data === "string") {
@@ -292,5 +327,211 @@ export function setupTerminalWebSocket(
});
});
console.log(`Terminal WebSocket server mounted at /api/terminal/ws`);
console.log("Terminal WebSocket server mounted at /api/terminal/ws");
}
export function setupBadgeWebSocket(
app: ReturnType<typeof express>,
server: import("http").Server,
store: TaskStore,
options?: ServerOptions,
): void {
const dashboardApp = app as DashboardExpressApp;
const wsManager = new WebSocketManager();
const badgeSnapshots = new Map<string, string>();
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
githubPoller.configure({
store,
token: githubToken,
});
void store.listTasks().then((tasks) => {
for (const task of tasks) {
badgeSnapshots.set(task.id, serializeBadgeSnapshot(task));
}
}).catch(() => {
// Best-effort cache prime only
});
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== "/api/ws") {
return;
}
wss.handleUpgrade(req, socket, head, (upgraded) => {
wss.emit("connection", upgraded, req);
});
});
dashboardApp.badgeWsServer = wss;
dashboardApp.badgeWsManager = wsManager;
const syncPollerTask = async (taskId: string): Promise<void> => {
if (wsManager.getSubscriptionCount(taskId) === 0) {
githubPoller.unwatchTask(taskId);
return;
}
try {
const task = await store.getTask(taskId);
const watches: TaskWatchInput[] = [];
if (task.prInfo) {
const repo = resolveBadgeRepo(task.prInfo.url, store);
if (repo) {
watches.push({
taskId: task.id,
type: "pr",
owner: repo.owner,
repo: repo.repo,
number: task.prInfo.number,
});
}
}
if (task.issueInfo) {
const repo = resolveBadgeRepo(task.issueInfo.url, store);
if (repo) {
watches.push({
taskId: task.id,
type: "issue",
owner: repo.owner,
repo: repo.repo,
number: task.issueInfo.number,
});
}
}
githubPoller.replaceTaskWatches(task.id, watches);
} catch (err: any) {
if (err?.code === "ENOENT") {
githubPoller.unwatchTask(taskId);
}
}
};
const broadcastBadgeSnapshot = (task: Task): void => {
wsManager.broadcastBadgeUpdate(task.id, {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
});
};
const onTaskUpdated = (task: Task) => {
const nextSnapshot = serializeBadgeSnapshot(task);
const previousSnapshot = badgeSnapshots.get(task.id);
badgeSnapshots.set(task.id, nextSnapshot);
if (previousSnapshot === nextSnapshot) {
return;
}
if (wsManager.getSubscriptionCount(task.id) > 0) {
broadcastBadgeSnapshot(task);
void syncPollerTask(task.id);
}
};
const onTaskCreated = (task: Task) => {
badgeSnapshots.set(task.id, serializeBadgeSnapshot(task));
};
const onTaskDeleted = (task: Task) => {
badgeSnapshots.delete(task.id);
githubPoller.unwatchTask(task.id);
};
store.on("task:updated", onTaskUpdated);
store.on("task:created", onTaskCreated);
store.on("task:deleted", onTaskDeleted);
wsManager.on("client:connected", (_clientId, totalClients) => {
if (totalClients === 1) {
githubPoller.start();
}
});
wsManager.on("client:disconnected", (_clientId, totalClients) => {
if (totalClients === 0) {
githubPoller.stop();
}
});
wsManager.on("subscription:changed", (taskId, subscriberCount) => {
if (subscriberCount === 0) {
githubPoller.unwatchTask(taskId);
return;
}
void syncPollerTask(taskId);
});
wss.on("connection", (ws: WebSocket) => {
wsManager.addClient(ws, randomUUID());
});
server.once("close", () => {
store.off("task:updated", onTaskUpdated);
store.off("task:created", onTaskCreated);
store.off("task:deleted", onTaskDeleted);
for (const client of wss.clients) {
client.terminate();
}
wsManager.dispose();
githubPoller.reset();
wss.close();
dashboardApp.terminalWsServer = null;
dashboardApp.badgeWsServer = null;
dashboardApp.badgeWsManager = null;
dashboardApp.__kbWebSocketsAttached = false;
});
}
function serializeBadgeSnapshot(task: Pick<Task, "id" | "prInfo" | "issueInfo">): string {
return JSON.stringify({
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
});
}
function resolveBadgeRepo(url: string, store: TaskStore): { owner: string; repo: string } | null {
const parsedUrl = parseGitHubBadgeUrl(url);
if (parsedUrl) {
return parsedUrl;
}
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [owner, repo] = envRepo.split("/");
if (owner && repo) {
return { owner, repo };
}
}
return getCurrentGitHubRepo(store.getRootDir());
}
function parseGitHubBadgeUrl(url: string): { owner: string; repo: string } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") {
return null;
}
const [owner, repo] = parsed.pathname.split("/").filter(Boolean);
if (!owner || !repo) {
return null;
}
return { owner, repo };
} catch {
return null;
}
}