miravo 0.2.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miravo",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Industrial environment platform with canonical asset models and MQTT + OPC UA protocol adapters",
6
6
  "license": "Apache-2.0",
@@ -31,10 +31,10 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@clack/prompts": "^1.1.0",
34
- "@miravo/core": "0.2.0",
35
- "@miravo/mqtt": "0.2.0",
36
- "@miravo/opcua": "0.2.0",
37
- "@miravo/shared": "0.2.0",
34
+ "@miravo/core": "0.2.1",
35
+ "@miravo/mqtt": "0.2.1",
36
+ "@miravo/opcua": "0.2.1",
37
+ "@miravo/shared": "0.2.1",
38
38
  "commander": "^13.0.0",
39
39
  "consola": "^3.4.0",
40
40
  "mqtt": "^5.15.0"
@@ -35,7 +35,7 @@ import {
35
35
  parsePositiveFloat,
36
36
  parsePositiveInt,
37
37
  } from "../lib/parse.ts";
38
- import { installShutdownHandlers, printSummaryBox } from "../lib/shutdown.ts";
38
+ import { createShutdownController, printSummaryLine } from "../lib/shutdown.ts";
39
39
  import {
40
40
  groupTemplatesByCategory,
41
41
  isStressBenchmarkTemplate,
@@ -156,8 +156,9 @@ export function registerStartCommand(program: Command): void {
156
156
 
157
157
  async function startAction(options: StartOptions): Promise<void> {
158
158
  let runtimeLogContextSet = false;
159
- /** Adapter stop functions accumulated during setup. Drained on partial startup failure. */
160
- const pendingAdapterStops: Array<() => Promise<void>> = [];
159
+ let shutdown: ReturnType<typeof createShutdownController> | null = null;
160
+ let engine: Engine | undefined;
161
+ let stopCliResources = (): void => {};
161
162
  try {
162
163
  const catalog = createCatalog();
163
164
 
@@ -307,31 +308,60 @@ async function startAction(options: StartOptions): Promise<void> {
307
308
  runtimeLogContextSet = true;
308
309
  controlLog.info(`Run ID: ${runId}`);
309
310
 
310
- // Step 4: Create engine and populate
311
- const engine = createAndPopulateEngine(
312
- template,
313
- models,
311
+ shutdown = createShutdownController({
314
312
  seed,
315
- options,
316
- templatePath,
317
- simulationName,
318
- );
313
+ seedExplicit,
314
+ });
315
+ const shutdownRequested = shutdown.waitForShutdownRequest();
316
+ shutdown.addTask("finalize", "runtime log context", () => {
317
+ if (runtimeLogContextSet) {
318
+ clearRuntimeLogContext();
319
+ runtimeLogContextSet = false;
320
+ }
321
+ });
322
+
323
+ // Step 4: Create engine and populate
324
+ engine = createAndPopulateEngine(template, models, seed, options, templatePath, simulationName);
325
+ shutdown.setEngine(engine);
326
+
327
+ let controlPlane: ControlPlane | null = null;
328
+ let adminServer: { endpoint: string; stop: () => void } | null = null;
329
+ let lastTickProgressAt = performance.now();
330
+ let tickProgressSubscribed = false;
331
+ const onTickProgress = (): void => {
332
+ lastTickProgressAt = performance.now();
333
+ };
334
+ let unsubscribeEvents = (): void => {};
335
+ let summaryTimer: ReturnType<typeof setInterval> | null = null;
336
+
337
+ stopCliResources = (): void => {
338
+ controlPlane?.stop();
339
+ adminServer?.stop();
340
+ if (summaryTimer !== null) {
341
+ clearInterval(summaryTimer);
342
+ summaryTimer = null;
343
+ }
344
+ if (tickProgressSubscribed && engine) {
345
+ engine.bus.off("tick:complete", onTickProgress);
346
+ tickProgressSubscribed = false;
347
+ }
348
+ unsubscribeEvents();
349
+ };
350
+
351
+ shutdown.addTask("cleanup", "CLI resources", stopCliResources);
319
352
 
320
353
  // Step 5: Start MQTT adapter (unless --no-mqtt)
321
354
  const mqttStartResult = mqttEnabled
322
355
  ? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
323
356
  : null;
324
357
  const adapter = mqttStartResult?.adapter ?? null;
325
- if (adapter) pendingAdapterStops.push(() => adapter.stop());
326
358
 
327
359
  // Step 5a: Start OPC UA adapter (only when --opcua)
328
360
  const opcuaResult = opcuaEnabled
329
361
  ? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
330
362
  : null;
331
- if (opcuaResult) pendingAdapterStops.push(() => opcuaResult.adapter.stop());
332
363
 
333
364
  // Step 5b: Wire control plane
334
- let controlPlane: ControlPlane | null = null;
335
365
  if (adapter) {
336
366
  const transport = adapter.getControlTransport();
337
367
  if (transport) {
@@ -347,12 +377,8 @@ async function startAction(options: StartOptions): Promise<void> {
347
377
  }
348
378
 
349
379
  // Step 5c: Start admin HTTP server
350
- let adminServer: { endpoint: string; stop: () => void } | null = null;
351
- let lastTickProgressAt = performance.now();
352
- const onTickProgress = (): void => {
353
- lastTickProgressAt = performance.now();
354
- };
355
380
  engine.bus.on("tick:complete", onTickProgress);
381
+ tickProgressSubscribed = true;
356
382
  try {
357
383
  adminServer = startAdminServer({
358
384
  engine,
@@ -367,6 +393,7 @@ async function startAction(options: StartOptions): Promise<void> {
367
393
  });
368
394
  controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
369
395
  } catch (error) {
396
+ tickProgressSubscribed = false;
370
397
  engine.bus.off("tick:complete", onTickProgress);
371
398
  const message = error instanceof Error ? error.message : String(error);
372
399
  throw new MiravoError(
@@ -390,46 +417,30 @@ async function startAction(options: StartOptions): Promise<void> {
390
417
 
391
418
  // Step 7: Subscribe to events + periodic summary
392
419
  const errorContextCounts = new Map<string, number>();
393
- const unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
420
+ unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
421
+ shutdown.setErrorContextCounts(errorContextCounts);
394
422
  const summaryInterval = options.summaryInterval ?? 5;
395
- const summaryTimer = startPeriodicSummary(engine, summaryInterval);
396
-
397
- // Step 8: Install shutdown handler and start
398
- const teardownShutdown = installShutdownHandlers({
399
- engine,
400
- seed,
401
- seedExplicit,
402
- cleanup: () => {
403
- controlPlane?.stop();
404
- adminServer?.stop();
405
- engine.bus.off("tick:complete", onTickProgress);
406
- clearInterval(summaryTimer);
407
- unsubscribeEvents();
408
- clearRuntimeLogContext();
409
- runtimeLogContextSet = false;
410
- },
411
- errorContextCounts,
412
- });
423
+ summaryTimer = startPeriodicSummary(engine, summaryInterval);
413
424
 
414
- try {
415
- engine.start();
416
- } catch (error) {
417
- teardownShutdown();
418
- controlPlane?.stop();
419
- adminServer?.stop();
420
- engine.bus.off("tick:complete", onTickProgress);
421
- clearInterval(summaryTimer);
422
- unsubscribeEvents();
423
- clearRuntimeLogContext();
424
- runtimeLogContextSet = false;
425
- throw error;
426
- }
427
- // Engine running — shutdown handlers now own adapter lifecycle
428
- pendingAdapterStops.length = 0;
425
+ engine.start();
426
+ shutdown.markRunning();
429
427
  engineLog.success("Simulation started.");
428
+ await shutdownRequested;
429
+ await shutdown.runShutdown();
430
+ shutdown.teardown();
430
431
  } catch (error) {
431
- // Stop adapters started before engine/shutdown handlers took ownership
432
- await Promise.allSettled(pendingAdapterStops.map((fn) => fn()));
432
+ if (shutdown?.isShuttingDown()) {
433
+ await shutdown.runShutdown();
434
+ shutdown.teardown();
435
+ return;
436
+ }
437
+ shutdown?.teardown();
438
+ stopCliResources();
439
+ try {
440
+ await engine?.stop("boot error");
441
+ } catch {
442
+ // engine may not exist yet or stop may fail — swallow
443
+ }
433
444
  if (runtimeLogContextSet) {
434
445
  clearRuntimeLogContext();
435
446
  runtimeLogContextSet = false;
@@ -826,9 +837,7 @@ async function startMQTTAdapter(
826
837
  ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
827
838
  };
828
839
 
829
- await adapter.start({
830
- ...resolvedConfig,
831
- });
840
+ await adapter.start(resolvedConfig);
832
841
  engine.registerAdapter(adapter);
833
842
  mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
834
843
  return { adapter, endpoint };
@@ -861,7 +870,6 @@ async function startOPCUAAdapter(
861
870
  opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
862
871
 
863
872
  const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
864
-
865
873
  try {
866
874
  const resolvedConfig = {
867
875
  name: "opcua" as const,
@@ -1009,7 +1017,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1009
1017
  };
1010
1018
 
1011
1019
  const onWarn = (event: { message: string; context: string }): void => {
1012
- engineLog.warn(event.message, { context: event.context });
1020
+ engineLog.debug(event.message, { context: event.context });
1013
1021
  };
1014
1022
 
1015
1023
  const onError = (event: { context: string; error: Error }): void => {
@@ -1068,7 +1076,7 @@ function startPeriodicSummary(
1068
1076
  ): ReturnType<typeof setInterval> {
1069
1077
  return setInterval(() => {
1070
1078
  const metrics = engine.getMetrics();
1071
- printSummaryBox("Summary", { metrics }, "Periodic summary");
1079
+ printSummaryLine(metrics);
1072
1080
  }, intervalSeconds * 1_000);
1073
1081
  }
1074
1082
 
package/src/index.ts CHANGED
@@ -80,11 +80,35 @@ export function createProgram(): Command {
80
80
 
81
81
  // Only run when executed directly, not when imported in tests
82
82
  if (import.meta.path === Bun.main) {
83
- // Import the generated manifest to trigger Bun's file embedding in compiled binaries.
83
+ // In compiled binaries, extract embedded content to a temp directory so
84
+ // ContentCatalog's filesystem-based discovery can find built-in models/templates.
84
85
  // The manifest only exists during `bun run build:standalone`; the catch is a no-op in dev.
85
86
  try {
86
87
  // @ts-expect-error — file is generated at build time and does not exist in source tree
87
- await import("./_embedded-manifest.ts");
88
+ const manifest = await import("./_embedded-manifest.ts");
89
+ const { modelPaths, templatePaths } = manifest as {
90
+ modelPaths: string[];
91
+ templatePaths: string[];
92
+ };
93
+ const { basename, join } = await import("node:path");
94
+ const { tmpdir } = await import("node:os");
95
+ const { mkdirSync } = await import("node:fs");
96
+ const { registerEmbeddedContentDir } = await import("@miravo/core");
97
+
98
+ const contentDir = join(tmpdir(), "miravo-embedded");
99
+ const modelsDir = join(contentDir, "models");
100
+ const templatesDir = join(contentDir, "templates");
101
+ mkdirSync(modelsDir, { recursive: true });
102
+ mkdirSync(templatesDir, { recursive: true });
103
+
104
+ for (const p of modelPaths) {
105
+ await Bun.write(join(modelsDir, basename(p)), Bun.file(p));
106
+ }
107
+ for (const p of templatePaths) {
108
+ await Bun.write(join(templatesDir, basename(p)), Bun.file(p));
109
+ }
110
+
111
+ registerEmbeddedContentDir(contentDir);
88
112
  } catch {
89
113
  // Not a compiled binary or manifest not present — expected in dev mode
90
114
  }
@@ -11,9 +11,52 @@ import {
11
11
  lockLogger,
12
12
  logger,
13
13
  mqttLog,
14
+ opcuaLog,
14
15
  unlockLogger,
15
16
  } from "../logger.ts";
16
- import { installShutdownHandlers } from "./shutdown.ts";
17
+ import { createShutdownController } from "./shutdown.ts";
18
+
19
+ const noopReporter = { log() {} };
20
+
21
+ function configureSummaryLogger(
22
+ options: { plain?: boolean; logFormat?: "pretty" | "json" } = {},
23
+ ): void {
24
+ unlockLogger();
25
+ configureLogger({
26
+ plain: options.plain ?? false,
27
+ logFormat: options.logFormat ?? "pretty",
28
+ });
29
+ for (const l of [
30
+ logger,
31
+ engineLog,
32
+ mqttLog,
33
+ opcuaLog,
34
+ configLog,
35
+ lifecycleLog,
36
+ faultLog,
37
+ controlLog,
38
+ ]) {
39
+ l.level = -999;
40
+ l.setReporters([noopReporter]);
41
+ }
42
+ lockLogger();
43
+ }
44
+
45
+ function setStdoutIsTTY(value: boolean): () => void {
46
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
47
+ Object.defineProperty(process.stdout, "isTTY", {
48
+ configurable: true,
49
+ value,
50
+ });
51
+
52
+ return () => {
53
+ if (descriptor) {
54
+ Object.defineProperty(process.stdout, "isTTY", descriptor);
55
+ return;
56
+ }
57
+ delete (process.stdout as { isTTY?: boolean }).isTTY;
58
+ };
59
+ }
17
60
 
18
61
  function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string | undefined } {
19
62
  const metrics: EngineMetricsSnapshot = {
@@ -53,31 +96,33 @@ function createMockEngine(): Engine & { stopCalled: boolean; stopReason: string
53
96
  return mock;
54
97
  }
55
98
 
56
- describe("installShutdownHandlers", () => {
57
- let teardown: (() => void) | undefined;
99
+ describe("createShutdownController", () => {
100
+ const teardowns: Array<() => void> = [];
58
101
 
59
102
  afterEach(() => {
60
- teardown?.();
61
- teardown = undefined;
103
+ while (teardowns.length > 0) {
104
+ teardowns.pop()?.();
105
+ }
106
+ muteAllLoggers();
62
107
  });
63
108
 
64
109
  test("first SIGINT calls engine.stop and exit(0)", async () => {
65
110
  const engine = createMockEngine();
66
111
  let exitCode: number | undefined;
67
112
 
68
- teardown = installShutdownHandlers({
69
- engine,
113
+ const controller = createShutdownController({
70
114
  seed: 42,
71
115
  seedExplicit: true,
72
116
  exit: (code) => {
73
117
  exitCode = code;
74
118
  },
75
119
  });
120
+ teardowns.push(controller.teardown);
121
+ controller.setEngine(engine);
76
122
 
77
123
  process.emit("SIGINT");
78
-
79
- // Allow async shutdown to complete
80
- await new Promise((resolve) => setTimeout(resolve, 50));
124
+ await controller.waitForShutdownRequest();
125
+ await controller.runShutdown();
81
126
 
82
127
  expect(engine.stopCalled).toBe(true);
83
128
  expect(engine.stopReason).toBe("user interrupt");
@@ -88,153 +133,236 @@ describe("installShutdownHandlers", () => {
88
133
  const engine = createMockEngine();
89
134
  const exitCodes: number[] = [];
90
135
 
91
- // Make stop() slow so we can fire a second signal during shutdown
92
136
  (engine as unknown as Record<string, unknown>)["stop"] = () =>
93
137
  new Promise((resolve) => setTimeout(resolve, 200));
94
138
 
95
- teardown = installShutdownHandlers({
96
- engine,
139
+ const controller = createShutdownController({
97
140
  seed: 42,
98
- seedExplicit: false,
141
+ seedExplicit: true,
99
142
  exit: (code) => {
100
143
  exitCodes.push(code);
101
144
  },
102
145
  });
146
+ teardowns.push(controller.teardown);
147
+ controller.setEngine(engine);
103
148
 
104
- // First signal starts shutdown
105
149
  process.emit("SIGINT");
106
- // Brief delay to let shuttingDown be set
150
+ await controller.waitForShutdownRequest();
151
+ const shutdownPromise = controller.runShutdown();
107
152
  await new Promise((resolve) => setTimeout(resolve, 10));
108
- // Second signal should force exit
109
153
  process.emit("SIGINT");
110
-
111
- await new Promise((resolve) => setTimeout(resolve, 250));
154
+ await controller.completion;
155
+ await shutdownPromise;
112
156
 
113
157
  expect(exitCodes).toContain(1);
114
158
  });
115
159
 
116
- test("engine.stop() throwing still exits 0", async () => {
160
+ test("shutdown request is inert until the owner runs shutdown", async () => {
117
161
  const engine = createMockEngine();
118
- let exitCode: number | undefined;
162
+ let stopCallCount = 0;
119
163
 
120
- // Make stop() throw
121
164
  (engine as unknown as Record<string, unknown>)["stop"] = async () => {
122
- throw new Error("adapter disconnect");
165
+ stopCallCount++;
123
166
  };
124
167
 
125
- teardown = installShutdownHandlers({
126
- engine,
168
+ const controller = createShutdownController({
127
169
  seed: 1,
128
170
  seedExplicit: true,
129
- exit: (code) => {
130
- exitCode = code;
131
- },
171
+ exit: () => {},
132
172
  });
173
+ teardowns.push(controller.teardown);
174
+ controller.setEngine(engine);
133
175
 
134
176
  process.emit("SIGINT");
135
- await new Promise((resolve) => setTimeout(resolve, 50));
177
+ await controller.waitForShutdownRequest();
136
178
 
137
- expect(exitCode).toBe(0);
179
+ expect(stopCallCount).toBe(0);
180
+
181
+ await controller.runShutdown();
182
+
183
+ expect(stopCallCount).toBe(1);
138
184
  });
139
185
 
140
- test("concurrent SIGINT+SIGTERM only runs shutdown once", async () => {
186
+ test("teardown removes signal handlers", async () => {
141
187
  const engine = createMockEngine();
142
- let stopCallCount = 0;
143
-
144
- (engine as unknown as Record<string, unknown>)["stop"] = async () => {
145
- stopCallCount++;
146
- };
147
188
 
148
- teardown = installShutdownHandlers({
149
- engine,
189
+ const controller = createShutdownController({
150
190
  seed: 1,
151
191
  seedExplicit: true,
152
192
  exit: () => {},
153
193
  });
194
+ controller.setEngine(engine);
195
+ controller.teardown();
154
196
 
155
- // Fire both signals nearly simultaneously
156
197
  process.emit("SIGINT");
157
- process.emit("SIGTERM");
158
198
  await new Promise((resolve) => setTimeout(resolve, 50));
159
199
 
160
- // The shuttingDown guard should prevent the second from starting a new shutdown
161
- expect(stopCallCount).toBe(1);
200
+ expect(engine.stopCalled).toBe(false);
162
201
  });
163
202
 
164
- test("teardown removes signal handlers", async () => {
203
+ test("runs cleanup before summary and exit", async () => {
165
204
  const engine = createMockEngine();
205
+ const steps: string[] = [];
206
+ configureSummaryLogger({ plain: false, logFormat: "pretty" });
166
207
 
167
- teardown = installShutdownHandlers({
168
- engine,
169
- seed: 1,
208
+ const controller = createShutdownController({
209
+ seed: 42,
170
210
  seedExplicit: true,
171
- exit: () => {},
211
+ writeFinalOutput: async () => {
212
+ steps.push("summary");
213
+ },
214
+ exit: () => {
215
+ steps.push("exit");
216
+ },
217
+ });
218
+ teardowns.push(controller.teardown);
219
+ controller.setEngine(engine);
220
+ controller.addTask("cleanup", "cleanup", async () => {
221
+ await new Promise((resolve) => setTimeout(resolve, 25));
222
+ steps.push("cleanup");
223
+ });
224
+ controller.addTask("finalize", "finalize", () => {
225
+ steps.push("finalize");
172
226
  });
173
227
 
174
- // Remove handlers
175
- teardown();
176
- teardown = undefined;
177
-
178
- // Fire SIGINT after teardown — our handler should NOT run.
179
- // If handlers weren't removed, the mock engine would get stopped.
180
228
  process.emit("SIGINT");
181
- await new Promise((resolve) => setTimeout(resolve, 50));
182
- expect(engine.stopCalled).toBe(false);
229
+ await controller.waitForShutdownRequest();
230
+ await controller.runShutdown();
231
+
232
+ expect(steps).toEqual(["cleanup", "summary", "finalize", "exit"]);
183
233
  });
184
234
 
185
- test("prints top 5 error contexts in shutdown summary", async () => {
235
+ test("cleanup failure does not suppress the shutdown summary", async () => {
186
236
  const engine = createMockEngine();
187
237
  let exitCode: number | undefined;
188
- const originalLog = console.log;
189
- const calls: string[] = [];
190
- console.log = (...args: unknown[]) => {
191
- calls.push(args.map((arg) => String(arg)).join(" "));
192
- };
238
+ const renders: string[] = [];
193
239
 
194
- // Enable writeOutput (plain mode, not suppressed) so the shutdown summary
195
- // flows through console.log where we can capture it. Then mute consola
196
- // reporters so engineLog/mqttLog don't leak to stdout.
197
- unlockLogger();
198
- configureLogger({ plain: true });
199
- const noopReporter = { log() {} };
200
- for (const l of [logger, engineLog, mqttLog, configLog, lifecycleLog, faultLog, controlLog]) {
201
- l.level = -999;
202
- l.setReporters([noopReporter]);
203
- }
204
- lockLogger();
240
+ configureSummaryLogger({ plain: false, logFormat: "pretty" });
241
+
242
+ const controller = createShutdownController({
243
+ seed: 42,
244
+ seedExplicit: true,
245
+ writeFinalOutput: async (text) => {
246
+ renders.push(text);
247
+ },
248
+ exit: (code) => {
249
+ exitCode = code;
250
+ },
251
+ });
252
+ teardowns.push(controller.teardown);
253
+ controller.setEngine(engine);
254
+ controller.addTask("cleanup", "cleanup", () => {
255
+ throw new Error("cleanup failed");
256
+ });
257
+
258
+ process.emit("SIGINT");
259
+ await controller.waitForShutdownRequest();
260
+ await controller.runShutdown();
261
+
262
+ expect(exitCode).toBe(0);
263
+ expect(renders).toHaveLength(1);
264
+ expect(renders[0]?.includes("Shutdown Summary")).toBe(true);
265
+ });
266
+
267
+ test("renders the rounded shutdown box in pretty mode even when stdout is not a TTY", async () => {
268
+ const engine = createMockEngine();
269
+ let exitCode: number | undefined;
270
+ const renders: string[] = [];
271
+ const restoreTTY = setStdoutIsTTY(false);
272
+ configureSummaryLogger({ plain: false, logFormat: "pretty" });
205
273
 
206
274
  try {
207
- teardown = installShutdownHandlers({
208
- engine,
275
+ const controller = createShutdownController({
209
276
  seed: 42,
210
277
  seedExplicit: true,
211
- errorContextCounts: new Map([
212
- ["mqtt:publish", 9],
213
- ["tick-overrun", 7],
214
- ["mqtt:backpressure", 5],
215
- ["mqtt:reconnect", 3],
216
- ['circuit-breaker instance="pump-001"', 2],
217
- ["mqtt:client", 1],
218
- ]),
278
+ writeFinalOutput: async (text) => {
279
+ renders.push(text);
280
+ },
219
281
  exit: (code) => {
220
282
  exitCode = code;
221
283
  },
222
284
  });
285
+ teardowns.push(controller.teardown);
286
+ controller.setEngine(engine);
223
287
 
224
288
  process.emit("SIGINT");
225
- await new Promise((resolve) => setTimeout(resolve, 50));
289
+ await controller.waitForShutdownRequest();
290
+ await controller.runShutdown();
226
291
  } finally {
227
- console.log = originalLog;
228
- muteAllLoggers();
292
+ restoreTTY();
229
293
  }
230
294
 
231
295
  expect(exitCode).toBe(0);
232
- expect(calls.some((line) => line.includes("Top errors"))).toBe(true);
233
- expect(calls.some((line) => line.includes("mqtt:publish"))).toBe(true);
234
- expect(calls.some((line) => line.includes("tick-overrun"))).toBe(true);
235
- expect(calls.some((line) => line.includes("mqtt:backpressure"))).toBe(true);
236
- expect(calls.some((line) => line.includes("mqtt:reconnect"))).toBe(true);
237
- expect(calls.some((line) => line.includes('circuit-breaker instance="pump-001"'))).toBe(true);
238
- expect(calls.some((line) => line.includes("mqtt:client"))).toBe(false);
296
+ expect(renders).toHaveLength(1);
297
+ expect(renders[0]?.includes("Shutdown Summary")).toBe(true);
298
+ expect(renders[0]?.includes("╭")).toBe(true);
299
+ expect(renders[0]?.includes("╰")).toBe(true);
300
+ });
301
+
302
+ test("keeps shutdown output plain when --plain is enabled", async () => {
303
+ const engine = createMockEngine();
304
+ const renders: string[] = [];
305
+ configureSummaryLogger({ plain: true, logFormat: "pretty" });
306
+
307
+ const controller = createShutdownController({
308
+ seed: 42,
309
+ seedExplicit: true,
310
+ writeFinalOutput: async (text) => {
311
+ renders.push(text);
312
+ },
313
+ exit: () => {},
314
+ });
315
+ teardowns.push(controller.teardown);
316
+ controller.setEngine(engine);
317
+
318
+ process.emit("SIGINT");
319
+ await controller.waitForShutdownRequest();
320
+ await controller.runShutdown();
321
+
322
+ expect(renders).toHaveLength(1);
323
+ expect(renders[0]?.startsWith("Shutdown Summary\n")).toBe(true);
324
+ expect(renders[0]?.includes("╭")).toBe(false);
325
+ });
326
+
327
+ test("prints top 5 error contexts in shutdown summary", async () => {
328
+ const engine = createMockEngine();
329
+ let exitCode: number | undefined;
330
+ const renders: string[] = [];
331
+ configureSummaryLogger({ plain: true, logFormat: "pretty" });
332
+
333
+ const controller = createShutdownController({
334
+ seed: 42,
335
+ seedExplicit: true,
336
+ errorContextCounts: new Map([
337
+ ["mqtt:publish", 9],
338
+ ["tick-overrun", 7],
339
+ ["mqtt:backpressure", 5],
340
+ ["mqtt:reconnect", 3],
341
+ ['circuit-breaker instance="pump-001"', 2],
342
+ ["mqtt:client", 1],
343
+ ]),
344
+ writeFinalOutput: async (text) => {
345
+ renders.push(text);
346
+ },
347
+ exit: (code) => {
348
+ exitCode = code;
349
+ },
350
+ });
351
+ teardowns.push(controller.teardown);
352
+ controller.setEngine(engine);
353
+
354
+ process.emit("SIGINT");
355
+ await controller.waitForShutdownRequest();
356
+ await controller.runShutdown();
357
+
358
+ const output = renders[0] ?? "";
359
+ expect(exitCode).toBe(0);
360
+ expect(output.includes("Top errors")).toBe(true);
361
+ expect(output.includes("mqtt:publish")).toBe(true);
362
+ expect(output.includes("tick-overrun")).toBe(true);
363
+ expect(output.includes("mqtt:backpressure")).toBe(true);
364
+ expect(output.includes("mqtt:reconnect")).toBe(true);
365
+ expect(output.includes('circuit-breaker instance="pump-001"')).toBe(true);
366
+ expect(output.includes("mqtt:client")).toBe(false);
239
367
  });
240
368
  });
@@ -1,124 +1,245 @@
1
1
  import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
2
2
  import type { AdapterHealthSnapshot } from "@miravo/shared";
3
3
  import type { TreeItem } from "consola/utils";
4
- import { box, colors, formatTree } from "consola/utils";
5
- import { engineLog, isJsonLogFormat, isPlain, isSilent, writeOutput } from "../logger.ts";
4
+ import { box, colors, formatTree, stripAnsi } from "consola/utils";
5
+ import { engineLog, isJsonLogFormat, isPlain, isSilent, mqttLog, opcuaLog } from "../logger.ts";
6
6
  import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
7
7
 
8
- /** Parameters for the shutdown handler. Supports dependency injection for tests. */
9
- export interface ShutdownOptions {
10
- /** The running engine to stop. */
11
- readonly engine: Engine;
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 {
12
21
  /** The RNG seed used for this run (printed in summary for reproducibility). */
13
22
  readonly seed: number;
14
23
  /** Whether the seed was explicitly configured (vs. randomly generated). */
15
24
  readonly seedExplicit: boolean;
16
- /** Optional cleanup callback for timers, intervals, etc. */
17
- readonly cleanup?: () => void;
18
25
  /** Optional aggregated engine-error counts by context. */
19
26
  readonly errorContextCounts?: ReadonlyMap<string, number>;
20
27
  /** Process exit function. Defaults to process.exit. Inject for testing. */
21
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>;
22
44
  }
23
45
 
24
46
  /**
25
- * Install SIGINT and SIGTERM handlers for graceful shutdown.
26
- *
27
- * - First signal: stop engine, drain adapters, print summary, exit 0.
28
- * - Second signal during shutdown: force exit immediately with exit 1.
47
+ * Create a single-owner shutdown controller for `miravo start`.
29
48
  *
30
- * @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.
31
53
  */
32
- export function installShutdownHandlers(options: ShutdownOptions): () => void {
33
- const { engine, seed, seedExplicit, cleanup, errorContextCounts } = options;
54
+ export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
34
55
  const exit = options.exit ?? ((code: number) => process.exit(code));
56
+ const writeFinalOutput = options.writeFinalOutput ?? writeShutdownOutput;
57
+
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;
64
+
65
+ const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
66
+ cleanup: [],
67
+ finalize: [],
68
+ };
35
69
 
36
- let shuttingDown = false;
37
- let forcedExit = false;
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
+ });
78
+
79
+ const keepAlive = setInterval(() => {}, 60_000);
80
+ const forceSignalHandler = (): void => {
81
+ forceExit();
82
+ };
38
83
 
39
- const forceExitHandler = (): void => {
40
- if (forcedExit) return;
41
- forcedExit = true;
84
+ const forceExit = (): void => {
85
+ if (phase === "forced_exit" || phase === "exited") return;
86
+ forcedExitRequested = true;
87
+ phase = "forced_exit";
88
+ clearInterval(keepAlive);
42
89
  engineLog.warn("Force stopping. Goodbye.");
90
+ resolveCompletion();
43
91
  exit(1);
44
92
  };
45
93
 
46
- const handleSignal = async (signal: string): Promise<void> => {
47
- // A second signal during shutdown should always force an immediate exit.
48
- if (shuttingDown) {
49
- forceExitHandler();
50
- return;
51
- }
52
- shuttingDown = true;
53
-
54
- engineLog.start("Stopping simulation...");
55
-
56
- // Capture metrics before stop() clears bus listeners
57
- const finalMetrics = engine.getMetrics();
58
-
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
- engineLog.start("Flushing pending messages (5s timeout)...");
63
- const stopResult = engine.stop(signal === "SIGINT" ? "user interrupt" : signal);
64
- // Suppress unhandled rejection if stop() rejects after timeout wins the race
65
- stopResult.catch(() => {});
66
97
  await Promise.race([
67
- stopResult,
98
+ Promise.resolve(run()),
68
99
  new Promise<void>((resolve) => {
69
- timeoutHandle = setTimeout(() => {
70
- timedOut = true;
100
+ timer = setTimeout(() => {
101
+ engineLog.warn(`${name} timed out after ${SHUTDOWN_TASK_TIMEOUT_MS / 1000}s.`);
71
102
  resolve();
72
- }, 5_000);
103
+ }, SHUTDOWN_TASK_TIMEOUT_MS);
73
104
  }),
74
105
  ]);
75
- if (timedOut) {
76
- engineLog.warn("Engine stop timed out after 5s, proceeding with exit.");
77
- } else {
78
- engineLog.success("Stopped.");
79
- }
80
- } catch (err) {
81
- engineLog.error("Error during shutdown:", err);
106
+ } catch (error) {
107
+ engineLog.error(`Error during ${name}:`, error);
82
108
  } finally {
83
- if (timeoutHandle !== null) {
84
- clearTimeout(timeoutHandle);
109
+ if (timer !== null) {
110
+ clearTimeout(timer);
85
111
  }
86
112
  }
113
+ };
87
114
 
88
- // Clean up timers, admin server, and bus listeners AFTER engine.stop()
89
- // so that adapter shutdown errors are still observable on the bus.
90
- cleanup?.();
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
+ };
91
126
 
92
- if (forcedExit) {
93
- return;
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);
94
162
  }
163
+ };
95
164
 
96
- printShutdownSummary(finalMetrics, seed, seedExplicit, errorContextCounts);
97
- Bun.stdout.writer().flush();
98
- exit(0);
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
+ };
171
+
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();
99
181
  };
100
182
 
101
183
  const sigintHandler = (): void => {
102
- void handleSignal("SIGINT");
184
+ requestShutdown("user interrupt");
103
185
  };
104
186
 
105
187
  const sigtermHandler = (): void => {
106
- void handleSignal("SIGTERM");
188
+ requestShutdown("SIGTERM");
107
189
  };
108
190
 
109
- process.on("SIGINT", sigintHandler);
110
- process.on("SIGTERM", sigtermHandler);
111
-
112
- return () => {
113
- process.removeListener("SIGINT", sigintHandler);
114
- process.removeListener("SIGTERM", sigtermHandler);
191
+ process.once("SIGINT", sigintHandler);
192
+ process.once("SIGTERM", sigtermHandler);
193
+
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
+ },
115
234
  };
116
235
  }
117
236
 
118
237
  // ---------------------------------------------------------------------------
119
- // Shared summary rendering
238
+ // Shared helpers
120
239
  // ---------------------------------------------------------------------------
121
240
 
241
+ const sep = colors.dim(" \u00B7 ");
242
+
122
243
  interface SummaryInput {
123
244
  readonly metrics: EngineMetricsSnapshot;
124
245
  readonly seed?: number | undefined;
@@ -137,16 +258,81 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
137
258
  }
138
259
  }
139
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
+
140
293
  /**
141
- * Build the pretty summary lines from an engine metrics snapshot.
142
- * Used by both the shutdown summary and periodic summary.
294
+ * Print a compact periodic summary: one line for core stats, one per adapter.
143
295
  */
296
+ export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
297
+ if (isSilent()) return;
298
+
299
+ if (isJsonLogFormat()) {
300
+ engineLog.info("Periodic summary", buildJsonSummary({ metrics }));
301
+ return;
302
+ }
303
+
304
+ const { core, fleet, adapters } = metrics;
305
+
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
+
144
331
  function buildSummaryLines(input: SummaryInput): string[] {
145
332
  const { core, fleet, adapters } = input.metrics;
146
- const sep = colors.dim(" \u00B7 ");
147
333
  const lines: string[] = [];
148
334
 
149
- // --- Core stats ---
335
+ // Core stats
150
336
  lines.push(
151
337
  `${colors.bold(formatDuration(core.uptimeMs))}${sep}${colors.bold(formatCount(core.tickCount))} ticks${sep}${colors.bold(String(core.actualTicksPerSecond.toFixed(1)))} ticks/s`,
152
338
  );
@@ -154,11 +340,10 @@ function buildSummaryLines(input: SummaryInput): string[] {
154
340
  `${colors.bold(formatCount(core.instanceCount))} instances${sep}${colors.bold(formatCount(core.memberValueCount))} data points${sep}sim: ${colors.bold(core.simulationTimeSeconds.toFixed(1))}s`,
155
341
  );
156
342
 
157
- // --- Protocol adapters ---
343
+ // Adapters
158
344
  for (const adapter of adapters) {
159
345
  const m = adapter.metrics;
160
- const label = adapter.name === "opcua" ? "OPC UA" : adapter.name.toUpperCase();
161
- const verb = adapter.name === "opcua" ? "updates" : "published";
346
+ const label = adapterLabel(adapter.name);
162
347
  const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
163
348
  const errStr =
164
349
  m.errorsCount === 0
@@ -166,21 +351,20 @@ function buildSummaryLines(input: SummaryInput): string[] {
166
351
  : colors.yellow(formatCount(m.errorsCount));
167
352
  const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
168
353
  const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
169
- const filteredStr =
170
- m.messagesFiltered > 0 ? `${sep}${colors.dim(formatCount(m.messagesFiltered))} filtered` : "";
354
+ const verb = adapter.name === "opcua" ? "updates" : "published";
171
355
  lines.push(
172
- `${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}${filteredStr}`,
356
+ `${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
173
357
  );
174
- // Health on a separate line to keep the box narrow
175
- const health = m.health;
176
- if (health) {
177
- const pendingStr =
178
- health.pendingWorkCount > 0 ? `${formatCount(health.pendingWorkCount)} pending${sep}` : "";
179
- lines.push(` ${pendingStr}${healthStateLabel(health.state)}`);
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)}`);
180
364
  }
181
365
  }
182
366
 
183
- // --- Fleet health ---
367
+ // Fleet health warnings
184
368
  const healthParts: string[] = [];
185
369
  if (fleet.activeFaultCount > 0) {
186
370
  healthParts.push(`${colors.yellow(String(fleet.activeFaultCount))} active faults`);
@@ -201,7 +385,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
201
385
  lines.push(healthParts.join(sep));
202
386
  }
203
387
 
204
- // --- Fault breakdown (tree) ---
388
+ // Fault breakdown
205
389
  if (fleet.faultBreakdown.length > 0) {
206
390
  const faultItems: TreeItem[] = fleet.faultBreakdown.map(
207
391
  (f) => `${colors.yellow(String(f.count))} ${f.fault}`,
@@ -209,7 +393,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
209
393
  lines.push(formatTree(faultItems, { color: "yellow", prefix: " " }).trimEnd());
210
394
  }
211
395
 
212
- // --- Model & lifecycle (tree) ---
396
+ // Models & lifecycle
213
397
  const treeItems: TreeItem[] = [];
214
398
  if (fleet.modelBreakdown.length > 0) {
215
399
  treeItems.push({
@@ -230,7 +414,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
230
414
  lines.push(formatTree(treeItems, { color: "gray", prefix: " " }).trimEnd());
231
415
  }
232
416
 
233
- // --- Seed ---
417
+ // Seed
234
418
  if (input.seed !== undefined) {
235
419
  const seedLine = input.seedExplicit
236
420
  ? `Seed: ${colors.bold(String(input.seed))}`
@@ -238,7 +422,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
238
422
  lines.push(seedLine);
239
423
  }
240
424
 
241
- // --- Top error contexts (tree) ---
425
+ // Top error contexts
242
426
  if (input.errorContextCounts && input.errorContextCounts.size > 0) {
243
427
  const topContexts = [...input.errorContextCounts.entries()]
244
428
  .sort((a, b) => b[1] - a[1])
@@ -246,16 +430,13 @@ function buildSummaryLines(input: SummaryInput): string[] {
246
430
  const errorItems: TreeItem[] = topContexts.map(
247
431
  ([context, count]) => `${context} ${colors.dim("\u00B7")} ${colors.bold(formatCount(count))}`,
248
432
  );
249
- lines.push(`${colors.cyan("Top errors")}`);
433
+ lines.push(colors.cyan("Top errors"));
250
434
  lines.push(formatTree(errorItems, { color: "red", prefix: " " }).trimEnd());
251
435
  }
252
436
 
253
437
  return lines;
254
438
  }
255
439
 
256
- /**
257
- * Build a structured summary object for JSON log output.
258
- */
259
440
  function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
260
441
  const { core, fleet, adapters } = input.metrics;
261
442
 
@@ -306,49 +487,65 @@ function buildJsonSummary(input: SummaryInput): Record<string, unknown> {
306
487
  return summary;
307
488
  }
308
489
 
309
- /**
310
- * Render a summary box (or plain/JSON equivalent) with a given title.
311
- * Shared renderer for shutdown and periodic summaries.
312
- */
313
- export function printSummaryBox(title: string, input: SummaryInput, logLabel: string): void {
314
- if (isSilent()) return;
315
-
316
- if (isJsonLogFormat()) {
317
- engineLog.info(logLabel, buildJsonSummary(input));
318
- }
319
-
490
+ function renderSummaryText(title: string, input: SummaryInput): string {
320
491
  const lines = buildSummaryLines(input);
321
492
 
322
493
  if (isPlain()) {
323
- writeOutput(title);
324
- for (const line of lines) {
325
- writeOutput(` ${line}`);
326
- }
327
- writeOutput();
328
- return;
494
+ return [title, ...lines.map((line) => ` ${stripAnsi(line)}`), ""].join("\n");
329
495
  }
330
496
 
331
- writeOutput(
332
- box(lines.join("\n"), {
333
- title: ` ${title} `,
334
- style: { borderColor: "gray", borderStyle: "rounded" },
335
- }),
336
- );
497
+ return box(lines.join("\n"), {
498
+ title: ` ${title} `,
499
+ style: { borderColor: "gray", borderStyle: "rounded" },
500
+ });
337
501
  }
338
502
 
339
- // ---------------------------------------------------------------------------
340
- // Shutdown-specific
341
- // ---------------------------------------------------------------------------
342
-
343
- function printShutdownSummary(
503
+ async function printShutdownSummary(
344
504
  metrics: EngineMetricsSnapshot,
345
505
  seed: number,
346
506
  seedExplicit: boolean,
347
507
  errorContextCounts?: ReadonlyMap<string, number>,
348
- ): void {
349
- printSummaryBox(
350
- "Shutdown Summary",
351
- { metrics, seed, seedExplicit, errorContextCounts },
352
- "Shutdown summary",
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();
518
+ return;
519
+ }
520
+
521
+ await writeFinal(
522
+ renderSummaryText("Shutdown Summary", {
523
+ metrics,
524
+ seed,
525
+ seedExplicit,
526
+ errorContextCounts,
527
+ }),
353
528
  );
354
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
+ }
package/src/logger.ts CHANGED
@@ -19,7 +19,7 @@ const LOG_LEVEL_MAP: Readonly<Record<LogLevel, number>> = {
19
19
  const TAG_COLORS: Readonly<Record<string, (s: string) => string>> = {
20
20
  engine: colors.cyan,
21
21
  mqtt: colors.green,
22
- opcua: colors.green,
22
+ opcua: colors.blue,
23
23
  config: colors.yellow,
24
24
  lifecycle: colors.magenta,
25
25
  fault: colors.yellowBright,