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.
@@ -113,6 +113,62 @@ class UltravisorBeaconCoordinator extends libPictService
113
113
  return (tmpStore && tmpStore.isEnabled && tmpStore.isEnabled()) ? tmpStore : null;
114
114
  }
115
115
 
116
+ /**
117
+ * Get the queue persistence bridge (lazy). Falls back to direct
118
+ * QueueStore writes when the bridge isn't installed (older
119
+ * deployments, tests).
120
+ *
121
+ * @returns {object|null}
122
+ */
123
+ _getQueuePersistenceBridge()
124
+ {
125
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorQueuePersistenceBridge;
126
+ if (!tmpMap) return null;
127
+ return Object.values(tmpMap)[0] || null;
128
+ }
129
+
130
+ /**
131
+ * Capabilities the bridge skips persistence for. Their own dispatches
132
+ * are themselves work items; recording them would loop:
133
+ * QueuePersistence → QP_AppendEvent → enqueueWorkItem → QP_AppendEvent → ...
134
+ * ManifestStore → MS_UpsertManifest → enqueueWorkItem → MS_UpsertManifest → ...
135
+ * MeadowProxy → MeadowProxy.Request (the bridge's new dispatch
136
+ * backend) → enqueueWorkItem → MeadowProxy.Request → ...
137
+ * DataBeaconSchema → bootstrap-time EnsureSchema/IntrospectSchema; same loop hazard.
138
+ * DataBeaconManagement → bootstrap-time EnableEndpoint/UpdateProxyConfig; same loop hazard.
139
+ * The set lives on the coordinator because it's the only place that
140
+ * decides "is this work item worth persisting?".
141
+ */
142
+ _isMetaCapability(pCapability)
143
+ {
144
+ return pCapability === 'QueuePersistence'
145
+ || pCapability === 'ManifestStore'
146
+ || pCapability === 'MeadowProxy'
147
+ || pCapability === 'DataBeaconSchema'
148
+ || pCapability === 'DataBeaconManagement';
149
+ }
150
+
151
+ /**
152
+ * Centralized fire-and-forget for bridge calls. Promise rejections
153
+ * land here as warnings instead of unhandled-rejection events,
154
+ * matching the original `try { ... } catch (...)` best-effort
155
+ * shape of every legacy QueueStore call site.
156
+ */
157
+ _persistBest(pPromise, pLabel)
158
+ {
159
+ if (!pPromise || typeof pPromise.then !== 'function') return;
160
+ pPromise.then((pResult) =>
161
+ {
162
+ if (pResult && pResult.Success === false && pResult.Reason)
163
+ {
164
+ this.log.warn(`BeaconCoordinator: ${pLabel}: ${pResult.Reason}`);
165
+ }
166
+ }).catch((pErr) =>
167
+ {
168
+ this.log.warn(`BeaconCoordinator: ${pLabel} threw: ${(pErr && pErr.message) || pErr}`);
169
+ });
170
+ }
171
+
116
172
  /**
117
173
  * Get the scheduler service (lazy lookup).
118
174
  *
@@ -313,6 +369,12 @@ class UltravisorBeaconCoordinator extends libPictService
313
369
  tmpReachability.onBeaconRegistered(tmpExistingBeacon.BeaconID);
314
370
  }
315
371
 
372
+ // Bootstrap-flush: a reconnect is exactly the case we care
373
+ // about — the beacon was offline, local writes accumulated,
374
+ // now the beacon is back. Fire the same notification path
375
+ // as a fresh register so the bridges sweep their backlog.
376
+ this._notifyPersistenceBridgesOnConnect(tmpExistingBeacon.BeaconID);
377
+
316
378
  return tmpExistingBeacon;
317
379
  }
318
380
 
@@ -393,9 +455,50 @@ class UltravisorBeaconCoordinator extends libPictService
393
455
  });
394
456
  }
395
457
 
458
+ // Bootstrap-flush: notify persistence bridges that a beacon
459
+ // just connected so they can flush locally-buffered writes the
460
+ // beacon doesn't have. Each bridge inspects whether the new
461
+ // beacon supplies its capability (QueuePersistence /
462
+ // ManifestStore) and fires a sweep when relevant. setImmediate
463
+ // for the same reason as the FleetManager hook above — the
464
+ // WebSocket upgrade hasn't fully wired the push channel yet.
465
+ this._notifyPersistenceBridgesOnConnect(tmpBeaconID);
466
+
396
467
  return tmpBeacon;
397
468
  }
398
469
 
470
+ /**
471
+ * Notify both persistence bridges that a beacon just connected.
472
+ * Each bridge filters by capability internally so this is safe to
473
+ * call for every beacon registration. Fire-and-forget; bridges
474
+ * log their own success/failure. Wrapped in setImmediate so flush
475
+ * dispatches don't preempt the WebSocket upgrade settlement.
476
+ */
477
+ _notifyPersistenceBridgesOnConnect(pBeaconID)
478
+ {
479
+ let tmpQB = this._getService('UltravisorQueuePersistenceBridge');
480
+ let tmpMB = this._getService('UltravisorManifestStoreBridge');
481
+ setImmediate(() =>
482
+ {
483
+ if (tmpQB && typeof tmpQB.onBeaconConnected === 'function')
484
+ {
485
+ try { tmpQB.onBeaconConnected(pBeaconID); }
486
+ catch (pErr)
487
+ {
488
+ this.log.warn(`BeaconCoordinator: QueuePersistenceBridge.onBeaconConnected threw: ${pErr.message}`);
489
+ }
490
+ }
491
+ if (tmpMB && typeof tmpMB.onBeaconConnected === 'function')
492
+ {
493
+ try { tmpMB.onBeaconConnected(pBeaconID); }
494
+ catch (pErr)
495
+ {
496
+ this.log.warn(`BeaconCoordinator: ManifestStoreBridge.onBeaconConnected threw: ${pErr.message}`);
497
+ }
498
+ }
499
+ });
500
+ }
501
+
399
502
  // ================================================================
400
503
  // Action Catalog
401
504
  // ================================================================
@@ -1112,31 +1215,27 @@ class UltravisorBeaconCoordinator extends libPictService
1112
1215
  }
1113
1216
  }
1114
1217
 
1115
- // Persist the full record to the SQLite-backed queue store so
1116
- // the /queue view + historical queries + cross-restart recovery
1117
- // have a canonical source of truth.
1118
- let tmpQueueStore = this._getQueueStore();
1119
- if (tmpQueueStore)
1218
+ // Persist via the bridge (beacon when connected, in-process
1219
+ // QueueStore otherwise). Skip QueuePersistence's own dispatches
1220
+ // recording them would create infinite recursion through the
1221
+ // bridge coordinator → bridge → ... loop.
1222
+ let tmpBridge = this._getQueuePersistenceBridge();
1223
+ if (tmpBridge && !this._isMetaCapability(tmpWorkItem.Capability))
1120
1224
  {
1121
- try { tmpQueueStore.upsertWorkItem(tmpWorkItem); }
1122
- catch (pStoreErr) { this.log.warn(`BeaconCoordinator: queue store upsert failed: ${pStoreErr.message}`); }
1123
- try
1124
- {
1125
- tmpQueueStore.appendEvent({
1126
- WorkItemHash: tmpWorkItem.WorkItemHash,
1127
- RunID: tmpWorkItem.RunID,
1128
- EventType: 'enqueued',
1129
- FromStatus: '',
1130
- ToStatus: tmpWorkItem.Status,
1131
- Payload: {
1132
- Capability: tmpWorkItem.Capability,
1133
- Action: tmpWorkItem.Action,
1134
- Priority: tmpWorkItem.Priority,
1135
- AffinityKey: tmpWorkItem.AffinityKey
1136
- }
1137
- });
1138
- }
1139
- catch (pStoreErr2) { /* best effort */ }
1225
+ this._persistBest(tmpBridge.upsertWorkItem(tmpWorkItem), 'queue upsert');
1226
+ this._persistBest(tmpBridge.appendEvent({
1227
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1228
+ RunID: tmpWorkItem.RunID,
1229
+ EventType: 'enqueued',
1230
+ FromStatus: '',
1231
+ ToStatus: tmpWorkItem.Status,
1232
+ Payload: {
1233
+ Capability: tmpWorkItem.Capability,
1234
+ Action: tmpWorkItem.Action,
1235
+ Priority: tmpWorkItem.Priority,
1236
+ AffinityKey: tmpWorkItem.AffinityKey
1237
+ }
1238
+ }), 'queue enqueued event');
1140
1239
  }
1141
1240
 
1142
1241
  // Broadcast the enqueue event. Scheduler owns the ws envelope.
@@ -1348,37 +1447,35 @@ class UltravisorBeaconCoordinator extends libPictService
1348
1447
  if (!isNaN(tmpEnqMs)) tmpWorkItem.QueueWaitMs = Math.max(0, Date.now() - tmpEnqMs);
1349
1448
  }
1350
1449
  tmpWorkItem.AttemptNumber = (tmpWorkItem.AttemptNumber || 0) + 1;
1351
- let tmpPollStoreA = this._getQueueStore();
1352
- if (tmpPollStoreA)
1450
+ // Affinity-poll dispatch path. Bridge handles beacon-or-local
1451
+ // routing; meta-capability skip mirrors the other write sites.
1452
+ let tmpPollBridgeA = this._getQueuePersistenceBridge();
1453
+ if (tmpPollBridgeA && !this._isMetaCapability(tmpWorkItem.Capability))
1353
1454
  {
1354
- try
1355
- {
1356
- tmpPollStoreA.updateWorkItem(tmpWorkItem.WorkItemHash, {
1357
- Status: 'Running',
1358
- StartedAt: tmpWorkItem.StartedAt,
1359
- DispatchedAt: tmpWorkItem.DispatchedAt,
1360
- LastEventAt: tmpPollNowIso,
1361
- QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
1362
- AttemptNumber: tmpWorkItem.AttemptNumber
1363
- });
1364
- tmpPollStoreA.appendEvent({
1365
- WorkItemHash: tmpWorkItem.WorkItemHash,
1366
- RunID: tmpWorkItem.RunID,
1367
- EventType: 'dispatched',
1368
- FromStatus: 'Assigned',
1369
- ToStatus: 'Running',
1370
- BeaconID: pBeaconID,
1371
- Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
1372
- });
1373
- tmpPollStoreA.insertAttempt({
1374
- WorkItemHash: tmpWorkItem.WorkItemHash,
1375
- AttemptNumber: tmpWorkItem.AttemptNumber,
1376
- BeaconID: pBeaconID,
1377
- DispatchedAt: tmpWorkItem.DispatchedAt,
1378
- Outcome: 'Dispatched'
1379
- });
1380
- }
1381
- catch (pErr) { /* best effort */ }
1455
+ this._persistBest(tmpPollBridgeA.updateWorkItem(tmpWorkItem.WorkItemHash, {
1456
+ Status: 'Running',
1457
+ StartedAt: tmpWorkItem.StartedAt,
1458
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1459
+ LastEventAt: tmpPollNowIso,
1460
+ QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
1461
+ AttemptNumber: tmpWorkItem.AttemptNumber
1462
+ }), 'queue dispatch update (affinity)');
1463
+ this._persistBest(tmpPollBridgeA.appendEvent({
1464
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1465
+ RunID: tmpWorkItem.RunID,
1466
+ EventType: 'dispatched',
1467
+ FromStatus: 'Assigned',
1468
+ ToStatus: 'Running',
1469
+ BeaconID: pBeaconID,
1470
+ Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
1471
+ }), 'queue dispatched event (affinity)');
1472
+ this._persistBest(tmpPollBridgeA.insertAttempt({
1473
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1474
+ AttemptNumber: tmpWorkItem.AttemptNumber,
1475
+ BeaconID: pBeaconID,
1476
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1477
+ Outcome: 'Dispatched'
1478
+ }), 'queue insert attempt (affinity)');
1382
1479
  }
1383
1480
  this.log.info(`BeaconCoordinator: beacon [${pBeaconID}] picked up affinity-assigned work item [${tmpWorkItem.WorkItemHash}].`);
1384
1481
  return this._sanitizeWorkItemForBeacon(tmpWorkItem);
@@ -1452,39 +1549,38 @@ class UltravisorBeaconCoordinator extends libPictService
1452
1549
  if (!isNaN(tmpEnqMs2)) tmpWorkItem.QueueWaitMs = Math.max(0, Date.now() - tmpEnqMs2);
1453
1550
  }
1454
1551
  tmpWorkItem.AttemptNumber = (tmpWorkItem.AttemptNumber || 0) + 1;
1455
- let tmpPollStoreB = this._getQueueStore();
1456
- if (tmpPollStoreB)
1457
- {
1458
- try
1459
- {
1460
- tmpPollStoreB.updateWorkItem(tmpWorkItem.WorkItemHash, {
1461
- Status: 'Running',
1462
- AssignedBeaconID: pBeaconID,
1463
- AssignedAt: tmpWorkItem.AssignedAt,
1464
- DispatchedAt: tmpWorkItem.DispatchedAt,
1465
- StartedAt: tmpWorkItem.StartedAt,
1466
- LastEventAt: tmpPollClaimIso,
1467
- QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
1468
- AttemptNumber: tmpWorkItem.AttemptNumber
1469
- });
1470
- tmpPollStoreB.appendEvent({
1471
- WorkItemHash: tmpWorkItem.WorkItemHash,
1472
- RunID: tmpWorkItem.RunID,
1473
- EventType: 'dispatched',
1474
- FromStatus: tmpPollFromStatus,
1475
- ToStatus: 'Running',
1476
- BeaconID: pBeaconID,
1477
- Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
1478
- });
1479
- tmpPollStoreB.insertAttempt({
1480
- WorkItemHash: tmpWorkItem.WorkItemHash,
1481
- AttemptNumber: tmpWorkItem.AttemptNumber,
1482
- BeaconID: pBeaconID,
1483
- DispatchedAt: tmpWorkItem.DispatchedAt,
1484
- Outcome: 'Dispatched'
1485
- });
1486
- }
1487
- catch (pErr) { /* best effort */ }
1552
+ // Free-pool poll dispatch path. Same bridge + meta-capability
1553
+ // guard as the affinity poll above; the only difference is
1554
+ // the FromStatus carried into the event row.
1555
+ let tmpPollBridgeB = this._getQueuePersistenceBridge();
1556
+ if (tmpPollBridgeB && !this._isMetaCapability(tmpWorkItem.Capability))
1557
+ {
1558
+ this._persistBest(tmpPollBridgeB.updateWorkItem(tmpWorkItem.WorkItemHash, {
1559
+ Status: 'Running',
1560
+ AssignedBeaconID: pBeaconID,
1561
+ AssignedAt: tmpWorkItem.AssignedAt,
1562
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1563
+ StartedAt: tmpWorkItem.StartedAt,
1564
+ LastEventAt: tmpPollClaimIso,
1565
+ QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
1566
+ AttemptNumber: tmpWorkItem.AttemptNumber
1567
+ }), 'queue dispatch update (poll)');
1568
+ this._persistBest(tmpPollBridgeB.appendEvent({
1569
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1570
+ RunID: tmpWorkItem.RunID,
1571
+ EventType: 'dispatched',
1572
+ FromStatus: tmpPollFromStatus,
1573
+ ToStatus: 'Running',
1574
+ BeaconID: pBeaconID,
1575
+ Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
1576
+ }), 'queue dispatched event (poll)');
1577
+ this._persistBest(tmpPollBridgeB.insertAttempt({
1578
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1579
+ AttemptNumber: tmpWorkItem.AttemptNumber,
1580
+ BeaconID: pBeaconID,
1581
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1582
+ Outcome: 'Dispatched'
1583
+ }), 'queue insert attempt (poll)');
1488
1584
  }
1489
1585
 
1490
1586
  if (tmpBeacon.CurrentWorkItems.indexOf(tmpWorkItem.WorkItemHash) === -1)
@@ -1675,34 +1771,33 @@ class UltravisorBeaconCoordinator extends libPictService
1675
1771
  tmpJournal.appendEntry('complete', { WorkItemHash: pWorkItemHash });
1676
1772
  }
1677
1773
 
1678
- // Persist to the new queue store + append event + update attempt.
1679
- let tmpQueueStore = this._getQueueStore();
1680
- if (tmpQueueStore)
1681
- {
1682
- try
1683
- {
1684
- tmpQueueStore.updateWorkItem(pWorkItemHash, {
1685
- Status: 'Complete',
1686
- CompletedAt: tmpWorkItem.CompletedAt,
1687
- LastEventAt: tmpWorkItem.LastEventAt,
1688
- Result: pResult || null
1689
- });
1690
- tmpQueueStore.appendEvent({
1691
- WorkItemHash: pWorkItemHash,
1692
- RunID: tmpWorkItem.RunID,
1693
- EventType: 'completed',
1694
- FromStatus: tmpFromStatus,
1695
- ToStatus: 'Complete',
1696
- BeaconID: tmpWorkItem.AssignedBeaconID,
1697
- Payload: { DurationMs: tmpDurationMs }
1698
- });
1699
- tmpQueueStore.updateAttemptOutcome(pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
1774
+ // Persist via the bridge beacon-backed when connected,
1775
+ // in-process QueueStore otherwise. Skip meta-capability
1776
+ // dispatches to avoid the QP_*-on-QP_* recursion.
1777
+ let tmpCompleteBridge = this._getQueuePersistenceBridge();
1778
+ if (tmpCompleteBridge && !this._isMetaCapability(tmpWorkItem.Capability))
1779
+ {
1780
+ this._persistBest(tmpCompleteBridge.updateWorkItem(pWorkItemHash, {
1781
+ Status: 'Complete',
1782
+ CompletedAt: tmpWorkItem.CompletedAt,
1783
+ LastEventAt: tmpWorkItem.LastEventAt,
1784
+ Result: pResult || null
1785
+ }), 'queue complete update');
1786
+ this._persistBest(tmpCompleteBridge.appendEvent({
1787
+ WorkItemHash: pWorkItemHash,
1788
+ RunID: tmpWorkItem.RunID,
1789
+ EventType: 'completed',
1790
+ FromStatus: tmpFromStatus,
1791
+ ToStatus: 'Complete',
1792
+ BeaconID: tmpWorkItem.AssignedBeaconID,
1793
+ Payload: { DurationMs: tmpDurationMs }
1794
+ }), 'queue completed event');
1795
+ this._persistBest(tmpCompleteBridge.updateAttemptOutcome(
1796
+ pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
1700
1797
  CompletedAt: tmpWorkItem.CompletedAt,
1701
1798
  Outcome: 'Complete',
1702
1799
  DurationMs: tmpDurationMs
1703
- });
1704
- }
1705
- catch (pStoreErr) { /* best effort */ }
1800
+ }), 'queue attempt outcome');
1706
1801
  }
1707
1802
 
1708
1803
  let tmpScheduler = this._getScheduler();
@@ -1872,36 +1967,35 @@ class UltravisorBeaconCoordinator extends libPictService
1872
1967
  tmpJournal.appendEntry('fail', { WorkItemHash: pWorkItemHash });
1873
1968
  }
1874
1969
 
1875
- // Persist to the new queue store + event + attempt outcome.
1876
- let tmpFailStore = this._getQueueStore();
1877
- if (tmpFailStore)
1878
- {
1879
- try
1880
- {
1881
- tmpFailStore.updateWorkItem(pWorkItemHash, {
1882
- Status: 'Error',
1883
- CompletedAt: tmpWorkItem.CompletedAt,
1884
- LastEventAt: tmpWorkItem.LastEventAt,
1885
- LastError: tmpWorkItem.LastError,
1886
- Result: tmpWorkItem.Result
1887
- });
1888
- tmpFailStore.appendEvent({
1889
- WorkItemHash: pWorkItemHash,
1890
- RunID: tmpWorkItem.RunID,
1891
- EventType: 'failed',
1892
- FromStatus: tmpFromStatus,
1893
- ToStatus: 'Error',
1894
- BeaconID: tmpWorkItem.AssignedBeaconID,
1895
- Payload: { Error: tmpWorkItem.LastError, DurationMs: tmpFailDurationMs }
1896
- });
1897
- tmpFailStore.updateAttemptOutcome(pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
1970
+ // Persist via the bridge beacon-backed when connected,
1971
+ // in-process QueueStore otherwise. Skip meta-capability
1972
+ // dispatches to avoid recursion.
1973
+ let tmpFailBridge = this._getQueuePersistenceBridge();
1974
+ if (tmpFailBridge && !this._isMetaCapability(tmpWorkItem.Capability))
1975
+ {
1976
+ this._persistBest(tmpFailBridge.updateWorkItem(pWorkItemHash, {
1977
+ Status: 'Error',
1978
+ CompletedAt: tmpWorkItem.CompletedAt,
1979
+ LastEventAt: tmpWorkItem.LastEventAt,
1980
+ LastError: tmpWorkItem.LastError,
1981
+ Result: tmpWorkItem.Result
1982
+ }), 'queue fail update');
1983
+ this._persistBest(tmpFailBridge.appendEvent({
1984
+ WorkItemHash: pWorkItemHash,
1985
+ RunID: tmpWorkItem.RunID,
1986
+ EventType: 'failed',
1987
+ FromStatus: tmpFromStatus,
1988
+ ToStatus: 'Error',
1989
+ BeaconID: tmpWorkItem.AssignedBeaconID,
1990
+ Payload: { Error: tmpWorkItem.LastError, DurationMs: tmpFailDurationMs }
1991
+ }), 'queue failed event');
1992
+ this._persistBest(tmpFailBridge.updateAttemptOutcome(
1993
+ pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
1898
1994
  CompletedAt: tmpWorkItem.CompletedAt,
1899
1995
  Outcome: 'Error',
1900
1996
  ErrorMessage: tmpWorkItem.LastError,
1901
1997
  DurationMs: tmpFailDurationMs
1902
- });
1903
- }
1904
- catch (pStoreErr) { /* best effort */ }
1998
+ }), 'queue fail attempt outcome');
1905
1999
  }
1906
2000
 
1907
2001
  let tmpFailScheduler = this._getScheduler();
@@ -2263,17 +2357,16 @@ class UltravisorBeaconCoordinator extends libPictService
2263
2357
  // Mark the item as freshly heard-from for health scoring.
2264
2358
  tmpWorkItem.LastEventAt = tmpWorkItem.Progress.UpdatedAt;
2265
2359
  if (!tmpWorkItem.StartedAt) tmpWorkItem.StartedAt = tmpWorkItem.Progress.UpdatedAt;
2266
- let tmpProgressStore = this._getQueueStore();
2267
- if (tmpProgressStore)
2268
- {
2269
- try
2270
- {
2271
- tmpProgressStore.updateWorkItem(pWorkItemHash, {
2272
- LastEventAt: tmpWorkItem.LastEventAt,
2273
- StartedAt: tmpWorkItem.StartedAt
2274
- });
2275
- }
2276
- catch (pErr) { /* best effort */ }
2360
+ // Progress updates also land via the bridge so a beacon-backed
2361
+ // persistence backend sees the same heartbeat trail an in-process
2362
+ // store would. Skip meta-capabilities to avoid recursion.
2363
+ let tmpProgressBridge = this._getQueuePersistenceBridge();
2364
+ if (tmpProgressBridge && !this._isMetaCapability(tmpWorkItem.Capability))
2365
+ {
2366
+ this._persistBest(tmpProgressBridge.updateWorkItem(pWorkItemHash, {
2367
+ LastEventAt: tmpWorkItem.LastEventAt,
2368
+ StartedAt: tmpWorkItem.StartedAt
2369
+ }), 'queue progress update');
2277
2370
  }
2278
2371
 
2279
2372
  // Accumulate log entries
@@ -82,6 +82,42 @@ class UltravisorBeaconScheduler extends libPictService
82
82
  return (tmpStore && tmpStore.isEnabled()) ? tmpStore : null;
83
83
  }
84
84
 
85
+ /**
86
+ * Persistence bridge — beacon-or-local fallback. Same shape the
87
+ * Coordinator uses; the scheduler funnels every write through it
88
+ * so a connected QueuePersistence beacon sees the full event log
89
+ * (dispatched, canceled, health updates, reorders).
90
+ */
91
+ _getBridge()
92
+ {
93
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorQueuePersistenceBridge;
94
+ if (!tmpMap) return null;
95
+ return Object.values(tmpMap)[0] || null;
96
+ }
97
+
98
+ _isMetaCapability(pCapability)
99
+ {
100
+ // Mirror the coordinator's gate. Kept as a per-class method
101
+ // rather than a shared module so both classes can independently
102
+ // add their own meta-capabilities later.
103
+ return pCapability === 'QueuePersistence' || pCapability === 'ManifestStore';
104
+ }
105
+
106
+ _persistBest(pPromise, pLabel)
107
+ {
108
+ if (!pPromise || typeof pPromise.then !== 'function') return;
109
+ pPromise.then((pResult) =>
110
+ {
111
+ if (pResult && pResult.Success === false && pResult.Reason)
112
+ {
113
+ this.log.warn(`BeaconScheduler: ${pLabel}: ${pResult.Reason}`);
114
+ }
115
+ }).catch((pErr) =>
116
+ {
117
+ this.log.warn(`BeaconScheduler: ${pLabel} threw: ${(pErr && pErr.message) || pErr}`);
118
+ });
119
+ }
120
+
85
121
  _getDefaults()
86
122
  {
87
123
  let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconActionDefaults;
@@ -256,10 +292,10 @@ class UltravisorBeaconScheduler extends libPictService
256
292
  pBeacon.CurrentWorkItems.push(pItem.WorkItemHash);
257
293
  }
258
294
 
259
- let tmpStore = this._getStore();
260
- if (tmpStore)
295
+ let tmpDispatchBridge = this._getBridge();
296
+ if (tmpDispatchBridge && !this._isMetaCapability(pItem.Capability))
261
297
  {
262
- tmpStore.updateWorkItem(pItem.WorkItemHash, {
298
+ this._persistBest(tmpDispatchBridge.updateWorkItem(pItem.WorkItemHash, {
263
299
  Status: 'Dispatched',
264
300
  AssignedBeaconID: pBeacon.BeaconID,
265
301
  AssignedAt: pItem.AssignedAt,
@@ -268,8 +304,8 @@ class UltravisorBeaconScheduler extends libPictService
268
304
  AttemptNumber: pItem.AttemptNumber,
269
305
  LastEventAt: pItem.LastEventAt,
270
306
  Settings: pItem.Settings
271
- });
272
- tmpStore.appendEvent({
307
+ }), 'dispatch update');
308
+ this._persistBest(tmpDispatchBridge.appendEvent({
273
309
  WorkItemHash: pItem.WorkItemHash,
274
310
  RunID: pItem.RunID,
275
311
  EventType: 'dispatched',
@@ -277,14 +313,14 @@ class UltravisorBeaconScheduler extends libPictService
277
313
  ToStatus: 'Dispatched',
278
314
  BeaconID: pBeacon.BeaconID,
279
315
  Payload: { QueueWaitMs: tmpQueueWaitMs }
280
- });
281
- tmpStore.insertAttempt({
316
+ }), 'dispatched event');
317
+ this._persistBest(tmpDispatchBridge.insertAttempt({
282
318
  WorkItemHash: pItem.WorkItemHash,
283
319
  AttemptNumber: pItem.AttemptNumber,
284
320
  BeaconID: pBeacon.BeaconID,
285
321
  DispatchedAt: pItem.DispatchedAt,
286
322
  Outcome: 'Dispatched'
287
- });
323
+ }), 'dispatch attempt');
288
324
  }
289
325
 
290
326
  this.log.info(`BeaconScheduler: dispatched [${pItem.WorkItemHash}] to beacon [${pBeacon.BeaconID}] (queue_wait=${tmpQueueWaitMs}ms, attempt=${pItem.AttemptNumber}).`);
@@ -327,23 +363,23 @@ class UltravisorBeaconScheduler extends libPictService
327
363
  pItem.CancelReason = pItem.CancelReason || pReason || '';
328
364
  pItem.LastEventAt = tmpNowIso;
329
365
 
330
- let tmpStore = this._getStore();
331
- if (tmpStore)
366
+ let tmpCancelBridge = this._getBridge();
367
+ if (tmpCancelBridge && !this._isMetaCapability(pItem.Capability))
332
368
  {
333
- tmpStore.updateWorkItem(pItem.WorkItemHash, {
369
+ this._persistBest(tmpCancelBridge.updateWorkItem(pItem.WorkItemHash, {
334
370
  Status: 'Canceled',
335
371
  CanceledAt: pItem.CanceledAt,
336
372
  CancelReason: pItem.CancelReason,
337
373
  LastEventAt: tmpNowIso
338
- });
339
- tmpStore.appendEvent({
374
+ }), 'cancel update');
375
+ this._persistBest(tmpCancelBridge.appendEvent({
340
376
  WorkItemHash: pItem.WorkItemHash,
341
377
  RunID: pItem.RunID,
342
378
  EventType: 'canceled',
343
379
  FromStatus: tmpFromStatus,
344
380
  ToStatus: 'Canceled',
345
381
  Payload: { Reason: pItem.CancelReason }
346
- });
382
+ }), 'canceled event');
347
383
  }
348
384
 
349
385
  this._broadcast('queue.canceled', {
@@ -489,15 +525,15 @@ class UltravisorBeaconScheduler extends libPictService
489
525
  pItem.HealthReason = tmpHealth.Reason;
490
526
  pItem.HealthComputedAt = tmpNowIso;
491
527
 
492
- let tmpStore = this._getStore();
493
- if (tmpStore)
528
+ let tmpHealthBridge = this._getBridge();
529
+ if (tmpHealthBridge && !this._isMetaCapability(pItem.Capability))
494
530
  {
495
- tmpStore.updateWorkItem(pItem.WorkItemHash, {
531
+ this._persistBest(tmpHealthBridge.updateWorkItem(pItem.WorkItemHash, {
496
532
  Health: tmpHealth.Score,
497
533
  HealthLabel: tmpHealth.Label,
498
534
  HealthReason: tmpHealth.Reason,
499
535
  HealthComputedAt: tmpNowIso
500
- });
536
+ }), 'health update');
501
537
  }
502
538
 
503
539
  // Only broadcast on label change or meaningful score delta.
@@ -642,21 +678,21 @@ class UltravisorBeaconScheduler extends libPictService
642
678
  {
643
679
  tmpItem.CancelRequested = true;
644
680
  tmpItem.CancelReason = pReason || 'cancel requested';
645
- let tmpStore = this._getStore();
646
- if (tmpStore)
681
+ let tmpCancelReqBridge = this._getBridge();
682
+ if (tmpCancelReqBridge && !this._isMetaCapability(tmpItem.Capability))
647
683
  {
648
- tmpStore.updateWorkItem(pWorkItemHash, {
684
+ this._persistBest(tmpCancelReqBridge.updateWorkItem(pWorkItemHash, {
649
685
  CancelRequested: true,
650
686
  CancelReason: tmpItem.CancelReason
651
- });
652
- tmpStore.appendEvent({
687
+ }), 'cancel-requested update');
688
+ this._persistBest(tmpCancelReqBridge.appendEvent({
653
689
  WorkItemHash: pWorkItemHash,
654
690
  RunID: tmpItem.RunID,
655
691
  EventType: 'cancel_requested',
656
692
  FromStatus: tmpItem.Status,
657
693
  ToStatus: tmpItem.Status,
658
694
  Payload: { Reason: tmpItem.CancelReason }
659
- });
695
+ }), 'cancel-requested event');
660
696
  }
661
697
  this._broadcast('queue.cancel_requested', {
662
698
  WorkItemHash: pWorkItemHash,
@@ -728,18 +764,18 @@ class UltravisorBeaconScheduler extends libPictService
728
764
  : ((tmpNeighbor.Priority || 0) - 1);
729
765
  tmpItem.Priority = tmpNewPriority;
730
766
 
731
- let tmpStore = this._getStore();
732
- if (tmpStore)
767
+ let tmpReorderBridge = this._getBridge();
768
+ if (tmpReorderBridge && !this._isMetaCapability(tmpItem.Capability))
733
769
  {
734
- tmpStore.updateWorkItem(pWorkItemHash, { Priority: tmpNewPriority });
735
- tmpStore.appendEvent({
770
+ this._persistBest(tmpReorderBridge.updateWorkItem(pWorkItemHash, { Priority: tmpNewPriority }), 'reorder update');
771
+ this._persistBest(tmpReorderBridge.appendEvent({
736
772
  WorkItemHash: pWorkItemHash,
737
773
  RunID: tmpItem.RunID,
738
774
  EventType: 'reordered',
739
775
  FromStatus: tmpItem.Status,
740
776
  ToStatus: tmpItem.Status,
741
777
  Payload: { Direction: pDirection, OldPriority: tmpOldPriority, NewPriority: tmpNewPriority }
742
- });
778
+ }), 'reordered event');
743
779
  }
744
780
 
745
781
  // Two broadcasts: a targeted reorder event so the UI can update