scripts-orchestrator 3.0.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.
@@ -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';
@@ -6,6 +7,7 @@ import { healthCheck } from './health-check.js';
6
7
  import { log } from './logger.js';
7
8
  import { GitCache } from './git-cache.js';
8
9
  import { renderReportHtml } from './report-html.js';
10
+ import { findRepoRoot, writeAggregateReport } from './workspaces.js';
9
11
  import chalk from 'chalk';
10
12
 
11
13
  export class Orchestrator {
@@ -27,6 +29,18 @@ export class Orchestrator {
27
29
  this.sequential = sequential;
28
30
  this.force = force;
29
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
+ );
30
44
  // Global command prefix. Defaults to 'npm run' so existing configs keep working.
31
45
  // Set `command_prefix` to '' / false / null in the config to run commands verbatim
32
46
  // as regular shell commands. Per-command `shell: true` or `prefix` overrides this.
@@ -55,9 +69,21 @@ export class Orchestrator {
55
69
  : null;
56
70
  // post-run hook command (shell string)
57
71
  this.postRun = null; // set from config in index.js
72
+ // Memory heat thresholds for the HTML report (fractions of the run's peak). Set from config in
73
+ // index.js; embedded into the results payload so the renderer (and --render) honour them.
74
+ this.memoryHeat = null;
75
+ // Duration heat thresholds for the HTML report (fractions of the run's slowest command). Set from
76
+ // config in index.js; embedded into the results payload so the renderer (and --render) honour them.
77
+ this.durationHeat = null;
58
78
  // Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
59
79
  // The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
60
80
  this.periodicHook = null;
81
+ // Declarative npm-workspace roll-up (set from config in index.js). When non-null, the library
82
+ // drives the workspace aggregate IN-PROCESS — no shell-out — using these writeAggregateReport
83
+ // options: the repo-root run owns the periodic cadence + final static report, while a fanned-out
84
+ // workspace run refreshes the roll-up once when it finishes. Replaces wiring a periodic_hook /
85
+ // post_run that shells out to `scripts-orchestrator --aggregate`.
86
+ this.aggregateOptions = null;
61
87
  this.periodicIntervalMs = 45000;
62
88
  this._periodicTimer = null;
63
89
  this._periodicRunning = false;
@@ -85,6 +111,52 @@ export class Orchestrator {
85
111
  return [];
86
112
  }
87
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
+
88
160
  _deriveEventsPath(jsonResultsPath) {
89
161
  if (!jsonResultsPath || jsonResultsPath === '-') return null;
90
162
  return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
@@ -186,6 +258,8 @@ export class Orchestrator {
186
258
  ...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
187
259
  commands,
188
260
  ...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
261
+ ...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
262
+ ...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
189
263
  };
190
264
 
191
265
  const tmpPath = outPath + '.tmp';
@@ -597,6 +671,8 @@ export class Orchestrator {
597
671
  ...(this.config.phases && this.phaseResults.length > 0
598
672
  ? { phases: this.phaseResults }
599
673
  : {}),
674
+ ...(this.memoryHeat ? { memoryHeat: this.memoryHeat } : {}),
675
+ ...(this.durationHeat ? { durationHeat: this.durationHeat } : {}),
600
676
  };
601
677
 
602
678
  const json = JSON.stringify(payload, null, 2);
@@ -677,16 +753,21 @@ export class Orchestrator {
677
753
  }
678
754
  }
679
755
  } else {
680
- const tasks = this.config.map((commandConfig) =>
681
- this.executeCommand(commandConfig),
756
+ const results = await this._runWithConcurrency(
757
+ this.config,
758
+ this.maxConcurrency,
759
+ (commandConfig) => this.executeCommand(commandConfig),
682
760
  );
683
- const results = await Promise.all(tasks);
684
761
  hasFailures = results.some((result) => !result);
685
762
  }
686
763
  } else if (this.config.phases) {
687
764
  // New: Run phases sequentially, commands within phases in parallel or sequential based on flag
688
765
  if (this.sequential) {
689
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
+ );
690
771
  }
691
772
 
692
773
  for (const phase of this.config.phases) {
@@ -749,11 +830,22 @@ export class Orchestrator {
749
830
  }
750
831
  }
751
832
  } else {
752
- // Run commands in parallel
753
- const tasks = phase.parallel.map((commandConfig) =>
754
- 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),
755
848
  );
756
- results = await Promise.all(tasks);
757
849
  }
758
850
 
759
851
  const phaseHasFailures = results.some((result) => !result);
@@ -893,11 +985,43 @@ export class Orchestrator {
893
985
  }
894
986
  }
895
987
 
988
+ // Is this process the repo-root orchestrator run (vs. a fanned-out workspace run)? The root run
989
+ // owns the periodic roll-up cadence and the final static report; a workspace run only refreshes
990
+ // the aggregate once as it finishes (mirrors the old root periodic_hook / workspace post_run split).
991
+ _isRepoRootRun() {
992
+ try {
993
+ const root = findRepoRoot(process.cwd());
994
+ return root != null && path.resolve(process.cwd()) === root;
995
+ } catch {
996
+ return false;
997
+ }
998
+ }
999
+
1000
+ // Roll up every workspace's results into the aggregate report, in-process. inProgress is left
1001
+ // undefined (auto-detected from the root run-state file) except for the root run's final fire.
1002
+ _fireAggregate(inProgress) {
1003
+ try {
1004
+ const opts = inProgress == null
1005
+ ? this.aggregateOptions
1006
+ : { ...this.aggregateOptions, inProgress };
1007
+ const { jsonPath } = writeAggregateReport(opts);
1008
+ this.logger.verbose(`[aggregate] rolled up workspaces → ${jsonPath}`);
1009
+ } catch (err) {
1010
+ this.logger.warn(`[aggregate] roll-up failed: ${err.message}`);
1011
+ }
1012
+ }
1013
+
896
1014
  // Periodic hook: start the interval timer (fires once promptly, then every interval).
897
1015
  _startPeriodicHook() {
898
- if (!this.periodicHook || this._periodicTimer) return;
1016
+ if (this._periodicTimer) return;
1017
+ if (!this.periodicHook && !this.aggregateOptions) return;
1018
+ // In-process aggregate: only the repo-root run drives the periodic cadence. A fanned-out
1019
+ // workspace run refreshes the roll-up just once, at the end (see _firePeriodicHookFinal).
1020
+ if (this.aggregateOptions && !this.periodicHook && !this._isRepoRootRun()) return;
899
1021
  this.logger.info(
900
- `⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`,
1022
+ this.periodicHook
1023
+ ? `⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`
1024
+ : `⏱️ Periodic workspace roll-up every ${Math.round(this.periodicIntervalMs / 1000)}s (in-process)`,
901
1025
  );
902
1026
  this._firePeriodicTick(); // prompt first roll-up so an initial aggregate exists
903
1027
  this._periodicTimer = setInterval(() => this._firePeriodicTick(), this.periodicIntervalMs);
@@ -906,6 +1030,11 @@ export class Orchestrator {
906
1030
 
907
1031
  // Fire the periodic hook asynchronously, with an overlap guard so slow hooks don't pile up.
908
1032
  _firePeriodicTick() {
1033
+ // In-process aggregate path: synchronous, so no overlap guard needed.
1034
+ if (this.aggregateOptions && !this.periodicHook) {
1035
+ this._fireAggregate(undefined);
1036
+ return;
1037
+ }
909
1038
  if (!this.periodicHook || this._periodicRunning) {
910
1039
  if (this._periodicRunning) {
911
1040
  this.logger.verbose('[periodic_hook] previous invocation still running; skipping tick');
@@ -947,6 +1076,14 @@ export class Orchestrator {
947
1076
 
948
1077
  // Final synchronous fire so the aggregate reflects the finished run before the process exits.
949
1078
  _firePeriodicHookFinal() {
1079
+ // In-process aggregate path: the repo-root run owns the "run complete" signal, so it forces a
1080
+ // static (non-refresh) report. A fanned-out workspace run must NOT — the root run is still in
1081
+ // flight, so leave inProgress auto-detected from the (still-present) root run-state file.
1082
+ if (this.aggregateOptions && !this.periodicHook) {
1083
+ this._stopPeriodicHook();
1084
+ this._fireAggregate(this._isRepoRootRun() ? false : undefined);
1085
+ return;
1086
+ }
950
1087
  if (!this.periodicHook) return;
951
1088
  this._stopPeriodicHook();
952
1089
  try {
@@ -27,6 +27,45 @@ export function formatMs(ms) {
27
27
  return `${s}s`;
28
28
  }
29
29
 
30
+ // Compact memory formatting (input in KB): MB up to ~1 GB, then GB.
31
+ export function formatMem(kb) {
32
+ if (kb == null) return '—';
33
+ const mb = kb / 1024;
34
+ if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
35
+ return `${(mb / 1024).toFixed(1)} GB`;
36
+ }
37
+
38
+ // Default heat thresholds (fraction of the heaviest command in the run).
39
+ export const DEFAULT_HEAT_THRESHOLDS = { mid: 0.33, high: 0.66 };
40
+
41
+ // Normalize caller-supplied thresholds, clamping to (0,1) and ensuring mid < high.
42
+ // Falls back to a default for any missing/invalid value.
43
+ export function normalizeHeatThresholds(t) {
44
+ const def = DEFAULT_HEAT_THRESHOLDS;
45
+ const clamp = (v, fallback) => (Number.isFinite(v) && v > 0 && v < 1 ? v : fallback);
46
+ let mid = clamp(t?.mid, def.mid);
47
+ let high = clamp(t?.high, def.high);
48
+ if (mid >= high) { mid = def.mid; high = def.high; }
49
+ return { mid, high };
50
+ }
51
+
52
+ // Classify a value relative to the maximum in the run (low / mid / high), so heavy or
53
+ // slow commands can be flagged green→amber→red. Thresholds (fractions of the run's max)
54
+ // are configurable; defaults are used otherwise. Returns null when no value was recorded.
55
+ export function heatLevel(value, max, thresholds = DEFAULT_HEAT_THRESHOLDS) {
56
+ if (value == null || !(max > 0)) return null;
57
+ const frac = value / max;
58
+ if (frac >= thresholds.high) return 'high';
59
+ if (frac >= thresholds.mid) return 'mid';
60
+ return 'low';
61
+ }
62
+
63
+ // Classify a command's memory use relative to the heaviest in the run, so the
64
+ // Gantt can flag which bars are too memory-hungry to run alongside others.
65
+ export function memoryHeat(memoryKb, maxMemory, thresholds = DEFAULT_HEAT_THRESHOLDS) {
66
+ return heatLevel(memoryKb, maxMemory, thresholds);
67
+ }
68
+
30
69
  // Keys that are folded into the synthetic Status column or the Gantt rather than shown raw.
31
70
  const HANDLED_KEYS = new Set(['success', 'startedAt', 'skipReason']);
32
71
 
@@ -83,6 +122,24 @@ function barCellWrap(text, percent, kind) {
83
122
  return `<div class="cellbar"><span>${text}</span><div class="bar ${kind}" style="width:${percent.toFixed(1)}%"></div></div>`;
84
123
  }
85
124
 
125
+ // Duration cell: green→amber→red heat scale relative to the slowest command in the run,
126
+ // applied to both the bar and the value text, so the slowest commands stand out at a glance.
127
+ function durCellWrap(durationMs, maxDuration, thresholds) {
128
+ const heat = heatLevel(durationMs, maxDuration, thresholds);
129
+ const heatCls = heat ? ` dur-${heat}` : '';
130
+ const percent = pct(durationMs, maxDuration);
131
+ return `<div class="cellbar"><span class="durval${heatCls}">${formatMs(durationMs)}</span><div class="bar dur${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
132
+ }
133
+
134
+ // Memory cell: same green→amber→red heat scale as the Gantt, applied to both the
135
+ // bar and the value text, so the table and the Gantt read identically.
136
+ function memCellWrap(memoryKb, maxMemory, thresholds) {
137
+ const heat = memoryHeat(memoryKb, maxMemory, thresholds);
138
+ const heatCls = heat ? ` mem-${heat}` : '';
139
+ const percent = pct(memoryKb, maxMemory);
140
+ return `<div class="cellbar"><span class="memval${heatCls}">${formatMem(memoryKb)}</span><div class="bar mem${heatCls}" style="width:${percent.toFixed(1)}%"></div></div>`;
141
+ }
142
+
86
143
  // Render a single command cell for a given column key.
87
144
  function renderCell(key, c, ctx) {
88
145
  const v = c[key];
@@ -92,13 +149,15 @@ function renderCell(key, c, ctx) {
92
149
  case 'phase':
93
150
  return v != null ? escapeHtml(v) : '—';
94
151
  case 'durationMs':
95
- return v != null ? barCellWrap(formatMs(v), pct(v, ctx.maxDuration), 'dur') : '—';
152
+ return v != null ? durCellWrap(v, ctx.maxDuration, ctx.durationHeatThresholds) : '—';
96
153
  case 'memoryKb':
97
- return v != null ? barCellWrap(`${(v / 1024).toFixed(1)} MB`, pct(v, ctx.maxMemory), 'mem') : '—';
98
- case 'logFile':
154
+ return v != null ? memCellWrap(v, ctx.maxMemory, ctx.heatThresholds) : '—';
155
+ case 'logFile': {
156
+ const base = ctx.repoRoot || process.cwd();
99
157
  return v
100
- ? `<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>`
101
159
  : '—';
160
+ }
102
161
  default:
103
162
  if (v == null) return '—';
104
163
  return escapeHtml(typeof v === 'object' ? JSON.stringify(v) : v);
@@ -130,7 +189,7 @@ function renderPhasesTable(phases, ctx) {
130
189
  }
131
190
 
132
191
  // Critical-path Gantt for one command list (uses observed startedAt + durationMs).
133
- function renderGantt(commands, overallDurationMs, hasPhases) {
192
+ function renderGantt(commands, overallDurationMs, hasPhases, ctx = {}) {
134
193
  const timed = (commands || [])
135
194
  .filter((c) => c.startedAt && c.durationMs != null)
136
195
  .map((c) => ({ ...c, _start: Date.parse(c.startedAt) }))
@@ -160,15 +219,29 @@ function renderGantt(commands, overallDurationMs, hasPhases) {
160
219
  }
161
220
  const criticalTotal = timed.filter((c) => criticalKeys.has(c.command)).reduce((sum, c) => sum + c.durationMs, 0);
162
221
 
222
+ const anyMemory = timed.some((c) => c.memoryKb != null);
223
+
163
224
  const ganttRow = (c) => {
164
225
  const offsetPct = ((c._start - runStart) / spanMs) * 100;
165
226
  const widthPct = Math.max((c.durationMs / spanMs) * 100, 0.5);
166
227
  const crit = criticalKeys.has(c.command);
167
228
  const cls = c.success === false ? 'failed' : crit ? 'crit' : '';
168
- return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div><div class="gantt-track"><div class="gantt-bar ${cls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
229
+ const heat = memoryHeat(c.memoryKb, ctx.maxMemory, ctx.heatThresholds);
230
+ const heatCls = heat ? ` mem-${heat}` : '';
231
+ const memTitle = c.memoryKb != null ? ` · ${formatMem(c.memoryKb)}` : '';
232
+ const memChip = anyMemory
233
+ ? `<div class="gantt-mem${heat ? ` mem-${heat}` : ''}">${c.memoryKb != null ? formatMem(c.memoryKb) : '—'}</div>`
234
+ : '';
235
+ return `<div class="gantt-row"><div class="gantt-label" title="${escapeHtml(c.command)}${c.phase ? ` — ${escapeHtml(c.phase)}` : ''}">${crit ? '★ ' : ''}${escapeHtml(c.command)}</div>${memChip}<div class="gantt-track"><div class="gantt-bar ${cls}${heatCls}" style="left:${offsetPct.toFixed(2)}%;width:${widthPct.toFixed(2)}%" title="${escapeHtml(c.command)} — ${formatMs(c.durationMs)}${memTitle}"><span class="gantt-dur">${formatMs(c.durationMs)}</span></div></div></div>`;
169
236
  };
170
237
 
171
- return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
238
+ const t = ctx.heatThresholds || DEFAULT_HEAT_THRESHOLDS;
239
+ const memLegend = anyMemory
240
+ ? ' Bar outline + memory column show peak RSS (green→red, relative to the heaviest command:' +
241
+ ` amber ≥ ${Math.round(t.mid * 100)}%, red ≥ ${Math.round(t.high * 100)}%) so you can spot which` +
242
+ ' parallel commands are too memory-hungry to overlap.'
243
+ : '';
244
+ return `<section><h3>Actual Critical Path</h3><p class="muted">★ marks each phase's bottleneck — the chain that drives wall-clock time. Critical path ≈ <strong>${formatMs(criticalTotal)}</strong>${overallDurationMs != null ? ` of ${formatMs(overallDurationMs)} total` : ''}.${memLegend}</p><div class="gantt">${timed.map(ganttRow).join('')}</div></section>`;
172
245
  }
173
246
 
174
247
  function renderMeta(meta) {
@@ -188,7 +261,7 @@ function renderSection(section, columns, ctx) {
188
261
  const inner =
189
262
  renderMeta(section.meta) +
190
263
  renderPhasesTable(section.phases, ctx) +
191
- renderGantt(section.commands, section.overallDurationMs, hasPhases) +
264
+ renderGantt(section.commands, section.overallDurationMs, hasPhases, ctx) +
192
265
  renderCommandsTable(section.commands, columns, ctx) +
193
266
  (section.sections || []).map((s) => renderSection(s, columns, ctx)).join('');
194
267
  return `<details class="section" open><summary><span class="badge ${st.kind}">${escapeHtml(st.label)}</span> <span class="section-title">${escapeHtml(section.title || 'Section')}</span><span class="section-dur">${dur}</span></summary><div class="section-body">${inner}</div></details>`;
@@ -224,6 +297,20 @@ tr.running { background: rgba(59,130,246,0.08); }
224
297
  .cellbar span { font-size: 0.85em; }
225
298
  .bar { height: 6px; background: #3b82f6; border-radius: 4px; min-width: 2px; margin-top: 2px; }
226
299
  .bar.mem { background: #8b5cf6; }
300
+ /* Duration heat: same green→amber→red scale, relative to the slowest command. */
301
+ .bar.dur.dur-low { background: #22c55e; }
302
+ .bar.dur.dur-mid { background: #f59e0b; }
303
+ .bar.dur.dur-high { background: #ef4444; }
304
+ .durval.dur-low { color: #22c55e; }
305
+ .durval.dur-mid { color: #f59e0b; }
306
+ .durval.dur-high { color: #ef4444; font-weight: 600; }
307
+ /* Memory heat: same green→amber→red scale as the Gantt's bar outline. */
308
+ .bar.mem.mem-low { background: #22c55e; }
309
+ .bar.mem.mem-mid { background: #f59e0b; }
310
+ .bar.mem.mem-high { background: #ef4444; }
311
+ .memval.mem-low { color: #22c55e; }
312
+ .memval.mem-mid { color: #f59e0b; }
313
+ .memval.mem-high { color: #ef4444; font-weight: 600; }
227
314
  code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
228
315
  a.logref { color: #60a5fa; text-decoration: none; }
229
316
  a.logref:hover { text-decoration: underline; }
@@ -235,6 +322,14 @@ a.logref:hover { text-decoration: underline; }
235
322
  .gantt-bar { position: absolute; top: 0; height: 16px; background: #3b82f6; border-radius: 4px; min-width: 2px; display: flex; align-items: center; overflow: hidden; }
236
323
  .gantt-bar.crit { background: #f59e0b; }
237
324
  .gantt-bar.failed { background: #ef4444; }
325
+ /* Memory heat: an inset ring on the bar (doesn't change layout or overlap neighbours). */
326
+ .gantt-bar.mem-low { box-shadow: inset 0 0 0 2px #22c55e; }
327
+ .gantt-bar.mem-mid { box-shadow: inset 0 0 0 2px #f59e0b; }
328
+ .gantt-bar.mem-high { box-shadow: inset 0 0 0 2px #ef4444; }
329
+ .gantt-mem { width: 64px; flex: 0 0 64px; text-align: right; font-family: ui-monospace, monospace; font-size: 0.7rem; color: #888; }
330
+ .gantt-mem.mem-low { color: #22c55e; }
331
+ .gantt-mem.mem-mid { color: #f59e0b; }
332
+ .gantt-mem.mem-high { color: #ef4444; font-weight: 600; }
238
333
  .gantt-dur { font-size: 0.65rem; color: #0f0f0f; padding: 0 4px; white-space: nowrap; }
239
334
  details.section { background: #232323; border-radius: 8px; margin-bottom: 0.6rem; padding: 0.25rem 0.75rem; }
240
335
  details.section > summary { cursor: pointer; padding: 0.5rem 0; display: flex; align-items: center; gap: 0.6rem; }
@@ -263,6 +358,10 @@ export function renderReportHtml(payload) {
263
358
  const ctx = {
264
359
  maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
265
360
  maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
361
+ repoRoot: payload.repoRoot,
362
+ // Heat thresholds travel in the payload so re-rendering a saved JSON (--render) honours them.
363
+ heatThresholds: normalizeHeatThresholds(payload.memoryHeat),
364
+ durationHeatThresholds: normalizeHeatThresholds(payload.durationHeat),
266
365
  };
267
366
 
268
367
  const top = statusOf({ success });
@@ -270,7 +369,7 @@ export function renderReportHtml(payload) {
270
369
 
271
370
  const topBlocks =
272
371
  renderPhasesTable(payload.phases, ctx) +
273
- renderGantt(topCommands, overallDurationMs, hasTopPhases) +
372
+ renderGantt(topCommands, overallDurationMs, hasTopPhases, ctx) +
274
373
  (topCommands.length > 0
275
374
  ? `<section><h3>Commands</h3>${renderCommandsTable(topCommands, columns, ctx)}</section>`
276
375
  : '');
@@ -85,4 +85,124 @@ describe('renderReportHtml', () => {
85
85
  expect(html).toContain('badge warn');
86
86
  expect(html).toContain('NX CACHE');
87
87
  });
88
+
89
+ test('Memory table cell uses the same green→red heat scale as the Gantt', () => {
90
+ const html = renderReportHtml({
91
+ success: true,
92
+ timestamp: 't',
93
+ overallDurationMs: 10000,
94
+ commands: [
95
+ { command: 'light', success: true, durationMs: 2000, memoryKb: 100 * 1024 },
96
+ { command: 'heavy', success: true, durationMs: 3000, memoryKb: 2048 * 1024 },
97
+ ],
98
+ });
99
+ // heaviest cell heat-coloured red; lightest green — both bar and value text
100
+ expect(html).toContain('bar mem mem-high');
101
+ expect(html).toContain('bar mem mem-low');
102
+ expect(html).toContain('memval mem-high');
103
+ expect(html).toContain('memval mem-low');
104
+ });
105
+
106
+ test('memoryHeat thresholds are configurable via payload.memoryHeat', () => {
107
+ // With a low high-threshold (0.4), a command at 50% of peak should read as high.
108
+ const payload = {
109
+ success: true,
110
+ timestamp: 't',
111
+ overallDurationMs: 10000,
112
+ memoryHeat: { mid: 0.2, high: 0.4 },
113
+ commands: [
114
+ { command: 'mid', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 1000, memoryKb: 500 * 1024 }, // 50% of peak
115
+ { command: 'peak', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 1000, memoryKb: 1000 * 1024 },
116
+ ],
117
+ };
118
+ const html = renderReportHtml(payload);
119
+ // 50% ≥ configured high (40%) → high; default thresholds (66%) would have made it 'mid'.
120
+ expect(html).toContain('bar mem mem-high');
121
+ expect(html).not.toContain('bar mem mem-mid');
122
+ // legend reflects the configured thresholds
123
+ expect(html).toContain('amber ≥ 20%, red ≥ 40%');
124
+ });
125
+
126
+ test('invalid memoryHeat thresholds fall back to defaults', () => {
127
+ const html = renderReportHtml({
128
+ success: true,
129
+ timestamp: 't',
130
+ overallDurationMs: 10000,
131
+ memoryHeat: { mid: 0.9, high: 0.1 }, // mid >= high → invalid, use defaults
132
+ commands: [
133
+ { command: 'a', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 1000, memoryKb: 500 * 1024 },
134
+ { command: 'b', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 1000, memoryKb: 1000 * 1024 },
135
+ ],
136
+ });
137
+ // default thresholds restored: 50% of peak → mid (≥33%, <66%)
138
+ expect(html).toContain('amber ≥ 33%, red ≥ 66%');
139
+ expect(html).toContain('bar mem mem-mid');
140
+ });
141
+
142
+ test('Duration table cell uses a green→red heat scale relative to the slowest command', () => {
143
+ const html = renderReportHtml({
144
+ success: true,
145
+ timestamp: 't',
146
+ overallDurationMs: 10000,
147
+ commands: [
148
+ { command: 'quick', success: true, durationMs: 500, memoryKb: 100 * 1024 },
149
+ { command: 'slow', success: true, durationMs: 9000, memoryKb: 200 * 1024 },
150
+ ],
151
+ });
152
+ // slowest cell heat-coloured red; quickest green — both bar and value text
153
+ expect(html).toContain('bar dur dur-high');
154
+ expect(html).toContain('bar dur dur-low');
155
+ expect(html).toContain('durval dur-high');
156
+ expect(html).toContain('durval dur-low');
157
+ });
158
+
159
+ test('durationHeat thresholds are configurable via payload.durationHeat', () => {
160
+ // With a low high-threshold (0.4), a command at 50% of the slowest should read as high.
161
+ const html = renderReportHtml({
162
+ success: true,
163
+ timestamp: 't',
164
+ overallDurationMs: 10000,
165
+ durationHeat: { mid: 0.2, high: 0.4 },
166
+ commands: [
167
+ { command: 'mid', success: true, durationMs: 500, memoryKb: 100 * 1024 }, // 50% of slowest
168
+ { command: 'slow', success: true, durationMs: 1000, memoryKb: 100 * 1024 },
169
+ ],
170
+ });
171
+ // 50% ≥ configured high (40%) → high; default thresholds (66%) would have made it 'mid'.
172
+ expect(html).toContain('bar dur dur-high');
173
+ expect(html).not.toContain('bar dur dur-mid');
174
+ });
175
+
176
+ test('Gantt flags memory-heavy commands with a heat ring and memory chip', () => {
177
+ const html = renderReportHtml({
178
+ success: true,
179
+ timestamp: 't',
180
+ overallDurationMs: 10000,
181
+ commands: [
182
+ { command: 'light', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 2000, memoryKb: 100 * 1024 },
183
+ { command: 'heavy', success: true, startedAt: '2026-06-17T00:00:01.000Z', durationMs: 3000, memoryKb: 2048 * 1024 },
184
+ ],
185
+ });
186
+ // heaviest command gets the high-heat ring + bold chip; lightest gets low
187
+ expect(html).toContain('mem-high');
188
+ expect(html).toContain('mem-low');
189
+ // memory value surfaced on the Gantt row itself (no table lookup needed)
190
+ expect(html).toContain('class="gantt-mem');
191
+ expect(html).toContain('2.0 GB');
192
+ });
193
+
194
+ test('Gantt omits the memory chip entirely when no command reports memory', () => {
195
+ const html = renderReportHtml({
196
+ success: true,
197
+ timestamp: 't',
198
+ overallDurationMs: 10000,
199
+ commands: [
200
+ { command: 'a', success: true, startedAt: '2026-06-17T00:00:00.000Z', durationMs: 2000 },
201
+ ],
202
+ });
203
+ // no rendered chip element and no heat ring on the bar
204
+ expect(html).not.toContain('class="gantt-mem');
205
+ expect(html).not.toContain('gantt-bar mem-');
206
+ expect(html).not.toContain('gantt-bar crit mem-');
207
+ });
88
208
  });