ultravisor 1.0.25 → 1.0.26

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.
@@ -2,11 +2,81 @@ const libPictService = require(`pict-serviceproviderbase`);
2
2
 
3
3
  const libFS = require('fs');
4
4
  const libPath = require('path');
5
+ const libCrypto = require('crypto');
5
6
  const libOrator = require('orator');
6
7
  const libOratorServiceServerRestify = require(`orator-serviceserver-restify`);
7
8
  const libOratorAuthentication = require('orator-authentication');
8
9
  const libWebSocket = require('ws');
9
10
 
11
+ // Strip a manifest down to the JSON-serializable shape the wire
12
+ // expects. The in-memory ExecutionContext can carry closures in
13
+ // PendingEvents and circular refs in WaitingTasks; this projection
14
+ // is what /Manifest/:RunHash has always returned. Used by both the
15
+ // in-memory and the bridge-backed read paths so callers see the
16
+ // same schema regardless of where the manifest came from.
17
+ function _cleanManifestForWire(pManifest)
18
+ {
19
+ if (!pManifest) return null;
20
+ return {
21
+ Hash: pManifest.Hash,
22
+ OperationHash: pManifest.OperationHash,
23
+ OperationName: pManifest.OperationName,
24
+ Status: pManifest.Status,
25
+ RunMode: pManifest.RunMode,
26
+ Live: pManifest.Live || false,
27
+ StartTime: pManifest.StartTime,
28
+ StopTime: pManifest.StopTime,
29
+ ElapsedMs: pManifest.ElapsedMs,
30
+ Output: pManifest.Output || {},
31
+ GlobalState: pManifest.GlobalState || {},
32
+ OperationState: pManifest.OperationState || {},
33
+ TaskOutputs: pManifest.TaskOutputs || {},
34
+ TaskManifests: pManifest.TaskManifests || {},
35
+ WaitingTasks: pManifest.WaitingTasks || {},
36
+ TimingSummary: pManifest.TimingSummary || null,
37
+ EventLog: pManifest.EventLog || [],
38
+ Errors: pManifest.Errors || [],
39
+ Log: pManifest.Log || []
40
+ };
41
+ }
42
+
43
+ // Lightweight query-string parser. Restify's queryParser plugin is
44
+ // not enabled on this server (mounting it would change the request
45
+ // shape for every existing handler), so the few routes that need
46
+ // query params parse the URL directly. Returns an empty object when
47
+ // no query string is present.
48
+ function _parseQueryString(pURL)
49
+ {
50
+ if (!pURL) return {};
51
+ let tmpQ = pURL.indexOf('?');
52
+ if (tmpQ < 0) return {};
53
+ let tmpStr = pURL.slice(tmpQ + 1);
54
+ let tmpOut = {};
55
+ let tmpPairs = tmpStr.split('&');
56
+ for (let i = 0; i < tmpPairs.length; i++)
57
+ {
58
+ if (!tmpPairs[i]) continue;
59
+ let tmpEq = tmpPairs[i].indexOf('=');
60
+ let tmpKey, tmpVal;
61
+ if (tmpEq < 0)
62
+ {
63
+ tmpKey = tmpPairs[i];
64
+ tmpVal = '';
65
+ }
66
+ else
67
+ {
68
+ tmpKey = tmpPairs[i].slice(0, tmpEq);
69
+ tmpVal = tmpPairs[i].slice(tmpEq + 1);
70
+ }
71
+ try { tmpKey = decodeURIComponent(tmpKey.replace(/\+/g, ' ')); }
72
+ catch (e) { /* leave raw */ }
73
+ try { tmpVal = decodeURIComponent(tmpVal.replace(/\+/g, ' ')); }
74
+ catch (e) { /* leave raw */ }
75
+ tmpOut[tmpKey] = tmpVal;
76
+ }
77
+ return tmpOut;
78
+ }
79
+
10
80
  class UltravisorAPIServer extends libPictService
11
81
  {
12
82
  constructor(pPict, pOptions, pServiceHash)
@@ -31,6 +101,110 @@ class UltravisorAPIServer extends libPictService
31
101
  // Set of WebSocket clients subscribed to the queue.* topic
32
102
  // (broadcast by UltravisorBeaconScheduler).
33
103
  this._QueueSubscribers = new Set();
104
+
105
+ // Queue-event ring buffer for GUID-anchored catch-up replay.
106
+ //
107
+ // Every queue.* delta the scheduler emits gets stamped with an
108
+ // EventGUID (immutable identity) + Seq (monotonic-per-process
109
+ // ordering hint, NOT identity) and stored here. When a client
110
+ // reconnects with {Action:"QueueSubscribe", LastEventGUID:X},
111
+ // we replay everything after X.
112
+ //
113
+ // Seq alone can't be used for identity: it resets on process
114
+ // restart, and a persistence beacon catching up from durable
115
+ // history would have to reconcile with whatever Seq counter
116
+ // happens to be running. EventGUID is the only stable handle.
117
+ //
118
+ // Snapshot/control frames (queue.summary, queue.replay_*,
119
+ // queue.reset) get an EventGUID for wire consistency but are
120
+ // NOT buffered — replaying a stale summary would briefly clobber
121
+ // correct counts, and control frames are session-scoped.
122
+ this._QueueEventBuffer = [];
123
+ this._QueueEventBufferCap = 2000;
124
+ this._QueueEventSeq = 0;
125
+
126
+ // Manifest (per-RunHash execution event) catch-up buffers.
127
+ //
128
+ // Same EventGUID-anchored resync protocol as the queue side, but
129
+ // with PER-RUN buffers instead of one global ring. A single ring
130
+ // would either evict mid-run events (when many runs are active)
131
+ // or be huge to compensate; per-run is cleaner because runs have
132
+ // a clear lifecycle (created → events → ExecutionComplete →
133
+ // dropped after a grace period). Per-run Seq counters also
134
+ // surface gaps locally to clients ("I had Seq=7 then jumped to
135
+ // Seq=10 — what happened to 8 and 9?") in a way a global Seq
136
+ // shared across runs can't.
137
+ //
138
+ // Maps keyed by RunHash:
139
+ // _ManifestEventBuffers — Array<envelope> per run
140
+ // _ManifestEventSeqs — int counter per run
141
+ // _ManifestEventCleanupTimers — setTimeout handle per finalized run
142
+ //
143
+ // Buffers persist until grace period elapses after the
144
+ // ExecutionComplete event, so a subscriber that reconnects
145
+ // shortly after a run finishes can still get the full replay.
146
+ this._ManifestEventBuffers = new Map();
147
+ this._ManifestEventSeqs = new Map();
148
+ this._ManifestEventCleanupTimers = new Map();
149
+ this._ManifestEventBufferCapPerRun = 5000;
150
+ this._ManifestEventGracePeriodMs = 5 * 60 * 1000;
151
+ }
152
+
153
+ // Topics that flow through the wire envelope but should NOT be
154
+ // retained in the replay buffer. queue.summary is a snapshot
155
+ // (replaying old ones would briefly show stale counts);
156
+ // queue.replay_* and queue.reset are control frames meant for a
157
+ // specific subscriber's resume cycle and don't belong in the
158
+ // shared history.
159
+ _isReplayableQueueTopic(pTopic)
160
+ {
161
+ if (pTopic === 'queue.summary') return false;
162
+ if (pTopic === 'queue.replay_begin') return false;
163
+ if (pTopic === 'queue.replay_complete') return false;
164
+ if (pTopic === 'queue.reset') return false;
165
+ return true;
166
+ }
167
+
168
+ // Find the buffer index of pGUID. Scans newest-first because the
169
+ // common case is "client reconnected after a brief gap" — the
170
+ // requested GUID is near the tail. Returns -1 when not found.
171
+ _findQueueEventIndex(pGUID)
172
+ {
173
+ if (!pGUID) return -1;
174
+ let tmpBuf = this._QueueEventBuffer;
175
+ for (let i = tmpBuf.length - 1; i >= 0; i--)
176
+ {
177
+ if (tmpBuf[i].EventGUID === pGUID) return i;
178
+ }
179
+ return -1;
180
+ }
181
+
182
+ // Manifest analogs — the protocol is the same shape but the buffer
183
+ // is partitioned by RunHash (see constructor).
184
+
185
+ // Control event types are session-scoped and never buffered. They
186
+ // also don't advance the per-run cursor (the cursor on the
187
+ // browser side intentionally pins to the last "real" execution
188
+ // event so a subsequent reconnect anchors against something the
189
+ // server actually has in its buffer).
190
+ _isReplayableExecutionEventType(pType)
191
+ {
192
+ if (pType === 'execution.replay_begin') return false;
193
+ if (pType === 'execution.replay_complete') return false;
194
+ if (pType === 'execution.reset') return false;
195
+ return true;
196
+ }
197
+
198
+ _findManifestEventIndex(pRunHash, pGUID)
199
+ {
200
+ if (!pRunHash || !pGUID) return -1;
201
+ let tmpBuf = this._ManifestEventBuffers.get(pRunHash);
202
+ if (!tmpBuf) return -1;
203
+ for (let i = tmpBuf.length - 1; i >= 0; i--)
204
+ {
205
+ if (tmpBuf[i].EventGUID === pGUID) return i;
206
+ }
207
+ return -1;
34
208
  }
35
209
 
36
210
  /**
@@ -751,14 +925,51 @@ class UltravisorAPIServer extends libPictService
751
925
  );
752
926
 
753
927
  // --- Manifests ---
928
+ // Reads merge two sources:
929
+ // - Live runs from the in-process UltravisorExecutionManifest
930
+ // service (in-memory, includes still-running operations).
931
+ // - Historical runs from the ManifestStore bridge (beacon when
932
+ // connected, on-disk fallback otherwise).
933
+ // Dedup by Hash; live wins so an in-flight run doesn't get
934
+ // replaced by a stale persisted snapshot.
754
935
  this._OratorServer.get
755
936
  (
756
937
  '/Manifest',
757
938
  function (pRequest, pResponse, fNext)
758
939
  {
759
940
  let tmpManifest = this._getService('UltravisorExecutionManifest');
760
- pResponse.send(tmpManifest ? tmpManifest.listRuns() : []);
761
- return fNext();
941
+ let tmpBridge = this._getService('UltravisorManifestStoreBridge');
942
+ let tmpLiveRuns = tmpManifest ? (tmpManifest.listRuns() || []) : [];
943
+ if (!tmpBridge)
944
+ {
945
+ pResponse.send(tmpLiveRuns);
946
+ return fNext();
947
+ }
948
+ tmpBridge.listManifests({}).then((pHist) =>
949
+ {
950
+ let tmpHist = (pHist && pHist.Manifests) || [];
951
+ let tmpSeen = new Set();
952
+ let tmpOut = [];
953
+ for (let i = 0; i < tmpLiveRuns.length; i++)
954
+ {
955
+ tmpSeen.add(tmpLiveRuns[i].Hash);
956
+ tmpOut.push(tmpLiveRuns[i]);
957
+ }
958
+ for (let i = 0; i < tmpHist.length; i++)
959
+ {
960
+ if (!tmpSeen.has(tmpHist[i].Hash))
961
+ {
962
+ tmpOut.push(tmpHist[i]);
963
+ }
964
+ }
965
+ pResponse.send(tmpOut);
966
+ return fNext();
967
+ }).catch(() =>
968
+ {
969
+ // Bridge failed — return whatever live data we have.
970
+ pResponse.send(tmpLiveRuns);
971
+ return fNext();
972
+ });
762
973
  }.bind(this)
763
974
  );
764
975
 
@@ -767,40 +978,37 @@ class UltravisorAPIServer extends libPictService
767
978
  '/Manifest/:RunHash',
768
979
  function (pRequest, pResponse, fNext)
769
980
  {
981
+ let tmpHash = pRequest.params.RunHash;
770
982
  let tmpManifest = this._getService('UltravisorExecutionManifest');
771
- let tmpRun = tmpManifest ? tmpManifest.getRun(pRequest.params.RunHash) : null;
983
+ let tmpRun = tmpManifest ? tmpManifest.getRun(tmpHash) : null;
772
984
  if (tmpRun)
773
985
  {
774
- // Send a clean, JSON-serializable snapshot of the run
775
- // (the raw context may contain closures in PendingEvents)
776
- let tmpClean = {
777
- Hash: tmpRun.Hash,
778
- OperationHash: tmpRun.OperationHash,
779
- OperationName: tmpRun.OperationName,
780
- Status: tmpRun.Status,
781
- RunMode: tmpRun.RunMode,
782
- Live: tmpRun.Live || false,
783
- StartTime: tmpRun.StartTime,
784
- StopTime: tmpRun.StopTime,
785
- ElapsedMs: tmpRun.ElapsedMs,
786
- Output: tmpRun.Output || {},
787
- GlobalState: tmpRun.GlobalState || {},
788
- OperationState: tmpRun.OperationState || {},
789
- TaskOutputs: tmpRun.TaskOutputs || {},
790
- TaskManifests: tmpRun.TaskManifests || {},
791
- WaitingTasks: tmpRun.WaitingTasks || {},
792
- TimingSummary: tmpRun.TimingSummary || null,
793
- EventLog: tmpRun.EventLog || [],
794
- Errors: tmpRun.Errors || [],
795
- Log: tmpRun.Log || []
796
- };
797
- pResponse.send(tmpClean);
986
+ pResponse.send(_cleanManifestForWire(tmpRun));
987
+ return fNext();
798
988
  }
799
- else
989
+ // Not in memory — try the bridge (beacon-backed history).
990
+ let tmpBridge = this._getService('UltravisorManifestStoreBridge');
991
+ if (!tmpBridge)
800
992
  {
801
- pResponse.send(404, { Error: `Manifest ${pRequest.params.RunHash} not found.` });
993
+ pResponse.send(404, { Error: `Manifest ${tmpHash} not found.` });
994
+ return fNext();
802
995
  }
803
- return fNext();
996
+ tmpBridge.getManifest(tmpHash).then((pResult) =>
997
+ {
998
+ if (pResult && pResult.Success && pResult.Manifest)
999
+ {
1000
+ pResponse.send(_cleanManifestForWire(pResult.Manifest));
1001
+ }
1002
+ else
1003
+ {
1004
+ pResponse.send(404, { Error: `Manifest ${tmpHash} not found.` });
1005
+ }
1006
+ return fNext();
1007
+ }).catch((pErr) =>
1008
+ {
1009
+ pResponse.send(500, { Error: 'Bridge read failed: ' + (pErr && pErr.message) });
1010
+ return fNext();
1011
+ });
804
1012
  }.bind(this)
805
1013
  );
806
1014
 
@@ -1719,36 +1927,56 @@ class UltravisorAPIServer extends libPictService
1719
1927
  );
1720
1928
 
1721
1929
  // --- Per-Work-Item Event Log ---
1930
+ // Read-only — matches the no-auth precedent of /Beacon/Queue
1931
+ // and /Manifest/:hash. Retold-Labs proxies this for the /queue
1932
+ // drawer so the lab user can see what happened to a work item
1933
+ // without needing a separate ultravisor session. (Cancel and
1934
+ // Reorder, the state-changing siblings, still require a session.)
1722
1935
  this._OratorServer.get
1723
1936
  (
1724
1937
  '/Beacon/Work/:WorkItemHash/Events',
1725
1938
  function (pRequest, pResponse, fNext)
1726
1939
  {
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())
1940
+ // Read via the persistence bridge — beacon-backed when
1941
+ // connected, local QueueStore otherwise. The bridge
1942
+ // returns {Available, Success, WorkItem|Events, Reason}
1943
+ // so we can distinguish "no backend at all" (503),
1944
+ // "unknown work item" (404), and success (200).
1945
+ let tmpBridge = this._getService('UltravisorQueuePersistenceBridge');
1946
+ if (!tmpBridge)
1732
1947
  {
1733
- pResponse.send(503, { Error: 'BeaconQueueStore service not available.' });
1948
+ pResponse.send(503, { Error: 'QueuePersistenceBridge service not available.' });
1734
1949
  return fNext();
1735
1950
  }
1736
1951
 
1737
1952
  let tmpHash = pRequest.params.WorkItemHash;
1738
1953
  let tmpLimit = pRequest.query ? parseInt(pRequest.query.limit, 10) || 500 : 500;
1739
- let tmpItem = tmpStore.getWorkItemByHash(tmpHash);
1740
- if (!tmpItem)
1954
+
1955
+ tmpBridge.getWorkItemByHash(tmpHash).then((pItemResult) =>
1956
+ {
1957
+ if (!pItemResult || !pItemResult.Available)
1958
+ {
1959
+ pResponse.send(503, { Error: 'No persistence backend available.' });
1960
+ return fNext();
1961
+ }
1962
+ if (!pItemResult.Success || !pItemResult.WorkItem)
1963
+ {
1964
+ pResponse.send(404, { Error: `Work item [${tmpHash}] not found.` });
1965
+ return fNext();
1966
+ }
1967
+ return tmpBridge.getEvents(tmpHash, tmpLimit).then((pEventsResult) =>
1968
+ {
1969
+ pResponse.send({
1970
+ WorkItemHash: tmpHash,
1971
+ Events: (pEventsResult && pEventsResult.Events) || []
1972
+ });
1973
+ return fNext();
1974
+ });
1975
+ }).catch((pErr) =>
1741
1976
  {
1742
- pResponse.send(404, { Error: `Work item [${tmpHash}] not found.` });
1977
+ pResponse.send(500, { Error: 'Bridge dispatch failed: ' + (pErr && pErr.message) });
1743
1978
  return fNext();
1744
- }
1745
-
1746
- let tmpEvents = tmpStore.listEventsForWorkItem(tmpHash, tmpLimit);
1747
- pResponse.send({
1748
- WorkItemHash: tmpHash,
1749
- Events: tmpEvents
1750
1979
  });
1751
- return fNext();
1752
1980
  }.bind(this)
1753
1981
  );
1754
1982
 
@@ -1759,24 +1987,99 @@ class UltravisorAPIServer extends libPictService
1759
1987
  function (pRequest, pResponse, fNext)
1760
1988
  {
1761
1989
  let tmpScheduler = this._getService('UltravisorBeaconScheduler');
1762
- let tmpStore = this._getService('UltravisorBeaconQueueStore');
1763
1990
  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;
1991
+ // Restify's queryParser plugin isn't installed on this
1992
+ // server, so pRequest.query is undefined. Parse the URL
1993
+ // directly. Cheap, no dependency.
1994
+ let tmpQuery = _parseQueryString(pRequest.url || '');
1995
+ let tmpBucket = tmpQuery.bucket || null;
1996
+ let tmpLimit = parseInt(tmpQuery.limit, 10) || 200;
1766
1997
 
1767
1998
  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')
1999
+
2000
+ // History is opt-in via ?include=history. When opted in,
2001
+ // pull through the persistence bridge so a connected
2002
+ // QueuePersistence beacon owns the long-tail history view.
2003
+ let tmpWantsHistory = tmpQuery.include === 'history';
2004
+ if (!tmpWantsHistory)
1771
2005
  {
1772
- tmpHistorical = tmpStore.listWorkItems({ Limit: tmpLimit, OrderBy: 'IDBeaconWorkItem DESC' });
2006
+ pResponse.send({ Summary: tmpSummary, Items: tmpItems, History: null });
2007
+ return fNext();
1773
2008
  }
1774
- pResponse.send({
1775
- Summary: tmpSummary,
1776
- Items: tmpItems,
1777
- History: tmpHistorical
1778
- });
1779
- return fNext();
2009
+ let tmpBridge = this._getService('UltravisorQueuePersistenceBridge');
2010
+ if (!tmpBridge)
2011
+ {
2012
+ pResponse.send({ Summary: tmpSummary, Items: tmpItems, History: null });
2013
+ return fNext();
2014
+ }
2015
+ tmpBridge.listWorkItems({ Limit: tmpLimit, OrderBy: '-EnqueuedAt' })
2016
+ .then((pHistResult) =>
2017
+ {
2018
+ let tmpHistorical = (pHistResult && pHistResult.WorkItems) || null;
2019
+ pResponse.send({
2020
+ Summary: tmpSummary,
2021
+ Items: tmpItems,
2022
+ History: tmpHistorical
2023
+ });
2024
+ return fNext();
2025
+ })
2026
+ .catch(() =>
2027
+ {
2028
+ // Don't fail the whole snapshot if history
2029
+ // fetch errors — return what we have.
2030
+ pResponse.send({ Summary: tmpSummary, Items: tmpItems, History: null });
2031
+ return fNext();
2032
+ });
2033
+ }.bind(this)
2034
+ );
2035
+
2036
+ // --- One-time admin bootstrap (no session required) ---
2037
+ // Body: {Token, UserSpec:{Username, Password, Roles?}}
2038
+ // Dispatches AUTH_BootstrapAdmin via the bridge. Intentionally
2039
+ // unauthenticated — the bootstrap is the chicken-and-egg path
2040
+ // for creating the very first admin in a fresh non-promiscuous
2041
+ // mesh, before any session exists. Defense in depth: the auth
2042
+ // beacon validates the token via constant-time compare AND
2043
+ // consumes it on first success, so brute-force or replay
2044
+ // attempts past the first hit fail.
2045
+ this._OratorServer.post
2046
+ (
2047
+ '/Beacon/BootstrapAdmin',
2048
+ function (pRequest, pResponse, fNext)
2049
+ {
2050
+ let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
2051
+ if (!tmpBridge)
2052
+ {
2053
+ pResponse.send(503, { Success: false, Reason: 'AuthBeaconBridge not available' });
2054
+ return fNext();
2055
+ }
2056
+ if (!tmpBridge.isAvailable())
2057
+ {
2058
+ pResponse.send(503, { Success: false, Reason: 'No auth beacon connected' });
2059
+ return fNext();
2060
+ }
2061
+ let tmpBody = pRequest.body || {};
2062
+ tmpBridge.bootstrapAdmin(tmpBody.Token, tmpBody.UserSpec || {})
2063
+ .then((pResult) =>
2064
+ {
2065
+ let tmpStatus = (pResult && pResult.Success) ? 200 : 400;
2066
+ if (pResult && /not (supported|reachable)/i.test(pResult.Reason || ''))
2067
+ {
2068
+ tmpStatus = 503;
2069
+ }
2070
+ pResponse.send(tmpStatus, pResult || { Success: false });
2071
+ return fNext();
2072
+ })
2073
+ .catch((pErr) =>
2074
+ {
2075
+ pResponse.send(502,
2076
+ {
2077
+ Success: false,
2078
+ Error: 'Bootstrap dispatch failed',
2079
+ Reason: (pErr && pErr.message) || String(pErr)
2080
+ });
2081
+ return fNext();
2082
+ });
1780
2083
  }.bind(this)
1781
2084
  );
1782
2085
 
@@ -2279,6 +2582,86 @@ class UltravisorAPIServer extends libPictService
2279
2582
  }.bind(this)
2280
2583
  );
2281
2584
 
2585
+ // --- Persistence assignment (Session 3) ---
2586
+ // The lab POSTs an explicit persistence assignment to a running
2587
+ // UV; both bridges' setPersistenceAssignment fires their bootstrap
2588
+ // state machines if the chosen beacon is already Online. The GET
2589
+ // returns the merged Queue + Manifest status object the lab status
2590
+ // pill polls. See docs/features/persistence-via-databeacon.md.
2591
+ this._OratorServer.post
2592
+ (
2593
+ '/Ultravisor/Persistence/Assign',
2594
+ function (pRequest, pResponse, fNext)
2595
+ {
2596
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
2597
+ if (tmpSession === null) return;
2598
+
2599
+ let tmpQueueBridge = this._getService('UltravisorQueuePersistenceBridge');
2600
+ let tmpManifestBridge = this._getService('UltravisorManifestStoreBridge');
2601
+ if (!tmpQueueBridge || !tmpManifestBridge)
2602
+ {
2603
+ pResponse.send(502, { Error: 'Persistence bridges not available.' });
2604
+ return fNext();
2605
+ }
2606
+
2607
+ let tmpBody = pRequest.body || {};
2608
+ let tmpBeaconID = (tmpBody.BeaconID === null || tmpBody.BeaconID === undefined) ? null : String(tmpBody.BeaconID);
2609
+ let tmpIDConn = parseInt(tmpBody.IDBeaconConnection, 10) || 0;
2610
+
2611
+ try
2612
+ {
2613
+ if (!tmpBeaconID)
2614
+ {
2615
+ tmpQueueBridge.clearPersistenceAssignment();
2616
+ tmpManifestBridge.clearPersistenceAssignment();
2617
+ }
2618
+ else
2619
+ {
2620
+ tmpQueueBridge.setPersistenceAssignment(tmpBeaconID, tmpIDConn);
2621
+ tmpManifestBridge.setPersistenceAssignment(tmpBeaconID, tmpIDConn);
2622
+ }
2623
+ }
2624
+ catch (pErr)
2625
+ {
2626
+ pResponse.send(500, { Error: pErr.message || String(pErr) });
2627
+ return fNext();
2628
+ }
2629
+
2630
+ pResponse.send(
2631
+ {
2632
+ Success: true,
2633
+ Queue: tmpQueueBridge.getPersistenceStatus(),
2634
+ Manifest: tmpManifestBridge.getPersistenceStatus()
2635
+ });
2636
+ return fNext();
2637
+ }.bind(this)
2638
+ );
2639
+
2640
+ this._OratorServer.get
2641
+ (
2642
+ '/Ultravisor/Persistence/Status',
2643
+ function (pRequest, pResponse, fNext)
2644
+ {
2645
+ let tmpSession = this._requireSession(pRequest, pResponse, fNext);
2646
+ if (tmpSession === null) return;
2647
+
2648
+ let tmpQueueBridge = this._getService('UltravisorQueuePersistenceBridge');
2649
+ let tmpManifestBridge = this._getService('UltravisorManifestStoreBridge');
2650
+ if (!tmpQueueBridge || !tmpManifestBridge)
2651
+ {
2652
+ pResponse.send(502, { Error: 'Persistence bridges not available.' });
2653
+ return fNext();
2654
+ }
2655
+
2656
+ pResponse.send(
2657
+ {
2658
+ Queue: tmpQueueBridge.getPersistenceStatus(),
2659
+ Manifest: tmpManifestBridge.getPersistenceStatus()
2660
+ });
2661
+ return fNext();
2662
+ }.bind(this)
2663
+ );
2664
+
2282
2665
  return fCallback();
2283
2666
  }
2284
2667
 
@@ -2335,15 +2718,80 @@ class UltravisorAPIServer extends libPictService
2335
2718
  function (fNext)
2336
2719
  {
2337
2720
  this.fable.addServiceTypeIfNotExists('OratorAuthentication', libOratorAuthentication);
2721
+
2722
+ // Wire orator-authentication's BeaconAuthenticator hook
2723
+ // at construction time — orator-auth installs the provider
2724
+ // in its constructor when this option is present, so we
2725
+ // don't need any setAuthenticator() call here. The
2726
+ // dispatcher is a thin wrapper around the bridge so that
2727
+ // orator-authentication itself stays free of any
2728
+ // ultravisor-specific imports.
2729
+ let tmpBeaconAuth =
2730
+ {
2731
+ Dispatcher: (pCapability, pAction, pSettings) =>
2732
+ {
2733
+ let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
2734
+ if (!tmpBridge)
2735
+ {
2736
+ return Promise.resolve({ Outputs: { Success: false, Reason: 'No bridge' } });
2737
+ }
2738
+ // dispatchAction returns {Available, ...Outputs} —
2739
+ // wrap it in {Outputs:...} so the orator-auth
2740
+ // provider's response-shape parser unwraps cleanly
2741
+ // (it accepts either bare outputs or an Outputs
2742
+ // envelope, but the envelope is more honest about
2743
+ // where the data came from).
2744
+ return tmpBridge.dispatchAction(pAction, pSettings)
2745
+ .then((pResult) => ({ Outputs: pResult }));
2746
+ }
2747
+ };
2748
+
2338
2749
  this._OratorAuth = this.fable.instantiateServiceProvider('OratorAuthentication',
2339
2750
  {
2340
2751
  RoutePrefix: '/1.0/',
2341
2752
  SessionTTL: this.fable.settings.UltravisorBeaconSessionTTLMs || 86400000,
2342
2753
  CookieHttpOnly: true,
2343
- CookieSecure: false
2754
+ CookieSecure: false,
2755
+ BeaconAuthenticator: tmpBeaconAuth
2344
2756
  });
2345
2757
  this._OratorAuth.connectRoutes();
2346
2758
  this.log.info('Ultravisor: OratorAuthentication routes registered.');
2759
+
2760
+ // User management routes — REST surface for the auth-beacon
2761
+ // AUTH_*User actions, gated by orator-auth sessions + a
2762
+ // configurable IsAdmin check. Mounted under the same /1.0/
2763
+ // prefix so login cookies cover both paths. The helper
2764
+ // itself lives in the auth-beacon module so any other
2765
+ // orator-auth consumer can use it (lab, content-system, ...).
2766
+ try
2767
+ {
2768
+ let libUserMgmtRoutes = require('ultravisor-auth-beacon/source/server-routes.cjs');
2769
+ libUserMgmtRoutes.mountUserManagementRoutes(this._OratorServer.server,
2770
+ {
2771
+ OratorAuth: this._OratorAuth,
2772
+ RoutePrefix: '/1.0/',
2773
+ Log: this.log,
2774
+ Dispatcher: (pAction, pSettings) =>
2775
+ {
2776
+ let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
2777
+ if (!tmpBridge)
2778
+ {
2779
+ return Promise.resolve({ Success: false, Reason: 'No bridge' });
2780
+ }
2781
+ return tmpBridge.dispatchAction(pAction, pSettings);
2782
+ }
2783
+ });
2784
+ }
2785
+ catch (pUMError)
2786
+ {
2787
+ // Non-fatal: ultravisor still works without user-mgmt
2788
+ // routes. The auth flow still functions through
2789
+ // orator-auth + the bridge. Log loud so an operator
2790
+ // noticing missing /Users endpoints can find the cause.
2791
+ this.log.warn('Ultravisor: user-management route mount failed: '
2792
+ + (pUMError && pUMError.message)
2793
+ + ' — Login still works, user CRUD endpoints disabled.');
2794
+ }
2347
2795
  return fNext();
2348
2796
  }.bind(this));
2349
2797
 
@@ -2488,6 +2936,18 @@ class UltravisorAPIServer extends libPictService
2488
2936
  if (tmpData.Action === 'Subscribe' && tmpData.RunHash)
2489
2937
  {
2490
2938
  // --- Execution event subscription (web UI) ---
2939
+ //
2940
+ // Mirrors the QueueSubscribe resync protocol:
2941
+ // callers that pass LastEventGUID get a
2942
+ // replay block (replay_begin / events after
2943
+ // that GUID, in order / replay_complete).
2944
+ // Callers that pass null/omit get today's
2945
+ // behavior — subscribe and resume the live
2946
+ // feed. If LastEventGUID isn't in our
2947
+ // per-run buffer (gap older than the buffer
2948
+ // or run finalized + dropped after grace),
2949
+ // emit execution.reset so the client falls
2950
+ // back to /Manifest/:RunHash.
2491
2951
  this._unsubscribeClient(pWebSocket);
2492
2952
  pWebSocket._SubscribedRunHash = tmpData.RunHash;
2493
2953
 
@@ -2496,6 +2956,37 @@ class UltravisorAPIServer extends libPictService
2496
2956
  this._WebSocketSubscriptions[tmpData.RunHash] = new Set();
2497
2957
  }
2498
2958
  this._WebSocketSubscriptions[tmpData.RunHash].add(pWebSocket);
2959
+
2960
+ let tmpLastGUID = tmpData.LastEventGUID || null;
2961
+ if (tmpLastGUID)
2962
+ {
2963
+ let tmpIdx = this._findManifestEventIndex(
2964
+ tmpData.RunHash, tmpLastGUID);
2965
+ if (tmpIdx < 0)
2966
+ {
2967
+ this._sendManifestControlFrame(pWebSocket,
2968
+ 'execution.reset', tmpData.RunHash,
2969
+ { Reason: 'history-too-old', LastEventGUID: tmpLastGUID });
2970
+ }
2971
+ else
2972
+ {
2973
+ let tmpBuf = this._ManifestEventBuffers.get(tmpData.RunHash) || [];
2974
+ let tmpReplay = tmpBuf.slice(tmpIdx + 1);
2975
+ this._sendManifestControlFrame(pWebSocket,
2976
+ 'execution.replay_begin', tmpData.RunHash,
2977
+ { FromGUID: tmpLastGUID, Count: tmpReplay.length });
2978
+ for (let i = 0; i < tmpReplay.length; i++)
2979
+ {
2980
+ this._sendManifestEnvelope(pWebSocket, tmpReplay[i]);
2981
+ }
2982
+ let tmpThrough = tmpReplay.length
2983
+ ? tmpReplay[tmpReplay.length - 1].EventGUID
2984
+ : tmpLastGUID;
2985
+ this._sendManifestControlFrame(pWebSocket,
2986
+ 'execution.replay_complete', tmpData.RunHash,
2987
+ { ThroughGUID: tmpThrough, Count: tmpReplay.length });
2988
+ }
2989
+ }
2499
2990
  }
2500
2991
  else if (tmpData.Action === 'Unsubscribe')
2501
2992
  {
@@ -2538,19 +3029,69 @@ class UltravisorAPIServer extends libPictService
2538
3029
  }
2539
3030
  else if (tmpData.Action === 'QueueSubscribe')
2540
3031
  {
3032
+ // QueueSubscribe doubles as a resync request:
3033
+ // callers that pass LastEventGUID get a
3034
+ // replay block (replay_begin / events after
3035
+ // that GUID, in order / replay_complete) BEFORE
3036
+ // the live summary push. Callers that pass
3037
+ // null (or omit it) get today's behavior — a
3038
+ // fresh summary with no replay.
3039
+ //
3040
+ // If the requested LastEventGUID isn't in our
3041
+ // ring buffer (gap older than the buffer,
3042
+ // process restarted, etc.), we emit
3043
+ // queue.reset so the client falls back to a
3044
+ // REST snapshot. The persistence-beacon
3045
+ // fallback for deeper history is the
3046
+ // bootstrap-flush task's territory.
2541
3047
  this._QueueSubscribers.add(pWebSocket);
2542
3048
  pWebSocket._QueueSubscribed = true;
3049
+ let tmpLastGUID = tmpData.LastEventGUID || null;
3050
+ if (tmpLastGUID)
3051
+ {
3052
+ let tmpIdx = this._findQueueEventIndex(tmpLastGUID);
3053
+ if (tmpIdx < 0)
3054
+ {
3055
+ // TODO(bootstrap-flush): before giving up,
3056
+ // query the queue persistence beacon for
3057
+ // events after pGUID and emit them here.
3058
+ // Today the in-process ring is the only
3059
+ // tier, so a miss = reset.
3060
+ this._sendQueueControlFrame(pWebSocket,
3061
+ 'queue.reset',
3062
+ { Reason: 'history-too-old', LastEventGUID: tmpLastGUID });
3063
+ }
3064
+ else
3065
+ {
3066
+ let tmpReplay = this._QueueEventBuffer.slice(tmpIdx + 1);
3067
+ this._sendQueueControlFrame(pWebSocket,
3068
+ 'queue.replay_begin',
3069
+ { FromGUID: tmpLastGUID, Count: tmpReplay.length });
3070
+ for (let i = 0; i < tmpReplay.length; i++)
3071
+ {
3072
+ this._sendQueueEnvelope(pWebSocket, tmpReplay[i]);
3073
+ }
3074
+ let tmpThrough = tmpReplay.length
3075
+ ? tmpReplay[tmpReplay.length - 1].EventGUID
3076
+ : tmpLastGUID;
3077
+ this._sendQueueControlFrame(pWebSocket,
3078
+ 'queue.replay_complete',
3079
+ { ThroughGUID: tmpThrough, Count: tmpReplay.length });
3080
+ }
3081
+ }
2543
3082
  // Send current summary immediately so the UI
2544
- // doesn't wait a full tick to populate.
3083
+ // doesn't wait a full tick to populate. Stamp
3084
+ // it through the same envelope path so
3085
+ // LastEventGUID tracking on the client stays
3086
+ // monotone.
2545
3087
  let tmpSched = this._getService('UltravisorBeaconScheduler');
2546
3088
  if (tmpSched && typeof tmpSched.summarize === 'function')
2547
3089
  {
2548
3090
  try
2549
3091
  {
2550
- pWebSocket.send(JSON.stringify({
2551
- Topic: 'queue.summary',
2552
- Payload: tmpSched.summarize()
2553
- }));
3092
+ let tmpSummaryEnv = this._stampQueueEvent(
3093
+ 'queue.summary', tmpSched.summarize());
3094
+ this._sendQueueEnvelope(pWebSocket, tmpSummaryEnv);
2554
3095
  }
2555
3096
  catch (pErr) { /* ignore */ }
2556
3097
  }
@@ -2600,15 +3141,55 @@ class UltravisorAPIServer extends libPictService
2600
3141
  }
2601
3142
 
2602
3143
  /**
2603
- * Fan out a queue.* topic payload to all subscribed WebSocket clients.
3144
+ * Stamp a queue topic payload with an envelope and (when replayable)
3145
+ * append it to the ring buffer. Returns the wire envelope so callers
3146
+ * can serialize once and reuse the same frame for live + replay.
3147
+ *
3148
+ * The envelope shape is:
3149
+ * { Topic, Payload, EventGUID, Seq, EmittedAt }
3150
+ *
3151
+ * EventGUID is the durable identity (UUID v4); Seq is a per-process
3152
+ * monotonic ordering hint that resets on restart and so cannot be
3153
+ * trusted for dedup.
3154
+ */
3155
+ _stampQueueEvent(pTopic, pPayload)
3156
+ {
3157
+ this._QueueEventSeq += 1;
3158
+ let tmpEnvelope =
3159
+ {
3160
+ Topic: pTopic,
3161
+ Payload: pPayload,
3162
+ EventGUID: libCrypto.randomUUID(),
3163
+ Seq: this._QueueEventSeq,
3164
+ EmittedAt: new Date().toISOString()
3165
+ };
3166
+ if (this._isReplayableQueueTopic(pTopic))
3167
+ {
3168
+ this._QueueEventBuffer.push(tmpEnvelope);
3169
+ if (this._QueueEventBuffer.length > this._QueueEventBufferCap)
3170
+ {
3171
+ this._QueueEventBuffer.splice(
3172
+ 0, this._QueueEventBuffer.length - this._QueueEventBufferCap);
3173
+ }
3174
+ }
3175
+ return tmpEnvelope;
3176
+ }
3177
+
3178
+ /**
3179
+ * Fan out a queue.* topic payload to all subscribed WebSocket
3180
+ * clients. Stamps the envelope first so the live feed and the
3181
+ * replay buffer carry the same frame shape.
2604
3182
  *
2605
3183
  * @param {string} pTopic - e.g. "queue.enqueued" / "queue.dispatched" / ...
2606
3184
  * @param {object} pPayload - topic-specific JSON body
2607
3185
  */
2608
3186
  _broadcastQueueTopic(pTopic, pPayload)
2609
3187
  {
3188
+ // Always stamp — even if no current subscribers — so the buffer
3189
+ // captures history that a future reconnect can replay against.
3190
+ let tmpEnvelope = this._stampQueueEvent(pTopic, pPayload);
2610
3191
  if (!this._QueueSubscribers || this._QueueSubscribers.size === 0) return;
2611
- let tmpMessage = JSON.stringify({ Topic: pTopic, Payload: pPayload });
3192
+ let tmpMessage = JSON.stringify(tmpEnvelope);
2612
3193
  this._QueueSubscribers.forEach(
2613
3194
  function (pClient)
2614
3195
  {
@@ -2621,40 +3202,169 @@ class UltravisorAPIServer extends libPictService
2621
3202
  }
2622
3203
 
2623
3204
  /**
2624
- * Handle an execution event from the manifest service and broadcast
2625
- * it to all WebSocket clients subscribed to that RunHash.
3205
+ * Send a stamped envelope to a single subscriber. Used by the
3206
+ * replay path so the catch-up frames carry the same shape (and
3207
+ * the same EventGUID) as the originals on the live feed.
3208
+ */
3209
+ _sendQueueEnvelope(pClient, pEnvelope)
3210
+ {
3211
+ if (!pClient || pClient.readyState !== libWebSocket.OPEN) return;
3212
+ try { pClient.send(JSON.stringify(pEnvelope)); }
3213
+ catch (pErr) { /* best effort */ }
3214
+ }
3215
+
3216
+ /**
3217
+ * Emit a control frame (replay_begin / replay_complete / reset) to
3218
+ * a single subscriber. Stamps the envelope so the wire shape is
3219
+ * uniform, but skips the ring buffer per `_isReplayableQueueTopic`.
3220
+ */
3221
+ _sendQueueControlFrame(pClient, pTopic, pPayload)
3222
+ {
3223
+ let tmpEnvelope = this._stampQueueEvent(pTopic, pPayload || {});
3224
+ this._sendQueueEnvelope(pClient, tmpEnvelope);
3225
+ }
3226
+
3227
+ /**
3228
+ * Stamp an execution event with the resync envelope (EventGUID,
3229
+ * Seq, EmittedAt) and append it to the per-run buffer when the
3230
+ * type is replayable. Returns the wire envelope so callers can
3231
+ * broadcast the same frame to live subscribers.
2626
3232
  *
2627
- * @param {string} pEventType - The event type (TaskStart, TaskComplete, etc.).
2628
- * @param {string} pRunHash - The execution run hash.
2629
- * @param {object} pEventData - The event data.
3233
+ * Per-run Seq is monotonic within a single run only; different
3234
+ * runs have independent counters. EventGUID is globally unique
3235
+ * (UUID v4) and is what survives across process restarts as the
3236
+ * dedup key.
2630
3237
  */
2631
- _onExecutionEvent(pEventType, pRunHash, pEventData)
3238
+ _stampManifestEvent(pEventType, pRunHash, pData)
2632
3239
  {
2633
- let tmpSubscribers = this._WebSocketSubscriptions[pRunHash];
3240
+ let tmpSeq = (this._ManifestEventSeqs.get(pRunHash) || 0) + 1;
3241
+ this._ManifestEventSeqs.set(pRunHash, tmpSeq);
3242
+ let tmpEnvelope =
3243
+ {
3244
+ EventType: pEventType,
3245
+ RunHash: pRunHash,
3246
+ Data: pData,
3247
+ EventGUID: libCrypto.randomUUID(),
3248
+ Seq: tmpSeq,
3249
+ EmittedAt: new Date().toISOString()
3250
+ };
3251
+ if (this._isReplayableExecutionEventType(pEventType))
3252
+ {
3253
+ let tmpBuf = this._ManifestEventBuffers.get(pRunHash);
3254
+ if (!tmpBuf)
3255
+ {
3256
+ tmpBuf = [];
3257
+ this._ManifestEventBuffers.set(pRunHash, tmpBuf);
3258
+ }
3259
+ tmpBuf.push(tmpEnvelope);
3260
+ if (tmpBuf.length > this._ManifestEventBufferCapPerRun)
3261
+ {
3262
+ tmpBuf.splice(0, tmpBuf.length - this._ManifestEventBufferCapPerRun);
3263
+ }
3264
+ }
3265
+ return tmpEnvelope;
3266
+ }
3267
+
3268
+ /**
3269
+ * Send a stamped envelope to a single subscriber. Used by both the
3270
+ * live broadcast and the replay path.
3271
+ */
3272
+ _sendManifestEnvelope(pClient, pEnvelope)
3273
+ {
3274
+ if (!pClient || pClient.readyState !== libWebSocket.OPEN) return;
3275
+ try { pClient.send(JSON.stringify(pEnvelope)); }
3276
+ catch (pErr) { /* best effort */ }
3277
+ }
3278
+
3279
+ /**
3280
+ * Emit a control frame (execution.replay_begin / replay_complete /
3281
+ * reset) to a single subscriber. Stamped through the same envelope
3282
+ * path so wire shape is uniform; gated out of the buffer by
3283
+ * _isReplayableExecutionEventType.
3284
+ */
3285
+ _sendManifestControlFrame(pClient, pEventType, pRunHash, pData)
3286
+ {
3287
+ let tmpEnvelope = this._stampManifestEvent(pEventType, pRunHash, pData || {});
3288
+ this._sendManifestEnvelope(pClient, tmpEnvelope);
3289
+ }
2634
3290
 
2635
- if (!tmpSubscribers || tmpSubscribers.size === 0)
3291
+ /**
3292
+ * Schedule a per-run buffer cleanup. Called once we see the
3293
+ * terminal ExecutionComplete event for a run; the buffer survives
3294
+ * the grace period so a subscriber that reconnects shortly after
3295
+ * the run finishes can still pull the full event log.
3296
+ *
3297
+ * Re-arming is idempotent: if a cleanup is already scheduled (e.g.
3298
+ * because of an earlier near-terminal event we treated as
3299
+ * "probably the end"), the existing timer is replaced with a fresh
3300
+ * one. Active runs that haven't finished never have a timer, so
3301
+ * their buffers grow until ExecutionComplete arrives.
3302
+ *
3303
+ * Assumption: every run that emits any execution event also emits
3304
+ * exactly one terminal ExecutionComplete. UltravisorExecutionManifest
3305
+ * funnels success/error/abandon through finalizeExecution which
3306
+ * unconditionally emits ExecutionComplete, so this holds. If a
3307
+ * future code path bypasses finalizeExecution, that run's buffer
3308
+ * will not be cleaned up — at which point either route the new
3309
+ * code path through finalizeExecution, or add a periodic GC pass
3310
+ * that drops buffers idle for >> _ManifestEventGracePeriodMs.
3311
+ */
3312
+ _scheduleManifestBufferCleanup(pRunHash)
3313
+ {
3314
+ if (!pRunHash) return;
3315
+ let tmpExisting = this._ManifestEventCleanupTimers.get(pRunHash);
3316
+ if (tmpExisting) clearTimeout(tmpExisting);
3317
+ let tmpHandle = setTimeout(() =>
2636
3318
  {
2637
- return;
3319
+ this._ManifestEventBuffers.delete(pRunHash);
3320
+ this._ManifestEventSeqs.delete(pRunHash);
3321
+ this._ManifestEventCleanupTimers.delete(pRunHash);
3322
+ }, this._ManifestEventGracePeriodMs);
3323
+ if (tmpHandle && typeof tmpHandle.unref === 'function')
3324
+ {
3325
+ tmpHandle.unref();
2638
3326
  }
3327
+ this._ManifestEventCleanupTimers.set(pRunHash, tmpHandle);
3328
+ }
2639
3329
 
2640
- let tmpMessage = JSON.stringify({
2641
- EventType: pEventType,
2642
- RunHash: pRunHash,
2643
- Data: pEventData
2644
- });
3330
+ /**
3331
+ * Handle an execution event from the manifest service: stamp the
3332
+ * envelope (always — buffer captures history even when no live
3333
+ * subscribers), then broadcast to subscribers of that RunHash.
3334
+ *
3335
+ * @param {string} pEventType - The event type (TaskStart, TaskComplete, ...).
3336
+ * @param {string} pRunHash - The execution run hash.
3337
+ * @param {object} pEventData - The event data.
3338
+ */
3339
+ _onExecutionEvent(pEventType, pRunHash, pEventData)
3340
+ {
3341
+ // Always stamp so the buffer captures history a future
3342
+ // reconnect can replay against. Subscribers may be zero
3343
+ // today and present tomorrow.
3344
+ let tmpEnvelope = this._stampManifestEvent(pEventType, pRunHash, pEventData);
2645
3345
 
2646
- tmpSubscribers.forEach(
2647
- function (pClient)
2648
- {
2649
- if (pClient.readyState === libWebSocket.OPEN)
3346
+ let tmpSubscribers = this._WebSocketSubscriptions[pRunHash];
3347
+ if (tmpSubscribers && tmpSubscribers.size > 0)
3348
+ {
3349
+ let tmpMessage = JSON.stringify(tmpEnvelope);
3350
+ tmpSubscribers.forEach(
3351
+ function (pClient)
2650
3352
  {
2651
- pClient.send(tmpMessage);
2652
- }
2653
- });
3353
+ if (pClient.readyState === libWebSocket.OPEN)
3354
+ {
3355
+ pClient.send(tmpMessage);
3356
+ }
3357
+ });
3358
+ }
2654
3359
 
2655
- // Clean up subscription set when execution completes
3360
+ // Terminal event: arm the per-run buffer cleanup grace timer
3361
+ // and drop the live-subscriber set (subscribers reconnecting
3362
+ // during the grace window will still get a fresh subscription
3363
+ // + replay of the recorded buffer). The buffer itself is
3364
+ // preserved until the timer fires.
2656
3365
  if (pEventType === 'ExecutionComplete')
2657
3366
  {
3367
+ this._scheduleManifestBufferCleanup(pRunHash);
2658
3368
  delete this._WebSocketSubscriptions[pRunHash];
2659
3369
  }
2660
3370
  }
@@ -2697,6 +3407,37 @@ class UltravisorAPIServer extends libPictService
2697
3407
  return;
2698
3408
  }
2699
3409
 
3410
+ // Non-promiscuous-mode admission gate. In default (promiscuous)
3411
+ // mode the lambda below resolves true synchronously; in non-
3412
+ // promiscuous mode it dispatches to the auth beacon and resolves
3413
+ // asynchronously. Either way, registration only proceeds when
3414
+ // allowed.
3415
+ this._admitBeaconForRegistration(pData, (pError, pAdmitted, pRejectReason) =>
3416
+ {
3417
+ if (pError)
3418
+ {
3419
+ this.log.warn(`Ultravisor WebSocket: beacon "${pData.Name}" admission errored: ${pError.message || pError}`);
3420
+ this._rejectBeaconJoin(pWebSocket, 'Admission check failed: ' + (pError.message || pError));
3421
+ return;
3422
+ }
3423
+ if (!pAdmitted)
3424
+ {
3425
+ this.log.warn(`Ultravisor WebSocket: beacon "${pData.Name}" rejected by admission gate: ${pRejectReason || 'denied'}`);
3426
+ this._rejectBeaconJoin(pWebSocket, pRejectReason || 'Beacon admission denied');
3427
+ return;
3428
+ }
3429
+ this._completeBeaconWSRegister(pWebSocket, pData, tmpCoordinator);
3430
+ });
3431
+ }
3432
+
3433
+ /**
3434
+ * Finish a WS beacon registration after the admission gate has
3435
+ * passed. Split out from _handleBeaconWSRegister so the gate can
3436
+ * short-circuit cleanly without nesting the success path inside
3437
+ * a callback that's also responsible for failure handling.
3438
+ */
3439
+ _completeBeaconWSRegister(pWebSocket, pData, pCoordinator)
3440
+ {
2700
3441
  // Diagnostic: log what we RECEIVED from the client before forwarding
2701
3442
  // to registerBeacon. If the client sent HostID but we're storing null,
2702
3443
  // this log line pins down exactly where the drop happens (client vs
@@ -2712,7 +3453,7 @@ class UltravisorAPIServer extends libPictService
2712
3453
  // reachability auto-detect). Forgetting to forward a field here means
2713
3454
  // the WebSocket-registered beacon record will have that field set to
2714
3455
  // null/empty in the coordinator, even though the client sent the value.
2715
- let tmpBeacon = tmpCoordinator.registerBeacon({
3456
+ let tmpBeacon = pCoordinator.registerBeacon({
2716
3457
  Name: pData.Name,
2717
3458
  Capabilities: pData.Capabilities,
2718
3459
  ActionSchemas: pData.ActionSchemas,
@@ -2746,6 +3487,126 @@ class UltravisorAPIServer extends libPictService
2746
3487
  }
2747
3488
  }
2748
3489
 
3490
+ /**
3491
+ * Decide whether a beacon may join the mesh. Three modes, in order:
3492
+ *
3493
+ * 1. Promiscuous (default) — always admit. Same behavior the hub
3494
+ * had before the auth beacon work; nothing on the wire changes
3495
+ * and existing deployments need no config update.
3496
+ *
3497
+ * 2. Non-promiscuous + this is the auth beacon's own join — accept
3498
+ * iff the JoinSecret matches `UltravisorBootstrapAuthSecret`
3499
+ * from config. We cannot consult the auth beacon to validate
3500
+ * itself (chicken-and-egg), so the bootstrap secret is the one
3501
+ * "trust me, I'm authorized" credential the hub keeps locally.
3502
+ * A beacon counts as "the auth beacon" when it advertises the
3503
+ * Authentication capability AND no other auth beacon is
3504
+ * currently registered.
3505
+ *
3506
+ * 3. Non-promiscuous + any other beacon — dispatch
3507
+ * AUTH_ValidateBeaconJoin to the live auth beacon via the
3508
+ * bridge. If the bridge isn't available (no auth beacon
3509
+ * connected yet) we fail closed: better to reject a real beacon
3510
+ * than to admit an attacker during a bootstrap window.
3511
+ *
3512
+ * The callback is invoked exactly once: (pError, pAdmitted, pReason).
3513
+ */
3514
+ _admitBeaconForRegistration(pData, fCallback)
3515
+ {
3516
+ // Config keys live in fable.ProgramConfiguration (gathered from
3517
+ // .ultravisor.json + DefaultProgramConfiguration). fable.settings
3518
+ // is a separate, mostly-empty bag that some legacy code reads —
3519
+ // don't be fooled by either pattern in nearby files.
3520
+ let tmpConfig = (this.fable && this.fable.ProgramConfiguration) || {};
3521
+ let tmpNonPromiscuous = !!tmpConfig.UltravisorNonPromiscuous;
3522
+ if (this.fable && this.fable.LogNoisiness >= 1)
3523
+ {
3524
+ this.log.info(`[Admission] beacon "${pData.Name}" caps=[${(pData.Capabilities||[]).join(',')}] joinSecret=${pData.JoinSecret ? '(present)' : '(none)'} nonPromiscuous=${tmpNonPromiscuous}`);
3525
+ }
3526
+ if (!tmpNonPromiscuous)
3527
+ {
3528
+ return fCallback(null, true);
3529
+ }
3530
+
3531
+ let tmpCaps = Array.isArray(pData.Capabilities) ? pData.Capabilities : [];
3532
+ let tmpClaimsAuth = tmpCaps.indexOf('Authentication') >= 0;
3533
+ let tmpJoinSecret = pData.JoinSecret || '';
3534
+
3535
+ // Bootstrap path: this beacon claims Authentication AND no auth
3536
+ // beacon is registered yet. Compare against the local
3537
+ // UltravisorBootstrapAuthSecret using a constant-time compare
3538
+ // so timing differences can't leak the secret.
3539
+ let tmpBridge = this._getService('UltravisorAuthBeaconBridge');
3540
+ let tmpAuthAlreadyConnected = tmpBridge && tmpBridge.isAvailable();
3541
+ if (tmpClaimsAuth && !tmpAuthAlreadyConnected)
3542
+ {
3543
+ let tmpExpected = tmpConfig.UltravisorBootstrapAuthSecret || '';
3544
+ if (!tmpExpected)
3545
+ {
3546
+ return fCallback(null, false,
3547
+ 'Non-promiscuous mode requires UltravisorBootstrapAuthSecret in config');
3548
+ }
3549
+ if (!this._constantTimeEqual(tmpJoinSecret, tmpExpected))
3550
+ {
3551
+ return fCallback(null, false, 'Bootstrap auth secret mismatch');
3552
+ }
3553
+ return fCallback(null, true);
3554
+ }
3555
+
3556
+ // Standard path: ask the auth beacon to validate this join.
3557
+ if (!tmpBridge || !tmpAuthAlreadyConnected)
3558
+ {
3559
+ return fCallback(null, false,
3560
+ 'No auth beacon connected; cannot validate beacon join in non-promiscuous mode');
3561
+ }
3562
+ tmpBridge.validateBeaconJoin(pData.Name || '', tmpJoinSecret, tmpCaps).then((pResult) =>
3563
+ {
3564
+ if (pResult && pResult.Available && pResult.Allowed)
3565
+ {
3566
+ return fCallback(null, true);
3567
+ }
3568
+ let tmpReason = (pResult && (pResult.Reason || pResult.Error))
3569
+ || 'Auth beacon denied beacon join';
3570
+ return fCallback(null, false, tmpReason);
3571
+ }).catch((pErr) => fCallback(pErr));
3572
+ }
3573
+
3574
+ /**
3575
+ * Send an explicit rejection frame and close the socket so the
3576
+ * client knows it was denied (vs. a generic disconnect that looks
3577
+ * like a transient network blip and triggers exponential reconnect).
3578
+ */
3579
+ _rejectBeaconJoin(pWebSocket, pReason)
3580
+ {
3581
+ if (pWebSocket && pWebSocket.readyState === libWebSocket.OPEN)
3582
+ {
3583
+ try
3584
+ {
3585
+ pWebSocket.send(JSON.stringify({
3586
+ EventType: 'BeaconRejected',
3587
+ Reason: pReason || 'Beacon admission denied'
3588
+ }));
3589
+ }
3590
+ catch (pErr) { /* socket dying — close handler will clean up */ }
3591
+ try { pWebSocket.close(4403, 'Beacon admission denied'); }
3592
+ catch (pErr) { /* ignore */ }
3593
+ }
3594
+ }
3595
+
3596
+ /**
3597
+ * Constant-time string compare via crypto.timingSafeEqual on
3598
+ * Buffers of equal length. Pads/diffs unequal-length inputs as
3599
+ * "not equal" without revealing length difference timing.
3600
+ */
3601
+ _constantTimeEqual(pA, pB)
3602
+ {
3603
+ let tmpA = Buffer.from(String(pA || ''), 'utf8');
3604
+ let tmpB = Buffer.from(String(pB || ''), 'utf8');
3605
+ if (tmpA.length !== tmpB.length) return false;
3606
+ try { return libCrypto.timingSafeEqual(tmpA, tmpB); }
3607
+ catch (pErr) { return false; }
3608
+ }
3609
+
2749
3610
  /**
2750
3611
  * Handle a beacon heartbeat over WebSocket.
2751
3612
  */