@yail259/overnight 0.3.0 → 1.0.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/src/runner.ts DELETED
@@ -1,663 +0,0 @@
1
- import { query, type Options as ClaudeCodeOptions } from "@anthropic-ai/claude-agent-sdk";
2
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
3
- import { execSync } from "child_process";
4
- import { createHash } from "crypto";
5
- import {
6
- type JobConfig,
7
- type JobResult,
8
- type RunState,
9
- DEFAULT_TOOLS,
10
- DEFAULT_TIMEOUT,
11
- DEFAULT_RETRY_COUNT,
12
- DEFAULT_RETRY_DELAY,
13
- DEFAULT_VERIFY_PROMPT,
14
- DEFAULT_STATE_FILE,
15
- DEFAULT_MAX_TURNS,
16
- } from "./types.js";
17
- import { createSecurityHooks } from "./security.js";
18
-
19
- type LogCallback = (msg: string) => void;
20
- type ProgressCallback = (activity: string) => void;
21
-
22
- // Progress display
23
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
24
-
25
- class ProgressDisplay {
26
- private interval: ReturnType<typeof setInterval> | null = null;
27
- private frame = 0;
28
- private startTime = Date.now();
29
- private currentActivity = "Working";
30
- private lastToolUse = "";
31
-
32
- start(activity: string): void {
33
- this.currentActivity = activity;
34
- this.startTime = Date.now();
35
- this.frame = 0;
36
-
37
- if (this.interval) return;
38
-
39
- this.interval = setInterval(() => {
40
- const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
41
- const toolInfo = this.lastToolUse ? ` → ${this.lastToolUse}` : "";
42
- process.stdout.write(
43
- `\r\x1b[K${SPINNER_FRAMES[this.frame]} ${this.currentActivity} (${elapsed}s)${toolInfo}`
44
- );
45
- this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
46
- }, 100);
47
- }
48
-
49
- updateActivity(activity: string): void {
50
- this.currentActivity = activity;
51
- }
52
-
53
- updateTool(toolName: string, detail?: string): void {
54
- this.lastToolUse = detail ? `${toolName}: ${detail}` : toolName;
55
- }
56
-
57
- stop(finalMessage?: string): void {
58
- if (this.interval) {
59
- clearInterval(this.interval);
60
- this.interval = null;
61
- }
62
- process.stdout.write("\r\x1b[K"); // Clear line
63
- if (finalMessage) {
64
- console.log(finalMessage);
65
- }
66
- }
67
-
68
- getElapsed(): number {
69
- return (Date.now() - this.startTime) / 1000;
70
- }
71
- }
72
-
73
- // Cache the claude executable path
74
- let claudeExecutablePath: string | undefined;
75
-
76
- function findClaudeExecutable(): string | undefined {
77
- if (claudeExecutablePath !== undefined) return claudeExecutablePath;
78
-
79
- // Check environment variable first
80
- if (process.env.CLAUDE_CODE_PATH) {
81
- claudeExecutablePath = process.env.CLAUDE_CODE_PATH;
82
- return claudeExecutablePath;
83
- }
84
-
85
- // Try to find claude using which/where
86
- try {
87
- const cmd = process.platform === "win32" ? "where claude" : "which claude";
88
- claudeExecutablePath = execSync(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
89
- return claudeExecutablePath;
90
- } catch {
91
- // Fall back to common locations
92
- const commonPaths = [
93
- "/usr/local/bin/claude",
94
- "/opt/homebrew/bin/claude",
95
- `${process.env.HOME}/.local/bin/claude`,
96
- `${process.env.HOME}/.nvm/versions/node/v22.12.0/bin/claude`,
97
- ];
98
-
99
- for (const p of commonPaths) {
100
- if (existsSync(p)) {
101
- claudeExecutablePath = p;
102
- return claudeExecutablePath;
103
- }
104
- }
105
- }
106
-
107
- return undefined;
108
- }
109
-
110
- function isRetryableError(error: Error): boolean {
111
- const errorStr = error.message.toLowerCase();
112
- const retryablePatterns = [
113
- "api",
114
- "timeout",
115
- "connection",
116
- "network",
117
- "rate limit",
118
- "503",
119
- "502",
120
- "500",
121
- "unavailable",
122
- "overloaded",
123
- ];
124
- return retryablePatterns.some((pattern) => errorStr.includes(pattern));
125
- }
126
-
127
- async function sleep(ms: number): Promise<void> {
128
- return new Promise((resolve) => setTimeout(resolve, ms));
129
- }
130
-
131
- async function runWithTimeout<T>(
132
- promise: Promise<T>,
133
- timeoutMs: number
134
- ): Promise<T> {
135
- let timeoutId: ReturnType<typeof setTimeout>;
136
- const timeoutPromise = new Promise<never>((_, reject) => {
137
- timeoutId = setTimeout(() => reject(new Error("TIMEOUT")), timeoutMs);
138
- });
139
-
140
- try {
141
- const result = await Promise.race([promise, timeoutPromise]);
142
- clearTimeout(timeoutId!);
143
- return result;
144
- } catch (e) {
145
- clearTimeout(timeoutId!);
146
- throw e;
147
- }
148
- }
149
-
150
- // Extract useful info from tool input for display
151
- function getToolDetail(toolName: string, toolInput: Record<string, unknown>): string {
152
- switch (toolName) {
153
- case "Read":
154
- case "Write":
155
- case "Edit":
156
- const filePath = toolInput.file_path as string;
157
- if (filePath) {
158
- // Show just filename, not full path
159
- return filePath.split("/").pop() || filePath;
160
- }
161
- break;
162
- case "Glob":
163
- return (toolInput.pattern as string) || "";
164
- case "Grep":
165
- return (toolInput.pattern as string)?.slice(0, 20) || "";
166
- case "Bash":
167
- const cmd = (toolInput.command as string) || "";
168
- return cmd.slice(0, 30) + (cmd.length > 30 ? "..." : "");
169
- }
170
- return "";
171
- }
172
-
173
- async function collectResultWithProgress(
174
- prompt: string,
175
- options: ClaudeCodeOptions,
176
- progress: ProgressDisplay,
177
- onSessionId?: (sessionId: string) => void
178
- ): Promise<{ sessionId?: string; result?: string; error?: string }> {
179
- let sessionId: string | undefined;
180
- let result: string | undefined;
181
- let lastError: string | undefined;
182
-
183
- try {
184
- const conversation = query({ prompt, options });
185
-
186
- for await (const message of conversation) {
187
- // Debug logging
188
- if (process.env.OVERNIGHT_DEBUG) {
189
- console.error(`\n[DEBUG] message.type=${message.type}, keys=${Object.keys(message).join(",")}`);
190
- }
191
-
192
- // Handle different message types
193
- if (message.type === "result") {
194
- sessionId = message.session_id;
195
- if (message.subtype === "success") {
196
- result = message.result;
197
- }
198
- } else if (message.type === "assistant" && "message" in message) {
199
- // Assistant message with tool use - SDK nests content in message.message
200
- const assistantMsg = message.message as { content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }> };
201
- if (assistantMsg.content) {
202
- for (const block of assistantMsg.content) {
203
- if (process.env.OVERNIGHT_DEBUG) {
204
- console.error(`[DEBUG] content block: type=${block.type}, name=${block.name}`);
205
- }
206
- if (block.type === "tool_use" && block.name) {
207
- const detail = block.input ? getToolDetail(block.name, block.input) : "";
208
- progress.updateTool(block.name, detail);
209
- }
210
- }
211
- }
212
- } else if (message.type === "system" && "subtype" in message) {
213
- // System messages
214
- if (message.subtype === "init") {
215
- sessionId = message.session_id;
216
- if (sessionId && onSessionId) {
217
- onSessionId(sessionId);
218
- }
219
- }
220
- }
221
- }
222
- } catch (e) {
223
- lastError = (e as Error).message;
224
- throw e;
225
- }
226
-
227
- return { sessionId, result, error: lastError };
228
- }
229
-
230
- export async function runJob(
231
- config: JobConfig,
232
- log?: LogCallback,
233
- options?: {
234
- resumeSessionId?: string; // Resume from a previous session
235
- onSessionId?: (id: string) => void; // Called when session ID is available
236
- }
237
- ): Promise<JobResult> {
238
- const startTime = Date.now();
239
- const tools = config.allowed_tools ?? DEFAULT_TOOLS;
240
- const timeout = (config.timeout_seconds ?? DEFAULT_TIMEOUT) * 1000;
241
- const retryCount = config.retry_count ?? DEFAULT_RETRY_COUNT;
242
- const retryDelay = config.retry_delay ?? DEFAULT_RETRY_DELAY;
243
- const verifyPrompt = config.verify_prompt ?? DEFAULT_VERIFY_PROMPT;
244
- let retriesUsed = 0;
245
- let resumeSessionId = options?.resumeSessionId;
246
-
247
- const logMsg = (msg: string) => log?.(msg);
248
- const progress = new ProgressDisplay();
249
-
250
- // Find claude executable once at start
251
- const claudePath = findClaudeExecutable();
252
- if (!claudePath) {
253
- logMsg("\x1b[31m✗ Error: Could not find 'claude' CLI.\x1b[0m");
254
- logMsg("\x1b[33m Install it with:\x1b[0m");
255
- logMsg(" curl -fsSL https://claude.ai/install.sh | bash");
256
- logMsg("\x1b[33m Or set CLAUDE_CODE_PATH environment variable.\x1b[0m");
257
- return {
258
- task: config.prompt,
259
- status: "failed",
260
- error: "Claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash",
261
- duration_seconds: 0,
262
- verified: false,
263
- retries: 0,
264
- };
265
- }
266
-
267
- if (process.env.OVERNIGHT_DEBUG) {
268
- logMsg(`\x1b[2mDebug: Claude path = ${claudePath}\x1b[0m`);
269
- }
270
-
271
- // Show task being started
272
- const taskPreview = config.prompt.slice(0, 60) + (config.prompt.length > 60 ? "..." : "");
273
- if (resumeSessionId) {
274
- logMsg(`\x1b[36m▶\x1b[0m Resuming: ${taskPreview}`);
275
- } else {
276
- logMsg(`\x1b[36m▶\x1b[0m ${taskPreview}`);
277
- }
278
-
279
- let sessionId: string | undefined;
280
-
281
- for (let attempt = 0; attempt <= retryCount; attempt++) {
282
- try {
283
- // Build security hooks if security config provided
284
- const securityHooks = config.security ? createSecurityHooks(config.security) : undefined;
285
-
286
- const sdkOptions: ClaudeCodeOptions = {
287
- allowedTools: tools,
288
- permissionMode: "acceptEdits",
289
- ...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
290
- ...(config.working_dir && { cwd: config.working_dir }),
291
- ...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
292
- ...(securityHooks && { hooks: securityHooks }),
293
- ...(resumeSessionId && { resume: resumeSessionId }),
294
- };
295
-
296
- let result: string | undefined;
297
-
298
- // Prompt: if resuming, ask to continue; otherwise use original prompt
299
- const prompt = resumeSessionId
300
- ? "Continue where you left off. Complete the original task."
301
- : config.prompt;
302
-
303
- // Start progress display
304
- progress.start(resumeSessionId ? "Resuming" : "Working");
305
-
306
- try {
307
- const collected = await runWithTimeout(
308
- collectResultWithProgress(prompt, sdkOptions, progress, (id) => {
309
- sessionId = id;
310
- options?.onSessionId?.(id);
311
- }),
312
- timeout
313
- );
314
- sessionId = collected.sessionId;
315
- result = collected.result;
316
- progress.stop();
317
- } catch (e) {
318
- progress.stop();
319
- if ((e as Error).message === "TIMEOUT") {
320
- if (attempt < retryCount) {
321
- retriesUsed = attempt + 1;
322
- // On timeout, if we have a session ID, use it for the retry
323
- if (sessionId) {
324
- resumeSessionId = sessionId;
325
- }
326
- const delay = retryDelay * Math.pow(2, attempt);
327
- logMsg(
328
- `\x1b[33m⚠ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
329
- );
330
- await sleep(delay * 1000);
331
- continue;
332
- }
333
- logMsg(
334
- `\x1b[31m✗ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)\x1b[0m`
335
- );
336
- return {
337
- task: config.prompt,
338
- status: "timeout",
339
- error: `Timed out after ${config.timeout_seconds ?? DEFAULT_TIMEOUT} seconds`,
340
- duration_seconds: (Date.now() - startTime) / 1000,
341
- verified: false,
342
- retries: retriesUsed,
343
- };
344
- }
345
- throw e;
346
- }
347
-
348
- // Verification pass if enabled — verify and fix issues
349
- if (config.verify !== false && sessionId) {
350
- progress.start("Verifying");
351
-
352
- const verifyOptions: ClaudeCodeOptions = {
353
- allowedTools: tools,
354
- resume: sessionId,
355
- permissionMode: "acceptEdits",
356
- ...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
357
- ...(config.working_dir && { cwd: config.working_dir }),
358
- ...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
359
- };
360
-
361
- const fixPrompt = verifyPrompt +
362
- " If you find any issues, fix them now. Only report issues you cannot fix.";
363
-
364
- try {
365
- const verifyResult = await runWithTimeout(
366
- collectResultWithProgress(fixPrompt, verifyOptions, progress, (id) => {
367
- sessionId = id;
368
- options?.onSessionId?.(id);
369
- }),
370
- timeout / 2
371
- );
372
- progress.stop();
373
-
374
- // Update result with verification output
375
- if (verifyResult.result) {
376
- result = verifyResult.result;
377
- }
378
-
379
- // Only mark as failed if there are issues that couldn't be fixed
380
- const unfixableWords = ["cannot fix", "unable to", "blocked by", "requires manual"];
381
- if (
382
- verifyResult.result &&
383
- unfixableWords.some((word) =>
384
- verifyResult.result!.toLowerCase().includes(word)
385
- )
386
- ) {
387
- logMsg(`\x1b[33m⚠ Verification found unfixable issues\x1b[0m`);
388
- return {
389
- task: config.prompt,
390
- status: "verification_failed",
391
- result,
392
- error: `Unfixable issues: ${verifyResult.result}`,
393
- duration_seconds: (Date.now() - startTime) / 1000,
394
- verified: false,
395
- retries: retriesUsed,
396
- };
397
- }
398
- } catch (e) {
399
- progress.stop();
400
- if ((e as Error).message === "TIMEOUT") {
401
- logMsg("\x1b[33m⚠ Verification timed out - continuing anyway\x1b[0m");
402
- } else {
403
- throw e;
404
- }
405
- }
406
- }
407
-
408
- const duration = (Date.now() - startTime) / 1000;
409
- logMsg(`\x1b[32m✓ Completed in ${duration.toFixed(1)}s\x1b[0m`);
410
-
411
- return {
412
- task: config.prompt,
413
- status: "success",
414
- result,
415
- duration_seconds: duration,
416
- verified: config.verify !== false,
417
- retries: retriesUsed,
418
- };
419
- } catch (e) {
420
- progress.stop();
421
- const error = e as Error;
422
- if (isRetryableError(error) && attempt < retryCount) {
423
- retriesUsed = attempt + 1;
424
- // Preserve session for resumption on retry
425
- if (sessionId) {
426
- resumeSessionId = sessionId;
427
- }
428
- const delay = retryDelay * Math.pow(2, attempt);
429
- logMsg(
430
- `\x1b[33m⚠ ${error.message}, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
431
- );
432
- await sleep(delay * 1000);
433
- continue;
434
- }
435
-
436
- const duration = (Date.now() - startTime) / 1000;
437
- logMsg(`\x1b[31m✗ Failed: ${error.message}\x1b[0m`);
438
- return {
439
- task: config.prompt,
440
- status: "failed",
441
- error: error.message,
442
- duration_seconds: duration,
443
- verified: false,
444
- retries: retriesUsed,
445
- };
446
- }
447
- }
448
-
449
- // Should not reach here
450
- return {
451
- task: config.prompt,
452
- status: "failed",
453
- error: "Exhausted all retries",
454
- duration_seconds: (Date.now() - startTime) / 1000,
455
- verified: false,
456
- retries: retriesUsed,
457
- };
458
- }
459
-
460
- export function taskKey(config: JobConfig): string {
461
- if (config.id) return config.id;
462
- return createHash("sha256").update(config.prompt).digest("hex").slice(0, 12);
463
- }
464
-
465
- /** @deprecated Use taskKey(config) instead — kept for CLI backward compat */
466
- export function taskHash(prompt: string): string {
467
- return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
468
- }
469
-
470
- function validateDag(configs: JobConfig[]): string | null {
471
- const ids = new Set(configs.map(c => c.id).filter((id): id is string => Boolean(id)));
472
- // Check all depends_on references exist
473
- for (const c of configs) {
474
- for (const dep of c.depends_on ?? []) {
475
- if (!ids.has(dep)) {
476
- return `Task "${c.id ?? c.prompt.slice(0, 40)}" depends on unknown id "${dep}"`;
477
- }
478
- }
479
- }
480
- // Check for cycles via DFS
481
- const visited = new Set<string>();
482
- const inStack = new Set<string>();
483
- const idToConfig = new Map(configs.filter(c => c.id).map(c => [c.id!, c]));
484
-
485
- function hasCycle(id: string): boolean {
486
- if (inStack.has(id)) return true;
487
- if (visited.has(id)) return false;
488
- visited.add(id);
489
- inStack.add(id);
490
- const config = idToConfig.get(id);
491
- for (const dep of config?.depends_on ?? []) {
492
- if (hasCycle(dep)) return true;
493
- }
494
- inStack.delete(id);
495
- return false;
496
- }
497
-
498
- for (const id of ids) {
499
- if (hasCycle(id)) return `Dependency cycle detected involving "${id}"`;
500
- }
501
- return null;
502
- }
503
-
504
- function depsReady(
505
- config: JobConfig,
506
- completed: Record<string, JobResult>,
507
- ): "ready" | "waiting" | "blocked" {
508
- if (!config.depends_on || config.depends_on.length === 0) return "ready";
509
- for (const dep of config.depends_on) {
510
- const result = completed[dep];
511
- if (!result) return "waiting";
512
- if (result.status !== "success") return "blocked";
513
- }
514
- return "ready";
515
- }
516
-
517
- export function saveState(state: RunState, stateFile: string): void {
518
- writeFileSync(stateFile, JSON.stringify(state, null, 2));
519
- }
520
-
521
- export function loadState(stateFile: string): RunState | null {
522
- if (!existsSync(stateFile)) return null;
523
- return JSON.parse(readFileSync(stateFile, "utf-8"));
524
- }
525
-
526
- export function clearState(stateFile: string): void {
527
- if (existsSync(stateFile)) unlinkSync(stateFile);
528
- }
529
-
530
- export async function runJobsWithState(
531
- configs: JobConfig[],
532
- options: {
533
- stateFile?: string;
534
- log?: LogCallback;
535
- reloadConfigs?: () => JobConfig[]; // Called between jobs to pick up new tasks
536
- } = {}
537
- ): Promise<JobResult[]> {
538
- const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
539
-
540
- // Validate DAG if any tasks have dependencies
541
- const dagError = validateDag(configs);
542
- if (dagError) {
543
- options.log?.(`\x1b[31m✗ DAG error: ${dagError}\x1b[0m`);
544
- return [];
545
- }
546
-
547
- // Load existing state or start fresh
548
- const state: RunState = loadState(stateFile) ?? {
549
- completed: {},
550
- timestamp: new Date().toISOString(),
551
- };
552
-
553
- let currentConfigs = configs;
554
-
555
- while (true) {
556
- // Find tasks not yet completed
557
- const notDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
558
- if (notDone.length === 0) break;
559
-
560
- // Among not-done tasks, find those whose dependencies are satisfied
561
- const ready = notDone.filter(c => depsReady(c, state.completed) === "ready");
562
-
563
- // Find tasks blocked by failed dependencies
564
- const blocked = notDone.filter(c => depsReady(c, state.completed) === "blocked");
565
-
566
- // Mark blocked tasks as failed without running them
567
- for (const bc of blocked) {
568
- const key = taskKey(bc);
569
- if (key in state.completed) continue; // already recorded
570
- const failedDeps = (bc.depends_on ?? []).filter(
571
- dep => state.completed[dep] && state.completed[dep].status !== "success"
572
- );
573
- const label = bc.id ?? bc.prompt.slice(0, 40);
574
- options.log?.(`\n\x1b[31m✗ Skipping "${label}" — dependency failed: ${failedDeps.join(", ")}\x1b[0m`);
575
- state.completed[key] = {
576
- task: bc.prompt,
577
- status: "failed",
578
- error: `Blocked by failed dependencies: ${failedDeps.join(", ")}`,
579
- duration_seconds: 0,
580
- verified: false,
581
- retries: 0,
582
- };
583
- state.timestamp = new Date().toISOString();
584
- saveState(state, stateFile);
585
- }
586
-
587
- // If nothing is ready and nothing is blocked, everything remaining is waiting
588
- // on something that will never complete — break to avoid infinite loop
589
- if (ready.length === 0) break;
590
-
591
- const config = ready[0];
592
- const key = taskKey(config);
593
-
594
- const totalNotDone = notDone.length - blocked.length;
595
- const totalDone = Object.keys(state.completed).length;
596
- const label = config.id ? `${config.id}` : "";
597
- options.log?.(`\n\x1b[1m[${totalDone + 1}/${totalDone + totalNotDone}]${label ? ` ${label}` : ""}\x1b[0m`);
598
-
599
- // Check if this task was previously in-progress (crashed mid-task)
600
- const resumeSessionId = (state.inProgress?.hash === key)
601
- ? state.inProgress.sessionId
602
- : undefined;
603
-
604
- if (resumeSessionId) {
605
- options.log?.(`\x1b[2mResuming session ${resumeSessionId.slice(0, 8)}...\x1b[0m`);
606
- }
607
-
608
- // Mark task as in-progress before starting
609
- state.inProgress = { hash: key, prompt: config.prompt, startedAt: new Date().toISOString() };
610
- saveState(state, stateFile);
611
-
612
- const result = await runJob(config, options.log, {
613
- resumeSessionId,
614
- onSessionId: (id) => {
615
- // Checkpoint the session ID so we can resume on crash
616
- state.inProgress = { hash: key, prompt: config.prompt, sessionId: id, startedAt: state.inProgress!.startedAt };
617
- saveState(state, stateFile);
618
- },
619
- });
620
-
621
- // Task done — save result and clear in-progress
622
- state.completed[key] = result;
623
- state.inProgress = undefined;
624
- state.timestamp = new Date().toISOString();
625
- saveState(state, stateFile);
626
-
627
- // Re-read YAML to pick up new tasks added while running
628
- if (options.reloadConfigs) {
629
- try {
630
- currentConfigs = options.reloadConfigs();
631
- // Re-validate DAG with new configs
632
- const newDagError = validateDag(currentConfigs);
633
- if (newDagError) {
634
- options.log?.(`\x1b[33m⚠ DAG error in updated YAML, ignoring reload: ${newDagError}\x1b[0m`);
635
- currentConfigs = configs; // revert to original
636
- }
637
- } catch {
638
- // If reload fails (e.g. YAML syntax error mid-edit), keep current list
639
- }
640
- }
641
-
642
- // Brief pause between jobs
643
- const nextNotDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
644
- const nextReady = nextNotDone.filter(c => depsReady(c, state.completed) === "ready");
645
- if (nextReady.length > 0) {
646
- await sleep(1000);
647
- }
648
- }
649
-
650
- // Collect results in original order
651
- const results = currentConfigs
652
- .map(c => state.completed[taskKey(c)])
653
- .filter((r): r is JobResult => r !== undefined);
654
-
655
- // Clean up state file on completion
656
- clearState(stateFile);
657
-
658
- return results;
659
- }
660
-
661
- export function resultsToJson(results: JobResult[]): string {
662
- return JSON.stringify(results, null, 2);
663
- }