miravo 0.6.0 → 0.6.2
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 +29 -59
- package/bin/miravo.js +84 -0
- package/package.json +21 -37
- package/console-dist/assets/activity-DsWeRq4h.js +0 -1
- package/console-dist/assets/api-4AqKvsm9.js +0 -1
- package/console-dist/assets/arrow-right-Ea_UhTNi.js +0 -1
- package/console-dist/assets/arrow-up-down-DFSjzzl2.js +0 -1
- package/console-dist/assets/blueprint-card-classes-DBFCDy0M.js +0 -1
- package/console-dist/assets/box-C1WaoOMF.js +0 -1
- package/console-dist/assets/card-BjeP32PN.js +0 -1
- package/console-dist/assets/catalog-BJzyzbLR.js +0 -1
- package/console-dist/assets/catalog-DKjEm-c4.js +0 -1
- package/console-dist/assets/catalog-template-icons-BrUeHaCY.js +0 -1
- package/console-dist/assets/checkbox-DZlJXTE4.js +0 -1
- package/console-dist/assets/chunk-OE4NN4TA-CEjpfhS6.js +0 -3
- package/console-dist/assets/combobox-BGnuCYxO.js +0 -1
- package/console-dist/assets/control-CPhvwx9o.js +0 -1
- package/console-dist/assets/control-Cg2up9NA.js +0 -1
- package/console-dist/assets/copy-button-WkbNkMcd.js +0 -1
- package/console-dist/assets/createLucideIcon-Bwxw859K.js +0 -1
- package/console-dist/assets/deploy-tab-JeWDwPoZ.js +0 -1
- package/console-dist/assets/dist-BhbDML0a.js +0 -1
- package/console-dist/assets/dist-Cy8XTNFT.js +0 -2
- package/console-dist/assets/download-DQ6NH16U.js +0 -1
- package/console-dist/assets/engine-store-CrLnz692.js +0 -1
- package/console-dist/assets/events-CXm29Ok4.js +0 -1
- package/console-dist/assets/events-D01IxhnP.js +0 -1
- 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/fleet-CfxbKXt9.js +0 -1
- package/console-dist/assets/fleet-lotMGVqe.js +0 -1
- package/console-dist/assets/format-kXTonpDJ.js +0 -1
- package/console-dist/assets/gauge-Ci8zNZlk.js +0 -1
- package/console-dist/assets/get-error-message-3Q5Q-2G5.js +0 -1
- package/console-dist/assets/index-CVPKSDvJ.js +0 -12
- package/console-dist/assets/index-DJkgDeun.css +0 -2
- package/console-dist/assets/input-BWEQFptx.js +0 -1
- package/console-dist/assets/instance-detail-C5IVwuh3.js +0 -1
- 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/label-uephX5ZG.js +0 -1
- package/console-dist/assets/layers-CUsLQ1fq.js +0 -1
- package/console-dist/assets/login-C3RJli0m.js +0 -126
- package/console-dist/assets/models-B_UioWQ5.js +0 -1
- package/console-dist/assets/models-_IPlPiZ7.js +0 -1
- package/console-dist/assets/not-found-B8gAm08S.js +0 -1
- package/console-dist/assets/platform-targets-R3T-4eJ7.js +0 -1
- package/console-dist/assets/platforms-BWh0BQ3Y.js +0 -1
- package/console-dist/assets/platforms-DStNbtVc.js +0 -1
- package/console-dist/assets/proxy-CsnYqnPp.js +0 -9
- package/console-dist/assets/query-keys-BvtE7BMs.js +0 -1
- package/console-dist/assets/radio-CoHegOv5.js +0 -1
- package/console-dist/assets/rolldown-runtime-COnpUsM8.js +0 -1
- 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/assets/select-CeO4vP0G.js +0 -1
- package/console-dist/assets/select-display-items-CIzY8953.js +0 -1
- package/console-dist/assets/server-D9Kfn8fQ.js +0 -1
- package/console-dist/assets/shallow-6lGjQhtJ.js +0 -1
- package/console-dist/assets/tags-CiXOIHtY.js +0 -4
- package/console-dist/assets/tags-FQCXIxrP.js +0 -1
- package/console-dist/assets/tooltip-HGLF366Q.js +0 -1
- package/console-dist/assets/transition-nav-link-Df4Rk6vV.js +0 -1
- package/console-dist/assets/transition-types-CPlGtM8w.js +0 -1
- package/console-dist/assets/triangle-alert-adNk_sea.js +0 -1
- package/console-dist/assets/use-platform-stream-DGbK1k2L.js +0 -2
- package/console-dist/assets/use-transition-navigate-CCpcWXB0.js +0 -2
- package/console-dist/assets/validation-Blvud9_O.js +0 -1
- package/console-dist/assets/vendor-charts-CAZdaPlq.js +0 -60
- package/console-dist/assets/vendor-table-wd8JuMmT.js +0 -4
- package/console-dist/assets/vendor-ui-ByahnaY0.js +0 -1
- package/console-dist/favicon.svg +0 -37
- package/console-dist/index.html +0 -82
- package/console-dist/miravo-logo.png +0 -0
- package/src/__tests__/integration/executable-methods-runner.ts +0 -262
- package/src/__tests__/integration/executable-methods.integration.ts +0 -2348
- package/src/__tests__/integration/opcua-concurrent-clients.integration.ts +0 -314
- package/src/commands/adapter.ts +0 -249
- package/src/commands/apply.test.ts +0 -62
- package/src/commands/apply.ts +0 -216
- package/src/commands/cleanup.test.ts +0 -34
- package/src/commands/cleanup.ts +0 -201
- package/src/commands/cli-test-setup.ts +0 -91
- package/src/commands/export.test.ts +0 -51
- package/src/commands/export.ts +0 -404
- 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 -173
- 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/platforms.test.ts +0 -186
- package/src/commands/platforms.ts +0 -371
- 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/adapter-config.ts +0 -157
- package/src/commands/start/catalog-le-dt.test.ts +0 -50
- package/src/commands/start/catalog-le-dt.ts +0 -187
- package/src/commands/start/catalog-model-schema.ts +0 -89
- package/src/commands/start/catalog-template-detail.ts +0 -54
- package/src/commands/start/daemon.ts +0 -255
- package/src/commands/start/display.ts +0 -150
- package/src/commands/start/engine-setup.ts +0 -247
- package/src/commands/start/events.ts +0 -126
- package/src/commands/start/index.ts +0 -897
- package/src/commands/start/protocol-plans.ts +0 -239
- package/src/commands/start/template-loader.ts +0 -197
- package/src/commands/start/template-resolver.ts +0 -185
- package/src/commands/start/types.ts +0 -73
- package/src/commands/start.test.ts +0 -480
- 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 -169
- package/src/commands/templates.test.ts +0 -58
- package/src/commands/templates.ts +0 -277
- package/src/index.ts +0 -166
- package/src/lib/admin-auth.ts +0 -157
- package/src/lib/admin-client.test.ts +0 -51
- package/src/lib/admin-client.ts +0 -326
- package/src/lib/admin-platform-routes.ts +0 -1017
- package/src/lib/admin-platform-stream.test.ts +0 -434
- package/src/lib/admin-platform-stream.ts +0 -788
- package/src/lib/admin-responses.ts +0 -211
- package/src/lib/admin-server.test.ts +0 -781
- package/src/lib/admin-server.ts +0 -797
- package/src/lib/auth-store.ts +0 -87
- package/src/lib/daemon-registry.test.ts +0 -429
- package/src/lib/daemon-registry.ts +0 -343
- package/src/lib/le/le-apply.test.ts +0 -591
- package/src/lib/le/le-apply.ts +0 -832
- package/src/lib/le/le-auth.ts +0 -89
- package/src/lib/le/le-cleanup.test.ts +0 -168
- package/src/lib/le/le-cleanup.ts +0 -399
- package/src/lib/le/le-cli-helpers.ts +0 -77
- package/src/lib/le/le-client.ts +0 -184
- package/src/lib/le/le-concurrency.test.ts +0 -157
- package/src/lib/le/le-concurrency.ts +0 -78
- package/src/lib/le/le-connection-test.ts +0 -42
- package/src/lib/le/le-devicehub-properties.test.ts +0 -53
- package/src/lib/le/le-devicehub-properties.ts +0 -33
- package/src/lib/le/le-devicehub.ts +0 -213
- package/src/lib/le/le-digital-twins.ts +0 -285
- package/src/lib/le/le-drivers.ts +0 -23
- package/src/lib/le/le-endpoint.ts +0 -40
- package/src/lib/le/le-environments.test.ts +0 -73
- package/src/lib/le/le-environments.ts +0 -156
- package/src/lib/le/le-markers.ts +0 -87
- package/src/lib/le/le-plan.test.ts +0 -26
- package/src/lib/le/le-plan.ts +0 -109
- package/src/lib/le/le-platform-operations.test.ts +0 -198
- package/src/lib/le/le-platform-operations.ts +0 -340
- package/src/lib/le/le-static-model-schema.ts +0 -78
- package/src/lib/le/le-static-planner.test.ts +0 -255
- package/src/lib/le/le-static-planner.ts +0 -442
- package/src/lib/le/le-template-exporter.test.ts +0 -527
- package/src/lib/le/le-template-exporter.ts +0 -459
- package/src/lib/le/le-value-types.ts +0 -23
- package/src/lib/mqtt-client.ts +0 -115
- 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 -181
- package/src/lib/session-store.ts +0 -110
- package/src/lib/shutdown.test.ts +0 -562
- package/src/lib/shutdown.ts +0 -621
- package/src/lib/template-category.test.ts +0 -71
- package/src/lib/template-category.ts +0 -72
- package/src/lib/template-locator.ts +0 -55
- package/src/lib/template-utils.ts +0 -45
- package/src/lib/ws-hub.ts +0 -281
- package/src/logger.test.ts +0 -172
- package/src/logger.ts +0 -402
- package/src/output.test.ts +0 -64
- package/src/output.ts +0 -160
package/src/lib/le/le-apply.ts
DELETED
|
@@ -1,832 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LE Apply reconciler.
|
|
3
|
-
*
|
|
4
|
-
* Builds a plan from desired state, then executes create/update
|
|
5
|
-
* operations in dependency order with best-effort error handling
|
|
6
|
-
* (continues on failure, collects all errors).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { isRecord, type ProtocolName } from "@miravo/shared";
|
|
10
|
-
import { formatThrownError, pMap } from "./le-concurrency.ts";
|
|
11
|
-
import type { LEDevice, LEDeviceHub, LEItem } from "./le-devicehub.ts";
|
|
12
|
-
import type { LEDigitalTwins, LEInstance, LEModel } from "./le-digital-twins.ts";
|
|
13
|
-
import { getDriverName } from "./le-drivers.ts";
|
|
14
|
-
import { formatEndpoint } from "./le-endpoint.ts";
|
|
15
|
-
import {
|
|
16
|
-
encodeDeviceMarkers,
|
|
17
|
-
encodeDTInstanceMarkers,
|
|
18
|
-
isDeviceManaged,
|
|
19
|
-
isDTManaged,
|
|
20
|
-
parseDeviceMarkers,
|
|
21
|
-
} from "./le-markers.ts";
|
|
22
|
-
import type { ApplyPlan, ApplyScope, PlanEntry } from "./le-plan.ts";
|
|
23
|
-
|
|
24
|
-
/** Max concurrent LE API calls per execution phase. Tuned for edge devices. */
|
|
25
|
-
const LE_PHASE_CONCURRENCY = 5;
|
|
26
|
-
|
|
27
|
-
/** Enforce scope dependencies: instances requires models. */
|
|
28
|
-
function normalizeScopeDeps(scope: readonly ApplyScope[]): readonly ApplyScope[] {
|
|
29
|
-
const set = new Set(scope);
|
|
30
|
-
if (set.has("instances")) set.add("models");
|
|
31
|
-
return [...set];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// --- Desired state types ---
|
|
35
|
-
|
|
36
|
-
export interface DesiredDevice {
|
|
37
|
-
readonly instanceId: string;
|
|
38
|
-
readonly modelName: string;
|
|
39
|
-
readonly protocol: ProtocolName;
|
|
40
|
-
readonly deviceName: string;
|
|
41
|
-
readonly browsePath: string;
|
|
42
|
-
readonly endpoint: { host: string; port: number };
|
|
43
|
-
readonly properties: readonly LEItem[];
|
|
44
|
-
readonly tags: readonly DesiredTag[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface DesiredTag {
|
|
48
|
-
readonly name: string;
|
|
49
|
-
readonly tagName: string;
|
|
50
|
-
readonly valueType: string;
|
|
51
|
-
readonly properties: readonly LEItem[];
|
|
52
|
-
readonly description?: string | undefined;
|
|
53
|
-
readonly unsPath: string;
|
|
54
|
-
readonly access: string;
|
|
55
|
-
readonly unit?: string | undefined;
|
|
56
|
-
readonly euRange?: readonly [number, number] | undefined;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface DesiredModel {
|
|
60
|
-
readonly name: string;
|
|
61
|
-
readonly leDtJson: unknown;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface DesiredInstance {
|
|
65
|
-
readonly instanceId: string;
|
|
66
|
-
readonly modelName: string;
|
|
67
|
-
readonly dtInstanceName: string;
|
|
68
|
-
readonly dtModelName: string;
|
|
69
|
-
readonly boundDeviceName: string;
|
|
70
|
-
readonly browsePath: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface ResourceProgress {
|
|
74
|
-
readonly phase: string;
|
|
75
|
-
readonly resourceType: string;
|
|
76
|
-
readonly name: string;
|
|
77
|
-
readonly action: string;
|
|
78
|
-
readonly status: "success" | "failed";
|
|
79
|
-
readonly error?: string | undefined;
|
|
80
|
-
readonly index: number;
|
|
81
|
-
readonly of: number;
|
|
82
|
-
/** Number of sub-resources affected (e.g. tags per device). Defaults to 1. */
|
|
83
|
-
readonly count?: number | undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface ApplyOptions {
|
|
87
|
-
readonly templateId: string;
|
|
88
|
-
readonly targetHost: string;
|
|
89
|
-
readonly sourceHostname: string | undefined;
|
|
90
|
-
readonly scope: readonly ApplyScope[];
|
|
91
|
-
readonly filters: {
|
|
92
|
-
readonly area?: string | undefined;
|
|
93
|
-
readonly line?: string | undefined;
|
|
94
|
-
readonly instance?: string | undefined;
|
|
95
|
-
};
|
|
96
|
-
readonly ifUnmanaged: "fail" | "skip";
|
|
97
|
-
readonly dryRun: boolean;
|
|
98
|
-
readonly onProgress?: ((msg: string) => void) | undefined;
|
|
99
|
-
readonly onResourceComplete?: ((progress: ResourceProgress) => void) | undefined;
|
|
100
|
-
readonly signal?: AbortSignal | undefined;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface ApplyDesiredState {
|
|
104
|
-
readonly devices: readonly DesiredDevice[];
|
|
105
|
-
readonly models: readonly DesiredModel[];
|
|
106
|
-
readonly instances: readonly DesiredInstance[];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Snapshot of LE state fetched during planning, reused by the executor. */
|
|
110
|
-
interface LESnapshot {
|
|
111
|
-
readonly deviceByName: Map<string, LEDevice>;
|
|
112
|
-
readonly modelsByName: Map<string, LEModel[]>;
|
|
113
|
-
readonly instanceByName: Map<string, LEInstance>;
|
|
114
|
-
readonly driverIdByProtocol: Map<ProtocolName, string>;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
interface PlanResult {
|
|
118
|
-
readonly plan: ApplyPlan;
|
|
119
|
-
readonly snapshot: LESnapshot;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function indexModelsByName(models: readonly LEModel[]): Map<string, LEModel[]> {
|
|
123
|
-
const byName = new Map<string, LEModel[]>();
|
|
124
|
-
for (const model of models) {
|
|
125
|
-
const existing = byName.get(model.Name);
|
|
126
|
-
if (existing) {
|
|
127
|
-
existing.push(model);
|
|
128
|
-
} else {
|
|
129
|
-
byName.set(model.Name, [model]);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return byName;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function resolveModelMarkersInImport(
|
|
136
|
-
modelJson: unknown,
|
|
137
|
-
data: { templateId: string; modelName: string },
|
|
138
|
-
): unknown {
|
|
139
|
-
if (!Array.isArray(modelJson)) return modelJson;
|
|
140
|
-
|
|
141
|
-
return modelJson.map((entry) => {
|
|
142
|
-
if (!isRecord(entry)) return entry;
|
|
143
|
-
const staticAttributes = entry["staticAttributes"];
|
|
144
|
-
if (!Array.isArray(staticAttributes)) return entry;
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
...entry,
|
|
148
|
-
staticAttributes: staticAttributes.map((attr) => {
|
|
149
|
-
if (!isRecord(attr) || typeof attr["key"] !== "string") return attr;
|
|
150
|
-
switch (attr["key"]) {
|
|
151
|
-
case "miravo_managed":
|
|
152
|
-
return { ...attr, value: "true" };
|
|
153
|
-
case "miravo_template":
|
|
154
|
-
return { ...attr, value: data.templateId };
|
|
155
|
-
case "miravo_model":
|
|
156
|
-
return { ...attr, value: data.modelName };
|
|
157
|
-
default:
|
|
158
|
-
return attr;
|
|
159
|
-
}
|
|
160
|
-
}),
|
|
161
|
-
};
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function buildTagMetaData(tag: DesiredTag, dev: DesiredDevice): LEItem[] {
|
|
166
|
-
const meta: LEItem[] = [
|
|
167
|
-
{ Key: "uns_path", Value: tag.unsPath },
|
|
168
|
-
{ Key: "model", Value: dev.modelName },
|
|
169
|
-
{ Key: "instance", Value: dev.instanceId },
|
|
170
|
-
{ Key: "access", Value: tag.access },
|
|
171
|
-
];
|
|
172
|
-
if (tag.unit) meta.push({ Key: "unit", Value: tag.unit });
|
|
173
|
-
if (tag.euRange) {
|
|
174
|
-
meta.push(
|
|
175
|
-
{ Key: "eu_range_min", Value: String(tag.euRange[0]) },
|
|
176
|
-
{ Key: "eu_range_max", Value: String(tag.euRange[1]) },
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
return meta;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// --- Plan builder ---
|
|
183
|
-
|
|
184
|
-
export async function buildApplyPlan(
|
|
185
|
-
hub: LEDeviceHub,
|
|
186
|
-
dt: LEDigitalTwins,
|
|
187
|
-
desired: ApplyDesiredState,
|
|
188
|
-
opts: ApplyOptions,
|
|
189
|
-
): Promise<PlanResult> {
|
|
190
|
-
const entries: PlanEntry[] = [];
|
|
191
|
-
// Enforce scope dependencies: instances requires models
|
|
192
|
-
const scope = normalizeScopeDeps(opts.scope);
|
|
193
|
-
|
|
194
|
-
const needDevices = scope.includes("devices");
|
|
195
|
-
const needModels = scope.includes("models");
|
|
196
|
-
const needInstances = scope.includes("instances");
|
|
197
|
-
|
|
198
|
-
const [existingDevices, existingModels, existingInstances] = await Promise.all([
|
|
199
|
-
needDevices ? hub.listDevices() : ([] as readonly LEDevice[]),
|
|
200
|
-
needModels ? dt.listModels() : ([] as readonly LEModel[]),
|
|
201
|
-
needInstances
|
|
202
|
-
? dt.listInstances().catch((): readonly LEInstance[] => {
|
|
203
|
-
opts.onProgress?.(
|
|
204
|
-
"⚠ Warning: LE ListInstances failed — treating all DT instances as new (create).",
|
|
205
|
-
);
|
|
206
|
-
return [];
|
|
207
|
-
})
|
|
208
|
-
: ([] as readonly LEInstance[]),
|
|
209
|
-
]);
|
|
210
|
-
|
|
211
|
-
const deviceByName = new Map(existingDevices.map((d) => [d.Name, d]));
|
|
212
|
-
const modelsByName = indexModelsByName(existingModels);
|
|
213
|
-
const instanceByName = new Map(existingInstances.map((i) => [i.Name, i]));
|
|
214
|
-
|
|
215
|
-
const driverCache = new Map<ProtocolName, string>();
|
|
216
|
-
|
|
217
|
-
if (scope.includes("devices")) {
|
|
218
|
-
for (const dev of desired.devices) {
|
|
219
|
-
const prefixedName = dev.deviceName;
|
|
220
|
-
const existing = deviceByName.get(prefixedName);
|
|
221
|
-
|
|
222
|
-
if (existing) {
|
|
223
|
-
const managed = isDeviceManaged(existing.MetaData);
|
|
224
|
-
if (!managed) {
|
|
225
|
-
if (opts.ifUnmanaged === "fail") {
|
|
226
|
-
entries.push({
|
|
227
|
-
action: "blocked",
|
|
228
|
-
resourceType: "device",
|
|
229
|
-
name: prefixedName,
|
|
230
|
-
protocol: dev.protocol,
|
|
231
|
-
blockReason: `unmanaged device "${prefixedName}" already exists on LE`,
|
|
232
|
-
});
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
// DriverID is immutable on update — replace the device if the protocol changed.
|
|
238
|
-
const existingMarkers = parseDeviceMarkers(existing.MetaData);
|
|
239
|
-
const protocolChanged = !!existingMarkers && existingMarkers.protocol !== dev.protocol;
|
|
240
|
-
|
|
241
|
-
if (protocolChanged) {
|
|
242
|
-
if (!driverCache.has(dev.protocol)) {
|
|
243
|
-
try {
|
|
244
|
-
const driver = await hub.resolveDriverByName(getDriverName(dev.protocol));
|
|
245
|
-
driverCache.set(dev.protocol, driver.ID);
|
|
246
|
-
} catch {
|
|
247
|
-
entries.push({
|
|
248
|
-
action: "blocked",
|
|
249
|
-
resourceType: "device",
|
|
250
|
-
name: prefixedName,
|
|
251
|
-
protocol: dev.protocol,
|
|
252
|
-
blockReason: `LE driver "${getDriverName(dev.protocol)}" not found`,
|
|
253
|
-
});
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
entries.push({
|
|
258
|
-
action: "replace",
|
|
259
|
-
resourceType: "device",
|
|
260
|
-
name: prefixedName,
|
|
261
|
-
protocol: dev.protocol,
|
|
262
|
-
endpoint: formatEndpoint(dev.endpoint),
|
|
263
|
-
});
|
|
264
|
-
} else {
|
|
265
|
-
entries.push({
|
|
266
|
-
action: "update",
|
|
267
|
-
resourceType: "device",
|
|
268
|
-
name: prefixedName,
|
|
269
|
-
protocol: dev.protocol,
|
|
270
|
-
endpoint: formatEndpoint(dev.endpoint),
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
} else {
|
|
274
|
-
if (!driverCache.has(dev.protocol)) {
|
|
275
|
-
try {
|
|
276
|
-
const driver = await hub.resolveDriverByName(getDriverName(dev.protocol));
|
|
277
|
-
driverCache.set(dev.protocol, driver.ID);
|
|
278
|
-
} catch {
|
|
279
|
-
entries.push({
|
|
280
|
-
action: "blocked",
|
|
281
|
-
resourceType: "device",
|
|
282
|
-
name: prefixedName,
|
|
283
|
-
protocol: dev.protocol,
|
|
284
|
-
blockReason: `LE driver "${getDriverName(dev.protocol)}" not found`,
|
|
285
|
-
});
|
|
286
|
-
continue;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
entries.push({
|
|
290
|
-
action: "create",
|
|
291
|
-
resourceType: "device",
|
|
292
|
-
name: prefixedName,
|
|
293
|
-
protocol: dev.protocol,
|
|
294
|
-
endpoint: formatEndpoint(dev.endpoint),
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (scope.includes("models")) {
|
|
301
|
-
for (const model of desired.models) {
|
|
302
|
-
const dtName = model.name;
|
|
303
|
-
const existing = modelsByName.get(dtName) ?? [];
|
|
304
|
-
if (existing.length > 1) {
|
|
305
|
-
const ids = existing.map((m) => m.ID).join(", ");
|
|
306
|
-
entries.push({
|
|
307
|
-
action: "blocked",
|
|
308
|
-
resourceType: "model",
|
|
309
|
-
name: dtName,
|
|
310
|
-
blockReason: `duplicate DT models "${dtName}" exist on LE (ids: ${ids}); clean duplicates before apply`,
|
|
311
|
-
});
|
|
312
|
-
} else {
|
|
313
|
-
entries.push({
|
|
314
|
-
action: existing.length === 1 ? "update" : "create",
|
|
315
|
-
resourceType: "model",
|
|
316
|
-
name: dtName,
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (scope.includes("instances")) {
|
|
323
|
-
// Pre-fetch managed status for all existing instances concurrently
|
|
324
|
-
const existingInstancePairs = desired.instances
|
|
325
|
-
.map((inst) => ({
|
|
326
|
-
inst,
|
|
327
|
-
existing: instanceByName.get(inst.dtInstanceName),
|
|
328
|
-
}))
|
|
329
|
-
.filter((p): p is typeof p & { existing: LEInstance } => p.existing !== undefined);
|
|
330
|
-
|
|
331
|
-
const managedStatusMap = new Map<string, boolean>();
|
|
332
|
-
if (existingInstancePairs.length > 0) {
|
|
333
|
-
await pMap(
|
|
334
|
-
existingInstancePairs,
|
|
335
|
-
async ({ inst, existing }) => {
|
|
336
|
-
const dtInstName = inst.dtInstanceName;
|
|
337
|
-
try {
|
|
338
|
-
const attrs = await dt.listStaticAttributes({ instanceId: existing.ID });
|
|
339
|
-
managedStatusMap.set(dtInstName, isDTManaged(attrs));
|
|
340
|
-
} catch {
|
|
341
|
-
opts.onProgress?.(
|
|
342
|
-
`⚠ Warning: could not check managed status of instance ${dtInstName} — assuming managed.`,
|
|
343
|
-
);
|
|
344
|
-
managedStatusMap.set(dtInstName, true);
|
|
345
|
-
}
|
|
346
|
-
},
|
|
347
|
-
{ concurrency: LE_PHASE_CONCURRENCY },
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
for (const inst of desired.instances) {
|
|
352
|
-
const dtInstName = inst.dtInstanceName;
|
|
353
|
-
const existing = instanceByName.get(dtInstName);
|
|
354
|
-
|
|
355
|
-
if (existing) {
|
|
356
|
-
const managed = managedStatusMap.get(dtInstName) ?? true;
|
|
357
|
-
if (!managed) {
|
|
358
|
-
if (opts.ifUnmanaged === "fail") {
|
|
359
|
-
entries.push({
|
|
360
|
-
action: "blocked",
|
|
361
|
-
resourceType: "instance",
|
|
362
|
-
name: dtInstName,
|
|
363
|
-
blockReason: `unmanaged instance "${dtInstName}" already exists on LE`,
|
|
364
|
-
});
|
|
365
|
-
continue;
|
|
366
|
-
}
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
entries.push({ action: "update", resourceType: "instance", name: dtInstName });
|
|
370
|
-
} else {
|
|
371
|
-
entries.push({ action: "create", resourceType: "instance", name: dtInstName });
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const instEntry = entries[entries.length - 1];
|
|
375
|
-
if (instEntry && instEntry.action !== "blocked") {
|
|
376
|
-
entries.push({
|
|
377
|
-
action: instEntry.action,
|
|
378
|
-
resourceType: "param-sync",
|
|
379
|
-
name: dtInstName,
|
|
380
|
-
detail: `device=${inst.boundDeviceName}`,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const plan: ApplyPlan = {
|
|
387
|
-
sourceMode: "static",
|
|
388
|
-
targetHost: opts.targetHost,
|
|
389
|
-
sourceHostname: opts.sourceHostname,
|
|
390
|
-
scope: [...scope],
|
|
391
|
-
filters: opts.filters,
|
|
392
|
-
entries,
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
return {
|
|
396
|
-
plan,
|
|
397
|
-
snapshot: { deviceByName, modelsByName, instanceByName, driverIdByProtocol: driverCache },
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// --- Executor ---
|
|
402
|
-
|
|
403
|
-
interface ApplyResult {
|
|
404
|
-
readonly plan: ApplyPlan;
|
|
405
|
-
readonly applied: number;
|
|
406
|
-
readonly failed: number;
|
|
407
|
-
readonly failedEntries: readonly { entry: PlanEntry; error: string }[];
|
|
408
|
-
readonly error?: string | undefined;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
export async function executePlan(
|
|
412
|
-
hub: LEDeviceHub,
|
|
413
|
-
dt: LEDigitalTwins,
|
|
414
|
-
plan: ApplyPlan,
|
|
415
|
-
desired: ApplyDesiredState,
|
|
416
|
-
opts: ApplyOptions,
|
|
417
|
-
snapshot: LESnapshot,
|
|
418
|
-
): Promise<ApplyResult> {
|
|
419
|
-
const log = opts.onProgress ?? (() => {});
|
|
420
|
-
const onResource = opts.onResourceComplete;
|
|
421
|
-
let applied = 0;
|
|
422
|
-
let nonFatalFailures = 0;
|
|
423
|
-
|
|
424
|
-
// Pre-compute per-phase totals for progress reporting
|
|
425
|
-
const phaseTotals = new Map<string, number>();
|
|
426
|
-
const phaseCounters = new Map<string, number>();
|
|
427
|
-
for (const entry of plan.entries) {
|
|
428
|
-
if (entry.action === "blocked") continue;
|
|
429
|
-
phaseTotals.set(entry.resourceType, (phaseTotals.get(entry.resourceType) ?? 0) + 1);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function emitProgress(
|
|
433
|
-
phase: string,
|
|
434
|
-
name: string,
|
|
435
|
-
action: string,
|
|
436
|
-
status: "success" | "failed",
|
|
437
|
-
error?: string,
|
|
438
|
-
count?: number,
|
|
439
|
-
): void {
|
|
440
|
-
const idx = (phaseCounters.get(phase) ?? 0) + 1;
|
|
441
|
-
phaseCounters.set(phase, idx);
|
|
442
|
-
onResource?.({
|
|
443
|
-
phase,
|
|
444
|
-
resourceType: phase,
|
|
445
|
-
name,
|
|
446
|
-
action,
|
|
447
|
-
status,
|
|
448
|
-
error,
|
|
449
|
-
index: idx,
|
|
450
|
-
of: phaseTotals.get(phase) ?? 0,
|
|
451
|
-
count,
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Mutable copies — updated as we create/update resources
|
|
456
|
-
const { deviceByName, modelsByName, instanceByName, driverIdByProtocol } = snapshot;
|
|
457
|
-
|
|
458
|
-
// Pre-index desired state for O(1) lookup instead of linear scans
|
|
459
|
-
const desiredDeviceByName = new Map(desired.devices.map((d) => [d.deviceName, d]));
|
|
460
|
-
const desiredModelByName = new Map(desired.models.map((m) => [m.name, m]));
|
|
461
|
-
const desiredInstanceByName = new Map(desired.instances.map((i) => [i.dtInstanceName, i]));
|
|
462
|
-
|
|
463
|
-
function recordNonFatalFailure(msg: string): void {
|
|
464
|
-
nonFatalFailures++;
|
|
465
|
-
log(`⚠ Warning: ${msg}`);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function getSingleModelByName(name: string): LEModel | undefined {
|
|
469
|
-
const matches = modelsByName.get(name) ?? [];
|
|
470
|
-
if (matches.length === 0) return undefined;
|
|
471
|
-
if (matches.length > 1) {
|
|
472
|
-
const ids = matches.map((model) => model.ID).join(", ");
|
|
473
|
-
throw new Error(`duplicate DT models "${name}" exist on LE (ids: ${ids})`);
|
|
474
|
-
}
|
|
475
|
-
return matches[0];
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Group actionable entries by resource type (phase)
|
|
479
|
-
const PHASE_ORDER = ["device", "model", "instance", "param-sync"] as const;
|
|
480
|
-
type PhaseKey = (typeof PHASE_ORDER)[number];
|
|
481
|
-
const groups = new Map<PhaseKey, PlanEntry[]>();
|
|
482
|
-
for (const entry of plan.entries) {
|
|
483
|
-
if (entry.action === "blocked") continue;
|
|
484
|
-
const list = groups.get(entry.resourceType) ?? [];
|
|
485
|
-
list.push(entry);
|
|
486
|
-
groups.set(entry.resourceType, list);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// --- Per-entry execution by resource type ---
|
|
490
|
-
|
|
491
|
-
async function execDevice(entry: PlanEntry): Promise<void> {
|
|
492
|
-
const dev = desiredDeviceByName.get(entry.name);
|
|
493
|
-
if (!dev) throw new Error(`Desired device "${entry.name}" not found`);
|
|
494
|
-
|
|
495
|
-
const markers = encodeDeviceMarkers({
|
|
496
|
-
templateId: opts.templateId,
|
|
497
|
-
instanceId: dev.instanceId,
|
|
498
|
-
modelName: dev.modelName,
|
|
499
|
-
protocol: dev.protocol,
|
|
500
|
-
unsPath: dev.browsePath,
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
const existing = deviceByName.get(entry.name);
|
|
504
|
-
let deviceId: string;
|
|
505
|
-
|
|
506
|
-
if (existing && entry.action === "replace") {
|
|
507
|
-
const driverId = driverIdByProtocol.get(dev.protocol);
|
|
508
|
-
if (!driverId) throw new Error(`Driver for protocol "${dev.protocol}" not resolved`);
|
|
509
|
-
|
|
510
|
-
// Protocol changed — DriverID is immutable; delete the old device and recreate
|
|
511
|
-
// (LE cascade-deletes registers with the device)
|
|
512
|
-
await hub.deleteDevices([existing.ID]);
|
|
513
|
-
deviceByName.delete(entry.name);
|
|
514
|
-
let replaced: Awaited<ReturnType<typeof hub.createDevice>>;
|
|
515
|
-
try {
|
|
516
|
-
replaced = await hub.createDevice({
|
|
517
|
-
DriverID: driverId,
|
|
518
|
-
Name: entry.name,
|
|
519
|
-
Properties: dev.properties as LEItem[],
|
|
520
|
-
MetaData: markers,
|
|
521
|
-
Description: `Miravo managed: ${dev.instanceId}`,
|
|
522
|
-
});
|
|
523
|
-
} catch (err) {
|
|
524
|
-
const msg = formatThrownError(err);
|
|
525
|
-
throw new Error(
|
|
526
|
-
`Device "${entry.name}" was deleted for protocol change but recreation failed: ${msg}`,
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
deviceByName.set(entry.name, replaced);
|
|
530
|
-
deviceId = replaced.ID;
|
|
531
|
-
} else if (existing) {
|
|
532
|
-
const result = await hub.updateDevice({
|
|
533
|
-
ID: existing.ID,
|
|
534
|
-
Name: entry.name,
|
|
535
|
-
Properties: dev.properties as LEItem[],
|
|
536
|
-
MetaData: markers,
|
|
537
|
-
Description: `Miravo managed: ${dev.instanceId}`,
|
|
538
|
-
});
|
|
539
|
-
deviceByName.set(entry.name, result);
|
|
540
|
-
// Clear existing registers before writing new ones (LE does not cascade-update them)
|
|
541
|
-
await hub.deleteDeviceRegisters(existing.ID);
|
|
542
|
-
deviceId = existing.ID;
|
|
543
|
-
} else {
|
|
544
|
-
const driverId = driverIdByProtocol.get(dev.protocol);
|
|
545
|
-
if (!driverId) throw new Error(`Driver for protocol "${dev.protocol}" not resolved`);
|
|
546
|
-
|
|
547
|
-
const result = await hub.createDevice({
|
|
548
|
-
DriverID: driverId,
|
|
549
|
-
Name: entry.name,
|
|
550
|
-
Properties: dev.properties as LEItem[],
|
|
551
|
-
MetaData: markers,
|
|
552
|
-
Description: `Miravo managed: ${dev.instanceId}`,
|
|
553
|
-
});
|
|
554
|
-
deviceByName.set(entry.name, result);
|
|
555
|
-
deviceId = result.ID;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if (dev.tags.length > 0) {
|
|
559
|
-
await hub.createRegisters(
|
|
560
|
-
dev.tags.map((tag) => ({
|
|
561
|
-
DeviceID: deviceId,
|
|
562
|
-
Name: tag.name,
|
|
563
|
-
TagName: tag.tagName,
|
|
564
|
-
ValueType: tag.valueType,
|
|
565
|
-
Properties: tag.properties as LEItem[],
|
|
566
|
-
Description: tag.description ?? "",
|
|
567
|
-
MetaData: buildTagMetaData(tag, dev),
|
|
568
|
-
})),
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
log(
|
|
572
|
-
`✓ ${entry.action.toUpperCase()} device ${entry.name} [${dev.protocol}] (${dev.tags.length} tags)`,
|
|
573
|
-
);
|
|
574
|
-
applied++;
|
|
575
|
-
emitProgress("device", entry.name, entry.action, "success", undefined, dev.tags.length);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
async function execModelImport(entry: PlanEntry): Promise<void> {
|
|
579
|
-
const model = desiredModelByName.get(entry.name);
|
|
580
|
-
if (!model) throw new Error(`Desired model "${entry.name}" not found`);
|
|
581
|
-
|
|
582
|
-
// LE ImportModel always creates a new model entity — it does not upsert.
|
|
583
|
-
// Skip import for existing models to prevent duplicates.
|
|
584
|
-
// Model definition changes require cleanup → re-apply.
|
|
585
|
-
if (entry.action === "update") {
|
|
586
|
-
log(`✓ UPDATE DT model ${entry.name} (exists)`);
|
|
587
|
-
applied++;
|
|
588
|
-
emitProgress("model", entry.name, entry.action, "success");
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
await dt.importModel(
|
|
593
|
-
resolveModelMarkersInImport(model.leDtJson, {
|
|
594
|
-
templateId: opts.templateId,
|
|
595
|
-
modelName: model.name,
|
|
596
|
-
}),
|
|
597
|
-
);
|
|
598
|
-
log(`✓ CREATE DT model ${entry.name}`);
|
|
599
|
-
applied++;
|
|
600
|
-
emitProgress("model", entry.name, entry.action, "success");
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
async function execInstance(entry: PlanEntry): Promise<void> {
|
|
604
|
-
const inst = desiredInstanceByName.get(entry.name);
|
|
605
|
-
if (!inst) throw new Error(`Desired instance "${entry.name}" not found`);
|
|
606
|
-
|
|
607
|
-
const dtModelName = inst.dtModelName;
|
|
608
|
-
const leModel = getSingleModelByName(dtModelName);
|
|
609
|
-
if (!leModel) {
|
|
610
|
-
throw new Error(`DT model "${dtModelName}" not found on LE. Ensure models are in scope.`);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
const dtTopic = `digitaltwins.${leModel.Name}.${entry.name}`;
|
|
614
|
-
const existing = instanceByName.get(entry.name);
|
|
615
|
-
if (existing) {
|
|
616
|
-
const result = await dt.updateInstance({
|
|
617
|
-
ID: existing.ID,
|
|
618
|
-
ModelID: leModel.ID,
|
|
619
|
-
Name: entry.name,
|
|
620
|
-
Topic: dtTopic,
|
|
621
|
-
Interval: 1,
|
|
622
|
-
});
|
|
623
|
-
instanceByName.set(entry.name, result);
|
|
624
|
-
} else {
|
|
625
|
-
const result = await dt.createInstance({
|
|
626
|
-
ModelID: leModel.ID,
|
|
627
|
-
Name: entry.name,
|
|
628
|
-
Topic: dtTopic,
|
|
629
|
-
Interval: 1,
|
|
630
|
-
});
|
|
631
|
-
instanceByName.set(entry.name, result);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const leInst = instanceByName.get(entry.name);
|
|
635
|
-
if (leInst) {
|
|
636
|
-
try {
|
|
637
|
-
const existingAttrs = await dt.listStaticAttributes({ instanceId: leInst.ID });
|
|
638
|
-
const attrByKey = new Map(existingAttrs.map((attr) => [attr.Key, attr]));
|
|
639
|
-
const instanceMarkers = encodeDTInstanceMarkers({
|
|
640
|
-
templateId: opts.templateId,
|
|
641
|
-
modelName: inst.modelName,
|
|
642
|
-
instanceId: inst.instanceId,
|
|
643
|
-
unsPath: inst.browsePath,
|
|
644
|
-
});
|
|
645
|
-
const updates = instanceMarkers
|
|
646
|
-
.map((marker) => {
|
|
647
|
-
const attr = attrByKey.get(marker.Key);
|
|
648
|
-
if (!attr) return undefined;
|
|
649
|
-
return {
|
|
650
|
-
ID: attr.ID,
|
|
651
|
-
ModelID: attr.ModelID,
|
|
652
|
-
InstanceID: attr.InstanceID,
|
|
653
|
-
Key: marker.Key,
|
|
654
|
-
Value: marker.Value,
|
|
655
|
-
};
|
|
656
|
-
})
|
|
657
|
-
.filter((item): item is NonNullable<typeof item> => item !== undefined);
|
|
658
|
-
|
|
659
|
-
if (updates.length !== instanceMarkers.length) {
|
|
660
|
-
const missing = instanceMarkers
|
|
661
|
-
.map((marker) => marker.Key)
|
|
662
|
-
.filter((key) => !attrByKey.has(key));
|
|
663
|
-
recordNonFatalFailure(
|
|
664
|
-
`could not set markers on instance ${entry.name}: missing placeholder attrs (${missing.join(", ")})`,
|
|
665
|
-
);
|
|
666
|
-
} else {
|
|
667
|
-
await dt.updateInstanceStaticAttributes(leInst.ID, updates);
|
|
668
|
-
}
|
|
669
|
-
} catch (err) {
|
|
670
|
-
const msg = formatThrownError(err);
|
|
671
|
-
recordNonFatalFailure(`could not set markers on instance ${entry.name}: ${msg}`);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
log(`✓ ${entry.action.toUpperCase()} DT instance ${entry.name}`);
|
|
676
|
-
applied++;
|
|
677
|
-
emitProgress("instance", entry.name, entry.action, "success");
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
async function execParamSync(entry: PlanEntry): Promise<void> {
|
|
681
|
-
const inst = desiredInstanceByName.get(entry.name);
|
|
682
|
-
if (!inst) throw new Error(`Desired instance "${entry.name}" not found for param sync`);
|
|
683
|
-
|
|
684
|
-
const leInst = instanceByName.get(entry.name);
|
|
685
|
-
if (!leInst) throw new Error(`LE instance "${entry.name}" not found for param sync`);
|
|
686
|
-
|
|
687
|
-
const deviceAlias = inst.boundDeviceName;
|
|
688
|
-
await dt.setInstanceParameters(leInst.ID, [{ Name: "device", Value: deviceAlias }]);
|
|
689
|
-
log(`✓ SYNC DT parameter device=${deviceAlias} on ${entry.name}`);
|
|
690
|
-
applied++;
|
|
691
|
-
emitProgress("param-sync", entry.name, entry.action, "success");
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const execByPhase: Record<PhaseKey, (entry: PlanEntry) => Promise<void>> = {
|
|
695
|
-
device: execDevice,
|
|
696
|
-
model: execModelImport, // model phase uses special two-step flow below
|
|
697
|
-
instance: execInstance,
|
|
698
|
-
"param-sync": execParamSync,
|
|
699
|
-
};
|
|
700
|
-
|
|
701
|
-
/** Run a pMap over entries with best-effort semantics — collect all failures. */
|
|
702
|
-
async function runPhase(
|
|
703
|
-
phase: PhaseKey,
|
|
704
|
-
entries: readonly PlanEntry[],
|
|
705
|
-
fn: (entry: PlanEntry) => Promise<void>,
|
|
706
|
-
): Promise<{ entry: PlanEntry; error: string }[]> {
|
|
707
|
-
const phaseFailures: { entry: PlanEntry; error: string }[] = [];
|
|
708
|
-
|
|
709
|
-
await pMap(
|
|
710
|
-
entries,
|
|
711
|
-
async (entry) => {
|
|
712
|
-
try {
|
|
713
|
-
await fn(entry);
|
|
714
|
-
} catch (err) {
|
|
715
|
-
const msg = formatThrownError(err);
|
|
716
|
-
log(`✗ ${entry.action.toUpperCase()} ${phase} ${entry.name} — ${msg}`);
|
|
717
|
-
emitProgress(phase, entry.name, entry.action, "failed", msg);
|
|
718
|
-
phaseFailures.push({ entry, error: msg });
|
|
719
|
-
}
|
|
720
|
-
},
|
|
721
|
-
{ concurrency: LE_PHASE_CONCURRENCY, ...(opts.signal ? { signal: opts.signal } : {}) },
|
|
722
|
-
);
|
|
723
|
-
|
|
724
|
-
return phaseFailures;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// --- Phase-ordered execution with bounded concurrency ---
|
|
728
|
-
|
|
729
|
-
const allFailedEntries: { entry: PlanEntry; error: string }[] = [];
|
|
730
|
-
|
|
731
|
-
const refreshModelCache = async () => {
|
|
732
|
-
const models = await dt.listModels();
|
|
733
|
-
modelsByName.clear();
|
|
734
|
-
for (const [name, list] of indexModelsByName(models)) {
|
|
735
|
-
modelsByName.set(name, list);
|
|
736
|
-
}
|
|
737
|
-
};
|
|
738
|
-
|
|
739
|
-
function failAllEntries(phase: string, entries: readonly PlanEntry[], msg: string): void {
|
|
740
|
-
log(`✗ ${phase} pre-phase setup failed — ${msg}`);
|
|
741
|
-
for (const entry of entries) {
|
|
742
|
-
emitProgress(phase, entry.name, entry.action, "failed", msg);
|
|
743
|
-
allFailedEntries.push({ entry, error: msg });
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// --- Parallel: device + model (target independent LE services) ---
|
|
748
|
-
|
|
749
|
-
async function executeDevicePhase(): Promise<void> {
|
|
750
|
-
const entries = groups.get("device");
|
|
751
|
-
if (!entries?.length) return;
|
|
752
|
-
|
|
753
|
-
try {
|
|
754
|
-
const neededProtocols = new Set(
|
|
755
|
-
entries
|
|
756
|
-
.filter((e) => e.action === "create" || e.action === "replace")
|
|
757
|
-
.map((e) => desiredDeviceByName.get(e.name)?.protocol)
|
|
758
|
-
.filter((p): p is ProtocolName => p !== undefined),
|
|
759
|
-
);
|
|
760
|
-
for (const proto of neededProtocols) {
|
|
761
|
-
if (!driverIdByProtocol.has(proto)) {
|
|
762
|
-
const driver = await hub.resolveDriverByName(getDriverName(proto));
|
|
763
|
-
driverIdByProtocol.set(proto, driver.ID);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
} catch (err) {
|
|
767
|
-
failAllEntries("device", entries, formatThrownError(err));
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
allFailedEntries.push(...(await runPhase("device", entries, execDevice)));
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
async function executeModelPhase(): Promise<void> {
|
|
775
|
-
const entries = groups.get("model");
|
|
776
|
-
if (!entries?.length) return;
|
|
777
|
-
|
|
778
|
-
allFailedEntries.push(...(await runPhase("model", entries, execModelImport)));
|
|
779
|
-
|
|
780
|
-
// Refresh model cache only after actual imports so the instance phase can
|
|
781
|
-
// resolve new model IDs. Skip when all models are updates (no-ops) — the
|
|
782
|
-
// planning-time cache is already accurate.
|
|
783
|
-
if (entries.some((e) => e.action === "create")) {
|
|
784
|
-
try {
|
|
785
|
-
await refreshModelCache();
|
|
786
|
-
} catch {
|
|
787
|
-
try {
|
|
788
|
-
await refreshModelCache();
|
|
789
|
-
} catch {
|
|
790
|
-
log("⚠ Warning: model cache refresh failed — markers may be incomplete");
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
await Promise.all([executeDevicePhase(), executeModelPhase()]);
|
|
797
|
-
|
|
798
|
-
// --- Sequential: instance → param-sync (depend on model cache + instance IDs) ---
|
|
799
|
-
|
|
800
|
-
let skipInstancePhases = false;
|
|
801
|
-
|
|
802
|
-
if (groups.has("instance") && !groups.has("model")) {
|
|
803
|
-
// Model phase didn't run — refresh cache before instance creation
|
|
804
|
-
try {
|
|
805
|
-
await refreshModelCache();
|
|
806
|
-
} catch (err) {
|
|
807
|
-
const msg = formatThrownError(err);
|
|
808
|
-
// biome-ignore lint/style/noNonNullAssertion: group guaranteed to exist by scope dependency check
|
|
809
|
-
failAllEntries("instance", groups.get("instance")!, msg);
|
|
810
|
-
const ps = groups.get("param-sync");
|
|
811
|
-
if (ps?.length) failAllEntries("param-sync", ps, msg);
|
|
812
|
-
skipInstancePhases = true;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
if (!skipInstancePhases) {
|
|
817
|
-
for (const phase of ["instance", "param-sync"] as const) {
|
|
818
|
-
const entries = groups.get(phase);
|
|
819
|
-
if (!entries?.length) continue;
|
|
820
|
-
allFailedEntries.push(...(await runPhase(phase, entries, execByPhase[phase])));
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
const totalFailed = allFailedEntries.length + nonFatalFailures;
|
|
825
|
-
return {
|
|
826
|
-
plan,
|
|
827
|
-
applied,
|
|
828
|
-
failed: totalFailed,
|
|
829
|
-
failedEntries: allFailedEntries,
|
|
830
|
-
error: allFailedEntries[0]?.error,
|
|
831
|
-
};
|
|
832
|
-
}
|