miravo 0.5.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 -9
  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 +20 -21
  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 +96 -36
  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 +18 -10
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C1oOHU1J.js +0 -119
  160. package/console-dist/assets/index-C3XMYq26.css +0 -2
  161. package/src/commands/start.ts +0 -2253
@@ -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
 
@@ -3,21 +3,22 @@ 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
5
  import {
6
+ adapterLogger,
6
7
  engineLog,
7
8
  isJsonLogFormat,
8
9
  isPlain,
9
10
  isQuiet,
10
- modbusLog,
11
- mqttLog,
12
- opcuaLog,
11
+ writeOutput,
13
12
  } from "../logger.ts";
14
- import { computeAverageThroughput, formatCount, formatDuration } from "../output.ts";
13
+ import { computeAverageThroughput, formatCount, formatDuration, sep } from "../output.ts";
15
14
 
16
15
  const SHUTDOWN_TASK_TIMEOUT_MS = 6_000;
17
16
  const FINAL_OUTPUT_GRACE_MS = 50;
17
+ const FORWARDED_SIGINT_COALESCE_WINDOW_MS = 100;
18
18
 
19
19
  type ShutdownTaskStage = "cleanup" | "finalize";
20
20
  type ShutdownPhase = "booting" | "running" | "shutting_down" | "forced_exit" | "exited";
21
+ type ShutdownRequestSource = "api" | "sigint" | "sigterm";
21
22
 
22
23
  interface ShutdownTask {
23
24
  readonly name: string;
@@ -25,7 +26,7 @@ interface ShutdownTask {
25
26
  }
26
27
 
27
28
  /** Parameters for the shutdown controller. Supports dependency injection for tests. */
28
- export interface ShutdownControllerOptions {
29
+ interface ShutdownControllerOptions {
29
30
  /** The RNG seed used for this run (printed in summary for reproducibility). */
30
31
  readonly seed: number;
31
32
  /** Whether the seed was explicitly configured (vs. randomly generated). */
@@ -36,9 +37,13 @@ export interface ShutdownControllerOptions {
36
37
  readonly exit?: (code: number) => void;
37
38
  /** Optional writer override for the final shutdown summary render. */
38
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;
39
44
  }
40
45
 
41
- export interface ShutdownController {
46
+ interface ShutdownController {
42
47
  readonly completion: Promise<void>;
43
48
  addTask(stage: ShutdownTaskStage, name: string, run: () => void | Promise<void>): void;
44
49
  isShuttingDown(): boolean;
@@ -62,13 +67,19 @@ export interface ShutdownController {
62
67
  export function createShutdownController(options: ShutdownControllerOptions): ShutdownController {
63
68
  const exit = options.exit ?? ((code: number) => process.exit(code));
64
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;
65
73
 
66
74
  let phase: ShutdownPhase = "booting";
67
75
  let forcedExitRequested = false;
68
76
  let engine: Engine | null = null;
69
77
  let errorContextCounts = options.errorContextCounts;
70
78
  let requestedReason: string | null = null;
79
+ let requestSource: ShutdownRequestSource | null = null;
71
80
  let shutdownPromise: Promise<void> | null = null;
81
+ let requestedAtMs = 0;
82
+ let forwardedSigintIgnored = false;
72
83
 
73
84
  const tasks: Record<ShutdownTaskStage, ShutdownTask[]> = {
74
85
  cleanup: [],
@@ -85,9 +96,31 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
85
96
  });
86
97
 
87
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
+
88
112
  const forceSignalHandler = (): void => {
113
+ if (shouldIgnoreForwardedSigint()) {
114
+ return;
115
+ }
89
116
  forceExit();
90
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 => {};
91
124
 
92
125
  const forceExit = (): void => {
93
126
  if (phase === "forced_exit" || phase === "exited") return;
@@ -101,14 +134,18 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
101
134
 
102
135
  const runWithTimeout = async (name: string, run: () => void | Promise<void>): Promise<void> => {
103
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;
104
140
  try {
141
+ runPromise = Promise.resolve(run()); // sync throws are caught by this try block
105
142
  await Promise.race([
106
- Promise.resolve(run()),
143
+ runPromise,
107
144
  new Promise<void>((resolve) => {
108
145
  timer = setTimeout(() => {
109
- engineLog.warn(`${name} timed out after ${SHUTDOWN_TASK_TIMEOUT_MS / 1000}s.`);
146
+ engineLog.warn(`${name} timed out after ${taskTimeoutMs / 1000}s.`);
110
147
  resolve();
111
- }, SHUTDOWN_TASK_TIMEOUT_MS);
148
+ }, taskTimeoutMs);
112
149
  }),
113
150
  ]);
114
151
  } catch (error) {
@@ -117,6 +154,10 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
117
154
  if (timer !== null) {
118
155
  clearTimeout(timer);
119
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(() => {});
120
161
  }
121
162
  };
122
163
 
@@ -173,27 +214,52 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
173
214
  const armForceExitHandlers = (): void => {
174
215
  process.removeListener("SIGINT", sigintHandler);
175
216
  process.removeListener("SIGTERM", sigtermHandler);
176
- process.once("SIGINT", forceSignalHandler);
177
- 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);
178
232
  };
179
233
 
180
- const requestShutdown = (reason: string): void => {
234
+ const requestShutdownInternal = (reason: string, source: ShutdownRequestSource): void => {
181
235
  if (phase === "forced_exit" || phase === "exited") return;
182
236
  if (requestedReason !== null) {
183
- 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
+ }
184
243
  return;
185
244
  }
186
245
  requestedReason = reason;
246
+ requestSource = source;
247
+ requestedAtMs = performance.now();
248
+ forwardedSigintIgnored = false;
187
249
  resolveRequest(reason);
188
250
  armForceExitHandlers();
189
251
  };
190
252
 
253
+ const requestShutdown = (reason: string): void => {
254
+ requestShutdownInternal(reason, "api");
255
+ };
256
+
191
257
  const sigintHandler = (): void => {
192
- requestShutdown("user interrupt");
258
+ requestShutdownInternal("user interrupt", "sigint");
193
259
  };
194
260
 
195
261
  const sigtermHandler = (): void => {
196
- requestShutdown("SIGTERM");
262
+ requestShutdownInternal("SIGTERM", "sigterm");
197
263
  };
198
264
 
199
265
  process.once("SIGINT", sigintHandler);
@@ -233,7 +299,7 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
233
299
  process.removeListener("SIGINT", sigintHandler);
234
300
  process.removeListener("SIGTERM", sigtermHandler);
235
301
  process.removeListener("SIGINT", forceSignalHandler);
236
- process.removeListener("SIGTERM", forceSignalHandler);
302
+ process.removeListener("SIGTERM", absorbSigterm);
237
303
  clearInterval(keepAlive);
238
304
  },
239
305
  waitForShutdownRequest() {
@@ -246,8 +312,6 @@ export function createShutdownController(options: ShutdownControllerOptions): Sh
246
312
  // Shared helpers
247
313
  // ---------------------------------------------------------------------------
248
314
 
249
- const sep = colors.dim(" \u00B7 ");
250
-
251
315
  interface SummaryInput {
252
316
  readonly metrics: EngineMetricsSnapshot;
253
317
  readonly seed?: number | undefined;
@@ -263,16 +327,13 @@ function healthStateLabel(state: AdapterHealthSnapshot["state"]): string {
263
327
  return colors.yellow(state);
264
328
  case "saturated":
265
329
  return colors.red(state);
330
+ default: {
331
+ const _exhaustive: never = state;
332
+ return _exhaustive;
333
+ }
266
334
  }
267
335
  }
268
336
 
269
- function adapterLogger(name: string) {
270
- if (name === "mqtt") return mqttLog;
271
- if (name === "opcua") return opcuaLog;
272
- if (name === "modbus") return modbusLog;
273
- return engineLog;
274
- }
275
-
276
337
  function adapterLabel(name: string): string {
277
338
  if (name === "opcua") return "OPC UA";
278
339
  if (name === "modbus") return "Modbus";
@@ -284,16 +345,14 @@ function formatAdapterLine(
284
345
  m: EngineMetricsSnapshot["adapters"][number]["metrics"],
285
346
  uptimeMs: number,
286
347
  ): string {
287
- const connStr = m.connected ? colors.green("connected") : colors.red("disconnected");
288
- const errStr =
289
- m.errorsCount === 0
290
- ? colors.green(formatCount(m.errorsCount))
291
- : colors.yellow(formatCount(m.errorsCount));
348
+ const connStr = m.connected ? colors.green("✓") : colors.red("✗");
349
+ const verb = name === "opcua" || name === "modbus" ? "updates" : "published";
292
350
  const avgThroughput = computeAverageThroughput(m.messagesPublished, uptimeMs);
293
351
  const throughputStr = avgThroughput !== null ? `${sep}${formatCount(avgThroughput)} msg/s` : "";
294
- const verb = name === "opcua" || name === "modbus" ? "updates" : "published";
352
+ const errStr =
353
+ m.errorsCount > 0 ? `${sep}${colors.yellow(formatCount(m.errorsCount))} errors` : "";
295
354
  const health = m.health ? `${sep}${healthStateLabel(m.health.state)}` : "";
296
- return `${connStr}${sep}${formatCount(m.messagesPublished)} ${verb}${sep}${errStr} errors${throughputStr}${health}`;
355
+ return `${connStr} ${formatCount(m.messagesPublished)} ${verb}${throughputStr}${errStr}${health}`;
297
356
  }
298
357
 
299
358
  // ---------------------------------------------------------------------------
@@ -311,14 +370,15 @@ export function printSummaryLine(metrics: EngineMetricsSnapshot): void {
311
370
  return;
312
371
  }
313
372
 
373
+ // Blank line before each summary block to visually separate it from event lines
374
+ writeOutput();
375
+
314
376
  const { core, fleet, adapters } = metrics;
315
377
 
316
- // Core stats
378
+ // Core stats — omit tick count (redundant with rate) and instance count (static, already in banner)
317
379
  const parts = [
318
380
  formatDuration(core.uptimeMs),
319
- `${formatCount(core.tickCount)} ticks`,
320
381
  `${core.actualTicksPerSecond.toFixed(1)} ticks/s`,
321
- `${formatCount(core.instanceCount)} instances`,
322
382
  `${formatCount(core.memberValueCount)} data points`,
323
383
  ];
324
384
  if (fleet.activeFaultCount > 0) {
@@ -557,5 +617,5 @@ async function writeToStream(stream: NodeJS.WriteStream, text: string): Promise<
557
617
 
558
618
  async function flushOutputStreams(): Promise<void> {
559
619
  await Promise.all([writeToStream(process.stdout, ""), writeToStream(process.stderr, "")]);
560
- await new Promise<void>((resolve) => setTimeout(resolve, FINAL_OUTPUT_GRACE_MS));
620
+ await Bun.sleep(FINAL_OUTPUT_GRACE_MS);
561
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 {