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,526 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
2
|
+
import { createInterface } from 'readline'
|
|
3
|
+
import { existsSync, readdirSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import treeKill from 'tree-kill'
|
|
6
|
+
import type { WsMessage } from './types'
|
|
7
|
+
|
|
8
|
+
// ─── Checkpoint definitions ───────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface CheckpointDefinition {
|
|
11
|
+
key: string
|
|
12
|
+
name: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CHECKPOINTS: CheckpointDefinition[] = [
|
|
16
|
+
{ key: 'base_install', name: 'Base installation' },
|
|
17
|
+
{ key: 'repo_analysis', name: 'Repository analysis' },
|
|
18
|
+
{ key: 'stack_conventions', name: 'Stack & conventions' },
|
|
19
|
+
{ key: 'product_discovery', name: 'Product discovery' },
|
|
20
|
+
{ key: 'agent_generation', name: 'Agent generation' },
|
|
21
|
+
{ key: 'command_config', name: 'Command configuration' },
|
|
22
|
+
{ key: 'final_verification', name: 'Final verification' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
// ─── Checkpoint filesystem checks ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface CheckpointStatus {
|
|
28
|
+
key: string
|
|
29
|
+
name: string
|
|
30
|
+
status: 'pending' | 'running' | 'done'
|
|
31
|
+
detail?: string
|
|
32
|
+
duration_ms?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkFilesystem(projectPath: string): Partial<Record<string, boolean>> {
|
|
36
|
+
const hasBaseInstall = existsSync(join(projectPath, '.specrails-version'))
|
|
37
|
+
const hasSetupTemplates = existsSync(join(projectPath, '.claude', 'setup-templates'))
|
|
38
|
+
const hasRules = existsSync(join(projectPath, '.claude', 'rules')) &&
|
|
39
|
+
hasFiles(join(projectPath, '.claude', 'rules'), /\.md$/)
|
|
40
|
+
const hasPersonas = existsSync(join(projectPath, '.claude', 'agents', 'personas')) &&
|
|
41
|
+
hasFiles(join(projectPath, '.claude', 'agents', 'personas'), /\.md$/)
|
|
42
|
+
const hasAgents = existsSync(join(projectPath, '.claude', 'agents')) &&
|
|
43
|
+
hasFiles(join(projectPath, '.claude', 'agents'), /^sr-.*\.md$/)
|
|
44
|
+
const hasCommands = existsSync(join(projectPath, '.claude', 'commands', 'sr')) &&
|
|
45
|
+
hasFiles(join(projectPath, '.claude', 'commands', 'sr'), /\.md$/)
|
|
46
|
+
const hasCLAUDE = existsSync(join(projectPath, 'CLAUDE.md'))
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
base_install: hasBaseInstall,
|
|
50
|
+
// repo_analysis: detected when setup templates exist and CLAUDE.md is written
|
|
51
|
+
// (Claude writes CLAUDE.md after analyzing the repo)
|
|
52
|
+
repo_analysis: hasBaseInstall && (hasCLAUDE || hasSetupTemplates),
|
|
53
|
+
// stack_conventions: detected when rules files are generated
|
|
54
|
+
stack_conventions: hasRules,
|
|
55
|
+
product_discovery: hasPersonas,
|
|
56
|
+
agent_generation: hasAgents,
|
|
57
|
+
command_config: hasCommands,
|
|
58
|
+
// Final verification: agents + commands must exist (manifest from install.sh is unreliable —
|
|
59
|
+
// it's created during scaffolding before /setup generates the actual artifacts)
|
|
60
|
+
final_verification: hasAgents && hasCommands,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasFiles(dir: string, pattern: RegExp): boolean {
|
|
65
|
+
try {
|
|
66
|
+
return readdirSync(dir).some((f) => pattern.test(f as string))
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Stream-based checkpoint detection ───────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function detectCheckpointFromText(
|
|
75
|
+
text: string
|
|
76
|
+
): { key: string; detail?: string }[] {
|
|
77
|
+
const hits: { key: string; detail?: string }[] = []
|
|
78
|
+
|
|
79
|
+
// Match phase headers from Claude's /setup output
|
|
80
|
+
if (/phase\s*1|codebase\s*analysis|repository\s*analysis/i.test(text)) {
|
|
81
|
+
hits.push({ key: 'repo_analysis', detail: 'Analyzing codebase...' })
|
|
82
|
+
}
|
|
83
|
+
if (/phase\s*2|user\s*personas|product\s*discovery/i.test(text)) {
|
|
84
|
+
hits.push({ key: 'product_discovery', detail: 'Generating personas...' })
|
|
85
|
+
}
|
|
86
|
+
if (/phase\s*3|configuration|agent\s*selection|backlog\s*provider/i.test(text)) {
|
|
87
|
+
hits.push({ key: 'stack_conventions', detail: 'Configuring stack...' })
|
|
88
|
+
}
|
|
89
|
+
if (/generating\s*all\s*files|writing.*agent|sr-architect|sr-developer|sr-reviewer/i.test(text)) {
|
|
90
|
+
hits.push({ key: 'agent_generation', detail: 'Generating agents...' })
|
|
91
|
+
}
|
|
92
|
+
if (/command\s*selection|installing.*commands|\.claude\/commands\/sr/i.test(text)) {
|
|
93
|
+
hits.push({ key: 'command_config', detail: 'Configuring commands...' })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// File path detection in tool_use events
|
|
97
|
+
if (text.includes('.specrails-version')) hits.push({ key: 'base_install' })
|
|
98
|
+
if (text.includes('/agents/personas/') && text.includes('.md')) {
|
|
99
|
+
hits.push({ key: 'product_discovery', detail: 'Writing personas...' })
|
|
100
|
+
}
|
|
101
|
+
if (/\/agents\/sr-[^/]+\.md/.test(text)) {
|
|
102
|
+
hits.push({ key: 'agent_generation', detail: 'Writing agents...' })
|
|
103
|
+
}
|
|
104
|
+
if (text.includes('/commands/sr/') && text.includes('.md')) {
|
|
105
|
+
hits.push({ key: 'command_config', detail: 'Writing commands...' })
|
|
106
|
+
}
|
|
107
|
+
if (text.includes('/rules/') && text.includes('.md')) {
|
|
108
|
+
hits.push({ key: 'stack_conventions', detail: 'Writing conventions...' })
|
|
109
|
+
}
|
|
110
|
+
if (text.includes('.specrails-manifest.json')) {
|
|
111
|
+
hits.push({ key: 'final_verification' })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return hits
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Setup summary computation ────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export interface SetupSummary {
|
|
120
|
+
agents: number
|
|
121
|
+
personas: number
|
|
122
|
+
commands: number
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function computeSummary(projectPath: string): SetupSummary {
|
|
126
|
+
let agents = 0
|
|
127
|
+
let personas = 0
|
|
128
|
+
let commands = 0
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const agentsDir = join(projectPath, '.claude', 'agents')
|
|
132
|
+
if (existsSync(agentsDir)) {
|
|
133
|
+
const files = readdirSync(agentsDir) as string[]
|
|
134
|
+
agents = files.filter((f) => /^sr-.*\.md$/.test(f)).length
|
|
135
|
+
const personasDir = join(agentsDir, 'personas')
|
|
136
|
+
if (existsSync(personasDir)) {
|
|
137
|
+
personas = (readdirSync(personasDir) as string[]).filter((f) => f.endsWith('.md')).length
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const commandsDir = join(projectPath, '.claude', 'commands', 'sr')
|
|
141
|
+
if (existsSync(commandsDir)) {
|
|
142
|
+
commands = (readdirSync(commandsDir) as string[]).filter((f) => f.endsWith('.md')).length
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// non-fatal
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { agents, personas, commands }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── SetupManager ─────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export class SetupManager {
|
|
154
|
+
private _broadcast: (msg: WsMessage) => void
|
|
155
|
+
// Map from projectId → active child processes
|
|
156
|
+
private _installProcesses: Map<string, ChildProcess>
|
|
157
|
+
private _setupProcesses: Map<string, ChildProcess>
|
|
158
|
+
// Track checkpoint states per project
|
|
159
|
+
private _checkpoints: Map<string, Map<string, CheckpointStatus>>
|
|
160
|
+
// Track checkpoint start times
|
|
161
|
+
private _checkpointStart: Map<string, Map<string, number>>
|
|
162
|
+
|
|
163
|
+
constructor(broadcast: (msg: WsMessage) => void) {
|
|
164
|
+
this._broadcast = broadcast
|
|
165
|
+
this._installProcesses = new Map()
|
|
166
|
+
this._setupProcesses = new Map()
|
|
167
|
+
this._checkpoints = new Map()
|
|
168
|
+
this._checkpointStart = new Map()
|
|
169
|
+
this._pollTimers = new Map()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Install: npx specrails ──────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
startInstall(projectId: string, projectPath: string): void {
|
|
175
|
+
if (this._installProcesses.has(projectId)) {
|
|
176
|
+
console.warn(`[SetupManager] install already running for ${projectId}`)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const child = spawn('npx', ['specrails', 'init', '--yes'], {
|
|
181
|
+
cwd: projectPath,
|
|
182
|
+
env: process.env,
|
|
183
|
+
shell: false,
|
|
184
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
this._installProcesses.set(projectId, child)
|
|
188
|
+
|
|
189
|
+
const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
|
|
190
|
+
const stderrReader = createInterface({ input: child.stderr!, crlfDelay: Infinity })
|
|
191
|
+
|
|
192
|
+
stdoutReader.on('line', (line) => {
|
|
193
|
+
this._broadcast({ type: 'setup_log', projectId, line, stream: 'stdout' })
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
stderrReader.on('line', (line) => {
|
|
197
|
+
this._broadcast({ type: 'setup_log', projectId, line, stream: 'stderr' })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
child.on('close', (code) => {
|
|
201
|
+
this._installProcesses.delete(projectId)
|
|
202
|
+
if (code === 0) {
|
|
203
|
+
this._broadcast({
|
|
204
|
+
type: 'setup_install_done',
|
|
205
|
+
projectId,
|
|
206
|
+
timestamp: new Date().toISOString(),
|
|
207
|
+
})
|
|
208
|
+
} else {
|
|
209
|
+
this._broadcast({
|
|
210
|
+
type: 'setup_error',
|
|
211
|
+
projectId,
|
|
212
|
+
error: `npx specrails exited with code ${code ?? 'unknown'}`,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Setup: claude -p "/setup" ───────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
startSetup(projectId: string, projectPath: string): void {
|
|
221
|
+
if (this._setupProcesses.has(projectId)) {
|
|
222
|
+
console.warn(`[SetupManager] setup already running for ${projectId}`)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this._initCheckpoints(projectId)
|
|
227
|
+
|
|
228
|
+
const args = [
|
|
229
|
+
'-p', '/setup',
|
|
230
|
+
'--dangerously-skip-permissions',
|
|
231
|
+
'--output-format', 'stream-json',
|
|
232
|
+
'--verbose',
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
this._spawnSetup(projectId, projectPath, args)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
resumeSetup(projectId: string, projectPath: string, sessionId: string, userMessage: string): void {
|
|
239
|
+
if (this._setupProcesses.has(projectId)) {
|
|
240
|
+
console.warn(`[SetupManager] setup already running for ${projectId}`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const args = [
|
|
245
|
+
'--resume', sessionId,
|
|
246
|
+
'--dangerously-skip-permissions',
|
|
247
|
+
'--output-format', 'stream-json',
|
|
248
|
+
'--verbose',
|
|
249
|
+
'-p', userMessage,
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
this._spawnSetup(projectId, projectPath, args)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Active filesystem poll timers per project
|
|
256
|
+
private _pollTimers: Map<string, ReturnType<typeof setInterval>>
|
|
257
|
+
|
|
258
|
+
private _startFilesystemPoll(projectId: string, projectPath: string): void {
|
|
259
|
+
this._stopFilesystemPoll(projectId)
|
|
260
|
+
const timer = setInterval(() => {
|
|
261
|
+
this._syncFilesystemCheckpoints(projectId, projectPath)
|
|
262
|
+
}, 3000)
|
|
263
|
+
this._pollTimers.set(projectId, timer)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private _stopFilesystemPoll(projectId: string): void {
|
|
267
|
+
const timer = this._pollTimers.get(projectId)
|
|
268
|
+
if (timer) {
|
|
269
|
+
clearInterval(timer)
|
|
270
|
+
this._pollTimers.delete(projectId)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private _spawnSetup(projectId: string, projectPath: string, args: string[]): void {
|
|
275
|
+
const child = spawn('claude', args, {
|
|
276
|
+
cwd: projectPath,
|
|
277
|
+
env: process.env,
|
|
278
|
+
shell: false,
|
|
279
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
this._setupProcesses.set(projectId, child)
|
|
283
|
+
|
|
284
|
+
// Start periodic filesystem polling for checkpoint detection
|
|
285
|
+
this._startFilesystemPoll(projectId, projectPath)
|
|
286
|
+
|
|
287
|
+
let capturedSessionId: string | null = null
|
|
288
|
+
|
|
289
|
+
const stdoutReader = createInterface({ input: child.stdout!, crlfDelay: Infinity })
|
|
290
|
+
const stderrReader = createInterface({ input: child.stderr!, crlfDelay: Infinity })
|
|
291
|
+
|
|
292
|
+
stdoutReader.on('line', (line) => {
|
|
293
|
+
let parsed: Record<string, unknown> | null = null
|
|
294
|
+
try { parsed = JSON.parse(line) } catch { /* plain text */ }
|
|
295
|
+
|
|
296
|
+
if (parsed) {
|
|
297
|
+
this._handleSetupStreamEvent(projectId, projectPath, parsed)
|
|
298
|
+
|
|
299
|
+
if ((parsed.type as string) === 'result') {
|
|
300
|
+
const sid = parsed.session_id as string | undefined
|
|
301
|
+
if (sid) capturedSessionId = sid
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Also broadcast as raw log for the collapsible log viewer
|
|
305
|
+
const eventType = parsed.type as string
|
|
306
|
+
if (eventType === 'assistant') {
|
|
307
|
+
const message = parsed.message as { content?: Array<{ type: string; text?: string; name?: string }> } | undefined
|
|
308
|
+
for (const block of message?.content ?? []) {
|
|
309
|
+
if (block.type === 'text' && block.text) {
|
|
310
|
+
this._broadcast({ type: 'setup_log', projectId, line: block.text, stream: 'stdout' })
|
|
311
|
+
} else if (block.type === 'tool_use' && block.name) {
|
|
312
|
+
this._broadcast({ type: 'setup_log', projectId, line: `[tool] ${block.name}`, stream: 'stdout' })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
// Plain text line — broadcast as log
|
|
318
|
+
this._broadcast({ type: 'setup_log', projectId, line, stream: 'stdout' })
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
stderrReader.on('line', (line) => {
|
|
323
|
+
this._broadcast({ type: 'setup_log', projectId, line, stream: 'stderr' })
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
child.on('close', (code) => {
|
|
327
|
+
this._setupProcesses.delete(projectId)
|
|
328
|
+
this._stopFilesystemPoll(projectId)
|
|
329
|
+
|
|
330
|
+
// Final filesystem sync
|
|
331
|
+
this._syncFilesystemCheckpoints(projectId, projectPath)
|
|
332
|
+
|
|
333
|
+
if (code === 0) {
|
|
334
|
+
// Sync filesystem checkpoints
|
|
335
|
+
this._syncFilesystemCheckpoints(projectId, projectPath)
|
|
336
|
+
|
|
337
|
+
// Check if setup is truly complete — real artifacts must exist
|
|
338
|
+
const hasAgents = existsSync(join(projectPath, '.claude', 'agents')) &&
|
|
339
|
+
hasFiles(join(projectPath, '.claude', 'agents'), /^sr-.*\.md$/)
|
|
340
|
+
const hasCommands = existsSync(join(projectPath, '.claude', 'commands', 'sr')) &&
|
|
341
|
+
hasFiles(join(projectPath, '.claude', 'commands', 'sr'), /\.md$/)
|
|
342
|
+
const isComplete = hasAgents && hasCommands
|
|
343
|
+
|
|
344
|
+
if (isComplete) {
|
|
345
|
+
const summary = computeSummary(projectPath)
|
|
346
|
+
this._broadcast({
|
|
347
|
+
type: 'setup_complete',
|
|
348
|
+
projectId,
|
|
349
|
+
sessionId: capturedSessionId ?? undefined,
|
|
350
|
+
summary,
|
|
351
|
+
})
|
|
352
|
+
} else {
|
|
353
|
+
// Claude finished one turn but setup isn't done yet.
|
|
354
|
+
// Emit turn_done so the wizard knows to wait for user input.
|
|
355
|
+
this._broadcast({
|
|
356
|
+
type: 'setup_turn_done',
|
|
357
|
+
projectId,
|
|
358
|
+
sessionId: capturedSessionId ?? undefined,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
this._broadcast({
|
|
363
|
+
type: 'setup_error',
|
|
364
|
+
projectId,
|
|
365
|
+
error: `claude setup exited with code ${code ?? 'unknown'}`,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private _handleSetupStreamEvent(
|
|
372
|
+
projectId: string,
|
|
373
|
+
projectPath: string,
|
|
374
|
+
event: Record<string, unknown>
|
|
375
|
+
): void {
|
|
376
|
+
const eventType = event.type as string
|
|
377
|
+
|
|
378
|
+
// Extract text for chat messages + detect checkpoints from content
|
|
379
|
+
if (eventType === 'assistant') {
|
|
380
|
+
const message = event.message as { content?: Array<{ type: string; text?: string }> } | undefined
|
|
381
|
+
const texts = (message?.content ?? [])
|
|
382
|
+
.filter((c) => c.type === 'text')
|
|
383
|
+
.map((c) => c.text ?? '')
|
|
384
|
+
const text = texts.join('')
|
|
385
|
+
if (text) {
|
|
386
|
+
this._broadcast({ type: 'setup_chat', projectId, text, role: 'assistant' })
|
|
387
|
+
|
|
388
|
+
// Detect phase transitions from Claude's output text
|
|
389
|
+
const hits = detectCheckpointFromText(text)
|
|
390
|
+
for (const hit of hits) {
|
|
391
|
+
this._advanceCheckpoint(projectId, hit.key, hit.detail)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Tool use events — check if writing to checkpoint-relevant paths
|
|
397
|
+
if (eventType === 'tool_use') {
|
|
398
|
+
const inputStr = JSON.stringify(event.input ?? {})
|
|
399
|
+
const hits = detectCheckpointFromText(inputStr)
|
|
400
|
+
for (const hit of hits) {
|
|
401
|
+
this._advanceCheckpoint(projectId, hit.key, hit.detail)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// After any event, sync filesystem checkpoints
|
|
406
|
+
this._syncFilesystemCheckpoints(projectId, projectPath)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private _initCheckpoints(projectId: string): void {
|
|
410
|
+
const statuses = new Map<string, CheckpointStatus>()
|
|
411
|
+
const starts = new Map<string, number>()
|
|
412
|
+
for (const def of CHECKPOINTS) {
|
|
413
|
+
statuses.set(def.key, { key: def.key, name: def.name, status: 'pending' })
|
|
414
|
+
}
|
|
415
|
+
this._checkpoints.set(projectId, statuses)
|
|
416
|
+
this._checkpointStart.set(projectId, starts)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private _advanceCheckpoint(projectId: string, key: string, detail?: string): void {
|
|
420
|
+
const statuses = this._checkpoints.get(projectId)
|
|
421
|
+
if (!statuses) return
|
|
422
|
+
|
|
423
|
+
const checkpoint = statuses.get(key)
|
|
424
|
+
if (!checkpoint || checkpoint.status === 'done') return
|
|
425
|
+
|
|
426
|
+
const starts = this._checkpointStart.get(projectId)!
|
|
427
|
+
|
|
428
|
+
// When a later checkpoint starts, auto-complete all earlier ones
|
|
429
|
+
const checkpointKeys = CHECKPOINTS.map((c) => c.key)
|
|
430
|
+
const targetIdx = checkpointKeys.indexOf(key)
|
|
431
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
432
|
+
const prevKey = checkpointKeys[i]
|
|
433
|
+
const prev = statuses.get(prevKey)
|
|
434
|
+
if (prev && prev.status !== 'done') {
|
|
435
|
+
this._completeCheckpoint(projectId, prevKey)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (checkpoint.status === 'pending') {
|
|
440
|
+
checkpoint.status = 'running'
|
|
441
|
+
starts.set(key, Date.now())
|
|
442
|
+
if (detail) checkpoint.detail = detail
|
|
443
|
+
this._broadcast({ type: 'setup_checkpoint', projectId, checkpoint: key, status: 'running', detail })
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private _completeCheckpoint(projectId: string, key: string): void {
|
|
448
|
+
const statuses = this._checkpoints.get(projectId)
|
|
449
|
+
if (!statuses) return
|
|
450
|
+
|
|
451
|
+
const checkpoint = statuses.get(key)
|
|
452
|
+
if (!checkpoint || checkpoint.status === 'done') return
|
|
453
|
+
|
|
454
|
+
const starts = this._checkpointStart.get(projectId)!
|
|
455
|
+
const startTime = starts.get(key) ?? Date.now()
|
|
456
|
+
const duration_ms = Date.now() - startTime
|
|
457
|
+
starts.delete(key)
|
|
458
|
+
|
|
459
|
+
checkpoint.status = 'done'
|
|
460
|
+
checkpoint.duration_ms = duration_ms
|
|
461
|
+
|
|
462
|
+
this._broadcast({ type: 'setup_checkpoint', projectId, checkpoint: key, status: 'done', duration_ms })
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private _syncFilesystemCheckpoints(projectId: string, projectPath: string): void {
|
|
466
|
+
const statuses = this._checkpoints.get(projectId)
|
|
467
|
+
if (!statuses) return
|
|
468
|
+
|
|
469
|
+
const fsChecks = checkFilesystem(projectPath)
|
|
470
|
+
|
|
471
|
+
for (const [key, exists] of Object.entries(fsChecks)) {
|
|
472
|
+
if (!exists) continue
|
|
473
|
+
const cp = statuses.get(key)
|
|
474
|
+
if (!cp) continue
|
|
475
|
+
|
|
476
|
+
if (cp.status === 'pending') {
|
|
477
|
+
// Fast-path: mark running then done immediately
|
|
478
|
+
this._advanceCheckpoint(projectId, key)
|
|
479
|
+
this._completeCheckpoint(projectId, key)
|
|
480
|
+
} else if (cp.status === 'running') {
|
|
481
|
+
this._completeCheckpoint(projectId, key)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Checkpoint poll endpoint ─────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
getCheckpointStatus(projectId: string, projectPath: string): CheckpointStatus[] {
|
|
489
|
+
// Sync from filesystem before returning
|
|
490
|
+
this._syncFilesystemCheckpoints(projectId, projectPath)
|
|
491
|
+
|
|
492
|
+
const statuses = this._checkpoints.get(projectId)
|
|
493
|
+
if (!statuses) {
|
|
494
|
+
// Return all-pending if setup hasn't started
|
|
495
|
+
return CHECKPOINTS.map((def) => ({ key: def.key, name: def.name, status: 'pending' as const }))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return CHECKPOINTS.map((def) => statuses.get(def.key) ?? { key: def.key, name: def.name, status: 'pending' as const })
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ─── Abort ────────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
abort(projectId: string): void {
|
|
504
|
+
this._stopFilesystemPoll(projectId)
|
|
505
|
+
|
|
506
|
+
const installChild = this._installProcesses.get(projectId)
|
|
507
|
+
if (installChild?.pid) {
|
|
508
|
+
treeKill(installChild.pid, 'SIGTERM')
|
|
509
|
+
this._installProcesses.delete(projectId)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const setupChild = this._setupProcesses.get(projectId)
|
|
513
|
+
if (setupChild?.pid) {
|
|
514
|
+
treeKill(setupChild.pid, 'SIGTERM')
|
|
515
|
+
this._setupProcesses.delete(projectId)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
isInstalling(projectId: string): boolean {
|
|
520
|
+
return this._installProcesses.has(projectId)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
isSettingUp(projectId: string): boolean {
|
|
524
|
+
return this._setupProcesses.has(projectId)
|
|
525
|
+
}
|
|
526
|
+
}
|