scripts-orchestrator 3.5.0 → 3.7.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 +68 -0
- package/index.js +9 -0
- package/lib/orchestrator.concurrency.test.js +112 -0
- package/lib/orchestrator.js +82 -7
- package/lib/report-html.js +5 -2
- package/lib/workspaces.js +29 -6
- package/lib/workspaces.test.js +37 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ npm install --save-dev scripts-orchestrator
|
|
|
26
26
|
## Features
|
|
27
27
|
|
|
28
28
|
- **Parallel Execution**: Runs multiple commands concurrently for faster execution
|
|
29
|
+
- **Concurrency cap**: Bound how many commands a phase runs at once with `max_concurrency` / `--max-concurrency` (defaults to `auto` = CPU count − 1) so smaller machines aren't asked to host every command's toolchain simultaneously (v3.6+). A single phase can pin its own cap with a phase-level `max_concurrency` — e.g. `1` to serialise just that phase's commands while continuing past failures (v3.7+)
|
|
29
30
|
- **Sequential Mode**: Option to run all commands sequentially for low CPU machines
|
|
30
31
|
- **Dependency Management**: Handles command dependencies and ensures proper execution order
|
|
31
32
|
- **Background Processes**: Supports running commands in the background with health checks
|
|
@@ -417,6 +418,73 @@ This is particularly useful for:
|
|
|
417
418
|
- Debugging individual command failures
|
|
418
419
|
- Avoiding resource contention between commands
|
|
419
420
|
|
|
421
|
+
### Limiting Concurrency (`max_concurrency`)
|
|
422
|
+
|
|
423
|
+
Sequential mode is all-or-nothing. When a phase declares many parallel commands, running *every* one at once can overwhelm a smaller machine (each command may spin up its own Node/toolchain), while `--sequential` over-corrects by dropping to one at a time. `max_concurrency` is the middle ground: it caps how many of a phase's commands run **at once** without serialising everything.
|
|
424
|
+
|
|
425
|
+
- **`'auto'` (the default)** resolves to `max(1, cpuCount - 1)`, leaving one core for the OS/editor.
|
|
426
|
+
- **A positive integer** pins the cap to that exact number.
|
|
427
|
+
- **`0`, negative, or unparseable** values fall back to `auto`.
|
|
428
|
+
|
|
429
|
+
When the cap is greater than or equal to a phase's command count, behaviour is identical to unbounded parallel execution — so well-provisioned machines see no change. As each command finishes, the next queued one starts, keeping at most `max_concurrency` in flight. `--sequential` still wins (it is equivalent to a cap of 1).
|
|
430
|
+
|
|
431
|
+
#### Configuration
|
|
432
|
+
|
|
433
|
+
```js
|
|
434
|
+
export default {
|
|
435
|
+
max_concurrency: 'auto', // or a number like 4
|
|
436
|
+
phases: [
|
|
437
|
+
{
|
|
438
|
+
name: 'quality checks',
|
|
439
|
+
parallel: [
|
|
440
|
+
{ command: 'lint' },
|
|
441
|
+
{ command: 'typecheck' },
|
|
442
|
+
{ command: 'test' },
|
|
443
|
+
// ...more commands than the machine can run at once
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
};
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
#### CLI override
|
|
451
|
+
|
|
452
|
+
The `--max-concurrency` flag overrides the configured value for a single run:
|
|
453
|
+
|
|
454
|
+
```bash
|
|
455
|
+
# Run at most 3 commands per phase concurrently
|
|
456
|
+
npm run scripts-orchestrator -- --max-concurrency 3
|
|
457
|
+
|
|
458
|
+
# Force the auto cap (CPU count - 1) regardless of config
|
|
459
|
+
npm run scripts-orchestrator -- --max-concurrency auto
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
At the start of a parallel run the orchestrator logs the resolved cap, e.g. `🧮 Max concurrency: 3 (of 8 CPUs)`.
|
|
463
|
+
|
|
464
|
+
#### Per-phase override
|
|
465
|
+
|
|
466
|
+
A single phase can pin its own cap by setting `max_concurrency` on the phase itself. This is the right tool when one phase's commands share a single resource — a dev server, a GPU, a port — and must run one at a time, while every other phase keeps the global cap and its parallelism. The phase value resolves the same way as the global one (`'auto'` and invalid values fall back to CPU count − 1), and overrides both the configured global cap and any `--max-concurrency` flag for that phase only.
|
|
467
|
+
|
|
468
|
+
```js
|
|
469
|
+
export default {
|
|
470
|
+
max_concurrency: 'auto', // global default for every phase
|
|
471
|
+
phases: [
|
|
472
|
+
{ name: 'quality checks', parallel: [/* runs at the global cap */] },
|
|
473
|
+
{
|
|
474
|
+
name: 'browser suites',
|
|
475
|
+
max_concurrency: 1, // serialise just this phase (one shared dev server)
|
|
476
|
+
parallel: [
|
|
477
|
+
{ command: 'e2e:group-a' },
|
|
478
|
+
{ command: 'e2e:group-b' },
|
|
479
|
+
// ...each runs in turn; a failed group does NOT abort the others
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
};
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
A serial phase (`max_concurrency: 1`) still runs through the parallel path, so it **continues past a failed command** rather than stopping on the first failure — unlike the global `--sequential` flag, which both serialises everything and stops the phase at its first failure. When a phase's cap differs from the global one the orchestrator logs the override, e.g. `↳ phase concurrency: 1 (phase "browser suites" overrides the 3 default)`.
|
|
487
|
+
|
|
420
488
|
## Error Handling
|
|
421
489
|
|
|
422
490
|
- The script tracks failed and skipped commands
|
package/index.js
CHANGED
|
@@ -35,6 +35,11 @@ const argv = yargs(hideBin(process.argv))
|
|
|
35
35
|
type: 'boolean',
|
|
36
36
|
description: 'Run all commands sequentially instead of in parallel (for low CPU machines)',
|
|
37
37
|
})
|
|
38
|
+
.option('max-concurrency', {
|
|
39
|
+
type: 'string',
|
|
40
|
+
description:
|
|
41
|
+
'Cap how many commands a phase runs at once: a positive integer, or \'auto\' (cpuCount - 1). Overrides config max_concurrency. Ignored under --sequential.',
|
|
42
|
+
})
|
|
38
43
|
.option('force', {
|
|
39
44
|
type: 'boolean',
|
|
40
45
|
description: 'Force execution even if git state is unchanged',
|
|
@@ -323,6 +328,10 @@ orchestrator.periodicHook = periodicHook;
|
|
|
323
328
|
orchestrator.periodicIntervalMs = periodicIntervalMs;
|
|
324
329
|
// Wire declarative in-process workspace roll-up (takes the in-process path when set)
|
|
325
330
|
orchestrator.aggregateOptions = aggregateOptions;
|
|
331
|
+
// CLI --max-concurrency overrides the config's max_concurrency (resolved to a concrete cap).
|
|
332
|
+
if (argv.maxConcurrency != null && argv.maxConcurrency !== '') {
|
|
333
|
+
orchestrator.maxConcurrency = orchestrator._resolveMaxConcurrency(argv.maxConcurrency);
|
|
334
|
+
}
|
|
326
335
|
|
|
327
336
|
// Enhanced signal handlers
|
|
328
337
|
const handleSignal = async (signal) => {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import { Orchestrator } from './orchestrator.js';
|
|
3
|
+
|
|
4
|
+
const baseConfig = (extra = {}) => ({
|
|
5
|
+
phases: [{ name: 'p', parallel: [{ command: 'build' }] }],
|
|
6
|
+
...extra,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const cpuAuto = () => Math.max(1, os.cpus().length - 1);
|
|
10
|
+
|
|
11
|
+
describe('Orchestrator max concurrency resolution', () => {
|
|
12
|
+
test('defaults to auto (cpuCount - 1) when nothing is configured', () => {
|
|
13
|
+
const orch = new Orchestrator(baseConfig());
|
|
14
|
+
expect(orch.maxConcurrency).toBe(cpuAuto());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('config max_concurrency: \'auto\' resolves to cpuCount - 1', () => {
|
|
18
|
+
const orch = new Orchestrator(baseConfig({ max_concurrency: 'auto' }));
|
|
19
|
+
expect(orch.maxConcurrency).toBe(cpuAuto());
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a positive integer is used verbatim (floored)', () => {
|
|
23
|
+
expect(new Orchestrator(baseConfig({ max_concurrency: 4 })).maxConcurrency).toBe(4);
|
|
24
|
+
expect(new Orchestrator(baseConfig({ max_concurrency: 3.9 })).maxConcurrency).toBe(3);
|
|
25
|
+
expect(new Orchestrator(baseConfig({ max_concurrency: '6' })).maxConcurrency).toBe(6);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('zero, negative, and unparseable values fall back to auto', () => {
|
|
29
|
+
expect(new Orchestrator(baseConfig({ max_concurrency: 0 })).maxConcurrency).toBe(cpuAuto());
|
|
30
|
+
expect(new Orchestrator(baseConfig({ max_concurrency: -2 })).maxConcurrency).toBe(cpuAuto());
|
|
31
|
+
expect(new Orchestrator(baseConfig({ max_concurrency: 'lots' })).maxConcurrency).toBe(cpuAuto());
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('legacy array config still resolves a cap (no max_concurrency key available)', () => {
|
|
35
|
+
const orch = new Orchestrator([{ command: 'a' }, { command: 'b' }]);
|
|
36
|
+
expect(orch.maxConcurrency).toBe(cpuAuto());
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('Orchestrator._runWithConcurrency', () => {
|
|
41
|
+
test('preserves input order in the results array', async () => {
|
|
42
|
+
const orch = new Orchestrator(baseConfig());
|
|
43
|
+
const items = [10, 20, 30, 40];
|
|
44
|
+
const out = await orch._runWithConcurrency(items, 2, async (n) => n * 2);
|
|
45
|
+
expect(out).toEqual([20, 40, 60, 80]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('never exceeds the concurrency limit and still runs every item', async () => {
|
|
49
|
+
const orch = new Orchestrator(baseConfig());
|
|
50
|
+
let inFlight = 0;
|
|
51
|
+
let peak = 0;
|
|
52
|
+
const limit = 3;
|
|
53
|
+
const items = Array.from({ length: 12 }, (_, i) => i);
|
|
54
|
+
const worker = async (i) => {
|
|
55
|
+
inFlight += 1;
|
|
56
|
+
peak = Math.max(peak, inFlight);
|
|
57
|
+
// Yield a couple of microtasks so overlap actually happens.
|
|
58
|
+
await Promise.resolve();
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
inFlight -= 1;
|
|
61
|
+
return i;
|
|
62
|
+
};
|
|
63
|
+
const out = await orch._runWithConcurrency(items, limit, worker);
|
|
64
|
+
expect(out).toEqual(items);
|
|
65
|
+
expect(peak).toBeLessThanOrEqual(limit);
|
|
66
|
+
expect(peak).toBeGreaterThan(1); // proves it actually parallelised
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('limit >= item count behaves like Promise.all (all start immediately)', async () => {
|
|
70
|
+
const orch = new Orchestrator(baseConfig());
|
|
71
|
+
let inFlight = 0;
|
|
72
|
+
let peak = 0;
|
|
73
|
+
const items = [1, 2, 3];
|
|
74
|
+
await orch._runWithConcurrency(items, 10, async (n) => {
|
|
75
|
+
inFlight += 1;
|
|
76
|
+
peak = Math.max(peak, inFlight);
|
|
77
|
+
await Promise.resolve();
|
|
78
|
+
inFlight -= 1;
|
|
79
|
+
return n;
|
|
80
|
+
});
|
|
81
|
+
expect(peak).toBe(items.length);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('empty input resolves to an empty array', async () => {
|
|
85
|
+
const orch = new Orchestrator(baseConfig());
|
|
86
|
+
await expect(orch._runWithConcurrency([], 4, async (n) => n)).resolves.toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('Orchestrator._phaseConcurrency (per-phase override)', () => {
|
|
91
|
+
test('falls back to the global cap when a phase has no max_concurrency', () => {
|
|
92
|
+
const orch = new Orchestrator(baseConfig({ max_concurrency: 5 }));
|
|
93
|
+
expect(orch._phaseConcurrency({ name: 'p', parallel: [] })).toBe(5);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('a phase max_concurrency overrides the global cap (both directions)', () => {
|
|
97
|
+
const orch = new Orchestrator(baseConfig({ max_concurrency: 5 }));
|
|
98
|
+
expect(orch._phaseConcurrency({ name: 'serial', max_concurrency: 1 })).toBe(1);
|
|
99
|
+
expect(orch._phaseConcurrency({ name: 'wider', max_concurrency: 8 })).toBe(8);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a phase\'s \'auto\' resolves to cpuCount - 1 regardless of the global cap', () => {
|
|
103
|
+
const orch = new Orchestrator(baseConfig({ max_concurrency: 1 }));
|
|
104
|
+
expect(orch._phaseConcurrency({ name: 'p', max_concurrency: 'auto' })).toBe(cpuAuto());
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('an invalid phase value falls back to auto, not to the global cap', () => {
|
|
108
|
+
const orch = new Orchestrator(baseConfig({ max_concurrency: 5 }));
|
|
109
|
+
expect(orch._phaseConcurrency({ name: 'p', max_concurrency: 0 })).toBe(cpuAuto());
|
|
110
|
+
expect(orch._phaseConcurrency({ name: 'p', max_concurrency: 'lots' })).toBe(cpuAuto());
|
|
111
|
+
});
|
|
112
|
+
});
|
package/lib/orchestrator.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import { spawn, spawnSync } from 'child_process';
|
|
4
5
|
import { processManager } from './process-manager.js';
|
|
@@ -28,6 +29,18 @@ export class Orchestrator {
|
|
|
28
29
|
this.sequential = sequential;
|
|
29
30
|
this.force = force;
|
|
30
31
|
this.metrics = Array.isArray(metrics) ? metrics : [];
|
|
32
|
+
// Maximum number of commands a phase runs concurrently. Without this, a phase fires every
|
|
33
|
+
// enabled command at once (Promise.all) — fine on a big CI box, but a smaller machine can't
|
|
34
|
+
// sustain N heavy toolchains in parallel. `max_concurrency` caps the in-flight count:
|
|
35
|
+
// - 'auto' (default) -> max(1, cpuCount - 1)
|
|
36
|
+
// - a positive integer -> that exact cap
|
|
37
|
+
// - 0 / negative / invalid -> treated as 'auto'
|
|
38
|
+
// `--sequential` still wins (it pins the cap to 1). When the cap >= a phase's command count the
|
|
39
|
+
// behaviour is identical to the old unbounded Promise.all, so big machines see no change.
|
|
40
|
+
// CLI `--max-concurrency` overrides the config value (wired in index.js).
|
|
41
|
+
this.maxConcurrency = this._resolveMaxConcurrency(
|
|
42
|
+
config && !Array.isArray(config) ? config.max_concurrency : undefined,
|
|
43
|
+
);
|
|
31
44
|
// Global command prefix. Defaults to 'npm run' so existing configs keep working.
|
|
32
45
|
// Set `command_prefix` to '' / false / null in the config to run commands verbatim
|
|
33
46
|
// as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
|
|
@@ -98,6 +111,52 @@ export class Orchestrator {
|
|
|
98
111
|
return [];
|
|
99
112
|
}
|
|
100
113
|
|
|
114
|
+
// Resolve a configured/CLI max_concurrency value to a concrete positive integer cap.
|
|
115
|
+
// 'auto' (or anything unparseable / <= 0) maps to max(1, cpuCount - 1); a positive number is
|
|
116
|
+
// floored and used verbatim. Kept side-effect free so it can be reused by the CLI override.
|
|
117
|
+
_resolveMaxConcurrency(value) {
|
|
118
|
+
const auto = Math.max(1, os.cpus().length - 1);
|
|
119
|
+
if (value == null || value === 'auto') return auto;
|
|
120
|
+
const n = Math.floor(Number(value));
|
|
121
|
+
if (!Number.isFinite(n) || n <= 0) return auto;
|
|
122
|
+
return n;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Concurrency cap for a single phase. A phase may pin its own `max_concurrency` to
|
|
126
|
+
// run its commands at a different in-flight count than the rest of the run — e.g. a
|
|
127
|
+
// heavy phase whose commands share one resource (a single dev server, a GPU) sets
|
|
128
|
+
// `max_concurrency: 1` to serialise itself while every other phase keeps the global
|
|
129
|
+
// cap and its parallelism. Resolved the same way as the global value, so 'auto' and
|
|
130
|
+
// invalid entries fall back to cpuCount-1 (not silently to the global cap). Phase
|
|
131
|
+
// commands still run through the non-breaking parallel path, so a serial phase
|
|
132
|
+
// continues past a failed command rather than aborting its siblings.
|
|
133
|
+
_phaseConcurrency(phase) {
|
|
134
|
+
if (phase && phase.max_concurrency != null) {
|
|
135
|
+
return this._resolveMaxConcurrency(phase.max_concurrency);
|
|
136
|
+
}
|
|
137
|
+
return this.maxConcurrency;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Run `items` through `worker` with at most `limit` in flight at once, preserving result order.
|
|
141
|
+
// This is the bounded-concurrency replacement for `Promise.all(items.map(worker))`: when
|
|
142
|
+
// `limit >= items.length` it is behaviourally identical (everything starts immediately), but a
|
|
143
|
+
// smaller limit keeps only `limit` tasks running and starts the next as each finishes. A worker
|
|
144
|
+
// that rejects rejects the whole batch (matching Promise.all semantics); executeCommand resolves
|
|
145
|
+
// rather than throws, so in practice failures surface as falsy results, not rejections.
|
|
146
|
+
async _runWithConcurrency(items, limit, worker) {
|
|
147
|
+
const results = new Array(items.length);
|
|
148
|
+
let next = 0;
|
|
149
|
+
const runNext = async () => {
|
|
150
|
+
while (next < items.length) {
|
|
151
|
+
const current = next++;
|
|
152
|
+
results[current] = await worker(items[current], current);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const pool = Array.from({ length: Math.min(limit, items.length) }, () => runNext());
|
|
156
|
+
await Promise.all(pool);
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
101
160
|
_deriveEventsPath(jsonResultsPath) {
|
|
102
161
|
if (!jsonResultsPath || jsonResultsPath === '-') return null;
|
|
103
162
|
return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
|
|
@@ -694,16 +753,21 @@ export class Orchestrator {
|
|
|
694
753
|
}
|
|
695
754
|
}
|
|
696
755
|
} else {
|
|
697
|
-
const
|
|
698
|
-
this.
|
|
756
|
+
const results = await this._runWithConcurrency(
|
|
757
|
+
this.config,
|
|
758
|
+
this.maxConcurrency,
|
|
759
|
+
(commandConfig) => this.executeCommand(commandConfig),
|
|
699
760
|
);
|
|
700
|
-
const results = await Promise.all(tasks);
|
|
701
761
|
hasFailures = results.some((result) => !result);
|
|
702
762
|
}
|
|
703
763
|
} else if (this.config.phases) {
|
|
704
764
|
// New: Run phases sequentially, commands within phases in parallel or sequential based on flag
|
|
705
765
|
if (this.sequential) {
|
|
706
766
|
this.logger.info('🔄 Running in sequential mode');
|
|
767
|
+
} else {
|
|
768
|
+
this.logger.info(
|
|
769
|
+
`🧮 Max concurrency: ${this.maxConcurrency} (of ${os.cpus().length} CPUs) — commands per phase run at most this many at a time`,
|
|
770
|
+
);
|
|
707
771
|
}
|
|
708
772
|
|
|
709
773
|
for (const phase of this.config.phases) {
|
|
@@ -766,11 +830,22 @@ export class Orchestrator {
|
|
|
766
830
|
}
|
|
767
831
|
}
|
|
768
832
|
} else {
|
|
769
|
-
// Run commands in parallel
|
|
770
|
-
|
|
771
|
-
|
|
833
|
+
// Run commands in parallel, but never more than the cap at once so a smaller
|
|
834
|
+
// machine isn't asked to host every command's toolchain simultaneously. The
|
|
835
|
+
// cap is per-phase: a phase may pin its own `max_concurrency` (e.g. force
|
|
836
|
+
// serial execution of commands that share one resource) without changing the
|
|
837
|
+
// limit any other phase runs at.
|
|
838
|
+
const phaseConcurrency = this._phaseConcurrency(phase);
|
|
839
|
+
if (phaseConcurrency !== this.maxConcurrency) {
|
|
840
|
+
this.logger.info(
|
|
841
|
+
` ↳ phase concurrency: ${phaseConcurrency} (phase "${phase.name}" overrides the ${this.maxConcurrency} default)`,
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
results = await this._runWithConcurrency(
|
|
845
|
+
phase.parallel,
|
|
846
|
+
phaseConcurrency,
|
|
847
|
+
(commandConfig) => this.executeCommand(commandConfig, new Set(), phase.name),
|
|
772
848
|
);
|
|
773
|
-
results = await Promise.all(tasks);
|
|
774
849
|
}
|
|
775
850
|
|
|
776
851
|
const phaseHasFailures = results.some((result) => !result);
|
package/lib/report-html.js
CHANGED
|
@@ -152,10 +152,12 @@ function renderCell(key, c, ctx) {
|
|
|
152
152
|
return v != null ? durCellWrap(v, ctx.maxDuration, ctx.durationHeatThresholds) : '—';
|
|
153
153
|
case 'memoryKb':
|
|
154
154
|
return v != null ? memCellWrap(v, ctx.maxMemory, ctx.heatThresholds) : '—';
|
|
155
|
-
case 'logFile':
|
|
155
|
+
case 'logFile': {
|
|
156
|
+
const base = ctx.repoRoot || process.cwd();
|
|
156
157
|
return v
|
|
157
|
-
? `<a class="logref" href="file://${escapeHtml(path.resolve(
|
|
158
|
+
? `<a class="logref" href="file://${escapeHtml(path.resolve(base, v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
|
|
158
159
|
: '—';
|
|
160
|
+
}
|
|
159
161
|
default:
|
|
160
162
|
if (v == null) return '—';
|
|
161
163
|
return escapeHtml(typeof v === 'object' ? JSON.stringify(v) : v);
|
|
@@ -356,6 +358,7 @@ export function renderReportHtml(payload) {
|
|
|
356
358
|
const ctx = {
|
|
357
359
|
maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
|
|
358
360
|
maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
|
|
361
|
+
repoRoot: payload.repoRoot,
|
|
359
362
|
// Heat thresholds travel in the payload so re-rendering a saved JSON (--render) honours them.
|
|
360
363
|
heatThresholds: normalizeHeatThresholds(payload.memoryHeat),
|
|
361
364
|
durationHeatThresholds: normalizeHeatThresholds(payload.durationHeat),
|
package/lib/workspaces.js
CHANGED
|
@@ -247,12 +247,26 @@ export function aggregateWorkspacesReport(options = {}) {
|
|
|
247
247
|
const fanoutOk = fanoutCommands.length === 0 ? null : fanoutCommands.every((c) => c.success !== false);
|
|
248
248
|
|
|
249
249
|
// Global quality checks section (commands run by the root orchestrator itself).
|
|
250
|
+
// Classify from the section's OWN commands, not the report-wide inProgress flag: the global
|
|
251
|
+
// checks finish before the workspace fan-out, so they must read OK as soon as they are done —
|
|
252
|
+
// even while other workspaces are still RUNNING in an in-progress snapshot.
|
|
250
253
|
const globalCommands = collectGlobalCommands(opts, runStartedAt);
|
|
251
254
|
const globalFailed = globalCommands.filter((c) => c.success === false).length;
|
|
255
|
+
const globalRunning = globalCommands.some((c) => c.success == null);
|
|
256
|
+
const globalState =
|
|
257
|
+
globalCommands.length === 0
|
|
258
|
+
? { statusKind: 'muted', statusLabel: 'PENDING', success: null }
|
|
259
|
+
: globalRunning
|
|
260
|
+
? { statusKind: 'running', statusLabel: 'RUNNING', success: null }
|
|
261
|
+
: globalFailed > 0
|
|
262
|
+
? { statusKind: 'fail', statusLabel: 'FAIL', success: false }
|
|
263
|
+
: { statusKind: 'ok', statusLabel: 'OK', success: true };
|
|
252
264
|
const sections = [
|
|
253
265
|
{
|
|
254
266
|
title: 'Global quality checks',
|
|
255
|
-
|
|
267
|
+
statusKind: globalState.statusKind,
|
|
268
|
+
statusLabel: globalState.statusLabel,
|
|
269
|
+
success: globalState.success,
|
|
256
270
|
commands: globalCommands,
|
|
257
271
|
},
|
|
258
272
|
];
|
|
@@ -277,15 +291,18 @@ export function aggregateWorkspacesReport(options = {}) {
|
|
|
277
291
|
const st = classifyWorkspace({ noOp, exists, stale, success, inProgress, fanoutOk });
|
|
278
292
|
if (st.state === 'fail' || st.state === 'interrupted') anyWorkspaceBad = true;
|
|
279
293
|
|
|
280
|
-
const showCommands =
|
|
281
|
-
|
|
294
|
+
const showCommands =
|
|
295
|
+
st.state === 'ok' || st.state === 'fail' || st.state === 'cached' || st.state === 'running';
|
|
296
|
+
const commands = showCommands ? commandsWithRepoRelLogs(payload?.commands, relDir) : [];
|
|
282
297
|
|
|
283
298
|
const note =
|
|
284
299
|
commands.length === 0 && st.state === 'pending'
|
|
285
300
|
? 'Queued — not started yet this run'
|
|
286
301
|
: commands.length === 0 && st.state === 'stale'
|
|
287
302
|
? 'No results for this run window (stale snapshot)'
|
|
288
|
-
:
|
|
303
|
+
: st.state === 'running' && commands.length > 0
|
|
304
|
+
? 'In progress — partial command list'
|
|
305
|
+
: null;
|
|
289
306
|
|
|
290
307
|
sections.push({
|
|
291
308
|
title: name,
|
|
@@ -297,7 +314,7 @@ export function aggregateWorkspacesReport(options = {}) {
|
|
|
297
314
|
: st.state === 'fail'
|
|
298
315
|
? false
|
|
299
316
|
: null,
|
|
300
|
-
overallDurationMs: showCommands ? payload
|
|
317
|
+
overallDurationMs: showCommands ? payload?.overallDurationMs : undefined,
|
|
301
318
|
meta: { path: relDir, ...(note ? { note } : {}) },
|
|
302
319
|
commands,
|
|
303
320
|
});
|
|
@@ -306,12 +323,18 @@ export function aggregateWorkspacesReport(options = {}) {
|
|
|
306
323
|
const rootOk = rootJson ? rootJson.success !== false : true;
|
|
307
324
|
const success = inProgress ? null : rootOk && globalFailed === 0 && !anyWorkspaceBad;
|
|
308
325
|
|
|
326
|
+
const overallDurationMs =
|
|
327
|
+
inProgress && runStartedAt != null
|
|
328
|
+
? Math.max(0, Date.now() - runStartedAt)
|
|
329
|
+
: rootJson?.overallDurationMs;
|
|
330
|
+
|
|
309
331
|
return {
|
|
310
332
|
title: opts.title,
|
|
311
333
|
success,
|
|
312
334
|
timestamp: new Date().toISOString(),
|
|
313
|
-
overallDurationMs
|
|
335
|
+
overallDurationMs,
|
|
314
336
|
inProgress,
|
|
337
|
+
repoRoot: toPosix(opts.repoRoot),
|
|
315
338
|
sections,
|
|
316
339
|
// Carry heat thresholds into the aggregate payload so the renderer honours them.
|
|
317
340
|
...(opts.memoryHeat ? { memoryHeat: opts.memoryHeat } : {}),
|
package/lib/workspaces.test.js
CHANGED
|
@@ -142,6 +142,7 @@ describe('aggregateWorkspacesReport', () => {
|
|
|
142
142
|
expect(report.success).toBe(false);
|
|
143
143
|
const global = report.sections.find((s) => s.title === 'Global quality checks');
|
|
144
144
|
expect(global.success).toBe(false);
|
|
145
|
+
expect(global.statusLabel).toBe('FAIL');
|
|
145
146
|
});
|
|
146
147
|
|
|
147
148
|
test('in-progress run: success:null workspace is RUNNING, missing is PENDING', () => {
|
|
@@ -155,10 +156,45 @@ describe('aggregateWorkspacesReport', () => {
|
|
|
155
156
|
const report = aggregateWorkspacesReport({ repoRoot });
|
|
156
157
|
expect(report.inProgress).toBe(true);
|
|
157
158
|
expect(report.success).toBeNull();
|
|
158
|
-
|
|
159
|
+
const web = report.sections.find((s) => s.title === 'apps/web');
|
|
160
|
+
expect(web.statusLabel).toBe('RUNNING');
|
|
161
|
+
expect(web.commands).toHaveLength(1);
|
|
162
|
+
expect(web.commands[0].command).toBe('test');
|
|
163
|
+
expect(web.meta.note).toMatch(/partial command list/i);
|
|
159
164
|
expect(report.sections.find((s) => s.title === 'packages/a').statusLabel).toBe('PENDING');
|
|
160
165
|
});
|
|
161
166
|
|
|
167
|
+
test('global checks read OK as soon as they finish, even mid-run while a workspace is RUNNING', () => {
|
|
168
|
+
markRunning();
|
|
169
|
+
rootResults([{ command: 'lint', phase: 'global quality checks', success: true }], null);
|
|
170
|
+
makeWorkspace('apps/web', {
|
|
171
|
+
results: { success: null, timestamp: fresh(), commands: [{ command: 'test', success: null }] },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const report = aggregateWorkspacesReport({ repoRoot });
|
|
175
|
+
expect(report.inProgress).toBe(true);
|
|
176
|
+
const global = report.sections.find((s) => s.title === 'Global quality checks');
|
|
177
|
+
expect(global.statusLabel).toBe('OK');
|
|
178
|
+
expect(global.success).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('global section is RUNNING while one of its own commands is still in flight', () => {
|
|
182
|
+
markRunning();
|
|
183
|
+
rootResults(
|
|
184
|
+
[
|
|
185
|
+
{ command: 'lint', phase: 'global quality checks', success: true },
|
|
186
|
+
{ command: 'i18n', phase: 'global quality checks', success: null, startedAt: fresh() },
|
|
187
|
+
],
|
|
188
|
+
null,
|
|
189
|
+
);
|
|
190
|
+
makeWorkspace('apps/web'); // no results yet
|
|
191
|
+
|
|
192
|
+
const report = aggregateWorkspacesReport({ repoRoot });
|
|
193
|
+
const global = report.sections.find((s) => s.title === 'Global quality checks');
|
|
194
|
+
expect(global.statusLabel).toBe('RUNNING');
|
|
195
|
+
expect(global.success).toBeNull();
|
|
196
|
+
});
|
|
197
|
+
|
|
162
198
|
test('after the run, a success:null results file reads as INTERRUPTED, not running', () => {
|
|
163
199
|
rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
|
|
164
200
|
makeWorkspace('apps/web', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scripts-orchestrator",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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",
|