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 +16 -7
- package/index.js +22 -6
- package/lib/index.js +2 -0
- package/lib/orchestrator.js +17 -17
- package/lib/recommend-phases.js +96 -7
- package/lib/recommend-phases.test.js +57 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
530
|
-
phase recommendation**: it never runs anything and
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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,
|
package/lib/orchestrator.js
CHANGED
|
@@ -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
|
-
//
|
|
42
|
+
// track per-command start times for incremental JSON
|
|
43
43
|
this.commandStartTimes = new Map(); // command -> ISO start string
|
|
44
|
-
//
|
|
44
|
+
// events file path derived from jsonResultsPath
|
|
45
45
|
this.eventsPath = this._deriveEventsPath(jsonResultsPath);
|
|
46
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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}`);
|
package/lib/recommend-phases.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file recommend-phases.js
|
|
3
|
-
* @description
|
|
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.
|
|
8
|
-
*
|
|
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
|
|
30
|
-
*
|
|
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 (
|
|
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)
|
|
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.
|
|
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",
|