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

@@ -285,9 +285,24 @@ Key server capabilities:
### Real-time channels
- **SSE**: `/api/events` (`sse.ts`)
- 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`)
- Broadcasts lightweight badge snapshots (`prInfo` / `issueInfo`)
- **Terminal WebSocket**: `/api/terminal/ws` (also in `server.ts`)
- WebSocket endpoint for lightweight badge snapshot fan-out
- **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
- 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. |
| 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. |
| 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. |
| 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. |