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.
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/cli/dist/srm.js +895 -0
- package/client/dist/assets/index-BEc7DzgE.css +1 -0
- package/client/dist/assets/index-DoIYcnfd.js +486 -0
- package/client/dist/index.html +13 -0
- package/package.json +57 -0
- package/server/analytics.test.ts +166 -0
- package/server/analytics.ts +318 -0
- package/server/chat-manager.test.ts +216 -0
- package/server/chat-manager.ts +289 -0
- package/server/command-grid-logic.test.ts +480 -0
- package/server/command-resolver.test.ts +136 -0
- package/server/command-resolver.ts +29 -0
- package/server/config.test.ts +193 -0
- package/server/config.ts +321 -0
- package/server/db.test.ts +409 -0
- package/server/db.ts +514 -0
- package/server/hooks.test.ts +196 -0
- package/server/hooks.ts +117 -0
- package/server/hub-db.ts +141 -0
- package/server/hub-router.ts +137 -0
- package/server/index.test.ts +538 -0
- package/server/index.ts +539 -0
- package/server/project-registry.ts +130 -0
- package/server/project-router.ts +451 -0
- package/server/proposal-manager.test.ts +410 -0
- package/server/proposal-manager.ts +285 -0
- package/server/proposal-routes.test.ts +424 -0
- package/server/queue-manager.test.ts +400 -0
- package/server/queue-manager.ts +545 -0
- package/server/setup-manager.ts +526 -0
- package/server/types.ts +360 -0
package/server/index.ts
ADDED
|
@@ -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
|
+
}
|