miravo 0.4.0 → 0.6.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 (161) hide show
  1. package/README.md +1 -1
  2. package/console-dist/assets/activity-DsWeRq4h.js +1 -0
  3. package/console-dist/assets/api-4AqKvsm9.js +1 -0
  4. package/console-dist/assets/arrow-right-Ea_UhTNi.js +1 -0
  5. package/console-dist/assets/arrow-up-down-DFSjzzl2.js +1 -0
  6. package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +1 -0
  7. package/console-dist/assets/box-C1WaoOMF.js +1 -0
  8. package/console-dist/assets/card-BjeP32PN.js +1 -0
  9. package/console-dist/assets/catalog-BJzyzbLR.js +1 -0
  10. package/console-dist/assets/catalog-DKjEm-c4.js +1 -0
  11. package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +1 -0
  12. package/console-dist/assets/checkbox-DZlJXTE4.js +1 -0
  13. package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +3 -0
  14. package/console-dist/assets/combobox-BGnuCYxO.js +1 -0
  15. package/console-dist/assets/control-CPhvwx9o.js +1 -0
  16. package/console-dist/assets/control-Cg2up9NA.js +1 -0
  17. package/console-dist/assets/copy-button-WkbNkMcd.js +1 -0
  18. package/console-dist/assets/createLucideIcon-Bwxw859K.js +1 -0
  19. package/console-dist/assets/deploy-tab-JeWDwPoZ.js +1 -0
  20. package/console-dist/assets/dist-BhbDML0a.js +1 -0
  21. package/console-dist/assets/dist-Cy8XTNFT.js +2 -0
  22. package/console-dist/assets/download-DQ6NH16U.js +1 -0
  23. package/console-dist/assets/engine-store-CrLnz692.js +1 -0
  24. package/console-dist/assets/events-CXm29Ok4.js +1 -0
  25. package/console-dist/assets/events-D01IxhnP.js +1 -0
  26. package/console-dist/assets/fleet-CfxbKXt9.js +1 -0
  27. package/console-dist/assets/fleet-lotMGVqe.js +1 -0
  28. package/console-dist/assets/format-kXTonpDJ.js +1 -0
  29. package/console-dist/assets/gauge-Ci8zNZlk.js +1 -0
  30. package/console-dist/assets/get-error-message-3Q5Q-2G5.js +1 -0
  31. package/console-dist/assets/index-CVPKSDvJ.js +12 -0
  32. package/console-dist/assets/index-DJkgDeun.css +2 -0
  33. package/console-dist/assets/input-BWEQFptx.js +1 -0
  34. package/console-dist/assets/instance-detail-C5IVwuh3.js +1 -0
  35. package/console-dist/assets/label-uephX5ZG.js +1 -0
  36. package/console-dist/assets/layers-CUsLQ1fq.js +1 -0
  37. package/console-dist/assets/login-C3RJli0m.js +126 -0
  38. package/console-dist/assets/models-B_UioWQ5.js +1 -0
  39. package/console-dist/assets/models-_IPlPiZ7.js +1 -0
  40. package/console-dist/assets/not-found-B8gAm08S.js +1 -0
  41. package/console-dist/assets/platform-targets-R3T-4eJ7.js +1 -0
  42. package/console-dist/assets/platforms-BWh0BQ3Y.js +1 -0
  43. package/console-dist/assets/platforms-DStNbtVc.js +1 -0
  44. package/console-dist/assets/proxy-CsnYqnPp.js +9 -0
  45. package/console-dist/assets/query-keys-BvtE7BMs.js +1 -0
  46. package/console-dist/assets/radio-CoHegOv5.js +1 -0
  47. package/console-dist/assets/rolldown-runtime-COnpUsM8.js +1 -0
  48. package/console-dist/assets/select-CeO4vP0G.js +1 -0
  49. package/console-dist/assets/select-display-items-CIzY8953.js +1 -0
  50. package/console-dist/assets/server-D9Kfn8fQ.js +1 -0
  51. package/console-dist/assets/shallow-6lGjQhtJ.js +1 -0
  52. package/console-dist/assets/tags-CiXOIHtY.js +4 -0
  53. package/console-dist/assets/tags-FQCXIxrP.js +1 -0
  54. package/console-dist/assets/tooltip-HGLF366Q.js +1 -0
  55. package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +1 -0
  56. package/console-dist/assets/transition-types-CPlGtM8w.js +1 -0
  57. package/console-dist/assets/triangle-alert-adNk_sea.js +1 -0
  58. package/console-dist/assets/use-platform-stream-DGbK1k2L.js +2 -0
  59. package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +2 -0
  60. package/console-dist/assets/validation-Blvud9_O.js +1 -0
  61. package/console-dist/assets/vendor-charts-CAZdaPlq.js +60 -0
  62. package/console-dist/assets/vendor-table-wd8JuMmT.js +4 -0
  63. package/console-dist/assets/vendor-ui-ByahnaY0.js +1 -0
  64. package/console-dist/index.html +70 -2
  65. package/package.json +9 -8
  66. package/src/__tests__/integration/executable-methods-runner.ts +7 -6
  67. package/src/__tests__/integration/executable-methods.integration.ts +214 -122
  68. package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +314 -0
  69. package/src/commands/adapter.ts +180 -7
  70. package/src/commands/apply.test.ts +62 -0
  71. package/src/commands/apply.ts +216 -0
  72. package/src/commands/cleanup.test.ts +34 -0
  73. package/src/commands/cleanup.ts +201 -0
  74. package/src/commands/export.test.ts +51 -0
  75. package/src/commands/export.ts +404 -0
  76. package/src/commands/fork.ts +2 -2
  77. package/src/commands/inject.ts +2 -2
  78. package/src/commands/load.ts +1 -1
  79. package/src/commands/logs.ts +13 -15
  80. package/src/commands/models.test.ts +1 -1
  81. package/src/commands/models.ts +1 -1
  82. package/src/commands/pause.ts +3 -3
  83. package/src/commands/platforms.test.ts +186 -0
  84. package/src/commands/platforms.ts +371 -0
  85. package/src/commands/remove.ts +1 -1
  86. package/src/commands/reset.ts +2 -2
  87. package/src/commands/spawn.ts +2 -2
  88. package/src/commands/speed.ts +2 -2
  89. package/src/commands/start/adapter-config.ts +157 -0
  90. package/src/commands/start/catalog-le-dt.test.ts +50 -0
  91. package/src/commands/start/catalog-le-dt.ts +187 -0
  92. package/src/commands/start/catalog-model-schema.ts +89 -0
  93. package/src/commands/start/catalog-template-detail.ts +54 -0
  94. package/src/commands/start/daemon.ts +255 -0
  95. package/src/commands/start/display.ts +150 -0
  96. package/src/commands/start/engine-setup.ts +247 -0
  97. package/src/commands/start/events.ts +126 -0
  98. package/src/commands/start/index.ts +897 -0
  99. package/src/commands/start/protocol-plans.ts +239 -0
  100. package/src/commands/start/template-loader.ts +197 -0
  101. package/src/commands/start/template-resolver.ts +185 -0
  102. package/src/commands/start/types.ts +73 -0
  103. package/src/commands/start.test.ts +137 -16
  104. package/src/commands/status.ts +1 -1
  105. package/src/commands/stop.ts +19 -8
  106. package/src/commands/templates.test.ts +1 -1
  107. package/src/commands/templates.ts +1 -3
  108. package/src/index.ts +11 -3
  109. package/src/lib/admin-auth.ts +157 -0
  110. package/src/lib/admin-client.test.ts +31 -15
  111. package/src/lib/admin-client.ts +106 -6
  112. package/src/lib/admin-platform-routes.ts +1017 -0
  113. package/src/lib/admin-platform-stream.test.ts +434 -0
  114. package/src/lib/admin-platform-stream.ts +788 -0
  115. package/src/lib/admin-responses.ts +211 -0
  116. package/src/lib/admin-server.test.ts +274 -55
  117. package/src/lib/admin-server.ts +387 -248
  118. package/src/lib/auth-store.ts +87 -0
  119. package/src/lib/daemon-registry.ts +12 -14
  120. package/src/lib/le/le-apply.test.ts +591 -0
  121. package/src/lib/le/le-apply.ts +832 -0
  122. package/src/lib/le/le-auth.ts +89 -0
  123. package/src/lib/le/le-cleanup.test.ts +168 -0
  124. package/src/lib/le/le-cleanup.ts +399 -0
  125. package/src/lib/le/le-cli-helpers.ts +77 -0
  126. package/src/lib/le/le-client.ts +184 -0
  127. package/src/lib/le/le-concurrency.test.ts +157 -0
  128. package/src/lib/le/le-concurrency.ts +78 -0
  129. package/src/lib/le/le-connection-test.ts +42 -0
  130. package/src/lib/le/le-devicehub-properties.test.ts +53 -0
  131. package/src/lib/le/le-devicehub-properties.ts +33 -0
  132. package/src/lib/le/le-devicehub.ts +213 -0
  133. package/src/lib/le/le-digital-twins.ts +285 -0
  134. package/src/lib/le/le-drivers.ts +23 -0
  135. package/src/lib/le/le-endpoint.ts +40 -0
  136. package/src/lib/le/le-environments.test.ts +73 -0
  137. package/src/lib/le/le-environments.ts +156 -0
  138. package/src/lib/le/le-markers.ts +87 -0
  139. package/src/lib/le/le-plan.test.ts +26 -0
  140. package/src/lib/le/le-plan.ts +109 -0
  141. package/src/lib/le/le-platform-operations.test.ts +198 -0
  142. package/src/lib/le/le-platform-operations.ts +340 -0
  143. package/src/lib/le/le-static-model-schema.ts +78 -0
  144. package/src/lib/le/le-static-planner.test.ts +255 -0
  145. package/src/lib/le/le-static-planner.ts +442 -0
  146. package/src/lib/le/le-template-exporter.test.ts +527 -0
  147. package/src/lib/le/le-template-exporter.ts +459 -0
  148. package/src/lib/le/le-value-types.ts +23 -0
  149. package/src/lib/mqtt-client.ts +1 -2
  150. package/src/lib/resolve-simulation.ts +1 -6
  151. package/src/lib/session-store.ts +110 -0
  152. package/src/lib/shutdown.test.ts +184 -2
  153. package/src/lib/shutdown.ts +104 -34
  154. package/src/lib/template-category.ts +3 -3
  155. package/src/lib/template-locator.ts +8 -2
  156. package/src/lib/ws-hub.ts +72 -46
  157. package/src/logger.ts +39 -14
  158. package/src/output.ts +3 -0
  159. package/console-dist/assets/index-C3XMYq26.css +0 -2
  160. package/console-dist/assets/index-hUJSpKtB.js +0 -119
  161. package/src/commands/start.ts +0 -2023
@@ -0,0 +1,156 @@
1
+ /**
2
+ * LE platform storage paths and environment credentials persistence.
3
+ *
4
+ * Layout:
5
+ * - ~/.miravo/platforms/environments.json (credentials, 0o600)
6
+ * - ~/.miravo/platforms/operations.jsonl (audit trail, 0o600)
7
+ */
8
+
9
+ import { chmod, mkdir } from "node:fs/promises";
10
+ import { resolveMiravoHome } from "@miravo/core";
11
+ import { z } from "zod";
12
+
13
+ export interface LEEnvironment {
14
+ readonly id: string;
15
+ readonly platformId: string;
16
+ readonly label: string;
17
+ readonly hostname: string;
18
+ readonly apiToken: string;
19
+ readonly sourceHostname: string;
20
+ readonly lastUsedAt?: number | undefined;
21
+ }
22
+
23
+ export const DEFAULT_PLATFORM_ID = "litmus-edge";
24
+
25
+ const LEEnvironmentSchema = z.object({
26
+ id: z.string(),
27
+ platformId: z.string().min(1).default(DEFAULT_PLATFORM_ID),
28
+ label: z.string(),
29
+ hostname: z.string(),
30
+ apiToken: z.string(),
31
+ sourceHostname: z.string(),
32
+ lastUsedAt: z.number().optional(),
33
+ });
34
+
35
+ const PlatformsFileSchema = z.object({ environments: z.array(LEEnvironmentSchema) });
36
+
37
+ const PLATFORMS_DIR_MODE = 0o700;
38
+ const PLATFORMS_FILE_MODE = 0o600;
39
+
40
+ export function platformsDirPath(): string {
41
+ return `${resolveMiravoHome()}/platforms`;
42
+ }
43
+
44
+ export function environmentsFilePath(): string {
45
+ return `${platformsDirPath()}/environments.json`;
46
+ }
47
+
48
+ export function operationsFilePath(): string {
49
+ return `${platformsDirPath()}/operations.jsonl`;
50
+ }
51
+
52
+ export async function ensurePlatformsDir(): Promise<void> {
53
+ const dir = platformsDirPath();
54
+ await mkdir(dir, { recursive: true, mode: PLATFORMS_DIR_MODE });
55
+ try {
56
+ await chmod(dir, PLATFORMS_DIR_MODE);
57
+ } catch {
58
+ // Best effort hardening; platform may reject chmod.
59
+ }
60
+ }
61
+
62
+ export async function loadEnvironments(): Promise<readonly LEEnvironment[]> {
63
+ try {
64
+ const raw: unknown = await Bun.file(environmentsFilePath()).json();
65
+ const result = PlatformsFileSchema.safeParse(raw);
66
+ if (!result.success) return [];
67
+ return [...result.data.environments].sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0));
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ async function writeEnvironments(envs: readonly LEEnvironment[]): Promise<void> {
74
+ const filePath = environmentsFilePath();
75
+ await ensurePlatformsDir();
76
+ await Bun.write(filePath, JSON.stringify({ environments: envs }, null, 2), {
77
+ mode: PLATFORMS_FILE_MODE,
78
+ });
79
+ try {
80
+ await chmod(filePath, PLATFORMS_FILE_MODE);
81
+ } catch {
82
+ // Best effort hardening; platform may reject chmod.
83
+ }
84
+ }
85
+
86
+ export async function getEnvironment(id: string): Promise<LEEnvironment | undefined> {
87
+ const envs = await loadEnvironments();
88
+ return envs.find((e) => e.id === id);
89
+ }
90
+
91
+ export async function addEnvironment(env: Omit<LEEnvironment, "id">): Promise<LEEnvironment> {
92
+ const envs = await loadEnvironments();
93
+ const created: LEEnvironment = {
94
+ id: crypto.randomUUID(),
95
+ ...env,
96
+ platformId: env.platformId || DEFAULT_PLATFORM_ID,
97
+ };
98
+ await writeEnvironments([...envs, created]);
99
+ return created;
100
+ }
101
+
102
+ export async function updateEnvironment(
103
+ id: string,
104
+ update: {
105
+ readonly platformId?: string | undefined;
106
+ readonly label?: string | undefined;
107
+ readonly hostname?: string | undefined;
108
+ readonly apiToken?: string | undefined;
109
+ readonly sourceHostname?: string | undefined;
110
+ readonly lastUsedAt?: number | undefined;
111
+ },
112
+ ): Promise<LEEnvironment | undefined> {
113
+ const envs = await loadEnvironments();
114
+ const existing = envs.find((e) => e.id === id);
115
+ if (!existing) return undefined;
116
+ const index = envs.indexOf(existing);
117
+ const updated: LEEnvironment = {
118
+ id: existing.id,
119
+ platformId: update.platformId ?? existing.platformId,
120
+ label: update.label ?? existing.label,
121
+ hostname: update.hostname ?? existing.hostname,
122
+ apiToken: update.apiToken ?? existing.apiToken,
123
+ sourceHostname: update.sourceHostname ?? existing.sourceHostname,
124
+ lastUsedAt: update.lastUsedAt ?? existing.lastUsedAt,
125
+ };
126
+ const next = [...envs];
127
+ next[index] = updated;
128
+ await writeEnvironments(next);
129
+ return updated;
130
+ }
131
+
132
+ /** Update only the lastUsedAt timestamp (fire-and-forget safe). */
133
+ export async function touchEnvironment(id: string): Promise<void> {
134
+ await updateEnvironment(id, { lastUsedAt: Date.now() });
135
+ }
136
+
137
+ export async function removeEnvironment(id: string): Promise<boolean> {
138
+ const envs = await loadEnvironments();
139
+ const filtered = envs.filter((e) => e.id !== id);
140
+ if (filtered.length === envs.length) return false;
141
+ await writeEnvironments(filtered);
142
+ return true;
143
+ }
144
+
145
+ /** Find an environment by label (case-insensitive). */
146
+ export async function getEnvironmentByLabel(label: string): Promise<LEEnvironment | undefined> {
147
+ const envs = await loadEnvironments();
148
+ const lower = label.toLowerCase();
149
+ return envs.find((e) => e.label.toLowerCase() === lower);
150
+ }
151
+
152
+ /** Find the first environment (used as CLI default when no flags/env provided). */
153
+ export async function getDefaultEnvironment(): Promise<LEEnvironment | undefined> {
154
+ const envs = await loadEnvironments();
155
+ return envs[0];
156
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Ownership markers for Miravo-managed LE resources.
3
+ *
4
+ * DeviceHub devices: metadata items (Key/Value pairs)
5
+ * DT models/instances: static attributes
6
+ */
7
+
8
+ import type { ProtocolName } from "@miravo/shared";
9
+ import type { LEItem, LEItemOutput } from "./le-devicehub.ts";
10
+
11
+ // --- Device metadata markers ---
12
+
13
+ export interface DeviceMarkerData {
14
+ readonly templateId: string;
15
+ readonly instanceId: string;
16
+ readonly modelName: string;
17
+ readonly protocol: ProtocolName;
18
+ readonly unsPath: string;
19
+ }
20
+
21
+ export function encodeDeviceMarkers(data: DeviceMarkerData): LEItem[] {
22
+ return [
23
+ { Key: "miravo_managed", Value: "true" },
24
+ { Key: "miravo_template", Value: data.templateId },
25
+ { Key: "miravo_instance", Value: data.instanceId },
26
+ { Key: "miravo_model", Value: data.modelName },
27
+ { Key: "miravo_protocol", Value: data.protocol },
28
+ { Key: "uns_path", Value: data.unsPath },
29
+ ];
30
+ }
31
+
32
+ export function isDeviceManaged(metadata: readonly LEItemOutput[]): boolean {
33
+ return metadata.some((m) => m.Name === "miravo_managed" && m.Value === "true");
34
+ }
35
+
36
+ export function parseDeviceMarkers(
37
+ metadata: readonly LEItemOutput[],
38
+ ): DeviceMarkerData | undefined {
39
+ const map = new Map(metadata.map((m) => [m.Name, m.Value]));
40
+ if (map.get("miravo_managed") !== "true") return undefined;
41
+ const templateId = map.get("miravo_template");
42
+ const instanceId = map.get("miravo_instance");
43
+ const modelName = map.get("miravo_model");
44
+ const protocol = map.get("miravo_protocol") as ProtocolName | undefined;
45
+ if (!templateId || !instanceId || !modelName || !protocol) return undefined;
46
+ const unsPath = map.get("uns_path") ?? "";
47
+ return { templateId, instanceId, modelName, protocol, unsPath };
48
+ }
49
+
50
+ // --- DT static attribute markers ---
51
+
52
+ interface DTMarkerData {
53
+ readonly templateId: string;
54
+ readonly modelName: string;
55
+ readonly instanceId?: string | undefined;
56
+ }
57
+
58
+ export function encodeDTInstanceMarkers(data: {
59
+ templateId: string;
60
+ modelName: string;
61
+ instanceId: string;
62
+ unsPath: string;
63
+ }): { Key: string; Value: string }[] {
64
+ return [
65
+ { Key: "miravo_managed", Value: "true" },
66
+ { Key: "miravo_template", Value: data.templateId },
67
+ { Key: "miravo_model", Value: data.modelName },
68
+ { Key: "miravo_instance", Value: data.instanceId },
69
+ { Key: "uns_path", Value: data.unsPath },
70
+ ];
71
+ }
72
+
73
+ export function isDTManaged(attrs: readonly { Key: string; Value: string }[]): boolean {
74
+ return attrs.some((a) => a.Key === "miravo_managed" && a.Value === "true");
75
+ }
76
+
77
+ export function parseDTMarkers(
78
+ attrs: readonly { Key: string; Value: string }[],
79
+ ): DTMarkerData | undefined {
80
+ const map = new Map(attrs.map((a) => [a.Key, a.Value]));
81
+ if (map.get("miravo_managed") !== "true") return undefined;
82
+ const templateId = map.get("miravo_template");
83
+ const modelName = map.get("miravo_model");
84
+ if (!templateId || !modelName) return undefined;
85
+ const instanceId = map.get("miravo_instance");
86
+ return { templateId, modelName, instanceId };
87
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type ApplyPlan, renderPlanHuman } from "./le-plan.ts";
3
+
4
+ function createPlan(sourceMode: ApplyPlan["sourceMode"]): ApplyPlan {
5
+ return {
6
+ sourceMode,
7
+ targetHost: "le.example.local",
8
+ sourceHostname: undefined,
9
+ scope: ["devices"],
10
+ filters: {},
11
+ entries: [],
12
+ };
13
+ }
14
+
15
+ describe("renderPlanHuman source rendering", () => {
16
+ test("omits source line for managed cleanup plans", () => {
17
+ const output = renderPlanHuman(createPlan("managed"));
18
+ expect(output).not.toContain("Source:");
19
+ expect(output).toContain("Target LE: le.example.local");
20
+ });
21
+
22
+ test("renders source line for static apply plans", () => {
23
+ const output = renderPlanHuman(createPlan("static"));
24
+ expect(output).toContain("Source: static template");
25
+ });
26
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * LE Apply plan model.
3
+ *
4
+ * Each desired resource becomes one of: create, update, blocked.
5
+ * The plan is built before any mutation and can be rendered as
6
+ * dry-run output (human or JSON).
7
+ */
8
+
9
+ import type { ProtocolName } from "@miravo/shared";
10
+
11
+ // --- Scope ---
12
+
13
+ export type ApplyScope = "devices" | "models" | "instances";
14
+
15
+ // --- Plan actions ---
16
+
17
+ export type PlanAction = "create" | "update" | "replace" | "delete" | "blocked";
18
+
19
+ export interface PlanEntry {
20
+ readonly action: PlanAction;
21
+ readonly resourceType: "device" | "model" | "instance" | "param-sync";
22
+ readonly name: string;
23
+ /** Stable LE resource ID for deterministic execution (cleanup, duplicates). */
24
+ readonly resourceId?: string | undefined;
25
+ readonly protocol?: ProtocolName | undefined;
26
+ readonly endpoint?: string | undefined;
27
+ readonly detail?: string | undefined;
28
+ readonly blockReason?: string | undefined;
29
+ }
30
+
31
+ // --- Plan ---
32
+
33
+ export interface ApplyPlan {
34
+ readonly sourceMode: "static" | "managed";
35
+ readonly targetHost: string;
36
+ readonly sourceHostname: string | undefined;
37
+ readonly scope: readonly ApplyScope[];
38
+ readonly filters: {
39
+ readonly area?: string | undefined;
40
+ readonly line?: string | undefined;
41
+ readonly instance?: string | undefined;
42
+ };
43
+ readonly entries: readonly PlanEntry[];
44
+ }
45
+
46
+ // --- Rendering ---
47
+
48
+ export function renderPlanHuman(plan: ApplyPlan): string {
49
+ const lines: string[] = [];
50
+ switch (plan.sourceMode) {
51
+ case "static":
52
+ lines.push("Source: static template");
53
+ break;
54
+ case "managed":
55
+ // Cleanup --all-managed has no source input.
56
+ break;
57
+ default: {
58
+ const _never: never = plan.sourceMode;
59
+ throw new Error(`Unknown plan source mode: ${_never}`);
60
+ }
61
+ }
62
+ lines.push(`Target LE: ${plan.targetHost}`);
63
+ if (plan.sourceHostname) {
64
+ lines.push(`Source hostname: ${plan.sourceHostname}`);
65
+ }
66
+ lines.push(`Scope: ${plan.scope.join(",")}`);
67
+
68
+ const filterParts: string[] = [];
69
+ if (plan.filters.area) filterParts.push(`area=${plan.filters.area}`);
70
+ if (plan.filters.line) filterParts.push(`line=${plan.filters.line}`);
71
+ if (plan.filters.instance) filterParts.push(`instance=${plan.filters.instance}`);
72
+ if (filterParts.length > 0) {
73
+ lines.push(`Selection: ${filterParts.join(" ")}`);
74
+ }
75
+
76
+ lines.push("");
77
+ lines.push("Actions:");
78
+
79
+ const actions = plan.entries.filter((e) => e.action !== "blocked");
80
+ if (actions.length === 0) {
81
+ lines.push(" (none)");
82
+ } else {
83
+ for (const entry of actions) {
84
+ const proto = entry.protocol
85
+ ? ` [${entry.protocol}${entry.endpoint ? ` → ${entry.endpoint}` : ""}]`
86
+ : "";
87
+ lines.push(
88
+ ` ${entry.action.toUpperCase()} ${entry.resourceType} ${entry.name}${proto}${entry.detail ? ` ${entry.detail}` : ""}`,
89
+ );
90
+ }
91
+ }
92
+
93
+ const blockers = plan.entries.filter((e) => e.action === "blocked");
94
+ lines.push("");
95
+ lines.push("Blockers:");
96
+ if (blockers.length === 0) {
97
+ lines.push(" none");
98
+ } else {
99
+ for (const entry of blockers) {
100
+ lines.push(` BLOCKED ${entry.resourceType} ${entry.name} — ${entry.blockReason}`);
101
+ }
102
+ }
103
+
104
+ return lines.join("\n");
105
+ }
106
+
107
+ export function renderPlanJSON(plan: ApplyPlan): string {
108
+ return JSON.stringify(plan, null, 2);
109
+ }
@@ -0,0 +1,198 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { ResourceProgress } from "./le-apply.ts";
6
+ import {
7
+ appendPlatformOperation,
8
+ createPlatformOperationTracker,
9
+ listPlatformOperations,
10
+ type PlatformOperationRecord,
11
+ } from "./le-platform-operations.ts";
12
+
13
+ let testHome = "";
14
+ let prevMiravoHome: string | undefined;
15
+
16
+ beforeEach(async () => {
17
+ prevMiravoHome = Bun.env["MIRAVO_HOME"];
18
+ testHome = await mkdtemp(join(tmpdir(), "miravo-platform-ops-test-"));
19
+ Bun.env["MIRAVO_HOME"] = testHome;
20
+ });
21
+
22
+ afterEach(async () => {
23
+ if (prevMiravoHome === undefined) {
24
+ delete Bun.env["MIRAVO_HOME"];
25
+ } else {
26
+ Bun.env["MIRAVO_HOME"] = prevMiravoHome;
27
+ }
28
+ await rm(testHome, { recursive: true, force: true });
29
+ });
30
+
31
+ function progress(overrides: Partial<ResourceProgress>): ResourceProgress {
32
+ return {
33
+ phase: "device",
34
+ resourceType: "device",
35
+ name: "pump-001",
36
+ action: "create",
37
+ status: "success",
38
+ index: 1,
39
+ of: 1,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe("le-platform-operations", () => {
45
+ test("tracker aggregates resource action counts and derives partial status", () => {
46
+ const tracker = createPlatformOperationTracker({
47
+ operationId: "op-1",
48
+ operationType: "apply",
49
+ templateId: "smart-factory",
50
+ environmentId: "env-1",
51
+ environmentLabel: "Prod LE",
52
+ hostname: "10.0.0.10",
53
+ scope: ["devices", "models", "instances"],
54
+ filters: {},
55
+ startedAt: 1_700_000_000_000,
56
+ });
57
+
58
+ tracker.onResourceComplete(progress({ resourceType: "device", action: "create" }));
59
+ tracker.onResourceComplete(
60
+ progress({
61
+ phase: "model",
62
+ resourceType: "model",
63
+ action: "create",
64
+ status: "failed",
65
+ error: "import failed",
66
+ }),
67
+ );
68
+
69
+ const record = tracker.finalize({
70
+ successCount: 1,
71
+ failedCount: 1,
72
+ completedAt: 1_700_000_001_000,
73
+ });
74
+
75
+ expect(record.status).toBe("partial");
76
+ expect(record.resourceCounts.device.create).toBe(1);
77
+ expect(record.resourceCounts.model.create).toBe(1);
78
+ expect(record.resourceCounts.model.failed).toBe(1);
79
+ expect(record.durationMs).toBe(1000);
80
+ });
81
+
82
+ test("append + list supports ordering, filtering, and offset pagination", async () => {
83
+ const base = {
84
+ schemaVersion: 1 as const,
85
+ environmentId: "env-1",
86
+ environmentLabel: "Prod LE",
87
+ hostname: "10.0.0.10",
88
+ scope: ["devices", "models", "instances"],
89
+ filters: {},
90
+ blockedCount: 0,
91
+ resourceCounts: {
92
+ device: { create: 1, update: 0, replace: 0, delete: 0, failed: 0, total: 1 },
93
+ model: { create: 1, update: 0, replace: 0, delete: 0, failed: 0, total: 1 },
94
+ instance: { create: 1, update: 0, replace: 0, delete: 0, failed: 0, total: 1 },
95
+ "param-sync": { create: 0, update: 0, replace: 0, delete: 0, failed: 0, total: 0 },
96
+ },
97
+ };
98
+
99
+ const op1: PlatformOperationRecord = {
100
+ ...base,
101
+ operationId: "op-1",
102
+ operationType: "apply",
103
+ status: "success",
104
+ templateId: "smart-factory",
105
+ successCount: 4,
106
+ failedCount: 0,
107
+ startedAt: 1000,
108
+ completedAt: 2000,
109
+ durationMs: 1000,
110
+ };
111
+ const op2: PlatformOperationRecord = {
112
+ ...base,
113
+ operationId: "op-2",
114
+ operationType: "cleanup",
115
+ status: "success",
116
+ templateId: "smart-factory",
117
+ successCount: 4,
118
+ failedCount: 0,
119
+ startedAt: 3000,
120
+ completedAt: 4000,
121
+ durationMs: 1000,
122
+ };
123
+
124
+ await appendPlatformOperation(op1);
125
+ await appendPlatformOperation(op2);
126
+
127
+ const page1 = await listPlatformOperations({ limit: 1, offset: 0 });
128
+ expect(page1.operations).toHaveLength(1);
129
+ expect(page1.operations[0]?.operationId).toBe("op-2");
130
+ expect(page1.total).toBe(2);
131
+ expect(page1.hasMore).toBe(true);
132
+
133
+ const page2 = await listPlatformOperations({ limit: 5, offset: 1 });
134
+ expect(page2.operations).toHaveLength(1);
135
+ expect(page2.operations[0]?.operationId).toBe("op-1");
136
+ expect(page2.total).toBe(2);
137
+ expect(page2.hasMore).toBe(false);
138
+
139
+ const applyOnly = await listPlatformOperations({ type: "apply" });
140
+ expect(applyOnly.operations).toHaveLength(1);
141
+ expect(applyOnly.total).toBe(1);
142
+ expect(applyOnly.operations[0]?.operationType).toBe("apply");
143
+ });
144
+
145
+ test("offset pagination does not drop rows when completedAt ties", async () => {
146
+ const base = {
147
+ schemaVersion: 1 as const,
148
+ environmentId: "env-1",
149
+ environmentLabel: "Prod LE",
150
+ hostname: "10.0.0.10",
151
+ scope: ["devices", "models", "instances"],
152
+ filters: {},
153
+ blockedCount: 0,
154
+ resourceCounts: {
155
+ device: { create: 1, update: 0, replace: 0, delete: 0, failed: 0, total: 1 },
156
+ model: { create: 1, update: 0, replace: 0, delete: 0, failed: 0, total: 1 },
157
+ instance: { create: 1, update: 0, replace: 0, delete: 0, failed: 0, total: 1 },
158
+ "param-sync": { create: 0, update: 0, replace: 0, delete: 0, failed: 0, total: 0 },
159
+ },
160
+ };
161
+
162
+ const opA: PlatformOperationRecord = {
163
+ ...base,
164
+ operationId: "op-a",
165
+ operationType: "apply",
166
+ status: "success",
167
+ templateId: "t1",
168
+ successCount: 1,
169
+ failedCount: 0,
170
+ startedAt: 1000,
171
+ completedAt: 5000,
172
+ durationMs: 4000,
173
+ };
174
+ const opB: PlatformOperationRecord = {
175
+ ...base,
176
+ operationId: "op-b",
177
+ operationType: "cleanup",
178
+ status: "success",
179
+ templateId: "t1",
180
+ successCount: 1,
181
+ failedCount: 0,
182
+ startedAt: 3000,
183
+ completedAt: 5000,
184
+ durationMs: 2000,
185
+ };
186
+
187
+ await appendPlatformOperation(opA);
188
+ await appendPlatformOperation(opB);
189
+
190
+ const p1 = await listPlatformOperations({ limit: 1, offset: 0 });
191
+ const p2 = await listPlatformOperations({ limit: 1, offset: 1 });
192
+ expect(p1.total).toBe(2);
193
+ expect(p2.total).toBe(2);
194
+ expect(new Set([p1.operations[0]?.operationId, p2.operations[0]?.operationId])).toEqual(
195
+ new Set(["op-a", "op-b"]),
196
+ );
197
+ });
198
+ });