ultravisor 1.0.24 → 1.0.25
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/package.json +1 -1
- package/source/cli/Ultravisor-CLIProgram.cjs +18 -0
- package/source/datamodel/Ultravisor-Fleet.json +66 -0
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +97 -2
- package/source/services/Ultravisor-DirectoryDistributor.cjs +280 -0
- package/source/services/Ultravisor-FleetManager.cjs +853 -0
- package/source/services/persistence/Ultravisor-Beacon-FleetStore.cjs +570 -0
- package/source/web_server/Ultravisor-API-Server.cjs +234 -0
- package/test/fleetstore-smoke.js +152 -0
- package/webinterface/source/Pict-Application-Ultravisor.js +2 -0
- package/webinterface/source/providers/PictRouter-Ultravisor-Configuration.json +4 -0
- package/webinterface/source/views/PictView-Ultravisor-Fleet.js +489 -0
- package/webinterface/source/views/PictView-Ultravisor-TopBar.js +1 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultravisor — FleetManager
|
|
3
|
+
*
|
|
4
|
+
* The control-plane brain for the beacon mesh. Owns the lifecycle of
|
|
5
|
+
* "what code/runtime each beacon has, what models each beacon has,
|
|
6
|
+
* which models are enabled for dispatch on each beacon."
|
|
7
|
+
*
|
|
8
|
+
* Composes three lower-level services:
|
|
9
|
+
* - UltravisorBeaconCoordinator — to dispatch work items + look
|
|
10
|
+
* up live beacon state
|
|
11
|
+
* - UltravisorBeaconFleetStore — to persist installation rows
|
|
12
|
+
* - UltravisorDirectoryDistributor — to chunk + stream a source
|
|
13
|
+
* directory to a target beacon
|
|
14
|
+
*
|
|
15
|
+
* Design notes:
|
|
16
|
+
* - Apps (retold-labs, etc.) register their runtime sources and
|
|
17
|
+
* model catalogs at startup. The FleetManager holds NO hardcoded
|
|
18
|
+
* knowledge of any particular app's directory layout.
|
|
19
|
+
* - Install + Enable are deliberately separate operator actions.
|
|
20
|
+
* Install moves bytes; Enable flips a boolean. Both persist.
|
|
21
|
+
* - On beacon connect, the manager auto-pushes any registered
|
|
22
|
+
* runtime whose AutoPushOnConnect is true AND whose worker hash
|
|
23
|
+
* doesn't match the source hash. Models are NEVER auto-pushed —
|
|
24
|
+
* that's the operator's call via the fleet UI.
|
|
25
|
+
* - Models the worker reports via LWM_Inventory at connect time
|
|
26
|
+
* get auto-imported into the fleet table as Source='discovered',
|
|
27
|
+
* EnabledForDispatch=true. This preserves backwards-compatible
|
|
28
|
+
* "I just connected a worker that already has models, dispatch
|
|
29
|
+
* should still work" behavior without operator intervention.
|
|
30
|
+
* Operator-installed models default to EnabledForDispatch=false.
|
|
31
|
+
*
|
|
32
|
+
* @module Ultravisor-FleetManager
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const libPictService = require('pict-serviceproviderbase');
|
|
36
|
+
const libPath = require('path');
|
|
37
|
+
const libFs = require('fs');
|
|
38
|
+
|
|
39
|
+
class UltravisorFleetManager extends libPictService
|
|
40
|
+
{
|
|
41
|
+
constructor(pPict, pOptions, pServiceHash)
|
|
42
|
+
{
|
|
43
|
+
super(pPict, pOptions, pServiceHash);
|
|
44
|
+
this.serviceType = 'UltravisorFleetManager';
|
|
45
|
+
|
|
46
|
+
// Registered runtimes: Name → { Name, SourceDir, AutoPushOnConnect,
|
|
47
|
+
// PushAction, FinalizeAction, ExpectedHashKey, IgnoreBasenames,
|
|
48
|
+
// CapabilityFilter, BeaconNameFilter }
|
|
49
|
+
this._runtimes = new Map();
|
|
50
|
+
|
|
51
|
+
// Registered model catalogs: Name → { Name, RootPath,
|
|
52
|
+
// ManifestFilename, ModelKeyResolver }
|
|
53
|
+
this._modelCatalogs = new Map();
|
|
54
|
+
|
|
55
|
+
// Cached available-models scan: ModelKey → { ModelKey, ModelName,
|
|
56
|
+
// ModelSourceDir, CatalogName, Manifest, Hash, BytesOnDisk }
|
|
57
|
+
this._availableModels = new Map();
|
|
58
|
+
this._availableModelsLastScan = 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Service refs ────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
_coordinator()
|
|
64
|
+
{
|
|
65
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
66
|
+
&& this.fable.servicesMap['UltravisorBeaconCoordinator'];
|
|
67
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_fleetStore()
|
|
71
|
+
{
|
|
72
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
73
|
+
&& this.fable.servicesMap['UltravisorBeaconFleetStore'];
|
|
74
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_distributor()
|
|
78
|
+
{
|
|
79
|
+
let tmpMap = this.fable && this.fable.servicesMap
|
|
80
|
+
&& this.fable.servicesMap['UltravisorDirectoryDistributor'];
|
|
81
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── App-side registration API ───────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Register a runtime source. retold-labs calls this at boot for
|
|
88
|
+
* the pipeline-workers directory.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} pConfig
|
|
91
|
+
* - Name (required) e.g. 'pipeline-workers'
|
|
92
|
+
* - SourceDir (required) absolute path on hub disk
|
|
93
|
+
* - PushAction (required) LWM action name for chunks
|
|
94
|
+
* - FinalizeAction (required) LWM action name for finalize
|
|
95
|
+
* - ExpectedHashKey (optional) finalize Settings key for hash
|
|
96
|
+
* (default 'ExpectedRuntimeHash')
|
|
97
|
+
* - AutoPushOnConnect (optional) bool, default true
|
|
98
|
+
* - CapabilityFilter (optional) Set<string> — only auto-push to
|
|
99
|
+
* beacons advertising at least one
|
|
100
|
+
* of these capabilities. Default:
|
|
101
|
+
* ['LabsWorkerManagement'] so we
|
|
102
|
+
* don't push retold-labs' runtime
|
|
103
|
+
* to beacons that aren't labs
|
|
104
|
+
* workers. Pass an empty Set to
|
|
105
|
+
* push to every beacon.
|
|
106
|
+
* - IgnoreBasenames (optional) extra skip names
|
|
107
|
+
* - ChunkBytes (optional) chunk size override
|
|
108
|
+
*/
|
|
109
|
+
registerRuntime(pConfig)
|
|
110
|
+
{
|
|
111
|
+
if (!pConfig || !pConfig.Name || !pConfig.SourceDir
|
|
112
|
+
|| !pConfig.PushAction || !pConfig.FinalizeAction)
|
|
113
|
+
{
|
|
114
|
+
throw new Error('registerRuntime: Name, SourceDir, PushAction, FinalizeAction required');
|
|
115
|
+
}
|
|
116
|
+
let tmpEntry = {
|
|
117
|
+
Name: pConfig.Name,
|
|
118
|
+
SourceDir: pConfig.SourceDir,
|
|
119
|
+
PushAction: pConfig.PushAction,
|
|
120
|
+
FinalizeAction: pConfig.FinalizeAction,
|
|
121
|
+
ExpectedHashKey: pConfig.ExpectedHashKey || 'ExpectedRuntimeHash',
|
|
122
|
+
AutoPushOnConnect: pConfig.AutoPushOnConnect !== false,
|
|
123
|
+
CapabilityFilter: pConfig.CapabilityFilter
|
|
124
|
+
|| new Set(['LabsWorkerManagement']),
|
|
125
|
+
IgnoreBasenames: pConfig.IgnoreBasenames || new Set(),
|
|
126
|
+
ChunkBytes: pConfig.ChunkBytes
|
|
127
|
+
};
|
|
128
|
+
this._runtimes.set(tmpEntry.Name, tmpEntry);
|
|
129
|
+
this.log.info(
|
|
130
|
+
`FleetManager: registered runtime '${tmpEntry.Name}' from ${tmpEntry.SourceDir} `
|
|
131
|
+
+ `(auto-push-on-connect=${tmpEntry.AutoPushOnConnect})`);
|
|
132
|
+
return tmpEntry;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Register a model catalog. retold-labs calls this at boot pointing
|
|
137
|
+
* at /Users/steven/Code/models or whatever the operator's models
|
|
138
|
+
* root is. The manager scans it lazily for available-model lists.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} pConfig
|
|
141
|
+
* - Name (required) e.g. 'retold-labs-models'
|
|
142
|
+
* - RootPath (required) absolute path on hub disk
|
|
143
|
+
* - ManifestFilename (optional) file marking a model dir
|
|
144
|
+
* (default 'model.json')
|
|
145
|
+
* - PushAction (required) LWM action used to push a model
|
|
146
|
+
* (e.g. 'LWM_PushModel')
|
|
147
|
+
* - FinalizeAction (required) LWM action used to finalize
|
|
148
|
+
* - ExpectedHashKey (optional) finalize Settings key for hash
|
|
149
|
+
* (default 'ExpectedModelHash')
|
|
150
|
+
* - IgnoreBasenames (optional) extra skip names; defaults
|
|
151
|
+
* include 'venvs' since venvs are
|
|
152
|
+
* platform-specific.
|
|
153
|
+
* - ModelKeyResolver (optional) function(modelDir) → string;
|
|
154
|
+
* default = libPath.basename(dir)
|
|
155
|
+
*/
|
|
156
|
+
registerModelCatalog(pConfig)
|
|
157
|
+
{
|
|
158
|
+
if (!pConfig || !pConfig.Name || !pConfig.RootPath
|
|
159
|
+
|| !pConfig.PushAction || !pConfig.FinalizeAction)
|
|
160
|
+
{
|
|
161
|
+
throw new Error('registerModelCatalog: Name, RootPath, PushAction, FinalizeAction required');
|
|
162
|
+
}
|
|
163
|
+
let tmpEntry = {
|
|
164
|
+
Name: pConfig.Name,
|
|
165
|
+
RootPath: pConfig.RootPath,
|
|
166
|
+
ManifestFilename: pConfig.ManifestFilename || 'model.json',
|
|
167
|
+
PushAction: pConfig.PushAction,
|
|
168
|
+
FinalizeAction: pConfig.FinalizeAction,
|
|
169
|
+
ExpectedHashKey: pConfig.ExpectedHashKey || 'ExpectedModelHash',
|
|
170
|
+
IgnoreBasenames: pConfig.IgnoreBasenames || new Set(['venvs']),
|
|
171
|
+
ModelKeyResolver: pConfig.ModelKeyResolver
|
|
172
|
+
|| ((pDir) => libPath.basename(pDir)),
|
|
173
|
+
ChunkBytes: pConfig.ChunkBytes
|
|
174
|
+
};
|
|
175
|
+
this._modelCatalogs.set(tmpEntry.Name, tmpEntry);
|
|
176
|
+
this.log.info(
|
|
177
|
+
`FleetManager: registered model catalog '${tmpEntry.Name}' at ${tmpEntry.RootPath}`);
|
|
178
|
+
// Invalidate cache.
|
|
179
|
+
this._availableModelsLastScan = 0;
|
|
180
|
+
return tmpEntry;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
listRegisteredRuntimes()
|
|
184
|
+
{
|
|
185
|
+
return Array.from(this._runtimes.values()).map(r => ({
|
|
186
|
+
Name: r.Name,
|
|
187
|
+
SourceDir: r.SourceDir,
|
|
188
|
+
AutoPushOnConnect: r.AutoPushOnConnect,
|
|
189
|
+
CapabilityFilter: Array.from(r.CapabilityFilter || [])
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
listRegisteredModelCatalogs()
|
|
194
|
+
{
|
|
195
|
+
return Array.from(this._modelCatalogs.values()).map(c => ({
|
|
196
|
+
Name: c.Name,
|
|
197
|
+
RootPath: c.RootPath
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Available-model catalog scan ─────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Walk every registered model catalog, find every directory with a
|
|
205
|
+
* matching manifest file, and produce the global available-models
|
|
206
|
+
* map keyed by ModelKey. Cached for `pCacheMs` ms (default 30s).
|
|
207
|
+
*/
|
|
208
|
+
scanAvailableModels(pCacheMs)
|
|
209
|
+
{
|
|
210
|
+
let tmpCacheMs = (pCacheMs == null) ? 30_000 : pCacheMs;
|
|
211
|
+
let tmpNow = Date.now();
|
|
212
|
+
if (this._availableModels.size > 0
|
|
213
|
+
&& (tmpNow - this._availableModelsLastScan) < tmpCacheMs)
|
|
214
|
+
{
|
|
215
|
+
return this._availableModels;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let tmpMap = new Map();
|
|
219
|
+
for (let tmpCatalog of this._modelCatalogs.values())
|
|
220
|
+
{
|
|
221
|
+
if (!libFs.existsSync(tmpCatalog.RootPath)) continue;
|
|
222
|
+
this._walkCatalog(tmpCatalog, tmpCatalog.RootPath, tmpMap);
|
|
223
|
+
}
|
|
224
|
+
this._availableModels = tmpMap;
|
|
225
|
+
this._availableModelsLastScan = tmpNow;
|
|
226
|
+
this.log.info(`FleetManager: scanned ${tmpMap.size} available model(s) across `
|
|
227
|
+
+ `${this._modelCatalogs.size} catalog(s).`);
|
|
228
|
+
return tmpMap;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
_walkCatalog(pCatalog, pDir, pInto)
|
|
232
|
+
{
|
|
233
|
+
let tmpEntries;
|
|
234
|
+
try { tmpEntries = libFs.readdirSync(pDir); }
|
|
235
|
+
catch (e) { return; }
|
|
236
|
+
|
|
237
|
+
// Stop recursing once we've found a manifest in this dir.
|
|
238
|
+
if (tmpEntries.indexOf(pCatalog.ManifestFilename) >= 0)
|
|
239
|
+
{
|
|
240
|
+
let tmpManifestPath = libPath.join(pDir, pCatalog.ManifestFilename);
|
|
241
|
+
let tmpManifest = null;
|
|
242
|
+
try { tmpManifest = JSON.parse(libFs.readFileSync(tmpManifestPath, 'utf8')); }
|
|
243
|
+
catch (e)
|
|
244
|
+
{
|
|
245
|
+
this.log.warn(
|
|
246
|
+
`FleetManager: failed to parse manifest ${tmpManifestPath}: ${e.message}`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
let tmpModelKey = pCatalog.ModelKeyResolver(pDir);
|
|
250
|
+
if (!tmpModelKey) return;
|
|
251
|
+
pInto.set(tmpModelKey, {
|
|
252
|
+
ModelKey: tmpModelKey,
|
|
253
|
+
ModelName: tmpManifest.Name || tmpModelKey,
|
|
254
|
+
DisplayName: tmpManifest.DisplayName || tmpManifest.Name || tmpModelKey,
|
|
255
|
+
ModelSourceDir: pDir,
|
|
256
|
+
ManifestFilename: pCatalog.ManifestFilename,
|
|
257
|
+
CatalogName: pCatalog.Name,
|
|
258
|
+
Manifest: tmpManifest,
|
|
259
|
+
PushAction: pCatalog.PushAction,
|
|
260
|
+
FinalizeAction: pCatalog.FinalizeAction,
|
|
261
|
+
ExpectedHashKey: pCatalog.ExpectedHashKey,
|
|
262
|
+
IgnoreBasenames: pCatalog.IgnoreBasenames,
|
|
263
|
+
ChunkBytes: pCatalog.ChunkBytes
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (let tmpEntry of tmpEntries)
|
|
269
|
+
{
|
|
270
|
+
if (tmpEntry.startsWith('.') || tmpEntry === 'node_modules'
|
|
271
|
+
|| tmpEntry === '__pycache__' || tmpEntry === 'venvs') continue;
|
|
272
|
+
let tmpFull = libPath.join(pDir, tmpEntry);
|
|
273
|
+
let tmpStat;
|
|
274
|
+
try { tmpStat = libFs.statSync(tmpFull); } catch (e) { continue; }
|
|
275
|
+
if (tmpStat.isDirectory())
|
|
276
|
+
{
|
|
277
|
+
this._walkCatalog(pCatalog, tmpFull, pInto);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
getAvailableModel(pModelKey)
|
|
283
|
+
{
|
|
284
|
+
this.scanAvailableModels();
|
|
285
|
+
return this._availableModels.get(pModelKey) || null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Beacon lifecycle hooks ───────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Call this from the BeaconCoordinator on registerBeacon().
|
|
292
|
+
*
|
|
293
|
+
* @param {string} pBeaconID
|
|
294
|
+
*/
|
|
295
|
+
onBeaconConnected(pBeaconID)
|
|
296
|
+
{
|
|
297
|
+
let tmpCoordinator = this._coordinator();
|
|
298
|
+
if (!tmpCoordinator) return;
|
|
299
|
+
let tmpBeacon = tmpCoordinator.getBeacon ? tmpCoordinator.getBeacon(pBeaconID) : null;
|
|
300
|
+
if (!tmpBeacon)
|
|
301
|
+
{
|
|
302
|
+
// Some coordinator implementations expose _Beacons; fall back.
|
|
303
|
+
tmpBeacon = (tmpCoordinator._Beacons || {})[pBeaconID] || null;
|
|
304
|
+
}
|
|
305
|
+
if (!tmpBeacon) return;
|
|
306
|
+
|
|
307
|
+
this.log.info(
|
|
308
|
+
`FleetManager: beacon connected ${tmpBeacon.Name || pBeaconID} `
|
|
309
|
+
+ `(caps: ${(tmpBeacon.Capabilities || []).join(', ') || 'none'})`);
|
|
310
|
+
|
|
311
|
+
// Fire-and-forget runtime auto-push for any matching registration.
|
|
312
|
+
// Each runtime push happens in its own promise chain so they don't
|
|
313
|
+
// block one another.
|
|
314
|
+
for (let tmpRuntime of this._runtimes.values())
|
|
315
|
+
{
|
|
316
|
+
if (!tmpRuntime.AutoPushOnConnect) continue;
|
|
317
|
+
if (!this._beaconMatchesCapabilityFilter(tmpBeacon, tmpRuntime.CapabilityFilter))
|
|
318
|
+
{
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
this._autoPushRuntimeToBeacon(tmpBeacon, tmpRuntime).catch((pErr) =>
|
|
322
|
+
{
|
|
323
|
+
this.log.warn(
|
|
324
|
+
`FleetManager: runtime auto-push '${tmpRuntime.Name}' → `
|
|
325
|
+
+ `${tmpBeacon.Name || pBeaconID} threw: ${pErr.message}`);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Auto-discover models the beacon already has via LWM_Inventory.
|
|
330
|
+
this._discoverInstalledModelsOnBeacon(tmpBeacon).catch((pErr) =>
|
|
331
|
+
{
|
|
332
|
+
this.log.warn(
|
|
333
|
+
`FleetManager: model auto-discovery on `
|
|
334
|
+
+ `${tmpBeacon.Name || pBeaconID} threw: ${pErr.message}`);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
_beaconMatchesCapabilityFilter(pBeacon, pFilter)
|
|
339
|
+
{
|
|
340
|
+
if (!pFilter || pFilter.size === 0) return true;
|
|
341
|
+
let tmpCaps = pBeacon.Capabilities || [];
|
|
342
|
+
for (let tmpCap of tmpCaps)
|
|
343
|
+
{
|
|
344
|
+
if (pFilter.has(tmpCap)) return true;
|
|
345
|
+
}
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async _autoPushRuntimeToBeacon(pBeacon, pRuntime)
|
|
350
|
+
{
|
|
351
|
+
let tmpStore = this._fleetStore();
|
|
352
|
+
let tmpDistributor = this._distributor();
|
|
353
|
+
if (!tmpStore || !tmpDistributor) return;
|
|
354
|
+
|
|
355
|
+
// Compute current source hash and check against last-known.
|
|
356
|
+
let tmpScan = tmpDistributor.scan(pRuntime.SourceDir,
|
|
357
|
+
{ IgnoreBasenames: pRuntime.IgnoreBasenames });
|
|
358
|
+
let tmpExisting = tmpStore.getRuntimeInstallation(pBeacon.BeaconID, pRuntime.Name);
|
|
359
|
+
if (tmpExisting && tmpExisting.InstalledRuntimeHash === tmpScan.Hash
|
|
360
|
+
&& tmpExisting.Status === 'installed')
|
|
361
|
+
{
|
|
362
|
+
this.log.info(
|
|
363
|
+
`FleetManager: runtime '${pRuntime.Name}' on ${pBeacon.Name} `
|
|
364
|
+
+ `already at hash ${tmpScan.Hash.slice(0, 12)}; skipping push.`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
tmpStore.upsertRuntimeInstallation({
|
|
369
|
+
BeaconID: pBeacon.BeaconID,
|
|
370
|
+
BeaconName: pBeacon.Name,
|
|
371
|
+
RuntimeName: pRuntime.Name,
|
|
372
|
+
ExpectedRuntimeHash: tmpScan.Hash,
|
|
373
|
+
Status: 'pushing'
|
|
374
|
+
});
|
|
375
|
+
this.log.info(
|
|
376
|
+
`FleetManager: pushing runtime '${pRuntime.Name}' to ${pBeacon.Name} `
|
|
377
|
+
+ `(${tmpScan.FileCount} files, ${tmpScan.TotalBytes} B, hash ${tmpScan.Hash.slice(0, 12)})`);
|
|
378
|
+
|
|
379
|
+
try
|
|
380
|
+
{
|
|
381
|
+
let tmpResult = await tmpDistributor.pushDirectoryToTarget(
|
|
382
|
+
{
|
|
383
|
+
SourceDir: pRuntime.SourceDir,
|
|
384
|
+
PushAction: pRuntime.PushAction,
|
|
385
|
+
FinalizeAction: pRuntime.FinalizeAction,
|
|
386
|
+
ExpectedHashKey: pRuntime.ExpectedHashKey,
|
|
387
|
+
IgnoreBasenames: pRuntime.IgnoreBasenames
|
|
388
|
+
},
|
|
389
|
+
this._buildDispatcher(pBeacon.BeaconID));
|
|
390
|
+
if (tmpResult.Status === 'Success')
|
|
391
|
+
{
|
|
392
|
+
tmpStore.updateRuntimeInstallationStatus(
|
|
393
|
+
pBeacon.BeaconID, pRuntime.Name, 'installed',
|
|
394
|
+
{
|
|
395
|
+
InstalledRuntimeHash: tmpResult.TreeHash,
|
|
396
|
+
InstalledAt: new Date().toISOString(),
|
|
397
|
+
LastError: null
|
|
398
|
+
});
|
|
399
|
+
this.log.info(
|
|
400
|
+
`FleetManager: runtime '${pRuntime.Name}' → ${pBeacon.Name} `
|
|
401
|
+
+ `installed (${tmpResult.FilesPushed} files, ${tmpResult.DurationMs}ms).`);
|
|
402
|
+
}
|
|
403
|
+
else
|
|
404
|
+
{
|
|
405
|
+
tmpStore.updateRuntimeInstallationStatus(
|
|
406
|
+
pBeacon.BeaconID, pRuntime.Name, 'error',
|
|
407
|
+
{ LastError: tmpResult.Error || 'unknown' });
|
|
408
|
+
this.log.warn(
|
|
409
|
+
`FleetManager: runtime '${pRuntime.Name}' → ${pBeacon.Name} `
|
|
410
|
+
+ `failed: ${tmpResult.Error}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch (pErr)
|
|
414
|
+
{
|
|
415
|
+
tmpStore.updateRuntimeInstallationStatus(
|
|
416
|
+
pBeacon.BeaconID, pRuntime.Name, 'error',
|
|
417
|
+
{ LastError: pErr.message });
|
|
418
|
+
throw pErr;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async _discoverInstalledModelsOnBeacon(pBeacon)
|
|
423
|
+
{
|
|
424
|
+
// Skip if the beacon doesn't advertise LabsWorkerManagement —
|
|
425
|
+
// only labs-worker beacons have an LWM_Inventory action.
|
|
426
|
+
if (!(pBeacon.Capabilities || []).includes('LabsWorkerManagement'))
|
|
427
|
+
{
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let tmpDispatcher = this._buildDispatcher(pBeacon.BeaconID);
|
|
432
|
+
let tmpResp;
|
|
433
|
+
try { tmpResp = await tmpDispatcher('LWM_Inventory', {}); }
|
|
434
|
+
catch (pErr)
|
|
435
|
+
{
|
|
436
|
+
this.log.warn(
|
|
437
|
+
`FleetManager: LWM_Inventory on ${pBeacon.Name} failed: ${pErr.message}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let tmpOutputs = (tmpResp && tmpResp.Outputs) || {};
|
|
442
|
+
let tmpModels = tmpOutputs.Models || [];
|
|
443
|
+
if (!Array.isArray(tmpModels) || tmpModels.length === 0) return;
|
|
444
|
+
|
|
445
|
+
let tmpStore = this._fleetStore();
|
|
446
|
+
if (!tmpStore) return;
|
|
447
|
+
|
|
448
|
+
for (let tmpModel of tmpModels)
|
|
449
|
+
{
|
|
450
|
+
let tmpKey = libPath.basename(tmpModel.ModelPath || '');
|
|
451
|
+
if (!tmpKey) continue;
|
|
452
|
+
let tmpExisting = tmpStore.getModelInstallation(pBeacon.BeaconID, tmpKey);
|
|
453
|
+
if (tmpExisting) continue; // operator-managed; don't clobber
|
|
454
|
+
tmpStore.upsertModelInstallation({
|
|
455
|
+
BeaconID: pBeacon.BeaconID,
|
|
456
|
+
BeaconName: pBeacon.Name,
|
|
457
|
+
ModelKey: tmpKey,
|
|
458
|
+
ModelName: tmpModel.DisplayName || tmpModel.Name || tmpKey,
|
|
459
|
+
ModelSourceDir: tmpModel.ModelPath || '',
|
|
460
|
+
Status: 'installed',
|
|
461
|
+
EnabledForDispatch: true, // discovered models are enabled by default
|
|
462
|
+
InstalledBytes: tmpModel.BytesOnDisk || 0,
|
|
463
|
+
InstalledTreeHash: '', // unknown — never pushed via fleet
|
|
464
|
+
InstalledAt: new Date().toISOString(),
|
|
465
|
+
Source: 'discovered'
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
this.log.info(
|
|
469
|
+
`FleetManager: discovered ${tmpModels.length} pre-existing model(s) on ${pBeacon.Name}.`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Operator actions ─────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Install a model on a beacon.
|
|
476
|
+
*
|
|
477
|
+
* @param {string} pBeaconID
|
|
478
|
+
* @param {string} pModelKey
|
|
479
|
+
* @param {object} [pOptions]
|
|
480
|
+
* - EnableAfterInstall bool, default false (separate operator step)
|
|
481
|
+
* @returns Promise<installation row>
|
|
482
|
+
*/
|
|
483
|
+
async installModel(pBeaconID, pModelKey, pOptions)
|
|
484
|
+
{
|
|
485
|
+
let tmpOptions = pOptions || {};
|
|
486
|
+
let tmpStore = this._fleetStore();
|
|
487
|
+
let tmpCoordinator = this._coordinator();
|
|
488
|
+
let tmpDistributor = this._distributor();
|
|
489
|
+
if (!tmpStore || !tmpCoordinator || !tmpDistributor)
|
|
490
|
+
{
|
|
491
|
+
throw new Error('installModel: required services not available');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let tmpBeacon = (tmpCoordinator._Beacons || {})[pBeaconID];
|
|
495
|
+
if (!tmpBeacon)
|
|
496
|
+
{
|
|
497
|
+
throw new Error(`installModel: beacon '${pBeaconID}' not registered`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
let tmpAvail = this.getAvailableModel(pModelKey);
|
|
501
|
+
if (!tmpAvail)
|
|
502
|
+
{
|
|
503
|
+
throw new Error(`installModel: model '${pModelKey}' not in any registered catalog`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Preflight scan to capture totals.
|
|
507
|
+
let tmpScan = tmpDistributor.scan(tmpAvail.ModelSourceDir,
|
|
508
|
+
{ IgnoreBasenames: tmpAvail.IgnoreBasenames });
|
|
509
|
+
|
|
510
|
+
tmpStore.upsertModelInstallation({
|
|
511
|
+
BeaconID: pBeaconID,
|
|
512
|
+
BeaconName: tmpBeacon.Name,
|
|
513
|
+
ModelKey: pModelKey,
|
|
514
|
+
ModelName: tmpAvail.ModelName,
|
|
515
|
+
ModelSourceDir: tmpAvail.ModelSourceDir,
|
|
516
|
+
ExpectedTreeHash: tmpScan.Hash,
|
|
517
|
+
PushTotalBytes: tmpScan.TotalBytes,
|
|
518
|
+
PushProgressBytes: 0,
|
|
519
|
+
Status: 'installing',
|
|
520
|
+
Source: 'operator'
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
this.log.info(
|
|
524
|
+
`FleetManager: installing '${pModelKey}' (${tmpScan.FileCount} files, `
|
|
525
|
+
+ `${tmpScan.TotalBytes} B) on ${tmpBeacon.Name}.`);
|
|
526
|
+
|
|
527
|
+
try
|
|
528
|
+
{
|
|
529
|
+
let tmpResult = await tmpDistributor.pushDirectoryToTarget(
|
|
530
|
+
{
|
|
531
|
+
SourceDir: tmpAvail.ModelSourceDir,
|
|
532
|
+
PushAction: tmpAvail.PushAction,
|
|
533
|
+
FinalizeAction: tmpAvail.FinalizeAction,
|
|
534
|
+
ExpectedHashKey: tmpAvail.ExpectedHashKey,
|
|
535
|
+
DestPathPrefix: pModelKey,
|
|
536
|
+
IgnoreBasenames: tmpAvail.IgnoreBasenames,
|
|
537
|
+
FinalizeExtras: { ModelKey: pModelKey, ModelName: tmpAvail.ModelName }
|
|
538
|
+
},
|
|
539
|
+
this._buildDispatcher(pBeaconID),
|
|
540
|
+
(pProg) =>
|
|
541
|
+
{
|
|
542
|
+
// Persist progress periodically (cheap; better-sqlite3
|
|
543
|
+
// is sync; OK to write per-chunk).
|
|
544
|
+
tmpStore.upsertModelInstallation({
|
|
545
|
+
BeaconID: pBeaconID,
|
|
546
|
+
ModelKey: pModelKey,
|
|
547
|
+
PushProgressBytes: pProg.BytesPushed
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (tmpResult.Status === 'Success')
|
|
552
|
+
{
|
|
553
|
+
let tmpUpdates = {
|
|
554
|
+
InstalledTreeHash: tmpResult.TreeHash,
|
|
555
|
+
InstalledBytes: tmpResult.BytesPushed,
|
|
556
|
+
PushProgressBytes: tmpResult.BytesPushed,
|
|
557
|
+
InstalledAt: new Date().toISOString(),
|
|
558
|
+
LastError: null
|
|
559
|
+
};
|
|
560
|
+
if (tmpOptions.EnableAfterInstall)
|
|
561
|
+
{
|
|
562
|
+
tmpUpdates.EnabledForDispatch = true;
|
|
563
|
+
}
|
|
564
|
+
tmpStore.updateModelInstallationStatus(
|
|
565
|
+
pBeaconID, pModelKey, 'installed', tmpUpdates);
|
|
566
|
+
this.log.info(
|
|
567
|
+
`FleetManager: '${pModelKey}' installed on ${tmpBeacon.Name} `
|
|
568
|
+
+ `(${tmpResult.DurationMs}ms).`);
|
|
569
|
+
}
|
|
570
|
+
else
|
|
571
|
+
{
|
|
572
|
+
tmpStore.updateModelInstallationStatus(
|
|
573
|
+
pBeaconID, pModelKey, 'error',
|
|
574
|
+
{ LastError: tmpResult.Error || 'unknown' });
|
|
575
|
+
}
|
|
576
|
+
return tmpStore.getModelInstallation(pBeaconID, pModelKey);
|
|
577
|
+
}
|
|
578
|
+
catch (pErr)
|
|
579
|
+
{
|
|
580
|
+
tmpStore.updateModelInstallationStatus(
|
|
581
|
+
pBeaconID, pModelKey, 'error',
|
|
582
|
+
{ LastError: pErr.message });
|
|
583
|
+
throw pErr;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async uninstallModel(pBeaconID, pModelKey)
|
|
588
|
+
{
|
|
589
|
+
let tmpStore = this._fleetStore();
|
|
590
|
+
let tmpCoordinator = this._coordinator();
|
|
591
|
+
if (!tmpStore || !tmpCoordinator)
|
|
592
|
+
{
|
|
593
|
+
throw new Error('uninstallModel: required services not available');
|
|
594
|
+
}
|
|
595
|
+
tmpStore.updateModelInstallationStatus(pBeaconID, pModelKey, 'uninstalling');
|
|
596
|
+
try
|
|
597
|
+
{
|
|
598
|
+
let tmpDispatcher = this._buildDispatcher(pBeaconID);
|
|
599
|
+
let tmpResp = await tmpDispatcher('LWM_DeleteModel',
|
|
600
|
+
{ ModelKey: pModelKey });
|
|
601
|
+
let tmpOk = !tmpResp || !tmpResp.Outputs || !tmpResp.Outputs.ExitCode
|
|
602
|
+
|| tmpResp.Outputs.Status === 'Success';
|
|
603
|
+
if (tmpOk)
|
|
604
|
+
{
|
|
605
|
+
tmpStore.deleteModelInstallation(pBeaconID, pModelKey);
|
|
606
|
+
this.log.info(
|
|
607
|
+
`FleetManager: '${pModelKey}' uninstalled from beacon ${pBeaconID}.`);
|
|
608
|
+
return { Status: 'Success' };
|
|
609
|
+
}
|
|
610
|
+
let tmpErr = (tmpResp && tmpResp.Outputs && tmpResp.Outputs.Error) || 'unknown';
|
|
611
|
+
tmpStore.updateModelInstallationStatus(pBeaconID, pModelKey, 'error',
|
|
612
|
+
{ LastError: tmpErr });
|
|
613
|
+
return { Status: 'Error', Error: tmpErr };
|
|
614
|
+
}
|
|
615
|
+
catch (pErr)
|
|
616
|
+
{
|
|
617
|
+
tmpStore.updateModelInstallationStatus(pBeaconID, pModelKey, 'error',
|
|
618
|
+
{ LastError: pErr.message });
|
|
619
|
+
throw pErr;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
enableModel(pBeaconID, pModelKey)
|
|
624
|
+
{
|
|
625
|
+
let tmpStore = this._fleetStore();
|
|
626
|
+
if (!tmpStore) throw new Error('enableModel: FleetStore unavailable');
|
|
627
|
+
this.log.info(`FleetManager: enabling '${pModelKey}' on beacon ${pBeaconID}.`);
|
|
628
|
+
return tmpStore.setModelEnabled(pBeaconID, pModelKey, true);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
disableModel(pBeaconID, pModelKey)
|
|
632
|
+
{
|
|
633
|
+
let tmpStore = this._fleetStore();
|
|
634
|
+
if (!tmpStore) throw new Error('disableModel: FleetStore unavailable');
|
|
635
|
+
this.log.info(`FleetManager: disabling '${pModelKey}' on beacon ${pBeaconID}.`);
|
|
636
|
+
return tmpStore.setModelEnabled(pBeaconID, pModelKey, false);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Read API for the fleet UI ───────────────────────────────
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Returns the full beacons × models grid the operator UI consumes.
|
|
643
|
+
*/
|
|
644
|
+
getFleetSnapshot()
|
|
645
|
+
{
|
|
646
|
+
let tmpStore = this._fleetStore();
|
|
647
|
+
let tmpCoordinator = this._coordinator();
|
|
648
|
+
this.scanAvailableModels();
|
|
649
|
+
|
|
650
|
+
let tmpBeacons = [];
|
|
651
|
+
if (tmpCoordinator && tmpCoordinator._Beacons)
|
|
652
|
+
{
|
|
653
|
+
for (let tmpBcn of Object.values(tmpCoordinator._Beacons))
|
|
654
|
+
{
|
|
655
|
+
tmpBeacons.push({
|
|
656
|
+
BeaconID: tmpBcn.BeaconID,
|
|
657
|
+
Name: tmpBcn.Name,
|
|
658
|
+
Status: tmpBcn.Status,
|
|
659
|
+
LastHeartbeat: tmpBcn.LastHeartbeat,
|
|
660
|
+
Capabilities: tmpBcn.Capabilities || [],
|
|
661
|
+
HostID: tmpBcn.HostID
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let tmpAvailableModels = [];
|
|
667
|
+
for (let tmpM of this._availableModels.values())
|
|
668
|
+
{
|
|
669
|
+
tmpAvailableModels.push({
|
|
670
|
+
ModelKey: tmpM.ModelKey,
|
|
671
|
+
ModelName: tmpM.ModelName,
|
|
672
|
+
DisplayName: tmpM.DisplayName,
|
|
673
|
+
CatalogName: tmpM.CatalogName,
|
|
674
|
+
ModelSourceDir: tmpM.ModelSourceDir
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let tmpInstallations = tmpStore ? tmpStore.listModelInstallations() : [];
|
|
679
|
+
let tmpRuntimes = tmpStore ? tmpStore.listRuntimeInstallations() : [];
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
Beacons: tmpBeacons,
|
|
683
|
+
AvailableModels: tmpAvailableModels,
|
|
684
|
+
ModelInstallations: tmpInstallations,
|
|
685
|
+
RuntimeInstallations: tmpRuntimes,
|
|
686
|
+
RegisteredRuntimes: this.listRegisteredRuntimes(),
|
|
687
|
+
RegisteredCatalogs: this.listRegisteredModelCatalogs()
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── Dispatch filter ──────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Called by the BeaconCoordinator's pollForWork to gate work-item
|
|
695
|
+
* routing on the (BeaconID, model) installation state.
|
|
696
|
+
*
|
|
697
|
+
* Returns an OBJECT for explainability:
|
|
698
|
+
* { Allowed: true|false, Reason: string|null, MatchedModelKey: string|null }
|
|
699
|
+
*
|
|
700
|
+
* Logic:
|
|
701
|
+
* - System actions (no model-bound Capability): always allowed
|
|
702
|
+
* - Settings.model_path or Settings.ModelKey identifies a model:
|
|
703
|
+
* gate on isModelEnabledOn(beaconID, modelKey)
|
|
704
|
+
* - No model identifiable + model-bound capability: allowed
|
|
705
|
+
* (best-effort; the worker will fail at dispatch if it doesn't
|
|
706
|
+
* have the model)
|
|
707
|
+
*/
|
|
708
|
+
checkDispatchAllowed(pBeaconID, pWorkItem)
|
|
709
|
+
{
|
|
710
|
+
// LabsWorkerManagement actions are always allowed — they're how
|
|
711
|
+
// the hub talks to the worker (push, finalize, inventory).
|
|
712
|
+
if (pWorkItem && pWorkItem.Capability === 'LabsWorkerManagement')
|
|
713
|
+
{
|
|
714
|
+
return { Allowed: true, Reason: null, MatchedModelKey: null };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Make sure the available-models cache is hot before extraction
|
|
718
|
+
// (otherwise the prefix match against ModelSourceDir comes up empty).
|
|
719
|
+
this.scanAvailableModels();
|
|
720
|
+
|
|
721
|
+
let tmpModelKey = this._extractModelKey(pWorkItem);
|
|
722
|
+
if (this.log)
|
|
723
|
+
{
|
|
724
|
+
let tmpModelPath = (pWorkItem && pWorkItem.Settings && (pWorkItem.Settings.model_path || pWorkItem.Settings.ModelPath || pWorkItem.Settings.weights_path)) || '';
|
|
725
|
+
this.log.info(
|
|
726
|
+
`FleetManager: checkDispatchAllowed beacon=${pBeaconID} `
|
|
727
|
+
+ `cap=${pWorkItem && pWorkItem.Capability} modelKey=${tmpModelKey || '(none)'} `
|
|
728
|
+
+ `model_path=${tmpModelPath.slice(0, 80)}`);
|
|
729
|
+
}
|
|
730
|
+
if (!tmpModelKey)
|
|
731
|
+
{
|
|
732
|
+
return { Allowed: true, Reason: null, MatchedModelKey: null };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
let tmpStore = this._fleetStore();
|
|
736
|
+
if (!tmpStore || !tmpStore.isEnabled())
|
|
737
|
+
{
|
|
738
|
+
// Fleet store offline — fall open. The dispatch will work
|
|
739
|
+
// or fail on the worker side as it always has.
|
|
740
|
+
return { Allowed: true, Reason: 'fleet-store-offline', MatchedModelKey: tmpModelKey };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
let tmpEnabled = tmpStore.isModelEnabledOn(pBeaconID, tmpModelKey);
|
|
744
|
+
if (tmpEnabled)
|
|
745
|
+
{
|
|
746
|
+
return { Allowed: true, Reason: null, MatchedModelKey: tmpModelKey };
|
|
747
|
+
}
|
|
748
|
+
let tmpInst = tmpStore.getModelInstallation(pBeaconID, tmpModelKey);
|
|
749
|
+
let tmpReason;
|
|
750
|
+
if (!tmpInst) tmpReason = 'not-installed';
|
|
751
|
+
else if (tmpInst.Status !== 'installed') tmpReason = `status=${tmpInst.Status}`;
|
|
752
|
+
else tmpReason = 'disabled';
|
|
753
|
+
return { Allowed: false, Reason: tmpReason, MatchedModelKey: tmpModelKey };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
_extractModelKey(pWorkItem)
|
|
757
|
+
{
|
|
758
|
+
if (!pWorkItem) return null;
|
|
759
|
+
let tmpSettings = pWorkItem.Settings || {};
|
|
760
|
+
// Explicit ModelKey wins.
|
|
761
|
+
if (tmpSettings.ModelKey) return tmpSettings.ModelKey;
|
|
762
|
+
if (tmpSettings.model_key) return tmpSettings.model_key;
|
|
763
|
+
// Otherwise look up by ModelSourceDir prefix-match against installed
|
|
764
|
+
// models OR by basename heuristic on model_path.
|
|
765
|
+
let tmpModelPath = tmpSettings.model_path
|
|
766
|
+
|| tmpSettings.ModelPath
|
|
767
|
+
|| tmpSettings.weights_path;
|
|
768
|
+
if (!tmpModelPath) return null;
|
|
769
|
+
// Try to match against any registered model's source dir.
|
|
770
|
+
for (let tmpAvail of (this._availableModels || new Map()).values())
|
|
771
|
+
{
|
|
772
|
+
if (tmpModelPath.indexOf(tmpAvail.ModelSourceDir) === 0)
|
|
773
|
+
{
|
|
774
|
+
return tmpAvail.ModelKey;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// Heuristic fallback: the segment immediately under the catalog
|
|
778
|
+
// root. Walk catalogs.
|
|
779
|
+
for (let tmpCatalog of this._modelCatalogs.values())
|
|
780
|
+
{
|
|
781
|
+
let tmpRoot = tmpCatalog.RootPath;
|
|
782
|
+
if (tmpModelPath.indexOf(tmpRoot) === 0)
|
|
783
|
+
{
|
|
784
|
+
let tmpRel = tmpModelPath.substring(tmpRoot.length).replace(/^[\/\\]+/, '');
|
|
785
|
+
let tmpFirst = tmpRel.split(/[\/\\]/)[0];
|
|
786
|
+
let tmpSecond = tmpRel.split(/[\/\\]/)[1];
|
|
787
|
+
// Catalog roots are commonly category dirs (e.g.
|
|
788
|
+
// 'video-pipeline/wan22-i2v-14b-diffusers/...'); the model
|
|
789
|
+
// dir is at depth 1 under root. If the catalog uses
|
|
790
|
+
// flat layout, use depth-0.
|
|
791
|
+
return tmpSecond || tmpFirst || null;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ── Dispatcher composition ──────────────────────────────────
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Build a `pDispatch(actionName, settings) -> Promise<response>`
|
|
801
|
+
* function bound to a specific beacon.
|
|
802
|
+
*
|
|
803
|
+
* Routing trick: we pre-create an affinity binding (`fleet-push-<beaconID>`)
|
|
804
|
+
* so the FIRST chunk + every subsequent chunk + the finalize all
|
|
805
|
+
* route to the intended beacon, never to some other beacon that
|
|
806
|
+
* happens to advertise LabsWorkerManagement. The coordinator's
|
|
807
|
+
* enqueueWorkItem (line ~1014) checks `_AffinityBindings` before
|
|
808
|
+
* normal capability-match routing, so a pre-existing binding wins.
|
|
809
|
+
*/
|
|
810
|
+
_buildDispatcher(pBeaconID)
|
|
811
|
+
{
|
|
812
|
+
let tmpCoordinator = this._coordinator();
|
|
813
|
+
let tmpAffinityKey = `fleet-push-${pBeaconID}`;
|
|
814
|
+
|
|
815
|
+
if (tmpCoordinator && tmpCoordinator._AffinityBindings)
|
|
816
|
+
{
|
|
817
|
+
let tmpExpiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
|
818
|
+
tmpCoordinator._AffinityBindings[tmpAffinityKey] =
|
|
819
|
+
{
|
|
820
|
+
AffinityKey: tmpAffinityKey,
|
|
821
|
+
BeaconID: pBeaconID,
|
|
822
|
+
ExpiresAt: tmpExpiresAt,
|
|
823
|
+
CreatedAt: new Date().toISOString()
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return (pAction, pSettings) =>
|
|
828
|
+
{
|
|
829
|
+
return new Promise((resolve, reject) =>
|
|
830
|
+
{
|
|
831
|
+
if (!tmpCoordinator || typeof tmpCoordinator.dispatchAndWait !== 'function')
|
|
832
|
+
{
|
|
833
|
+
return reject(new Error('FleetManager: coordinator.dispatchAndWait unavailable'));
|
|
834
|
+
}
|
|
835
|
+
tmpCoordinator.dispatchAndWait(
|
|
836
|
+
{
|
|
837
|
+
Capability: 'LabsWorkerManagement',
|
|
838
|
+
Action: pAction,
|
|
839
|
+
Settings: pSettings,
|
|
840
|
+
AffinityKey: tmpAffinityKey,
|
|
841
|
+
TimeoutMs: 600_000 // 10 min per chunk dispatch ceiling
|
|
842
|
+
},
|
|
843
|
+
(pErr, pResult) =>
|
|
844
|
+
{
|
|
845
|
+
if (pErr) return reject(pErr);
|
|
846
|
+
resolve(pResult || {});
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
module.exports = UltravisorFleetManager;
|