miravo 0.1.4 → 0.2.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,171 +1,551 @@
1
1
  import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
2
- import { box, colors } from "consola/utils";
3
- import { engineLog, isPlain, isSilent, mqttLog, writeOutput } from "../logger.ts";
4
- import { formatCount, formatDuration } from "../output.ts";
5
-
6
- /** Parameters for the shutdown handler. Supports dependency injection for tests. */
7
- export interface ShutdownOptions {
8
- /** The running engine to stop. */
9
- readonly engine: Engine;
2
+ import type { AdapterHealthSnapshot } from "@miravo/shared";
3
+ import type { TreeItem } from "consola/utils";
4
+ import { box, colors, formatTree, stripAnsi } from "consola/utils";
5
+ import { engineLog, isJsonLogFormat, isPlain, isSilent, mqttLog, opcuaLog } from "../logger.ts";
6
+ import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
7
+
8
+ const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
9
+ const FINAL_OUTPUT_GRACE_MS = 50;
10
+
11
+ type ShutdownTaskStage = "cleanup" | "finalize";
12
+ type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
13
+
14
+ interface ShutdownTask {
15
+ readonly name: string;
16
+ readonly run: () => void | Promise<void>;
17
+ }
18
+
19
+ /** Parameters for the shutdown controller. Supports dependency injection for tests. */
20
+ export interface ShutdownControllerOptions {
10
21
  /** The RNG seed used for this run (printed in summary for reproducibility). */
11
22
  readonly seed: number;
12
23
  /** Whether the seed was explicitly configured (vs. randomly generated). */
13
24
  readonly seedExplicit: boolean;
14
- /** Optional cleanup callback for timers, intervals, etc. */
15
- readonly cleanup?: () => void;
16
25
  /** Optional aggregated engine-error counts by context. */
17
26
  readonly errorContextCounts?: ReadonlyMap<string, number>;
18
27
  /** Process exit function. Defaults to process.exit. Inject for testing. */
19
28
  readonly exit?: (code: number) => void;
29
+ /** Optional writer override for the final shutdown summary render. */
30
+ readonly writeFinalOutput?: (text: string) => Promise<void>;
31
+ }
32
+
33
+ export interface ShutdownController {
34
+ readonly completion: Promise<void>;
35
+ addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
36
+ isShuttingDown(): boolean;
37
+ markRunning(): void;
38
+ requestShutdown(reason: string): void;
39
+ runShutdown(): Promise<void>;
40
+ setEngine(engine: Engine): void;
41
+ setErrorContextCounts(counts: ReadonlyMap<string, number>): void;
42
+ teardown(): void;
43
+ waitForShutdownRequest(): Promise<string>;
20
44
  }
21
45
 
22
46
  /**
23
- * Install SIGINT and SIGTERM handlers for graceful shutdown.
24
- *
25
- * - First signal: stop engine, drain adapters, print summary, exit 0.
26
- * - Second signal during shutdown: force exit immediately with exit 1.
47
+ * Create a single-owner shutdown controller for `miravo start`.
27
48
  *
28
- * @returns A teardown function that removes the installed signal handlers.
49
+ * Signals are installed immediately. The controller owns the full shutdown
50
+ * lifecycle: pre-stop cleanup, engine stop, post-stop cleanup, final summary,
51
+ * and process exit. Callers should await `completion` to keep the command alive
52
+ * until shutdown fully finishes.
29
53
  */
30
- export function installShutdownHandlers(options: ShutdownOptions): () => void {
31
- const { engine, seed, seedExplicit, cleanup, errorContextCounts } = options;
54
+ export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
32
55
  const exit = options.exit ?? ((code: number) => process.exit(code));
56
+ const writeFinalOutput = options.writeFinalOutput ?? writeShutdownOutput;
33
57
 
34
- let shuttingDown = false;
35
- let forcedExit = false;
58
+ let phase: ShutdownPhase = "booting";
59
+ let forcedExitRequested = false;
60
+ let engine: Engine | null = null;
61
+ let errorContextCounts = options.errorContextCounts;
62
+ let requestedReason: string | null = null;
63
+ let shutdownPromise: Promise<void> | null = null;
36
64
 
37
- const forceExitHandler = (): void => {
38
- if (forcedExit) return;
39
- forcedExit = true;
40
- engineLog.warn("Force stopping. Goodbye.");
41
- exit(1);
65
+ const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
66
+ cleanup: [],
67
+ finalize: [],
42
68
  };
43
69
 
44
- const handleSignal = async (signal: string): Promise<void> => {
45
- // A second signal during shutdown should always force an immediate exit.
46
- if (shuttingDown) {
47
- forceExitHandler();
48
- return;
49
- }
50
- shuttingDown = true;
51
-
52
- engineLog.start("Stopping simulation...");
70
+ let resolveCompletion = () => {};
71
+ const completion = new Promise<void>((resolve) => {
72
+ resolveCompletion = resolve;
73
+ });
74
+ let resolveRequest = (_reason: string) => {};
75
+ const request = new Promise<string>((resolve) => {
76
+ resolveRequest = resolve;
77
+ });
53
78
 
54
- // Capture metrics before stop() clears bus listeners
55
- const finalMetrics = engine.getMetrics();
79
+ const keepAlive = setInterval(() => {}, 60_000);
80
+ const forceSignalHandler = (): void => {
81
+ forceExit();
82
+ };
56
83
 
57
- cleanup?.();
84
+ const forceExit = (): void => {
85
+ if (phase === "forced_exit" || phase === "exited") return;
86
+ forcedExitRequested = true;
87
+ phase = "forced_exit";
88
+ clearInterval(keepAlive);
89
+ engineLog.warn("Force stopping. Goodbye.");
90
+ resolveCompletion();
91
+ exit(1);
92
+ };
58
93
 
59
- let timedOut = false;
60
- let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
94
+ const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
95
+ let timer: ReturnType<typeof setTimeout> | null = null;
61
96
  try {
62
- mqttLog.start("Flushing pending messages (5s timeout)...");
63
- const stopResult = engine.stop(signal === "SIGINT" ? "user interrupt" : signal);
64
- // Race engine.stop() against a 5s timeout so shutdown never hangs
65
97
  await Promise.race([
66
- stopResult,
98
+ Promise.resolve(run()),
67
99
  new Promise<void>((resolve) => {
68
- timeoutHandle = setTimeout(() => {
69
- timedOut = true;
100
+ timer = setTimeout(() => {
101
+ engineLog.warn(`${name} timed out after ${SHUTDOWN_TASK_TIMEOUT_MS / 1000}s.`);
70
102
  resolve();
71
- }, 5_000);
103
+ }, SHUTDOWN_TASK_TIMEOUT_MS);
72
104
  }),
73
105
  ]);
74
- if (timedOut) {
75
- engineLog.warn("Engine stop timed out after 5s, proceeding with exit.");
76
- } else {
77
- mqttLog.success("Stopped.");
78
- }
79
- } catch (err) {
80
- engineLog.error("Error during shutdown:", err);
106
+ } catch (error) {
107
+ engineLog.error(`Error during ${name}:`, error);
81
108
  } finally {
82
- if (timeoutHandle !== null) {
83
- clearTimeout(timeoutHandle);
109
+ if (timer !== null) {
110
+ clearTimeout(timer);
84
111
  }
85
112
  }
113
+ };
86
114
 
87
- if (forcedExit) {
88
- return;
115
+ const runStage = async (stage: ShutdownTaskStage): Promise<void> => {
116
+ const stageTasks = tasks[stage];
117
+ let index = 0;
118
+ while (index < stageTasks.length) {
119
+ const task = stageTasks[index];
120
+ if (task !== undefined) {
121
+ await runWithTimeout(task.name, task.run);
122
+ }
123
+ index++;
124
+ }
125
+ };
126
+
127
+ const shutdown = async (reason: string): Promise<void> => {
128
+ if (phase === "forced_exit" || phase === "exited") return;
129
+ phase = "shutting_down";
130
+ engineLog.info("Stopping simulation...");
131
+
132
+ const finalMetrics = engine?.getMetrics() ?? null;
133
+
134
+ await runStage("cleanup");
135
+
136
+ const engineToStop = engine;
137
+ if (engineToStop) {
138
+ await runWithTimeout("engine stop", () => engineToStop.stop(reason));
139
+ }
140
+
141
+ if (!forcedExitRequested && finalMetrics !== null) {
142
+ await printShutdownSummary(
143
+ finalMetrics,
144
+ options.seed,
145
+ options.seedExplicit,
146
+ errorContextCounts,
147
+ writeFinalOutput,
148
+ ).catch((error) => {
149
+ engineLog.error("Error rendering shutdown summary:", error);
150
+ });
151
+ }
152
+
153
+ await runStage("finalize");
154
+
155
+ const forced = forcedExitRequested;
156
+ clearInterval(keepAlive);
157
+ phase = "exited";
158
+ resolveCompletion();
159
+
160
+ if (!forced) {
161
+ exit(0);
89
162
  }
163
+ };
164
+
165
+ const armForceExitHandlers = (): void => {
166
+ process.removeListener("SIGINT", sigintHandler);
167
+ process.removeListener("SIGTERM", sigtermHandler);
168
+ process.once("SIGINT", forceSignalHandler);
169
+ process.once("SIGTERM", forceSignalHandler);
170
+ };
90
171
 
91
- printShutdownSummary(finalMetrics, seed, seedExplicit, errorContextCounts);
92
- exit(0);
172
+ const requestShutdown = (reason: string): void => {
173
+ if (phase === "forced_exit" || phase === "exited") return;
174
+ if (requestedReason !== null) {
175
+ forceExit();
176
+ return;
177
+ }
178
+ requestedReason = reason;
179
+ resolveRequest(reason);
180
+ armForceExitHandlers();
93
181
  };
94
182
 
95
183
  const sigintHandler = (): void => {
96
- void handleSignal("SIGINT");
184
+ requestShutdown("user interrupt");
97
185
  };
98
186
 
99
187
  const sigtermHandler = (): void => {
100
- void handleSignal("SIGTERM");
188
+ requestShutdown("SIGTERM");
101
189
  };
102
190
 
103
- process.on("SIGINT", sigintHandler);
104
- process.on("SIGTERM", sigtermHandler);
191
+ process.once("SIGINT", sigintHandler);
192
+ process.once("SIGTERM", sigtermHandler);
105
193
 
106
- return () => {
107
- process.removeListener("SIGINT", sigintHandler);
108
- process.removeListener("SIGTERM", sigtermHandler);
194
+ return {
195
+ completion,
196
+ addTask(stage, name, run) {
197
+ tasks[stage].push({ name, run });
198
+ },
199
+ isShuttingDown() {
200
+ return requestedReason !== null || phase === "shutting_down" || phase === "forced_exit";
201
+ },
202
+ markRunning() {
203
+ if (phase === "booting") {
204
+ phase = "running";
205
+ }
206
+ },
207
+ requestShutdown,
208
+ runShutdown() {
209
+ if (shutdownPromise) {
210
+ return shutdownPromise;
211
+ }
212
+ if (requestedReason === null) {
213
+ throw new Error("Shutdown was not requested before runShutdown().");
214
+ }
215
+ shutdownPromise = shutdown(requestedReason);
216
+ return shutdownPromise;
217
+ },
218
+ setEngine(nextEngine) {
219
+ engine = nextEngine;
220
+ },
221
+ setErrorContextCounts(counts) {
222
+ errorContextCounts = counts;
223
+ },
224
+ teardown() {
225
+ process.removeListener("SIGINT", sigintHandler);
226
+ process.removeListener("SIGTERM", sigtermHandler);
227
+ process.removeListener("SIGINT", forceSignalHandler);
228
+ process.removeListener("SIGTERM", forceSignalHandler);
229
+ clearInterval(keepAlive);
230
+ },
231
+ waitForShutdownRequest() {
232
+ return request;
233
+ },
109
234
  };
110
235
  }
111
236
 
112
- function printShutdownSummary(
113
- metrics: EngineMetricsSnapshot,
114
- seed: number,
115
- seedExplicit: boolean,
116
- errorContextCounts?: ReadonlyMap<string, number>,
117
- ): void {
237
+ // ---------------------------------------------------------------------------
238
+ // Shared helpers
239
+ // ---------------------------------------------------------------------------
240
+
241
+ const sep = colors.dim(" \u00B7 ");
242
+
243
+ interface SummaryInput {
244
+ readonly metrics: EngineMetricsSnapshot;
245
+ readonly seed?: number | undefined;
246
+ readonly seedExplicit?: boolean | undefined;
247
+ readonly errorContextCounts?: ReadonlyMap<string, number> | undefined;
248
+ }
249
+
250
+ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
251
+ switch (state) {
252
+ case "healthy":
253
+ return colors.green(state);
254
+ case "busy":
255
+ return colors.yellow(state);
256
+ case "saturated":
257
+ return colors.red(state);
258
+ }
259
+ }
260
+
261
+ function adapterLogger(name: string) {
262
+ if (name === "mqtt") return mqttLog;
263
+ if (name === "opcua") return opcuaLog;
264
+ return engineLog;
265
+ }
266
+
267
+ function adapterLabel(name: string): string {
268
+ if (name === "opcua") return "OPC UA";
269
+ return name.toUpperCase();
270
+ }
271
+
272
+ function formatAdapterLine(
273
+ name: string,
274
+ m: EngineMetricsSnapshot["adapters"][number]["metrics"],
275
+ uptimeMs: number,
276
+ ): string {
277
+ const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
278
+ const errStr =
279
+ m.errorsCount === 0
280
+ ? colors.green(formatCount(m.errorsCount))
281
+ : colors.yellow(formatCount(m.errorsCount));
282
+ const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
283
+ const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
284
+ const verb = name === "opcua" ? "updates" : "published";
285
+ const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
286
+ return `${connStr}${sep}${formatCount(m.messagesPublished)} ${verb}${sep}${errStr} errors${throughputStr}${health}`;
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Periodic summary (single-line per component)
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Print a compact periodic summary: one line for core stats, one per adapter.
295
+ */
296
+ export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
118
297
  if (isSilent()) return;
119
298
 
299
+ if (isJsonLogFormat()) {
300
+ engineLog.info("Periodic summary", buildJsonSummary({ metrics }));
301
+ return;
302
+ }
303
+
120
304
  const { core, fleet, adapters } = metrics;
121
- const mqttAdapter = adapters.find((a) => a.name === "mqtt");
122
305
 
123
- const sep = colors.dim(" \u00B7 ");
306
+ // Core stats
307
+ const parts = [
308
+ formatDuration(core.uptimeMs),
309
+ `${formatCount(core.tickCount)} ticks`,
310
+ `${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
311
+ `${formatCount(core.instanceCount)} instances`,
312
+ `${formatCount(core.memberValueCount)} data points`,
313
+ ];
314
+ if (fleet.activeFaultCount > 0) {
315
+ parts.push(`${colors.yellow(String(fleet.activeFaultCount))} faults`);
316
+ }
317
+ engineLog.info(parts.join(sep));
318
+
319
+ // Per-adapter line
320
+ for (const adapter of adapters) {
321
+ adapterLogger(adapter.name).info(
322
+ formatAdapterLine(adapter.name, adapter.metrics, core.uptimeMs),
323
+ );
324
+ }
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Shutdown summary (box)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function buildSummaryLines(input: SummaryInput): string[] {
332
+ const { core, fleet, adapters } = input.metrics;
124
333
  const lines: string[] = [];
125
334
 
335
+ // Core stats
126
336
  lines.push(
127
- `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.unsEntryCount))} UNS topics`,
337
+ `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
338
+ );
339
+ lines.push(
340
+ `${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.memberValueCount))} data points${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s`,
128
341
  );
129
342
 
130
- if (mqttAdapter) {
131
- const errCount = mqttAdapter.metrics.errorsCount;
343
+ // Adapters
344
+ for (const adapter of adapters) {
345
+ const m = adapter.metrics;
346
+ const label = adapterLabel(adapter.name);
347
+ const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
132
348
  const errStr =
133
- errCount === 0 ? colors.green(formatCount(errCount)) : colors.yellow(formatCount(errCount));
349
+ m.errorsCount === 0
350
+ ? colors.green(formatCount(m.errorsCount))
351
+ : colors.yellow(formatCount(m.errorsCount));
352
+ const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
353
+ const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
354
+ const verb = adapter.name === "opcua" ? "updates" : "published";
134
355
  lines.push(
135
- `MQTT: ${colors.bold(formatCount(mqttAdapter.metrics.messagesPublished))} published${sep}${errStr} errors`,
356
+ `${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
136
357
  );
358
+ if (m.health) {
359
+ const pendingPrefix =
360
+ m.health.pendingWorkCount > 0
361
+ ? `${formatCount(m.health.pendingWorkCount)} pending${sep}`
362
+ : "";
363
+ lines.push(` ${pendingPrefix}${healthStateLabel(m.health.state)}`);
364
+ }
137
365
  }
138
366
 
367
+ // Fleet health warnings
368
+ const healthParts: string[] = [];
139
369
  if (fleet.activeFaultCount > 0) {
140
- lines.push(`Active faults at shutdown: ${colors.yellow(String(fleet.activeFaultCount))}`);
370
+ healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
371
+ }
372
+ if (core.quarantinedInstanceCount > 0) {
373
+ healthParts.push(`${colors.yellow(String(core.quarantinedInstanceCount))} quarantined`);
374
+ }
375
+ if (core.engineErrorsCount > 0) {
376
+ healthParts.push(`${colors.yellow(formatCount(core.engineErrorsCount))} engine errors`);
377
+ }
378
+ if (core.tickOverrunsCount > 0) {
379
+ healthParts.push(`${colors.yellow(formatCount(core.tickOverrunsCount))} overruns`);
380
+ }
381
+ if (core.circuitBreakerTripsCount > 0) {
382
+ healthParts.push(`${colors.yellow(formatCount(core.circuitBreakerTripsCount))} breaker trips`);
383
+ }
384
+ if (healthParts.length > 0) {
385
+ lines.push(healthParts.join(sep));
141
386
  }
142
387
 
143
- const seedLine = seedExplicit
144
- ? `Seed: ${colors.bold(String(seed))}`
145
- : `Seed: ${colors.bold(String(seed))} ${colors.gray("(add seed to config to reproduce)")}`;
146
- lines.push(seedLine);
388
+ // Fault breakdown
389
+ if (fleet.faultBreakdown.length > 0) {
390
+ const faultItems: TreeItem[] = fleet.faultBreakdown.map(
391
+ (f) => `${colors.yellow(String(f.count))} ${f.fault}`,
392
+ );
393
+ lines.push(formatTree(faultItems, { color: "yellow", prefix: " " }).trimEnd());
394
+ }
147
395
 
148
- if (errorContextCounts && errorContextCounts.size > 0) {
149
- const topContexts = [...errorContextCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
150
- lines.push("Top error contexts:");
151
- for (const [context, count] of topContexts) {
152
- lines.push(` ${context} ${colors.dim("·")} ${colors.bold(formatCount(count))}`);
153
- }
396
+ // Models & lifecycle
397
+ const treeItems: TreeItem[] = [];
398
+ if (fleet.modelBreakdown.length > 0) {
399
+ treeItems.push({
400
+ text: `${colors.cyan("Models")} ${colors.dim(`(${fleet.modelBreakdown.length})`)}`,
401
+ children: fleet.modelBreakdown.map((m) => `${colors.bold(String(m.count))} ${m.model}`),
402
+ });
403
+ }
404
+ const lifecycleEntries = Object.entries(fleet.lifecycleBreakdown).filter(
405
+ ([, count]) => count > 0,
406
+ );
407
+ if (lifecycleEntries.length > 0) {
408
+ treeItems.push({
409
+ text: colors.cyan("Lifecycle"),
410
+ children: lifecycleEntries.map(([stage, count]) => `${colors.bold(String(count))} ${stage}`),
411
+ });
412
+ }
413
+ if (treeItems.length > 0) {
414
+ lines.push(formatTree(treeItems, { color: "gray", prefix: " " }).trimEnd());
415
+ }
416
+
417
+ // Seed
418
+ if (input.seed !== undefined) {
419
+ const seedLine = input.seedExplicit
420
+ ? `Seed: ${colors.bold(String(input.seed))}`
421
+ : `Seed: ${colors.bold(String(input.seed))} ${colors.gray("(add seed to config to reproduce)")}`;
422
+ lines.push(seedLine);
423
+ }
424
+
425
+ // Top error contexts
426
+ if (input.errorContextCounts && input.errorContextCounts.size > 0) {
427
+ const topContexts = [...input.errorContextCounts.entries()]
428
+ .sort((a, b) => b[1] - a[1])
429
+ .slice(0, 5);
430
+ const errorItems: TreeItem[] = topContexts.map(
431
+ ([context, count]) => `${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`,
432
+ );
433
+ lines.push(colors.cyan("Top errors"));
434
+ lines.push(formatTree(errorItems, { color: "red", prefix: " " }).trimEnd());
435
+ }
436
+
437
+ return lines;
438
+ }
439
+
440
+ function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
441
+ const { core, fleet, adapters } = input.metrics;
442
+
443
+ const adapterSummaries: Record<string, unknown>[] = [];
444
+ for (const adapter of adapters) {
445
+ const m = adapter.metrics;
446
+ const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
447
+ adapterSummaries.push({
448
+ name: adapter.name,
449
+ messagesPublished: m.messagesPublished,
450
+ errorsCount: m.errorsCount,
451
+ connected: m.connected,
452
+ ...(avgThroughput !== null ? { avgMsgPerSecond: avgThroughput } : {}),
453
+ ...(m.health
454
+ ? { health: { state: m.health.state, pendingWorkCount: m.health.pendingWorkCount } }
455
+ : {}),
456
+ });
457
+ }
458
+
459
+ const summary: Record<string, unknown> = {
460
+ uptimeMs: core.uptimeMs,
461
+ tickCount: core.tickCount,
462
+ ticksPerSecond: Number(core.actualTicksPerSecond.toFixed(1)),
463
+ simulationTimeSeconds: Number(core.simulationTimeSeconds.toFixed(1)),
464
+ instanceCount: core.instanceCount,
465
+ memberValueCount: core.memberValueCount,
466
+ engineErrorsCount: core.engineErrorsCount,
467
+ activeFaultCount: fleet.activeFaultCount,
468
+ quarantinedInstanceCount: core.quarantinedInstanceCount,
469
+ tickOverrunsCount: core.tickOverrunsCount,
470
+ circuitBreakerTripsCount: core.circuitBreakerTripsCount,
471
+ lifecycleBreakdown: fleet.lifecycleBreakdown,
472
+ adapters: adapterSummaries,
473
+ };
474
+
475
+ if (input.seed !== undefined) {
476
+ summary["seed"] = input.seed;
477
+ summary["seedExplicit"] = input.seedExplicit ?? false;
154
478
  }
155
479
 
480
+ if (input.errorContextCounts && input.errorContextCounts.size > 0) {
481
+ const topContexts = [...input.errorContextCounts.entries()]
482
+ .sort((a, b) => b[1] - a[1])
483
+ .slice(0, 5);
484
+ summary["topErrorContexts"] = Object.fromEntries(topContexts);
485
+ }
486
+
487
+ return summary;
488
+ }
489
+
490
+ function renderSummaryText(title: string, input: SummaryInput): string {
491
+ const lines = buildSummaryLines(input);
492
+
156
493
  if (isPlain()) {
157
- writeOutput("Shutdown Summary");
158
- for (const line of lines) {
159
- writeOutput(` ${line}`);
160
- }
161
- writeOutput();
494
+ return [title, ...lines.map((line) => ` ${stripAnsi(line)}`), ""].join("\n");
495
+ }
496
+
497
+ return box(lines.join("\n"), {
498
+ title: ` ${title} `,
499
+ style: { borderColor: "gray", borderStyle: "rounded" },
500
+ });
501
+ }
502
+
503
+ async function printShutdownSummary(
504
+ metrics: EngineMetricsSnapshot,
505
+ seed: number,
506
+ seedExplicit: boolean,
507
+ errorContextCounts?: ReadonlyMap<string, number>,
508
+ writeFinal: (text: string) => Promise<void> = writeShutdownOutput,
509
+ ): Promise<void> {
510
+ if (isSilent()) return;
511
+
512
+ if (isJsonLogFormat()) {
513
+ engineLog.info(
514
+ "Shutdown summary",
515
+ buildJsonSummary({ metrics, seed, seedExplicit, errorContextCounts }),
516
+ );
517
+ await flushOutputStreams();
162
518
  return;
163
519
  }
164
520
 
165
- writeOutput(
166
- box(lines.join("\n"), {
167
- title: " Shutdown Summary ",
168
- style: { borderColor: "gray", borderStyle: "rounded" },
521
+ await writeFinal(
522
+ renderSummaryText("Shutdown Summary", {
523
+ metrics,
524
+ seed,
525
+ seedExplicit,
526
+ errorContextCounts,
169
527
  }),
170
528
  );
171
529
  }
530
+
531
+ async function writeShutdownOutput(text: string): Promise<void> {
532
+ await writeToStream(process.stdout, text.endsWith("\n") ? text : `${text}\n`);
533
+ await flushOutputStreams();
534
+ }
535
+
536
+ async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<void> {
537
+ await new Promise<void>((resolve, reject) => {
538
+ stream.write(text, (error?: Error | null) => {
539
+ if (error) {
540
+ reject(error);
541
+ return;
542
+ }
543
+ resolve();
544
+ });
545
+ });
546
+ }
547
+
548
+ async function flushOutputStreams(): Promise<void> {
549
+ await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
550
+ await new Promise<void>((resolve) => setTimeout(resolve, FINAL_OUTPUT_GRACE_MS));
551
+ }