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 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
+ });
@@ -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 tasks = this.config.map((commandConfig) =>
698
- this.executeCommand(commandConfig),
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
- const tasks = phase.parallel.map((commandConfig) =>
771
- this.executeCommand(commandConfig, new Set(), phase.name),
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);
@@ -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(process.cwd(), v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
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
- success: inProgress ? null : globalFailed === 0,
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 = st.state === 'ok' || st.state === 'fail' || st.state === 'cached';
281
- const commands = showCommands ? commandsWithRepoRelLogs(payload.commands, relDir) : [];
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
- : null;
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.overallDurationMs : undefined,
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: rootJson?.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 } : {}),
@@ -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
- expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('RUNNING');
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.5.0",
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",