scripts-orchestrator 2.13.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
@@ -111,6 +111,9 @@ const htmlResultsPath =
111
111
  ? argv.htmlResults
112
112
  : (commandsConfig.html_results ?? commandsConfig.html_results_path ?? null);
113
113
 
114
+ // A7: post-run hook — shell command run after json_results written
115
+ const postRun = commandsConfig.post_run ?? null;
116
+
114
117
  // Set the log folder for the main orchestrator logs if specified
115
118
  if (logFolder) {
116
119
  log.setLogFolder(logFolder);
@@ -128,6 +131,8 @@ const orchestrator = new Orchestrator(
128
131
  jsonResultsPath,
129
132
  htmlResultsPath,
130
133
  );
134
+ // A7: wire post-run hook from config
135
+ orchestrator.postRun = postRun;
131
136
 
132
137
  // Enhanced signal handlers
133
138
  const handleSignal = async (signal) => {
@@ -137,6 +142,8 @@ const handleSignal = async (signal) => {
137
142
  } catch (error) {
138
143
  log.error(`Cleanup failed: ${error.message}`);
139
144
  }
145
+ // A4: clear run-state so dashboards know the run ended
146
+ orchestrator._clearRunState();
140
147
  process.exit(1);
141
148
  };
142
149
 
@@ -1,4 +1,6 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawnSync } from 'child_process';
2
4
  import { processManager } from './process-manager.js';
3
5
  import { healthCheck } from './health-check.js';
4
6
  import { log } from './logger.js';
@@ -35,6 +37,16 @@ export class Orchestrator {
35
37
  this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
36
38
  this.phaseResults = []; // { name, success, durationMs } per phase run
37
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
38
50
 
39
51
  // Set the log folder in process manager
40
52
  if (logFolder) {
@@ -58,6 +70,118 @@ export class Orchestrator {
58
70
  return [];
59
71
  }
60
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
+
61
185
  formatDuration(ms) {
62
186
  if (ms < 1000) return `${ms}ms`;
63
187
  const seconds = Math.floor(ms / 1000);
@@ -69,7 +193,7 @@ export class Orchestrator {
69
193
  return `${seconds}s`;
70
194
  }
71
195
 
72
- async executeCommand(commandConfig, visited = new Set()) {
196
+ async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
73
197
  const {
74
198
  command,
75
199
  dependencies = [],
@@ -184,6 +308,11 @@ export class Orchestrator {
184
308
  }
185
309
  }
186
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
+
187
316
  // Execute the main command with retries
188
317
  let result = false;
189
318
  let commandOutput = '';
@@ -258,6 +387,9 @@ export class Orchestrator {
258
387
 
259
388
  const totalDurationMs = Date.now() - startTime;
260
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();
261
393
  visited.delete(command);
262
394
  return result;
263
395
  }
@@ -500,6 +632,9 @@ export class Orchestrator {
500
632
 
501
633
  async run() {
502
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 });
503
638
  try {
504
639
  // Check if we should skip execution based on git state (unless forced)
505
640
  if (!this.force) {
@@ -598,7 +733,7 @@ export class Orchestrator {
598
733
  // Run commands sequentially
599
734
  results = [];
600
735
  for (const commandConfig of phase.parallel) {
601
- const result = await this.executeCommand(commandConfig);
736
+ const result = await this.executeCommand(commandConfig, new Set(), phase.name);
602
737
  results.push(result);
603
738
  if (!result) {
604
739
  // In sequential mode, stop phase execution on first failure
@@ -608,7 +743,7 @@ export class Orchestrator {
608
743
  } else {
609
744
  // Run commands in parallel
610
745
  const tasks = phase.parallel.map((commandConfig) =>
611
- this.executeCommand(commandConfig),
746
+ this.executeCommand(commandConfig, new Set(), phase.name),
612
747
  );
613
748
  results = await Promise.all(tasks);
614
749
  }
@@ -698,11 +833,21 @@ export class Orchestrator {
698
833
  );
699
834
  }
700
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
+
701
840
  // Write JSON results if requested
702
841
  if (this.jsonResultsPath != null) {
703
842
  this.writeJsonResults(hasFailures);
704
843
  }
705
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
+
706
851
  // Update git cache on successful execution
707
852
  if (!hasFailures) {
708
853
  await this.gitCache.updateCache();
@@ -719,6 +864,9 @@ export class Orchestrator {
719
864
  } catch (error) {
720
865
  this.logger.error(`Orchestrator failed: ${error.message}`);
721
866
 
867
+ // A4: clear run-state on error too
868
+ this._clearRunState();
869
+
722
870
  // Cleanup on error
723
871
  try {
724
872
  await this.processManager.cleanup();
@@ -729,4 +877,23 @@ export class Orchestrator {
729
877
  process.exit(1);
730
878
  }
731
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
+ }
732
899
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripts-orchestrator",
3
- "version": "2.13.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",