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,255 @@
1
+ import { resolve } from "node:path";
2
+ import {
3
+ DEFAULT_MODBUS_PORT,
4
+ DEFAULT_MQTT_PORT,
5
+ DEFAULT_OPCUA_PORT,
6
+ MiravoError,
7
+ } from "@miravo/shared";
8
+ import { colors } from "consola/utils";
9
+ import type { DaemonRegistryEntry } from "../../lib/daemon-registry.ts";
10
+ import {
11
+ acquireNameLock,
12
+ checkNameConflict,
13
+ checkPortConflicts,
14
+ displayHost,
15
+ isPidAlive,
16
+ writeEntry,
17
+ } from "../../lib/daemon-registry.ts";
18
+ import { controlLog, writeOutput } from "../../logger.ts";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Detach mode — spawn background simulation
22
+ // ---------------------------------------------------------------------------
23
+
24
+ interface DetachOptions {
25
+ readonly simulationName: string;
26
+ readonly templatePath: string;
27
+ readonly seed: number;
28
+ readonly seedExplicit: boolean;
29
+ readonly adminHost: string;
30
+ readonly adminPort: number;
31
+ readonly mqttPort: number | undefined;
32
+ readonly mqttHost: string | undefined;
33
+ readonly mqttEmbedded: boolean;
34
+ readonly mqttEnabled: boolean;
35
+ readonly opcuaPort: number | undefined;
36
+ readonly opcuaHost: string | undefined;
37
+ readonly opcuaEnabled: boolean;
38
+ readonly modbusPort: number | undefined;
39
+ readonly modbusHost: string | undefined;
40
+ readonly modbusEnabled: boolean;
41
+ }
42
+
43
+ export async function detachAction(opts: DetachOptions): Promise<void> {
44
+ const { simulationName } = opts;
45
+
46
+ // 1. Acquire exclusive lock to prevent concurrent starts
47
+ const releaseLock = await acquireNameLock(simulationName);
48
+ try {
49
+ await detachActionLocked(opts, releaseLock);
50
+ } catch (error) {
51
+ await releaseLock();
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ async function detachActionLocked(
57
+ opts: DetachOptions,
58
+ releaseLock: () => Promise<void>,
59
+ ): Promise<void> {
60
+ const { simulationName, adminHost, adminPort } = opts;
61
+
62
+ // 1. Check name conflict
63
+ const existing = await checkNameConflict(simulationName);
64
+ if (existing) {
65
+ throw new MiravoError(
66
+ `Simulation "${simulationName}" is already running (PID ${existing.pid}).`,
67
+ `Stop it first: miravo stop ${simulationName}`,
68
+ );
69
+ }
70
+
71
+ // 2. Check port conflicts
72
+ const ports = new Map<string, number>();
73
+ ports.set("admin", adminPort);
74
+ if (opts.mqttEnabled && opts.mqttEmbedded && opts.mqttPort !== undefined) {
75
+ ports.set("mqtt", opts.mqttPort);
76
+ }
77
+ if (opts.opcuaEnabled && opts.opcuaPort !== undefined) ports.set("opcua", opts.opcuaPort);
78
+ if (opts.modbusEnabled && opts.modbusPort !== undefined) ports.set("modbus", opts.modbusPort);
79
+
80
+ const conflicts = await checkPortConflicts(ports);
81
+ if (conflicts.length > 0) {
82
+ throw new MiravoError(
83
+ conflicts[0] ?? "Port conflict detected.",
84
+ "Use different port options or stop the conflicting simulation.",
85
+ );
86
+ }
87
+
88
+ // 3. Generate run ID and rebuild args
89
+ const runId = crypto.randomUUID();
90
+ const childArgs = rebuildArgsForDetach(opts);
91
+
92
+ // 4. Spawn child process
93
+ controlLog.info(`Starting simulation "${simulationName}" in background...`);
94
+ const proc = Bun.spawn({
95
+ cmd: [process.execPath, ...childArgs],
96
+ stdin: "ignore",
97
+ stdout: "ignore",
98
+ stderr: "ignore",
99
+ env: {
100
+ ...process.env,
101
+ MIRAVO_DAEMON_RUN_ID: runId,
102
+ },
103
+ });
104
+ proc.unref();
105
+
106
+ // 5. Poll for readiness
107
+ const adminUrl = `http://${adminHost}:${adminPort}`;
108
+ const ready = await pollReadiness(proc.pid, adminUrl, runId, 10, 1_000);
109
+
110
+ if (!ready) {
111
+ // Kill child if still alive
112
+ if (isPidAlive(proc.pid)) {
113
+ try {
114
+ process.kill(proc.pid, "SIGKILL");
115
+ } catch {
116
+ // Ignore kill errors
117
+ }
118
+ }
119
+
120
+ throw new MiravoError(
121
+ `Simulation "${simulationName}" failed to start within the readiness timeout.`,
122
+ "Check template and port availability. Run without -d to see full output.",
123
+ );
124
+ }
125
+
126
+ // 6. Write registry entry
127
+ const adapters: DaemonRegistryEntry["adapters"] = {};
128
+ if (opts.mqttEnabled) {
129
+ const mqttPort = opts.mqttPort ?? DEFAULT_MQTT_PORT;
130
+ adapters["mqtt"] = { port: mqttPort, host: opts.mqttHost ?? "0.0.0.0" };
131
+ }
132
+ if (opts.opcuaEnabled) {
133
+ const opcuaPort = opts.opcuaPort ?? DEFAULT_OPCUA_PORT;
134
+ adapters["opcua"] = { port: opcuaPort, host: opts.opcuaHost ?? "0.0.0.0" };
135
+ }
136
+ if (opts.modbusEnabled) {
137
+ const modbusPort = opts.modbusPort ?? DEFAULT_MODBUS_PORT;
138
+ adapters["modbus"] = { port: modbusPort, host: opts.modbusHost ?? "0.0.0.0" };
139
+ }
140
+
141
+ const entry: DaemonRegistryEntry = {
142
+ name: simulationName,
143
+ pid: proc.pid,
144
+ adminPort,
145
+ adminHost,
146
+ adapters,
147
+ templatePath: resolve(opts.templatePath),
148
+ startedAt: new Date().toISOString(),
149
+ seed: opts.seed,
150
+ runId,
151
+ };
152
+ try {
153
+ await writeEntry(entry);
154
+ } catch (err) {
155
+ // Registry write failed — kill the orphan child so it doesn't run invisible
156
+ // to miravo ps/stop/rm.
157
+ try {
158
+ process.kill(proc.pid, "SIGKILL");
159
+ } catch {
160
+ // Process may have already exited
161
+ }
162
+ throw err;
163
+ }
164
+ await releaseLock();
165
+
166
+ // 7. Print detach summary
167
+ printDetachSummary(entry);
168
+ process.exit(0);
169
+ }
170
+
171
+ function rebuildArgsForDetach(opts: DetachOptions): string[] {
172
+ // Start from the original argv, filtering out -d/--detach
173
+ const originalArgs = process.argv.slice(1); // skip bun/node executable
174
+ const filtered: string[] = [];
175
+ let hasName = false;
176
+ let hasSeed = false;
177
+
178
+ for (let i = 0; i < originalArgs.length; i++) {
179
+ const arg = originalArgs[i];
180
+ if (arg === undefined) continue;
181
+ if (arg === "-d" || arg === "--detach") continue;
182
+ if (arg === "-n" || arg === "--name") hasName = true;
183
+ if (arg === "--seed") hasSeed = true;
184
+ filtered.push(arg);
185
+ }
186
+
187
+ // Inject --name if not explicitly passed (ensures child uses same resolved name)
188
+ if (!hasName) {
189
+ filtered.push("--name", opts.simulationName);
190
+ }
191
+ // Inject --seed to freeze the seed (so child reproduces the same simulation)
192
+ if (!hasSeed) {
193
+ filtered.push("--seed", String(opts.seed));
194
+ }
195
+
196
+ return filtered;
197
+ }
198
+
199
+ async function pollReadiness(
200
+ pid: number,
201
+ adminUrl: string,
202
+ runId: string,
203
+ maxRetries: number,
204
+ intervalMs: number,
205
+ ): Promise<boolean> {
206
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
207
+ await Bun.sleep(intervalMs);
208
+
209
+ if (!isPidAlive(pid)) return false;
210
+
211
+ try {
212
+ // Use /readyz to verify identity via runId. Accept any status (not just "ok")
213
+ // so external-MQTT daemons with slow broker reconnect can still start.
214
+ const response = await fetch(`${adminUrl}/readyz`, {
215
+ signal: AbortSignal.timeout(2_000),
216
+ });
217
+ const body = (await response.json()) as Record<string, unknown>;
218
+ if (body["runId"] === runId) return true;
219
+ } catch {
220
+ // Not ready yet — retry
221
+ }
222
+ }
223
+ return false;
224
+ }
225
+
226
+ function printDetachSummary(entry: DaemonRegistryEntry): void {
227
+ const admin = `http://${displayHost(entry.adminHost)}:${entry.adminPort}`;
228
+ const lines = [
229
+ `Simulation "${entry.name}" started in background (PID ${entry.pid})`,
230
+ "",
231
+ ` Console ${admin}`,
232
+ ];
233
+
234
+ for (const [name, adapter] of Object.entries(entry.adapters) as [
235
+ string,
236
+ { port: number; host: string },
237
+ ][]) {
238
+ const host = displayHost(adapter.host);
239
+ if (name === "mqtt") {
240
+ lines.push(` MQTT mqtt://${host}:${adapter.port}`);
241
+ } else if (name === "opcua") {
242
+ lines.push(` OPC UA opc.tcp://${host}:${adapter.port}`);
243
+ } else if (name === "modbus") {
244
+ lines.push(` Modbus modbus://${host}:${adapter.port}`);
245
+ } else {
246
+ lines.push(` ${name} ${host}:${adapter.port}`);
247
+ }
248
+ }
249
+
250
+ lines.push("");
251
+ lines.push(` Use ${colors.cyan(`miravo logs ${entry.name}`)} to view events`);
252
+ lines.push(` Use ${colors.cyan(`miravo stop ${entry.name}`)} to stop`);
253
+
254
+ writeOutput(lines.join("\n"));
255
+ }
@@ -0,0 +1,150 @@
1
+ import { basename } from "node:path";
2
+ import type { Engine } from "@miravo/core";
3
+ import type { MiravoTemplate, TwinModel } from "@miravo/shared";
4
+ import { MIRAVO_VERSION } from "@miravo/shared";
5
+ import { box, colors } from "consola/utils";
6
+ import { forEachTwin } from "../../lib/template-utils.ts";
7
+ import { configLog, engineLog, isPlain, writeOutput } from "../../logger.ts";
8
+ import { formatKeyValue, formatTable, sep } from "../../output.ts";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Step 6: Startup banner
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function printStartupBanner(
15
+ templatePath: string,
16
+ engine: Engine,
17
+ seed: number,
18
+ seedExplicit: boolean,
19
+ mqttEndpoint: string | null,
20
+ opcuaEndpoint: string | null,
21
+ modbusEndpoint: string | null,
22
+ simulationName: string,
23
+ adminEndpoint: string,
24
+ ): void {
25
+ const state = engine.getState();
26
+ // Tier 1: identity — name, instance count, model count
27
+ const identity =
28
+ ` ${colors.bold(simulationName)}` +
29
+ `${sep}${colors.bold(String(state.instances.length))} instances` +
30
+ `${sep}${colors.bold(String(state.models.length))} models`;
31
+
32
+ // Tier 2: active endpoints only — disabled adapters are omitted
33
+ const endpointEntries: Array<readonly [string, string]> = [
34
+ [colors.dim("Admin"), colors.cyan(adminEndpoint)],
35
+ ];
36
+ if (mqttEndpoint) endpointEntries.push([colors.dim("MQTT"), colors.cyan(mqttEndpoint)]);
37
+ if (opcuaEndpoint) endpointEntries.push([colors.dim("OPC UA"), colors.cyan(opcuaEndpoint)]);
38
+ if (modbusEndpoint) endpointEntries.push([colors.dim("Modbus"), colors.cyan(modbusEndpoint)]);
39
+
40
+ // Tier 3: config footer — compact, all dimmed, one line
41
+ const seedStr = seedExplicit ? `seed ${seed}` : `seed ${seed} (random)`;
42
+ const configFooter = colors.dim(
43
+ ` ${basename(templatePath)} · ${state.config.tickIntervalMs}ms · ${state.config.timeMultiplier}x · ${seedStr}`,
44
+ );
45
+
46
+ const content = [identity, "", formatKeyValue(endpointEntries), "", configFooter].join("\n");
47
+ const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
48
+
49
+ if (isPlain()) {
50
+ writeOutput(header);
51
+ writeOutput(content);
52
+ writeOutput();
53
+ engineLog.start("Press Ctrl+C to stop.");
54
+ return;
55
+ }
56
+
57
+ writeOutput(
58
+ box(content, {
59
+ title: header,
60
+ style: { borderColor: "cyan", borderStyle: "rounded" },
61
+ }),
62
+ );
63
+ engineLog.start("Press Ctrl+C to stop.");
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Step 3: Dry-run summary
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export function printDryRunSummary(
71
+ template: MiravoTemplate,
72
+ models: Map<string, TwinModel>,
73
+ seed: number,
74
+ seedExplicit: boolean,
75
+ templatePath: string,
76
+ ): void {
77
+ configLog.success("Dry-run validation passed.");
78
+
79
+ const seedValue = seedExplicit
80
+ ? colors.bold(String(seed))
81
+ : `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
82
+
83
+ const lines: string[] = [];
84
+
85
+ lines.push(
86
+ formatKeyValue([
87
+ [colors.dim("Template"), templatePath],
88
+ [colors.dim("Seed"), seedValue],
89
+ ]),
90
+ );
91
+
92
+ // Model table
93
+ const modelRows = [...models.values()].map((m) => ({
94
+ name: colors.cyan(m.model.name),
95
+ members: String(Object.keys(m.members).length),
96
+ faults: String(m.faults?.length ?? 0),
97
+ lifecycle: m.lifecycle ? `${m.lifecycle.stages.length} stages` : colors.dim("none"),
98
+ }));
99
+
100
+ if (modelRows.length > 0) {
101
+ lines.push("");
102
+ lines.push(` ${colors.bold("Models:")}`);
103
+ lines.push(
104
+ formatTable(
105
+ {
106
+ name: colors.dim("Name"),
107
+ members: colors.dim("Members"),
108
+ faults: colors.dim("Faults"),
109
+ lifecycle: colors.dim("Lifecycle"),
110
+ },
111
+ modelRows,
112
+ ),
113
+ );
114
+ }
115
+
116
+ // Instance summary
117
+ let totalInstances = 0;
118
+ let totalMembers = 0;
119
+ forEachTwin(template, (_area, _line, twin) => {
120
+ totalInstances += twin.count;
121
+ const model = models.get(twin.model);
122
+ if (model) {
123
+ totalMembers += twin.count * Object.keys(model.members).length;
124
+ }
125
+ });
126
+
127
+ lines.push("");
128
+ lines.push(
129
+ formatKeyValue([
130
+ [colors.dim("Total instances"), colors.bold(String(totalInstances))],
131
+ [colors.dim("Total members"), colors.bold(String(totalMembers))],
132
+ ]),
133
+ );
134
+
135
+ if (isPlain()) {
136
+ writeOutput("Summary");
137
+ for (const line of lines) {
138
+ writeOutput(line);
139
+ }
140
+ writeOutput();
141
+ return;
142
+ }
143
+
144
+ writeOutput(
145
+ box(lines.join("\n"), {
146
+ title: " Summary ",
147
+ style: { borderColor: "cyan", borderStyle: "rounded" },
148
+ }),
149
+ );
150
+ }
@@ -0,0 +1,247 @@
1
+ import { resolve } from "node:path";
2
+ import type { ContentCatalog, Engine } from "@miravo/core";
3
+ import {
4
+ createEngine,
5
+ loadTemplate,
6
+ loadTwinModel,
7
+ resolveModelPath,
8
+ validateFullTemplate,
9
+ } from "@miravo/core";
10
+ import type { MiravoTemplate, TwinModel } from "@miravo/shared";
11
+ import {
12
+ ConfigValidationError,
13
+ DEFAULT_TICK_INTERVAL_MS,
14
+ DEFAULT_TIME_MULTIPLIER,
15
+ SimulationNameSchema,
16
+ } from "@miravo/shared";
17
+ import {
18
+ buildUNSBasePath,
19
+ collectReferencedModels,
20
+ forEachTwin,
21
+ } from "../../lib/template-utils.ts";
22
+ import { configLog } from "../../logger.ts";
23
+ import {
24
+ getCommandOptionSource,
25
+ reconcileProtocolBootPlan,
26
+ resolveProtocolBootPlans,
27
+ } from "./protocol-plans.ts";
28
+ import { loadTemplateModels, registerModelFromSearchPaths } from "./template-loader.ts";
29
+ import type { StartOptions } from "./types.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Step 2: Load and validate
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export async function loadAndValidateTemplate(
36
+ templatePath: string,
37
+ options: StartOptions,
38
+ catalog: ContentCatalog,
39
+ ): Promise<{
40
+ template: MiravoTemplate;
41
+ models: Map<string, TwinModel>;
42
+ seed: number;
43
+ seedExplicit: boolean;
44
+ }> {
45
+ const template = await loadTemplate(templatePath);
46
+
47
+ // Build model search paths from catalog
48
+ const catalogSearchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
49
+ const searchPaths = [...catalogSearchPaths];
50
+ if (options.modelsDir) {
51
+ // Insert after template dir but before other paths
52
+ searchPaths.splice(1, 0, resolve(options.modelsDir));
53
+ }
54
+ const uniqueSearchPaths = [...new Set(searchPaths.map((path) => resolve(path)))];
55
+
56
+ // Cross-validate
57
+ const validation = await validateFullTemplate(templatePath, uniqueSearchPaths);
58
+ for (const issue of validation.issues) {
59
+ if (issue.severity === "error") {
60
+ configLog.error(`${issue.message}\n Fix: ${issue.fix}`);
61
+ } else {
62
+ configLog.warn(issue.message);
63
+ }
64
+ }
65
+ if (!validation.valid) {
66
+ throw new ConfigValidationError({
67
+ message: "Template validation failed. See errors above.",
68
+ fix: "Fix the reported issues and try again.",
69
+ filePath: templatePath,
70
+ });
71
+ }
72
+
73
+ // Build inline models map
74
+ const models = new Map<string, TwinModel>();
75
+ if (template.models) {
76
+ for (const inlineModel of template.models) {
77
+ models.set(inlineModel.model.name, inlineModel);
78
+ }
79
+ }
80
+
81
+ // Resolve and load all referenced models not already inline
82
+ const referencedModelNames = collectReferencedModels(template);
83
+
84
+ for (const modelName of referencedModelNames) {
85
+ if (models.has(modelName)) continue;
86
+ const modelPath = await resolveModelPath(modelName, uniqueSearchPaths);
87
+ const model = await loadTwinModel(modelPath);
88
+ models.set(modelName, model);
89
+ }
90
+
91
+ // Resolve seed: CLI flag > config setting > random
92
+ const configSeed = template.settings?.seed;
93
+ const cliSeed = options.seed;
94
+ const seedExplicit = cliSeed !== undefined || configSeed !== undefined;
95
+ const seed = cliSeed ?? configSeed ?? Date.now();
96
+
97
+ return { template, models, seed, seedExplicit };
98
+ }
99
+
100
+ export function resolveSimulationName(input: {
101
+ readonly template: MiravoTemplate;
102
+ readonly cliName?: string;
103
+ readonly envName?: string;
104
+ }): string {
105
+ const { template, cliName, envName } = input;
106
+
107
+ const nameFromNamespace = (): string | undefined => {
108
+ const namespace = template.namespace;
109
+ if (!namespace) return undefined;
110
+ if ("enterprise" in namespace) {
111
+ return `${namespace.enterprise}-${namespace.site}`.toLowerCase();
112
+ }
113
+ return namespace.prefix.toLowerCase();
114
+ };
115
+
116
+ const rawName = cliName ?? envName ?? nameFromNamespace();
117
+ if (!rawName) {
118
+ throw new ConfigValidationError({
119
+ message: "Template has no namespace.",
120
+ fix: "Provide a simulation name via --name or MIRAVO_NAME.",
121
+ });
122
+ }
123
+
124
+ const parsed = SimulationNameSchema.safeParse(rawName);
125
+ if (!parsed.success) {
126
+ throw new ConfigValidationError({
127
+ message: `Invalid simulation name "${rawName}".`,
128
+ fix: "Use lowercase letters, numbers, and hyphens only (for example: north-plant).",
129
+ });
130
+ }
131
+
132
+ return parsed.data;
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Step 4: Engine creation
137
+ // ---------------------------------------------------------------------------
138
+
139
+ export function createAndPopulateEngine(
140
+ template: MiravoTemplate,
141
+ models: Map<string, TwinModel>,
142
+ catalog: ContentCatalog,
143
+ seed: number,
144
+ options: StartOptions,
145
+ templatePath: string,
146
+ simulationName: string,
147
+ ): Engine {
148
+ const tickIntervalMs =
149
+ options.tick ?? template.settings?.tick_interval_ms ?? DEFAULT_TICK_INTERVAL_MS;
150
+ const timeMultiplier =
151
+ options.speed ?? template.settings?.time_multiplier ?? DEFAULT_TIME_MULTIPLIER;
152
+ const overrunThresholdMultiplier = template.settings?.overrun_threshold_multiplier;
153
+ const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
154
+
155
+ let engine!: Engine;
156
+ engine = createEngine({
157
+ simulationName,
158
+ seed,
159
+ tickIntervalMs,
160
+ timeMultiplier,
161
+ overrunThresholdMultiplier,
162
+ templateMetadata: {
163
+ name: template.name,
164
+ path: templatePath,
165
+ },
166
+ beforeRun: async ({ restoredFromPersistence, persistedDisabledAdapters }) => {
167
+ const protocolBootPlans = resolveProtocolBootPlans({
168
+ options,
169
+ optionSources: {
170
+ mqtt: getCommandOptionSource(options.command, "mqtt"),
171
+ opcua: getCommandOptionSource(options.command, "opcua"),
172
+ mqttPort: getCommandOptionSource(options.command, "mqttPort"),
173
+ mqttHost: getCommandOptionSource(options.command, "mqttHost"),
174
+ mqttMode: getCommandOptionSource(options.command, "mqttMode"),
175
+ mqttUrl: getCommandOptionSource(options.command, "mqttUrl"),
176
+ mqttUsername: getCommandOptionSource(options.command, "mqttUsername"),
177
+ mqttPassword: getCommandOptionSource(options.command, "mqttPassword"),
178
+ opcuaPort: getCommandOptionSource(options.command, "opcuaPort"),
179
+ opcuaHost: getCommandOptionSource(options.command, "opcuaHost"),
180
+ modbus: getCommandOptionSource(options.command, "modbus"),
181
+ modbusPort: getCommandOptionSource(options.command, "modbusPort"),
182
+ modbusHost: getCommandOptionSource(options.command, "modbusHost"),
183
+ },
184
+ restoredFromPersistence,
185
+ persistedDisabledAdapters,
186
+ templateProtocols: template.protocols,
187
+ });
188
+
189
+ for (const plan of protocolBootPlans) {
190
+ await reconcileProtocolBootPlan(engine, plan);
191
+ }
192
+ },
193
+ persistence: {
194
+ enabled: true,
195
+ resetOnStart: options.resetState ?? false,
196
+ ensureModelRegistered: (modelName) =>
197
+ registerModelFromSearchPaths(engine.runtime, modelName, searchPaths),
198
+ loadTemplateModels: (persistedTemplatePath) => {
199
+ loadTemplateModels(catalog, persistedTemplatePath, engine.runtime);
200
+ },
201
+ },
202
+ });
203
+
204
+ // Register template-referenced models
205
+ for (const [, model] of models) {
206
+ engine.runtime.registerModel(model);
207
+ }
208
+
209
+ // Register all builtin models so they're available for ad-hoc spawning
210
+ const builtinDir = catalog.getBuiltinModelsDir();
211
+ for (const path of new Bun.Glob("*.twin.yaml").scanSync({ cwd: builtinDir })) {
212
+ const name = path.replace(".twin.yaml", "");
213
+ if (!engine.runtime.getModel(name)) {
214
+ registerModelFromSearchPaths(engine.runtime, name, searchPaths);
215
+ }
216
+ }
217
+
218
+ // Spawn instances from template with boot-time run ID
219
+ const bootRunId = 1;
220
+ const allInstanceIds: string[] = [];
221
+
222
+ forEachTwin(template, (area, line, twin) => {
223
+ const unsBasePath = buildUNSBasePath(template, area.name, line.name);
224
+ const spawned = engine.runtime.spawn(twin.model, twin.count, {
225
+ variation: twin.variation,
226
+ unsBasePath,
227
+ ...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
228
+ ...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
229
+ templateRunId: bootRunId,
230
+ });
231
+ for (const node of spawned) {
232
+ allInstanceIds.push(node.id);
233
+ }
234
+ });
235
+
236
+ // Register boot-time template as a template run for persistence/unload support
237
+ engine.registerTemplateRun({
238
+ templateRunId: bootRunId,
239
+ templateName: template.name,
240
+ templatePath,
241
+ prefix: "boot",
242
+ instanceIds: allInstanceIds,
243
+ models: [...models.keys()],
244
+ });
245
+
246
+ return engine;
247
+ }