miravo 0.2.3 → 0.4.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 +14 -150
  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-C3XMYq26.css +2 -0
  10. package/console-dist/assets/index-hUJSpKtB.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 +37 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +12 -9
  19. package/src/__tests__/integration/executable-methods-runner.ts +53 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +648 -80
  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 +0 -24
  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 +23 -2
  29. package/src/commands/models.ts +20 -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 +1043 -193
  39. package/src/commands/status.test.ts +0 -24
  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 -14
  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,19 +1,27 @@
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,
8
9
  discoverTemplates,
9
10
  loadTemplate,
10
11
  loadTwinModel,
12
+ resolveMiravoHome,
11
13
  resolveModelPath,
12
14
  validateFullTemplate,
13
15
  } from "@miravo/core";
14
16
  import { MQTTAdapter } from "@miravo/mqtt";
15
17
  import { OPCUAAdapter } from "@miravo/opcua";
16
- import type { DiscoveredTemplate, MiravoTemplate, TwinModel } from "@miravo/shared";
18
+ import type {
19
+ CatalogTemplate,
20
+ DiscoveredTemplate,
21
+ MiravoTemplate,
22
+ TemplateRun,
23
+ TwinModel,
24
+ } from "@miravo/shared";
17
25
  import {
18
26
  ConfigNotFoundError,
19
27
  ConfigValidationError,
@@ -23,11 +31,26 @@ import {
23
31
  DEFAULT_TIME_MULTIPLIER,
24
32
  MIRAVO_VERSION,
25
33
  MiravoError,
34
+ MiravoTemplateSchema,
35
+ PersistedStateSchema,
26
36
  SimulationNameSchema,
37
+ TWIN_FILE_EXTENSION,
38
+ TwinModelSchema,
27
39
  } from "@miravo/shared";
40
+ import { Glob } from "bun";
28
41
  import type { Command } from "commander";
29
42
  import { box, colors, stripAnsi } from "consola/utils";
43
+ import { parse as parseYAML } from "yaml";
30
44
  import { startAdminServer } from "../lib/admin-server.ts";
45
+ import type { DaemonRegistryEntry } from "../lib/daemon-registry.ts";
46
+ import {
47
+ acquireNameLock,
48
+ checkNameConflict,
49
+ checkPortConflicts,
50
+ displayHost,
51
+ isPidAlive,
52
+ writeEntry,
53
+ } from "../lib/daemon-registry.ts";
31
54
  import {
32
55
  parseDurationMs,
33
56
  parseNonNegativeInt,
@@ -44,8 +67,10 @@ import {
44
67
  import {
45
68
  createCatalog,
46
69
  discoverPreferredLocalTemplates,
70
+ isTemplatePathLike,
47
71
  resolveTemplateIdentifier,
48
72
  } from "../lib/template-locator.ts";
73
+ import { buildUNSBasePath, collectReferencedModels, forEachTwin } from "../lib/template-utils.ts";
49
74
  import {
50
75
  clearRuntimeLogContext,
51
76
  configLog,
@@ -83,16 +108,76 @@ interface StartOptions {
83
108
  readonly adminPort?: number;
84
109
  readonly dryRun?: boolean;
85
110
  readonly summaryInterval?: number;
111
+ readonly noUi?: boolean;
112
+ readonly resetState?: boolean;
113
+ readonly detach?: boolean;
114
+ readonly command?: Command;
115
+ }
116
+
117
+ interface RestoreSummary {
118
+ readonly instanceCount: number;
119
+ readonly templateRunCount: number;
120
+ readonly adapterCount: number;
121
+ }
122
+
123
+ interface ProtocolBootPlan {
124
+ readonly name: "mqtt" | "opcua";
125
+ readonly shouldEnable: boolean;
126
+ readonly overrideRestore: boolean;
86
127
  }
87
128
 
129
+ type ProtocolOptionSource =
130
+ | "mqtt"
131
+ | "opcua"
132
+ | "mqttPort"
133
+ | "mqttHost"
134
+ | "mqttMode"
135
+ | "mqttUrl"
136
+ | "mqttUsername"
137
+ | "mqttPassword"
138
+ | "opcuaPort"
139
+ | "opcuaHost";
140
+
88
141
  type MqttMode = "embedded" | "external";
89
142
 
143
+ /** Resolve the path to the built console dist directory. Returns undefined if not found. */
144
+ function resolveConsoleDir(): string | undefined {
145
+ // 1. Compiled standalone binary: Bun.main resolves to a /$bunfs/ virtual path.
146
+ // Look for console-dist/ packaged alongside the binary.
147
+ if (Bun.main.startsWith("/$bunfs/")) {
148
+ const binaryDir = new URL(".", `file://${process.execPath}`).pathname.replace(/\/$/, "");
149
+ const sibling = `${binaryDir}/console-dist`;
150
+ if (Bun.file(`${sibling}/index.html`).size > 0) return sibling;
151
+ }
152
+
153
+ // 2. Dev / Docker: resolve via workspace package (@miravo/console is a devDependency,
154
+ // available in workspace but not when installed from npm).
155
+ try {
156
+ const resolved = import.meta.resolve("@miravo/console");
157
+ const resolvedDir = new URL(".", resolved).pathname.replace(/\/$/, "");
158
+ if (resolvedDir.endsWith("/dist")) return resolvedDir;
159
+ return `${resolvedDir}/dist`;
160
+ } catch {
161
+ // @miravo/console not installed (npm/bunx) — fall through
162
+ }
163
+
164
+ // 3. npm install / bunx: look for console-dist/ shipped inside the published package.
165
+ // start.ts is at packages/cli/src/commands/ → ../../console-dist/
166
+ const shipped = resolve(import.meta.dir, "..", "..", "console-dist");
167
+ if (existsSync(`${shipped}/index.html`)) return shipped;
168
+
169
+ return undefined;
170
+ }
171
+
90
172
  interface MqttRunOverrides {
91
173
  readonly mode?: MqttMode;
92
174
  readonly host?: string;
93
175
  readonly url?: string;
94
176
  readonly username?: string;
95
177
  readonly password?: string;
178
+ readonly drainBudget?: number;
179
+ readonly batchSize?: number;
180
+ readonly brokerConcurrency?: number;
96
181
  }
97
182
 
98
183
  const MAX_ERROR_CONTEXT_KEYS = 256;
@@ -105,11 +190,11 @@ const ERROR_CONTEXT_OVERFLOW = "__overflow__";
105
190
  export function registerStartCommand(program: Command): void {
106
191
  program
107
192
  .command("start")
108
- .description("Start the simulation engine")
109
- .option("-n, --name <name>", "Simulation name for control topics (env: MIRAVO_NAME)")
193
+ .description("Start a simulation from a template")
194
+ .option("-n, --name <name>", "Simulation name (env: MIRAVO_NAME, flag takes precedence)")
110
195
  .option(
111
- "-t, --template <id|name|path>",
112
- "Template identifier, display name, or .miravo.yaml path (env: MIRAVO_TEMPLATE)",
196
+ "-t, --template <id|name|path,...>",
197
+ "Template id, name, path, or comma-separated list (env: MIRAVO_TEMPLATE, flag takes precedence)",
113
198
  )
114
199
  .option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
115
200
  .option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
@@ -139,14 +224,29 @@ export function registerStartCommand(program: Command): void {
139
224
  "Admin server port (default: 8080, env: MIRAVO_ADMIN_PORT)",
140
225
  parsePort,
141
226
  )
142
- .option("-d, --dry-run", "Validate config and print summary without starting")
227
+ .option("-d, --detach", "Start simulation in background (daemon mode)")
228
+ .option("--dry-run", "Validate config and print summary without starting")
229
+ .option("--no-ui", "Disable the embedded web console")
230
+ .option("--reset-state", "Reset persisted state — start fresh (ignore any saved snapshot)")
143
231
  .option(
144
232
  "--summary-interval <seconds>",
145
233
  "Seconds between periodic summary lines (default: 5)",
146
234
  parsePositiveInt,
147
235
  )
148
- .action(async (options: StartOptions) => {
149
- await startAction(options);
236
+ .addHelpText(
237
+ "after",
238
+ `
239
+ Examples:
240
+ $ miravo start Start with auto-detected template
241
+ $ miravo start -t water-treatment Start a specific template
242
+ $ miravo start -t smart-factory,water-treatment Start with two templates
243
+ $ miravo start -d --name my-sim Start in background (daemon mode)
244
+ $ miravo start -s 10 --seed 42 Run at 10x speed with fixed seed
245
+ $ miravo start --no-mqtt --opcua Disable MQTT, enable OPC UA
246
+ $ miravo start --dry-run Validate and print summary only`,
247
+ )
248
+ .action(async (options: StartOptions, command: Command) => {
249
+ await startAction({ ...options, command });
150
250
  });
151
251
  }
152
252
 
@@ -163,7 +263,16 @@ async function startAction(options: StartOptions): Promise<void> {
163
263
  const catalog = createCatalog();
164
264
 
165
265
  // Resolve env-var fallbacks for common options
166
- const templateOption = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
266
+ const templateOptionRaw = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
267
+ // Support comma-separated template identifiers: first is the boot template, rest loaded after startup
268
+ const templateParts: string[] = templateOptionRaw
269
+ ? templateOptionRaw
270
+ .split(",")
271
+ .map((s) => s.trim())
272
+ .filter(Boolean)
273
+ : [];
274
+ const templateOption: string | undefined = templateParts[0];
275
+ const extraTemplateIdentifiers: string[] = templateParts.slice(1);
167
276
  const simulationNameEnv = Bun.env["MIRAVO_NAME"];
168
277
  // Protocol selection: CLI flags decide which protocols run
169
278
  const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
@@ -213,6 +322,15 @@ async function startAction(options: StartOptions): Promise<void> {
213
322
  const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
214
323
  const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
215
324
  const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
325
+ // Tuning env vars for adapter performance
326
+ const drainBudgetEnv = Bun.env["MIRAVO_MQTT_DRAIN_BUDGET"];
327
+ const batchSizeEnv = Bun.env["MIRAVO_MQTT_BATCH_SIZE"];
328
+ const brokerConcurrencyEnv = Bun.env["MIRAVO_MQTT_BROKER_CONCURRENCY"];
329
+ const drainBudget = drainBudgetEnv ? parsePositiveInt(drainBudgetEnv) : undefined;
330
+ const batchSize = batchSizeEnv ? parsePositiveInt(batchSizeEnv) : undefined;
331
+ const brokerConcurrency = brokerConcurrencyEnv
332
+ ? parsePositiveInt(brokerConcurrencyEnv)
333
+ : undefined;
216
334
  mqttOverrides = {
217
335
  ...(mqttMode !== undefined ? { mode: mqttMode } : {}),
218
336
  ...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
@@ -223,6 +341,9 @@ async function startAction(options: StartOptions): Promise<void> {
223
341
  ...(mqttPassword !== undefined && mqttPassword.length > 0
224
342
  ? { password: mqttPassword }
225
343
  : {}),
344
+ ...(drainBudget !== undefined ? { drainBudget } : {}),
345
+ ...(batchSize !== undefined ? { batchSize } : {}),
346
+ ...(brokerConcurrency !== undefined ? { brokerConcurrency } : {}),
226
347
  };
227
348
  if (portOption === undefined && portEnv) {
228
349
  try {
@@ -245,6 +366,7 @@ async function startAction(options: StartOptions): Promise<void> {
245
366
  // OPC UA env overrides — only parsed when OPC UA is enabled
246
367
  let opcuaPort: number | undefined = options.opcuaPort;
247
368
  let opcuaHost: string | undefined;
369
+ let opcuaWriteBudget: number | undefined;
248
370
  if (opcuaEnabled) {
249
371
  const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
250
372
  if (opcuaPort === undefined && opcuaPortEnv) {
@@ -264,6 +386,10 @@ async function startAction(options: StartOptions): Promise<void> {
264
386
  fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
265
387
  });
266
388
  }
389
+ const writeBudgetEnv = Bun.env["MIRAVO_OPCUA_WRITE_BUDGET"];
390
+ if (writeBudgetEnv) {
391
+ opcuaWriteBudget = parsePositiveInt(writeBudgetEnv);
392
+ }
267
393
  }
268
394
 
269
395
  let adminPort = options.adminPort;
@@ -279,9 +405,35 @@ async function startAction(options: StartOptions): Promise<void> {
279
405
  }
280
406
  adminPort ??= 8080;
281
407
 
282
- // Step 1: Resolve template path
283
- const templatePath = await resolveTemplatePath(templateOption, catalog);
408
+ // Step 1: Resolve template path(s)
409
+ // If the simulation name is explicitly known and no template was given,
410
+ // peek at persisted state to recover the template path — skipping the
411
+ // interactive picker on resume.
412
+ let effectiveTemplateOption = templateOption;
413
+ if (!effectiveTemplateOption && !options.resetState) {
414
+ const knownName = options.name ?? simulationNameEnv;
415
+ if (knownName) {
416
+ // Named: peek by explicit simulation name
417
+ const peeked = await peekStateTemplatePath(knownName);
418
+ if (peeked) {
419
+ effectiveTemplateOption = peeked;
420
+ configLog.debug(`Resuming from persisted state: template=${peeked}`);
421
+ }
422
+ } else {
423
+ // Unnamed: scan state dir — auto-resume if exactly one simulation exists
424
+ const peeked = await peekSingleStateTemplatePath();
425
+ if (peeked) {
426
+ effectiveTemplateOption = peeked;
427
+ configLog.debug(`Resuming from persisted state: template=${peeked}`);
428
+ }
429
+ }
430
+ }
431
+ const templatePath = await resolveTemplatePath(effectiveTemplateOption, catalog);
284
432
  configLog.debug(`Resolved template: ${templatePath}`);
433
+ // Resolve any additional comma-separated templates upfront so errors surface before engine start
434
+ const extraTemplatePaths = await Promise.all(
435
+ extraTemplateIdentifiers.map((id) => resolveTemplateIdentifier(id, catalog)),
436
+ );
285
437
 
286
438
  // Step 2: Load and validate
287
439
  const { template, models, seed, seedExplicit } = await loadAndValidateTemplate(
@@ -303,7 +455,26 @@ async function startAction(options: StartOptions): Promise<void> {
303
455
  return;
304
456
  }
305
457
 
306
- const runId = crypto.randomUUID();
458
+ // Step 3b: Detach check — spawn background process and exit
459
+ if (options.detach) {
460
+ await detachAction({
461
+ simulationName,
462
+ templatePath,
463
+ seed,
464
+ seedExplicit,
465
+ adminHost,
466
+ adminPort,
467
+ mqttPort: options.mqttPort,
468
+ mqttHost: options.mqttHost,
469
+ mqttEnabled,
470
+ opcuaPort,
471
+ opcuaHost,
472
+ opcuaEnabled,
473
+ });
474
+ return;
475
+ }
476
+
477
+ const runId = Bun.env["MIRAVO_DAEMON_RUN_ID"] ?? crypto.randomUUID();
307
478
  setRuntimeLogContext({ simulationName, runId });
308
479
  runtimeLogContextSet = true;
309
480
  controlLog.info(`Run ID: ${runId}`);
@@ -321,9 +492,39 @@ async function startAction(options: StartOptions): Promise<void> {
321
492
  });
322
493
 
323
494
  // Step 4: Create engine and populate
324
- engine = createAndPopulateEngine(template, models, seed, options, templatePath, simulationName);
495
+ engine = createAndPopulateEngine(
496
+ template,
497
+ models,
498
+ catalog,
499
+ seed,
500
+ options,
501
+ templatePath,
502
+ simulationName,
503
+ );
325
504
  shutdown.setEngine(engine);
326
505
 
506
+ // Wire up template loader for runtime loadTemplate commands
507
+ // Single discovery pass builds both the path cache and catalog (avoids re-reading YAML)
508
+ const { pathCache: templatePathCache, catalog: catalogTemplates } =
509
+ await buildTemplateCacheAndCatalog(catalog);
510
+ engine.setTemplateLoader(createRuntimeTemplateLoader(catalog, templatePathCache));
511
+
512
+ // Load any additional comma-separated templates as extra template runs
513
+ for (const extraPath of extraTemplatePaths) {
514
+ const result = await engine.execute({
515
+ type: "loadTemplate",
516
+ templatePath: extraPath,
517
+ count: 1,
518
+ });
519
+ if (!result.success) {
520
+ throw new MiravoError(
521
+ `Failed to load additional template "${extraPath}": ${result.message}`,
522
+ result.error ?? "Check that the template is valid and compatible.",
523
+ );
524
+ }
525
+ configLog.info(`Loaded additional template: ${extraPath}`);
526
+ }
527
+
327
528
  let controlPlane: ControlPlane | null = null;
328
529
  let adminServer: { endpoint: string; stop: () => void } | null = null;
329
530
  let lastTickProgressAt = performance.now();
@@ -350,51 +551,136 @@ async function startAction(options: StartOptions): Promise<void> {
350
551
 
351
552
  shutdown.addTask("cleanup", "CLI resources", stopCliResources);
352
553
 
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`);
554
+ // Step 5: Register adapter factories and enable active adapters
555
+ // Track the last created MQTT adapter via closure (needed for control plane transport)
556
+ let lastCreatedMqttAdapter: MQTTAdapter | null = null;
557
+ const eng = engine; // const alias for closures (engine is `let` and TS can't narrow across closures)
558
+
559
+ const mqttDefaultConfig = buildMQTTDefaultConfig(
560
+ template,
561
+ portOption,
562
+ mqttOverrides,
563
+ simulationName,
564
+ );
565
+ eng.registerAvailableAdapter(
566
+ "mqtt",
567
+ async (config) => {
568
+ const adapter = new MQTTAdapter(eng.bus);
569
+ await adapter.start(config);
570
+ lastCreatedMqttAdapter = adapter;
571
+ return { adapter, endpoint: buildMQTTEndpoint(config) };
572
+ },
573
+ mqttDefaultConfig,
574
+ );
575
+
576
+ const opcuaDefaultConfig = buildOPCUADefaultConfig(
577
+ template,
578
+ opcuaPort,
579
+ opcuaHost,
580
+ simulationName,
581
+ opcuaWriteBudget,
582
+ );
583
+ eng.registerAvailableAdapter(
584
+ "opcua",
585
+ async (config) => {
586
+ const adapter = new OPCUAAdapter(eng.bus, eng);
587
+ await adapter.start(config);
588
+ return { adapter, endpoint: buildOPCUAEndpoint(config) };
589
+ },
590
+ opcuaDefaultConfig,
591
+ );
592
+
593
+ // Step 5b: Wire control plane (dynamically follows MQTT adapter lifecycle)
594
+ function wireControlPlane(): void {
595
+ // Stop old control plane before creating a new one (prevents listener leaks)
596
+ if (controlPlane) {
597
+ controlPlane.stop();
598
+ controlPlane = null;
376
599
  }
600
+ const mqttAdapter = lastCreatedMqttAdapter as MQTTAdapter | null;
601
+ if (!mqttAdapter) return;
602
+ const transport = mqttAdapter.getControlTransport();
603
+ if (!transport) return;
604
+ controlPlane = createControlPlane({
605
+ simulationName,
606
+ engine: eng,
607
+ transport,
608
+ bus: eng.bus,
609
+ });
610
+ controlPlane.start();
611
+ controlLog.info(`active on $miravo/${simulationName}/* topics`);
377
612
  }
378
613
 
379
- // Step 5c: Start admin HTTP server
380
- engine.bus.on("tick:complete", onTickProgress);
614
+ // Wire now if MQTT was enabled at boot
615
+ wireControlPlane();
616
+
617
+ // Re-wire on runtime MQTT enable, tear down on disable
618
+ eng.bus.on("adapter:enabled", (evt) => {
619
+ if (evt.name === "mqtt") wireControlPlane();
620
+ });
621
+ eng.bus.on("adapter:disabled", (evt) => {
622
+ if (evt.name === "mqtt") {
623
+ controlPlane?.stop();
624
+ controlPlane = null;
625
+ lastCreatedMqttAdapter = null;
626
+ controlLog.info("control plane stopped (MQTT adapter disabled)");
627
+ }
628
+ });
629
+
630
+ // Step 5c: Subscribe to events + periodic summary before engine.start() so restore events are visible
631
+ const errorContextCounts = new Map<string, number>();
632
+ unsubscribeEvents = subscribeToEvents(eng, errorContextCounts);
633
+ shutdown.setErrorContextCounts(errorContextCounts);
634
+ const summaryInterval = options.summaryInterval ?? 5;
635
+ summaryTimer = startPeriodicSummary(eng, summaryInterval);
636
+
637
+ eng.bus.on("tick:complete", onTickProgress);
381
638
  tickProgressSubscribed = true;
639
+
640
+ let restoredState: RestoreSummary | null = null;
641
+ const onRestored = (event: RestoreSummary): void => {
642
+ restoredState = event;
643
+ };
644
+ eng.bus.on("engine:restored", onRestored);
645
+ try {
646
+ await eng.start();
647
+ } finally {
648
+ eng.bus.off("engine:restored", onRestored);
649
+ }
650
+
651
+ // Step 6: Start admin HTTP server after restore + adapter reconciliation
382
652
  try {
383
653
  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,
654
+ engine: eng,
655
+ network: {
656
+ host: adminHost,
657
+ port: adminPort,
658
+ tickIntervalMs: eng.getState().config.tickIntervalMs,
659
+ },
660
+ protocols: {
661
+ getLastTickProgressAt: () => lastTickProgressAt,
662
+ },
663
+ ws: {
664
+ bus: eng.bus,
665
+ getEngineState: () => eng.getMetrics().core.state,
666
+ getTickInfo: () => {
667
+ const core = eng.getMetrics().core;
668
+ return {
669
+ tick: core.tickCount,
670
+ simulationTime: core.simulationTimeSeconds,
671
+ instanceCount: core.instanceCount,
672
+ };
673
+ },
674
+ },
675
+ adminToken: Bun.env["MIRAVO_ADMIN_TOKEN"],
676
+ consoleDir: options.noUi ? undefined : resolveConsoleDir(),
677
+ getCatalog: () => catalogTemplates,
678
+ simulationName,
679
+ runId,
680
+ requestShutdown: (reason) => shutdown?.requestShutdown(reason),
393
681
  });
394
682
  controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
395
683
  } catch (error) {
396
- tickProgressSubscribed = false;
397
- engine.bus.off("tick:complete", onTickProgress);
398
684
  const message = error instanceof Error ? error.message : String(error);
399
685
  throw new MiravoError(
400
686
  `Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
@@ -402,27 +688,33 @@ async function startAction(options: StartOptions): Promise<void> {
402
688
  );
403
689
  }
404
690
 
405
- // Step 6: Print startup banner
691
+ // Step 7: Print startup banner from the post-restore runtime state
692
+ const availableAdapters = eng.getAvailableAdapters();
693
+ const mqttEndpoint =
694
+ availableAdapters.find((a) => a.name === "mqtt" && a.enabled)?.endpoint ?? null;
695
+ const opcuaEndpoint =
696
+ availableAdapters.find((a) => a.name === "opcua" && a.enabled)?.endpoint ?? null;
406
697
  printStartupBanner(
407
698
  templatePath,
408
- engine,
699
+ eng,
409
700
  seed,
410
701
  seedExplicit,
411
- mqttStartResult?.endpoint ?? null,
412
- opcuaResult?.endpoint ?? null,
702
+ mqttEndpoint,
703
+ opcuaEndpoint,
413
704
  simulationName,
414
705
  runId,
415
706
  adminServer.endpoint,
416
707
  );
417
708
 
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);
709
+ const restoreSummary = restoredState as RestoreSummary | null;
710
+ if (restoreSummary !== null) {
711
+ engineLog.info(
712
+ `restored saved state · ${colors.bold(String(restoreSummary.instanceCount))} instances · ${colors.bold(String(restoreSummary.templateRunCount))} template runs · ${colors.bold(String(restoreSummary.adapterCount))} adapters`,
713
+ );
714
+ } else if (options.resetState) {
715
+ engineLog.info("starting fresh (--reset-state)");
716
+ }
424
717
 
425
- engine.start();
426
718
  shutdown.markRunning();
427
719
  engineLog.success("Simulation started.");
428
720
  await shutdownRequested;
@@ -449,10 +741,277 @@ async function startAction(options: StartOptions): Promise<void> {
449
741
  }
450
742
  }
451
743
 
744
+ // ---------------------------------------------------------------------------
745
+ // Detach mode — spawn background simulation
746
+ // ---------------------------------------------------------------------------
747
+
748
+ interface DetachOptions {
749
+ readonly simulationName: string;
750
+ readonly templatePath: string;
751
+ readonly seed: number;
752
+ readonly seedExplicit: boolean;
753
+ readonly adminHost: string;
754
+ readonly adminPort: number;
755
+ readonly mqttPort: number | undefined;
756
+ readonly mqttHost: string | undefined;
757
+ readonly mqttEnabled: boolean;
758
+ readonly opcuaPort: number | undefined;
759
+ readonly opcuaHost: string | undefined;
760
+ readonly opcuaEnabled: boolean;
761
+ }
762
+
763
+ async function detachAction(opts: DetachOptions): Promise<void> {
764
+ const { simulationName } = opts;
765
+
766
+ // 1. Acquire exclusive lock to prevent concurrent starts
767
+ const releaseLock = await acquireNameLock(simulationName);
768
+ try {
769
+ await detachActionLocked(opts, releaseLock);
770
+ } catch (error) {
771
+ await releaseLock();
772
+ throw error;
773
+ }
774
+ }
775
+
776
+ async function detachActionLocked(
777
+ opts: DetachOptions,
778
+ releaseLock: () => Promise<void>,
779
+ ): Promise<void> {
780
+ const { simulationName, adminHost, adminPort } = opts;
781
+
782
+ // 1. Check name conflict
783
+ const existing = await checkNameConflict(simulationName);
784
+ if (existing) {
785
+ throw new MiravoError(
786
+ `Simulation "${simulationName}" is already running (PID ${existing.pid}).`,
787
+ `Stop it first: miravo stop ${simulationName}`,
788
+ );
789
+ }
790
+
791
+ // 2. Check port conflicts
792
+ const ports = new Map<string, number>();
793
+ ports.set("admin", adminPort);
794
+ if (opts.mqttEnabled && opts.mqttPort) ports.set("mqtt", opts.mqttPort);
795
+ if (opts.mqttEnabled && !opts.mqttPort) ports.set("mqtt", DEFAULT_MQTT_PORT);
796
+ if (opts.opcuaEnabled && opts.opcuaPort) ports.set("opcua", opts.opcuaPort);
797
+ if (opts.opcuaEnabled && !opts.opcuaPort) ports.set("opcua", DEFAULT_OPCUA_PORT);
798
+
799
+ const conflicts = await checkPortConflicts(ports);
800
+ if (conflicts.length > 0) {
801
+ throw new MiravoError(
802
+ conflicts[0] ?? "Port conflict detected.",
803
+ "Use different port options or stop the conflicting simulation.",
804
+ );
805
+ }
806
+
807
+ // 3. Generate run ID and rebuild args
808
+ const runId = crypto.randomUUID();
809
+ const childArgs = rebuildArgsForDetach(opts);
810
+
811
+ // 4. Spawn child process
812
+ controlLog.info(`Starting simulation "${simulationName}" in background...`);
813
+ const proc = Bun.spawn({
814
+ cmd: [process.execPath, ...childArgs],
815
+ stdin: "ignore",
816
+ stdout: "ignore",
817
+ stderr: "ignore",
818
+ env: {
819
+ ...process.env,
820
+ MIRAVO_DAEMON_RUN_ID: runId,
821
+ },
822
+ });
823
+ proc.unref();
824
+
825
+ // 5. Poll for readiness
826
+ const adminUrl = `http://${adminHost}:${adminPort}`;
827
+ const ready = await pollReadiness(proc.pid, adminUrl, runId, 10, 1_000);
828
+
829
+ if (!ready) {
830
+ // Kill child if still alive
831
+ if (isPidAlive(proc.pid)) {
832
+ try {
833
+ process.kill(proc.pid, "SIGKILL");
834
+ } catch {
835
+ // Ignore kill errors
836
+ }
837
+ }
838
+
839
+ throw new MiravoError(
840
+ `Simulation "${simulationName}" failed to start within the readiness timeout.`,
841
+ "Check template and port availability. Run without -d to see full output.",
842
+ );
843
+ }
844
+
845
+ // 6. Write registry entry
846
+ const adapters: DaemonRegistryEntry["adapters"] = {};
847
+ if (opts.mqttEnabled) {
848
+ const mqttPort = opts.mqttPort ?? DEFAULT_MQTT_PORT;
849
+ adapters["mqtt"] = { port: mqttPort, host: opts.mqttHost ?? "127.0.0.1" };
850
+ }
851
+ if (opts.opcuaEnabled) {
852
+ const opcuaPort = opts.opcuaPort ?? DEFAULT_OPCUA_PORT;
853
+ adapters["opcua"] = { port: opcuaPort, host: opts.opcuaHost ?? "0.0.0.0" };
854
+ }
855
+
856
+ const entry: DaemonRegistryEntry = {
857
+ name: simulationName,
858
+ pid: proc.pid,
859
+ adminPort,
860
+ adminHost,
861
+ adapters,
862
+ templatePath: resolve(opts.templatePath),
863
+ startedAt: new Date().toISOString(),
864
+ seed: opts.seed,
865
+ runId,
866
+ };
867
+ try {
868
+ await writeEntry(entry);
869
+ } catch (err) {
870
+ // Registry write failed — kill the orphan child so it doesn't run invisible
871
+ // to miravo ps/stop/rm.
872
+ try {
873
+ process.kill(proc.pid, "SIGKILL");
874
+ } catch {
875
+ // Process may have already exited
876
+ }
877
+ throw err;
878
+ }
879
+ await releaseLock();
880
+
881
+ // 7. Print detach summary
882
+ printDetachSummary(entry);
883
+ process.exit(0);
884
+ }
885
+
886
+ function rebuildArgsForDetach(opts: DetachOptions): string[] {
887
+ // Start from the original argv, filtering out -d/--detach
888
+ const originalArgs = process.argv.slice(1); // skip bun/node executable
889
+ const filtered: string[] = [];
890
+ let hasName = false;
891
+ let hasSeed = false;
892
+
893
+ for (let i = 0; i < originalArgs.length; i++) {
894
+ const arg = originalArgs[i];
895
+ if (arg === undefined) continue;
896
+ if (arg === "-d" || arg === "--detach") continue;
897
+ if (arg === "-n" || arg === "--name") hasName = true;
898
+ if (arg === "--seed") hasSeed = true;
899
+ filtered.push(arg);
900
+ }
901
+
902
+ // Inject --name if not explicitly passed (ensures child uses same resolved name)
903
+ if (!hasName) {
904
+ filtered.push("--name", opts.simulationName);
905
+ }
906
+ // Inject --seed to freeze the seed (so child reproduces the same simulation)
907
+ if (!hasSeed) {
908
+ filtered.push("--seed", String(opts.seed));
909
+ }
910
+
911
+ return filtered;
912
+ }
913
+
914
+ async function pollReadiness(
915
+ pid: number,
916
+ adminUrl: string,
917
+ runId: string,
918
+ maxRetries: number,
919
+ intervalMs: number,
920
+ ): Promise<boolean> {
921
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
922
+ await Bun.sleep(intervalMs);
923
+
924
+ if (!isPidAlive(pid)) return false;
925
+
926
+ try {
927
+ // Use /readyz to verify identity via runId. Accept any status (not just "ok")
928
+ // so external-MQTT daemons with slow broker reconnect can still start.
929
+ const response = await fetch(`${adminUrl}/readyz`, {
930
+ signal: AbortSignal.timeout(2_000),
931
+ });
932
+ const body = (await response.json()) as Record<string, unknown>;
933
+ if (body["runId"] === runId) return true;
934
+ } catch {
935
+ // Not ready yet — retry
936
+ }
937
+ }
938
+ return false;
939
+ }
940
+
941
+ function printDetachSummary(entry: DaemonRegistryEntry): void {
942
+ const admin = `http://${displayHost(entry.adminHost)}:${entry.adminPort}`;
943
+ const lines = [
944
+ `Simulation "${entry.name}" started in background (PID ${entry.pid})`,
945
+ "",
946
+ ` Console ${admin}`,
947
+ ];
948
+
949
+ for (const [name, adapter] of Object.entries(entry.adapters) as [
950
+ string,
951
+ { port: number; host: string },
952
+ ][]) {
953
+ const host = displayHost(adapter.host);
954
+ if (name === "mqtt") {
955
+ lines.push(` MQTT mqtt://${host}:${adapter.port}`);
956
+ } else if (name === "opcua") {
957
+ lines.push(` OPC UA opc.tcp://${host}:${adapter.port}`);
958
+ } else {
959
+ lines.push(` ${name} ${host}:${adapter.port}`);
960
+ }
961
+ }
962
+
963
+ lines.push("");
964
+ lines.push(` Use ${colors.cyan(`miravo logs ${entry.name}`)} to view events`);
965
+ lines.push(` Use ${colors.cyan(`miravo stop ${entry.name}`)} to stop`);
966
+
967
+ writeOutput(lines.join("\n"));
968
+ }
969
+
452
970
  // ---------------------------------------------------------------------------
453
971
  // Step 1: Template resolution
454
972
  // ---------------------------------------------------------------------------
455
973
 
974
+ async function peekStateTemplatePath(simulationName: string): Promise<string | undefined> {
975
+ const stateDir = `${resolveMiravoHome()}/state`;
976
+ const file = Bun.file(`${stateDir}/${simulationName}.json`);
977
+ if (!(await file.exists())) return undefined;
978
+ try {
979
+ const parsed = JSON.parse(await file.text()) as unknown;
980
+ const result = PersistedStateSchema.safeParse(parsed);
981
+ if (!result.success) return undefined;
982
+ return result.data.templateRuns.find((r) => r.templatePath.trim().length > 0)?.templatePath;
983
+ } catch {
984
+ return undefined;
985
+ }
986
+ }
987
+
988
+ async function peekSingleStateTemplatePath(): Promise<string | undefined> {
989
+ const stateDir = `${resolveMiravoHome()}/state`;
990
+ let bestTemplatePath: string | undefined;
991
+ let bestSavedAt = "";
992
+ try {
993
+ const glob = new Glob("*.json");
994
+ for await (const file of glob.scan({ cwd: stateDir, onlyFiles: true })) {
995
+ try {
996
+ const parsed = JSON.parse(await Bun.file(`${stateDir}/${file}`).text()) as unknown;
997
+ const result = PersistedStateSchema.safeParse(parsed);
998
+ if (!result.success) continue;
999
+ const templatePath = result.data.templateRuns.find(
1000
+ (r) => r.templatePath.trim().length > 0,
1001
+ )?.templatePath;
1002
+ if (!templatePath) continue;
1003
+ if (result.data.savedAt > bestSavedAt) {
1004
+ bestSavedAt = result.data.savedAt;
1005
+ bestTemplatePath = templatePath;
1006
+ }
1007
+ } catch {}
1008
+ }
1009
+ } catch {
1010
+ return undefined; // state dir doesn't exist yet (first-ever run)
1011
+ }
1012
+ return bestTemplatePath;
1013
+ }
1014
+
456
1015
  async function resolveTemplatePath(
457
1016
  templateOption: string | undefined,
458
1017
  catalog: ContentCatalog,
@@ -626,14 +1185,7 @@ async function loadAndValidateTemplate(
626
1185
  }
627
1186
 
628
1187
  // 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
- }
1188
+ const referencedModelNames = collectReferencedModels(template);
637
1189
 
638
1190
  for (const modelName of referencedModelNames) {
639
1191
  if (models.has(modelName)) continue;
@@ -693,6 +1245,7 @@ export function resolveSimulationName(input: {
693
1245
  function createAndPopulateEngine(
694
1246
  template: MiravoTemplate,
695
1247
  models: Map<string, TwinModel>,
1248
+ catalog: ContentCatalog,
696
1249
  seed: number,
697
1250
  options: StartOptions,
698
1251
  templatePath: string,
@@ -703,8 +1256,10 @@ function createAndPopulateEngine(
703
1256
  const timeMultiplier =
704
1257
  options.speed ?? template.settings?.time_multiplier ?? DEFAULT_TIME_MULTIPLIER;
705
1258
  const overrunThresholdMultiplier = template.settings?.overrun_threshold_multiplier;
1259
+ const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
706
1260
 
707
- const engine = createEngine({
1261
+ let engine!: Engine;
1262
+ engine = createEngine({
708
1263
  simulationName,
709
1264
  seed,
710
1265
  tickIntervalMs,
@@ -714,6 +1269,37 @@ function createAndPopulateEngine(
714
1269
  name: template.name,
715
1270
  path: templatePath,
716
1271
  },
1272
+ beforeRun: async ({ restoredFromPersistence }) => {
1273
+ const protocolBootPlans = resolveProtocolBootPlans({
1274
+ options,
1275
+ optionSources: {
1276
+ mqtt: getCommandOptionSource(options.command, "mqtt"),
1277
+ opcua: getCommandOptionSource(options.command, "opcua"),
1278
+ mqttPort: getCommandOptionSource(options.command, "mqttPort"),
1279
+ mqttHost: getCommandOptionSource(options.command, "mqttHost"),
1280
+ mqttMode: getCommandOptionSource(options.command, "mqttMode"),
1281
+ mqttUrl: getCommandOptionSource(options.command, "mqttUrl"),
1282
+ mqttUsername: getCommandOptionSource(options.command, "mqttUsername"),
1283
+ mqttPassword: getCommandOptionSource(options.command, "mqttPassword"),
1284
+ opcuaPort: getCommandOptionSource(options.command, "opcuaPort"),
1285
+ opcuaHost: getCommandOptionSource(options.command, "opcuaHost"),
1286
+ },
1287
+ restoredFromPersistence,
1288
+ });
1289
+
1290
+ for (const plan of protocolBootPlans) {
1291
+ await reconcileProtocolBootPlan(engine, plan);
1292
+ }
1293
+ },
1294
+ persistence: {
1295
+ enabled: true,
1296
+ resetOnStart: options.resetState ?? false,
1297
+ ensureModelRegistered: (modelName) =>
1298
+ registerModelFromSearchPaths(engine.runtime, modelName, searchPaths),
1299
+ loadTemplateModels: (persistedTemplatePath) => {
1300
+ loadTemplateModels(catalog, persistedTemplatePath, engine.runtime);
1301
+ },
1302
+ },
717
1303
  });
718
1304
 
719
1305
  // Register all models
@@ -721,49 +1307,226 @@ function createAndPopulateEngine(
721
1307
  engine.runtime.registerModel(model);
722
1308
  }
723
1309
 
724
- // Spawn instances from template
725
- for (const area of template.areas) {
726
- for (const line of area.lines) {
727
- for (const twin of line.twins) {
728
- const unsBasePath = buildUNSBasePath(template, area.name, line.name);
1310
+ // Spawn instances from template with boot-time run ID
1311
+ const bootRunId = 1;
1312
+ const allInstanceIds: string[] = [];
1313
+
1314
+ forEachTwin(template, (area, line, twin) => {
1315
+ const unsBasePath = buildUNSBasePath(template, area.name, line.name);
1316
+ const spawned = engine.runtime.spawn(twin.model, twin.count, {
1317
+ variation: twin.variation,
1318
+ unsBasePath,
1319
+ ...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
1320
+ ...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
1321
+ templateRunId: bootRunId,
1322
+ });
1323
+ for (const node of spawned) {
1324
+ allInstanceIds.push(node.id);
1325
+ }
1326
+ });
729
1327
 
730
- engine.runtime.spawn(twin.model, twin.count, {
1328
+ // Register boot-time template as a template run for persistence/unload support
1329
+ engine.registerTemplateRun({
1330
+ templateRunId: bootRunId,
1331
+ templateName: template.name,
1332
+ templatePath,
1333
+ prefix: "boot",
1334
+ instanceIds: allInstanceIds,
1335
+ models: [...models.keys()],
1336
+ });
1337
+
1338
+ return engine;
1339
+ }
1340
+
1341
+ // ---------------------------------------------------------------------------
1342
+ // Runtime template loader (for loadTemplate command from console/API)
1343
+ // ---------------------------------------------------------------------------
1344
+
1345
+ interface CachedTemplate {
1346
+ readonly id: string;
1347
+ readonly name: string;
1348
+ readonly path: string;
1349
+ }
1350
+
1351
+ interface TemplateCacheResult {
1352
+ readonly pathCache: Map<string, CachedTemplate>;
1353
+ readonly catalog: readonly CatalogTemplate[];
1354
+ }
1355
+
1356
+ /**
1357
+ * Discover templates once and build both the path lookup cache (for runtime
1358
+ * template loading) and the catalog (for the REST API) in a single pass.
1359
+ * All data comes from discovery metadata — no additional file I/O.
1360
+ */
1361
+ async function buildTemplateCacheAndCatalog(
1362
+ contentCatalog: ContentCatalog,
1363
+ ): Promise<TemplateCacheResult> {
1364
+ const pathCache = new Map<string, CachedTemplate>();
1365
+ const catalogEntries: CatalogTemplate[] = [];
1366
+ const discovered = await discoverTemplates(contentCatalog.getBuiltinTemplatesDir());
1367
+
1368
+ for (const t of discovered) {
1369
+ const entry: CachedTemplate = { id: t.id, name: t.name, path: t.path };
1370
+ pathCache.set(t.id, entry);
1371
+ pathCache.set(t.name, entry);
1372
+
1373
+ catalogEntries.push({
1374
+ id: t.id,
1375
+ name: t.name,
1376
+ description: t.description ?? null,
1377
+ category: "Industrial",
1378
+ models: t.modelNames,
1379
+ instanceCount: t.totalTwinCount,
1380
+ memberCount: 0,
1381
+ source: "builtin",
1382
+ });
1383
+ }
1384
+
1385
+ return { pathCache, catalog: catalogEntries };
1386
+ }
1387
+
1388
+ function createRuntimeTemplateLoader(
1389
+ catalog: ContentCatalog,
1390
+ templatePathCache: Map<string, CachedTemplate>,
1391
+ ): (templatePath: string, count: number, runId: number, runtime: TwinRuntime) => TemplateRun {
1392
+ return (templateIdentifier, count, runId, runtime) => {
1393
+ // Resolve template path from pre-built cache or as a file path
1394
+ let templatePath: string;
1395
+ let slugName: string;
1396
+ if (isTemplatePathLike(templateIdentifier)) {
1397
+ templatePath = resolve(templateIdentifier);
1398
+ slugName = basename(templatePath).replace(/\.miravo\.yaml$/, "");
1399
+ } else {
1400
+ const cached = templatePathCache.get(templateIdentifier);
1401
+ if (!cached) {
1402
+ const ids = [...new Set([...templatePathCache.values()].map((e) => e.id))];
1403
+ throw new MiravoError(
1404
+ `Template "${templateIdentifier}" not found.`,
1405
+ `Available templates: ${ids.join(", ") || "(none)"}`,
1406
+ );
1407
+ }
1408
+ templatePath = cached.path;
1409
+ slugName = cached.id;
1410
+ }
1411
+
1412
+ const template = loadTemplateSync(templatePath);
1413
+ const referencedModels = registerTemplateModels(catalog, templatePath, template, runtime);
1414
+
1415
+ // Spawn instances with auto-prefix using kebab-case slug
1416
+ const prefix = `${slugName}-${runId}`;
1417
+ const allInstanceIds: string[] = [];
1418
+
1419
+ for (let c = 0; c < count; c++) {
1420
+ const runPrefix = count > 1 ? `${prefix}-${c + 1}` : prefix;
1421
+ forEachTwin(template, (area, line, twin) => {
1422
+ const unsBasePath = `${runPrefix}/${buildUNSBasePath(template, area.name, line.name)}`;
1423
+ const spawned = runtime.spawn(twin.model, twin.count, {
731
1424
  variation: twin.variation,
732
1425
  unsBasePath,
733
1426
  ...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
1427
+ ...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
1428
+ templateRunId: runId,
734
1429
  });
735
- }
1430
+ for (const node of spawned) {
1431
+ allInstanceIds.push(node.id);
1432
+ }
1433
+ });
736
1434
  }
1435
+
1436
+ return {
1437
+ templateRunId: runId,
1438
+ templateName: template.name,
1439
+ templatePath,
1440
+ prefix,
1441
+ instanceIds: allInstanceIds,
1442
+ models: [...referencedModels],
1443
+ };
1444
+ };
1445
+ }
1446
+
1447
+ function loadTemplateSync(templatePath: string): MiravoTemplate {
1448
+ try {
1449
+ return MiravoTemplateSchema.parse(parseYAML(readFileSync(templatePath, "utf-8")));
1450
+ } catch (err) {
1451
+ throw new MiravoError(
1452
+ `Failed to load template "${templatePath}".`,
1453
+ err instanceof Error ? err.message : "Check that the file is valid YAML.",
1454
+ );
737
1455
  }
1456
+ }
738
1457
 
739
- return engine;
1458
+ function registerModelFromSearchPaths(
1459
+ runtime: TwinRuntime,
1460
+ modelName: string,
1461
+ searchPaths: readonly string[],
1462
+ ): void {
1463
+ if (runtime.getModel(modelName)) {
1464
+ return;
1465
+ }
1466
+
1467
+ const { path: modelPath, content: modelContent } = loadModelSync(modelName, searchPaths);
1468
+ try {
1469
+ const model = TwinModelSchema.parse(parseYAML(modelContent));
1470
+ runtime.registerModel(model);
1471
+ } catch (err) {
1472
+ throw new MiravoError(
1473
+ `Failed to load model "${modelName}" from "${modelPath}".`,
1474
+ err instanceof Error ? err.message : "Check that the model file is valid YAML.",
1475
+ );
1476
+ }
1477
+ }
1478
+
1479
+ function loadTemplateModels(
1480
+ catalog: ContentCatalog,
1481
+ templatePath: string,
1482
+ runtime: TwinRuntime,
1483
+ ): readonly string[] {
1484
+ const template = loadTemplateSync(templatePath);
1485
+ return registerTemplateModels(catalog, templatePath, template, runtime);
740
1486
  }
741
1487
 
742
- function buildUNSBasePath(template: MiravoTemplate, areaName: string, lineName: string): string {
743
- const segments: string[] = [];
744
- const ns = template.namespace;
745
- if (ns !== undefined) {
746
- if ("enterprise" in ns) {
747
- segments.push(ns.enterprise, ns.site);
748
- } else if ("prefix" in ns) {
749
- segments.push(ns.prefix);
1488
+ function registerTemplateModels(
1489
+ catalog: ContentCatalog,
1490
+ templatePath: string,
1491
+ template: MiravoTemplate,
1492
+ runtime: TwinRuntime,
1493
+ ): readonly string[] {
1494
+ const referencedModels = collectReferencedModels(template);
1495
+ const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
1496
+ for (const modelName of referencedModels) {
1497
+ registerModelFromSearchPaths(runtime, modelName, searchPaths);
1498
+ }
1499
+ return [...referencedModels];
1500
+ }
1501
+
1502
+ function loadModelSync(
1503
+ modelName: string,
1504
+ searchPaths: readonly string[],
1505
+ ): { path: string; content: string } {
1506
+ for (const dir of searchPaths) {
1507
+ const candidate = resolve(dir, `${modelName}${TWIN_FILE_EXTENSION}`);
1508
+ try {
1509
+ return { path: candidate, content: readFileSync(candidate, "utf-8") };
1510
+ } catch {
1511
+ // File doesn't exist or can't be read — try next path
750
1512
  }
751
1513
  }
752
- segments.push(areaName, lineName);
753
- return segments.join("/");
1514
+ throw new MiravoError(
1515
+ `Model "${modelName}" not found in search paths.`,
1516
+ `Searched: ${searchPaths.join(", ")}. Ensure the model file exists as "${modelName}${TWIN_FILE_EXTENSION}".`,
1517
+ );
754
1518
  }
755
1519
 
756
1520
  // ---------------------------------------------------------------------------
757
- // Step 5: MQTT adapter
1521
+ // Step 5: Adapter default config builders
758
1522
  // ---------------------------------------------------------------------------
759
1523
 
760
- async function startMQTTAdapter(
761
- engine: Engine,
1524
+ function buildMQTTDefaultConfig(
762
1525
  template: MiravoTemplate,
763
1526
  portOverride: number | undefined,
764
1527
  mqttOverrides: MqttRunOverrides,
765
1528
  simulationName: string,
766
- ): Promise<{ adapter: MQTTAdapter; endpoint: string } | null> {
1529
+ ): Record<string, unknown> {
767
1530
  const mqttConfig = template.protocols?.mqtt;
768
1531
  const port = portOverride ?? mqttConfig?.port ?? DEFAULT_MQTT_PORT;
769
1532
  let embedded = mqttConfig?.embedded ?? true;
@@ -780,17 +1543,16 @@ async function startMQTTAdapter(
780
1543
  password = undefined;
781
1544
  }
782
1545
  }
783
- if (mqttOverrides.url !== undefined) {
784
- external = mqttOverrides.url;
785
- }
786
- if (mqttOverrides.host !== undefined) {
787
- host = mqttOverrides.host;
788
- }
789
- if (mqttOverrides.username !== undefined) {
790
- username = mqttOverrides.username;
791
- }
792
- if (mqttOverrides.password !== undefined) {
793
- password = mqttOverrides.password;
1546
+ if (mqttOverrides.url !== undefined) external = mqttOverrides.url;
1547
+ if (mqttOverrides.host !== undefined) host = mqttOverrides.host;
1548
+ if (mqttOverrides.username !== undefined) username = mqttOverrides.username;
1549
+ if (mqttOverrides.password !== undefined) password = mqttOverrides.password;
1550
+
1551
+ if (embedded && (external || username || password)) {
1552
+ throw new ConfigValidationError({
1553
+ message: "Embedded MQTT mode cannot be combined with external MQTT URL or credentials.",
1554
+ fix: "Use --mqtt-mode external for external broker settings, or remove external MQTT overrides.",
1555
+ });
794
1556
  }
795
1557
  if (!embedded && !external) {
796
1558
  throw new ConfigValidationError({
@@ -798,12 +1560,6 @@ async function startMQTTAdapter(
798
1560
  fix: "Set protocols.mqtt.external in template or pass --mqtt-url / MIRAVO_MQTT_URL.",
799
1561
  });
800
1562
  }
801
- if (embedded && (external || username || password)) {
802
- throw new ConfigValidationError({
803
- message: "Embedded MQTT mode cannot be combined with external MQTT URL or credentials.",
804
- fix: "Use --mqtt-mode external for external broker settings, or remove external MQTT overrides.",
805
- });
806
- }
807
1563
  if (password && !username) {
808
1564
  throw new ConfigValidationError({
809
1565
  message: "MQTT password was provided without username.",
@@ -811,92 +1567,70 @@ async function startMQTTAdapter(
811
1567
  });
812
1568
  }
813
1569
 
814
- if (!embedded && !external) {
815
- mqttLog.warn("MQTT disabled — embedded is false with no external broker URL.");
816
- return null;
817
- }
818
-
819
- const endpoint = embedded ? `mqtt://localhost:${port}` : (external as string);
820
- mqttLog.debug(
821
- `starting adapter · mode: ${embedded ? "embedded" : "external"} · endpoint: ${endpoint}`,
822
- );
1570
+ const drainBudget = mqttOverrides.drainBudget ?? mqttConfig?.drain_budget;
1571
+ const batchSize = mqttOverrides.batchSize ?? mqttConfig?.batch_size;
1572
+ const brokerConcurrency = mqttOverrides.brokerConcurrency ?? mqttConfig?.broker_concurrency;
823
1573
 
824
- const adapter = new MQTTAdapter(engine.bus);
825
- try {
826
- const resolvedConfig = {
827
- name: "mqtt" as const,
828
- enabled: true,
829
- simulationName,
830
- embedded,
831
- port,
832
- host,
833
- retain: mqttConfig?.retain ?? false,
834
- ...(external !== undefined ? { external } : {}),
835
- ...(username !== undefined ? { username } : {}),
836
- ...(password !== undefined ? { password } : {}),
837
- ...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
838
- ...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
839
- ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
840
- };
841
-
842
- await adapter.start(resolvedConfig);
843
- engine.registerAdapter(adapter);
844
- mqttLog.ready(`connected · broker: ${colors.cyan(endpoint)}`);
845
- return { adapter, endpoint };
846
- } catch (error) {
847
- const message = error instanceof Error ? error.message : String(error);
848
- const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
849
- const fix = isPortConflict
850
- ? `Port ${port} is in use. Use '--mqtt-port ${port + 1}' or set protocols.mqtt.port in template.`
851
- : "Check the MQTT configuration in your template file.";
852
- throw new MiravoError(`MQTT adapter failed to start: ${message}`, fix);
853
- }
1574
+ return {
1575
+ name: "mqtt",
1576
+ enabled: true,
1577
+ simulationName,
1578
+ embedded,
1579
+ port,
1580
+ host,
1581
+ retain: mqttConfig?.retain ?? false,
1582
+ ...(external !== undefined ? { external } : {}),
1583
+ ...(username !== undefined ? { username } : {}),
1584
+ ...(password !== undefined ? { password } : {}),
1585
+ ...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
1586
+ ...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
1587
+ ...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
1588
+ ...(drainBudget !== undefined ? { drain_budget: drainBudget } : {}),
1589
+ ...(batchSize !== undefined ? { batch_size: batchSize } : {}),
1590
+ ...(brokerConcurrency !== undefined ? { broker_concurrency: brokerConcurrency } : {}),
1591
+ };
854
1592
  }
855
1593
 
856
- // ---------------------------------------------------------------------------
857
- // Step 5a: OPC UA adapter
858
- // ---------------------------------------------------------------------------
859
-
860
- async function startOPCUAAdapter(
861
- engine: Engine,
1594
+ function buildOPCUADefaultConfig(
862
1595
  template: MiravoTemplate,
863
1596
  portOverride: number | undefined,
864
1597
  hostOverride: string | undefined,
865
1598
  simulationName: string,
866
- ): Promise<{ adapter: OPCUAAdapter; endpoint: string } | null> {
1599
+ writeBudgetOverride?: number,
1600
+ ): Record<string, unknown> {
867
1601
  const opcuaConfig = template.protocols?.opcua;
868
-
869
1602
  const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
870
1603
  const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
1604
+ const writeBudget = writeBudgetOverride ?? opcuaConfig?.write_budget;
1605
+
1606
+ return {
1607
+ name: "opcua",
1608
+ enabled: true,
1609
+ port,
1610
+ host,
1611
+ security: opcuaConfig?.security ?? "none",
1612
+ simulationName,
1613
+ ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
1614
+ ...(writeBudget !== undefined ? { write_budget: writeBudget } : {}),
1615
+ };
1616
+ }
871
1617
 
872
- opcuaLog.debug(`starting adapter · endpoint: opc.tcp://${host}:${port}`);
873
-
874
- const opcuaAdapter = new OPCUAAdapter(engine.bus, engine);
875
- try {
876
- const resolvedConfig = {
877
- name: "opcua" as const,
878
- enabled: true,
879
- port,
880
- host,
881
- security: opcuaConfig?.security ?? "none",
882
- simulationName,
883
- ...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
884
- };
885
-
886
- await opcuaAdapter.start(resolvedConfig);
887
- engine.registerAdapter(opcuaAdapter);
1618
+ /** Derive the MQTT endpoint string from a resolved adapter config. */
1619
+ function buildMQTTEndpoint(config: import("@miravo/shared").ProtocolConfig): string {
1620
+ const cfg = config as Record<string, unknown>;
1621
+ const port = cfg["port"] ?? DEFAULT_MQTT_PORT;
1622
+ const embedded = cfg["embedded"] ?? true;
1623
+ const external = cfg["external"] as string | undefined;
1624
+ return embedded ? `mqtt://localhost:${port}` : (external as string);
1625
+ }
888
1626
 
889
- const endpoint = `opc.tcp://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
890
- opcuaLog.ready(`server ready · endpoint: ${colors.cyan(endpoint)}`);
891
- return { adapter: opcuaAdapter, endpoint };
892
- } catch (error) {
893
- const message = error instanceof Error ? error.message : String(error);
894
- const isPortConflict = message.includes("EADDRINUSE") || message.includes("in use");
895
- const fix = isPortConflict
896
- ? `Port ${port} is in use. Use '--opcua-port ${port + 1}'.`
897
- : "Check the OPC UA port/network settings and retry.";
898
- throw new MiravoError(`OPC UA adapter failed to start: ${message}`, fix);
899
- }
1627
+ /** Derive the OPC UA endpoint string from a resolved adapter config. */
1628
+ function buildOPCUAEndpoint(config: import("@miravo/shared").ProtocolConfig): string {
1629
+ const cfg = config as Record<string, unknown>;
1630
+ const port = cfg["port"] ?? DEFAULT_OPCUA_PORT;
1631
+ const host = cfg["host"] ?? "0.0.0.0";
1632
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
1633
+ return `opc.tcp://${displayHost}:${port}`;
900
1634
  }
901
1635
 
902
1636
  // ---------------------------------------------------------------------------
@@ -1022,6 +1756,17 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1022
1756
  engineLog.debug(event.message, { context: event.context });
1023
1757
  };
1024
1758
 
1759
+ const onReset = (event: {
1760
+ instanceCount: number;
1761
+ templateRunCount: number;
1762
+ clearPersistence: boolean;
1763
+ }): void => {
1764
+ const suffix = event.clearPersistence ? " · saved state cleared" : "";
1765
+ engineLog.info(
1766
+ `simulation reset · ${colors.bold(String(event.instanceCount))} instances · ${colors.bold(String(event.templateRunCount))} template runs${suffix}`,
1767
+ );
1768
+ };
1769
+
1025
1770
  const onError = (event: { context: string; error: Error }): void => {
1026
1771
  incrementErrorContextCount(errorContextCounts, event.context);
1027
1772
  engineLog.error(
@@ -1040,6 +1785,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1040
1785
  engine.bus.on("fault:cleared", onFaultCleared);
1041
1786
  engine.bus.on("engine:warn", onWarn);
1042
1787
  engine.bus.on("engine:error", onError);
1788
+ engine.bus.on("engine:reset", onReset);
1043
1789
 
1044
1790
  return () => {
1045
1791
  engine.bus.off("lifecycle:changed", onLifecycle);
@@ -1047,6 +1793,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
1047
1793
  engine.bus.off("fault:cleared", onFaultCleared);
1048
1794
  engine.bus.off("engine:warn", onWarn);
1049
1795
  engine.bus.off("engine:error", onError);
1796
+ engine.bus.off("engine:reset", onReset);
1050
1797
  };
1051
1798
  }
1052
1799
 
@@ -1072,6 +1819,113 @@ export function incrementErrorContextCount(counts: Map<string, number>, rawConte
1072
1819
  counts.set(ERROR_CONTEXT_OVERFLOW, (counts.get(ERROR_CONTEXT_OVERFLOW) ?? 0) + 1);
1073
1820
  }
1074
1821
 
1822
+ function getCommandOptionSource(
1823
+ command: Command | undefined,
1824
+ optionName: ProtocolOptionSource,
1825
+ ): string | undefined {
1826
+ const maybeGetOptionValueSource = (
1827
+ command as Command & { getOptionValueSource?: (key: string) => string | undefined }
1828
+ )?.getOptionValueSource;
1829
+ return maybeGetOptionValueSource?.call(command, optionName);
1830
+ }
1831
+
1832
+ export function resolveProtocolBootPlans(input: {
1833
+ readonly options: StartOptions & { readonly command?: Command | undefined };
1834
+ readonly optionSources: Partial<Record<ProtocolOptionSource, string | undefined>>;
1835
+ readonly restoredFromPersistence: boolean;
1836
+ }): readonly ProtocolBootPlan[] {
1837
+ const mqttConfigExplicit = [
1838
+ "mqttPort",
1839
+ "mqttHost",
1840
+ "mqttMode",
1841
+ "mqttUrl",
1842
+ "mqttUsername",
1843
+ "mqttPassword",
1844
+ ].some((option) => input.optionSources[option as ProtocolOptionSource] === "cli");
1845
+
1846
+ const opcuaConfigExplicit =
1847
+ input.options.opcua === true &&
1848
+ ["opcuaPort", "opcuaHost"].some(
1849
+ (option) => input.optionSources[option as ProtocolOptionSource] === "cli",
1850
+ );
1851
+
1852
+ return [
1853
+ {
1854
+ name: "mqtt",
1855
+ shouldEnable: input.options.mqtt !== false,
1856
+ overrideRestore:
1857
+ !input.restoredFromPersistence ||
1858
+ input.optionSources["mqtt"] === "cli" ||
1859
+ mqttConfigExplicit,
1860
+ },
1861
+ {
1862
+ name: "opcua",
1863
+ shouldEnable: input.options.opcua === true,
1864
+ overrideRestore:
1865
+ !input.restoredFromPersistence ||
1866
+ input.optionSources["opcua"] === "cli" ||
1867
+ opcuaConfigExplicit,
1868
+ },
1869
+ ];
1870
+ }
1871
+
1872
+ async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan): Promise<void> {
1873
+ const adapter = engine.getAvailableAdapters().find((candidate) => candidate.name === plan.name);
1874
+ const isEnabled = adapter?.enabled === true;
1875
+
1876
+ if (!plan.shouldEnable) {
1877
+ if (!isEnabled || !plan.overrideRestore) {
1878
+ return;
1879
+ }
1880
+
1881
+ const result = await engine.disableAdapter(plan.name);
1882
+ if (!result.success) {
1883
+ throw new MiravoError(
1884
+ result.message,
1885
+ result.error ?? `Disable adapter "${plan.name}" manually and retry startup.`,
1886
+ );
1887
+ }
1888
+ return;
1889
+ }
1890
+
1891
+ if (plan.overrideRestore && isEnabled) {
1892
+ const disableResult = await engine.disableAdapter(plan.name);
1893
+ if (!disableResult.success) {
1894
+ throw new MiravoError(
1895
+ disableResult.message,
1896
+ disableResult.error ?? `Disable adapter "${plan.name}" manually and retry startup.`,
1897
+ );
1898
+ }
1899
+ if ((disableResult.data as { stopWarning?: boolean } | undefined)?.stopWarning === true) {
1900
+ throw new MiravoError(
1901
+ `Failed to safely reconfigure adapter "${plan.name}" during startup.`,
1902
+ `The previous ${plan.name.toUpperCase()} adapter instance did not stop cleanly. Free the port or clear the persisted adapter state before retrying.`,
1903
+ );
1904
+ }
1905
+ } else if (isEnabled) {
1906
+ return;
1907
+ }
1908
+
1909
+ const result = await engine.enableAdapter(plan.name);
1910
+ if (!result.success) {
1911
+ throw new MiravoError(
1912
+ result.message,
1913
+ result.error ?? `Check ${plan.name.toUpperCase()} configuration.`,
1914
+ );
1915
+ }
1916
+
1917
+ if (plan.name === "mqtt") {
1918
+ mqttLog.ready(
1919
+ `connected · broker: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
1920
+ );
1921
+ return;
1922
+ }
1923
+
1924
+ opcuaLog.ready(
1925
+ `server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
1926
+ );
1927
+ }
1928
+
1075
1929
  function startPeriodicSummary(
1076
1930
  engine: Engine,
1077
1931
  intervalSeconds: number,
@@ -1135,17 +1989,13 @@ function printDryRunSummary(
1135
1989
  // Instance summary
1136
1990
  let totalInstances = 0;
1137
1991
  let totalMembers = 0;
1138
- for (const area of template.areas) {
1139
- for (const line of area.lines) {
1140
- for (const twin of line.twins) {
1141
- totalInstances += twin.count;
1142
- const model = models.get(twin.model);
1143
- if (model) {
1144
- totalMembers += twin.count * Object.keys(model.members).length;
1145
- }
1146
- }
1992
+ forEachTwin(template, (_area, _line, twin) => {
1993
+ totalInstances += twin.count;
1994
+ const model = models.get(twin.model);
1995
+ if (model) {
1996
+ totalMembers += twin.count * Object.keys(model.members).length;
1147
1997
  }
1148
- }
1998
+ });
1149
1999
 
1150
2000
  lines.push("");
1151
2001
  lines.push(