ultravisor 1.0.22 → 1.0.24

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.
@@ -11,6 +11,7 @@
11
11
 
12
12
  const libFS = require('fs');
13
13
  const libPath = require('path');
14
+ const libFileStream = require('ultravisor-file-stream');
14
15
 
15
16
  /**
16
17
  * Recursively sort all object keys alphabetically (deep).
@@ -424,6 +425,86 @@ module.exports =
424
425
  }
425
426
  },
426
427
 
428
+ // ── chunked-write ─────────────────────────────────────────
429
+ // Multi-chunk file writer for large transfers (model pushes across
430
+ // the beacon mesh). Thin adapter over `ultravisor-file-stream`'s
431
+ // writeChunk primitive — this task type just resolves the staging
432
+ // path, delegates to the library, and translates the return value
433
+ // into task-event shape.
434
+ {
435
+ Definition: require('./definitions/chunked-write.json'),
436
+ Execute: function (pTask, pResolvedSettings, pExecutionContext, fCallback)
437
+ {
438
+ let tmpFileLocation = pResolvedSettings.FilePath || '';
439
+ if (!tmpFileLocation)
440
+ {
441
+ return fCallback(null, { EventToFire: 'Error', Outputs: {}, Log: ['ChunkedWrite: no FilePath specified.'] });
442
+ }
443
+
444
+ let tmpFilePath = pTask.resolveFilePath(tmpFileLocation, pExecutionContext.StagingPath);
445
+
446
+ let tmpResult = libFileStream.writeChunk(
447
+ Object.assign({}, pResolvedSettings, { TargetPath: tmpFilePath }));
448
+
449
+ let tmpChunkIndex = parseInt(pResolvedSettings.ChunkIndex, 10) || 0;
450
+ let tmpTotalChunks = parseInt(pResolvedSettings.TotalChunks, 10) || 0;
451
+ let tmpOffset = parseInt(pResolvedSettings.Offset, 10) || 0;
452
+
453
+ if (tmpResult.Status === 'Sha256Mismatch')
454
+ {
455
+ return fCallback(null, {
456
+ EventToFire: 'Sha256Mismatch',
457
+ Outputs:
458
+ {
459
+ FilePath: tmpFilePath,
460
+ PartialFilePath: tmpResult.Result ? tmpResult.Result.PartialFilePath : tmpFilePath + '.part',
461
+ BytesWritten: tmpResult.Result ? tmpResult.Result.BytesWritten : 0,
462
+ TotalBytesOnDisk: tmpResult.Result ? tmpResult.Result.TotalBytesOnDisk : 0,
463
+ IsComplete: false,
464
+ Sha256Verified: false,
465
+ ChunkIndex: tmpChunkIndex
466
+ },
467
+ Log: [ `ChunkedWrite: ${tmpResult.Error}` ]
468
+ });
469
+ }
470
+ if (tmpResult.Status !== 'Success')
471
+ {
472
+ return fCallback(null, {
473
+ EventToFire: 'Error',
474
+ Outputs: {},
475
+ Log: [ `ChunkedWrite: ${tmpResult.Error || 'unknown error'}` ]
476
+ });
477
+ }
478
+
479
+ let tmpR = tmpResult.Result;
480
+ let tmpEvent = tmpR.IsComplete ? 'WriteComplete' : 'ChunkWritten';
481
+ let tmpLogSuffix = tmpR.IsComplete
482
+ ? `final chunk ${tmpChunkIndex}`
483
+ + (tmpTotalChunks ? `/${tmpTotalChunks}` : '')
484
+ + ` @ offset ${tmpOffset} (${tmpR.BytesWritten} B); ${tmpR.TotalBytesOnDisk} B total; `
485
+ + (pResolvedSettings.Sha256 ? `sha256 ${tmpR.Sha256Verified ? 'verified' : 'unverified'}; ` : '')
486
+ + `renamed ${tmpFilePath}.part -> ${tmpFilePath}`
487
+ : `chunk ${tmpChunkIndex}`
488
+ + (tmpTotalChunks ? `/${tmpTotalChunks}` : '')
489
+ + ` @ offset ${tmpOffset} (${tmpR.BytesWritten} B, ${tmpR.TotalBytesOnDisk} B total on disk)`;
490
+
491
+ return fCallback(null, {
492
+ EventToFire: tmpEvent,
493
+ Outputs:
494
+ {
495
+ FilePath: tmpR.TargetPath,
496
+ PartialFilePath: tmpR.PartialFilePath,
497
+ BytesWritten: tmpR.BytesWritten,
498
+ TotalBytesOnDisk: tmpR.TotalBytesOnDisk,
499
+ IsComplete: tmpR.IsComplete,
500
+ Sha256Verified: tmpR.Sha256Verified,
501
+ ChunkIndex: tmpChunkIndex
502
+ },
503
+ Log: [ `ChunkedWrite: ${tmpLogSuffix}` ]
504
+ });
505
+ }
506
+ },
507
+
427
508
  // ── read-file-buffered ────────────────────────────────────
428
509
  {
429
510
  Definition: require('./definitions/read-file-buffered.json'),
@@ -0,0 +1,38 @@
1
+ {
2
+ "Hash": "chunked-write",
3
+ "Type": "chunked-write",
4
+ "Name": "Chunked Write",
5
+ "Description": "Writes one chunk of a large file at a given byte offset. Intended for multi-chunk transfers (e.g. multi-GB model pushes across the beacon mesh). Chunks are written to a .part sidecar file; the final chunk triggers fsync, optional sha256 verification, and atomic rename to the target path.",
6
+ "Category": "file-io",
7
+ "Capability": "File System",
8
+ "Action": "ChunkedWrite",
9
+ "Tier": "Platform",
10
+ "EventInputs": [{ "Name": "BeginChunkWrite", "Description": "Triggers writing this chunk" }],
11
+ "EventOutputs": [
12
+ { "Name": "ChunkWritten", "Description": "Fires after a non-final chunk is written" },
13
+ { "Name": "WriteComplete", "Description": "Fires after the final chunk is written, fsynced, verified, and the .part file has been renamed to the target path" },
14
+ { "Name": "Sha256Mismatch", "Description": "Fires when IsFinal=true and the computed sha256 of the completed .part file does not match the Sha256 input", "IsError": true },
15
+ { "Name": "Error", "Description": "Fires on write/fsync/rename failure", "IsError": true }
16
+ ],
17
+ "SettingsInputs": [
18
+ { "Name": "FilePath", "DataType": "String", "Required": true, "Description": "Final path the completed file will live at. Chunks are written to <FilePath>.part until the final chunk renames it." },
19
+ { "Name": "Content", "DataType": "String", "Required": true, "Description": "This chunk's bytes. If ContentEncoding=\"base64\", interpreted as base64 (for binary transport over JSON). Otherwise written as-is per Encoding." },
20
+ { "Name": "ContentEncoding", "DataType": "String", "Required": false, "Default": "base64", "Description": "How to interpret Content on the wire. \"base64\" (default) is safe for binary; \"utf8\" for text." },
21
+ { "Name": "Offset", "DataType": "Number", "Required": true, "Description": "Byte offset where this chunk begins in the final file. Zero for the first chunk; subsequent chunks advance by the decoded byte length of the prior chunk's content." },
22
+ { "Name": "ChunkIndex", "DataType": "Number", "Required": false, "Description": "Zero-based index of this chunk in the sequence (informational; used in logs)" },
23
+ { "Name": "TotalChunks", "DataType": "Number", "Required": false, "Description": "Total chunk count the sender expects to send (informational; used in logs)" },
24
+ { "Name": "IsFinal", "DataType": "Boolean", "Required": false, "Description": "True for the last chunk in the sequence. Triggers fsync, optional sha256 verify, and atomic rename <FilePath>.part -> <FilePath>." },
25
+ { "Name": "Sha256", "DataType": "String", "Required": false, "Description": "Hex-encoded sha256 of the complete final file. When set and IsFinal=true, the task verifies before renaming; mismatch fires Sha256Mismatch and deletes the .part file." },
26
+ { "Name": "CreateDirectory", "DataType": "Boolean", "Required": false, "Default": true, "Description": "If true (default), the target directory is created recursively when missing." }
27
+ ],
28
+ "StateOutputs": [
29
+ { "Name": "FilePath", "DataType": "String", "Description": "The fully resolved absolute final path" },
30
+ { "Name": "PartialFilePath", "DataType": "String", "Description": "The <FilePath>.part file in progress (or the finalized file after IsFinal)" },
31
+ { "Name": "BytesWritten", "DataType": "Number", "Description": "Bytes written for this chunk" },
32
+ { "Name": "TotalBytesOnDisk", "DataType": "Number", "Description": "Current size of the .part file (or the final file if complete)" },
33
+ { "Name": "IsComplete", "DataType": "Boolean", "Description": "True only after the final chunk successfully renamed the .part file to FilePath" },
34
+ { "Name": "Sha256Verified", "DataType": "Boolean", "Description": "True if IsFinal=true, Sha256 was provided, and verification passed" },
35
+ { "Name": "ChunkIndex", "DataType": "Number", "Description": "Echo of input ChunkIndex, for convenience in downstream wiring" }
36
+ ],
37
+ "DefaultSettings": { "FilePath": "", "Content": "", "ContentEncoding": "base64", "Offset": 0, "ChunkIndex": 0, "TotalChunks": 0, "IsFinal": false, "Sha256": "", "CreateDirectory": true }
38
+ }
@@ -28,6 +28,9 @@ class UltravisorAPIServer extends libPictService
28
28
  this._WebSocketSubscriptions = {};
29
29
  // Map of BeaconID -> WebSocket for beacon worker connections
30
30
  this._BeaconWebSockets = {};
31
+ // Set of WebSocket clients subscribed to the queue.* topic
32
+ // (broadcast by UltravisorBeaconScheduler).
33
+ this._QueueSubscribers = new Set();
31
34
  }
32
35
 
33
36
  /**
@@ -956,6 +959,42 @@ class UltravisorAPIServer extends libPictService
956
959
  }.bind(this)
957
960
  );
958
961
 
962
+ // --- Retry from checkpoint ---
963
+ // Re-dispatches the failed node in a completed/errored operation.
964
+ // All prior node outputs are preserved; only the failed node re-runs.
965
+ this._OratorServer.post
966
+ (
967
+ '/Operation/:RunHash/Retry',
968
+ function (pRequest, pResponse, fNext)
969
+ {
970
+ let tmpBody = pRequest.body || {};
971
+ let tmpEngine = this._getService('UltravisorExecutionEngine');
972
+ let tmpRunHash = pRequest.params.RunHash;
973
+
974
+ tmpEngine.retryFromCheckpoint(tmpRunHash,
975
+ {
976
+ NodeHash: tmpBody.NodeHash || null,
977
+ SettingsOverrides: tmpBody.SettingsOverrides || {}
978
+ },
979
+ function (pError, pContext)
980
+ {
981
+ if (pError)
982
+ {
983
+ pResponse.send(400, { Error: pError.message });
984
+ return fNext();
985
+ }
986
+ pResponse.send({
987
+ Success: true,
988
+ Status: pContext.Status,
989
+ Hash: pContext.Hash,
990
+ RetryNode: tmpBody.NodeHash || '(auto-detected)',
991
+ WaitingTasks: Object.keys(pContext.WaitingTasks || {})
992
+ });
993
+ return fNext();
994
+ });
995
+ }.bind(this)
996
+ );
997
+
959
998
  // --- Operation Resume (for value-input tasks) ---
960
999
  this._OratorServer.post
961
1000
  (
@@ -1480,6 +1519,267 @@ class UltravisorAPIServer extends libPictService
1480
1519
  }.bind(this)
1481
1520
  );
1482
1521
 
1522
+ // --- Run Lifecycle: Start a hub-owned run ---
1523
+ this._OratorServer.post
1524
+ (
1525
+ '/Beacon/Run/Start',
1526
+ function (pRequest, pResponse, fNext)
1527
+ {
1528
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
1529
+ if (!tmpSession) { return; }
1530
+
1531
+ let tmpRunManager = this._getService('UltravisorBeaconRunManager');
1532
+ if (!tmpRunManager)
1533
+ {
1534
+ pResponse.send(500, { Error: 'BeaconRunManager service not available.' });
1535
+ return fNext();
1536
+ }
1537
+
1538
+ let tmpBody = pRequest.body || {};
1539
+ let tmpIdempotency = pRequest.headers['x-idempotency-key'] || tmpBody.IdempotencyKey || '';
1540
+
1541
+ let tmpRun = tmpRunManager.startRun({
1542
+ IdempotencyKey: tmpIdempotency,
1543
+ SubmitterTag: tmpBody.SubmitterTag || '',
1544
+ Metadata: tmpBody.Metadata || {}
1545
+ });
1546
+
1547
+ pResponse.send({
1548
+ Success: true,
1549
+ RunID: tmpRun.RunID,
1550
+ State: tmpRun.State,
1551
+ StartedAt: tmpRun.StartedAt,
1552
+ IdempotencyKey: tmpRun.IdempotencyKey || ''
1553
+ });
1554
+ return fNext();
1555
+ }.bind(this)
1556
+ );
1557
+
1558
+ // --- Run Lifecycle: End a run explicitly ---
1559
+ this._OratorServer.post
1560
+ (
1561
+ '/Beacon/Run/:RunID/End',
1562
+ function (pRequest, pResponse, fNext)
1563
+ {
1564
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
1565
+ if (!tmpSession) { return; }
1566
+
1567
+ let tmpRunManager = this._getService('UltravisorBeaconRunManager');
1568
+ if (!tmpRunManager)
1569
+ {
1570
+ pResponse.send(500, { Error: 'BeaconRunManager service not available.' });
1571
+ return fNext();
1572
+ }
1573
+
1574
+ let tmpBody = pRequest.body || {};
1575
+ let tmpState = tmpBody.State || 'Ended';
1576
+ let tmpOK = tmpRunManager.endRun(pRequest.params.RunID, tmpState);
1577
+ if (!tmpOK)
1578
+ {
1579
+ pResponse.send(404, { Error: `Run [${pRequest.params.RunID}] not found.` });
1580
+ return fNext();
1581
+ }
1582
+ pResponse.send({ Success: true, RunID: pRequest.params.RunID, State: tmpState });
1583
+ return fNext();
1584
+ }.bind(this)
1585
+ );
1586
+
1587
+ // --- Async Enqueue: returns the WorkItemHash immediately ---
1588
+ this._OratorServer.post
1589
+ (
1590
+ '/Beacon/Work/Enqueue',
1591
+ function (pRequest, pResponse, fNext)
1592
+ {
1593
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
1594
+ if (!tmpSession) { return; }
1595
+
1596
+ let tmpCoordinator = this._getService('UltravisorBeaconCoordinator');
1597
+ if (!tmpCoordinator)
1598
+ {
1599
+ pResponse.send(500, { Error: 'BeaconCoordinator service not available.' });
1600
+ return fNext();
1601
+ }
1602
+
1603
+ let tmpBody = pRequest.body || {};
1604
+ if (!tmpBody.Capability)
1605
+ {
1606
+ pResponse.send(400, { Error: 'Capability is required.' });
1607
+ return fNext();
1608
+ }
1609
+
1610
+ // If the client didn't supply a RunID, mint one on the fly so
1611
+ // every enqueue ends up attached to a durable run record.
1612
+ let tmpRunID = tmpBody.RunID || '';
1613
+ if (!tmpRunID)
1614
+ {
1615
+ let tmpRunManager = this._getService('UltravisorBeaconRunManager');
1616
+ if (tmpRunManager)
1617
+ {
1618
+ let tmpIdempotency = pRequest.headers['x-idempotency-key'] || '';
1619
+ let tmpRun = tmpRunManager.startRun({
1620
+ IdempotencyKey: tmpIdempotency,
1621
+ SubmitterTag: tmpBody.SubmitterTag || '',
1622
+ Metadata: tmpBody.Metadata || {}
1623
+ });
1624
+ tmpRunID = tmpRun.RunID;
1625
+ }
1626
+ }
1627
+
1628
+ let tmpWorkItemInfo = {
1629
+ RunID: tmpRunID,
1630
+ RunHash: tmpBody.RunHash || '',
1631
+ NodeHash: tmpBody.NodeHash || '',
1632
+ OperationHash: tmpBody.OperationHash || '',
1633
+ Capability: tmpBody.Capability,
1634
+ Action: tmpBody.Action || 'Execute',
1635
+ Settings: tmpBody.Settings || {},
1636
+ AffinityKey: tmpBody.AffinityKey || '',
1637
+ TimeoutMs: parseInt(tmpBody.TimeoutMs, 10) || 0,
1638
+ Priority: (tmpBody.Priority != null) ? parseInt(tmpBody.Priority, 10) : null
1639
+ };
1640
+
1641
+ let tmpWorkItem = tmpCoordinator.enqueueWorkItem(tmpWorkItemInfo);
1642
+ pResponse.send({
1643
+ Success: true,
1644
+ RunID: tmpRunID,
1645
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1646
+ Status: tmpWorkItem.Status,
1647
+ EnqueuedAt: tmpWorkItem.EnqueuedAt,
1648
+ Priority: tmpWorkItem.Priority
1649
+ });
1650
+ return fNext();
1651
+ }.bind(this)
1652
+ );
1653
+
1654
+ // --- Work Item Cancellation ---
1655
+ this._OratorServer.post
1656
+ (
1657
+ '/Beacon/Work/:WorkItemHash/Cancel',
1658
+ function (pRequest, pResponse, fNext)
1659
+ {
1660
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
1661
+ if (!tmpSession) { return; }
1662
+
1663
+ let tmpScheduler = this._getService('UltravisorBeaconScheduler');
1664
+ if (!tmpScheduler)
1665
+ {
1666
+ pResponse.send(500, { Error: 'BeaconScheduler service not available.' });
1667
+ return fNext();
1668
+ }
1669
+ let tmpBody = pRequest.body || {};
1670
+ let tmpResult = tmpScheduler.requestCancel(pRequest.params.WorkItemHash,
1671
+ tmpBody.Reason || 'cancel requested');
1672
+ if (tmpResult.Error === 'not found')
1673
+ {
1674
+ pResponse.send(404, { Error: `Work item [${pRequest.params.WorkItemHash}] not found.` });
1675
+ return fNext();
1676
+ }
1677
+ pResponse.send(Object.assign({ Success: !tmpResult.Error, WorkItemHash: pRequest.params.WorkItemHash }, tmpResult));
1678
+ return fNext();
1679
+ }.bind(this)
1680
+ );
1681
+
1682
+ // --- Work Item Reorder (Upcoming bucket) ---
1683
+ this._OratorServer.post
1684
+ (
1685
+ '/Beacon/Work/:WorkItemHash/Reorder',
1686
+ function (pRequest, pResponse, fNext)
1687
+ {
1688
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
1689
+ if (!tmpSession) { return; }
1690
+
1691
+ let tmpScheduler = this._getService('UltravisorBeaconScheduler');
1692
+ if (!tmpScheduler)
1693
+ {
1694
+ pResponse.send(500, { Error: 'BeaconScheduler service not available.' });
1695
+ return fNext();
1696
+ }
1697
+ let tmpBody = pRequest.body || {};
1698
+ let tmpDirection = tmpBody.Direction;
1699
+ if (tmpDirection !== 'up' && tmpDirection !== 'down')
1700
+ {
1701
+ pResponse.send(400, { Error: 'Direction must be "up" or "down".' });
1702
+ return fNext();
1703
+ }
1704
+ let tmpResult = tmpScheduler.reorderWorkItem(pRequest.params.WorkItemHash, tmpDirection);
1705
+ if (tmpResult.Error === 'not found')
1706
+ {
1707
+ pResponse.send(404, { Error: `Work item [${pRequest.params.WorkItemHash}] not found.` });
1708
+ return fNext();
1709
+ }
1710
+ if (!tmpResult.Reordered)
1711
+ {
1712
+ // 409 for "can't reorder in this state" (at edge, not Upcoming, etc.)
1713
+ pResponse.send(409, tmpResult);
1714
+ return fNext();
1715
+ }
1716
+ pResponse.send(Object.assign({ Success: true }, tmpResult));
1717
+ return fNext();
1718
+ }.bind(this)
1719
+ );
1720
+
1721
+ // --- Per-Work-Item Event Log ---
1722
+ this._OratorServer.get
1723
+ (
1724
+ '/Beacon/Work/:WorkItemHash/Events',
1725
+ function (pRequest, pResponse, fNext)
1726
+ {
1727
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
1728
+ if (!tmpSession) { return; }
1729
+
1730
+ let tmpStore = this._getService('UltravisorBeaconQueueStore');
1731
+ if (!tmpStore || !tmpStore.isEnabled || !tmpStore.isEnabled())
1732
+ {
1733
+ pResponse.send(503, { Error: 'BeaconQueueStore service not available.' });
1734
+ return fNext();
1735
+ }
1736
+
1737
+ let tmpHash = pRequest.params.WorkItemHash;
1738
+ let tmpLimit = pRequest.query ? parseInt(pRequest.query.limit, 10) || 500 : 500;
1739
+ let tmpItem = tmpStore.getWorkItemByHash(tmpHash);
1740
+ if (!tmpItem)
1741
+ {
1742
+ pResponse.send(404, { Error: `Work item [${tmpHash}] not found.` });
1743
+ return fNext();
1744
+ }
1745
+
1746
+ let tmpEvents = tmpStore.listEventsForWorkItem(tmpHash, tmpLimit);
1747
+ pResponse.send({
1748
+ WorkItemHash: tmpHash,
1749
+ Events: tmpEvents
1750
+ });
1751
+ return fNext();
1752
+ }.bind(this)
1753
+ );
1754
+
1755
+ // --- Queue Snapshot (buckets + summary + items) ---
1756
+ this._OratorServer.get
1757
+ (
1758
+ '/Beacon/Queue',
1759
+ function (pRequest, pResponse, fNext)
1760
+ {
1761
+ let tmpScheduler = this._getService('UltravisorBeaconScheduler');
1762
+ let tmpStore = this._getService('UltravisorBeaconQueueStore');
1763
+ let tmpSummary = tmpScheduler ? tmpScheduler.summarize() : null;
1764
+ let tmpBucket = pRequest.query ? pRequest.query.bucket : null;
1765
+ let tmpLimit = pRequest.query ? parseInt(pRequest.query.limit, 10) || 200 : 200;
1766
+
1767
+ let tmpItems = tmpScheduler ? tmpScheduler.listBuckets(tmpBucket, tmpLimit) : [];
1768
+ let tmpHistorical = null;
1769
+ if (tmpStore && tmpStore.isEnabled && tmpStore.isEnabled()
1770
+ && pRequest.query && pRequest.query.include === 'history')
1771
+ {
1772
+ tmpHistorical = tmpStore.listWorkItems({ Limit: tmpLimit, OrderBy: 'IDBeaconWorkItem DESC' });
1773
+ }
1774
+ pResponse.send({
1775
+ Summary: tmpSummary,
1776
+ Items: tmpItems,
1777
+ History: tmpHistorical
1778
+ });
1779
+ return fNext();
1780
+ }.bind(this)
1781
+ );
1782
+
1483
1783
  // --- Direct Dispatch (synchronous) ---
1484
1784
  this._OratorServer.post
1485
1785
  (
@@ -2002,6 +2302,30 @@ class UltravisorAPIServer extends libPictService
2002
2302
  {
2003
2303
  this._handleBeaconWSDeregister(pWebSocket, tmpData);
2004
2304
  }
2305
+ else if (tmpData.Action === 'QueueSubscribe')
2306
+ {
2307
+ this._QueueSubscribers.add(pWebSocket);
2308
+ pWebSocket._QueueSubscribed = true;
2309
+ // Send current summary immediately so the UI
2310
+ // doesn't wait a full tick to populate.
2311
+ let tmpSched = this._getService('UltravisorBeaconScheduler');
2312
+ if (tmpSched && typeof tmpSched.summarize === 'function')
2313
+ {
2314
+ try
2315
+ {
2316
+ pWebSocket.send(JSON.stringify({
2317
+ Topic: 'queue.summary',
2318
+ Payload: tmpSched.summarize()
2319
+ }));
2320
+ }
2321
+ catch (pErr) { /* ignore */ }
2322
+ }
2323
+ }
2324
+ else if (tmpData.Action === 'QueueUnsubscribe')
2325
+ {
2326
+ this._QueueSubscribers.delete(pWebSocket);
2327
+ pWebSocket._QueueSubscribed = false;
2328
+ }
2005
2329
  }.bind(this));
2006
2330
 
2007
2331
  pWebSocket.on('close',
@@ -2009,6 +2333,7 @@ class UltravisorAPIServer extends libPictService
2009
2333
  {
2010
2334
  this._unsubscribeClient(pWebSocket);
2011
2335
  this._cleanupBeaconWS(pWebSocket);
2336
+ this._QueueSubscribers.delete(pWebSocket);
2012
2337
  }.bind(this));
2013
2338
  }.bind(this));
2014
2339
 
@@ -2029,9 +2354,38 @@ class UltravisorAPIServer extends libPictService
2029
2354
  this.pushWorkItemToBeacon.bind(this));
2030
2355
  }
2031
2356
 
2357
+ // Wire the scheduler's broadcast hook to the queue.* WebSocket topic.
2358
+ let tmpScheduler = this._getService('UltravisorBeaconScheduler');
2359
+ if (tmpScheduler && typeof tmpScheduler.setBroadcastHandler === 'function')
2360
+ {
2361
+ tmpScheduler.setBroadcastHandler(
2362
+ this._broadcastQueueTopic.bind(this));
2363
+ }
2364
+
2032
2365
  this.log.info('Ultravisor WebSocket: execution event WebSocket server initialized.');
2033
2366
  }
2034
2367
 
2368
+ /**
2369
+ * Fan out a queue.* topic payload to all subscribed WebSocket clients.
2370
+ *
2371
+ * @param {string} pTopic - e.g. "queue.enqueued" / "queue.dispatched" / ...
2372
+ * @param {object} pPayload - topic-specific JSON body
2373
+ */
2374
+ _broadcastQueueTopic(pTopic, pPayload)
2375
+ {
2376
+ if (!this._QueueSubscribers || this._QueueSubscribers.size === 0) return;
2377
+ let tmpMessage = JSON.stringify({ Topic: pTopic, Payload: pPayload });
2378
+ this._QueueSubscribers.forEach(
2379
+ function (pClient)
2380
+ {
2381
+ if (pClient.readyState === libWebSocket.OPEN)
2382
+ {
2383
+ try { pClient.send(tmpMessage); }
2384
+ catch (pErr) { /* best effort */ }
2385
+ }
2386
+ });
2387
+ }
2388
+
2035
2389
  /**
2036
2390
  * Handle an execution event from the manifest service and broadcast
2037
2391
  * it to all WebSocket clients subscribed to that RunHash.