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 +82 -0
- package/index.js +7 -0
- package/lib/orchestrator.js +170 -3
- package/package.json +1 -1
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
|
|
package/lib/orchestrator.js
CHANGED
|
@@ -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.
|
|
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",
|