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