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,410 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import { Readable } from 'stream'
|
|
4
|
+
|
|
5
|
+
vi.mock('child_process', () => ({
|
|
6
|
+
spawn: vi.fn(),
|
|
7
|
+
execSync: vi.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
vi.mock('tree-kill', () => ({
|
|
11
|
+
default: vi.fn(),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
// Mock command-resolver to return a resolved prompt (different from raw command)
|
|
15
|
+
vi.mock('./command-resolver', () => ({
|
|
16
|
+
resolveCommand: vi.fn((command: string) => 'Resolved prompt for: ' + command),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
import { spawn as mockSpawn } from 'child_process'
|
|
20
|
+
import treeKill from 'tree-kill'
|
|
21
|
+
import { ProposalManager } from './proposal-manager'
|
|
22
|
+
import { initDb, createProposal, getProposal } from './db'
|
|
23
|
+
import type { DbInstance } from './db'
|
|
24
|
+
|
|
25
|
+
// ─── Mock helpers ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function createMockChildProcess() {
|
|
28
|
+
const child = new EventEmitter() as any
|
|
29
|
+
child.stdout = new Readable({ read() {} })
|
|
30
|
+
child.stderr = new Readable({ read() {} })
|
|
31
|
+
child.pid = 42000
|
|
32
|
+
child.kill = vi.fn()
|
|
33
|
+
return child
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pushLine(child: any, line: string) {
|
|
37
|
+
child.stdout.push(line + '\n')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function finishProcess(child: any, code: number): Promise<void> {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
child.stdout.push(null)
|
|
43
|
+
setImmediate(() => {
|
|
44
|
+
child.emit('close', code)
|
|
45
|
+
resolve()
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assistantEvent(text: string): string {
|
|
51
|
+
return JSON.stringify({
|
|
52
|
+
type: 'assistant',
|
|
53
|
+
message: { content: [{ type: 'text', text }] },
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resultEvent(sessionId: string): string {
|
|
58
|
+
return JSON.stringify({ type: 'result', session_id: sessionId })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getBroadcastedByType(broadcast: ReturnType<typeof vi.fn>, type: string) {
|
|
62
|
+
return broadcast.mock.calls
|
|
63
|
+
.map((args) => args[0] as Record<string, unknown>)
|
|
64
|
+
.filter((msg) => msg.type === type)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TEST_PROPOSAL_ID = 'proposal-test-001'
|
|
68
|
+
const TEST_CWD = '/fake/project/path'
|
|
69
|
+
|
|
70
|
+
// ─── Test suite ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe('ProposalManager', () => {
|
|
73
|
+
let db: DbInstance
|
|
74
|
+
let broadcast: ReturnType<typeof vi.fn>
|
|
75
|
+
let pm: ProposalManager
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.resetAllMocks()
|
|
79
|
+
db = initDb(':memory:')
|
|
80
|
+
broadcast = vi.fn()
|
|
81
|
+
pm = new ProposalManager(broadcast, db, TEST_CWD)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
vi.restoreAllMocks()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function setupProposal(id = TEST_PROPOSAL_ID, idea = 'Add dark mode') {
|
|
89
|
+
createProposal(db, { id, idea })
|
|
90
|
+
return id
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── startExploration ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('startExploration', () => {
|
|
96
|
+
it('spawns claude with correct args', async () => {
|
|
97
|
+
const proposalId = setupProposal()
|
|
98
|
+
const child = createMockChildProcess()
|
|
99
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
100
|
+
|
|
101
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
102
|
+
await finishProcess(child, 0)
|
|
103
|
+
await explorePromise
|
|
104
|
+
|
|
105
|
+
expect(vi.mocked(mockSpawn)).toHaveBeenCalledWith(
|
|
106
|
+
'claude',
|
|
107
|
+
expect.arrayContaining([
|
|
108
|
+
'--dangerously-skip-permissions',
|
|
109
|
+
'--output-format', 'stream-json',
|
|
110
|
+
'--verbose',
|
|
111
|
+
'-p',
|
|
112
|
+
]),
|
|
113
|
+
expect.objectContaining({ cwd: TEST_CWD })
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('broadcasts proposal_stream deltas as text arrives', async () => {
|
|
118
|
+
const proposalId = setupProposal()
|
|
119
|
+
const child = createMockChildProcess()
|
|
120
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
121
|
+
|
|
122
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
123
|
+
|
|
124
|
+
pushLine(child, assistantEvent('## Feature Title\n'))
|
|
125
|
+
pushLine(child, assistantEvent('Add Dark Mode'))
|
|
126
|
+
pushLine(child, resultEvent('sess-001'))
|
|
127
|
+
await finishProcess(child, 0)
|
|
128
|
+
await explorePromise
|
|
129
|
+
|
|
130
|
+
const streamMsgs = getBroadcastedByType(broadcast, 'proposal_stream')
|
|
131
|
+
expect(streamMsgs.length).toBeGreaterThan(0)
|
|
132
|
+
expect(streamMsgs[0].proposalId).toBe(proposalId)
|
|
133
|
+
expect(streamMsgs[0].delta).toBeTruthy()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('captures session_id from result event', async () => {
|
|
137
|
+
const proposalId = setupProposal()
|
|
138
|
+
const child = createMockChildProcess()
|
|
139
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
140
|
+
|
|
141
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
142
|
+
pushLine(child, assistantEvent('Some content'))
|
|
143
|
+
pushLine(child, resultEvent('sess-captured-001'))
|
|
144
|
+
await finishProcess(child, 0)
|
|
145
|
+
await explorePromise
|
|
146
|
+
|
|
147
|
+
const row = getProposal(db, proposalId)!
|
|
148
|
+
expect(row.session_id).toBe('sess-captured-001')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('broadcasts proposal_ready with full markdown on close(0)', async () => {
|
|
152
|
+
const proposalId = setupProposal()
|
|
153
|
+
const child = createMockChildProcess()
|
|
154
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
155
|
+
|
|
156
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
157
|
+
pushLine(child, assistantEvent('## Feature Title\nAdd Dark Mode'))
|
|
158
|
+
pushLine(child, resultEvent('sess-002'))
|
|
159
|
+
await finishProcess(child, 0)
|
|
160
|
+
await explorePromise
|
|
161
|
+
|
|
162
|
+
const readyMsgs = getBroadcastedByType(broadcast, 'proposal_ready')
|
|
163
|
+
expect(readyMsgs).toHaveLength(1)
|
|
164
|
+
expect(readyMsgs[0].proposalId).toBe(proposalId)
|
|
165
|
+
expect(readyMsgs[0].markdown).toContain('Add Dark Mode')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('updates proposal status to review on success', async () => {
|
|
169
|
+
const proposalId = setupProposal()
|
|
170
|
+
const child = createMockChildProcess()
|
|
171
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
172
|
+
|
|
173
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
174
|
+
pushLine(child, assistantEvent('Content'))
|
|
175
|
+
pushLine(child, resultEvent('sess-003'))
|
|
176
|
+
await finishProcess(child, 0)
|
|
177
|
+
await explorePromise
|
|
178
|
+
|
|
179
|
+
const row = getProposal(db, proposalId)!
|
|
180
|
+
expect(row.status).toBe('review')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('broadcasts proposal_error and resets status to input on close(non-0)', async () => {
|
|
184
|
+
const proposalId = setupProposal()
|
|
185
|
+
const child = createMockChildProcess()
|
|
186
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
187
|
+
|
|
188
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
189
|
+
await finishProcess(child, 1)
|
|
190
|
+
await explorePromise
|
|
191
|
+
|
|
192
|
+
const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
|
|
193
|
+
expect(errorMsgs).toHaveLength(1)
|
|
194
|
+
expect(errorMsgs[0].proposalId).toBe(proposalId)
|
|
195
|
+
|
|
196
|
+
const row = getProposal(db, proposalId)!
|
|
197
|
+
expect(row.status).toBe('input')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('does nothing if proposal not found in DB', async () => {
|
|
201
|
+
vi.mocked(mockSpawn)
|
|
202
|
+
|
|
203
|
+
await pm.startExploration('nonexistent-id', 'some idea')
|
|
204
|
+
|
|
205
|
+
const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
|
|
206
|
+
expect(errorMsgs).toHaveLength(1)
|
|
207
|
+
expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// ─── sendRefinement ────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe('sendRefinement', () => {
|
|
214
|
+
it('spawns with --resume <session_id>', async () => {
|
|
215
|
+
const proposalId = setupProposal()
|
|
216
|
+
// Set up proposal in review state with session_id
|
|
217
|
+
const db2 = db
|
|
218
|
+
db2.prepare("UPDATE proposals SET status = 'review', session_id = ? WHERE id = ?")
|
|
219
|
+
.run('sess-existing', proposalId)
|
|
220
|
+
|
|
221
|
+
const child = createMockChildProcess()
|
|
222
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
223
|
+
|
|
224
|
+
const refinePromise = pm.sendRefinement(proposalId, 'Make it simpler')
|
|
225
|
+
pushLine(child, assistantEvent('Simplified'))
|
|
226
|
+
pushLine(child, resultEvent('sess-refined'))
|
|
227
|
+
await finishProcess(child, 0)
|
|
228
|
+
await refinePromise
|
|
229
|
+
|
|
230
|
+
const spawnArgs = vi.mocked(mockSpawn).mock.calls[0][1] as string[]
|
|
231
|
+
expect(spawnArgs).toContain('--resume')
|
|
232
|
+
expect(spawnArgs).toContain('sess-existing')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('broadcasts proposal_refined on success', async () => {
|
|
236
|
+
const proposalId = setupProposal()
|
|
237
|
+
db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-r1' WHERE id = ?").run(proposalId)
|
|
238
|
+
|
|
239
|
+
const child = createMockChildProcess()
|
|
240
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
241
|
+
|
|
242
|
+
const refinePromise = pm.sendRefinement(proposalId, 'Refine this')
|
|
243
|
+
pushLine(child, assistantEvent('Refined content'))
|
|
244
|
+
pushLine(child, resultEvent('sess-r2'))
|
|
245
|
+
await finishProcess(child, 0)
|
|
246
|
+
await refinePromise
|
|
247
|
+
|
|
248
|
+
const refinedMsgs = getBroadcastedByType(broadcast, 'proposal_refined')
|
|
249
|
+
expect(refinedMsgs).toHaveLength(1)
|
|
250
|
+
expect(refinedMsgs[0].proposalId).toBe(proposalId)
|
|
251
|
+
expect(refinedMsgs[0].markdown).toContain('Refined content')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('returns early and broadcasts error if session_id is null', async () => {
|
|
255
|
+
const proposalId = setupProposal()
|
|
256
|
+
// session_id is null by default
|
|
257
|
+
|
|
258
|
+
await pm.sendRefinement(proposalId, 'Some feedback')
|
|
259
|
+
|
|
260
|
+
const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
|
|
261
|
+
expect(errorMsgs).toHaveLength(1)
|
|
262
|
+
expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
// ─── createIssue ──────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
describe('createIssue', () => {
|
|
269
|
+
it('extracts GitHub URL from response and broadcasts proposal_issue_created', async () => {
|
|
270
|
+
const proposalId = setupProposal()
|
|
271
|
+
db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-ci1' WHERE id = ?").run(proposalId)
|
|
272
|
+
|
|
273
|
+
const child = createMockChildProcess()
|
|
274
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
275
|
+
|
|
276
|
+
const issuePromise = pm.createIssue(proposalId)
|
|
277
|
+
pushLine(child, assistantEvent('I created the issue.\nhttps://github.com/owner/repo/issues/99'))
|
|
278
|
+
pushLine(child, resultEvent('sess-ci2'))
|
|
279
|
+
await finishProcess(child, 0)
|
|
280
|
+
await issuePromise
|
|
281
|
+
|
|
282
|
+
const issueMsgs = getBroadcastedByType(broadcast, 'proposal_issue_created')
|
|
283
|
+
expect(issueMsgs).toHaveLength(1)
|
|
284
|
+
expect(issueMsgs[0].proposalId).toBe(proposalId)
|
|
285
|
+
expect(issueMsgs[0].issueUrl).toBe('https://github.com/owner/repo/issues/99')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('broadcasts proposal_error if no GitHub URL found in response', async () => {
|
|
289
|
+
const proposalId = setupProposal()
|
|
290
|
+
db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-ci3' WHERE id = ?").run(proposalId)
|
|
291
|
+
|
|
292
|
+
const child = createMockChildProcess()
|
|
293
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
294
|
+
|
|
295
|
+
const issuePromise = pm.createIssue(proposalId)
|
|
296
|
+
pushLine(child, assistantEvent('I could not create the issue. GitHub CLI not found.'))
|
|
297
|
+
pushLine(child, resultEvent('sess-ci4'))
|
|
298
|
+
await finishProcess(child, 0)
|
|
299
|
+
await issuePromise
|
|
300
|
+
|
|
301
|
+
const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
|
|
302
|
+
expect(errorMsgs).toHaveLength(1)
|
|
303
|
+
expect(errorMsgs[0].proposalId).toBe(proposalId)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('updates proposal status to created when URL found', async () => {
|
|
307
|
+
const proposalId = setupProposal()
|
|
308
|
+
db.prepare("UPDATE proposals SET status = 'review', session_id = 'sess-ci5' WHERE id = ?").run(proposalId)
|
|
309
|
+
|
|
310
|
+
const child = createMockChildProcess()
|
|
311
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
312
|
+
|
|
313
|
+
const issuePromise = pm.createIssue(proposalId)
|
|
314
|
+
pushLine(child, assistantEvent('Done. https://github.com/owner/repo/issues/123'))
|
|
315
|
+
pushLine(child, resultEvent('sess-ci6'))
|
|
316
|
+
await finishProcess(child, 0)
|
|
317
|
+
await issuePromise
|
|
318
|
+
|
|
319
|
+
const row = getProposal(db, proposalId)!
|
|
320
|
+
expect(row.status).toBe('created')
|
|
321
|
+
expect(row.issue_url).toBe('https://github.com/owner/repo/issues/123')
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// ─── cancel ───────────────────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
describe('cancel', () => {
|
|
328
|
+
it('calls treeKill with SIGTERM on active process', async () => {
|
|
329
|
+
const proposalId = setupProposal()
|
|
330
|
+
const child = createMockChildProcess()
|
|
331
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
332
|
+
|
|
333
|
+
// Start exploration to create an active process
|
|
334
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
335
|
+
|
|
336
|
+
pm.cancel(proposalId)
|
|
337
|
+
|
|
338
|
+
expect(vi.mocked(treeKill)).toHaveBeenCalledWith(child.pid, 'SIGTERM')
|
|
339
|
+
|
|
340
|
+
// Let the process close to avoid open handles
|
|
341
|
+
await finishProcess(child, 1)
|
|
342
|
+
await explorePromise
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('updates proposal status to cancelled', () => {
|
|
346
|
+
const proposalId = setupProposal()
|
|
347
|
+
|
|
348
|
+
pm.cancel(proposalId)
|
|
349
|
+
|
|
350
|
+
const row = getProposal(db, proposalId)!
|
|
351
|
+
expect(row.status).toBe('cancelled')
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('broadcasts proposal_error with error: cancelled', () => {
|
|
355
|
+
const proposalId = setupProposal()
|
|
356
|
+
|
|
357
|
+
pm.cancel(proposalId)
|
|
358
|
+
|
|
359
|
+
const errorMsgs = getBroadcastedByType(broadcast, 'proposal_error')
|
|
360
|
+
expect(errorMsgs).toHaveLength(1)
|
|
361
|
+
expect(errorMsgs[0].error).toBe('cancelled')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('does nothing if no active process (cancel still updates DB)', () => {
|
|
365
|
+
const proposalId = setupProposal()
|
|
366
|
+
|
|
367
|
+
pm.cancel(proposalId)
|
|
368
|
+
|
|
369
|
+
expect(vi.mocked(treeKill)).not.toHaveBeenCalled()
|
|
370
|
+
// DB update and broadcast still happen
|
|
371
|
+
expect(getProposal(db, proposalId)!.status).toBe('cancelled')
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
// ─── isActive ─────────────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
describe('isActive', () => {
|
|
378
|
+
it('returns false before exploration starts', () => {
|
|
379
|
+
const proposalId = setupProposal()
|
|
380
|
+
expect(pm.isActive(proposalId)).toBe(false)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('returns true while exploration is running', () => {
|
|
384
|
+
const proposalId = setupProposal()
|
|
385
|
+
const child = createMockChildProcess()
|
|
386
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
387
|
+
|
|
388
|
+
pm.startExploration(proposalId, 'Add dark mode')
|
|
389
|
+
expect(pm.isActive(proposalId)).toBe(true)
|
|
390
|
+
|
|
391
|
+
// Cleanup
|
|
392
|
+
child.stdout.push(null)
|
|
393
|
+
child.emit('close', 0)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
it('returns false after exploration completes', async () => {
|
|
397
|
+
const proposalId = setupProposal()
|
|
398
|
+
const child = createMockChildProcess()
|
|
399
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
400
|
+
|
|
401
|
+
const explorePromise = pm.startExploration(proposalId, 'Add dark mode')
|
|
402
|
+
pushLine(child, assistantEvent('Content'))
|
|
403
|
+
pushLine(child, resultEvent('sess-x'))
|
|
404
|
+
await finishProcess(child, 0)
|
|
405
|
+
await explorePromise
|
|
406
|
+
|
|
407
|
+
expect(pm.isActive(proposalId)).toBe(false)
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
})
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
2
|
+
import { createInterface } from 'readline'
|
|
3
|
+
import treeKill from 'tree-kill'
|
|
4
|
+
import type { WsMessage } from './types'
|
|
5
|
+
import type { DbInstance } from './db'
|
|
6
|
+
import {
|
|
7
|
+
getProposal,
|
|
8
|
+
updateProposal,
|
|
9
|
+
} from './db'
|
|
10
|
+
import { resolveCommand } from './command-resolver'
|
|
11
|
+
|
|
12
|
+
// ─── ProposalManager ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export class ProposalManager {
|
|
15
|
+
private _broadcast: (msg: WsMessage) => void
|
|
16
|
+
private _db: DbInstance
|
|
17
|
+
private _cwd: string
|
|
18
|
+
private _activeProcesses: Map<string, ChildProcess>
|
|
19
|
+
private _buffers: Map<string, string>
|
|
20
|
+
|
|
21
|
+
constructor(broadcast: (msg: WsMessage) => void, db: DbInstance, cwd: string) {
|
|
22
|
+
this._broadcast = broadcast
|
|
23
|
+
this._db = db
|
|
24
|
+
this._cwd = cwd
|
|
25
|
+
this._activeProcesses = new Map()
|
|
26
|
+
this._buffers = new Map()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
isActive(proposalId: string): boolean {
|
|
30
|
+
return this._activeProcesses.has(proposalId)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async startExploration(proposalId: string, idea: string): Promise<void> {
|
|
34
|
+
const proposal = getProposal(this._db, proposalId)
|
|
35
|
+
if (!proposal) {
|
|
36
|
+
this._broadcastError(proposalId, 'Proposal not found')
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve the command file — error if not installed
|
|
41
|
+
const rawCommand = `/sr:propose-feature ${idea}`
|
|
42
|
+
const prompt = resolveCommand(rawCommand, this._cwd)
|
|
43
|
+
if (prompt === rawCommand) {
|
|
44
|
+
updateProposal(this._db, proposalId, { status: 'cancelled' })
|
|
45
|
+
this._broadcastError(proposalId, 'This project does not have the /sr:propose-feature command installed. Run "npx specrails" to update.')
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
updateProposal(this._db, proposalId, { status: 'exploring' })
|
|
50
|
+
|
|
51
|
+
const args = [
|
|
52
|
+
'--dangerously-skip-permissions',
|
|
53
|
+
'--output-format', 'stream-json',
|
|
54
|
+
'--verbose',
|
|
55
|
+
'-p', prompt,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
await this._runProcess(proposalId, args, (fullText, sessionId) => {
|
|
59
|
+
updateProposal(this._db, proposalId, {
|
|
60
|
+
status: 'review',
|
|
61
|
+
result_markdown: fullText,
|
|
62
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
63
|
+
})
|
|
64
|
+
this._broadcast({
|
|
65
|
+
type: 'proposal_ready',
|
|
66
|
+
projectId: '',
|
|
67
|
+
proposalId,
|
|
68
|
+
markdown: fullText,
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
})
|
|
71
|
+
}, () => {
|
|
72
|
+
updateProposal(this._db, proposalId, { status: 'input' })
|
|
73
|
+
this._broadcastError(proposalId, 'Exploration failed')
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async sendRefinement(proposalId: string, feedback: string): Promise<void> {
|
|
78
|
+
const proposal = getProposal(this._db, proposalId)
|
|
79
|
+
if (!proposal) {
|
|
80
|
+
this._broadcastError(proposalId, 'Proposal not found')
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!proposal.session_id) {
|
|
85
|
+
this._broadcastError(proposalId, 'No session available for refinement')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
updateProposal(this._db, proposalId, { status: 'refining' })
|
|
90
|
+
|
|
91
|
+
const args = [
|
|
92
|
+
'--dangerously-skip-permissions',
|
|
93
|
+
'--output-format', 'stream-json',
|
|
94
|
+
'--verbose',
|
|
95
|
+
'--resume', proposal.session_id,
|
|
96
|
+
'-p', feedback,
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
await this._runProcess(proposalId, args, (fullText, sessionId) => {
|
|
100
|
+
updateProposal(this._db, proposalId, {
|
|
101
|
+
status: 'review',
|
|
102
|
+
result_markdown: fullText,
|
|
103
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
104
|
+
})
|
|
105
|
+
this._broadcast({
|
|
106
|
+
type: 'proposal_refined',
|
|
107
|
+
projectId: '',
|
|
108
|
+
proposalId,
|
|
109
|
+
markdown: fullText,
|
|
110
|
+
timestamp: new Date().toISOString(),
|
|
111
|
+
})
|
|
112
|
+
}, () => {
|
|
113
|
+
updateProposal(this._db, proposalId, { status: 'review' })
|
|
114
|
+
this._broadcastError(proposalId, 'Refinement failed')
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async createIssue(proposalId: string): Promise<void> {
|
|
119
|
+
const proposal = getProposal(this._db, proposalId)
|
|
120
|
+
if (!proposal) {
|
|
121
|
+
this._broadcastError(proposalId, 'Proposal not found')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!proposal.session_id) {
|
|
126
|
+
this._broadcastError(proposalId, 'No session available for issue creation')
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
updateProposal(this._db, proposalId, { status: 'refining' })
|
|
131
|
+
|
|
132
|
+
const prompt =
|
|
133
|
+
"Based on the proposal above, create a GitHub Issue with the label 'user-proposed'. " +
|
|
134
|
+
"Output only the URL of the created issue on the last line of your response."
|
|
135
|
+
|
|
136
|
+
const args = [
|
|
137
|
+
'--dangerously-skip-permissions',
|
|
138
|
+
'--output-format', 'stream-json',
|
|
139
|
+
'--verbose',
|
|
140
|
+
'--resume', proposal.session_id,
|
|
141
|
+
'-p', prompt,
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
await this._runProcess(proposalId, args, (fullText, sessionId) => {
|
|
145
|
+
const match = fullText.match(/https:\/\/github\.com\/[^\s]+\/issues\/\d+/)
|
|
146
|
+
const issueUrl = match ? match[0] : null
|
|
147
|
+
|
|
148
|
+
if (issueUrl) {
|
|
149
|
+
updateProposal(this._db, proposalId, {
|
|
150
|
+
status: 'created',
|
|
151
|
+
issue_url: issueUrl,
|
|
152
|
+
...(sessionId ? { session_id: sessionId } : {}),
|
|
153
|
+
})
|
|
154
|
+
this._broadcast({
|
|
155
|
+
type: 'proposal_issue_created',
|
|
156
|
+
projectId: '',
|
|
157
|
+
proposalId,
|
|
158
|
+
issueUrl,
|
|
159
|
+
timestamp: new Date().toISOString(),
|
|
160
|
+
})
|
|
161
|
+
} else {
|
|
162
|
+
updateProposal(this._db, proposalId, { status: 'review' })
|
|
163
|
+
this._broadcastError(
|
|
164
|
+
proposalId,
|
|
165
|
+
'Issue creation failed — GitHub CLI may not be available or not authenticated'
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}, () => {
|
|
169
|
+
updateProposal(this._db, proposalId, { status: 'review' })
|
|
170
|
+
this._broadcastError(proposalId, 'Issue creation failed')
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
cancel(proposalId: string): void {
|
|
175
|
+
const child = this._activeProcesses.get(proposalId)
|
|
176
|
+
if (child?.pid) {
|
|
177
|
+
treeKill(child.pid, 'SIGTERM')
|
|
178
|
+
}
|
|
179
|
+
updateProposal(this._db, proposalId, { status: 'cancelled' })
|
|
180
|
+
this._broadcast({
|
|
181
|
+
type: 'proposal_error',
|
|
182
|
+
projectId: '',
|
|
183
|
+
proposalId,
|
|
184
|
+
error: 'cancelled',
|
|
185
|
+
timestamp: new Date().toISOString(),
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Private helpers ───────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
private async _runProcess(
|
|
192
|
+
proposalId: string,
|
|
193
|
+
args: string[],
|
|
194
|
+
onSuccess: (fullText: string, sessionId: string | null) => void,
|
|
195
|
+
onError: () => void
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const child = spawn('claude', args, {
|
|
198
|
+
env: process.env,
|
|
199
|
+
shell: false,
|
|
200
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
201
|
+
cwd: this._cwd,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
this._activeProcesses.set(proposalId, child)
|
|
205
|
+
this._buffers.set(proposalId, '')
|
|
206
|
+
|
|
207
|
+
let capturedSessionId: string | null = null
|
|
208
|
+
|
|
209
|
+
const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
|
|
210
|
+
|
|
211
|
+
stdoutReader.on('line', (line) => {
|
|
212
|
+
let parsed: Record<string, unknown> | null = null
|
|
213
|
+
try { parsed = JSON.parse(line) } catch { /* skip non-JSON */ }
|
|
214
|
+
if (!parsed) return
|
|
215
|
+
|
|
216
|
+
const eventType = parsed.type as string
|
|
217
|
+
|
|
218
|
+
if (eventType === 'result') {
|
|
219
|
+
const sid = parsed.session_id as string | undefined
|
|
220
|
+
if (sid) capturedSessionId = sid
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (eventType === 'assistant') {
|
|
224
|
+
const msg = parsed.message as { content?: Array<{ type: string; text?: string; name?: string }> } | undefined
|
|
225
|
+
const blocks = msg?.content ?? []
|
|
226
|
+
|
|
227
|
+
// Extract text from text blocks (skip thinking blocks)
|
|
228
|
+
const texts = blocks
|
|
229
|
+
.filter((c) => c.type === 'text')
|
|
230
|
+
.map((c) => c.text ?? '')
|
|
231
|
+
const newText = texts.join('')
|
|
232
|
+
if (newText) {
|
|
233
|
+
const prev = this._buffers.get(proposalId) ?? ''
|
|
234
|
+
this._buffers.set(proposalId, prev + newText)
|
|
235
|
+
this._broadcast({
|
|
236
|
+
type: 'proposal_stream',
|
|
237
|
+
projectId: '',
|
|
238
|
+
proposalId,
|
|
239
|
+
delta: newText,
|
|
240
|
+
timestamp: new Date().toISOString(),
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Broadcast tool_use activity so the UI can show "reading codebase..."
|
|
245
|
+
for (const block of blocks) {
|
|
246
|
+
if (block.type === 'tool_use' && block.name) {
|
|
247
|
+
this._broadcast({
|
|
248
|
+
type: 'proposal_stream',
|
|
249
|
+
projectId: '',
|
|
250
|
+
proposalId,
|
|
251
|
+
delta: `<!--tool:${block.name}-->`,
|
|
252
|
+
timestamp: new Date().toISOString(),
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
return new Promise<void>((resolve) => {
|
|
260
|
+
child.on('close', (code) => {
|
|
261
|
+
const fullText = this._buffers.get(proposalId) ?? ''
|
|
262
|
+
this._activeProcesses.delete(proposalId)
|
|
263
|
+
this._buffers.delete(proposalId)
|
|
264
|
+
|
|
265
|
+
if (code === 0) {
|
|
266
|
+
onSuccess(fullText, capturedSessionId)
|
|
267
|
+
} else {
|
|
268
|
+
onError()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
resolve()
|
|
272
|
+
})
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private _broadcastError(proposalId: string, error: string): void {
|
|
277
|
+
this._broadcast({
|
|
278
|
+
type: 'proposal_error',
|
|
279
|
+
projectId: '',
|
|
280
|
+
proposalId,
|
|
281
|
+
error,
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|