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
package/package.json
CHANGED
|
@@ -18,6 +18,9 @@ const libServiceExecutionManifest = require('../services/Ultravisor-ExecutionMan
|
|
|
18
18
|
const libServiceBeaconCoordinator = require('../services/Ultravisor-Beacon-Coordinator.cjs');
|
|
19
19
|
const libServiceBeaconReachability = require('../services/Ultravisor-Beacon-Reachability.cjs');
|
|
20
20
|
const libServiceBeaconQueueJournal = require('../services/persistence/Ultravisor-Beacon-QueueJournal.cjs');
|
|
21
|
+
const libServiceBeaconFleetStore = require('../services/persistence/Ultravisor-Beacon-FleetStore.cjs');
|
|
22
|
+
const libServiceDirectoryDistributor = require('../services/Ultravisor-DirectoryDistributor.cjs');
|
|
23
|
+
const libServiceFleetManager = require('../services/Ultravisor-FleetManager.cjs');
|
|
21
24
|
|
|
22
25
|
// TODO: Remove this when Restify is fixed.
|
|
23
26
|
process.removeAllListeners('warning')
|
|
@@ -215,6 +218,21 @@ if (tmpCoordinator)
|
|
|
215
218
|
tmpCoordinator.loadActionCatalog();
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
// --- Fleet management (per-(beacon, model) install/enable state) ---
|
|
222
|
+
_Ultravisor_Pict.fable.addAndInstantiateServiceTypeIfNotExists(
|
|
223
|
+
'UltravisorBeaconFleetStore', libServiceBeaconFleetStore);
|
|
224
|
+
let tmpFleetStore = Object.values(_Ultravisor_Pict.fable.servicesMap['UltravisorBeaconFleetStore'])[0];
|
|
225
|
+
if (tmpFleetStore)
|
|
226
|
+
{
|
|
227
|
+
tmpFleetStore.initialize(_Ultravisor_Pict.fable.settings.UltravisorFileStorePath);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_Ultravisor_Pict.fable.addAndInstantiateServiceTypeIfNotExists(
|
|
231
|
+
'UltravisorDirectoryDistributor', libServiceDirectoryDistributor);
|
|
232
|
+
|
|
233
|
+
_Ultravisor_Pict.fable.addAndInstantiateServiceTypeIfNotExists(
|
|
234
|
+
'UltravisorFleetManager', libServiceFleetManager);
|
|
235
|
+
|
|
218
236
|
_Ultravisor_Pict.fable.addAndInstantiateServiceTypeIfNotExists('UltravisorAPIServer', libWebServerAPIServer);
|
|
219
237
|
|
|
220
238
|
// ── Service name aliases ────────────────────────────────
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Tables":
|
|
3
|
+
{
|
|
4
|
+
"BeaconModelInstallation":
|
|
5
|
+
{
|
|
6
|
+
"TableName": "BeaconModelInstallation",
|
|
7
|
+
"Domain": "Default",
|
|
8
|
+
"Description": "Per-(beacon, model) installation state. Authoritative source of truth for which beacons may be dispatched a given model. Status tracks install lifecycle; EnabledForDispatch is a separate operator toggle.",
|
|
9
|
+
"Columns":
|
|
10
|
+
[
|
|
11
|
+
{ "Column": "IDBeaconModelInstallation", "DataType": "ID" },
|
|
12
|
+
{ "Column": "GUIDBeaconModelInstallation", "DataType": "GUID", "Size": "36" },
|
|
13
|
+
{ "Column": "CreateDate", "DataType": "DateTime" },
|
|
14
|
+
{ "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
|
|
15
|
+
{ "Column": "UpdateDate", "DataType": "DateTime" },
|
|
16
|
+
{ "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
|
|
17
|
+
{ "Column": "Deleted", "DataType": "Boolean" },
|
|
18
|
+
{ "Column": "DeleteDate", "DataType": "DateTime" },
|
|
19
|
+
{ "Column": "DeletingIDUser", "DataType": "Numeric", "Size": "int" },
|
|
20
|
+
{ "Column": "BeaconID", "DataType": "String", "Size": "128" },
|
|
21
|
+
{ "Column": "BeaconName", "DataType": "String", "Size": "256" },
|
|
22
|
+
{ "Column": "ModelKey", "DataType": "String", "Size": "256" },
|
|
23
|
+
{ "Column": "ModelName", "DataType": "String", "Size": "256" },
|
|
24
|
+
{ "Column": "ModelSourceDir", "DataType": "String", "Size": "1024" },
|
|
25
|
+
{ "Column": "ExpectedTreeHash", "DataType": "String", "Size": "128" },
|
|
26
|
+
{ "Column": "InstalledTreeHash", "DataType": "String", "Size": "128" },
|
|
27
|
+
{ "Column": "InstalledBytes", "DataType": "Numeric", "Size": "bigint" },
|
|
28
|
+
{ "Column": "Status", "DataType": "String", "Size": "32" },
|
|
29
|
+
{ "Column": "EnabledForDispatch", "DataType": "Boolean" },
|
|
30
|
+
{ "Column": "PushProgressBytes", "DataType": "Numeric", "Size": "bigint" },
|
|
31
|
+
{ "Column": "PushTotalBytes", "DataType": "Numeric", "Size": "bigint" },
|
|
32
|
+
{ "Column": "LastError", "DataType": "String", "Size": "4096" },
|
|
33
|
+
{ "Column": "InstalledAt", "DataType": "DateTime" },
|
|
34
|
+
{ "Column": "LastUpdatedAt", "DataType": "DateTime" },
|
|
35
|
+
{ "Column": "Source", "DataType": "String", "Size": "32" }
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"BeaconRuntimeInstallation":
|
|
39
|
+
{
|
|
40
|
+
"TableName": "BeaconRuntimeInstallation",
|
|
41
|
+
"Domain": "Default",
|
|
42
|
+
"Description": "Per-(beacon, runtime) state. A runtime is a named directory of pipeline-worker code (e.g. retold-labs' examples/pipeline-workers). Auto-pushed on beacon connect. Tracks last-known runtime hash so the hub can short-circuit pushes when the worker is already up to date.",
|
|
43
|
+
"Columns":
|
|
44
|
+
[
|
|
45
|
+
{ "Column": "IDBeaconRuntimeInstallation", "DataType": "ID" },
|
|
46
|
+
{ "Column": "GUIDBeaconRuntimeInstallation", "DataType": "GUID", "Size": "36" },
|
|
47
|
+
{ "Column": "CreateDate", "DataType": "DateTime" },
|
|
48
|
+
{ "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
|
|
49
|
+
{ "Column": "UpdateDate", "DataType": "DateTime" },
|
|
50
|
+
{ "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
|
|
51
|
+
{ "Column": "Deleted", "DataType": "Boolean" },
|
|
52
|
+
{ "Column": "DeleteDate", "DataType": "DateTime" },
|
|
53
|
+
{ "Column": "DeletingIDUser", "DataType": "Numeric", "Size": "int" },
|
|
54
|
+
{ "Column": "BeaconID", "DataType": "String", "Size": "128" },
|
|
55
|
+
{ "Column": "BeaconName", "DataType": "String", "Size": "256" },
|
|
56
|
+
{ "Column": "RuntimeName", "DataType": "String", "Size": "128" },
|
|
57
|
+
{ "Column": "ExpectedRuntimeHash", "DataType": "String", "Size": "128" },
|
|
58
|
+
{ "Column": "InstalledRuntimeHash", "DataType": "String", "Size": "128" },
|
|
59
|
+
{ "Column": "Status", "DataType": "String", "Size": "32" },
|
|
60
|
+
{ "Column": "LastError", "DataType": "String", "Size": "4096" },
|
|
61
|
+
{ "Column": "InstalledAt", "DataType": "DateTime" },
|
|
62
|
+
{ "Column": "LastUpdatedAt", "DataType": "DateTime" }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -242,9 +242,19 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
242
242
|
{
|
|
243
243
|
let tmpName = pBeaconInfo.Name || 'unnamed';
|
|
244
244
|
|
|
245
|
-
// Check for an existing
|
|
245
|
+
// Check for an existing beacon with the same name to reclaim.
|
|
246
|
+
// Status='Offline' is the original reconnect-after-disconnect case.
|
|
247
|
+
// Status='Online' is the post-enable refreshRegistration case
|
|
248
|
+
// (SDK pushed updated capabilities without disconnecting first).
|
|
249
|
+
// Either way, same name → same beacon — update in place rather
|
|
250
|
+
// than creating a duplicate record (which would orphan the
|
|
251
|
+
// FleetStore installation rows keyed on the old BeaconID and
|
|
252
|
+
// confuse the dispatch filter).
|
|
246
253
|
let tmpExistingBeacon = this.findBeaconByName(tmpName);
|
|
247
|
-
if (tmpExistingBeacon
|
|
254
|
+
if (tmpExistingBeacon
|
|
255
|
+
&& (tmpExistingBeacon.Status === 'Offline'
|
|
256
|
+
|| tmpExistingBeacon.Status === 'Online'
|
|
257
|
+
|| tmpExistingBeacon.Status === 'Busy'))
|
|
248
258
|
{
|
|
249
259
|
tmpExistingBeacon.SessionID = pSessionID || null;
|
|
250
260
|
tmpExistingBeacon.LastHeartbeat = new Date().toISOString();
|
|
@@ -352,6 +362,37 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
352
362
|
tmpReachability.onBeaconRegistered(tmpBeaconID);
|
|
353
363
|
}
|
|
354
364
|
|
|
365
|
+
// Notify the fleet manager so it can:
|
|
366
|
+
// - auto-push any registered runtimes whose AutoPushOnConnect
|
|
367
|
+
// is true and whose hash on the worker is stale
|
|
368
|
+
// - auto-discover models the worker reports via LWM_Inventory
|
|
369
|
+
// and import them into the fleet table at Source='discovered',
|
|
370
|
+
// EnabledForDispatch=true (so existing dispatches keep working
|
|
371
|
+
// without operator intervention)
|
|
372
|
+
//
|
|
373
|
+
// Critical: defer to the next tick. The WebSocket upgrade handler
|
|
374
|
+
// (which sets `_WorkItemPushHandler`) runs in the SAME tick as
|
|
375
|
+
// `/Beacon/Register`. If we kick off LWM_Inventory or runtime
|
|
376
|
+
// pushes synchronously here, those work items get pre-assigned
|
|
377
|
+
// (via the fleet-push affinity) BUT the WS handler isn't wired
|
|
378
|
+
// yet, so they never deliver — they just fill the beacon's
|
|
379
|
+
// CurrentWorkItems slot and starve every subsequent dispatch.
|
|
380
|
+
// setImmediate buys us "after the WS upgrade settles" without
|
|
381
|
+
// adding a real timer.
|
|
382
|
+
let tmpFleet = this._getService('UltravisorFleetManager');
|
|
383
|
+
if (tmpFleet && typeof tmpFleet.onBeaconConnected === 'function')
|
|
384
|
+
{
|
|
385
|
+
setImmediate(() =>
|
|
386
|
+
{
|
|
387
|
+
try { tmpFleet.onBeaconConnected(tmpBeaconID); }
|
|
388
|
+
catch (pErr)
|
|
389
|
+
{
|
|
390
|
+
this.log.warn(
|
|
391
|
+
`BeaconCoordinator: FleetManager.onBeaconConnected threw: ${pErr.message}`);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
355
396
|
return tmpBeacon;
|
|
356
397
|
}
|
|
357
398
|
|
|
@@ -1171,6 +1212,31 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1171
1212
|
continue;
|
|
1172
1213
|
}
|
|
1173
1214
|
|
|
1215
|
+
// Fleet-manager dispatch filter (same logic as pollForWork's
|
|
1216
|
+
// pending-pass). System actions / unknown-model dispatches
|
|
1217
|
+
// fall through; per-(beacon, model) gating only applies when
|
|
1218
|
+
// the work item targets a known-installed model.
|
|
1219
|
+
let tmpFleetWS = this._getService('UltravisorFleetManager');
|
|
1220
|
+
if (tmpFleetWS && typeof tmpFleetWS.checkDispatchAllowed === 'function')
|
|
1221
|
+
{
|
|
1222
|
+
let tmpAllowWS;
|
|
1223
|
+
try { tmpAllowWS = tmpFleetWS.checkDispatchAllowed(tmpBeacon.BeaconID, pWorkItem); }
|
|
1224
|
+
catch (pErr)
|
|
1225
|
+
{
|
|
1226
|
+
this.log.warn(
|
|
1227
|
+
`BeaconCoordinator: WS-push fleet filter threw: ${pErr.message} — allowing`);
|
|
1228
|
+
tmpAllowWS = { Allowed: true };
|
|
1229
|
+
}
|
|
1230
|
+
if (tmpAllowWS && tmpAllowWS.Allowed === false)
|
|
1231
|
+
{
|
|
1232
|
+
this.log.info(
|
|
1233
|
+
`BeaconCoordinator: WS-push fleet filter denied beacon [${tmpBeacon.BeaconID}] `
|
|
1234
|
+
+ `for [${pWorkItem.Capability}/${pWorkItem.Action}] `
|
|
1235
|
+
+ `(model=${tmpAllowWS.MatchedModelKey}, reason=${tmpAllowWS.Reason}).`);
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1174
1240
|
// Assign the work item to this beacon
|
|
1175
1241
|
pWorkItem.Status = 'Running';
|
|
1176
1242
|
pWorkItem.AssignedBeaconID = tmpBeacon.BeaconID;
|
|
@@ -1341,6 +1407,35 @@ class UltravisorBeaconCoordinator extends libPictService
|
|
|
1341
1407
|
continue;
|
|
1342
1408
|
}
|
|
1343
1409
|
|
|
1410
|
+
// Fleet-manager dispatch filter: gate on per-(beacon, model)
|
|
1411
|
+
// EnabledForDispatch state when the work item targets a
|
|
1412
|
+
// known-installed model. System actions (LabsWorkerManagement,
|
|
1413
|
+
// VideoPipeline/VP_EncodeVideo, etc.) and dispatches with no
|
|
1414
|
+
// extractable model key fall through unchanged. See
|
|
1415
|
+
// FleetManager.checkDispatchAllowed() for policy.
|
|
1416
|
+
let tmpFleetForFilter = this._getService('UltravisorFleetManager');
|
|
1417
|
+
if (tmpFleetForFilter && typeof tmpFleetForFilter.checkDispatchAllowed === 'function')
|
|
1418
|
+
{
|
|
1419
|
+
let tmpAllow;
|
|
1420
|
+
try { tmpAllow = tmpFleetForFilter.checkDispatchAllowed(pBeaconID, tmpWorkItem); }
|
|
1421
|
+
catch (pErr)
|
|
1422
|
+
{
|
|
1423
|
+
this.log.warn(
|
|
1424
|
+
`BeaconCoordinator: fleet filter threw on `
|
|
1425
|
+
+ `[${tmpWorkItem.WorkItemHash}]: ${pErr.message} — allowing`);
|
|
1426
|
+
tmpAllow = { Allowed: true };
|
|
1427
|
+
}
|
|
1428
|
+
if (tmpAllow && tmpAllow.Allowed === false)
|
|
1429
|
+
{
|
|
1430
|
+
this.log.info(
|
|
1431
|
+
`BeaconCoordinator: fleet filter denied beacon [${pBeaconID}] `
|
|
1432
|
+
+ `for [${tmpWorkItem.Capability}/${tmpWorkItem.Action}] `
|
|
1433
|
+
+ `(model=${tmpAllow.MatchedModelKey}, reason=${tmpAllow.Reason}) — `
|
|
1434
|
+
+ `keeping work item pending for an enabled beacon.`);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1344
1439
|
// Claim this work item
|
|
1345
1440
|
let tmpPollClaimIso = new Date().toISOString();
|
|
1346
1441
|
let tmpPollFromStatus = tmpWorkItem.Status;
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultravisor — DirectoryDistributor
|
|
3
|
+
*
|
|
4
|
+
* Generic mesh primitive for "push the contents of a directory to a
|
|
5
|
+
* remote beacon, validate end-to-end with sha256 tree-hashing, and
|
|
6
|
+
* finalize." Originally cribbed from retold-labs/source/RetoldLabs-PythonRuntime.cjs
|
|
7
|
+
* (which was app-specific to pushing pipeline-workers/) and
|
|
8
|
+
* generalized so both runtime push (Layer 1b) and model push (Layer 2)
|
|
9
|
+
* — and any future "ship a directory tree to a worker" use case — can
|
|
10
|
+
* use the same code path.
|
|
11
|
+
*
|
|
12
|
+
* What it does:
|
|
13
|
+
* 1. Scans a source directory (skipping ignored basenames), computes
|
|
14
|
+
* a sha256 tree-hash + per-file chunk plan.
|
|
15
|
+
* 2. Streams each file as a sequence of chunks via a caller-supplied
|
|
16
|
+
* `pDispatch(actionName, settingsObject)` async function. The
|
|
17
|
+
* dispatcher is the only thing this service knows about beacons
|
|
18
|
+
* (we don't reach into the BeaconCoordinator directly — that's
|
|
19
|
+
* the FleetManager's job).
|
|
20
|
+
* 3. Calls a finalizer LWM action with the expected tree-hash.
|
|
21
|
+
*
|
|
22
|
+
* What it doesn't do:
|
|
23
|
+
* - Pick which beacon to push to (FleetManager).
|
|
24
|
+
* - Persist installation state (FleetManager + DB).
|
|
25
|
+
* - Run the worker's post-finalize re-scan (worker handles that
|
|
26
|
+
* locally inside its finalize action handler).
|
|
27
|
+
*
|
|
28
|
+
* Caller contract for `pDispatch`:
|
|
29
|
+
* pDispatch(actionName: string, settings: object) -> Promise<response>
|
|
30
|
+
* where `response` is the beacon's `{ Outputs: {...}, Log: [...] }`
|
|
31
|
+
* envelope (see Ultravisor-Beacon-Client.cjs:588). Outputs.Status
|
|
32
|
+
* and Outputs.ExitCode drive success/failure detection.
|
|
33
|
+
*
|
|
34
|
+
* @author Steven Velozo <steven@velozo.com>
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const libFableServiceProviderBase = require('fable-serviceproviderbase');
|
|
38
|
+
const libPath = require('path');
|
|
39
|
+
const libFileStream = require('ultravisor-file-stream');
|
|
40
|
+
|
|
41
|
+
// Always-skip names. Callers may add to this set per push (e.g.
|
|
42
|
+
// pushing a model dir excludes 'venvs' too); we keep the universal
|
|
43
|
+
// suspects baked in.
|
|
44
|
+
const DEFAULT_IGNORE_BASENAMES = new Set([
|
|
45
|
+
'__pycache__',
|
|
46
|
+
'node_modules',
|
|
47
|
+
'.DS_Store',
|
|
48
|
+
'.git'
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const DEFAULT_CHUNK_BYTES = libFileStream.DEFAULT_CHUNK_BYTES;
|
|
52
|
+
|
|
53
|
+
class UltravisorDirectoryDistributor extends libFableServiceProviderBase
|
|
54
|
+
{
|
|
55
|
+
constructor(pFable, pOptions, pServiceHash)
|
|
56
|
+
{
|
|
57
|
+
super(pFable, pOptions, pServiceHash);
|
|
58
|
+
this.serviceType = 'UltravisorDirectoryDistributor';
|
|
59
|
+
|
|
60
|
+
this._chunkBytes = this.options.ChunkBytes || DEFAULT_CHUNK_BYTES;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hash + manifest a source directory.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} pSourceDir
|
|
67
|
+
* @param {object} [pOptions]
|
|
68
|
+
* - IgnoreBasenames Set<string> extra skip names (merged with default)
|
|
69
|
+
* @returns {{Hash, FileCount, TotalBytes, Files}}
|
|
70
|
+
*/
|
|
71
|
+
scan(pSourceDir, pOptions)
|
|
72
|
+
{
|
|
73
|
+
let tmpIgnore = this._mergedIgnore(pOptions && pOptions.IgnoreBasenames);
|
|
74
|
+
let tmpScan = libFileStream.hashDirectoryTree(pSourceDir, tmpIgnore);
|
|
75
|
+
if (this.log)
|
|
76
|
+
{
|
|
77
|
+
this.log.info(
|
|
78
|
+
`UltravisorDirectoryDistributor: scanned ${pSourceDir} — `
|
|
79
|
+
+ `hash=${tmpScan.Hash.slice(0, 16)}... `
|
|
80
|
+
+ `files=${tmpScan.FileCount} bytes=${tmpScan.TotalBytes}`);
|
|
81
|
+
}
|
|
82
|
+
return tmpScan;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build the chunk payloads for a single file inside a source dir.
|
|
87
|
+
*
|
|
88
|
+
* The wire RelativePath is what the worker stores at — it's the
|
|
89
|
+
* caller's responsibility to compose that. For a runtime push the
|
|
90
|
+
* orchestrator passes the in-source relative path verbatim
|
|
91
|
+
* ('worker_protocol.py'); for a model push the orchestrator
|
|
92
|
+
* prefixes the model basename ('sd15/weights/model.safetensors')
|
|
93
|
+
* so the worker's LWM_PushModel handler writes it under
|
|
94
|
+
* `<library>/sd15/weights/model.safetensors`.
|
|
95
|
+
*/
|
|
96
|
+
buildChunksForFile(pSourceDir, pInSourceRelativePath, pWireRelativePath)
|
|
97
|
+
{
|
|
98
|
+
let tmpLocalRel = pInSourceRelativePath.split('/').join(libPath.sep);
|
|
99
|
+
let tmpFullPath = libPath.join(pSourceDir, tmpLocalRel);
|
|
100
|
+
return libFileStream.buildChunksForFile(tmpFullPath,
|
|
101
|
+
{ ChunkBytes: this._chunkBytes, RelativePath: pWireRelativePath });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Orchestrate the full push to one beacon target.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} pConfig
|
|
108
|
+
* - SourceDir (required) absolute path on hub disk
|
|
109
|
+
* - PushAction (required) LWM action name for chunks
|
|
110
|
+
* (e.g. 'LWM_PushPythonRuntime',
|
|
111
|
+
* 'LWM_PushModel')
|
|
112
|
+
* - FinalizeAction (required) LWM action name for finalize
|
|
113
|
+
* - DestPathPrefix (optional) prepended to each file's
|
|
114
|
+
* in-source RelativePath when
|
|
115
|
+
* building the wire RelativePath.
|
|
116
|
+
* Empty for runtime pushes;
|
|
117
|
+
* '<model-name>' for model pushes.
|
|
118
|
+
* - ExpectedHashKey (optional) Settings key the finalize action
|
|
119
|
+
* reads to validate the tree-hash.
|
|
120
|
+
* 'ExpectedRuntimeHash' (default),
|
|
121
|
+
* 'ExpectedModelHash', etc.
|
|
122
|
+
* - FinalizeExtras (optional) merged into the finalize-action
|
|
123
|
+
* Settings (worker-side handlers
|
|
124
|
+
* may need extra context — e.g.
|
|
125
|
+
* the model name for inventory
|
|
126
|
+
* re-scan).
|
|
127
|
+
* - IgnoreBasenames (optional) Set of extra skip names.
|
|
128
|
+
* @param {function} pDispatch async (actionName, settings) -> response
|
|
129
|
+
* @param {function} [pProgress] optional ({BytesPushed, TotalBytes,
|
|
130
|
+
* FilesPushed, FileCount, CurrentFile})
|
|
131
|
+
*/
|
|
132
|
+
async pushDirectoryToTarget(pConfig, pDispatch, pProgress)
|
|
133
|
+
{
|
|
134
|
+
let tmpSourceDir = pConfig.SourceDir;
|
|
135
|
+
let tmpPushAction = pConfig.PushAction;
|
|
136
|
+
let tmpFinalizeAction = pConfig.FinalizeAction;
|
|
137
|
+
let tmpDestPrefix = pConfig.DestPathPrefix || '';
|
|
138
|
+
let tmpExpectedHashKey = pConfig.ExpectedHashKey || 'ExpectedRuntimeHash';
|
|
139
|
+
let tmpFinalizeExtras = pConfig.FinalizeExtras || {};
|
|
140
|
+
|
|
141
|
+
if (!tmpSourceDir || !tmpPushAction || !tmpFinalizeAction)
|
|
142
|
+
{
|
|
143
|
+
return {
|
|
144
|
+
Status: 'Error',
|
|
145
|
+
Error: 'pushDirectoryToTarget: SourceDir, PushAction, FinalizeAction all required.',
|
|
146
|
+
FilesPushed: 0,
|
|
147
|
+
BytesPushed: 0
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let tmpStart = Date.now();
|
|
152
|
+
let tmpScan = this.scan(tmpSourceDir, { IgnoreBasenames: pConfig.IgnoreBasenames });
|
|
153
|
+
|
|
154
|
+
if (tmpScan.FileCount === 0)
|
|
155
|
+
{
|
|
156
|
+
return {
|
|
157
|
+
Status: 'Error',
|
|
158
|
+
Error: `pushDirectoryToTarget: source directory ${tmpSourceDir} is empty.`,
|
|
159
|
+
FilesPushed: 0,
|
|
160
|
+
BytesPushed: 0
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let tmpBytesPushed = 0;
|
|
165
|
+
let tmpFilesPushed = 0;
|
|
166
|
+
|
|
167
|
+
for (let tmpFile of tmpScan.Files)
|
|
168
|
+
{
|
|
169
|
+
// Compose wire RelativePath: optionally prefix with the
|
|
170
|
+
// destination subdir so the worker writes the file at
|
|
171
|
+
// '<base>/<DestPathPrefix>/<file-rel>'.
|
|
172
|
+
let tmpWireRel = tmpDestPrefix
|
|
173
|
+
? this._joinForwardSlash(tmpDestPrefix, tmpFile.RelativePath)
|
|
174
|
+
: tmpFile.RelativePath;
|
|
175
|
+
|
|
176
|
+
let tmpChunks = this.buildChunksForFile(tmpSourceDir, tmpFile.RelativePath, tmpWireRel);
|
|
177
|
+
for (let tmpChunk of tmpChunks)
|
|
178
|
+
{
|
|
179
|
+
let tmpResp = await pDispatch(tmpPushAction, tmpChunk);
|
|
180
|
+
if (!this._isOkResponse(tmpResp))
|
|
181
|
+
{
|
|
182
|
+
return {
|
|
183
|
+
Status: 'Error',
|
|
184
|
+
Error: `push chunk ${tmpChunk.ChunkIndex}/${tmpChunk.TotalChunks} `
|
|
185
|
+
+ `for ${tmpWireRel} failed: `
|
|
186
|
+
+ `${this._extractError(tmpResp)}`,
|
|
187
|
+
FilesPushed: tmpFilesPushed,
|
|
188
|
+
BytesPushed: tmpBytesPushed,
|
|
189
|
+
FailedResponse: tmpResp
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
tmpBytesPushed += Buffer.byteLength(tmpChunk.Content || '', 'base64');
|
|
193
|
+
if (typeof pProgress === 'function')
|
|
194
|
+
{
|
|
195
|
+
try
|
|
196
|
+
{
|
|
197
|
+
pProgress({
|
|
198
|
+
BytesPushed: tmpBytesPushed,
|
|
199
|
+
TotalBytes: tmpScan.TotalBytes,
|
|
200
|
+
FilesPushed: tmpFilesPushed,
|
|
201
|
+
FileCount: tmpScan.FileCount,
|
|
202
|
+
CurrentFile: tmpWireRel
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (e) { /* progress callbacks shouldn't fail the push */ }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
tmpFilesPushed++;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Finalize.
|
|
212
|
+
let tmpFinalizeSettings = Object.assign({}, tmpFinalizeExtras);
|
|
213
|
+
tmpFinalizeSettings[tmpExpectedHashKey] = tmpScan.Hash;
|
|
214
|
+
|
|
215
|
+
let tmpFinalResp = await pDispatch(tmpFinalizeAction, tmpFinalizeSettings);
|
|
216
|
+
let tmpFinalOk = this._isOkResponse(tmpFinalResp);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
Status: tmpFinalOk ? 'Success' : 'Error',
|
|
220
|
+
Error: tmpFinalOk ? null : this._extractError(tmpFinalResp),
|
|
221
|
+
SourceDir: tmpSourceDir,
|
|
222
|
+
TreeHash: tmpScan.Hash,
|
|
223
|
+
FilesPushed: tmpFilesPushed,
|
|
224
|
+
FileCount: tmpScan.FileCount,
|
|
225
|
+
BytesPushed: tmpBytesPushed,
|
|
226
|
+
TotalBytes: tmpScan.TotalBytes,
|
|
227
|
+
FinalizeResponse: tmpFinalResp,
|
|
228
|
+
DurationMs: Date.now() - tmpStart
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Internals ──────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
_mergedIgnore(pExtra)
|
|
235
|
+
{
|
|
236
|
+
if (!pExtra || (pExtra.size === 0))
|
|
237
|
+
{
|
|
238
|
+
return DEFAULT_IGNORE_BASENAMES;
|
|
239
|
+
}
|
|
240
|
+
let tmpMerged = new Set(DEFAULT_IGNORE_BASENAMES);
|
|
241
|
+
for (let tmpName of pExtra) tmpMerged.add(tmpName);
|
|
242
|
+
return tmpMerged;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_joinForwardSlash(pA, pB)
|
|
246
|
+
{
|
|
247
|
+
// Always emit forward-slash-delimited wire paths regardless of
|
|
248
|
+
// host OS. Worker's chunked-write normalizes to local sep.
|
|
249
|
+
let tmpA = String(pA || '').replace(/^\/+|\/+$/g, '');
|
|
250
|
+
let tmpB = String(pB || '').replace(/^\/+/, '');
|
|
251
|
+
if (!tmpA) return tmpB;
|
|
252
|
+
if (!tmpB) return tmpA;
|
|
253
|
+
return tmpA + '/' + tmpB;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_isOkResponse(pResp)
|
|
257
|
+
{
|
|
258
|
+
if (!pResp) return false;
|
|
259
|
+
if (pResp.Success === false) return false;
|
|
260
|
+
let tmpOutputs = pResp.Outputs || {};
|
|
261
|
+
// Worker contract: Outputs.Status === 'Success' OR ExitCode falsy.
|
|
262
|
+
if (tmpOutputs.Status && tmpOutputs.Status !== 'Success') return false;
|
|
263
|
+
if (tmpOutputs.ExitCode && tmpOutputs.ExitCode !== 0) return false;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_extractError(pResp)
|
|
268
|
+
{
|
|
269
|
+
if (!pResp) return 'no response';
|
|
270
|
+
let tmpOutputs = pResp.Outputs || {};
|
|
271
|
+
return tmpOutputs.Error
|
|
272
|
+
|| tmpOutputs.Status
|
|
273
|
+
|| pResp.Error
|
|
274
|
+
|| 'unknown';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = UltravisorDirectoryDistributor;
|
|
279
|
+
module.exports.DEFAULT_IGNORE_BASENAMES = DEFAULT_IGNORE_BASENAMES;
|
|
280
|
+
module.exports.DEFAULT_CHUNK_BYTES = DEFAULT_CHUNK_BYTES;
|