scripts-orchestrator 2.15.1 → 3.5.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 +109 -3
- package/index.js +92 -0
- package/lib/index.js +10 -0
- package/lib/orchestrator.aggregate.test.js +83 -0
- package/lib/orchestrator.js +98 -3
- 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/report-html.js +103 -7
- package/lib/report-html.test.js +120 -0
- package/lib/workspaces.js +353 -0
- package/lib/workspaces.test.js +303 -0
- package/package.json +1 -1
- package/scripts-orchestrator.config.js +12 -0
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ npm install --save-dev scripts-orchestrator
|
|
|
41
41
|
- **Post-run hook**: Run a shell command after results are written via `post_run` config (v2.14+)
|
|
42
42
|
- **Run-state file**: Library-owned in-progress indicator for live dashboard integration (v2.14+)
|
|
43
43
|
- **Phase recommendations**: Memory-aware `--recommend` mode that proposes an optimal phase layout from a run's time/memory metrics (advisory, v2.15+)
|
|
44
|
+
- **npm workspace aggregation**: First-class workspace roll-up that discovers the npm workspaces in a repo and rolls each workspace's results JSON — plus the root run's global checks — into a single report. Drive it declaratively with the `aggregate` config key (in-process; v3.2+) or via the standalone `--aggregate` CLI mode (v3.1+)
|
|
44
45
|
|
|
45
46
|
## Configuration
|
|
46
47
|
|
|
@@ -48,12 +49,14 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
|
|
|
48
49
|
|
|
49
50
|
```javascript
|
|
50
51
|
{
|
|
51
|
-
command: 'command_name', // The
|
|
52
|
+
command: 'command_name', // The command to run (see "Command prefix" below)
|
|
52
53
|
description: 'Description', // Optional description
|
|
53
54
|
status: 'enabled', // 'enabled' or 'disabled'
|
|
54
55
|
attempts: 1, // Number of retry attempts
|
|
55
56
|
dependencies: [], // Array of dependent commands
|
|
56
57
|
background: false, // Whether to run in background
|
|
58
|
+
shell: false, // true => run `command` verbatim as a shell command (no prefix)
|
|
59
|
+
prefix: 'npm run', // Optional per-command prefix override ('' to disable)
|
|
57
60
|
env: { // Optional environment variables
|
|
58
61
|
PORT: 3000,
|
|
59
62
|
NODE_ENV: 'production'
|
|
@@ -70,6 +73,44 @@ Create a configuration file (default: `scripts-orchestrator.config.js`) that def
|
|
|
70
73
|
}
|
|
71
74
|
```
|
|
72
75
|
|
|
76
|
+
### Command prefix (`npm run` is optional)
|
|
77
|
+
|
|
78
|
+
By default every `command` is run as an npm script — the orchestrator prepends `npm run`,
|
|
79
|
+
so `command: 'build'` executes `npm run build`. This prefix is configurable:
|
|
80
|
+
|
|
81
|
+
- **Global default** — set `command_prefix` at the top level of the config. Use it to point at a
|
|
82
|
+
different runner (`'pnpm run'`, `'yarn'`) or to disable prefixing entirely so commands run as
|
|
83
|
+
regular shell commands:
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
export default {
|
|
87
|
+
command_prefix: '', // '' / false / null => run commands verbatim (plain shell)
|
|
88
|
+
phases: [
|
|
89
|
+
{ name: 'checks', parallel: [
|
|
90
|
+
{ command: 'eslint . --max-warnings 0' }, // runs as-is, supports args/pipes/&&
|
|
91
|
+
{ command: './scripts/verify.sh' },
|
|
92
|
+
]},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- **Per command** — `shell: true` forces a single command to run verbatim as a shell command
|
|
98
|
+
(ignoring any global prefix), and `prefix: '...'` overrides the prefix for just that command:
|
|
99
|
+
|
|
100
|
+
```javascript
|
|
101
|
+
{
|
|
102
|
+
phases: [{ name: 'mixed', parallel: [
|
|
103
|
+
{ command: 'build' }, // -> npm run build (global default)
|
|
104
|
+
{ command: 'docker compose up -d', shell: true }, // raw shell command
|
|
105
|
+
{ command: 'release', prefix: 'yarn' }, // -> yarn release
|
|
106
|
+
]}],
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Precedence per command: `shell: true` (raw) → per-command `prefix` → global `command_prefix`
|
|
111
|
+
→ the built-in `npm run` default. Existing configs are unaffected — omitting all of these keeps
|
|
112
|
+
the original `npm run` behaviour.
|
|
113
|
+
|
|
73
114
|
### Phase Configuration
|
|
74
115
|
|
|
75
116
|
When using the phases format, each phase can have the following properties:
|
|
@@ -488,11 +529,76 @@ The hook receives two environment variables:
|
|
|
488
529
|
The hook runs synchronously and its exit code is logged but does not change the orchestrator's
|
|
489
530
|
own exit code.
|
|
490
531
|
|
|
491
|
-
**Typical use case:**
|
|
532
|
+
**Typical use case:** roll up a monorepo report after each workspace finishes (see
|
|
533
|
+
**npm workspace aggregation** below):
|
|
492
534
|
```javascript
|
|
493
|
-
post_run: '
|
|
535
|
+
post_run: 'npx scripts-orchestrator --aggregate ../../scripts-orchestrator-aggregate.config.js'
|
|
494
536
|
```
|
|
495
537
|
|
|
538
|
+
## npm workspace aggregation (v3.1+)
|
|
539
|
+
|
|
540
|
+
In a monorepo, each npm workspace can run its own orchestrator gate (writing its own
|
|
541
|
+
`json_results`), and a root run can run repo-wide "global" checks. The aggregator rolls all of
|
|
542
|
+
these into one report — no per-repo merge script required. Drive it the easy way with the
|
|
543
|
+
declarative [`aggregate` config key](#declarative-aggregate-config-key-recommended-v32) below, or
|
|
544
|
+
invoke the [`--aggregate` CLI mode](#--aggregate-cli-mode) directly.
|
|
545
|
+
|
|
546
|
+
It reads only artifacts the library itself writes — each scope's `json_results` and the
|
|
547
|
+
**run-state file** (`.scripts-orchestrator-run.json`) — so it needs no log scraping. The
|
|
548
|
+
run-state file tells it whether the run is still in flight (live report with auto-refresh) or
|
|
549
|
+
finished (static report). Each workspace section is classified as **OK / FAIL / RUNNING /
|
|
550
|
+
PENDING / STALE / INTERRUPTED / N/A** from its own results JSON and the run window.
|
|
551
|
+
|
|
552
|
+
### Declarative `aggregate` config key (recommended, v3.2+)
|
|
553
|
+
|
|
554
|
+
Rather than wiring a `periodic_hook` / `post_run` that shells out to `--aggregate`, set the
|
|
555
|
+
**`aggregate`** key in your orchestrator config and the library drives the roll-up **in-process** —
|
|
556
|
+
no subprocess spawned every interval, no dependency on `npx`/PATH resolution:
|
|
557
|
+
|
|
558
|
+
```javascript
|
|
559
|
+
// root run config — roll up periodically while running + once, static, at the end
|
|
560
|
+
aggregate: './scripts-orchestrator-aggregate.config.js', // or `true` for the built-in defaults
|
|
561
|
+
periodic_interval_ms: 45000, // cadence for the in-process roll-up
|
|
562
|
+
|
|
563
|
+
// each workspace gate config — refresh the roll-up as that workspace finishes
|
|
564
|
+
aggregate: '../../scripts-orchestrator-aggregate.config.js',
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
The value may be `true` (use defaults), a path to a config module (its `default` export is used as
|
|
568
|
+
the options below), or an options object inline. The library auto-detects whether the current run is
|
|
569
|
+
the **repo-root run** (it owns the periodic cadence and writes the final static report) or a
|
|
570
|
+
**fanned-out workspace run** (it refreshes the roll-up once, as that workspace finishes, leaving the
|
|
571
|
+
report in-progress because the root run is still live). On interrupt, the orchestrator writes one
|
|
572
|
+
final static roll-up itself.
|
|
573
|
+
|
|
574
|
+
### `--aggregate` CLI mode
|
|
575
|
+
|
|
576
|
+
The same roll-up is available as a standalone CLI mode (used internally by the declarative key, and
|
|
577
|
+
handy for manual/one-off rendering or legacy hook wiring). It is safe to fire repeatedly:
|
|
578
|
+
|
|
579
|
+
```bash
|
|
580
|
+
scripts-orchestrator --aggregate # use the built-in defaults
|
|
581
|
+
scripts-orchestrator --aggregate ./scripts-orchestrator-aggregate.config.js # override paths/title
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
The optional config module's `default` export may override any of these (all paths are
|
|
585
|
+
resolved against the auto-detected repo root unless absolute):
|
|
586
|
+
|
|
587
|
+
| Key | Default | Meaning |
|
|
588
|
+
| --- | --- | --- |
|
|
589
|
+
| `title` | `Workspaces Quality Report` | Report heading |
|
|
590
|
+
| `outJson` / `outHtml` | `logs/monorepo-quality-report.{json,html}` | Where the roll-up is written |
|
|
591
|
+
| `runStateFile` | `logs/.scripts-orchestrator-run.json` | Run-state file used to detect in-progress + run start |
|
|
592
|
+
| `rootResults` | `logs/scripts-orchestrator-logs/scripts-orchestrator-results.json` | Root run's results (source of global-check rows) |
|
|
593
|
+
| `globalResults` | `logs/scripts-orchestrator-logs/scripts-orchestrator-global-results.json` | Fallback source of global-check rows |
|
|
594
|
+
| `workspaceResults` | `logs/scripts-orchestrator-logs/scripts-orchestrator-results.json` | Per-workspace results path (relative to each workspace) |
|
|
595
|
+
| `globalPhase` / `workspacePhase` | `global quality checks` / `workspace quality gates` | Phase names used to split global rows from the fan-out row |
|
|
596
|
+
| `refreshSecs` | `5` | Auto-refresh cadence injected while the run is in progress |
|
|
597
|
+
| `exclude` | `[]` | Workspace directories (repo-root-relative) to omit |
|
|
598
|
+
|
|
599
|
+
The library also exports the building blocks for programmatic use:
|
|
600
|
+
`findRepoRoot`, `discoverWorkspaceDirs`, `aggregateWorkspacesReport`, `writeAggregateReport`.
|
|
601
|
+
|
|
496
602
|
## Git-Based Caching
|
|
497
603
|
|
|
498
604
|
The orchestrator automatically tracks the git commit hash and repository state to optimize execution:
|
package/index.js
CHANGED
|
@@ -60,6 +60,11 @@ const argv = yargs(hideBin(process.argv))
|
|
|
60
60
|
description:
|
|
61
61
|
'Analyse an existing results JSON and print a memory-aware phase recommendation (no run).',
|
|
62
62
|
})
|
|
63
|
+
.option('aggregate', {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description:
|
|
66
|
+
'Aggregate every npm workspace\'s results JSON into one roll-up report (no run). Pass an optional config path to override the default paths/title.',
|
|
67
|
+
})
|
|
63
68
|
.option('fanout', {
|
|
64
69
|
type: 'number',
|
|
65
70
|
description: 'Workspace fan-out (parallel gates sharing the host) used to size the --recommend budget. Default 1.',
|
|
@@ -148,6 +153,47 @@ if (argv.recommend != null) {
|
|
|
148
153
|
process.exit(0);
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
// --aggregate mode: roll up every npm workspace's results JSON (plus the root run's global
|
|
157
|
+
// checks) into a single report. Advisory of nothing — it just renders state already on disk, so
|
|
158
|
+
// it is safe to fire repeatedly (e.g. from the periodic_hook while the run is in flight, or once
|
|
159
|
+
// at the end). No orchestration run happens here.
|
|
160
|
+
if (argv.aggregate != null) {
|
|
161
|
+
const { writeAggregateReport } = await import('./lib/index.js');
|
|
162
|
+
let options = {};
|
|
163
|
+
const cfgArg = argv.aggregate;
|
|
164
|
+
if (cfgArg && cfgArg !== '-') {
|
|
165
|
+
const cfgPath = path.resolve(process.cwd(), cfgArg);
|
|
166
|
+
if (!fs.existsSync(cfgPath)) {
|
|
167
|
+
log.error(`Error: --aggregate config not found at ${cfgPath}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
options = (await import(new URL(`file://${cfgPath}`).href)).default ?? {};
|
|
172
|
+
} catch (err) {
|
|
173
|
+
log.error(`Error: failed to load --aggregate config: ${err.message}`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// The orchestrator fires this hook a final time AFTER clearing its run-state, tagging it with
|
|
178
|
+
// SCRIPTS_ORCHESTRATOR_PERIODIC=final. Honour that as an explicit "run is over" signal so the
|
|
179
|
+
// final report is static (no auto-refresh) even if a stray marker lingers.
|
|
180
|
+
if (process.env.SCRIPTS_ORCHESTRATOR_PERIODIC === 'final') {
|
|
181
|
+
options = { ...options, inProgress: false };
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const { jsonPath, htmlPath } = writeAggregateReport(options);
|
|
185
|
+
if (process.env.ORCHESTRATOR_MERGE_QUIET !== '1') {
|
|
186
|
+
log.info(
|
|
187
|
+
`📄 Aggregated workspace report → ${path.relative(process.cwd(), jsonPath)} (+ ${path.basename(htmlPath)})`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
log.error(`Error: failed to aggregate workspace report: ${err.message}`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
process.exit(0);
|
|
195
|
+
}
|
|
196
|
+
|
|
151
197
|
// Extract arguments
|
|
152
198
|
const args = argv._;
|
|
153
199
|
const configPath = args[0] || './scripts-orchestrator.config.js';
|
|
@@ -207,6 +253,14 @@ const htmlResultsPath =
|
|
|
207
253
|
// post-run hook — shell command run after json_results written
|
|
208
254
|
const postRun = commandsConfig.post_run ?? null;
|
|
209
255
|
|
|
256
|
+
// Memory heat thresholds for the HTML report — fractions (0–1) of the run's peak memory above which
|
|
257
|
+
// a command is coloured amber (mid) / red (high). Config: `memory_heat: { mid: 0.33, high: 0.66 }`.
|
|
258
|
+
const memoryHeat = commandsConfig.memory_heat ?? null;
|
|
259
|
+
|
|
260
|
+
// Duration heat thresholds for the HTML report — fractions (0–1) of the run's slowest command above
|
|
261
|
+
// which a command is coloured amber (mid) / red (high). Config: `duration_heat: { mid: 0.33, high: 0.66 }`.
|
|
262
|
+
const durationHeat = commandsConfig.duration_heat ?? null;
|
|
263
|
+
|
|
210
264
|
// Periodic hook — shell command run on an interval WHILE the run is in flight (e.g. to roll up
|
|
211
265
|
// results into an aggregate report). Library owns only the cadence; the command is project-specific.
|
|
212
266
|
const periodicHook = commandsConfig.periodic_hook ?? null;
|
|
@@ -214,6 +268,34 @@ const periodicIntervalMs = Number(commandsConfig.periodic_interval_ms) > 0
|
|
|
214
268
|
? Number(commandsConfig.periodic_interval_ms)
|
|
215
269
|
: 45000;
|
|
216
270
|
|
|
271
|
+
// Declarative npm-workspace roll-up. `aggregate: true` uses library defaults; a string loads that
|
|
272
|
+
// config module's default export as writeAggregateReport options; an object is used verbatim. When
|
|
273
|
+
// set, the library drives the workspace aggregate IN-PROCESS — the repo-root run rolls up on the
|
|
274
|
+
// periodic cadence + once at the end (static), and a fanned-out workspace run rolls up once as it
|
|
275
|
+
// finishes. This is the declarative replacement for wiring periodic_hook / post_run to shell out
|
|
276
|
+
// to `scripts-orchestrator --aggregate`.
|
|
277
|
+
let aggregateOptions = null;
|
|
278
|
+
const aggregateCfg = commandsConfig.aggregate;
|
|
279
|
+
if (aggregateCfg != null && aggregateCfg !== false) {
|
|
280
|
+
if (aggregateCfg === true) {
|
|
281
|
+
aggregateOptions = {};
|
|
282
|
+
} else if (typeof aggregateCfg === 'string') {
|
|
283
|
+
const aggCfgPath = path.resolve(process.cwd(), aggregateCfg);
|
|
284
|
+
if (!fs.existsSync(aggCfgPath)) {
|
|
285
|
+
log.error(`Error: aggregate config not found at ${aggCfgPath}`);
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
aggregateOptions = (await import(new URL(`file://${aggCfgPath}`).href)).default ?? {};
|
|
290
|
+
} catch (err) {
|
|
291
|
+
log.error(`Error: failed to load aggregate config: ${err.message}`);
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
} else if (typeof aggregateCfg === 'object') {
|
|
295
|
+
aggregateOptions = aggregateCfg;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
217
299
|
// Set the log folder for the main orchestrator logs if specified
|
|
218
300
|
if (logFolder) {
|
|
219
301
|
log.setLogFolder(logFolder);
|
|
@@ -233,9 +315,14 @@ const orchestrator = new Orchestrator(
|
|
|
233
315
|
);
|
|
234
316
|
// wire post-run hook from config
|
|
235
317
|
orchestrator.postRun = postRun;
|
|
318
|
+
// wire memory heat thresholds from config (HTML report colouring)
|
|
319
|
+
orchestrator.memoryHeat = memoryHeat;
|
|
320
|
+
orchestrator.durationHeat = durationHeat;
|
|
236
321
|
// Wire periodic hook (cadence owned by the library)
|
|
237
322
|
orchestrator.periodicHook = periodicHook;
|
|
238
323
|
orchestrator.periodicIntervalMs = periodicIntervalMs;
|
|
324
|
+
// Wire declarative in-process workspace roll-up (takes the in-process path when set)
|
|
325
|
+
orchestrator.aggregateOptions = aggregateOptions;
|
|
239
326
|
|
|
240
327
|
// Enhanced signal handlers
|
|
241
328
|
const handleSignal = async (signal) => {
|
|
@@ -248,6 +335,11 @@ const handleSignal = async (signal) => {
|
|
|
248
335
|
}
|
|
249
336
|
// clear run-state so dashboards know the run ended
|
|
250
337
|
orchestrator._clearRunState();
|
|
338
|
+
// Write one final static workspace roll-up so an interrupted run leaves a non-refreshing report
|
|
339
|
+
// (only when the declarative in-process aggregate is configured; a no-op otherwise).
|
|
340
|
+
if (orchestrator.aggregateOptions) {
|
|
341
|
+
orchestrator._firePeriodicHookFinal();
|
|
342
|
+
}
|
|
251
343
|
process.exit(1);
|
|
252
344
|
};
|
|
253
345
|
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
observedTimeline,
|
|
14
14
|
packPhases,
|
|
15
15
|
} from './recommend-phases.js';
|
|
16
|
+
import {
|
|
17
|
+
findRepoRoot,
|
|
18
|
+
discoverWorkspaceDirs,
|
|
19
|
+
aggregateWorkspacesReport,
|
|
20
|
+
writeAggregateReport,
|
|
21
|
+
} from './workspaces.js';
|
|
16
22
|
|
|
17
23
|
export {
|
|
18
24
|
Orchestrator,
|
|
@@ -28,5 +34,9 @@ export {
|
|
|
28
34
|
usableSteps,
|
|
29
35
|
observedTimeline,
|
|
30
36
|
packPhases,
|
|
37
|
+
findRepoRoot,
|
|
38
|
+
discoverWorkspaceDirs,
|
|
39
|
+
aggregateWorkspacesReport,
|
|
40
|
+
writeAggregateReport,
|
|
31
41
|
};
|
|
32
42
|
export default Orchestrator;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Orchestrator } from './orchestrator.js';
|
|
2
|
+
|
|
3
|
+
const baseConfig = (extra = {}) => ({
|
|
4
|
+
phases: [{ name: 'p', parallel: [{ command: 'build' }] }],
|
|
5
|
+
...extra,
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Build an orchestrator with the in-process aggregate wired, and stub the seams that would
|
|
9
|
+
// otherwise spawn processes / touch disk so we can assert the decision logic in isolation.
|
|
10
|
+
function makeOrch({ aggregateOptions = {}, periodicHook = null, isRoot = true } = {}) {
|
|
11
|
+
const orch = new Orchestrator(baseConfig());
|
|
12
|
+
orch.aggregateOptions = aggregateOptions;
|
|
13
|
+
orch.periodicHook = periodicHook;
|
|
14
|
+
orch._isRepoRootRun = () => isRoot;
|
|
15
|
+
// Capture aggregate fires instead of writing a report.
|
|
16
|
+
orch._fireAggregateCalls = [];
|
|
17
|
+
orch._fireAggregate = (inProgress) => orch._fireAggregateCalls.push(inProgress);
|
|
18
|
+
return orch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Minimal call-counter so the suite stays dependency-free (no jest globals under the ESM runner).
|
|
22
|
+
function counter() {
|
|
23
|
+
const fn = (...args) => { fn.calls.push(args); };
|
|
24
|
+
fn.calls = [];
|
|
25
|
+
return fn;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('Orchestrator in-process workspace aggregate', () => {
|
|
29
|
+
test('no aggregate configured: periodic machinery is inert', () => {
|
|
30
|
+
const orch = new Orchestrator(baseConfig());
|
|
31
|
+
const fireSpy = counter();
|
|
32
|
+
orch._firePeriodicTick = fireSpy;
|
|
33
|
+
orch._startPeriodicHook();
|
|
34
|
+
expect(orch._periodicTimer).toBeNull();
|
|
35
|
+
expect(fireSpy.calls).toHaveLength(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('root run with aggregate: starts cadence with a prompt first roll-up', () => {
|
|
39
|
+
const orch = makeOrch({ isRoot: true });
|
|
40
|
+
const tickSpy = counter();
|
|
41
|
+
orch._firePeriodicTick = tickSpy;
|
|
42
|
+
orch._startPeriodicHook();
|
|
43
|
+
expect(tickSpy.calls).toHaveLength(1); // prompt first roll-up
|
|
44
|
+
expect(orch._periodicTimer).not.toBeNull();
|
|
45
|
+
orch._stopPeriodicHook(); // avoid a dangling interval
|
|
46
|
+
expect(orch._periodicTimer).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('fanned-out workspace run with aggregate: no periodic cadence', () => {
|
|
50
|
+
const orch = makeOrch({ isRoot: false });
|
|
51
|
+
const tickSpy = counter();
|
|
52
|
+
orch._firePeriodicTick = tickSpy;
|
|
53
|
+
orch._startPeriodicHook();
|
|
54
|
+
expect(tickSpy.calls).toHaveLength(0);
|
|
55
|
+
expect(orch._periodicTimer).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('periodic tick rolls up with auto-detected inProgress (undefined)', () => {
|
|
59
|
+
const orch = makeOrch({ isRoot: true });
|
|
60
|
+
orch._firePeriodicTick();
|
|
61
|
+
expect(orch._fireAggregateCalls).toEqual([undefined]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('final fire on the root run forces a static (inProgress=false) report', () => {
|
|
65
|
+
const orch = makeOrch({ isRoot: true });
|
|
66
|
+
orch._firePeriodicHookFinal();
|
|
67
|
+
expect(orch._fireAggregateCalls).toEqual([false]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('final fire on a workspace run leaves inProgress auto-detected (root still running)', () => {
|
|
71
|
+
const orch = makeOrch({ isRoot: false });
|
|
72
|
+
orch._firePeriodicHookFinal();
|
|
73
|
+
expect(orch._fireAggregateCalls).toEqual([undefined]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a legacy periodic_hook takes precedence over the in-process aggregate', () => {
|
|
77
|
+
const orch = makeOrch({ isRoot: true, periodicHook: 'echo hi' });
|
|
78
|
+
// With a shell hook configured, the tick must NOT take the in-process path.
|
|
79
|
+
orch._periodicRunning = true; // make the shell path a no-op without spawning
|
|
80
|
+
orch._firePeriodicTick();
|
|
81
|
+
expect(orch._fireAggregateCalls).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/lib/orchestrator.js
CHANGED
|
@@ -6,6 +6,7 @@ import { healthCheck } from './health-check.js';
|
|
|
6
6
|
import { log } from './logger.js';
|
|
7
7
|
import { GitCache } from './git-cache.js';
|
|
8
8
|
import { renderReportHtml } from './report-html.js';
|
|
9
|
+
import { findRepoRoot, writeAggregateReport } from './workspaces.js';
|
|
9
10
|
import chalk from 'chalk';
|
|
10
11
|
|
|
11
12
|
export class Orchestrator {
|
|
@@ -27,6 +28,12 @@ export class Orchestrator {
|
|
|
27
28
|
this.sequential = sequential;
|
|
28
29
|
this.force = force;
|
|
29
30
|
this.metrics = Array.isArray(metrics) ? metrics : [];
|
|
31
|
+
// Global command prefix. Defaults to 'npm run' so existing configs keep working.
|
|
32
|
+
// Set `command_prefix` to '' / false / null in the config to run commands verbatim
|
|
33
|
+
// as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
|
|
34
|
+
this.commandPrefix = Object.prototype.hasOwnProperty.call(config ?? {}, 'command_prefix')
|
|
35
|
+
? this._normalizePrefix(config.command_prefix)
|
|
36
|
+
: 'npm run';
|
|
30
37
|
this.jsonResultsPath = jsonResultsPath ?? null;
|
|
31
38
|
this.htmlResultsPath = htmlResultsPath ?? null;
|
|
32
39
|
this.processManager = processManager;
|
|
@@ -49,9 +56,21 @@ export class Orchestrator {
|
|
|
49
56
|
: null;
|
|
50
57
|
// post-run hook command (shell string)
|
|
51
58
|
this.postRun = null; // set from config in index.js
|
|
59
|
+
// Memory heat thresholds for the HTML report (fractions of the run's peak). Set from config in
|
|
60
|
+
// index.js; embedded into the results payload so the renderer (and --render) honour them.
|
|
61
|
+
this.memoryHeat = null;
|
|
62
|
+
// Duration heat thresholds for the HTML report (fractions of the run's slowest command). Set from
|
|
63
|
+
// config in index.js; embedded into the results payload so the renderer (and --render) honour them.
|
|
64
|
+
this.durationHeat = null;
|
|
52
65
|
// Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
|
|
53
66
|
// The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
|
|
54
67
|
this.periodicHook = null;
|
|
68
|
+
// Declarative npm-workspace roll-up (set from config in index.js). When non-null, the library
|
|
69
|
+
// drives the workspace aggregate IN-PROCESS — no shell-out — using these writeAggregateReport
|
|
70
|
+
// options: the repo-root run owns the periodic cadence + final static report, while a fanned-out
|
|
71
|
+
// workspace run refreshes the roll-up once when it finishes. Replaces wiring a periodic_hook /
|
|
72
|
+
// post_run that shells out to `scripts-orchestrator --aggregate`.
|
|
73
|
+
this.aggregateOptions = null;
|
|
55
74
|
this.periodicIntervalMs = 45000;
|
|
56
75
|
this._periodicTimer = null;
|
|
57
76
|
this._periodicRunning = false;
|
|
@@ -180,6 +199,8 @@ export class Orchestrator {
|
|
|
180
199
|
...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
|
|
181
200
|
commands,
|
|
182
201
|
...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
|
|
202
|
+
...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
|
|
203
|
+
...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
|
|
183
204
|
};
|
|
184
205
|
|
|
185
206
|
const tmpPath = outPath + '.tmp';
|
|
@@ -263,6 +284,29 @@ export class Orchestrator {
|
|
|
263
284
|
}
|
|
264
285
|
}
|
|
265
286
|
|
|
287
|
+
// Normalize a prefix value into a clean string. false/null/'' all mean "no prefix"
|
|
288
|
+
// (run the command verbatim); any string is trimmed.
|
|
289
|
+
_normalizePrefix(value) {
|
|
290
|
+
if (value === false || value === null || value === undefined) return '';
|
|
291
|
+
return String(value).trim();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Resolve the effective prefix for a single command.
|
|
295
|
+
// Precedence: per-command `shell: true` (raw) > per-command `prefix` > global commandPrefix.
|
|
296
|
+
_resolvePrefix(commandConfig = {}) {
|
|
297
|
+
if (commandConfig.shell === true) return '';
|
|
298
|
+
if (Object.prototype.hasOwnProperty.call(commandConfig, 'prefix')) {
|
|
299
|
+
return this._normalizePrefix(commandConfig.prefix);
|
|
300
|
+
}
|
|
301
|
+
return this.commandPrefix;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Format a command for display, honoring its resolved prefix.
|
|
305
|
+
_displayCommand(command, commandConfig = {}) {
|
|
306
|
+
const prefix = this._resolvePrefix(commandConfig);
|
|
307
|
+
return prefix ? `${prefix} ${command}` : command;
|
|
308
|
+
}
|
|
309
|
+
|
|
266
310
|
async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
|
|
267
311
|
const {
|
|
268
312
|
command,
|
|
@@ -281,6 +325,8 @@ export class Orchestrator {
|
|
|
281
325
|
} = commandConfig;
|
|
282
326
|
|
|
283
327
|
const startTime = Date.now();
|
|
328
|
+
// Effective invocation prefix for this command ('' => run verbatim as a shell command).
|
|
329
|
+
const prefix = this._resolvePrefix(commandConfig);
|
|
284
330
|
|
|
285
331
|
// Record the destination log file for this command (honors per-command override).
|
|
286
332
|
// Done early so even disabled/skipped commands report where output would land.
|
|
@@ -303,7 +349,7 @@ export class Orchestrator {
|
|
|
303
349
|
|
|
304
350
|
// Skip execution if the command is disabled
|
|
305
351
|
if (status === 'disabled') {
|
|
306
|
-
this.logger.warn(`Skipping:
|
|
352
|
+
this.logger.warn(`Skipping: ${this._displayCommand(command, commandConfig)} (status: disabled)`);
|
|
307
353
|
this.skippedCommands.push(command);
|
|
308
354
|
this.skipReasons.set(command, 'disabled');
|
|
309
355
|
setTiming(Date.now() - startTime);
|
|
@@ -336,6 +382,7 @@ export class Orchestrator {
|
|
|
336
382
|
startedByScript: false,
|
|
337
383
|
process_tracking,
|
|
338
384
|
kill_command,
|
|
385
|
+
prefix,
|
|
339
386
|
});
|
|
340
387
|
setTiming(Date.now() - startTime);
|
|
341
388
|
visited.delete(command);
|
|
@@ -411,6 +458,7 @@ export class Orchestrator {
|
|
|
411
458
|
env,
|
|
412
459
|
reportTime: this.metrics.includes('time'),
|
|
413
460
|
reportMemory: this.metrics.includes('memory'),
|
|
461
|
+
prefix,
|
|
414
462
|
});
|
|
415
463
|
lastRunResult = runResult;
|
|
416
464
|
const { success, output } = runResult;
|
|
@@ -564,6 +612,8 @@ export class Orchestrator {
|
|
|
564
612
|
...(this.config.phases && this.phaseResults.length > 0
|
|
565
613
|
? { phases: this.phaseResults }
|
|
566
614
|
: {}),
|
|
615
|
+
...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
|
|
616
|
+
...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
|
|
567
617
|
};
|
|
568
618
|
|
|
569
619
|
const json = JSON.stringify(payload, null, 2);
|
|
@@ -860,11 +910,43 @@ export class Orchestrator {
|
|
|
860
910
|
}
|
|
861
911
|
}
|
|
862
912
|
|
|
913
|
+
// Is this process the repo-root orchestrator run (vs. a fanned-out workspace run)? The root run
|
|
914
|
+
// owns the periodic roll-up cadence and the final static report; a workspace run only refreshes
|
|
915
|
+
// the aggregate once as it finishes (mirrors the old root periodic_hook / workspace post_run split).
|
|
916
|
+
_isRepoRootRun() {
|
|
917
|
+
try {
|
|
918
|
+
const root = findRepoRoot(process.cwd());
|
|
919
|
+
return root != null && path.resolve(process.cwd()) === root;
|
|
920
|
+
} catch {
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Roll up every workspace's results into the aggregate report, in-process. inProgress is left
|
|
926
|
+
// undefined (auto-detected from the root run-state file) except for the root run's final fire.
|
|
927
|
+
_fireAggregate(inProgress) {
|
|
928
|
+
try {
|
|
929
|
+
const opts = inProgress == null
|
|
930
|
+
? this.aggregateOptions
|
|
931
|
+
: { ...this.aggregateOptions, inProgress };
|
|
932
|
+
const { jsonPath } = writeAggregateReport(opts);
|
|
933
|
+
this.logger.verbose(`[aggregate] rolled up workspaces → ${jsonPath}`);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
this.logger.warn(`[aggregate] roll-up failed: ${err.message}`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
863
939
|
// Periodic hook: start the interval timer (fires once promptly, then every interval).
|
|
864
940
|
_startPeriodicHook() {
|
|
865
|
-
if (
|
|
941
|
+
if (this._periodicTimer) return;
|
|
942
|
+
if (!this.periodicHook && !this.aggregateOptions) return;
|
|
943
|
+
// In-process aggregate: only the repo-root run drives the periodic cadence. A fanned-out
|
|
944
|
+
// workspace run refreshes the roll-up just once, at the end (see _firePeriodicHookFinal).
|
|
945
|
+
if (this.aggregateOptions && !this.periodicHook && !this._isRepoRootRun()) return;
|
|
866
946
|
this.logger.info(
|
|
867
|
-
|
|
947
|
+
this.periodicHook
|
|
948
|
+
? `⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`
|
|
949
|
+
: `⏱️ Periodic workspace roll-up every ${Math.round(this.periodicIntervalMs / 1000)}s (in-process)`,
|
|
868
950
|
);
|
|
869
951
|
this._firePeriodicTick(); // prompt first roll-up so an initial aggregate exists
|
|
870
952
|
this._periodicTimer = setInterval(() => this._firePeriodicTick(), this.periodicIntervalMs);
|
|
@@ -873,6 +955,11 @@ export class Orchestrator {
|
|
|
873
955
|
|
|
874
956
|
// Fire the periodic hook asynchronously, with an overlap guard so slow hooks don't pile up.
|
|
875
957
|
_firePeriodicTick() {
|
|
958
|
+
// In-process aggregate path: synchronous, so no overlap guard needed.
|
|
959
|
+
if (this.aggregateOptions && !this.periodicHook) {
|
|
960
|
+
this._fireAggregate(undefined);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
876
963
|
if (!this.periodicHook || this._periodicRunning) {
|
|
877
964
|
if (this._periodicRunning) {
|
|
878
965
|
this.logger.verbose('[periodic_hook] previous invocation still running; skipping tick');
|
|
@@ -914,6 +1001,14 @@ export class Orchestrator {
|
|
|
914
1001
|
|
|
915
1002
|
// Final synchronous fire so the aggregate reflects the finished run before the process exits.
|
|
916
1003
|
_firePeriodicHookFinal() {
|
|
1004
|
+
// In-process aggregate path: the repo-root run owns the "run complete" signal, so it forces a
|
|
1005
|
+
// static (non-refresh) report. A fanned-out workspace run must NOT — the root run is still in
|
|
1006
|
+
// flight, so leave inProgress auto-detected from the (still-present) root run-state file.
|
|
1007
|
+
if (this.aggregateOptions && !this.periodicHook) {
|
|
1008
|
+
this._stopPeriodicHook();
|
|
1009
|
+
this._fireAggregate(this._isRepoRootRun() ? false : undefined);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
917
1012
|
if (!this.periodicHook) return;
|
|
918
1013
|
this._stopPeriodicHook();
|
|
919
1014
|
try {
|
|
@@ -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
|
+
});
|