scripts-orchestrator 2.13.0 → 2.15.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/README.md +128 -0
- package/index.js +95 -0
- package/lib/index.js +24 -2
- package/lib/orchestrator.js +347 -130
- package/lib/process-manager.js +5 -2
- package/lib/process-manager.test.js +22 -0
- package/lib/recommend-phases.js +251 -0
- package/lib/recommend-phases.test.js +107 -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,8 +1,11 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
4
|
import { processManager } from './process-manager.js';
|
|
3
5
|
import { healthCheck } from './health-check.js';
|
|
4
6
|
import { log } from './logger.js';
|
|
5
7
|
import { GitCache } from './git-cache.js';
|
|
8
|
+
import { renderReportHtml } from './report-html.js';
|
|
6
9
|
import chalk from 'chalk';
|
|
7
10
|
|
|
8
11
|
export class Orchestrator {
|
|
@@ -33,8 +36,26 @@ export class Orchestrator {
|
|
|
33
36
|
this.skippedCommands = [];
|
|
34
37
|
this.skipReasons = new Map(); // Track why commands were skipped
|
|
35
38
|
this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
|
|
39
|
+
this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
|
|
36
40
|
this.phaseResults = []; // { name, success, durationMs } per phase run
|
|
37
41
|
this.gitCache = new GitCache(logFolder);
|
|
42
|
+
// A1: track per-command start times for incremental JSON
|
|
43
|
+
this.commandStartTimes = new Map(); // command -> ISO start string
|
|
44
|
+
// A2: events file path derived from jsonResultsPath
|
|
45
|
+
this.eventsPath = this._deriveEventsPath(jsonResultsPath);
|
|
46
|
+
// A4: library-owned run-state file
|
|
47
|
+
this.runStatePath = logFolder
|
|
48
|
+
? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
|
|
49
|
+
: null;
|
|
50
|
+
// A7: post-run hook command (shell string)
|
|
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;
|
|
38
59
|
|
|
39
60
|
// Set the log folder in process manager
|
|
40
61
|
if (logFolder) {
|
|
@@ -58,6 +79,138 @@ export class Orchestrator {
|
|
|
58
79
|
return [];
|
|
59
80
|
}
|
|
60
81
|
|
|
82
|
+
_deriveEventsPath(jsonResultsPath) {
|
|
83
|
+
if (!jsonResultsPath || jsonResultsPath === '-') return null;
|
|
84
|
+
return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A4: write current run state atomically
|
|
88
|
+
_writeRunState(extra = {}) {
|
|
89
|
+
if (!this.runStatePath) return;
|
|
90
|
+
const state = {
|
|
91
|
+
startedAt: this.runStartedAt ? new Date(this.runStartedAt).toISOString() : new Date().toISOString(),
|
|
92
|
+
pid: process.pid,
|
|
93
|
+
...extra,
|
|
94
|
+
};
|
|
95
|
+
const tmp = this.runStatePath + '.tmp';
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(path.dirname(this.runStatePath), { recursive: true });
|
|
98
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
99
|
+
fs.renameSync(tmp, this.runStatePath);
|
|
100
|
+
} catch { /* non-fatal */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// A4: remove run-state file on run end
|
|
104
|
+
_clearRunState() {
|
|
105
|
+
if (!this.runStatePath) return;
|
|
106
|
+
try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A2: append a structured NDJSON event
|
|
110
|
+
_appendEvent(type, data = {}) {
|
|
111
|
+
if (!this.eventsPath) return;
|
|
112
|
+
const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
|
|
113
|
+
try {
|
|
114
|
+
fs.appendFileSync(this.eventsPath, line + '\n', 'utf8');
|
|
115
|
+
} catch {
|
|
116
|
+
// non-fatal: don't let event logging break the run
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A1: atomically write current run state (completed + in-flight commands) to json_results
|
|
121
|
+
_writePartialResults() {
|
|
122
|
+
if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
|
|
123
|
+
const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
|
|
124
|
+
|
|
125
|
+
const commands = [];
|
|
126
|
+
|
|
127
|
+
const buildEntry = (command, phaseName) => {
|
|
128
|
+
const timing = this.commandTimings.get(command);
|
|
129
|
+
const startedAt = this.commandStartTimes.get(command);
|
|
130
|
+
const skipped = this.skippedCommands.includes(command);
|
|
131
|
+
const done = timing != null || skipped;
|
|
132
|
+
|
|
133
|
+
if (!done && startedAt) {
|
|
134
|
+
return {
|
|
135
|
+
command,
|
|
136
|
+
...(phaseName ? { phase: phaseName } : {}),
|
|
137
|
+
success: null,
|
|
138
|
+
startedAt,
|
|
139
|
+
...this._logFileField(command),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (!done) return null; // not yet started — omit
|
|
143
|
+
|
|
144
|
+
const skipReason = skipped ? (this.skipReasons.get(command) ?? null) : null;
|
|
145
|
+
const success =
|
|
146
|
+
!this.failedCommands.includes(command) &&
|
|
147
|
+
(skipReason === null ||
|
|
148
|
+
skipReason === 'disabled' ||
|
|
149
|
+
skipReason === 'optional_phase_not_requested' ||
|
|
150
|
+
skipReason === 'before_start_phase');
|
|
151
|
+
return {
|
|
152
|
+
command,
|
|
153
|
+
...(phaseName ? { phase: phaseName } : {}),
|
|
154
|
+
success,
|
|
155
|
+
...(startedAt ? { startedAt } : {}),
|
|
156
|
+
...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
|
|
157
|
+
...(this.metrics.includes('memory') ? { memoryKb: timing?.memoryKb ?? null } : {}),
|
|
158
|
+
...this._logFileField(command),
|
|
159
|
+
...(skipReason ? { skipReason } : {}),
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(this.config)) {
|
|
164
|
+
for (const { command } of this.config) {
|
|
165
|
+
const entry = buildEntry(command, null);
|
|
166
|
+
if (entry) commands.push(entry);
|
|
167
|
+
}
|
|
168
|
+
} else if (this.config.phases) {
|
|
169
|
+
for (const phase of this.config.phases) {
|
|
170
|
+
for (const { command } of (phase.parallel || [])) {
|
|
171
|
+
const entry = buildEntry(command, phase.name);
|
|
172
|
+
if (entry) commands.push(entry);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const payload = {
|
|
178
|
+
success: null, // in-progress sentinel; replaced by writeJsonResults on completion
|
|
179
|
+
timestamp: new Date().toISOString(),
|
|
180
|
+
...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
|
|
181
|
+
commands,
|
|
182
|
+
...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const tmpPath = outPath + '.tmp';
|
|
186
|
+
try {
|
|
187
|
+
fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
188
|
+
fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
|
|
189
|
+
fs.renameSync(tmpPath, outPath);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
this.logger.verbose(`Partial results write failed: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
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
|
+
// A4: keep run-state file in sync with current active commands and phase
|
|
206
|
+
const inFlight = commands.filter(c => c.success === null).map(c => c.command);
|
|
207
|
+
const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
|
|
208
|
+
this._writeRunState({
|
|
209
|
+
activeCommand: inFlight.length === 1 ? inFlight[0] : (inFlight.length > 1 ? inFlight : null),
|
|
210
|
+
phase: currentPhase,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
61
214
|
formatDuration(ms) {
|
|
62
215
|
if (ms < 1000) return `${ms}ms`;
|
|
63
216
|
const seconds = Math.floor(ms / 1000);
|
|
@@ -69,7 +222,48 @@ export class Orchestrator {
|
|
|
69
222
|
return `${seconds}s`;
|
|
70
223
|
}
|
|
71
224
|
|
|
72
|
-
|
|
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
|
+
|
|
266
|
+
async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
|
|
73
267
|
const {
|
|
74
268
|
command,
|
|
75
269
|
dependencies = [],
|
|
@@ -88,6 +282,10 @@ export class Orchestrator {
|
|
|
88
282
|
|
|
89
283
|
const startTime = Date.now();
|
|
90
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
|
+
|
|
91
289
|
const setTiming = (durationMs, memoryKb = null) => {
|
|
92
290
|
this.commandTimings.set(command, { durationMs, memoryKb });
|
|
93
291
|
};
|
|
@@ -184,6 +382,11 @@ export class Orchestrator {
|
|
|
184
382
|
}
|
|
185
383
|
}
|
|
186
384
|
|
|
385
|
+
// A1/A2: record start and emit event
|
|
386
|
+
this.commandStartTimes.set(command, new Date().toISOString());
|
|
387
|
+
this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
|
|
388
|
+
this._writePartialResults();
|
|
389
|
+
|
|
187
390
|
// Execute the main command with retries
|
|
188
391
|
let result = false;
|
|
189
392
|
let commandOutput = '';
|
|
@@ -258,6 +461,9 @@ export class Orchestrator {
|
|
|
258
461
|
|
|
259
462
|
const totalDurationMs = Date.now() - startTime;
|
|
260
463
|
setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
|
|
464
|
+
// A1/A2: emit completion event and write incremental results
|
|
465
|
+
this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
|
|
466
|
+
this._writePartialResults();
|
|
261
467
|
visited.delete(command);
|
|
262
468
|
return result;
|
|
263
469
|
}
|
|
@@ -288,10 +494,7 @@ export class Orchestrator {
|
|
|
288
494
|
}
|
|
289
495
|
|
|
290
496
|
writeJsonResults(hasFailures) {
|
|
291
|
-
const overallDurationMs =
|
|
292
|
-
this.metrics.includes('time') && this.startTime
|
|
293
|
-
? Date.now() - this.startTime
|
|
294
|
-
: undefined;
|
|
497
|
+
const overallDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
|
|
295
498
|
|
|
296
499
|
const commands = [];
|
|
297
500
|
if (Array.isArray(this.config)) {
|
|
@@ -309,12 +512,14 @@ export class Orchestrator {
|
|
|
309
512
|
const entry = {
|
|
310
513
|
command,
|
|
311
514
|
success,
|
|
312
|
-
...(
|
|
313
|
-
? {
|
|
515
|
+
...(this.commandStartTimes.has(command)
|
|
516
|
+
? { startedAt: this.commandStartTimes.get(command) }
|
|
314
517
|
: {}),
|
|
518
|
+
...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
|
|
315
519
|
...(this.metrics.includes('memory')
|
|
316
520
|
? { memoryKb: timing?.memoryKb ?? null }
|
|
317
521
|
: {}),
|
|
522
|
+
...this._logFileField(command),
|
|
318
523
|
...(skipReason ? { skipReason } : {}),
|
|
319
524
|
};
|
|
320
525
|
commands.push(entry);
|
|
@@ -336,12 +541,14 @@ export class Orchestrator {
|
|
|
336
541
|
command,
|
|
337
542
|
phase: phase.name,
|
|
338
543
|
success,
|
|
339
|
-
...(
|
|
340
|
-
? {
|
|
544
|
+
...(this.commandStartTimes.has(command)
|
|
545
|
+
? { startedAt: this.commandStartTimes.get(command) }
|
|
341
546
|
: {}),
|
|
547
|
+
...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
|
|
342
548
|
...(this.metrics.includes('memory')
|
|
343
549
|
? { memoryKb: timing?.memoryKb ?? null }
|
|
344
550
|
: {}),
|
|
551
|
+
...this._logFileField(command),
|
|
345
552
|
...(skipReason ? { skipReason } : {}),
|
|
346
553
|
};
|
|
347
554
|
commands.push(entry);
|
|
@@ -371,135 +578,31 @@ export class Orchestrator {
|
|
|
371
578
|
if (this.htmlResultsPath != null) {
|
|
372
579
|
this.writeHtmlResults(payload);
|
|
373
580
|
}
|
|
374
|
-
}
|
|
375
581
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (ms < 1000) return `${ms}ms`;
|
|
379
|
-
const s = (ms / 1000).toFixed(1);
|
|
380
|
-
return `${s}s`;
|
|
582
|
+
// Announce the important output files so they're easy to find in the logs.
|
|
583
|
+
this._announceReportFiles('Reports written');
|
|
381
584
|
}
|
|
382
585
|
|
|
383
586
|
writeHtmlResults(payload) {
|
|
384
|
-
const
|
|
385
|
-
if (s == null) return '';
|
|
386
|
-
return String(s)
|
|
387
|
-
.replace(/&/g, '&')
|
|
388
|
-
.replace(/</g, '<')
|
|
389
|
-
.replace(/>/g, '>')
|
|
390
|
-
.replace(/"/g, '"');
|
|
391
|
-
};
|
|
392
|
-
const { success, timestamp, overallDurationMs, commands = [], phases = [] } = payload;
|
|
393
|
-
const maxDuration = Math.max(0, ...commands.map((c) => c.durationMs || 0), ...phases.map((p) => p.durationMs || 0));
|
|
394
|
-
const maxMemory = Math.max(0, ...commands.map((c) => c.memoryKb || 0));
|
|
395
|
-
|
|
396
|
-
const row = (c) => {
|
|
397
|
-
const durationPct = maxDuration > 0 && c.durationMs != null ? (c.durationMs / maxDuration) * 100 : 0;
|
|
398
|
-
const memoryPct = maxMemory > 0 && c.memoryKb != null ? (c.memoryKb / maxMemory) * 100 : 0;
|
|
399
|
-
const statusClass = c.success ? 'ok' : 'fail';
|
|
400
|
-
const statusLabel = c.success ? 'OK' : (c.skipReason || 'Failed');
|
|
401
|
-
return `
|
|
402
|
-
<tr class="${statusClass}">
|
|
403
|
-
<td><code>${escapeHtml(c.command)}</code></td>
|
|
404
|
-
<td>${c.phase != null ? escapeHtml(c.phase) : '—'}</td>
|
|
405
|
-
<td><span class="badge ${statusClass}">${escapeHtml(statusLabel)}</span></td>
|
|
406
|
-
<td>${this.formatMs(c.durationMs)}</td>
|
|
407
|
-
<td>${c.memoryKb != null ? `${(c.memoryKb / 1024).toFixed(1)} MB` : '—'}</td>
|
|
408
|
-
<td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
|
|
409
|
-
<td class="bar-cell"><div class="bar mem" style="width:${memoryPct}%"></div></td>
|
|
410
|
-
</tr>`;
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const phaseRow = (p) => {
|
|
414
|
-
const durationPct = maxDuration > 0 && p.durationMs != null ? (p.durationMs / maxDuration) * 100 : 0;
|
|
415
|
-
const statusClass = p.success ? 'ok' : 'fail';
|
|
416
|
-
return `
|
|
417
|
-
<tr class="${statusClass}">
|
|
418
|
-
<td>${escapeHtml(p.name)}</td>
|
|
419
|
-
<td><span class="badge ${statusClass}">${p.success ? 'OK' : 'Failed'}</span></td>
|
|
420
|
-
<td>${this.formatMs(p.durationMs)}</td>
|
|
421
|
-
<td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
|
|
422
|
-
</tr>`;
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
const html = `<!DOCTYPE html>
|
|
426
|
-
<html lang="en">
|
|
427
|
-
<head>
|
|
428
|
-
<meta charset="utf-8">
|
|
429
|
-
<title>Scripts Orchestrator Report</title>
|
|
430
|
-
<style>
|
|
431
|
-
* { box-sizing: border-box; }
|
|
432
|
-
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
|
|
433
|
-
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
434
|
-
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
|
435
|
-
.summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
|
|
436
|
-
.summary .card.success { border-left: 4px solid #22c55e; }
|
|
437
|
-
.summary .card.fail { border-left: 4px solid #ef4444; }
|
|
438
|
-
.summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
|
|
439
|
-
.summary .value { font-size: 1.25rem; font-weight: 600; }
|
|
440
|
-
section { margin-bottom: 1.5rem; }
|
|
441
|
-
section h2 { font-size: 1.1rem; color: #a0a0a0; margin-bottom: 0.5rem; }
|
|
442
|
-
table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; }
|
|
443
|
-
th, td { padding: 0.5rem 0.75rem; text-align: left; }
|
|
444
|
-
th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
|
|
445
|
-
tr.fail { background: rgba(239,68,68,0.08); }
|
|
446
|
-
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
|
|
447
|
-
.badge.ok { background: #22c55e; color: #0f0f0f; }
|
|
448
|
-
.badge.fail { background: #ef4444; color: #fff; }
|
|
449
|
-
.bar-cell { width: 120px; }
|
|
450
|
-
.bar { height: 8px; background: #3b82f6; border-radius: 4px; min-width: 2px; }
|
|
451
|
-
.bar.mem { background: #8b5cf6; }
|
|
452
|
-
code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
|
453
|
-
</style>
|
|
454
|
-
</head>
|
|
455
|
-
<body>
|
|
456
|
-
<h1>Scripts Orchestrator Report</h1>
|
|
457
|
-
<div class="summary">
|
|
458
|
-
<div class="card ${success ? 'success' : 'fail'}">
|
|
459
|
-
<div class="label">Status</div>
|
|
460
|
-
<div class="value">${success ? 'Success' : 'Failed'}</div>
|
|
461
|
-
</div>
|
|
462
|
-
<div class="card">
|
|
463
|
-
<div class="label">Timestamp</div>
|
|
464
|
-
<div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
|
|
465
|
-
</div>
|
|
466
|
-
${overallDurationMs != null ? `
|
|
467
|
-
<div class="card">
|
|
468
|
-
<div class="label">Total time</div>
|
|
469
|
-
<div class="value">${this.formatMs(overallDurationMs)}</div>
|
|
470
|
-
</div>` : ''}
|
|
471
|
-
</div>
|
|
472
|
-
|
|
473
|
-
${phases.length > 0 ? `
|
|
474
|
-
<section>
|
|
475
|
-
<h2>Phases</h2>
|
|
476
|
-
<table>
|
|
477
|
-
<thead><tr><th>Phase</th><th>Status</th><th>Duration</th><th></th></tr></thead>
|
|
478
|
-
<tbody>${phases.map(phaseRow).join('')}</tbody>
|
|
479
|
-
</table>
|
|
480
|
-
</section>` : ''}
|
|
481
|
-
|
|
482
|
-
<section>
|
|
483
|
-
<h2>Commands</h2>
|
|
484
|
-
<table>
|
|
485
|
-
<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>
|
|
486
|
-
<tbody>${commands.map(row).join('')}</tbody>
|
|
487
|
-
</table>
|
|
488
|
-
</section>
|
|
489
|
-
</body>
|
|
490
|
-
</html>`;
|
|
491
|
-
|
|
587
|
+
const html = renderReportHtml(payload);
|
|
492
588
|
if (this.htmlResultsPath === '-') {
|
|
493
589
|
console.log(html);
|
|
494
|
-
|
|
495
|
-
const outPath = this.htmlResultsPath || './scripts-orchestrator-results.html';
|
|
496
|
-
fs.writeFileSync(outPath, html, 'utf8');
|
|
497
|
-
this.logger.verbose(`Wrote HTML report to ${outPath}`);
|
|
590
|
+
return;
|
|
498
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}`);
|
|
499
599
|
}
|
|
500
600
|
|
|
501
601
|
async run() {
|
|
502
602
|
this.startTime = Date.now();
|
|
603
|
+
this.runStartedAt = this.startTime;
|
|
604
|
+
// A4: write initial run-state at start
|
|
605
|
+
this._writeRunState({ phase: null, activeCommand: null });
|
|
503
606
|
try {
|
|
504
607
|
// Check if we should skip execution based on git state (unless forced)
|
|
505
608
|
if (!this.force) {
|
|
@@ -519,6 +622,13 @@ export class Orchestrator {
|
|
|
519
622
|
let phaseFailed = false;
|
|
520
623
|
let startPhaseFound = false;
|
|
521
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
|
+
|
|
522
632
|
// Handle both old array format and new phases format
|
|
523
633
|
if (Array.isArray(this.config)) {
|
|
524
634
|
// Legacy: Run all commands in parallel or sequential based on flag
|
|
@@ -598,7 +708,7 @@ export class Orchestrator {
|
|
|
598
708
|
// Run commands sequentially
|
|
599
709
|
results = [];
|
|
600
710
|
for (const commandConfig of phase.parallel) {
|
|
601
|
-
const result = await this.executeCommand(commandConfig);
|
|
711
|
+
const result = await this.executeCommand(commandConfig, new Set(), phase.name);
|
|
602
712
|
results.push(result);
|
|
603
713
|
if (!result) {
|
|
604
714
|
// In sequential mode, stop phase execution on first failure
|
|
@@ -608,7 +718,7 @@ export class Orchestrator {
|
|
|
608
718
|
} else {
|
|
609
719
|
// Run commands in parallel
|
|
610
720
|
const tasks = phase.parallel.map((commandConfig) =>
|
|
611
|
-
this.executeCommand(commandConfig),
|
|
721
|
+
this.executeCommand(commandConfig, new Set(), phase.name),
|
|
612
722
|
);
|
|
613
723
|
results = await Promise.all(tasks);
|
|
614
724
|
}
|
|
@@ -622,7 +732,7 @@ export class Orchestrator {
|
|
|
622
732
|
this.phaseResults.push({
|
|
623
733
|
name: phase.name,
|
|
624
734
|
success: !phaseHasFailures,
|
|
625
|
-
durationMs:
|
|
735
|
+
durationMs: phaseDurationMs,
|
|
626
736
|
});
|
|
627
737
|
|
|
628
738
|
if (phaseHasFailures) {
|
|
@@ -698,11 +808,25 @@ export class Orchestrator {
|
|
|
698
808
|
);
|
|
699
809
|
}
|
|
700
810
|
|
|
811
|
+
// A2: emit run_end event; A1: final JSON written by writeJsonResults (replaces partial)
|
|
812
|
+
const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
|
|
813
|
+
this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
|
|
814
|
+
|
|
701
815
|
// Write JSON results if requested
|
|
702
816
|
if (this.jsonResultsPath != null) {
|
|
703
817
|
this.writeJsonResults(hasFailures);
|
|
704
818
|
}
|
|
705
819
|
|
|
820
|
+
// A4: clear run-state file — run is done
|
|
821
|
+
this._clearRunState();
|
|
822
|
+
|
|
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
|
+
// A7: run post_run hook after results are written
|
|
828
|
+
this._runPostRunHook(hasFailures);
|
|
829
|
+
|
|
706
830
|
// Update git cache on successful execution
|
|
707
831
|
if (!hasFailures) {
|
|
708
832
|
await this.gitCache.updateCache();
|
|
@@ -719,6 +843,12 @@ export class Orchestrator {
|
|
|
719
843
|
} catch (error) {
|
|
720
844
|
this.logger.error(`Orchestrator failed: ${error.message}`);
|
|
721
845
|
|
|
846
|
+
// Stop periodic ticks on error.
|
|
847
|
+
this._stopPeriodicHook();
|
|
848
|
+
|
|
849
|
+
// A4: clear run-state on error too
|
|
850
|
+
this._clearRunState();
|
|
851
|
+
|
|
722
852
|
// Cleanup on error
|
|
723
853
|
try {
|
|
724
854
|
await this.processManager.cleanup();
|
|
@@ -729,4 +859,91 @@ export class Orchestrator {
|
|
|
729
859
|
process.exit(1);
|
|
730
860
|
}
|
|
731
861
|
}
|
|
862
|
+
|
|
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
|
+
// A7: run user-configured post_run shell command
|
|
932
|
+
_runPostRunHook(hasFailures) {
|
|
933
|
+
if (!this.postRun) return;
|
|
934
|
+
this.logger.info(`[post_run] ${this.postRun}`);
|
|
935
|
+
const result = spawnSync(this.postRun, {
|
|
936
|
+
shell: true,
|
|
937
|
+
stdio: 'inherit',
|
|
938
|
+
cwd: process.cwd(),
|
|
939
|
+
env: {
|
|
940
|
+
...process.env,
|
|
941
|
+
SCRIPTS_ORCHESTRATOR_SUCCESS: hasFailures ? '0' : '1',
|
|
942
|
+
SCRIPTS_ORCHESTRATOR_EXIT_CODE: hasFailures ? '1' : '0',
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
if (result.status !== 0) {
|
|
946
|
+
this.logger.warn(`[post_run] hook exited with code ${result.status}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
732
949
|
}
|
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
|
+
});
|