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.
- package/README.md +35 -22
- package/VERSION +1 -1
- package/bin/perf-check.sh +21 -0
- package/bin/specrails-core.js +7 -4
- package/commands/setup.md +51 -33
- package/docs/agents.md +43 -1
- package/docs/deployment.md +4 -4
- package/docs/getting-started.md +22 -8
- package/docs/installation.md +41 -25
- package/docs/local-tickets.md +192 -0
- package/docs/migration-guide.md +140 -0
- package/docs/testing/test-matrix-codex.md +0 -1
- package/docs/user-docs/cli-reference.md +0 -12
- package/docs/user-docs/codex-vs-claude-code.md +0 -1
- package/docs/user-docs/faq.md +6 -3
- package/docs/user-docs/installation.md +6 -3
- package/docs/user-docs/quick-start.md +8 -6
- package/docs/workflows.md +2 -14
- package/package.json +1 -1
- package/update.sh +0 -1
- package/docs/api-reference.md +0 -266
- package/integration-contract.json +0 -45
- package/templates/local-tickets-schema.json +0 -7
- package/templates/skills/sr-health-check/SKILL.md +0 -531
- package/templates/web-manager/package-lock.json +0 -3740
- package/templates/web-manager/server/queue-manager.test.ts +0 -607
- package/templates/web-manager/server/queue-manager.ts +0 -565
|
@@ -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
|
-
}
|