scripts-orchestrator 2.15.0 → 3.0.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 +57 -8
- package/index.js +22 -6
- package/lib/index.js +2 -0
- package/lib/orchestrator.js +51 -18
- package/lib/orchestrator.prefix.test.js +60 -0
- package/lib/process-manager.js +21 -9
- package/lib/process-manager.test.js +58 -0
- package/lib/recommend-phases.js +96 -7
- package/lib/recommend-phases.test.js +57 -0
- package/package.json +1 -1
- package/scripts-orchestrator.config.js +4 -0
package/README.md
CHANGED
|
@@ -48,12 +48,14 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
|
|
|
48
48
|
|
|
49
49
|
```javascript
|
|
50
50
|
{
|
|
51
|
-
command: 'command_name', // The
|
|
51
|
+
command: 'command_name', // The command to run (see "Command prefix" below)
|
|
52
52
|
description: 'Description', // Optional description
|
|
53
53
|
status: 'enabled', // 'enabled' or 'disabled'
|
|
54
54
|
attempts: 1, // Number of retry attempts
|
|
55
55
|
dependencies: [], // Array of dependent commands
|
|
56
56
|
background: false, // Whether to run in background
|
|
57
|
+
shell: false, // true => run `command` verbatim as a shell command (no prefix)
|
|
58
|
+
prefix: 'npm run', // Optional per-command prefix override ('' to disable)
|
|
57
59
|
env: { // Optional environment variables
|
|
58
60
|
PORT: 3000,
|
|
59
61
|
NODE_ENV: 'production'
|
|
@@ -70,6 +72,44 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
|
|
|
70
72
|
}
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
### Command prefix (`npm run` is optional)
|
|
76
|
+
|
|
77
|
+
By default every `command` is run as an npm script — the orchestrator prepends `npm run`,
|
|
78
|
+
so `command: 'build'` executes `npm run build`. This prefix is configurable:
|
|
79
|
+
|
|
80
|
+
- **Global default** — set `command_prefix` at the top level of the config. Use it to point at a
|
|
81
|
+
different runner (`'pnpm run'`, `'yarn'`) or to disable prefixing entirely so commands run as
|
|
82
|
+
regular shell commands:
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
export default {
|
|
86
|
+
command_prefix: '', // '' / false / null => run commands verbatim (plain shell)
|
|
87
|
+
phases: [
|
|
88
|
+
{ name: 'checks', parallel: [
|
|
89
|
+
{ command: 'eslint . --max-warnings 0' }, // runs as-is, supports args/pipes/&&
|
|
90
|
+
{ command: './scripts/verify.sh' },
|
|
91
|
+
]},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- **Per command** — `shell: true` forces a single command to run verbatim as a shell command
|
|
97
|
+
(ignoring any global prefix), and `prefix: '...'` overrides the prefix for just that command:
|
|
98
|
+
|
|
99
|
+
```javascript
|
|
100
|
+
{
|
|
101
|
+
phases: [{ name: 'mixed', parallel: [
|
|
102
|
+
{ command: 'build' }, // -> npm run build (global default)
|
|
103
|
+
{ command: 'docker compose up -d', shell: true }, // raw shell command
|
|
104
|
+
{ command: 'release', prefix: 'yarn' }, // -> yarn release
|
|
105
|
+
]}],
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Precedence per command: `shell: true` (raw) → per-command `prefix` → global `command_prefix`
|
|
110
|
+
→ the built-in `npm run` default. Existing configs are unaffected — omitting all of these keeps
|
|
111
|
+
the original `npm run` behaviour.
|
|
112
|
+
|
|
73
113
|
### Phase Configuration
|
|
74
114
|
|
|
75
115
|
When using the phases format, each phase can have the following properties:
|
|
@@ -451,7 +491,7 @@ Alongside `json_results`, the library writes one NDJSON line per event to
|
|
|
451
491
|
Dashboard tools can `tail -f` this file or watch it with `fs.watch` to get real-time updates
|
|
452
492
|
without parsing human-readable log lines.
|
|
453
493
|
|
|
454
|
-
### Run-state file
|
|
494
|
+
### Run-state file
|
|
455
495
|
|
|
456
496
|
When `--logFolder` is specified, the library writes `{logFolder}/.scripts-orchestrator-run.json`
|
|
457
497
|
at run start and removes it on run end:
|
|
@@ -468,7 +508,7 @@ at run start and removes it on run end:
|
|
|
468
508
|
This file is the authoritative in-progress signal for live dashboards. Its absence means the run
|
|
469
509
|
has finished (or never started).
|
|
470
510
|
|
|
471
|
-
### Post-run hook
|
|
511
|
+
### Post-run hook
|
|
472
512
|
|
|
473
513
|
Add `post_run` to your config to run a shell command **after** `json_results` is written and the
|
|
474
514
|
run-state file is cleared, but **before** `process.exit()`:
|
|
@@ -526,13 +566,16 @@ This is useful when you want to:
|
|
|
526
566
|
## Phase Recommendations (advisory)
|
|
527
567
|
|
|
528
568
|
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
|
|
569
|
+
`durationMs` and peak `memoryKb`. The `--recommend` mode reads that JSON and reports a **memory-aware
|
|
570
|
+
phase recommendation**: it never runs anything and changes no run state.
|
|
531
571
|
|
|
532
572
|
```bash
|
|
533
|
-
# Analyse an existing results JSON and print a suggested phase layout
|
|
573
|
+
# Analyse an existing results JSON and print a suggested phase layout to the console
|
|
534
574
|
scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json
|
|
535
575
|
|
|
576
|
+
# Write the report to a plain-text log file instead of the console (only a pointer line is printed)
|
|
577
|
+
scripts-orchestrator --recommend ./logs/results.json --recommend-out ./logs/recommendation.log
|
|
578
|
+
|
|
536
579
|
# Size the budget for a machine running N gates in parallel (each gets 1/N of RAM and cores)
|
|
537
580
|
scripts-orchestrator --recommend ./logs/scripts-orchestrator-results.json --fanout 3
|
|
538
581
|
|
|
@@ -541,7 +584,7 @@ scripts-orchestrator --recommend ./logs/results.json --budget-mb 8192
|
|
|
541
584
|
scripts-orchestrator --recommend ./logs/results.json --mem-safety 0.7
|
|
542
585
|
```
|
|
543
586
|
|
|
544
|
-
It reports
|
|
587
|
+
It reports three things:
|
|
545
588
|
|
|
546
589
|
1. **Observed timeline** — each phase's wall-clock (the longest step in it) and the concurrent peak
|
|
547
590
|
memory (Σ of member peaks), flagging any phase whose concurrent peak exceeds the host budget.
|
|
@@ -551,6 +594,9 @@ It reports two things:
|
|
|
551
594
|
`coreShare = (cores − 2) ÷ fanout`. Long steps seed phases; short steps fill the gaps beneath them,
|
|
552
595
|
so the estimated makespan stays near the theoretical floor (the single longest step) without
|
|
553
596
|
oversubscribing RAM.
|
|
597
|
+
3. **Verdict** — a single yes/no line: whether re-grouping is worth it (it must trim ≥5% and ≥5s off
|
|
598
|
+
the makespan), or — when one step is ≥95% of the makespan — that the only remaining lever is to
|
|
599
|
+
split that step into smaller commands the orchestrator can schedule separately.
|
|
554
600
|
|
|
555
601
|
The same logic is exported for programmatic use:
|
|
556
602
|
|
|
@@ -560,9 +606,12 @@ import { recommendPhases, formatRecommendationReport } from 'scripts-orchestrato
|
|
|
560
606
|
const payload = JSON.parse(fs.readFileSync('./logs/results.json', 'utf8'));
|
|
561
607
|
const rec = recommendPhases(payload, { fanout: 3 });
|
|
562
608
|
console.log(formatRecommendationReport(rec));
|
|
563
|
-
// rec.
|
|
609
|
+
// rec.verdict.worthwhile, rec.verdict.reason, rec.recommended.bins, rec.observed, rec.budgetBytes, …
|
|
564
610
|
```
|
|
565
611
|
|
|
612
|
+
A natural place to wire it is the `post_run` config hook, so each run prints a recommendation for its
|
|
613
|
+
own results JSON when it finishes.
|
|
614
|
+
|
|
566
615
|
This is advisory only — the budget is conservative (per-process peaks summed as if they coincide) and
|
|
567
616
|
the packing does not model inter-phase data dependencies, so validate any suggested layout against a
|
|
568
617
|
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
|
@@ -27,6 +27,12 @@ export class Orchestrator {
|
|
|
27
27
|
this.sequential = sequential;
|
|
28
28
|
this.force = force;
|
|
29
29
|
this.metrics = Array.isArray(metrics) ? metrics : [];
|
|
30
|
+
// Global command prefix. Defaults to 'npm run' so existing configs keep working.
|
|
31
|
+
// Set `command_prefix` to '' / false / null in the config to run commands verbatim
|
|
32
|
+
// as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
|
|
33
|
+
this.commandPrefix = Object.prototype.hasOwnProperty.call(config ?? {}, 'command_prefix')
|
|
34
|
+
? this._normalizePrefix(config.command_prefix)
|
|
35
|
+
: 'npm run';
|
|
30
36
|
this.jsonResultsPath = jsonResultsPath ?? null;
|
|
31
37
|
this.htmlResultsPath = htmlResultsPath ?? null;
|
|
32
38
|
this.processManager = processManager;
|
|
@@ -39,15 +45,15 @@ export class Orchestrator {
|
|
|
39
45
|
this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
|
|
40
46
|
this.phaseResults = []; // { name, success, durationMs } per phase run
|
|
41
47
|
this.gitCache = new GitCache(logFolder);
|
|
42
|
-
//
|
|
48
|
+
// track per-command start times for incremental JSON
|
|
43
49
|
this.commandStartTimes = new Map(); // command -> ISO start string
|
|
44
|
-
//
|
|
50
|
+
// events file path derived from jsonResultsPath
|
|
45
51
|
this.eventsPath = this._deriveEventsPath(jsonResultsPath);
|
|
46
|
-
//
|
|
52
|
+
// library-owned run-state file
|
|
47
53
|
this.runStatePath = logFolder
|
|
48
54
|
? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
|
|
49
55
|
: null;
|
|
50
|
-
//
|
|
56
|
+
// post-run hook command (shell string)
|
|
51
57
|
this.postRun = null; // set from config in index.js
|
|
52
58
|
// Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
|
|
53
59
|
// The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
|
|
@@ -84,7 +90,7 @@ export class Orchestrator {
|
|
|
84
90
|
return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
//
|
|
93
|
+
// write current run state atomically
|
|
88
94
|
_writeRunState(extra = {}) {
|
|
89
95
|
if (!this.runStatePath) return;
|
|
90
96
|
const state = {
|
|
@@ -100,13 +106,13 @@ export class Orchestrator {
|
|
|
100
106
|
} catch { /* non-fatal */ }
|
|
101
107
|
}
|
|
102
108
|
|
|
103
|
-
//
|
|
109
|
+
// remove run-state file on run end
|
|
104
110
|
_clearRunState() {
|
|
105
111
|
if (!this.runStatePath) return;
|
|
106
112
|
try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
|
|
107
113
|
}
|
|
108
114
|
|
|
109
|
-
//
|
|
115
|
+
// append a structured NDJSON event
|
|
110
116
|
_appendEvent(type, data = {}) {
|
|
111
117
|
if (!this.eventsPath) return;
|
|
112
118
|
const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
|
|
@@ -117,7 +123,7 @@ export class Orchestrator {
|
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
|
|
120
|
-
//
|
|
126
|
+
// atomically write current run state (completed + in-flight commands) to json_results
|
|
121
127
|
_writePartialResults() {
|
|
122
128
|
if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
|
|
123
129
|
const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
|
|
@@ -202,7 +208,7 @@ export class Orchestrator {
|
|
|
202
208
|
}
|
|
203
209
|
}
|
|
204
210
|
|
|
205
|
-
//
|
|
211
|
+
// keep run-state file in sync with current active commands and phase
|
|
206
212
|
const inFlight = commands.filter(c => c.success === null).map(c => c.command);
|
|
207
213
|
const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
|
|
208
214
|
this._writeRunState({
|
|
@@ -263,6 +269,29 @@ export class Orchestrator {
|
|
|
263
269
|
}
|
|
264
270
|
}
|
|
265
271
|
|
|
272
|
+
// Normalize a prefix value into a clean string. false/null/'' all mean "no prefix"
|
|
273
|
+
// (run the command verbatim); any string is trimmed.
|
|
274
|
+
_normalizePrefix(value) {
|
|
275
|
+
if (value === false || value === null || value === undefined) return '';
|
|
276
|
+
return String(value).trim();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Resolve the effective prefix for a single command.
|
|
280
|
+
// Precedence: per-command `shell: true` (raw) > per-command `prefix` > global commandPrefix.
|
|
281
|
+
_resolvePrefix(commandConfig = {}) {
|
|
282
|
+
if (commandConfig.shell === true) return '';
|
|
283
|
+
if (Object.prototype.hasOwnProperty.call(commandConfig, 'prefix')) {
|
|
284
|
+
return this._normalizePrefix(commandConfig.prefix);
|
|
285
|
+
}
|
|
286
|
+
return this.commandPrefix;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Format a command for display, honoring its resolved prefix.
|
|
290
|
+
_displayCommand(command, commandConfig = {}) {
|
|
291
|
+
const prefix = this._resolvePrefix(commandConfig);
|
|
292
|
+
return prefix ? `${prefix} ${command}` : command;
|
|
293
|
+
}
|
|
294
|
+
|
|
266
295
|
async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
|
|
267
296
|
const {
|
|
268
297
|
command,
|
|
@@ -281,6 +310,8 @@ export class Orchestrator {
|
|
|
281
310
|
} = commandConfig;
|
|
282
311
|
|
|
283
312
|
const startTime = Date.now();
|
|
313
|
+
// Effective invocation prefix for this command ('' => run verbatim as a shell command).
|
|
314
|
+
const prefix = this._resolvePrefix(commandConfig);
|
|
284
315
|
|
|
285
316
|
// Record the destination log file for this command (honors per-command override).
|
|
286
317
|
// Done early so even disabled/skipped commands report where output would land.
|
|
@@ -303,7 +334,7 @@ export class Orchestrator {
|
|
|
303
334
|
|
|
304
335
|
// Skip execution if the command is disabled
|
|
305
336
|
if (status === 'disabled') {
|
|
306
|
-
this.logger.warn(`Skipping:
|
|
337
|
+
this.logger.warn(`Skipping: ${this._displayCommand(command, commandConfig)} (status: disabled)`);
|
|
307
338
|
this.skippedCommands.push(command);
|
|
308
339
|
this.skipReasons.set(command, 'disabled');
|
|
309
340
|
setTiming(Date.now() - startTime);
|
|
@@ -336,6 +367,7 @@ export class Orchestrator {
|
|
|
336
367
|
startedByScript: false,
|
|
337
368
|
process_tracking,
|
|
338
369
|
kill_command,
|
|
370
|
+
prefix,
|
|
339
371
|
});
|
|
340
372
|
setTiming(Date.now() - startTime);
|
|
341
373
|
visited.delete(command);
|
|
@@ -382,7 +414,7 @@ export class Orchestrator {
|
|
|
382
414
|
}
|
|
383
415
|
}
|
|
384
416
|
|
|
385
|
-
//
|
|
417
|
+
// record start and emit event
|
|
386
418
|
this.commandStartTimes.set(command, new Date().toISOString());
|
|
387
419
|
this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
|
|
388
420
|
this._writePartialResults();
|
|
@@ -411,6 +443,7 @@ export class Orchestrator {
|
|
|
411
443
|
env,
|
|
412
444
|
reportTime: this.metrics.includes('time'),
|
|
413
445
|
reportMemory: this.metrics.includes('memory'),
|
|
446
|
+
prefix,
|
|
414
447
|
});
|
|
415
448
|
lastRunResult = runResult;
|
|
416
449
|
const { success, output } = runResult;
|
|
@@ -461,7 +494,7 @@ export class Orchestrator {
|
|
|
461
494
|
|
|
462
495
|
const totalDurationMs = Date.now() - startTime;
|
|
463
496
|
setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
|
|
464
|
-
//
|
|
497
|
+
// emit completion event and write incremental results
|
|
465
498
|
this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
|
|
466
499
|
this._writePartialResults();
|
|
467
500
|
visited.delete(command);
|
|
@@ -601,7 +634,7 @@ export class Orchestrator {
|
|
|
601
634
|
async run() {
|
|
602
635
|
this.startTime = Date.now();
|
|
603
636
|
this.runStartedAt = this.startTime;
|
|
604
|
-
//
|
|
637
|
+
// write initial run-state at start
|
|
605
638
|
this._writeRunState({ phase: null, activeCommand: null });
|
|
606
639
|
try {
|
|
607
640
|
// Check if we should skip execution based on git state (unless forced)
|
|
@@ -808,7 +841,7 @@ export class Orchestrator {
|
|
|
808
841
|
);
|
|
809
842
|
}
|
|
810
843
|
|
|
811
|
-
//
|
|
844
|
+
// emit run_end event; final JSON written by writeJsonResults (replaces partial)
|
|
812
845
|
const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
|
|
813
846
|
this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
|
|
814
847
|
|
|
@@ -817,14 +850,14 @@ export class Orchestrator {
|
|
|
817
850
|
this.writeJsonResults(hasFailures);
|
|
818
851
|
}
|
|
819
852
|
|
|
820
|
-
//
|
|
853
|
+
// clear run-state file — run is done
|
|
821
854
|
this._clearRunState();
|
|
822
855
|
|
|
823
856
|
// Final roll-up (synchronous) AFTER run-state is cleared, so the aggregate reflects the
|
|
824
857
|
// finished run (an in-flight marker would otherwise make the final report read as running).
|
|
825
858
|
this._firePeriodicHookFinal();
|
|
826
859
|
|
|
827
|
-
//
|
|
860
|
+
// run post_run hook after results are written
|
|
828
861
|
this._runPostRunHook(hasFailures);
|
|
829
862
|
|
|
830
863
|
// Update git cache on successful execution
|
|
@@ -846,7 +879,7 @@ export class Orchestrator {
|
|
|
846
879
|
// Stop periodic ticks on error.
|
|
847
880
|
this._stopPeriodicHook();
|
|
848
881
|
|
|
849
|
-
//
|
|
882
|
+
// clear run-state on error too
|
|
850
883
|
this._clearRunState();
|
|
851
884
|
|
|
852
885
|
// Cleanup on error
|
|
@@ -928,7 +961,7 @@ export class Orchestrator {
|
|
|
928
961
|
}
|
|
929
962
|
}
|
|
930
963
|
|
|
931
|
-
//
|
|
964
|
+
// run user-configured post_run shell command
|
|
932
965
|
_runPostRunHook(hasFailures) {
|
|
933
966
|
if (!this.postRun) return;
|
|
934
967
|
this.logger.info(`[post_run] ${this.postRun}`);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Orchestrator } from './orchestrator.js';
|
|
2
|
+
|
|
3
|
+
const baseConfig = (extra = {}) => ({
|
|
4
|
+
phases: [{ name: 'p', parallel: [{ command: 'build' }] }],
|
|
5
|
+
...extra,
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe('Orchestrator command prefix resolution', () => {
|
|
9
|
+
test('defaults to "npm run" when no command_prefix is configured', () => {
|
|
10
|
+
const orch = new Orchestrator(baseConfig());
|
|
11
|
+
expect(orch.commandPrefix).toBe('npm run');
|
|
12
|
+
expect(orch._resolvePrefix({ command: 'build' })).toBe('npm run');
|
|
13
|
+
expect(orch._displayCommand('build', { command: 'build' })).toBe('npm run build');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('global command_prefix can be disabled with empty string', () => {
|
|
17
|
+
const orch = new Orchestrator(baseConfig({ command_prefix: '' }));
|
|
18
|
+
expect(orch.commandPrefix).toBe('');
|
|
19
|
+
expect(orch._resolvePrefix({ command: 'ls -la' })).toBe('');
|
|
20
|
+
expect(orch._displayCommand('ls -la', { command: 'ls -la' })).toBe('ls -la');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('global command_prefix can be disabled with false or null', () => {
|
|
24
|
+
expect(new Orchestrator(baseConfig({ command_prefix: false })).commandPrefix).toBe('');
|
|
25
|
+
expect(new Orchestrator(baseConfig({ command_prefix: null })).commandPrefix).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('global command_prefix can be set to a custom runner', () => {
|
|
29
|
+
const orch = new Orchestrator(baseConfig({ command_prefix: 'pnpm run' }));
|
|
30
|
+
expect(orch._resolvePrefix({ command: 'build' })).toBe('pnpm run');
|
|
31
|
+
expect(orch._displayCommand('build', { command: 'build' })).toBe('pnpm run build');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('per-command shell:true runs verbatim as a bash command (overrides global)', () => {
|
|
35
|
+
const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
|
|
36
|
+
const cmd = { command: 'echo hello && ls', shell: true };
|
|
37
|
+
expect(orch._resolvePrefix(cmd)).toBe('');
|
|
38
|
+
expect(orch._displayCommand('echo hello && ls', cmd)).toBe('echo hello && ls');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('per-command prefix string overrides the global prefix', () => {
|
|
42
|
+
const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
|
|
43
|
+
const cmd = { command: 'build', prefix: 'yarn' };
|
|
44
|
+
expect(orch._resolvePrefix(cmd)).toBe('yarn');
|
|
45
|
+
expect(orch._displayCommand('build', cmd)).toBe('yarn build');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('per-command empty prefix runs verbatim even when global prefix is set', () => {
|
|
49
|
+
const orch = new Orchestrator(baseConfig({ command_prefix: 'npm run' }));
|
|
50
|
+
const cmd = { command: 'make build', prefix: '' };
|
|
51
|
+
expect(orch._resolvePrefix(cmd)).toBe('');
|
|
52
|
+
expect(orch._displayCommand('make build', cmd)).toBe('make build');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('shell:true takes precedence over an explicit prefix', () => {
|
|
56
|
+
const orch = new Orchestrator(baseConfig());
|
|
57
|
+
const cmd = { command: 'echo hi', shell: true, prefix: 'yarn' };
|
|
58
|
+
expect(orch._resolvePrefix(cmd)).toBe('');
|
|
59
|
+
});
|
|
60
|
+
});
|
package/lib/process-manager.js
CHANGED
|
@@ -46,6 +46,7 @@ export class ProcessManager {
|
|
|
46
46
|
startedByScript,
|
|
47
47
|
process_tracking,
|
|
48
48
|
kill_command,
|
|
49
|
+
prefix = 'npm run',
|
|
49
50
|
}) {
|
|
50
51
|
this.logger.verbose(`Adding background process: ${command} (${url})`);
|
|
51
52
|
this.backgroundProcessesDetails.push({
|
|
@@ -54,6 +55,7 @@ export class ProcessManager {
|
|
|
54
55
|
startedByScript,
|
|
55
56
|
process_tracking,
|
|
56
57
|
kill_command,
|
|
58
|
+
prefix,
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -93,7 +95,13 @@ export class ProcessManager {
|
|
|
93
95
|
env = null,
|
|
94
96
|
reportTime = false,
|
|
95
97
|
reportMemory = false,
|
|
98
|
+
prefix = 'npm run',
|
|
96
99
|
}) {
|
|
100
|
+
// Resolve how the command is invoked. A non-empty prefix (e.g. 'npm run') is
|
|
101
|
+
// prepended to the command name; an empty/false prefix runs the command verbatim
|
|
102
|
+
// as a regular shell command. `displayCmd` is what we surface in logs.
|
|
103
|
+
const commandPrefix = prefix ? String(prefix).trim() : '';
|
|
104
|
+
const displayCmd = commandPrefix ? `${commandPrefix} ${cmd}` : cmd;
|
|
97
105
|
const baseDir = this.logFolder
|
|
98
106
|
? path.resolve(this.logFolder)
|
|
99
107
|
: process.cwd();
|
|
@@ -131,12 +139,12 @@ export class ProcessManager {
|
|
|
131
139
|
const startTime = Date.now();
|
|
132
140
|
let timeOutputPath = null;
|
|
133
141
|
// Build command with environment variables if provided
|
|
134
|
-
let fullCommand =
|
|
142
|
+
let fullCommand = displayCmd;
|
|
135
143
|
if (env && Object.keys(env).length > 0) {
|
|
136
144
|
const envStr = Object.entries(env)
|
|
137
145
|
.map(([key, value]) => `${key}=${value}`)
|
|
138
146
|
.join(' ');
|
|
139
|
-
fullCommand = `${envStr}
|
|
147
|
+
fullCommand = `${envStr} ${displayCmd}`;
|
|
140
148
|
}
|
|
141
149
|
const useTimeWrapper =
|
|
142
150
|
reportMemory && !background && (process.platform === 'linux' || process.platform === 'darwin');
|
|
@@ -286,6 +294,7 @@ export class ProcessManager {
|
|
|
286
294
|
url: healthCheck?.url,
|
|
287
295
|
startedByScript: true,
|
|
288
296
|
kill_command,
|
|
297
|
+
prefix: commandPrefix,
|
|
289
298
|
});
|
|
290
299
|
|
|
291
300
|
this.logger.verbose(`Unreferencing process ${processGroupId}`);
|
|
@@ -293,7 +302,7 @@ export class ProcessManager {
|
|
|
293
302
|
|
|
294
303
|
this.logger.stopTask(cmd);
|
|
295
304
|
this.logger.verbose(
|
|
296
|
-
`Background process started:
|
|
305
|
+
`Background process started: ${displayCmd} (PGID: ${processGroupId})`,
|
|
297
306
|
);
|
|
298
307
|
return {
|
|
299
308
|
success: true,
|
|
@@ -304,7 +313,7 @@ export class ProcessManager {
|
|
|
304
313
|
} catch (error) {
|
|
305
314
|
if (attempt === maxAttempts) {
|
|
306
315
|
this.logger.error(
|
|
307
|
-
`Failed to start background process:
|
|
316
|
+
`Failed to start background process: ${displayCmd}`,
|
|
308
317
|
);
|
|
309
318
|
this.logger.verbose(
|
|
310
319
|
`Final verification attempt failed: ${error.message}`,
|
|
@@ -382,7 +391,7 @@ export class ProcessManager {
|
|
|
382
391
|
|
|
383
392
|
if (code !== 0) {
|
|
384
393
|
this.logger.error(
|
|
385
|
-
`Failed:
|
|
394
|
+
`Failed: ${displayCmd} ❌${durationStr} (exit code: ${code})`,
|
|
386
395
|
);
|
|
387
396
|
this.logger.verbose(`Process output: ${output}`);
|
|
388
397
|
resolve({
|
|
@@ -392,7 +401,7 @@ export class ProcessManager {
|
|
|
392
401
|
memoryKb,
|
|
393
402
|
});
|
|
394
403
|
} else {
|
|
395
|
-
this.logger.success(`Completed:
|
|
404
|
+
this.logger.success(`Completed: ${displayCmd} ✅${durationStr}`);
|
|
396
405
|
resolve({
|
|
397
406
|
success: true,
|
|
398
407
|
output,
|
|
@@ -507,13 +516,14 @@ export class ProcessManager {
|
|
|
507
516
|
);
|
|
508
517
|
|
|
509
518
|
const killPromises = commandProcesses.map(
|
|
510
|
-
async ({ command, pgid, url, startedByScript, kill_command }) => {
|
|
519
|
+
async ({ command, pgid, url, startedByScript, kill_command, prefix }) => {
|
|
511
520
|
await this.cleanupProcess({
|
|
512
521
|
command,
|
|
513
522
|
pgid,
|
|
514
523
|
url,
|
|
515
524
|
startedByScript,
|
|
516
525
|
kill_command,
|
|
526
|
+
prefix,
|
|
517
527
|
});
|
|
518
528
|
},
|
|
519
529
|
);
|
|
@@ -529,7 +539,7 @@ export class ProcessManager {
|
|
|
529
539
|
);
|
|
530
540
|
}
|
|
531
541
|
|
|
532
|
-
async cleanupProcess({ command, pgid, url, startedByScript, kill_command }) {
|
|
542
|
+
async cleanupProcess({ command, pgid, url, startedByScript, kill_command, prefix = 'npm run' }) {
|
|
533
543
|
if (!startedByScript) {
|
|
534
544
|
this.logger.verbose(
|
|
535
545
|
`- Skipping cleanup for ${command} (${url}) as it was not started by this script`,
|
|
@@ -544,13 +554,15 @@ export class ProcessManager {
|
|
|
544
554
|
// Try custom kill command first if specified
|
|
545
555
|
if (kill_command) {
|
|
546
556
|
try {
|
|
557
|
+
const killDisplay = prefix ? `${String(prefix).trim()} ${kill_command}` : kill_command;
|
|
547
558
|
this.logger.verbose(
|
|
548
|
-
`- Using custom kill command:
|
|
559
|
+
`- Using custom kill command: ${killDisplay}`,
|
|
549
560
|
);
|
|
550
561
|
const result = await this.runCommand({
|
|
551
562
|
cmd: kill_command,
|
|
552
563
|
logFile: null,
|
|
553
564
|
background: false,
|
|
565
|
+
prefix,
|
|
554
566
|
});
|
|
555
567
|
if (result.success) {
|
|
556
568
|
this.logger.verbose(
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
2
4
|
import { ProcessManager } from './process-manager.js';
|
|
3
5
|
|
|
4
6
|
describe('ProcessManager.getLogPath', () => {
|
|
@@ -20,3 +22,59 @@ describe('ProcessManager.getLogPath', () => {
|
|
|
20
22
|
expect(result).toBe(path.resolve(override));
|
|
21
23
|
});
|
|
22
24
|
});
|
|
25
|
+
|
|
26
|
+
describe('ProcessManager.runCommand prefix handling', () => {
|
|
27
|
+
let tmpDir;
|
|
28
|
+
let prevCwd;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'so-prefix-'));
|
|
32
|
+
prevCwd = process.cwd();
|
|
33
|
+
process.chdir(tmpDir);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
process.chdir(prevCwd);
|
|
38
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('runs a regular bash command verbatim when prefix is disabled', async () => {
|
|
42
|
+
const pm = new ProcessManager();
|
|
43
|
+
pm.setLogFolder(tmpDir);
|
|
44
|
+
const marker = 'orchestrator-raw-bash-ok';
|
|
45
|
+
const result = await pm.runCommand({
|
|
46
|
+
cmd: `echo ${marker}`,
|
|
47
|
+
background: false,
|
|
48
|
+
prefix: '',
|
|
49
|
+
});
|
|
50
|
+
expect(result.success).toBe(true);
|
|
51
|
+
const logPath = pm.getLogPath(`echo ${marker}`);
|
|
52
|
+
expect(fs.readFileSync(logPath, 'utf8')).toContain(marker);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('honors a custom prefix by invoking it (failure surfaces a non-zero exit)', async () => {
|
|
56
|
+
const pm = new ProcessManager();
|
|
57
|
+
pm.setLogFolder(tmpDir);
|
|
58
|
+
// With prefix 'npm run' and no package.json script, the command must fail —
|
|
59
|
+
// proving the prefix is actually prepended rather than the command run raw.
|
|
60
|
+
const result = await pm.runCommand({
|
|
61
|
+
cmd: 'definitely-not-a-script',
|
|
62
|
+
background: false,
|
|
63
|
+
prefix: 'npm run',
|
|
64
|
+
});
|
|
65
|
+
expect(result.success).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('supports multi-token shell commands (pipes, &&) when run raw', async () => {
|
|
69
|
+
const pm = new ProcessManager();
|
|
70
|
+
pm.setLogFolder(tmpDir);
|
|
71
|
+
const result = await pm.runCommand({
|
|
72
|
+
cmd: 'printf "a\\nb\\nc\\n" | grep b',
|
|
73
|
+
background: false,
|
|
74
|
+
prefix: '',
|
|
75
|
+
});
|
|
76
|
+
expect(result.success).toBe(true);
|
|
77
|
+
const logPath = pm.getLogPath('printf');
|
|
78
|
+
expect(fs.readFileSync(logPath, 'utf8').trim()).toBe('b');
|
|
79
|
+
});
|
|
80
|
+
});
|
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": "
|
|
3
|
+
"version": "3.0.0",
|
|
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",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export default {
|
|
2
|
+
// Optional: prefix prepended to every command. Defaults to 'npm run'.
|
|
3
|
+
// Set to '' (or false/null) to run commands verbatim as regular shell commands,
|
|
4
|
+
// or to another runner like 'pnpm run'. Per-command `shell: true` / `prefix` override this.
|
|
5
|
+
// command_prefix: 'npm run',
|
|
2
6
|
// Optional: metrics to report (time, memory). CLI --metrics overrides.
|
|
3
7
|
// metrics: ['time'],
|
|
4
8
|
// Optional: path for JSON results, or '-' for stdout. CLI --json-results overrides.
|