specrails-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,539 @@
1
+ import http from 'http'
2
+ import path from 'path'
3
+ import fs from 'fs'
4
+ import os from 'os'
5
+ import express from 'express'
6
+ import { WebSocketServer, WebSocket } from 'ws'
7
+ import type { WsMessage } from './types'
8
+ import { ProjectRegistry } from './project-registry'
9
+ import { createHubRouter } from './hub-router'
10
+ import { createProjectRouter } from './project-router'
11
+ import { createHooksRouter, getPhaseStates, getPhaseDefinitions } from './hooks'
12
+ import { QueueManager, ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError } from './queue-manager'
13
+ import { initDb, listJobs, getJob, getJobEvents, getStats, purgeJobs,
14
+ createConversation, listConversations, getConversation,
15
+ deleteConversation, updateConversation, addMessage, getMessages,
16
+ createProposal, getProposal, listProposals, deleteProposal } from './db'
17
+ import { ChatManager } from './chat-manager'
18
+ import { ProposalManager } from './proposal-manager'
19
+ import type { ChatConversationRow } from './types'
20
+ import { getConfig, fetchIssues } from './config'
21
+ import { getAnalytics } from './analytics'
22
+ import { resolveCommand } from './command-resolver'
23
+ import { v4 as uuidv4 } from 'uuid'
24
+
25
+ // Read package.json version once at startup
26
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
27
+ const PKG_VERSION: string = (() => {
28
+ try {
29
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
30
+ return (require('../package.json') as { version?: string }).version ?? '0.0.0'
31
+ } catch {
32
+ return '0.0.0'
33
+ }
34
+ })()
35
+
36
+ // ─── Mode detection ───────────────────────────────────────────────────────────
37
+
38
+ // Hub mode is the default. Use --legacy or SPECRAILS_LEGACY=1 for single-project mode.
39
+ const isHubMode = !process.argv.includes('--legacy') && process.env.SPECRAILS_LEGACY !== '1'
40
+
41
+ // ─── Resolve project name (legacy single-project mode) ────────────────────────
42
+
43
+ function resolveProjectName(): string {
44
+ if (process.env.SPECRAILS_PROJECT_NAME) {
45
+ return process.env.SPECRAILS_PROJECT_NAME
46
+ }
47
+ const cwd = process.cwd()
48
+ const parentDir = path.basename(path.resolve(cwd, '../..'))
49
+ const immediateParent = path.basename(path.resolve(cwd, '..'))
50
+ if (immediateParent === 'specrails') {
51
+ return parentDir
52
+ }
53
+ return path.basename(cwd)
54
+ }
55
+
56
+ // ─── Parse CLI args ───────────────────────────────────────────────────────────
57
+
58
+ let projectName = resolveProjectName()
59
+ let port = 4200
60
+
61
+ for (let i = 2; i < process.argv.length; i++) {
62
+ if (process.argv[i] === '--project' && process.argv[i + 1]) {
63
+ projectName = process.argv[++i]
64
+ } else if (process.argv[i] === '--port' && process.argv[i + 1]) {
65
+ port = parseInt(process.argv[++i], 10)
66
+ }
67
+ }
68
+
69
+ // ─── PID file management ──────────────────────────────────────────────────────
70
+
71
+ const PID_DIR = path.join(os.homedir(), '.specrails')
72
+ const PID_FILE = path.join(PID_DIR, 'manager.pid')
73
+
74
+ function writePidFile(): void {
75
+ try {
76
+ fs.mkdirSync(PID_DIR, { recursive: true })
77
+ fs.writeFileSync(PID_FILE, String(process.pid), 'utf-8')
78
+ } catch {
79
+ // Non-fatal
80
+ }
81
+ }
82
+
83
+ function removePidFile(): void {
84
+ try {
85
+ fs.unlinkSync(PID_FILE)
86
+ } catch {
87
+ // Non-fatal
88
+ }
89
+ }
90
+
91
+ // ─── Express + WebSocket setup ────────────────────────────────────────────────
92
+
93
+ const app = express()
94
+ app.use(express.json())
95
+
96
+ const server = http.createServer(app)
97
+ const wss = new WebSocketServer({ noServer: true })
98
+ const clients = new Set<WebSocket>()
99
+
100
+ function broadcast(msg: WsMessage): void {
101
+ const data = JSON.stringify(msg)
102
+ for (const client of clients) {
103
+ if (client.readyState === WebSocket.OPEN) {
104
+ client.send(data)
105
+ }
106
+ }
107
+ }
108
+
109
+ server.on('upgrade', (request, socket, head) => {
110
+ wss.handleUpgrade(request, socket, head, (ws) => {
111
+ wss.emit('connection', ws, request)
112
+ })
113
+ })
114
+
115
+ // ─── Hub mode ─────────────────────────────────────────────────────────────────
116
+
117
+ if (isHubMode) {
118
+ const registry = new ProjectRegistry(broadcast)
119
+ registry.loadAll()
120
+
121
+ // Hub-level routes
122
+ app.use('/api/hub', createHubRouter(registry, broadcast))
123
+
124
+ // Per-project routes under /api/projects/:projectId/*
125
+ app.use('/api/projects', createProjectRouter(registry))
126
+
127
+ // Return 410 Gone for old per-project hook endpoint in hub mode
128
+ app.post('/hooks/events', (_req, res) => {
129
+ res.status(410).json({
130
+ error: 'In hub mode, use /api/projects/:projectId/hooks/events',
131
+ })
132
+ })
133
+
134
+ wss.on('connection', (ws: WebSocket) => {
135
+ clients.add(ws)
136
+
137
+ // Send hub state init
138
+ const projects = registry.listContexts().map((ctx) => ctx.project)
139
+ ws.send(JSON.stringify({
140
+ type: 'hub.projects',
141
+ projects,
142
+ timestamp: new Date().toISOString(),
143
+ }))
144
+
145
+ ws.on('close', () => {
146
+ clients.delete(ws)
147
+ })
148
+ })
149
+
150
+ } else {
151
+ // ─── Single-project (legacy) mode ─────────────────────────────────────────
152
+
153
+ const db = initDb(path.join(process.cwd(), 'data', 'jobs.sqlite'))
154
+ const queueManager = new QueueManager(broadcast, db)
155
+ const chatManager = new ChatManager(broadcast, db)
156
+ const proposalManager = new ProposalManager(broadcast, db, process.cwd())
157
+
158
+ try {
159
+ const initialConfig = getConfig(process.cwd(), db, projectName)
160
+ queueManager.setCommands(initialConfig.commands)
161
+ } catch {
162
+ console.warn('[init] failed to load commands for phase resolution')
163
+ }
164
+
165
+ wss.on('connection', (ws: WebSocket) => {
166
+ clients.add(ws)
167
+
168
+ const initMsg: WsMessage = {
169
+ type: 'init',
170
+ projectName,
171
+ phases: getPhaseStates(),
172
+ phaseDefinitions: getPhaseDefinitions(),
173
+ logBuffer: queueManager.getLogBuffer().slice(-500),
174
+ recentJobs: listJobs(db, { limit: 10 }).jobs,
175
+ queue: {
176
+ jobs: queueManager.getJobs(),
177
+ activeJobId: queueManager.getActiveJobId(),
178
+ paused: queueManager.isPaused(),
179
+ },
180
+ }
181
+ ws.send(JSON.stringify(initMsg))
182
+
183
+ ws.on('close', () => {
184
+ clients.delete(ws)
185
+ })
186
+ })
187
+
188
+ app.use('/hooks', createHooksRouter(broadcast, db, {
189
+ get current() { return queueManager.getActiveJobId() },
190
+ set current(_: string | null) { /* managed by QueueManager */ },
191
+ }))
192
+
193
+ app.post('/api/spawn', (req, res) => {
194
+ const { command } = req.body ?? {}
195
+ if (!command || typeof command !== 'string' || !command.trim()) {
196
+ res.status(400).json({ error: 'command is required' })
197
+ return
198
+ }
199
+ try {
200
+ const job = queueManager.enqueue(command)
201
+ const position = job.queuePosition ?? 0
202
+ res.status(202).json({ jobId: job.id, position })
203
+ } catch (err) {
204
+ if (err instanceof ClaudeNotFoundError) {
205
+ res.status(400).json({ error: err.message })
206
+ } else {
207
+ console.error('[spawn] unexpected error:', err)
208
+ res.status(500).json({ error: 'Internal server error' })
209
+ }
210
+ }
211
+ })
212
+
213
+ app.get('/api/state', (_req, res) => {
214
+ res.json({
215
+ projectName,
216
+ phases: getPhaseStates(),
217
+ busy: queueManager.getActiveJobId() !== null,
218
+ currentJobId: queueManager.getActiveJobId(),
219
+ version: PKG_VERSION,
220
+ })
221
+ })
222
+
223
+ app.delete('/api/jobs/:id', (req, res) => {
224
+ try {
225
+ const result = queueManager.cancel(req.params.id)
226
+ res.json({ ok: true, status: result })
227
+ } catch (err) {
228
+ if (err instanceof JobNotFoundError) {
229
+ res.status(404).json({ error: 'Job not found' })
230
+ } else if (err instanceof JobAlreadyTerminalError) {
231
+ res.status(409).json({ error: 'Job is already in terminal state' })
232
+ } else {
233
+ res.status(500).json({ error: 'Internal server error' })
234
+ }
235
+ }
236
+ })
237
+
238
+ app.post('/api/queue/pause', (_req, res) => {
239
+ queueManager.pause()
240
+ res.json({ ok: true, paused: true })
241
+ })
242
+
243
+ app.post('/api/queue/resume', (_req, res) => {
244
+ queueManager.resume()
245
+ res.json({ ok: true, paused: false })
246
+ })
247
+
248
+ app.put('/api/queue/reorder', (req, res) => {
249
+ const { jobIds } = req.body ?? {}
250
+ if (!Array.isArray(jobIds)) {
251
+ res.status(400).json({ error: 'jobIds must be an array' })
252
+ return
253
+ }
254
+ try {
255
+ queueManager.reorder(jobIds)
256
+ res.json({ ok: true, queue: jobIds })
257
+ } catch (err) {
258
+ res.status(400).json({ error: (err as Error).message })
259
+ }
260
+ })
261
+
262
+ app.get('/api/queue', (_req, res) => {
263
+ res.json({
264
+ jobs: queueManager.getJobs(),
265
+ paused: queueManager.isPaused(),
266
+ activeJobId: queueManager.getActiveJobId(),
267
+ })
268
+ })
269
+
270
+ app.get('/api/jobs', (req, res) => {
271
+ const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200)
272
+ const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0
273
+ const status = req.query.status as string | undefined
274
+ const from = req.query.from as string | undefined
275
+ const to = req.query.to as string | undefined
276
+ const result = listJobs(db, { limit, offset, status, from, to })
277
+ res.json(result)
278
+ })
279
+
280
+ app.get('/api/jobs/:id', (req, res) => {
281
+ const job = getJob(db, req.params.id)
282
+ if (!job) { res.status(404).json({ error: 'Job not found' }); return }
283
+ const events = getJobEvents(db, req.params.id)
284
+ const phaseDefinitions = queueManager.phasesForCommand(job.command)
285
+ res.json({ job, events, phaseDefinitions })
286
+ })
287
+
288
+ app.delete('/api/jobs', (req, res) => {
289
+ try {
290
+ const { from, to } = req.body ?? {}
291
+ const deleted = purgeJobs(db, { from, to })
292
+ res.json({ ok: true, deleted })
293
+ } catch (err) {
294
+ console.error('[purge] error:', err)
295
+ res.status(500).json({ error: 'Failed to purge jobs' })
296
+ }
297
+ })
298
+
299
+ app.get('/api/stats', (_req, res) => {
300
+ res.json(getStats(db))
301
+ })
302
+
303
+ app.get('/api/analytics', (req, res) => {
304
+ const period = (req.query.period as string) || '7d'
305
+ const from = req.query.from as string | undefined
306
+ const to = req.query.to as string | undefined
307
+ const validPeriods = ['7d', '30d', '90d', 'all', 'custom']
308
+ if (!validPeriods.includes(period)) {
309
+ res.status(400).json({ error: 'Invalid period. Must be one of: 7d, 30d, 90d, all, custom' })
310
+ return
311
+ }
312
+ if (period === 'custom' && (!from || !to)) {
313
+ res.status(400).json({ error: 'from and to are required for custom period' })
314
+ return
315
+ }
316
+ try {
317
+ res.json(getAnalytics(db, { period: period as any, from, to }))
318
+ } catch (err) {
319
+ console.error('[analytics] error:', err)
320
+ res.status(500).json({ error: 'Failed to compute analytics' })
321
+ }
322
+ })
323
+
324
+ app.get('/api/config', (_req, res) => {
325
+ try {
326
+ const config = getConfig(process.cwd(), db, projectName)
327
+ res.json(config)
328
+ } catch (err) {
329
+ console.error('[config] error:', err)
330
+ res.status(500).json({ error: 'Failed to read config' })
331
+ }
332
+ })
333
+
334
+ app.post('/api/config', (req, res) => {
335
+ const { active, labelFilter } = req.body ?? {}
336
+ try {
337
+ if (active !== undefined) {
338
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.active_tracker', ?)`).run(active ?? '')
339
+ }
340
+ if (labelFilter !== undefined) {
341
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.label_filter', ?)`).run(labelFilter ?? '')
342
+ }
343
+ res.json({ ok: true })
344
+ } catch (err) {
345
+ console.error('[config] persist error:', err)
346
+ res.status(500).json({ error: 'Failed to persist config' })
347
+ }
348
+ })
349
+
350
+ app.get('/api/issues', (_req, res) => {
351
+ try {
352
+ const config = getConfig(process.cwd(), db, projectName)
353
+ const tracker = config.issueTracker.active
354
+ if (!tracker) {
355
+ res.status(503).json({ error: 'No issue tracker configured', trackers: config.issueTracker })
356
+ return
357
+ }
358
+ const search = _req.query.search as string | undefined
359
+ const label = _req.query.label as string | undefined
360
+ const issues = fetchIssues(tracker, { search, label, repo: config.project.repo, cwd: process.cwd() })
361
+ res.json(issues)
362
+ } catch (err) {
363
+ console.error('[issues] error:', err)
364
+ res.status(500).json({ error: 'Failed to fetch issues' })
365
+ }
366
+ })
367
+
368
+ // Chat routes
369
+ app.get('/api/chat/conversations', (_req, res) => {
370
+ const conversations = listConversations(db)
371
+ res.json({ conversations })
372
+ })
373
+
374
+ app.post('/api/chat/conversations', (req, res) => {
375
+ const model = (req.body?.model as string | undefined) ?? 'claude-sonnet-4-5'
376
+ const id = uuidv4()
377
+ createConversation(db, { id, model })
378
+ const conversation = getConversation(db, id) as ChatConversationRow
379
+ res.status(201).json({ conversation })
380
+ })
381
+
382
+ app.get('/api/chat/conversations/:id', (req, res) => {
383
+ const conversation = getConversation(db, req.params.id)
384
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
385
+ const messages = getMessages(db, req.params.id)
386
+ res.json({ conversation, messages })
387
+ })
388
+
389
+ app.delete('/api/chat/conversations/:id', (req, res) => {
390
+ const conversation = getConversation(db, req.params.id)
391
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
392
+ deleteConversation(db, req.params.id)
393
+ res.json({ ok: true })
394
+ })
395
+
396
+ app.patch('/api/chat/conversations/:id', (req, res) => {
397
+ const conversation = getConversation(db, req.params.id)
398
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
399
+ const { title, model } = req.body ?? {}
400
+ const patch: { title?: string; model?: string } = {}
401
+ if (title !== undefined) patch.title = title
402
+ if (model !== undefined) patch.model = model
403
+ updateConversation(db, req.params.id, patch)
404
+ const updated = getConversation(db, req.params.id) as ChatConversationRow
405
+ res.json({ ok: true, conversation: updated })
406
+ })
407
+
408
+ app.get('/api/chat/conversations/:id/messages', (req, res) => {
409
+ const conversation = getConversation(db, req.params.id)
410
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
411
+ const messages = getMessages(db, req.params.id)
412
+ res.json({ messages })
413
+ })
414
+
415
+ app.post('/api/chat/conversations/:id/messages', async (req, res) => {
416
+ const conversation = getConversation(db, req.params.id)
417
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
418
+ const text = req.body?.text as string | undefined
419
+ if (!text || !text.trim()) { res.status(400).json({ error: 'text is required' }); return }
420
+ if (chatManager.isActive(req.params.id)) {
421
+ res.status(409).json({ error: 'CONVERSATION_BUSY' }); return
422
+ }
423
+ res.status(202).json({ ok: true })
424
+ chatManager.sendMessage(req.params.id, text.trim()).catch((err) => {
425
+ console.error('[chat] sendMessage error:', err)
426
+ })
427
+ })
428
+
429
+ app.delete('/api/chat/conversations/:id/messages/stream', (req, res) => {
430
+ if (!chatManager.isActive(req.params.id)) {
431
+ res.status(404).json({ error: 'No active stream for this conversation' }); return
432
+ }
433
+ chatManager.abort(req.params.id)
434
+ res.json({ ok: true })
435
+ })
436
+
437
+ // ─── Proposal routes (legacy mode) ──────────────────────────────────────────
438
+
439
+ app.get('/api/propose', (_req, res) => {
440
+ const limit = Math.min(parseInt(String(_req.query.limit ?? '20'), 10) || 20, 100)
441
+ const offset = parseInt(String(_req.query.offset ?? '0'), 10) || 0
442
+ const result = listProposals(db, { limit, offset })
443
+ res.json(result)
444
+ })
445
+
446
+ app.post('/api/propose', (req, res) => {
447
+ const { idea } = req.body ?? {}
448
+ if (!idea || typeof idea !== 'string' || !idea.trim()) {
449
+ res.status(400).json({ error: 'idea is required' }); return
450
+ }
451
+ const testCmd = `/sr:propose-feature test`
452
+ const resolved = resolveCommand(testCmd, process.cwd())
453
+ if (resolved === testCmd) {
454
+ res.status(400).json({ error: 'This project does not have the /sr:propose-feature command installed. Run "npx specrails" to update.' }); return
455
+ }
456
+ const id = uuidv4()
457
+ createProposal(db, { id, idea: idea.trim() })
458
+ res.status(202).json({ proposalId: id })
459
+ proposalManager.startExploration(id, idea.trim()).catch((err) => {
460
+ console.error('[propose] startExploration error:', err)
461
+ })
462
+ })
463
+
464
+ app.get('/api/propose/:id', (req, res) => {
465
+ const proposal = getProposal(db, req.params.id)
466
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
467
+ res.json({ proposal })
468
+ })
469
+
470
+ app.post('/api/propose/:id/refine', (req, res) => {
471
+ const proposal = getProposal(db, req.params.id)
472
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
473
+ const { feedback } = req.body ?? {}
474
+ if (!feedback || typeof feedback !== 'string' || !feedback.trim()) {
475
+ res.status(400).json({ error: 'feedback is required' }); return
476
+ }
477
+ if (proposalManager.isActive(req.params.id)) {
478
+ res.status(409).json({ error: 'PROPOSAL_BUSY' }); return
479
+ }
480
+ if (proposal.status !== 'review') {
481
+ res.status(409).json({ error: 'Proposal is not in review state' }); return
482
+ }
483
+ res.status(202).json({ ok: true })
484
+ proposalManager.sendRefinement(req.params.id, feedback.trim()).catch((err) => {
485
+ console.error('[propose] sendRefinement error:', err)
486
+ })
487
+ })
488
+
489
+ app.post('/api/propose/:id/create-issue', (req, res) => {
490
+ const proposal = getProposal(db, req.params.id)
491
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
492
+ if (proposalManager.isActive(req.params.id)) {
493
+ res.status(409).json({ error: 'PROPOSAL_BUSY' }); return
494
+ }
495
+ if (proposal.status !== 'review') {
496
+ res.status(409).json({ error: 'Proposal is not in review state' }); return
497
+ }
498
+ res.status(202).json({ ok: true })
499
+ proposalManager.createIssue(req.params.id).catch((err) => {
500
+ console.error('[propose] createIssue error:', err)
501
+ })
502
+ })
503
+
504
+ app.delete('/api/propose/:id', (req, res) => {
505
+ const proposal = getProposal(db, req.params.id)
506
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
507
+ proposalManager.cancel(req.params.id)
508
+ res.json({ ok: true })
509
+ })
510
+ }
511
+
512
+ // ─── Start server ─────────────────────────────────────────────────────────────
513
+
514
+ server.on('error', (err: NodeJS.ErrnoException) => {
515
+ if (err.code === 'EADDRINUSE') {
516
+ console.error(`[error] Port ${port} is already in use. Is another manager instance running?`)
517
+ console.error(`[error] Try stopping it first: srm hub stop`)
518
+ process.exit(1)
519
+ }
520
+ throw err
521
+ })
522
+
523
+ server.listen(port, '127.0.0.1', () => {
524
+ const mode = isHubMode ? 'hub mode' : 'single-project mode'
525
+ console.log(`specrails web manager (${mode}) running on http://127.0.0.1:${port}`)
526
+ writePidFile()
527
+ })
528
+
529
+ // ─── Clean shutdown ───────────────────────────────────────────────────────────
530
+
531
+ function shutdown(): void {
532
+ removePidFile()
533
+ server.close(() => {
534
+ process.exit(0)
535
+ })
536
+ }
537
+
538
+ process.on('SIGTERM', shutdown)
539
+ process.on('SIGINT', shutdown)
@@ -0,0 +1,130 @@
1
+ import path from 'path'
2
+ import type { DbInstance } from './db'
3
+ import { initDb } from './db'
4
+ import { QueueManager } from './queue-manager'
5
+ import { ChatManager } from './chat-manager'
6
+ import { SetupManager } from './setup-manager'
7
+ import { ProposalManager } from './proposal-manager'
8
+ import type { WsMessage } from './types'
9
+ import {
10
+ initHubDb,
11
+ getHubDbPath,
12
+ listProjects,
13
+ addProject as addProjectToHub,
14
+ removeProject as removeProjectFromHub,
15
+ getProject,
16
+ getProjectByPath,
17
+ touchProject,
18
+ type ProjectRow,
19
+ } from './hub-db'
20
+ import { getConfig } from './config'
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export interface ProjectContext {
25
+ project: ProjectRow
26
+ db: DbInstance
27
+ queueManager: QueueManager
28
+ chatManager: ChatManager
29
+ setupManager: SetupManager
30
+ proposalManager: ProposalManager
31
+ broadcast: (msg: WsMessage) => void
32
+ }
33
+
34
+ // ─── ProjectRegistry ──────────────────────────────────────────────────────────
35
+
36
+ export class ProjectRegistry {
37
+ private _hubDb: DbInstance
38
+ private _contexts: Map<string, ProjectContext>
39
+ private _broadcast: (msg: WsMessage) => void
40
+
41
+ constructor(broadcast: (msg: WsMessage) => void, hubDbPath?: string) {
42
+ this._broadcast = broadcast
43
+ this._hubDb = initHubDb(hubDbPath ?? getHubDbPath())
44
+ this._contexts = new Map()
45
+ }
46
+
47
+ get hubDb(): DbInstance {
48
+ return this._hubDb
49
+ }
50
+
51
+ loadAll(): void {
52
+ const projects = listProjects(this._hubDb)
53
+ for (const project of projects) {
54
+ this._loadProjectContext(project)
55
+ }
56
+ }
57
+
58
+ addProject(opts: {
59
+ id: string
60
+ slug: string
61
+ name: string
62
+ path: string
63
+ }): ProjectContext {
64
+ const row = addProjectToHub(this._hubDb, opts)
65
+ return this._loadProjectContext(row)
66
+ }
67
+
68
+ removeProject(id: string): void {
69
+ const ctx = this._contexts.get(id)
70
+ if (ctx) {
71
+ // Close the DB connection
72
+ try { ctx.db.close() } catch { /* ignore */ }
73
+ this._contexts.delete(id)
74
+ }
75
+ removeProjectFromHub(this._hubDb, id)
76
+ }
77
+
78
+ getContext(id: string): ProjectContext | undefined {
79
+ return this._contexts.get(id)
80
+ }
81
+
82
+ getContextByPath(projectPath: string): ProjectContext | undefined {
83
+ const row = getProjectByPath(this._hubDb, projectPath)
84
+ if (!row) return undefined
85
+ return this._contexts.get(row.id)
86
+ }
87
+
88
+ listContexts(): ProjectContext[] {
89
+ return Array.from(this._contexts.values())
90
+ }
91
+
92
+ touchProject(id: string): void {
93
+ touchProject(this._hubDb, id)
94
+ }
95
+
96
+ getProjectRow(id: string): ProjectRow | undefined {
97
+ return getProject(this._hubDb, id)
98
+ }
99
+
100
+ private _loadProjectContext(project: ProjectRow): ProjectContext {
101
+ // Avoid double-loading
102
+ const existing = this._contexts.get(project.id)
103
+ if (existing) return existing
104
+
105
+ const db = initDb(project.db_path)
106
+
107
+ // Bind broadcast with projectId so all WS messages carry context
108
+ const boundBroadcast = (msg: WsMessage): void => {
109
+ const enriched = { ...msg, projectId: project.id }
110
+ this._broadcast(enriched as WsMessage)
111
+ }
112
+
113
+ const queueManager = new QueueManager(boundBroadcast, db, undefined, project.path)
114
+ const chatManager = new ChatManager(boundBroadcast, db, project.path)
115
+ const setupManager = new SetupManager(boundBroadcast)
116
+ const proposalManager = new ProposalManager(boundBroadcast, db, project.path)
117
+
118
+ // Load commands for this project
119
+ try {
120
+ const config = getConfig(project.path, db, project.name)
121
+ queueManager.setCommands(config.commands)
122
+ } catch {
123
+ // Non-fatal: project may not have commands yet
124
+ }
125
+
126
+ const ctx: ProjectContext = { project, db, queueManager, chatManager, setupManager, proposalManager, broadcast: boundBroadcast }
127
+ this._contexts.set(project.id, ctx)
128
+ return ctx
129
+ }
130
+ }