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
@@ -0,0 +1,897 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { ControlPlane, Engine } from "@miravo/core";
4
+ import { createControlPlane } from "@miravo/core";
5
+ import { ModbusAdapter } from "@miravo/modbus";
6
+ import { MQTTAdapter } from "@miravo/mqtt";
7
+ import { OPCUAAdapter } from "@miravo/opcua";
8
+ import { ConfigValidationError, MiravoError } from "@miravo/shared";
9
+ import type { Command } from "commander";
10
+ import { colors } from "consola/utils";
11
+ import { startAdminServer } from "../../lib/admin-server.ts";
12
+ import { resolveAdminPassword, saveCredential } from "../../lib/auth-store.ts";
13
+ import { buildDesiredStateFromTemplate } from "../../lib/le/le-static-planner.ts";
14
+ import { buildLeExport } from "../../lib/le/le-template-exporter.ts";
15
+ import {
16
+ parseDurationMs,
17
+ parseNonNegativeInt,
18
+ parsePort,
19
+ parsePositiveFloat,
20
+ parsePositiveInt,
21
+ } from "../../lib/parse.ts";
22
+ import { createSessionStore } from "../../lib/session-store.ts";
23
+ import { createShutdownController } from "../../lib/shutdown.ts";
24
+ import { createCatalog, resolveTemplateIdentifier } from "../../lib/template-locator.ts";
25
+ import {
26
+ adapterLogger,
27
+ clearRuntimeLogContext,
28
+ configLog,
29
+ controlLog,
30
+ engineLog,
31
+ handleCommandError,
32
+ setRuntimeLogContext,
33
+ } from "../../logger.ts";
34
+ import {
35
+ buildModbusDefaultConfig,
36
+ buildModbusEndpoint,
37
+ buildMQTTDefaultConfig,
38
+ buildMQTTEndpoint,
39
+ buildOPCUADefaultConfig,
40
+ buildOPCUAEndpoint,
41
+ parseMqttModeOption,
42
+ } from "./adapter-config.ts";
43
+ import { buildLeDtJsonFromSchema } from "./catalog-le-dt.ts";
44
+ import { buildModelSchemaFromYaml } from "./catalog-model-schema.ts";
45
+ import { buildTemplateDetail } from "./catalog-template-detail.ts";
46
+ import { detachAction } from "./daemon.ts";
47
+ import { printDryRunSummary, printStartupBanner } from "./display.ts";
48
+ import {
49
+ createAndPopulateEngine,
50
+ loadAndValidateTemplate,
51
+ resolveSimulationName,
52
+ } from "./engine-setup.ts";
53
+ import { startPeriodicSummary, subscribeToEvents } from "./events.ts";
54
+ import { resolveDetachAdapterConfigs } from "./protocol-plans.ts";
55
+ import { buildTemplateCacheAndCatalog, createRuntimeTemplateLoader } from "./template-loader.ts";
56
+ import {
57
+ peekSingleStateTemplatePath,
58
+ peekStateTemplatePath,
59
+ resolveTemplatePath,
60
+ } from "./template-resolver.ts";
61
+ import type { MqttRunOverrides, RestoreSummary, StartOptions } from "./types.ts";
62
+
63
+ export { resolveSimulationName } from "./engine-setup.ts";
64
+ export { incrementErrorContextCount, normalizeErrorContext } from "./events.ts";
65
+ export { resolveDetachAdapterConfigs, resolveProtocolBootPlans } from "./protocol-plans.ts";
66
+ export { buildBuiltinTemplateChoices } from "./template-resolver.ts";
67
+
68
+ /** Resolve the path to the built console dist directory. Returns undefined if not found. */
69
+ function resolveConsoleDir(): string | undefined {
70
+ // 1. Compiled standalone binary: Bun.main resolves to a /$bunfs/ virtual path.
71
+ // Look for console-dist/ packaged alongside the binary.
72
+ if (Bun.main.startsWith("/$bunfs/")) {
73
+ const binaryDir = new URL(".", `file://${process.execPath}`).pathname.replace(/\/$/, "");
74
+ const sibling = `${binaryDir}/console-dist`;
75
+ if (Bun.file(`${sibling}/index.html`).size > 0) return sibling;
76
+ }
77
+
78
+ // 2. Dev / Docker: resolve via workspace package (@miravo/console is a devDependency,
79
+ // available in workspace but not when installed from npm).
80
+ try {
81
+ const resolved = import.meta.resolve("@miravo/console");
82
+ const resolvedDir = new URL(".", resolved).pathname.replace(/\/$/, "");
83
+ if (resolvedDir.endsWith("/dist")) return resolvedDir;
84
+ return `${resolvedDir}/dist`;
85
+ } catch {
86
+ // @miravo/console not installed (npm/bunx) — fall through
87
+ }
88
+
89
+ // 3. Docker / monorepo: workspace symlinks may not exist (production install).
90
+ // index.ts is at packages/cli/src/commands/start/ → ../../../../console/dist
91
+ const monorepo = resolve(import.meta.dir, "..", "..", "..", "..", "console", "dist");
92
+ if (existsSync(`${monorepo}/index.html`)) return monorepo;
93
+
94
+ // 4. npm install / bunx: look for console-dist/ shipped inside the published package.
95
+ // index.ts is at packages/cli/src/commands/start/ → ../../../console-dist/
96
+ const shipped = resolve(import.meta.dir, "..", "..", "..", "console-dist");
97
+ if (existsSync(`${shipped}/index.html`)) return shipped;
98
+
99
+ return undefined;
100
+ }
101
+
102
+ /**
103
+ * Register the `miravo start` command with the root Commander program.
104
+ * Resolves template, creates the engine, starts MQTT, and runs the simulation loop.
105
+ */
106
+ export function registerStartCommand(program: Command): void {
107
+ program
108
+ .command("start")
109
+ .description("Start a simulation from a template")
110
+ .option("-n, --name <name>", "Simulation name (env: MIRAVO_NAME, flag takes precedence)")
111
+ .option(
112
+ "-t, --template <id|name|path,...>",
113
+ "Template id, name, path, or comma-separated list (env: MIRAVO_TEMPLATE, flag takes precedence)",
114
+ )
115
+ .option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
116
+ .option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
117
+ .option("--seed <number>", "RNG seed for deterministic simulation", parseNonNegativeInt)
118
+ .option("--tick <ms>", "Tick interval in milliseconds (default: 1000)", parseDurationMs)
119
+ .option(
120
+ "-p, --mqtt-port <number>",
121
+ "MQTT broker port (default: 1883, env: MIRAVO_MQTT_PORT)",
122
+ parsePort,
123
+ )
124
+ .option("--mqtt-host <host>", "MQTT bind host (default: 0.0.0.0, env: MIRAVO_MQTT_HOST)")
125
+ .option(
126
+ "--mqtt-mode <mode>",
127
+ "MQTT broker mode: embedded (local) | external (remote) (env: MIRAVO_MQTT_MODE)",
128
+ )
129
+ .option("--mqtt-url <url>", "External MQTT broker URL (env: MIRAVO_MQTT_URL)")
130
+ .option("--mqtt-username <username>", "External MQTT username (env: MIRAVO_MQTT_USERNAME)")
131
+ .option("--mqtt-password <password>", "External MQTT password (env: MIRAVO_MQTT_PASSWORD)")
132
+ .option("--no-mqtt", "Disable MQTT broker")
133
+ .option("--opcua", "Enable OPC UA server")
134
+ .option(
135
+ "--opcua-port <number>",
136
+ "OPC UA server port (default: 4840, env: MIRAVO_OPCUA_PORT)",
137
+ parsePort,
138
+ )
139
+ .option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
140
+ .option("--modbus", "Enable Modbus TCP server adapter")
141
+ .option(
142
+ "--modbus-port <number>",
143
+ `Modbus TCP port (default: 502, env: MIRAVO_MODBUS_PORT)`,
144
+ parsePort,
145
+ )
146
+ .option("--modbus-host <host>", "Modbus TCP bind host (env: MIRAVO_MODBUS_HOST)")
147
+ .option("--admin-host <host>", "Admin server host (default: 0.0.0.0, env: MIRAVO_ADMIN_HOST)")
148
+ .option(
149
+ "--admin-port <number>",
150
+ "Admin server port (default: 8080, env: MIRAVO_ADMIN_PORT)",
151
+ parsePort,
152
+ )
153
+ .option("-d, --detach", "Start simulation in background (daemon mode)")
154
+ .option("--dry-run", "Validate config and print summary without starting")
155
+ .option("--no-ui", "Disable embedded admin console")
156
+ .option("--no-auth", "Disable authentication — all requests treated as authenticated")
157
+ .option("--reset-state", "Reset persisted state — start fresh (ignore any saved snapshot)")
158
+ .option(
159
+ "--summary-interval <seconds>",
160
+ "Seconds between periodic summary lines (default: 5)",
161
+ parsePositiveInt,
162
+ )
163
+ .addHelpText(
164
+ "after",
165
+ `
166
+ Examples:
167
+ $ miravo start Start with auto-detected template
168
+ $ miravo start -t water-treatment Start a specific template
169
+ $ miravo start -t smart-factory,water-treatment Start with two templates
170
+ $ miravo start -d --name my-sim Start in background (daemon mode)
171
+ $ miravo start -s 10 --seed 42 Run at 10x speed with fixed seed
172
+ $ miravo start --no-mqtt --opcua Disable MQTT, enable OPC UA
173
+ $ miravo start --dry-run Validate and print summary only`,
174
+ )
175
+ .action(async (options: StartOptions, command: Command) => {
176
+ await startAction({ ...options, command });
177
+ });
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Start action orchestration
182
+ // ---------------------------------------------------------------------------
183
+
184
+ async function startAction(options: StartOptions): Promise<void> {
185
+ let runtimeLogContextSet = false;
186
+ let shutdown: ReturnType<typeof createShutdownController> | null = null;
187
+ let engine: Engine | undefined;
188
+ let stopCliResources = (): void => {};
189
+ try {
190
+ const catalog = createCatalog();
191
+
192
+ // Resolve env-var fallbacks for common options
193
+ const templateOptionRaw = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
194
+ // Support comma-separated template identifiers: first is the boot template, rest loaded after startup
195
+ const templateParts: string[] = templateOptionRaw
196
+ ? templateOptionRaw
197
+ .split(",")
198
+ .map((s) => s.trim())
199
+ .filter(Boolean)
200
+ : [];
201
+ const templateOption: string | undefined = templateParts[0];
202
+ const extraTemplateIdentifiers: string[] = templateParts.slice(1);
203
+ const simulationNameEnv = Bun.env["MIRAVO_NAME"];
204
+ // Protocol selection: CLI flags override, template.protocols used as fallback
205
+ const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
206
+ let opcuaEnabled = options.opcua === true; // --opcua enables
207
+ let modbusEnabled = options.modbus === true; // --modbus enables
208
+
209
+ // Warn on ignored MQTT flags (MQTT status is known at CLI parse time)
210
+ if (!mqttEnabled) {
211
+ if (options.mqttPort !== undefined) {
212
+ configLog.warn("--mqtt-port ignored because MQTT is disabled.");
213
+ }
214
+ if (options.mqttHost !== undefined) {
215
+ configLog.warn("--mqtt-host ignored because MQTT is disabled.");
216
+ }
217
+ if (options.mqttMode !== undefined) {
218
+ configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
219
+ }
220
+ }
221
+ // OPC UA / Modbus warnings are deferred to after template load (template.protocols may auto-enable)
222
+
223
+ const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "0.0.0.0";
224
+ const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
225
+ if (adminHost.trim().length === 0) {
226
+ throw new ConfigValidationError({
227
+ message: "Invalid admin server host: value is empty.",
228
+ fix: "Set --admin-host / MIRAVO_ADMIN_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
229
+ });
230
+ }
231
+
232
+ // MQTT env overrides — only parsed when MQTT is enabled
233
+ let portOption = options.mqttPort;
234
+ let mqttOverrides: MqttRunOverrides = {};
235
+ if (mqttEnabled) {
236
+ const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
237
+ const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
238
+ const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
239
+ const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
240
+ const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
241
+ const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
242
+ // Tuning env vars for adapter performance
243
+ const drainBudgetEnv = Bun.env["MIRAVO_MQTT_DRAIN_BUDGET"];
244
+ const batchSizeEnv = Bun.env["MIRAVO_MQTT_BATCH_SIZE"];
245
+ const brokerConcurrencyEnv = Bun.env["MIRAVO_MQTT_BROKER_CONCURRENCY"];
246
+ const drainBudget = drainBudgetEnv ? parsePositiveInt(drainBudgetEnv) : undefined;
247
+ const batchSize = batchSizeEnv ? parsePositiveInt(batchSizeEnv) : undefined;
248
+ const brokerConcurrency = brokerConcurrencyEnv
249
+ ? parsePositiveInt(brokerConcurrencyEnv)
250
+ : undefined;
251
+ mqttOverrides = {
252
+ ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
253
+ ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
254
+ ...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
255
+ ...(mqttUsername !== undefined && mqttUsername.length > 0
256
+ ? { username: mqttUsername }
257
+ : {}),
258
+ ...(mqttPassword !== undefined && mqttPassword.length > 0
259
+ ? { password: mqttPassword }
260
+ : {}),
261
+ ...(drainBudget !== undefined ? { drainBudget } : {}),
262
+ ...(batchSize !== undefined ? { batchSize } : {}),
263
+ ...(brokerConcurrency !== undefined ? { brokerConcurrency } : {}),
264
+ };
265
+ if (portOption === undefined && portEnv) {
266
+ try {
267
+ portOption = parsePort(portEnv);
268
+ } catch {
269
+ throw new ConfigValidationError({
270
+ message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
271
+ fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
272
+ });
273
+ }
274
+ }
275
+ if (mqttHost !== undefined && mqttHost.trim().length === 0) {
276
+ throw new ConfigValidationError({
277
+ message: "Invalid MQTT bind host: value is empty.",
278
+ fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
279
+ });
280
+ }
281
+ }
282
+
283
+ // OPC UA env overrides — only parsed when OPC UA is enabled
284
+ let opcuaPort: number | undefined = options.opcuaPort;
285
+ let opcuaHost: string | undefined;
286
+ let opcuaWriteBudget: number | undefined;
287
+ if (opcuaEnabled) {
288
+ const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
289
+ if (opcuaPort === undefined && opcuaPortEnv) {
290
+ try {
291
+ opcuaPort = parsePort(opcuaPortEnv);
292
+ } catch {
293
+ throw new ConfigValidationError({
294
+ message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
295
+ fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
296
+ });
297
+ }
298
+ }
299
+ opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
300
+ if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
301
+ throw new ConfigValidationError({
302
+ message: "Invalid OPC UA bind host: value is empty.",
303
+ fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
304
+ });
305
+ }
306
+ const writeBudgetEnv = Bun.env["MIRAVO_OPCUA_WRITE_BUDGET"];
307
+ if (writeBudgetEnv) {
308
+ opcuaWriteBudget = parsePositiveInt(writeBudgetEnv);
309
+ }
310
+ }
311
+
312
+ // Modbus env overrides — only parsed when Modbus is enabled
313
+ let modbusPort: number | undefined = options.modbusPort;
314
+ let modbusHost: string | undefined;
315
+ if (modbusEnabled) {
316
+ const modbusPortEnv = Bun.env["MIRAVO_MODBUS_PORT"];
317
+ if (modbusPort === undefined && modbusPortEnv) {
318
+ try {
319
+ modbusPort = parsePort(modbusPortEnv);
320
+ } catch {
321
+ throw new ConfigValidationError({
322
+ message: `Invalid MIRAVO_MODBUS_PORT environment variable: "${modbusPortEnv}".`,
323
+ fix: "Set MIRAVO_MODBUS_PORT to a valid port number between 0 and 65535.",
324
+ });
325
+ }
326
+ }
327
+ modbusHost = options.modbusHost ?? Bun.env["MIRAVO_MODBUS_HOST"] ?? undefined;
328
+ if (modbusHost !== undefined && modbusHost.trim().length === 0) {
329
+ throw new ConfigValidationError({
330
+ message: "Invalid Modbus TCP bind host: value is empty.",
331
+ fix: "Set --modbus-host / MIRAVO_MODBUS_HOST to a valid host (for example, 0.0.0.0 or 127.0.0.1).",
332
+ });
333
+ }
334
+ }
335
+
336
+ let adminPort = options.adminPort;
337
+ if (adminPort === undefined && adminPortEnv) {
338
+ try {
339
+ adminPort = parsePort(adminPortEnv);
340
+ } catch {
341
+ throw new ConfigValidationError({
342
+ message: `Invalid MIRAVO_ADMIN_PORT environment variable: "${adminPortEnv}".`,
343
+ fix: "Set MIRAVO_ADMIN_PORT to a valid port number between 1 and 65535 (for example, 8080).",
344
+ });
345
+ }
346
+ }
347
+ adminPort ??= 8080;
348
+
349
+ // Step 1: Resolve template path(s)
350
+ // If the simulation name is explicitly known and no template was given,
351
+ // peek at persisted state to recover the template path — skipping the
352
+ // interactive picker on resume.
353
+ let effectiveTemplateOption = templateOption;
354
+ if (!effectiveTemplateOption && !options.resetState) {
355
+ const knownName = options.name ?? simulationNameEnv;
356
+ if (knownName) {
357
+ // Named: peek by explicit simulation name
358
+ const peeked = await peekStateTemplatePath(knownName);
359
+ if (peeked) {
360
+ effectiveTemplateOption = peeked;
361
+ configLog.debug(`Resuming from persisted state: template=${peeked}`);
362
+ }
363
+ } else {
364
+ // Unnamed: scan state dir — auto-resume if exactly one simulation exists
365
+ const peeked = await peekSingleStateTemplatePath();
366
+ if (peeked) {
367
+ effectiveTemplateOption = peeked;
368
+ configLog.debug(`Resuming from persisted state: template=${peeked}`);
369
+ }
370
+ }
371
+ }
372
+ const templatePath = await resolveTemplatePath(effectiveTemplateOption, catalog);
373
+ configLog.debug(`Resolved template: ${templatePath}`);
374
+ // Resolve any additional comma-separated templates upfront so errors surface before engine start
375
+ const extraTemplatePaths = await Promise.all(
376
+ extraTemplateIdentifiers.map((id) => resolveTemplateIdentifier(id, catalog)),
377
+ );
378
+
379
+ // Step 2: Load and validate
380
+ const { template, models, seed, seedExplicit } = await loadAndValidateTemplate(
381
+ templatePath,
382
+ options,
383
+ catalog,
384
+ );
385
+ // Auto-enable protocols declared in template.protocols (unless user explicitly set flags)
386
+ if (template.protocols) {
387
+ if (template.protocols.opcua !== undefined && options.opcua === undefined) {
388
+ opcuaEnabled = true;
389
+ const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
390
+ if (opcuaPort === undefined && opcuaPortEnv) {
391
+ try {
392
+ opcuaPort = parsePort(opcuaPortEnv);
393
+ } catch {
394
+ throw new ConfigValidationError({
395
+ message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
396
+ fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
397
+ });
398
+ }
399
+ }
400
+ opcuaHost = opcuaHost ?? options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
401
+ const writeBudgetEnv = Bun.env["MIRAVO_OPCUA_WRITE_BUDGET"];
402
+ if (writeBudgetEnv && opcuaWriteBudget === undefined) {
403
+ opcuaWriteBudget = parsePositiveInt(writeBudgetEnv);
404
+ }
405
+ }
406
+ if (template.protocols.modbus !== undefined && options.modbus === undefined) {
407
+ modbusEnabled = true;
408
+ const modbusPortEnv = Bun.env["MIRAVO_MODBUS_PORT"];
409
+ if (modbusPort === undefined && modbusPortEnv) {
410
+ try {
411
+ modbusPort = parsePort(modbusPortEnv);
412
+ } catch {
413
+ throw new ConfigValidationError({
414
+ message: `Invalid MIRAVO_MODBUS_PORT environment variable: "${modbusPortEnv}".`,
415
+ fix: "Set MIRAVO_MODBUS_PORT to a valid port number between 0 and 65535.",
416
+ });
417
+ }
418
+ }
419
+ modbusHost = modbusHost ?? options.modbusHost ?? Bun.env["MIRAVO_MODBUS_HOST"] ?? undefined;
420
+ }
421
+ // MQTT is already enabled by default; template.protocols.mqtt only affects config (port/host)
422
+ }
423
+
424
+ // Deferred warnings for OPC UA / Modbus flags (now that template auto-enable has run)
425
+ if (!opcuaEnabled) {
426
+ if (options.opcuaPort !== undefined) {
427
+ configLog.warn(
428
+ "--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
429
+ );
430
+ }
431
+ if (options.opcuaHost !== undefined) {
432
+ configLog.warn(
433
+ "--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
434
+ );
435
+ }
436
+ }
437
+ if (!modbusEnabled) {
438
+ if (options.modbusPort !== undefined) {
439
+ configLog.warn(
440
+ "--modbus-port ignored because Modbus is not enabled. Use --modbus to enable it.",
441
+ );
442
+ }
443
+ if (options.modbusHost !== undefined) {
444
+ configLog.warn(
445
+ "--modbus-host ignored because Modbus is not enabled. Use --modbus to enable it.",
446
+ );
447
+ }
448
+ }
449
+
450
+ const simulationName = resolveSimulationName({
451
+ template,
452
+ ...(options.name !== undefined ? { cliName: options.name } : {}),
453
+ ...(simulationNameEnv !== undefined ? { envName: simulationNameEnv } : {}),
454
+ });
455
+ configLog.debug(`Loaded ${models.size} models, seed=${seed} (explicit=${seedExplicit})`);
456
+
457
+ // Step 3: Dry-run check
458
+ if (options.dryRun) {
459
+ printDryRunSummary(template, models, seed, seedExplicit, templatePath);
460
+ return;
461
+ }
462
+
463
+ // Step 3b: Detach check — spawn background process and exit
464
+ if (options.detach) {
465
+ const detachAdapterConfigs = resolveDetachAdapterConfigs({
466
+ template,
467
+ simulationName,
468
+ mqtt: {
469
+ enabled: mqttEnabled,
470
+ portOverride: portOption,
471
+ overrides: mqttOverrides,
472
+ },
473
+ opcua: {
474
+ enabled: opcuaEnabled,
475
+ portOverride: opcuaPort,
476
+ hostOverride: opcuaHost,
477
+ writeBudgetOverride: opcuaWriteBudget,
478
+ },
479
+ modbus: {
480
+ enabled: modbusEnabled,
481
+ portOverride: modbusPort,
482
+ hostOverride: modbusHost,
483
+ },
484
+ });
485
+
486
+ await detachAction({
487
+ simulationName,
488
+ templatePath,
489
+ seed,
490
+ seedExplicit,
491
+ adminHost,
492
+ adminPort,
493
+ mqttPort: detachAdapterConfigs.mqtt.port,
494
+ mqttHost: detachAdapterConfigs.mqtt.host,
495
+ mqttEmbedded: detachAdapterConfigs.mqtt.embedded,
496
+ mqttEnabled,
497
+ opcuaPort: detachAdapterConfigs.opcua.port,
498
+ opcuaHost: detachAdapterConfigs.opcua.host,
499
+ opcuaEnabled,
500
+ modbusPort: detachAdapterConfigs.modbus.port,
501
+ modbusHost: detachAdapterConfigs.modbus.host,
502
+ modbusEnabled,
503
+ });
504
+ return;
505
+ }
506
+
507
+ const runId = Bun.env["MIRAVO_DAEMON_RUN_ID"] ?? crypto.randomUUID();
508
+ setRuntimeLogContext({ simulationName, runId });
509
+ runtimeLogContextSet = true;
510
+
511
+ shutdown = createShutdownController({
512
+ seed,
513
+ seedExplicit,
514
+ });
515
+ const shutdownRequested = shutdown.waitForShutdownRequest();
516
+ shutdown.addTask("finalize", "runtime log context", () => {
517
+ if (runtimeLogContextSet) {
518
+ clearRuntimeLogContext();
519
+ runtimeLogContextSet = false;
520
+ }
521
+ });
522
+
523
+ // Step 4: Create engine and populate
524
+ engine = createAndPopulateEngine(
525
+ template,
526
+ models,
527
+ catalog,
528
+ seed,
529
+ options,
530
+ templatePath,
531
+ simulationName,
532
+ );
533
+ shutdown.setEngine(engine);
534
+
535
+ // Wire up template loader for runtime loadTemplate commands
536
+ // Single discovery pass builds both the path cache and catalog (avoids re-reading YAML)
537
+ const { pathCache: templatePathCache, catalog: catalogTemplates } =
538
+ await buildTemplateCacheAndCatalog(catalog);
539
+ engine.setTemplateLoader(createRuntimeTemplateLoader(catalog, templatePathCache));
540
+
541
+ // Load any additional comma-separated templates as extra template runs
542
+ for (const extraPath of extraTemplatePaths) {
543
+ const result = await engine.execute({
544
+ type: "loadTemplate",
545
+ templatePath: extraPath,
546
+ count: 1,
547
+ });
548
+ if (!result.success) {
549
+ throw new MiravoError(
550
+ `Failed to load additional template "${extraPath}": ${result.message}`,
551
+ result.error ?? "Check that the template is valid and compatible.",
552
+ );
553
+ }
554
+ configLog.info(`Loaded additional template: ${extraPath}`);
555
+ }
556
+
557
+ let controlPlane: ControlPlane | null = null;
558
+ let adminServer: { endpoint: string; stop: () => void } | null = null;
559
+ let lastTickProgressAt = performance.now();
560
+ let tickProgressSubscribed = false;
561
+ const onTickProgress = (): void => {
562
+ lastTickProgressAt = performance.now();
563
+ };
564
+ let unsubscribeEvents = (): void => {};
565
+ let unsubscribeAdapterLogging = (): void => {};
566
+ let summaryTimer: ReturnType<typeof setInterval> | null = null;
567
+
568
+ stopCliResources = (): void => {
569
+ controlPlane?.stop();
570
+ adminServer?.stop();
571
+ if (summaryTimer !== null) {
572
+ clearInterval(summaryTimer);
573
+ summaryTimer = null;
574
+ }
575
+ if (tickProgressSubscribed && engine) {
576
+ engine.bus.off("tick:complete", onTickProgress);
577
+ tickProgressSubscribed = false;
578
+ }
579
+ unsubscribeEvents();
580
+ unsubscribeAdapterLogging();
581
+ };
582
+
583
+ shutdown.addTask("cleanup", "CLI resources", stopCliResources);
584
+
585
+ // Step 5: Register adapter factories and enable active adapters
586
+ // Track the last created MQTT adapter via closure (needed for control plane transport)
587
+ let lastCreatedMqttAdapter: MQTTAdapter | null = null;
588
+ const eng = engine; // const alias for closures (engine is `let` and TS can't narrow across closures)
589
+
590
+ const mqttDefaultConfig = buildMQTTDefaultConfig(
591
+ template,
592
+ portOption,
593
+ mqttOverrides,
594
+ simulationName,
595
+ );
596
+ eng.registerAvailableAdapter(
597
+ "mqtt",
598
+ async (config) => {
599
+ const adapter = new MQTTAdapter(eng.bus);
600
+ await adapter.start(config);
601
+ lastCreatedMqttAdapter = adapter;
602
+ return { adapter, endpoint: buildMQTTEndpoint(config) };
603
+ },
604
+ mqttDefaultConfig,
605
+ );
606
+
607
+ const opcuaDefaultConfig = buildOPCUADefaultConfig(
608
+ template,
609
+ opcuaPort,
610
+ opcuaHost,
611
+ simulationName,
612
+ opcuaWriteBudget,
613
+ );
614
+ eng.registerAvailableAdapter(
615
+ "opcua",
616
+ async (config) => {
617
+ const adapter = new OPCUAAdapter(eng.bus, eng);
618
+ await adapter.start(config);
619
+ return { adapter, endpoint: buildOPCUAEndpoint(config) };
620
+ },
621
+ opcuaDefaultConfig,
622
+ );
623
+
624
+ const modbusDefaultConfig = buildModbusDefaultConfig(template, modbusPort, modbusHost);
625
+ eng.registerAvailableAdapter(
626
+ "modbus",
627
+ async (config) => {
628
+ const adapter = new ModbusAdapter(eng.bus, eng);
629
+ await adapter.start(config);
630
+ return { adapter, endpoint: buildModbusEndpoint(config) };
631
+ },
632
+ modbusDefaultConfig,
633
+ );
634
+
635
+ // Step 5b: Wire control plane (dynamically follows MQTT adapter lifecycle)
636
+ function wireControlPlane(): void {
637
+ // Stop old control plane before creating a new one (prevents listener leaks)
638
+ if (controlPlane) {
639
+ controlPlane.stop();
640
+ controlPlane = null;
641
+ }
642
+ const mqttAdapter = lastCreatedMqttAdapter as MQTTAdapter | null;
643
+ if (!mqttAdapter) return;
644
+ const transport = mqttAdapter.getControlTransport();
645
+ if (!transport) return;
646
+ controlPlane = createControlPlane({
647
+ simulationName,
648
+ engine: eng,
649
+ transport,
650
+ bus: eng.bus,
651
+ });
652
+ controlPlane.start();
653
+ controlLog.info(`active on $miravo/${simulationName}/* topics`);
654
+ }
655
+
656
+ // Wire now if MQTT was enabled at boot
657
+ wireControlPlane();
658
+
659
+ // Re-wire control plane on runtime MQTT enable/disable.
660
+ // Logging is intentionally absent here — startup adapter events would duplicate the banner.
661
+ // Terminal logging for adapter state changes is registered after the banner (below).
662
+ eng.bus.on("adapter:enabled", (evt) => {
663
+ if (evt.name === "mqtt") wireControlPlane();
664
+ });
665
+ eng.bus.on("adapter:disabled", (evt) => {
666
+ if (evt.name === "mqtt") {
667
+ controlPlane?.stop();
668
+ controlPlane = null;
669
+ lastCreatedMqttAdapter = null;
670
+ }
671
+ });
672
+
673
+ // Step 5c: Subscribe to events + periodic summary before engine.start() so restore events are visible
674
+ const errorContextCounts = new Map<string, number>();
675
+ unsubscribeEvents = subscribeToEvents(eng, errorContextCounts);
676
+ shutdown.setErrorContextCounts(errorContextCounts);
677
+ const summaryInterval = options.summaryInterval ?? 5;
678
+ summaryTimer = startPeriodicSummary(eng, summaryInterval);
679
+
680
+ eng.bus.on("tick:complete", onTickProgress);
681
+ tickProgressSubscribed = true;
682
+
683
+ let restoredState: RestoreSummary | null = null;
684
+ const onRestored = (event: RestoreSummary): void => {
685
+ restoredState = event;
686
+ };
687
+ eng.bus.on("engine:restored", onRestored);
688
+ try {
689
+ await eng.start();
690
+ } finally {
691
+ eng.bus.off("engine:restored", onRestored);
692
+ }
693
+ // Step 6: Start admin HTTP server after restore + adapter reconciliation
694
+ try {
695
+ const anonymousMode = options.auth === false || Bun.env["MIRAVO_ANONYMOUS_LOGIN"] === "true";
696
+ const resolved = await resolveAdminPassword();
697
+ let currentHash = resolved.passwordHash;
698
+ let isDefault = resolved.isDefault;
699
+ const sessionStore = createSessionStore();
700
+
701
+ if (anonymousMode) {
702
+ controlLog.warn(
703
+ "Authentication disabled — all requests are unauthenticated. Do not expose this server publicly.",
704
+ );
705
+ }
706
+
707
+ if (isDefault && !anonymousMode) {
708
+ controlLog.warn("Using default admin password (admin/admin). Change it at first login.");
709
+ }
710
+
711
+ adminServer = startAdminServer({
712
+ engine: eng,
713
+ eventLog: eng.eventLog,
714
+ network: {
715
+ host: adminHost,
716
+ port: adminPort,
717
+ tickIntervalMs: eng.getState().config.tickIntervalMs,
718
+ },
719
+ protocols: {
720
+ getLastTickProgressAt: () => lastTickProgressAt,
721
+ },
722
+ ws: {
723
+ bus: eng.bus,
724
+ },
725
+ auth: {
726
+ verifyPassword: (pw) => Bun.password.verify(pw, currentHash),
727
+ get isDefaultPassword() {
728
+ return isDefault;
729
+ },
730
+ sessionStore,
731
+ changePassword: async (oldPw, newPw) => {
732
+ const valid = await Bun.password.verify(oldPw, currentHash);
733
+ if (!valid) throw new Error("Invalid old password.");
734
+ const newHash = await Bun.password.hash(newPw);
735
+ await saveCredential("admin", newHash);
736
+ currentHash = newHash;
737
+ isDefault = false;
738
+ },
739
+ anonymousMode,
740
+ },
741
+ consoleDir: options.ui === false ? undefined : resolveConsoleDir(),
742
+ getCatalog: () => catalogTemplates,
743
+ getCatalogModels: async () => {
744
+ const models = await catalog.listModels();
745
+ return models.map((m) => ({
746
+ name: m.name,
747
+ description: m.description ?? null,
748
+ memberCount: m.memberCount,
749
+ methodCount: m.methodCount,
750
+ faultCount: m.faultCount,
751
+ hasLifecycle: m.hasLifecycle,
752
+ lifecycleStageCount: m.lifecycleStageCount,
753
+ source: m.source.kind,
754
+ }));
755
+ },
756
+ getCatalogModelSchema: async (name) => {
757
+ try {
758
+ const path = await catalog.resolveModel(name);
759
+ return await buildModelSchemaFromYaml(path);
760
+ } catch {
761
+ return null;
762
+ }
763
+ },
764
+ getModelYaml: async (name) => {
765
+ try {
766
+ const path = await catalog.resolveModel(name);
767
+ return await Bun.file(path).text();
768
+ } catch {
769
+ return null;
770
+ }
771
+ },
772
+ getLeDtJson: async (name) => {
773
+ try {
774
+ const path = await catalog.resolveModel(name);
775
+ const schema = await buildModelSchemaFromYaml(path);
776
+ return buildLeDtJsonFromSchema(schema);
777
+ } catch {
778
+ return null;
779
+ }
780
+ },
781
+ getLeExport: async (id, { host, protocol }) => {
782
+ const base = catalogTemplates.find((t) => t.id === id);
783
+ if (!base) return null;
784
+ try {
785
+ const path = await catalog.resolveTemplate(id);
786
+ const { templateId, desired } = await buildDesiredStateFromTemplate({
787
+ templatePath: path,
788
+ catalog,
789
+ sourceHostname: host,
790
+ protocol,
791
+ });
792
+ return buildLeExport(desired, templateId);
793
+ } catch {
794
+ return null;
795
+ }
796
+ },
797
+ getTemplateDetail: async (id) => {
798
+ const base = catalogTemplates.find((t) => t.id === id);
799
+ if (!base) return null;
800
+ try {
801
+ const path = await catalog.resolveTemplate(id);
802
+ return await buildTemplateDetail(path, base);
803
+ } catch {
804
+ return null;
805
+ }
806
+ },
807
+ getTemplateYaml: async (id) => {
808
+ try {
809
+ const path = await catalog.resolveTemplate(id);
810
+ return await Bun.file(path).text();
811
+ } catch {
812
+ return null;
813
+ }
814
+ },
815
+ simulationName,
816
+ runId,
817
+ requestShutdown: (reason) => shutdown?.requestShutdown(reason),
818
+ });
819
+ controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
820
+ } catch (error) {
821
+ const message = error instanceof Error ? error.message : String(error);
822
+ throw new MiravoError(
823
+ `Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
824
+ "Set --admin-port / MIRAVO_ADMIN_PORT to a free port, or change --admin-host.",
825
+ );
826
+ }
827
+
828
+ // Step 7: Print startup banner from the post-restore runtime state
829
+ const availableAdapters = eng.getAvailableAdapters();
830
+ const mqttEndpoint =
831
+ availableAdapters.find((a) => a.name === "mqtt" && a.enabled)?.endpoint ?? null;
832
+ const opcuaEndpoint =
833
+ availableAdapters.find((a) => a.name === "opcua" && a.enabled)?.endpoint ?? null;
834
+ const modbusEndpoint =
835
+ availableAdapters.find((a) => a.name === "modbus" && a.enabled)?.endpoint ?? null;
836
+ printStartupBanner(
837
+ templatePath,
838
+ eng,
839
+ seed,
840
+ seedExplicit,
841
+ mqttEndpoint,
842
+ opcuaEndpoint,
843
+ modbusEndpoint,
844
+ simulationName,
845
+ adminServer.endpoint,
846
+ );
847
+
848
+ // Register adapter logging after the banner — events from here on are runtime toggles,
849
+ // not startup initialisation that would duplicate what the banner already shows.
850
+ const onAdapterEnabled = (evt: { name: string; endpoint: string }): void => {
851
+ adapterLogger(evt.name).info(`enabled · ${colors.cyan(evt.endpoint)}`);
852
+ };
853
+ const onAdapterDisabled = (evt: { name: string }): void => {
854
+ adapterLogger(evt.name).info("disabled");
855
+ if (evt.name === "mqtt") controlLog.info("control plane stopped");
856
+ };
857
+ eng.bus.on("adapter:enabled", onAdapterEnabled);
858
+ eng.bus.on("adapter:disabled", onAdapterDisabled);
859
+ unsubscribeAdapterLogging = (): void => {
860
+ eng.bus.off("adapter:enabled", onAdapterEnabled);
861
+ eng.bus.off("adapter:disabled", onAdapterDisabled);
862
+ };
863
+
864
+ const restoreSummary = restoredState as RestoreSummary | null;
865
+ if (restoreSummary !== null) {
866
+ engineLog.info(
867
+ `restored saved state · ${colors.bold(String(restoreSummary.instanceCount))} instances · ${colors.bold(String(restoreSummary.templateRunCount))} template runs · ${colors.bold(String(restoreSummary.adapterCount))} adapters · ${colors.cyan(restoreSummary.stateFilePath)}`,
868
+ );
869
+ } else if (options.resetState) {
870
+ engineLog.info("starting fresh (--reset-state)");
871
+ }
872
+
873
+ shutdown.markRunning();
874
+ engineLog.success("Simulation started.");
875
+ await shutdownRequested;
876
+ await shutdown.runShutdown();
877
+ shutdown.teardown();
878
+ } catch (error) {
879
+ if (shutdown?.isShuttingDown()) {
880
+ await shutdown.runShutdown();
881
+ shutdown.teardown();
882
+ return;
883
+ }
884
+ shutdown?.teardown();
885
+ stopCliResources();
886
+ try {
887
+ await engine?.stop("boot error");
888
+ } catch {
889
+ // engine may not exist yet or stop may fail — swallow
890
+ }
891
+ if (runtimeLogContextSet) {
892
+ clearRuntimeLogContext();
893
+ runtimeLogContextSet = false;
894
+ }
895
+ handleCommandError(error);
896
+ }
897
+ }