scripts-orchestrator 2.10.0 → 2.13.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,29 +1,46 @@
1
+ import fs from 'fs';
1
2
  import { processManager } from './process-manager.js';
2
3
  import { healthCheck } from './health-check.js';
3
4
  import { log } from './logger.js';
4
5
  import { GitCache } from './git-cache.js';
6
+ import chalk from 'chalk';
5
7
 
6
8
  export class Orchestrator {
7
- constructor(config, startPhase = null, logFolder = null, phases = null, sequential = false, force = false) {
9
+ constructor(
10
+ config,
11
+ startPhase = null,
12
+ logFolder = null,
13
+ phases = null,
14
+ sequential = false,
15
+ force = false,
16
+ metrics = [],
17
+ jsonResultsPath = null,
18
+ htmlResultsPath = null,
19
+ ) {
8
20
  this.config = config;
9
21
  this.startPhase = startPhase;
10
22
  this.logFolder = logFolder;
11
23
  this.phases = phases;
12
24
  this.sequential = sequential;
13
25
  this.force = force;
26
+ this.metrics = Array.isArray(metrics) ? metrics : [];
27
+ this.jsonResultsPath = jsonResultsPath ?? null;
28
+ this.htmlResultsPath = htmlResultsPath ?? null;
14
29
  this.processManager = processManager;
15
30
  this.healthCheck = healthCheck;
16
31
  this.logger = log;
17
32
  this.failedCommands = [];
18
33
  this.skippedCommands = [];
19
- this.commandTimings = new Map();
34
+ this.skipReasons = new Map(); // Track why commands were skipped
35
+ this.commandTimings = new Map(); // command -> { durationMs, memoryKb? }
36
+ this.phaseResults = []; // { name, success, durationMs } per phase run
20
37
  this.gitCache = new GitCache(logFolder);
21
-
38
+
22
39
  // Set the log folder in process manager
23
40
  if (logFolder) {
24
41
  this.processManager.setLogFolder(logFolder);
25
42
  }
26
-
43
+
27
44
  // Flatten commands for easier tracking
28
45
  this.allCommands = this.flattenCommands(config);
29
46
  }
@@ -33,11 +50,11 @@ export class Orchestrator {
33
50
  if (Array.isArray(config)) {
34
51
  return config;
35
52
  }
36
-
53
+
37
54
  if (config.phases) {
38
- return config.phases.flatMap(phase => phase.parallel || []);
55
+ return config.phases.flatMap((phase) => phase.parallel || []);
39
56
  }
40
-
57
+
41
58
  return [];
42
59
  }
43
60
 
@@ -71,13 +88,17 @@ export class Orchestrator {
71
88
 
72
89
  const startTime = Date.now();
73
90
 
91
+ const setTiming = (durationMs, memoryKb = null) => {
92
+ this.commandTimings.set(command, { durationMs, memoryKb });
93
+ };
94
+
74
95
  // Check for circular dependencies
75
96
  if (visited.has(command)) {
76
97
  this.logger.error(
77
98
  `Circular dependency detected: ${Array.from(visited).join(' -> ')} -> ${command}`,
78
99
  );
79
100
  this.failedCommands.push(command);
80
- this.commandTimings.set(command, Date.now() - startTime);
101
+ setTiming(Date.now() - startTime);
81
102
  return false;
82
103
  }
83
104
  visited.add(command);
@@ -86,17 +107,31 @@ export class Orchestrator {
86
107
  if (status === 'disabled') {
87
108
  this.logger.warn(`Skipping: npm run ${command} (status: disabled)`);
88
109
  this.skippedCommands.push(command);
89
- this.commandTimings.set(command, Date.now() - startTime);
110
+ this.skipReasons.set(command, 'disabled');
111
+ setTiming(Date.now() - startTime);
90
112
  visited.delete(command);
91
113
  return true;
92
114
  }
93
115
 
94
116
  const checkUrl = health_check?.url;
95
117
  if (checkUrl) {
96
- this.logger.info(`Checking if ${checkUrl} is already available...`);
97
- const urlAvailable = await this.healthCheck.waitForUrl({url: checkUrl, maxAttempts: 1, silent:true});
98
- if (urlAvailable) {
99
- this.logger.verbose(`${checkUrl} is already available. Skipping ${command} start.`);
118
+ this.logger.startEphemeral(
119
+ `check_${checkUrl}`,
120
+ chalk.blue(`[INFO] ⏳ Checking if ${checkUrl} is already available...`),
121
+ );
122
+ const urlAvailable = await this.healthCheck.waitForUrl({
123
+ url: checkUrl,
124
+ maxAttempts: 1,
125
+ silent: true,
126
+ });
127
+
128
+ if (!urlAvailable) {
129
+ this.logger.stopEphemeral(`check_${checkUrl}`);
130
+ } else {
131
+ this.logger.stopEphemeral(
132
+ `check_${checkUrl}`,
133
+ `✅ ${checkUrl} is already available. Skipping ${command} start.`,
134
+ );
100
135
  this.processManager.addBackgroundProcess({
101
136
  command,
102
137
  url: checkUrl,
@@ -104,7 +139,7 @@ export class Orchestrator {
104
139
  process_tracking,
105
140
  kill_command,
106
141
  });
107
- this.commandTimings.set(command, Date.now() - startTime);
142
+ setTiming(Date.now() - startTime);
108
143
  visited.delete(command);
109
144
  return true;
110
145
  }
@@ -116,24 +151,22 @@ export class Orchestrator {
116
151
  if (!dependencySuccess) {
117
152
  this.logger.error(`Skipping ${command} due to failed dependency`);
118
153
  this.skippedCommands.push(command);
119
- this.commandTimings.set(command, Date.now() - startTime);
154
+ this.skipReasons.set(command, 'failed_dependency');
155
+ setTiming(Date.now() - startTime);
120
156
  visited.delete(command);
121
157
  return false;
122
158
  }
123
159
 
124
160
  if (dependency.health_check?.url) {
125
- this.logger.info(`Waiting for ${dependency.health_check.url} to be available...`);
126
161
  const urlAvailable = await this.healthCheck.waitForUrl({
127
162
  url: dependency.health_check.url,
128
163
  maxAttempts: dependency.health_check?.max_attempts || 20,
129
164
  interval: dependency.health_check?.interval || 2000,
130
165
  });
131
166
  if (!urlAvailable) {
132
- this.logger.error(
133
- `URL ${dependency.health_check.url} is not available after maximum attempts`,
134
- );
135
167
  this.skippedCommands.push(command);
136
- this.commandTimings.set(command, Date.now() - startTime);
168
+ this.skipReasons.set(command, 'failed_dependency');
169
+ setTiming(Date.now() - startTime);
137
170
  visited.delete(command);
138
171
  return false;
139
172
  }
@@ -141,7 +174,9 @@ export class Orchestrator {
141
174
  this.logger.verbose(`Waiting ${dependency.wait}ms`);
142
175
  await new Promise((resolve) => {
143
176
  setTimeout(() => {
144
- this.logger.verbose(`Resolving after a wait of ${dependency.wait}ms`);
177
+ this.logger.verbose(
178
+ `Resolving after a wait of ${dependency.wait}ms`,
179
+ );
145
180
  resolve(true);
146
181
  }, dependency.wait);
147
182
  });
@@ -153,14 +188,17 @@ export class Orchestrator {
153
188
  let result = false;
154
189
  let commandOutput = '';
155
190
  let commandFailed = false;
156
-
191
+ let lastRunResult = null;
192
+
157
193
  for (let attempt = 1; attempt <= attempts; attempt++) {
158
194
  if (attempt > 1) {
159
- this.logger.warn(`Retrying ${command} (attempt ${attempt}/${attempts})`);
195
+ this.logger.warn(
196
+ `Retrying ${command} (attempt ${attempt}/${attempts})`,
197
+ );
160
198
  await new Promise((resolve) => setTimeout(resolve, 1000));
161
199
  }
162
200
 
163
- const { success, output } = await this.processManager.runCommand({
201
+ const runResult = await this.processManager.runCommand({
164
202
  cmd: attempt === 1 ? command : retry_command || command,
165
203
  logFile: log || logFile, // Prefer 'log' key over 'logFile' for backwards compatibility
166
204
  background,
@@ -168,13 +206,19 @@ export class Orchestrator {
168
206
  kill_command,
169
207
  isRetry: attempt > 1,
170
208
  env,
209
+ reportTime: this.metrics.includes('time'),
210
+ reportMemory: this.metrics.includes('memory'),
171
211
  });
212
+ lastRunResult = runResult;
213
+ const { success, output } = runResult;
172
214
  commandOutput = output;
173
215
  result = success;
174
216
 
175
217
  if (result) {
176
218
  // Remove from failed commands if it was there
177
- this.failedCommands = this.failedCommands.filter(cmd => cmd !== command);
219
+ this.failedCommands = this.failedCommands.filter(
220
+ (cmd) => cmd !== command,
221
+ );
178
222
  commandFailed = false;
179
223
  break;
180
224
  } else if (attempt < attempts) {
@@ -185,7 +229,9 @@ export class Orchestrator {
185
229
  commandFailed = true;
186
230
  break;
187
231
  }
188
- this.logger.error(`Attempt ${attempt}/${attempts} failed for ${command}`);
232
+ this.logger.error(
233
+ `Attempt ${attempt}/${attempts} failed for ${command}`,
234
+ );
189
235
  commandFailed = true;
190
236
  } else {
191
237
  commandFailed = true;
@@ -194,52 +240,266 @@ export class Orchestrator {
194
240
 
195
241
  if (commandFailed) {
196
242
  this.failedCommands.push(command);
197
-
243
+
198
244
  // Cleanup any background processes for this failed command
199
245
  if (background) {
200
- this.logger.warn(`Command ${command} failed after all attempts. Cleaning up background processes.`);
246
+ this.logger.warn(
247
+ `Command ${command} failed after all attempts. Cleaning up background processes.`,
248
+ );
201
249
  try {
202
250
  await this.processManager.cleanupCommand(command);
203
251
  } catch (cleanupError) {
204
- this.logger.error(`Failed to cleanup processes for ${command}: ${cleanupError.message}`);
252
+ this.logger.error(
253
+ `Failed to cleanup processes for ${command}: ${cleanupError.message}`,
254
+ );
205
255
  }
206
256
  }
207
257
  }
208
258
 
209
- this.commandTimings.set(command, Date.now() - startTime);
259
+ const totalDurationMs = Date.now() - startTime;
260
+ setTiming(totalDurationMs, lastRunResult?.memoryKb ?? null);
210
261
  visited.delete(command);
211
262
  return result;
212
263
  }
213
264
 
214
265
  summarizeResults() {
215
- this.logger.info('\nCommand Summary:');
216
266
  let hasFailures = false;
217
-
267
+
268
+ // Check if any command failed or was skipped due to failure
218
269
  this.allCommands.forEach(({ command }) => {
219
- const duration = this.commandTimings.get(command);
220
- const durationStr = duration ? ` (${this.formatDuration(duration)})` : '';
221
-
222
270
  if (this.failedCommands.includes(command)) {
223
271
  hasFailures = true;
224
- // Get the actual log path from process manager
225
- const logPath = this.processManager.getLogPath(command);
226
- this.logger.error(`- ${command}: ❌${durationStr} (See ${logPath})`);
227
272
  } else if (this.skippedCommands.includes(command)) {
228
- hasFailures = true;
229
- this.logger.warn(`- ${command}: ⚠️${durationStr} (Skipped due to failed dependency)`);
230
- } else {
231
- this.logger.success(`- ${command}: ✅${durationStr}`);
273
+ const skipReason = this.skipReasons.get(command);
274
+ if (
275
+ skipReason === 'failed_dependency' ||
276
+ skipReason === 'after_phase_failure'
277
+ ) {
278
+ hasFailures = true;
279
+ }
232
280
  }
233
281
  });
234
282
 
235
283
  if (hasFailures) {
236
- this.logger.error('\n❌ Some commands failed or were skipped. See details above.');
284
+ this.logger.error('\n❌ Some commands failed or were skipped.');
237
285
  } else {
238
286
  this.logger.success('\n🎉 All commands executed successfully!');
239
287
  }
240
288
  }
241
289
 
290
+ writeJsonResults(hasFailures) {
291
+ const overallDurationMs =
292
+ this.metrics.includes('time') && this.startTime
293
+ ? Date.now() - this.startTime
294
+ : undefined;
295
+
296
+ const commands = [];
297
+ if (Array.isArray(this.config)) {
298
+ this.config.forEach(({ command }) => {
299
+ const timing = this.commandTimings.get(command);
300
+ const skipReason = this.skippedCommands.includes(command)
301
+ ? this.skipReasons.get(command) ?? null
302
+ : null;
303
+ const success =
304
+ !this.failedCommands.includes(command) &&
305
+ (skipReason === null ||
306
+ skipReason === 'disabled' ||
307
+ skipReason === 'optional_phase_not_requested' ||
308
+ skipReason === 'before_start_phase');
309
+ const entry = {
310
+ command,
311
+ success,
312
+ ...(timing?.durationMs != null && this.metrics.includes('time')
313
+ ? { durationMs: timing.durationMs }
314
+ : {}),
315
+ ...(this.metrics.includes('memory')
316
+ ? { memoryKb: timing?.memoryKb ?? null }
317
+ : {}),
318
+ ...(skipReason ? { skipReason } : {}),
319
+ };
320
+ commands.push(entry);
321
+ });
322
+ } else if (this.config.phases) {
323
+ this.config.phases.forEach((phase) => {
324
+ (phase.parallel || []).forEach(({ command }) => {
325
+ const timing = this.commandTimings.get(command);
326
+ const skipReason = this.skippedCommands.includes(command)
327
+ ? this.skipReasons.get(command) ?? null
328
+ : null;
329
+ const success =
330
+ !this.failedCommands.includes(command) &&
331
+ (skipReason === null ||
332
+ skipReason === 'disabled' ||
333
+ skipReason === 'optional_phase_not_requested' ||
334
+ skipReason === 'before_start_phase');
335
+ const entry = {
336
+ command,
337
+ phase: phase.name,
338
+ success,
339
+ ...(timing?.durationMs != null && this.metrics.includes('time')
340
+ ? { durationMs: timing.durationMs }
341
+ : {}),
342
+ ...(this.metrics.includes('memory')
343
+ ? { memoryKb: timing?.memoryKb ?? null }
344
+ : {}),
345
+ ...(skipReason ? { skipReason } : {}),
346
+ };
347
+ commands.push(entry);
348
+ });
349
+ });
350
+ }
351
+
352
+ const payload = {
353
+ success: !hasFailures,
354
+ timestamp: new Date().toISOString(),
355
+ ...(overallDurationMs != null ? { overallDurationMs } : {}),
356
+ commands,
357
+ ...(this.config.phases && this.phaseResults.length > 0
358
+ ? { phases: this.phaseResults }
359
+ : {}),
360
+ };
361
+
362
+ const json = JSON.stringify(payload, null, 2);
363
+ if (this.jsonResultsPath === '-') {
364
+ console.log(json);
365
+ } else {
366
+ const outPath = this.jsonResultsPath || './scripts-orchestrator-results.json';
367
+ fs.writeFileSync(outPath, json, 'utf8');
368
+ this.logger.verbose(`Wrote results to ${outPath}`);
369
+ }
370
+
371
+ if (this.htmlResultsPath != null) {
372
+ this.writeHtmlResults(payload);
373
+ }
374
+ }
375
+
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`;
381
+ }
382
+
383
+ 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
+
492
+ if (this.htmlResultsPath === '-') {
493
+ 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}`);
498
+ }
499
+ }
500
+
242
501
  async run() {
502
+ this.startTime = Date.now();
243
503
  try {
244
504
  // Check if we should skip execution based on git state (unless forced)
245
505
  if (!this.force) {
@@ -250,7 +510,9 @@ export class Orchestrator {
250
510
  process.exit(0);
251
511
  }
252
512
  } else {
253
- this.logger.info('⚡ Force execution enabled, skipping git cache check');
513
+ this.logger.info(
514
+ '⚡ Force execution enabled, skipping git cache check',
515
+ );
254
516
  }
255
517
 
256
518
  let hasFailures = false;
@@ -276,14 +538,14 @@ export class Orchestrator {
276
538
  this.executeCommand(commandConfig),
277
539
  );
278
540
  const results = await Promise.all(tasks);
279
- hasFailures = results.some(result => !result);
541
+ hasFailures = results.some((result) => !result);
280
542
  }
281
543
  } else if (this.config.phases) {
282
544
  // New: Run phases sequentially, commands within phases in parallel or sequential based on flag
283
545
  if (this.sequential) {
284
546
  this.logger.info('🔄 Running in sequential mode');
285
547
  }
286
-
548
+
287
549
  for (const phase of this.config.phases) {
288
550
  // Check if we should start from this phase
289
551
  if (this.startPhase && !startPhaseFound) {
@@ -294,19 +556,27 @@ export class Orchestrator {
294
556
  // Mark all commands in previous phases as skipped
295
557
  phase.parallel.forEach(({ command }) => {
296
558
  this.skippedCommands.push(command);
297
- this.commandTimings.set(command, 0);
559
+ this.skipReasons.set(command, 'before_start_phase');
560
+ this.commandTimings.set(command, { durationMs: 0, memoryKb: null });
298
561
  });
299
562
  continue;
300
563
  }
301
564
  }
302
565
 
303
566
  // Check if this is an optional phase that should be skipped
304
- if (phase.optional === true && this.phases && !this.phases.includes(phase.name)) {
305
- this.logger.info(`\n⏭️ Skipping optional phase: ${phase.name} (not explicitly requested)`);
567
+ if (
568
+ phase.optional === true &&
569
+ this.phases &&
570
+ !this.phases.includes(phase.name)
571
+ ) {
572
+ this.logger.info(
573
+ `\n⏭️ Skipping optional phase: ${phase.name} (not explicitly requested)`,
574
+ );
306
575
  // Mark all commands in this phase as skipped
307
576
  phase.parallel.forEach(({ command }) => {
308
577
  this.skippedCommands.push(command);
309
- this.commandTimings.set(command, 0);
578
+ this.skipReasons.set(command, 'optional_phase_not_requested');
579
+ this.commandTimings.set(command, { durationMs: 0, memoryKb: null });
310
580
  });
311
581
  continue;
312
582
  }
@@ -315,13 +585,14 @@ export class Orchestrator {
315
585
  // Mark all commands in remaining phases as skipped
316
586
  phase.parallel.forEach(({ command }) => {
317
587
  this.skippedCommands.push(command);
318
- this.commandTimings.set(command, 0);
588
+ this.skipReasons.set(command, 'after_phase_failure');
589
+ this.commandTimings.set(command, { durationMs: 0, memoryKb: null });
319
590
  });
320
591
  continue;
321
592
  }
322
593
 
323
- this.logger.info(`\n🔄 Starting phase: ${phase.name}`);
324
-
594
+ const phaseStartTime = Date.now();
595
+
325
596
  let results;
326
597
  if (this.sequential) {
327
598
  // Run commands sequentially
@@ -341,58 +612,102 @@ export class Orchestrator {
341
612
  );
342
613
  results = await Promise.all(tasks);
343
614
  }
344
-
345
- const phaseHasFailures = results.some(result => !result);
346
-
615
+
616
+ const phaseHasFailures = results.some((result) => !result);
617
+ const phaseDurationMs = Date.now() - phaseStartTime;
618
+ const phaseDurationStr = this.metrics.includes('time')
619
+ ? `(${this.formatDuration(phaseDurationMs)})`
620
+ : '';
621
+
622
+ this.phaseResults.push({
623
+ name: phase.name,
624
+ success: !phaseHasFailures,
625
+ durationMs: this.metrics.includes('time') ? phaseDurationMs : undefined,
626
+ });
627
+
347
628
  if (phaseHasFailures) {
348
629
  hasFailures = true;
349
630
  phaseFailed = true;
350
- this.logger.error(`❌ Phase "${phase.name}" completed with failures`);
631
+ this.logger.stopPhase(phase.name, false, phaseDurationStr);
351
632
  } else {
352
- this.logger.success(`✅ Phase "${phase.name}" completed successfully`);
633
+ this.logger.stopPhase(phase.name, true, phaseDurationStr);
353
634
  }
354
635
  }
355
636
  }
356
637
 
357
638
  // Validate start phase if specified
358
639
  if (this.startPhase && !startPhaseFound) {
359
- const availablePhases = this.config.phases.map(p => p.name).join(', ');
360
- this.logger.error(`❌ Start phase "${this.startPhase}" not found. Available phases: ${availablePhases}`);
640
+ const availablePhases = this.config.phases
641
+ .map((p) => p.name)
642
+ .join(', ');
643
+ this.logger.error(
644
+ `❌ Start phase "${this.startPhase}" not found. Available phases: ${availablePhases}`,
645
+ );
361
646
  process.exit(1);
362
647
  }
363
648
 
364
649
  // Validate phases if specified
365
650
  if (this.phases) {
366
- const availablePhases = this.config.phases.map(p => p.name);
367
- const invalidPhases = this.phases.filter(phase => !availablePhases.includes(phase));
651
+ const availablePhases = this.config.phases.map((p) => p.name);
652
+ const invalidPhases = this.phases.filter(
653
+ (phase) => !availablePhases.includes(phase),
654
+ );
368
655
  if (invalidPhases.length > 0) {
369
- this.logger.error(`❌ Invalid phases specified: ${invalidPhases.join(', ')}. Available phases: ${availablePhases.join(', ')}`);
656
+ this.logger.error(
657
+ `❌ Invalid phases specified: ${invalidPhases.join(', ')}. Available phases: ${availablePhases.join(', ')}`,
658
+ );
370
659
  process.exit(1);
371
660
  }
372
661
  }
373
662
 
374
663
  // Check final status
375
- hasFailures = hasFailures ||
376
- this.failedCommands.length > 0 ||
377
- this.skippedCommands.length > 0;
664
+ // Only count skipped commands as failures if they're due to dependency issues or phase failures
665
+ const failureSkippedCommands = Array.from(this.skipReasons.entries())
666
+ .filter(
667
+ ([, reason]) =>
668
+ reason === 'failed_dependency' || reason === 'after_phase_failure',
669
+ )
670
+ .map(([command]) => command);
671
+
672
+ hasFailures =
673
+ hasFailures ||
674
+ this.failedCommands.length > 0 ||
675
+ failureSkippedCommands.length > 0;
378
676
 
379
677
  // Add a small delay to ensure all processes have finished
380
678
  await new Promise((resolve) => setTimeout(resolve, 1000));
381
679
 
382
680
  this.summarizeResults();
383
-
681
+
384
682
  // Cleanup before exit since finally blocks don't run after process.exit()
385
683
  try {
386
684
  await this.processManager.cleanup();
387
685
  } catch (error) {
388
686
  this.logger.error(`Cleanup failed: ${error.message}`);
389
687
  }
390
-
688
+
689
+ // Log overall time after cleanup has finished (only when metrics include time)
690
+ if (this.startTime && this.metrics.includes('time')) {
691
+ const overallDuration = Date.now() - this.startTime;
692
+ this.logger.printMessage(() =>
693
+ console.log(
694
+ chalk.cyan(
695
+ `[INFO] ⏱️ Overall time taken: ${this.formatDuration(overallDuration)}`,
696
+ ),
697
+ ),
698
+ );
699
+ }
700
+
701
+ // Write JSON results if requested
702
+ if (this.jsonResultsPath != null) {
703
+ this.writeJsonResults(hasFailures);
704
+ }
705
+
391
706
  // Update git cache on successful execution
392
707
  if (!hasFailures) {
393
708
  await this.gitCache.updateCache();
394
709
  }
395
-
710
+
396
711
  // Force exit with appropriate status
397
712
  if (hasFailures) {
398
713
  this.logger.info('Exiting with failure status...');
@@ -403,15 +718,15 @@ export class Orchestrator {
403
718
  }
404
719
  } catch (error) {
405
720
  this.logger.error(`Orchestrator failed: ${error.message}`);
406
-
721
+
407
722
  // Cleanup on error
408
723
  try {
409
724
  await this.processManager.cleanup();
410
725
  } catch (cleanupError) {
411
726
  this.logger.error(`Cleanup failed: ${cleanupError.message}`);
412
727
  }
413
-
728
+
414
729
  process.exit(1);
415
730
  }
416
731
  }
417
- }
732
+ }