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.
- package/index.js +48 -1
- package/lib/health-check.js +27 -11
- package/lib/logger.js +142 -12
- package/lib/orchestrator.js +387 -72
- package/lib/process-manager.js +336 -100
- package/package.json +1 -1
- package/scripts-orchestrator.config.js +6 -0
package/lib/orchestrator.js
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
252
|
+
this.logger.error(
|
|
253
|
+
`Failed to cleanup processes for ${command}: ${cleanupError.message}`,
|
|
254
|
+
);
|
|
205
255
|
}
|
|
206
256
|
}
|
|
207
257
|
}
|
|
208
258
|
|
|
209
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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.
|
|
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, '&')
|
|
388
|
+
.replace(/</g, '<')
|
|
389
|
+
.replace(/>/g, '>')
|
|
390
|
+
.replace(/"/g, '"');
|
|
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(
|
|
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.
|
|
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 (
|
|
305
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
631
|
+
this.logger.stopPhase(phase.name, false, phaseDurationStr);
|
|
351
632
|
} else {
|
|
352
|
-
this.logger.
|
|
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
|
|
360
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
}
|