scripts-orchestrator 2.15.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
@@ -451,7 +451,7 @@ Alongside `json_results`, the library writes one NDJSON line per event to
451
451
  Dashboard tools can `tail -f` this file or watch it with `fs.watch` to get real-time updates
452
452
  without parsing human-readable log lines.
453
453
 
454
- ### Run-state file (A4)
454
+ ### Run-state file
455
455
 
456
456
  When `--logFolder` is specified, the library writes `{logFolder}/.scripts-orchestrator-run.json`
457
457
  at run start and removes it on run end:
@@ -468,7 +468,7 @@ at run start and removes it on run end:
468
468
  This file is the authoritative in-progress signal for live dashboards. Its absence means the run
469
469
  has finished (or never started).
470
470
 
471
- ### Post-run hook (A7)
471
+ ### Post-run hook
472
472
 
473
473
  Add `post_run` to your config to run a shell command **after** `json_results` is written and the
474
474
  run-state file is cleared, but **before** `process.exit()`:
@@ -526,13 +526,16 @@ This is useful when you want to:
526
526
  ## Phase Recommendations (advisory)
527
527
 
528
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.
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
531
 
532
532
  ```bash
533
- # Analyse an existing results JSON and print a suggested phase layout
533
+ # Analyse an existing results JSON and print a suggested phase layout to the console
534
534
  scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json
535
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
+
536
539
  # Size the budget for a machine running N gates in parallel (each gets 1/N of RAM and cores)
537
540
  scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json --fanout 3
538
541
 
@@ -541,7 +544,7 @@ scripts-orchestrator --recommend ./logs/results.json --budget-mb 8192
541
544
  scripts-orchestrator --recommend ./logs/results.json --mem-safety 0.7
542
545
  ```
543
546
 
544
- It reports two things:
547
+ It reports three things:
545
548
 
546
549
  1. **Observed timeline** — each phase's wall-clock (the longest step in it) and the concurrent peak
547
550
  memory (Σ of member peaks), flagging any phase whose concurrent peak exceeds the host budget.
@@ -551,6 +554,9 @@ It reports two things:
551
554
  `coreShare = (cores − 2) ÷ fanout`. Long steps seed phases; short steps fill the gaps beneath them,
552
555
  so the estimated makespan stays near the theoretical floor (the single longest step) without
553
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.
554
560
 
555
561
  The same logic is exported for programmatic use:
556
562
 
@@ -560,9 +566,12 @@ import { recommendPhases, formatRecommendationReport } from 'scripts-orchestrato
560
566
  const payload = JSON.parse(fs.readFileSync('./logs/results.json', 'utf8'));
561
567
  const rec = recommendPhases(payload, { fanout: 3 });
562
568
  console.log(formatRecommendationReport(rec));
563
- // rec.recommended.bins, rec.observed, rec.observedMakespanMs, rec.budgetBytes, …
569
+ // rec.verdict.worthwhile, rec.verdict.reason, rec.recommended.bins, rec.observed, rec.budgetBytes, …
564
570
  ```
565
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
+
566
575
  This is advisory only — the budget is conservative (per-process peaks summed as if they coincide) and
567
576
  the packing does not model inter-phase data dependencies, so validate any suggested layout against a
568
577
  real run before adopting it.
package/index.js CHANGED
@@ -58,7 +58,7 @@ const argv = yargs(hideBin(process.argv))
58
58
  .option('recommend', {
59
59
  type: 'string',
60
60
  description:
61
- 'Analyse an existing results JSON and print a memory-aware phase recommendation (R12, no run).',
61
+ 'Analyse an existing results JSON and print a memory-aware phase recommendation (no run).',
62
62
  })
63
63
  .option('fanout', {
64
64
  type: 'number',
@@ -72,6 +72,10 @@ const argv = yargs(hideBin(process.argv))
72
72
  type: 'number',
73
73
  description: 'Override the --recommend memory budget with a fixed value in MB.',
74
74
  })
75
+ .option('recommend-out', {
76
+ type: 'string',
77
+ description: 'Write the --recommend report to this file (plain text) instead of the console.',
78
+ })
75
79
  .help()
76
80
  .alias('h', 'help')
77
81
  .parse();
@@ -108,7 +112,8 @@ if (argv.render != null) {
108
112
  }
109
113
 
110
114
  // --recommend mode: analyse an existing results JSON and print a memory-aware phase
111
- // recommendation (R12). Advisory only — no orchestration run, no files written.
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.
112
117
  if (argv.recommend != null) {
113
118
  const { recommendPhases, formatRecommendationReport } = await import('./lib/index.js');
114
119
  const srcPath = path.resolve(process.cwd(), argv.recommend);
@@ -128,7 +133,18 @@ if (argv.recommend != null) {
128
133
  memSafety: argv.memSafety,
129
134
  budgetMb: argv.budgetMb,
130
135
  });
131
- console.log(formatRecommendationReport(rec, { sourcePath: path.relative(process.cwd(), srcPath) }));
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
+ }
132
148
  process.exit(0);
133
149
  }
134
150
 
@@ -188,7 +204,7 @@ const htmlResultsPath =
188
204
  ? argv.htmlResults
189
205
  : (commandsConfig.html_results ?? commandsConfig.html_results_path ?? null);
190
206
 
191
- // A7: post-run hook — shell command run after json_results written
207
+ // post-run hook — shell command run after json_results written
192
208
  const postRun = commandsConfig.post_run ?? null;
193
209
 
194
210
  // Periodic hook — shell command run on an interval WHILE the run is in flight (e.g. to roll up
@@ -215,7 +231,7 @@ const orchestrator = new Orchestrator(
215
231
  jsonResultsPath,
216
232
  htmlResultsPath,
217
233
  );
218
- // A7: wire post-run hook from config
234
+ // wire post-run hook from config
219
235
  orchestrator.postRun = postRun;
220
236
  // Wire periodic hook (cadence owned by the library)
221
237
  orchestrator.periodicHook = periodicHook;
@@ -230,7 +246,7 @@ const handleSignal = async (signal) => {
230
246
  } catch (error) {
231
247
  log.error(`Cleanup failed: ${error.message}`);
232
248
  }
233
- // A4: clear run-state so dashboards know the run ended
249
+ // clear run-state so dashboards know the run ended
234
250
  orchestrator._clearRunState();
235
251
  process.exit(1);
236
252
  };
package/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import { GitCache } from './git-cache.js';
6
6
  import { renderReportHtml } from './report-html.js';
7
7
  import {
8
8
  recommendPhases,
9
+ decideVerdict,
9
10
  formatRecommendationReport,
10
11
  computeBudget,
11
12
  usableSteps,
@@ -21,6 +22,7 @@ export {
21
22
  GitCache,
22
23
  renderReportHtml,
23
24
  recommendPhases,
25
+ decideVerdict,
24
26
  formatRecommendationReport,
25
27
  computeBudget,
26
28
  usableSteps,
@@ -39,15 +39,15 @@ export class Orchestrator {
39
39
  this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
40
40
  this.phaseResults = []; // { name, success, durationMs } per phase run
41
41
  this.gitCache = new GitCache(logFolder);
42
- // A1: track per-command start times for incremental JSON
42
+ // track per-command start times for incremental JSON
43
43
  this.commandStartTimes = new Map(); // command -> ISO start string
44
- // A2: events file path derived from jsonResultsPath
44
+ // events file path derived from jsonResultsPath
45
45
  this.eventsPath = this._deriveEventsPath(jsonResultsPath);
46
- // A4: library-owned run-state file
46
+ // library-owned run-state file
47
47
  this.runStatePath = logFolder
48
48
  ? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
49
49
  : null;
50
- // A7: post-run hook command (shell string)
50
+ // post-run hook command (shell string)
51
51
  this.postRun = null; // set from config in index.js
52
52
  // Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
53
53
  // The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
@@ -84,7 +84,7 @@ export class Orchestrator {
84
84
  return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
85
85
  }
86
86
 
87
- // A4: write current run state atomically
87
+ // write current run state atomically
88
88
  _writeRunState(extra = {}) {
89
89
  if (!this.runStatePath) return;
90
90
  const state = {
@@ -100,13 +100,13 @@ export class Orchestrator {
100
100
  } catch { /* non-fatal */ }
101
101
  }
102
102
 
103
- // A4: remove run-state file on run end
103
+ // remove run-state file on run end
104
104
  _clearRunState() {
105
105
  if (!this.runStatePath) return;
106
106
  try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
107
107
  }
108
108
 
109
- // A2: append a structured NDJSON event
109
+ // append a structured NDJSON event
110
110
  _appendEvent(type, data = {}) {
111
111
  if (!this.eventsPath) return;
112
112
  const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
@@ -117,7 +117,7 @@ export class Orchestrator {
117
117
  }
118
118
  }
119
119
 
120
- // A1: atomically write current run state (completed + in-flight commands) to json_results
120
+ // atomically write current run state (completed + in-flight commands) to json_results
121
121
  _writePartialResults() {
122
122
  if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
123
123
  const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
@@ -202,7 +202,7 @@ export class Orchestrator {
202
202
  }
203
203
  }
204
204
 
205
- // A4: keep run-state file in sync with current active commands and phase
205
+ // keep run-state file in sync with current active commands and phase
206
206
  const inFlight = commands.filter(c => c.success === null).map(c => c.command);
207
207
  const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
208
208
  this._writeRunState({
@@ -382,7 +382,7 @@ export class Orchestrator {
382
382
  }
383
383
  }
384
384
 
385
- // A1/A2: record start and emit event
385
+ // record start and emit event
386
386
  this.commandStartTimes.set(command, new Date().toISOString());
387
387
  this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
388
388
  this._writePartialResults();
@@ -461,7 +461,7 @@ export class Orchestrator {
461
461
 
462
462
  const totalDurationMs = Date.now() - startTime;
463
463
  setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
464
- // A1/A2: emit completion event and write incremental results
464
+ // emit completion event and write incremental results
465
465
  this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
466
466
  this._writePartialResults();
467
467
  visited.delete(command);
@@ -601,7 +601,7 @@ export class Orchestrator {
601
601
  async run() {
602
602
  this.startTime = Date.now();
603
603
  this.runStartedAt = this.startTime;
604
- // A4: write initial run-state at start
604
+ // write initial run-state at start
605
605
  this._writeRunState({ phase: null, activeCommand: null });
606
606
  try {
607
607
  // Check if we should skip execution based on git state (unless forced)
@@ -808,7 +808,7 @@ export class Orchestrator {
808
808
  );
809
809
  }
810
810
 
811
- // A2: emit run_end event; A1: final JSON written by writeJsonResults (replaces partial)
811
+ // emit run_end event; final JSON written by writeJsonResults (replaces partial)
812
812
  const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
813
813
  this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
814
814
 
@@ -817,14 +817,14 @@ export class Orchestrator {
817
817
  this.writeJsonResults(hasFailures);
818
818
  }
819
819
 
820
- // A4: clear run-state file — run is done
820
+ // clear run-state file — run is done
821
821
  this._clearRunState();
822
822
 
823
823
  // Final roll-up (synchronous) AFTER run-state is cleared, so the aggregate reflects the
824
824
  // finished run (an in-flight marker would otherwise make the final report read as running).
825
825
  this._firePeriodicHookFinal();
826
826
 
827
- // A7: run post_run hook after results are written
827
+ // run post_run hook after results are written
828
828
  this._runPostRunHook(hasFailures);
829
829
 
830
830
  // Update git cache on successful execution
@@ -846,7 +846,7 @@ export class Orchestrator {
846
846
  // Stop periodic ticks on error.
847
847
  this._stopPeriodicHook();
848
848
 
849
- // A4: clear run-state on error too
849
+ // clear run-state on error too
850
850
  this._clearRunState();
851
851
 
852
852
  // Cleanup on error
@@ -928,7 +928,7 @@ export class Orchestrator {
928
928
  }
929
929
  }
930
930
 
931
- // A7: run user-configured post_run shell command
931
+ // run user-configured post_run shell command
932
932
  _runPostRunHook(hasFailures) {
933
933
  if (!this.postRun) return;
934
934
  this.logger.info(`[post_run] ${this.postRun}`);
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * @file recommend-phases.js
3
- * @description R12 — memory-aware phase recommender (advisory).
3
+ * @description Memory-aware phase recommender (advisory).
4
4
  *
5
5
  * Reads a results JSON (the same payload the orchestrator writes via `metrics: ['time','memory']`)
6
6
  * and proposes a phase layout that keeps each phase's concurrent peak memory under a per-host
7
- * budget while letting long-running steps overlap. This is the "advisory" first step of R12:
8
- * it only reports — it does not change how a run is scheduled.
7
+ * budget while letting long-running steps overlap. It only reports it does not change how a
8
+ * run is scheduled.
9
9
  *
10
10
  * Algorithm: First-Fit-Decreasing bin-packing by step duration. Steps are sorted longest-first
11
11
  * and each is placed into the earliest phase where adding it keeps Σ(concurrent peak memory) ≤ budget
@@ -26,8 +26,8 @@ const GB = 1024 * 1024 * 1024;
26
26
  * budget = totalmem × memSafety ÷ fanout (overridable wholesale via budgetMb)
27
27
  * coreShare = (cores − 2) ÷ fanout (≥ 1)
28
28
  *
29
- * `fanout` models the workspace-level parallelism (R1's `--parallel=N`): when N workspaces gate
30
- * concurrently they share the host, so each gets 1/N of RAM and cores.
29
+ * `fanout` models the workspace-level parallelism: when N workspaces gate concurrently they share
30
+ * the host, so each gets 1/N of RAM and cores.
31
31
  */
32
32
  export function computeBudget(opts = {}) {
33
33
  const totalMemBytes = opts.totalMemBytes != null ? Number(opts.totalMemBytes) : os.totalmem();
@@ -143,6 +143,18 @@ export function recommendPhases(payload, opts = {}) {
143
143
  const observedMakespanMs = observed.reduce((sum, p) => sum + p.wallclockMs, 0);
144
144
  const recommendedMakespanMs = bins.reduce((sum, b) => sum + b.wallclockMs, 0);
145
145
  const optimalMakespanMs = steps.length ? Math.max(...steps.map((s) => s.durationMs)) : 0;
146
+ const longestStep = steps.length
147
+ ? steps.reduce((a, b) => (b.durationMs > a.durationMs ? b : a))
148
+ : null;
149
+
150
+ const verdict = decideVerdict({
151
+ steps,
152
+ observedMakespanMs,
153
+ recommendedMakespanMs,
154
+ optimalMakespanMs,
155
+ longestStep,
156
+ binCount: bins.length,
157
+ });
146
158
 
147
159
  return {
148
160
  ...budget,
@@ -151,10 +163,70 @@ export function recommendPhases(payload, opts = {}) {
151
163
  observedMakespanMs,
152
164
  recommended: { bins, makespanMs: recommendedMakespanMs },
153
165
  optimalMakespanMs,
166
+ verdict,
154
167
  warnings,
155
168
  };
156
169
  }
157
170
 
171
+ /**
172
+ * Reduce the numbers to a single yes/no answer: "is re-grouping these phases worth it?".
173
+ *
174
+ * Re-grouping helps only when packing meaningfully beats the observed makespan. It cannot beat the
175
+ * single longest step (the theoretical floor), so when one step dominates the makespan the honest
176
+ * answer is "no — splitting that step is the only lever left", not "re-group".
177
+ *
178
+ * Returns `{ worthwhile, savedMs, reason }`. Thresholds are deliberately conservative so the advice
179
+ * stays quiet unless there's a real, non-trivial win.
180
+ */
181
+ export function decideVerdict({
182
+ steps,
183
+ observedMakespanMs,
184
+ recommendedMakespanMs,
185
+ optimalMakespanMs,
186
+ longestStep,
187
+ binCount,
188
+ }) {
189
+ if (!steps.length) {
190
+ return { worthwhile: false, savedMs: 0, reason: 'No timed steps to analyse.' };
191
+ }
192
+
193
+ const savedMs = observedMakespanMs - recommendedMakespanMs;
194
+ const savedFraction = observedMakespanMs > 0 ? savedMs / observedMakespanMs : 0;
195
+ const dominantFraction = observedMakespanMs > 0 ? optimalMakespanMs / observedMakespanMs : 0;
196
+
197
+ // A real win: packing trims at least 5% AND at least 5s off the observed makespan.
198
+ const significant = savedMs >= 5000 && savedFraction >= 0.05;
199
+ if (significant) {
200
+ return {
201
+ worthwhile: true,
202
+ savedMs,
203
+ reason:
204
+ `Re-grouping into ${binCount} phase(s) could trim ~${fmtDuration(savedMs)} ` +
205
+ `(${Math.round(savedFraction * 100)}%) off the makespan.`,
206
+ };
207
+ }
208
+
209
+ // One step is ≥95% of the makespan: nothing else matters until it's broken up.
210
+ if (dominantFraction >= 0.95 && longestStep) {
211
+ return {
212
+ worthwhile: false,
213
+ savedMs,
214
+ reason:
215
+ `One step ("${longestStep.command}", ${fmtDuration(optimalMakespanMs)}) is ` +
216
+ `~${Math.round(dominantFraction * 100)}% of the makespan, so re-grouping the rest cannot help. ` +
217
+ 'To go faster, split that step into smaller commands the orchestrator can schedule separately.',
218
+ };
219
+ }
220
+
221
+ return {
222
+ worthwhile: false,
223
+ savedMs,
224
+ reason:
225
+ `The current layout is already within ~${fmtDuration(Math.max(0, savedMs))} of the packed ` +
226
+ 'optimum — re-grouping is not worth it.',
227
+ };
228
+ }
229
+
158
230
  // ---- formatting helpers ---------------------------------------------------
159
231
 
160
232
  export function fmtDuration(ms) {
@@ -185,7 +257,7 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
185
257
  const c = chalk;
186
258
  const L = [];
187
259
 
188
- L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (R12, advisory)'));
260
+ L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (advisory)'));
189
261
  if (sourcePath) L.push(c.dim(` Source: ${sourcePath}`));
190
262
  L.push(
191
263
  ` Budget: ${c.yellow(fmtMemKb(rec.budgetBytes / KB))} ` +
@@ -194,7 +266,11 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
194
266
  );
195
267
 
196
268
  for (const w of rec.warnings) L.push(c.yellow(` ⚠ ${w}`));
197
- if (rec.steps.length === 0) return L.join('\n');
269
+ if (rec.steps.length === 0) {
270
+ L.push('');
271
+ L.push(`${c.bold('Verdict:')} ${verdictLine(rec.verdict)}`);
272
+ return L.join('\n');
273
+ }
198
274
 
199
275
  // Observed timeline
200
276
  L.push('');
@@ -239,6 +315,11 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
239
315
  : c.dim('(no change)');
240
316
  L.push(` recommended (packed): ${fmtDuration(rec.recommended.makespanMs)} ${delta}`);
241
317
  L.push(` theoretical floor (∞ RAM): ${fmtDuration(rec.optimalMakespanMs)}`);
318
+
319
+ // Verdict — the one-line yes/no the reader actually wants.
320
+ L.push('');
321
+ L.push(`${c.bold('Verdict:')} ${verdictLine(rec.verdict)}`);
322
+
242
323
  L.push('');
243
324
  L.push(
244
325
  c.dim(
@@ -249,3 +330,11 @@ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
249
330
 
250
331
  return L.join('\n');
251
332
  }
333
+
334
+ /** Render the verdict as a colored ✅/❌ one-liner. */
335
+ function verdictLine(verdict) {
336
+ if (!verdict) return '';
337
+ return verdict.worthwhile
338
+ ? `${chalk.green('✅ Yes')} — ${verdict.reason}`
339
+ : `${chalk.yellow('❌ No')} — ${verdict.reason}`;
340
+ }
@@ -4,6 +4,7 @@ import {
4
4
  observedTimeline,
5
5
  packPhases,
6
6
  recommendPhases,
7
+ decideVerdict,
7
8
  formatRecommendationReport,
8
9
  } from './recommend-phases.js';
9
10
 
@@ -104,4 +105,60 @@ describe('recommendPhases', () => {
104
105
  expect(rec.warnings.join(' ')).toMatch(/nothing to recommend/i);
105
106
  expect(() => formatRecommendationReport(rec)).not.toThrow();
106
107
  });
108
+
109
+ test('attaches a verdict object to the recommendation', () => {
110
+ const rec = recommendPhases(payload, { totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1 });
111
+ expect(rec.verdict).toBeDefined();
112
+ expect(typeof rec.verdict.worthwhile).toBe('boolean');
113
+ expect(typeof rec.verdict.reason).toBe('string');
114
+ });
115
+ });
116
+
117
+ describe('decideVerdict', () => {
118
+ const longest = { command: 'build', durationMs: 600000 };
119
+
120
+ test('says yes when packing trims a meaningful chunk off the makespan', () => {
121
+ const v = decideVerdict({
122
+ steps: [{}, {}, {}],
123
+ observedMakespanMs: 100000,
124
+ recommendedMakespanMs: 70000, // 30s / 30% saved
125
+ optimalMakespanMs: 60000,
126
+ longestStep: longest,
127
+ binCount: 2,
128
+ });
129
+ expect(v.worthwhile).toBe(true);
130
+ expect(v.savedMs).toBe(30000);
131
+ });
132
+
133
+ test('says no — and points at the monolith — when one step dominates', () => {
134
+ const v = decideVerdict({
135
+ steps: [{}, {}],
136
+ observedMakespanMs: 610000,
137
+ recommendedMakespanMs: 610000,
138
+ optimalMakespanMs: 600000, // ~98% of makespan
139
+ longestStep: longest,
140
+ binCount: 1,
141
+ });
142
+ expect(v.worthwhile).toBe(false);
143
+ expect(v.reason).toMatch(/split that step/i);
144
+ expect(v.reason).toContain('build');
145
+ });
146
+
147
+ test('says no when the saving is below the threshold', () => {
148
+ const v = decideVerdict({
149
+ steps: [{}, {}, {}, {}],
150
+ observedMakespanMs: 100000,
151
+ recommendedMakespanMs: 98000, // only 2% / 2s
152
+ optimalMakespanMs: 40000,
153
+ longestStep: longest,
154
+ binCount: 2,
155
+ });
156
+ expect(v.worthwhile).toBe(false);
157
+ expect(v.reason).toMatch(/isn't worth it|within/i);
158
+ });
159
+
160
+ test('handles the no-steps case', () => {
161
+ const v = decideVerdict({ steps: [], observedMakespanMs: 0, recommendedMakespanMs: 0, optimalMakespanMs: 0, longestStep: null, binCount: 0 });
162
+ expect(v.worthwhile).toBe(false);
163
+ });
107
164
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.15.0",
3
+ "version": "2.15.1",
4
4
  "description": "A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",