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,400 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
|
2
|
+
import { EventEmitter } from 'events'
|
|
3
|
+
import { Readable } from 'stream'
|
|
4
|
+
|
|
5
|
+
// Mock child_process and uuid before importing queue-manager
|
|
6
|
+
vi.mock('child_process', () => ({
|
|
7
|
+
spawn: vi.fn(),
|
|
8
|
+
execSync: vi.fn(),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock('uuid', () => ({
|
|
12
|
+
v4: vi.fn(() => 'test-uuid-1111'),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('tree-kill', () => ({
|
|
16
|
+
default: vi.fn(),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
// Mock hooks to avoid side effects in tests
|
|
20
|
+
vi.mock('./hooks', () => ({
|
|
21
|
+
resetPhases: vi.fn(),
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
import { spawn as mockSpawn, execSync as mockExecSync } from 'child_process'
|
|
25
|
+
import treeKill from 'tree-kill'
|
|
26
|
+
import { v4 as mockUuidV4 } from 'uuid'
|
|
27
|
+
import { QueueManager, ClaudeNotFoundError, JobNotFoundError, JobAlreadyTerminalError } from './queue-manager'
|
|
28
|
+
import type { WsMessage } from './types'
|
|
29
|
+
|
|
30
|
+
function createMockChildProcess() {
|
|
31
|
+
const child = new EventEmitter() as any
|
|
32
|
+
child.stdout = new Readable({ read() {} })
|
|
33
|
+
child.stderr = new Readable({ read() {} })
|
|
34
|
+
child.pid = 12345
|
|
35
|
+
return child
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('QueueManager', () => {
|
|
39
|
+
let qm: QueueManager
|
|
40
|
+
let broadcast: ReturnType<typeof vi.fn>
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.resetAllMocks()
|
|
44
|
+
broadcast = vi.fn()
|
|
45
|
+
qm = new QueueManager(broadcast)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.restoreAllMocks()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// ─── enqueue ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe('enqueue', () => {
|
|
55
|
+
it('returns a job with status queued when a process is already running', () => {
|
|
56
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
57
|
+
const child1 = createMockChildProcess()
|
|
58
|
+
const child2 = createMockChildProcess()
|
|
59
|
+
vi.mocked(mockSpawn)
|
|
60
|
+
.mockReturnValueOnce(child1 as any)
|
|
61
|
+
.mockReturnValueOnce(child2 as any)
|
|
62
|
+
vi.mocked(mockUuidV4)
|
|
63
|
+
.mockReturnValueOnce('job-1' as any)
|
|
64
|
+
.mockReturnValueOnce('job-2' as any)
|
|
65
|
+
|
|
66
|
+
qm.enqueue('/implement #1')
|
|
67
|
+
const secondJob = qm.enqueue('/implement #2')
|
|
68
|
+
|
|
69
|
+
expect(secondJob.status).toBe('queued')
|
|
70
|
+
expect(secondJob.queuePosition).toBe(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('returns a job with status running when queue is empty (auto-drains)', () => {
|
|
74
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
75
|
+
const child = createMockChildProcess()
|
|
76
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
77
|
+
|
|
78
|
+
const job = qm.enqueue('/implement #1')
|
|
79
|
+
|
|
80
|
+
expect(job.status).toBe('running')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('throws ClaudeNotFoundError when claude is not on PATH', () => {
|
|
84
|
+
vi.mocked(mockExecSync).mockImplementation(() => {
|
|
85
|
+
throw new Error('not found')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(() => qm.enqueue('/implement #1')).toThrow(ClaudeNotFoundError)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('broadcasts queue state after enqueue', () => {
|
|
92
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
93
|
+
const child = createMockChildProcess()
|
|
94
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
95
|
+
|
|
96
|
+
qm.enqueue('/implement #1')
|
|
97
|
+
|
|
98
|
+
const queueBroadcasts = broadcast.mock.calls.filter(
|
|
99
|
+
(args: unknown[]) => (args[0] as WsMessage).type === 'queue'
|
|
100
|
+
)
|
|
101
|
+
expect(queueBroadcasts.length).toBeGreaterThanOrEqual(1)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ─── cancel ───────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
describe('cancel', () => {
|
|
108
|
+
it('on a queued job: removes from queue and broadcasts queue state', () => {
|
|
109
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
110
|
+
const child = createMockChildProcess()
|
|
111
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
112
|
+
vi.mocked(mockUuidV4)
|
|
113
|
+
.mockReturnValueOnce('job-running' as any)
|
|
114
|
+
.mockReturnValueOnce('job-queued' as any)
|
|
115
|
+
|
|
116
|
+
qm.enqueue('/implement #1')
|
|
117
|
+
qm.enqueue('/implement #2')
|
|
118
|
+
|
|
119
|
+
broadcast.mockClear()
|
|
120
|
+
|
|
121
|
+
const result = qm.cancel('job-queued')
|
|
122
|
+
|
|
123
|
+
expect(result).toBe('canceled')
|
|
124
|
+
const jobs = qm.getJobs()
|
|
125
|
+
const canceledJob = jobs.find((j) => j.id === 'job-queued')
|
|
126
|
+
expect(canceledJob?.status).toBe('canceled')
|
|
127
|
+
|
|
128
|
+
const queueBroadcast = broadcast.mock.calls.find(
|
|
129
|
+
(args: unknown[]) => (args[0] as WsMessage).type === 'queue'
|
|
130
|
+
)
|
|
131
|
+
expect(queueBroadcast).toBeDefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('on a running job: calls treeKill with SIGTERM and returns canceling', () => {
|
|
135
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
136
|
+
const child = createMockChildProcess()
|
|
137
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
138
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-running' as any)
|
|
139
|
+
|
|
140
|
+
qm.enqueue('/implement #1')
|
|
141
|
+
|
|
142
|
+
const result = qm.cancel('job-running')
|
|
143
|
+
|
|
144
|
+
expect(result).toBe('canceling')
|
|
145
|
+
expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('on a non-existent ID: throws JobNotFoundError', () => {
|
|
149
|
+
expect(() => qm.cancel('no-such-id')).toThrow(JobNotFoundError)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('on a completed job: throws JobAlreadyTerminalError', async () => {
|
|
153
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
154
|
+
const child = createMockChildProcess()
|
|
155
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
156
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
|
|
157
|
+
|
|
158
|
+
qm.enqueue('/implement #1')
|
|
159
|
+
child.emit('close', 0)
|
|
160
|
+
|
|
161
|
+
// Let close handler run
|
|
162
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
163
|
+
|
|
164
|
+
expect(() => qm.cancel('job-1')).toThrow(JobAlreadyTerminalError)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ─── pause / resume ───────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
describe('pause', () => {
|
|
171
|
+
it('prevents _drainQueue from starting the next job', () => {
|
|
172
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
173
|
+
const child = createMockChildProcess()
|
|
174
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
175
|
+
vi.mocked(mockUuidV4)
|
|
176
|
+
.mockReturnValueOnce('job-1' as any)
|
|
177
|
+
.mockReturnValueOnce('job-2' as any)
|
|
178
|
+
|
|
179
|
+
qm.pause()
|
|
180
|
+
qm.enqueue('/implement #1')
|
|
181
|
+
qm.enqueue('/implement #2')
|
|
182
|
+
|
|
183
|
+
// spawn should not have been called because queue is paused
|
|
184
|
+
expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('resume', () => {
|
|
189
|
+
it('calls _drainQueue and starts the next job if one is queued', () => {
|
|
190
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
191
|
+
const child = createMockChildProcess()
|
|
192
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
193
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
|
|
194
|
+
|
|
195
|
+
qm.pause()
|
|
196
|
+
qm.enqueue('/implement #1')
|
|
197
|
+
expect(vi.mocked(mockSpawn)).not.toHaveBeenCalled()
|
|
198
|
+
|
|
199
|
+
qm.resume()
|
|
200
|
+
expect(vi.mocked(mockSpawn)).toHaveBeenCalledOnce()
|
|
201
|
+
|
|
202
|
+
const jobs = qm.getJobs()
|
|
203
|
+
expect(jobs[0].status).toBe('running')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// ─── reorder ──────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
describe('reorder', () => {
|
|
210
|
+
it('reorders the queue array', () => {
|
|
211
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
212
|
+
const child = createMockChildProcess()
|
|
213
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
214
|
+
vi.mocked(mockUuidV4)
|
|
215
|
+
.mockReturnValueOnce('job-running' as any)
|
|
216
|
+
.mockReturnValueOnce('job-a' as any)
|
|
217
|
+
.mockReturnValueOnce('job-b' as any)
|
|
218
|
+
|
|
219
|
+
qm.enqueue('/implement #1')
|
|
220
|
+
qm.enqueue('/implement #2')
|
|
221
|
+
qm.enqueue('/implement #3')
|
|
222
|
+
|
|
223
|
+
qm.reorder(['job-b', 'job-a'])
|
|
224
|
+
|
|
225
|
+
const jobs = qm.getJobs()
|
|
226
|
+
const jobB = jobs.find((j) => j.id === 'job-b')
|
|
227
|
+
const jobA = jobs.find((j) => j.id === 'job-a')
|
|
228
|
+
expect(jobB?.queuePosition).toBe(1)
|
|
229
|
+
expect(jobA?.queuePosition).toBe(2)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('throws when jobIds do not match the queued set', () => {
|
|
233
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
234
|
+
const child = createMockChildProcess()
|
|
235
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
236
|
+
vi.mocked(mockUuidV4)
|
|
237
|
+
.mockReturnValueOnce('job-running' as any)
|
|
238
|
+
.mockReturnValueOnce('job-a' as any)
|
|
239
|
+
|
|
240
|
+
qm.enqueue('/implement #1')
|
|
241
|
+
qm.enqueue('/implement #2')
|
|
242
|
+
|
|
243
|
+
// Provide wrong ID
|
|
244
|
+
expect(() => qm.reorder(['job-a', 'wrong-id'])).toThrow()
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ─── job transitions ──────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
describe('job status transitions', () => {
|
|
251
|
+
it('job transitions to completed when process exits with code 0', async () => {
|
|
252
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
253
|
+
const child = createMockChildProcess()
|
|
254
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
255
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
|
|
256
|
+
|
|
257
|
+
qm.enqueue('/implement #1')
|
|
258
|
+
child.emit('close', 0)
|
|
259
|
+
|
|
260
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
261
|
+
|
|
262
|
+
const jobs = qm.getJobs()
|
|
263
|
+
expect(jobs[0].status).toBe('completed')
|
|
264
|
+
expect(jobs[0].exitCode).toBe(0)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('job transitions to failed when process exits with non-zero code', async () => {
|
|
268
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
269
|
+
const child = createMockChildProcess()
|
|
270
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
271
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
|
|
272
|
+
|
|
273
|
+
qm.enqueue('/implement #1')
|
|
274
|
+
child.emit('close', 1)
|
|
275
|
+
|
|
276
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
277
|
+
|
|
278
|
+
const jobs = qm.getJobs()
|
|
279
|
+
expect(jobs[0].status).toBe('failed')
|
|
280
|
+
expect(jobs[0].exitCode).toBe(1)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ─── getLogBuffer ─────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe('getLogBuffer', () => {
|
|
287
|
+
it('returns log lines accumulated during job execution', async () => {
|
|
288
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
289
|
+
const child = createMockChildProcess()
|
|
290
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
291
|
+
|
|
292
|
+
qm.enqueue('/implement #1')
|
|
293
|
+
|
|
294
|
+
child.stdout.push('hello from stdout\n')
|
|
295
|
+
child.stdout.push(null)
|
|
296
|
+
|
|
297
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
298
|
+
|
|
299
|
+
const buf = qm.getLogBuffer()
|
|
300
|
+
const line = buf.find((l) => l.line === 'hello from stdout')
|
|
301
|
+
expect(line).toBeDefined()
|
|
302
|
+
expect(line?.source).toBe('stdout')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('returns a copy, not a reference', () => {
|
|
306
|
+
const buf = qm.getLogBuffer()
|
|
307
|
+
buf.push({} as any)
|
|
308
|
+
expect(qm.getLogBuffer()).toEqual([])
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// ─── sequential queue drain ───────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
describe('sequential queue drain', () => {
|
|
315
|
+
it('second job starts when first jobs process emits close', async () => {
|
|
316
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
317
|
+
const child1 = createMockChildProcess()
|
|
318
|
+
const child2 = createMockChildProcess()
|
|
319
|
+
vi.mocked(mockSpawn)
|
|
320
|
+
.mockReturnValueOnce(child1 as any)
|
|
321
|
+
.mockReturnValueOnce(child2 as any)
|
|
322
|
+
vi.mocked(mockUuidV4)
|
|
323
|
+
.mockReturnValueOnce('job-1' as any)
|
|
324
|
+
.mockReturnValueOnce('job-2' as any)
|
|
325
|
+
|
|
326
|
+
qm.enqueue('/implement #1')
|
|
327
|
+
qm.enqueue('/implement #2')
|
|
328
|
+
|
|
329
|
+
expect(qm.getActiveJobId()).toBe('job-1')
|
|
330
|
+
|
|
331
|
+
child1.emit('close', 0)
|
|
332
|
+
|
|
333
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
334
|
+
|
|
335
|
+
expect(qm.getActiveJobId()).toBe('job-2')
|
|
336
|
+
|
|
337
|
+
const jobs = qm.getJobs()
|
|
338
|
+
expect(jobs.find((j) => j.id === 'job-2')?.status).toBe('running')
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// ─── kill timer ───────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
describe('kill timer', () => {
|
|
345
|
+
it('fires SIGKILL after 5s if process does not exit', async () => {
|
|
346
|
+
vi.useFakeTimers()
|
|
347
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
348
|
+
const child = createMockChildProcess()
|
|
349
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
350
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
|
|
351
|
+
|
|
352
|
+
qm.enqueue('/implement #1')
|
|
353
|
+
qm.cancel('job-1')
|
|
354
|
+
|
|
355
|
+
// Advance past 5s timeout
|
|
356
|
+
vi.advanceTimersByTime(5100)
|
|
357
|
+
|
|
358
|
+
expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGTERM')
|
|
359
|
+
expect(vi.mocked(treeKill)).toHaveBeenCalledWith(12345, 'SIGKILL')
|
|
360
|
+
|
|
361
|
+
vi.useRealTimers()
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// ─── getActiveJobId / isPaused ────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
describe('getActiveJobId', () => {
|
|
368
|
+
it('returns null when no job is running', () => {
|
|
369
|
+
expect(qm.getActiveJobId()).toBeNull()
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('returns the running job id after enqueue', () => {
|
|
373
|
+
vi.mocked(mockExecSync).mockReturnValue(Buffer.from('/usr/bin/claude'))
|
|
374
|
+
const child = createMockChildProcess()
|
|
375
|
+
vi.mocked(mockSpawn).mockReturnValue(child as any)
|
|
376
|
+
vi.mocked(mockUuidV4).mockReturnValue('job-1' as any)
|
|
377
|
+
|
|
378
|
+
qm.enqueue('/implement #1')
|
|
379
|
+
|
|
380
|
+
expect(qm.getActiveJobId()).toBe('job-1')
|
|
381
|
+
})
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
describe('isPaused', () => {
|
|
385
|
+
it('returns false by default', () => {
|
|
386
|
+
expect(qm.isPaused()).toBe(false)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('returns true after pause()', () => {
|
|
390
|
+
qm.pause()
|
|
391
|
+
expect(qm.isPaused()).toBe(true)
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('returns false after resume()', () => {
|
|
395
|
+
qm.pause()
|
|
396
|
+
qm.resume()
|
|
397
|
+
expect(qm.isPaused()).toBe(false)
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
})
|