miravo 0.4.0 → 0.6.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.
Files changed (161) hide show
  1. package/README.md +1 -1
  2. package/console-dist/assets/activity-DsWeRq4h.js +1 -0
  3. package/console-dist/assets/api-4AqKvsm9.js +1 -0
  4. package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
  5. package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
  6. package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
  7. package/console-dist/assets/box-C1WaoOMF.js +1 -0
  8. package/console-dist/assets/card-BjeP32PN.js +1 -0
  9. package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
  10. package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
  11. package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
  12. package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
  13. package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
  14. package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
  15. package/console-dist/assets/control-CPhvwx9o.js +1 -0
  16. package/console-dist/assets/control-Cg2up9NA.js +1 -0
  17. package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
  18. package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
  19. package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
  20. package/console-dist/assets/dist-BhbDML0a.js +1 -0
  21. package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
  22. package/console-dist/assets/download-DQ6NH16U.js +1 -0
  23. package/console-dist/assets/engine-store-CrLnz692.js +1 -0
  24. package/console-dist/assets/events-CXm29Ok4.js +1 -0
  25. package/console-dist/assets/events-D01IxhnP.js +1 -0
  26. package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
  27. package/console-dist/assets/fleet-lotMGVqe.js +1 -0
  28. package/console-dist/assets/format-kXTonpDJ.js +1 -0
  29. package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
  30. package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
  31. package/console-dist/assets/index-CVPKSDvJ.js +12 -0
  32. package/console-dist/assets/index-DJkgDeun.css +2 -0
  33. package/console-dist/assets/input-BWEQFptx.js +1 -0
  34. package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
  35. package/console-dist/assets/label-uephX5ZG.js +1 -0
  36. package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
  37. package/console-dist/assets/login-C3RJli0m.js +126 -0
  38. package/console-dist/assets/models-B_UioWQ5.js +1 -0
  39. package/console-dist/assets/models-_IPlPiZ7.js +1 -0
  40. package/console-dist/assets/not-found-B8gAm08S.js +1 -0
  41. package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
  42. package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
  43. package/console-dist/assets/platforms-DStNbtVc.js +1 -0
  44. package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
  45. package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
  46. package/console-dist/assets/radio-CoHegOv5.js +1 -0
  47. package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
  48. package/console-dist/assets/select-CeO4vP0G.js +1 -0
  49. package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
  50. package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
  51. package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
  52. package/console-dist/assets/tags-CiXOIHtY.js +4 -0
  53. package/console-dist/assets/tags-FQCXIxrP.js +1 -0
  54. package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
  55. package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
  56. package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
  57. package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
  58. package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
  59. package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
  60. package/console-dist/assets/validation-Blvud9_O.js +1 -0
  61. package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
  62. package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
  63. package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
  64. package/console-dist/index.html +70 -2
  65. package/package.json +9 -8
  66. package/src/__tests__/integration/executable-methods-runner.ts +7 -6
  67. package/src/__tests__/integration/executable-methods.integration.ts +214 -122
  68. package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
  69. package/src/commands/adapter.ts +180 -7
  70. package/src/commands/apply.test.ts +62 -0
  71. package/src/commands/apply.ts +216 -0
  72. package/src/commands/cleanup.test.ts +34 -0
  73. package/src/commands/cleanup.ts +201 -0
  74. package/src/commands/export.test.ts +51 -0
  75. package/src/commands/export.ts +404 -0
  76. package/src/commands/fork.ts +2 -2
  77. package/src/commands/inject.ts +2 -2
  78. package/src/commands/load.ts +1 -1
  79. package/src/commands/logs.ts +13 -15
  80. package/src/commands/models.test.ts +1 -1
  81. package/src/commands/models.ts +1 -1
  82. package/src/commands/pause.ts +3 -3
  83. package/src/commands/platforms.test.ts +186 -0
  84. package/src/commands/platforms.ts +371 -0
  85. package/src/commands/remove.ts +1 -1
  86. package/src/commands/reset.ts +2 -2
  87. package/src/commands/spawn.ts +2 -2
  88. package/src/commands/speed.ts +2 -2
  89. package/src/commands/start/adapter-config.ts +157 -0
  90. package/src/commands/start/catalog-le-dt.test.ts +50 -0
  91. package/src/commands/start/catalog-le-dt.ts +187 -0
  92. package/src/commands/start/catalog-model-schema.ts +89 -0
  93. package/src/commands/start/catalog-template-detail.ts +54 -0
  94. package/src/commands/start/daemon.ts +255 -0
  95. package/src/commands/start/display.ts +150 -0
  96. package/src/commands/start/engine-setup.ts +247 -0
  97. package/src/commands/start/events.ts +126 -0
  98. package/src/commands/start/index.ts +897 -0
  99. package/src/commands/start/protocol-plans.ts +239 -0
  100. package/src/commands/start/template-loader.ts +197 -0
  101. package/src/commands/start/template-resolver.ts +185 -0
  102. package/src/commands/start/types.ts +73 -0
  103. package/src/commands/start.test.ts +137 -16
  104. package/src/commands/status.ts +1 -1
  105. package/src/commands/stop.ts +19 -8
  106. package/src/commands/templates.test.ts +1 -1
  107. package/src/commands/templates.ts +1 -3
  108. package/src/index.ts +11 -3
  109. package/src/lib/admin-auth.ts +157 -0
  110. package/src/lib/admin-client.test.ts +31 -15
  111. package/src/lib/admin-client.ts +106 -6
  112. package/src/lib/admin-platform-routes.ts +1017 -0
  113. package/src/lib/admin-platform-stream.test.ts +434 -0
  114. package/src/lib/admin-platform-stream.ts +788 -0
  115. package/src/lib/admin-responses.ts +211 -0
  116. package/src/lib/admin-server.test.ts +274 -55
  117. package/src/lib/admin-server.ts +387 -248
  118. package/src/lib/auth-store.ts +87 -0
  119. package/src/lib/daemon-registry.ts +12 -14
  120. package/src/lib/le/le-apply.test.ts +591 -0
  121. package/src/lib/le/le-apply.ts +832 -0
  122. package/src/lib/le/le-auth.ts +89 -0
  123. package/src/lib/le/le-cleanup.test.ts +168 -0
  124. package/src/lib/le/le-cleanup.ts +399 -0
  125. package/src/lib/le/le-cli-helpers.ts +77 -0
  126. package/src/lib/le/le-client.ts +184 -0
  127. package/src/lib/le/le-concurrency.test.ts +157 -0
  128. package/src/lib/le/le-concurrency.ts +78 -0
  129. package/src/lib/le/le-connection-test.ts +42 -0
  130. package/src/lib/le/le-devicehub-properties.test.ts +53 -0
  131. package/src/lib/le/le-devicehub-properties.ts +33 -0
  132. package/src/lib/le/le-devicehub.ts +213 -0
  133. package/src/lib/le/le-digital-twins.ts +285 -0
  134. package/src/lib/le/le-drivers.ts +23 -0
  135. package/src/lib/le/le-endpoint.ts +40 -0
  136. package/src/lib/le/le-environments.test.ts +73 -0
  137. package/src/lib/le/le-environments.ts +156 -0
  138. package/src/lib/le/le-markers.ts +87 -0
  139. package/src/lib/le/le-plan.test.ts +26 -0
  140. package/src/lib/le/le-plan.ts +109 -0
  141. package/src/lib/le/le-platform-operations.test.ts +198 -0
  142. package/src/lib/le/le-platform-operations.ts +340 -0
  143. package/src/lib/le/le-static-model-schema.ts +78 -0
  144. package/src/lib/le/le-static-planner.test.ts +255 -0
  145. package/src/lib/le/le-static-planner.ts +442 -0
  146. package/src/lib/le/le-template-exporter.test.ts +527 -0
  147. package/src/lib/le/le-template-exporter.ts +459 -0
  148. package/src/lib/le/le-value-types.ts +23 -0
  149. package/src/lib/mqtt-client.ts +1 -2
  150. package/src/lib/resolve-simulation.ts +1 -6
  151. package/src/lib/session-store.ts +110 -0
  152. package/src/lib/shutdown.test.ts +184 -2
  153. package/src/lib/shutdown.ts +104 -34
  154. package/src/lib/template-category.ts +3 -3
  155. package/src/lib/template-locator.ts +8 -2
  156. package/src/lib/ws-hub.ts +72 -46
  157. package/src/logger.ts +39 -14
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C3XMYq26.css +0 -2
  160. package/console-dist/assets/index-hUJSpKtB.js +0 -119
  161. package/src/commands/start.ts +0 -2023
@@ -141,6 +141,43 @@ describe("createShutdownController", () => {
141
141
  expect(exitCode).toBe(0);
142
142
  });
143
143
 
144
+ test("SIGTERM during graceful shutdown does not force-exit (parent runner cleanup)", async () => {
145
+ const engine = createMockEngine();
146
+ let exitCode: number | undefined;
147
+ let engineStopped = false;
148
+
149
+ (engine as unknown as Record<string, unknown>)["stop"] = () =>
150
+ new Promise<void>((resolve) =>
151
+ setTimeout(() => {
152
+ engineStopped = true;
153
+ resolve();
154
+ }, 50),
155
+ );
156
+
157
+ const controller = createShutdownController({
158
+ seed: 42,
159
+ seedExplicit: true,
160
+ exit: (code) => {
161
+ exitCode = code;
162
+ },
163
+ });
164
+ teardowns.push(controller.teardown);
165
+ controller.setEngine(engine);
166
+
167
+ process.emit("SIGINT");
168
+ await controller.waitForShutdownRequest();
169
+ const shutdownPromise = controller.runShutdown();
170
+ await new Promise((resolve) => setTimeout(resolve, 10));
171
+ // Simulate bun run / bunx forwarding SIGTERM to the child as the parent exits
172
+ process.emit("SIGTERM");
173
+ await controller.completion;
174
+ await shutdownPromise;
175
+
176
+ // Graceful shutdown must complete: engine stopped, exit(0), not force-stopped
177
+ expect(engineStopped).toBe(true);
178
+ expect(exitCode).toBe(0);
179
+ });
180
+
144
181
  test("second signal during shutdown calls exit(1)", async () => {
145
182
  const engine = createMockEngine();
146
183
  const exitCodes: number[] = [];
@@ -151,6 +188,7 @@ describe("createShutdownController", () => {
151
188
  const controller = createShutdownController({
152
189
  seed: 42,
153
190
  seedExplicit: true,
191
+ sigintCoalesceWindowMs: 20,
154
192
  exit: (code) => {
155
193
  exitCodes.push(code);
156
194
  },
@@ -161,12 +199,86 @@ describe("createShutdownController", () => {
161
199
  process.emit("SIGINT");
162
200
  await controller.waitForShutdownRequest();
163
201
  const shutdownPromise = controller.runShutdown();
164
- await new Promise((resolve) => setTimeout(resolve, 10));
202
+ await new Promise((resolve) => setTimeout(resolve, 40));
165
203
  process.emit("SIGINT");
166
204
  await controller.completion;
167
205
  await shutdownPromise;
168
206
 
169
- expect(exitCodes).toContain(1);
207
+ expect(exitCodes).toEqual([1]);
208
+ });
209
+
210
+ test("duplicate SIGINT from bun run does not force-exit on a single Ctrl+C", async () => {
211
+ const engine = createMockEngine();
212
+ let exitCode: number | undefined;
213
+ let engineStopped = false;
214
+
215
+ (engine as unknown as Record<string, unknown>)["stop"] = () =>
216
+ new Promise<void>((resolve) =>
217
+ setTimeout(() => {
218
+ engineStopped = true;
219
+ resolve();
220
+ }, 50),
221
+ );
222
+
223
+ const controller = createShutdownController({
224
+ seed: 42,
225
+ seedExplicit: true,
226
+ sigintCoalesceWindowMs: 20,
227
+ exit: (code) => {
228
+ exitCode = code;
229
+ },
230
+ });
231
+ teardowns.push(controller.teardown);
232
+ controller.setEngine(engine);
233
+
234
+ process.emit("SIGINT");
235
+ await controller.waitForShutdownRequest();
236
+ const shutdownPromise = controller.runShutdown();
237
+ // Regression: `bun run <script>` forwards a second SIGINT to its child
238
+ // immediately after the terminal already delivered SIGINT to the group.
239
+ process.emit("SIGINT");
240
+ await controller.completion;
241
+ await shutdownPromise;
242
+
243
+ expect(engineStopped).toBe(true);
244
+ expect(exitCode).toBe(0);
245
+ });
246
+
247
+ test("runShutdown() without prior requestShutdown() throws synchronously", () => {
248
+ const controller = createShutdownController({
249
+ seed: 1,
250
+ seedExplicit: true,
251
+ exit: () => {},
252
+ });
253
+ teardowns.push(controller.teardown);
254
+
255
+ expect(() => controller.runShutdown()).toThrow(
256
+ "Shutdown was not requested before runShutdown()",
257
+ );
258
+ });
259
+
260
+ test("duplicate requestShutdown before runShutdown is deduplicated (no force-exit)", async () => {
261
+ const engine = createMockEngine();
262
+ const exitCodes: number[] = [];
263
+
264
+ const controller = createShutdownController({
265
+ seed: 1,
266
+ seedExplicit: true,
267
+ exit: (code) => {
268
+ exitCodes.push(code);
269
+ },
270
+ });
271
+ teardowns.push(controller.teardown);
272
+ controller.setEngine(engine);
273
+
274
+ // First request via signal, second via direct API (simulating admin /shutdown race with SIGINT)
275
+ process.emit("SIGINT");
276
+ await controller.waitForShutdownRequest();
277
+ controller.requestShutdown("admin api"); // second call before runShutdown starts
278
+ await controller.runShutdown();
279
+
280
+ expect(exitCodes).toEqual([0]);
281
+ expect(engine.stopCalled).toBe(true);
170
282
  });
171
283
 
172
284
  test("shutdown request is inert until the owner runs shutdown", async () => {
@@ -195,6 +307,76 @@ describe("createShutdownController", () => {
195
307
  expect(stopCallCount).toBe(1);
196
308
  });
197
309
 
310
+ test("task that hangs past timeout: warns and shutdown continues", async () => {
311
+ const engine = createMockEngine();
312
+ const steps: string[] = [];
313
+ let exitCode: number | undefined;
314
+
315
+ const controller = createShutdownController({
316
+ seed: 1,
317
+ seedExplicit: true,
318
+ taskTimeoutMs: 30,
319
+ exit: (code) => {
320
+ exitCode = code;
321
+ },
322
+ });
323
+ teardowns.push(controller.teardown);
324
+ controller.setEngine(engine);
325
+
326
+ controller.addTask("cleanup", "hanging task", () => new Promise<void>(() => {}));
327
+ controller.addTask("finalize", "post-timeout step", () => {
328
+ steps.push("finalize ran");
329
+ });
330
+
331
+ process.emit("SIGINT");
332
+ await controller.waitForShutdownRequest();
333
+ await controller.runShutdown();
334
+
335
+ expect(steps).toEqual(["finalize ran"]);
336
+ expect(exitCode).toBe(0);
337
+ });
338
+
339
+ test("task that rejects after timeout does not cause unhandled rejection", async () => {
340
+ const engine = createMockEngine();
341
+ const unhandledReasons: unknown[] = [];
342
+ const onUnhandled = (reason: unknown) => {
343
+ unhandledReasons.push(reason);
344
+ };
345
+ process.on("unhandledRejection", onUnhandled);
346
+
347
+ try {
348
+ const controller = createShutdownController({
349
+ seed: 1,
350
+ seedExplicit: true,
351
+ taskTimeoutMs: 30,
352
+ exit: () => {},
353
+ });
354
+ teardowns.push(controller.teardown);
355
+ controller.setEngine(engine);
356
+
357
+ let rejectLate!: (err: Error) => void;
358
+ controller.addTask(
359
+ "cleanup",
360
+ "slow-then-reject",
361
+ () =>
362
+ new Promise<void>((_, reject) => {
363
+ rejectLate = reject;
364
+ }),
365
+ );
366
+
367
+ process.emit("SIGINT");
368
+ await controller.waitForShutdownRequest();
369
+ await controller.runShutdown(); // task times out after 30ms
370
+
371
+ rejectLate(new Error("late rejection after timeout"));
372
+ await new Promise((resolve) => setTimeout(resolve, 50));
373
+ } finally {
374
+ process.removeListener("unhandledRejection", onUnhandled);
375
+ }
376
+
377
+ expect(unhandledReasons).toHaveLength(0);
378
+ });
379
+
198
380
  test("teardown removes signal handlers", async () => {
199
381
  const engine = createMockEngine();
200
382
 
@@ -2,14 +2,23 @@ import type { Engine, EngineMetricsSnapshot } from "@miravo/core";
2
2
  import type { AdapterHealthSnapshot } from "@miravo/shared";
3
3
  import type { TreeItem } from "consola/utils";
4
4
  import { box, colors, formatTree, stripAnsi } from "consola/utils";
5
- import { engineLog, isJsonLogFormat, isPlain, isQuiet, mqttLog, opcuaLog } from "../logger.ts";
6
- import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
5
+ import {
6
+ adapterLogger,
7
+ engineLog,
8
+ isJsonLogFormat,
9
+ isPlain,
10
+ isQuiet,
11
+ writeOutput,
12
+ } from "../logger.ts";
13
+ import { computeAverageThroughput, formatCount, formatDuration, sep } from "../output.ts";
7
14
 
8
15
  const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
9
16
  const FINAL_OUTPUT_GRACE_MS = 50;
17
+ const FORWARDED_SIGINT_COALESCE_WINDOW_MS = 100;
10
18
 
11
19
  type ShutdownTaskStage = "cleanup" | "finalize";
12
20
  type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
21
+ type ShutdownRequestSource = "api" | "sigint" | "sigterm";
13
22
 
14
23
  interface ShutdownTask {
15
24
  readonly name: string;
@@ -17,7 +26,7 @@ interface ShutdownTask {
17
26
  }
18
27
 
19
28
  /** Parameters for the shutdown controller. Supports dependency injection for tests. */
20
- export interface ShutdownControllerOptions {
29
+ interface ShutdownControllerOptions {
21
30
  /** The RNG seed used for this run (printed in summary for reproducibility). */
22
31
  readonly seed: number;
23
32
  /** Whether the seed was explicitly configured (vs. randomly generated). */
@@ -28,9 +37,13 @@ export interface ShutdownControllerOptions {
28
37
  readonly exit?: (code: number) => void;
29
38
  /** Optional writer override for the final shutdown summary render. */
30
39
  readonly writeFinalOutput?: (text: string) => Promise<void>;
40
+ /** Per-task timeout in ms. Defaults to SHUTDOWN_TASK_TIMEOUT_MS. Inject a small value in tests. */
41
+ readonly taskTimeoutMs?: number;
42
+ /** Duplicate SIGINT coalescing window in ms. Inject a small value in tests. */
43
+ readonly sigintCoalesceWindowMs?: number;
31
44
  }
32
45
 
33
- export interface ShutdownController {
46
+ interface ShutdownController {
34
47
  readonly completion: Promise<void>;
35
48
  addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
36
49
  isShuttingDown(): boolean;
@@ -54,13 +67,19 @@ export interface ShutdownController {
54
67
  export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
55
68
  const exit = options.exit ?? ((code: number) => process.exit(code));
56
69
  const writeFinalOutput = options.writeFinalOutput ?? writeShutdownOutput;
70
+ const taskTimeoutMs = options.taskTimeoutMs ?? SHUTDOWN_TASK_TIMEOUT_MS;
71
+ const sigintCoalesceWindowMs =
72
+ options.sigintCoalesceWindowMs ?? FORWARDED_SIGINT_COALESCE_WINDOW_MS;
57
73
 
58
74
  let phase: ShutdownPhase = "booting";
59
75
  let forcedExitRequested = false;
60
76
  let engine: Engine | null = null;
61
77
  let errorContextCounts = options.errorContextCounts;
62
78
  let requestedReason: string | null = null;
79
+ let requestSource: ShutdownRequestSource | null = null;
63
80
  let shutdownPromise: Promise<void> | null = null;
81
+ let requestedAtMs = 0;
82
+ let forwardedSigintIgnored = false;
64
83
 
65
84
  const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
66
85
  cleanup: [],
@@ -77,9 +96,31 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
77
96
  });
78
97
 
79
98
  const keepAlive = setInterval(() => {}, 60_000);
99
+ const shouldIgnoreForwardedSigint = (): boolean => {
100
+ if (requestSource !== "sigint" || forwardedSigintIgnored) {
101
+ return false;
102
+ }
103
+ const elapsedMs = performance.now() - requestedAtMs;
104
+ if (elapsedMs > sigintCoalesceWindowMs) {
105
+ return false;
106
+ }
107
+ forwardedSigintIgnored = true;
108
+ engineLog.debug(`Ignoring duplicate SIGINT ${elapsedMs.toFixed(1)}ms after shutdown request.`);
109
+ return true;
110
+ };
111
+
80
112
  const forceSignalHandler = (): void => {
113
+ if (shouldIgnoreForwardedSigint()) {
114
+ return;
115
+ }
81
116
  forceExit();
82
117
  };
118
+ // Absorbs SIGTERM during graceful shutdown without terminating.
119
+ // bun run / bunx forwards SIGTERM to the child process when the parent
120
+ // runner receives SIGINT and exits — leaving SIGTERM unhandled would restore
121
+ // the OS default (abrupt termination). This no-op keeps the process alive
122
+ // long enough for cleanup tasks and engine.stop() to complete.
123
+ const absorbSigterm = (): void => {};
83
124
 
84
125
  const forceExit = (): void => {
85
126
  if (phase === "forced_exit" || phase === "exited") return;
@@ -93,14 +134,18 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
93
134
 
94
135
  const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
95
136
  let timer: ReturnType<typeof setTimeout> | null = null;
137
+ // Declared outside try so finally can suppress late rejections on timeout.
138
+ // Remains null if run() throws synchronously — no dangling promise in that case.
139
+ let runPromise: Promise<void> | null = null;
96
140
  try {
141
+ runPromise = Promise.resolve(run()); // sync throws are caught by this try block
97
142
  await Promise.race([
98
- Promise.resolve(run()),
143
+ runPromise,
99
144
  new Promise<void>((resolve) => {
100
145
  timer = setTimeout(() => {
101
- engineLog.warn(`${name} timed out after ${SHUTDOWN_TASK_TIMEOUT_MS / 1000}s.`);
146
+ engineLog.warn(`${name} timed out after ${taskTimeoutMs / 1000}s.`);
102
147
  resolve();
103
- }, SHUTDOWN_TASK_TIMEOUT_MS);
148
+ }, taskTimeoutMs);
104
149
  }),
105
150
  ]);
106
151
  } catch (error) {
@@ -109,6 +154,10 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
109
154
  if (timer !== null) {
110
155
  clearTimeout(timer);
111
156
  }
157
+ // If the timeout fired first, runPromise is still pending. Attach a no-op
158
+ // catch so a future rejection from the dangling task does not become an
159
+ // unhandled-rejection crash.
160
+ runPromise?.catch(() => {});
112
161
  }
113
162
  };
114
163
 
@@ -165,27 +214,52 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
165
214
  const armForceExitHandlers = (): void => {
166
215
  process.removeListener("SIGINT", sigintHandler);
167
216
  process.removeListener("SIGTERM", sigtermHandler);
168
- process.once("SIGINT", forceSignalHandler);
169
- process.once("SIGTERM", forceSignalHandler);
217
+ // Use process.on (not once): under `bun run <script>` a single Ctrl+C sends
218
+ // SIGINT to the entire foreground process group, then Bun forwards the same
219
+ // SIGINT to its child process as well. The first duplicate is coalesced
220
+ // inside forceSignalHandler(); later SIGINTs still force-exit.
221
+ process.on("SIGINT", forceSignalHandler);
222
+ // SIGTERM is absorbed (no-op) rather than re-armed as a force handler.
223
+ // bun run / bunx forwards SIGTERM to the child process when the parent
224
+ // runner exits from its own SIGINT — leaving SIGTERM unhandled would
225
+ // restore the OS default (abrupt kill), so we absorb it instead and let
226
+ // cleanup tasks finish. A second Ctrl+C (SIGINT) still force-stops.
227
+ // Use process.on (not once): after the first SIGTERM is absorbed the listener
228
+ // must remain registered. If it were once, a second OS-level SIGTERM would
229
+ // find no handler and revert to the OS default (abrupt termination).
230
+ // teardown() removes it when shutdown completes.
231
+ process.on("SIGTERM", absorbSigterm);
170
232
  };
171
233
 
172
- const requestShutdown = (reason: string): void => {
234
+ const requestShutdownInternal = (reason: string, source: ShutdownRequestSource): void => {
173
235
  if (phase === "forced_exit" || phase === "exited") return;
174
236
  if (requestedReason !== null) {
175
- forceExit();
237
+ // Only force-exit if shutdown is actively running. A duplicate request
238
+ // that arrives before runShutdown() starts (e.g. admin /shutdown racing
239
+ // with SIGINT) is silently deduplicated — first caller wins.
240
+ if (phase === "shutting_down") {
241
+ forceExit();
242
+ }
176
243
  return;
177
244
  }
178
245
  requestedReason = reason;
246
+ requestSource = source;
247
+ requestedAtMs = performance.now();
248
+ forwardedSigintIgnored = false;
179
249
  resolveRequest(reason);
180
250
  armForceExitHandlers();
181
251
  };
182
252
 
253
+ const requestShutdown = (reason: string): void => {
254
+ requestShutdownInternal(reason, "api");
255
+ };
256
+
183
257
  const sigintHandler = (): void => {
184
- requestShutdown("user interrupt");
258
+ requestShutdownInternal("user interrupt", "sigint");
185
259
  };
186
260
 
187
261
  const sigtermHandler = (): void => {
188
- requestShutdown("SIGTERM");
262
+ requestShutdownInternal("SIGTERM", "sigterm");
189
263
  };
190
264
 
191
265
  process.once("SIGINT", sigintHandler);
@@ -225,7 +299,7 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
225
299
  process.removeListener("SIGINT", sigintHandler);
226
300
  process.removeListener("SIGTERM", sigtermHandler);
227
301
  process.removeListener("SIGINT", forceSignalHandler);
228
- process.removeListener("SIGTERM", forceSignalHandler);
302
+ process.removeListener("SIGTERM", absorbSigterm);
229
303
  clearInterval(keepAlive);
230
304
  },
231
305
  waitForShutdownRequest() {
@@ -238,8 +312,6 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
238
312
  // Shared helpers
239
313
  // ---------------------------------------------------------------------------
240
314
 
241
- const sep = colors.dim(" \u00B7 ");
242
-
243
315
  interface SummaryInput {
244
316
  readonly metrics: EngineMetricsSnapshot;
245
317
  readonly seed?: number | undefined;
@@ -255,17 +327,16 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
255
327
  return colors.yellow(state);
256
328
  case "saturated":
257
329
  return colors.red(state);
330
+ default: {
331
+ const _exhaustive: never = state;
332
+ return _exhaustive;
333
+ }
258
334
  }
259
335
  }
260
336
 
261
- function adapterLogger(name: string) {
262
- if (name === "mqtt") return mqttLog;
263
- if (name === "opcua") return opcuaLog;
264
- return engineLog;
265
- }
266
-
267
337
  function adapterLabel(name: string): string {
268
338
  if (name === "opcua") return "OPC UA";
339
+ if (name === "modbus") return "Modbus";
269
340
  return name.toUpperCase();
270
341
  }
271
342
 
@@ -274,16 +345,14 @@ function formatAdapterLine(
274
345
  m: EngineMetricsSnapshot["adapters"][number]["metrics"],
275
346
  uptimeMs: number,
276
347
  ): 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));
348
+ const connStr = m.connected ? colors.green("✓") : colors.red("✗");
349
+ const verb = name === "opcua" || name === "modbus" ? "updates" : "published";
282
350
  const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
283
351
  const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
284
- const verb = name === "opcua" ? "updates" : "published";
352
+ const errStr =
353
+ m.errorsCount > 0 ? `${sep}${colors.yellow(formatCount(m.errorsCount))} errors` : "";
285
354
  const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
286
- return `${connStr}${sep}${formatCount(m.messagesPublished)} ${verb}${sep}${errStr} errors${throughputStr}${health}`;
355
+ return `${connStr} ${formatCount(m.messagesPublished)} ${verb}${throughputStr}${errStr}${health}`;
287
356
  }
288
357
 
289
358
  // ---------------------------------------------------------------------------
@@ -301,14 +370,15 @@ export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
301
370
  return;
302
371
  }
303
372
 
373
+ // Blank line before each summary block to visually separate it from event lines
374
+ writeOutput();
375
+
304
376
  const { core, fleet, adapters } = metrics;
305
377
 
306
- // Core stats
378
+ // Core stats — omit tick count (redundant with rate) and instance count (static, already in banner)
307
379
  const parts = [
308
380
  formatDuration(core.uptimeMs),
309
- `${formatCount(core.tickCount)} ticks`,
310
381
  `${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
311
- `${formatCount(core.instanceCount)} instances`,
312
382
  `${formatCount(core.memberValueCount)} data points`,
313
383
  ];
314
384
  if (fleet.activeFaultCount > 0) {
@@ -351,7 +421,7 @@ function buildSummaryLines(input: SummaryInput): string[] {
351
421
  : colors.yellow(formatCount(m.errorsCount));
352
422
  const avgThroughput = computeAverageThroughput(m.messagesPublished, core.uptimeMs);
353
423
  const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
354
- const verb = adapter.name === "opcua" ? "updates" : "published";
424
+ const verb = adapter.name === "opcua" || adapter.name === "modbus" ? "updates" : "published";
355
425
  lines.push(
356
426
  `${label}: ${connStr}${sep}${colors.bold(formatCount(m.messagesPublished))} ${verb}${sep}${errStr} errors${throughputStr}`,
357
427
  );
@@ -547,5 +617,5 @@ async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<
547
617
 
548
618
  async function flushOutputStreams(): Promise<void> {
549
619
  await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
550
- await new Promise<void>((resolve) => setTimeout(resolve, FINAL_OUTPUT_GRACE_MS));
620
+ await Bun.sleep(FINAL_OUTPUT_GRACE_MS);
551
621
  }
@@ -1,6 +1,6 @@
1
1
  import type { DiscoveredTemplate } from "@miravo/shared";
2
2
 
3
- export type TemplateCategory = "Industrial Scenarios" | "Stress & Benchmark";
3
+ type TemplateCategory = "Industrial Scenarios" | "Stress & Benchmark";
4
4
 
5
5
  const CATEGORY_ORDER: readonly TemplateCategory[] = ["Industrial Scenarios", "Stress & Benchmark"];
6
6
 
@@ -16,7 +16,7 @@ export function categorizeTemplate(template: Pick<DiscoveredTemplate, "id">): Te
16
16
  return "Industrial Scenarios";
17
17
  }
18
18
 
19
- export function compareTemplatesByCategoryAndName(
19
+ function compareTemplatesByCategoryAndName(
20
20
  a: Pick<DiscoveredTemplate, "id" | "name">,
21
21
  b: Pick<DiscoveredTemplate, "id" | "name">,
22
22
  ): number {
@@ -39,7 +39,7 @@ export function sortTemplatesByCategoryAndName<T extends Pick<DiscoveredTemplate
39
39
  return [...templates].sort(compareTemplatesByCategoryAndName);
40
40
  }
41
41
 
42
- export interface TemplateCategoryGroup<T extends Pick<DiscoveredTemplate, "id" | "name">> {
42
+ interface TemplateCategoryGroup<T extends Pick<DiscoveredTemplate, "id" | "name">> {
43
43
  readonly category: TemplateCategory;
44
44
  readonly templates: readonly T[];
45
45
  }
@@ -5,9 +5,15 @@ import { MiravoError, TEMPLATE_FILE_EXTENSION } from "@miravo/shared";
5
5
  /**
6
6
  * Create a ContentCatalog with default settings.
7
7
  * Shared across all CLI commands to ensure consistent resolution.
8
+ *
9
+ * @param opts.extraModelsDir - Optional extra directory scanned for .twin.yaml files
10
+ * before the built-in catalog. Useful when template files live in a sibling
11
+ * directory not on the default discovery path (e.g. --models-dir ./models).
8
12
  */
9
- export function createCatalog(): ContentCatalog {
10
- return new ContentCatalog();
13
+ export function createCatalog(opts: { extraModelsDir?: string } = {}): ContentCatalog {
14
+ return new ContentCatalog(
15
+ opts.extraModelsDir ? { extraModelsDirs: [resolve(opts.extraModelsDir)] } : undefined,
16
+ );
11
17
  }
12
18
 
13
19
  export function isTemplatePathLike(idNameOrPath: string): boolean {