scripts-orchestrator 2.13.0 → 2.15.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.
@@ -1,8 +1,11 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
3
+ import { spawn, 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';
5
7
  import { GitCache } from './git-cache.js';
8
+ import { renderReportHtml } from './report-html.js';
6
9
  import chalk from 'chalk';
7
10
 
8
11
  export class Orchestrator {
@@ -33,8 +36,26 @@ export class Orchestrator {
33
36
  this.skippedCommands = [];
34
37
  this.skipReasons = new Map(); // Track why commands were skipped
35
38
  this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
39
+ this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
36
40
  this.phaseResults = []; // { name, success, durationMs } per phase run
37
41
  this.gitCache = new GitCache(logFolder);
42
+ // A1: track per-command start times for incremental JSON
43
+ this.commandStartTimes = new Map(); // command -> ISO start string
44
+ // A2: events file path derived from jsonResultsPath
45
+ this.eventsPath = this._deriveEventsPath(jsonResultsPath);
46
+ // A4: library-owned run-state file
47
+ this.runStatePath = logFolder
48
+ ? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
49
+ : null;
50
+ // A7: post-run hook command (shell string)
51
+ this.postRun = null; // set from config in index.js
52
+ // Periodic hook: shell command fired on an interval while the run is in flight (set in index.js).
53
+ // The library owns only the cadence; the command itself is project-specific (e.g. roll-up render).
54
+ this.periodicHook = null;
55
+ this.periodicIntervalMs = 45000;
56
+ this._periodicTimer = null;
57
+ this._periodicRunning = false;
58
+ this._periodicChild = null;
38
59
 
39
60
  // Set the log folder in process manager
40
61
  if (logFolder) {
@@ -58,6 +79,138 @@ export class Orchestrator {
58
79
  return [];
59
80
  }
60
81
 
82
+ _deriveEventsPath(jsonResultsPath) {
83
+ if (!jsonResultsPath || jsonResultsPath === '-') return null;
84
+ return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
85
+ }
86
+
87
+ // A4: write current run state atomically
88
+ _writeRunState(extra = {}) {
89
+ if (!this.runStatePath) return;
90
+ const state = {
91
+ startedAt: this.runStartedAt ? new Date(this.runStartedAt).toISOString() : new Date().toISOString(),
92
+ pid: process.pid,
93
+ ...extra,
94
+ };
95
+ const tmp = this.runStatePath + '.tmp';
96
+ try {
97
+ fs.mkdirSync(path.dirname(this.runStatePath), { recursive: true });
98
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8');
99
+ fs.renameSync(tmp, this.runStatePath);
100
+ } catch { /* non-fatal */ }
101
+ }
102
+
103
+ // A4: remove run-state file on run end
104
+ _clearRunState() {
105
+ if (!this.runStatePath) return;
106
+ try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
107
+ }
108
+
109
+ // A2: append a structured NDJSON event
110
+ _appendEvent(type, data = {}) {
111
+ if (!this.eventsPath) return;
112
+ const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
113
+ try {
114
+ fs.appendFileSync(this.eventsPath, line + '\n', 'utf8');
115
+ } catch {
116
+ // non-fatal: don't let event logging break the run
117
+ }
118
+ }
119
+
120
+ // A1: atomically write current run state (completed + in-flight commands) to json_results
121
+ _writePartialResults() {
122
+ if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
123
+ const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
124
+
125
+ const commands = [];
126
+
127
+ const buildEntry = (command, phaseName) => {
128
+ const timing = this.commandTimings.get(command);
129
+ const startedAt = this.commandStartTimes.get(command);
130
+ const skipped = this.skippedCommands.includes(command);
131
+ const done = timing != null || skipped;
132
+
133
+ if (!done && startedAt) {
134
+ return {
135
+ command,
136
+ ...(phaseName ? { phase: phaseName } : {}),
137
+ success: null,
138
+ startedAt,
139
+ ...this._logFileField(command),
140
+ };
141
+ }
142
+ if (!done) return null; // not yet started — omit
143
+
144
+ const skipReason = skipped ? (this.skipReasons.get(command) ?? null) : null;
145
+ const success =
146
+ !this.failedCommands.includes(command) &&
147
+ (skipReason === null ||
148
+ skipReason === 'disabled' ||
149
+ skipReason === 'optional_phase_not_requested' ||
150
+ skipReason === 'before_start_phase');
151
+ return {
152
+ command,
153
+ ...(phaseName ? { phase: phaseName } : {}),
154
+ success,
155
+ ...(startedAt ? { startedAt } : {}),
156
+ ...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
157
+ ...(this.metrics.includes('memory') ? { memoryKb: timing?.memoryKb ?? null } : {}),
158
+ ...this._logFileField(command),
159
+ ...(skipReason ? { skipReason } : {}),
160
+ };
161
+ };
162
+
163
+ if (Array.isArray(this.config)) {
164
+ for (const { command } of this.config) {
165
+ const entry = buildEntry(command, null);
166
+ if (entry) commands.push(entry);
167
+ }
168
+ } else if (this.config.phases) {
169
+ for (const phase of this.config.phases) {
170
+ for (const { command } of (phase.parallel || [])) {
171
+ const entry = buildEntry(command, phase.name);
172
+ if (entry) commands.push(entry);
173
+ }
174
+ }
175
+ }
176
+
177
+ const payload = {
178
+ success: null, // in-progress sentinel; replaced by writeJsonResults on completion
179
+ timestamp: new Date().toISOString(),
180
+ ...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
181
+ commands,
182
+ ...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
183
+ };
184
+
185
+ const tmpPath = outPath + '.tmp';
186
+ try {
187
+ fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
188
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), 'utf8');
189
+ fs.renameSync(tmpPath, outPath);
190
+ } catch (err) {
191
+ this.logger.verbose(`Partial results write failed: ${err.message}`);
192
+ }
193
+
194
+ // Incrementally refresh the HTML report too (like merge-report:live), so a live,
195
+ // up-to-date report exists from the first command onward and survives interruption.
196
+ // Skip the stdout sink ('-') to avoid spamming the console on every update.
197
+ if (this.htmlResultsPath != null && this.htmlResultsPath !== '-') {
198
+ try {
199
+ this.writeHtmlResults(payload);
200
+ } catch (err) {
201
+ this.logger.verbose(`Partial HTML write failed: ${err.message}`);
202
+ }
203
+ }
204
+
205
+ // A4: keep run-state file in sync with current active commands and phase
206
+ const inFlight = commands.filter(c => c.success === null).map(c => c.command);
207
+ const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
208
+ this._writeRunState({
209
+ activeCommand: inFlight.length === 1 ? inFlight[0] : (inFlight.length > 1 ? inFlight : null),
210
+ phase: currentPhase,
211
+ });
212
+ }
213
+
61
214
  formatDuration(ms) {
62
215
  if (ms < 1000) return `${ms}ms`;
63
216
  const seconds = Math.floor(ms / 1000);
@@ -69,7 +222,48 @@ export class Orchestrator {
69
222
  return `${seconds}s`;
70
223
  }
71
224
 
72
- async executeCommand(commandConfig, visited = new Set()) {
225
+ // Resolve a command's destination log file as a path relative to cwd (absolute if outside),
226
+ // returned as a spreadable object so callers can inline it into result entries.
227
+ _logFileField(command) {
228
+ const p = this.commandLogPaths.get(command);
229
+ if (!p) return {};
230
+ let rel = p;
231
+ try {
232
+ const r = path.relative(process.cwd(), p);
233
+ if (r && !r.startsWith('..')) rel = r;
234
+ } catch {
235
+ // keep absolute path on any failure
236
+ }
237
+ return { logFile: rel };
238
+ }
239
+
240
+ // The important output files this run produces, as [label, absolutePath] pairs.
241
+ // Excludes stdout sinks ('-'). Used to announce report locations in the logs.
242
+ _reportFiles() {
243
+ const files = [];
244
+ if (this.jsonResultsPath != null && this.jsonResultsPath !== '-') {
245
+ files.push(['JSON results', path.resolve(this.jsonResultsPath || './scripts-orchestrator-results.json')]);
246
+ }
247
+ if (this.htmlResultsPath != null && this.htmlResultsPath !== '-') {
248
+ files.push(['HTML report', path.resolve(this.htmlResultsPath || './scripts-orchestrator-results.html')]);
249
+ }
250
+ if (this.eventsPath) {
251
+ files.push(['Events (NDJSON)', path.resolve(this.eventsPath)]);
252
+ }
253
+ return files;
254
+ }
255
+
256
+ // Announce report file locations in the logs (prefix e.g. 'Live reports' / 'Reports written').
257
+ _announceReportFiles(prefix) {
258
+ const files = this._reportFiles();
259
+ if (files.length === 0) return;
260
+ this.logger.info(`📄 ${prefix}:`);
261
+ for (const [label, file] of files) {
262
+ this.logger.info(` • ${label}: ${file}`);
263
+ }
264
+ }
265
+
266
+ async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
73
267
  const {
74
268
  command,
75
269
  dependencies = [],
@@ -88,6 +282,10 @@ export class Orchestrator {
88
282
 
89
283
  const startTime = Date.now();
90
284
 
285
+ // Record the destination log file for this command (honors per-command override).
286
+ // Done early so even disabled/skipped commands report where output would land.
287
+ this.commandLogPaths.set(command, this.processManager.getLogPath(command, log || logFile));
288
+
91
289
  const setTiming = (durationMs, memoryKb = null) => {
92
290
  this.commandTimings.set(command, { durationMs, memoryKb });
93
291
  };
@@ -184,6 +382,11 @@ export class Orchestrator {
184
382
  }
185
383
  }
186
384
 
385
+ // A1/A2: record start and emit event
386
+ this.commandStartTimes.set(command, new Date().toISOString());
387
+ this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
388
+ this._writePartialResults();
389
+
187
390
  // Execute the main command with retries
188
391
  let result = false;
189
392
  let commandOutput = '';
@@ -258,6 +461,9 @@ export class Orchestrator {
258
461
 
259
462
  const totalDurationMs = Date.now() - startTime;
260
463
  setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
464
+ // A1/A2: emit completion event and write incremental results
465
+ this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
466
+ this._writePartialResults();
261
467
  visited.delete(command);
262
468
  return result;
263
469
  }
@@ -288,10 +494,7 @@ export class Orchestrator {
288
494
  }
289
495
 
290
496
  writeJsonResults(hasFailures) {
291
- const overallDurationMs =
292
- this.metrics.includes('time') && this.startTime
293
- ? Date.now() - this.startTime
294
- : undefined;
497
+ const overallDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
295
498
 
296
499
  const commands = [];
297
500
  if (Array.isArray(this.config)) {
@@ -309,12 +512,14 @@ export class Orchestrator {
309
512
  const entry = {
310
513
  command,
311
514
  success,
312
- ...(timing?.durationMs != null && this.metrics.includes('time')
313
- ? { durationMs: timing.durationMs }
515
+ ...(this.commandStartTimes.has(command)
516
+ ? { startedAt: this.commandStartTimes.get(command) }
314
517
  : {}),
518
+ ...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
315
519
  ...(this.metrics.includes('memory')
316
520
  ? { memoryKb: timing?.memoryKb ?? null }
317
521
  : {}),
522
+ ...this._logFileField(command),
318
523
  ...(skipReason ? { skipReason } : {}),
319
524
  };
320
525
  commands.push(entry);
@@ -336,12 +541,14 @@ export class Orchestrator {
336
541
  command,
337
542
  phase: phase.name,
338
543
  success,
339
- ...(timing?.durationMs != null && this.metrics.includes('time')
340
- ? { durationMs: timing.durationMs }
544
+ ...(this.commandStartTimes.has(command)
545
+ ? { startedAt: this.commandStartTimes.get(command) }
341
546
  : {}),
547
+ ...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
342
548
  ...(this.metrics.includes('memory')
343
549
  ? { memoryKb: timing?.memoryKb ?? null }
344
550
  : {}),
551
+ ...this._logFileField(command),
345
552
  ...(skipReason ? { skipReason } : {}),
346
553
  };
347
554
  commands.push(entry);
@@ -371,135 +578,31 @@ export class Orchestrator {
371
578
  if (this.htmlResultsPath != null) {
372
579
  this.writeHtmlResults(payload);
373
580
  }
374
- }
375
581
 
376
- formatMs(ms) {
377
- if (ms == null || ms === 0) return '—';
378
- if (ms < 1000) return `${ms}ms`;
379
- const s = (ms / 1000).toFixed(1);
380
- return `${s}s`;
582
+ // Announce the important output files so they're easy to find in the logs.
583
+ this._announceReportFiles('Reports written');
381
584
  }
382
585
 
383
586
  writeHtmlResults(payload) {
384
- const escapeHtml = (s) => {
385
- if (s == null) return '';
386
- return String(s)
387
- .replace(/&/g, '&amp;')
388
- .replace(/</g, '&lt;')
389
- .replace(/>/g, '&gt;')
390
- .replace(/"/g, '&quot;');
391
- };
392
- const { success, timestamp, overallDurationMs, commands = [], phases = [] } = payload;
393
- const maxDuration = Math.max(0, ...commands.map((c) => c.durationMs || 0), ...phases.map((p) => p.durationMs || 0));
394
- const maxMemory = Math.max(0, ...commands.map((c) => c.memoryKb || 0));
395
-
396
- const row = (c) => {
397
- const durationPct = maxDuration > 0 && c.durationMs != null ? (c.durationMs / maxDuration) * 100 : 0;
398
- const memoryPct = maxMemory > 0 && c.memoryKb != null ? (c.memoryKb / maxMemory) * 100 : 0;
399
- const statusClass = c.success ? 'ok' : 'fail';
400
- const statusLabel = c.success ? 'OK' : (c.skipReason || 'Failed');
401
- return `
402
- <tr class="${statusClass}">
403
- <td><code>${escapeHtml(c.command)}</code></td>
404
- <td>${c.phase != null ? escapeHtml(c.phase) : '—'}</td>
405
- <td><span class="badge ${statusClass}">${escapeHtml(statusLabel)}</span></td>
406
- <td>${this.formatMs(c.durationMs)}</td>
407
- <td>${c.memoryKb != null ? `${(c.memoryKb / 1024).toFixed(1)} MB` : '—'}</td>
408
- <td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
409
- <td class="bar-cell"><div class="bar mem" style="width:${memoryPct}%"></div></td>
410
- </tr>`;
411
- };
412
-
413
- const phaseRow = (p) => {
414
- const durationPct = maxDuration > 0 && p.durationMs != null ? (p.durationMs / maxDuration) * 100 : 0;
415
- const statusClass = p.success ? 'ok' : 'fail';
416
- return `
417
- <tr class="${statusClass}">
418
- <td>${escapeHtml(p.name)}</td>
419
- <td><span class="badge ${statusClass}">${p.success ? 'OK' : 'Failed'}</span></td>
420
- <td>${this.formatMs(p.durationMs)}</td>
421
- <td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
422
- </tr>`;
423
- };
424
-
425
- const html = `<!DOCTYPE html>
426
- <html lang="en">
427
- <head>
428
- <meta charset="utf-8">
429
- <title>Scripts Orchestrator Report</title>
430
- <style>
431
- * { box-sizing: border-box; }
432
- body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
433
- h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
434
- .summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
435
- .summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
436
- .summary .card.success { border-left: 4px solid #22c55e; }
437
- .summary .card.fail { border-left: 4px solid #ef4444; }
438
- .summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
439
- .summary .value { font-size: 1.25rem; font-weight: 600; }
440
- section { margin-bottom: 1.5rem; }
441
- section h2 { font-size: 1.1rem; color: #a0a0a0; margin-bottom: 0.5rem; }
442
- table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; }
443
- th, td { padding: 0.5rem 0.75rem; text-align: left; }
444
- th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
445
- tr.fail { background: rgba(239,68,68,0.08); }
446
- .badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
447
- .badge.ok { background: #22c55e; color: #0f0f0f; }
448
- .badge.fail { background: #ef4444; color: #fff; }
449
- .bar-cell { width: 120px; }
450
- .bar { height: 8px; background: #3b82f6; border-radius: 4px; min-width: 2px; }
451
- .bar.mem { background: #8b5cf6; }
452
- code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
453
- </style>
454
- </head>
455
- <body>
456
- <h1>Scripts Orchestrator Report</h1>
457
- <div class="summary">
458
- <div class="card ${success ? 'success' : 'fail'}">
459
- <div class="label">Status</div>
460
- <div class="value">${success ? 'Success' : 'Failed'}</div>
461
- </div>
462
- <div class="card">
463
- <div class="label">Timestamp</div>
464
- <div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
465
- </div>
466
- ${overallDurationMs != null ? `
467
- <div class="card">
468
- <div class="label">Total time</div>
469
- <div class="value">${this.formatMs(overallDurationMs)}</div>
470
- </div>` : ''}
471
- </div>
472
-
473
- ${phases.length > 0 ? `
474
- <section>
475
- <h2>Phases</h2>
476
- <table>
477
- <thead><tr><th>Phase</th><th>Status</th><th>Duration</th><th></th></tr></thead>
478
- <tbody>${phases.map(phaseRow).join('')}</tbody>
479
- </table>
480
- </section>` : ''}
481
-
482
- <section>
483
- <h2>Commands</h2>
484
- <table>
485
- <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>
486
- <tbody>${commands.map(row).join('')}</tbody>
487
- </table>
488
- </section>
489
- </body>
490
- </html>`;
491
-
587
+ const html = renderReportHtml(payload);
492
588
  if (this.htmlResultsPath === '-') {
493
589
  console.log(html);
494
- } else {
495
- const outPath = this.htmlResultsPath || './scripts-orchestrator-results.html';
496
- fs.writeFileSync(outPath, html, 'utf8');
497
- this.logger.verbose(`Wrote HTML report to ${outPath}`);
590
+ return;
498
591
  }
592
+ const outPath = this.htmlResultsPath || './scripts-orchestrator-results.html';
593
+ // Atomic write so live reloaders (incremental refresh) never read a half-written file.
594
+ const tmpPath = outPath + '.tmp';
595
+ fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
596
+ fs.writeFileSync(tmpPath, html, 'utf8');
597
+ fs.renameSync(tmpPath, outPath);
598
+ this.logger.verbose(`Wrote HTML report to ${outPath}`);
499
599
  }
500
600
 
501
601
  async run() {
502
602
  this.startTime = Date.now();
603
+ this.runStartedAt = this.startTime;
604
+ // A4: write initial run-state at start
605
+ this._writeRunState({ phase: null, activeCommand: null });
503
606
  try {
504
607
  // Check if we should skip execution based on git state (unless forced)
505
608
  if (!this.force) {
@@ -519,6 +622,13 @@ export class Orchestrator {
519
622
  let phaseFailed = false;
520
623
  let startPhaseFound = false;
521
624
 
625
+ // Announce where the live report files will be written (they update incrementally,
626
+ // so they can be opened to watch progress while the run is in flight).
627
+ this._announceReportFiles('Live reports (updated as the run progresses)');
628
+
629
+ // Start the periodic roll-up hook (no-op unless configured).
630
+ this._startPeriodicHook();
631
+
522
632
  // Handle both old array format and new phases format
523
633
  if (Array.isArray(this.config)) {
524
634
  // Legacy: Run all commands in parallel or sequential based on flag
@@ -598,7 +708,7 @@ export class Orchestrator {
598
708
  // Run commands sequentially
599
709
  results = [];
600
710
  for (const commandConfig of phase.parallel) {
601
- const result = await this.executeCommand(commandConfig);
711
+ const result = await this.executeCommand(commandConfig, new Set(), phase.name);
602
712
  results.push(result);
603
713
  if (!result) {
604
714
  // In sequential mode, stop phase execution on first failure
@@ -608,7 +718,7 @@ export class Orchestrator {
608
718
  } else {
609
719
  // Run commands in parallel
610
720
  const tasks = phase.parallel.map((commandConfig) =>
611
- this.executeCommand(commandConfig),
721
+ this.executeCommand(commandConfig, new Set(), phase.name),
612
722
  );
613
723
  results = await Promise.all(tasks);
614
724
  }
@@ -622,7 +732,7 @@ export class Orchestrator {
622
732
  this.phaseResults.push({
623
733
  name: phase.name,
624
734
  success: !phaseHasFailures,
625
- durationMs: this.metrics.includes('time') ? phaseDurationMs : undefined,
735
+ durationMs: phaseDurationMs,
626
736
  });
627
737
 
628
738
  if (phaseHasFailures) {
@@ -698,11 +808,25 @@ export class Orchestrator {
698
808
  );
699
809
  }
700
810
 
811
+ // A2: emit run_end event; A1: final JSON written by writeJsonResults (replaces partial)
812
+ const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
813
+ this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
814
+
701
815
  // Write JSON results if requested
702
816
  if (this.jsonResultsPath != null) {
703
817
  this.writeJsonResults(hasFailures);
704
818
  }
705
819
 
820
+ // A4: clear run-state file — run is done
821
+ this._clearRunState();
822
+
823
+ // Final roll-up (synchronous) AFTER run-state is cleared, so the aggregate reflects the
824
+ // finished run (an in-flight marker would otherwise make the final report read as running).
825
+ this._firePeriodicHookFinal();
826
+
827
+ // A7: run post_run hook after results are written
828
+ this._runPostRunHook(hasFailures);
829
+
706
830
  // Update git cache on successful execution
707
831
  if (!hasFailures) {
708
832
  await this.gitCache.updateCache();
@@ -719,6 +843,12 @@ export class Orchestrator {
719
843
  } catch (error) {
720
844
  this.logger.error(`Orchestrator failed: ${error.message}`);
721
845
 
846
+ // Stop periodic ticks on error.
847
+ this._stopPeriodicHook();
848
+
849
+ // A4: clear run-state on error too
850
+ this._clearRunState();
851
+
722
852
  // Cleanup on error
723
853
  try {
724
854
  await this.processManager.cleanup();
@@ -729,4 +859,91 @@ export class Orchestrator {
729
859
  process.exit(1);
730
860
  }
731
861
  }
862
+
863
+ // Periodic hook: start the interval timer (fires once promptly, then every interval).
864
+ _startPeriodicHook() {
865
+ if (!this.periodicHook || this._periodicTimer) return;
866
+ this.logger.info(
867
+ `⏱️ Periodic report hook every ${Math.round(this.periodicIntervalMs / 1000)}s: ${this.periodicHook}`,
868
+ );
869
+ this._firePeriodicTick(); // prompt first roll-up so an initial aggregate exists
870
+ this._periodicTimer = setInterval(() => this._firePeriodicTick(), this.periodicIntervalMs);
871
+ if (this._periodicTimer.unref) this._periodicTimer.unref();
872
+ }
873
+
874
+ // Fire the periodic hook asynchronously, with an overlap guard so slow hooks don't pile up.
875
+ _firePeriodicTick() {
876
+ if (!this.periodicHook || this._periodicRunning) {
877
+ if (this._periodicRunning) {
878
+ this.logger.verbose('[periodic_hook] previous invocation still running; skipping tick');
879
+ }
880
+ return;
881
+ }
882
+ this._periodicRunning = true;
883
+ try {
884
+ const child = spawn(this.periodicHook, {
885
+ shell: true,
886
+ stdio: 'ignore',
887
+ cwd: process.cwd(),
888
+ env: { ...process.env, SCRIPTS_ORCHESTRATOR_PERIODIC: '1' },
889
+ });
890
+ this._periodicChild = child;
891
+ child.on('exit', (code) => {
892
+ this._periodicRunning = false;
893
+ this._periodicChild = null;
894
+ if (code && code !== 0) this.logger.verbose(`[periodic_hook] exited with code ${code}`);
895
+ });
896
+ child.on('error', (err) => {
897
+ this._periodicRunning = false;
898
+ this._periodicChild = null;
899
+ this.logger.warn(`[periodic_hook] failed: ${err.message}`);
900
+ });
901
+ } catch (err) {
902
+ this._periodicRunning = false;
903
+ this.logger.warn(`[periodic_hook] error: ${err.message}`);
904
+ }
905
+ }
906
+
907
+ // Stop scheduling further periodic ticks.
908
+ _stopPeriodicHook() {
909
+ if (this._periodicTimer) {
910
+ clearInterval(this._periodicTimer);
911
+ this._periodicTimer = null;
912
+ }
913
+ }
914
+
915
+ // Final synchronous fire so the aggregate reflects the finished run before the process exits.
916
+ _firePeriodicHookFinal() {
917
+ if (!this.periodicHook) return;
918
+ this._stopPeriodicHook();
919
+ try {
920
+ spawnSync(this.periodicHook, {
921
+ shell: true,
922
+ stdio: 'ignore',
923
+ cwd: process.cwd(),
924
+ env: { ...process.env, SCRIPTS_ORCHESTRATOR_PERIODIC: 'final' },
925
+ });
926
+ } catch (err) {
927
+ this.logger.warn(`[periodic_hook] final invocation failed: ${err.message}`);
928
+ }
929
+ }
930
+
931
+ // A7: run user-configured post_run shell command
932
+ _runPostRunHook(hasFailures) {
933
+ if (!this.postRun) return;
934
+ this.logger.info(`[post_run] ${this.postRun}`);
935
+ const result = spawnSync(this.postRun, {
936
+ shell: true,
937
+ stdio: 'inherit',
938
+ cwd: process.cwd(),
939
+ env: {
940
+ ...process.env,
941
+ SCRIPTS_ORCHESTRATOR_SUCCESS: hasFailures ? '0' : '1',
942
+ SCRIPTS_ORCHESTRATOR_EXIT_CODE: hasFailures ? '1' : '0',
943
+ },
944
+ });
945
+ if (result.status !== 0) {
946
+ this.logger.warn(`[post_run] hook exited with code ${result.status}`);
947
+ }
948
+ }
732
949
  }
@@ -28,7 +28,9 @@ export class ProcessManager {
28
28
  this.logger.verbose(`Log folder set to: ${logFolder}`);
29
29
  }
30
30
 
31
- getLogPath(command) {
31
+ getLogPath(command, logFileOverride = null) {
32
+ // A per-command 'log'/'logFile' override wins, resolved against cwd.
33
+ if (logFileOverride) return path.resolve(logFileOverride);
32
34
  const baseDir = this.logFolder
33
35
  ? path.resolve(this.logFolder)
34
36
  : process.cwd();
@@ -98,7 +100,8 @@ export class ProcessManager {
98
100
  const LOGS_DIR = path.join(baseDir, 'scripts-orchestrator-logs');
99
101
  // Use only the first word of the command for the log filename
100
102
  const logName = cmd.split(/\s+/)[0];
101
- const LOG_FILE = logFile || path.join(LOGS_DIR, `${logName}.log`);
103
+ // Single source of truth for the destination log path (honors per-command override).
104
+ const LOG_FILE = this.getLogPath(cmd, logFile);
102
105
 
103
106
  try {
104
107
  if (!fs.existsSync(LOGS_DIR)) {
@@ -0,0 +1,22 @@
1
+ import path from 'path';
2
+ import { ProcessManager } from './process-manager.js';
3
+
4
+ describe('ProcessManager.getLogPath', () => {
5
+ test('resolves the default per-command log path under the log folder', () => {
6
+ const pm = new ProcessManager();
7
+ pm.setLogFolder('/tmp/example-logs');
8
+ const result = pm.getLogPath('build -- --outDir dist');
9
+ // Uses only the first word of the command for the filename.
10
+ expect(result).toBe(
11
+ path.join('/tmp/example-logs', 'scripts-orchestrator-logs', 'build.log'),
12
+ );
13
+ });
14
+
15
+ test('honors a per-command log file override, resolved against cwd', () => {
16
+ const pm = new ProcessManager();
17
+ pm.setLogFolder('/tmp/example-logs');
18
+ const override = './logs/scripts-orchestrator-logs/custom.log';
19
+ const result = pm.getLogPath('lint', override);
20
+ expect(result).toBe(path.resolve(override));
21
+ });
22
+ });