scripts-orchestrator 2.14.0 → 2.15.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 +57 -2
- package/index.js +107 -3
- package/lib/index.js +26 -2
- package/lib/orchestrator.js +197 -147
- package/lib/process-manager.js +5 -2
- package/lib/process-manager.test.js +22 -0
- package/lib/recommend-phases.js +340 -0
- package/lib/recommend-phases.test.js +164 -0
- package/lib/report-html.js +308 -0
- package/lib/report-html.test.js +88 -0
- package/package.json +1 -1
package/lib/orchestrator.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import { spawnSync } from 'child_process';
|
|
3
|
+
import { spawn, spawnSync } from 'child_process';
|
|
4
4
|
import { processManager } from './process-manager.js';
|
|
5
5
|
import { healthCheck } from './health-check.js';
|
|
6
6
|
import { log } from './logger.js';
|
|
7
7
|
import { GitCache } from './git-cache.js';
|
|
8
|
+
import { renderReportHtml } from './report-html.js';
|
|
8
9
|
import chalk from 'chalk';
|
|
9
10
|
|
|
10
11
|
export class Orchestrator {
|
|
@@ -35,18 +36,26 @@ export class Orchestrator {
|
|
|
35
36
|
this.skippedCommands = [];
|
|
36
37
|
this.skipReasons = new Map(); // Track why commands were skipped
|
|
37
38
|
this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
|
|
39
|
+
this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
|
|
38
40
|
this.phaseResults = []; // { name, success, durationMs } per phase run
|
|
39
41
|
this.gitCache = new GitCache(logFolder);
|
|
40
|
-
//
|
|
42
|
+
// track per-command start times for incremental JSON
|
|
41
43
|
this.commandStartTimes = new Map(); // command -> ISO start string
|
|
42
|
-
//
|
|
44
|
+
// events file path derived from jsonResultsPath
|
|
43
45
|
this.eventsPath = this._deriveEventsPath(jsonResultsPath);
|
|
44
|
-
//
|
|
46
|
+
// library-owned run-state file
|
|
45
47
|
this.runStatePath = logFolder
|
|
46
48
|
? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
|
|
47
49
|
: null;
|
|
48
|
-
//
|
|
50
|
+
// post-run hook command (shell string)
|
|
49
51
|
this.postRun = null; // set from config in index.js
|
|
52
|
+
// Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
|
|
53
|
+
// The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
|
|
54
|
+
this.periodicHook = null;
|
|
55
|
+
this.periodicIntervalMs = 45000;
|
|
56
|
+
this._periodicTimer = null;
|
|
57
|
+
this._periodicRunning = false;
|
|
58
|
+
this._periodicChild = null;
|
|
50
59
|
|
|
51
60
|
// Set the log folder in process manager
|
|
52
61
|
if (logFolder) {
|
|
@@ -75,7 +84,7 @@ export class Orchestrator {
|
|
|
75
84
|
return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
|
|
76
85
|
}
|
|
77
86
|
|
|
78
|
-
//
|
|
87
|
+
// write current run state atomically
|
|
79
88
|
_writeRunState(extra = {}) {
|
|
80
89
|
if (!this.runStatePath) return;
|
|
81
90
|
const state = {
|
|
@@ -91,13 +100,13 @@ export class Orchestrator {
|
|
|
91
100
|
} catch { /* non-fatal */ }
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
//
|
|
103
|
+
// remove run-state file on run end
|
|
95
104
|
_clearRunState() {
|
|
96
105
|
if (!this.runStatePath) return;
|
|
97
106
|
try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
//
|
|
109
|
+
// append a structured NDJSON event
|
|
101
110
|
_appendEvent(type, data = {}) {
|
|
102
111
|
if (!this.eventsPath) return;
|
|
103
112
|
const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
|
|
@@ -108,7 +117,7 @@ export class Orchestrator {
|
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
|
|
111
|
-
//
|
|
120
|
+
// atomically write current run state (completed + in-flight commands) to json_results
|
|
112
121
|
_writePartialResults() {
|
|
113
122
|
if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
|
|
114
123
|
const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
|
|
@@ -122,7 +131,13 @@ export class Orchestrator {
|
|
|
122
131
|
const done = timing != null || skipped;
|
|
123
132
|
|
|
124
133
|
if (!done && startedAt) {
|
|
125
|
-
return {
|
|
134
|
+
return {
|
|
135
|
+
command,
|
|
136
|
+
...(phaseName ? { phase: phaseName } : {}),
|
|
137
|
+
success: null,
|
|
138
|
+
startedAt,
|
|
139
|
+
...this._logFileField(command),
|
|
140
|
+
};
|
|
126
141
|
}
|
|
127
142
|
if (!done) return null; // not yet started — omit
|
|
128
143
|
|
|
@@ -137,8 +152,10 @@ export class Orchestrator {
|
|
|
137
152
|
command,
|
|
138
153
|
...(phaseName ? { phase: phaseName } : {}),
|
|
139
154
|
success,
|
|
140
|
-
...(
|
|
155
|
+
...(startedAt ? { startedAt } : {}),
|
|
156
|
+
...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
|
|
141
157
|
...(this.metrics.includes('memory') ? { memoryKb: timing?.memoryKb ?? null } : {}),
|
|
158
|
+
...this._logFileField(command),
|
|
142
159
|
...(skipReason ? { skipReason } : {}),
|
|
143
160
|
};
|
|
144
161
|
};
|
|
@@ -160,6 +177,7 @@ export class Orchestrator {
|
|
|
160
177
|
const payload = {
|
|
161
178
|
success: null, // in-progress sentinel; replaced by writeJsonResults on completion
|
|
162
179
|
timestamp: new Date().toISOString(),
|
|
180
|
+
...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
|
|
163
181
|
commands,
|
|
164
182
|
...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
|
|
165
183
|
};
|
|
@@ -173,7 +191,18 @@ export class Orchestrator {
|
|
|
173
191
|
this.logger.verbose(`Partial results write failed: ${err.message}`);
|
|
174
192
|
}
|
|
175
193
|
|
|
176
|
-
//
|
|
194
|
+
// Incrementally refresh the HTML report too (like merge-report:live), so a live,
|
|
195
|
+
// up-to-date report exists from the first command onward and survives interruption.
|
|
196
|
+
// Skip the stdout sink ('-') to avoid spamming the console on every update.
|
|
197
|
+
if (this.htmlResultsPath != null && this.htmlResultsPath !== '-') {
|
|
198
|
+
try {
|
|
199
|
+
this.writeHtmlResults(payload);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
this.logger.verbose(`Partial HTML write failed: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// keep run-state file in sync with current active commands and phase
|
|
177
206
|
const inFlight = commands.filter(c => c.success === null).map(c => c.command);
|
|
178
207
|
const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
|
|
179
208
|
this._writeRunState({
|
|
@@ -193,6 +222,47 @@ export class Orchestrator {
|
|
|
193
222
|
return `${seconds}s`;
|
|
194
223
|
}
|
|
195
224
|
|
|
225
|
+
// Resolve a command's destination log file as a path relative to cwd (absolute if outside),
|
|
226
|
+
// returned as a spreadable object so callers can inline it into result entries.
|
|
227
|
+
_logFileField(command) {
|
|
228
|
+
const p = this.commandLogPaths.get(command);
|
|
229
|
+
if (!p) return {};
|
|
230
|
+
let rel = p;
|
|
231
|
+
try {
|
|
232
|
+
const r = path.relative(process.cwd(), p);
|
|
233
|
+
if (r && !r.startsWith('..')) rel = r;
|
|
234
|
+
} catch {
|
|
235
|
+
// keep absolute path on any failure
|
|
236
|
+
}
|
|
237
|
+
return { logFile: rel };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// The important output files this run produces, as [label, absolutePath] pairs.
|
|
241
|
+
// Excludes stdout sinks ('-'). Used to announce report locations in the logs.
|
|
242
|
+
_reportFiles() {
|
|
243
|
+
const files = [];
|
|
244
|
+
if (this.jsonResultsPath != null && this.jsonResultsPath !== '-') {
|
|
245
|
+
files.push(['JSON results', path.resolve(this.jsonResultsPath || './scripts-orchestrator-results.json')]);
|
|
246
|
+
}
|
|
247
|
+
if (this.htmlResultsPath != null && this.htmlResultsPath !== '-') {
|
|
248
|
+
files.push(['HTML report', path.resolve(this.htmlResultsPath || './scripts-orchestrator-results.html')]);
|
|
249
|
+
}
|
|
250
|
+
if (this.eventsPath) {
|
|
251
|
+
files.push(['Events (NDJSON)', path.resolve(this.eventsPath)]);
|
|
252
|
+
}
|
|
253
|
+
return files;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Announce report file locations in the logs (prefix e.g. 'Live reports' / 'Reports written').
|
|
257
|
+
_announceReportFiles(prefix) {
|
|
258
|
+
const files = this._reportFiles();
|
|
259
|
+
if (files.length === 0) return;
|
|
260
|
+
this.logger.info(`📄 ${prefix}:`);
|
|
261
|
+
for (const [label, file] of files) {
|
|
262
|
+
this.logger.info(` • ${label}: ${file}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
196
266
|
async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
|
|
197
267
|
const {
|
|
198
268
|
command,
|
|
@@ -212,6 +282,10 @@ export class Orchestrator {
|
|
|
212
282
|
|
|
213
283
|
const startTime = Date.now();
|
|
214
284
|
|
|
285
|
+
// Record the destination log file for this command (honors per-command override).
|
|
286
|
+
// Done early so even disabled/skipped commands report where output would land.
|
|
287
|
+
this.commandLogPaths.set(command, this.processManager.getLogPath(command, log || logFile));
|
|
288
|
+
|
|
215
289
|
const setTiming = (durationMs, memoryKb = null) => {
|
|
216
290
|
this.commandTimings.set(command, { durationMs, memoryKb });
|
|
217
291
|
};
|
|
@@ -308,7 +382,7 @@ export class Orchestrator {
|
|
|
308
382
|
}
|
|
309
383
|
}
|
|
310
384
|
|
|
311
|
-
//
|
|
385
|
+
// record start and emit event
|
|
312
386
|
this.commandStartTimes.set(command, new Date().toISOString());
|
|
313
387
|
this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
|
|
314
388
|
this._writePartialResults();
|
|
@@ -387,7 +461,7 @@ export class Orchestrator {
|
|
|
387
461
|
|
|
388
462
|
const totalDurationMs = Date.now() - startTime;
|
|
389
463
|
setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
|
|
390
|
-
//
|
|
464
|
+
// emit completion event and write incremental results
|
|
391
465
|
this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
|
|
392
466
|
this._writePartialResults();
|
|
393
467
|
visited.delete(command);
|
|
@@ -420,10 +494,7 @@ export class Orchestrator {
|
|
|
420
494
|
}
|
|
421
495
|
|
|
422
496
|
writeJsonResults(hasFailures) {
|
|
423
|
-
const overallDurationMs =
|
|
424
|
-
this.metrics.includes('time') && this.startTime
|
|
425
|
-
? Date.now() - this.startTime
|
|
426
|
-
: undefined;
|
|
497
|
+
const overallDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
|
|
427
498
|
|
|
428
499
|
const commands = [];
|
|
429
500
|
if (Array.isArray(this.config)) {
|
|
@@ -441,12 +512,14 @@ export class Orchestrator {
|
|
|
441
512
|
const entry = {
|
|
442
513
|
command,
|
|
443
514
|
success,
|
|
444
|
-
...(
|
|
445
|
-
? {
|
|
515
|
+
...(this.commandStartTimes.has(command)
|
|
516
|
+
? { startedAt: this.commandStartTimes.get(command) }
|
|
446
517
|
: {}),
|
|
518
|
+
...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
|
|
447
519
|
...(this.metrics.includes('memory')
|
|
448
520
|
? { memoryKb: timing?.memoryKb ?? null }
|
|
449
521
|
: {}),
|
|
522
|
+
...this._logFileField(command),
|
|
450
523
|
...(skipReason ? { skipReason } : {}),
|
|
451
524
|
};
|
|
452
525
|
commands.push(entry);
|
|
@@ -468,12 +541,14 @@ export class Orchestrator {
|
|
|
468
541
|
command,
|
|
469
542
|
phase: phase.name,
|
|
470
543
|
success,
|
|
471
|
-
...(
|
|
472
|
-
? {
|
|
544
|
+
...(this.commandStartTimes.has(command)
|
|
545
|
+
? { startedAt: this.commandStartTimes.get(command) }
|
|
473
546
|
: {}),
|
|
547
|
+
...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
|
|
474
548
|
...(this.metrics.includes('memory')
|
|
475
549
|
? { memoryKb: timing?.memoryKb ?? null }
|
|
476
550
|
: {}),
|
|
551
|
+
...this._logFileField(command),
|
|
477
552
|
...(skipReason ? { skipReason } : {}),
|
|
478
553
|
};
|
|
479
554
|
commands.push(entry);
|
|
@@ -503,137 +578,30 @@ export class Orchestrator {
|
|
|
503
578
|
if (this.htmlResultsPath != null) {
|
|
504
579
|
this.writeHtmlResults(payload);
|
|
505
580
|
}
|
|
506
|
-
}
|
|
507
581
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (ms < 1000) return `${ms}ms`;
|
|
511
|
-
const s = (ms / 1000).toFixed(1);
|
|
512
|
-
return `${s}s`;
|
|
582
|
+
// Announce the important output files so they're easy to find in the logs.
|
|
583
|
+
this._announceReportFiles('Reports written');
|
|
513
584
|
}
|
|
514
585
|
|
|
515
586
|
writeHtmlResults(payload) {
|
|
516
|
-
const
|
|
517
|
-
if (s == null) return '';
|
|
518
|
-
return String(s)
|
|
519
|
-
.replace(/&/g, '&')
|
|
520
|
-
.replace(/</g, '<')
|
|
521
|
-
.replace(/>/g, '>')
|
|
522
|
-
.replace(/"/g, '"');
|
|
523
|
-
};
|
|
524
|
-
const { success, timestamp, overallDurationMs, commands = [], phases = [] } = payload;
|
|
525
|
-
const maxDuration = Math.max(0, ...commands.map((c) => c.durationMs || 0), ...phases.map((p) => p.durationMs || 0));
|
|
526
|
-
const maxMemory = Math.max(0, ...commands.map((c) => c.memoryKb || 0));
|
|
527
|
-
|
|
528
|
-
const row = (c) => {
|
|
529
|
-
const durationPct = maxDuration > 0 && c.durationMs != null ? (c.durationMs / maxDuration) * 100 : 0;
|
|
530
|
-
const memoryPct = maxMemory > 0 && c.memoryKb != null ? (c.memoryKb / maxMemory) * 100 : 0;
|
|
531
|
-
const statusClass = c.success ? 'ok' : 'fail';
|
|
532
|
-
const statusLabel = c.success ? 'OK' : (c.skipReason || 'Failed');
|
|
533
|
-
return `
|
|
534
|
-
<tr class="${statusClass}">
|
|
535
|
-
<td><code>${escapeHtml(c.command)}</code></td>
|
|
536
|
-
<td>${c.phase != null ? escapeHtml(c.phase) : '—'}</td>
|
|
537
|
-
<td><span class="badge ${statusClass}">${escapeHtml(statusLabel)}</span></td>
|
|
538
|
-
<td>${this.formatMs(c.durationMs)}</td>
|
|
539
|
-
<td>${c.memoryKb != null ? `${(c.memoryKb / 1024).toFixed(1)} MB` : '—'}</td>
|
|
540
|
-
<td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
|
|
541
|
-
<td class="bar-cell"><div class="bar mem" style="width:${memoryPct}%"></div></td>
|
|
542
|
-
</tr>`;
|
|
543
|
-
};
|
|
544
|
-
|
|
545
|
-
const phaseRow = (p) => {
|
|
546
|
-
const durationPct = maxDuration > 0 && p.durationMs != null ? (p.durationMs / maxDuration) * 100 : 0;
|
|
547
|
-
const statusClass = p.success ? 'ok' : 'fail';
|
|
548
|
-
return `
|
|
549
|
-
<tr class="${statusClass}">
|
|
550
|
-
<td>${escapeHtml(p.name)}</td>
|
|
551
|
-
<td><span class="badge ${statusClass}">${p.success ? 'OK' : 'Failed'}</span></td>
|
|
552
|
-
<td>${this.formatMs(p.durationMs)}</td>
|
|
553
|
-
<td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
|
|
554
|
-
</tr>`;
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
const html = `<!DOCTYPE html>
|
|
558
|
-
<html lang="en">
|
|
559
|
-
<head>
|
|
560
|
-
<meta charset="utf-8">
|
|
561
|
-
<title>Scripts Orchestrator Report</title>
|
|
562
|
-
<style>
|
|
563
|
-
* { box-sizing: border-box; }
|
|
564
|
-
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
|
|
565
|
-
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
566
|
-
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
|
567
|
-
.summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
|
|
568
|
-
.summary .card.success { border-left: 4px solid #22c55e; }
|
|
569
|
-
.summary .card.fail { border-left: 4px solid #ef4444; }
|
|
570
|
-
.summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
|
|
571
|
-
.summary .value { font-size: 1.25rem; font-weight: 600; }
|
|
572
|
-
section { margin-bottom: 1.5rem; }
|
|
573
|
-
section h2 { font-size: 1.1rem; color: #a0a0a0; margin-bottom: 0.5rem; }
|
|
574
|
-
table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; }
|
|
575
|
-
th, td { padding: 0.5rem 0.75rem; text-align: left; }
|
|
576
|
-
th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
|
|
577
|
-
tr.fail { background: rgba(239,68,68,0.08); }
|
|
578
|
-
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
|
579
|
-
.badge.ok { background: #22c55e; color: #0f0f0f; }
|
|
580
|
-
.badge.fail { background: #ef4444; color: #fff; }
|
|
581
|
-
.bar-cell { width: 120px; }
|
|
582
|
-
.bar { height: 8px; background: #3b82f6; border-radius: 4px; min-width: 2px; }
|
|
583
|
-
.bar.mem { background: #8b5cf6; }
|
|
584
|
-
code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
|
585
|
-
</style>
|
|
586
|
-
</head>
|
|
587
|
-
<body>
|
|
588
|
-
<h1>Scripts Orchestrator Report</h1>
|
|
589
|
-
<div class="summary">
|
|
590
|
-
<div class="card ${success ? 'success' : 'fail'}">
|
|
591
|
-
<div class="label">Status</div>
|
|
592
|
-
<div class="value">${success ? 'Success' : 'Failed'}</div>
|
|
593
|
-
</div>
|
|
594
|
-
<div class="card">
|
|
595
|
-
<div class="label">Timestamp</div>
|
|
596
|
-
<div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
|
|
597
|
-
</div>
|
|
598
|
-
${overallDurationMs != null ? `
|
|
599
|
-
<div class="card">
|
|
600
|
-
<div class="label">Total time</div>
|
|
601
|
-
<div class="value">${this.formatMs(overallDurationMs)}</div>
|
|
602
|
-
</div>` : ''}
|
|
603
|
-
</div>
|
|
604
|
-
|
|
605
|
-
${phases.length > 0 ? `
|
|
606
|
-
<section>
|
|
607
|
-
<h2>Phases</h2>
|
|
608
|
-
<table>
|
|
609
|
-
<thead><tr><th>Phase</th><th>Status</th><th>Duration</th><th></th></tr></thead>
|
|
610
|
-
<tbody>${phases.map(phaseRow).join('')}</tbody>
|
|
611
|
-
</table>
|
|
612
|
-
</section>` : ''}
|
|
613
|
-
|
|
614
|
-
<section>
|
|
615
|
-
<h2>Commands</h2>
|
|
616
|
-
<table>
|
|
617
|
-
<thead><tr><th>Command</th><th>Phase</th><th>Status</th><th>Duration</th><th>Memory</th><th>Time</th><th>Memory</th></tr></thead>
|
|
618
|
-
<tbody>${commands.map(row).join('')}</tbody>
|
|
619
|
-
</table>
|
|
620
|
-
</section>
|
|
621
|
-
</body>
|
|
622
|
-
</html>`;
|
|
623
|
-
|
|
587
|
+
const html = renderReportHtml(payload);
|
|
624
588
|
if (this.htmlResultsPath === '-') {
|
|
625
589
|
console.log(html);
|
|
626
|
-
|
|
627
|
-
const outPath = this.htmlResultsPath || './scripts-orchestrator-results.html';
|
|
628
|
-
fs.writeFileSync(outPath, html, 'utf8');
|
|
629
|
-
this.logger.verbose(`Wrote HTML report to ${outPath}`);
|
|
590
|
+
return;
|
|
630
591
|
}
|
|
592
|
+
const outPath = this.htmlResultsPath || './scripts-orchestrator-results.html';
|
|
593
|
+
// Atomic write so live reloaders (incremental refresh) never read a half-written file.
|
|
594
|
+
const tmpPath = outPath + '.tmp';
|
|
595
|
+
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
596
|
+
fs.writeFileSync(tmpPath, html, 'utf8');
|
|
597
|
+
fs.renameSync(tmpPath, outPath);
|
|
598
|
+
this.logger.verbose(`Wrote HTML report to ${outPath}`);
|
|
631
599
|
}
|
|
632
600
|
|
|
633
601
|
async run() {
|
|
634
602
|
this.startTime = Date.now();
|
|
635
603
|
this.runStartedAt = this.startTime;
|
|
636
|
-
//
|
|
604
|
+
// write initial run-state at start
|
|
637
605
|
this._writeRunState({ phase: null, activeCommand: null });
|
|
638
606
|
try {
|
|
639
607
|
// Check if we should skip execution based on git state (unless forced)
|
|
@@ -654,6 +622,13 @@ export class Orchestrator {
|
|
|
654
622
|
let phaseFailed = false;
|
|
655
623
|
let startPhaseFound = false;
|
|
656
624
|
|
|
625
|
+
// Announce where the live report files will be written (they update incrementally,
|
|
626
|
+
// so they can be opened to watch progress while the run is in flight).
|
|
627
|
+
this._announceReportFiles('Live reports (updated as the run progresses)');
|
|
628
|
+
|
|
629
|
+
// Start the periodic roll-up hook (no-op unless configured).
|
|
630
|
+
this._startPeriodicHook();
|
|
631
|
+
|
|
657
632
|
// Handle both old array format and new phases format
|
|
658
633
|
if (Array.isArray(this.config)) {
|
|
659
634
|
// Legacy: Run all commands in parallel or sequential based on flag
|
|
@@ -757,7 +732,7 @@ export class Orchestrator {
|
|
|
757
732
|
this.phaseResults.push({
|
|
758
733
|
name: phase.name,
|
|
759
734
|
success: !phaseHasFailures,
|
|
760
|
-
durationMs:
|
|
735
|
+
durationMs: phaseDurationMs,
|
|
761
736
|
});
|
|
762
737
|
|
|
763
738
|
if (phaseHasFailures) {
|
|
@@ -833,7 +808,7 @@ export class Orchestrator {
|
|
|
833
808
|
);
|
|
834
809
|
}
|
|
835
810
|
|
|
836
|
-
//
|
|
811
|
+
// emit run_end event; final JSON written by writeJsonResults (replaces partial)
|
|
837
812
|
const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
|
|
838
813
|
this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
|
|
839
814
|
|
|
@@ -842,10 +817,14 @@ export class Orchestrator {
|
|
|
842
817
|
this.writeJsonResults(hasFailures);
|
|
843
818
|
}
|
|
844
819
|
|
|
845
|
-
//
|
|
820
|
+
// clear run-state file — run is done
|
|
846
821
|
this._clearRunState();
|
|
847
822
|
|
|
848
|
-
//
|
|
823
|
+
// Final roll-up (synchronous) AFTER run-state is cleared, so the aggregate reflects the
|
|
824
|
+
// finished run (an in-flight marker would otherwise make the final report read as running).
|
|
825
|
+
this._firePeriodicHookFinal();
|
|
826
|
+
|
|
827
|
+
// run post_run hook after results are written
|
|
849
828
|
this._runPostRunHook(hasFailures);
|
|
850
829
|
|
|
851
830
|
// Update git cache on successful execution
|
|
@@ -864,7 +843,10 @@ export class Orchestrator {
|
|
|
864
843
|
} catch (error) {
|
|
865
844
|
this.logger.error(`Orchestrator failed: ${error.message}`);
|
|
866
845
|
|
|
867
|
-
//
|
|
846
|
+
// Stop periodic ticks on error.
|
|
847
|
+
this._stopPeriodicHook();
|
|
848
|
+
|
|
849
|
+
// clear run-state on error too
|
|
868
850
|
this._clearRunState();
|
|
869
851
|
|
|
870
852
|
// Cleanup on error
|
|
@@ -878,7 +860,75 @@ export class Orchestrator {
|
|
|
878
860
|
}
|
|
879
861
|
}
|
|
880
862
|
|
|
881
|
-
//
|
|
863
|
+
// Periodic hook: start the interval timer (fires once promptly, then every interval).
|
|
864
|
+
_startPeriodicHook() {
|
|
865
|
+
if (!this.periodicHook || this._periodicTimer) return;
|
|
866
|
+
this.logger.info(
|
|
867
|
+
`⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`,
|
|
868
|
+
);
|
|
869
|
+
this._firePeriodicTick(); // prompt first roll-up so an initial aggregate exists
|
|
870
|
+
this._periodicTimer = setInterval(() => this._firePeriodicTick(), this.periodicIntervalMs);
|
|
871
|
+
if (this._periodicTimer.unref) this._periodicTimer.unref();
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Fire the periodic hook asynchronously, with an overlap guard so slow hooks don't pile up.
|
|
875
|
+
_firePeriodicTick() {
|
|
876
|
+
if (!this.periodicHook || this._periodicRunning) {
|
|
877
|
+
if (this._periodicRunning) {
|
|
878
|
+
this.logger.verbose('[periodic_hook] previous invocation still running; skipping tick');
|
|
879
|
+
}
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
this._periodicRunning = true;
|
|
883
|
+
try {
|
|
884
|
+
const child = spawn(this.periodicHook, {
|
|
885
|
+
shell: true,
|
|
886
|
+
stdio: 'ignore',
|
|
887
|
+
cwd: process.cwd(),
|
|
888
|
+
env: { ...process.env, SCRIPTS_ORCHESTRATOR_PERIODIC: '1' },
|
|
889
|
+
});
|
|
890
|
+
this._periodicChild = child;
|
|
891
|
+
child.on('exit', (code) => {
|
|
892
|
+
this._periodicRunning = false;
|
|
893
|
+
this._periodicChild = null;
|
|
894
|
+
if (code && code !== 0) this.logger.verbose(`[periodic_hook] exited with code ${code}`);
|
|
895
|
+
});
|
|
896
|
+
child.on('error', (err) => {
|
|
897
|
+
this._periodicRunning = false;
|
|
898
|
+
this._periodicChild = null;
|
|
899
|
+
this.logger.warn(`[periodic_hook] failed: ${err.message}`);
|
|
900
|
+
});
|
|
901
|
+
} catch (err) {
|
|
902
|
+
this._periodicRunning = false;
|
|
903
|
+
this.logger.warn(`[periodic_hook] error: ${err.message}`);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Stop scheduling further periodic ticks.
|
|
908
|
+
_stopPeriodicHook() {
|
|
909
|
+
if (this._periodicTimer) {
|
|
910
|
+
clearInterval(this._periodicTimer);
|
|
911
|
+
this._periodicTimer = null;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Final synchronous fire so the aggregate reflects the finished run before the process exits.
|
|
916
|
+
_firePeriodicHookFinal() {
|
|
917
|
+
if (!this.periodicHook) return;
|
|
918
|
+
this._stopPeriodicHook();
|
|
919
|
+
try {
|
|
920
|
+
spawnSync(this.periodicHook, {
|
|
921
|
+
shell: true,
|
|
922
|
+
stdio: 'ignore',
|
|
923
|
+
cwd: process.cwd(),
|
|
924
|
+
env: { ...process.env, SCRIPTS_ORCHESTRATOR_PERIODIC: 'final' },
|
|
925
|
+
});
|
|
926
|
+
} catch (err) {
|
|
927
|
+
this.logger.warn(`[periodic_hook] final invocation failed: ${err.message}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// run user-configured post_run shell command
|
|
882
932
|
_runPostRunHook(hasFailures) {
|
|
883
933
|
if (!this.postRun) return;
|
|
884
934
|
this.logger.info(`[post_run] ${this.postRun}`);
|
package/lib/process-manager.js
CHANGED
|
@@ -28,7 +28,9 @@ export class ProcessManager {
|
|
|
28
28
|
this.logger.verbose(`Log folder set to: ${logFolder}`);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
getLogPath(command) {
|
|
31
|
+
getLogPath(command, logFileOverride = null) {
|
|
32
|
+
// A per-command 'log'/'logFile' override wins, resolved against cwd.
|
|
33
|
+
if (logFileOverride) return path.resolve(logFileOverride);
|
|
32
34
|
const baseDir = this.logFolder
|
|
33
35
|
? path.resolve(this.logFolder)
|
|
34
36
|
: process.cwd();
|
|
@@ -98,7 +100,8 @@ export class ProcessManager {
|
|
|
98
100
|
const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
|
|
99
101
|
// Use only the first word of the command for the log filename
|
|
100
102
|
const logName = cmd.split(/\s+/)[0];
|
|
101
|
-
|
|
103
|
+
// Single source of truth for the destination log path (honors per-command override).
|
|
104
|
+
const LOG_FILE = this.getLogPath(cmd, logFile);
|
|
102
105
|
|
|
103
106
|
try {
|
|
104
107
|
if (!fs.existsSync(LOGS_DIR)) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { ProcessManager } from './process-manager.js';
|
|
3
|
+
|
|
4
|
+
describe('ProcessManager.getLogPath', () => {
|
|
5
|
+
test('resolves the default per-command log path under the log folder', () => {
|
|
6
|
+
const pm = new ProcessManager();
|
|
7
|
+
pm.setLogFolder('/tmp/example-logs');
|
|
8
|
+
const result = pm.getLogPath('build -- --outDir dist');
|
|
9
|
+
// Uses only the first word of the command for the filename.
|
|
10
|
+
expect(result).toBe(
|
|
11
|
+
path.join('/tmp/example-logs', 'scripts-orchestrator-logs', 'build.log'),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('honors a per-command log file override, resolved against cwd', () => {
|
|
16
|
+
const pm = new ProcessManager();
|
|
17
|
+
pm.setLogFolder('/tmp/example-logs');
|
|
18
|
+
const override = './logs/scripts-orchestrator-logs/custom.log';
|
|
19
|
+
const result = pm.getLogPath('lint', override);
|
|
20
|
+
expect(result).toBe(path.resolve(override));
|
|
21
|
+
});
|
|
22
|
+
});
|