scripts-orchestrator 2.15.1 → 3.5.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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @file workspaces.js
3
+ * @description First-class npm-workspace support: discover the workspaces declared in a
4
+ * repo's root package.json, then aggregate each workspace's orchestrator results JSON
5
+ * (plus the root run's global-check results) into a single, generic report document that
6
+ * the shared HTML renderer can turn into one roll-up page.
7
+ *
8
+ * The aggregator is intentionally domain-agnostic: it reads only artifacts the library
9
+ * itself writes (per-scope results JSON + the run-state file), so it needs no log scraping
10
+ * and no knowledge of any particular task runner. All path conventions are configurable.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { renderReportHtml } from './report-html.js';
16
+
17
+ const DEFAULTS = {
18
+ title: 'Workspaces Quality Report',
19
+ outJson: 'logs/monorepo-quality-report.json',
20
+ outHtml: 'logs/monorepo-quality-report.html',
21
+ runStateFile: 'logs/.scripts-orchestrator-run.json',
22
+ rootResults: 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json',
23
+ globalResults: 'logs/scripts-orchestrator-logs/scripts-orchestrator-global-results.json',
24
+ workspaceResults: 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json',
25
+ globalPhase: 'global quality checks',
26
+ workspacePhase: 'workspace quality gates',
27
+ refreshSecs: 5,
28
+ exclude: [],
29
+ };
30
+
31
+ function readJson(absPath) {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(absPath, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function statMtimeMs(absPath) {
40
+ try {
41
+ return fs.statSync(absPath).mtimeMs;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function toMs(iso) {
48
+ if (!iso) return null;
49
+ const ms = Date.parse(iso);
50
+ return Number.isNaN(ms) ? null : ms;
51
+ }
52
+
53
+ function toPosix(p) {
54
+ return p.split(path.sep).join('/');
55
+ }
56
+
57
+ /**
58
+ * Walk up from `startDir` (inclusive) to the nearest ancestor whose package.json declares a
59
+ * non-empty `workspaces` array. Returns that directory, or null if none is found.
60
+ *
61
+ * @param {string} startDir
62
+ * @returns {string | null}
63
+ */
64
+ export function findRepoRoot(startDir = process.cwd()) {
65
+ let dir = path.resolve(startDir);
66
+ for (;;) {
67
+ const pkg = readJson(path.join(dir, 'package.json'));
68
+ const ws = pkg?.workspaces;
69
+ const patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : null;
70
+ if (patterns && patterns.length > 0) return dir;
71
+ const parent = path.dirname(dir);
72
+ if (parent === dir) return null;
73
+ dir = parent;
74
+ }
75
+ }
76
+
77
+ function workspacePatternsOf(pkg) {
78
+ const ws = pkg?.workspaces;
79
+ if (Array.isArray(ws)) return ws;
80
+ if (Array.isArray(ws?.packages)) return ws.packages;
81
+ return [];
82
+ }
83
+
84
+ /**
85
+ * Resolve the directories of the npm workspaces declared in `repoRoot`'s package.json.
86
+ *
87
+ * Supports the two common glob shapes (`dir/*` and an exact `dir/name`). A trailing `/*`
88
+ * expands to the immediate child directories that contain a package.json. Dot-prefixed
89
+ * directories (e.g. `.template`) are skipped to match npm's own glob behaviour.
90
+ *
91
+ * @param {string} repoRoot
92
+ * @returns {string[]} absolute workspace directories
93
+ */
94
+ export function discoverWorkspaceDirs(repoRoot) {
95
+ const pkg = readJson(path.join(repoRoot, 'package.json'));
96
+ const patterns = workspacePatternsOf(pkg);
97
+ const dirs = [];
98
+ const seen = new Set();
99
+ const add = (absDir) => {
100
+ if (seen.has(absDir)) return;
101
+ if (!fs.existsSync(path.join(absDir, 'package.json'))) return;
102
+ seen.add(absDir);
103
+ dirs.push(absDir);
104
+ };
105
+
106
+ for (const pattern of patterns) {
107
+ const normalized = pattern.replace(/\/+$/, '');
108
+ if (normalized.endsWith('/*')) {
109
+ const baseDir = path.join(repoRoot, normalized.slice(0, -2));
110
+ let entries;
111
+ try {
112
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
113
+ } catch {
114
+ continue;
115
+ }
116
+ for (const entry of entries) {
117
+ if (!entry.isDirectory()) continue;
118
+ if (entry.name.startsWith('.')) continue; // npm's `*` does not match dotfiles
119
+ add(path.join(baseDir, entry.name));
120
+ }
121
+ } else {
122
+ add(path.join(repoRoot, normalized));
123
+ }
124
+ }
125
+ return dirs.sort();
126
+ }
127
+
128
+ function isNoOpGate(pkgJson) {
129
+ const script = pkgJson?.scripts?.['scripts-orchestrator'];
130
+ if (script == null) return true; // workspace does not participate in the gate
131
+ return /echo\s+["']not applicable["']/i.test(script);
132
+ }
133
+
134
+ /** A results file from a previous run window (older than this run started). */
135
+ function isStale(jsonAbs, payload, runStartedAt) {
136
+ if (runStartedAt == null) return false;
137
+ if (payload?.timestamp != null) {
138
+ const ts = Date.parse(payload.timestamp);
139
+ if (!Number.isNaN(ts) && ts < runStartedAt - 2000) return true;
140
+ }
141
+ const mtime = statMtimeMs(jsonAbs);
142
+ if (mtime != null && mtime < runStartedAt) return true;
143
+ return false;
144
+ }
145
+
146
+ /** Re-root a workspace command's logFile (relative to the workspace) onto the repo root. */
147
+ function commandsWithRepoRelLogs(commands, relDir) {
148
+ return (commands || []).map((c) => {
149
+ if (!c.logFile) return { ...c };
150
+ return { ...c, logFile: toPosix(path.join(relDir, c.logFile)) };
151
+ });
152
+ }
153
+
154
+ function classifyWorkspace({ noOp, exists, stale, success, inProgress, fanoutOk }) {
155
+ if (noOp) return { statusKind: 'muted', statusLabel: 'N/A', state: 'noOp' };
156
+ if (!exists) {
157
+ return inProgress
158
+ ? { statusKind: 'muted', statusLabel: 'PENDING', state: 'pending' }
159
+ : { statusKind: 'muted', statusLabel: '—', state: 'absent' };
160
+ }
161
+ if (stale) {
162
+ if (inProgress) return { statusKind: 'muted', statusLabel: 'PENDING', state: 'pending' };
163
+ // The workspace's own results predate this run, but the root run's workspace fan-out phase
164
+ // still succeeded — so the gate passed this run from a task-runner cache replay (it was not
165
+ // re-executed, so no fresh JSON was written). Surface its last-known (cached) results rather
166
+ // than a misleading "no data" STALE. Only a clean fan-out earns this; a failed/partial fan-out
167
+ // leaves genuinely-unrun workspaces as STALE.
168
+ if (fanoutOk === true) {
169
+ return { statusKind: 'warn', statusLabel: 'CACHED', state: 'cached' };
170
+ }
171
+ return { statusKind: 'warn', statusLabel: 'STALE', state: 'stale' };
172
+ }
173
+ if (success === null || success === undefined) {
174
+ return inProgress
175
+ ? { statusKind: 'running', statusLabel: 'RUNNING', state: 'running' }
176
+ : { statusKind: 'fail', statusLabel: 'INTERRUPTED', state: 'interrupted' };
177
+ }
178
+ if (success === false) return { statusKind: 'fail', statusLabel: 'FAIL', state: 'fail' };
179
+ return { statusKind: 'ok', statusLabel: 'OK', state: 'ok' };
180
+ }
181
+
182
+ function resolveOptions(options = {}) {
183
+ const repoRoot = path.resolve(options.repoRoot || findRepoRoot() || process.cwd());
184
+ const merged = { ...DEFAULTS, ...options, repoRoot };
185
+ const abs = (p) => (path.isAbsolute(p) ? p : path.join(repoRoot, p));
186
+ return {
187
+ ...merged,
188
+ runStateAbs: abs(merged.runStateFile),
189
+ rootResultsAbs: abs(merged.rootResults),
190
+ globalResultsAbs: abs(merged.globalResults),
191
+ outJsonAbs: abs(merged.outJson),
192
+ outHtmlAbs: abs(merged.outHtml),
193
+ };
194
+ }
195
+
196
+ function collectGlobalCommands(opts, runStartedAt) {
197
+ const root = readJson(opts.rootResultsAbs);
198
+ if (root?.commands && !isStale(opts.rootResultsAbs, root, runStartedAt)) {
199
+ const fromRoot = root.commands.filter((c) => c.phase === opts.globalPhase);
200
+ if (fromRoot.length > 0) return fromRoot;
201
+ }
202
+ const global = readJson(opts.globalResultsAbs);
203
+ if (global?.commands && !isStale(opts.globalResultsAbs, global, runStartedAt)) {
204
+ return global.commands.map((c) => ({ ...c, phase: c.phase ?? opts.globalPhase }));
205
+ }
206
+ return [];
207
+ }
208
+
209
+ /**
210
+ * Build the generic report document aggregating the root run's global checks and every
211
+ * workspace's own orchestrator results.
212
+ *
213
+ * @param {object} [options] see DEFAULTS for the supported keys (all paths repo-root-relative)
214
+ * @returns {object} a renderable report payload ({ title, success, sections, ... })
215
+ */
216
+ export function aggregateWorkspacesReport(options = {}) {
217
+ const opts = resolveOptions(options);
218
+ const runState = readJson(opts.runStateAbs);
219
+ const rootJson = readJson(opts.rootResultsAbs);
220
+
221
+ // Prefer the run-state file's startedAt (present while the run is in flight). The end-of-run
222
+ // aggregate fires AFTER the orchestrator clears that file, so fall back to deriving the run
223
+ // start from the root results: its `timestamp` is the run's END, so subtract its duration.
224
+ // (Using the raw end timestamp would wrongly flag every workspace JSON — all written before
225
+ // the root run finished — as stale.) With no duration to anchor the window, stay lenient
226
+ // (null) rather than risk false "stale" verdicts on this run's results.
227
+ let runStartedAt = toMs(runState?.startedAt);
228
+ if (runStartedAt == null && rootJson?.timestamp) {
229
+ const endMs = toMs(rootJson.timestamp);
230
+ const durationMs = Number(rootJson.overallDurationMs);
231
+ if (endMs != null && Number.isFinite(durationMs) && durationMs > 0) {
232
+ runStartedAt = endMs - durationMs;
233
+ }
234
+ }
235
+
236
+ const inProgress =
237
+ options.inProgress != null
238
+ ? Boolean(options.inProgress)
239
+ : runState != null || rootJson?.success === null;
240
+
241
+ const excludeSet = new Set((opts.exclude || []).map((e) => toPosix(e)));
242
+
243
+ // Did this run's workspace fan-out phase pass? (Used to recognise cache-replayed workspaces,
244
+ // whose own results JSON predates this run because they were not re-executed.) null = no
245
+ // fan-out phase in the root results (e.g. a standalone workspace run) → no cache recovery.
246
+ const fanoutCommands = (rootJson?.commands || []).filter((c) => c.phase === opts.workspacePhase);
247
+ const fanoutOk = fanoutCommands.length === 0 ? null : fanoutCommands.every((c) => c.success !== false);
248
+
249
+ // Global quality checks section (commands run by the root orchestrator itself).
250
+ const globalCommands = collectGlobalCommands(opts, runStartedAt);
251
+ const globalFailed = globalCommands.filter((c) => c.success === false).length;
252
+ const sections = [
253
+ {
254
+ title: 'Global quality checks',
255
+ success: inProgress ? null : globalFailed === 0,
256
+ commands: globalCommands,
257
+ },
258
+ ];
259
+
260
+ // One section per discovered workspace.
261
+ let anyWorkspaceBad = false;
262
+ for (const wsDir of discoverWorkspaceDirs(opts.repoRoot)) {
263
+ const relDir = toPosix(path.relative(opts.repoRoot, wsDir));
264
+ if (excludeSet.has(relDir)) continue;
265
+
266
+ const pkg = readJson(path.join(wsDir, 'package.json'));
267
+ if (!pkg) continue;
268
+ const name = pkg.name || relDir;
269
+ const noOp = isNoOpGate(pkg);
270
+
271
+ const jsonAbs = path.join(wsDir, opts.workspaceResults);
272
+ const payload = readJson(jsonAbs);
273
+ const exists = payload != null;
274
+ const stale = exists && isStale(jsonAbs, payload, runStartedAt);
275
+ const success = payload?.success;
276
+
277
+ const st = classifyWorkspace({ noOp, exists, stale, success, inProgress, fanoutOk });
278
+ if (st.state === 'fail' || st.state === 'interrupted') anyWorkspaceBad = true;
279
+
280
+ const showCommands = st.state === 'ok' || st.state === 'fail' || st.state === 'cached';
281
+ const commands = showCommands ? commandsWithRepoRelLogs(payload.commands, relDir) : [];
282
+
283
+ const note =
284
+ commands.length === 0 && st.state === 'pending'
285
+ ? 'Queued — not started yet this run'
286
+ : commands.length === 0 && st.state === 'stale'
287
+ ? 'No results for this run window (stale snapshot)'
288
+ : null;
289
+
290
+ sections.push({
291
+ title: name,
292
+ statusKind: st.statusKind,
293
+ statusLabel: st.statusLabel,
294
+ success:
295
+ st.state === 'ok' || st.state === 'cached'
296
+ ? success ?? true
297
+ : st.state === 'fail'
298
+ ? false
299
+ : null,
300
+ overallDurationMs: showCommands ? payload.overallDurationMs : undefined,
301
+ meta: { path: relDir, ...(note ? { note } : {}) },
302
+ commands,
303
+ });
304
+ }
305
+
306
+ const rootOk = rootJson ? rootJson.success !== false : true;
307
+ const success = inProgress ? null : rootOk && globalFailed === 0 && !anyWorkspaceBad;
308
+
309
+ return {
310
+ title: opts.title,
311
+ success,
312
+ timestamp: new Date().toISOString(),
313
+ overallDurationMs: rootJson?.overallDurationMs,
314
+ inProgress,
315
+ sections,
316
+ // Carry heat thresholds into the aggregate payload so the renderer honours them.
317
+ ...(opts.memoryHeat ? { memoryHeat: opts.memoryHeat } : {}),
318
+ ...(opts.durationHeat ? { durationHeat: opts.durationHeat } : {}),
319
+ };
320
+ }
321
+
322
+ /** Insert a meta-refresh so an open browser tab live-reloads while the run is in flight. */
323
+ function injectAutoRefresh(html, refreshSecs) {
324
+ if (html.includes('http-equiv="refresh"')) return html;
325
+ const meta = ` <meta http-equiv="refresh" content="${refreshSecs}">\n`;
326
+ return html.replace(/(<meta charset="utf-8">\n)/, `$1${meta}`);
327
+ }
328
+
329
+ function atomicWrite(absPath, content) {
330
+ const tmp = `${absPath}.tmp`;
331
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
332
+ fs.writeFileSync(tmp, content, 'utf8');
333
+ fs.renameSync(tmp, absPath);
334
+ }
335
+
336
+ /**
337
+ * Build the aggregate report and write both the JSON document and the rendered HTML.
338
+ *
339
+ * @param {object} [options]
340
+ * @returns {{ payload: object, jsonPath: string, htmlPath: string }}
341
+ */
342
+ export function writeAggregateReport(options = {}) {
343
+ const opts = resolveOptions(options);
344
+ const payload = aggregateWorkspacesReport(options);
345
+
346
+ atomicWrite(opts.outJsonAbs, JSON.stringify(payload, null, 2));
347
+
348
+ let html = renderReportHtml(payload);
349
+ if (payload.inProgress) html = injectAutoRefresh(html, opts.refreshSecs);
350
+ atomicWrite(opts.outHtmlAbs, html);
351
+
352
+ return { payload, jsonPath: opts.outJsonAbs, htmlPath: opts.outHtmlAbs };
353
+ }
@@ -0,0 +1,303 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import {
5
+ findRepoRoot,
6
+ discoverWorkspaceDirs,
7
+ aggregateWorkspacesReport,
8
+ writeAggregateReport,
9
+ } from './workspaces.js';
10
+
11
+ // Anchored in the past so freshly-written fixture files always have an mtime AFTER the run
12
+ // started (the staleness check considers both the payload timestamp and the file mtime).
13
+ const RUN_STARTED = '2020-01-01T10:00:00.000Z';
14
+ const RUN_STARTED_MS = Date.parse(RUN_STARTED);
15
+ const fresh = (offsetSec = 10) => new Date(RUN_STARTED_MS + offsetSec * 1000).toISOString();
16
+ const old = (offsetSec = 60) => new Date(RUN_STARTED_MS - offsetSec * 1000).toISOString();
17
+
18
+ let repoRoot;
19
+
20
+ function writeJson(absPath, obj) {
21
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
22
+ fs.writeFileSync(absPath, JSON.stringify(obj, null, 2));
23
+ }
24
+
25
+ function makeWorkspace(relDir, { name, orchestratorScript, results } = {}) {
26
+ const wsDir = path.join(repoRoot, relDir);
27
+ writeJson(path.join(wsDir, 'package.json'), {
28
+ name: name ?? relDir,
29
+ scripts:
30
+ orchestratorScript === undefined
31
+ ? { 'scripts-orchestrator': 'run-gate' }
32
+ : orchestratorScript === null
33
+ ? {}
34
+ : { 'scripts-orchestrator': orchestratorScript },
35
+ });
36
+ if (results) {
37
+ writeJson(
38
+ path.join(wsDir, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
39
+ results,
40
+ );
41
+ }
42
+ }
43
+
44
+ beforeEach(() => {
45
+ repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'so-ws-'));
46
+ writeJson(path.join(repoRoot, 'package.json'), {
47
+ name: 'root',
48
+ workspaces: ['packages/*', 'apps/*'],
49
+ });
50
+ });
51
+
52
+ afterEach(() => {
53
+ fs.rmSync(repoRoot, { recursive: true, force: true });
54
+ });
55
+
56
+ describe('discoverWorkspaceDirs', () => {
57
+ test('expands dir/* patterns, skips dotdirs and dirs without package.json', () => {
58
+ makeWorkspace('packages/a');
59
+ makeWorkspace('packages/b');
60
+ makeWorkspace('apps/web');
61
+ fs.mkdirSync(path.join(repoRoot, 'packages/.template'), { recursive: true });
62
+ writeJson(path.join(repoRoot, 'packages/.template/package.json'), { name: 't' });
63
+ fs.mkdirSync(path.join(repoRoot, 'packages/no-pkg'), { recursive: true });
64
+
65
+ const dirs = discoverWorkspaceDirs(repoRoot).map((d) => path.relative(repoRoot, d));
66
+ expect(dirs).toEqual(['apps/web', 'packages/a', 'packages/b']);
67
+ });
68
+ });
69
+
70
+ describe('findRepoRoot', () => {
71
+ test('walks up to the package.json that declares workspaces', () => {
72
+ makeWorkspace('packages/a');
73
+ const found = findRepoRoot(path.join(repoRoot, 'packages/a'));
74
+ expect(found).toBe(path.resolve(repoRoot));
75
+ });
76
+ });
77
+
78
+ describe('aggregateWorkspacesReport', () => {
79
+ function rootResults(commands, success = true) {
80
+ writeJson(
81
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
82
+ { success, timestamp: fresh(5), overallDurationMs: 1234, commands },
83
+ );
84
+ }
85
+
86
+ function markRunning() {
87
+ writeJson(path.join(repoRoot, 'logs/.scripts-orchestrator-run.json'), {
88
+ startedAt: RUN_STARTED,
89
+ pid: 123,
90
+ });
91
+ }
92
+
93
+ test('classifies ok / fail workspaces and re-roots their log paths', () => {
94
+ rootResults([
95
+ { command: 'lint', phase: 'global quality checks', success: true },
96
+ ]);
97
+ makeWorkspace('apps/web', {
98
+ name: '@app/web',
99
+ results: {
100
+ success: true,
101
+ timestamp: fresh(),
102
+ commands: [{ command: 'test', success: true, logFile: 'logs/scripts-orchestrator-logs/test.log' }],
103
+ },
104
+ });
105
+ makeWorkspace('packages/a', {
106
+ name: '@pkg/a',
107
+ results: {
108
+ success: false,
109
+ timestamp: fresh(),
110
+ commands: [{ command: 'build', success: false }],
111
+ },
112
+ });
113
+
114
+ const report = aggregateWorkspacesReport({ repoRoot });
115
+ expect(report.inProgress).toBe(false);
116
+ expect(report.success).toBe(false); // a failing workspace fails the roll-up
117
+
118
+ const web = report.sections.find((s) => s.title === '@app/web');
119
+ expect(web.statusLabel).toBe('OK');
120
+ expect(web.commands[0].logFile).toBe('apps/web/logs/scripts-orchestrator-logs/test.log');
121
+
122
+ const a = report.sections.find((s) => s.title === '@pkg/a');
123
+ expect(a.statusLabel).toBe('FAIL');
124
+ });
125
+
126
+ test('success when every scope passes', () => {
127
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
128
+ makeWorkspace('apps/web', {
129
+ results: { success: true, timestamp: fresh(), commands: [] },
130
+ });
131
+ const report = aggregateWorkspacesReport({ repoRoot });
132
+ expect(report.success).toBe(true);
133
+ });
134
+
135
+ test('global check failure fails the roll-up', () => {
136
+ rootResults(
137
+ [{ command: 'lint', phase: 'global quality checks', success: false }],
138
+ false,
139
+ );
140
+ makeWorkspace('apps/web', { results: { success: true, timestamp: fresh(), commands: [] } });
141
+ const report = aggregateWorkspacesReport({ repoRoot });
142
+ expect(report.success).toBe(false);
143
+ const global = report.sections.find((s) => s.title === 'Global quality checks');
144
+ expect(global.success).toBe(false);
145
+ });
146
+
147
+ test('in-progress run: success:null workspace is RUNNING, missing is PENDING', () => {
148
+ markRunning();
149
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }], null);
150
+ makeWorkspace('apps/web', {
151
+ results: { success: null, timestamp: fresh(), commands: [{ command: 'test', success: null }] },
152
+ });
153
+ makeWorkspace('packages/a'); // no results yet
154
+
155
+ const report = aggregateWorkspacesReport({ repoRoot });
156
+ expect(report.inProgress).toBe(true);
157
+ expect(report.success).toBeNull();
158
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('RUNNING');
159
+ expect(report.sections.find((s) => s.title === 'packages/a').statusLabel).toBe('PENDING');
160
+ });
161
+
162
+ test('after the run, a success:null results file reads as INTERRUPTED, not running', () => {
163
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
164
+ makeWorkspace('apps/web', {
165
+ results: { success: null, timestamp: fresh(), commands: [{ command: 'test', success: null }] },
166
+ });
167
+ const report = aggregateWorkspacesReport({ repoRoot });
168
+ expect(report.inProgress).toBe(false);
169
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('INTERRUPTED');
170
+ expect(report.success).toBe(false);
171
+ });
172
+
173
+ test('end-of-run fire (no run-state): derives run start from root end − duration, not end', () => {
174
+ // The orchestrator clears the run-state file before its final aggregate, and the root
175
+ // results timestamp is the run's END. A workspace that finished mid-run has a timestamp
176
+ // BEFORE that end — it must read OK, not STALE.
177
+ const runStart = RUN_STARTED_MS;
178
+ const runEnd = runStart + 200 * 1000; // 200s run
179
+ writeJson(
180
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
181
+ {
182
+ success: true,
183
+ timestamp: new Date(runEnd).toISOString(),
184
+ overallDurationMs: runEnd - runStart,
185
+ commands: [{ command: 'lint', phase: 'global quality checks', success: true }],
186
+ },
187
+ );
188
+ // Workspace finished 60s into the run — before the root end timestamp.
189
+ makeWorkspace('apps/web', {
190
+ results: {
191
+ success: true,
192
+ timestamp: new Date(runStart + 60 * 1000).toISOString(),
193
+ commands: [{ command: 'test', success: true }],
194
+ },
195
+ });
196
+
197
+ const report = aggregateWorkspacesReport({ repoRoot });
198
+ expect(report.inProgress).toBe(false);
199
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('OK');
200
+ expect(report.success).toBe(true);
201
+ });
202
+
203
+ test('stale results from a previous run are not counted as this run', () => {
204
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
205
+ makeWorkspace('apps/web', {
206
+ results: { success: true, timestamp: old(120), commands: [{ command: 'test', success: true }] },
207
+ });
208
+ const report = aggregateWorkspacesReport({ repoRoot });
209
+ const web = report.sections.find((s) => s.title === 'apps/web');
210
+ expect(web.statusLabel).toBe('STALE');
211
+ expect(web.commands).toHaveLength(0);
212
+ });
213
+
214
+ test('cache replay: stale workspace JSON + passing fan-out phase reads as CACHED with commands', () => {
215
+ // Root results carry the workspace fan-out phase (it passed this run) plus globals.
216
+ writeJson(
217
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
218
+ {
219
+ success: true,
220
+ timestamp: fresh(5),
221
+ overallDurationMs: 1234,
222
+ commands: [
223
+ { command: 'lint', phase: 'global quality checks', success: true },
224
+ { command: 'fan-out', phase: 'workspace quality gates', success: true },
225
+ ],
226
+ },
227
+ );
228
+ // Workspace results predate this run (cache replay did not re-execute it / rewrite its JSON).
229
+ makeWorkspace('apps/web', {
230
+ results: { success: true, timestamp: old(600), commands: [{ command: 'test', success: true }] },
231
+ });
232
+
233
+ const report = aggregateWorkspacesReport({ repoRoot });
234
+ const web = report.sections.find((s) => s.title === 'apps/web');
235
+ expect(web.statusLabel).toBe('CACHED');
236
+ expect(web.commands).toHaveLength(1); // last-known (cached) commands surfaced
237
+ expect(web.success).toBe(true);
238
+ expect(report.success).toBe(true); // cached pass does not fail the roll-up
239
+ });
240
+
241
+ test('failed fan-out phase does not turn stale workspaces into CACHED', () => {
242
+ writeJson(
243
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
244
+ {
245
+ success: false,
246
+ timestamp: fresh(5),
247
+ overallDurationMs: 1234,
248
+ commands: [{ command: 'fan-out', phase: 'workspace quality gates', success: false }],
249
+ },
250
+ );
251
+ makeWorkspace('apps/web', {
252
+ results: { success: true, timestamp: old(600), commands: [{ command: 'test', success: true }] },
253
+ });
254
+ const report = aggregateWorkspacesReport({ repoRoot });
255
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('STALE');
256
+ });
257
+
258
+ test('a no-op gate (echo "not applicable" or no script) is marked N/A', () => {
259
+ rootResults([{ command: 'lint', phase: 'global quality checks', success: true }]);
260
+ makeWorkspace('apps/web', { orchestratorScript: 'echo "not applicable"' });
261
+ makeWorkspace('packages/a', { orchestratorScript: null });
262
+ const report = aggregateWorkspacesReport({ repoRoot });
263
+ expect(report.sections.find((s) => s.title === 'apps/web').statusLabel).toBe('N/A');
264
+ expect(report.sections.find((s) => s.title === 'packages/a').statusLabel).toBe('N/A');
265
+ });
266
+
267
+ test('exclude option drops a workspace from the report', () => {
268
+ rootResults([]);
269
+ makeWorkspace('packages/.ignored-by-glob');
270
+ makeWorkspace('packages/a', { results: { success: true, timestamp: fresh(), commands: [] } });
271
+ const report = aggregateWorkspacesReport({ repoRoot, exclude: ['packages/a'] });
272
+ expect(report.sections.find((s) => s.title === 'packages/a')).toBeUndefined();
273
+ });
274
+ });
275
+
276
+ describe('writeAggregateReport', () => {
277
+ test('writes JSON + HTML and injects auto-refresh only while in progress', () => {
278
+ writeJson(path.join(repoRoot, 'logs/.scripts-orchestrator-run.json'), {
279
+ startedAt: RUN_STARTED,
280
+ pid: 1,
281
+ });
282
+ writeJson(
283
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
284
+ { success: null, timestamp: fresh(), commands: [] },
285
+ );
286
+ makeWorkspace('apps/web', { results: { success: true, timestamp: fresh(), commands: [] } });
287
+
288
+ const { jsonPath, htmlPath } = writeAggregateReport({ repoRoot, title: 'My Roll-up' });
289
+ expect(fs.existsSync(jsonPath)).toBe(true);
290
+ const html = fs.readFileSync(htmlPath, 'utf8');
291
+ expect(html).toContain('My Roll-up');
292
+ expect(html).toContain('http-equiv="refresh"'); // in progress → live refresh
293
+
294
+ // Once finished (no run-state, root results finalized) the refresh meta is gone.
295
+ fs.rmSync(path.join(repoRoot, 'logs/.scripts-orchestrator-run.json'));
296
+ writeJson(
297
+ path.join(repoRoot, 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json'),
298
+ { success: true, timestamp: fresh(), commands: [] },
299
+ );
300
+ const res = writeAggregateReport({ repoRoot, title: 'My Roll-up' });
301
+ expect(fs.readFileSync(res.htmlPath, 'utf8')).not.toContain('http-equiv="refresh"');
302
+ });
303
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.15.1",
3
+ "version": "3.5.0",
4
4
  "description": "A powerful script orchestrator for running parallel commands with dependency management, background processes, and health checks",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",
@@ -1,10 +1,22 @@
1
1
  export default {
2
+ // Optional: prefix prepended to every command. Defaults to 'npm run'.
3
+ // Set to '' (or false/null) to run commands verbatim as regular shell commands,
4
+ // or to another runner like 'pnpm run'. Per-command `shell: true` / `prefix` override this.
5
+ // command_prefix: 'npm run',
2
6
  // Optional: metrics to report (time, memory). CLI --metrics overrides.
3
7
  // metrics: ['time'],
4
8
  // Optional: path for JSON results, or '-' for stdout. CLI --json-results overrides.
5
9
  // json_results: './scripts-orchestrator-results.json',
6
10
  // Optional: path for HTML report. CLI --html-results overrides.
7
11
  // html_results: './scripts-orchestrator-results.html',
12
+ // Optional: memory heat thresholds for the HTML report — fractions (0–1) of the run's peak memory.
13
+ // A command at/above `high` is coloured red, at/above `mid` amber, below `mid` green, on both the
14
+ // Gantt and the Memory table column. Defaults to { mid: 0.33, high: 0.66 } if omitted/invalid.
15
+ // memory_heat: { mid: 0.33, high: 0.66 },
16
+ // Optional: duration heat thresholds for the HTML report — fractions (0–1) of the run's slowest
17
+ // command. A command at/above `high` is coloured red, at/above `mid` amber, below `mid` green, in
18
+ // the Duration table column. Defaults to { mid: 0.33, high: 0.66 } if omitted/invalid.
19
+ // duration_heat: { mid: 0.33, high: 0.66 },
8
20
  phases: [
9
21
  {
10
22
  name: 'build',