scripts-orchestrator 2.13.0 → 2.15.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 +128 -0
- package/index.js +95 -0
- package/lib/index.js +24 -2
- package/lib/orchestrator.js +347 -130
- package/lib/process-manager.js +5 -2
- package/lib/process-manager.test.js +22 -0
- package/lib/recommend-phases.js +251 -0
- package/lib/recommend-phases.test.js +107 -0
- package/lib/report-html.js +308 -0
- package/lib/report-html.test.js +88 -0
- package/package.json +1 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file recommend-phases.js
|
|
3
|
+
* @description R12 — memory-aware phase recommender (advisory).
|
|
4
|
+
*
|
|
5
|
+
* Reads a results JSON (the same payload the orchestrator writes via `metrics: ['time','memory']`)
|
|
6
|
+
* and proposes a phase layout that keeps each phase's concurrent peak memory under a per-host
|
|
7
|
+
* budget while letting long-running steps overlap. This is the "advisory" first step of R12:
|
|
8
|
+
* it only reports — it does not change how a run is scheduled.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm: First-Fit-Decreasing bin-packing by step duration. Steps are sorted longest-first
|
|
11
|
+
* and each is placed into the earliest phase where adding it keeps Σ(concurrent peak memory) ≤ budget
|
|
12
|
+
* and concurrent step count ≤ core share; otherwise a new phase is opened. Long steps seed phases;
|
|
13
|
+
* short steps fill the gaps beneath them, so makespan (Σ of each phase's longest step) stays near the
|
|
14
|
+
* theoretical floor (the single longest step) without oversubscribing RAM.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import chalk from 'chalk';
|
|
19
|
+
|
|
20
|
+
const KB = 1024;
|
|
21
|
+
const GB = 1024 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the per-host scheduling budget.
|
|
25
|
+
*
|
|
26
|
+
* budget = totalmem × memSafety ÷ fanout (overridable wholesale via budgetMb)
|
|
27
|
+
* coreShare = (cores − 2) ÷ fanout (≥ 1)
|
|
28
|
+
*
|
|
29
|
+
* `fanout` models the workspace-level parallelism (R1's `--parallel=N`): when N workspaces gate
|
|
30
|
+
* concurrently they share the host, so each gets 1/N of RAM and cores.
|
|
31
|
+
*/
|
|
32
|
+
export function computeBudget(opts = {}) {
|
|
33
|
+
const totalMemBytes = opts.totalMemBytes != null ? Number(opts.totalMemBytes) : os.totalmem();
|
|
34
|
+
const cores = opts.cores != null ? Number(opts.cores) : os.cpus().length;
|
|
35
|
+
const fanout = Math.max(1, Number(opts.fanout) || 1);
|
|
36
|
+
const memSafety = opts.memSafety != null ? Number(opts.memSafety) : 0.8;
|
|
37
|
+
const budgetBytes =
|
|
38
|
+
opts.budgetMb != null
|
|
39
|
+
? Number(opts.budgetMb) * 1024 * 1024
|
|
40
|
+
: (totalMemBytes * memSafety) / fanout;
|
|
41
|
+
const coreShare = Math.max(1, Math.floor((cores - 2) / fanout));
|
|
42
|
+
return { totalMemBytes, cores, fanout, memSafety, budgetBytes, coreShare };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract the steps that actually ran and were timed. Skipped/disabled commands and untimed
|
|
47
|
+
* entries are excluded — they tell us nothing about contention. `memoryKb` defaults to 0 when the
|
|
48
|
+
* memory metric wasn't collected (the report then warns and packs by core share alone).
|
|
49
|
+
*/
|
|
50
|
+
export function usableSteps(payload) {
|
|
51
|
+
const commands = Array.isArray(payload?.commands) ? payload.commands : [];
|
|
52
|
+
return commands
|
|
53
|
+
.filter((c) => c && typeof c.durationMs === 'number' && c.durationMs > 0 && !c.skipReason)
|
|
54
|
+
.map((c) => ({
|
|
55
|
+
command: c.command,
|
|
56
|
+
phase: c.phase != null ? c.phase : '(no phase)',
|
|
57
|
+
durationMs: c.durationMs,
|
|
58
|
+
memoryKb: typeof c.memoryKb === 'number' && c.memoryKb > 0 ? c.memoryKb : 0,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Group steps by their original phase (first-seen order) and, for each phase, report the
|
|
64
|
+
* concurrent peak memory (Σ of member peaks — conservative, since peaks rarely coincide exactly)
|
|
65
|
+
* and the phase wall-clock (max member duration, because the phase runs them in parallel).
|
|
66
|
+
*/
|
|
67
|
+
export function observedTimeline(steps, budgetBytes) {
|
|
68
|
+
const order = [];
|
|
69
|
+
const byPhase = new Map();
|
|
70
|
+
for (const s of steps) {
|
|
71
|
+
if (!byPhase.has(s.phase)) {
|
|
72
|
+
byPhase.set(s.phase, []);
|
|
73
|
+
order.push(s.phase);
|
|
74
|
+
}
|
|
75
|
+
byPhase.get(s.phase).push(s);
|
|
76
|
+
}
|
|
77
|
+
return order.map((name) => {
|
|
78
|
+
const members = byPhase.get(name);
|
|
79
|
+
const concurrentMemBytes = members.reduce((sum, s) => sum + s.memoryKb * KB, 0);
|
|
80
|
+
const wallclockMs = Math.max(...members.map((s) => s.durationMs));
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
steps: members,
|
|
84
|
+
concurrentMemBytes,
|
|
85
|
+
wallclockMs,
|
|
86
|
+
overBudget: concurrentMemBytes > budgetBytes,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* First-Fit-Decreasing bin-packing by duration. A step too large to fit any existing bin under the
|
|
93
|
+
* budget opens a new bin; a single step whose own peak exceeds the budget still gets its own bin
|
|
94
|
+
* (it can't be split) and is flagged.
|
|
95
|
+
*/
|
|
96
|
+
export function packPhases(steps, budgetBytes, coreShare) {
|
|
97
|
+
const sorted = [...steps].sort((a, b) => b.durationMs - a.durationMs);
|
|
98
|
+
const bins = [];
|
|
99
|
+
for (const step of sorted) {
|
|
100
|
+
const stepBytes = step.memoryKb * KB;
|
|
101
|
+
let placed = false;
|
|
102
|
+
for (const bin of bins) {
|
|
103
|
+
if (bin.memBytes + stepBytes <= budgetBytes && bin.steps.length < coreShare) {
|
|
104
|
+
bin.steps.push(step);
|
|
105
|
+
bin.memBytes += stepBytes;
|
|
106
|
+
placed = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!placed) {
|
|
111
|
+
bins.push({ steps: [step], memBytes: stepBytes });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const bin of bins) {
|
|
115
|
+
bin.wallclockMs = Math.max(...bin.steps.map((s) => s.durationMs));
|
|
116
|
+
bin.exceedsBudget = bin.memBytes > budgetBytes; // only possible for a lone oversized step
|
|
117
|
+
}
|
|
118
|
+
return bins;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Produce a full recommendation object from a results payload. Pure — all host inputs are taken from
|
|
123
|
+
* `opts` or `os`, so it's testable without a real machine.
|
|
124
|
+
*/
|
|
125
|
+
export function recommendPhases(payload, opts = {}) {
|
|
126
|
+
const budget = computeBudget(opts);
|
|
127
|
+
const steps = usableSteps(payload);
|
|
128
|
+
const warnings = [];
|
|
129
|
+
|
|
130
|
+
if (steps.length === 0) {
|
|
131
|
+
warnings.push('No completed, timed commands found in the results — nothing to recommend.');
|
|
132
|
+
}
|
|
133
|
+
const hasMemory = steps.some((s) => s.memoryKb > 0);
|
|
134
|
+
if (steps.length > 0 && !hasMemory) {
|
|
135
|
+
warnings.push(
|
|
136
|
+
'No memory metrics in the results — re-run with metrics including "memory" for a meaningful budget. Packing falls back to the core-share limit only.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const observed = observedTimeline(steps, budget.budgetBytes);
|
|
141
|
+
const bins = packPhases(steps, budget.budgetBytes, budget.coreShare);
|
|
142
|
+
|
|
143
|
+
const observedMakespanMs = observed.reduce((sum, p) => sum + p.wallclockMs, 0);
|
|
144
|
+
const recommendedMakespanMs = bins.reduce((sum, b) => sum + b.wallclockMs, 0);
|
|
145
|
+
const optimalMakespanMs = steps.length ? Math.max(...steps.map((s) => s.durationMs)) : 0;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...budget,
|
|
149
|
+
steps,
|
|
150
|
+
observed,
|
|
151
|
+
observedMakespanMs,
|
|
152
|
+
recommended: { bins, makespanMs: recommendedMakespanMs },
|
|
153
|
+
optimalMakespanMs,
|
|
154
|
+
warnings,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- formatting helpers ---------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export function fmtDuration(ms) {
|
|
161
|
+
if (ms == null) return '—';
|
|
162
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
163
|
+
const seconds = ms / 1000;
|
|
164
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
165
|
+
const minutes = Math.floor(seconds / 60);
|
|
166
|
+
const rem = Math.round(seconds % 60);
|
|
167
|
+
return `${minutes}m ${rem}s`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function fmtMemKb(kb) {
|
|
171
|
+
if (!kb) return '0';
|
|
172
|
+
const bytes = kb * KB;
|
|
173
|
+
if (bytes >= GB) return `${(bytes / GB).toFixed(2)} GB`;
|
|
174
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function stepLine(s) {
|
|
178
|
+
return ` ${fmtDuration(s.durationMs).padStart(8)} ${fmtMemKb(s.memoryKb).padStart(9)} ${s.command}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Render a recommendation as a human-readable, optionally-colored report.
|
|
183
|
+
*/
|
|
184
|
+
export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
|
|
185
|
+
const c = chalk;
|
|
186
|
+
const L = [];
|
|
187
|
+
|
|
188
|
+
L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (R12, advisory)'));
|
|
189
|
+
if (sourcePath) L.push(c.dim(` Source: ${sourcePath}`));
|
|
190
|
+
L.push(
|
|
191
|
+
` Budget: ${c.yellow(fmtMemKb(rec.budgetBytes / KB))} ` +
|
|
192
|
+
`(RAM ${fmtMemKb(rec.totalMemBytes / KB)} × ${rec.memSafety} ÷ fan-out ${rec.fanout}) · ` +
|
|
193
|
+
`core share: ${c.yellow(String(rec.coreShare))} (${rec.cores} cores − 2 ÷ ${rec.fanout})`,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
for (const w of rec.warnings) L.push(c.yellow(` ⚠ ${w}`));
|
|
197
|
+
if (rec.steps.length === 0) return L.join('\n');
|
|
198
|
+
|
|
199
|
+
// Observed timeline
|
|
200
|
+
L.push('');
|
|
201
|
+
L.push(
|
|
202
|
+
c.bold(
|
|
203
|
+
`Observed timeline — ${rec.steps.length} steps · ${rec.observed.length} phase(s) · makespan ${fmtDuration(rec.observedMakespanMs)}`,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
for (const ph of rec.observed) {
|
|
207
|
+
const flag = ph.overBudget ? c.red(' ⚠ concurrent peak over budget') : '';
|
|
208
|
+
L.push(
|
|
209
|
+
` ${c.cyan(ph.name)} ${fmtDuration(ph.wallclockMs)} · ${ph.steps.length} step(s) · Σ peak ${fmtMemKb(ph.concurrentMemBytes / KB)}${flag}`,
|
|
210
|
+
);
|
|
211
|
+
for (const s of [...ph.steps].sort((a, b) => b.durationMs - a.durationMs)) L.push(stepLine(s));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Recommended layout
|
|
215
|
+
L.push('');
|
|
216
|
+
L.push(
|
|
217
|
+
c.bold(
|
|
218
|
+
`Recommended layout — First-Fit-Decreasing by duration (≤ ${fmtMemKb(rec.budgetBytes / KB)} mem, ≤ ${rec.coreShare} concurrent)`,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
rec.recommended.bins.forEach((bin, i) => {
|
|
222
|
+
const flag = bin.exceedsBudget ? c.red(' ⚠ lone step exceeds budget (cannot split)') : '';
|
|
223
|
+
L.push(
|
|
224
|
+
` ${c.green('phase ' + (i + 1))} ${fmtDuration(bin.wallclockMs)} · ${bin.steps.length} step(s) · Σ peak ${fmtMemKb(bin.memBytes / KB)}${flag}`,
|
|
225
|
+
);
|
|
226
|
+
for (const s of [...bin.steps].sort((a, b) => b.durationMs - a.durationMs)) L.push(stepLine(s));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Summary
|
|
230
|
+
const saved = rec.observedMakespanMs - rec.recommended.makespanMs;
|
|
231
|
+
L.push('');
|
|
232
|
+
L.push(c.bold('Estimated makespan'));
|
|
233
|
+
L.push(` observed (sequential phases): ${fmtDuration(rec.observedMakespanMs)}`);
|
|
234
|
+
const delta =
|
|
235
|
+
saved > 0
|
|
236
|
+
? c.green(`(−${fmtDuration(saved)})`)
|
|
237
|
+
: saved < 0
|
|
238
|
+
? c.red(`(+${fmtDuration(-saved)})`)
|
|
239
|
+
: c.dim('(no change)');
|
|
240
|
+
L.push(` recommended (packed): ${fmtDuration(rec.recommended.makespanMs)} ${delta}`);
|
|
241
|
+
L.push(` theoretical floor (∞ RAM): ${fmtDuration(rec.optimalMakespanMs)}`);
|
|
242
|
+
L.push('');
|
|
243
|
+
L.push(
|
|
244
|
+
c.dim(
|
|
245
|
+
' Note: advisory only — peaks are per-process maxima summed conservatively, and packing ignores\n' +
|
|
246
|
+
' inter-phase data dependencies (e.g. build → post-build checks). Validate against a real run.',
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return L.join('\n');
|
|
251
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeBudget,
|
|
3
|
+
usableSteps,
|
|
4
|
+
observedTimeline,
|
|
5
|
+
packPhases,
|
|
6
|
+
recommendPhases,
|
|
7
|
+
formatRecommendationReport,
|
|
8
|
+
} from './recommend-phases.js';
|
|
9
|
+
|
|
10
|
+
const GB_KB = 1024 * 1024; // 1 GB expressed in KB
|
|
11
|
+
|
|
12
|
+
// A small synthetic run loosely modelled on the finalyzerui worktree profile.
|
|
13
|
+
const payload = {
|
|
14
|
+
success: true,
|
|
15
|
+
commands: [
|
|
16
|
+
{ command: 'build', phase: 'build', success: true, durationMs: 123000, memoryKb: 3.5 * GB_KB },
|
|
17
|
+
{ command: 'type-check', phase: 'build', success: true, durationMs: 28000, memoryKb: 1.9 * GB_KB },
|
|
18
|
+
{ command: 'test-ci', phase: 'tests', success: true, durationMs: 92000, memoryKb: 1.3 * GB_KB },
|
|
19
|
+
{ command: 'build-storybook', phase: 'storybook', success: true, durationMs: 97000, memoryKb: 6.8 * GB_KB },
|
|
20
|
+
{ command: 'lint-ci', phase: 'lint', success: true, durationMs: 6500, memoryKb: 0.3 * GB_KB },
|
|
21
|
+
{ command: 'disabled-thing', phase: 'lint', success: true, durationMs: 0, skipReason: 'disabled' },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('computeBudget', () => {
|
|
26
|
+
test('derives budget and core share from host inputs and fan-out', () => {
|
|
27
|
+
const b = computeBudget({ totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1, memSafety: 0.8 });
|
|
28
|
+
expect(b.budgetBytes).toBeCloseTo(16 * 1024 ** 3 * 0.8);
|
|
29
|
+
expect(b.coreShare).toBe(8); // (10 - 2) / 1
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('fan-out divides both memory and core budgets', () => {
|
|
33
|
+
const b = computeBudget({ totalMemBytes: 32 * 1024 ** 3, cores: 12, fanout: 2, memSafety: 0.8 });
|
|
34
|
+
expect(b.budgetBytes).toBeCloseTo((32 * 1024 ** 3 * 0.8) / 2);
|
|
35
|
+
expect(b.coreShare).toBe(5); // floor((12 - 2) / 2)
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('budgetMb overrides the computed budget; core share never drops below 1', () => {
|
|
39
|
+
const b = computeBudget({ totalMemBytes: 8 * 1024 ** 3, cores: 2, budgetMb: 4096 });
|
|
40
|
+
expect(b.budgetBytes).toBe(4096 * 1024 * 1024);
|
|
41
|
+
expect(b.coreShare).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('usableSteps', () => {
|
|
46
|
+
test('drops skipped/untimed commands and normalises fields', () => {
|
|
47
|
+
const steps = usableSteps(payload);
|
|
48
|
+
expect(steps.map((s) => s.command)).not.toContain('disabled-thing');
|
|
49
|
+
expect(steps).toHaveLength(5);
|
|
50
|
+
expect(steps.every((s) => s.durationMs > 0)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('observedTimeline', () => {
|
|
55
|
+
test('groups by phase, sums concurrent peaks, flags over-budget phases', () => {
|
|
56
|
+
const steps = usableSteps(payload);
|
|
57
|
+
const budgetBytes = 4 * 1024 ** 3; // 4 GB
|
|
58
|
+
const tl = observedTimeline(steps, budgetBytes);
|
|
59
|
+
const build = tl.find((p) => p.name === 'build');
|
|
60
|
+
// build (3.5) + type-check (1.9) = 5.4 GB > 4 GB budget
|
|
61
|
+
expect(build.overBudget).toBe(true);
|
|
62
|
+
expect(build.wallclockMs).toBe(123000); // max of its members
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('packPhases (First-Fit-Decreasing)', () => {
|
|
67
|
+
test('keeps each bin under the memory budget and respects core share', () => {
|
|
68
|
+
const steps = usableSteps(payload);
|
|
69
|
+
const budgetBytes = 12.8 * 1024 ** 3; // 16 GB laptop @ 0.8
|
|
70
|
+
const bins = packPhases(steps, budgetBytes, 8);
|
|
71
|
+
for (const bin of bins) {
|
|
72
|
+
expect(bin.memBytes).toBeLessThanOrEqual(budgetBytes);
|
|
73
|
+
expect(bin.steps.length).toBeLessThanOrEqual(8);
|
|
74
|
+
}
|
|
75
|
+
// longest step seeds the first bin
|
|
76
|
+
expect(bins[0].steps[0].command).toBe('build');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a lone oversized step gets its own bin and is flagged', () => {
|
|
80
|
+
const big = [{ command: 'huge', phase: 'p', durationMs: 1000, memoryKb: 10 * GB_KB }];
|
|
81
|
+
const bins = packPhases(big, 4 * 1024 ** 3, 8);
|
|
82
|
+
expect(bins).toHaveLength(1);
|
|
83
|
+
expect(bins[0].exceedsBudget).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('recommendPhases', () => {
|
|
88
|
+
test('packed makespan beats the sequential-phase makespan on a constrained host', () => {
|
|
89
|
+
const rec = recommendPhases(payload, { totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1 });
|
|
90
|
+
expect(rec.recommended.makespanMs).toBeLessThan(rec.observedMakespanMs);
|
|
91
|
+
expect(rec.optimalMakespanMs).toBe(123000); // single longest step
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('warns when no memory metrics are present', () => {
|
|
95
|
+
const rec = recommendPhases({
|
|
96
|
+
commands: [{ command: 'a', phase: 'p', success: true, durationMs: 1000 }],
|
|
97
|
+
});
|
|
98
|
+
expect(rec.warnings.join(' ')).toMatch(/memory/i);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('empty results produce a graceful warning, no throw', () => {
|
|
102
|
+
const rec = recommendPhases({ commands: [] });
|
|
103
|
+
expect(rec.steps).toHaveLength(0);
|
|
104
|
+
expect(rec.warnings.join(' ')).toMatch(/nothing to recommend/i);
|
|
105
|
+
expect(() => formatRecommendationReport(rec)).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
// Generic, domain-agnostic HTML renderer for orchestrator-shaped result payloads.
|
|
4
|
+
//
|
|
5
|
+
// The renderer knows ONLY about generic orchestrator concepts: a document has optional
|
|
6
|
+
// `commands` and optional nested `sections` (recursive). It has no notion of workspaces,
|
|
7
|
+
// monorepos, nx, etc. — any such labels arrive purely as data (section.title, section.meta,
|
|
8
|
+
// section.statusLabel) and are rendered opaquely.
|
|
9
|
+
//
|
|
10
|
+
// Columns are discovered as the union of keys across all command entries, so new JSON fields
|
|
11
|
+
// appear automatically. Known keys get rich formatting (badges, bars, links); unknown keys
|
|
12
|
+
// render as plain text.
|
|
13
|
+
|
|
14
|
+
export function escapeHtml(s) {
|
|
15
|
+
if (s == null) return '';
|
|
16
|
+
return String(s)
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatMs(ms) {
|
|
24
|
+
if (ms == null || ms === 0) return '—';
|
|
25
|
+
if (ms < 1000) return `${ms}ms`;
|
|
26
|
+
const s = (ms / 1000).toFixed(1);
|
|
27
|
+
return `${s}s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Keys that are folded into the synthetic Status column or the Gantt rather than shown raw.
|
|
31
|
+
const HANDLED_KEYS = new Set(['success', 'startedAt', 'skipReason']);
|
|
32
|
+
|
|
33
|
+
// Preferred left-to-right order for known columns (others appended alphabetically).
|
|
34
|
+
const KNOWN_ORDER = ['command', 'phase', 'durationMs', 'memoryKb', 'logFile'];
|
|
35
|
+
|
|
36
|
+
const COLUMN_LABELS = {
|
|
37
|
+
command: 'Command',
|
|
38
|
+
phase: 'Phase',
|
|
39
|
+
durationMs: 'Duration',
|
|
40
|
+
memoryKb: 'Memory',
|
|
41
|
+
logFile: 'Log',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function humanizeKey(key) {
|
|
45
|
+
if (COLUMN_LABELS[key]) return COLUMN_LABELS[key];
|
|
46
|
+
return key
|
|
47
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
48
|
+
.replace(/[_-]+/g, ' ')
|
|
49
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Map a command/section to a generic status kind + label.
|
|
53
|
+
function statusOf(entry) {
|
|
54
|
+
if (entry.statusKind) {
|
|
55
|
+
return { kind: entry.statusKind, label: entry.statusLabel ?? entry.statusKind };
|
|
56
|
+
}
|
|
57
|
+
if (entry.success === null || entry.success === undefined) {
|
|
58
|
+
// For commands: started-but-not-finished is "running"; otherwise unknown → muted.
|
|
59
|
+
if (entry.startedAt && entry.durationMs == null) return { kind: 'running', label: 'Running' };
|
|
60
|
+
if (entry.success === null) return { kind: 'running', label: 'Running' };
|
|
61
|
+
return { kind: 'muted', label: '—' };
|
|
62
|
+
}
|
|
63
|
+
if (entry.success) return { kind: 'ok', label: 'OK' };
|
|
64
|
+
return { kind: 'fail', label: entry.skipReason || 'Failed' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Collect every command entry in the document (top-level + all nested sections).
|
|
68
|
+
function collectCommands(payload) {
|
|
69
|
+
const all = [];
|
|
70
|
+
const visit = (node) => {
|
|
71
|
+
for (const c of node.commands || []) all.push(c);
|
|
72
|
+
for (const s of node.sections || []) visit(s);
|
|
73
|
+
};
|
|
74
|
+
visit(payload);
|
|
75
|
+
return all;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pct(value, max) {
|
|
79
|
+
return max > 0 && value != null ? (value / max) * 100 : 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function barCellWrap(text, percent, kind) {
|
|
83
|
+
return `<div class="cellbar"><span>${text}</span><div class="bar ${kind}" style="width:${percent.toFixed(1)}%"></div></div>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Render a single command cell for a given column key.
|
|
87
|
+
function renderCell(key, c, ctx) {
|
|
88
|
+
const v = c[key];
|
|
89
|
+
switch (key) {
|
|
90
|
+
case 'command':
|
|
91
|
+
return `<code>${escapeHtml(v)}</code>`;
|
|
92
|
+
case 'phase':
|
|
93
|
+
return v != null ? escapeHtml(v) : '—';
|
|
94
|
+
case 'durationMs':
|
|
95
|
+
return v != null ? barCellWrap(formatMs(v), pct(v, ctx.maxDuration), 'dur') : '—';
|
|
96
|
+
case 'memoryKb':
|
|
97
|
+
return v != null ? barCellWrap(`${(v / 1024).toFixed(1)} MB`, pct(v, ctx.maxMemory), 'mem') : '—';
|
|
98
|
+
case 'logFile':
|
|
99
|
+
return v
|
|
100
|
+
? `<a class="logref" href="file://${escapeHtml(path.resolve(process.cwd(), v))}" title="${escapeHtml(v)}"><code>${escapeHtml(v)}</code></a>`
|
|
101
|
+
: '—';
|
|
102
|
+
default:
|
|
103
|
+
if (v == null) return '—';
|
|
104
|
+
return escapeHtml(typeof v === 'object' ? JSON.stringify(v) : v);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderCommandsTable(commands, columns, ctx) {
|
|
109
|
+
if (!commands || commands.length === 0) return '';
|
|
110
|
+
const headerCells = ['<th>Status</th>', ...columns.map((k) => `<th>${escapeHtml(humanizeKey(k))}</th>`)];
|
|
111
|
+
const rows = commands
|
|
112
|
+
.map((c) => {
|
|
113
|
+
const st = statusOf(c);
|
|
114
|
+
const cells = columns.map((k) => `<td>${renderCell(k, c, ctx)}</td>`).join('');
|
|
115
|
+
return `<tr class="${st.kind}"><td><span class="badge ${st.kind}">${escapeHtml(st.label)}</span></td>${cells}</tr>`;
|
|
116
|
+
})
|
|
117
|
+
.join('');
|
|
118
|
+
return `<table><thead><tr>${headerCells.join('')}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderPhasesTable(phases, ctx) {
|
|
122
|
+
if (!phases || phases.length === 0) return '';
|
|
123
|
+
const rows = phases
|
|
124
|
+
.map((p) => {
|
|
125
|
+
const st = statusOf(p);
|
|
126
|
+
return `<tr class="${st.kind}"><td>${escapeHtml(p.name)}</td><td><span class="badge ${st.kind}">${escapeHtml(st.label)}</span></td><td>${barCellWrap(formatMs(p.durationMs), pct(p.durationMs, ctx.maxDuration), 'dur')}</td></tr>`;
|
|
127
|
+
})
|
|
128
|
+
.join('');
|
|
129
|
+
return `<section><h3>Phases</h3><table><thead><tr><th>Phase</th><th>Status</th><th>Duration</th></tr></thead><tbody>${rows}</tbody></table></section>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Critical-path Gantt for one command list (uses observed startedAt + durationMs).
|
|
133
|
+
function renderGantt(commands, overallDurationMs, hasPhases) {
|
|
134
|
+
const timed = (commands || [])
|
|
135
|
+
.filter((c) => c.startedAt && c.durationMs != null)
|
|
136
|
+
.map((c) => ({ ...c, _start: Date.parse(c.startedAt) }))
|
|
137
|
+
.filter((c) => Number.isFinite(c._start))
|
|
138
|
+
.sort((a, b) => a._start - b._start);
|
|
139
|
+
if (timed.length === 0) return '';
|
|
140
|
+
|
|
141
|
+
const runStart = Math.min(...timed.map((c) => c._start));
|
|
142
|
+
const maxEnd = Math.max(...timed.map((c) => c._start + c.durationMs));
|
|
143
|
+
const spanMs = overallDurationMs != null && overallDurationMs > 0 ? overallDurationMs : Math.max(1, maxEnd - runStart);
|
|
144
|
+
|
|
145
|
+
// Critical chain: longest command per phase (phases run sequentially, commands within run parallel).
|
|
146
|
+
const criticalKeys = new Set();
|
|
147
|
+
if (hasPhases) {
|
|
148
|
+
const byPhase = new Map();
|
|
149
|
+
for (const c of timed) {
|
|
150
|
+
const arr = byPhase.get(c.phase) || [];
|
|
151
|
+
arr.push(c);
|
|
152
|
+
byPhase.set(c.phase, arr);
|
|
153
|
+
}
|
|
154
|
+
for (const arr of byPhase.values()) {
|
|
155
|
+
const top = arr.reduce((a, b) => (b.durationMs > a.durationMs ? b : a));
|
|
156
|
+
criticalKeys.add(top.command);
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
timed.forEach((c) => criticalKeys.add(c.command));
|
|
160
|
+
}
|
|
161
|
+
const criticalTotal = timed.filter((c) => criticalKeys.has(c.command)).reduce((sum, c) => sum + c.durationMs, 0);
|
|
162
|
+
|
|
163
|
+
const ganttRow = (c) => {
|
|
164
|
+
const offsetPct = ((c._start - runStart) / spanMs) * 100;
|
|
165
|
+
const widthPct = Math.max((c.durationMs / spanMs) * 100, 0.5);
|
|
166
|
+
const crit = criticalKeys.has(c.command);
|
|
167
|
+
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>`;
|
|
169
|
+
};
|
|
170
|
+
|
|
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>`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function renderMeta(meta) {
|
|
175
|
+
if (!meta || typeof meta !== 'object') return '';
|
|
176
|
+
const items = Object.entries(meta)
|
|
177
|
+
.filter(([, v]) => v != null && v !== '')
|
|
178
|
+
.map(([k, v]) => `<span class="meta-item"><span class="meta-k">${escapeHtml(humanizeKey(k))}</span> ${escapeHtml(typeof v === 'object' ? JSON.stringify(v) : v)}</span>`)
|
|
179
|
+
.join('');
|
|
180
|
+
return items ? `<div class="meta">${items}</div>` : '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Render one section as a collapsible block.
|
|
184
|
+
function renderSection(section, columns, ctx) {
|
|
185
|
+
const st = statusOf(section);
|
|
186
|
+
const hasPhases = (section.phases || []).length > 0;
|
|
187
|
+
const dur = section.overallDurationMs != null ? ` · ${formatMs(section.overallDurationMs)}` : '';
|
|
188
|
+
const inner =
|
|
189
|
+
renderMeta(section.meta) +
|
|
190
|
+
renderPhasesTable(section.phases, ctx) +
|
|
191
|
+
renderGantt(section.commands, section.overallDurationMs, hasPhases) +
|
|
192
|
+
renderCommandsTable(section.commands, columns, ctx) +
|
|
193
|
+
(section.sections || []).map((s) => renderSection(s, columns, ctx)).join('');
|
|
194
|
+
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>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const STYLES = `
|
|
198
|
+
* { box-sizing: border-box; }
|
|
199
|
+
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
|
|
200
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
201
|
+
h3 { font-size: 1rem; color: #a0a0a0; margin: 0.75rem 0 0.4rem; }
|
|
202
|
+
.summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
|
|
203
|
+
.summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
|
|
204
|
+
.summary .card.ok { border-left: 4px solid #22c55e; }
|
|
205
|
+
.summary .card.fail { border-left: 4px solid #ef4444; }
|
|
206
|
+
.summary .card.running { border-left: 4px solid #3b82f6; }
|
|
207
|
+
.summary .card.warn { border-left: 4px solid #f59e0b; }
|
|
208
|
+
.summary .card.muted { border-left: 4px solid #666; }
|
|
209
|
+
.summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
|
|
210
|
+
.summary .value { font-size: 1.25rem; font-weight: 600; }
|
|
211
|
+
section { margin-bottom: 1rem; }
|
|
212
|
+
table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; margin-bottom: 0.5rem; }
|
|
213
|
+
th, td { padding: 0.5rem 0.75rem; text-align: left; vertical-align: top; }
|
|
214
|
+
th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
|
|
215
|
+
tr.fail { background: rgba(239,68,68,0.08); }
|
|
216
|
+
tr.running { background: rgba(59,130,246,0.08); }
|
|
217
|
+
.badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; white-space: nowrap; }
|
|
218
|
+
.badge.ok { background: #22c55e; color: #0f0f0f; }
|
|
219
|
+
.badge.fail { background: #ef4444; color: #fff; }
|
|
220
|
+
.badge.running { background: #3b82f6; color: #fff; }
|
|
221
|
+
.badge.warn { background: #f59e0b; color: #0f0f0f; }
|
|
222
|
+
.badge.muted { background: #555; color: #ddd; }
|
|
223
|
+
.cellbar { min-width: 90px; }
|
|
224
|
+
.cellbar span { font-size: 0.85em; }
|
|
225
|
+
.bar { height: 6px; background: #3b82f6; border-radius: 4px; min-width: 2px; margin-top: 2px; }
|
|
226
|
+
.bar.mem { background: #8b5cf6; }
|
|
227
|
+
code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
|
228
|
+
a.logref { color: #60a5fa; text-decoration: none; }
|
|
229
|
+
a.logref:hover { text-decoration: underline; }
|
|
230
|
+
.muted { color: #888; font-size: 0.85rem; margin: 0 0 0.6rem; }
|
|
231
|
+
.gantt { background: #2a2a2a; border-radius: 8px; padding: 0.6rem 0.75rem; }
|
|
232
|
+
.gantt-row { display: flex; align-items: center; gap: 0.5rem; padding: 2px 0; }
|
|
233
|
+
.gantt-label { width: 280px; flex: 0 0 280px; font-family: ui-monospace, monospace; font-size: 0.78rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #bbb; }
|
|
234
|
+
.gantt-track { position: relative; flex: 1; height: 16px; background: #1f1f1f; border-radius: 4px; }
|
|
235
|
+
.gantt-bar { position: absolute; top: 0; height: 16px; background: #3b82f6; border-radius: 4px; min-width: 2px; display: flex; align-items: center; overflow: hidden; }
|
|
236
|
+
.gantt-bar.crit { background: #f59e0b; }
|
|
237
|
+
.gantt-bar.failed { background: #ef4444; }
|
|
238
|
+
.gantt-dur { font-size: 0.65rem; color: #0f0f0f; padding: 0 4px; white-space: nowrap; }
|
|
239
|
+
details.section { background: #232323; border-radius: 8px; margin-bottom: 0.6rem; padding: 0.25rem 0.75rem; }
|
|
240
|
+
details.section > summary { cursor: pointer; padding: 0.5rem 0; display: flex; align-items: center; gap: 0.6rem; }
|
|
241
|
+
.section-title { font-weight: 600; }
|
|
242
|
+
.section-dur { color: #888; font-size: 0.85rem; }
|
|
243
|
+
.section-body { padding: 0.25rem 0 0.5rem; }
|
|
244
|
+
.meta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.5rem; }
|
|
245
|
+
.meta-item { font-size: 0.8rem; color: #bbb; }
|
|
246
|
+
.meta-k { color: #777; text-transform: uppercase; font-size: 0.7rem; }
|
|
247
|
+
`;
|
|
248
|
+
|
|
249
|
+
export function renderReportHtml(payload) {
|
|
250
|
+
const { success, timestamp, overallDurationMs, title } = payload;
|
|
251
|
+
const topCommands = payload.commands || [];
|
|
252
|
+
const sections = payload.sections || [];
|
|
253
|
+
const hasTopPhases = (payload.phases || []).length > 0;
|
|
254
|
+
|
|
255
|
+
// Global column set + bar scaling across every command in the document.
|
|
256
|
+
const allCommands = collectCommands(payload);
|
|
257
|
+
const present = new Set();
|
|
258
|
+
for (const c of allCommands) for (const k of Object.keys(c)) if (!HANDLED_KEYS.has(k)) present.add(k);
|
|
259
|
+
const ordered = KNOWN_ORDER.filter((k) => present.has(k));
|
|
260
|
+
const extras = [...present].filter((k) => !KNOWN_ORDER.includes(k)).sort();
|
|
261
|
+
const columns = [...ordered, ...extras];
|
|
262
|
+
|
|
263
|
+
const ctx = {
|
|
264
|
+
maxDuration: Math.max(0, ...allCommands.map((c) => c.durationMs || 0)),
|
|
265
|
+
maxMemory: Math.max(0, ...allCommands.map((c) => c.memoryKb || 0)),
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const top = statusOf({ success });
|
|
269
|
+
const statusLabel = success === null ? 'Running…' : success ? 'Success' : 'Failed';
|
|
270
|
+
|
|
271
|
+
const topBlocks =
|
|
272
|
+
renderPhasesTable(payload.phases, ctx) +
|
|
273
|
+
renderGantt(topCommands, overallDurationMs, hasTopPhases) +
|
|
274
|
+
(topCommands.length > 0
|
|
275
|
+
? `<section><h3>Commands</h3>${renderCommandsTable(topCommands, columns, ctx)}</section>`
|
|
276
|
+
: '');
|
|
277
|
+
|
|
278
|
+
const sectionBlocks = sections.map((s) => renderSection(s, columns, ctx)).join('');
|
|
279
|
+
|
|
280
|
+
return `<!DOCTYPE html>
|
|
281
|
+
<html lang="en">
|
|
282
|
+
<head>
|
|
283
|
+
<meta charset="utf-8">
|
|
284
|
+
<title>${escapeHtml(title || 'Scripts Orchestrator Report')}</title>
|
|
285
|
+
<style>${STYLES}</style>
|
|
286
|
+
</head>
|
|
287
|
+
<body>
|
|
288
|
+
<h1>${escapeHtml(title || 'Scripts Orchestrator Report')}</h1>
|
|
289
|
+
<div class="summary">
|
|
290
|
+
<div class="card ${top.kind}">
|
|
291
|
+
<div class="label">Status</div>
|
|
292
|
+
<div class="value">${statusLabel}</div>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="card">
|
|
295
|
+
<div class="label">Timestamp</div>
|
|
296
|
+
<div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
|
|
297
|
+
</div>
|
|
298
|
+
${overallDurationMs != null ? `
|
|
299
|
+
<div class="card">
|
|
300
|
+
<div class="label">${success === null ? 'Elapsed' : 'Total time'}</div>
|
|
301
|
+
<div class="value">${formatMs(overallDurationMs)}</div>
|
|
302
|
+
</div>` : ''}
|
|
303
|
+
</div>
|
|
304
|
+
${topBlocks}
|
|
305
|
+
${sectionBlocks}
|
|
306
|
+
</body>
|
|
307
|
+
</html>`;
|
|
308
|
+
}
|