ultravisor-beacon 0.0.1

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,500 @@
1
+ /**
2
+ * Ultravisor Beacon Executor
3
+ *
4
+ * Routes work items to the appropriate capability provider via the
5
+ * ProviderRegistry. Replaces the former hard-coded switch statement
6
+ * with a pluggable, composable provider architecture.
7
+ *
8
+ * Supports file transfer for remote dispatch scenarios:
9
+ * - Pre-execute: downloads source files from a SourceURL
10
+ * - Post-execute: base64-encodes output files into Outputs
11
+ * - Affinity-scoped download caching for repeated operations on the same file
12
+ */
13
+
14
+ const libFS = require('fs');
15
+ const libPath = require('path');
16
+ const libHTTP = require('http');
17
+ const libHTTPS = require('https');
18
+
19
+ const libBeaconProviderRegistry = require('./Ultravisor-Beacon-ProviderRegistry.cjs');
20
+
21
+ class UltravisorBeaconExecutor
22
+ {
23
+ constructor(pConfig)
24
+ {
25
+ this._Config = pConfig || {};
26
+ this._StagingPath = this._Config.StagingPath || process.cwd();
27
+ this._ProviderRegistry = new libBeaconProviderRegistry();
28
+ }
29
+
30
+ /**
31
+ * Get the provider registry.
32
+ * Used by BeaconClient for capability list and provider lifecycle.
33
+ */
34
+ get providerRegistry()
35
+ {
36
+ return this._ProviderRegistry;
37
+ }
38
+
39
+ /**
40
+ * Execute a work item by routing to the appropriate provider.
41
+ *
42
+ * If the work item's Settings include file transfer directives
43
+ * (SourceURL, OutputFilename), the executor handles downloading
44
+ * the source file before execution and collecting the output file
45
+ * after execution. This is transparent to providers.
46
+ *
47
+ * @param {object} pWorkItem - { WorkItemHash, Capability, Action, Settings, TimeoutMs }
48
+ * @param {function} fCallback - function(pError, pResult) where pResult = { Outputs, Log }
49
+ * @param {function} [fReportProgress] - Optional progress callback passed through to provider
50
+ */
51
+ execute(pWorkItem, fCallback, fReportProgress)
52
+ {
53
+ let tmpCapability = pWorkItem.Capability || 'Shell';
54
+ let tmpAction = pWorkItem.Action || '';
55
+
56
+ let tmpResolved = this._ProviderRegistry.resolve(tmpCapability, tmpAction);
57
+
58
+ if (!tmpResolved)
59
+ {
60
+ return fCallback(null, {
61
+ Outputs: {
62
+ StdOut: `Unknown capability: ${tmpCapability}` +
63
+ (tmpAction ? `/${tmpAction}` : ''),
64
+ ExitCode: -1,
65
+ Result: ''
66
+ },
67
+ Log: [`Beacon Executor: no provider for [${tmpCapability}` +
68
+ (tmpAction ? `/${tmpAction}` : '') + `].`]
69
+ });
70
+ }
71
+
72
+ let tmpContext = {
73
+ StagingPath: this._StagingPath
74
+ };
75
+
76
+ let tmpSettings = pWorkItem.Settings || {};
77
+
78
+ // Check if file transfer is needed
79
+ if (tmpSettings.SourceURL || tmpSettings.OutputFilename)
80
+ {
81
+ return this._executeWithFileTransfer(
82
+ pWorkItem, tmpResolved, tmpContext, fCallback, fReportProgress);
83
+ }
84
+
85
+ // Standard execution — no file transfer
86
+ tmpResolved.provider.execute(
87
+ tmpResolved.action, pWorkItem, tmpContext, fCallback, fReportProgress);
88
+ }
89
+
90
+ // ================================================================
91
+ // File Transfer Execution
92
+ // ================================================================
93
+
94
+ /**
95
+ * Execute a work item with file transfer support.
96
+ *
97
+ * 1. Download source file (if SourceURL specified)
98
+ * 2. Substitute {SourcePath} and {OutputPath} in Command
99
+ * 3. Execute the provider
100
+ * 4. Collect output file (if OutputFilename specified)
101
+ * 5. Clean up work directory
102
+ */
103
+ _executeWithFileTransfer(pWorkItem, pResolved, pContext, fCallback, fReportProgress)
104
+ {
105
+ let tmpSelf = this;
106
+ let tmpSettings = pWorkItem.Settings || {};
107
+ let tmpLog = [];
108
+
109
+ // Phase 1: Prepare — download source file and set up paths
110
+ this._prepareFileTransfer(pWorkItem, tmpLog,
111
+ (pPrepareError) =>
112
+ {
113
+ if (pPrepareError)
114
+ {
115
+ return fCallback(null, {
116
+ Outputs: {
117
+ StdOut: `File transfer preparation failed: ${pPrepareError.message}`,
118
+ ExitCode: -1,
119
+ Result: ''
120
+ },
121
+ Log: tmpLog.concat([`File transfer error: ${pPrepareError.message}`])
122
+ });
123
+ }
124
+
125
+ // Phase 2: Execute the provider
126
+ pResolved.provider.execute(
127
+ pResolved.action, pWorkItem, pContext,
128
+ (pExecError, pResult) =>
129
+ {
130
+ if (pExecError)
131
+ {
132
+ tmpSelf._cleanupWorkDir(pWorkItem.WorkItemHash);
133
+ return fCallback(pExecError);
134
+ }
135
+
136
+ // Phase 3: Collect output files
137
+ tmpSelf._collectOutputFiles(pWorkItem, pResult, tmpLog,
138
+ (pCollectError, pFinalResult) =>
139
+ {
140
+ // Clean up work directory (keep affinity staging)
141
+ tmpSelf._cleanupWorkDir(pWorkItem.WorkItemHash);
142
+
143
+ if (pCollectError)
144
+ {
145
+ return fCallback(null, {
146
+ Outputs: Object.assign(pResult.Outputs || {},
147
+ {
148
+ StdOut: (pResult.Outputs ? pResult.Outputs.StdOut || '' : '') +
149
+ '\nOutput collection failed: ' + pCollectError.message,
150
+ ExitCode: -1
151
+ }),
152
+ Log: (pResult.Log || []).concat(tmpLog)
153
+ });
154
+ }
155
+
156
+ // Merge file transfer log into result
157
+ pFinalResult.Log = (pFinalResult.Log || []).concat(tmpLog);
158
+ return fCallback(null, pFinalResult);
159
+ });
160
+ }, fReportProgress);
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Prepare for file transfer: download source file, substitute paths.
166
+ *
167
+ * @param {object} pWorkItem - The work item (Settings will be modified in-place)
168
+ * @param {Array} pLog - Log array to append messages to
169
+ * @param {function} fCallback - function(pError)
170
+ */
171
+ _prepareFileTransfer(pWorkItem, pLog, fCallback)
172
+ {
173
+ let tmpSettings = pWorkItem.Settings || {};
174
+ let tmpCommand = tmpSettings.Command || '';
175
+
176
+ // Set up output path (always, even if no source download)
177
+ if (tmpSettings.OutputFilename)
178
+ {
179
+ let tmpWorkDir = this._getWorkDir(pWorkItem);
180
+ let tmpOutputPath = libPath.join(tmpWorkDir, tmpSettings.OutputFilename);
181
+ tmpCommand = tmpCommand.replace(/\{OutputPath\}/g, tmpOutputPath);
182
+ // Store resolved path for later collection
183
+ tmpSettings._ResolvedOutputPath = tmpOutputPath;
184
+ }
185
+
186
+ // Download source file if URL specified
187
+ if (tmpSettings.SourceURL)
188
+ {
189
+ let tmpSourceFilename = tmpSettings.SourceFilename || 'source_file';
190
+ let tmpDownloadDir;
191
+ let tmpSourcePath;
192
+
193
+ // Use affinity-scoped directory if AffinityKey is present
194
+ if (pWorkItem.Settings.AffinityKey)
195
+ {
196
+ tmpDownloadDir = this._getAffinityDir(pWorkItem);
197
+ }
198
+ else
199
+ {
200
+ tmpDownloadDir = this._getWorkDir(pWorkItem);
201
+ }
202
+
203
+ tmpSourcePath = libPath.join(tmpDownloadDir, tmpSourceFilename);
204
+
205
+ // Check if file already exists (affinity cache hit)
206
+ if (libFS.existsSync(tmpSourcePath))
207
+ {
208
+ pLog.push(`Source file cached (affinity): ${tmpSourceFilename}`);
209
+ tmpCommand = tmpCommand.replace(/\{SourcePath\}/g, tmpSourcePath);
210
+ tmpSettings.Command = tmpCommand;
211
+ tmpSettings._ResolvedSourcePath = tmpSourcePath;
212
+ return fCallback(null);
213
+ }
214
+
215
+ // Download the file
216
+ pLog.push(`Downloading source: ${tmpSettings.SourceURL}`);
217
+
218
+ this._downloadFile(tmpSettings.SourceURL, tmpSourcePath,
219
+ (pDownloadError) =>
220
+ {
221
+ if (pDownloadError)
222
+ {
223
+ return fCallback(pDownloadError);
224
+ }
225
+
226
+ pLog.push(`Downloaded: ${tmpSourceFilename} (${this._formatFileSize(tmpSourcePath)})`);
227
+ tmpCommand = tmpCommand.replace(/\{SourcePath\}/g, tmpSourcePath);
228
+ tmpSettings.Command = tmpCommand;
229
+ tmpSettings._ResolvedSourcePath = tmpSourcePath;
230
+ return fCallback(null);
231
+ });
232
+ }
233
+ else
234
+ {
235
+ // No download needed — just update the command
236
+ tmpSettings.Command = tmpCommand;
237
+ return fCallback(null);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Collect output files after execution, base64-encoding if requested.
243
+ *
244
+ * @param {object} pWorkItem - The work item
245
+ * @param {object} pResult - The execution result { Outputs, Log }
246
+ * @param {Array} pLog - Log array to append messages to
247
+ * @param {function} fCallback - function(pError, pFinalResult)
248
+ */
249
+ _collectOutputFiles(pWorkItem, pResult, pLog, fCallback)
250
+ {
251
+ let tmpSettings = pWorkItem.Settings || {};
252
+
253
+ if (!tmpSettings.OutputFilename || !tmpSettings.ReturnOutputAsBase64)
254
+ {
255
+ return fCallback(null, pResult);
256
+ }
257
+
258
+ let tmpOutputPath = tmpSettings._ResolvedOutputPath;
259
+
260
+ if (!tmpOutputPath || !libFS.existsSync(tmpOutputPath))
261
+ {
262
+ pLog.push(`Output file not found: ${tmpSettings.OutputFilename}`);
263
+ return fCallback(new Error(`Output file not found: ${tmpSettings.OutputFilename}`));
264
+ }
265
+
266
+ try
267
+ {
268
+ let tmpBuffer = libFS.readFileSync(tmpOutputPath);
269
+ let tmpBase64 = tmpBuffer.toString('base64');
270
+
271
+ pLog.push(`Output collected: ${tmpSettings.OutputFilename} (${this._formatFileSize(tmpOutputPath)})`);
272
+
273
+ // Merge into result
274
+ let tmpOutputs = pResult.Outputs || {};
275
+ tmpOutputs.OutputData = tmpBase64;
276
+ tmpOutputs.OutputFilename = tmpSettings.OutputFilename;
277
+ tmpOutputs.OutputSize = tmpBuffer.length;
278
+
279
+ return fCallback(null, {
280
+ Outputs: tmpOutputs,
281
+ Log: pResult.Log || []
282
+ });
283
+ }
284
+ catch (pReadError)
285
+ {
286
+ return fCallback(pReadError);
287
+ }
288
+ }
289
+
290
+ // ================================================================
291
+ // File Download
292
+ // ================================================================
293
+
294
+ /**
295
+ * Download a file from a URL to a local path.
296
+ * Streams to disk to handle large files.
297
+ *
298
+ * @param {string} pURL - The URL to download from
299
+ * @param {string} pOutputPath - Local file path to write to
300
+ * @param {function} fCallback - function(pError)
301
+ */
302
+ _downloadFile(pURL, pOutputPath, fCallback)
303
+ {
304
+ let tmpLib = pURL.startsWith('https') ? libHTTPS : libHTTP;
305
+
306
+ // Ensure the directory exists
307
+ let tmpDir = libPath.dirname(pOutputPath);
308
+ if (!libFS.existsSync(tmpDir))
309
+ {
310
+ libFS.mkdirSync(tmpDir, { recursive: true });
311
+ }
312
+
313
+ let tmpFileStream = libFS.createWriteStream(pOutputPath);
314
+ let tmpCallbackFired = false;
315
+
316
+ let tmpComplete = (pError) =>
317
+ {
318
+ if (tmpCallbackFired)
319
+ {
320
+ return;
321
+ }
322
+ tmpCallbackFired = true;
323
+
324
+ if (pError)
325
+ {
326
+ tmpFileStream.close();
327
+ // Clean up partial download
328
+ try { libFS.unlinkSync(pOutputPath); }
329
+ catch (pErr) { /* ignore */ }
330
+ return fCallback(pError);
331
+ }
332
+
333
+ return fCallback(null);
334
+ };
335
+
336
+ tmpLib.get(pURL, (pResponse) =>
337
+ {
338
+ // Handle redirects
339
+ if (pResponse.statusCode >= 300 && pResponse.statusCode < 400 && pResponse.headers.location)
340
+ {
341
+ tmpFileStream.close();
342
+ try { libFS.unlinkSync(pOutputPath); }
343
+ catch (pErr) { /* ignore */ }
344
+ return this._downloadFile(pResponse.headers.location, pOutputPath, fCallback);
345
+ }
346
+
347
+ if (pResponse.statusCode !== 200)
348
+ {
349
+ return tmpComplete(new Error(`Download failed: HTTP ${pResponse.statusCode} for ${pURL}`));
350
+ }
351
+
352
+ pResponse.pipe(tmpFileStream);
353
+
354
+ tmpFileStream.on('finish', () =>
355
+ {
356
+ tmpFileStream.close(() =>
357
+ {
358
+ tmpComplete(null);
359
+ });
360
+ });
361
+
362
+ pResponse.on('error', tmpComplete);
363
+ tmpFileStream.on('error', tmpComplete);
364
+
365
+ }).on('error', tmpComplete);
366
+ }
367
+
368
+ // ================================================================
369
+ // Staging Directory Management
370
+ // ================================================================
371
+
372
+ /**
373
+ * Get or create the affinity-scoped staging directory.
374
+ * Files here persist across work items with the same affinity key.
375
+ *
376
+ * @param {object} pWorkItem - Work item with Settings.AffinityKey
377
+ * @returns {string} Absolute path to the affinity directory
378
+ */
379
+ _getAffinityDir(pWorkItem)
380
+ {
381
+ let tmpAffinityKey = (pWorkItem.Settings && pWorkItem.Settings.AffinityKey) || 'default';
382
+ // Sanitize the affinity key for use as a directory name
383
+ let tmpSafeKey = tmpAffinityKey.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 64);
384
+ let tmpDir = libPath.join(this._StagingPath, `affinity-${tmpSafeKey}`);
385
+
386
+ if (!libFS.existsSync(tmpDir))
387
+ {
388
+ libFS.mkdirSync(tmpDir, { recursive: true });
389
+ }
390
+
391
+ return tmpDir;
392
+ }
393
+
394
+ /**
395
+ * Get or create the work-item-scoped staging directory.
396
+ * Files here are cleaned up after the work item completes.
397
+ *
398
+ * @param {object} pWorkItem - Work item with WorkItemHash
399
+ * @returns {string} Absolute path to the work directory
400
+ */
401
+ _getWorkDir(pWorkItem)
402
+ {
403
+ let tmpHash = pWorkItem.WorkItemHash || 'unknown';
404
+ let tmpDir = libPath.join(this._StagingPath, `work-${tmpHash}`);
405
+
406
+ if (!libFS.existsSync(tmpDir))
407
+ {
408
+ libFS.mkdirSync(tmpDir, { recursive: true });
409
+ }
410
+
411
+ return tmpDir;
412
+ }
413
+
414
+ /**
415
+ * Clean up a work item's staging directory.
416
+ *
417
+ * @param {string} pWorkItemHash - The work item hash
418
+ */
419
+ _cleanupWorkDir(pWorkItemHash)
420
+ {
421
+ let tmpDir = libPath.join(this._StagingPath, `work-${pWorkItemHash}`);
422
+
423
+ if (!libFS.existsSync(tmpDir))
424
+ {
425
+ return;
426
+ }
427
+
428
+ try
429
+ {
430
+ libFS.rmSync(tmpDir, { recursive: true, force: true });
431
+ }
432
+ catch (pError)
433
+ {
434
+ // Best-effort cleanup
435
+ console.warn(`[Beacon Executor] Could not clean up work directory: ${pError.message}`);
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Clean up all affinity staging directories.
441
+ * Called during beacon shutdown.
442
+ */
443
+ cleanupAffinityDirs()
444
+ {
445
+ try
446
+ {
447
+ let tmpEntries = libFS.readdirSync(this._StagingPath);
448
+
449
+ for (let i = 0; i < tmpEntries.length; i++)
450
+ {
451
+ if (tmpEntries[i].startsWith('affinity-'))
452
+ {
453
+ let tmpDir = libPath.join(this._StagingPath, tmpEntries[i]);
454
+ try
455
+ {
456
+ libFS.rmSync(tmpDir, { recursive: true, force: true });
457
+ }
458
+ catch (pError)
459
+ {
460
+ console.warn(`[Beacon Executor] Could not clean up affinity dir [${tmpEntries[i]}]: ${pError.message}`);
461
+ }
462
+ }
463
+ }
464
+ }
465
+ catch (pError)
466
+ {
467
+ // Staging path doesn't exist or can't be read — fine
468
+ }
469
+ }
470
+
471
+ // ================================================================
472
+ // Utilities
473
+ // ================================================================
474
+
475
+ /**
476
+ * Format a file size for logging.
477
+ *
478
+ * @param {string} pFilePath - Path to the file
479
+ * @returns {string} Human-readable file size
480
+ */
481
+ _formatFileSize(pFilePath)
482
+ {
483
+ try
484
+ {
485
+ let tmpStat = libFS.statSync(pFilePath);
486
+ let tmpSize = tmpStat.size;
487
+
488
+ if (tmpSize < 1024) return `${tmpSize} B`;
489
+ if (tmpSize < 1024 * 1024) return `${(tmpSize / 1024).toFixed(1)} KB`;
490
+ if (tmpSize < 1024 * 1024 * 1024) return `${(tmpSize / (1024 * 1024)).toFixed(1)} MB`;
491
+ return `${(tmpSize / (1024 * 1024 * 1024)).toFixed(2)} GB`;
492
+ }
493
+ catch (pError)
494
+ {
495
+ return 'unknown size';
496
+ }
497
+ }
498
+ }
499
+
500
+ module.exports = UltravisorBeaconExecutor;