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.
@@ -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;