@yail259/overnight 0.2.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,660 +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: Timer;
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
- result = message.result;
195
- sessionId = message.session_id;
196
- } else if (message.type === "assistant" && "message" in message) {
197
- // Assistant message with tool use - SDK nests content in message.message
198
- const assistantMsg = message.message as { content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }> };
199
- if (assistantMsg.content) {
200
- for (const block of assistantMsg.content) {
201
- if (process.env.OVERNIGHT_DEBUG) {
202
- console.error(`[DEBUG] content block: type=${block.type}, name=${block.name}`);
203
- }
204
- if (block.type === "tool_use" && block.name) {
205
- const detail = block.input ? getToolDetail(block.name, block.input) : "";
206
- progress.updateTool(block.name, detail);
207
- }
208
- }
209
- }
210
- } else if (message.type === "system" && "subtype" in message) {
211
- // System messages
212
- if (message.subtype === "init") {
213
- sessionId = message.session_id;
214
- if (sessionId && onSessionId) {
215
- onSessionId(sessionId);
216
- }
217
- }
218
- }
219
- }
220
- } catch (e) {
221
- lastError = (e as Error).message;
222
- throw e;
223
- }
224
-
225
- return { sessionId, result, error: lastError };
226
- }
227
-
228
- export async function runJob(
229
- config: JobConfig,
230
- log?: LogCallback,
231
- options?: {
232
- resumeSessionId?: string; // Resume from a previous session
233
- onSessionId?: (id: string) => void; // Called when session ID is available
234
- }
235
- ): Promise<JobResult> {
236
- const startTime = Date.now();
237
- const tools = config.allowed_tools ?? DEFAULT_TOOLS;
238
- const timeout = (config.timeout_seconds ?? DEFAULT_TIMEOUT) * 1000;
239
- const retryCount = config.retry_count ?? DEFAULT_RETRY_COUNT;
240
- const retryDelay = config.retry_delay ?? DEFAULT_RETRY_DELAY;
241
- const verifyPrompt = config.verify_prompt ?? DEFAULT_VERIFY_PROMPT;
242
- let retriesUsed = 0;
243
- let resumeSessionId = options?.resumeSessionId;
244
-
245
- const logMsg = (msg: string) => log?.(msg);
246
- const progress = new ProgressDisplay();
247
-
248
- // Find claude executable once at start
249
- const claudePath = findClaudeExecutable();
250
- if (!claudePath) {
251
- logMsg("\x1b[31m✗ Error: Could not find 'claude' CLI.\x1b[0m");
252
- logMsg("\x1b[33m Install it with:\x1b[0m");
253
- logMsg(" curl -fsSL https://claude.ai/install.sh | bash");
254
- logMsg("\x1b[33m Or set CLAUDE_CODE_PATH environment variable.\x1b[0m");
255
- return {
256
- task: config.prompt,
257
- status: "failed",
258
- error: "Claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash",
259
- duration_seconds: 0,
260
- verified: false,
261
- retries: 0,
262
- };
263
- }
264
-
265
- if (process.env.OVERNIGHT_DEBUG) {
266
- logMsg(`\x1b[2mDebug: Claude path = ${claudePath}\x1b[0m`);
267
- }
268
-
269
- // Show task being started
270
- const taskPreview = config.prompt.slice(0, 60) + (config.prompt.length > 60 ? "..." : "");
271
- if (resumeSessionId) {
272
- logMsg(`\x1b[36m▶\x1b[0m Resuming: ${taskPreview}`);
273
- } else {
274
- logMsg(`\x1b[36m▶\x1b[0m ${taskPreview}`);
275
- }
276
-
277
- for (let attempt = 0; attempt <= retryCount; attempt++) {
278
- try {
279
- // Build security hooks if security config provided
280
- const securityHooks = config.security ? createSecurityHooks(config.security) : undefined;
281
-
282
- const sdkOptions: ClaudeCodeOptions = {
283
- allowedTools: tools,
284
- permissionMode: "acceptEdits",
285
- ...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
286
- ...(config.working_dir && { cwd: config.working_dir }),
287
- ...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
288
- ...(securityHooks && { hooks: securityHooks }),
289
- ...(resumeSessionId && { resume: resumeSessionId }),
290
- };
291
-
292
- let sessionId: string | undefined;
293
- let result: string | undefined;
294
-
295
- // Prompt: if resuming, ask to continue; otherwise use original prompt
296
- const prompt = resumeSessionId
297
- ? "Continue where you left off. Complete the original task."
298
- : config.prompt;
299
-
300
- // Start progress display
301
- progress.start(resumeSessionId ? "Resuming" : "Working");
302
-
303
- try {
304
- const collected = await runWithTimeout(
305
- collectResultWithProgress(prompt, sdkOptions, progress, (id) => {
306
- sessionId = id;
307
- options?.onSessionId?.(id);
308
- }),
309
- timeout
310
- );
311
- sessionId = collected.sessionId;
312
- result = collected.result;
313
- progress.stop();
314
- } catch (e) {
315
- progress.stop();
316
- if ((e as Error).message === "TIMEOUT") {
317
- if (attempt < retryCount) {
318
- retriesUsed = attempt + 1;
319
- // On timeout, if we have a session ID, use it for the retry
320
- if (sessionId) {
321
- resumeSessionId = sessionId;
322
- }
323
- const delay = retryDelay * Math.pow(2, attempt);
324
- logMsg(
325
- `\x1b[33m⚠ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
326
- );
327
- await sleep(delay * 1000);
328
- continue;
329
- }
330
- logMsg(
331
- `\x1b[31m✗ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)\x1b[0m`
332
- );
333
- return {
334
- task: config.prompt,
335
- status: "timeout",
336
- error: `Timed out after ${config.timeout_seconds ?? DEFAULT_TIMEOUT} seconds`,
337
- duration_seconds: (Date.now() - startTime) / 1000,
338
- verified: false,
339
- retries: retriesUsed,
340
- };
341
- }
342
- throw e;
343
- }
344
-
345
- // Verification pass if enabled — verify and fix issues
346
- if (config.verify !== false && sessionId) {
347
- progress.start("Verifying");
348
-
349
- const verifyOptions: ClaudeCodeOptions = {
350
- allowedTools: tools,
351
- resume: sessionId,
352
- permissionMode: "acceptEdits",
353
- ...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
354
- ...(config.working_dir && { cwd: config.working_dir }),
355
- ...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
356
- };
357
-
358
- const fixPrompt = verifyPrompt +
359
- " If you find any issues, fix them now. Only report issues you cannot fix.";
360
-
361
- try {
362
- const verifyResult = await runWithTimeout(
363
- collectResultWithProgress(fixPrompt, verifyOptions, progress, (id) => {
364
- sessionId = id;
365
- options?.onSessionId?.(id);
366
- }),
367
- timeout / 2
368
- );
369
- progress.stop();
370
-
371
- // Update result with verification output
372
- if (verifyResult.result) {
373
- result = verifyResult.result;
374
- }
375
-
376
- // Only mark as failed if there are issues that couldn't be fixed
377
- const unfixableWords = ["cannot fix", "unable to", "blocked by", "requires manual"];
378
- if (
379
- verifyResult.result &&
380
- unfixableWords.some((word) =>
381
- verifyResult.result!.toLowerCase().includes(word)
382
- )
383
- ) {
384
- logMsg(`\x1b[33m⚠ Verification found unfixable issues\x1b[0m`);
385
- return {
386
- task: config.prompt,
387
- status: "verification_failed",
388
- result,
389
- error: `Unfixable issues: ${verifyResult.result}`,
390
- duration_seconds: (Date.now() - startTime) / 1000,
391
- verified: false,
392
- retries: retriesUsed,
393
- };
394
- }
395
- } catch (e) {
396
- progress.stop();
397
- if ((e as Error).message === "TIMEOUT") {
398
- logMsg("\x1b[33m⚠ Verification timed out - continuing anyway\x1b[0m");
399
- } else {
400
- throw e;
401
- }
402
- }
403
- }
404
-
405
- const duration = (Date.now() - startTime) / 1000;
406
- logMsg(`\x1b[32m✓ Completed in ${duration.toFixed(1)}s\x1b[0m`);
407
-
408
- return {
409
- task: config.prompt,
410
- status: "success",
411
- result,
412
- duration_seconds: duration,
413
- verified: config.verify !== false,
414
- retries: retriesUsed,
415
- };
416
- } catch (e) {
417
- progress.stop();
418
- const error = e as Error;
419
- if (isRetryableError(error) && attempt < retryCount) {
420
- retriesUsed = attempt + 1;
421
- // Preserve session for resumption on retry
422
- if (sessionId) {
423
- resumeSessionId = sessionId;
424
- }
425
- const delay = retryDelay * Math.pow(2, attempt);
426
- logMsg(
427
- `\x1b[33m⚠ ${error.message}, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
428
- );
429
- await sleep(delay * 1000);
430
- continue;
431
- }
432
-
433
- const duration = (Date.now() - startTime) / 1000;
434
- logMsg(`\x1b[31m✗ Failed: ${error.message}\x1b[0m`);
435
- return {
436
- task: config.prompt,
437
- status: "failed",
438
- error: error.message,
439
- duration_seconds: duration,
440
- verified: false,
441
- retries: retriesUsed,
442
- };
443
- }
444
- }
445
-
446
- // Should not reach here
447
- return {
448
- task: config.prompt,
449
- status: "failed",
450
- error: "Exhausted all retries",
451
- duration_seconds: (Date.now() - startTime) / 1000,
452
- verified: false,
453
- retries: retriesUsed,
454
- };
455
- }
456
-
457
- export function taskKey(config: JobConfig): string {
458
- if (config.id) return config.id;
459
- return createHash("sha256").update(config.prompt).digest("hex").slice(0, 12);
460
- }
461
-
462
- /** @deprecated Use taskKey(config) instead — kept for CLI backward compat */
463
- export function taskHash(prompt: string): string {
464
- return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
465
- }
466
-
467
- function validateDag(configs: JobConfig[]): string | null {
468
- const ids = new Set(configs.map(c => c.id).filter(Boolean));
469
- // Check all depends_on references exist
470
- for (const c of configs) {
471
- for (const dep of c.depends_on ?? []) {
472
- if (!ids.has(dep)) {
473
- return `Task "${c.id ?? c.prompt.slice(0, 40)}" depends on unknown id "${dep}"`;
474
- }
475
- }
476
- }
477
- // Check for cycles via DFS
478
- const visited = new Set<string>();
479
- const inStack = new Set<string>();
480
- const idToConfig = new Map(configs.filter(c => c.id).map(c => [c.id!, c]));
481
-
482
- function hasCycle(id: string): boolean {
483
- if (inStack.has(id)) return true;
484
- if (visited.has(id)) return false;
485
- visited.add(id);
486
- inStack.add(id);
487
- const config = idToConfig.get(id);
488
- for (const dep of config?.depends_on ?? []) {
489
- if (hasCycle(dep)) return true;
490
- }
491
- inStack.delete(id);
492
- return false;
493
- }
494
-
495
- for (const id of ids) {
496
- if (hasCycle(id)) return `Dependency cycle detected involving "${id}"`;
497
- }
498
- return null;
499
- }
500
-
501
- function depsReady(
502
- config: JobConfig,
503
- completed: Record<string, JobResult>,
504
- ): "ready" | "waiting" | "blocked" {
505
- if (!config.depends_on || config.depends_on.length === 0) return "ready";
506
- for (const dep of config.depends_on) {
507
- const result = completed[dep];
508
- if (!result) return "waiting";
509
- if (result.status !== "success") return "blocked";
510
- }
511
- return "ready";
512
- }
513
-
514
- export function saveState(state: RunState, stateFile: string): void {
515
- writeFileSync(stateFile, JSON.stringify(state, null, 2));
516
- }
517
-
518
- export function loadState(stateFile: string): RunState | null {
519
- if (!existsSync(stateFile)) return null;
520
- return JSON.parse(readFileSync(stateFile, "utf-8"));
521
- }
522
-
523
- export function clearState(stateFile: string): void {
524
- if (existsSync(stateFile)) unlinkSync(stateFile);
525
- }
526
-
527
- export async function runJobsWithState(
528
- configs: JobConfig[],
529
- options: {
530
- stateFile?: string;
531
- log?: LogCallback;
532
- reloadConfigs?: () => JobConfig[]; // Called between jobs to pick up new tasks
533
- } = {}
534
- ): Promise<JobResult[]> {
535
- const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
536
-
537
- // Validate DAG if any tasks have dependencies
538
- const dagError = validateDag(configs);
539
- if (dagError) {
540
- options.log?.(`\x1b[31m✗ DAG error: ${dagError}\x1b[0m`);
541
- return [];
542
- }
543
-
544
- // Load existing state or start fresh
545
- const state: RunState = loadState(stateFile) ?? {
546
- completed: {},
547
- timestamp: new Date().toISOString(),
548
- };
549
-
550
- let currentConfigs = configs;
551
-
552
- while (true) {
553
- // Find tasks not yet completed
554
- const notDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
555
- if (notDone.length === 0) break;
556
-
557
- // Among not-done tasks, find those whose dependencies are satisfied
558
- const ready = notDone.filter(c => depsReady(c, state.completed) === "ready");
559
-
560
- // Find tasks blocked by failed dependencies
561
- const blocked = notDone.filter(c => depsReady(c, state.completed) === "blocked");
562
-
563
- // Mark blocked tasks as failed without running them
564
- for (const bc of blocked) {
565
- const key = taskKey(bc);
566
- if (key in state.completed) continue; // already recorded
567
- const failedDeps = (bc.depends_on ?? []).filter(
568
- dep => state.completed[dep] && state.completed[dep].status !== "success"
569
- );
570
- const label = bc.id ?? bc.prompt.slice(0, 40);
571
- options.log?.(`\n\x1b[31m✗ Skipping "${label}" — dependency failed: ${failedDeps.join(", ")}\x1b[0m`);
572
- state.completed[key] = {
573
- task: bc.prompt,
574
- status: "failed",
575
- error: `Blocked by failed dependencies: ${failedDeps.join(", ")}`,
576
- duration_seconds: 0,
577
- verified: false,
578
- retries: 0,
579
- };
580
- state.timestamp = new Date().toISOString();
581
- saveState(state, stateFile);
582
- }
583
-
584
- // If nothing is ready and nothing is blocked, everything remaining is waiting
585
- // on something that will never complete — break to avoid infinite loop
586
- if (ready.length === 0) break;
587
-
588
- const config = ready[0];
589
- const key = taskKey(config);
590
-
591
- const totalNotDone = notDone.length - blocked.length;
592
- const totalDone = Object.keys(state.completed).length;
593
- const label = config.id ? `${config.id}` : "";
594
- options.log?.(`\n\x1b[1m[${totalDone + 1}/${totalDone + totalNotDone}]${label ? ` ${label}` : ""}\x1b[0m`);
595
-
596
- // Check if this task was previously in-progress (crashed mid-task)
597
- const resumeSessionId = (state.inProgress?.hash === key)
598
- ? state.inProgress.sessionId
599
- : undefined;
600
-
601
- if (resumeSessionId) {
602
- options.log?.(`\x1b[2mResuming session ${resumeSessionId.slice(0, 8)}...\x1b[0m`);
603
- }
604
-
605
- // Mark task as in-progress before starting
606
- state.inProgress = { hash: key, prompt: config.prompt, startedAt: new Date().toISOString() };
607
- saveState(state, stateFile);
608
-
609
- const result = await runJob(config, options.log, {
610
- resumeSessionId,
611
- onSessionId: (id) => {
612
- // Checkpoint the session ID so we can resume on crash
613
- state.inProgress = { hash: key, prompt: config.prompt, sessionId: id, startedAt: state.inProgress!.startedAt };
614
- saveState(state, stateFile);
615
- },
616
- });
617
-
618
- // Task done — save result and clear in-progress
619
- state.completed[key] = result;
620
- state.inProgress = undefined;
621
- state.timestamp = new Date().toISOString();
622
- saveState(state, stateFile);
623
-
624
- // Re-read YAML to pick up new tasks added while running
625
- if (options.reloadConfigs) {
626
- try {
627
- currentConfigs = options.reloadConfigs();
628
- // Re-validate DAG with new configs
629
- const newDagError = validateDag(currentConfigs);
630
- if (newDagError) {
631
- options.log?.(`\x1b[33m⚠ DAG error in updated YAML, ignoring reload: ${newDagError}\x1b[0m`);
632
- currentConfigs = configs; // revert to original
633
- }
634
- } catch {
635
- // If reload fails (e.g. YAML syntax error mid-edit), keep current list
636
- }
637
- }
638
-
639
- // Brief pause between jobs
640
- const nextNotDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
641
- const nextReady = nextNotDone.filter(c => depsReady(c, state.completed) === "ready");
642
- if (nextReady.length > 0) {
643
- await sleep(1000);
644
- }
645
- }
646
-
647
- // Collect results in original order
648
- const results = currentConfigs
649
- .map(c => state.completed[taskKey(c)])
650
- .filter((r): r is JobResult => r !== undefined);
651
-
652
- // Clean up state file on completion
653
- clearState(stateFile);
654
-
655
- return results;
656
- }
657
-
658
- export function resultsToJson(results: JobResult[]): string {
659
- return JSON.stringify(results, null, 2);
660
- }