scripts-orchestrator 2.14.0 → 2.15.1

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,10 +1,11 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { spawnSync } from 'child_process';
3
+ import { spawn, spawnSync } from 'child_process';
4
4
  import { processManager } from './process-manager.js';
5
5
  import { healthCheck } from './health-check.js';
6
6
  import { log } from './logger.js';
7
7
  import { GitCache } from './git-cache.js';
8
+ import { renderReportHtml } from './report-html.js';
8
9
  import chalk from 'chalk';
9
10
 
10
11
  export class Orchestrator {
@@ -35,18 +36,26 @@ export class Orchestrator {
35
36
  this.skippedCommands = [];
36
37
  this.skipReasons = new Map(); // Track why commands were skipped
37
38
  this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
39
+ this.commandLogPaths = new Map(); // command -> resolved destination log file (absolute)
38
40
  this.phaseResults = []; // { name, success, durationMs } per phase run
39
41
  this.gitCache = new GitCache(logFolder);
40
- // A1: track per-command start times for incremental JSON
42
+ // track per-command start times for incremental JSON
41
43
  this.commandStartTimes = new Map(); // command -> ISO start string
42
- // A2: events file path derived from jsonResultsPath
44
+ // events file path derived from jsonResultsPath
43
45
  this.eventsPath = this._deriveEventsPath(jsonResultsPath);
44
- // A4: library-owned run-state file
46
+ // library-owned run-state file
45
47
  this.runStatePath = logFolder
46
48
  ? path.join(path.resolve(logFolder), '.scripts-orchestrator-run.json')
47
49
  : null;
48
- // A7: post-run hook command (shell string)
50
+ // post-run hook command (shell string)
49
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;
50
59
 
51
60
  // Set the log folder in process manager
52
61
  if (logFolder) {
@@ -75,7 +84,7 @@ export class Orchestrator {
75
84
  return jsonResultsPath.replace(/\.json$/, '') + '-events.ndjson';
76
85
  }
77
86
 
78
- // A4: write current run state atomically
87
+ // write current run state atomically
79
88
  _writeRunState(extra = {}) {
80
89
  if (!this.runStatePath) return;
81
90
  const state = {
@@ -91,13 +100,13 @@ export class Orchestrator {
91
100
  } catch { /* non-fatal */ }
92
101
  }
93
102
 
94
- // A4: remove run-state file on run end
103
+ // remove run-state file on run end
95
104
  _clearRunState() {
96
105
  if (!this.runStatePath) return;
97
106
  try { fs.unlinkSync(this.runStatePath); } catch { /* ignore */ }
98
107
  }
99
108
 
100
- // A2: append a structured NDJSON event
109
+ // append a structured NDJSON event
101
110
  _appendEvent(type, data = {}) {
102
111
  if (!this.eventsPath) return;
103
112
  const line = JSON.stringify({ type, timestamp: new Date().toISOString(), ...data });
@@ -108,7 +117,7 @@ export class Orchestrator {
108
117
  }
109
118
  }
110
119
 
111
- // A1: atomically write current run state (completed + in-flight commands) to json_results
120
+ // atomically write current run state (completed + in-flight commands) to json_results
112
121
  _writePartialResults() {
113
122
  if (this.jsonResultsPath == null || this.jsonResultsPath === '-') return;
114
123
  const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
@@ -122,7 +131,13 @@ export class Orchestrator {
122
131
  const done = timing != null || skipped;
123
132
 
124
133
  if (!done && startedAt) {
125
- return { command, ...(phaseName ? { phase: phaseName } : {}), success: null, startedAt };
134
+ return {
135
+ command,
136
+ ...(phaseName ? { phase: phaseName } : {}),
137
+ success: null,
138
+ startedAt,
139
+ ...this._logFileField(command),
140
+ };
126
141
  }
127
142
  if (!done) return null; // not yet started — omit
128
143
 
@@ -137,8 +152,10 @@ export class Orchestrator {
137
152
  command,
138
153
  ...(phaseName ? { phase: phaseName } : {}),
139
154
  success,
140
- ...(timing?.durationMs != null && this.metrics.includes('time') ? { durationMs: timing.durationMs } : {}),
155
+ ...(startedAt ? { startedAt } : {}),
156
+ ...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
141
157
  ...(this.metrics.includes('memory') ? { memoryKb: timing?.memoryKb ?? null } : {}),
158
+ ...this._logFileField(command),
142
159
  ...(skipReason ? { skipReason } : {}),
143
160
  };
144
161
  };
@@ -160,6 +177,7 @@ export class Orchestrator {
160
177
  const payload = {
161
178
  success: null, // in-progress sentinel; replaced by writeJsonResults on completion
162
179
  timestamp: new Date().toISOString(),
180
+ ...(this.startTime ? { overallDurationMs: Date.now() - this.startTime } : {}), // elapsed so far
163
181
  commands,
164
182
  ...(this.config.phases && this.phaseResults.length > 0 ? { phases: this.phaseResults } : {}),
165
183
  };
@@ -173,7 +191,18 @@ export class Orchestrator {
173
191
  this.logger.verbose(`Partial results write failed: ${err.message}`);
174
192
  }
175
193
 
176
- // A4: keep run-state file in sync with current active commands and phase
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
+ // keep run-state file in sync with current active commands and phase
177
206
  const inFlight = commands.filter(c => c.success === null).map(c => c.command);
178
207
  const currentPhase = commands.length > 0 ? (commands[commands.length - 1].phase ?? null) : null;
179
208
  this._writeRunState({
@@ -193,6 +222,47 @@ export class Orchestrator {
193
222
  return `${seconds}s`;
194
223
  }
195
224
 
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
+
196
266
  async executeCommand(commandConfig, visited = new Set(), phaseName = null) {
197
267
  const {
198
268
  command,
@@ -212,6 +282,10 @@ export class Orchestrator {
212
282
 
213
283
  const startTime = Date.now();
214
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
+
215
289
  const setTiming = (durationMs, memoryKb = null) => {
216
290
  this.commandTimings.set(command, { durationMs, memoryKb });
217
291
  };
@@ -308,7 +382,7 @@ export class Orchestrator {
308
382
  }
309
383
  }
310
384
 
311
- // A1/A2: record start and emit event
385
+ // record start and emit event
312
386
  this.commandStartTimes.set(command, new Date().toISOString());
313
387
  this._appendEvent('command_start', { command, phase: phaseName, scope: 'workspace' });
314
388
  this._writePartialResults();
@@ -387,7 +461,7 @@ export class Orchestrator {
387
461
 
388
462
  const totalDurationMs = Date.now() - startTime;
389
463
  setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
390
- // A1/A2: emit completion event and write incremental results
464
+ // emit completion event and write incremental results
391
465
  this._appendEvent('command_end', { command, phase: phaseName, success: result, durationMs: totalDurationMs });
392
466
  this._writePartialResults();
393
467
  visited.delete(command);
@@ -420,10 +494,7 @@ export class Orchestrator {
420
494
  }
421
495
 
422
496
  writeJsonResults(hasFailures) {
423
- const overallDurationMs =
424
- this.metrics.includes('time') && this.startTime
425
- ? Date.now() - this.startTime
426
- : undefined;
497
+ const overallDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
427
498
 
428
499
  const commands = [];
429
500
  if (Array.isArray(this.config)) {
@@ -441,12 +512,14 @@ export class Orchestrator {
441
512
  const entry = {
442
513
  command,
443
514
  success,
444
- ...(timing?.durationMs != null && this.metrics.includes('time')
445
- ? { durationMs: timing.durationMs }
515
+ ...(this.commandStartTimes.has(command)
516
+ ? { startedAt: this.commandStartTimes.get(command) }
446
517
  : {}),
518
+ ...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
447
519
  ...(this.metrics.includes('memory')
448
520
  ? { memoryKb: timing?.memoryKb ?? null }
449
521
  : {}),
522
+ ...this._logFileField(command),
450
523
  ...(skipReason ? { skipReason } : {}),
451
524
  };
452
525
  commands.push(entry);
@@ -468,12 +541,14 @@ export class Orchestrator {
468
541
  command,
469
542
  phase: phase.name,
470
543
  success,
471
- ...(timing?.durationMs != null && this.metrics.includes('time')
472
- ? { durationMs: timing.durationMs }
544
+ ...(this.commandStartTimes.has(command)
545
+ ? { startedAt: this.commandStartTimes.get(command) }
473
546
  : {}),
547
+ ...(timing?.durationMs != null ? { durationMs: timing.durationMs } : {}),
474
548
  ...(this.metrics.includes('memory')
475
549
  ? { memoryKb: timing?.memoryKb ?? null }
476
550
  : {}),
551
+ ...this._logFileField(command),
477
552
  ...(skipReason ? { skipReason } : {}),
478
553
  };
479
554
  commands.push(entry);
@@ -503,137 +578,30 @@ export class Orchestrator {
503
578
  if (this.htmlResultsPath != null) {
504
579
  this.writeHtmlResults(payload);
505
580
  }
506
- }
507
581
 
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`;
582
+ // Announce the important output files so they're easy to find in the logs.
583
+ this._announceReportFiles('Reports written');
513
584
  }
514
585
 
515
586
  writeHtmlResults(payload) {
516
- const escapeHtml = (s) => {
517
- if (s == null) return '';
518
- return String(s)
519
- .replace(/&/g, '&amp;')
520
- .replace(/</g, '&lt;')
521
- .replace(/>/g, '&gt;')
522
- .replace(/"/g, '&quot;');
523
- };
524
- const { success, timestamp, overallDurationMs, commands = [], phases = [] } = payload;
525
- const maxDuration = Math.max(0, ...commands.map((c) => c.durationMs || 0), ...phases.map((p) => p.durationMs || 0));
526
- const maxMemory = Math.max(0, ...commands.map((c) => c.memoryKb || 0));
527
-
528
- const row = (c) => {
529
- const durationPct = maxDuration > 0 && c.durationMs != null ? (c.durationMs / maxDuration) * 100 : 0;
530
- const memoryPct = maxMemory > 0 && c.memoryKb != null ? (c.memoryKb / maxMemory) * 100 : 0;
531
- const statusClass = c.success ? 'ok' : 'fail';
532
- const statusLabel = c.success ? 'OK' : (c.skipReason || 'Failed');
533
- return `
534
- <tr class="${statusClass}">
535
- <td><code>${escapeHtml(c.command)}</code></td>
536
- <td>${c.phase != null ? escapeHtml(c.phase) : '—'}</td>
537
- <td><span class="badge ${statusClass}">${escapeHtml(statusLabel)}</span></td>
538
- <td>${this.formatMs(c.durationMs)}</td>
539
- <td>${c.memoryKb != null ? `${(c.memoryKb / 1024).toFixed(1)} MB` : '—'}</td>
540
- <td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
541
- <td class="bar-cell"><div class="bar mem" style="width:${memoryPct}%"></div></td>
542
- </tr>`;
543
- };
544
-
545
- const phaseRow = (p) => {
546
- const durationPct = maxDuration > 0 && p.durationMs != null ? (p.durationMs / maxDuration) * 100 : 0;
547
- const statusClass = p.success ? 'ok' : 'fail';
548
- return `
549
- <tr class="${statusClass}">
550
- <td>${escapeHtml(p.name)}</td>
551
- <td><span class="badge ${statusClass}">${p.success ? 'OK' : 'Failed'}</span></td>
552
- <td>${this.formatMs(p.durationMs)}</td>
553
- <td class="bar-cell"><div class="bar" style="width:${durationPct}%"></div></td>
554
- </tr>`;
555
- };
556
-
557
- const html = `<!DOCTYPE html>
558
- <html lang="en">
559
- <head>
560
- <meta charset="utf-8">
561
- <title>Scripts Orchestrator Report</title>
562
- <style>
563
- * { box-sizing: border-box; }
564
- body { font-family: system-ui, sans-serif; margin: 1rem 2rem; background: #1a1a1a; color: #e0e0e0; }
565
- h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
566
- .summary { display: flex; gap: 1.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
567
- .summary .card { background: #2a2a2a; padding: 1rem 1.25rem; border-radius: 8px; min-width: 140px; }
568
- .summary .card.success { border-left: 4px solid #22c55e; }
569
- .summary .card.fail { border-left: 4px solid #ef4444; }
570
- .summary .label { font-size: 0.75rem; text-transform: uppercase; color: #888; }
571
- .summary .value { font-size: 1.25rem; font-weight: 600; }
572
- section { margin-bottom: 1.5rem; }
573
- section h2 { font-size: 1.1rem; color: #a0a0a0; margin-bottom: 0.5rem; }
574
- table { width: 100%; border-collapse: collapse; background: #2a2a2a; border-radius: 8px; overflow: hidden; }
575
- th, td { padding: 0.5rem 0.75rem; text-align: left; }
576
- th { background: #333; color: #888; font-weight: 600; font-size: 0.8rem; }
577
- tr.fail { background: rgba(239,68,68,0.08); }
578
- .badge { padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.8rem; }
579
- .badge.ok { background: #22c55e; color: #0f0f0f; }
580
- .badge.fail { background: #ef4444; color: #fff; }
581
- .bar-cell { width: 120px; }
582
- .bar { height: 8px; background: #3b82f6; border-radius: 4px; min-width: 2px; }
583
- .bar.mem { background: #8b5cf6; }
584
- code { font-size: 0.9em; background: #333; padding: 0.1rem 0.3rem; border-radius: 4px; }
585
- </style>
586
- </head>
587
- <body>
588
- <h1>Scripts Orchestrator Report</h1>
589
- <div class="summary">
590
- <div class="card ${success ? 'success' : 'fail'}">
591
- <div class="label">Status</div>
592
- <div class="value">${success ? 'Success' : 'Failed'}</div>
593
- </div>
594
- <div class="card">
595
- <div class="label">Timestamp</div>
596
- <div class="value" style="font-size:0.9rem">${escapeHtml(timestamp)}</div>
597
- </div>
598
- ${overallDurationMs != null ? `
599
- <div class="card">
600
- <div class="label">Total time</div>
601
- <div class="value">${this.formatMs(overallDurationMs)}</div>
602
- </div>` : ''}
603
- </div>
604
-
605
- ${phases.length > 0 ? `
606
- <section>
607
- <h2>Phases</h2>
608
- <table>
609
- <thead><tr><th>Phase</th><th>Status</th><th>Duration</th><th></th></tr></thead>
610
- <tbody>${phases.map(phaseRow).join('')}</tbody>
611
- </table>
612
- </section>` : ''}
613
-
614
- <section>
615
- <h2>Commands</h2>
616
- <table>
617
- <thead><tr><th>Command</th><th>Phase</th><th>Status</th><th>Duration</th><th>Memory</th><th>Time</th><th>Memory</th></tr></thead>
618
- <tbody>${commands.map(row).join('')}</tbody>
619
- </table>
620
- </section>
621
- </body>
622
- </html>`;
623
-
587
+ const html = renderReportHtml(payload);
624
588
  if (this.htmlResultsPath === '-') {
625
589
  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}`);
590
+ return;
630
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}`);
631
599
  }
632
600
 
633
601
  async run() {
634
602
  this.startTime = Date.now();
635
603
  this.runStartedAt = this.startTime;
636
- // A4: write initial run-state at start
604
+ // write initial run-state at start
637
605
  this._writeRunState({ phase: null, activeCommand: null });
638
606
  try {
639
607
  // Check if we should skip execution based on git state (unless forced)
@@ -654,6 +622,13 @@ export class Orchestrator {
654
622
  let phaseFailed = false;
655
623
  let startPhaseFound = false;
656
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
+
657
632
  // Handle both old array format and new phases format
658
633
  if (Array.isArray(this.config)) {
659
634
  // Legacy: Run all commands in parallel or sequential based on flag
@@ -757,7 +732,7 @@ export class Orchestrator {
757
732
  this.phaseResults.push({
758
733
  name: phase.name,
759
734
  success: !phaseHasFailures,
760
- durationMs: this.metrics.includes('time') ? phaseDurationMs : undefined,
735
+ durationMs: phaseDurationMs,
761
736
  });
762
737
 
763
738
  if (phaseHasFailures) {
@@ -833,7 +808,7 @@ export class Orchestrator {
833
808
  );
834
809
  }
835
810
 
836
- // A2: emit run_end event; A1: final JSON written by writeJsonResults (replaces partial)
811
+ // emit run_end event; final JSON written by writeJsonResults (replaces partial)
837
812
  const runDurationMs = this.startTime ? Date.now() - this.startTime : undefined;
838
813
  this._appendEvent('run_end', { success: !hasFailures, ...(runDurationMs != null ? { durationMs: runDurationMs } : {}) });
839
814
 
@@ -842,10 +817,14 @@ export class Orchestrator {
842
817
  this.writeJsonResults(hasFailures);
843
818
  }
844
819
 
845
- // A4: clear run-state file — run is done
820
+ // clear run-state file — run is done
846
821
  this._clearRunState();
847
822
 
848
- // A7: run post_run hook after results are written
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
+ // run post_run hook after results are written
849
828
  this._runPostRunHook(hasFailures);
850
829
 
851
830
  // Update git cache on successful execution
@@ -864,7 +843,10 @@ export class Orchestrator {
864
843
  } catch (error) {
865
844
  this.logger.error(`Orchestrator failed: ${error.message}`);
866
845
 
867
- // A4: clear run-state on error too
846
+ // Stop periodic ticks on error.
847
+ this._stopPeriodicHook();
848
+
849
+ // clear run-state on error too
868
850
  this._clearRunState();
869
851
 
870
852
  // Cleanup on error
@@ -878,7 +860,75 @@ export class Orchestrator {
878
860
  }
879
861
  }
880
862
 
881
- // A7: run user-configured post_run shell command
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
+ // run user-configured post_run shell command
882
932
  _runPostRunHook(hasFailures) {
883
933
  if (!this.postRun) return;
884
934
  this.logger.info(`[post_run] ${this.postRun}`);
@@ -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
+ });