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 +82 -0
- package/index.js +55 -1
- package/lib/orchestrator.js +427 -19
- package/lib/process-manager.js +109 -14
- package/package.json +1 -1
- package/scripts-orchestrator.config.js +6 -0
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(
|
|
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
|
|
package/lib/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, '&')
|
|
520
|
+
.replace(/</g, '<')
|
|
521
|
+
.replace(/>/g, '>')
|
|
522
|
+
.replace(/"/g, '"');
|
|
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
|
|
389
|
-
|
|
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
|
}
|
package/lib/process-manager.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
130
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
297
|
-
const durationStr =
|
|
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({
|
|
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({
|
|
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
|
-
|
|
315
|
-
|
|
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.
|
|
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',
|