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,545 @@
1
+ import { spawn, execSync, ChildProcess } from 'child_process'
2
+ import { createInterface } from 'readline'
3
+ import { v4 as uuidv4 } from 'uuid'
4
+ import treeKill from 'tree-kill'
5
+ import type { WsMessage, LogMessage, Job, PhaseDefinition } from './types'
6
+ import { resolveCommand } from './command-resolver'
7
+ import { resetPhases, setActivePhases } from './hooks'
8
+ import { createJob, finishJob, appendEvent } from './db'
9
+ import type { JobResult } from './db'
10
+ import type { CommandInfo } from './config'
11
+
12
+ const LOG_BUFFER_MAX = 5000
13
+ const LOG_BUFFER_DROP = 1000
14
+
15
+ // ─── Error classes ────────────────────────────────────────────────────────────
16
+
17
+ export class ClaudeNotFoundError extends Error {
18
+ constructor() {
19
+ super('claude binary not found')
20
+ this.name = 'ClaudeNotFoundError'
21
+ }
22
+ }
23
+
24
+ export class JobNotFoundError extends Error {
25
+ constructor() {
26
+ super('Job not found')
27
+ this.name = 'JobNotFoundError'
28
+ }
29
+ }
30
+
31
+ export class JobAlreadyTerminalError extends Error {
32
+ constructor() {
33
+ super('Job is already in terminal state')
34
+ this.name = 'JobAlreadyTerminalError'
35
+ }
36
+ }
37
+
38
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
39
+
40
+ function claudeOnPath(): boolean {
41
+ try {
42
+ execSync('which claude', { stdio: 'ignore' })
43
+ return true
44
+ } catch {
45
+ return false
46
+ }
47
+ }
48
+
49
+ function extractDisplayText(event: Record<string, unknown>): string | null {
50
+ const type = event.type as string
51
+ if (type === 'assistant') {
52
+ const content = event.message as { content?: Array<{ type: string; text?: string }> } | undefined
53
+ const texts = (content?.content ?? [])
54
+ .filter((c) => c.type === 'text')
55
+ .map((c) => c.text ?? '')
56
+ return texts.join('') || null
57
+ }
58
+ if (type === 'tool_use') {
59
+ const name = (event as Record<string, unknown>).name as string
60
+ const input = JSON.stringify((event as Record<string, unknown>).input ?? {})
61
+ return `[tool: ${name}] ${input.slice(0, 120)}`
62
+ }
63
+ if (type === 'tool_result' || type === 'system_prompt' || type === 'user' || type === 'system' || type === 'result') {
64
+ return null
65
+ }
66
+ return null
67
+ }
68
+
69
+ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'canceled'])
70
+
71
+ // ─── QueueManager ─────────────────────────────────────────────────────────────
72
+
73
+ export class QueueManager {
74
+ private _queue: string[]
75
+ private _jobs: Map<string, Job>
76
+ private _activeProcess: ChildProcess | null
77
+ private _activeJobId: string | null
78
+ private _paused: boolean
79
+ private _killTimer: ReturnType<typeof setTimeout> | null
80
+ private _cancelingJobs: Set<string>
81
+ private _broadcast: (msg: WsMessage) => void
82
+ private _db: any
83
+ private _logBuffer: LogMessage[]
84
+ private _commands: CommandInfo[]
85
+ private _cwd: string | undefined
86
+
87
+ constructor(broadcast: (msg: WsMessage) => void, db?: any, commands?: CommandInfo[], cwd?: string) {
88
+ this._queue = []
89
+ this._jobs = new Map()
90
+ this._activeProcess = null
91
+ this._activeJobId = null
92
+ this._paused = false
93
+ this._killTimer = null
94
+ this._cancelingJobs = new Set()
95
+ this._broadcast = broadcast
96
+ this._db = db ?? null
97
+ this._logBuffer = []
98
+ this._commands = commands ?? []
99
+ this._cwd = cwd
100
+
101
+ if (this._db) {
102
+ this._restoreFromDb()
103
+ }
104
+ }
105
+
106
+ setCommands(commands: CommandInfo[]): void {
107
+ this._commands = commands
108
+ }
109
+
110
+ // ─── Public API ─────────────────────────────────────────────────────────────
111
+
112
+ enqueue(command: string): Job {
113
+ if (!claudeOnPath()) {
114
+ throw new ClaudeNotFoundError()
115
+ }
116
+
117
+ const id = uuidv4()
118
+ const job: Job = {
119
+ id,
120
+ command,
121
+ status: 'queued',
122
+ queuePosition: this._queue.length + 1,
123
+ startedAt: null,
124
+ finishedAt: null,
125
+ exitCode: null,
126
+ }
127
+
128
+ this._jobs.set(id, job)
129
+ this._queue.push(id)
130
+ this._persistJob(job)
131
+ this._broadcastQueueState()
132
+ this._drainQueue()
133
+
134
+ return job
135
+ }
136
+
137
+ cancel(jobId: string): 'canceled' | 'canceling' {
138
+ const job = this._jobs.get(jobId)
139
+ if (!job) {
140
+ throw new JobNotFoundError()
141
+ }
142
+ if (TERMINAL_STATUSES.has(job.status)) {
143
+ throw new JobAlreadyTerminalError()
144
+ }
145
+
146
+ if (job.status === 'queued') {
147
+ const idx = this._queue.indexOf(jobId)
148
+ if (idx !== -1) {
149
+ this._queue.splice(idx, 1)
150
+ }
151
+ job.status = 'canceled'
152
+ job.finishedAt = new Date().toISOString()
153
+ this._recomputePositions()
154
+ this._persistJob(job)
155
+ this._broadcastQueueState()
156
+ return 'canceled'
157
+ }
158
+
159
+ // job.status === 'running'
160
+ this._kill(jobId)
161
+ return 'canceling'
162
+ }
163
+
164
+ pause(): void {
165
+ this._paused = true
166
+ this._persistQueueState()
167
+ this._broadcastQueueState()
168
+ }
169
+
170
+ resume(): void {
171
+ this._paused = false
172
+ this._persistQueueState()
173
+ this._broadcastQueueState()
174
+ this._drainQueue()
175
+ }
176
+
177
+ reorder(jobIds: string[]): void {
178
+ const queuedSet = new Set(this._queue)
179
+ const incomingSet = new Set(jobIds)
180
+
181
+ if (queuedSet.size !== incomingSet.size) {
182
+ throw new Error('jobIds must contain exactly the IDs of all currently-queued jobs')
183
+ }
184
+ for (const id of jobIds) {
185
+ if (!queuedSet.has(id)) {
186
+ throw new Error(`Job ${id} is not in queued state`)
187
+ }
188
+ }
189
+
190
+ this._queue = [...jobIds]
191
+ this._recomputePositions()
192
+
193
+ if (this._db) {
194
+ for (const id of jobIds) {
195
+ const job = this._jobs.get(id)
196
+ if (job) {
197
+ this._persistJob(job)
198
+ }
199
+ }
200
+ }
201
+
202
+ this._broadcastQueueState()
203
+ }
204
+
205
+ getJobs(): Job[] {
206
+ return Array.from(this._jobs.values())
207
+ }
208
+
209
+ getActiveJobId(): string | null {
210
+ return this._activeJobId
211
+ }
212
+
213
+ isPaused(): boolean {
214
+ return this._paused
215
+ }
216
+
217
+ getLogBuffer(): LogMessage[] {
218
+ return [...this._logBuffer]
219
+ }
220
+
221
+ // ─── Private methods ────────────────────────────────────────────────────────
222
+
223
+ phasesForCommand(command: string): PhaseDefinition[] {
224
+ return this._phasesForCommand(command)
225
+ }
226
+
227
+ /**
228
+ * Resolve a slash command into a full prompt with $ARGUMENTS substituted.
229
+ * Delegates to the shared resolveCommand utility in command-resolver.ts.
230
+ */
231
+ private _resolveCommand(command: string): string {
232
+ return resolveCommand(command, this._cwd ?? process.cwd())
233
+ }
234
+
235
+ private _phasesForCommand(command: string): PhaseDefinition[] {
236
+ // Extract slug from command strings like "/sr:implement #5" or "implement"
237
+ const firstToken = command.trim().split(/\s+/)[0]
238
+ const slug = firstToken.includes(':') ? firstToken.split(':').pop()! : firstToken.replace(/^\//, '')
239
+ const info = this._commands.find((c) => c.slug === slug)
240
+ return info?.phases ?? []
241
+ }
242
+
243
+ private _drainQueue(): void {
244
+ if (this._activeJobId !== null) return
245
+ if (this._paused) return
246
+ if (this._queue.length === 0) return
247
+
248
+ const nextJobId = this._queue.shift()!
249
+ this._startJob(nextJobId)
250
+ }
251
+
252
+ private _startJob(jobId: string): void {
253
+ const job = this._jobs.get(jobId)
254
+ if (!job) return
255
+
256
+ job.status = 'running'
257
+ job.startedAt = new Date().toISOString()
258
+ job.queuePosition = null
259
+
260
+ this._recomputePositions()
261
+ this._persistJob(job)
262
+
263
+ const commandPhases = this._phasesForCommand(job.command)
264
+ if (commandPhases.length > 0) {
265
+ setActivePhases(commandPhases, this._broadcast)
266
+ } else {
267
+ resetPhases(this._broadcast)
268
+ }
269
+
270
+ const trimmedCommand = job.command.trim()
271
+ const args = [
272
+ '--dangerously-skip-permissions',
273
+ '--output-format', 'stream-json',
274
+ '--verbose',
275
+ '-p',
276
+ this._resolveCommand(trimmedCommand),
277
+ ]
278
+
279
+ const child = spawn('claude', args, {
280
+ env: process.env,
281
+ shell: false,
282
+ stdio: ['ignore', 'pipe', 'pipe'],
283
+ cwd: this._cwd,
284
+ })
285
+
286
+ this._activeProcess = child
287
+ this._activeJobId = jobId
288
+
289
+ let eventSeq = 0
290
+ let lastResultEvent: Record<string, unknown> | null = null
291
+
292
+ if (this._db) {
293
+ createJob(this._db, { id: jobId, command: job.command, started_at: job.startedAt! })
294
+ }
295
+
296
+ const emitLine = (source: 'stdout' | 'stderr', line: string): void => {
297
+ const msg: LogMessage = {
298
+ type: 'log',
299
+ source,
300
+ line,
301
+ timestamp: new Date().toISOString(),
302
+ processId: jobId,
303
+ }
304
+ this._logBuffer.push(msg)
305
+ if (this._logBuffer.length > LOG_BUFFER_MAX) {
306
+ this._logBuffer.splice(0, LOG_BUFFER_DROP)
307
+ }
308
+ this._broadcast(msg)
309
+ }
310
+
311
+ const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
312
+ const stderrReader = createInterface({ input: child.stderr!, crlfDelay: Infinity })
313
+
314
+ stdoutReader.on('line', (line) => {
315
+ let parsed: Record<string, unknown> | null = null
316
+ try { parsed = JSON.parse(line) } catch { /* plain text */ }
317
+
318
+ if (parsed) {
319
+ const eventType = (parsed.type as string) ?? 'unknown'
320
+ if (this._db) {
321
+ appendEvent(this._db, jobId, eventSeq++, {
322
+ event_type: eventType,
323
+ source: 'stdout',
324
+ payload: line,
325
+ })
326
+ }
327
+ this._broadcast({
328
+ type: 'event',
329
+ jobId,
330
+ event_type: eventType,
331
+ source: 'stdout',
332
+ payload: line,
333
+ timestamp: new Date().toISOString(),
334
+ seq: eventSeq - 1,
335
+ })
336
+ if (eventType === 'result') {
337
+ lastResultEvent = parsed
338
+ }
339
+ const displayText = extractDisplayText(parsed)
340
+ if (displayText !== null) {
341
+ if (this._db) {
342
+ appendEvent(this._db, jobId, eventSeq++, {
343
+ event_type: 'log',
344
+ source: 'stdout',
345
+ payload: JSON.stringify({ line: displayText }),
346
+ })
347
+ }
348
+ emitLine('stdout', displayText)
349
+ }
350
+ } else {
351
+ if (this._db) {
352
+ appendEvent(this._db, jobId, eventSeq++, {
353
+ event_type: 'log',
354
+ source: 'stdout',
355
+ payload: JSON.stringify({ line }),
356
+ })
357
+ }
358
+ emitLine('stdout', line)
359
+ }
360
+ })
361
+
362
+ stderrReader.on('line', (line) => {
363
+ if (this._db) {
364
+ appendEvent(this._db, jobId, eventSeq++, {
365
+ event_type: 'log',
366
+ source: 'stderr',
367
+ payload: JSON.stringify({ line }),
368
+ })
369
+ }
370
+ emitLine('stderr', line)
371
+ })
372
+
373
+ child.on('close', (code) => {
374
+ this._onJobExit(jobId, code, lastResultEvent, emitLine)
375
+ })
376
+
377
+ this._broadcastQueueState()
378
+ }
379
+
380
+ private _onJobExit(
381
+ jobId: string,
382
+ code: number | null,
383
+ lastResultEvent: Record<string, unknown> | null,
384
+ emitLine: (source: 'stdout' | 'stderr', line: string) => void
385
+ ): void {
386
+ if (this._killTimer !== null) {
387
+ clearTimeout(this._killTimer)
388
+ this._killTimer = null
389
+ }
390
+
391
+ const job = this._jobs.get(jobId)
392
+ if (!job) return
393
+
394
+ const wasCanceling = this._cancelingJobs.has(jobId)
395
+ this._cancelingJobs.delete(jobId)
396
+
397
+ let finalStatus: Job['status']
398
+ if (wasCanceling) {
399
+ finalStatus = 'canceled'
400
+ } else if (code === 0) {
401
+ finalStatus = 'completed'
402
+ } else {
403
+ finalStatus = 'failed'
404
+ }
405
+
406
+ job.status = finalStatus
407
+ job.finishedAt = new Date().toISOString()
408
+ job.exitCode = code
409
+
410
+ this._activeProcess = null
411
+ this._activeJobId = null
412
+
413
+ if (this._db) {
414
+ let tokenData: Partial<JobResult> = {}
415
+ if (lastResultEvent) {
416
+ const usage = lastResultEvent.usage as Record<string, number> | undefined
417
+ tokenData = {
418
+ tokens_in: usage?.input_tokens,
419
+ tokens_out: usage?.output_tokens,
420
+ tokens_cache_read: usage?.cache_read_input_tokens,
421
+ tokens_cache_create: usage?.cache_creation_input_tokens,
422
+ total_cost_usd: lastResultEvent.total_cost_usd as number | undefined,
423
+ num_turns: lastResultEvent.num_turns as number | undefined,
424
+ model: lastResultEvent.model as string | undefined,
425
+ duration_ms: lastResultEvent.duration_ms as number | undefined,
426
+ duration_api_ms: lastResultEvent.api_duration_ms as number | undefined,
427
+ session_id: lastResultEvent.session_id as string | undefined,
428
+ }
429
+ }
430
+ finishJob(this._db, jobId, {
431
+ exit_code: code ?? -1,
432
+ status: finalStatus,
433
+ ...tokenData,
434
+ })
435
+ const costStr = (lastResultEvent?.total_cost_usd as number | undefined) != null
436
+ ? ` | cost: $${(lastResultEvent!.total_cost_usd as number).toFixed(4)}`
437
+ : ''
438
+ emitLine('stdout', `[process exited with code ${code ?? 'unknown'}${costStr}]`)
439
+ } else {
440
+ emitLine('stdout', `[process exited with code ${code ?? 'unknown'}]`)
441
+ }
442
+
443
+ this._broadcastQueueState()
444
+ this._drainQueue()
445
+ }
446
+
447
+ private _kill(jobId: string): void {
448
+ if (!this._activeProcess || !this._activeProcess.pid) return
449
+
450
+ this._cancelingJobs.add(jobId)
451
+ treeKill(this._activeProcess.pid, 'SIGTERM')
452
+
453
+ const pid = this._activeProcess.pid
454
+ this._killTimer = setTimeout(() => {
455
+ treeKill(pid, 'SIGKILL')
456
+ this._killTimer = null
457
+ }, 5000)
458
+ }
459
+
460
+ private _broadcastQueueState(): void {
461
+ this._broadcast({
462
+ type: 'queue',
463
+ jobs: this.getJobs(),
464
+ activeJobId: this._activeJobId,
465
+ paused: this._paused,
466
+ timestamp: new Date().toISOString(),
467
+ })
468
+ }
469
+
470
+ private _persistJob(job: Job): void {
471
+ if (!this._db) return
472
+ // For queued jobs, we use the DB to store queue position for startup restore.
473
+ // We only upsert queue_position — the rest is handled by createJob/finishJob.
474
+ // Since this method is called for all status transitions, we use a flexible upsert
475
+ // that only touches queue_position (for queued jobs) — other fields are
476
+ // managed by the existing createJob/finishJob API.
477
+ try {
478
+ this._db.prepare(
479
+ `UPDATE jobs SET queue_position = ? WHERE id = ?`
480
+ ).run(job.queuePosition ?? null, job.id)
481
+ } catch {
482
+ // Job may not exist in DB yet (e.g., queued before createJob is called in _startJob)
483
+ // This is fine — we'll create the row when the job starts.
484
+ }
485
+ }
486
+
487
+ private _persistQueueState(): void {
488
+ if (!this._db) return
489
+ try {
490
+ this._db.prepare(
491
+ `INSERT OR REPLACE INTO queue_state (key, value) VALUES ('paused', ?)`
492
+ ).run(this._paused ? 'true' : 'false')
493
+ } catch {
494
+ // queue_state table may not exist if migration hasn't run
495
+ }
496
+ }
497
+
498
+ private _restoreFromDb(): void {
499
+ if (!this._db) return
500
+
501
+ try {
502
+ // Fail any jobs that were running when the server last shut down
503
+ this._db.prepare(
504
+ `UPDATE jobs SET status = 'failed', finished_at = CURRENT_TIMESTAMP WHERE status = 'running'`
505
+ ).run()
506
+
507
+ // Restore queued jobs in order
508
+ const rows = this._db.prepare(
509
+ `SELECT id, command, queue_position FROM jobs WHERE status = 'queued' ORDER BY queue_position ASC`
510
+ ).all() as Array<{ id: string; command: string; queue_position: number | null }>
511
+
512
+ for (const row of rows) {
513
+ const job: Job = {
514
+ id: row.id,
515
+ command: row.command,
516
+ status: 'queued',
517
+ queuePosition: row.queue_position,
518
+ startedAt: null,
519
+ finishedAt: null,
520
+ exitCode: null,
521
+ }
522
+ this._jobs.set(row.id, job)
523
+ this._queue.push(row.id)
524
+ }
525
+
526
+ // Restore pause state
527
+ const pauseRow = this._db.prepare(
528
+ `SELECT value FROM queue_state WHERE key = 'paused'`
529
+ ).get() as { value: string } | undefined
530
+
531
+ this._paused = pauseRow?.value === 'true'
532
+ } catch {
533
+ // DB may not have queue_state table yet — ignore
534
+ }
535
+ }
536
+
537
+ private _recomputePositions(): void {
538
+ this._queue.forEach((id, index) => {
539
+ const job = this._jobs.get(id)
540
+ if (job) {
541
+ job.queuePosition = index + 1
542
+ }
543
+ })
544
+ }
545
+ }