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 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 { Orchestrator, ProcessManager, HealthCheck, Logger, GitCache };
8
- export default Orchestrator;
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;
@@ -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 { command, ...(phaseName ? { phase: phaseName } : {}), success: null, startedAt };
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
- ...(timing?.durationMs != null && this.metrics.includes('time') ? { durationMs: timing.durationMs } : {}),
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
- ...(timing?.durationMs != null && this.metrics.includes('time')
445
- ? { durationMs: timing.durationMs }
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
- ...(timing?.durationMs != null && this.metrics.includes('time')
472
- ? { durationMs: timing.durationMs }
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
- formatMs(ms) {
509
- if (ms == null || ms === 0) return '—';
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 escapeHtml = (s) => {
517
- if (s == null) return '';
518
- return String(s)
519
- .replace(/&/g, '&amp;')
520
- .replace(/</g, '&lt;')
521
- .replace(/>/g, '&gt;')
522
- .replace(/"/g, '&quot;');
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
- } else {
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: this.metrics.includes('time') ? phaseDurationMs : undefined,
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;
@@ -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
- const LOG_FILE = logFile || path.join(LOGS_DIR, `${logName}.log`);
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)) {