scripts-orchestrator 2.14.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 +46 -0
- package/index.js +88 -0
- package/lib/index.js +24 -2
- package/lib/orchestrator.js +180 -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/README.md
CHANGED
|
@@ -40,6 +40,7 @@ npm install --save-dev scripts-orchestrator
|
|
|
40
40
|
- **NDJSON event stream**: Machine-readable per-command events for dashboards (v2.14+)
|
|
41
41
|
- **Post-run hook**: Run a shell command after results are written via `post_run` config (v2.14+)
|
|
42
42
|
- **Run-state file**: Library-owned in-progress indicator for live dashboard integration (v2.14+)
|
|
43
|
+
- **Phase recommendations**: Memory-aware `--recommend` mode that proposes an optimal phase layout from a run's time/memory metrics (advisory, v2.15+)
|
|
43
44
|
|
|
44
45
|
## Configuration
|
|
45
46
|
|
|
@@ -522,6 +523,50 @@ This is useful when you want to:
|
|
|
522
523
|
- Debug issues without modifying the codebase
|
|
523
524
|
- Override the cache in CI/CD pipelines
|
|
524
525
|
|
|
526
|
+
## Phase Recommendations (advisory)
|
|
527
|
+
|
|
528
|
+
When a run is executed with `metrics: ['time', 'memory']`, the results JSON records each command's
|
|
529
|
+
`durationMs` and peak `memoryKb`. The `--recommend` mode reads that JSON and prints a **memory-aware
|
|
530
|
+
phase recommendation**: it never runs anything and writes no files.
|
|
531
|
+
|
|
532
|
+
```bash
|
|
533
|
+
# Analyse an existing results JSON and print a suggested phase layout
|
|
534
|
+
scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json
|
|
535
|
+
|
|
536
|
+
# Size the budget for a machine running N gates in parallel (each gets 1/N of RAM and cores)
|
|
537
|
+
scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json --fanout 3
|
|
538
|
+
|
|
539
|
+
# Override the memory budget explicitly (MB) or change the RAM safety fraction
|
|
540
|
+
scripts-orchestrator --recommend ./logs/results.json --budget-mb 8192
|
|
541
|
+
scripts-orchestrator --recommend ./logs/results.json --mem-safety 0.7
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
It reports two things:
|
|
545
|
+
|
|
546
|
+
1. **Observed timeline** — each phase's wall-clock (the longest step in it) and the concurrent peak
|
|
547
|
+
memory (Σ of member peaks), flagging any phase whose concurrent peak exceeds the host budget.
|
|
548
|
+
2. **Recommended layout** — a [First-Fit-Decreasing](https://en.wikipedia.org/wiki/Bin_packing_problem)
|
|
549
|
+
bin-packing by duration that groups steps into sequential phases so each phase's concurrent peak
|
|
550
|
+
memory stays under `budget = totalmem × memSafety ÷ fanout` and its step count stays under
|
|
551
|
+
`coreShare = (cores − 2) ÷ fanout`. Long steps seed phases; short steps fill the gaps beneath them,
|
|
552
|
+
so the estimated makespan stays near the theoretical floor (the single longest step) without
|
|
553
|
+
oversubscribing RAM.
|
|
554
|
+
|
|
555
|
+
The same logic is exported for programmatic use:
|
|
556
|
+
|
|
557
|
+
```js
|
|
558
|
+
import { recommendPhases, formatRecommendationReport } from 'scripts-orchestrator';
|
|
559
|
+
|
|
560
|
+
const payload = JSON.parse(fs.readFileSync('./logs/results.json', 'utf8'));
|
|
561
|
+
const rec = recommendPhases(payload, { fanout: 3 });
|
|
562
|
+
console.log(formatRecommendationReport(rec));
|
|
563
|
+
// rec.recommended.bins, rec.observed, rec.observedMakespanMs, rec.budgetBytes, …
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
This is advisory only — the budget is conservative (per-process peaks summed as if they coincide) and
|
|
567
|
+
the packing does not model inter-phase data dependencies, so validate any suggested layout against a
|
|
568
|
+
real run before adopting it.
|
|
569
|
+
|
|
525
570
|
## Exit Codes
|
|
526
571
|
|
|
527
572
|
- `0`: All commands executed successfully
|
|
@@ -535,6 +580,7 @@ See [versions](./docs/versions.md)
|
|
|
535
580
|
- Better UX to indicate what is happening
|
|
536
581
|
- Tests to avoid regression
|
|
537
582
|
- Run any shell command rather than assume the command is specified in package.json (? tentative)
|
|
583
|
+
- Promote the advisory `--recommend` phase recommender into an opt-in automatic scheduler that packs each phase under a per-host memory budget at run time
|
|
538
584
|
|
|
539
585
|
|
|
540
586
|
## Disclaimer
|
package/index.js
CHANGED
|
@@ -51,10 +51,87 @@ const argv = yargs(hideBin(process.argv))
|
|
|
51
51
|
type: 'string',
|
|
52
52
|
description: 'Write HTML report to this path; use "-" for stdout only',
|
|
53
53
|
})
|
|
54
|
+
.option('render', {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Render an existing results JSON file to HTML (no run). Use with --html-results.',
|
|
57
|
+
})
|
|
58
|
+
.option('recommend', {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description:
|
|
61
|
+
'Analyse an existing results JSON and print a memory-aware phase recommendation (R12, no run).',
|
|
62
|
+
})
|
|
63
|
+
.option('fanout', {
|
|
64
|
+
type: 'number',
|
|
65
|
+
description: 'Workspace fan-out (parallel gates sharing the host) used to size the --recommend budget. Default 1.',
|
|
66
|
+
})
|
|
67
|
+
.option('mem-safety', {
|
|
68
|
+
type: 'number',
|
|
69
|
+
description: 'Fraction of total RAM the --recommend budget may use (default 0.8).',
|
|
70
|
+
})
|
|
71
|
+
.option('budget-mb', {
|
|
72
|
+
type: 'number',
|
|
73
|
+
description: 'Override the --recommend memory budget with a fixed value in MB.',
|
|
74
|
+
})
|
|
54
75
|
.help()
|
|
55
76
|
.alias('h', 'help')
|
|
56
77
|
.parse();
|
|
57
78
|
|
|
79
|
+
// --render mode: turn an existing results JSON into HTML and exit (no orchestration run).
|
|
80
|
+
// Keeps all HTML rendering in the library so consumers never reimplement it.
|
|
81
|
+
if (argv.render != null) {
|
|
82
|
+
const { renderReportHtml } = await import('./lib/index.js');
|
|
83
|
+
const srcPath = path.resolve(process.cwd(), argv.render);
|
|
84
|
+
if (!fs.existsSync(srcPath)) {
|
|
85
|
+
log.error(`Error: --render source not found at ${srcPath}`);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
let payload;
|
|
89
|
+
try {
|
|
90
|
+
payload = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
|
|
91
|
+
} catch (err) {
|
|
92
|
+
log.error(`Error: failed to parse --render JSON: ${err.message}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
const html = renderReportHtml(payload);
|
|
96
|
+
const out = argv.htmlResults ?? null;
|
|
97
|
+
if (out == null || out === '-') {
|
|
98
|
+
console.log(html);
|
|
99
|
+
} else {
|
|
100
|
+
const outPath = path.resolve(process.cwd(), out);
|
|
101
|
+
const tmpPath = outPath + '.tmp';
|
|
102
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
103
|
+
fs.writeFileSync(tmpPath, html, 'utf8');
|
|
104
|
+
fs.renameSync(tmpPath, outPath);
|
|
105
|
+
log.info(`📄 Rendered ${path.relative(process.cwd(), srcPath)} → ${path.relative(process.cwd(), outPath)}`);
|
|
106
|
+
}
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --recommend mode: analyse an existing results JSON and print a memory-aware phase
|
|
111
|
+
// recommendation (R12). Advisory only — no orchestration run, no files written.
|
|
112
|
+
if (argv.recommend != null) {
|
|
113
|
+
const { recommendPhases, formatRecommendationReport } = await import('./lib/index.js');
|
|
114
|
+
const srcPath = path.resolve(process.cwd(), argv.recommend);
|
|
115
|
+
if (!fs.existsSync(srcPath)) {
|
|
116
|
+
log.error(`Error: --recommend source not found at ${srcPath}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
let payload;
|
|
120
|
+
try {
|
|
121
|
+
payload = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
log.error(`Error: failed to parse --recommend JSON: ${err.message}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
const rec = recommendPhases(payload, {
|
|
127
|
+
fanout: argv.fanout,
|
|
128
|
+
memSafety: argv.memSafety,
|
|
129
|
+
budgetMb: argv.budgetMb,
|
|
130
|
+
});
|
|
131
|
+
console.log(formatRecommendationReport(rec, { sourcePath: path.relative(process.cwd(), srcPath) }));
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
58
135
|
// Extract arguments
|
|
59
136
|
const args = argv._;
|
|
60
137
|
const configPath = args[0] || './scripts-orchestrator.config.js';
|
|
@@ -114,6 +191,13 @@ const htmlResultsPath =
|
|
|
114
191
|
// A7: post-run hook — shell command run after json_results written
|
|
115
192
|
const postRun = commandsConfig.post_run ?? null;
|
|
116
193
|
|
|
194
|
+
// Periodic hook — shell command run on an interval WHILE the run is in flight (e.g. to roll up
|
|
195
|
+
// results into an aggregate report). Library owns only the cadence; the command is project-specific.
|
|
196
|
+
const periodicHook = commandsConfig.periodic_hook ?? null;
|
|
197
|
+
const periodicIntervalMs = Number(commandsConfig.periodic_interval_ms) > 0
|
|
198
|
+
? Number(commandsConfig.periodic_interval_ms)
|
|
199
|
+
: 45000;
|
|
200
|
+
|
|
117
201
|
// Set the log folder for the main orchestrator logs if specified
|
|
118
202
|
if (logFolder) {
|
|
119
203
|
log.setLogFolder(logFolder);
|
|
@@ -133,10 +217,14 @@ const orchestrator = new Orchestrator(
|
|
|
133
217
|
);
|
|
134
218
|
// A7: wire post-run hook from config
|
|
135
219
|
orchestrator.postRun = postRun;
|
|
220
|
+
// Wire periodic hook (cadence owned by the library)
|
|
221
|
+
orchestrator.periodicHook = periodicHook;
|
|
222
|
+
orchestrator.periodicIntervalMs = periodicIntervalMs;
|
|
136
223
|
|
|
137
224
|
// Enhanced signal handlers
|
|
138
225
|
const handleSignal = async (signal) => {
|
|
139
226
|
log.warn(`\nReceived ${signal} signal. Cleaning up...`);
|
|
227
|
+
orchestrator._stopPeriodicHook();
|
|
140
228
|
try {
|
|
141
229
|
await orchestrator.processManager.cleanup();
|
|
142
230
|
} catch (error) {
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,28 @@ import { ProcessManager } from './process-manager.js';
|
|
|
3
3
|
import { HealthCheck } from './health-check.js';
|
|
4
4
|
import { Logger } from './logger.js';
|
|
5
5
|
import { GitCache } from './git-cache.js';
|
|
6
|
+
import { renderReportHtml } from './report-html.js';
|
|
7
|
+
import {
|
|
8
|
+
recommendPhases,
|
|
9
|
+
formatRecommendationReport,
|
|
10
|
+
computeBudget,
|
|
11
|
+
usableSteps,
|
|
12
|
+
observedTimeline,
|
|
13
|
+
packPhases,
|
|
14
|
+
} from './recommend-phases.js';
|
|
6
15
|
|
|
7
|
-
export {
|
|
8
|
-
|
|
16
|
+
export {
|
|
17
|
+
Orchestrator,
|
|
18
|
+
ProcessManager,
|
|
19
|
+
HealthCheck,
|
|
20
|
+
Logger,
|
|
21
|
+
GitCache,
|
|
22
|
+
renderReportHtml,
|
|
23
|
+
recommendPhases,
|
|
24
|
+
formatRecommendationReport,
|
|
25
|
+
computeBudget,
|
|
26
|
+
usableSteps,
|
|
27
|
+
observedTimeline,
|
|
28
|
+
packPhases,
|
|
29
|
+
};
|
|
30
|
+
export default Orchestrator;
|
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,6 +36,7 @@ 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
|
// A1: track per-command start times for incremental JSON
|
|
@@ -47,6 +49,13 @@ export class Orchestrator {
|
|
|
47
49
|
: null;
|
|
48
50
|
// A7: 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) {
|
|
@@ -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,6 +191,17 @@ export class Orchestrator {
|
|
|
173
191
|
this.logger.verbose(`Partial results write failed: ${err.message}`);
|
|
174
192
|
}
|
|
175
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
|
+
|
|
176
205
|
// A4: 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;
|
|
@@ -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
|
};
|
|
@@ -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,131 +578,24 @@ 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() {
|
|
@@ -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) {
|
|
@@ -845,6 +820,10 @@ export class Orchestrator {
|
|
|
845
820
|
// A4: clear run-state file — run is done
|
|
846
821
|
this._clearRunState();
|
|
847
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
|
+
|
|
848
827
|
// A7: run post_run hook after results are written
|
|
849
828
|
this._runPostRunHook(hasFailures);
|
|
850
829
|
|
|
@@ -864,6 +843,9 @@ export class Orchestrator {
|
|
|
864
843
|
} catch (error) {
|
|
865
844
|
this.logger.error(`Orchestrator failed: ${error.message}`);
|
|
866
845
|
|
|
846
|
+
// Stop periodic ticks on error.
|
|
847
|
+
this._stopPeriodicHook();
|
|
848
|
+
|
|
867
849
|
// A4: clear run-state on error too
|
|
868
850
|
this._clearRunState();
|
|
869
851
|
|
|
@@ -878,6 +860,74 @@ export class Orchestrator {
|
|
|
878
860
|
}
|
|
879
861
|
}
|
|
880
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
|
+
|
|
881
931
|
// A7: run user-configured post_run shell command
|
|
882
932
|
_runPostRunHook(hasFailures) {
|
|
883
933
|
if (!this.postRun) return;
|
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)) {
|