feat(FN-1745): scope WebSocket channels to project context for multi-project support

- Scope /api/tasks/:id/logs/stream SSE endpoint to resolved project context
- Make badge WebSocket/pubsub channels project-aware using scopeKey
- Fix terminal WebSocket project/service resolution with auth middleware
- Add cross-instance badge broadcast via BadgePubSub with projectId metadata
- Update AGENTS.md documentation for badge WebSocket project-scoping
- Add tests for project-scoped WebSocket and SSE endpoints
- Add tests for project detection in WebSocket upgrade handling
This commit is contained in:
gsxdsm
2026-04-14 08:13:25 -07:00
parent 98bc656a6b
commit a2aa0289a8
8 changed files with 395 additions and 115 deletions

View File

@@ -1420,11 +1420,14 @@ Use `useBadgeWebSocket()` when a UI surface needs live badge snapshots for speci
- Always pair subscriptions with `unsubscribeFromBadge(taskId)` on unmount or when the card leaves the viewport. - Always pair subscriptions with `unsubscribeFromBadge(taskId)` on unmount or when the card leaves the viewport.
- Treat websocket payloads as **timestamped badge snapshots**. Merge them with task data using freshness comparisons so stale cached websocket data does not override newer SSE/task state. - Treat websocket payloads as **timestamped badge snapshots**. Merge them with task data using freshness comparisons so stale cached websocket data does not override newer SSE/task state.
- Preserve omitted fields on partial updates; only treat explicit `null` payloads as badge clears. - Preserve omitted fields on partial updates; only treat explicit `null` payloads as badge clears.
- The frontend does not pass `projectId` to the hook — the project context is resolved server-side from the connection scope.
### Server-side expectations ### Server-side expectations
- `/api/ws` is badge-specific; do **not** reuse it for general task updates. - `/api/ws` is badge-specific; do **not** reuse it for general task updates.
- Badge broadcasts should contain only `prInfo` / `issueInfo` snapshot data, never full task objects. - Badge broadcasts should contain only `prInfo` / `issueInfo` snapshot data, never full task objects.
- **Project-scoped channels**: connections are bound to scope keys derived from `projectId` (defaults to `"default"`). Channel keying uses `badge:{scopeKey}:{taskId}` to prevent collisions across projects with identical task IDs.
- **Cross-instance delivery**: badge messages carry `projectId` metadata and are rebroadcast across instances via `BadgePubSub` (`badge-pubsub.ts`).
- Badge updates are now **push-based via GitHub App webhooks** at `POST /api/github/webhooks`. - Badge updates are now **push-based via GitHub App webhooks** at `POST /api/github/webhooks`.
- The server verifies webhook signatures using `FUSION_GITHUB_WEBHOOK_SECRET`, fetches canonical badge state with GitHub App installation tokens, and broadcasts updates via the existing `task:updated``/api/ws` bridge. - The server verifies webhook signatures using `FUSION_GITHUB_WEBHOOK_SECRET`, fetches canonical badge state with GitHub App installation tokens, and broadcasts updates via the existing `task:updated``/api/ws` bridge.
- Keep the existing 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) as a fallback path when webhook delivery is unavailable. - Keep the existing 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) as a fallback path when webhook delivery is unavailable.

View File

@@ -285,9 +285,24 @@ Key server capabilities:
### Real-time channels ### Real-time channels
- **SSE**: `/api/events` (`sse.ts`) - **SSE**: `/api/events` (`sse.ts`)
- Emits `task:*`, mission events, AI session updates - Emits `task:*`, mission events, AI session updates
- Project-scoped: resolves project context from query param or engine manager
- **Task log stream**: `/api/tasks/:id/logs/stream` (`server.ts`)
- Server-Sent Events endpoint for live task log streaming
- **Project-scoped**: when `projectId` is provided, resolves scoped task store via `getScopedTaskStore()` (prefers `engineManager.getEngine(projectId)?.getTaskStore()`, falls back to `getOrCreateProjectStore(projectId)`, then defaults)
- Listeners are attached to the resolved scoped store and properly detached on connection close
- Unscoped requests fall back to the default store (backward compatible)
- **Badge WebSocket**: `/api/ws` (`setupBadgeWebSocket` in `server.ts`, manager in `websocket.ts`) - **Badge WebSocket**: `/api/ws` (`setupBadgeWebSocket` in `server.ts`, manager in `websocket.ts`)
- Broadcasts lightweight badge snapshots (`prInfo` / `issueInfo`) - WebSocket endpoint for lightweight badge snapshot fan-out
- **Terminal WebSocket**: `/api/terminal/ws` (also in `server.ts`) - **Project-scoped channels**: each connection is bound to a scope key derived from `projectId` (defaults to `"default"` when omitted)
- **Channel keying**: `badge:{scopeKey}:{taskId}` instead of task-only `badge:{taskId}` to prevent collisions across projects with identical task IDs
- **Badge cache**: snapshot cache keys include project scope; broadcasts only reach subscribers in matching scope
- **Cross-instance delivery**: badge messages include `projectId` metadata and are rebroadcast across instances via `BadgePubSub`
- **Backward compatibility**: unscoped clients receive the default scope; unscoped messages default to `"default"` project
- **Terminal WebSocket**: `/api/terminal/ws` (`server.ts`, `terminal-service.ts`)
- WebSocket endpoint for terminal sessions
- **Project-scoped service resolution**: when `projectId` is provided in URL query, resolves terminal service via `getTerminalService(scopedRootDir)` instead of unscoped fallback
- **Scope validation**: websocket attach validates session ownership against resolved project scope; wrong-scope sessions are rejected
- **Backward compatibility**: requests without `projectId` use safe fallback that does not reintroduce cross-project leakage
### Frontend SPA layer ### Frontend SPA layer
- App entry: `packages/dashboard/app/main.tsx` - App entry: `packages/dashboard/app/main.tsx`

View File

@@ -39,7 +39,7 @@
| 4 | **Child-process runtime kill/restart lifecycle has timer races** | **Partially addressed** | `child-process-runtime.ts:347-361` (`killChild`) now clears `sigkillTimer` immediately. `handleUnhealthy` at line 492+ uses generation tracking (`this.generation`) to prevent delayed callbacks from acting on wrong child. However, `this.child` is still nulled immediately after scheduling SIGKILL, which could cause race if SIGKILL fires before null assignment completes. | | 4 | **Child-process runtime kill/restart lifecycle has timer races** | **Partially addressed** | `child-process-runtime.ts:347-361` (`killChild`) now clears `sigkillTimer` immediately. `handleUnhealthy` at line 492+ uses generation tracking (`this.generation`) to prevent delayed callbacks from acting on wrong child. However, `this.child` is still nulled immediately after scheduling SIGKILL, which could cause race if SIGKILL fires before null assignment completes. |
| 5 | **Global limit refresh timer leak** | **Still open** | `project-manager.ts:95-123` still has `setInterval` that is never cleared. `globalSemaphore` is recreated on each refresh but not wired into project admission control. The semaphore is instantiated but never used for actual limiting. | | 5 | **Global limit refresh timer leak** | **Still open** | `project-manager.ts:95-123` still has `setInterval` that is never cleared. `globalSemaphore` is recreated on each refresh but not wired into project admission control. The semaphore is instantiated but never used for actual limiting. |
| 6 | **Multi-project scoping bypass in dashboard mutation routes** | **Partially addressed** | `routes.ts:1422+` (`getScopedStore`) is used in most routes. However, some routes (GitHub import, planning, subtask create) may still use unscoped handlers. Need comprehensive audit of route handlers. | | 6 | **Multi-project scoping bypass in dashboard mutation routes** | **Partially addressed** | `routes.ts:1422+` (`getScopedStore`) is used in most routes. However, some routes (GitHub import, planning, subtask create) may still use unscoped handlers. Need comprehensive audit of route handlers. |
| 7 | **Realtime channels not uniformly project-scoped** | **Still open** | SSE has scoped store support per `useTasks.ts:34-108`. Badge WebSocket (`/api/ws`) subscriptions tied to root store. Client hooks note unfiltered SSE behavior. | | 7 | **Realtime channels not uniformly project-scoped** | **Resolved** | All realtime channels are now project-scoped: `/api/tasks/:id/logs/stream` uses `getScopedTaskStore()` for scoped listener attachment; badge WebSocket uses project+task channel keys (`badge:{scopeKey}:{taskId}`) with cross-instance pub/sub carrying `projectId` metadata; terminal WebSocket validates session scope against resolved project. See `server.ts`, `websocket.ts`, `badge-pubsub.ts`, `terminal-service.ts`. |
| 8 | **CLI extension mutates global console for output capture** | **Still open** | `extension.ts:681-696`, `extension.ts:1015-1034` still monkey-patch `console.log/error`. No structured result return pattern implemented. | | 8 | **CLI extension mutates global console for output capture** | **Still open** | `extension.ts:681-696`, `extension.ts:1015-1034` still monkey-patch `console.log/error`. No structured result return pattern implemented. |
| 9 | **Dashboard command lifecycle leaks signal listeners** | **Still open** | `dashboard.ts:30` still registers `process.on("SIGINT")` without paired teardown. No listener registrar utility. `MaxListenersExceededWarning` still observable in test runs. | | 9 | **Dashboard command lifecycle leaks signal listeners** | **Still open** | `dashboard.ts:30` still registers `process.on("SIGINT")` without paired teardown. No listener registrar utility. `MaxListenersExceededWarning` still observable in test runs. |
| 10 | **AI automation timeout does not cancel underlying work** | **Still open** | `cron-runner.ts:371-439` (`executeAiPromptStep`) uses `Promise.race` with `setTimeout`. The timeout does not abort the running AI session. When timeout fires, the executor continues running until completion or next invocation cleanup. | | 10 | **AI automation timeout does not cancel underlying work** | **Still open** | `cron-runner.ts:371-439` (`executeAiPromptStep`) uses `Promise.race` with `setTimeout`. The timeout does not abort the running AI session. When timeout fires, the executor continues running until completion or next invocation cleanup. |

View File

@@ -5,8 +5,8 @@ import type { IssueInfo, PrInfo } from "@fusion/core";
* Badge snapshot message envelope for shared pub/sub. * Badge snapshot message envelope for shared pub/sub.
* *
* This contract is used for cross-instance badge updates. Each message includes * This contract is used for cross-instance badge updates. Each message includes
* a sourceId (server instance identifier), taskId, timestamp, and optional * a sourceId (server instance identifier), projectId (for cross-project isolation),
* prInfo/issueInfo snapshot data. * taskId, timestamp, and optional prInfo/issueInfo snapshot data.
* *
* Explicit null values indicate a badge was removed; omitted fields mean no change * Explicit null values indicate a badge was removed; omitted fields mean no change
* to that badge type's data. * to that badge type's data.
@@ -14,6 +14,8 @@ import type { IssueInfo, PrInfo } from "@fusion/core";
export interface BadgePubSubMessage { export interface BadgePubSubMessage {
/** Unique identifier for the originating server instance (for deduplication) */ /** Unique identifier for the originating server instance (for deduplication) */
sourceId: string; sourceId: string;
/** Project scope key for cross-project isolation (e.g., project ID or "default") */
projectId?: string;
/** Task identifier */ /** Task identifier */
taskId: string; taskId: string;
/** ISO timestamp when the snapshot was captured */ /** ISO timestamp when the snapshot was captured */

View File

@@ -8450,7 +8450,7 @@ describe("Terminal WebSocket close handler", () => {
const getSessionMock = vi.fn().mockReturnValue({ const getSessionMock = vi.fn().mockReturnValue({
id: "term-ws-test", id: "term-ws-test",
shell: "/bin/zsh", shell: "/bin/zsh",
cwd: "/test/project", cwd: "/fake/root",
scrollbackBuffer: "hello", scrollbackBuffer: "hello",
lastActivityAt: new Date(), lastActivityAt: new Date(),
}); });
@@ -8474,8 +8474,9 @@ describe("Terminal WebSocket close handler", () => {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const store = createMockStore();
setupTerminalWebSocket(app, server); setupTerminalWebSocket(app, server, store);
class FakeWebSocket extends EventEmitter { class FakeWebSocket extends EventEmitter {
send = vi.fn(); send = vi.fn();
close = vi.fn(() => this.emit("close")); close = vi.fn(() => this.emit("close"));
@@ -8504,7 +8505,7 @@ describe("Terminal WebSocket close handler", () => {
const getSessionMock = vi.fn().mockReturnValue({ const getSessionMock = vi.fn().mockReturnValue({
id: "term-ws-err", id: "term-ws-err",
shell: "/bin/zsh", shell: "/bin/zsh",
cwd: "/test/project", cwd: "/fake/root",
lastActivityAt: new Date(), lastActivityAt: new Date(),
}); });
const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null); const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null);
@@ -8527,8 +8528,9 @@ describe("Terminal WebSocket close handler", () => {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const store = createMockStore();
setupTerminalWebSocket(app, server); setupTerminalWebSocket(app, server, store);
class FakeWebSocket extends EventEmitter { class FakeWebSocket extends EventEmitter {
send = vi.fn(); send = vi.fn();
close = vi.fn(() => this.emit("close")); close = vi.fn(() => this.emit("close"));
@@ -8561,7 +8563,7 @@ describe("Terminal WebSocket close handler", () => {
const getSessionMock = vi.fn().mockReturnValue({ const getSessionMock = vi.fn().mockReturnValue({
id: "term-ws-unsub", id: "term-ws-unsub",
shell: "/bin/zsh", shell: "/bin/zsh",
cwd: "/test/project", cwd: "/fake/root",
lastActivityAt: new Date(), lastActivityAt: new Date(),
}); });
const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null); const getScrollbackAndClearPendingMock = vi.fn().mockReturnValue(null);
@@ -8584,8 +8586,9 @@ describe("Terminal WebSocket close handler", () => {
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const store = createMockStore();
setupTerminalWebSocket(app, server); setupTerminalWebSocket(app, server, store);
class FakeWebSocket extends EventEmitter { class FakeWebSocket extends EventEmitter {
send = vi.fn(); send = vi.fn();
close = vi.fn(() => this.emit("close")); close = vi.fn(() => this.emit("close"));

View File

@@ -304,10 +304,12 @@ describe("API Error Handling Middleware", () => {
describe("Terminal WebSocket heartbeat", () => { describe("Terminal WebSocket heartbeat", () => {
let app: ReturnType<typeof express>; let app: ReturnType<typeof express>;
let server: http.Server; let server: http.Server;
let store: TaskStore;
beforeEach(() => { beforeEach(() => {
app = express(); app = express();
server = http.createServer(app); server = http.createServer(app);
store = createMockStore();
vi.useFakeTimers(); vi.useFakeTimers();
vi.spyOn(console, "warn").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "info").mockImplementation(() => {});
@@ -349,7 +351,7 @@ describe("Terminal WebSocket heartbeat", () => {
/** Setup terminal WebSocket and trigger a connection */ /** Setup terminal WebSocket and trigger a connection */
function setupAndConnect(ws: any, req: any): void { function setupAndConnect(ws: any, req: any): void {
const wss = setupTerminalWebSocket(app, server); const wss = setupTerminalWebSocket(app, server, store);
// The function sets up wss on the server's upgrade event. // The function sets up wss on the server's upgrade event.
// We need to access the WebSocketServer directly to emit a connection. // We need to access the WebSocketServer directly to emit a connection.
@@ -368,7 +370,7 @@ describe("Terminal WebSocket heartbeat", () => {
mockTerminalService.getSession.mockReturnValue({ mockTerminalService.getSession.mockReturnValue({
id: "session-1", id: "session-1",
shell: "/bin/bash", shell: "/bin/bash",
cwd: "/project", cwd: "/fake/root",
lastActivityAt: new Date(), lastActivityAt: new Date(),
}); });
@@ -394,7 +396,7 @@ describe("Terminal WebSocket heartbeat", () => {
mockTerminalService.getSession.mockReturnValue({ mockTerminalService.getSession.mockReturnValue({
id: "session-2", id: "session-2",
shell: "/bin/bash", shell: "/bin/bash",
cwd: "/project", cwd: "/fake/root",
lastActivityAt: new Date(), lastActivityAt: new Date(),
}); });
@@ -420,7 +422,7 @@ describe("Terminal WebSocket heartbeat", () => {
mockTerminalService.getSession.mockReturnValue({ mockTerminalService.getSession.mockReturnValue({
id: "session-3", id: "session-3",
shell: "/bin/bash", shell: "/bin/bash",
cwd: "/project", cwd: "/fake/root",
lastActivityAt: new Date(), lastActivityAt: new Date(),
}); });
@@ -457,7 +459,7 @@ describe("Terminal WebSocket heartbeat", () => {
mockTerminalService.getSession.mockReturnValue({ mockTerminalService.getSession.mockReturnValue({
id: "stale-session", id: "stale-session",
shell: "/bin/bash", shell: "/bin/bash",
cwd: "/project", cwd: "/fake/root",
lastActivityAt: tenMinutesAgo, lastActivityAt: tenMinutesAgo,
}); });
@@ -479,7 +481,7 @@ describe("Terminal WebSocket heartbeat", () => {
mockTerminalService.getSession.mockReturnValue({ mockTerminalService.getSession.mockReturnValue({
id: "fresh-session", id: "fresh-session",
shell: "/bin/bash", shell: "/bin/bash",
cwd: "/project", cwd: "/fake/root",
lastActivityAt: new Date(Date.now() - 60_000), lastActivityAt: new Date(Date.now() - 60_000),
}); });

View File

@@ -66,6 +66,35 @@ process.on("beforeExit", () => {
clearAiSessionCleanupInterval(); clearAiSessionCleanupInterval();
}); });
/**
* Module-level helper for resolving a scoped TaskStore.
* Mirrors /api/events semantics: prefer engine's store, fallback to resolver, fallback to default store.
* Used by realtime endpoints that need project-aware store resolution.
*
* @param projectId - The project ID to resolve, or undefined for the default store
* @param store - The default TaskStore to use when projectId is undefined
* @param engineManager - Optional engine manager for per-project engine store access
* @returns The resolved TaskStore
*/
export async function resolveScopedStore(
projectId: string | undefined,
store: TaskStore,
engineManager?: import("@fusion/engine").ProjectEngineManager,
): Promise<TaskStore> {
if (!projectId) {
return store;
}
if (engineManager) {
const engine = engineManager.getEngine(projectId);
if (engine) {
return engine.getTaskStore();
}
}
return await getOrCreateProjectStore(projectId);
}
export interface ServerOptions { export interface ServerOptions {
/** Optional ProjectEngine — when provided, subsystems (onMerge, automationStore, /** Optional ProjectEngine — when provided, subsystems (onMerge, automationStore,
* missionAutopilot, missionExecutionLoop, heartbeatMonitor) are derived from it. * missionAutopilot, missionExecutionLoop, heartbeatMonitor) are derived from it.
@@ -320,9 +349,18 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
} }
}); });
/**
* Shared project-resolution helper for realtime endpoints.
* Uses module-level resolveScopedStore with current closure context.
*/
async function resolveProjectScopedStore(projectId: string | undefined): Promise<TaskStore> {
return resolveScopedStore(projectId, store, options?.engineManager);
}
// Per-task SSE endpoint for live agent log streaming // Per-task SSE endpoint for live agent log streaming
app.get("/api/tasks/:id/logs/stream", (req, res) => { app.get("/api/tasks/:id/logs/stream", async (req, res) => {
const taskId = req.params.id; const taskId = req.params.id;
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
@@ -332,23 +370,28 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
res.write(": connected\n\n"); res.write(": connected\n\n");
// agent:log events are emitted by the in-process TaskExecutor via // Resolve the store for this request:
// store.appendAgentLog(). The executor is always bound to the default // - With projectId: use scoped store from engine or resolver (ensures multi-project isolation)
// `store` passed to createServer — never to a project-scoped store // - Without projectId: use default store (preserves existing single-project behavior)
// created by getOrCreateProjectStore — so we must listen on `store`
// directly. Using getOrCreateProjectStore here would attach the listener
// to a different EventEmitter instance that the executor never writes to,
// breaking real-time log streaming.
// //
// Per-entry text and detail fields are serialized in full — there is no // Per-entry text and detail fields are serialized in full — there is no
// SSE-level truncation. The 500-entry cap is applied client-side in the // SSE-level truncation. The 500-entry cap is applied client-side in the
// React hooks (useAgentLogs / useMultiAgentLogs). // React hooks (useAgentLogs / useMultiAgentLogs).
let scopedStore: TaskStore;
try {
scopedStore = await resolveProjectScopedStore(projectId);
} catch (err) {
res.write(`event: error\ndata: ${JSON.stringify({ message: "Failed to resolve project store" })}\n\n`);
res.end();
return;
}
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => { const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
if (entry.taskId !== taskId) return; if (entry.taskId !== taskId) return;
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`); res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
}; };
store.on("agent:log", onAgentLog); scopedStore.on("agent:log", onAgentLog);
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
res.write(": heartbeat\n\n"); res.write(": heartbeat\n\n");
@@ -356,7 +399,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
req.on("close", () => { req.on("close", () => {
clearInterval(heartbeat); clearInterval(heartbeat);
store.off("agent:log", onAgentLog); scopedStore.off("agent:log", onAgentLog);
}); });
}); });
@@ -633,7 +676,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
if (!dashboardApp.__fnWebSocketsAttached) { if (!dashboardApp.__fnWebSocketsAttached) {
dashboardApp.__fnWebSocketsAttached = true; dashboardApp.__fnWebSocketsAttached = true;
setupTerminalWebSocket(dashboardApp, server); setupTerminalWebSocket(dashboardApp, server, store, options);
setupBadgeWebSocket(dashboardApp, server, store, options); setupBadgeWebSocket(dashboardApp, server, store, options);
} }
@@ -650,11 +693,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
export function setupTerminalWebSocket( export function setupTerminalWebSocket(
app: ReturnType<typeof express>, app: ReturnType<typeof express>,
server: import("http").Server, server: import("http").Server,
store: TaskStore,
options?: ServerOptions,
): void { ): void {
const terminalService = getTerminalService();
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
// Default terminal service for stale eviction (uses default store's root dir)
const defaultTerminalService = getTerminalService(store.getRootDir());
server.on("upgrade", (req, socket, head) => { server.on("upgrade", (req, socket, head) => {
const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname; const pathname = new URL(req.url || "", `http://${req.headers.host}`).pathname;
if (pathname !== "/api/terminal/ws") { if (pathname !== "/api/terminal/ws") {
@@ -669,22 +715,52 @@ export function setupTerminalWebSocket(
// Store reference on app for access // Store reference on app for access
(app as DashboardExpressApp).terminalWsServer = wss; (app as DashboardExpressApp).terminalWsServer = wss;
wss.on("connection", (ws: WebSocket, req) => { wss.on("connection", async (ws: WebSocket, req) => {
// Parse query params from URL // Parse query params from URL
const url = new URL(req.url || "", `http://${req.headers.host}`); const url = new URL(req.url || "", `http://${req.headers.host}`);
const sessionId = url.searchParams.get("sessionId"); const sessionId = url.searchParams.get("sessionId");
const projectId = url.searchParams.get("projectId") ?? undefined;
if (!sessionId) { if (!sessionId) {
ws.close(4000, "Missing sessionId"); ws.close(4000, "Missing sessionId");
return; return;
} }
// Resolve the scoped terminal service
let terminalService: ReturnType<typeof getTerminalService>;
let scopedRootDir: string;
try {
if (projectId) {
// When projectId is provided, resolve the scoped store and get its root dir
const scopedStore = await resolveScopedStore(projectId, store, options?.engineManager);
scopedRootDir = scopedStore.getRootDir();
terminalService = getTerminalService(scopedRootDir);
} else {
// Without projectId, use the default store's root dir
scopedRootDir = store.getRootDir();
terminalService = getTerminalService(scopedRootDir);
}
} catch (err) {
console.error("[terminal] Failed to resolve project scope:", err);
ws.close(4510, "Failed to resolve project scope");
return;
}
const session = terminalService.getSession(sessionId); const session = terminalService.getSession(sessionId);
if (!session) { if (!session) {
ws.close(4004, "Session not found"); ws.close(4004, "Session not found");
return; return;
} }
// Security check: reject sessions that don't belong to this project's root
// Session cwd must be within the resolved project root
if (!session.cwd.startsWith(scopedRootDir)) {
console.warn(`[terminal] Session ${sessionId} cwd ${session.cwd} does not belong to project root ${scopedRootDir}`);
ws.close(4503, "Session does not belong to this project");
return;
}
const MAX_MISSED_PONGS = 2; // Allow 2 missed pongs (~90s) before terminating const MAX_MISSED_PONGS = 2; // Allow 2 missed pongs (~90s) before terminating
// Track if connection is alive // Track if connection is alive
@@ -818,7 +894,7 @@ export function setupTerminalWebSocket(
// TerminalService (default 5 minutes of inactivity). // TerminalService (default 5 minutes of inactivity).
const staleEvictionInterval = setInterval(() => { const staleEvictionInterval = setInterval(() => {
try { try {
terminalService.evictStaleSessions(); defaultTerminalService.evictStaleSessions();
} catch { } catch {
// Ignore errors during periodic eviction // Ignore errors during periodic eviction
} }
@@ -842,7 +918,8 @@ export function setupBadgeWebSocket(
const wsManager = new WebSocketManager(); const wsManager = new WebSocketManager();
// Structured badge snapshot cache for local subscriptions and pub/sub sync // Structured badge snapshot cache for local subscriptions and pub/sub sync
// Maps taskId -> BadgeSnapshot with timestamp // Maps "{projectId}:{taskId}" -> BadgeSnapshot with timestamp
// Uses "default" for unscoped/default project
const badgeSnapshots = new Map<string, BadgeSnapshot>(); const badgeSnapshots = new Map<string, BadgeSnapshot>();
// Server instance ID for pub/sub deduplication // Server instance ID for pub/sub deduplication
@@ -852,10 +929,31 @@ export function setupBadgeWebSocket(
const badgePubSub = options?.badgePubSub ?? createBadgePubSub({ sourceId: serverId }); const badgePubSub = options?.badgePubSub ?? createBadgePubSub({ sourceId: serverId });
void badgePubSub.start(); void badgePubSub.start();
// Prime cache with existing tasks // Track scoped stores for multi-project support
const scopedStores = new Map<string, TaskStore>();
// Helper to get or create a scoped store
const getScopedStore = async (projectId: string): Promise<TaskStore> => {
// Always use the default store for the "default" scope
if (projectId === "default") {
return store;
}
let scopedStore = scopedStores.get(projectId);
if (scopedStore) {
return scopedStore;
}
// Create scoped store
scopedStore = await resolveScopedStore(projectId, store, options?.engineManager);
scopedStores.set(projectId, scopedStore);
return scopedStore;
};
// Prime cache with existing tasks from default store
void store.listTasks({ slim: true, includeArchived: false }).then((tasks) => { void store.listTasks({ slim: true, includeArchived: false }).then((tasks) => {
for (const task of tasks) { for (const task of tasks) {
badgeSnapshots.set(task.id, { badgeSnapshots.set(`default:${task.id}`, {
prInfo: task.prInfo ?? null, prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null, issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -881,95 +979,165 @@ export function setupBadgeWebSocket(
dashboardApp.badgeWsServer = wss; dashboardApp.badgeWsServer = wss;
dashboardApp.badgeWsManager = wsManager; dashboardApp.badgeWsManager = wsManager;
const broadcastBadgeSnapshot = (taskId: string, snapshot: BadgeSnapshot): void => { /**
wsManager.broadcastBadgeUpdate(taskId, snapshot); * Broadcast a badge snapshot to subscribed clients within a project scope.
*/
const broadcastBadgeSnapshot = (taskId: string, snapshot: BadgeSnapshot, projectId: string = "default"): void => {
wsManager.broadcastBadgeUpdate(taskId, snapshot, projectId);
}; };
const onTaskUpdated = (task: Task) => { /**
const previousSnapshot = badgeSnapshots.get(task.id); * Get or create scoped store and attach badge listeners.
const nextSnapshot: BadgeSnapshot = { * Returns cleanup function.
prInfo: task.prInfo ?? null, */
issueInfo: task.issueInfo ?? null, const attachScopedListeners = async (
timestamp: new Date().toISOString(), projectId: string,
scopedStore: TaskStore
): Promise<() => void> => {
const scopeKey = projectId === "default" ? "default" : projectId;
const onTaskUpdated = (task: Task) => {
const cacheKey = `${scopeKey}:${task.id}`;
const previousSnapshot = badgeSnapshots.get(cacheKey);
const nextSnapshot: BadgeSnapshot = {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
};
// Update local cache immediately
badgeSnapshots.set(cacheKey, 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,
projectId,
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, projectId) > 0) {
broadcastBadgeSnapshot(task.id, nextSnapshot, projectId);
}
}; };
// Update local cache immediately const onTaskCreated = (task: Task) => {
badgeSnapshots.set(task.id, nextSnapshot); const cacheKey = `${scopeKey}:${task.id}`;
badgeSnapshots.set(cacheKey, {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
});
};
// Check if badge data actually changed const onTaskDeleted = (task: Task) => {
if (snapshotsEqual(previousSnapshot, nextSnapshot)) { const cacheKey = `${scopeKey}:${task.id}`;
badgeSnapshots.delete(cacheKey);
};
scopedStore.on("task:updated", onTaskUpdated);
scopedStore.on("task:created", onTaskCreated);
scopedStore.on("task:deleted", onTaskDeleted);
return () => {
scopedStore.off("task:updated", onTaskUpdated);
scopedStore.off("task:created", onTaskCreated);
scopedStore.off("task:deleted", onTaskDeleted);
};
};
// Store cleanup functions for scoped listeners
const scopedCleanups = new Map<string, () => void>();
// Attach listeners to default store
void (async () => {
const cleanup = await attachScopedListeners("default", store);
scopedCleanups.set("default", cleanup);
})();
/**
* Ensure scoped listeners are attached for a project.
*/
const ensureScopedListeners = async (projectId: string): Promise<void> => {
if (scopedCleanups.has(projectId)) {
return; return;
} }
// Always publish to shared bus (even if no local subscribers) const scopedStore = await getScopedStore(projectId);
// This ensures other instances receive the update const cleanup = await attachScopedListeners(projectId, scopedStore);
const pubSubMessage: BadgePubSubMessage = { scopedCleanups.set(projectId, cleanup);
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.id, nextSnapshot);
}
}; };
const onTaskCreated = (task: Task) => {
badgeSnapshots.set(task.id, {
prInfo: task.prInfo ?? null,
issueInfo: task.issueInfo ?? null,
timestamp: new Date().toISOString(),
});
};
const onTaskDeleted = (task: Task) => {
badgeSnapshots.delete(task.id);
};
store.on("task:updated", onTaskUpdated);
store.on("task:created", onTaskCreated);
store.on("task:deleted", onTaskDeleted);
// Handle remote badge updates from other instances via pub/sub // Handle remote badge updates from other instances via pub/sub
badgePubSub.on("message", (message: BadgePubSubMessage) => { badgePubSub.on("message", (message: BadgePubSubMessage) => {
// Use provided projectId or default scope
const projectId = message.projectId ?? "default";
const cacheKey = `${projectId}:${message.taskId}`;
// Update local cache with remote snapshot // Update local cache with remote snapshot
const remoteSnapshot: BadgeSnapshot = { const remoteSnapshot: BadgeSnapshot = {
prInfo: message.prInfo, prInfo: message.prInfo,
issueInfo: message.issueInfo, issueInfo: message.issueInfo,
timestamp: message.timestamp, timestamp: message.timestamp,
}; };
badgeSnapshots.set(message.taskId, remoteSnapshot); badgeSnapshots.set(cacheKey, remoteSnapshot);
// Rebroadcast to local websocket subscribers // Rebroadcast to local websocket subscribers
// (No need to check for echo - pub/sub adapter already filtered our own messages) // (No need to check for echo - pub/sub adapter already filtered our own messages)
if (wsManager.getSubscriptionCount(message.taskId) > 0) { if (wsManager.getSubscriptionCount(message.taskId, projectId) > 0) {
broadcastBadgeSnapshot(message.taskId, remoteSnapshot); broadcastBadgeSnapshot(message.taskId, remoteSnapshot, projectId);
} }
}); });
wsManager.on("subscription:changed", (taskId, subscriberCount) => { wsManager.on("subscription:changed", (taskId, subscriberCount, projectId) => {
// Send cached snapshot to late subscriber if available // Send cached snapshot to late subscriber if available
// This ensures a client subscribing after a remote update still sees the latest state // This ensures a client subscribing after a remote update still sees the latest state
if (subscriberCount > 0) { if (subscriberCount > 0) {
const cachedSnapshot = badgeSnapshots.get(taskId); const cacheKey = `${projectId}:${taskId}`;
const cachedSnapshot = badgeSnapshots.get(cacheKey);
if (cachedSnapshot) { if (cachedSnapshot) {
broadcastBadgeSnapshot(taskId, cachedSnapshot); broadcastBadgeSnapshot(taskId, cachedSnapshot, projectId);
} }
} }
}); });
wss.on("connection", (ws: WebSocket) => { wss.on("connection", (ws: WebSocket, req) => {
wsManager.addClient(ws, randomUUID()); // Parse projectId from URL query params
const url = new URL(req.url || "", `http://${req.headers.host}`);
const projectId = url.searchParams.get("projectId") ?? "default";
// Ensure scoped listeners are attached for this project
void ensureScopedListeners(projectId);
// Add client bound to this project scope
wsManager.addClient(ws, randomUUID(), projectId);
}); });
server.once("close", () => { server.once("close", () => {
store.off("task:updated", onTaskUpdated); // Clean up all scoped listeners
store.off("task:created", onTaskCreated); for (const cleanup of scopedCleanups.values()) {
store.off("task:deleted", onTaskDeleted); cleanup();
}
scopedCleanups.clear();
for (const scopedStore of scopedStores.values()) {
// Don't close the default store - it's managed externally
if (scopedStore !== store) {
scopedStore.stopWatching?.();
scopedStore.close?.();
}
}
scopedStores.clear();
for (const client of wss.clients) { for (const client of wss.clients) {
client.terminate(); client.terminate();

View File

@@ -33,18 +33,23 @@ export type BadgeServerMessage = BadgeUpdatedMessage | WebSocketErrorMessage;
export interface SubscribeMessage { export interface SubscribeMessage {
type: "subscribe"; type: "subscribe";
taskId: string; taskId: string;
projectId?: string;
} }
export interface UnsubscribeMessage { export interface UnsubscribeMessage {
type: "unsubscribe"; type: "unsubscribe";
taskId: string; taskId: string;
projectId?: string;
} }
export type BadgeClientMessage = SubscribeMessage | UnsubscribeMessage; export type BadgeClientMessage = SubscribeMessage | UnsubscribeMessage;
interface ClientState { interface ClientState {
ws: WebSocket; ws: WebSocket;
/** Subscribed channels (e.g., "badge:project-123:FN-001") */
subscriptions: Set<string>; subscriptions: Set<string>;
/** The project scope this client is bound to */
projectId: string;
isAlive: boolean; isAlive: boolean;
handlers: { handlers: {
pong: () => void; pong: () => void;
@@ -57,7 +62,7 @@ interface ClientState {
export interface WebSocketManagerEvents { export interface WebSocketManagerEvents {
"client:connected": [clientId: string, totalClients: number]; "client:connected": [clientId: string, totalClients: number];
"client:disconnected": [clientId: string, totalClients: number]; "client:disconnected": [clientId: string, totalClients: number];
"subscription:changed": [taskId: string, subscriberCount: number]; "subscription:changed": [taskId: string, subscriberCount: number, projectId: string];
} }
export interface WebSocketManagerOptions { export interface WebSocketManagerOptions {
@@ -75,13 +80,20 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 30_000; this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 30_000;
} }
addClient(ws: WebSocket, clientId: string): void { /**
* Add a new client to the manager.
* @param ws - The WebSocket connection
* @param clientId - Unique identifier for this client
* @param projectId - The project scope this client is bound to (defaults to "default")
*/
addClient(ws: WebSocket, clientId: string, projectId: string = "default"): void {
this.removeClient(clientId); this.removeClient(clientId);
const handlers = this.createClientHandlers(clientId); const handlers = this.createClientHandlers(clientId);
const state: ClientState = { const state: ClientState = {
ws, ws,
subscriptions: new Set<string>(), subscriptions: new Set<string>(),
projectId,
isAlive: true, isAlive: true,
handlers, handlers,
}; };
@@ -118,11 +130,19 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
this.emit("client:disconnected", clientId, this.clients.size); this.emit("client:disconnected", clientId, this.clients.size);
} }
subscribe(clientId: string, taskId: string): void { /**
* Subscribe a client to badge updates for a task within their project scope.
* @param clientId - The client ID to subscribe
* @param taskId - The task ID to subscribe to
* @param projectIdOverride - Optional project scope override (uses client's bound scope if not provided)
*/
subscribe(clientId: string, taskId: string, projectIdOverride?: string): void {
const state = this.clients.get(clientId); const state = this.clients.get(clientId);
if (!state) return; if (!state) return;
const channel = toBadgeChannel(taskId); // Use client's bound scope by default, or override if explicitly provided
const scopeKey = projectIdOverride ?? state.projectId;
const channel = toBadgeChannel(scopeKey, taskId);
if (state.subscriptions.has(channel)) return; if (state.subscriptions.has(channel)) return;
state.subscriptions.add(channel); state.subscriptions.add(channel);
@@ -134,16 +154,33 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
} }
subscribers.add(clientId); subscribers.add(clientId);
this.emit("subscription:changed", taskId, subscribers.size); this.emit("subscription:changed", taskId, subscribers.size, scopeKey);
} }
unsubscribe(clientId: string, taskId: string): void { /**
const channel = toBadgeChannel(taskId); * Unsubscribe a client from badge updates for a task.
* @param clientId - The client ID to unsubscribe
* @param taskId - The task ID to unsubscribe from
* @param projectIdOverride - Optional project scope override (uses client's bound scope if not provided)
*/
unsubscribe(clientId: string, taskId: string, projectIdOverride?: string): void {
const state = this.clients.get(clientId);
if (!state) return;
const scopeKey = projectIdOverride ?? state.projectId;
const channel = toBadgeChannel(scopeKey, taskId);
this.removeChannelSubscription(clientId, channel); this.removeChannelSubscription(clientId, channel);
} }
broadcastBadgeUpdate(taskId: string, badgeData: BadgeUpdate): void { /**
const subscribers = this.channelSubscribers.get(toBadgeChannel(taskId)); * Broadcast a badge update to all clients subscribed to the task within the scope.
* @param taskId - The task ID
* @param badgeData - The badge data to broadcast
* @param projectId - Optional project scope (defaults to "default")
*/
broadcastBadgeUpdate(taskId: string, badgeData: BadgeUpdate, projectId?: string): void {
const scopeKey = projectId ?? "default";
const subscribers = this.channelSubscribers.get(toBadgeChannel(scopeKey, taskId));
if (!subscribers || subscribers.size === 0) return; if (!subscribers || subscribers.size === 0) return;
const message: BadgeUpdatedMessage = { const message: BadgeUpdatedMessage = {
@@ -171,14 +208,26 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
return this.clients.size > 0; return this.clients.size > 0;
} }
getSubscriptionCount(taskId: string): number { /**
return this.channelSubscribers.get(toBadgeChannel(taskId))?.size ?? 0; * Get subscription count for a task within a project scope.
* @param taskId - The task ID
* @param projectId - Optional project scope (defaults to "default")
*/
getSubscriptionCount(taskId: string, projectId?: string): number {
const scopeKey = projectId ?? "default";
return this.channelSubscribers.get(toBadgeChannel(scopeKey, taskId))?.size ?? 0;
} }
getSubscribedTaskIds(): string[] { /**
* Get all subscribed task IDs, optionally filtered by project scope.
* @param projectId - Optional project scope filter (returns all if not specified)
*/
getSubscribedTaskIds(projectId?: string): string[] {
const scopeKey = projectId ?? "default";
const prefix = `badge:${scopeKey}:`;
return [...this.channelSubscribers.entries()] return [...this.channelSubscribers.entries()]
.filter(([, subscribers]) => subscribers.size > 0) .filter(([channel, subscribers]) => subscribers.size > 0 && channel.startsWith(prefix))
.map(([channel]) => fromBadgeChannel(channel)); .map(([channel]) => fromBadgeChannel(scopeKey, channel));
} }
dispose(): void { dispose(): void {
@@ -223,11 +272,11 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
} }
if (parsed.value.type === "subscribe") { if (parsed.value.type === "subscribe") {
this.subscribe(clientId, parsed.value.taskId); this.subscribe(clientId, parsed.value.taskId, parsed.value.projectId);
return; return;
} }
this.unsubscribe(clientId, parsed.value.taskId); this.unsubscribe(clientId, parsed.value.taskId, parsed.value.projectId);
} }
private removeChannelSubscription(clientId: string, channel: string): void { private removeChannelSubscription(clientId: string, channel: string): void {
@@ -241,14 +290,15 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
subscribers.delete(clientId); subscribers.delete(clientId);
const taskId = fromBadgeChannel(channel); // Extract taskId and projectId from channel for the event
const { taskId, projectId } = extractPartsFromChannel(channel);
if (subscribers.size === 0) { if (subscribers.size === 0) {
this.channelSubscribers.delete(channel); this.channelSubscribers.delete(channel);
this.emit("subscription:changed", taskId, 0); this.emit("subscription:changed", taskId ?? "", 0, projectId ?? "default");
return; return;
} }
this.emit("subscription:changed", taskId, subscribers.size); this.emit("subscription:changed", taskId ?? "", subscribers.size, projectId ?? "default");
} }
private safeSend(ws: WebSocket, message: BadgeServerMessage): boolean { private safeSend(ws: WebSocket, message: BadgeServerMessage): boolean {
@@ -294,12 +344,43 @@ export class WebSocketManager extends EventEmitter<WebSocketManagerEvents> {
} }
} }
function toBadgeChannel(taskId: string): string { /**
return `badge:${taskId}`; * Create a badge channel key with project scope.
* Format: badge:{projectId}:{taskId}
*/
function toBadgeChannel(projectId: string, taskId: string): string {
return `badge:${projectId}:${taskId}`;
} }
function fromBadgeChannel(channel: string): string { /**
return channel.replace(/^badge:/, ""); * Extract taskId from a badge channel key.
*/
function fromBadgeChannel(projectId: string, channel: string): string {
const prefix = `badge:${projectId}:`;
return channel.startsWith(prefix) ? channel.slice(prefix.length) : channel;
}
/**
* Extract taskId and projectId from any badge channel key.
*/
function extractPartsFromChannel(channel: string): { taskId: string | null; projectId: string | null } {
// Channel format: badge:{projectId}:{taskId}
const match = channel.match(/^badge:([^:]+):(.+)$/);
if (match) {
return { projectId: match[1], taskId: match[2] };
}
return { projectId: null, taskId: null };
}
/**
* Extract taskId from any badge channel key (without knowing the projectId).
* @deprecated Use extractPartsFromChannel instead
*/
function extractTaskIdFromChannel(channel: string): string | null {
// Channel format: badge:{projectId}:{taskId}
// We need to find the taskId after the second colon
const match = channel.match(/^badge:[^:]+:(.+)$/);
return match ? match[1] : null;
} }
function parseClientMessage(raw: WebSocket.RawData): function parseClientMessage(raw: WebSocket.RawData):
@@ -324,11 +405,17 @@ function parseClientMessage(raw: WebSocket.RawData):
return { ok: false, error: "taskId is required" }; return { ok: false, error: "taskId is required" };
} }
// projectId is optional - validates that it's a non-empty string if provided
if (value.projectId !== undefined && (typeof value.projectId !== "string" || value.projectId.trim().length === 0)) {
return { ok: false, error: "projectId must be a non-empty string" };
}
return { return {
ok: true, ok: true,
value: { value: {
type: value.type, type: value.type,
taskId: value.taskId.trim(), taskId: value.taskId.trim(),
projectId: value.projectId?.trim(),
}, },
}; };
} catch { } catch {