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
|
@@ -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
|
+
}
|