miravo 0.2.2 → 0.3.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 (61) hide show
  1. package/README.md +116 -30
  2. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  3. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  4. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  5. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  6. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  7. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  8. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  9. package/console-dist/assets/index-BXtW73cF.css +2 -0
  10. package/console-dist/assets/index-ChSocZFW.js +119 -0
  11. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  12. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  13. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  14. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  15. package/console-dist/favicon.svg +20 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +10 -7
  19. package/src/__tests__/integration/executable-methods-runner.ts +52 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +609 -47
  21. package/src/commands/adapter.ts +76 -0
  22. package/src/commands/cli-test-setup.ts +1 -1
  23. package/src/commands/fork.ts +14 -0
  24. package/src/commands/inject.test.ts +1 -1
  25. package/src/commands/inject.ts +16 -8
  26. package/src/commands/load.ts +167 -0
  27. package/src/commands/logs.ts +175 -0
  28. package/src/commands/models.test.ts +22 -1
  29. package/src/commands/models.ts +14 -0
  30. package/src/commands/pause.ts +67 -0
  31. package/src/commands/ps.ts +116 -0
  32. package/src/commands/remove.ts +38 -0
  33. package/src/commands/reset.ts +44 -0
  34. package/src/commands/rm.ts +97 -0
  35. package/src/commands/spawn.ts +45 -0
  36. package/src/commands/speed.ts +40 -0
  37. package/src/commands/start.test.ts +93 -0
  38. package/src/commands/start.ts +976 -192
  39. package/src/commands/status.test.ts +3 -1
  40. package/src/commands/status.ts +12 -6
  41. package/src/commands/stop.test.ts +84 -0
  42. package/src/commands/stop.ts +158 -0
  43. package/src/commands/templates.test.ts +1 -1
  44. package/src/commands/templates.ts +13 -0
  45. package/src/index.ts +49 -7
  46. package/src/lib/admin-client.test.ts +35 -0
  47. package/src/lib/admin-client.ts +13 -1
  48. package/src/lib/admin-server.test.ts +339 -53
  49. package/src/lib/admin-server.ts +413 -62
  50. package/src/lib/daemon-registry.test.ts +429 -0
  51. package/src/lib/daemon-registry.ts +345 -0
  52. package/src/lib/mqtt-client.ts +1 -1
  53. package/src/lib/parse.ts +18 -0
  54. package/src/lib/resolve-simulation.ts +1 -1
  55. package/src/lib/shutdown.test.ts +16 -4
  56. package/src/lib/shutdown.ts +3 -3
  57. package/src/lib/template-category.test.ts +1 -0
  58. package/src/lib/template-utils.ts +33 -2
  59. package/src/lib/ws-hub.ts +255 -0
  60. package/src/logger.test.ts +19 -19
  61. package/src/logger.ts +34 -6
@@ -1,7 +1,8 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import { basename, resolve } from "node:path";
2
3
  import type { Option as ClackOption } from "@clack/prompts";
3
4
  import { isCancel, select } from "@clack/prompts";
4
- import type { ContentCatalog, ControlPlane, Engine } from "@miravo/core";
5
+ import type { ContentCatalog, ControlPlane, Engine, TwinRuntime } from "@miravo/core";
5
6
  import {
6
7
  createControlPlane,
7
8
  createEngine,
@@ -13,7 +14,13 @@ import {
13
14
  } from "@miravo/core";
14
15
  import { MQTTAdapter } from "@miravo/mqtt";
15
16
  import { OPCUAAdapter } from "@miravo/opcua";
16
- import type { DiscoveredTemplate, MiravoTemplate, TwinModel } from "@miravo/shared";
17
+ import type {
18
+ CatalogTemplate,
19
+ DiscoveredTemplate,
20
+ MiravoTemplate,
21
+ TemplateRun,
22
+ TwinModel,
23
+ } from "@miravo/shared";
17
24
  import {
18
25
  ConfigNotFoundError,
19
26
  ConfigValidationError,
@@ -23,11 +30,24 @@ import {
23
30
  DEFAULT_TIME_MULTIPLIER,
24
31
  MIRAVO_VERSION,
25
32
  MiravoError,
33
+ MiravoTemplateSchema,
26
34
  SimulationNameSchema,
35
+ TWIN_FILE_EXTENSION,
36
+ TwinModelSchema,
27
37
  } from "@miravo/shared";
28
38
  import type { Command } from "commander";
29
39
  import { box, colors, stripAnsi } from "consola/utils";
40
+ import { parse as parseYAML } from "yaml";
30
41
  import { startAdminServer } from "../lib/admin-server.ts";
42
+ import type { DaemonRegistryEntry } from "../lib/daemon-registry.ts";
43
+ import {
44
+ acquireNameLock,
45
+ checkNameConflict,
46
+ checkPortConflicts,
47
+ displayHost,
48
+ isPidAlive,
49
+ writeEntry,
50
+ } from "../lib/daemon-registry.ts";
31
51
  import {
32
52
  parseDurationMs,
33
53
  parseNonNegativeInt,
@@ -44,8 +64,10 @@ import {
44
64
  import {
45
65
  createCatalog,
46
66
  discoverPreferredLocalTemplates,
67
+ isTemplatePathLike,
47
68
  resolveTemplateIdentifier,
48
69
  } from "../lib/template-locator.ts";
70
+ import { buildUNSBasePath, collectReferencedModels, forEachTwin } from "../lib/template-utils.ts";
49
71
  import {
50
72
  clearRuntimeLogContext,
51
73
  configLog,
@@ -83,16 +105,76 @@ interface StartOptions {
83
105
  readonly adminPort?: number;
84
106
  readonly dryRun?: boolean;
85
107
  readonly summaryInterval?: number;
108
+ readonly noUi?: boolean;
109
+ readonly resetState?: boolean;
110
+ readonly detach?: boolean;
111
+ readonly command?: Command;
112
+ }
113
+
114
+ interface RestoreSummary {
115
+ readonly instanceCount: number;
116
+ readonly templateRunCount: number;
117
+ readonly adapterCount: number;
118
+ }
119
+
120
+ interface ProtocolBootPlan {
121
+ readonly name: "mqtt" | "opcua";
122
+ readonly shouldEnable: boolean;
123
+ readonly overrideRestore: boolean;
86
124
  }
87
125
 
126
+ type ProtocolOptionSource =
127
+ | "mqtt"
128
+ | "opcua"
129
+ | "mqttPort"
130
+ | "mqttHost"
131
+ | "mqttMode"
132
+ | "mqttUrl"
133
+ | "mqttUsername"
134
+ | "mqttPassword"
135
+ | "opcuaPort"
136
+ | "opcuaHost";
137
+
88
138
  type MqttMode = "embedded" | "external";
89
139
 
140
+ /** Resolve the path to the built console dist directory. Returns undefined if not found. */
141
+ function resolveConsoleDir(): string | undefined {
142
+ // 1. Compiled standalone binary: Bun.main resolves to a /$bunfs/ virtual path.
143
+ // Look for console-dist/ packaged alongside the binary.
144
+ if (Bun.main.startsWith("/$bunfs/")) {
145
+ const binaryDir = new URL(".", `file://${process.execPath}`).pathname.replace(/\/$/, "");
146
+ const sibling = `${binaryDir}/console-dist`;
147
+ if (Bun.file(`${sibling}/index.html`).size > 0) return sibling;
148
+ }
149
+
150
+ // 2. Dev / Docker: resolve via workspace package (@miravo/console is a devDependency,
151
+ // available in workspace but not when installed from npm).
152
+ try {
153
+ const resolved = import.meta.resolve("@miravo/console");
154
+ const resolvedDir = new URL(".", resolved).pathname.replace(/\/$/, "");
155
+ if (resolvedDir.endsWith("/dist")) return resolvedDir;
156
+ return `${resolvedDir}/dist`;
157
+ } catch {
158
+ // @miravo/console not installed (npm/bunx) — fall through
159
+ }
160
+
161
+ // 3. npm install / bunx: look for console-dist/ shipped inside the published package.
162
+ // start.ts is at packages/cli/src/commands/ → ../../console-dist/
163
+ const shipped = resolve(import.meta.dir, "..", "..", "console-dist");
164
+ if (existsSync(`${shipped}/index.html`)) return shipped;
165
+
166
+ return undefined;
167
+ }
168
+
90
169
  interface MqttRunOverrides {
91
170
  readonly mode?: MqttMode;
92
171
  readonly host?: string;
93
172
  readonly url?: string;
94
173
  readonly username?: string;
95
174
  readonly password?: string;
175
+ readonly drainBudget?: number;
176
+ readonly batchSize?: number;
177
+ readonly brokerConcurrency?: number;
96
178
  }
97
179
 
98
180
  const MAX_ERROR_CONTEXT_KEYS = 256;
@@ -105,11 +187,11 @@ const ERROR_CONTEXT_OVERFLOW = "__overflow__";
105
187
  export function registerStartCommand(program: Command): void {
106
188
  program
107
189
  .command("start")
108
- .description("Start the simulation engine")
109
- .option("-n, --name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
190
+ .description("Start a simulation from a template")
191
+ .option("-n, --name <name>", "Simulation name (env: MIRAVO_NAME, flag takes precedence)")
110
192
  .option(
111
- "-t, --template <id|name|path>",
112
- "Template identifier, display name, or .miravo.yaml path (env: MIRAVO_TEMPLATE)",
193
+ "-t, --template <id|name|path,...>",
194
+ "Template id, name, path, or comma-separated list (env: MIRAVO_TEMPLATE, flag takes precedence)",
113
195
  )
114
196
  .option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
115
197
  .option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
@@ -139,14 +221,29 @@ export function registerStartCommand(program: Command): void {
139
221
  "Admin server port (default: 8080, env: MIRAVO_ADMIN_PORT)",
140
222
  parsePort,
141
223
  )
142
- .option("-d, --dry-run", "Validate config and print summary without starting")
224
+ .option("-d, --detach", "Start simulation in background (daemon mode)")
225
+ .option("--dry-run", "Validate config and print summary without starting")
226
+ .option("--no-ui", "Disable the embedded web console")
227
+ .option("--reset-state", "Reset persisted state — start fresh (ignore any saved snapshot)")
143
228
  .option(
144
229
  "--summary-interval <seconds>",
145
230
  "Seconds between periodic summary lines (default: 5)",
146
231
  parsePositiveInt,
147
232
  )
148
- .action(async (options: StartOptions) => {
149
- await startAction(options);
233
+ .addHelpText(
234
+ "after",
235
+ `
236
+ Examples:
237
+ $ miravo start Start with auto-detected template
238
+ $ miravo start -t water-treatment Start a specific template
239
+ $ miravo start -t smart-factory,water-treatment Start with two templates
240
+ $ miravo start -d --name my-sim Start in background (daemon mode)
241
+ $ miravo start -s 10 --seed 42 Run at 10x speed with fixed seed
242
+ $ miravo start --no-mqtt --opcua Disable MQTT, enable OPC UA
243
+ $ miravo start --dry-run Validate and print summary only`,
244
+ )
245
+ .action(async (options: StartOptions, command: Command) => {
246
+ await startAction({ ...options, command });
150
247
  });
151
248
  }
152
249
 
@@ -163,7 +260,16 @@ async function startAction(options: StartOptions): Promise<void> {
163
260
  const catalog = createCatalog();
164
261
 
165
262
  // Resolve env-var fallbacks for common options
166
- const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
263
+ const templateOptionRaw = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
264
+ // Support comma-separated template identifiers: first is the boot template, rest loaded after startup
265
+ const templateParts: string[] = templateOptionRaw
266
+ ? templateOptionRaw
267
+ .split(",")
268
+ .map((s) => s.trim())
269
+ .filter(Boolean)
270
+ : [];
271
+ const templateOption: string | undefined = templateParts[0];
272
+ const extraTemplateIdentifiers: string[] = templateParts.slice(1);
167
273
  const simulationNameEnv = Bun.env["MIRAVO_NAME"];
168
274
  // Protocol selection: CLI flags decide which protocols run
169
275
  const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
@@ -213,6 +319,15 @@ async function startAction(options: StartOptions): Promise<void> {
213
319
  const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
214
320
  const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
215
321
  const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
322
+ // Tuning env vars for adapter performance
323
+ const drainBudgetEnv = Bun.env["MIRAVO_MQTT_DRAIN_BUDGET"];
324
+ const batchSizeEnv = Bun.env["MIRAVO_MQTT_BATCH_SIZE"];
325
+ const brokerConcurrencyEnv = Bun.env["MIRAVO_MQTT_BROKER_CONCURRENCY"];
326
+ const drainBudget = drainBudgetEnv ? parsePositiveInt(drainBudgetEnv) : undefined;
327
+ const batchSize = batchSizeEnv ? parsePositiveInt(batchSizeEnv) : undefined;
328
+ const brokerConcurrency = brokerConcurrencyEnv
329
+ ? parsePositiveInt(brokerConcurrencyEnv)
330
+ : undefined;
216
331
  mqttOverrides = {
217
332
  ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
218
333
  ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
@@ -223,6 +338,9 @@ async function startAction(options: StartOptions): Promise<void> {
223
338
  ...(mqttPassword !== undefined && mqttPassword.length > 0
224
339
  ? { password: mqttPassword }
225
340
  : {}),
341
+ ...(drainBudget !== undefined ? { drainBudget } : {}),
342
+ ...(batchSize !== undefined ? { batchSize } : {}),
343
+ ...(brokerConcurrency !== undefined ? { brokerConcurrency } : {}),
226
344
  };
227
345
  if (portOption === undefined && portEnv) {
228
346
  try {
@@ -245,6 +363,7 @@ async function startAction(options: StartOptions): Promise<void> {
245
363
  // OPC UA env overrides — only parsed when OPC UA is enabled
246
364
  let opcuaPort: number | undefined = options.opcuaPort;
247
365
  let opcuaHost: string | undefined;
366
+ let opcuaWriteBudget: number | undefined;
248
367
  if (opcuaEnabled) {
249
368
  const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
250
369
  if (opcuaPort === undefined && opcuaPortEnv) {
@@ -264,6 +383,10 @@ async function startAction(options: StartOptions): Promise<void> {
264
383
  fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
265
384
  });
266
385
  }
386
+ const writeBudgetEnv = Bun.env["MIRAVO_OPCUA_WRITE_BUDGET"];
387
+ if (writeBudgetEnv) {
388
+ opcuaWriteBudget = parsePositiveInt(writeBudgetEnv);
389
+ }
267
390
  }
268
391
 
269
392
  let adminPort = options.adminPort;
@@ -279,9 +402,13 @@ async function startAction(options: StartOptions): Promise<void> {
279
402
  }
280
403
  adminPort ??= 8080;
281
404
 
282
- // Step 1: Resolve template path
405
+ // Step 1: Resolve template path(s)
283
406
  const templatePath = await resolveTemplatePath(templateOption, catalog);
284
407
  configLog.debug(`Resolved template: ${templatePath}`);
408
+ // Resolve any additional comma-separated templates upfront so errors surface before engine start
409
+ const extraTemplatePaths = await Promise.all(
410
+ extraTemplateIdentifiers.map((id) => resolveTemplateIdentifier(id, catalog)),
411
+ );
285
412
 
286
413
  // Step 2: Load and validate
287
414
  const { template, models, seed, seedExplicit } = await loadAndValidateTemplate(
@@ -303,7 +430,26 @@ async function startAction(options: StartOptions): Promise<void> {
303
430
  return;
304
431
  }
305
432
 
306
- const runId = crypto.randomUUID();
433
+ // Step 3b: Detach check — spawn background process and exit
434
+ if (options.detach) {
435
+ await detachAction({
436
+ simulationName,
437
+ templatePath,
438
+ seed,
439
+ seedExplicit,
440
+ adminHost,
441
+ adminPort,
442
+ mqttPort: options.mqttPort,
443
+ mqttHost: options.mqttHost,
444
+ mqttEnabled,
445
+ opcuaPort,
446
+ opcuaHost,
447
+ opcuaEnabled,
448
+ });
449
+ return;
450
+ }
451
+
452
+ const runId = Bun.env["MIRAVO_DAEMON_RUN_ID"] ?? crypto.randomUUID();
307
453
  setRuntimeLogContext({ simulationName, runId });
308
454
  runtimeLogContextSet = true;
309
455
  controlLog.info(`Run ID: ${runId}`);
@@ -321,9 +467,39 @@ async function startAction(options: StartOptions): Promise<void> {
321
467
  });
322
468
 
323
469
  // Step 4: Create engine and populate
324
- engine = createAndPopulateEngine(template, models, seed, options, templatePath, simulationName);
470
+ engine = createAndPopulateEngine(
471
+ template,
472
+ models,
473
+ catalog,
474
+ seed,
475
+ options,
476
+ templatePath,
477
+ simulationName,
478
+ );
325
479
  shutdown.setEngine(engine);
326
480
 
481
+ // Wire up template loader for runtime loadTemplate commands
482
+ // Single discovery pass builds both the path cache and catalog (avoids re-reading YAML)
483
+ const { pathCache: templatePathCache, catalog: catalogTemplates } =
484
+ await buildTemplateCacheAndCatalog(catalog);
485
+ engine.setTemplateLoader(createRuntimeTemplateLoader(catalog, templatePathCache));
486
+
487
+ // Load any additional comma-separated templates as extra template runs
488
+ for (const extraPath of extraTemplatePaths) {
489
+ const result = await engine.execute({
490
+ type: "loadTemplate",
491
+ templatePath: extraPath,
492
+ count: 1,
493
+ });
494
+ if (!result.success) {
495
+ throw new MiravoError(
496
+ `Failed to load additional template "${extraPath}": ${result.message}`,
497
+ result.error ?? "Check that the template is valid and compatible.",
498
+ );
499
+ }
500
+ configLog.info(`Loaded additional template: ${extraPath}`);
501
+ }
502
+
327
503
  let controlPlane: ControlPlane | null = null;
328
504
  let adminServer: { endpoint: string; stop: () => void } | null = null;
329
505
  let lastTickProgressAt = performance.now();
@@ -350,51 +526,136 @@ async function startAction(options: StartOptions): Promise<void> {
350
526
 
351
527
  shutdown.addTask("cleanup", "CLI resources", stopCliResources);
352
528
 
353
- // Step 5: Start MQTT adapter (unless --no-mqtt)
354
- const mqttStartResult = mqttEnabled
355
- ? await startMQTTAdapter(engine, template, portOption, mqttOverrides, simulationName)
356
- : null;
357
- const adapter = mqttStartResult?.adapter ?? null;
358
-
359
- // Step 5a: Start OPC UA adapter (only when --opcua)
360
- const opcuaResult = opcuaEnabled
361
- ? await startOPCUAAdapter(engine, template, opcuaPort, opcuaHost, simulationName)
362
- : null;
363
-
364
- // Step 5b: Wire control plane
365
- if (adapter) {
366
- const transport = adapter.getControlTransport();
367
- if (transport) {
368
- controlPlane = createControlPlane({
369
- simulationName,
370
- engine,
371
- transport,
372
- bus: engine.bus,
373
- });
374
- controlPlane.start();
375
- controlLog.info(`active on $miravo/${simulationName}/* topics`);
529
+ // Step 5: Register adapter factories and enable active adapters
530
+ // Track the last created MQTT adapter via closure (needed for control plane transport)
531
+ let lastCreatedMqttAdapter: MQTTAdapter | null = null;
532
+ const eng = engine; // const alias for closures (engine is `let` and TS can't narrow across closures)
533
+
534
+ const mqttDefaultConfig = buildMQTTDefaultConfig(
535
+ template,
536
+ portOption,
537
+ mqttOverrides,
538
+ simulationName,
539
+ );
540
+ eng.registerAvailableAdapter(
541
+ "mqtt",
542
+ async (config) => {
543
+ const adapter = new MQTTAdapter(eng.bus);
544
+ await adapter.start(config);
545
+ lastCreatedMqttAdapter = adapter;
546
+ return { adapter, endpoint: buildMQTTEndpoint(config) };
547
+ },
548
+ mqttDefaultConfig,
549
+ );
550
+
551
+ const opcuaDefaultConfig = buildOPCUADefaultConfig(
552
+ template,
553
+ opcuaPort,
554
+ opcuaHost,
555
+ simulationName,
556
+ opcuaWriteBudget,
557
+ );
558
+ eng.registerAvailableAdapter(
559
+ "opcua",
560
+ async (config) => {
561
+ const adapter = new OPCUAAdapter(eng.bus, eng);
562
+ await adapter.start(config);
563
+ return { adapter, endpoint: buildOPCUAEndpoint(config) };
564
+ },
565
+ opcuaDefaultConfig,
566
+ );
567
+
568
+ // Step 5b: Wire control plane (dynamically follows MQTT adapter lifecycle)
569
+ function wireControlPlane(): void {
570
+ // Stop old control plane before creating a new one (prevents listener leaks)
571
+ if (controlPlane) {
572
+ controlPlane.stop();
573
+ controlPlane = null;
376
574
  }
575
+ const mqttAdapter = lastCreatedMqttAdapter as MQTTAdapter | null;
576
+ if (!mqttAdapter) return;
577
+ const transport = mqttAdapter.getControlTransport();
578
+ if (!transport) return;
579
+ controlPlane = createControlPlane({
580
+ simulationName,
581
+ engine: eng,
582
+ transport,
583
+ bus: eng.bus,
584
+ });
585
+ controlPlane.start();
586
+ controlLog.info(`active on $miravo/${simulationName}/* topics`);
377
587
  }
378
588
 
379
- // Step 5c: Start admin HTTP server
380
- engine.bus.on("tick:complete", onTickProgress);
589
+ // Wire now if MQTT was enabled at boot
590
+ wireControlPlane();
591
+
592
+ // Re-wire on runtime MQTT enable, tear down on disable
593
+ eng.bus.on("adapter:enabled", (evt) => {
594
+ if (evt.name === "mqtt") wireControlPlane();
595
+ });
596
+ eng.bus.on("adapter:disabled", (evt) => {
597
+ if (evt.name === "mqtt") {
598
+ controlPlane?.stop();
599
+ controlPlane = null;
600
+ lastCreatedMqttAdapter = null;
601
+ controlLog.info("control plane stopped (MQTT adapter disabled)");
602
+ }
603
+ });
604
+
605
+ // Step 5c: Subscribe to events + periodic summary before engine.start() so restore events are visible
606
+ const errorContextCounts = new Map<string, number>();
607
+ unsubscribeEvents = subscribeToEvents(eng, errorContextCounts);
608
+ shutdown.setErrorContextCounts(errorContextCounts);
609
+ const summaryInterval = options.summaryInterval ?? 5;
610
+ summaryTimer = startPeriodicSummary(eng, summaryInterval);
611
+
612
+ eng.bus.on("tick:complete", onTickProgress);
381
613
  tickProgressSubscribed = true;
614
+
615
+ let restoredState: RestoreSummary | null = null;
616
+ const onRestored = (event: RestoreSummary): void => {
617
+ restoredState = event;
618
+ };
619
+ eng.bus.on("engine:restored", onRestored);
620
+ try {
621
+ await eng.start();
622
+ } finally {
623
+ eng.bus.off("engine:restored", onRestored);
624
+ }
625
+
626
+ // Step 6: Start admin HTTP server after restore + adapter reconciliation
382
627
  try {
383
628
  adminServer = startAdminServer({
384
- engine,
385
- host: adminHost,
386
- port: adminPort,
387
- tickIntervalMs: engine.getState().config.tickIntervalMs,
388
- mqttEnabled: adapter !== null,
389
- isMqttConnected: () => (adapter ? adapter.getMetrics().connected : false),
390
- opcuaEnabled: opcuaResult !== null,
391
- isOpcuaConnected: () => (opcuaResult ? opcuaResult.adapter.getMetrics().connected : false),
392
- getLastTickProgressAt: () => lastTickProgressAt,
629
+ engine: eng,
630
+ network: {
631
+ host: adminHost,
632
+ port: adminPort,
633
+ tickIntervalMs: eng.getState().config.tickIntervalMs,
634
+ },
635
+ protocols: {
636
+ getLastTickProgressAt: () => lastTickProgressAt,
637
+ },
638
+ ws: {
639
+ bus: eng.bus,
640
+ getEngineState: () => eng.getMetrics().core.state,
641
+ getTickInfo: () => {
642
+ const core = eng.getMetrics().core;
643
+ return {
644
+ tick: core.tickCount,
645
+ simulationTime: core.simulationTimeSeconds,
646
+ instanceCount: core.instanceCount,
647
+ };
648
+ },
649
+ },
650
+ adminToken: Bun.env["MIRAVO_ADMIN_TOKEN"],
651
+ consoleDir: options.noUi ? undefined : resolveConsoleDir(),
652
+ getCatalog: () => catalogTemplates,
653
+ simulationName,
654
+ runId,
655
+ requestShutdown: (reason) => shutdown?.requestShutdown(reason),
393
656
  });
394
657
  controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
395
658
  } catch (error) {
396
- tickProgressSubscribed = false;
397
- engine.bus.off("tick:complete", onTickProgress);
398
659
  const message = error instanceof Error ? error.message : String(error);
399
660
  throw new MiravoError(
400
661
  `Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
@@ -402,27 +663,33 @@ async function startAction(options: StartOptions): Promise<void> {
402
663
  );
403
664
  }
404
665
 
405
- // Step 6: Print startup banner
666
+ // Step 7: Print startup banner from the post-restore runtime state
667
+ const availableAdapters = eng.getAvailableAdapters();
668
+ const mqttEndpoint =
669
+ availableAdapters.find((a) => a.name === "mqtt" && a.enabled)?.endpoint ?? null;
670
+ const opcuaEndpoint =
671
+ availableAdapters.find((a) => a.name === "opcua" && a.enabled)?.endpoint ?? null;
406
672
  printStartupBanner(
407
673
  templatePath,
408
- engine,
674
+ eng,
409
675
  seed,
410
676
  seedExplicit,
411
- mqttStartResult?.endpoint ?? null,
412
- opcuaResult?.endpoint ?? null,
677
+ mqttEndpoint,
678
+ opcuaEndpoint,
413
679
  simulationName,
414
680
  runId,
415
681
  adminServer.endpoint,
416
682
  );
417
683
 
418
- // Step 7: Subscribe to events + periodic summary
419
- const errorContextCounts = new Map<string, number>();
420
- unsubscribeEvents = subscribeToEvents(engine, errorContextCounts);
421
- shutdown.setErrorContextCounts(errorContextCounts);
422
- const summaryInterval = options.summaryInterval ?? 5;
423
- summaryTimer = startPeriodicSummary(engine, summaryInterval);
684
+ const restoreSummary = restoredState as RestoreSummary | null;
685
+ if (restoreSummary !== null) {
686
+ engineLog.info(
687
+ `restored saved state · ${colors.bold(String(restoreSummary.instanceCount))} instances · ${colors.bold(String(restoreSummary.templateRunCount))} template runs · ${colors.bold(String(restoreSummary.adapterCount))} adapters`,
688
+ );
689
+ } else if (options.resetState) {
690
+ engineLog.info("starting fresh (--reset-state)");
691
+ }
424
692
 
425
- engine.start();
426
693
  shutdown.markRunning();
427
694
  engineLog.success("Simulation started.");
428
695
  await shutdownRequested;
@@ -449,6 +716,232 @@ async function startAction(options: StartOptions): Promise<void> {
449
716
  }
450
717
  }
451
718
 
719
+ // ---------------------------------------------------------------------------
720
+ // Detach mode — spawn background simulation
721
+ // ---------------------------------------------------------------------------
722
+
723
+ interface DetachOptions {
724
+ readonly simulationName: string;
725
+ readonly templatePath: string;
726
+ readonly seed: number;
727
+ readonly seedExplicit: boolean;
728
+ readonly adminHost: string;
729
+ readonly adminPort: number;
730
+ readonly mqttPort: number | undefined;
731
+ readonly mqttHost: string | undefined;
732
+ readonly mqttEnabled: boolean;
733
+ readonly opcuaPort: number | undefined;
734
+ readonly opcuaHost: string | undefined;
735
+ readonly opcuaEnabled: boolean;
736
+ }
737
+
738
+ async function detachAction(opts: DetachOptions): Promise<void> {
739
+ const { simulationName } = opts;
740
+
741
+ // 1. Acquire exclusive lock to prevent concurrent starts
742
+ const releaseLock = await acquireNameLock(simulationName);
743
+ try {
744
+ await detachActionLocked(opts, releaseLock);
745
+ } catch (error) {
746
+ await releaseLock();
747
+ throw error;
748
+ }
749
+ }
750
+
751
+ async function detachActionLocked(
752
+ opts: DetachOptions,
753
+ releaseLock: () => Promise<void>,
754
+ ): Promise<void> {
755
+ const { simulationName, adminHost, adminPort } = opts;
756
+
757
+ // 1. Check name conflict
758
+ const existing = await checkNameConflict(simulationName);
759
+ if (existing) {
760
+ throw new MiravoError(
761
+ `Simulation "${simulationName}" is already running (PID ${existing.pid}).`,
762
+ `Stop it first: miravo stop ${simulationName}`,
763
+ );
764
+ }
765
+
766
+ // 2. Check port conflicts
767
+ const ports = new Map<string, number>();
768
+ ports.set("admin", adminPort);
769
+ if (opts.mqttEnabled && opts.mqttPort) ports.set("mqtt", opts.mqttPort);
770
+ if (opts.mqttEnabled && !opts.mqttPort) ports.set("mqtt", DEFAULT_MQTT_PORT);
771
+ if (opts.opcuaEnabled && opts.opcuaPort) ports.set("opcua", opts.opcuaPort);
772
+ if (opts.opcuaEnabled && !opts.opcuaPort) ports.set("opcua", DEFAULT_OPCUA_PORT);
773
+
774
+ const conflicts = await checkPortConflicts(ports);
775
+ if (conflicts.length > 0) {
776
+ throw new MiravoError(
777
+ conflicts[0] ?? "Port conflict detected.",
778
+ "Use different port options or stop the conflicting simulation.",
779
+ );
780
+ }
781
+
782
+ // 3. Generate run ID and rebuild args
783
+ const runId = crypto.randomUUID();
784
+ const childArgs = rebuildArgsForDetach(opts);
785
+
786
+ // 4. Spawn child process
787
+ controlLog.info(`Starting simulation "${simulationName}" in background...`);
788
+ const proc = Bun.spawn({
789
+ cmd: [process.execPath, ...childArgs],
790
+ stdin: "ignore",
791
+ stdout: "ignore",
792
+ stderr: "ignore",
793
+ env: {
794
+ ...process.env,
795
+ MIRAVO_DAEMON_RUN_ID: runId,
796
+ },
797
+ });
798
+ proc.unref();
799
+
800
+ // 5. Poll for readiness
801
+ const adminUrl = `http://${adminHost}:${adminPort}`;
802
+ const ready = await pollReadiness(proc.pid, adminUrl, runId, 10, 1_000);
803
+
804
+ if (!ready) {
805
+ // Kill child if still alive
806
+ if (isPidAlive(proc.pid)) {
807
+ try {
808
+ process.kill(proc.pid, "SIGKILL");
809
+ } catch {
810
+ // Ignore kill errors
811
+ }
812
+ }
813
+
814
+ throw new MiravoError(
815
+ `Simulation "${simulationName}" failed to start within the readiness timeout.`,
816
+ "Check template and port availability. Run without -d to see full output.",
817
+ );
818
+ }
819
+
820
+ // 6. Write registry entry
821
+ const adapters: DaemonRegistryEntry["adapters"] = {};
822
+ if (opts.mqttEnabled) {
823
+ const mqttPort = opts.mqttPort ?? DEFAULT_MQTT_PORT;
824
+ adapters["mqtt"] = { port: mqttPort, host: opts.mqttHost ?? "127.0.0.1" };
825
+ }
826
+ if (opts.opcuaEnabled) {
827
+ const opcuaPort = opts.opcuaPort ?? DEFAULT_OPCUA_PORT;
828
+ adapters["opcua"] = { port: opcuaPort, host: opts.opcuaHost ?? "0.0.0.0" };
829
+ }
830
+
831
+ const entry: DaemonRegistryEntry = {
832
+ name: simulationName,
833
+ pid: proc.pid,
834
+ adminPort,
835
+ adminHost,
836
+ adapters,
837
+ templatePath: resolve(opts.templatePath),
838
+ startedAt: new Date().toISOString(),
839
+ seed: opts.seed,
840
+ runId,
841
+ };
842
+ try {
843
+ await writeEntry(entry);
844
+ } catch (err) {
845
+ // Registry write failed — kill the orphan child so it doesn't run invisible
846
+ // to miravo ps/stop/rm.
847
+ try {
848
+ process.kill(proc.pid, "SIGKILL");
849
+ } catch {
850
+ // Process may have already exited
851
+ }
852
+ throw err;
853
+ }
854
+ await releaseLock();
855
+
856
+ // 7. Print detach summary
857
+ printDetachSummary(entry);
858
+ process.exit(0);
859
+ }
860
+
861
+ function rebuildArgsForDetach(opts: DetachOptions): string[] {
862
+ // Start from the original argv, filtering out -d/--detach
863
+ const originalArgs = process.argv.slice(1); // skip bun/node executable
864
+ const filtered: string[] = [];
865
+ let hasName = false;
866
+ let hasSeed = false;
867
+
868
+ for (let i = 0; i < originalArgs.length; i++) {
869
+ const arg = originalArgs[i];
870
+ if (arg === undefined) continue;
871
+ if (arg === "-d" || arg === "--detach") continue;
872
+ if (arg === "-n" || arg === "--name") hasName = true;
873
+ if (arg === "--seed") hasSeed = true;
874
+ filtered.push(arg);
875
+ }
876
+
877
+ // Inject --name if not explicitly passed (ensures child uses same resolved name)
878
+ if (!hasName) {
879
+ filtered.push("--name", opts.simulationName);
880
+ }
881
+ // Inject --seed to freeze the seed (so child reproduces the same simulation)
882
+ if (!hasSeed) {
883
+ filtered.push("--seed", String(opts.seed));
884
+ }
885
+
886
+ return filtered;
887
+ }
888
+
889
+ async function pollReadiness(
890
+ pid: number,
891
+ adminUrl: string,
892
+ runId: string,
893
+ maxRetries: number,
894
+ intervalMs: number,
895
+ ): Promise<boolean> {
896
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
897
+ await Bun.sleep(intervalMs);
898
+
899
+ if (!isPidAlive(pid)) return false;
900
+
901
+ try {
902
+ // Use /readyz to verify identity via runId. Accept any status (not just "ok")
903
+ // so external-MQTT daemons with slow broker reconnect can still start.
904
+ const response = await fetch(`${adminUrl}/readyz`, {
905
+ signal: AbortSignal.timeout(2_000),
906
+ });
907
+ const body = (await response.json()) as Record<string, unknown>;
908
+ if (body["runId"] === runId) return true;
909
+ } catch {
910
+ // Not ready yet — retry
911
+ }
912
+ }
913
+ return false;
914
+ }
915
+
916
+ function printDetachSummary(entry: DaemonRegistryEntry): void {
917
+ const admin = `http://${displayHost(entry.adminHost)}:${entry.adminPort}`;
918
+ const lines = [
919
+ `Simulation "${entry.name}" started in background (PID ${entry.pid})`,
920
+ "",
921
+ ` Console ${admin}`,
922
+ ];
923
+
924
+ for (const [name, adapter] of Object.entries(entry.adapters) as [
925
+ string,
926
+ { port: number; host: string },
927
+ ][]) {
928
+ const host = displayHost(adapter.host);
929
+ if (name === "mqtt") {
930
+ lines.push(` MQTT mqtt://${host}:${adapter.port}`);
931
+ } else if (name === "opcua") {
932
+ lines.push(` OPC UA opc.tcp://${host}:${adapter.port}`);
933
+ } else {
934
+ lines.push(` ${name} ${host}:${adapter.port}`);
935
+ }
936
+ }
937
+
938
+ lines.push("");
939
+ lines.push(` Use ${colors.cyan(`miravo logs ${entry.name}`)} to view events`);
940
+ lines.push(` Use ${colors.cyan(`miravo stop ${entry.name}`)} to stop`);
941
+
942
+ writeOutput(lines.join("\n"));
943
+ }
944
+
452
945
  // ---------------------------------------------------------------------------
453
946
  // Step 1: Template resolution
454
947
  // ---------------------------------------------------------------------------
@@ -626,14 +1119,7 @@ async function loadAndValidateTemplate(
626
1119
  }
627
1120
 
628
1121
  // Resolve and load all referenced models not already inline
629
- const referencedModelNames = new Set<string>();
630
- for (const area of template.areas) {
631
- for (const line of area.lines) {
632
- for (const twin of line.twins) {
633
- referencedModelNames.add(twin.model);
634
- }
635
- }
636
- }
1122
+ const referencedModelNames = collectReferencedModels(template);
637
1123
 
638
1124
  for (const modelName of referencedModelNames) {
639
1125
  if (models.has(modelName)) continue;
@@ -693,6 +1179,7 @@ export function resolveSimulationName(input: {
693
1179
  function createAndPopulateEngine(
694
1180
  template: MiravoTemplate,
695
1181
  models: Map<string, TwinModel>,
1182
+ catalog: ContentCatalog,
696
1183
  seed: number,
697
1184
  options: StartOptions,
698
1185
  templatePath: string,
@@ -702,16 +1189,51 @@ function createAndPopulateEngine(
702
1189
  options.tick ?? template.settings?.tick_interval_ms ?? DEFAULT_TICK_INTERVAL_MS;
703
1190
  const timeMultiplier =
704
1191
  options.speed ?? template.settings?.time_multiplier ?? DEFAULT_TIME_MULTIPLIER;
1192
+ const overrunThresholdMultiplier = template.settings?.overrun_threshold_multiplier;
1193
+ const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
705
1194
 
706
- const engine = createEngine({
1195
+ let engine!: Engine;
1196
+ engine = createEngine({
707
1197
  simulationName,
708
1198
  seed,
709
1199
  tickIntervalMs,
710
1200
  timeMultiplier,
1201
+ overrunThresholdMultiplier,
711
1202
  templateMetadata: {
712
1203
  name: template.name,
713
1204
  path: templatePath,
714
1205
  },
1206
+ beforeRun: async ({ restoredFromPersistence }) => {
1207
+ const protocolBootPlans = resolveProtocolBootPlans({
1208
+ options,
1209
+ optionSources: {
1210
+ mqtt: getCommandOptionSource(options.command, "mqtt"),
1211
+ opcua: getCommandOptionSource(options.command, "opcua"),
1212
+ mqttPort: getCommandOptionSource(options.command, "mqttPort"),
1213
+ mqttHost: getCommandOptionSource(options.command, "mqttHost"),
1214
+ mqttMode: getCommandOptionSource(options.command, "mqttMode"),
1215
+ mqttUrl: getCommandOptionSource(options.command, "mqttUrl"),
1216
+ mqttUsername: getCommandOptionSource(options.command, "mqttUsername"),
1217
+ mqttPassword: getCommandOptionSource(options.command, "mqttPassword"),
1218
+ opcuaPort: getCommandOptionSource(options.command, "opcuaPort"),
1219
+ opcuaHost: getCommandOptionSource(options.command, "opcuaHost"),
1220
+ },
1221
+ restoredFromPersistence,
1222
+ });
1223
+
1224
+ for (const plan of protocolBootPlans) {
1225
+ await reconcileProtocolBootPlan(engine, plan);
1226
+ }
1227
+ },
1228
+ persistence: {
1229
+ enabled: true,
1230
+ resetOnStart: options.resetState ?? false,
1231
+ ensureModelRegistered: (modelName) =>
1232
+ registerModelFromSearchPaths(engine.runtime, modelName, searchPaths),
1233
+ loadTemplateModels: (persistedTemplatePath) => {
1234
+ loadTemplateModels(catalog, persistedTemplatePath, engine.runtime);
1235
+ },
1236
+ },
715
1237
  });
716
1238
 
717
1239
  // Register all models
@@ -719,49 +1241,224 @@ function createAndPopulateEngine(
719
1241
  engine.runtime.registerModel(model);
720
1242
  }
721
1243
 
722
- // Spawn instances from template
723
- for (const area of template.areas) {
724
- for (const line of area.lines) {
725
- for (const twin of line.twins) {
726
- const unsBasePath = buildUNSBasePath(template, area.name, line.name);
1244
+ // Spawn instances from template with boot-time run ID
1245
+ const bootRunId = 1;
1246
+ const allInstanceIds: string[] = [];
1247
+
1248
+ forEachTwin(template, (area, line, twin) => {
1249
+ const unsBasePath = buildUNSBasePath(template, area.name, line.name);
1250
+ const spawned = engine.runtime.spawn(twin.model, twin.count, {
1251
+ variation: twin.variation,
1252
+ unsBasePath,
1253
+ ...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
1254
+ templateRunId: bootRunId,
1255
+ });
1256
+ for (const node of spawned) {
1257
+ allInstanceIds.push(node.id);
1258
+ }
1259
+ });
1260
+
1261
+ // Register boot-time template as a template run for persistence/unload support
1262
+ engine.registerTemplateRun({
1263
+ templateRunId: bootRunId,
1264
+ templateName: template.name,
1265
+ templatePath,
1266
+ prefix: "boot",
1267
+ instanceIds: allInstanceIds,
1268
+ models: [...models.keys()],
1269
+ });
1270
+
1271
+ return engine;
1272
+ }
1273
+
1274
+ // ---------------------------------------------------------------------------
1275
+ // Runtime template loader (for loadTemplate command from console/API)
1276
+ // ---------------------------------------------------------------------------
1277
+
1278
+ interface CachedTemplate {
1279
+ readonly id: string;
1280
+ readonly name: string;
1281
+ readonly path: string;
1282
+ }
1283
+
1284
+ interface TemplateCacheResult {
1285
+ readonly pathCache: Map<string, CachedTemplate>;
1286
+ readonly catalog: readonly CatalogTemplate[];
1287
+ }
1288
+
1289
+ /**
1290
+ * Discover templates once and build both the path lookup cache (for runtime
1291
+ * template loading) and the catalog (for the REST API) in a single pass.
1292
+ * All data comes from discovery metadata — no additional file I/O.
1293
+ */
1294
+ async function buildTemplateCacheAndCatalog(
1295
+ contentCatalog: ContentCatalog,
1296
+ ): Promise<TemplateCacheResult> {
1297
+ const pathCache = new Map<string, CachedTemplate>();
1298
+ const catalogEntries: CatalogTemplate[] = [];
1299
+ const discovered = await discoverTemplates(contentCatalog.getBuiltinTemplatesDir());
1300
+
1301
+ for (const t of discovered) {
1302
+ const entry: CachedTemplate = { id: t.id, name: t.name, path: t.path };
1303
+ pathCache.set(t.id, entry);
1304
+ pathCache.set(t.name, entry);
1305
+
1306
+ catalogEntries.push({
1307
+ id: t.id,
1308
+ name: t.name,
1309
+ description: t.description ?? null,
1310
+ category: "Industrial",
1311
+ models: t.modelNames,
1312
+ instanceCount: t.totalTwinCount,
1313
+ memberCount: 0,
1314
+ source: "builtin",
1315
+ });
1316
+ }
1317
+
1318
+ return { pathCache, catalog: catalogEntries };
1319
+ }
727
1320
 
728
- engine.runtime.spawn(twin.model, twin.count, {
1321
+ function createRuntimeTemplateLoader(
1322
+ catalog: ContentCatalog,
1323
+ templatePathCache: Map<string, CachedTemplate>,
1324
+ ): (templatePath: string, count: number, runId: number, runtime: TwinRuntime) => TemplateRun {
1325
+ return (templateIdentifier, count, runId, runtime) => {
1326
+ // Resolve template path from pre-built cache or as a file path
1327
+ let templatePath: string;
1328
+ let slugName: string;
1329
+ if (isTemplatePathLike(templateIdentifier)) {
1330
+ templatePath = resolve(templateIdentifier);
1331
+ slugName = basename(templatePath).replace(/\.miravo\.yaml$/, "");
1332
+ } else {
1333
+ const cached = templatePathCache.get(templateIdentifier);
1334
+ if (!cached) {
1335
+ const ids = [...new Set([...templatePathCache.values()].map((e) => e.id))];
1336
+ throw new MiravoError(
1337
+ `Template "${templateIdentifier}" not found.`,
1338
+ `Available templates: ${ids.join(", ") || "(none)"}`,
1339
+ );
1340
+ }
1341
+ templatePath = cached.path;
1342
+ slugName = cached.id;
1343
+ }
1344
+
1345
+ const template = loadTemplateSync(templatePath);
1346
+ const referencedModels = registerTemplateModels(catalog, templatePath, template, runtime);
1347
+
1348
+ // Spawn instances with auto-prefix using kebab-case slug
1349
+ const prefix = `${slugName}-${runId}`;
1350
+ const allInstanceIds: string[] = [];
1351
+
1352
+ for (let c = 0; c < count; c++) {
1353
+ const runPrefix = count > 1 ? `${prefix}-${c + 1}` : prefix;
1354
+ forEachTwin(template, (area, line, twin) => {
1355
+ const unsBasePath = `${runPrefix}/${buildUNSBasePath(template, area.name, line.name)}`;
1356
+ const spawned = runtime.spawn(twin.model, twin.count, {
729
1357
  variation: twin.variation,
730
1358
  unsBasePath,
731
1359
  ...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
1360
+ templateRunId: runId,
732
1361
  });
733
- }
1362
+ for (const node of spawned) {
1363
+ allInstanceIds.push(node.id);
1364
+ }
1365
+ });
734
1366
  }
1367
+
1368
+ return {
1369
+ templateRunId: runId,
1370
+ templateName: template.name,
1371
+ templatePath,
1372
+ prefix,
1373
+ instanceIds: allInstanceIds,
1374
+ models: [...referencedModels],
1375
+ };
1376
+ };
1377
+ }
1378
+
1379
+ function loadTemplateSync(templatePath: string): MiravoTemplate {
1380
+ try {
1381
+ return MiravoTemplateSchema.parse(parseYAML(readFileSync(templatePath, "utf-8")));
1382
+ } catch (err) {
1383
+ throw new MiravoError(
1384
+ `Failed to load template "${templatePath}".`,
1385
+ err instanceof Error ? err.message : "Check that the file is valid YAML.",
1386
+ );
735
1387
  }
1388
+ }
736
1389
 
737
- return engine;
1390
+ function registerModelFromSearchPaths(
1391
+ runtime: TwinRuntime,
1392
+ modelName: string,
1393
+ searchPaths: readonly string[],
1394
+ ): void {
1395
+ if (runtime.getModel(modelName)) {
1396
+ return;
1397
+ }
1398
+
1399
+ const { path: modelPath, content: modelContent } = loadModelSync(modelName, searchPaths);
1400
+ try {
1401
+ const model = TwinModelSchema.parse(parseYAML(modelContent));
1402
+ runtime.registerModel(model);
1403
+ } catch (err) {
1404
+ throw new MiravoError(
1405
+ `Failed to load model "${modelName}" from "${modelPath}".`,
1406
+ err instanceof Error ? err.message : "Check that the model file is valid YAML.",
1407
+ );
1408
+ }
1409
+ }
1410
+
1411
+ function loadTemplateModels(
1412
+ catalog: ContentCatalog,
1413
+ templatePath: string,
1414
+ runtime: TwinRuntime,
1415
+ ): readonly string[] {
1416
+ const template = loadTemplateSync(templatePath);
1417
+ return registerTemplateModels(catalog, templatePath, template, runtime);
1418
+ }
1419
+
1420
+ function registerTemplateModels(
1421
+ catalog: ContentCatalog,
1422
+ templatePath: string,
1423
+ template: MiravoTemplate,
1424
+ runtime: TwinRuntime,
1425
+ ): readonly string[] {
1426
+ const referencedModels = collectReferencedModels(template);
1427
+ const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
1428
+ for (const modelName of referencedModels) {
1429
+ registerModelFromSearchPaths(runtime, modelName, searchPaths);
1430
+ }
1431
+ return [...referencedModels];
738
1432
  }
739
1433
 
740
- function buildUNSBasePath(template: MiravoTemplate, areaName: string, lineName: string): string {
741
- const segments: string[] = [];
742
- const ns = template.namespace;
743
- if (ns !== undefined) {
744
- if ("enterprise" in ns) {
745
- segments.push(ns.enterprise, ns.site);
746
- } else if ("prefix" in ns) {
747
- segments.push(ns.prefix);
1434
+ function loadModelSync(
1435
+ modelName: string,
1436
+ searchPaths: readonly string[],
1437
+ ): { path: string; content: string } {
1438
+ for (const dir of searchPaths) {
1439
+ const candidate = resolve(dir, `${modelName}${TWIN_FILE_EXTENSION}`);
1440
+ try {
1441
+ return { path: candidate, content: readFileSync(candidate, "utf-8") };
1442
+ } catch {
1443
+ // File doesn't exist or can't be read — try next path
748
1444
  }
749
1445
  }
750
- segments.push(areaName, lineName);
751
- return segments.join("/");
1446
+ throw new MiravoError(
1447
+ `Model "${modelName}" not found in search paths.`,
1448
+ `Searched: ${searchPaths.join(", ")}. Ensure the model file exists as "${modelName}${TWIN_FILE_EXTENSION}".`,
1449
+ );
752
1450
  }
753
1451
 
754
1452
  // ---------------------------------------------------------------------------
755
- // Step 5: MQTT adapter
1453
+ // Step 5: Adapter default config builders
756
1454
  // ---------------------------------------------------------------------------
757
1455
 
758
- async function startMQTTAdapter(
759
- engine: Engine,
1456
+ function buildMQTTDefaultConfig(
760
1457
  template: MiravoTemplate,
761
1458
  portOverride: number | undefined,
762
1459
  mqttOverrides: MqttRunOverrides,
763
1460
  simulationName: string,
764
- ): Promise<{ adapter: MQTTAdapter; endpoint: string } | null> {
1461
+ ): Record<string, unknown> {
765
1462
  const mqttConfig = template.protocols?.mqtt;
766
1463
  const port = portOverride ?? mqttConfig?.port ?? DEFAULT_MQTT_PORT;
767
1464
  let embedded = mqttConfig?.embedded ?? true;
@@ -778,17 +1475,16 @@ async function startMQTTAdapter(
778
1475
  password = undefined;
779
1476
  }
780
1477
  }
781
- if (mqttOverrides.url !== undefined) {
782
- external = mqttOverrides.url;
783
- }
784
- if (mqttOverrides.host !== undefined) {
785
- host = mqttOverrides.host;
786
- }
787
- if (mqttOverrides.username !== undefined) {
788
- username = mqttOverrides.username;
789
- }
790
- if (mqttOverrides.password !== undefined) {
791
- password = mqttOverrides.password;
1478
+ if (mqttOverrides.url !== undefined) external = mqttOverrides.url;
1479
+ if (mqttOverrides.host !== undefined) host = mqttOverrides.host;
1480
+ if (mqttOverrides.username !== undefined) username = mqttOverrides.username;
1481
+ if (mqttOverrides.password !== undefined) password = mqttOverrides.password;
1482
+
1483
+ if (embedded && (external || username || password)) {
1484
+ throw new ConfigValidationError({
1485
+ message: "Embedded MQTT mode cannot be combined with external MQTT URL or credentials.",
1486
+ fix: "Use --mqtt-mode external for external broker settings, or remove external MQTT overrides.",
1487
+ });
792
1488
  }
793
1489
  if (!embedded && !external) {
794
1490
  throw new ConfigValidationError({
@@ -796,12 +1492,6 @@ async function startMQTTAdapter(
796
1492
  fix: "Set protocols.mqtt.external in template or pass --mqtt-url / MIRAVO_MQTT_URL.",
797
1493
  });
798
1494
  }
799
- if (embedded && (external || username || password)) {
800
- throw new ConfigValidationError({
801
- message: "Embedded MQTT mode cannot be combined with external MQTT URL or credentials.",
802
- fix: "Use --mqtt-mode external for external broker settings, or remove external MQTT overrides.",
803
- });
804
- }
805
1495
  if (password && !username) {
806
1496
  throw new ConfigValidationError({
807
1497
  message: "MQTT password was provided without username.",
@@ -809,92 +1499,70 @@ async function startMQTTAdapter(
809
1499
  });
810
1500
  }
811
1501
 
812
- if (!embedded && !external) {
813
- mqttLog.warn("MQTT disabled — embedded is false with no external broker URL.");
814
- return null;
815
- }
816
-
817
- const endpoint = embedded ? `mqtt://localhost:${port}` : (external as string);
818
- mqttLog.debug(
819
- `starting adapter · mode: ${embedded ? "embedded" : "external"} · endpoint: ${endpoint}`,
820
- );
821
-
822
- const adapter = new MQTTAdapter(engine.bus);
823
- try {
824
- const resolvedConfig = {
825
- name: "mqtt" as const,
826
- enabled: true,
827
- simulationName,
828
- embedded,
829
- port,
830
- host,
831
- retain: mqttConfig?.retain ?? false,
832
- ...(external !== undefined ? { external } : {}),
833
- ...(username !== undefined ? { username } : {}),
834
- ...(password !== undefined ? { password } : {}),
835
- ...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
836
- ...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
837
- ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
838
- };
1502
+ const drainBudget = mqttOverrides.drainBudget ?? mqttConfig?.drain_budget;
1503
+ const batchSize = mqttOverrides.batchSize ?? mqttConfig?.batch_size;
1504
+ const brokerConcurrency = mqttOverrides.brokerConcurrency ?? mqttConfig?.broker_concurrency;
839
1505
 
840
- await adapter.start(resolvedConfig);
841
- engine.registerAdapter(adapter);
842
- mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
843
- return { adapter, endpoint };
844
- } catch (error) {
845
- const message = error instanceof Error ? error.message : String(error);
846
- const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
847
- const fix = isPortConflict
848
- ? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
849
- : "Check the MQTT configuration in your template file.";
850
- throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
851
- }
1506
+ return {
1507
+ name: "mqtt",
1508
+ enabled: true,
1509
+ simulationName,
1510
+ embedded,
1511
+ port,
1512
+ host,
1513
+ retain: mqttConfig?.retain ?? false,
1514
+ ...(external !== undefined ? { external } : {}),
1515
+ ...(username !== undefined ? { username } : {}),
1516
+ ...(password !== undefined ? { password } : {}),
1517
+ ...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
1518
+ ...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
1519
+ ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
1520
+ ...(drainBudget !== undefined ? { drain_budget: drainBudget } : {}),
1521
+ ...(batchSize !== undefined ? { batch_size: batchSize } : {}),
1522
+ ...(brokerConcurrency !== undefined ? { broker_concurrency: brokerConcurrency } : {}),
1523
+ };
852
1524
  }
853
1525
 
854
- // ---------------------------------------------------------------------------
855
- // Step 5a: OPC UA adapter
856
- // ---------------------------------------------------------------------------
857
-
858
- async function startOPCUAAdapter(
859
- engine: Engine,
1526
+ function buildOPCUADefaultConfig(
860
1527
  template: MiravoTemplate,
861
1528
  portOverride: number | undefined,
862
1529
  hostOverride: string | undefined,
863
1530
  simulationName: string,
864
- ): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
1531
+ writeBudgetOverride?: number,
1532
+ ): Record<string, unknown> {
865
1533
  const opcuaConfig = template.protocols?.opcua;
866
-
867
1534
  const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
868
1535
  const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
1536
+ const writeBudget = writeBudgetOverride ?? opcuaConfig?.write_budget;
1537
+
1538
+ return {
1539
+ name: "opcua",
1540
+ enabled: true,
1541
+ port,
1542
+ host,
1543
+ security: opcuaConfig?.security ?? "none",
1544
+ simulationName,
1545
+ ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
1546
+ ...(writeBudget !== undefined ? { write_budget: writeBudget } : {}),
1547
+ };
1548
+ }
869
1549
 
870
- opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
871
-
872
- const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
873
- try {
874
- const resolvedConfig = {
875
- name: "opcua" as const,
876
- enabled: true,
877
- port,
878
- host,
879
- security: opcuaConfig?.security ?? "none",
880
- simulationName,
881
- ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
882
- };
883
-
884
- await opcuaAdapter.start(resolvedConfig);
885
- engine.registerAdapter(opcuaAdapter);
1550
+ /** Derive the MQTT endpoint string from a resolved adapter config. */
1551
+ function buildMQTTEndpoint(config: import("@miravo/shared").ProtocolConfig): string {
1552
+ const cfg = config as Record<string, unknown>;
1553
+ const port = cfg["port"] ?? DEFAULT_MQTT_PORT;
1554
+ const embedded = cfg["embedded"] ?? true;
1555
+ const external = cfg["external"] as string | undefined;
1556
+ return embedded ? `mqtt://localhost:${port}` : (external as string);
1557
+ }
886
1558
 
887
- const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
888
- opcuaLog.ready(`server ready · endpoint: ${colors.cyan(endpoint)}`);
889
- return { adapter: opcuaAdapter, endpoint };
890
- } catch (error) {
891
- const message = error instanceof Error ? error.message : String(error);
892
- const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
893
- const fix = isPortConflict
894
- ? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
895
- : "Check the OPC UA port/network settings and retry.";
896
- throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
897
- }
1559
+ /** Derive the OPC UA endpoint string from a resolved adapter config. */
1560
+ function buildOPCUAEndpoint(config: import("@miravo/shared").ProtocolConfig): string {
1561
+ const cfg = config as Record<string, unknown>;
1562
+ const port = cfg["port"] ?? DEFAULT_OPCUA_PORT;
1563
+ const host = cfg["host"] ?? "0.0.0.0";
1564
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
1565
+ return `opc.tcp://${displayHost}:${port}`;
898
1566
  }
899
1567
 
900
1568
  // ---------------------------------------------------------------------------
@@ -1020,6 +1688,17 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1020
1688
  engineLog.debug(event.message, { context: event.context });
1021
1689
  };
1022
1690
 
1691
+ const onReset = (event: {
1692
+ instanceCount: number;
1693
+ templateRunCount: number;
1694
+ clearPersistence: boolean;
1695
+ }): void => {
1696
+ const suffix = event.clearPersistence ? " · saved state cleared" : "";
1697
+ engineLog.info(
1698
+ `simulation reset · ${colors.bold(String(event.instanceCount))} instances · ${colors.bold(String(event.templateRunCount))} template runs${suffix}`,
1699
+ );
1700
+ };
1701
+
1023
1702
  const onError = (event: { context: string; error: Error }): void => {
1024
1703
  incrementErrorContextCount(errorContextCounts, event.context);
1025
1704
  engineLog.error(
@@ -1038,6 +1717,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1038
1717
  engine.bus.on("fault:cleared", onFaultCleared);
1039
1718
  engine.bus.on("engine:warn", onWarn);
1040
1719
  engine.bus.on("engine:error", onError);
1720
+ engine.bus.on("engine:reset", onReset);
1041
1721
 
1042
1722
  return () => {
1043
1723
  engine.bus.off("lifecycle:changed", onLifecycle);
@@ -1045,6 +1725,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1045
1725
  engine.bus.off("fault:cleared", onFaultCleared);
1046
1726
  engine.bus.off("engine:warn", onWarn);
1047
1727
  engine.bus.off("engine:error", onError);
1728
+ engine.bus.off("engine:reset", onReset);
1048
1729
  };
1049
1730
  }
1050
1731
 
@@ -1070,6 +1751,113 @@ export function incrementErrorContextCount(counts: Map<string, number>, rawConte
1070
1751
  counts.set(ERROR_CONTEXT_OVERFLOW, (counts.get(ERROR_CONTEXT_OVERFLOW) ?? 0) + 1);
1071
1752
  }
1072
1753
 
1754
+ function getCommandOptionSource(
1755
+ command: Command | undefined,
1756
+ optionName: ProtocolOptionSource,
1757
+ ): string | undefined {
1758
+ const maybeGetOptionValueSource = (
1759
+ command as Command & { getOptionValueSource?: (key: string) => string | undefined }
1760
+ )?.getOptionValueSource;
1761
+ return maybeGetOptionValueSource?.call(command, optionName);
1762
+ }
1763
+
1764
+ export function resolveProtocolBootPlans(input: {
1765
+ readonly options: StartOptions & { readonly command?: Command | undefined };
1766
+ readonly optionSources: Partial<Record<ProtocolOptionSource, string | undefined>>;
1767
+ readonly restoredFromPersistence: boolean;
1768
+ }): readonly ProtocolBootPlan[] {
1769
+ const mqttConfigExplicit = [
1770
+ "mqttPort",
1771
+ "mqttHost",
1772
+ "mqttMode",
1773
+ "mqttUrl",
1774
+ "mqttUsername",
1775
+ "mqttPassword",
1776
+ ].some((option) => input.optionSources[option as ProtocolOptionSource] === "cli");
1777
+
1778
+ const opcuaConfigExplicit =
1779
+ input.options.opcua === true &&
1780
+ ["opcuaPort", "opcuaHost"].some(
1781
+ (option) => input.optionSources[option as ProtocolOptionSource] === "cli",
1782
+ );
1783
+
1784
+ return [
1785
+ {
1786
+ name: "mqtt",
1787
+ shouldEnable: input.options.mqtt !== false,
1788
+ overrideRestore:
1789
+ !input.restoredFromPersistence ||
1790
+ input.optionSources["mqtt"] === "cli" ||
1791
+ mqttConfigExplicit,
1792
+ },
1793
+ {
1794
+ name: "opcua",
1795
+ shouldEnable: input.options.opcua === true,
1796
+ overrideRestore:
1797
+ !input.restoredFromPersistence ||
1798
+ input.optionSources["opcua"] === "cli" ||
1799
+ opcuaConfigExplicit,
1800
+ },
1801
+ ];
1802
+ }
1803
+
1804
+ async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan): Promise<void> {
1805
+ const adapter = engine.getAvailableAdapters().find((candidate) => candidate.name === plan.name);
1806
+ const isEnabled = adapter?.enabled === true;
1807
+
1808
+ if (!plan.shouldEnable) {
1809
+ if (!isEnabled || !plan.overrideRestore) {
1810
+ return;
1811
+ }
1812
+
1813
+ const result = await engine.disableAdapter(plan.name);
1814
+ if (!result.success) {
1815
+ throw new MiravoError(
1816
+ result.message,
1817
+ result.error ?? `Disable adapter "${plan.name}" manually and retry startup.`,
1818
+ );
1819
+ }
1820
+ return;
1821
+ }
1822
+
1823
+ if (plan.overrideRestore && isEnabled) {
1824
+ const disableResult = await engine.disableAdapter(plan.name);
1825
+ if (!disableResult.success) {
1826
+ throw new MiravoError(
1827
+ disableResult.message,
1828
+ disableResult.error ?? `Disable adapter "${plan.name}" manually and retry startup.`,
1829
+ );
1830
+ }
1831
+ if ((disableResult.data as { stopWarning?: boolean } | undefined)?.stopWarning === true) {
1832
+ throw new MiravoError(
1833
+ `Failed to safely reconfigure adapter "${plan.name}" during startup.`,
1834
+ `The previous ${plan.name.toUpperCase()} adapter instance did not stop cleanly. Free the port or clear the persisted adapter state before retrying.`,
1835
+ );
1836
+ }
1837
+ } else if (isEnabled) {
1838
+ return;
1839
+ }
1840
+
1841
+ const result = await engine.enableAdapter(plan.name);
1842
+ if (!result.success) {
1843
+ throw new MiravoError(
1844
+ result.message,
1845
+ result.error ?? `Check ${plan.name.toUpperCase()} configuration.`,
1846
+ );
1847
+ }
1848
+
1849
+ if (plan.name === "mqtt") {
1850
+ mqttLog.ready(
1851
+ `connected · broker: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
1852
+ );
1853
+ return;
1854
+ }
1855
+
1856
+ opcuaLog.ready(
1857
+ `server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
1858
+ );
1859
+ }
1860
+
1073
1861
  function startPeriodicSummary(
1074
1862
  engine: Engine,
1075
1863
  intervalSeconds: number,
@@ -1133,17 +1921,13 @@ function printDryRunSummary(
1133
1921
  // Instance summary
1134
1922
  let totalInstances = 0;
1135
1923
  let totalMembers = 0;
1136
- for (const area of template.areas) {
1137
- for (const line of area.lines) {
1138
- for (const twin of line.twins) {
1139
- totalInstances += twin.count;
1140
- const model = models.get(twin.model);
1141
- if (model) {
1142
- totalMembers += twin.count * Object.keys(model.members).length;
1143
- }
1144
- }
1924
+ forEachTwin(template, (_area, _line, twin) => {
1925
+ totalInstances += twin.count;
1926
+ const model = models.get(twin.model);
1927
+ if (model) {
1928
+ totalMembers += twin.count * Object.keys(model.members).length;
1145
1929
  }
1146
- }
1930
+ });
1147
1931
 
1148
1932
  lines.push("");
1149
1933
  lines.push(