specrails-core 3.2.0 → 3.3.1

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