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.
- package/README.md +136 -2
- package/index.js +101 -0
- package/lib/index.js +10 -0
- package/lib/orchestrator.aggregate.test.js +83 -0
- package/lib/orchestrator.concurrency.test.js +112 -0
- package/lib/orchestrator.js +146 -9
- package/lib/report-html.js +108 -9
- package/lib/report-html.test.js +120 -0
- package/lib/workspaces.js +376 -0
- package/lib/workspaces.test.js +339 -0
- package/package.json +1 -1
- package/scripts-orchestrator.config.js +8 -0
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';
|
|
@@ -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
|
|
681
|
-
this.
|
|
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
|
-
|
|
754
|
-
|
|
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 (
|
|
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
|
-
|
|
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 {
|
package/lib/report-html.js
CHANGED
|
@@ -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 ?
|
|
152
|
+
return v != null ? durCellWrap(v, ctx.maxDuration, ctx.durationHeatThresholds) : '—';
|
|
96
153
|
case 'memoryKb':
|
|
97
|
-
return v != null ?
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
: '');
|
package/lib/report-html.test.js
CHANGED
|
@@ -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
|
});
|