miravo 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +116 -30
  2. package/console-dist/assets/fira-code-cyrillic-ext-wght-normal-DhYMMuQd.woff2 +0 -0
  3. package/console-dist/assets/fira-code-cyrillic-wght-normal-Y3u8pIsh.woff2 +0 -0
  4. package/console-dist/assets/fira-code-greek-ext-wght-normal-wWus70Ix.woff2 +0 -0
  5. package/console-dist/assets/fira-code-greek-wght-normal-B2SviObF.woff2 +0 -0
  6. package/console-dist/assets/fira-code-latin-ext-wght-normal-Dvfvaomy.woff2 +0 -0
  7. package/console-dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  8. package/console-dist/assets/fira-code-symbols2-wght-normal-CE6EOz_n.woff2 +0 -0
  9. package/console-dist/assets/index-BXtW73cF.css +2 -0
  10. package/console-dist/assets/index-ChSocZFW.js +119 -0
  11. package/console-dist/assets/karla-latin-ext-wght-normal-Dq0rrjgs.woff2 +0 -0
  12. package/console-dist/assets/karla-latin-wght-normal-C3-ma4ov.woff2 +0 -0
  13. package/console-dist/assets/schibsted-grotesk-latin-ext-wght-normal-hsMS0n0O.woff2 +0 -0
  14. package/console-dist/assets/schibsted-grotesk-latin-wght-normal-Bb8VGrTG.woff2 +0 -0
  15. package/console-dist/favicon.svg +20 -0
  16. package/console-dist/index.html +14 -0
  17. package/console-dist/miravo-logo.png +0 -0
  18. package/package.json +10 -7
  19. package/src/__tests__/integration/executable-methods-runner.ts +52 -45
  20. package/src/__tests__/integration/executable-methods.integration.ts +609 -47
  21. package/src/commands/adapter.ts +76 -0
  22. package/src/commands/cli-test-setup.ts +1 -1
  23. package/src/commands/fork.ts +14 -0
  24. package/src/commands/inject.test.ts +1 -1
  25. package/src/commands/inject.ts +16 -8
  26. package/src/commands/load.ts +167 -0
  27. package/src/commands/logs.ts +175 -0
  28. package/src/commands/models.test.ts +22 -1
  29. package/src/commands/models.ts +14 -0
  30. package/src/commands/pause.ts +67 -0
  31. package/src/commands/ps.ts +116 -0
  32. package/src/commands/remove.ts +38 -0
  33. package/src/commands/reset.ts +44 -0
  34. package/src/commands/rm.ts +97 -0
  35. package/src/commands/spawn.ts +45 -0
  36. package/src/commands/speed.ts +40 -0
  37. package/src/commands/start.test.ts +93 -0
  38. package/src/commands/start.ts +976 -192
  39. package/src/commands/status.test.ts +3 -1
  40. package/src/commands/status.ts +12 -6
  41. package/src/commands/stop.test.ts +84 -0
  42. package/src/commands/stop.ts +158 -0
  43. package/src/commands/templates.test.ts +1 -1
  44. package/src/commands/templates.ts +13 -0
  45. package/src/index.ts +49 -7
  46. package/src/lib/admin-client.test.ts +35 -0
  47. package/src/lib/admin-client.ts +13 -1
  48. package/src/lib/admin-server.test.ts +339 -53
  49. package/src/lib/admin-server.ts +413 -62
  50. package/src/lib/daemon-registry.test.ts +429 -0
  51. package/src/lib/daemon-registry.ts +345 -0
  52. package/src/lib/mqtt-client.ts +1 -1
  53. package/src/lib/parse.ts +18 -0
  54. package/src/lib/resolve-simulation.ts +1 -1
  55. package/src/lib/shutdown.test.ts +16 -4
  56. package/src/lib/shutdown.ts +3 -3
  57. package/src/lib/template-category.test.ts +1 -0
  58. package/src/lib/template-utils.ts +33 -2
  59. package/src/lib/ws-hub.ts +255 -0
  60. package/src/logger.test.ts +19 -19
  61. package/src/logger.ts +34 -6
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import type { EventEmitter } from "node:events";
2
3
  import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
+ import { createServer } from "node:net";
3
5
  import { tmpdir } from "node:os";
4
6
  import { join, resolve } from "node:path";
5
7
  import {
@@ -8,6 +10,7 @@ import {
8
10
  controlTopicCmdResult,
9
11
  EngineMetricsSnapshotSchema,
10
12
  EngineStateSnapshotSchema,
13
+ PersistedStateSchema,
11
14
  type TwinModel,
12
15
  } from "@miravo/shared";
13
16
  import type { MqttClient } from "mqtt";
@@ -42,6 +45,11 @@ interface SmokeRuntimeHandle {
42
45
  readonly ready: SmokeRuntimeReady;
43
46
  }
44
47
 
48
+ const processOutputCache = new WeakMap<
49
+ SmokeRuntimeHandle["process"],
50
+ Promise<{ readonly stdout: string; readonly stderr: string }>
51
+ >();
52
+
45
53
  interface InvokeMethodCommandPayload {
46
54
  readonly type: "invokeMethod";
47
55
  readonly instanceId: string;
@@ -57,6 +65,68 @@ interface AdminHealthPayload {
57
65
  readonly reason?: string;
58
66
  }
59
67
 
68
+ async function canBindLocalTcp(): Promise<boolean> {
69
+ const probe = createServer();
70
+ return new Promise((resolvePromise) => {
71
+ const cleanup = () => {
72
+ try {
73
+ probe.close();
74
+ } catch {
75
+ // Ignore close races in probe cleanup.
76
+ }
77
+ };
78
+
79
+ (probe as unknown as EventEmitter).once("error", () => {
80
+ cleanup();
81
+ resolvePromise(false);
82
+ });
83
+ (probe as unknown as EventEmitter).once("listening", () => {
84
+ cleanup();
85
+ resolvePromise(true);
86
+ });
87
+ probe.listen(0, "127.0.0.1");
88
+ });
89
+ }
90
+
91
+ const TCP_AVAILABLE = await canBindLocalTcp();
92
+ const describeIfTcp = TCP_AVAILABLE ? describe : describe.skip;
93
+
94
+ async function reservePort(): Promise<number> {
95
+ const probe = createServer();
96
+ return new Promise((resolvePromise, reject) => {
97
+ const cleanup = () => {
98
+ try {
99
+ probe.close();
100
+ } catch {
101
+ // Ignore close races in probe cleanup.
102
+ }
103
+ };
104
+
105
+ (probe as unknown as EventEmitter).once("error", (error: Error) => {
106
+ cleanup();
107
+ reject(error);
108
+ });
109
+ (probe as unknown as EventEmitter).once("listening", () => {
110
+ const address = probe.address();
111
+ if (!address || typeof address === "string") {
112
+ cleanup();
113
+ reject(new Error("Failed to reserve a local TCP port for integration test."));
114
+ return;
115
+ }
116
+
117
+ const { port } = address;
118
+ probe.close((error) => {
119
+ if (error) {
120
+ reject(error);
121
+ return;
122
+ }
123
+ resolvePromise(port);
124
+ });
125
+ });
126
+ probe.listen(0, "127.0.0.1");
127
+ });
128
+ }
129
+
60
130
  function createSmokePumpModel(): TwinModel {
61
131
  return {
62
132
  schema_version: "2",
@@ -317,12 +387,12 @@ function createLifecycleTestModel(): TwinModel {
317
387
  stages: [
318
388
  {
319
389
  name: "healthy",
320
- duration_hours: [3.0, 3.0],
390
+ duration_hours: [0.3, 0.3],
321
391
  effects: {},
322
392
  },
323
393
  {
324
394
  name: "degraded",
325
- duration_hours: [1.5, 1.5],
395
+ duration_hours: [0.15, 0.15],
326
396
  effects: {
327
397
  pressure: { multiplier: 0.8, offset: -5 },
328
398
  },
@@ -583,9 +653,14 @@ async function waitForMQTTConnect(client: MqttClient, timeoutMs = 5000): Promise
583
653
  }
584
654
 
585
655
  async function stopMQTTClient(client: MqttClient): Promise<void> {
586
- await new Promise<void>((resolvePromise) => {
587
- client.end(true, {}, () => resolvePromise());
588
- });
656
+ await Promise.race([
657
+ new Promise<void>((resolvePromise) => {
658
+ client.end(true, {}, () => resolvePromise());
659
+ }),
660
+ Bun.sleep(5_000).then(() => {
661
+ throw new Error("MQTT client did not close within 5000ms.");
662
+ }),
663
+ ]);
589
664
  }
590
665
 
591
666
  async function subscribeMQTT(client: MqttClient, topics: readonly string[]): Promise<void> {
@@ -634,13 +709,22 @@ async function publishControlCommand(
634
709
  const correlationId = crypto.randomUUID();
635
710
  const commandTopic = controlTopicCmd(simulationName);
636
711
  const resultTopic = controlTopicCmdResult(simulationName);
637
- client.publish(
638
- commandTopic,
639
- JSON.stringify({
640
- correlationId,
641
- ...payload,
642
- }),
643
- );
712
+ await new Promise<void>((resolvePromise, reject) => {
713
+ client.publish(
714
+ commandTopic,
715
+ JSON.stringify({
716
+ correlationId,
717
+ ...payload,
718
+ }),
719
+ (error) => {
720
+ if (error) {
721
+ reject(error);
722
+ return;
723
+ }
724
+ resolvePromise();
725
+ },
726
+ );
727
+ });
644
728
 
645
729
  return waitFor(`MQTT command result for ${String(payload["type"])}`, () => {
646
730
  const result = recorder.messages
@@ -735,9 +819,18 @@ async function callOPCUAMethodResult(
735
819
  async function collectProcessOutput(
736
820
  process: SmokeRuntimeHandle["process"],
737
821
  ): Promise<{ readonly stdout: string; readonly stderr: string }> {
738
- const stdout = process.stdout ? await new Response(process.stdout).text() : "";
739
- const stderr = process.stderr ? await new Response(process.stderr).text() : "";
740
- return { stdout, stderr };
822
+ const cached = processOutputCache.get(process);
823
+ if (cached) {
824
+ return cached;
825
+ }
826
+
827
+ const pending = (async () => {
828
+ const stdout = process.stdout ? await new Response(process.stdout).text() : "";
829
+ const stderr = process.stderr ? await new Response(process.stderr).text() : "";
830
+ return { stdout, stderr };
831
+ })();
832
+ processOutputCache.set(process, pending);
833
+ return pending;
741
834
  }
742
835
 
743
836
  interface SmokeRuntimeOptions {
@@ -754,6 +847,14 @@ interface SmokeRuntimeOptions {
754
847
  };
755
848
  }
756
849
 
850
+ interface CliPersistenceRuntimeHandle {
851
+ readonly process: Bun.Subprocess<"ignore", "pipe", "pipe">;
852
+ readonly tmpDir: string;
853
+ readonly homeDir: string;
854
+ readonly stateFilePath: string;
855
+ readonly ready: SmokeRuntimeReady;
856
+ }
857
+
757
858
  async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeRuntimeHandle> {
758
859
  const model = options?.model ?? createSmokePumpModel();
759
860
  const prefix = options?.namespacePrefix ?? "method-smoke";
@@ -827,16 +928,21 @@ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeR
827
928
  });
828
929
 
829
930
  try {
830
- const ready = await waitForAsync(
831
- "smoke runtime ready file",
832
- async () => {
833
- if (!(await Bun.file(readyFile).exists())) {
834
- return undefined;
835
- }
836
- return (await Bun.file(readyFile).json()) as SmokeRuntimeReady;
837
- },
838
- 15000,
839
- );
931
+ const ready = await Promise.race([
932
+ waitForAsync(
933
+ "smoke runtime ready file",
934
+ async () => {
935
+ if (!(await Bun.file(readyFile).exists())) {
936
+ return undefined;
937
+ }
938
+ return (await Bun.file(readyFile).json()) as SmokeRuntimeReady;
939
+ },
940
+ 15000,
941
+ ),
942
+ process.exited.then((exitCode) => {
943
+ throw new Error(`Smoke runtime exited before it reported ready (exit code ${exitCode}).`);
944
+ }),
945
+ ]);
840
946
 
841
947
  return { process, tmpDir, ready };
842
948
  } catch (error) {
@@ -856,17 +962,206 @@ async function createSmokeRuntime(options?: SmokeRuntimeOptions): Promise<SmokeR
856
962
  }
857
963
  }
858
964
 
859
- async function stopSmokeRuntime(handle: SmokeRuntimeHandle): Promise<void> {
860
- handle.process.kill("SIGTERM");
965
+ async function createCliPersistenceRuntime(input: {
966
+ readonly tmpDir?: string;
967
+ readonly homeDir?: string;
968
+ readonly templatePath?: string;
969
+ readonly modelsDir?: string;
970
+ readonly model?: TwinModel;
971
+ readonly simulationName?: string;
972
+ readonly includeOpcuaFlag?: boolean;
973
+ readonly mqttPort?: number;
974
+ readonly opcuaPort?: number;
975
+ readonly adminPort?: number;
976
+ }): Promise<CliPersistenceRuntimeHandle> {
977
+ const model = input.model ?? createSmokePumpModel();
978
+ const simulationName = input.simulationName ?? "persistence-e2e";
979
+ const tmpDir = input.tmpDir ?? (await mkdtemp(join(tmpdir(), `miravo-${simulationName}-`)));
980
+ const homeDir = input.homeDir ?? join(tmpDir, "home");
981
+ const modelsDir = input.modelsDir ?? join(tmpDir, "models");
982
+ const templatePath = input.templatePath ?? join(tmpDir, `${simulationName}.miravo.yaml`);
983
+ const modelPath = join(modelsDir, `${model.model.name}.twin.yaml`);
984
+ const stateFilePath = join(homeDir, "state", `${simulationName}.json`);
985
+ const adminPort = input.adminPort ?? (await reservePort());
986
+
987
+ await mkdir(modelsDir, { recursive: true });
988
+ await mkdir(homeDir, { recursive: true });
989
+
990
+ if (!(await Bun.file(modelPath).exists())) {
991
+ await Bun.write(modelPath, stringifyYAML(model));
992
+ }
993
+ if (!(await Bun.file(templatePath).exists())) {
994
+ await Bun.write(
995
+ templatePath,
996
+ stringifyYAML({
997
+ kind: "MiravoTemplate",
998
+ version: "1",
999
+ name: `${simulationName} Template`,
1000
+ namespace: {
1001
+ prefix: simulationName,
1002
+ },
1003
+ areas: [
1004
+ {
1005
+ name: "validation",
1006
+ lines: [
1007
+ {
1008
+ name: "protocols",
1009
+ twins: [
1010
+ {
1011
+ model: model.model.name,
1012
+ count: 1,
1013
+ variation: 0,
1014
+ },
1015
+ ],
1016
+ },
1017
+ ],
1018
+ },
1019
+ ],
1020
+ settings: {
1021
+ seed: 42,
1022
+ tick_interval_ms: 100,
1023
+ time_multiplier: 1,
1024
+ },
1025
+ }),
1026
+ );
1027
+ }
1028
+
1029
+ const cmd = [
1030
+ "bun",
1031
+ "run",
1032
+ resolve(import.meta.dir, "..", "..", "index.ts"),
1033
+ "start",
1034
+ "--template",
1035
+ templatePath,
1036
+ "--models-dir",
1037
+ modelsDir,
1038
+ "--name",
1039
+ simulationName,
1040
+ "--admin-host",
1041
+ "127.0.0.1",
1042
+ "--admin-port",
1043
+ String(adminPort),
1044
+ "--no-ui",
1045
+ ];
1046
+ if (input.mqttPort !== undefined) {
1047
+ cmd.push("--mqtt-host", "127.0.0.1", "--mqtt-port", String(input.mqttPort));
1048
+ }
1049
+ if (input.includeOpcuaFlag === true) {
1050
+ cmd.push("--opcua");
1051
+ if (input.opcuaPort !== undefined) {
1052
+ cmd.push("--opcua-port", String(input.opcuaPort));
1053
+ }
1054
+ cmd.push("--opcua-host", "127.0.0.1");
1055
+ }
1056
+
1057
+ const process = Bun.spawn({
1058
+ cmd,
1059
+ cwd: resolve("."),
1060
+ stdin: "ignore",
1061
+ stdout: "pipe",
1062
+ stderr: "pipe",
1063
+ env: {
1064
+ ...Bun.env,
1065
+ MIRAVO_HOME: homeDir,
1066
+ },
1067
+ });
1068
+
1069
+ const adminUrl = `http://127.0.0.1:${adminPort}`;
1070
+ try {
1071
+ await Promise.race([
1072
+ waitForAsync(
1073
+ "CLI runtime ready",
1074
+ async () => {
1075
+ try {
1076
+ const response = await fetch(new URL("/readyz", `${adminUrl}/`));
1077
+ if (!response.ok) {
1078
+ return undefined;
1079
+ }
1080
+ const payload = (await response.json()) as AdminHealthPayload;
1081
+ return payload.status === "ok" ? payload : undefined;
1082
+ } catch {
1083
+ return undefined;
1084
+ }
1085
+ },
1086
+ 15000,
1087
+ ),
1088
+ process.exited.then((exitCode) => {
1089
+ throw new Error(`CLI runtime exited before it reported ready (exit code ${exitCode}).`);
1090
+ }),
1091
+ ]);
1092
+
1093
+ const state = await readAdminJson(
1094
+ {
1095
+ process,
1096
+ tmpDir,
1097
+ ready: {
1098
+ simulationName,
1099
+ adminUrl,
1100
+ mqttBrokerUrl: input.mqttPort ? `mqtt://127.0.0.1:${input.mqttPort}` : "",
1101
+ opcuaEndpointUrl: input.opcuaPort ? `opc.tcp://127.0.0.1:${input.opcuaPort}` : "",
1102
+ instanceId: "",
1103
+ instancePath: "",
1104
+ },
1105
+ },
1106
+ "/state",
1107
+ EngineStateSnapshotSchema,
1108
+ );
1109
+ const instance = state.instances[0];
1110
+ if (!instance) {
1111
+ throw new Error("CLI runtime did not expose any instances after startup.");
1112
+ }
1113
+
1114
+ return {
1115
+ process,
1116
+ tmpDir,
1117
+ homeDir,
1118
+ stateFilePath,
1119
+ ready: {
1120
+ simulationName,
1121
+ adminUrl,
1122
+ mqttBrokerUrl:
1123
+ input.mqttPort !== undefined ? `mqtt://127.0.0.1:${input.mqttPort}` : "mqtt://127.0.0.1",
1124
+ opcuaEndpointUrl:
1125
+ input.opcuaPort !== undefined
1126
+ ? `opc.tcp://127.0.0.1:${input.opcuaPort}`
1127
+ : "opc.tcp://127.0.0.1",
1128
+ instanceId: instance.id,
1129
+ instancePath: instance.browsePath.join("/"),
1130
+ },
1131
+ };
1132
+ } catch (error) {
1133
+ process.kill("SIGTERM");
1134
+ await process.exited;
1135
+ const output = await collectProcessOutput(process);
1136
+ throw new Error(
1137
+ [
1138
+ `Failed to start CLI persistence runtime: ${error instanceof Error ? error.message : String(error)}`,
1139
+ output.stdout.trim().length > 0 ? `stdout:\n${output.stdout.trim()}` : "",
1140
+ output.stderr.trim().length > 0 ? `stderr:\n${output.stderr.trim()}` : "",
1141
+ ]
1142
+ .filter(Boolean)
1143
+ .join("\n\n"),
1144
+ );
1145
+ }
1146
+ }
1147
+
1148
+ async function stopProcessGracefully(
1149
+ process: SmokeRuntimeHandle["process"],
1150
+ ): Promise<{ readonly stdout: string; readonly stderr: string }> {
1151
+ process.kill("SIGTERM");
861
1152
  const exitResult = await Promise.race([
862
- handle.process.exited.then(() => "exited"),
1153
+ process.exited.then(() => "exited"),
863
1154
  Bun.sleep(5000).then(() => "timeout"),
864
1155
  ]);
865
1156
  if (exitResult === "timeout") {
866
- handle.process.kill("SIGKILL");
867
- await handle.process.exited;
1157
+ process.kill("SIGKILL");
1158
+ await process.exited;
868
1159
  }
869
- await collectProcessOutput(handle.process);
1160
+ return collectProcessOutput(process);
1161
+ }
1162
+
1163
+ async function stopSmokeRuntime(handle: SmokeRuntimeHandle): Promise<void> {
1164
+ await stopProcessGracefully(handle.process);
870
1165
  await rm(handle.tmpDir, { recursive: true, force: true });
871
1166
  }
872
1167
 
@@ -874,17 +1169,7 @@ async function stopSmokeRuntimeWithFailure(
874
1169
  handle: SmokeRuntimeHandle,
875
1170
  cause: unknown,
876
1171
  ): Promise<never> {
877
- handle.process.kill("SIGTERM");
878
- const exitResult = await Promise.race([
879
- handle.process.exited.then(() => "exited"),
880
- Bun.sleep(5000).then(() => "timeout"),
881
- ]);
882
- if (exitResult === "timeout") {
883
- handle.process.kill("SIGKILL");
884
- await handle.process.exited;
885
- }
886
-
887
- const output = await collectProcessOutput(handle.process);
1172
+ const output = await stopProcessGracefully(handle.process);
888
1173
  await rm(handle.tmpDir, { recursive: true, force: true });
889
1174
 
890
1175
  throw new Error(
@@ -898,7 +1183,284 @@ async function stopSmokeRuntimeWithFailure(
898
1183
  );
899
1184
  }
900
1185
 
901
- describe("executable methods integration", () => {
1186
+ describeIfTcp("executable methods integration", () => {
1187
+ test("CLI restart restores persisted state and restored adapters remain operational", async () => {
1188
+ const simulationName = "persistence-e2e";
1189
+ const mqttPort = await reservePort();
1190
+ const opcuaPort = await reservePort();
1191
+ const adminPort = await reservePort();
1192
+ const restartAdminPort = await reservePort();
1193
+ const firstRuntime = await createCliPersistenceRuntime({
1194
+ simulationName,
1195
+ includeOpcuaFlag: true,
1196
+ mqttPort,
1197
+ opcuaPort,
1198
+ adminPort,
1199
+ });
1200
+ let restartRuntime: CliPersistenceRuntimeHandle | undefined;
1201
+ let mqttClient: MqttClient | undefined;
1202
+ let opcuaClient: ReturnType<typeof OPCUAClient.create> | undefined;
1203
+ let session: ClientSession | undefined;
1204
+ let firstRuntimeStopped = false;
1205
+ let stage = "mutate first runtime";
1206
+
1207
+ try {
1208
+ const firstState = await readAdminJson(
1209
+ {
1210
+ process: firstRuntime.process,
1211
+ tmpDir: firstRuntime.tmpDir,
1212
+ ready: firstRuntime.ready,
1213
+ },
1214
+ "/state",
1215
+ EngineStateSnapshotSchema,
1216
+ );
1217
+ const originalInstanceId = firstState.instances[0]?.id;
1218
+ const originalInstancePath = firstState.instances[0]?.browsePath.join("/");
1219
+ if (!originalInstanceId || !originalInstancePath) {
1220
+ throw new Error("First runtime did not expose a boot instance.");
1221
+ }
1222
+
1223
+ stage = "mutate persisted runtime state";
1224
+ expect(
1225
+ (
1226
+ await postAdminCommand(
1227
+ {
1228
+ process: firstRuntime.process,
1229
+ tmpDir: firstRuntime.tmpDir,
1230
+ ready: firstRuntime.ready,
1231
+ },
1232
+ {
1233
+ type: "invokeMethod",
1234
+ instanceId: originalInstanceId,
1235
+ method: "SetTarget",
1236
+ arguments: { target_value: 9.5 },
1237
+ },
1238
+ )
1239
+ ).success,
1240
+ ).toBe(true);
1241
+ expect(
1242
+ (
1243
+ await postAdminCommand(
1244
+ {
1245
+ process: firstRuntime.process,
1246
+ tmpDir: firstRuntime.tmpDir,
1247
+ ready: firstRuntime.ready,
1248
+ },
1249
+ {
1250
+ type: "triggerFault",
1251
+ instanceId: originalInstanceId,
1252
+ fault: "trip",
1253
+ },
1254
+ )
1255
+ ).success,
1256
+ ).toBe(true);
1257
+ const spawnResult = await postAdminCommand(
1258
+ {
1259
+ process: firstRuntime.process,
1260
+ tmpDir: firstRuntime.tmpDir,
1261
+ ready: firstRuntime.ready,
1262
+ },
1263
+ {
1264
+ type: "spawn",
1265
+ model: "method-smoke-pump",
1266
+ count: 1,
1267
+ variation: 0,
1268
+ unsBasePath: `${simulationName}/validation/protocols`,
1269
+ },
1270
+ );
1271
+ expect(spawnResult.success).toBe(true);
1272
+ const spawnedIds = Array.isArray(spawnResult.data)
1273
+ ? spawnResult.data.filter((value): value is string => typeof value === "string")
1274
+ : [];
1275
+ expect(spawnedIds).toHaveLength(1);
1276
+ const spawnedInstanceId = spawnedIds[0];
1277
+ if (!spawnedInstanceId) {
1278
+ throw new Error("Spawn command did not return the new instance ID.");
1279
+ }
1280
+
1281
+ const persistedStateBeforeRestart = await waitForAsync(
1282
+ "persisted runtime state before restart",
1283
+ async () => {
1284
+ const state = await readAdminJson(
1285
+ {
1286
+ process: firstRuntime.process,
1287
+ tmpDir: firstRuntime.tmpDir,
1288
+ ready: firstRuntime.ready,
1289
+ },
1290
+ "/state",
1291
+ EngineStateSnapshotSchema,
1292
+ );
1293
+ const original = state.instances.find((instance) => instance.id === originalInstanceId);
1294
+ return state.instances.length === 2 && original?.activeFaults.includes("trip") === true
1295
+ ? state
1296
+ : undefined;
1297
+ },
1298
+ );
1299
+ expect(
1300
+ persistedStateBeforeRestart.instances.some((instance) => instance.id === spawnedInstanceId),
1301
+ ).toBe(true);
1302
+
1303
+ stage = "stop first runtime and inspect saved snapshot";
1304
+ await stopProcessGracefully(firstRuntime.process);
1305
+ firstRuntimeStopped = true;
1306
+ const persistedSnapshot = PersistedStateSchema.parse(
1307
+ await Bun.file(firstRuntime.stateFilePath).json(),
1308
+ );
1309
+ expect(persistedSnapshot.instances).toHaveLength(2);
1310
+ expect(persistedSnapshot.adapters).toEqual(
1311
+ expect.arrayContaining([
1312
+ expect.objectContaining({ name: "mqtt", enabled: true }),
1313
+ expect.objectContaining({ name: "opcua", enabled: true }),
1314
+ ]),
1315
+ );
1316
+
1317
+ stage = "restart CLI against persisted snapshot";
1318
+ restartRuntime = await createCliPersistenceRuntime({
1319
+ tmpDir: firstRuntime.tmpDir,
1320
+ homeDir: firstRuntime.homeDir,
1321
+ templatePath: join(firstRuntime.tmpDir, `${simulationName}.miravo.yaml`),
1322
+ modelsDir: join(firstRuntime.tmpDir, "models"),
1323
+ simulationName,
1324
+ adminPort: restartAdminPort,
1325
+ });
1326
+ const restartedRuntime = restartRuntime;
1327
+
1328
+ const restoredState = await readAdminJson(
1329
+ {
1330
+ process: restartedRuntime.process,
1331
+ tmpDir: restartedRuntime.tmpDir,
1332
+ ready: restartedRuntime.ready,
1333
+ },
1334
+ "/state",
1335
+ EngineStateSnapshotSchema,
1336
+ );
1337
+ expect(restoredState.instances).toHaveLength(2);
1338
+ expect(restoredState.instances.map((instance) => instance.id).sort()).toEqual(
1339
+ [originalInstanceId, spawnedInstanceId].sort(),
1340
+ );
1341
+ expect(
1342
+ restoredState.instances.find((instance) => instance.id === originalInstanceId)
1343
+ ?.activeFaults,
1344
+ ).toEqual(["trip"]);
1345
+ expect(
1346
+ restoredState.availableAdapters?.map((adapter) => ({
1347
+ name: adapter.name,
1348
+ enabled: adapter.enabled,
1349
+ })),
1350
+ ).toEqual(
1351
+ expect.arrayContaining([
1352
+ { name: "mqtt", enabled: true },
1353
+ { name: "opcua", enabled: true },
1354
+ ]),
1355
+ );
1356
+
1357
+ stage = "verify restored OPC UA state";
1358
+ opcuaClient = OPCUAClient.create({
1359
+ securityMode: MessageSecurityMode.None,
1360
+ securityPolicy: SecurityPolicy.None,
1361
+ endpointMustExist: false,
1362
+ });
1363
+ await opcuaClient.connect(`opc.tcp://127.0.0.1:${opcuaPort}`);
1364
+ session = await opcuaClient.createSession();
1365
+ expect(
1366
+ await waitForOPCUAValue<number>(
1367
+ session,
1368
+ `ns=1;s=${originalInstancePath}/parameter_echo`,
1369
+ 9.5,
1370
+ ),
1371
+ ).toBe(9.5);
1372
+ expect(
1373
+ await waitForOPCUAValue<string>(
1374
+ session,
1375
+ `ns=1;s=${originalInstancePath}/active_fault_code`,
1376
+ "trip",
1377
+ ),
1378
+ ).toBe("trip");
1379
+
1380
+ stage = "verify restored MQTT control plane";
1381
+ mqttClient = connect(`mqtt://127.0.0.1:${mqttPort}`, { reconnectPeriod: 0 });
1382
+ await waitForMQTTConnect(mqttClient);
1383
+ const resultTopic = controlTopicCmdResult(simulationName);
1384
+ await subscribeMQTT(mqttClient, [`${originalInstancePath}/#`, resultTopic]);
1385
+ const recorder = createMQTTRecorder(mqttClient);
1386
+ try {
1387
+ const resetResult = await publishControlCommand(mqttClient, recorder, simulationName, {
1388
+ type: "invokeMethod",
1389
+ instanceId: originalInstanceId,
1390
+ method: "ResetFault",
1391
+ } satisfies InvokeMethodCommandPayload);
1392
+ expect(resetResult.success).toBe(true);
1393
+ expect(
1394
+ await waitForMQTTTopicValue(recorder, `${originalInstancePath}/fault_active`, false),
1395
+ ).toBe(false);
1396
+ const clearedState = await waitForAsync("cleared restored fault state", async () => {
1397
+ const state = await readAdminJson(
1398
+ {
1399
+ process: restartedRuntime.process,
1400
+ tmpDir: restartedRuntime.tmpDir,
1401
+ ready: restartedRuntime.ready,
1402
+ },
1403
+ "/state",
1404
+ EngineStateSnapshotSchema,
1405
+ );
1406
+ const original = state.instances.find((instance) => instance.id === originalInstanceId);
1407
+ return original?.activeFaults.length === 0 ? state : undefined;
1408
+ });
1409
+ expect(
1410
+ clearedState.instances.find((instance) => instance.id === originalInstanceId)
1411
+ ?.activeFaults,
1412
+ ).toEqual([]);
1413
+ } finally {
1414
+ recorder.dispose();
1415
+ }
1416
+
1417
+ if (session) {
1418
+ await session.close();
1419
+ session = undefined;
1420
+ }
1421
+ await opcuaClient.disconnect();
1422
+ opcuaClient = undefined;
1423
+ await stopMQTTClient(mqttClient);
1424
+ mqttClient = undefined;
1425
+ await stopProcessGracefully(restartRuntime.process);
1426
+ await rm(firstRuntime.tmpDir, { recursive: true, force: true });
1427
+ } catch (error) {
1428
+ if (session) {
1429
+ await session.close().catch(() => {});
1430
+ }
1431
+ await opcuaClient?.disconnect().catch(() => {});
1432
+ if (mqttClient) {
1433
+ await stopMQTTClient(mqttClient).catch(() => {});
1434
+ }
1435
+ if (!firstRuntimeStopped) {
1436
+ await stopProcessGracefully(firstRuntime.process).catch(() => ({
1437
+ stdout: "",
1438
+ stderr: "",
1439
+ }));
1440
+ }
1441
+ if (restartRuntime) {
1442
+ const output = await stopProcessGracefully(restartRuntime.process).catch(() => ({
1443
+ stdout: "",
1444
+ stderr: "",
1445
+ }));
1446
+ await rm(restartRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
1447
+ throw new Error(
1448
+ [
1449
+ `Persistence restart integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1450
+ output.stdout.trim().length > 0 ? `restart stdout:\n${output.stdout.trim()}` : "",
1451
+ output.stderr.trim().length > 0 ? `restart stderr:\n${output.stderr.trim()}` : "",
1452
+ ]
1453
+ .filter(Boolean)
1454
+ .join("\n\n"),
1455
+ );
1456
+ }
1457
+ await rm(firstRuntime.tmpDir, { recursive: true, force: true }).catch(() => {});
1458
+ throw new Error(
1459
+ `Persistence restart integration failure during "${stage}": ${error instanceof Error ? error.message : String(error)}`,
1460
+ );
1461
+ }
1462
+ }, 45000);
1463
+
902
1464
  test("admin HTTP server exposes runtime state, executes core commands, and drives published twin behavior", async () => {
903
1465
  const runtime = await createSmokeRuntime();
904
1466
  let client: MqttClient | undefined;
@@ -1415,13 +1977,13 @@ describe("executable methods integration", () => {
1415
1977
  }, 30000);
1416
1978
 
1417
1979
  test("lifecycle effects apply multiplier-then-offset and fleet metrics track stage transitions", async () => {
1418
- // time_multiplier=10000: each 100ms tick = 0.278 simulated hours.
1419
- // healthy: 3.0h (~11 ticks), degraded: 1.5h (~5 ticks), critical: terminal.
1420
- // Runner waits ~3 ticks (0.83h) before ready. Test observes healthy→degraded→critical.
1980
+ // time_multiplier=1000: each 100ms tick = 0.028 simulated hours.
1981
+ // healthy: 0.3h (~11 ticks), degraded: 0.15h (~5 ticks), critical: terminal.
1982
+ // Runner waits ~3 ticks (0.08h) before ready. Test observes healthy→degraded→critical.
1421
1983
  const runtime = await createSmokeRuntime({
1422
1984
  model: createLifecycleTestModel(),
1423
1985
  namespacePrefix: "lifecycle-test",
1424
- templateSettings: { tick_interval_ms: 100, time_multiplier: 10000, seed: 42 },
1986
+ templateSettings: { tick_interval_ms: 100, time_multiplier: 1000, seed: 42 },
1425
1987
  });
1426
1988
  let client: MqttClient | undefined;
1427
1989
  let stage = "connect MQTT observer";