scripts-orchestrator 2.12.0 → 2.14.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 CHANGED
@@ -36,6 +36,10 @@ npm install --save-dev scripts-orchestrator
36
36
  - **Optional Phases**: Mark phases as optional and run them selectively
37
37
  - **Git-Based Caching**: Automatically skips execution when git state is unchanged
38
38
  - **Comprehensive Logging**: Detailed logging of command execution and results
39
+ - **Incremental JSON results**: Live-updating `json_results` file as commands complete (v2.14+)
40
+ - **NDJSON event stream**: Machine-readable per-command events for dashboards (v2.14+)
41
+ - **Post-run hook**: Run a shell command after results are written via `post_run` config (v2.14+)
42
+ - **Run-state file**: Library-owned in-progress indicator for live dashboard integration (v2.14+)
39
43
 
40
44
  ## Configuration
41
45
 
@@ -410,6 +414,84 @@ export default {
410
414
 
411
415
  All logs (command logs, main orchestrator logs, and git cache) will be stored in the specified folder.
412
416
 
417
+ ## Live Dashboard Integration (v2.14+)
418
+
419
+ ### Incremental JSON results
420
+
421
+ By default `json_results` is written only at the end of a run. From v2.14 onward the file is
422
+ updated atomically (write-to-temp + rename) after **each command starts or completes**, so
423
+ watchers always see a consistent snapshot:
424
+
425
+ ```json
426
+ {
427
+ "success": null,
428
+ "timestamp": "2026-06-04T07:13:21.000Z",
429
+ "commands": [
430
+ { "command": "lint-ci", "phase": "lint", "success": true, "durationMs": 4200 },
431
+ { "command": "playwright_ci", "phase": "tests", "success": null, "startedAt": "2026-06-04T07:13:25.000Z" }
432
+ ]
433
+ }
434
+ ```
435
+
436
+ `"success": null` at the top level is the in-progress sentinel. It is replaced with `true` or
437
+ `false` when `writeJsonResults` writes the final result.
438
+
439
+ ### NDJSON event stream
440
+
441
+ Alongside `json_results`, the library writes one NDJSON line per event to
442
+ `<json_results_basename>-events.ndjson`:
443
+
444
+ ```jsonl
445
+ {"type":"command_start","timestamp":"...","command":"lint-ci","phase":"lint","scope":"workspace"}
446
+ {"type":"command_end","timestamp":"...","command":"lint-ci","phase":"lint","success":true,"durationMs":4200}
447
+ {"type":"run_end","timestamp":"...","success":true,"durationMs":12800}
448
+ ```
449
+
450
+ Dashboard tools can `tail -f` this file or watch it with `fs.watch` to get real-time updates
451
+ without parsing human-readable log lines.
452
+
453
+ ### Run-state file (A4)
454
+
455
+ When `--logFolder` is specified, the library writes `{logFolder}/.scripts-orchestrator-run.json`
456
+ at run start and removes it on run end:
457
+
458
+ ```json
459
+ {
460
+ "startedAt": "2026-06-04T07:13:17.000Z",
461
+ "pid": 12345,
462
+ "phase": "tests",
463
+ "activeCommand": "playwright_ci"
464
+ }
465
+ ```
466
+
467
+ This file is the authoritative in-progress signal for live dashboards. Its absence means the run
468
+ has finished (or never started).
469
+
470
+ ### Post-run hook (A7)
471
+
472
+ Add `post_run` to your config to run a shell command **after** `json_results` is written and the
473
+ run-state file is cleared, but **before** `process.exit()`:
474
+
475
+ ```javascript
476
+ export default {
477
+ json_results: './logs/scripts-orchestrator-results.json',
478
+ post_run: 'node scripts/generate-report.js', // called after every run
479
+ phases: [ /* ... */ ]
480
+ };
481
+ ```
482
+
483
+ The hook receives two environment variables:
484
+ - `SCRIPTS_ORCHESTRATOR_SUCCESS=1` (or `0`) — whether the run succeeded
485
+ - `SCRIPTS_ORCHESTRATOR_EXIT_CODE=0` (or `1`) — same, as a numeric exit code
486
+
487
+ The hook runs synchronously and its exit code is logged but does not change the orchestrator's
488
+ own exit code.
489
+
490
+ **Typical use case:** trigger a monorepo rollup report after each workspace finishes:
491
+ ```javascript
492
+ post_run: 'node ../../scripts/merge-orchestrator-report.js'
493
+ ```
494
+
413
495
  ## Git-Based Caching
414
496
 
415
497
  The orchestrator automatically tracks the git commit hash and repository state to optimize execution:
package/index.js CHANGED
@@ -39,6 +39,18 @@ const argv = yargs(hideBin(process.argv))
39
39
  type: 'boolean',
40
40
  description: 'Force execution even if git state is unchanged',
41
41
  })
42
+ .option('metrics', {
43
+ type: 'string',
44
+ description: 'Comma-separated metrics to collect and report: time, memory',
45
+ })
46
+ .option('json-results', {
47
+ type: 'string',
48
+ description: 'Write results JSON to this path; use "-" for stdout only',
49
+ })
50
+ .option('html-results', {
51
+ type: 'string',
52
+ description: 'Write HTML report to this path; use "-" for stdout only',
53
+ })
42
54
  .help()
43
55
  .alias('h', 'help')
44
56
  .parse();
@@ -52,6 +64,8 @@ const phases = argv.phases ? argv.phases.split(',').map(p => p.trim()) : null;
52
64
  const sequential = argv.sequential || false;
53
65
  const force = argv.force || false;
54
66
 
67
+ const validMetrics = ['time', 'memory'];
68
+
55
69
  // Validate config file exists
56
70
  if (!fs.existsSync(configPath)) {
57
71
  log.error(`Error: Config file not found at ${configPath}`);
@@ -74,13 +88,51 @@ if (!logFolder && commandsConfig.log_folder) {
74
88
  logFolder = commandsConfig.log_folder;
75
89
  }
76
90
 
91
+ // Metrics: CLI overrides config
92
+ let metrics = [];
93
+ if (argv.metrics != null && argv.metrics !== '') {
94
+ metrics = argv.metrics.split(',').map((m) => m.trim()).filter((m) => validMetrics.includes(m));
95
+ } else if (commandsConfig.metrics != null) {
96
+ const fromConfig = Array.isArray(commandsConfig.metrics)
97
+ ? commandsConfig.metrics
98
+ : String(commandsConfig.metrics).split(',').map((m) => m.trim());
99
+ metrics = fromConfig.filter((m) => validMetrics.includes(m));
100
+ }
101
+
102
+ // JSON results path: CLI overrides config
103
+ const jsonResultsPath =
104
+ argv.jsonResults != null
105
+ ? argv.jsonResults
106
+ : (commandsConfig.json_results ?? commandsConfig.json_results_path ?? null);
107
+
108
+ // HTML results path: CLI overrides config (optional)
109
+ const htmlResultsPath =
110
+ argv.htmlResults != null
111
+ ? argv.htmlResults
112
+ : (commandsConfig.html_results ?? commandsConfig.html_results_path ?? null);
113
+
114
+ // A7: post-run hook — shell command run after json_results written
115
+ const postRun = commandsConfig.post_run ?? null;
116
+
77
117
  // Set the log folder for the main orchestrator logs if specified
78
118
  if (logFolder) {
79
119
  log.setLogFolder(logFolder);
80
120
  }
81
121
 
82
122
  // Create and run the orchestrator
83
- const orchestrator = new Orchestrator(commandsConfig, startPhase, logFolder, phases, sequential, force);
123
+ const orchestrator = new Orchestrator(
124
+ commandsConfig,
125
+ startPhase,
126
+ logFolder,
127
+ phases,
128
+ sequential,
129
+ force,
130
+ metrics,
131
+ jsonResultsPath,
132
+ htmlResultsPath,
133
+ );
134
+ // A7: wire post-run hook from config
135
+ orchestrator.postRun = postRun;
84
136
 
85
137
  // Enhanced signal handlers
86
138
  const handleSignal = async (signal) => {
@@ -90,6 +142,8 @@ const handleSignal = async (signal) => {
90
142
  } catch (error) {
91
143
  log.error(`Cleanup failed: ${error.message}`);
92
144
  }
145
+ // A4: clear run-state so dashboards know the run ended
146
+ orchestrator._clearRunState();
93
147
  process.exit(1);
94
148
  };
95
149
 
@@ -1,3 +1,6 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
1
4
  import { processManager } from './process-manager.js';
2
5
  import { healthCheck } from './health-check.js';
3
6
  import { log } from './logger.js';
@@ -12,6 +15,9 @@ export class Orchestrator {
12
15
  phases = null,
13
16
  sequential = false,
14
17
  force = false,
18
+ metrics = [],
19
+ jsonResultsPath = null,
20
+ htmlResultsPath = null,
15
21
  ) {
16
22
  this.config = config;
17
23
  this.startPhase = startPhase;
@@ -19,14 +25,28 @@ export class Orchestrator {
19
25
  this.phases = phases;
20
26
  this.sequential = sequential;
21
27
  this.force = force;
28
+ this.metrics = Array.isArray(metrics) ? metrics : [];
29
+ this.jsonResultsPath = jsonResultsPath ?? null;
30
+ this.htmlResultsPath = htmlResultsPath ?? null;
22
31
  this.processManager = processManager;
23
32
  this.healthCheck = healthCheck;
24
33
  this.logger = log;
25
34
  this.failedCommands = [];
26
35
  this.skippedCommands = [];
27
36
  this.skipReasons = new Map(); // Track why commands were skipped
28
- this.commandTimings = new Map();
37
+ this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
38
+ this.phaseResults = []; // { name, success, durationMs } per phase run
29
39
  this.gitCache = new GitCache(logFolder);
40
+ // A1: track per-command start times for incremental JSON
41
+ this.commandStartTimes = new Map(); // command -> ISO start string
42
+ // A2: events file path derived from jsonResultsPath
43
+ this.eventsPath = this._deriveEventsPath(jsonResultsPath);
44
+ // A4: library-owned run-state file
45
+ this.runStatePath = logFolder
46
+ ? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
47
+ : null;
48
+ // A7: post-run hook command (shell string)
49
+ this.postRun = null; // set from config in index.js
30
50
 
31
51
  // Set the log folder in process manager
32
52
  if (logFolder) {
@@ -50,6 +70,118 @@ export class Orchestrator {
50
70
  return [];
51
71
  }
52
72
 
73
+ _deriveEventsPath(jsonResultsPath) {
74
+ if (!jsonResultsPath || jsonResultsPath === '-') return null;
75
+ return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
76
+ }
77
+
78
+ // A4: write current run state atomically
79
+ _writeRunState(extra = {}) {
80
+ if (!this.runStatePath) return;
81
+ const state = {
82
+ startedAt: this.runStartedAt ? new Date(this.runStartedAt).toISOString() : new Date().toISOString(),
83
+ pid: process.pid,
84
+ ...extra,
85
+ };
86
+ const tmp = this.runStatePath + '.tmp';
87
+ try {
88
+ fs.mkdirSync(path.dirname(this.runStatePath), { recursive: true });
89
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
90
+ fs.renameSync(tmp, this.runStatePath);
91
+ } catch { /* non-fatal */ }
92
+ }
93
+
94
+ // A4: remove run-state file on run end
95
+ _clearRunState() {
96
+ if (!this.runStatePath) return;
97
+ try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
98
+ }
99
+
100
+ // A2: append a structured NDJSON event
101
+ _appendEvent(type, data = {}) {
102
+ if (!this.eventsPath) return;
103
+ const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
104
+ try {
105
+ fs.appendFileSync(this.eventsPath, line + '\n', 'utf8');
106
+ } catch {
107
+ // non-fatal: don't let event logging break the run
108
+ }
109
+ }
110
+
111
+ // A1: atomically write current run state (completed + in-flight commands) to json_results
112
+ _writePartialResults() {
113
+ if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
114
+ const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
115
+
116
+ const commands = [];
117
+
118
+ const buildEntry = (command, phaseName) => {
119
+ const timing = this.commandTimings.get(command);
120
+ const startedAt = this.commandStartTimes.get(command);
121
+ const skipped = this.skippedCommands.includes(command);
122
+ const done = timing != null || skipped;
123
+
124
+ if (!done && startedAt) {
125
+ return { command, ...(phaseName ? { phase: phaseName } : {}), success: null, startedAt };
126
+ }
127
+ if (!done) return null; // not yet started — omit
128
+
129
+ const skipReason = skipped ? (this.skipReasons.get(command) ?? null) : null;
130
+ const success =
131
+ !this.failedCommands.includes(command) &&
132
+ (skipReason === null ||
133
+ skipReason === 'disabled' ||
134
+ skipReason === 'optional_phase_not_requested' ||
135
+ skipReason === 'before_start_phase');
136
+ return {
137
+ command,
138
+ ...(phaseName ? { phase: phaseName } : {}),
139
+ success,
140
+ ...(timing?.durationMs != null && this.metrics.includes('time') ? { durationMs: timing.durationMs } : {}),
141
+ ...(this.metrics.includes('memory') ? { memoryKb: timing?.memoryKb ?? null } : {}),
142
+ ...(skipReason ? { skipReason } : {}),
143
+ };
144
+ };
145
+
146
+ if (Array.isArray(this.config)) {
147
+ for (const { command } of this.config) {
148
+ const entry = buildEntry(command, null);
149
+ if (entry) commands.push(entry);
150
+ }
151
+ } else if (this.config.phases) {
152
+ for (const phase of this.config.phases) {
153
+ for (const { command } of (phase.parallel || [])) {
154
+ const entry = buildEntry(command, phase.name);
155
+ if (entry) commands.push(entry);
156
+ }
157
+ }
158
+ }
159
+
160
+ const payload = {
161
+ success: null, // in-progress sentinel; replaced by writeJsonResults on completion
162
+ timestamp: new Date().toISOString(),
163
+ commands,
164
+ ...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
165
+ };
166
+
167
+ const tmpPath = outPath + '.tmp';
168
+ try {
169
+ fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
170
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
171
+ fs.renameSync(tmpPath, outPath);
172
+ } catch (err) {
173
+ this.logger.verbose(`Partial results write failed: ${err.message}`);
174
+ }
175
+
176
+ // A4: keep run-state file in sync with current active commands and phase
177
+ const inFlight = commands.filter(c => c.success === null).map(c => c.command);
178
+ const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
179
+ this._writeRunState({
180
+ activeCommand: inFlight.length === 1 ? inFlight[0] : (inFlight.length > 1 ? inFlight : null),
181
+ phase: currentPhase,
182
+ });
183
+ }
184
+
53
185
  formatDuration(ms) {
54
186
  if (ms < 1000) return `${ms}ms`;
55
187
  const seconds = Math.floor(ms / 1000);
@@ -61,7 +193,7 @@ export class Orchestrator {
61
193
  return `${seconds}s`;
62
194
  }
63
195
 
64
- async executeCommand(commandConfig, visited = new Set()) {
196
+ async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
65
197
  const {
66
198
  command,
67
199
  dependencies = [],
@@ -80,13 +212,17 @@ export class Orchestrator {
80
212
 
81
213
  const startTime = Date.now();
82
214
 
215
+ const setTiming = (durationMs, memoryKb = null) => {
216
+ this.commandTimings.set(command, { durationMs, memoryKb });
217
+ };
218
+
83
219
  // Check for circular dependencies
84
220
  if (visited.has(command)) {
85
221
  this.logger.error(
86
222
  `Circular dependency detected: ${Array.from(visited).join(' -> ')} -> ${command}`,
87
223
  );
88
224
  this.failedCommands.push(command);
89
- this.commandTimings.set(command, Date.now() - startTime);
225
+ setTiming(Date.now() - startTime);
90
226
  return false;
91
227
  }
92
228
  visited.add(command);
@@ -96,7 +232,7 @@ export class Orchestrator {
96
232
  this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
97
233
  this.skippedCommands.push(command);
98
234
  this.skipReasons.set(command, 'disabled');
99
- this.commandTimings.set(command, Date.now() - startTime);
235
+ setTiming(Date.now() - startTime);
100
236
  visited.delete(command);
101
237
  return true;
102
238
  }
@@ -127,7 +263,7 @@ export class Orchestrator {
127
263
  process_tracking,
128
264
  kill_command,
129
265
  });
130
- this.commandTimings.set(command, Date.now() - startTime);
266
+ setTiming(Date.now() - startTime);
131
267
  visited.delete(command);
132
268
  return true;
133
269
  }
@@ -140,7 +276,7 @@ export class Orchestrator {
140
276
  this.logger.error(`Skipping ${command} due to failed dependency`);
141
277
  this.skippedCommands.push(command);
142
278
  this.skipReasons.set(command, 'failed_dependency');
143
- this.commandTimings.set(command, Date.now() - startTime);
279
+ setTiming(Date.now() - startTime);
144
280
  visited.delete(command);
145
281
  return false;
146
282
  }
@@ -154,7 +290,7 @@ export class Orchestrator {
154
290
  if (!urlAvailable) {
155
291
  this.skippedCommands.push(command);
156
292
  this.skipReasons.set(command, 'failed_dependency');
157
- this.commandTimings.set(command, Date.now() - startTime);
293
+ setTiming(Date.now() - startTime);
158
294
  visited.delete(command);
159
295
  return false;
160
296
  }
@@ -172,10 +308,16 @@ export class Orchestrator {
172
308
  }
173
309
  }
174
310
 
311
+ // A1/A2: record start and emit event
312
+ this.commandStartTimes.set(command, new Date().toISOString());
313
+ this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
314
+ this._writePartialResults();
315
+
175
316
  // Execute the main command with retries
176
317
  let result = false;
177
318
  let commandOutput = '';
178
319
  let commandFailed = false;
320
+ let lastRunResult = null;
179
321
 
180
322
  for (let attempt = 1; attempt <= attempts; attempt++) {
181
323
  if (attempt > 1) {
@@ -185,7 +327,7 @@ export class Orchestrator {
185
327
  await new Promise((resolve) => setTimeout(resolve, 1000));
186
328
  }
187
329
 
188
- const { success, output } = await this.processManager.runCommand({
330
+ const runResult = await this.processManager.runCommand({
189
331
  cmd: attempt === 1 ? command : retry_command || command,
190
332
  logFile: log || logFile, // Prefer 'log' key over 'logFile' for backwards compatibility
191
333
  background,
@@ -193,7 +335,11 @@ export class Orchestrator {
193
335
  kill_command,
194
336
  isRetry: attempt > 1,
195
337
  env,
338
+ reportTime: this.metrics.includes('time'),
339
+ reportMemory: this.metrics.includes('memory'),
196
340
  });
341
+ lastRunResult = runResult;
342
+ const { success, output } = runResult;
197
343
  commandOutput = output;
198
344
  result = success;
199
345
 
@@ -239,7 +385,11 @@ export class Orchestrator {
239
385
  }
240
386
  }
241
387
 
242
- this.commandTimings.set(command, Date.now() - startTime);
388
+ const totalDurationMs = Date.now() - startTime;
389
+ setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
390
+ // A1/A2: emit completion event and write incremental results
391
+ this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
392
+ this._writePartialResults();
243
393
  visited.delete(command);
244
394
  return result;
245
395
  }
@@ -269,8 +419,222 @@ export class Orchestrator {
269
419
  }
270
420
  }
271
421
 
422
+ writeJsonResults(hasFailures) {
423
+ const overallDurationMs =
424
+ this.metrics.includes('time') && this.startTime
425
+ ? Date.now() - this.startTime
426
+ : undefined;
427
+
428
+ const commands = [];
429
+ if (Array.isArray(this.config)) {
430
+ this.config.forEach(({ command }) => {
431
+ const timing = this.commandTimings.get(command);
432
+ const skipReason = this.skippedCommands.includes(command)
433
+ ? this.skipReasons.get(command) ?? null
434
+ : null;
435
+ const success =
436
+ !this.failedCommands.includes(command) &&
437
+ (skipReason === null ||
438
+ skipReason === 'disabled' ||
439
+ skipReason === 'optional_phase_not_requested' ||
440
+ skipReason === 'before_start_phase');
441
+ const entry = {
442
+ command,
443
+ success,
444
+ ...(timing?.durationMs != null && this.metrics.includes('time')
445
+ ? { durationMs: timing.durationMs }
446
+ : {}),
447
+ ...(this.metrics.includes('memory')
448
+ ? { memoryKb: timing?.memoryKb ?? null }
449
+ : {}),
450
+ ...(skipReason ? { skipReason } : {}),
451
+ };
452
+ commands.push(entry);
453
+ });
454
+ } else if (this.config.phases) {
455
+ this.config.phases.forEach((phase) => {
456
+ (phase.parallel || []).forEach(({ command }) => {
457
+ const timing = this.commandTimings.get(command);
458
+ const skipReason = this.skippedCommands.includes(command)
459
+ ? this.skipReasons.get(command) ?? null
460
+ : null;
461
+ const success =
462
+ !this.failedCommands.includes(command) &&
463
+ (skipReason === null ||
464
+ skipReason === 'disabled' ||
465
+ skipReason === 'optional_phase_not_requested' ||
466
+ skipReason === 'before_start_phase');
467
+ const entry = {
468
+ command,
469
+ phase: phase.name,
470
+ success,
471
+ ...(timing?.durationMs != null && this.metrics.includes('time')
472
+ ? { durationMs: timing.durationMs }
473
+ : {}),
474
+ ...(this.metrics.includes('memory')
475
+ ? { memoryKb: timing?.memoryKb ?? null }
476
+ : {}),
477
+ ...(skipReason ? { skipReason } : {}),
478
+ };
479
+ commands.push(entry);
480
+ });
481
+ });
482
+ }
483
+
484
+ const payload = {
485
+ success: !hasFailures,
486
+ timestamp: new Date().toISOString(),
487
+ ...(overallDurationMs != null ? { overallDurationMs } : {}),
488
+ commands,
489
+ ...(this.config.phases && this.phaseResults.length > 0
490
+ ? { phases: this.phaseResults }
491
+ : {}),
492
+ };
493
+
494
+ const json = JSON.stringify(payload, null, 2);
495
+ if (this.jsonResultsPath === '-') {
496
+ console.log(json);
497
+ } else {
498
+ const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
499
+ fs.writeFileSync(outPath, json, 'utf8');
500
+ this.logger.verbose(`Wrote results to ${outPath}`);
501
+ }
502
+
503
+ if (this.htmlResultsPath != null) {
504
+ this.writeHtmlResults(payload);
505
+ }
506
+ }
507
+
508
+ formatMs(ms) {
509
+ if (ms == null || ms === 0) return '—';
510
+ if (ms < 1000) return `${ms}ms`;
511
+ const s = (ms / 1000).toFixed(1);
512
+ return `${s}s`;
513
+ }
514
+
515
+ writeHtmlResults(payload) {
516
+ const escapeHtml = (s) => {
517
+ if (s == null) return '';
518
+ return String(s)
519
+ .replace(/&/g, '&amp;')
520
+ .replace(/</g, '&lt;')
521
+ .replace(/>/g, '&gt;')
522
+ .replace(/"/g, '&quot;');
523
+ };
524
+ const { success, timestamp, overallDurationMs, commands = [], phases = [] } = payload;
525
+ const maxDuration = Math.max(0, ...commands.map((c) => c.durationMs || 0), ...phases.map((p) => p.durationMs || 0));
526
+ const maxMemory = Math.max(0, ...commands.map((c) => c.memoryKb || 0));
527
+
528
+ const row = (c) => {
529
+ const durationPct = maxDuration > 0 && c.durationMs != null ? (c.durationMs / maxDuration) * 100 : 0;
530
+ const memoryPct = maxMemory > 0 && c.memoryKb != null ? (c.memoryKb / maxMemory) * 100 : 0;
531
+ const statusClass = c.success ? 'ok' : 'fail';
532
+ const statusLabel = c.success ? 'OK' : (c.skipReason || 'Failed');
533
+ return `
534
+ <tr class="${statusClass}">
535
+ <td><code>${escapeHtml(c.command)}</code></td>
536
+ <td>${c.phase != null ? escapeHtml(c.phase) : '—'}</td>
537
+ <td><span class="badge ${statusClass}">${escapeHtml(statusLabel)}</span></td>
538
+ <td>${this.formatMs(c.durationMs)}</td>
539
+ <td>${c.memoryKb != null ? `${(c.memoryKb / 1024).toFixed(1)} MB` : '—'}</td>
540
+ <td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
541
+ <td class="bar-cell"><div class="bar mem" style="width:${memoryPct}%"></div></td>
542
+ </tr>`;
543
+ };
544
+
545
+ const phaseRow = (p) => {
546
+ const durationPct = maxDuration > 0 && p.durationMs != null ? (p.durationMs / maxDuration) * 100 : 0;
547
+ const statusClass = p.success ? 'ok' : 'fail';
548
+ return `
549
+ <tr class="${statusClass}">
550
+ <td>${escapeHtml(p.name)}</td>
551
+ <td><span class="badge ${statusClass}">${p.success ? 'OK' : 'Failed'}</span></td>
552
+ <td>${this.formatMs(p.durationMs)}</td>
553
+ <td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
554
+ </tr>`;
555
+ };
556
+
557
+ const html = `<!DOCTYPE html>
558
+ <html lang="en">
559
+ <head>
560
+ <meta charset="utf-8">
561
+ <title>Scripts Orchestrator Report</title>
562
+ <style>
563
+ * { box-sizing: border-box; }
564
+ body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
565
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
566
+ .summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
567
+ .summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
568
+ .summary .card.success { border-left: 4px solid #22c55e; }
569
+ .summary .card.fail { border-left: 4px solid #ef4444; }
570
+ .summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
571
+ .summary .value { font-size: 1.25rem; font-weight: 600; }
572
+ section { margin-bottom: 1.5rem; }
573
+ section h2 { font-size: 1.1rem; color: #a0a0a0; margin-bottom: 0.5rem; }
574
+ table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; }
575
+ th, td { padding: 0.5rem 0.75rem; text-align: left; }
576
+ th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
577
+ tr.fail { background: rgba(239,68,68,0.08); }
578
+ .badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
579
+ .badge.ok { background: #22c55e; color: #0f0f0f; }
580
+ .badge.fail { background: #ef4444; color: #fff; }
581
+ .bar-cell { width: 120px; }
582
+ .bar { height: 8px; background: #3b82f6; border-radius: 4px; min-width: 2px; }
583
+ .bar.mem { background: #8b5cf6; }
584
+ code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
585
+ </style>
586
+ </head>
587
+ <body>
588
+ <h1>Scripts Orchestrator Report</h1>
589
+ <div class="summary">
590
+ <div class="card ${success ? 'success' : 'fail'}">
591
+ <div class="label">Status</div>
592
+ <div class="value">${success ? 'Success' : 'Failed'}</div>
593
+ </div>
594
+ <div class="card">
595
+ <div class="label">Timestamp</div>
596
+ <div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
597
+ </div>
598
+ ${overallDurationMs != null ? `
599
+ <div class="card">
600
+ <div class="label">Total time</div>
601
+ <div class="value">${this.formatMs(overallDurationMs)}</div>
602
+ </div>` : ''}
603
+ </div>
604
+
605
+ ${phases.length > 0 ? `
606
+ <section>
607
+ <h2>Phases</h2>
608
+ <table>
609
+ <thead><tr><th>Phase</th><th>Status</th><th>Duration</th><th></th></tr></thead>
610
+ <tbody>${phases.map(phaseRow).join('')}</tbody>
611
+ </table>
612
+ </section>` : ''}
613
+
614
+ <section>
615
+ <h2>Commands</h2>
616
+ <table>
617
+ <thead><tr><th>Command</th><th>Phase</th><th>Status</th><th>Duration</th><th>Memory</th><th>Time</th><th>Memory</th></tr></thead>
618
+ <tbody>${commands.map(row).join('')}</tbody>
619
+ </table>
620
+ </section>
621
+ </body>
622
+ </html>`;
623
+
624
+ if (this.htmlResultsPath === '-') {
625
+ console.log(html);
626
+ } else {
627
+ const outPath = this.htmlResultsPath || './scripts-orchestrator-results.html';
628
+ fs.writeFileSync(outPath, html, 'utf8');
629
+ this.logger.verbose(`Wrote HTML report to ${outPath}`);
630
+ }
631
+ }
632
+
272
633
  async run() {
273
634
  this.startTime = Date.now();
635
+ this.runStartedAt = this.startTime;
636
+ // A4: write initial run-state at start
637
+ this._writeRunState({ phase: null, activeCommand: null });
274
638
  try {
275
639
  // Check if we should skip execution based on git state (unless forced)
276
640
  if (!this.force) {
@@ -328,7 +692,7 @@ export class Orchestrator {
328
692
  phase.parallel.forEach(({ command }) => {
329
693
  this.skippedCommands.push(command);
330
694
  this.skipReasons.set(command, 'before_start_phase');
331
- this.commandTimings.set(command, 0);
695
+ this.commandTimings.set(command, { durationMs: 0, memoryKb: null });
332
696
  });
333
697
  continue;
334
698
  }
@@ -347,7 +711,7 @@ export class Orchestrator {
347
711
  phase.parallel.forEach(({ command }) => {
348
712
  this.skippedCommands.push(command);
349
713
  this.skipReasons.set(command, 'optional_phase_not_requested');
350
- this.commandTimings.set(command, 0);
714
+ this.commandTimings.set(command, { durationMs: 0, memoryKb: null });
351
715
  });
352
716
  continue;
353
717
  }
@@ -357,7 +721,7 @@ export class Orchestrator {
357
721
  phase.parallel.forEach(({ command }) => {
358
722
  this.skippedCommands.push(command);
359
723
  this.skipReasons.set(command, 'after_phase_failure');
360
- this.commandTimings.set(command, 0);
724
+ this.commandTimings.set(command, { durationMs: 0, memoryKb: null });
361
725
  });
362
726
  continue;
363
727
  }
@@ -369,7 +733,7 @@ export class Orchestrator {
369
733
  // Run commands sequentially
370
734
  results = [];
371
735
  for (const commandConfig of phase.parallel) {
372
- const result = await this.executeCommand(commandConfig);
736
+ const result = await this.executeCommand(commandConfig, new Set(), phase.name);
373
737
  results.push(result);
374
738
  if (!result) {
375
739
  // In sequential mode, stop phase execution on first failure
@@ -379,15 +743,22 @@ export class Orchestrator {
379
743
  } else {
380
744
  // Run commands in parallel
381
745
  const tasks = phase.parallel.map((commandConfig) =>
382
- this.executeCommand(commandConfig),
746
+ this.executeCommand(commandConfig, new Set(), phase.name),
383
747
  );
384
748
  results = await Promise.all(tasks);
385
749
  }
386
750
 
387
751
  const phaseHasFailures = results.some((result) => !result);
388
- const phaseDurationStr = `(${this.formatDuration(
389
- Date.now() - phaseStartTime,
390
- )})`;
752
+ const phaseDurationMs = Date.now() - phaseStartTime;
753
+ const phaseDurationStr = this.metrics.includes('time')
754
+ ? `(${this.formatDuration(phaseDurationMs)})`
755
+ : '';
756
+
757
+ this.phaseResults.push({
758
+ name: phase.name,
759
+ success: !phaseHasFailures,
760
+ durationMs: this.metrics.includes('time') ? phaseDurationMs : undefined,
761
+ });
391
762
 
392
763
  if (phaseHasFailures) {
393
764
  hasFailures = true;
@@ -450,8 +821,8 @@ export class Orchestrator {
450
821
  this.logger.error(`Cleanup failed: ${error.message}`);
451
822
  }
452
823
 
453
- // Log overall time after cleanup has finished
454
- if (this.startTime) {
824
+ // Log overall time after cleanup has finished (only when metrics include time)
825
+ if (this.startTime && this.metrics.includes('time')) {
455
826
  const overallDuration = Date.now() - this.startTime;
456
827
  this.logger.printMessage(() =>
457
828
  console.log(
@@ -462,6 +833,21 @@ export class Orchestrator {
462
833
  );
463
834
  }
464
835
 
836
+ // A2: emit run_end event; A1: final JSON written by writeJsonResults (replaces partial)
837
+ const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
838
+ this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
839
+
840
+ // Write JSON results if requested
841
+ if (this.jsonResultsPath != null) {
842
+ this.writeJsonResults(hasFailures);
843
+ }
844
+
845
+ // A4: clear run-state file — run is done
846
+ this._clearRunState();
847
+
848
+ // A7: run post_run hook after results are written
849
+ this._runPostRunHook(hasFailures);
850
+
465
851
  // Update git cache on successful execution
466
852
  if (!hasFailures) {
467
853
  await this.gitCache.updateCache();
@@ -478,6 +864,9 @@ export class Orchestrator {
478
864
  } catch (error) {
479
865
  this.logger.error(`Orchestrator failed: ${error.message}`);
480
866
 
867
+ // A4: clear run-state on error too
868
+ this._clearRunState();
869
+
481
870
  // Cleanup on error
482
871
  try {
483
872
  await this.processManager.cleanup();
@@ -488,4 +877,23 @@ export class Orchestrator {
488
877
  process.exit(1);
489
878
  }
490
879
  }
880
+
881
+ // A7: run user-configured post_run shell command
882
+ _runPostRunHook(hasFailures) {
883
+ if (!this.postRun) return;
884
+ this.logger.info(`[post_run] ${this.postRun}`);
885
+ const result = spawnSync(this.postRun, {
886
+ shell: true,
887
+ stdio: 'inherit',
888
+ cwd: process.cwd(),
889
+ env: {
890
+ ...process.env,
891
+ SCRIPTS_ORCHESTRATOR_SUCCESS: hasFailures ? '0' : '1',
892
+ SCRIPTS_ORCHESTRATOR_EXIT_CODE: hasFailures ? '1' : '0',
893
+ },
894
+ });
895
+ if (result.status !== 0) {
896
+ this.logger.warn(`[post_run] hook exited with code ${result.status}`);
897
+ }
898
+ }
491
899
  }
@@ -55,6 +55,32 @@ export class ProcessManager {
55
55
  });
56
56
  }
57
57
 
58
+ parseGnuTimeOutput(filePath) {
59
+ try {
60
+ if (!fs.existsSync(filePath)) return null;
61
+ const text = fs.readFileSync(filePath, 'utf8');
62
+ const m = text.match(/Maximum resident set size \(kbytes\):\s*(\d+)/i);
63
+ const kbytes = m ? parseInt(m[1], 10) : null;
64
+ try {
65
+ fs.unlinkSync(filePath);
66
+ } catch {
67
+ // ignore
68
+ }
69
+ return Number.isFinite(kbytes) ? kbytes : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Parse macOS BSD time -l output (bytes) from text; returns memory in KB or null. */
76
+ parseBsdTimeOutput(text) {
77
+ if (!text || typeof text !== 'string') return null;
78
+ const m = text.match(/(\d+)\s+maximum resident set size/i);
79
+ if (!m) return null;
80
+ const bytes = parseInt(m[1], 10);
81
+ return Number.isFinite(bytes) ? Math.round(bytes / 1024) : null;
82
+ }
83
+
58
84
  async runCommand({
59
85
  cmd,
60
86
  logFile,
@@ -63,6 +89,8 @@ export class ProcessManager {
63
89
  kill_command = null,
64
90
  isRetry = false,
65
91
  env = null,
92
+ reportTime = false,
93
+ reportMemory = false,
66
94
  }) {
67
95
  const baseDir = this.logFolder
68
96
  ? path.resolve(this.logFolder)
@@ -88,11 +116,17 @@ export class ProcessManager {
88
116
  }
89
117
  } catch (error) {
90
118
  this.logger.error(`Failed to setup log file: ${error.message}`);
91
- return Promise.resolve({ success: false, output: '' });
119
+ return Promise.resolve({
120
+ success: false,
121
+ output: '',
122
+ durationMs: 0,
123
+ memoryKb: null,
124
+ });
92
125
  }
93
126
 
94
127
  return new Promise((resolve) => {
95
128
  const startTime = Date.now();
129
+ let timeOutputPath = null;
96
130
  // Build command with environment variables if provided
97
131
  let fullCommand = `npm run ${cmd}`;
98
132
  if (env && Object.keys(env).length > 0) {
@@ -101,6 +135,14 @@ export class ProcessManager {
101
135
  .join(' ');
102
136
  fullCommand = `${envStr} npm run ${cmd}`;
103
137
  }
138
+ const useTimeWrapper =
139
+ reportMemory && !background && (process.platform === 'linux' || process.platform === 'darwin');
140
+ if (useTimeWrapper && process.platform === 'linux') {
141
+ timeOutputPath = path.join(LOGS_DIR, `.time-${logName}-${startTime}.txt`);
142
+ fullCommand = `/usr/bin/time -v -o ${JSON.stringify(timeOutputPath)} sh -c ${JSON.stringify(fullCommand)}`;
143
+ } else if (useTimeWrapper && process.platform === 'darwin') {
144
+ fullCommand = `/usr/bin/time -l sh -c ${JSON.stringify(fullCommand)}`;
145
+ }
104
146
 
105
147
  this.logger.startTask(cmd, fullCommand);
106
148
 
@@ -126,8 +168,12 @@ export class ProcessManager {
126
168
  processInstance.on('error', (error) => {
127
169
  this.logger.stopTask(cmd);
128
170
  this.logger.error(`Failed to start process: ${error.message}`);
129
- //this.logger.verbose(`Process error details: ${JSON.stringify(error, null, 2)}`);
130
- resolve({ success: false, output: '' });
171
+ resolve({
172
+ success: false,
173
+ output: '',
174
+ durationMs: Date.now() - startTime,
175
+ memoryKb: null,
176
+ });
131
177
  });
132
178
 
133
179
  if (background) {
@@ -189,7 +235,12 @@ export class ProcessManager {
189
235
  `Failed to read log file: ${error.message}`,
190
236
  );
191
237
  }
192
- return { success: false, output };
238
+ return {
239
+ success: false,
240
+ output,
241
+ durationMs: Date.now() - startTime,
242
+ memoryKb: null,
243
+ };
193
244
  }
194
245
 
195
246
  this.logger.verbose(
@@ -216,7 +267,12 @@ export class ProcessManager {
216
267
  `Failed to read log file: ${error.message}`,
217
268
  );
218
269
  }
219
- return { success: false, output };
270
+ return {
271
+ success: false,
272
+ output,
273
+ durationMs: Date.now() - startTime,
274
+ memoryKb: null,
275
+ };
220
276
  }
221
277
 
222
278
  this.backgroundProcesses.push(processGroupId);
@@ -236,7 +292,12 @@ export class ProcessManager {
236
292
  this.logger.verbose(
237
293
  `Background process started: npm run ${cmd} (PGID: ${processGroupId})`,
238
294
  );
239
- return { success: true, output: '' };
295
+ return {
296
+ success: true,
297
+ output: '',
298
+ durationMs: Date.now() - startTime,
299
+ memoryKb: null,
300
+ };
240
301
  } catch (error) {
241
302
  if (attempt === maxAttempts) {
242
303
  this.logger.error(
@@ -245,7 +306,12 @@ export class ProcessManager {
245
306
  this.logger.verbose(
246
307
  `Final verification attempt failed: ${error.message}`,
247
308
  );
248
- return { success: false, output: '' };
309
+ return {
310
+ success: false,
311
+ output: '',
312
+ durationMs: Date.now() - startTime,
313
+ memoryKb: null,
314
+ };
249
315
  }
250
316
  this.logger.verbose(
251
317
  `Verification attempt ${attempt} failed: ${error.message}`,
@@ -258,7 +324,12 @@ export class ProcessManager {
258
324
  );
259
325
  }
260
326
  }
261
- return { success: false, output: '' };
327
+ return {
328
+ success: false,
329
+ output: '',
330
+ durationMs: Date.now() - startTime,
331
+ memoryKb: null,
332
+ };
262
333
  };
263
334
 
264
335
  verifyProcess().then(resolve);
@@ -293,26 +364,50 @@ export class ProcessManager {
293
364
 
294
365
  this.logger.stopTask(cmd);
295
366
 
296
- const duration = Date.now() - startTime;
297
- const durationStr = ` (${this.formatDuration(duration)})`;
367
+ const durationMs = Date.now() - startTime;
368
+ const durationStr = reportTime
369
+ ? ` (${this.formatDuration(durationMs)})`
370
+ : '';
371
+ let memoryKb = null;
372
+ if (reportMemory) {
373
+ if (timeOutputPath) {
374
+ memoryKb = this.parseGnuTimeOutput(timeOutputPath);
375
+ } else if (process.platform === 'darwin') {
376
+ memoryKb = this.parseBsdTimeOutput(output);
377
+ }
378
+ }
298
379
 
299
380
  if (code !== 0) {
300
381
  this.logger.error(
301
382
  `Failed: npm run ${cmd} ❌${durationStr} (exit code: ${code})`,
302
383
  );
303
384
  this.logger.verbose(`Process output: ${output}`);
304
- resolve({ success: false, output });
385
+ resolve({
386
+ success: false,
387
+ output,
388
+ durationMs,
389
+ memoryKb,
390
+ });
305
391
  } else {
306
392
  this.logger.success(`Completed: npm run ${cmd} ✅${durationStr}`);
307
- resolve({ success: true, output });
393
+ resolve({
394
+ success: true,
395
+ output,
396
+ durationMs,
397
+ memoryKb,
398
+ });
308
399
  }
309
400
  });
310
401
  }
311
402
  } catch (error) {
312
403
  this.logger.stopTask(cmd);
313
404
  this.logger.error(`Failed to spawn process: ${error.message}`);
314
- //this.logger.verbose(`Spawn error details: ${JSON.stringify(error, null, 2)}`);
315
- resolve({ success: false, output: '' });
405
+ resolve({
406
+ success: false,
407
+ output: '',
408
+ durationMs: Date.now() - startTime,
409
+ memoryKb: null,
410
+ });
316
411
  }
317
412
  });
318
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.12.0",
3
+ "version": "2.14.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,4 +1,10 @@
1
1
  export default {
2
+ // Optional: metrics to report (time, memory). CLI --metrics overrides.
3
+ // metrics: ['time'],
4
+ // Optional: path for JSON results, or '-' for stdout. CLI --json-results overrides.
5
+ // json_results: './scripts-orchestrator-results.json',
6
+ // Optional: path for HTML report. CLI --html-results overrides.
7
+ // html_results: './scripts-orchestrator-results.html',
2
8
  phases: [
3
9
  {
4
10
  name: 'build',