scripts-orchestrator 2.14.0 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -450,7 +451,7 @@ Alongside `json_results`, the library writes one NDJSON line per event to
450
451
  Dashboard tools can `tail -f` this file or watch it with `fs.watch` to get real-time updates
451
452
  without parsing human-readable log lines.
452
453
 
453
- ### Run-state file (A4)
454
+ ### Run-state file
454
455
 
455
456
  When `--logFolder` is specified, the library writes `{logFolder}/.scripts-orchestrator-run.json`
456
457
  at run start and removes it on run end:
@@ -467,7 +468,7 @@ at run start and removes it on run end:
467
468
  This file is the authoritative in-progress signal for live dashboards. Its absence means the run
468
469
  has finished (or never started).
469
470
 
470
- ### Post-run hook (A7)
471
+ ### Post-run hook
471
472
 
472
473
  Add `post_run` to your config to run a shell command **after** `json_results` is written and the
473
474
  run-state file is cleared, but **before** `process.exit()`:
@@ -522,6 +523,59 @@ 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 reports a **memory-aware
530
+ phase recommendation**: it never runs anything and changes no run state.
531
+
532
+ ```bash
533
+ # Analyse an existing results JSON and print a suggested phase layout to the console
534
+ scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json
535
+
536
+ # Write the report to a plain-text log file instead of the console (only a pointer line is printed)
537
+ scripts-orchestrator --recommend ./logs/results.json --recommend-out ./logs/recommendation.log
538
+
539
+ # Size the budget for a machine running N gates in parallel (each gets 1/N of RAM and cores)
540
+ scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json --fanout 3
541
+
542
+ # Override the memory budget explicitly (MB) or change the RAM safety fraction
543
+ scripts-orchestrator --recommend ./logs/results.json --budget-mb 8192
544
+ scripts-orchestrator --recommend ./logs/results.json --mem-safety 0.7
545
+ ```
546
+
547
+ It reports three things:
548
+
549
+ 1. **Observed timeline** — each phase's wall-clock (the longest step in it) and the concurrent peak
550
+ memory (Σ of member peaks), flagging any phase whose concurrent peak exceeds the host budget.
551
+ 2. **Recommended layout** — a [First-Fit-Decreasing](https://en.wikipedia.org/wiki/Bin_packing_problem)
552
+ bin-packing by duration that groups steps into sequential phases so each phase's concurrent peak
553
+ memory stays under `budget = totalmem × memSafety ÷ fanout` and its step count stays under
554
+ `coreShare = (cores − 2) ÷ fanout`. Long steps seed phases; short steps fill the gaps beneath them,
555
+ so the estimated makespan stays near the theoretical floor (the single longest step) without
556
+ oversubscribing RAM.
557
+ 3. **Verdict** — a single yes/no line: whether re-grouping is worth it (it must trim ≥5% and ≥5s off
558
+ the makespan), or — when one step is ≥95% of the makespan — that the only remaining lever is to
559
+ split that step into smaller commands the orchestrator can schedule separately.
560
+
561
+ The same logic is exported for programmatic use:
562
+
563
+ ```js
564
+ import { recommendPhases, formatRecommendationReport } from 'scripts-orchestrator';
565
+
566
+ const payload = JSON.parse(fs.readFileSync('./logs/results.json', 'utf8'));
567
+ const rec = recommendPhases(payload, { fanout: 3 });
568
+ console.log(formatRecommendationReport(rec));
569
+ // rec.verdict.worthwhile, rec.verdict.reason, rec.recommended.bins, rec.observed, rec.budgetBytes, …
570
+ ```
571
+
572
+ A natural place to wire it is the `post_run` config hook, so each run prints a recommendation for its
573
+ own results JSON when it finishes.
574
+
575
+ This is advisory only — the budget is conservative (per-process peaks summed as if they coincide) and
576
+ the packing does not model inter-phase data dependencies, so validate any suggested layout against a
577
+ real run before adopting it.
578
+
525
579
  ## Exit Codes
526
580
 
527
581
  - `0`: All commands executed successfully
@@ -535,6 +589,7 @@ See [versions](./docs/versions.md)
535
589
  - Better UX to indicate what is happening
536
590
  - Tests to avoid regression
537
591
  - Run any shell command rather than assume the command is specified in package.json (? tentative)
592
+ - 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
593
 
539
594
 
540
595
  ## Disclaimer
package/index.js CHANGED
@@ -51,10 +51,103 @@ 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 (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
+ })
75
+ .option('recommend-out', {
76
+ type: 'string',
77
+ description: 'Write the --recommend report to this file (plain text) instead of the console.',
78
+ })
54
79
  .help()
55
80
  .alias('h', 'help')
56
81
  .parse();
57
82
 
83
+ // --render mode: turn an existing results JSON into HTML and exit (no orchestration run).
84
+ // Keeps all HTML rendering in the library so consumers never reimplement it.
85
+ if (argv.render != null) {
86
+ const { renderReportHtml } = await import('./lib/index.js');
87
+ const srcPath = path.resolve(process.cwd(), argv.render);
88
+ if (!fs.existsSync(srcPath)) {
89
+ log.error(`Error: --render source not found at ${srcPath}`);
90
+ process.exit(1);
91
+ }
92
+ let payload;
93
+ try {
94
+ payload = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
95
+ } catch (err) {
96
+ log.error(`Error: failed to parse --render JSON: ${err.message}`);
97
+ process.exit(1);
98
+ }
99
+ const html = renderReportHtml(payload);
100
+ const out = argv.htmlResults ?? null;
101
+ if (out == null || out === '-') {
102
+ console.log(html);
103
+ } else {
104
+ const outPath = path.resolve(process.cwd(), out);
105
+ const tmpPath = outPath + '.tmp';
106
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
107
+ fs.writeFileSync(tmpPath, html, 'utf8');
108
+ fs.renameSync(tmpPath, outPath);
109
+ log.info(`📄 Rendered ${path.relative(process.cwd(), srcPath)} → ${path.relative(process.cwd(), outPath)}`);
110
+ }
111
+ process.exit(0);
112
+ }
113
+
114
+ // --recommend mode: analyse an existing results JSON and print a memory-aware phase
115
+ // recommendation. Advisory only — no orchestration run. Output goes to the console, or to a
116
+ // plain-text log file when --recommend-out is given.
117
+ if (argv.recommend != null) {
118
+ const { recommendPhases, formatRecommendationReport } = await import('./lib/index.js');
119
+ const srcPath = path.resolve(process.cwd(), argv.recommend);
120
+ if (!fs.existsSync(srcPath)) {
121
+ log.error(`Error: --recommend source not found at ${srcPath}`);
122
+ process.exit(1);
123
+ }
124
+ let payload;
125
+ try {
126
+ payload = JSON.parse(fs.readFileSync(srcPath, 'utf8'));
127
+ } catch (err) {
128
+ log.error(`Error: failed to parse --recommend JSON: ${err.message}`);
129
+ process.exit(1);
130
+ }
131
+ const rec = recommendPhases(payload, {
132
+ fanout: argv.fanout,
133
+ memSafety: argv.memSafety,
134
+ budgetMb: argv.budgetMb,
135
+ });
136
+ const report = formatRecommendationReport(rec, { sourcePath: path.relative(process.cwd(), srcPath) });
137
+ if (argv.recommendOut != null && argv.recommendOut !== '-') {
138
+ // Strip ANSI colour codes so the log file stays plain text. The escape char is built at runtime
139
+ // so the regex carries no literal control character (keeps eslint's no-control-regex happy).
140
+ const ansi = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
141
+ const outPath = path.resolve(process.cwd(), argv.recommendOut);
142
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
143
+ fs.writeFileSync(outPath, report.replace(ansi, '') + '\n', 'utf8');
144
+ log.info(`📄 Phase recommendation written to ${path.relative(process.cwd(), outPath)}`);
145
+ } else {
146
+ console.log(report);
147
+ }
148
+ process.exit(0);
149
+ }
150
+
58
151
  // Extract arguments
59
152
  const args = argv._;
60
153
  const configPath = args[0] || './scripts-orchestrator.config.js';
@@ -111,9 +204,16 @@ const htmlResultsPath =
111
204
  ? argv.htmlResults
112
205
  : (commandsConfig.html_results ?? commandsConfig.html_results_path ?? null);
113
206
 
114
- // A7: post-run hook — shell command run after json_results written
207
+ // post-run hook — shell command run after json_results written
115
208
  const postRun = commandsConfig.post_run ?? null;
116
209
 
210
+ // Periodic hook — shell command run on an interval WHILE the run is in flight (e.g. to roll up
211
+ // results into an aggregate report). Library owns only the cadence; the command is project-specific.
212
+ const periodicHook = commandsConfig.periodic_hook ?? null;
213
+ const periodicIntervalMs = Number(commandsConfig.periodic_interval_ms) > 0
214
+ ? Number(commandsConfig.periodic_interval_ms)
215
+ : 45000;
216
+
117
217
  // Set the log folder for the main orchestrator logs if specified
118
218
  if (logFolder) {
119
219
  log.setLogFolder(logFolder);
@@ -131,18 +231,22 @@ const orchestrator = new Orchestrator(
131
231
  jsonResultsPath,
132
232
  htmlResultsPath,
133
233
  );
134
- // A7: wire post-run hook from config
234
+ // wire post-run hook from config
135
235
  orchestrator.postRun = postRun;
236
+ // Wire periodic hook (cadence owned by the library)
237
+ orchestrator.periodicHook = periodicHook;
238
+ orchestrator.periodicIntervalMs = periodicIntervalMs;
136
239
 
137
240
  // Enhanced signal handlers
138
241
  const handleSignal = async (signal) => {
139
242
  log.warn(`\nReceived ${signal} signal. Cleaning up...`);
243
+ orchestrator._stopPeriodicHook();
140
244
  try {
141
245
  await orchestrator.processManager.cleanup();
142
246
  } catch (error) {
143
247
  log.error(`Cleanup failed: ${error.message}`);
144
248
  }
145
- // A4: clear run-state so dashboards know the run ended
249
+ // clear run-state so dashboards know the run ended
146
250
  orchestrator._clearRunState();
147
251
  process.exit(1);
148
252
  };
package/lib/index.js CHANGED
@@ -3,6 +3,30 @@ 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
+ decideVerdict,
10
+ formatRecommendationReport,
11
+ computeBudget,
12
+ usableSteps,
13
+ observedTimeline,
14
+ packPhases,
15
+ } from './recommend-phases.js';
6
16
 
7
- export { Orchestrator, ProcessManager, HealthCheck, Logger, GitCache };
8
- export default Orchestrator;
17
+ export {
18
+ Orchestrator,
19
+ ProcessManager,
20
+ HealthCheck,
21
+ Logger,
22
+ GitCache,
23
+ renderReportHtml,
24
+ recommendPhases,
25
+ decideVerdict,
26
+ formatRecommendationReport,
27
+ computeBudget,
28
+ usableSteps,
29
+ observedTimeline,
30
+ packPhases,
31
+ };
32
+ export default Orchestrator;