miravo 0.5.0 → 0.6.1
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/LICENSE +199 -0
- package/README.md +7 -70
- package/bin/miravo.js +84 -0
- package/package.json +20 -36
- 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-C1oOHU1J.js +0 -119
- package/console-dist/assets/index-C3XMYq26.css +0 -2
- 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 +0 -37
- package/console-dist/index.html +0 -14
- package/console-dist/miravo-logo.png +0 -0
- package/src/__tests__/integration/executable-methods-runner.ts +0 -261
- package/src/__tests__/integration/executable-methods.integration.ts +0 -2256
- package/src/commands/adapter.ts +0 -76
- package/src/commands/cli-test-setup.ts +0 -91
- package/src/commands/fork.ts +0 -219
- package/src/commands/inject.test.ts +0 -57
- package/src/commands/inject.ts +0 -212
- package/src/commands/load.ts +0 -167
- package/src/commands/logs.ts +0 -175
- package/src/commands/models.test.ts +0 -162
- package/src/commands/models.ts +0 -338
- package/src/commands/pause.ts +0 -67
- package/src/commands/ps.ts +0 -116
- package/src/commands/remove.ts +0 -38
- package/src/commands/reset.ts +0 -44
- package/src/commands/rm.ts +0 -97
- package/src/commands/spawn.ts +0 -45
- package/src/commands/speed.ts +0 -40
- package/src/commands/start.test.ts +0 -481
- package/src/commands/start.ts +0 -2253
- package/src/commands/status.test.ts +0 -20
- package/src/commands/status.ts +0 -470
- package/src/commands/stop.test.ts +0 -84
- package/src/commands/stop.ts +0 -158
- package/src/commands/templates.test.ts +0 -58
- package/src/commands/templates.ts +0 -279
- package/src/index.ts +0 -158
- package/src/lib/admin-client.test.ts +0 -35
- package/src/lib/admin-client.ts +0 -226
- package/src/lib/admin-server.test.ts +0 -562
- package/src/lib/admin-server.ts +0 -658
- package/src/lib/daemon-registry.test.ts +0 -429
- package/src/lib/daemon-registry.ts +0 -345
- package/src/lib/mqtt-client.ts +0 -116
- package/src/lib/parse.test.ts +0 -123
- package/src/lib/parse.ts +0 -87
- package/src/lib/resolve-simulation.test.ts +0 -118
- package/src/lib/resolve-simulation.ts +0 -186
- package/src/lib/shutdown.test.ts +0 -380
- package/src/lib/shutdown.ts +0 -561
- package/src/lib/template-category.test.ts +0 -71
- package/src/lib/template-category.ts +0 -72
- package/src/lib/template-locator.ts +0 -49
- package/src/lib/template-utils.ts +0 -45
- package/src/lib/ws-hub.ts +0 -255
- package/src/logger.test.ts +0 -172
- package/src/logger.ts +0 -394
- package/src/output.test.ts +0 -64
- package/src/output.ts +0 -157
package/src/commands/start.ts
DELETED
|
@@ -1,2253 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { basename, resolve } from "node:path";
|
|
3
|
-
import type { Option as ClackOption } from "@clack/prompts";
|
|
4
|
-
import { isCancel, select } from "@clack/prompts";
|
|
5
|
-
import type { ContentCatalog, ControlPlane, Engine, TwinRuntime } from "@miravo/core";
|
|
6
|
-
import {
|
|
7
|
-
createControlPlane,
|
|
8
|
-
createEngine,
|
|
9
|
-
discoverTemplates,
|
|
10
|
-
loadTemplate,
|
|
11
|
-
loadTwinModel,
|
|
12
|
-
resolveMiravoHome,
|
|
13
|
-
resolveModelPath,
|
|
14
|
-
validateFullTemplate,
|
|
15
|
-
} from "@miravo/core";
|
|
16
|
-
import { ModbusAdapter } from "@miravo/modbus";
|
|
17
|
-
import { MQTTAdapter } from "@miravo/mqtt";
|
|
18
|
-
import { OPCUAAdapter } from "@miravo/opcua";
|
|
19
|
-
import type {
|
|
20
|
-
CatalogTemplate,
|
|
21
|
-
DiscoveredTemplate,
|
|
22
|
-
MiravoTemplate,
|
|
23
|
-
TemplateRun,
|
|
24
|
-
TwinModel,
|
|
25
|
-
} from "@miravo/shared";
|
|
26
|
-
import {
|
|
27
|
-
ConfigNotFoundError,
|
|
28
|
-
ConfigValidationError,
|
|
29
|
-
DEFAULT_MODBUS_PORT,
|
|
30
|
-
DEFAULT_MQTT_PORT,
|
|
31
|
-
DEFAULT_OPCUA_PORT,
|
|
32
|
-
DEFAULT_TICK_INTERVAL_MS,
|
|
33
|
-
DEFAULT_TIME_MULTIPLIER,
|
|
34
|
-
MIRAVO_VERSION,
|
|
35
|
-
MiravoError,
|
|
36
|
-
MiravoTemplateSchema,
|
|
37
|
-
PersistedStateSchema,
|
|
38
|
-
SimulationNameSchema,
|
|
39
|
-
TWIN_FILE_EXTENSION,
|
|
40
|
-
TwinModelSchema,
|
|
41
|
-
} from "@miravo/shared";
|
|
42
|
-
import { Glob } from "bun";
|
|
43
|
-
import type { Command } from "commander";
|
|
44
|
-
import { box, colors, stripAnsi } from "consola/utils";
|
|
45
|
-
import { parse as parseYAML } from "yaml";
|
|
46
|
-
import { startAdminServer } from "../lib/admin-server.ts";
|
|
47
|
-
import type { DaemonRegistryEntry } from "../lib/daemon-registry.ts";
|
|
48
|
-
import {
|
|
49
|
-
acquireNameLock,
|
|
50
|
-
checkNameConflict,
|
|
51
|
-
checkPortConflicts,
|
|
52
|
-
displayHost,
|
|
53
|
-
isPidAlive,
|
|
54
|
-
writeEntry,
|
|
55
|
-
} from "../lib/daemon-registry.ts";
|
|
56
|
-
import {
|
|
57
|
-
parseDurationMs,
|
|
58
|
-
parseNonNegativeInt,
|
|
59
|
-
parsePort,
|
|
60
|
-
parsePositiveFloat,
|
|
61
|
-
parsePositiveInt,
|
|
62
|
-
} from "../lib/parse.ts";
|
|
63
|
-
import { createShutdownController, printSummaryLine } from "../lib/shutdown.ts";
|
|
64
|
-
import {
|
|
65
|
-
groupTemplatesByCategory,
|
|
66
|
-
isStressBenchmarkTemplate,
|
|
67
|
-
sortTemplatesByCategoryAndName,
|
|
68
|
-
} from "../lib/template-category.ts";
|
|
69
|
-
import {
|
|
70
|
-
createCatalog,
|
|
71
|
-
discoverPreferredLocalTemplates,
|
|
72
|
-
isTemplatePathLike,
|
|
73
|
-
resolveTemplateIdentifier,
|
|
74
|
-
} from "../lib/template-locator.ts";
|
|
75
|
-
import { buildUNSBasePath, collectReferencedModels, forEachTwin } from "../lib/template-utils.ts";
|
|
76
|
-
import {
|
|
77
|
-
clearRuntimeLogContext,
|
|
78
|
-
configLog,
|
|
79
|
-
controlLog,
|
|
80
|
-
engineLog,
|
|
81
|
-
faultLog,
|
|
82
|
-
handleCommandError,
|
|
83
|
-
isPlain,
|
|
84
|
-
lifecycleLog,
|
|
85
|
-
modbusLog,
|
|
86
|
-
mqttLog,
|
|
87
|
-
opcuaLog,
|
|
88
|
-
setRuntimeLogContext,
|
|
89
|
-
writeOutput,
|
|
90
|
-
} from "../logger.ts";
|
|
91
|
-
import { formatKeyValue, formatTable } from "../output.ts";
|
|
92
|
-
|
|
93
|
-
interface StartOptions {
|
|
94
|
-
readonly name?: string;
|
|
95
|
-
readonly template?: string;
|
|
96
|
-
readonly modelsDir?: string;
|
|
97
|
-
readonly speed?: number;
|
|
98
|
-
readonly seed?: number;
|
|
99
|
-
readonly tick?: number;
|
|
100
|
-
readonly mqttPort?: number;
|
|
101
|
-
readonly mqttHost?: string;
|
|
102
|
-
readonly mqttMode?: string;
|
|
103
|
-
readonly mqttUrl?: string;
|
|
104
|
-
readonly mqttUsername?: string;
|
|
105
|
-
readonly mqttPassword?: string;
|
|
106
|
-
readonly mqtt?: boolean;
|
|
107
|
-
readonly opcuaPort?: number;
|
|
108
|
-
readonly opcuaHost?: string;
|
|
109
|
-
readonly opcua?: boolean;
|
|
110
|
-
readonly modbusPort?: number;
|
|
111
|
-
readonly modbusHost?: string;
|
|
112
|
-
readonly modbus?: boolean;
|
|
113
|
-
readonly adminHost?: string;
|
|
114
|
-
readonly adminPort?: number;
|
|
115
|
-
readonly dryRun?: boolean;
|
|
116
|
-
readonly summaryInterval?: number;
|
|
117
|
-
readonly noUi?: boolean;
|
|
118
|
-
readonly resetState?: boolean;
|
|
119
|
-
readonly detach?: boolean;
|
|
120
|
-
readonly command?: Command;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
interface RestoreSummary {
|
|
124
|
-
readonly instanceCount: number;
|
|
125
|
-
readonly templateRunCount: number;
|
|
126
|
-
readonly adapterCount: number;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
interface ProtocolBootPlan {
|
|
130
|
-
readonly name: "mqtt" | "opcua" | "modbus";
|
|
131
|
-
readonly shouldEnable: boolean;
|
|
132
|
-
readonly overrideRestore: boolean;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
type ProtocolOptionSource =
|
|
136
|
-
| "mqtt"
|
|
137
|
-
| "opcua"
|
|
138
|
-
| "modbus"
|
|
139
|
-
| "mqttPort"
|
|
140
|
-
| "mqttHost"
|
|
141
|
-
| "mqttMode"
|
|
142
|
-
| "mqttUrl"
|
|
143
|
-
| "mqttUsername"
|
|
144
|
-
| "mqttPassword"
|
|
145
|
-
| "opcuaPort"
|
|
146
|
-
| "opcuaHost"
|
|
147
|
-
| "modbusPort"
|
|
148
|
-
| "modbusHost";
|
|
149
|
-
|
|
150
|
-
type MqttMode = "embedded" | "external";
|
|
151
|
-
|
|
152
|
-
/** Resolve the path to the built console dist directory. Returns undefined if not found. */
|
|
153
|
-
function resolveConsoleDir(): string | undefined {
|
|
154
|
-
// 1. Compiled standalone binary: Bun.main resolves to a /$bunfs/ virtual path.
|
|
155
|
-
// Look for console-dist/ packaged alongside the binary.
|
|
156
|
-
if (Bun.main.startsWith("/$bunfs/")) {
|
|
157
|
-
const binaryDir = new URL(".", `file://${process.execPath}`).pathname.replace(/\/$/, "");
|
|
158
|
-
const sibling = `${binaryDir}/console-dist`;
|
|
159
|
-
if (Bun.file(`${sibling}/index.html`).size > 0) return sibling;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// 2. Dev / Docker: resolve via workspace package (@miravo/console is a devDependency,
|
|
163
|
-
// available in workspace but not when installed from npm).
|
|
164
|
-
try {
|
|
165
|
-
const resolved = import.meta.resolve("@miravo/console");
|
|
166
|
-
const resolvedDir = new URL(".", resolved).pathname.replace(/\/$/, "");
|
|
167
|
-
if (resolvedDir.endsWith("/dist")) return resolvedDir;
|
|
168
|
-
return `${resolvedDir}/dist`;
|
|
169
|
-
} catch {
|
|
170
|
-
// @miravo/console not installed (npm/bunx) — fall through
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// 3. Docker / monorepo: workspace symlinks may not exist (production install).
|
|
174
|
-
// start.ts is at packages/cli/src/commands/ → ../../../console/dist
|
|
175
|
-
const monorepo = resolve(import.meta.dir, "..", "..", "..", "console", "dist");
|
|
176
|
-
if (existsSync(`${monorepo}/index.html`)) return monorepo;
|
|
177
|
-
|
|
178
|
-
// 4. npm install / bunx: look for console-dist/ shipped inside the published package.
|
|
179
|
-
// start.ts is at packages/cli/src/commands/ → ../../console-dist/
|
|
180
|
-
const shipped = resolve(import.meta.dir, "..", "..", "console-dist");
|
|
181
|
-
if (existsSync(`${shipped}/index.html`)) return shipped;
|
|
182
|
-
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
interface MqttRunOverrides {
|
|
187
|
-
readonly mode?: MqttMode;
|
|
188
|
-
readonly host?: string;
|
|
189
|
-
readonly url?: string;
|
|
190
|
-
readonly username?: string;
|
|
191
|
-
readonly password?: string;
|
|
192
|
-
readonly drainBudget?: number;
|
|
193
|
-
readonly batchSize?: number;
|
|
194
|
-
readonly brokerConcurrency?: number;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const MAX_ERROR_CONTEXT_KEYS = 256;
|
|
198
|
-
const ERROR_CONTEXT_OVERFLOW = "__overflow__";
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Register the `miravo start` command with the root Commander program.
|
|
202
|
-
* Resolves template, creates the engine, starts MQTT, and runs the simulation loop.
|
|
203
|
-
*/
|
|
204
|
-
export function registerStartCommand(program: Command): void {
|
|
205
|
-
program
|
|
206
|
-
.command("start")
|
|
207
|
-
.description("Start a simulation from a template")
|
|
208
|
-
.option("-n, --name <name>", "Simulation name (env: MIRAVO_NAME, flag takes precedence)")
|
|
209
|
-
.option(
|
|
210
|
-
"-t, --template <id|name|path,...>",
|
|
211
|
-
"Template id, name, path, or comma-separated list (env: MIRAVO_TEMPLATE, flag takes precedence)",
|
|
212
|
-
)
|
|
213
|
-
.option("--models-dir <path>", "Additional directory to search for .twin.yaml models")
|
|
214
|
-
.option("-s, --speed <multiplier>", "Time multiplier (default: 1)", parsePositiveFloat)
|
|
215
|
-
.option("--seed <number>", "RNG seed for deterministic simulation", parseNonNegativeInt)
|
|
216
|
-
.option("--tick <ms>", "Tick interval in milliseconds (default: 1000)", parseDurationMs)
|
|
217
|
-
.option(
|
|
218
|
-
"-p, --mqtt-port <number>",
|
|
219
|
-
"MQTT broker port (default: 1883, env: MIRAVO_MQTT_PORT)",
|
|
220
|
-
parsePort,
|
|
221
|
-
)
|
|
222
|
-
.option("--mqtt-host <host>", "MQTT bind host (default: 127.0.0.1, env: MIRAVO_MQTT_HOST)")
|
|
223
|
-
.option("--mqtt-mode <mode>", "MQTT mode: embedded | external (env: MIRAVO_MQTT_MODE)")
|
|
224
|
-
.option("--mqtt-url <url>", "External MQTT broker URL (env: MIRAVO_MQTT_URL)")
|
|
225
|
-
.option("--mqtt-username <username>", "External MQTT username (env: MIRAVO_MQTT_USERNAME)")
|
|
226
|
-
.option("--mqtt-password <password>", "External MQTT password (env: MIRAVO_MQTT_PASSWORD)")
|
|
227
|
-
.option("--no-mqtt", "Disable MQTT broker")
|
|
228
|
-
.option("--opcua", "Enable OPC UA server")
|
|
229
|
-
.option(
|
|
230
|
-
"--opcua-port <number>",
|
|
231
|
-
"OPC UA server port (default: 4840, env: MIRAVO_OPCUA_PORT)",
|
|
232
|
-
parsePort,
|
|
233
|
-
)
|
|
234
|
-
.option("--opcua-host <host>", "OPC UA server bind host (env: MIRAVO_OPCUA_HOST)")
|
|
235
|
-
.option("--modbus", "Enable Modbus TCP server adapter")
|
|
236
|
-
.option(
|
|
237
|
-
"--modbus-port <number>",
|
|
238
|
-
`Modbus TCP port (default: ${DEFAULT_MODBUS_PORT}, env: MIRAVO_MODBUS_PORT)`,
|
|
239
|
-
parsePort,
|
|
240
|
-
)
|
|
241
|
-
.option("--modbus-host <host>", "Modbus TCP bind host (env: MIRAVO_MODBUS_HOST)")
|
|
242
|
-
.option("--admin-host <host>", "Admin server host (default: 127.0.0.1, env: MIRAVO_ADMIN_HOST)")
|
|
243
|
-
.option(
|
|
244
|
-
"--admin-port <number>",
|
|
245
|
-
"Admin server port (default: 8080, env: MIRAVO_ADMIN_PORT)",
|
|
246
|
-
parsePort,
|
|
247
|
-
)
|
|
248
|
-
.option("-d, --detach", "Start simulation in background (daemon mode)")
|
|
249
|
-
.option("--dry-run", "Validate config and print summary without starting")
|
|
250
|
-
.option("--no-ui", "Disable the embedded web console")
|
|
251
|
-
.option("--reset-state", "Reset persisted state — start fresh (ignore any saved snapshot)")
|
|
252
|
-
.option(
|
|
253
|
-
"--summary-interval <seconds>",
|
|
254
|
-
"Seconds between periodic summary lines (default: 5)",
|
|
255
|
-
parsePositiveInt,
|
|
256
|
-
)
|
|
257
|
-
.addHelpText(
|
|
258
|
-
"after",
|
|
259
|
-
`
|
|
260
|
-
Examples:
|
|
261
|
-
$ miravo start Start with auto-detected template
|
|
262
|
-
$ miravo start -t water-treatment Start a specific template
|
|
263
|
-
$ miravo start -t smart-factory,water-treatment Start with two templates
|
|
264
|
-
$ miravo start -d --name my-sim Start in background (daemon mode)
|
|
265
|
-
$ miravo start -s 10 --seed 42 Run at 10x speed with fixed seed
|
|
266
|
-
$ miravo start --no-mqtt --opcua Disable MQTT, enable OPC UA
|
|
267
|
-
$ miravo start --dry-run Validate and print summary only`,
|
|
268
|
-
)
|
|
269
|
-
.action(async (options: StartOptions, command: Command) => {
|
|
270
|
-
await startAction({ ...options, command });
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ---------------------------------------------------------------------------
|
|
275
|
-
// Start action orchestration
|
|
276
|
-
// ---------------------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
async function startAction(options: StartOptions): Promise<void> {
|
|
279
|
-
let runtimeLogContextSet = false;
|
|
280
|
-
let shutdown: ReturnType<typeof createShutdownController> | null = null;
|
|
281
|
-
let engine: Engine | undefined;
|
|
282
|
-
let stopCliResources = (): void => {};
|
|
283
|
-
try {
|
|
284
|
-
const catalog = createCatalog();
|
|
285
|
-
|
|
286
|
-
// Resolve env-var fallbacks for common options
|
|
287
|
-
const templateOptionRaw = options.template ?? Bun.env["MIRAVO_TEMPLATE"];
|
|
288
|
-
// Support comma-separated template identifiers: first is the boot template, rest loaded after startup
|
|
289
|
-
const templateParts: string[] = templateOptionRaw
|
|
290
|
-
? templateOptionRaw
|
|
291
|
-
.split(",")
|
|
292
|
-
.map((s) => s.trim())
|
|
293
|
-
.filter(Boolean)
|
|
294
|
-
: [];
|
|
295
|
-
const templateOption: string | undefined = templateParts[0];
|
|
296
|
-
const extraTemplateIdentifiers: string[] = templateParts.slice(1);
|
|
297
|
-
const simulationNameEnv = Bun.env["MIRAVO_NAME"];
|
|
298
|
-
// Protocol selection: CLI flags decide which protocols run
|
|
299
|
-
const mqttEnabled = options.mqtt !== false; // --no-mqtt disables
|
|
300
|
-
const opcuaEnabled = options.opcua === true; // --opcua enables
|
|
301
|
-
const modbusEnabled = options.modbus === true; // --modbus enables
|
|
302
|
-
|
|
303
|
-
// Warn on ignored protocol-specific flags
|
|
304
|
-
if (!mqttEnabled) {
|
|
305
|
-
if (options.mqttPort !== undefined) {
|
|
306
|
-
configLog.warn("--mqtt-port ignored because MQTT is disabled.");
|
|
307
|
-
}
|
|
308
|
-
if (options.mqttHost !== undefined) {
|
|
309
|
-
configLog.warn("--mqtt-host ignored because MQTT is disabled.");
|
|
310
|
-
}
|
|
311
|
-
if (options.mqttMode !== undefined) {
|
|
312
|
-
configLog.warn("--mqtt-mode ignored because MQTT is disabled.");
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (!opcuaEnabled) {
|
|
316
|
-
if (options.opcuaPort !== undefined) {
|
|
317
|
-
configLog.warn(
|
|
318
|
-
"--opcua-port ignored because OPC UA is not enabled. Use --opcua to enable it.",
|
|
319
|
-
);
|
|
320
|
-
}
|
|
321
|
-
if (options.opcuaHost !== undefined) {
|
|
322
|
-
configLog.warn(
|
|
323
|
-
"--opcua-host ignored because OPC UA is not enabled. Use --opcua to enable it.",
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
if (!modbusEnabled) {
|
|
328
|
-
if (options.modbusPort !== undefined) {
|
|
329
|
-
configLog.warn(
|
|
330
|
-
"--modbus-port ignored because Modbus is not enabled. Use --modbus to enable it.",
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
if (options.modbusHost !== undefined) {
|
|
334
|
-
configLog.warn(
|
|
335
|
-
"--modbus-host ignored because Modbus is not enabled. Use --modbus to enable it.",
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const adminHost = options.adminHost ?? Bun.env["MIRAVO_ADMIN_HOST"] ?? "127.0.0.1";
|
|
341
|
-
const adminPortEnv = Bun.env["MIRAVO_ADMIN_PORT"];
|
|
342
|
-
if (adminHost.trim().length === 0) {
|
|
343
|
-
throw new ConfigValidationError({
|
|
344
|
-
message: "Invalid admin server host: value is empty.",
|
|
345
|
-
fix: "Set --admin-host / MIRAVO_ADMIN_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// MQTT env overrides — only parsed when MQTT is enabled
|
|
350
|
-
let portOption = options.mqttPort;
|
|
351
|
-
let mqttOverrides: MqttRunOverrides = {};
|
|
352
|
-
if (mqttEnabled) {
|
|
353
|
-
const portEnv = Bun.env["MIRAVO_MQTT_PORT"];
|
|
354
|
-
const mqttHost = options.mqttHost ?? Bun.env["MIRAVO_MQTT_HOST"];
|
|
355
|
-
const mqttMode = parseMqttModeOption(options.mqttMode ?? Bun.env["MIRAVO_MQTT_MODE"]);
|
|
356
|
-
const mqttUrl = options.mqttUrl ?? Bun.env["MIRAVO_MQTT_URL"];
|
|
357
|
-
const mqttUsername = options.mqttUsername ?? Bun.env["MIRAVO_MQTT_USERNAME"];
|
|
358
|
-
const mqttPassword = options.mqttPassword ?? Bun.env["MIRAVO_MQTT_PASSWORD"];
|
|
359
|
-
// Tuning env vars for adapter performance
|
|
360
|
-
const drainBudgetEnv = Bun.env["MIRAVO_MQTT_DRAIN_BUDGET"];
|
|
361
|
-
const batchSizeEnv = Bun.env["MIRAVO_MQTT_BATCH_SIZE"];
|
|
362
|
-
const brokerConcurrencyEnv = Bun.env["MIRAVO_MQTT_BROKER_CONCURRENCY"];
|
|
363
|
-
const drainBudget = drainBudgetEnv ? parsePositiveInt(drainBudgetEnv) : undefined;
|
|
364
|
-
const batchSize = batchSizeEnv ? parsePositiveInt(batchSizeEnv) : undefined;
|
|
365
|
-
const brokerConcurrency = brokerConcurrencyEnv
|
|
366
|
-
? parsePositiveInt(brokerConcurrencyEnv)
|
|
367
|
-
: undefined;
|
|
368
|
-
mqttOverrides = {
|
|
369
|
-
...(mqttMode !== undefined ? { mode: mqttMode } : {}),
|
|
370
|
-
...(mqttHost !== undefined && mqttHost.length > 0 ? { host: mqttHost } : {}),
|
|
371
|
-
...(mqttUrl !== undefined && mqttUrl.length > 0 ? { url: mqttUrl } : {}),
|
|
372
|
-
...(mqttUsername !== undefined && mqttUsername.length > 0
|
|
373
|
-
? { username: mqttUsername }
|
|
374
|
-
: {}),
|
|
375
|
-
...(mqttPassword !== undefined && mqttPassword.length > 0
|
|
376
|
-
? { password: mqttPassword }
|
|
377
|
-
: {}),
|
|
378
|
-
...(drainBudget !== undefined ? { drainBudget } : {}),
|
|
379
|
-
...(batchSize !== undefined ? { batchSize } : {}),
|
|
380
|
-
...(brokerConcurrency !== undefined ? { brokerConcurrency } : {}),
|
|
381
|
-
};
|
|
382
|
-
if (portOption === undefined && portEnv) {
|
|
383
|
-
try {
|
|
384
|
-
portOption = parsePort(portEnv);
|
|
385
|
-
} catch {
|
|
386
|
-
throw new ConfigValidationError({
|
|
387
|
-
message: `Invalid MIRAVO_MQTT_PORT environment variable: "${portEnv}".`,
|
|
388
|
-
fix: "Set MIRAVO_MQTT_PORT to a valid port number between 1 and 65535 (for example, 1883).",
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
if (mqttHost !== undefined && mqttHost.trim().length === 0) {
|
|
393
|
-
throw new ConfigValidationError({
|
|
394
|
-
message: "Invalid MQTT bind host: value is empty.",
|
|
395
|
-
fix: "Set --mqtt-host / MIRAVO_MQTT_HOST to a valid host (for example, 127.0.0.1 or 0.0.0.0).",
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// OPC UA env overrides — only parsed when OPC UA is enabled
|
|
401
|
-
let opcuaPort: number | undefined = options.opcuaPort;
|
|
402
|
-
let opcuaHost: string | undefined;
|
|
403
|
-
let opcuaWriteBudget: number | undefined;
|
|
404
|
-
if (opcuaEnabled) {
|
|
405
|
-
const opcuaPortEnv = Bun.env["MIRAVO_OPCUA_PORT"];
|
|
406
|
-
if (opcuaPort === undefined && opcuaPortEnv) {
|
|
407
|
-
try {
|
|
408
|
-
opcuaPort = parsePort(opcuaPortEnv);
|
|
409
|
-
} catch {
|
|
410
|
-
throw new ConfigValidationError({
|
|
411
|
-
message: `Invalid MIRAVO_OPCUA_PORT environment variable: "${opcuaPortEnv}".`,
|
|
412
|
-
fix: "Set MIRAVO_OPCUA_PORT to a valid port number between 1 and 65535 (for example, 4840).",
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
opcuaHost = options.opcuaHost ?? Bun.env["MIRAVO_OPCUA_HOST"] ?? undefined;
|
|
417
|
-
if (opcuaHost !== undefined && opcuaHost.trim().length === 0) {
|
|
418
|
-
throw new ConfigValidationError({
|
|
419
|
-
message: "Invalid OPC UA bind host: value is empty.",
|
|
420
|
-
fix: "Set --opcua-host / MIRAVO_OPCUA_HOST to a valid host (for example, 0.0.0.0).",
|
|
421
|
-
});
|
|
422
|
-
}
|
|
423
|
-
const writeBudgetEnv = Bun.env["MIRAVO_OPCUA_WRITE_BUDGET"];
|
|
424
|
-
if (writeBudgetEnv) {
|
|
425
|
-
opcuaWriteBudget = parsePositiveInt(writeBudgetEnv);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Modbus env overrides — only parsed when Modbus is enabled
|
|
430
|
-
let modbusPort: number | undefined = options.modbusPort;
|
|
431
|
-
let modbusHost: string | undefined;
|
|
432
|
-
if (modbusEnabled) {
|
|
433
|
-
const modbusPortEnv = Bun.env["MIRAVO_MODBUS_PORT"];
|
|
434
|
-
if (modbusPort === undefined && modbusPortEnv) {
|
|
435
|
-
try {
|
|
436
|
-
modbusPort = parsePort(modbusPortEnv);
|
|
437
|
-
} catch {
|
|
438
|
-
throw new ConfigValidationError({
|
|
439
|
-
message: `Invalid MIRAVO_MODBUS_PORT environment variable: "${modbusPortEnv}".`,
|
|
440
|
-
fix: "Set MIRAVO_MODBUS_PORT to a valid port number between 0 and 65535.",
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
modbusHost = options.modbusHost ?? Bun.env["MIRAVO_MODBUS_HOST"] ?? undefined;
|
|
445
|
-
if (modbusHost !== undefined && modbusHost.trim().length === 0) {
|
|
446
|
-
throw new ConfigValidationError({
|
|
447
|
-
message: "Invalid Modbus TCP bind host: value is empty.",
|
|
448
|
-
fix: "Set --modbus-host / MIRAVO_MODBUS_HOST to a valid host (for example, 0.0.0.0 or 127.0.0.1).",
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
let adminPort = options.adminPort;
|
|
454
|
-
if (adminPort === undefined && adminPortEnv) {
|
|
455
|
-
try {
|
|
456
|
-
adminPort = parsePort(adminPortEnv);
|
|
457
|
-
} catch {
|
|
458
|
-
throw new ConfigValidationError({
|
|
459
|
-
message: `Invalid MIRAVO_ADMIN_PORT environment variable: "${adminPortEnv}".`,
|
|
460
|
-
fix: "Set MIRAVO_ADMIN_PORT to a valid port number between 1 and 65535 (for example, 8080).",
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
adminPort ??= 8080;
|
|
465
|
-
|
|
466
|
-
// Step 1: Resolve template path(s)
|
|
467
|
-
// If the simulation name is explicitly known and no template was given,
|
|
468
|
-
// peek at persisted state to recover the template path — skipping the
|
|
469
|
-
// interactive picker on resume.
|
|
470
|
-
let effectiveTemplateOption = templateOption;
|
|
471
|
-
if (!effectiveTemplateOption && !options.resetState) {
|
|
472
|
-
const knownName = options.name ?? simulationNameEnv;
|
|
473
|
-
if (knownName) {
|
|
474
|
-
// Named: peek by explicit simulation name
|
|
475
|
-
const peeked = await peekStateTemplatePath(knownName);
|
|
476
|
-
if (peeked) {
|
|
477
|
-
effectiveTemplateOption = peeked;
|
|
478
|
-
configLog.debug(`Resuming from persisted state: template=${peeked}`);
|
|
479
|
-
}
|
|
480
|
-
} else {
|
|
481
|
-
// Unnamed: scan state dir — auto-resume if exactly one simulation exists
|
|
482
|
-
const peeked = await peekSingleStateTemplatePath();
|
|
483
|
-
if (peeked) {
|
|
484
|
-
effectiveTemplateOption = peeked;
|
|
485
|
-
configLog.debug(`Resuming from persisted state: template=${peeked}`);
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
const templatePath = await resolveTemplatePath(effectiveTemplateOption, catalog);
|
|
490
|
-
configLog.debug(`Resolved template: ${templatePath}`);
|
|
491
|
-
// Resolve any additional comma-separated templates upfront so errors surface before engine start
|
|
492
|
-
const extraTemplatePaths = await Promise.all(
|
|
493
|
-
extraTemplateIdentifiers.map((id) => resolveTemplateIdentifier(id, catalog)),
|
|
494
|
-
);
|
|
495
|
-
|
|
496
|
-
// Step 2: Load and validate
|
|
497
|
-
const { template, models, seed, seedExplicit } = await loadAndValidateTemplate(
|
|
498
|
-
templatePath,
|
|
499
|
-
options,
|
|
500
|
-
catalog,
|
|
501
|
-
);
|
|
502
|
-
const simulationName = resolveSimulationName({
|
|
503
|
-
template,
|
|
504
|
-
...(options.name !== undefined ? { cliName: options.name } : {}),
|
|
505
|
-
...(simulationNameEnv !== undefined ? { envName: simulationNameEnv } : {}),
|
|
506
|
-
});
|
|
507
|
-
configLog.debug(`Loaded ${models.size} models, seed=${seed} (explicit=${seedExplicit})`);
|
|
508
|
-
controlLog.info(`Simulation name: ${simulationName}`);
|
|
509
|
-
|
|
510
|
-
// Step 3: Dry-run check
|
|
511
|
-
if (options.dryRun) {
|
|
512
|
-
printDryRunSummary(template, models, seed, seedExplicit, templatePath);
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Step 3b: Detach check — spawn background process and exit
|
|
517
|
-
if (options.detach) {
|
|
518
|
-
const detachAdapterConfigs = resolveDetachAdapterConfigs({
|
|
519
|
-
template,
|
|
520
|
-
simulationName,
|
|
521
|
-
mqtt: {
|
|
522
|
-
enabled: mqttEnabled,
|
|
523
|
-
portOverride: portOption,
|
|
524
|
-
overrides: mqttOverrides,
|
|
525
|
-
},
|
|
526
|
-
opcua: {
|
|
527
|
-
enabled: opcuaEnabled,
|
|
528
|
-
portOverride: opcuaPort,
|
|
529
|
-
hostOverride: opcuaHost,
|
|
530
|
-
writeBudgetOverride: opcuaWriteBudget,
|
|
531
|
-
},
|
|
532
|
-
modbus: {
|
|
533
|
-
enabled: modbusEnabled,
|
|
534
|
-
portOverride: modbusPort,
|
|
535
|
-
hostOverride: modbusHost,
|
|
536
|
-
},
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
await detachAction({
|
|
540
|
-
simulationName,
|
|
541
|
-
templatePath,
|
|
542
|
-
seed,
|
|
543
|
-
seedExplicit,
|
|
544
|
-
adminHost,
|
|
545
|
-
adminPort,
|
|
546
|
-
mqttPort: detachAdapterConfigs.mqtt.port,
|
|
547
|
-
mqttHost: detachAdapterConfigs.mqtt.host,
|
|
548
|
-
mqttEmbedded: detachAdapterConfigs.mqtt.embedded,
|
|
549
|
-
mqttEnabled,
|
|
550
|
-
opcuaPort: detachAdapterConfigs.opcua.port,
|
|
551
|
-
opcuaHost: detachAdapterConfigs.opcua.host,
|
|
552
|
-
opcuaEnabled,
|
|
553
|
-
modbusPort: detachAdapterConfigs.modbus.port,
|
|
554
|
-
modbusHost: detachAdapterConfigs.modbus.host,
|
|
555
|
-
modbusEnabled,
|
|
556
|
-
});
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const runId = Bun.env["MIRAVO_DAEMON_RUN_ID"] ?? crypto.randomUUID();
|
|
561
|
-
setRuntimeLogContext({ simulationName, runId });
|
|
562
|
-
runtimeLogContextSet = true;
|
|
563
|
-
controlLog.info(`Run ID: ${runId}`);
|
|
564
|
-
|
|
565
|
-
shutdown = createShutdownController({
|
|
566
|
-
seed,
|
|
567
|
-
seedExplicit,
|
|
568
|
-
});
|
|
569
|
-
const shutdownRequested = shutdown.waitForShutdownRequest();
|
|
570
|
-
shutdown.addTask("finalize", "runtime log context", () => {
|
|
571
|
-
if (runtimeLogContextSet) {
|
|
572
|
-
clearRuntimeLogContext();
|
|
573
|
-
runtimeLogContextSet = false;
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
// Step 4: Create engine and populate
|
|
578
|
-
engine = createAndPopulateEngine(
|
|
579
|
-
template,
|
|
580
|
-
models,
|
|
581
|
-
catalog,
|
|
582
|
-
seed,
|
|
583
|
-
options,
|
|
584
|
-
templatePath,
|
|
585
|
-
simulationName,
|
|
586
|
-
);
|
|
587
|
-
shutdown.setEngine(engine);
|
|
588
|
-
|
|
589
|
-
// Wire up template loader for runtime loadTemplate commands
|
|
590
|
-
// Single discovery pass builds both the path cache and catalog (avoids re-reading YAML)
|
|
591
|
-
const { pathCache: templatePathCache, catalog: catalogTemplates } =
|
|
592
|
-
await buildTemplateCacheAndCatalog(catalog);
|
|
593
|
-
engine.setTemplateLoader(createRuntimeTemplateLoader(catalog, templatePathCache));
|
|
594
|
-
|
|
595
|
-
// Load any additional comma-separated templates as extra template runs
|
|
596
|
-
for (const extraPath of extraTemplatePaths) {
|
|
597
|
-
const result = await engine.execute({
|
|
598
|
-
type: "loadTemplate",
|
|
599
|
-
templatePath: extraPath,
|
|
600
|
-
count: 1,
|
|
601
|
-
});
|
|
602
|
-
if (!result.success) {
|
|
603
|
-
throw new MiravoError(
|
|
604
|
-
`Failed to load additional template "${extraPath}": ${result.message}`,
|
|
605
|
-
result.error ?? "Check that the template is valid and compatible.",
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
configLog.info(`Loaded additional template: ${extraPath}`);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
let controlPlane: ControlPlane | null = null;
|
|
612
|
-
let adminServer: { endpoint: string; stop: () => void } | null = null;
|
|
613
|
-
let lastTickProgressAt = performance.now();
|
|
614
|
-
let tickProgressSubscribed = false;
|
|
615
|
-
const onTickProgress = (): void => {
|
|
616
|
-
lastTickProgressAt = performance.now();
|
|
617
|
-
};
|
|
618
|
-
let unsubscribeEvents = (): void => {};
|
|
619
|
-
let summaryTimer: ReturnType<typeof setInterval> | null = null;
|
|
620
|
-
|
|
621
|
-
stopCliResources = (): void => {
|
|
622
|
-
controlPlane?.stop();
|
|
623
|
-
adminServer?.stop();
|
|
624
|
-
if (summaryTimer !== null) {
|
|
625
|
-
clearInterval(summaryTimer);
|
|
626
|
-
summaryTimer = null;
|
|
627
|
-
}
|
|
628
|
-
if (tickProgressSubscribed && engine) {
|
|
629
|
-
engine.bus.off("tick:complete", onTickProgress);
|
|
630
|
-
tickProgressSubscribed = false;
|
|
631
|
-
}
|
|
632
|
-
unsubscribeEvents();
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
shutdown.addTask("cleanup", "CLI resources", stopCliResources);
|
|
636
|
-
|
|
637
|
-
// Step 5: Register adapter factories and enable active adapters
|
|
638
|
-
// Track the last created MQTT adapter via closure (needed for control plane transport)
|
|
639
|
-
let lastCreatedMqttAdapter: MQTTAdapter | null = null;
|
|
640
|
-
const eng = engine; // const alias for closures (engine is `let` and TS can't narrow across closures)
|
|
641
|
-
|
|
642
|
-
const mqttDefaultConfig = buildMQTTDefaultConfig(
|
|
643
|
-
template,
|
|
644
|
-
portOption,
|
|
645
|
-
mqttOverrides,
|
|
646
|
-
simulationName,
|
|
647
|
-
);
|
|
648
|
-
eng.registerAvailableAdapter(
|
|
649
|
-
"mqtt",
|
|
650
|
-
async (config) => {
|
|
651
|
-
const adapter = new MQTTAdapter(eng.bus);
|
|
652
|
-
await adapter.start(config);
|
|
653
|
-
lastCreatedMqttAdapter = adapter;
|
|
654
|
-
return { adapter, endpoint: buildMQTTEndpoint(config) };
|
|
655
|
-
},
|
|
656
|
-
mqttDefaultConfig,
|
|
657
|
-
);
|
|
658
|
-
|
|
659
|
-
const opcuaDefaultConfig = buildOPCUADefaultConfig(
|
|
660
|
-
template,
|
|
661
|
-
opcuaPort,
|
|
662
|
-
opcuaHost,
|
|
663
|
-
simulationName,
|
|
664
|
-
opcuaWriteBudget,
|
|
665
|
-
);
|
|
666
|
-
eng.registerAvailableAdapter(
|
|
667
|
-
"opcua",
|
|
668
|
-
async (config) => {
|
|
669
|
-
const adapter = new OPCUAAdapter(eng.bus, eng);
|
|
670
|
-
await adapter.start(config);
|
|
671
|
-
return { adapter, endpoint: buildOPCUAEndpoint(config) };
|
|
672
|
-
},
|
|
673
|
-
opcuaDefaultConfig,
|
|
674
|
-
);
|
|
675
|
-
|
|
676
|
-
const modbusDefaultConfig = buildModbusDefaultConfig(template, modbusPort, modbusHost);
|
|
677
|
-
eng.registerAvailableAdapter(
|
|
678
|
-
"modbus",
|
|
679
|
-
async (config) => {
|
|
680
|
-
const adapter = new ModbusAdapter(eng.bus, eng);
|
|
681
|
-
await adapter.start(config);
|
|
682
|
-
return { adapter, endpoint: buildModbusEndpoint(config) };
|
|
683
|
-
},
|
|
684
|
-
modbusDefaultConfig,
|
|
685
|
-
);
|
|
686
|
-
|
|
687
|
-
// Step 5b: Wire control plane (dynamically follows MQTT adapter lifecycle)
|
|
688
|
-
function wireControlPlane(): void {
|
|
689
|
-
// Stop old control plane before creating a new one (prevents listener leaks)
|
|
690
|
-
if (controlPlane) {
|
|
691
|
-
controlPlane.stop();
|
|
692
|
-
controlPlane = null;
|
|
693
|
-
}
|
|
694
|
-
const mqttAdapter = lastCreatedMqttAdapter as MQTTAdapter | null;
|
|
695
|
-
if (!mqttAdapter) return;
|
|
696
|
-
const transport = mqttAdapter.getControlTransport();
|
|
697
|
-
if (!transport) return;
|
|
698
|
-
controlPlane = createControlPlane({
|
|
699
|
-
simulationName,
|
|
700
|
-
engine: eng,
|
|
701
|
-
transport,
|
|
702
|
-
bus: eng.bus,
|
|
703
|
-
});
|
|
704
|
-
controlPlane.start();
|
|
705
|
-
controlLog.info(`active on $miravo/${simulationName}/* topics`);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Wire now if MQTT was enabled at boot
|
|
709
|
-
wireControlPlane();
|
|
710
|
-
|
|
711
|
-
// Re-wire on runtime MQTT enable, tear down on disable
|
|
712
|
-
eng.bus.on("adapter:enabled", (evt) => {
|
|
713
|
-
if (evt.name === "mqtt") wireControlPlane();
|
|
714
|
-
});
|
|
715
|
-
eng.bus.on("adapter:disabled", (evt) => {
|
|
716
|
-
if (evt.name === "mqtt") {
|
|
717
|
-
controlPlane?.stop();
|
|
718
|
-
controlPlane = null;
|
|
719
|
-
lastCreatedMqttAdapter = null;
|
|
720
|
-
controlLog.info("control plane stopped (MQTT adapter disabled)");
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
// Step 5c: Subscribe to events + periodic summary before engine.start() so restore events are visible
|
|
725
|
-
const errorContextCounts = new Map<string, number>();
|
|
726
|
-
unsubscribeEvents = subscribeToEvents(eng, errorContextCounts);
|
|
727
|
-
shutdown.setErrorContextCounts(errorContextCounts);
|
|
728
|
-
const summaryInterval = options.summaryInterval ?? 5;
|
|
729
|
-
summaryTimer = startPeriodicSummary(eng, summaryInterval);
|
|
730
|
-
|
|
731
|
-
eng.bus.on("tick:complete", onTickProgress);
|
|
732
|
-
tickProgressSubscribed = true;
|
|
733
|
-
|
|
734
|
-
let restoredState: RestoreSummary | null = null;
|
|
735
|
-
const onRestored = (event: RestoreSummary): void => {
|
|
736
|
-
restoredState = event;
|
|
737
|
-
};
|
|
738
|
-
eng.bus.on("engine:restored", onRestored);
|
|
739
|
-
try {
|
|
740
|
-
await eng.start();
|
|
741
|
-
} finally {
|
|
742
|
-
eng.bus.off("engine:restored", onRestored);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// Step 6: Start admin HTTP server after restore + adapter reconciliation
|
|
746
|
-
try {
|
|
747
|
-
adminServer = startAdminServer({
|
|
748
|
-
engine: eng,
|
|
749
|
-
network: {
|
|
750
|
-
host: adminHost,
|
|
751
|
-
port: adminPort,
|
|
752
|
-
tickIntervalMs: eng.getState().config.tickIntervalMs,
|
|
753
|
-
},
|
|
754
|
-
protocols: {
|
|
755
|
-
getLastTickProgressAt: () => lastTickProgressAt,
|
|
756
|
-
},
|
|
757
|
-
ws: {
|
|
758
|
-
bus: eng.bus,
|
|
759
|
-
getEngineState: () => eng.getMetrics().core.state,
|
|
760
|
-
getTickInfo: () => {
|
|
761
|
-
const core = eng.getMetrics().core;
|
|
762
|
-
return {
|
|
763
|
-
tick: core.tickCount,
|
|
764
|
-
simulationTime: core.simulationTimeSeconds,
|
|
765
|
-
instanceCount: core.instanceCount,
|
|
766
|
-
};
|
|
767
|
-
},
|
|
768
|
-
},
|
|
769
|
-
adminToken: Bun.env["MIRAVO_ADMIN_TOKEN"],
|
|
770
|
-
consoleDir: options.noUi ? undefined : resolveConsoleDir(),
|
|
771
|
-
getCatalog: () => catalogTemplates,
|
|
772
|
-
simulationName,
|
|
773
|
-
runId,
|
|
774
|
-
requestShutdown: (reason) => shutdown?.requestShutdown(reason),
|
|
775
|
-
});
|
|
776
|
-
controlLog.info(`admin server ready · endpoint: ${colors.cyan(adminServer.endpoint)}`);
|
|
777
|
-
} catch (error) {
|
|
778
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
779
|
-
throw new MiravoError(
|
|
780
|
-
`Admin server failed to start on ${adminHost}:${adminPort}: ${message}`,
|
|
781
|
-
"Set --admin-port / MIRAVO_ADMIN_PORT to a free port, or change --admin-host.",
|
|
782
|
-
);
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Step 7: Print startup banner from the post-restore runtime state
|
|
786
|
-
const availableAdapters = eng.getAvailableAdapters();
|
|
787
|
-
const mqttEndpoint =
|
|
788
|
-
availableAdapters.find((a) => a.name === "mqtt" && a.enabled)?.endpoint ?? null;
|
|
789
|
-
const opcuaEndpoint =
|
|
790
|
-
availableAdapters.find((a) => a.name === "opcua" && a.enabled)?.endpoint ?? null;
|
|
791
|
-
const modbusEndpoint =
|
|
792
|
-
availableAdapters.find((a) => a.name === "modbus" && a.enabled)?.endpoint ?? null;
|
|
793
|
-
printStartupBanner(
|
|
794
|
-
templatePath,
|
|
795
|
-
eng,
|
|
796
|
-
seed,
|
|
797
|
-
seedExplicit,
|
|
798
|
-
mqttEndpoint,
|
|
799
|
-
opcuaEndpoint,
|
|
800
|
-
modbusEndpoint,
|
|
801
|
-
simulationName,
|
|
802
|
-
runId,
|
|
803
|
-
adminServer.endpoint,
|
|
804
|
-
);
|
|
805
|
-
|
|
806
|
-
const restoreSummary = restoredState as RestoreSummary | null;
|
|
807
|
-
if (restoreSummary !== null) {
|
|
808
|
-
engineLog.info(
|
|
809
|
-
`restored saved state · ${colors.bold(String(restoreSummary.instanceCount))} instances · ${colors.bold(String(restoreSummary.templateRunCount))} template runs · ${colors.bold(String(restoreSummary.adapterCount))} adapters`,
|
|
810
|
-
);
|
|
811
|
-
} else if (options.resetState) {
|
|
812
|
-
engineLog.info("starting fresh (--reset-state)");
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
shutdown.markRunning();
|
|
816
|
-
engineLog.success("Simulation started.");
|
|
817
|
-
await shutdownRequested;
|
|
818
|
-
await shutdown.runShutdown();
|
|
819
|
-
shutdown.teardown();
|
|
820
|
-
} catch (error) {
|
|
821
|
-
if (shutdown?.isShuttingDown()) {
|
|
822
|
-
await shutdown.runShutdown();
|
|
823
|
-
shutdown.teardown();
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
shutdown?.teardown();
|
|
827
|
-
stopCliResources();
|
|
828
|
-
try {
|
|
829
|
-
await engine?.stop("boot error");
|
|
830
|
-
} catch {
|
|
831
|
-
// engine may not exist yet or stop may fail — swallow
|
|
832
|
-
}
|
|
833
|
-
if (runtimeLogContextSet) {
|
|
834
|
-
clearRuntimeLogContext();
|
|
835
|
-
runtimeLogContextSet = false;
|
|
836
|
-
}
|
|
837
|
-
handleCommandError(error);
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
// ---------------------------------------------------------------------------
|
|
842
|
-
// Detach mode — spawn background simulation
|
|
843
|
-
// ---------------------------------------------------------------------------
|
|
844
|
-
|
|
845
|
-
interface DetachOptions {
|
|
846
|
-
readonly simulationName: string;
|
|
847
|
-
readonly templatePath: string;
|
|
848
|
-
readonly seed: number;
|
|
849
|
-
readonly seedExplicit: boolean;
|
|
850
|
-
readonly adminHost: string;
|
|
851
|
-
readonly adminPort: number;
|
|
852
|
-
readonly mqttPort: number | undefined;
|
|
853
|
-
readonly mqttHost: string | undefined;
|
|
854
|
-
readonly mqttEmbedded: boolean;
|
|
855
|
-
readonly mqttEnabled: boolean;
|
|
856
|
-
readonly opcuaPort: number | undefined;
|
|
857
|
-
readonly opcuaHost: string | undefined;
|
|
858
|
-
readonly opcuaEnabled: boolean;
|
|
859
|
-
readonly modbusPort: number | undefined;
|
|
860
|
-
readonly modbusHost: string | undefined;
|
|
861
|
-
readonly modbusEnabled: boolean;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
interface ResolvedDetachAdapterConfigs {
|
|
865
|
-
readonly mqtt: {
|
|
866
|
-
readonly port: number | undefined;
|
|
867
|
-
readonly host: string | undefined;
|
|
868
|
-
readonly embedded: boolean;
|
|
869
|
-
};
|
|
870
|
-
readonly opcua: {
|
|
871
|
-
readonly port: number | undefined;
|
|
872
|
-
readonly host: string | undefined;
|
|
873
|
-
};
|
|
874
|
-
readonly modbus: {
|
|
875
|
-
readonly port: number | undefined;
|
|
876
|
-
readonly host: string | undefined;
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
async function detachAction(opts: DetachOptions): Promise<void> {
|
|
881
|
-
const { simulationName } = opts;
|
|
882
|
-
|
|
883
|
-
// 1. Acquire exclusive lock to prevent concurrent starts
|
|
884
|
-
const releaseLock = await acquireNameLock(simulationName);
|
|
885
|
-
try {
|
|
886
|
-
await detachActionLocked(opts, releaseLock);
|
|
887
|
-
} catch (error) {
|
|
888
|
-
await releaseLock();
|
|
889
|
-
throw error;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
async function detachActionLocked(
|
|
894
|
-
opts: DetachOptions,
|
|
895
|
-
releaseLock: () => Promise<void>,
|
|
896
|
-
): Promise<void> {
|
|
897
|
-
const { simulationName, adminHost, adminPort } = opts;
|
|
898
|
-
|
|
899
|
-
// 1. Check name conflict
|
|
900
|
-
const existing = await checkNameConflict(simulationName);
|
|
901
|
-
if (existing) {
|
|
902
|
-
throw new MiravoError(
|
|
903
|
-
`Simulation "${simulationName}" is already running (PID ${existing.pid}).`,
|
|
904
|
-
`Stop it first: miravo stop ${simulationName}`,
|
|
905
|
-
);
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// 2. Check port conflicts
|
|
909
|
-
const ports = new Map<string, number>();
|
|
910
|
-
ports.set("admin", adminPort);
|
|
911
|
-
if (opts.mqttEnabled && opts.mqttEmbedded && opts.mqttPort !== undefined) {
|
|
912
|
-
ports.set("mqtt", opts.mqttPort);
|
|
913
|
-
}
|
|
914
|
-
if (opts.opcuaEnabled && opts.opcuaPort !== undefined) ports.set("opcua", opts.opcuaPort);
|
|
915
|
-
if (opts.modbusEnabled && opts.modbusPort !== undefined) ports.set("modbus", opts.modbusPort);
|
|
916
|
-
|
|
917
|
-
const conflicts = await checkPortConflicts(ports);
|
|
918
|
-
if (conflicts.length > 0) {
|
|
919
|
-
throw new MiravoError(
|
|
920
|
-
conflicts[0] ?? "Port conflict detected.",
|
|
921
|
-
"Use different port options or stop the conflicting simulation.",
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
// 3. Generate run ID and rebuild args
|
|
926
|
-
const runId = crypto.randomUUID();
|
|
927
|
-
const childArgs = rebuildArgsForDetach(opts);
|
|
928
|
-
|
|
929
|
-
// 4. Spawn child process
|
|
930
|
-
controlLog.info(`Starting simulation "${simulationName}" in background...`);
|
|
931
|
-
const proc = Bun.spawn({
|
|
932
|
-
cmd: [process.execPath, ...childArgs],
|
|
933
|
-
stdin: "ignore",
|
|
934
|
-
stdout: "ignore",
|
|
935
|
-
stderr: "ignore",
|
|
936
|
-
env: {
|
|
937
|
-
...process.env,
|
|
938
|
-
MIRAVO_DAEMON_RUN_ID: runId,
|
|
939
|
-
},
|
|
940
|
-
});
|
|
941
|
-
proc.unref();
|
|
942
|
-
|
|
943
|
-
// 5. Poll for readiness
|
|
944
|
-
const adminUrl = `http://${adminHost}:${adminPort}`;
|
|
945
|
-
const ready = await pollReadiness(proc.pid, adminUrl, runId, 10, 1_000);
|
|
946
|
-
|
|
947
|
-
if (!ready) {
|
|
948
|
-
// Kill child if still alive
|
|
949
|
-
if (isPidAlive(proc.pid)) {
|
|
950
|
-
try {
|
|
951
|
-
process.kill(proc.pid, "SIGKILL");
|
|
952
|
-
} catch {
|
|
953
|
-
// Ignore kill errors
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
throw new MiravoError(
|
|
958
|
-
`Simulation "${simulationName}" failed to start within the readiness timeout.`,
|
|
959
|
-
"Check template and port availability. Run without -d to see full output.",
|
|
960
|
-
);
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
// 6. Write registry entry
|
|
964
|
-
const adapters: DaemonRegistryEntry["adapters"] = {};
|
|
965
|
-
if (opts.mqttEnabled) {
|
|
966
|
-
const mqttPort = opts.mqttPort ?? DEFAULT_MQTT_PORT;
|
|
967
|
-
adapters["mqtt"] = { port: mqttPort, host: opts.mqttHost ?? "127.0.0.1" };
|
|
968
|
-
}
|
|
969
|
-
if (opts.opcuaEnabled) {
|
|
970
|
-
const opcuaPort = opts.opcuaPort ?? DEFAULT_OPCUA_PORT;
|
|
971
|
-
adapters["opcua"] = { port: opcuaPort, host: opts.opcuaHost ?? "0.0.0.0" };
|
|
972
|
-
}
|
|
973
|
-
if (opts.modbusEnabled) {
|
|
974
|
-
const modbusPort = opts.modbusPort ?? DEFAULT_MODBUS_PORT;
|
|
975
|
-
adapters["modbus"] = { port: modbusPort, host: opts.modbusHost ?? "0.0.0.0" };
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
const entry: DaemonRegistryEntry = {
|
|
979
|
-
name: simulationName,
|
|
980
|
-
pid: proc.pid,
|
|
981
|
-
adminPort,
|
|
982
|
-
adminHost,
|
|
983
|
-
adapters,
|
|
984
|
-
templatePath: resolve(opts.templatePath),
|
|
985
|
-
startedAt: new Date().toISOString(),
|
|
986
|
-
seed: opts.seed,
|
|
987
|
-
runId,
|
|
988
|
-
};
|
|
989
|
-
try {
|
|
990
|
-
await writeEntry(entry);
|
|
991
|
-
} catch (err) {
|
|
992
|
-
// Registry write failed — kill the orphan child so it doesn't run invisible
|
|
993
|
-
// to miravo ps/stop/rm.
|
|
994
|
-
try {
|
|
995
|
-
process.kill(proc.pid, "SIGKILL");
|
|
996
|
-
} catch {
|
|
997
|
-
// Process may have already exited
|
|
998
|
-
}
|
|
999
|
-
throw err;
|
|
1000
|
-
}
|
|
1001
|
-
await releaseLock();
|
|
1002
|
-
|
|
1003
|
-
// 7. Print detach summary
|
|
1004
|
-
printDetachSummary(entry);
|
|
1005
|
-
process.exit(0);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
function rebuildArgsForDetach(opts: DetachOptions): string[] {
|
|
1009
|
-
// Start from the original argv, filtering out -d/--detach
|
|
1010
|
-
const originalArgs = process.argv.slice(1); // skip bun/node executable
|
|
1011
|
-
const filtered: string[] = [];
|
|
1012
|
-
let hasName = false;
|
|
1013
|
-
let hasSeed = false;
|
|
1014
|
-
|
|
1015
|
-
for (let i = 0; i < originalArgs.length; i++) {
|
|
1016
|
-
const arg = originalArgs[i];
|
|
1017
|
-
if (arg === undefined) continue;
|
|
1018
|
-
if (arg === "-d" || arg === "--detach") continue;
|
|
1019
|
-
if (arg === "-n" || arg === "--name") hasName = true;
|
|
1020
|
-
if (arg === "--seed") hasSeed = true;
|
|
1021
|
-
filtered.push(arg);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
// Inject --name if not explicitly passed (ensures child uses same resolved name)
|
|
1025
|
-
if (!hasName) {
|
|
1026
|
-
filtered.push("--name", opts.simulationName);
|
|
1027
|
-
}
|
|
1028
|
-
// Inject --seed to freeze the seed (so child reproduces the same simulation)
|
|
1029
|
-
if (!hasSeed) {
|
|
1030
|
-
filtered.push("--seed", String(opts.seed));
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
return filtered;
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
async function pollReadiness(
|
|
1037
|
-
pid: number,
|
|
1038
|
-
adminUrl: string,
|
|
1039
|
-
runId: string,
|
|
1040
|
-
maxRetries: number,
|
|
1041
|
-
intervalMs: number,
|
|
1042
|
-
): Promise<boolean> {
|
|
1043
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
1044
|
-
await Bun.sleep(intervalMs);
|
|
1045
|
-
|
|
1046
|
-
if (!isPidAlive(pid)) return false;
|
|
1047
|
-
|
|
1048
|
-
try {
|
|
1049
|
-
// Use /readyz to verify identity via runId. Accept any status (not just "ok")
|
|
1050
|
-
// so external-MQTT daemons with slow broker reconnect can still start.
|
|
1051
|
-
const response = await fetch(`${adminUrl}/readyz`, {
|
|
1052
|
-
signal: AbortSignal.timeout(2_000),
|
|
1053
|
-
});
|
|
1054
|
-
const body = (await response.json()) as Record<string, unknown>;
|
|
1055
|
-
if (body["runId"] === runId) return true;
|
|
1056
|
-
} catch {
|
|
1057
|
-
// Not ready yet — retry
|
|
1058
|
-
}
|
|
1059
|
-
}
|
|
1060
|
-
return false;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
function printDetachSummary(entry: DaemonRegistryEntry): void {
|
|
1064
|
-
const admin = `http://${displayHost(entry.adminHost)}:${entry.adminPort}`;
|
|
1065
|
-
const lines = [
|
|
1066
|
-
`Simulation "${entry.name}" started in background (PID ${entry.pid})`,
|
|
1067
|
-
"",
|
|
1068
|
-
` Console ${admin}`,
|
|
1069
|
-
];
|
|
1070
|
-
|
|
1071
|
-
for (const [name, adapter] of Object.entries(entry.adapters) as [
|
|
1072
|
-
string,
|
|
1073
|
-
{ port: number; host: string },
|
|
1074
|
-
][]) {
|
|
1075
|
-
const host = displayHost(adapter.host);
|
|
1076
|
-
if (name === "mqtt") {
|
|
1077
|
-
lines.push(` MQTT mqtt://${host}:${adapter.port}`);
|
|
1078
|
-
} else if (name === "opcua") {
|
|
1079
|
-
lines.push(` OPC UA opc.tcp://${host}:${adapter.port}`);
|
|
1080
|
-
} else if (name === "modbus") {
|
|
1081
|
-
lines.push(` Modbus modbus://${host}:${adapter.port}`);
|
|
1082
|
-
} else {
|
|
1083
|
-
lines.push(` ${name} ${host}:${adapter.port}`);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
lines.push("");
|
|
1088
|
-
lines.push(` Use ${colors.cyan(`miravo logs ${entry.name}`)} to view events`);
|
|
1089
|
-
lines.push(` Use ${colors.cyan(`miravo stop ${entry.name}`)} to stop`);
|
|
1090
|
-
|
|
1091
|
-
writeOutput(lines.join("\n"));
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// ---------------------------------------------------------------------------
|
|
1095
|
-
// Step 1: Template resolution
|
|
1096
|
-
// ---------------------------------------------------------------------------
|
|
1097
|
-
|
|
1098
|
-
async function peekStateTemplatePath(simulationName: string): Promise<string | undefined> {
|
|
1099
|
-
const stateDir = `${resolveMiravoHome()}/state`;
|
|
1100
|
-
const file = Bun.file(`${stateDir}/${simulationName}.json`);
|
|
1101
|
-
if (!(await file.exists())) return undefined;
|
|
1102
|
-
try {
|
|
1103
|
-
const parsed = JSON.parse(await file.text()) as unknown;
|
|
1104
|
-
const result = PersistedStateSchema.safeParse(parsed);
|
|
1105
|
-
if (!result.success) return undefined;
|
|
1106
|
-
return result.data.templateRuns.find((r) => r.templatePath.trim().length > 0)?.templatePath;
|
|
1107
|
-
} catch {
|
|
1108
|
-
return undefined;
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
async function peekSingleStateTemplatePath(): Promise<string | undefined> {
|
|
1113
|
-
const stateDir = `${resolveMiravoHome()}/state`;
|
|
1114
|
-
let bestTemplatePath: string | undefined;
|
|
1115
|
-
let bestSavedAt = "";
|
|
1116
|
-
try {
|
|
1117
|
-
const glob = new Glob("*.json");
|
|
1118
|
-
for await (const file of glob.scan({ cwd: stateDir, onlyFiles: true })) {
|
|
1119
|
-
try {
|
|
1120
|
-
const parsed = JSON.parse(await Bun.file(`${stateDir}/${file}`).text()) as unknown;
|
|
1121
|
-
const result = PersistedStateSchema.safeParse(parsed);
|
|
1122
|
-
if (!result.success) continue;
|
|
1123
|
-
const templatePath = result.data.templateRuns.find(
|
|
1124
|
-
(r) => r.templatePath.trim().length > 0,
|
|
1125
|
-
)?.templatePath;
|
|
1126
|
-
if (!templatePath) continue;
|
|
1127
|
-
if (result.data.savedAt > bestSavedAt) {
|
|
1128
|
-
bestSavedAt = result.data.savedAt;
|
|
1129
|
-
bestTemplatePath = templatePath;
|
|
1130
|
-
}
|
|
1131
|
-
} catch {}
|
|
1132
|
-
}
|
|
1133
|
-
} catch {
|
|
1134
|
-
return undefined; // state dir doesn't exist yet (first-ever run)
|
|
1135
|
-
}
|
|
1136
|
-
return bestTemplatePath;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
async function resolveTemplatePath(
|
|
1140
|
-
templateOption: string | undefined,
|
|
1141
|
-
catalog: ContentCatalog,
|
|
1142
|
-
): Promise<string> {
|
|
1143
|
-
// 1. Explicit --template identifier or path
|
|
1144
|
-
if (templateOption) {
|
|
1145
|
-
return resolveTemplateIdentifier(templateOption, catalog);
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// 2. Auto-discover local templates from content dir (if configured) and CWD
|
|
1149
|
-
const discovered = await discoverPreferredLocalTemplates(catalog);
|
|
1150
|
-
if (discovered.length === 1) {
|
|
1151
|
-
const first = discovered[0];
|
|
1152
|
-
if (first !== undefined) {
|
|
1153
|
-
configLog.info(`Found template: ${first.name} (${first.path})`);
|
|
1154
|
-
return first.path;
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
if (discovered.length > 1) {
|
|
1158
|
-
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
1159
|
-
const names = discovered.map((t) => t.name).join(", ");
|
|
1160
|
-
throw new MiravoError(
|
|
1161
|
-
`Found ${discovered.length} templates: ${names}.`,
|
|
1162
|
-
"Use '--template <name|path>' to specify which template to use.",
|
|
1163
|
-
);
|
|
1164
|
-
}
|
|
1165
|
-
const first = discovered[0];
|
|
1166
|
-
if (first !== undefined) {
|
|
1167
|
-
configLog.warn(
|
|
1168
|
-
`Found ${discovered.length} templates: ${discovered.map((t) => t.name).join(", ")}. Using ${first.name}. Use '--template <name|path>' to specify.`,
|
|
1169
|
-
);
|
|
1170
|
-
return first.path;
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
// 3. Fall back to built-in templates
|
|
1175
|
-
const builtinTemplateDir = catalog.getBuiltinTemplatesDir();
|
|
1176
|
-
let builtinTemplates: readonly DiscoveredTemplate[] = [];
|
|
1177
|
-
try {
|
|
1178
|
-
const discovered = await discoverTemplates(builtinTemplateDir);
|
|
1179
|
-
// Exclude stress/benchmark templates from built-in list (same as standalone binaries).
|
|
1180
|
-
builtinTemplates = discovered.filter((t) => !isStressBenchmarkTemplate(t.id));
|
|
1181
|
-
} catch {
|
|
1182
|
-
// Builtin dir may not exist in edge cases (e.g. standalone binary extraction failure)
|
|
1183
|
-
}
|
|
1184
|
-
if (builtinTemplates.length > 0) {
|
|
1185
|
-
if (process.stdout.isTTY && process.stdin.isTTY) {
|
|
1186
|
-
return pickBuiltinTemplate(builtinTemplates);
|
|
1187
|
-
}
|
|
1188
|
-
const selected = selectDefaultBuiltinTemplate(builtinTemplates);
|
|
1189
|
-
if (selected !== undefined) {
|
|
1190
|
-
configLog.info(`No template specified. Using built-in "${selected.name}".`);
|
|
1191
|
-
configLog.info(
|
|
1192
|
-
"Run 'miravo templates list' to see all options, or use --template <name|path>.",
|
|
1193
|
-
);
|
|
1194
|
-
return selected.path;
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// 4. Nothing available
|
|
1199
|
-
throw new ConfigNotFoundError(
|
|
1200
|
-
"No template found. Use 'miravo templates list' to find built-ins or pass 'miravo start --template <name|path>'.",
|
|
1201
|
-
);
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
async function pickBuiltinTemplate(templates: readonly DiscoveredTemplate[]): Promise<string> {
|
|
1205
|
-
configLog.info("No template found in current directory.\n");
|
|
1206
|
-
|
|
1207
|
-
const choice = await select({
|
|
1208
|
-
message: "Choose a built-in template to get started:",
|
|
1209
|
-
options: buildBuiltinTemplateChoices(templates),
|
|
1210
|
-
maxItems: 9,
|
|
1211
|
-
});
|
|
1212
|
-
|
|
1213
|
-
if (isCancel(choice)) {
|
|
1214
|
-
process.exit(0);
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
configLog.info("Tip: use --template <id> to skip this prompt next time.");
|
|
1218
|
-
|
|
1219
|
-
return choice as string;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
export function buildBuiltinTemplateChoices(
|
|
1223
|
-
templates: readonly DiscoveredTemplate[],
|
|
1224
|
-
): Array<ClackOption<string>> {
|
|
1225
|
-
const groupedTemplates = groupTemplatesByCategory(sortTemplatesByCategoryAndName(templates));
|
|
1226
|
-
const choices: ClackOption<string>[] = [];
|
|
1227
|
-
|
|
1228
|
-
for (const group of groupedTemplates) {
|
|
1229
|
-
choices.push({
|
|
1230
|
-
value: `__header__${group.category}`,
|
|
1231
|
-
label: group.category,
|
|
1232
|
-
hint: `${group.templates.length} templates`,
|
|
1233
|
-
disabled: true,
|
|
1234
|
-
});
|
|
1235
|
-
|
|
1236
|
-
for (const [index, template] of group.templates.entries()) {
|
|
1237
|
-
const isLast = index === group.templates.length - 1;
|
|
1238
|
-
const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
|
|
1239
|
-
const label = ` ${branch} ${template.name}`;
|
|
1240
|
-
const hintPrefix = template.description ?? template.id;
|
|
1241
|
-
choices.push({
|
|
1242
|
-
value: template.path,
|
|
1243
|
-
label,
|
|
1244
|
-
hint: `${hintPrefix} \u00B7 ${template.uniqueModelCount} models \u00B7 ${template.totalTwinCount} twins`,
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
return choices;
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
function selectDefaultBuiltinTemplate(
|
|
1253
|
-
templates: readonly DiscoveredTemplate[],
|
|
1254
|
-
): DiscoveredTemplate | undefined {
|
|
1255
|
-
const preferredFilename = "smart-factory.miravo.yaml";
|
|
1256
|
-
return (
|
|
1257
|
-
templates.find((template) => basename(template.path) === preferredFilename) ?? templates[0]
|
|
1258
|
-
);
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// ---------------------------------------------------------------------------
|
|
1262
|
-
// Step 2: Load and validate
|
|
1263
|
-
// ---------------------------------------------------------------------------
|
|
1264
|
-
|
|
1265
|
-
async function loadAndValidateTemplate(
|
|
1266
|
-
templatePath: string,
|
|
1267
|
-
options: StartOptions,
|
|
1268
|
-
catalog: ContentCatalog,
|
|
1269
|
-
): Promise<{
|
|
1270
|
-
template: MiravoTemplate;
|
|
1271
|
-
models: Map<string, TwinModel>;
|
|
1272
|
-
seed: number;
|
|
1273
|
-
seedExplicit: boolean;
|
|
1274
|
-
}> {
|
|
1275
|
-
const template = await loadTemplate(templatePath);
|
|
1276
|
-
|
|
1277
|
-
// Build model search paths from catalog
|
|
1278
|
-
const catalogSearchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
|
|
1279
|
-
const searchPaths = [...catalogSearchPaths];
|
|
1280
|
-
if (options.modelsDir) {
|
|
1281
|
-
// Insert after template dir but before other paths
|
|
1282
|
-
searchPaths.splice(1, 0, resolve(options.modelsDir));
|
|
1283
|
-
}
|
|
1284
|
-
const uniqueSearchPaths = [...new Set(searchPaths.map((path) => resolve(path)))];
|
|
1285
|
-
|
|
1286
|
-
// Cross-validate
|
|
1287
|
-
const validation = await validateFullTemplate(templatePath, uniqueSearchPaths);
|
|
1288
|
-
for (const issue of validation.issues) {
|
|
1289
|
-
if (issue.severity === "error") {
|
|
1290
|
-
configLog.error(`${issue.message}\n Fix: ${issue.fix}`);
|
|
1291
|
-
} else {
|
|
1292
|
-
configLog.warn(issue.message);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
if (!validation.valid) {
|
|
1296
|
-
throw new ConfigValidationError({
|
|
1297
|
-
message: "Template validation failed. See errors above.",
|
|
1298
|
-
fix: "Fix the reported issues and try again.",
|
|
1299
|
-
filePath: templatePath,
|
|
1300
|
-
});
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Build inline models map
|
|
1304
|
-
const models = new Map<string, TwinModel>();
|
|
1305
|
-
if (template.models) {
|
|
1306
|
-
for (const inlineModel of template.models) {
|
|
1307
|
-
models.set(inlineModel.model.name, inlineModel);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
// Resolve and load all referenced models not already inline
|
|
1312
|
-
const referencedModelNames = collectReferencedModels(template);
|
|
1313
|
-
|
|
1314
|
-
for (const modelName of referencedModelNames) {
|
|
1315
|
-
if (models.has(modelName)) continue;
|
|
1316
|
-
const modelPath = await resolveModelPath(modelName, uniqueSearchPaths);
|
|
1317
|
-
const model = await loadTwinModel(modelPath);
|
|
1318
|
-
models.set(modelName, model);
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
// Resolve seed: CLI flag > config setting > random
|
|
1322
|
-
const configSeed = template.settings?.seed;
|
|
1323
|
-
const cliSeed = options.seed;
|
|
1324
|
-
const seedExplicit = cliSeed !== undefined || configSeed !== undefined;
|
|
1325
|
-
const seed = cliSeed ?? configSeed ?? Date.now();
|
|
1326
|
-
|
|
1327
|
-
return { template, models, seed, seedExplicit };
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
export function resolveSimulationName(input: {
|
|
1331
|
-
readonly template: MiravoTemplate;
|
|
1332
|
-
readonly cliName?: string;
|
|
1333
|
-
readonly envName?: string;
|
|
1334
|
-
}): string {
|
|
1335
|
-
const { template, cliName, envName } = input;
|
|
1336
|
-
|
|
1337
|
-
const nameFromNamespace = (): string | undefined => {
|
|
1338
|
-
const namespace = template.namespace;
|
|
1339
|
-
if (!namespace) return undefined;
|
|
1340
|
-
if ("enterprise" in namespace) {
|
|
1341
|
-
return `${namespace.enterprise}-${namespace.site}`.toLowerCase();
|
|
1342
|
-
}
|
|
1343
|
-
return namespace.prefix.toLowerCase();
|
|
1344
|
-
};
|
|
1345
|
-
|
|
1346
|
-
const rawName = cliName ?? envName ?? nameFromNamespace();
|
|
1347
|
-
if (!rawName) {
|
|
1348
|
-
throw new ConfigValidationError({
|
|
1349
|
-
message: "Template has no namespace.",
|
|
1350
|
-
fix: "Provide a simulation name via --name or MIRAVO_NAME.",
|
|
1351
|
-
});
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
const parsed = SimulationNameSchema.safeParse(rawName);
|
|
1355
|
-
if (!parsed.success) {
|
|
1356
|
-
throw new ConfigValidationError({
|
|
1357
|
-
message: `Invalid simulation name "${rawName}".`,
|
|
1358
|
-
fix: "Use lowercase letters, numbers, and hyphens only (for example: north-plant).",
|
|
1359
|
-
});
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
return parsed.data;
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
// ---------------------------------------------------------------------------
|
|
1366
|
-
// Step 4: Engine creation
|
|
1367
|
-
// ---------------------------------------------------------------------------
|
|
1368
|
-
|
|
1369
|
-
function createAndPopulateEngine(
|
|
1370
|
-
template: MiravoTemplate,
|
|
1371
|
-
models: Map<string, TwinModel>,
|
|
1372
|
-
catalog: ContentCatalog,
|
|
1373
|
-
seed: number,
|
|
1374
|
-
options: StartOptions,
|
|
1375
|
-
templatePath: string,
|
|
1376
|
-
simulationName: string,
|
|
1377
|
-
): Engine {
|
|
1378
|
-
const tickIntervalMs =
|
|
1379
|
-
options.tick ?? template.settings?.tick_interval_ms ?? DEFAULT_TICK_INTERVAL_MS;
|
|
1380
|
-
const timeMultiplier =
|
|
1381
|
-
options.speed ?? template.settings?.time_multiplier ?? DEFAULT_TIME_MULTIPLIER;
|
|
1382
|
-
const overrunThresholdMultiplier = template.settings?.overrun_threshold_multiplier;
|
|
1383
|
-
const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
|
|
1384
|
-
|
|
1385
|
-
let engine!: Engine;
|
|
1386
|
-
engine = createEngine({
|
|
1387
|
-
simulationName,
|
|
1388
|
-
seed,
|
|
1389
|
-
tickIntervalMs,
|
|
1390
|
-
timeMultiplier,
|
|
1391
|
-
overrunThresholdMultiplier,
|
|
1392
|
-
templateMetadata: {
|
|
1393
|
-
name: template.name,
|
|
1394
|
-
path: templatePath,
|
|
1395
|
-
},
|
|
1396
|
-
beforeRun: async ({ restoredFromPersistence }) => {
|
|
1397
|
-
const protocolBootPlans = resolveProtocolBootPlans({
|
|
1398
|
-
options,
|
|
1399
|
-
optionSources: {
|
|
1400
|
-
mqtt: getCommandOptionSource(options.command, "mqtt"),
|
|
1401
|
-
opcua: getCommandOptionSource(options.command, "opcua"),
|
|
1402
|
-
mqttPort: getCommandOptionSource(options.command, "mqttPort"),
|
|
1403
|
-
mqttHost: getCommandOptionSource(options.command, "mqttHost"),
|
|
1404
|
-
mqttMode: getCommandOptionSource(options.command, "mqttMode"),
|
|
1405
|
-
mqttUrl: getCommandOptionSource(options.command, "mqttUrl"),
|
|
1406
|
-
mqttUsername: getCommandOptionSource(options.command, "mqttUsername"),
|
|
1407
|
-
mqttPassword: getCommandOptionSource(options.command, "mqttPassword"),
|
|
1408
|
-
opcuaPort: getCommandOptionSource(options.command, "opcuaPort"),
|
|
1409
|
-
opcuaHost: getCommandOptionSource(options.command, "opcuaHost"),
|
|
1410
|
-
modbus: getCommandOptionSource(options.command, "modbus"),
|
|
1411
|
-
modbusPort: getCommandOptionSource(options.command, "modbusPort"),
|
|
1412
|
-
modbusHost: getCommandOptionSource(options.command, "modbusHost"),
|
|
1413
|
-
},
|
|
1414
|
-
restoredFromPersistence,
|
|
1415
|
-
});
|
|
1416
|
-
|
|
1417
|
-
for (const plan of protocolBootPlans) {
|
|
1418
|
-
await reconcileProtocolBootPlan(engine, plan);
|
|
1419
|
-
}
|
|
1420
|
-
},
|
|
1421
|
-
persistence: {
|
|
1422
|
-
enabled: true,
|
|
1423
|
-
resetOnStart: options.resetState ?? false,
|
|
1424
|
-
ensureModelRegistered: (modelName) =>
|
|
1425
|
-
registerModelFromSearchPaths(engine.runtime, modelName, searchPaths),
|
|
1426
|
-
loadTemplateModels: (persistedTemplatePath) => {
|
|
1427
|
-
loadTemplateModels(catalog, persistedTemplatePath, engine.runtime);
|
|
1428
|
-
},
|
|
1429
|
-
},
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
// Register all models
|
|
1433
|
-
for (const [, model] of models) {
|
|
1434
|
-
engine.runtime.registerModel(model);
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
// Spawn instances from template with boot-time run ID
|
|
1438
|
-
const bootRunId = 1;
|
|
1439
|
-
const allInstanceIds: string[] = [];
|
|
1440
|
-
|
|
1441
|
-
forEachTwin(template, (area, line, twin) => {
|
|
1442
|
-
const unsBasePath = buildUNSBasePath(template, area.name, line.name);
|
|
1443
|
-
const spawned = engine.runtime.spawn(twin.model, twin.count, {
|
|
1444
|
-
variation: twin.variation,
|
|
1445
|
-
unsBasePath,
|
|
1446
|
-
...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
|
|
1447
|
-
...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
|
|
1448
|
-
templateRunId: bootRunId,
|
|
1449
|
-
});
|
|
1450
|
-
for (const node of spawned) {
|
|
1451
|
-
allInstanceIds.push(node.id);
|
|
1452
|
-
}
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
// Register boot-time template as a template run for persistence/unload support
|
|
1456
|
-
engine.registerTemplateRun({
|
|
1457
|
-
templateRunId: bootRunId,
|
|
1458
|
-
templateName: template.name,
|
|
1459
|
-
templatePath,
|
|
1460
|
-
prefix: "boot",
|
|
1461
|
-
instanceIds: allInstanceIds,
|
|
1462
|
-
models: [...models.keys()],
|
|
1463
|
-
});
|
|
1464
|
-
|
|
1465
|
-
return engine;
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
// ---------------------------------------------------------------------------
|
|
1469
|
-
// Runtime template loader (for loadTemplate command from console/API)
|
|
1470
|
-
// ---------------------------------------------------------------------------
|
|
1471
|
-
|
|
1472
|
-
interface CachedTemplate {
|
|
1473
|
-
readonly id: string;
|
|
1474
|
-
readonly name: string;
|
|
1475
|
-
readonly path: string;
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
interface TemplateCacheResult {
|
|
1479
|
-
readonly pathCache: Map<string, CachedTemplate>;
|
|
1480
|
-
readonly catalog: readonly CatalogTemplate[];
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* Discover templates once and build both the path lookup cache (for runtime
|
|
1485
|
-
* template loading) and the catalog (for the REST API) in a single pass.
|
|
1486
|
-
* All data comes from discovery metadata — no additional file I/O.
|
|
1487
|
-
*/
|
|
1488
|
-
async function buildTemplateCacheAndCatalog(
|
|
1489
|
-
contentCatalog: ContentCatalog,
|
|
1490
|
-
): Promise<TemplateCacheResult> {
|
|
1491
|
-
const pathCache = new Map<string, CachedTemplate>();
|
|
1492
|
-
const catalogEntries: CatalogTemplate[] = [];
|
|
1493
|
-
const discovered = await discoverTemplates(contentCatalog.getBuiltinTemplatesDir());
|
|
1494
|
-
|
|
1495
|
-
for (const t of discovered) {
|
|
1496
|
-
const entry: CachedTemplate = { id: t.id, name: t.name, path: t.path };
|
|
1497
|
-
pathCache.set(t.id, entry);
|
|
1498
|
-
pathCache.set(t.name, entry);
|
|
1499
|
-
|
|
1500
|
-
catalogEntries.push({
|
|
1501
|
-
id: t.id,
|
|
1502
|
-
name: t.name,
|
|
1503
|
-
description: t.description ?? null,
|
|
1504
|
-
category: "Industrial",
|
|
1505
|
-
models: t.modelNames,
|
|
1506
|
-
instanceCount: t.totalTwinCount,
|
|
1507
|
-
memberCount: 0,
|
|
1508
|
-
source: "builtin",
|
|
1509
|
-
});
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
return { pathCache, catalog: catalogEntries };
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
function createRuntimeTemplateLoader(
|
|
1516
|
-
catalog: ContentCatalog,
|
|
1517
|
-
templatePathCache: Map<string, CachedTemplate>,
|
|
1518
|
-
): (templatePath: string, count: number, runId: number, runtime: TwinRuntime) => TemplateRun {
|
|
1519
|
-
return (templateIdentifier, count, runId, runtime) => {
|
|
1520
|
-
// Resolve template path from pre-built cache or as a file path
|
|
1521
|
-
let templatePath: string;
|
|
1522
|
-
let slugName: string;
|
|
1523
|
-
if (isTemplatePathLike(templateIdentifier)) {
|
|
1524
|
-
templatePath = resolve(templateIdentifier);
|
|
1525
|
-
slugName = basename(templatePath).replace(/\.miravo\.yaml$/, "");
|
|
1526
|
-
} else {
|
|
1527
|
-
const cached = templatePathCache.get(templateIdentifier);
|
|
1528
|
-
if (!cached) {
|
|
1529
|
-
const ids = [...new Set([...templatePathCache.values()].map((e) => e.id))];
|
|
1530
|
-
throw new MiravoError(
|
|
1531
|
-
`Template "${templateIdentifier}" not found.`,
|
|
1532
|
-
`Available templates: ${ids.join(", ") || "(none)"}`,
|
|
1533
|
-
);
|
|
1534
|
-
}
|
|
1535
|
-
templatePath = cached.path;
|
|
1536
|
-
slugName = cached.id;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
const template = loadTemplateSync(templatePath);
|
|
1540
|
-
const referencedModels = registerTemplateModels(catalog, templatePath, template, runtime);
|
|
1541
|
-
|
|
1542
|
-
// Spawn instances with auto-prefix using kebab-case slug
|
|
1543
|
-
const prefix = `${slugName}-${runId}`;
|
|
1544
|
-
const allInstanceIds: string[] = [];
|
|
1545
|
-
|
|
1546
|
-
for (let c = 0; c < count; c++) {
|
|
1547
|
-
const runPrefix = count > 1 ? `${prefix}-${c + 1}` : prefix;
|
|
1548
|
-
forEachTwin(template, (area, line, twin) => {
|
|
1549
|
-
const unsBasePath = `${runPrefix}/${buildUNSBasePath(template, area.name, line.name)}`;
|
|
1550
|
-
const spawned = runtime.spawn(twin.model, twin.count, {
|
|
1551
|
-
variation: twin.variation,
|
|
1552
|
-
unsBasePath,
|
|
1553
|
-
...(twin.parameters !== undefined ? { parameterOverrides: twin.parameters } : {}),
|
|
1554
|
-
...(twin.starting_hours !== undefined ? { startingHours: twin.starting_hours } : {}),
|
|
1555
|
-
templateRunId: runId,
|
|
1556
|
-
});
|
|
1557
|
-
for (const node of spawned) {
|
|
1558
|
-
allInstanceIds.push(node.id);
|
|
1559
|
-
}
|
|
1560
|
-
});
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
return {
|
|
1564
|
-
templateRunId: runId,
|
|
1565
|
-
templateName: template.name,
|
|
1566
|
-
templatePath,
|
|
1567
|
-
prefix,
|
|
1568
|
-
instanceIds: allInstanceIds,
|
|
1569
|
-
models: [...referencedModels],
|
|
1570
|
-
};
|
|
1571
|
-
};
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
function loadTemplateSync(templatePath: string): MiravoTemplate {
|
|
1575
|
-
try {
|
|
1576
|
-
return MiravoTemplateSchema.parse(parseYAML(readFileSync(templatePath, "utf-8")));
|
|
1577
|
-
} catch (err) {
|
|
1578
|
-
throw new MiravoError(
|
|
1579
|
-
`Failed to load template "${templatePath}".`,
|
|
1580
|
-
err instanceof Error ? err.message : "Check that the file is valid YAML.",
|
|
1581
|
-
);
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
function registerModelFromSearchPaths(
|
|
1586
|
-
runtime: TwinRuntime,
|
|
1587
|
-
modelName: string,
|
|
1588
|
-
searchPaths: readonly string[],
|
|
1589
|
-
): void {
|
|
1590
|
-
if (runtime.getModel(modelName)) {
|
|
1591
|
-
return;
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
const { path: modelPath, content: modelContent } = loadModelSync(modelName, searchPaths);
|
|
1595
|
-
try {
|
|
1596
|
-
const model = TwinModelSchema.parse(parseYAML(modelContent));
|
|
1597
|
-
runtime.registerModel(model);
|
|
1598
|
-
} catch (err) {
|
|
1599
|
-
throw new MiravoError(
|
|
1600
|
-
`Failed to load model "${modelName}" from "${modelPath}".`,
|
|
1601
|
-
err instanceof Error ? err.message : "Check that the model file is valid YAML.",
|
|
1602
|
-
);
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
function loadTemplateModels(
|
|
1607
|
-
catalog: ContentCatalog,
|
|
1608
|
-
templatePath: string,
|
|
1609
|
-
runtime: TwinRuntime,
|
|
1610
|
-
): readonly string[] {
|
|
1611
|
-
const template = loadTemplateSync(templatePath);
|
|
1612
|
-
return registerTemplateModels(catalog, templatePath, template, runtime);
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
function registerTemplateModels(
|
|
1616
|
-
catalog: ContentCatalog,
|
|
1617
|
-
templatePath: string,
|
|
1618
|
-
template: MiravoTemplate,
|
|
1619
|
-
runtime: TwinRuntime,
|
|
1620
|
-
): readonly string[] {
|
|
1621
|
-
const referencedModels = collectReferencedModels(template);
|
|
1622
|
-
const searchPaths = catalog.getTemplateValidationSearchPaths(templatePath);
|
|
1623
|
-
for (const modelName of referencedModels) {
|
|
1624
|
-
registerModelFromSearchPaths(runtime, modelName, searchPaths);
|
|
1625
|
-
}
|
|
1626
|
-
return [...referencedModels];
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
function loadModelSync(
|
|
1630
|
-
modelName: string,
|
|
1631
|
-
searchPaths: readonly string[],
|
|
1632
|
-
): { path: string; content: string } {
|
|
1633
|
-
for (const dir of searchPaths) {
|
|
1634
|
-
const candidate = resolve(dir, `${modelName}${TWIN_FILE_EXTENSION}`);
|
|
1635
|
-
try {
|
|
1636
|
-
return { path: candidate, content: readFileSync(candidate, "utf-8") };
|
|
1637
|
-
} catch {
|
|
1638
|
-
// File doesn't exist or can't be read — try next path
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
throw new MiravoError(
|
|
1642
|
-
`Model "${modelName}" not found in search paths.`,
|
|
1643
|
-
`Searched: ${searchPaths.join(", ")}. Ensure the model file exists as "${modelName}${TWIN_FILE_EXTENSION}".`,
|
|
1644
|
-
);
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
// ---------------------------------------------------------------------------
|
|
1648
|
-
// Step 5: Adapter default config builders
|
|
1649
|
-
// ---------------------------------------------------------------------------
|
|
1650
|
-
|
|
1651
|
-
function buildMQTTDefaultConfig(
|
|
1652
|
-
template: MiravoTemplate,
|
|
1653
|
-
portOverride: number | undefined,
|
|
1654
|
-
mqttOverrides: MqttRunOverrides,
|
|
1655
|
-
simulationName: string,
|
|
1656
|
-
): Record<string, unknown> {
|
|
1657
|
-
const mqttConfig = template.protocols?.mqtt;
|
|
1658
|
-
const port = portOverride ?? mqttConfig?.port ?? DEFAULT_MQTT_PORT;
|
|
1659
|
-
let embedded = mqttConfig?.embedded ?? true;
|
|
1660
|
-
let external = mqttConfig?.external;
|
|
1661
|
-
let host = "127.0.0.1";
|
|
1662
|
-
let username = mqttConfig?.username;
|
|
1663
|
-
let password = mqttConfig?.password;
|
|
1664
|
-
const modeOverride = mqttOverrides.mode;
|
|
1665
|
-
if (modeOverride !== undefined) {
|
|
1666
|
-
embedded = modeOverride === "embedded";
|
|
1667
|
-
if (embedded) {
|
|
1668
|
-
external = undefined;
|
|
1669
|
-
username = undefined;
|
|
1670
|
-
password = undefined;
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
if (mqttOverrides.url !== undefined) external = mqttOverrides.url;
|
|
1674
|
-
if (mqttOverrides.host !== undefined) host = mqttOverrides.host;
|
|
1675
|
-
if (mqttOverrides.username !== undefined) username = mqttOverrides.username;
|
|
1676
|
-
if (mqttOverrides.password !== undefined) password = mqttOverrides.password;
|
|
1677
|
-
|
|
1678
|
-
if (embedded && (external || username || password)) {
|
|
1679
|
-
throw new ConfigValidationError({
|
|
1680
|
-
message: "Embedded MQTT mode cannot be combined with external MQTT URL or credentials.",
|
|
1681
|
-
fix: "Use --mqtt-mode external for external broker settings, or remove external MQTT overrides.",
|
|
1682
|
-
});
|
|
1683
|
-
}
|
|
1684
|
-
if (!embedded && !external) {
|
|
1685
|
-
throw new ConfigValidationError({
|
|
1686
|
-
message: "MQTT mode is external but no broker URL was provided.",
|
|
1687
|
-
fix: "Set protocols.mqtt.external in template or pass --mqtt-url / MIRAVO_MQTT_URL.",
|
|
1688
|
-
});
|
|
1689
|
-
}
|
|
1690
|
-
if (password && !username) {
|
|
1691
|
-
throw new ConfigValidationError({
|
|
1692
|
-
message: "MQTT password was provided without username.",
|
|
1693
|
-
fix: "Provide --mqtt-username / MIRAVO_MQTT_USERNAME when setting a password.",
|
|
1694
|
-
});
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
const drainBudget = mqttOverrides.drainBudget ?? mqttConfig?.drain_budget;
|
|
1698
|
-
const batchSize = mqttOverrides.batchSize ?? mqttConfig?.batch_size;
|
|
1699
|
-
const brokerConcurrency = mqttOverrides.brokerConcurrency ?? mqttConfig?.broker_concurrency;
|
|
1700
|
-
|
|
1701
|
-
return {
|
|
1702
|
-
name: "mqtt",
|
|
1703
|
-
enabled: true,
|
|
1704
|
-
simulationName,
|
|
1705
|
-
embedded,
|
|
1706
|
-
port,
|
|
1707
|
-
host,
|
|
1708
|
-
retain: mqttConfig?.retain ?? false,
|
|
1709
|
-
...(external !== undefined ? { external } : {}),
|
|
1710
|
-
...(username !== undefined ? { username } : {}),
|
|
1711
|
-
...(password !== undefined ? { password } : {}),
|
|
1712
|
-
...(mqttConfig?.reconnect !== undefined ? { reconnect: mqttConfig.reconnect } : {}),
|
|
1713
|
-
...(mqttConfig?.rbe !== undefined ? { rbe: mqttConfig.rbe } : {}),
|
|
1714
|
-
...(mqttConfig?.metadata !== undefined ? { metadata: mqttConfig.metadata } : {}),
|
|
1715
|
-
...(drainBudget !== undefined ? { drain_budget: drainBudget } : {}),
|
|
1716
|
-
...(batchSize !== undefined ? { batch_size: batchSize } : {}),
|
|
1717
|
-
...(brokerConcurrency !== undefined ? { broker_concurrency: brokerConcurrency } : {}),
|
|
1718
|
-
};
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
function buildOPCUADefaultConfig(
|
|
1722
|
-
template: MiravoTemplate,
|
|
1723
|
-
portOverride: number | undefined,
|
|
1724
|
-
hostOverride: string | undefined,
|
|
1725
|
-
simulationName: string,
|
|
1726
|
-
writeBudgetOverride?: number,
|
|
1727
|
-
): Record<string, unknown> {
|
|
1728
|
-
const opcuaConfig = template.protocols?.opcua;
|
|
1729
|
-
const port = portOverride ?? opcuaConfig?.port ?? DEFAULT_OPCUA_PORT;
|
|
1730
|
-
const host = hostOverride ?? opcuaConfig?.host ?? "0.0.0.0";
|
|
1731
|
-
const writeBudget = writeBudgetOverride ?? opcuaConfig?.write_budget;
|
|
1732
|
-
|
|
1733
|
-
return {
|
|
1734
|
-
name: "opcua",
|
|
1735
|
-
enabled: true,
|
|
1736
|
-
port,
|
|
1737
|
-
host,
|
|
1738
|
-
security: opcuaConfig?.security ?? "none",
|
|
1739
|
-
simulationName,
|
|
1740
|
-
...(opcuaConfig?.buildInfo ? { buildInfo: opcuaConfig.buildInfo } : {}),
|
|
1741
|
-
...(writeBudget !== undefined ? { write_budget: writeBudget } : {}),
|
|
1742
|
-
};
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
/** Derive the MQTT endpoint string from a resolved adapter config. */
|
|
1746
|
-
function buildMQTTEndpoint(config: import("@miravo/shared").ProtocolConfig): string {
|
|
1747
|
-
const cfg = config as Record<string, unknown>;
|
|
1748
|
-
const port = cfg["port"] ?? DEFAULT_MQTT_PORT;
|
|
1749
|
-
const embedded = cfg["embedded"] ?? true;
|
|
1750
|
-
const external = cfg["external"] as string | undefined;
|
|
1751
|
-
return embedded ? `mqtt://localhost:${port}` : (external as string);
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
/** Derive the OPC UA endpoint string from a resolved adapter config. */
|
|
1755
|
-
function buildOPCUAEndpoint(config: import("@miravo/shared").ProtocolConfig): string {
|
|
1756
|
-
const cfg = config as Record<string, unknown>;
|
|
1757
|
-
const port = cfg["port"] ?? DEFAULT_OPCUA_PORT;
|
|
1758
|
-
const host = cfg["host"] ?? "0.0.0.0";
|
|
1759
|
-
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
1760
|
-
return `opc.tcp://${displayHost}:${port}`;
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
function buildModbusDefaultConfig(
|
|
1764
|
-
template: MiravoTemplate,
|
|
1765
|
-
portOverride: number | undefined,
|
|
1766
|
-
hostOverride: string | undefined,
|
|
1767
|
-
): Record<string, unknown> {
|
|
1768
|
-
const modbusConfig = template.protocols?.modbus;
|
|
1769
|
-
const port = portOverride ?? modbusConfig?.port ?? DEFAULT_MODBUS_PORT;
|
|
1770
|
-
const host = hostOverride ?? modbusConfig?.host ?? "0.0.0.0";
|
|
1771
|
-
return { name: "modbus", enabled: true, port, host };
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
function buildModbusEndpoint(config: Record<string, unknown>): string {
|
|
1775
|
-
const port = (config as { port?: number }).port ?? DEFAULT_MODBUS_PORT;
|
|
1776
|
-
const host = (config as { host?: string }).host ?? "0.0.0.0";
|
|
1777
|
-
return `modbus://${displayHost(String(host))}:${port}`;
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
// ---------------------------------------------------------------------------
|
|
1781
|
-
// Step 6: Startup banner
|
|
1782
|
-
// ---------------------------------------------------------------------------
|
|
1783
|
-
|
|
1784
|
-
function printStartupBanner(
|
|
1785
|
-
templatePath: string,
|
|
1786
|
-
engine: Engine,
|
|
1787
|
-
seed: number,
|
|
1788
|
-
seedExplicit: boolean,
|
|
1789
|
-
mqttEndpoint: string | null,
|
|
1790
|
-
opcuaEndpoint: string | null,
|
|
1791
|
-
modbusEndpoint: string | null,
|
|
1792
|
-
simulationName: string,
|
|
1793
|
-
runId: string,
|
|
1794
|
-
adminEndpoint: string,
|
|
1795
|
-
): void {
|
|
1796
|
-
const state = engine.getState();
|
|
1797
|
-
|
|
1798
|
-
// Build model summary: one model per line for readability
|
|
1799
|
-
const modelLines = state.models.map(
|
|
1800
|
-
(m) => `${colors.bold(m.name)} ${colors.dim(`(${m.memberCount} members)`)}`,
|
|
1801
|
-
);
|
|
1802
|
-
const modelCount = `${colors.bold(String(state.models.length))} ${colors.dim("models")}`;
|
|
1803
|
-
|
|
1804
|
-
const seedValue = seedExplicit
|
|
1805
|
-
? colors.bold(String(seed))
|
|
1806
|
-
: `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
|
|
1807
|
-
|
|
1808
|
-
const mqttValue = mqttEndpoint ? colors.cyan(mqttEndpoint) : colors.dim("disabled");
|
|
1809
|
-
const opcuaValue = opcuaEndpoint ? colors.cyan(opcuaEndpoint) : colors.dim("disabled");
|
|
1810
|
-
const modbusValue = modbusEndpoint ? colors.cyan(modbusEndpoint) : colors.dim("disabled");
|
|
1811
|
-
|
|
1812
|
-
const allEntries: ReadonlyArray<readonly [string, string]> = [
|
|
1813
|
-
[colors.dim("Simulation"), colors.bold(simulationName)],
|
|
1814
|
-
[colors.dim("Run ID"), colors.bold(runId)],
|
|
1815
|
-
[colors.dim("Template"), templatePath],
|
|
1816
|
-
[colors.dim("Models"), modelCount],
|
|
1817
|
-
[colors.dim("Instances"), colors.bold(String(state.instances.length))],
|
|
1818
|
-
[colors.dim("MQTT"), mqttValue],
|
|
1819
|
-
[colors.dim("OPC UA"), opcuaValue],
|
|
1820
|
-
[colors.dim("Modbus"), modbusValue],
|
|
1821
|
-
[colors.dim("Admin"), colors.cyan(adminEndpoint)],
|
|
1822
|
-
[colors.dim("Tick interval"), colors.bold(`${state.config.tickIntervalMs}ms`)],
|
|
1823
|
-
[colors.dim("Time multiplier"), colors.bold(`${state.config.timeMultiplier}x`)],
|
|
1824
|
-
[colors.dim("Seed"), seedValue],
|
|
1825
|
-
];
|
|
1826
|
-
|
|
1827
|
-
const kvBlock = formatKeyValue(allEntries);
|
|
1828
|
-
|
|
1829
|
-
// Compute the value column offset used by formatKeyValue so model detail
|
|
1830
|
-
// lines align with the value of the "Models" row.
|
|
1831
|
-
const maxKeyWidth = Math.max(...allEntries.map(([key]) => stripAnsi(key).length));
|
|
1832
|
-
const valueIndent = 4 + maxKeyWidth + 3; // " " + key + ":" + padding(2) + " "
|
|
1833
|
-
|
|
1834
|
-
// Inject per-model detail lines right after the "Models" row
|
|
1835
|
-
const kvLines = kvBlock.split("\n");
|
|
1836
|
-
const modelsIdx = kvLines.findIndex((line) => stripAnsi(line).includes("Models:"));
|
|
1837
|
-
if (modelsIdx !== -1 && modelLines.length > 0) {
|
|
1838
|
-
const detailLines = modelLines.map((line) => `${"".padEnd(valueIndent)}${line}`);
|
|
1839
|
-
kvLines.splice(modelsIdx + 1, 0, ...detailLines);
|
|
1840
|
-
}
|
|
1841
|
-
const content = kvLines.join("\n");
|
|
1842
|
-
|
|
1843
|
-
const header = `${colors.cyan(colors.bold("Miravo"))} ${colors.dim(`v${MIRAVO_VERSION}`)}`;
|
|
1844
|
-
|
|
1845
|
-
if (isPlain()) {
|
|
1846
|
-
writeOutput(`${header}`);
|
|
1847
|
-
writeOutput(content);
|
|
1848
|
-
writeOutput();
|
|
1849
|
-
engineLog.start("Press Ctrl+C to stop.");
|
|
1850
|
-
return;
|
|
1851
|
-
}
|
|
1852
|
-
|
|
1853
|
-
writeOutput(
|
|
1854
|
-
box(content, {
|
|
1855
|
-
title: header,
|
|
1856
|
-
style: { borderColor: "cyan", borderStyle: "rounded" },
|
|
1857
|
-
}),
|
|
1858
|
-
);
|
|
1859
|
-
|
|
1860
|
-
engineLog.start("Press Ctrl+C to stop.");
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
function parseMqttModeOption(raw: string | undefined): MqttMode | undefined {
|
|
1864
|
-
if (raw === undefined) {
|
|
1865
|
-
return undefined;
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
if (raw === "embedded" || raw === "external") {
|
|
1869
|
-
return raw;
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
throw new ConfigValidationError({
|
|
1873
|
-
message: `Invalid MQTT mode "${raw}".`,
|
|
1874
|
-
fix: "Use --mqtt-mode embedded|external or MIRAVO_MQTT_MODE=embedded|external.",
|
|
1875
|
-
});
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
// ---------------------------------------------------------------------------
|
|
1879
|
-
// Step 7: Event subscriptions + periodic summary
|
|
1880
|
-
// ---------------------------------------------------------------------------
|
|
1881
|
-
|
|
1882
|
-
function subscribeToEvents(engine: Engine, errorContextCounts: Map<string, number>): () => void {
|
|
1883
|
-
const onLifecycle = (event: {
|
|
1884
|
-
instanceId: string;
|
|
1885
|
-
from: string;
|
|
1886
|
-
to: string;
|
|
1887
|
-
operatingHours: number;
|
|
1888
|
-
}): void => {
|
|
1889
|
-
lifecycleLog.info(
|
|
1890
|
-
`${event.instanceId} ${event.from} ${colors.dim("\u2192")} ${event.to} ${colors.gray(`(${event.operatingHours.toFixed(0)}h)`)}`,
|
|
1891
|
-
);
|
|
1892
|
-
};
|
|
1893
|
-
|
|
1894
|
-
const onFaultTriggered = (event: { instanceId: string; fault: string }): void => {
|
|
1895
|
-
faultLog.warn(`${event.instanceId} ${event.fault} triggered`);
|
|
1896
|
-
};
|
|
1897
|
-
|
|
1898
|
-
const onFaultCleared = (event: { instanceId: string; fault: string }): void => {
|
|
1899
|
-
faultLog.debug(`${event.instanceId} ${event.fault} cleared`);
|
|
1900
|
-
};
|
|
1901
|
-
|
|
1902
|
-
const onWarn = (event: { message: string; context: string }): void => {
|
|
1903
|
-
engineLog.debug(event.message, { context: event.context });
|
|
1904
|
-
};
|
|
1905
|
-
|
|
1906
|
-
const onReset = (event: {
|
|
1907
|
-
instanceCount: number;
|
|
1908
|
-
templateRunCount: number;
|
|
1909
|
-
clearPersistence: boolean;
|
|
1910
|
-
}): void => {
|
|
1911
|
-
const suffix = event.clearPersistence ? " · saved state cleared" : "";
|
|
1912
|
-
engineLog.info(
|
|
1913
|
-
`simulation reset · ${colors.bold(String(event.instanceCount))} instances · ${colors.bold(String(event.templateRunCount))} template runs${suffix}`,
|
|
1914
|
-
);
|
|
1915
|
-
};
|
|
1916
|
-
|
|
1917
|
-
const onError = (event: { context: string; error: Error }): void => {
|
|
1918
|
-
incrementErrorContextCount(errorContextCounts, event.context);
|
|
1919
|
-
engineLog.error(
|
|
1920
|
-
"engine:error",
|
|
1921
|
-
{
|
|
1922
|
-
context: event.context,
|
|
1923
|
-
errorName: event.error.name,
|
|
1924
|
-
errorMessage: event.error.message,
|
|
1925
|
-
},
|
|
1926
|
-
event.error,
|
|
1927
|
-
);
|
|
1928
|
-
};
|
|
1929
|
-
|
|
1930
|
-
engine.bus.on("lifecycle:changed", onLifecycle);
|
|
1931
|
-
engine.bus.on("fault:triggered", onFaultTriggered);
|
|
1932
|
-
engine.bus.on("fault:cleared", onFaultCleared);
|
|
1933
|
-
engine.bus.on("engine:warn", onWarn);
|
|
1934
|
-
engine.bus.on("engine:error", onError);
|
|
1935
|
-
engine.bus.on("engine:reset", onReset);
|
|
1936
|
-
|
|
1937
|
-
return () => {
|
|
1938
|
-
engine.bus.off("lifecycle:changed", onLifecycle);
|
|
1939
|
-
engine.bus.off("fault:triggered", onFaultTriggered);
|
|
1940
|
-
engine.bus.off("fault:cleared", onFaultCleared);
|
|
1941
|
-
engine.bus.off("engine:warn", onWarn);
|
|
1942
|
-
engine.bus.off("engine:error", onError);
|
|
1943
|
-
engine.bus.off("engine:reset", onReset);
|
|
1944
|
-
};
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
export function normalizeErrorContext(raw: string): string {
|
|
1948
|
-
return raw
|
|
1949
|
-
.replace(/tick=\d+/g, "tick=*")
|
|
1950
|
-
.replace(/instance="[^"]+"/g, 'instance="*"')
|
|
1951
|
-
.replace(/model="[^"]+"/g, 'model="*"');
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
export function incrementErrorContextCount(counts: Map<string, number>, rawContext: string): void {
|
|
1955
|
-
const normalizedContext = normalizeErrorContext(rawContext);
|
|
1956
|
-
if (counts.has(normalizedContext)) {
|
|
1957
|
-
counts.set(normalizedContext, (counts.get(normalizedContext) ?? 0) + 1);
|
|
1958
|
-
return;
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
if (counts.size < MAX_ERROR_CONTEXT_KEYS) {
|
|
1962
|
-
counts.set(normalizedContext, 1);
|
|
1963
|
-
return;
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
counts.set(ERROR_CONTEXT_OVERFLOW, (counts.get(ERROR_CONTEXT_OVERFLOW) ?? 0) + 1);
|
|
1967
|
-
}
|
|
1968
|
-
|
|
1969
|
-
function getCommandOptionSource(
|
|
1970
|
-
command: Command | undefined,
|
|
1971
|
-
optionName: ProtocolOptionSource,
|
|
1972
|
-
): string | undefined {
|
|
1973
|
-
const maybeGetOptionValueSource = (
|
|
1974
|
-
command as Command & { getOptionValueSource?: (key: string) => string | undefined }
|
|
1975
|
-
)?.getOptionValueSource;
|
|
1976
|
-
return maybeGetOptionValueSource?.call(command, optionName);
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
export function resolveProtocolBootPlans(input: {
|
|
1980
|
-
readonly options: StartOptions & { readonly command?: Command | undefined };
|
|
1981
|
-
readonly optionSources: Partial<Record<ProtocolOptionSource, string | undefined>>;
|
|
1982
|
-
readonly restoredFromPersistence: boolean;
|
|
1983
|
-
}): readonly ProtocolBootPlan[] {
|
|
1984
|
-
const mqttConfigExplicit = [
|
|
1985
|
-
"mqttPort",
|
|
1986
|
-
"mqttHost",
|
|
1987
|
-
"mqttMode",
|
|
1988
|
-
"mqttUrl",
|
|
1989
|
-
"mqttUsername",
|
|
1990
|
-
"mqttPassword",
|
|
1991
|
-
].some((option) => input.optionSources[option as ProtocolOptionSource] === "cli");
|
|
1992
|
-
|
|
1993
|
-
const opcuaConfigExplicit =
|
|
1994
|
-
input.options.opcua === true &&
|
|
1995
|
-
["opcuaPort", "opcuaHost"].some(
|
|
1996
|
-
(option) => input.optionSources[option as ProtocolOptionSource] === "cli",
|
|
1997
|
-
);
|
|
1998
|
-
|
|
1999
|
-
const modbusConfigExplicit =
|
|
2000
|
-
input.options.modbus === true &&
|
|
2001
|
-
["modbusPort", "modbusHost"].some(
|
|
2002
|
-
(option) => input.optionSources[option as ProtocolOptionSource] === "cli",
|
|
2003
|
-
);
|
|
2004
|
-
|
|
2005
|
-
return [
|
|
2006
|
-
{
|
|
2007
|
-
name: "mqtt",
|
|
2008
|
-
shouldEnable: input.options.mqtt !== false,
|
|
2009
|
-
overrideRestore:
|
|
2010
|
-
!input.restoredFromPersistence ||
|
|
2011
|
-
input.optionSources["mqtt"] === "cli" ||
|
|
2012
|
-
mqttConfigExplicit,
|
|
2013
|
-
},
|
|
2014
|
-
{
|
|
2015
|
-
name: "opcua",
|
|
2016
|
-
shouldEnable: input.options.opcua === true,
|
|
2017
|
-
overrideRestore:
|
|
2018
|
-
!input.restoredFromPersistence ||
|
|
2019
|
-
input.optionSources["opcua"] === "cli" ||
|
|
2020
|
-
opcuaConfigExplicit,
|
|
2021
|
-
},
|
|
2022
|
-
{
|
|
2023
|
-
name: "modbus",
|
|
2024
|
-
shouldEnable: input.options.modbus === true,
|
|
2025
|
-
overrideRestore:
|
|
2026
|
-
!input.restoredFromPersistence ||
|
|
2027
|
-
input.optionSources["modbus"] === "cli" ||
|
|
2028
|
-
modbusConfigExplicit,
|
|
2029
|
-
},
|
|
2030
|
-
];
|
|
2031
|
-
}
|
|
2032
|
-
|
|
2033
|
-
export function resolveDetachAdapterConfigs(input: {
|
|
2034
|
-
readonly template: MiravoTemplate;
|
|
2035
|
-
readonly simulationName: string;
|
|
2036
|
-
readonly mqtt: {
|
|
2037
|
-
readonly enabled: boolean;
|
|
2038
|
-
readonly portOverride: number | undefined;
|
|
2039
|
-
readonly overrides: MqttRunOverrides;
|
|
2040
|
-
};
|
|
2041
|
-
readonly opcua: {
|
|
2042
|
-
readonly enabled: boolean;
|
|
2043
|
-
readonly portOverride: number | undefined;
|
|
2044
|
-
readonly hostOverride: string | undefined;
|
|
2045
|
-
readonly writeBudgetOverride: number | undefined;
|
|
2046
|
-
};
|
|
2047
|
-
readonly modbus: {
|
|
2048
|
-
readonly enabled: boolean;
|
|
2049
|
-
readonly portOverride: number | undefined;
|
|
2050
|
-
readonly hostOverride: string | undefined;
|
|
2051
|
-
};
|
|
2052
|
-
}): ResolvedDetachAdapterConfigs {
|
|
2053
|
-
const mqttConfig = input.mqtt.enabled
|
|
2054
|
-
? buildMQTTDefaultConfig(
|
|
2055
|
-
input.template,
|
|
2056
|
-
input.mqtt.portOverride,
|
|
2057
|
-
input.mqtt.overrides,
|
|
2058
|
-
input.simulationName,
|
|
2059
|
-
)
|
|
2060
|
-
: null;
|
|
2061
|
-
const opcuaConfig = input.opcua.enabled
|
|
2062
|
-
? buildOPCUADefaultConfig(
|
|
2063
|
-
input.template,
|
|
2064
|
-
input.opcua.portOverride,
|
|
2065
|
-
input.opcua.hostOverride,
|
|
2066
|
-
input.simulationName,
|
|
2067
|
-
input.opcua.writeBudgetOverride,
|
|
2068
|
-
)
|
|
2069
|
-
: null;
|
|
2070
|
-
const modbusConfig = input.modbus.enabled
|
|
2071
|
-
? buildModbusDefaultConfig(input.template, input.modbus.portOverride, input.modbus.hostOverride)
|
|
2072
|
-
: null;
|
|
2073
|
-
|
|
2074
|
-
return {
|
|
2075
|
-
mqtt: {
|
|
2076
|
-
port: mqttConfig ? ((mqttConfig as { port?: number }).port ?? DEFAULT_MQTT_PORT) : undefined,
|
|
2077
|
-
host: mqttConfig ? ((mqttConfig as { host?: string }).host ?? "127.0.0.1") : undefined,
|
|
2078
|
-
embedded: mqttConfig ? ((mqttConfig as { embedded?: boolean }).embedded ?? true) : false,
|
|
2079
|
-
},
|
|
2080
|
-
opcua: {
|
|
2081
|
-
port: opcuaConfig
|
|
2082
|
-
? ((opcuaConfig as { port?: number }).port ?? DEFAULT_OPCUA_PORT)
|
|
2083
|
-
: undefined,
|
|
2084
|
-
host: opcuaConfig ? ((opcuaConfig as { host?: string }).host ?? "0.0.0.0") : undefined,
|
|
2085
|
-
},
|
|
2086
|
-
modbus: {
|
|
2087
|
-
port: modbusConfig
|
|
2088
|
-
? ((modbusConfig as { port?: number }).port ?? DEFAULT_MODBUS_PORT)
|
|
2089
|
-
: undefined,
|
|
2090
|
-
host: modbusConfig ? ((modbusConfig as { host?: string }).host ?? "0.0.0.0") : undefined,
|
|
2091
|
-
},
|
|
2092
|
-
};
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
async function reconcileProtocolBootPlan(engine: Engine, plan: ProtocolBootPlan): Promise<void> {
|
|
2096
|
-
const adapter = engine.getAvailableAdapters().find((candidate) => candidate.name === plan.name);
|
|
2097
|
-
const isEnabled = adapter?.enabled === true;
|
|
2098
|
-
|
|
2099
|
-
if (!plan.shouldEnable) {
|
|
2100
|
-
if (!isEnabled || !plan.overrideRestore) {
|
|
2101
|
-
return;
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
const result = await engine.disableAdapter(plan.name);
|
|
2105
|
-
if (!result.success) {
|
|
2106
|
-
throw new MiravoError(
|
|
2107
|
-
result.message,
|
|
2108
|
-
result.error ?? `Disable adapter "${plan.name}" manually and retry startup.`,
|
|
2109
|
-
);
|
|
2110
|
-
}
|
|
2111
|
-
return;
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
if (plan.overrideRestore && isEnabled) {
|
|
2115
|
-
const disableResult = await engine.disableAdapter(plan.name);
|
|
2116
|
-
if (!disableResult.success) {
|
|
2117
|
-
throw new MiravoError(
|
|
2118
|
-
disableResult.message,
|
|
2119
|
-
disableResult.error ?? `Disable adapter "${plan.name}" manually and retry startup.`,
|
|
2120
|
-
);
|
|
2121
|
-
}
|
|
2122
|
-
if ((disableResult.data as { stopWarning?: boolean } | undefined)?.stopWarning === true) {
|
|
2123
|
-
throw new MiravoError(
|
|
2124
|
-
`Failed to safely reconfigure adapter "${plan.name}" during startup.`,
|
|
2125
|
-
`The previous ${plan.name.toUpperCase()} adapter instance did not stop cleanly. Free the port or clear the persisted adapter state before retrying.`,
|
|
2126
|
-
);
|
|
2127
|
-
}
|
|
2128
|
-
} else if (isEnabled) {
|
|
2129
|
-
return;
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
const result = await engine.enableAdapter(plan.name);
|
|
2133
|
-
if (!result.success) {
|
|
2134
|
-
throw new MiravoError(
|
|
2135
|
-
result.message,
|
|
2136
|
-
result.error ?? `Check ${plan.name.toUpperCase()} configuration.`,
|
|
2137
|
-
);
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
if (plan.name === "mqtt") {
|
|
2141
|
-
mqttLog.ready(
|
|
2142
|
-
`connected · broker: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
|
|
2143
|
-
);
|
|
2144
|
-
return;
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
if (plan.name === "modbus") {
|
|
2148
|
-
modbusLog.ready(
|
|
2149
|
-
`server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
|
|
2150
|
-
);
|
|
2151
|
-
return;
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
|
-
opcuaLog.ready(
|
|
2155
|
-
`server ready · endpoint: ${colors.cyan(String((result.data as Record<string, unknown>)?.["endpoint"] ?? ""))}`,
|
|
2156
|
-
);
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
function startPeriodicSummary(
|
|
2160
|
-
engine: Engine,
|
|
2161
|
-
intervalSeconds: number,
|
|
2162
|
-
): ReturnType<typeof setInterval> {
|
|
2163
|
-
return setInterval(() => {
|
|
2164
|
-
const metrics = engine.getMetrics();
|
|
2165
|
-
printSummaryLine(metrics);
|
|
2166
|
-
}, intervalSeconds * 1_000);
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
// ---------------------------------------------------------------------------
|
|
2170
|
-
// Step 3: Dry-run summary
|
|
2171
|
-
// ---------------------------------------------------------------------------
|
|
2172
|
-
|
|
2173
|
-
function printDryRunSummary(
|
|
2174
|
-
template: MiravoTemplate,
|
|
2175
|
-
models: Map<string, TwinModel>,
|
|
2176
|
-
seed: number,
|
|
2177
|
-
seedExplicit: boolean,
|
|
2178
|
-
templatePath: string,
|
|
2179
|
-
): void {
|
|
2180
|
-
configLog.success("Dry-run validation passed.");
|
|
2181
|
-
|
|
2182
|
-
const seedValue = seedExplicit
|
|
2183
|
-
? colors.bold(String(seed))
|
|
2184
|
-
: `${colors.bold(String(seed))} ${colors.gray("(random — add seed to config to reproduce)")}`;
|
|
2185
|
-
|
|
2186
|
-
const lines: string[] = [];
|
|
2187
|
-
|
|
2188
|
-
lines.push(
|
|
2189
|
-
formatKeyValue([
|
|
2190
|
-
[colors.dim("Template"), templatePath],
|
|
2191
|
-
[colors.dim("Seed"), seedValue],
|
|
2192
|
-
]),
|
|
2193
|
-
);
|
|
2194
|
-
|
|
2195
|
-
// Model table
|
|
2196
|
-
const modelRows = [...models.values()].map((m) => ({
|
|
2197
|
-
name: colors.cyan(m.model.name),
|
|
2198
|
-
members: String(Object.keys(m.members).length),
|
|
2199
|
-
faults: String(m.faults?.length ?? 0),
|
|
2200
|
-
lifecycle: m.lifecycle ? `${m.lifecycle.stages.length} stages` : colors.dim("none"),
|
|
2201
|
-
}));
|
|
2202
|
-
|
|
2203
|
-
if (modelRows.length > 0) {
|
|
2204
|
-
lines.push("");
|
|
2205
|
-
lines.push(` ${colors.bold("Models:")}`);
|
|
2206
|
-
lines.push(
|
|
2207
|
-
formatTable(
|
|
2208
|
-
{
|
|
2209
|
-
name: colors.dim("Name"),
|
|
2210
|
-
members: colors.dim("Members"),
|
|
2211
|
-
faults: colors.dim("Faults"),
|
|
2212
|
-
lifecycle: colors.dim("Lifecycle"),
|
|
2213
|
-
},
|
|
2214
|
-
modelRows,
|
|
2215
|
-
),
|
|
2216
|
-
);
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
// Instance summary
|
|
2220
|
-
let totalInstances = 0;
|
|
2221
|
-
let totalMembers = 0;
|
|
2222
|
-
forEachTwin(template, (_area, _line, twin) => {
|
|
2223
|
-
totalInstances += twin.count;
|
|
2224
|
-
const model = models.get(twin.model);
|
|
2225
|
-
if (model) {
|
|
2226
|
-
totalMembers += twin.count * Object.keys(model.members).length;
|
|
2227
|
-
}
|
|
2228
|
-
});
|
|
2229
|
-
|
|
2230
|
-
lines.push("");
|
|
2231
|
-
lines.push(
|
|
2232
|
-
formatKeyValue([
|
|
2233
|
-
[colors.dim("Total instances"), colors.bold(String(totalInstances))],
|
|
2234
|
-
[colors.dim("Total members"), colors.bold(String(totalMembers))],
|
|
2235
|
-
]),
|
|
2236
|
-
);
|
|
2237
|
-
|
|
2238
|
-
if (isPlain()) {
|
|
2239
|
-
writeOutput("Summary");
|
|
2240
|
-
for (const line of lines) {
|
|
2241
|
-
writeOutput(line);
|
|
2242
|
-
}
|
|
2243
|
-
writeOutput();
|
|
2244
|
-
return;
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
writeOutput(
|
|
2248
|
-
box(lines.join("\n"), {
|
|
2249
|
-
title: " Summary ",
|
|
2250
|
-
style: { borderColor: "cyan", borderStyle: "rounded" },
|
|
2251
|
-
}),
|
|
2252
|
-
);
|
|
2253
|
-
}
|