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,451 @@
1
+ import { Router, Request, Response, NextFunction } from 'express'
2
+ import { v4 as uuidv4 } from 'uuid'
3
+ import type { AnalyticsOpts } from './types'
4
+ import type { ProjectRegistry, ProjectContext } from './project-registry'
5
+ import {
6
+ listJobs, getJob, getJobEvents, purgeJobs,
7
+ createConversation, listConversations, getConversation,
8
+ deleteConversation, updateConversation, getMessages,
9
+ getStats,
10
+ createProposal, getProposal, listProposals, deleteProposal,
11
+ } from './db'
12
+ import { ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError } from './queue-manager'
13
+ import { resolveCommand } from './command-resolver'
14
+ import { createHooksRouter, getPhaseStates } from './hooks'
15
+ import { getConfig, fetchIssues } from './config'
16
+ import { getAnalytics } from './analytics'
17
+ import type { ChatConversationRow } from './types'
18
+
19
+ // Extend Express Request to carry resolved ProjectContext
20
+ declare module 'express-serve-static-core' {
21
+ interface Request {
22
+ projectCtx?: ProjectContext
23
+ }
24
+ }
25
+
26
+ export function createProjectRouter(registry: ProjectRegistry): Router {
27
+ const router = Router({ mergeParams: true })
28
+
29
+ // Middleware: resolve project from :projectId param
30
+ router.use('/:projectId', (req: Request, res: Response, next: NextFunction) => {
31
+ const { projectId } = req.params
32
+ const ctx = registry.getContext(projectId)
33
+ if (!ctx) {
34
+ res.status(404).json({ error: 'Project not found' })
35
+ return
36
+ }
37
+ registry.touchProject(projectId)
38
+ req.projectCtx = ctx
39
+ next()
40
+ })
41
+
42
+ // Helper to get ctx (always defined after middleware)
43
+ function ctx(req: Request): ProjectContext {
44
+ return req.projectCtx!
45
+ }
46
+
47
+ // ─── Hooks ──────────────────────────────────────────────────────────────────
48
+
49
+ // Mount hooks router under each project
50
+ router.use('/:projectId/hooks', (req: Request, res: Response, next: NextFunction) => {
51
+ const projectCtx = ctx(req)
52
+ const hooksRouter = createHooksRouter(
53
+ projectCtx.broadcast,
54
+ projectCtx.db,
55
+ {
56
+ get current() { return projectCtx.queueManager.getActiveJobId() },
57
+ set current(_: string | null) { /* managed by QueueManager */ },
58
+ }
59
+ )
60
+ hooksRouter(req, res, next)
61
+ })
62
+
63
+ // ─── Queue / Spawn routes ────────────────────────────────────────────────────
64
+
65
+ router.post('/:projectId/spawn', (req: Request, res: Response) => {
66
+ const { command } = req.body ?? {}
67
+ if (!command || typeof command !== 'string' || !command.trim()) {
68
+ res.status(400).json({ error: 'command is required' })
69
+ return
70
+ }
71
+ try {
72
+ const job = ctx(req).queueManager.enqueue(command)
73
+ const position = job.queuePosition ?? 0
74
+ res.status(202).json({ jobId: job.id, position })
75
+ } catch (err) {
76
+ if (err instanceof ClaudeNotFoundError) {
77
+ res.status(400).json({ error: err.message })
78
+ } else {
79
+ console.error('[project-router] spawn error:', err)
80
+ res.status(500).json({ error: 'Internal server error' })
81
+ }
82
+ }
83
+ })
84
+
85
+ router.get('/:projectId/state', (req: Request, res: Response) => {
86
+ const { queueManager, project } = ctx(req)
87
+ res.json({
88
+ projectName: project.name,
89
+ projectId: project.id,
90
+ phases: getPhaseStates(),
91
+ busy: queueManager.getActiveJobId() !== null,
92
+ currentJobId: queueManager.getActiveJobId(),
93
+ })
94
+ })
95
+
96
+ router.delete('/:projectId/jobs/:id', (req: Request, res: Response) => {
97
+ try {
98
+ const result = ctx(req).queueManager.cancel(req.params.id)
99
+ res.json({ ok: true, status: result })
100
+ } catch (err) {
101
+ if (err instanceof JobNotFoundError) {
102
+ res.status(404).json({ error: 'Job not found' })
103
+ } else if (err instanceof JobAlreadyTerminalError) {
104
+ res.status(409).json({ error: 'Job is already in terminal state' })
105
+ } else {
106
+ res.status(500).json({ error: 'Internal server error' })
107
+ }
108
+ }
109
+ })
110
+
111
+ router.post('/:projectId/queue/pause', (req: Request, res: Response) => {
112
+ ctx(req).queueManager.pause()
113
+ res.json({ ok: true, paused: true })
114
+ })
115
+
116
+ router.post('/:projectId/queue/resume', (req: Request, res: Response) => {
117
+ ctx(req).queueManager.resume()
118
+ res.json({ ok: true, paused: false })
119
+ })
120
+
121
+ router.put('/:projectId/queue/reorder', (req: Request, res: Response) => {
122
+ const { jobIds } = req.body ?? {}
123
+ if (!Array.isArray(jobIds)) {
124
+ res.status(400).json({ error: 'jobIds must be an array' })
125
+ return
126
+ }
127
+ try {
128
+ ctx(req).queueManager.reorder(jobIds)
129
+ res.json({ ok: true, queue: jobIds })
130
+ } catch (err) {
131
+ res.status(400).json({ error: (err as Error).message })
132
+ }
133
+ })
134
+
135
+ router.get('/:projectId/queue', (req: Request, res: Response) => {
136
+ const { queueManager } = ctx(req)
137
+ res.json({
138
+ jobs: queueManager.getJobs(),
139
+ paused: queueManager.isPaused(),
140
+ activeJobId: queueManager.getActiveJobId(),
141
+ })
142
+ })
143
+
144
+ router.get('/:projectId/jobs', (req: Request, res: Response) => {
145
+ const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200)
146
+ const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0
147
+ const status = req.query.status as string | undefined
148
+ const from = req.query.from as string | undefined
149
+ const to = req.query.to as string | undefined
150
+ const result = listJobs(ctx(req).db, { limit, offset, status, from, to })
151
+ res.json(result)
152
+ })
153
+
154
+ router.get('/:projectId/jobs/:id', (req: Request, res: Response) => {
155
+ const { db, queueManager } = ctx(req)
156
+ const job = getJob(db, req.params.id)
157
+ if (!job) { res.status(404).json({ error: 'Job not found' }); return }
158
+ const events = getJobEvents(db, req.params.id)
159
+ const phaseDefinitions = queueManager.phasesForCommand(job.command)
160
+ res.json({ job, events, phaseDefinitions })
161
+ })
162
+
163
+ router.delete('/:projectId/jobs', (req: Request, res: Response) => {
164
+ try {
165
+ const { from, to } = req.body ?? {}
166
+ const deleted = purgeJobs(ctx(req).db, { from, to })
167
+ res.json({ ok: true, deleted })
168
+ } catch (err) {
169
+ console.error('[project-router] purge error:', err)
170
+ res.status(500).json({ error: 'Failed to purge jobs' })
171
+ }
172
+ })
173
+
174
+ router.get('/:projectId/stats', (req: Request, res: Response) => {
175
+ res.json(getStats(ctx(req).db))
176
+ })
177
+
178
+ router.get('/:projectId/analytics', (req: Request, res: Response) => {
179
+ const period = (req.query.period as string) || '7d'
180
+ const from = req.query.from as string | undefined
181
+ const to = req.query.to as string | undefined
182
+ const validPeriods = ['7d', '30d', '90d', 'all', 'custom']
183
+ if (!validPeriods.includes(period)) {
184
+ res.status(400).json({ error: 'Invalid period. Must be one of: 7d, 30d, 90d, all, custom' })
185
+ return
186
+ }
187
+ if (period === 'custom' && (!from || !to)) {
188
+ res.status(400).json({ error: 'from and to are required for custom period' })
189
+ return
190
+ }
191
+ try {
192
+ res.json(getAnalytics(ctx(req).db, { period: period as AnalyticsOpts['period'], from, to }))
193
+ } catch (err) {
194
+ console.error('[project-router] analytics error:', err)
195
+ res.status(500).json({ error: 'Failed to compute analytics' })
196
+ }
197
+ })
198
+
199
+ router.get('/:projectId/config', (req: Request, res: Response) => {
200
+ const { project, db } = ctx(req)
201
+ try {
202
+ const config = getConfig(project.path, db, project.name)
203
+ res.json(config)
204
+ } catch (err) {
205
+ console.error('[project-router] config error:', err)
206
+ res.status(500).json({ error: 'Failed to read config' })
207
+ }
208
+ })
209
+
210
+ router.post('/:projectId/config', (req: Request, res: Response) => {
211
+ const { active, labelFilter } = req.body ?? {}
212
+ const { db } = ctx(req)
213
+ try {
214
+ if (active !== undefined) {
215
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.active_tracker', ?)`).run(active ?? '')
216
+ }
217
+ if (labelFilter !== undefined) {
218
+ db.prepare(`INSERT OR REPLACE INTO queue_state (key, value) VALUES ('config.label_filter', ?)`).run(labelFilter ?? '')
219
+ }
220
+ res.json({ ok: true })
221
+ } catch (err) {
222
+ console.error('[project-router] config persist error:', err)
223
+ res.status(500).json({ error: 'Failed to persist config' })
224
+ }
225
+ })
226
+
227
+ router.get('/:projectId/issues', (req: Request, res: Response) => {
228
+ const { project, db } = ctx(req)
229
+ try {
230
+ const config = getConfig(project.path, db, project.name)
231
+ const tracker = config.issueTracker.active
232
+ if (!tracker) {
233
+ res.status(503).json({ error: 'No issue tracker configured', trackers: config.issueTracker })
234
+ return
235
+ }
236
+ const search = req.query.search as string | undefined
237
+ const label = req.query.label as string | undefined
238
+ const issues = fetchIssues(tracker, { search, label, repo: config.project.repo, cwd: project.path })
239
+ res.json(issues)
240
+ } catch (err) {
241
+ console.error('[project-router] issues error:', err)
242
+ res.status(500).json({ error: 'Failed to fetch issues' })
243
+ }
244
+ })
245
+
246
+ // ─── Chat routes ─────────────────────────────────────────────────────────────
247
+
248
+ router.get('/:projectId/chat/conversations', (req: Request, res: Response) => {
249
+ const conversations = listConversations(ctx(req).db)
250
+ res.json({ conversations })
251
+ })
252
+
253
+ router.post('/:projectId/chat/conversations', (req: Request, res: Response) => {
254
+ const { db } = ctx(req)
255
+ const model = (req.body?.model as string | undefined) ?? 'claude-sonnet-4-5'
256
+ const id = uuidv4()
257
+ createConversation(db, { id, model })
258
+ const conversation = getConversation(db, id) as ChatConversationRow
259
+ res.status(201).json({ conversation })
260
+ })
261
+
262
+ router.get('/:projectId/chat/conversations/:id', (req: Request, res: Response) => {
263
+ const { db } = ctx(req)
264
+ const conversation = getConversation(db, req.params.id)
265
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
266
+ const messages = getMessages(db, req.params.id)
267
+ res.json({ conversation, messages })
268
+ })
269
+
270
+ router.delete('/:projectId/chat/conversations/:id', (req: Request, res: Response) => {
271
+ const { db } = ctx(req)
272
+ const conversation = getConversation(db, req.params.id)
273
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
274
+ deleteConversation(db, req.params.id)
275
+ res.json({ ok: true })
276
+ })
277
+
278
+ router.patch('/:projectId/chat/conversations/:id', (req: Request, res: Response) => {
279
+ const { db } = ctx(req)
280
+ const conversation = getConversation(db, req.params.id)
281
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
282
+ const { title, model } = req.body ?? {}
283
+ const patch: { title?: string; model?: string } = {}
284
+ if (title !== undefined) patch.title = title
285
+ if (model !== undefined) patch.model = model
286
+ updateConversation(db, req.params.id, patch)
287
+ const updated = getConversation(db, req.params.id) as ChatConversationRow
288
+ res.json({ ok: true, conversation: updated })
289
+ })
290
+
291
+ router.get('/:projectId/chat/conversations/:id/messages', (req: Request, res: Response) => {
292
+ const { db } = ctx(req)
293
+ const conversation = getConversation(db, req.params.id)
294
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
295
+ const messages = getMessages(db, req.params.id)
296
+ res.json({ messages })
297
+ })
298
+
299
+ router.post('/:projectId/chat/conversations/:id/messages', async (req: Request, res: Response) => {
300
+ const { db, chatManager } = ctx(req)
301
+ const conversation = getConversation(db, req.params.id)
302
+ if (!conversation) { res.status(404).json({ error: 'Conversation not found' }); return }
303
+ const text = req.body?.text as string | undefined
304
+ if (!text || !text.trim()) { res.status(400).json({ error: 'text is required' }); return }
305
+ if (chatManager.isActive(req.params.id)) {
306
+ res.status(409).json({ error: 'CONVERSATION_BUSY' }); return
307
+ }
308
+ res.status(202).json({ ok: true })
309
+ chatManager.sendMessage(req.params.id, text.trim()).catch((err) => {
310
+ console.error('[project-router] chat sendMessage error:', err)
311
+ })
312
+ })
313
+
314
+ router.delete('/:projectId/chat/conversations/:id/messages/stream', (req: Request, res: Response) => {
315
+ const { chatManager } = ctx(req)
316
+ if (!chatManager.isActive(req.params.id)) {
317
+ res.status(404).json({ error: 'No active stream for this conversation' }); return
318
+ }
319
+ chatManager.abort(req.params.id)
320
+ res.json({ ok: true })
321
+ })
322
+
323
+ // ─── Setup routes ─────────────────────────────────────────────────────────────
324
+
325
+ router.post('/:projectId/setup/install', (req: Request, res: Response) => {
326
+ const { project, setupManager } = ctx(req)
327
+ if (setupManager.isInstalling(project.id)) {
328
+ res.status(409).json({ error: 'Install already in progress' }); return
329
+ }
330
+ res.status(202).json({ ok: true })
331
+ setupManager.startInstall(project.id, project.path)
332
+ })
333
+
334
+ router.post('/:projectId/setup/start', (req: Request, res: Response) => {
335
+ const { project, setupManager } = ctx(req)
336
+ if (setupManager.isSettingUp(project.id)) {
337
+ res.status(409).json({ error: 'Setup already in progress' }); return
338
+ }
339
+ res.status(202).json({ ok: true })
340
+ setupManager.startSetup(project.id, project.path)
341
+ })
342
+
343
+ router.post('/:projectId/setup/message', (req: Request, res: Response) => {
344
+ const { project, setupManager } = ctx(req)
345
+ const { sessionId, message } = req.body ?? {}
346
+ if (!sessionId || typeof sessionId !== 'string') {
347
+ res.status(400).json({ error: 'sessionId is required' }); return
348
+ }
349
+ if (!message || typeof message !== 'string' || !message.trim()) {
350
+ res.status(400).json({ error: 'message is required' }); return
351
+ }
352
+ if (setupManager.isSettingUp(project.id)) {
353
+ res.status(409).json({ error: 'Setup already in progress' }); return
354
+ }
355
+ res.status(202).json({ ok: true })
356
+ setupManager.resumeSetup(project.id, project.path, sessionId, message.trim())
357
+ })
358
+
359
+ router.get('/:projectId/setup/checkpoints', (req: Request, res: Response) => {
360
+ const { project, setupManager } = ctx(req)
361
+ const checkpoints = setupManager.getCheckpointStatus(project.id, project.path)
362
+ res.json({
363
+ checkpoints,
364
+ isInstalling: setupManager.isInstalling(project.id),
365
+ isSettingUp: setupManager.isSettingUp(project.id),
366
+ })
367
+ })
368
+
369
+ router.post('/:projectId/setup/abort', (req: Request, res: Response) => {
370
+ const { project, setupManager } = ctx(req)
371
+ setupManager.abort(project.id)
372
+ res.json({ ok: true })
373
+ })
374
+
375
+ // ─── Proposal routes ──────────────────────────────────────────────────────
376
+
377
+ router.get('/:projectId/propose', (req: Request, res: Response) => {
378
+ const limit = Math.min(parseInt(String(req.query.limit ?? '20'), 10) || 20, 100)
379
+ const offset = parseInt(String(req.query.offset ?? '0'), 10) || 0
380
+ const result = listProposals(ctx(req).db, { limit, offset })
381
+ res.json(result)
382
+ })
383
+
384
+ router.post('/:projectId/propose', async (req: Request, res: Response) => {
385
+ const { idea } = req.body ?? {}
386
+ if (!idea || typeof idea !== 'string' || !idea.trim()) {
387
+ res.status(400).json({ error: 'idea is required' }); return
388
+ }
389
+ // Pre-check: does the propose-feature command exist in this project?
390
+ const testCmd = `/sr:propose-feature test`
391
+ const resolved = resolveCommand(testCmd, ctx(req).project.path)
392
+ if (resolved === testCmd) {
393
+ res.status(400).json({ error: 'This project does not have the /sr:propose-feature command installed. Run "npx specrails" to update.' }); return
394
+ }
395
+ const id = uuidv4()
396
+ createProposal(ctx(req).db, { id, idea: idea.trim() })
397
+ res.status(202).json({ proposalId: id })
398
+ ctx(req).proposalManager.startExploration(id, idea.trim()).catch((err) => {
399
+ console.error('[project-router] proposal startExploration error:', err)
400
+ })
401
+ })
402
+
403
+ router.get('/:projectId/propose/:id', (req: Request, res: Response) => {
404
+ const proposal = getProposal(ctx(req).db, req.params.id)
405
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
406
+ res.json({ proposal })
407
+ })
408
+
409
+ router.post('/:projectId/propose/:id/refine', async (req: Request, res: Response) => {
410
+ const proposal = getProposal(ctx(req).db, req.params.id)
411
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
412
+ const { feedback } = req.body ?? {}
413
+ if (!feedback || typeof feedback !== 'string' || !feedback.trim()) {
414
+ res.status(400).json({ error: 'feedback is required' }); return
415
+ }
416
+ if (ctx(req).proposalManager.isActive(req.params.id)) {
417
+ res.status(409).json({ error: 'PROPOSAL_BUSY' }); return
418
+ }
419
+ if (proposal.status !== 'review') {
420
+ res.status(409).json({ error: 'Proposal is not in review state' }); return
421
+ }
422
+ res.status(202).json({ ok: true })
423
+ ctx(req).proposalManager.sendRefinement(req.params.id, feedback.trim()).catch((err) => {
424
+ console.error('[project-router] proposal sendRefinement error:', err)
425
+ })
426
+ })
427
+
428
+ router.post('/:projectId/propose/:id/create-issue', async (req: Request, res: Response) => {
429
+ const proposal = getProposal(ctx(req).db, req.params.id)
430
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
431
+ if (ctx(req).proposalManager.isActive(req.params.id)) {
432
+ res.status(409).json({ error: 'PROPOSAL_BUSY' }); return
433
+ }
434
+ if (proposal.status !== 'review') {
435
+ res.status(409).json({ error: 'Proposal is not in review state' }); return
436
+ }
437
+ res.status(202).json({ ok: true })
438
+ ctx(req).proposalManager.createIssue(req.params.id).catch((err) => {
439
+ console.error('[project-router] proposal createIssue error:', err)
440
+ })
441
+ })
442
+
443
+ router.delete('/:projectId/propose/:id', (req: Request, res: Response) => {
444
+ const proposal = getProposal(ctx(req).db, req.params.id)
445
+ if (!proposal) { res.status(404).json({ error: 'Proposal not found' }); return }
446
+ ctx(req).proposalManager.cancel(req.params.id)
447
+ res.json({ ok: true })
448
+ })
449
+
450
+ return router
451
+ }