scripts-orchestrator 2.14.0 → 2.15.1

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.
@@ -0,0 +1,340 @@
1
+ /**
2
+ * @file recommend-phases.js
3
+ * @description 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. It only reports — it does not change how a
8
+ * 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: when N workspaces gate concurrently they share
30
+ * 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
+ const longestStep = steps.length
147
+ ? steps.reduce((a, b) => (b.durationMs > a.durationMs ? b : a))
148
+ : null;
149
+
150
+ const verdict = decideVerdict({
151
+ steps,
152
+ observedMakespanMs,
153
+ recommendedMakespanMs,
154
+ optimalMakespanMs,
155
+ longestStep,
156
+ binCount: bins.length,
157
+ });
158
+
159
+ return {
160
+ ...budget,
161
+ steps,
162
+ observed,
163
+ observedMakespanMs,
164
+ recommended: { bins, makespanMs: recommendedMakespanMs },
165
+ optimalMakespanMs,
166
+ verdict,
167
+ warnings,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Reduce the numbers to a single yes/no answer: "is re-grouping these phases worth it?".
173
+ *
174
+ * Re-grouping helps only when packing meaningfully beats the observed makespan. It cannot beat the
175
+ * single longest step (the theoretical floor), so when one step dominates the makespan the honest
176
+ * answer is "no — splitting that step is the only lever left", not "re-group".
177
+ *
178
+ * Returns `{ worthwhile, savedMs, reason }`. Thresholds are deliberately conservative so the advice
179
+ * stays quiet unless there's a real, non-trivial win.
180
+ */
181
+ export function decideVerdict({
182
+ steps,
183
+ observedMakespanMs,
184
+ recommendedMakespanMs,
185
+ optimalMakespanMs,
186
+ longestStep,
187
+ binCount,
188
+ }) {
189
+ if (!steps.length) {
190
+ return { worthwhile: false, savedMs: 0, reason: 'No timed steps to analyse.' };
191
+ }
192
+
193
+ const savedMs = observedMakespanMs - recommendedMakespanMs;
194
+ const savedFraction = observedMakespanMs > 0 ? savedMs / observedMakespanMs : 0;
195
+ const dominantFraction = observedMakespanMs > 0 ? optimalMakespanMs / observedMakespanMs : 0;
196
+
197
+ // A real win: packing trims at least 5% AND at least 5s off the observed makespan.
198
+ const significant = savedMs >= 5000 && savedFraction >= 0.05;
199
+ if (significant) {
200
+ return {
201
+ worthwhile: true,
202
+ savedMs,
203
+ reason:
204
+ `Re-grouping into ${binCount} phase(s) could trim ~${fmtDuration(savedMs)} ` +
205
+ `(${Math.round(savedFraction * 100)}%) off the makespan.`,
206
+ };
207
+ }
208
+
209
+ // One step is ≥95% of the makespan: nothing else matters until it's broken up.
210
+ if (dominantFraction >= 0.95 && longestStep) {
211
+ return {
212
+ worthwhile: false,
213
+ savedMs,
214
+ reason:
215
+ `One step ("${longestStep.command}", ${fmtDuration(optimalMakespanMs)}) is ` +
216
+ `~${Math.round(dominantFraction * 100)}% of the makespan, so re-grouping the rest cannot help. ` +
217
+ 'To go faster, split that step into smaller commands the orchestrator can schedule separately.',
218
+ };
219
+ }
220
+
221
+ return {
222
+ worthwhile: false,
223
+ savedMs,
224
+ reason:
225
+ `The current layout is already within ~${fmtDuration(Math.max(0, savedMs))} of the packed ` +
226
+ 'optimum — re-grouping is not worth it.',
227
+ };
228
+ }
229
+
230
+ // ---- formatting helpers ---------------------------------------------------
231
+
232
+ export function fmtDuration(ms) {
233
+ if (ms == null) return '—';
234
+ if (ms < 1000) return `${Math.round(ms)}ms`;
235
+ const seconds = ms / 1000;
236
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
237
+ const minutes = Math.floor(seconds / 60);
238
+ const rem = Math.round(seconds % 60);
239
+ return `${minutes}m ${rem}s`;
240
+ }
241
+
242
+ export function fmtMemKb(kb) {
243
+ if (!kb) return '0';
244
+ const bytes = kb * KB;
245
+ if (bytes >= GB) return `${(bytes / GB).toFixed(2)} GB`;
246
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
247
+ }
248
+
249
+ function stepLine(s) {
250
+ return ` ${fmtDuration(s.durationMs).padStart(8)} ${fmtMemKb(s.memoryKb).padStart(9)} ${s.command}`;
251
+ }
252
+
253
+ /**
254
+ * Render a recommendation as a human-readable, optionally-colored report.
255
+ */
256
+ export function formatRecommendationReport(rec, { sourcePath = null } = {}) {
257
+ const c = chalk;
258
+ const L = [];
259
+
260
+ L.push(c.bold('🧮 Scripts-Orchestrator — memory-aware phase recommendation (advisory)'));
261
+ if (sourcePath) L.push(c.dim(` Source: ${sourcePath}`));
262
+ L.push(
263
+ ` Budget: ${c.yellow(fmtMemKb(rec.budgetBytes / KB))} ` +
264
+ `(RAM ${fmtMemKb(rec.totalMemBytes / KB)} × ${rec.memSafety} ÷ fan-out ${rec.fanout}) · ` +
265
+ `core share: ${c.yellow(String(rec.coreShare))} (${rec.cores} cores − 2 ÷ ${rec.fanout})`,
266
+ );
267
+
268
+ for (const w of rec.warnings) L.push(c.yellow(` ⚠ ${w}`));
269
+ if (rec.steps.length === 0) {
270
+ L.push('');
271
+ L.push(`${c.bold('Verdict:')} ${verdictLine(rec.verdict)}`);
272
+ return L.join('\n');
273
+ }
274
+
275
+ // Observed timeline
276
+ L.push('');
277
+ L.push(
278
+ c.bold(
279
+ `Observed timeline — ${rec.steps.length} steps · ${rec.observed.length} phase(s) · makespan ${fmtDuration(rec.observedMakespanMs)}`,
280
+ ),
281
+ );
282
+ for (const ph of rec.observed) {
283
+ const flag = ph.overBudget ? c.red(' ⚠ concurrent peak over budget') : '';
284
+ L.push(
285
+ ` ${c.cyan(ph.name)} ${fmtDuration(ph.wallclockMs)} · ${ph.steps.length} step(s) · Σ peak ${fmtMemKb(ph.concurrentMemBytes / KB)}${flag}`,
286
+ );
287
+ for (const s of [...ph.steps].sort((a, b) => b.durationMs - a.durationMs)) L.push(stepLine(s));
288
+ }
289
+
290
+ // Recommended layout
291
+ L.push('');
292
+ L.push(
293
+ c.bold(
294
+ `Recommended layout — First-Fit-Decreasing by duration (≤ ${fmtMemKb(rec.budgetBytes / KB)} mem, ≤ ${rec.coreShare} concurrent)`,
295
+ ),
296
+ );
297
+ rec.recommended.bins.forEach((bin, i) => {
298
+ const flag = bin.exceedsBudget ? c.red(' ⚠ lone step exceeds budget (cannot split)') : '';
299
+ L.push(
300
+ ` ${c.green('phase ' + (i + 1))} ${fmtDuration(bin.wallclockMs)} · ${bin.steps.length} step(s) · Σ peak ${fmtMemKb(bin.memBytes / KB)}${flag}`,
301
+ );
302
+ for (const s of [...bin.steps].sort((a, b) => b.durationMs - a.durationMs)) L.push(stepLine(s));
303
+ });
304
+
305
+ // Summary
306
+ const saved = rec.observedMakespanMs - rec.recommended.makespanMs;
307
+ L.push('');
308
+ L.push(c.bold('Estimated makespan'));
309
+ L.push(` observed (sequential phases): ${fmtDuration(rec.observedMakespanMs)}`);
310
+ const delta =
311
+ saved > 0
312
+ ? c.green(`(−${fmtDuration(saved)})`)
313
+ : saved < 0
314
+ ? c.red(`(+${fmtDuration(-saved)})`)
315
+ : c.dim('(no change)');
316
+ L.push(` recommended (packed): ${fmtDuration(rec.recommended.makespanMs)} ${delta}`);
317
+ L.push(` theoretical floor (∞ RAM): ${fmtDuration(rec.optimalMakespanMs)}`);
318
+
319
+ // Verdict — the one-line yes/no the reader actually wants.
320
+ L.push('');
321
+ L.push(`${c.bold('Verdict:')} ${verdictLine(rec.verdict)}`);
322
+
323
+ L.push('');
324
+ L.push(
325
+ c.dim(
326
+ ' Note: advisory only — peaks are per-process maxima summed conservatively, and packing ignores\n' +
327
+ ' inter-phase data dependencies (e.g. build → post-build checks). Validate against a real run.',
328
+ ),
329
+ );
330
+
331
+ return L.join('\n');
332
+ }
333
+
334
+ /** Render the verdict as a colored ✅/❌ one-liner. */
335
+ function verdictLine(verdict) {
336
+ if (!verdict) return '';
337
+ return verdict.worthwhile
338
+ ? `${chalk.green('✅ Yes')} — ${verdict.reason}`
339
+ : `${chalk.yellow('❌ No')} — ${verdict.reason}`;
340
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ computeBudget,
3
+ usableSteps,
4
+ observedTimeline,
5
+ packPhases,
6
+ recommendPhases,
7
+ decideVerdict,
8
+ formatRecommendationReport,
9
+ } from './recommend-phases.js';
10
+
11
+ const GB_KB = 1024 * 1024; // 1 GB expressed in KB
12
+
13
+ // A small synthetic run loosely modelled on the finalyzerui worktree profile.
14
+ const payload = {
15
+ success: true,
16
+ commands: [
17
+ { command: 'build', phase: 'build', success: true, durationMs: 123000, memoryKb: 3.5 * GB_KB },
18
+ { command: 'type-check', phase: 'build', success: true, durationMs: 28000, memoryKb: 1.9 * GB_KB },
19
+ { command: 'test-ci', phase: 'tests', success: true, durationMs: 92000, memoryKb: 1.3 * GB_KB },
20
+ { command: 'build-storybook', phase: 'storybook', success: true, durationMs: 97000, memoryKb: 6.8 * GB_KB },
21
+ { command: 'lint-ci', phase: 'lint', success: true, durationMs: 6500, memoryKb: 0.3 * GB_KB },
22
+ { command: 'disabled-thing', phase: 'lint', success: true, durationMs: 0, skipReason: 'disabled' },
23
+ ],
24
+ };
25
+
26
+ describe('computeBudget', () => {
27
+ test('derives budget and core share from host inputs and fan-out', () => {
28
+ const b = computeBudget({ totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1, memSafety: 0.8 });
29
+ expect(b.budgetBytes).toBeCloseTo(16 * 1024 ** 3 * 0.8);
30
+ expect(b.coreShare).toBe(8); // (10 - 2) / 1
31
+ });
32
+
33
+ test('fan-out divides both memory and core budgets', () => {
34
+ const b = computeBudget({ totalMemBytes: 32 * 1024 ** 3, cores: 12, fanout: 2, memSafety: 0.8 });
35
+ expect(b.budgetBytes).toBeCloseTo((32 * 1024 ** 3 * 0.8) / 2);
36
+ expect(b.coreShare).toBe(5); // floor((12 - 2) / 2)
37
+ });
38
+
39
+ test('budgetMb overrides the computed budget; core share never drops below 1', () => {
40
+ const b = computeBudget({ totalMemBytes: 8 * 1024 ** 3, cores: 2, budgetMb: 4096 });
41
+ expect(b.budgetBytes).toBe(4096 * 1024 * 1024);
42
+ expect(b.coreShare).toBe(1);
43
+ });
44
+ });
45
+
46
+ describe('usableSteps', () => {
47
+ test('drops skipped/untimed commands and normalises fields', () => {
48
+ const steps = usableSteps(payload);
49
+ expect(steps.map((s) => s.command)).not.toContain('disabled-thing');
50
+ expect(steps).toHaveLength(5);
51
+ expect(steps.every((s) => s.durationMs > 0)).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe('observedTimeline', () => {
56
+ test('groups by phase, sums concurrent peaks, flags over-budget phases', () => {
57
+ const steps = usableSteps(payload);
58
+ const budgetBytes = 4 * 1024 ** 3; // 4 GB
59
+ const tl = observedTimeline(steps, budgetBytes);
60
+ const build = tl.find((p) => p.name === 'build');
61
+ // build (3.5) + type-check (1.9) = 5.4 GB > 4 GB budget
62
+ expect(build.overBudget).toBe(true);
63
+ expect(build.wallclockMs).toBe(123000); // max of its members
64
+ });
65
+ });
66
+
67
+ describe('packPhases (First-Fit-Decreasing)', () => {
68
+ test('keeps each bin under the memory budget and respects core share', () => {
69
+ const steps = usableSteps(payload);
70
+ const budgetBytes = 12.8 * 1024 ** 3; // 16 GB laptop @ 0.8
71
+ const bins = packPhases(steps, budgetBytes, 8);
72
+ for (const bin of bins) {
73
+ expect(bin.memBytes).toBeLessThanOrEqual(budgetBytes);
74
+ expect(bin.steps.length).toBeLessThanOrEqual(8);
75
+ }
76
+ // longest step seeds the first bin
77
+ expect(bins[0].steps[0].command).toBe('build');
78
+ });
79
+
80
+ test('a lone oversized step gets its own bin and is flagged', () => {
81
+ const big = [{ command: 'huge', phase: 'p', durationMs: 1000, memoryKb: 10 * GB_KB }];
82
+ const bins = packPhases(big, 4 * 1024 ** 3, 8);
83
+ expect(bins).toHaveLength(1);
84
+ expect(bins[0].exceedsBudget).toBe(true);
85
+ });
86
+ });
87
+
88
+ describe('recommendPhases', () => {
89
+ test('packed makespan beats the sequential-phase makespan on a constrained host', () => {
90
+ const rec = recommendPhases(payload, { totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1 });
91
+ expect(rec.recommended.makespanMs).toBeLessThan(rec.observedMakespanMs);
92
+ expect(rec.optimalMakespanMs).toBe(123000); // single longest step
93
+ });
94
+
95
+ test('warns when no memory metrics are present', () => {
96
+ const rec = recommendPhases({
97
+ commands: [{ command: 'a', phase: 'p', success: true, durationMs: 1000 }],
98
+ });
99
+ expect(rec.warnings.join(' ')).toMatch(/memory/i);
100
+ });
101
+
102
+ test('empty results produce a graceful warning, no throw', () => {
103
+ const rec = recommendPhases({ commands: [] });
104
+ expect(rec.steps).toHaveLength(0);
105
+ expect(rec.warnings.join(' ')).toMatch(/nothing to recommend/i);
106
+ expect(() => formatRecommendationReport(rec)).not.toThrow();
107
+ });
108
+
109
+ test('attaches a verdict object to the recommendation', () => {
110
+ const rec = recommendPhases(payload, { totalMemBytes: 16 * 1024 ** 3, cores: 10, fanout: 1 });
111
+ expect(rec.verdict).toBeDefined();
112
+ expect(typeof rec.verdict.worthwhile).toBe('boolean');
113
+ expect(typeof rec.verdict.reason).toBe('string');
114
+ });
115
+ });
116
+
117
+ describe('decideVerdict', () => {
118
+ const longest = { command: 'build', durationMs: 600000 };
119
+
120
+ test('says yes when packing trims a meaningful chunk off the makespan', () => {
121
+ const v = decideVerdict({
122
+ steps: [{}, {}, {}],
123
+ observedMakespanMs: 100000,
124
+ recommendedMakespanMs: 70000, // 30s / 30% saved
125
+ optimalMakespanMs: 60000,
126
+ longestStep: longest,
127
+ binCount: 2,
128
+ });
129
+ expect(v.worthwhile).toBe(true);
130
+ expect(v.savedMs).toBe(30000);
131
+ });
132
+
133
+ test('says no — and points at the monolith — when one step dominates', () => {
134
+ const v = decideVerdict({
135
+ steps: [{}, {}],
136
+ observedMakespanMs: 610000,
137
+ recommendedMakespanMs: 610000,
138
+ optimalMakespanMs: 600000, // ~98% of makespan
139
+ longestStep: longest,
140
+ binCount: 1,
141
+ });
142
+ expect(v.worthwhile).toBe(false);
143
+ expect(v.reason).toMatch(/split that step/i);
144
+ expect(v.reason).toContain('build');
145
+ });
146
+
147
+ test('says no when the saving is below the threshold', () => {
148
+ const v = decideVerdict({
149
+ steps: [{}, {}, {}, {}],
150
+ observedMakespanMs: 100000,
151
+ recommendedMakespanMs: 98000, // only 2% / 2s
152
+ optimalMakespanMs: 40000,
153
+ longestStep: longest,
154
+ binCount: 2,
155
+ });
156
+ expect(v.worthwhile).toBe(false);
157
+ expect(v.reason).toMatch(/isn't worth it|within/i);
158
+ });
159
+
160
+ test('handles the no-steps case', () => {
161
+ const v = decideVerdict({ steps: [], observedMakespanMs: 0, recommendedMakespanMs: 0, optimalMakespanMs: 0, longestStep: null, binCount: 0 });
162
+ expect(v.worthwhile).toBe(false);
163
+ });
164
+ });