miravo 0.2.3 → 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.
- package/README.md +116 -30
- package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
- package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
- package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
- package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
- package/console-dist/assets/index-BXtW73cF.css +2 -0
- package/console-dist/assets/index-ChSocZFW.js +119 -0
- package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
- package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
- package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
- package/console-dist/favicon.svg +20 -0
- package/console-dist/index.html +14 -0
- package/console-dist/miravo-logo.png +0 -0
- package/package.json +10 -7
- package/src/__tests__/integration/executable-methods-runner.ts +52 -45
- package/src/__tests__/integration/executable-methods.integration.ts +609 -47
- package/src/commands/adapter.ts +76 -0
- package/src/commands/cli-test-setup.ts +1 -1
- package/src/commands/fork.ts +14 -0
- package/src/commands/inject.test.ts +1 -1
- package/src/commands/inject.ts +16 -8
- package/src/commands/load.ts +167 -0
- package/src/commands/logs.ts +175 -0
- package/src/commands/models.test.ts +22 -1
- package/src/commands/models.ts +14 -0
- package/src/commands/pause.ts +67 -0
- package/src/commands/ps.ts +116 -0
- package/src/commands/remove.ts +38 -0
- package/src/commands/reset.ts +44 -0
- package/src/commands/rm.ts +97 -0
- package/src/commands/spawn.ts +45 -0
- package/src/commands/speed.ts +40 -0
- package/src/commands/start.test.ts +93 -0
- package/src/commands/start.ts +974 -192
- package/src/commands/status.test.ts +3 -1
- package/src/commands/status.ts +12 -6
- package/src/commands/stop.test.ts +84 -0
- package/src/commands/stop.ts +158 -0
- package/src/commands/templates.test.ts +1 -1
- package/src/commands/templates.ts +13 -0
- package/src/index.ts +49 -7
- package/src/lib/admin-client.test.ts +35 -0
- package/src/lib/admin-client.ts +13 -1
- package/src/lib/admin-server.test.ts +339 -53
- package/src/lib/admin-server.ts +413 -62
- package/src/lib/daemon-registry.test.ts +429 -0
- package/src/lib/daemon-registry.ts +345 -0
- package/src/lib/mqtt-client.ts +1 -1
- package/src/lib/parse.ts +18 -0
- package/src/lib/resolve-simulation.ts +1 -1
- package/src/lib/shutdown.test.ts +16 -4
- package/src/lib/shutdown.ts +3 -3
- package/src/lib/template-category.test.ts +1 -0
- package/src/lib/template-utils.ts +33 -2
- package/src/lib/ws-hub.ts +255 -0
- package/src/logger.test.ts +19 -19
- package/src/logger.ts +34 -6
package/src/commands/start.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
109
|
-
.option("-n, --name <name>", "Simulation 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
|
|
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, --
|
|
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
|
-
.
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
//
|
|
380
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
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
|
-
|
|
674
|
+
eng,
|
|
409
675
|
seed,
|
|
410
676
|
seedExplicit,
|
|
411
|
-
|
|
412
|
-
|
|
677
|
+
mqttEndpoint,
|
|
678
|
+
opcuaEndpoint,
|
|
413
679
|
simulationName,
|
|
414
680
|
runId,
|
|
415
681
|
adminServer.endpoint,
|
|
416
682
|
);
|
|
417
683
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 =
|
|
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,
|
|
@@ -703,8 +1190,10 @@ function createAndPopulateEngine(
|
|
|
703
1190
|
const timeMultiplier =
|
|
704
1191
|
options.speed ?? template.settings?.time_multiplier ?? DEFAULT_TIME_MULTIPLIER;
|
|
705
1192
|
const overrunThresholdMultiplier = template.settings?.overrun_threshold_multiplier;
|
|
1193
|
+
const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
|
|
706
1194
|
|
|
707
|
-
|
|
1195
|
+
let engine!: Engine;
|
|
1196
|
+
engine = createEngine({
|
|
708
1197
|
simulationName,
|
|
709
1198
|
seed,
|
|
710
1199
|
tickIntervalMs,
|
|
@@ -714,6 +1203,37 @@ function createAndPopulateEngine(
|
|
|
714
1203
|
name: template.name,
|
|
715
1204
|
path: templatePath,
|
|
716
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
|
+
},
|
|
717
1237
|
});
|
|
718
1238
|
|
|
719
1239
|
// Register all models
|
|
@@ -721,49 +1241,224 @@ function createAndPopulateEngine(
|
|
|
721
1241
|
engine.runtime.registerModel(model);
|
|
722
1242
|
}
|
|
723
1243
|
|
|
724
|
-
// Spawn instances from template
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
+
}
|
|
729
1320
|
|
|
730
|
-
|
|
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, {
|
|
731
1357
|
variation: twin.variation,
|
|
732
1358
|
unsBasePath,
|
|
733
1359
|
...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
|
|
1360
|
+
templateRunId: runId,
|
|
734
1361
|
});
|
|
735
|
-
|
|
1362
|
+
for (const node of spawned) {
|
|
1363
|
+
allInstanceIds.push(node.id);
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
736
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
|
+
);
|
|
737
1387
|
}
|
|
1388
|
+
}
|
|
738
1389
|
|
|
739
|
-
|
|
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];
|
|
740
1432
|
}
|
|
741
1433
|
|
|
742
|
-
function
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
|
750
1444
|
}
|
|
751
1445
|
}
|
|
752
|
-
|
|
753
|
-
|
|
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
|
+
);
|
|
754
1450
|
}
|
|
755
1451
|
|
|
756
1452
|
// ---------------------------------------------------------------------------
|
|
757
|
-
// Step 5:
|
|
1453
|
+
// Step 5: Adapter default config builders
|
|
758
1454
|
// ---------------------------------------------------------------------------
|
|
759
1455
|
|
|
760
|
-
|
|
761
|
-
engine: Engine,
|
|
1456
|
+
function buildMQTTDefaultConfig(
|
|
762
1457
|
template: MiravoTemplate,
|
|
763
1458
|
portOverride: number | undefined,
|
|
764
1459
|
mqttOverrides: MqttRunOverrides,
|
|
765
1460
|
simulationName: string,
|
|
766
|
-
):
|
|
1461
|
+
): Record<string, unknown> {
|
|
767
1462
|
const mqttConfig = template.protocols?.mqtt;
|
|
768
1463
|
const port = portOverride ?? mqttConfig?.port ?? DEFAULT_MQTT_PORT;
|
|
769
1464
|
let embedded = mqttConfig?.embedded ?? true;
|
|
@@ -780,17 +1475,16 @@ async function startMQTTAdapter(
|
|
|
780
1475
|
password = undefined;
|
|
781
1476
|
}
|
|
782
1477
|
}
|
|
783
|
-
if (mqttOverrides.url !== undefined)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
if (mqttOverrides.
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
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
|
+
});
|
|
794
1488
|
}
|
|
795
1489
|
if (!embedded && !external) {
|
|
796
1490
|
throw new ConfigValidationError({
|
|
@@ -798,12 +1492,6 @@ async function startMQTTAdapter(
|
|
|
798
1492
|
fix: "Set protocols.mqtt.external in template or pass --mqtt-url / MIRAVO_MQTT_URL.",
|
|
799
1493
|
});
|
|
800
1494
|
}
|
|
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
1495
|
if (password && !username) {
|
|
808
1496
|
throw new ConfigValidationError({
|
|
809
1497
|
message: "MQTT password was provided without username.",
|
|
@@ -811,92 +1499,70 @@ async function startMQTTAdapter(
|
|
|
811
1499
|
});
|
|
812
1500
|
}
|
|
813
1501
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
-
);
|
|
823
|
-
|
|
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
|
-
};
|
|
1502
|
+
const drainBudget = mqttOverrides.drainBudget ?? mqttConfig?.drain_budget;
|
|
1503
|
+
const batchSize = mqttOverrides.batchSize ?? mqttConfig?.batch_size;
|
|
1504
|
+
const brokerConcurrency = mqttOverrides.brokerConcurrency ?? mqttConfig?.broker_concurrency;
|
|
841
1505
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
+
};
|
|
854
1524
|
}
|
|
855
1525
|
|
|
856
|
-
|
|
857
|
-
// Step 5a: OPC UA adapter
|
|
858
|
-
// ---------------------------------------------------------------------------
|
|
859
|
-
|
|
860
|
-
async function startOPCUAAdapter(
|
|
861
|
-
engine: Engine,
|
|
1526
|
+
function buildOPCUADefaultConfig(
|
|
862
1527
|
template: MiravoTemplate,
|
|
863
1528
|
portOverride: number | undefined,
|
|
864
1529
|
hostOverride: string | undefined,
|
|
865
1530
|
simulationName: string,
|
|
866
|
-
|
|
1531
|
+
writeBudgetOverride?: number,
|
|
1532
|
+
): Record<string, unknown> {
|
|
867
1533
|
const opcuaConfig = template.protocols?.opcua;
|
|
868
|
-
|
|
869
1534
|
const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
|
|
870
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
|
+
}
|
|
871
1549
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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);
|
|
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
|
+
}
|
|
888
1558
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
}
|
|
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}`;
|
|
900
1566
|
}
|
|
901
1567
|
|
|
902
1568
|
// ---------------------------------------------------------------------------
|
|
@@ -1022,6 +1688,17 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
1022
1688
|
engineLog.debug(event.message, { context: event.context });
|
|
1023
1689
|
};
|
|
1024
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
|
+
|
|
1025
1702
|
const onError = (event: { context: string; error: Error }): void => {
|
|
1026
1703
|
incrementErrorContextCount(errorContextCounts, event.context);
|
|
1027
1704
|
engineLog.error(
|
|
@@ -1040,6 +1717,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
1040
1717
|
engine.bus.on("fault:cleared", onFaultCleared);
|
|
1041
1718
|
engine.bus.on("engine:warn", onWarn);
|
|
1042
1719
|
engine.bus.on("engine:error", onError);
|
|
1720
|
+
engine.bus.on("engine:reset", onReset);
|
|
1043
1721
|
|
|
1044
1722
|
return () => {
|
|
1045
1723
|
engine.bus.off("lifecycle:changed", onLifecycle);
|
|
@@ -1047,6 +1725,7 @@ function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, numbe
|
|
|
1047
1725
|
engine.bus.off("fault:cleared", onFaultCleared);
|
|
1048
1726
|
engine.bus.off("engine:warn", onWarn);
|
|
1049
1727
|
engine.bus.off("engine:error", onError);
|
|
1728
|
+
engine.bus.off("engine:reset", onReset);
|
|
1050
1729
|
};
|
|
1051
1730
|
}
|
|
1052
1731
|
|
|
@@ -1072,6 +1751,113 @@ export function incrementErrorContextCount(counts: Map<string, number>, rawConte
|
|
|
1072
1751
|
counts.set(ERROR_CONTEXT_OVERFLOW, (counts.get(ERROR_CONTEXT_OVERFLOW) ?? 0) + 1);
|
|
1073
1752
|
}
|
|
1074
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
|
+
|
|
1075
1861
|
function startPeriodicSummary(
|
|
1076
1862
|
engine: Engine,
|
|
1077
1863
|
intervalSeconds: number,
|
|
@@ -1135,17 +1921,13 @@ function printDryRunSummary(
|
|
|
1135
1921
|
// Instance summary
|
|
1136
1922
|
let totalInstances = 0;
|
|
1137
1923
|
let totalMembers = 0;
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
if (model) {
|
|
1144
|
-
totalMembers += twin.count * Object.keys(model.members).length;
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
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;
|
|
1147
1929
|
}
|
|
1148
|
-
}
|
|
1930
|
+
});
|
|
1149
1931
|
|
|
1150
1932
|
lines.push("");
|
|
1151
1933
|
lines.push(
|