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.
@@ -98,6 +98,45 @@ class UltravisorBeaconCoordinator extends libPictService
98
98
  return this._Journal;
99
99
  }
100
100
 
101
+ /**
102
+ * Get the SQLite-backed queue store (lazy lookup, not cached so
103
+ * late-bound services are picked up after initialization order
104
+ * finishes settling).
105
+ *
106
+ * @returns {object|null}
107
+ */
108
+ _getQueueStore()
109
+ {
110
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconQueueStore;
111
+ if (!tmpMap) return null;
112
+ let tmpStore = Object.values(tmpMap)[0];
113
+ return (tmpStore && tmpStore.isEnabled && tmpStore.isEnabled()) ? tmpStore : null;
114
+ }
115
+
116
+ /**
117
+ * Get the scheduler service (lazy lookup).
118
+ *
119
+ * @returns {object|null}
120
+ */
121
+ _getScheduler()
122
+ {
123
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconScheduler;
124
+ if (!tmpMap) return null;
125
+ return Object.values(tmpMap)[0];
126
+ }
127
+
128
+ /**
129
+ * Get the action defaults service (lazy lookup).
130
+ *
131
+ * @returns {object|null}
132
+ */
133
+ _getActionDefaults()
134
+ {
135
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconActionDefaults;
136
+ if (!tmpMap) return null;
137
+ return Object.values(tmpMap)[0];
138
+ }
139
+
101
140
  /**
102
141
  * Restore work queue and affinity bindings from the journal.
103
142
  *
@@ -920,24 +959,57 @@ class UltravisorBeaconCoordinator extends libPictService
920
959
 
921
960
  let tmpDefaultTimeout = this.fable.settings.UltravisorBeaconWorkItemTimeoutMs || 300000;
922
961
 
962
+ let tmpSettings = pWorkItemInfo.Settings || {};
963
+ let tmpCreatedIso = new Date(tmpTimestamp).toISOString();
923
964
  let tmpWorkItem = {
924
965
  WorkItemHash: tmpWorkItemHash,
925
966
  RunHash: pWorkItemInfo.RunHash || '',
967
+ RunID: pWorkItemInfo.RunID || '',
926
968
  NodeHash: pWorkItemInfo.NodeHash || '',
927
969
  OperationHash: pWorkItemInfo.OperationHash || '',
928
970
  Capability: pWorkItemInfo.Capability || 'Shell',
929
971
  Action: pWorkItemInfo.Action || 'Execute',
930
- Settings: pWorkItemInfo.Settings || {},
972
+ Settings: tmpSettings,
931
973
  AffinityKey: pWorkItemInfo.AffinityKey || '',
932
974
  AssignedBeaconID: null,
933
975
  Status: 'Pending',
934
976
  TimeoutMs: pWorkItemInfo.TimeoutMs || tmpDefaultTimeout,
935
- CreatedAt: new Date(tmpTimestamp).toISOString(),
977
+ CreatedAt: tmpCreatedIso,
978
+ EnqueuedAt: pWorkItemInfo.EnqueuedAt || tmpCreatedIso,
979
+ AssignedAt: null,
980
+ DispatchedAt: null,
981
+ StartedAt: null,
936
982
  ClaimedAt: null,
937
983
  CompletedAt: null,
938
- Result: null
984
+ CanceledAt: null,
985
+ CancelRequested: false,
986
+ CancelReason: '',
987
+ LastEventAt: tmpCreatedIso,
988
+ QueueWaitMs: 0,
989
+ Priority: (pWorkItemInfo.Priority != null) ? pWorkItemInfo.Priority : (parseInt(tmpSettings.priority, 10) || 0),
990
+ Health: null,
991
+ HealthLabel: 'Unknown',
992
+ HealthReason: '',
993
+ HealthComputedAt: null,
994
+ Result: null,
995
+ // Retry support: configurable per-action via Settings.
996
+ // maxRetries=1 means no retry (single attempt, current behavior).
997
+ AttemptNumber: 0,
998
+ MaxAttempts: parseInt(tmpSettings.maxRetries, 10) || 1,
999
+ RetryBackoffMs: parseInt(tmpSettings.retryBackoffMs, 10) || 5000,
1000
+ RetryAfter: null,
1001
+ LastError: null
939
1002
  };
940
1003
 
1004
+ // Apply action defaults (timeout, retry, priority) if the
1005
+ // BeaconActionDefaults service is available. Per-request
1006
+ // Settings still override, handled inside applyToWorkItem.
1007
+ let tmpDefaults = this._getActionDefaults();
1008
+ if (tmpDefaults)
1009
+ {
1010
+ tmpDefaults.applyToWorkItem(tmpWorkItem, tmpSettings);
1011
+ }
1012
+
941
1013
  // Check for affinity binding — pre-assign to a specific Beacon
942
1014
  if (tmpWorkItem.AffinityKey)
943
1015
  {
@@ -999,6 +1071,41 @@ class UltravisorBeaconCoordinator extends libPictService
999
1071
  }
1000
1072
  }
1001
1073
 
1074
+ // Persist the full record to the SQLite-backed queue store so
1075
+ // the /queue view + historical queries + cross-restart recovery
1076
+ // have a canonical source of truth.
1077
+ let tmpQueueStore = this._getQueueStore();
1078
+ if (tmpQueueStore)
1079
+ {
1080
+ try { tmpQueueStore.upsertWorkItem(tmpWorkItem); }
1081
+ catch (pStoreErr) { this.log.warn(`BeaconCoordinator: queue store upsert failed: ${pStoreErr.message}`); }
1082
+ try
1083
+ {
1084
+ tmpQueueStore.appendEvent({
1085
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1086
+ RunID: tmpWorkItem.RunID,
1087
+ EventType: 'enqueued',
1088
+ FromStatus: '',
1089
+ ToStatus: tmpWorkItem.Status,
1090
+ Payload: {
1091
+ Capability: tmpWorkItem.Capability,
1092
+ Action: tmpWorkItem.Action,
1093
+ Priority: tmpWorkItem.Priority,
1094
+ AffinityKey: tmpWorkItem.AffinityKey
1095
+ }
1096
+ });
1097
+ }
1098
+ catch (pStoreErr2) { /* best effort */ }
1099
+ }
1100
+
1101
+ // Broadcast the enqueue event. Scheduler owns the ws envelope.
1102
+ let tmpScheduler = this._getScheduler();
1103
+ if (tmpScheduler)
1104
+ {
1105
+ try { tmpScheduler.notifyEnqueued(tmpWorkItem); }
1106
+ catch (pErr) { /* best effort */ }
1107
+ }
1108
+
1002
1109
  // Attempt immediate push to a WebSocket-connected beacon
1003
1110
  if (tmpWorkItem.Status === 'Pending')
1004
1111
  {
@@ -1164,7 +1271,49 @@ class UltravisorBeaconCoordinator extends libPictService
1164
1271
  if (tmpWorkItem.Status === 'Assigned' && tmpWorkItem.AssignedBeaconID === pBeaconID)
1165
1272
  {
1166
1273
  // This item was pre-assigned to us via affinity
1274
+ let tmpPollNowIso = new Date().toISOString();
1167
1275
  tmpWorkItem.Status = 'Running';
1276
+ tmpWorkItem.StartedAt = tmpWorkItem.StartedAt || tmpPollNowIso;
1277
+ tmpWorkItem.DispatchedAt = tmpWorkItem.DispatchedAt || tmpPollNowIso;
1278
+ tmpWorkItem.LastEventAt = tmpPollNowIso;
1279
+ if (!tmpWorkItem.QueueWaitMs && tmpWorkItem.EnqueuedAt)
1280
+ {
1281
+ let tmpEnqMs = Date.parse(tmpWorkItem.EnqueuedAt);
1282
+ if (!isNaN(tmpEnqMs)) tmpWorkItem.QueueWaitMs = Math.max(0, Date.now() - tmpEnqMs);
1283
+ }
1284
+ tmpWorkItem.AttemptNumber = (tmpWorkItem.AttemptNumber || 0) + 1;
1285
+ let tmpPollStoreA = this._getQueueStore();
1286
+ if (tmpPollStoreA)
1287
+ {
1288
+ try
1289
+ {
1290
+ tmpPollStoreA.updateWorkItem(tmpWorkItem.WorkItemHash, {
1291
+ Status: 'Running',
1292
+ StartedAt: tmpWorkItem.StartedAt,
1293
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1294
+ LastEventAt: tmpPollNowIso,
1295
+ QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
1296
+ AttemptNumber: tmpWorkItem.AttemptNumber
1297
+ });
1298
+ tmpPollStoreA.appendEvent({
1299
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1300
+ RunID: tmpWorkItem.RunID,
1301
+ EventType: 'dispatched',
1302
+ FromStatus: 'Assigned',
1303
+ ToStatus: 'Running',
1304
+ BeaconID: pBeaconID,
1305
+ Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
1306
+ });
1307
+ tmpPollStoreA.insertAttempt({
1308
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1309
+ AttemptNumber: tmpWorkItem.AttemptNumber,
1310
+ BeaconID: pBeaconID,
1311
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1312
+ Outcome: 'Dispatched'
1313
+ });
1314
+ }
1315
+ catch (pErr) { /* best effort */ }
1316
+ }
1168
1317
  this.log.info(`BeaconCoordinator: beacon [${pBeaconID}] picked up affinity-assigned work item [${tmpWorkItem.WorkItemHash}].`);
1169
1318
  return this._sanitizeWorkItemForBeacon(tmpWorkItem);
1170
1319
  }
@@ -1193,9 +1342,55 @@ class UltravisorBeaconCoordinator extends libPictService
1193
1342
  }
1194
1343
 
1195
1344
  // Claim this work item
1345
+ let tmpPollClaimIso = new Date().toISOString();
1346
+ let tmpPollFromStatus = tmpWorkItem.Status;
1196
1347
  tmpWorkItem.Status = 'Running';
1197
1348
  tmpWorkItem.AssignedBeaconID = pBeaconID;
1198
- tmpWorkItem.ClaimedAt = new Date().toISOString();
1349
+ tmpWorkItem.ClaimedAt = tmpPollClaimIso;
1350
+ tmpWorkItem.AssignedAt = tmpWorkItem.AssignedAt || tmpPollClaimIso;
1351
+ tmpWorkItem.DispatchedAt = tmpWorkItem.DispatchedAt || tmpPollClaimIso;
1352
+ tmpWorkItem.StartedAt = tmpWorkItem.StartedAt || tmpPollClaimIso;
1353
+ tmpWorkItem.LastEventAt = tmpPollClaimIso;
1354
+ if (!tmpWorkItem.QueueWaitMs && tmpWorkItem.EnqueuedAt)
1355
+ {
1356
+ let tmpEnqMs2 = Date.parse(tmpWorkItem.EnqueuedAt);
1357
+ if (!isNaN(tmpEnqMs2)) tmpWorkItem.QueueWaitMs = Math.max(0, Date.now() - tmpEnqMs2);
1358
+ }
1359
+ tmpWorkItem.AttemptNumber = (tmpWorkItem.AttemptNumber || 0) + 1;
1360
+ let tmpPollStoreB = this._getQueueStore();
1361
+ if (tmpPollStoreB)
1362
+ {
1363
+ try
1364
+ {
1365
+ tmpPollStoreB.updateWorkItem(tmpWorkItem.WorkItemHash, {
1366
+ Status: 'Running',
1367
+ AssignedBeaconID: pBeaconID,
1368
+ AssignedAt: tmpWorkItem.AssignedAt,
1369
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1370
+ StartedAt: tmpWorkItem.StartedAt,
1371
+ LastEventAt: tmpPollClaimIso,
1372
+ QueueWaitMs: tmpWorkItem.QueueWaitMs || 0,
1373
+ AttemptNumber: tmpWorkItem.AttemptNumber
1374
+ });
1375
+ tmpPollStoreB.appendEvent({
1376
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1377
+ RunID: tmpWorkItem.RunID,
1378
+ EventType: 'dispatched',
1379
+ FromStatus: tmpPollFromStatus,
1380
+ ToStatus: 'Running',
1381
+ BeaconID: pBeaconID,
1382
+ Payload: { QueueWaitMs: tmpWorkItem.QueueWaitMs || 0, Path: 'poll' }
1383
+ });
1384
+ tmpPollStoreB.insertAttempt({
1385
+ WorkItemHash: tmpWorkItem.WorkItemHash,
1386
+ AttemptNumber: tmpWorkItem.AttemptNumber,
1387
+ BeaconID: pBeaconID,
1388
+ DispatchedAt: tmpWorkItem.DispatchedAt,
1389
+ Outcome: 'Dispatched'
1390
+ });
1391
+ }
1392
+ catch (pErr) { /* best effort */ }
1393
+ }
1199
1394
 
1200
1395
  if (tmpBeacon.CurrentWorkItems.indexOf(tmpWorkItem.WorkItemHash) === -1)
1201
1396
  {
@@ -1248,13 +1443,34 @@ class UltravisorBeaconCoordinator extends libPictService
1248
1443
  */
1249
1444
  _sanitizeWorkItemForBeacon(pWorkItem)
1250
1445
  {
1446
+ // Populate QueueMetadata on the Settings envelope if the scheduler
1447
+ // hasn't done it yet (direct poll path bypasses the scheduler).
1448
+ let tmpSettings = pWorkItem.Settings || {};
1449
+ if (!tmpSettings.QueueMetadata && pWorkItem.EnqueuedAt)
1450
+ {
1451
+ let tmpNowIso = new Date().toISOString();
1452
+ let tmpEnqMs = Date.parse(pWorkItem.EnqueuedAt);
1453
+ let tmpWaitMs = isNaN(tmpEnqMs) ? 0 : Math.max(0, Date.now() - tmpEnqMs);
1454
+ tmpSettings.QueueMetadata = {
1455
+ RunID: pWorkItem.RunID || '',
1456
+ WorkItemHash: pWorkItem.WorkItemHash,
1457
+ EnqueuedAt: pWorkItem.EnqueuedAt,
1458
+ DispatchedAt: pWorkItem.DispatchedAt || tmpNowIso,
1459
+ QueueWaitMs: pWorkItem.QueueWaitMs || tmpWaitMs,
1460
+ AttemptNumber: pWorkItem.AttemptNumber || 1,
1461
+ HubInstanceID: (this.fable.settings && this.fable.settings.UltravisorHubInstanceID) || ''
1462
+ };
1463
+ }
1251
1464
  return {
1252
1465
  WorkItemHash: pWorkItem.WorkItemHash,
1466
+ RunID: pWorkItem.RunID || '',
1253
1467
  Capability: pWorkItem.Capability,
1254
1468
  Action: pWorkItem.Action,
1255
- Settings: pWorkItem.Settings,
1469
+ Settings: tmpSettings,
1256
1470
  OperationHash: pWorkItem.OperationHash,
1257
- TimeoutMs: pWorkItem.TimeoutMs
1471
+ TimeoutMs: pWorkItem.TimeoutMs,
1472
+ AttemptNumber: pWorkItem.AttemptNumber || 1,
1473
+ QueueMetadata: tmpSettings.QueueMetadata
1258
1474
  };
1259
1475
  }
1260
1476
 
@@ -1342,8 +1558,20 @@ class UltravisorBeaconCoordinator extends libPictService
1342
1558
  return fCallback(new Error(`BeaconCoordinator: work item [${pWorkItemHash}] already finalized (${tmpWorkItem.Status}).`));
1343
1559
  }
1344
1560
 
1561
+ let tmpFromStatus = tmpWorkItem.Status;
1345
1562
  tmpWorkItem.Status = 'Complete';
1346
1563
  tmpWorkItem.CompletedAt = new Date().toISOString();
1564
+ tmpWorkItem.LastEventAt = tmpWorkItem.CompletedAt;
1565
+
1566
+ let tmpDurationMs = 0;
1567
+ if (tmpWorkItem.DispatchedAt)
1568
+ {
1569
+ let tmpStartMs = Date.parse(tmpWorkItem.DispatchedAt);
1570
+ if (!isNaN(tmpStartMs))
1571
+ {
1572
+ tmpDurationMs = Math.max(0, Date.now() - tmpStartMs);
1573
+ }
1574
+ }
1347
1575
 
1348
1576
  // Journal the completion
1349
1577
  let tmpJournal = this._getJournal();
@@ -1352,6 +1580,43 @@ class UltravisorBeaconCoordinator extends libPictService
1352
1580
  tmpJournal.appendEntry('complete', { WorkItemHash: pWorkItemHash });
1353
1581
  }
1354
1582
 
1583
+ // Persist to the new queue store + append event + update attempt.
1584
+ let tmpQueueStore = this._getQueueStore();
1585
+ if (tmpQueueStore)
1586
+ {
1587
+ try
1588
+ {
1589
+ tmpQueueStore.updateWorkItem(pWorkItemHash, {
1590
+ Status: 'Complete',
1591
+ CompletedAt: tmpWorkItem.CompletedAt,
1592
+ LastEventAt: tmpWorkItem.LastEventAt,
1593
+ Result: pResult || null
1594
+ });
1595
+ tmpQueueStore.appendEvent({
1596
+ WorkItemHash: pWorkItemHash,
1597
+ RunID: tmpWorkItem.RunID,
1598
+ EventType: 'completed',
1599
+ FromStatus: tmpFromStatus,
1600
+ ToStatus: 'Complete',
1601
+ BeaconID: tmpWorkItem.AssignedBeaconID,
1602
+ Payload: { DurationMs: tmpDurationMs }
1603
+ });
1604
+ tmpQueueStore.updateAttemptOutcome(pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
1605
+ CompletedAt: tmpWorkItem.CompletedAt,
1606
+ Outcome: 'Complete',
1607
+ DurationMs: tmpDurationMs
1608
+ });
1609
+ }
1610
+ catch (pStoreErr) { /* best effort */ }
1611
+ }
1612
+
1613
+ let tmpScheduler = this._getScheduler();
1614
+ if (tmpScheduler)
1615
+ {
1616
+ try { tmpScheduler.notifyCompleted(tmpWorkItem, tmpDurationMs); }
1617
+ catch (pErr) { /* best effort */ }
1618
+ }
1619
+
1355
1620
  // Merge accumulated progress logs with the final completion log
1356
1621
  let tmpFinalResult = pResult || {};
1357
1622
  if (tmpWorkItem.AccumulatedLog && tmpWorkItem.AccumulatedLog.length > 0)
@@ -1457,10 +1722,54 @@ class UltravisorBeaconCoordinator extends libPictService
1457
1722
  return fCallback(new Error(`BeaconCoordinator: work item [${pWorkItemHash}] already finalized (${tmpWorkItem.Status}).`));
1458
1723
  }
1459
1724
 
1725
+ // ── Retry check: if attempts remain, schedule a retry instead
1726
+ // of routing to the error path. The retry scheduler in
1727
+ // _checkTimeouts() will re-enqueue the item after the backoff.
1728
+ if (tmpWorkItem.AttemptNumber < tmpWorkItem.MaxAttempts - 1)
1729
+ {
1730
+ tmpWorkItem.AttemptNumber++;
1731
+ tmpWorkItem.LastError = (pError && pError.ErrorMessage) || 'Unknown error';
1732
+ tmpWorkItem.Status = 'RetryScheduled';
1733
+ tmpWorkItem.RetryAfter = Date.now() + (tmpWorkItem.RetryBackoffMs * tmpWorkItem.AttemptNumber);
1734
+ tmpWorkItem.ClaimedAt = null;
1735
+ tmpWorkItem.AssignedBeaconID = null;
1736
+
1737
+ // Remove from the beacon's active list so it can accept new work
1738
+ this._removeWorkItemFromBeacon(tmpWorkItem.AssignedBeaconID, pWorkItemHash);
1739
+
1740
+ let tmpJournal = this._getJournal();
1741
+ if (tmpJournal)
1742
+ {
1743
+ tmpJournal.appendEntry('retry-scheduled', {
1744
+ WorkItemHash: pWorkItemHash,
1745
+ AttemptNumber: tmpWorkItem.AttemptNumber,
1746
+ MaxAttempts: tmpWorkItem.MaxAttempts,
1747
+ RetryAfterMs: tmpWorkItem.RetryBackoffMs * tmpWorkItem.AttemptNumber,
1748
+ LastError: tmpWorkItem.LastError
1749
+ });
1750
+ }
1751
+
1752
+ this.log.info(`BeaconCoordinator: scheduling retry ${tmpWorkItem.AttemptNumber}/${tmpWorkItem.MaxAttempts} for [${pWorkItemHash}] in ${tmpWorkItem.RetryBackoffMs * tmpWorkItem.AttemptNumber}ms (error: ${tmpWorkItem.LastError.slice(0, 100)})`);
1753
+ return fCallback(null);
1754
+ }
1755
+
1756
+ let tmpFromStatus = tmpWorkItem.Status;
1460
1757
  tmpWorkItem.Status = 'Error';
1461
1758
  tmpWorkItem.CompletedAt = new Date().toISOString();
1759
+ tmpWorkItem.LastEventAt = tmpWorkItem.CompletedAt;
1760
+ tmpWorkItem.LastError = (pError && pError.ErrorMessage) || 'Unknown error';
1462
1761
  tmpWorkItem.Result = { Error: pError.ErrorMessage || 'Unknown error', Log: pError.Log || [] };
1463
1762
 
1763
+ let tmpFailDurationMs = 0;
1764
+ if (tmpWorkItem.DispatchedAt)
1765
+ {
1766
+ let tmpFailStartMs = Date.parse(tmpWorkItem.DispatchedAt);
1767
+ if (!isNaN(tmpFailStartMs))
1768
+ {
1769
+ tmpFailDurationMs = Math.max(0, Date.now() - tmpFailStartMs);
1770
+ }
1771
+ }
1772
+
1464
1773
  // Journal the failure
1465
1774
  let tmpJournal = this._getJournal();
1466
1775
  if (tmpJournal)
@@ -1468,6 +1777,45 @@ class UltravisorBeaconCoordinator extends libPictService
1468
1777
  tmpJournal.appendEntry('fail', { WorkItemHash: pWorkItemHash });
1469
1778
  }
1470
1779
 
1780
+ // Persist to the new queue store + event + attempt outcome.
1781
+ let tmpFailStore = this._getQueueStore();
1782
+ if (tmpFailStore)
1783
+ {
1784
+ try
1785
+ {
1786
+ tmpFailStore.updateWorkItem(pWorkItemHash, {
1787
+ Status: 'Error',
1788
+ CompletedAt: tmpWorkItem.CompletedAt,
1789
+ LastEventAt: tmpWorkItem.LastEventAt,
1790
+ LastError: tmpWorkItem.LastError,
1791
+ Result: tmpWorkItem.Result
1792
+ });
1793
+ tmpFailStore.appendEvent({
1794
+ WorkItemHash: pWorkItemHash,
1795
+ RunID: tmpWorkItem.RunID,
1796
+ EventType: 'failed',
1797
+ FromStatus: tmpFromStatus,
1798
+ ToStatus: 'Error',
1799
+ BeaconID: tmpWorkItem.AssignedBeaconID,
1800
+ Payload: { Error: tmpWorkItem.LastError, DurationMs: tmpFailDurationMs }
1801
+ });
1802
+ tmpFailStore.updateAttemptOutcome(pWorkItemHash, tmpWorkItem.AttemptNumber || 1, {
1803
+ CompletedAt: tmpWorkItem.CompletedAt,
1804
+ Outcome: 'Error',
1805
+ ErrorMessage: tmpWorkItem.LastError,
1806
+ DurationMs: tmpFailDurationMs
1807
+ });
1808
+ }
1809
+ catch (pStoreErr) { /* best effort */ }
1810
+ }
1811
+
1812
+ let tmpFailScheduler = this._getScheduler();
1813
+ if (tmpFailScheduler)
1814
+ {
1815
+ try { tmpFailScheduler.notifyFailed(tmpWorkItem, tmpWorkItem.LastError); }
1816
+ catch (pErr) { /* best effort */ }
1817
+ }
1818
+
1471
1819
  // Remove from Beacon's current work list
1472
1820
  this._removeWorkItemFromBeacon(tmpWorkItem.AssignedBeaconID, pWorkItemHash);
1473
1821
 
@@ -1817,6 +2165,21 @@ class UltravisorBeaconCoordinator extends libPictService
1817
2165
  TotalSteps: (pProgressData.TotalSteps !== undefined) ? pProgressData.TotalSteps : (tmpWorkItem.Progress ? tmpWorkItem.Progress.TotalSteps : undefined),
1818
2166
  UpdatedAt: new Date().toISOString()
1819
2167
  };
2168
+ // Mark the item as freshly heard-from for health scoring.
2169
+ tmpWorkItem.LastEventAt = tmpWorkItem.Progress.UpdatedAt;
2170
+ if (!tmpWorkItem.StartedAt) tmpWorkItem.StartedAt = tmpWorkItem.Progress.UpdatedAt;
2171
+ let tmpProgressStore = this._getQueueStore();
2172
+ if (tmpProgressStore)
2173
+ {
2174
+ try
2175
+ {
2176
+ tmpProgressStore.updateWorkItem(pWorkItemHash, {
2177
+ LastEventAt: tmpWorkItem.LastEventAt,
2178
+ StartedAt: tmpWorkItem.StartedAt
2179
+ });
2180
+ }
2181
+ catch (pErr) { /* best effort */ }
2182
+ }
1820
2183
 
1821
2184
  // Accumulate log entries
1822
2185
  if (Array.isArray(pProgressData.Log) && pProgressData.Log.length > 0)
@@ -2026,6 +2389,19 @@ class UltravisorBeaconCoordinator extends libPictService
2026
2389
  }
2027
2390
  }
2028
2391
 
2392
+ // Re-enqueue retry-scheduled work items whose backoff has elapsed
2393
+ for (let j = 0; j < tmpWorkItemHashes.length; j++)
2394
+ {
2395
+ let tmpRetryItem = this._WorkQueue[tmpWorkItemHashes[j]];
2396
+ if (tmpRetryItem.Status === 'RetryScheduled' && tmpRetryItem.RetryAfter && tmpNow >= tmpRetryItem.RetryAfter)
2397
+ {
2398
+ tmpRetryItem.Status = 'Pending';
2399
+ tmpRetryItem.RetryAfter = null;
2400
+ this.log.info(`BeaconCoordinator: re-enqueuing [${tmpRetryItem.WorkItemHash}] for retry attempt ${tmpRetryItem.AttemptNumber + 1}/${tmpRetryItem.MaxAttempts}`);
2401
+ this._tryPushToWebSocketBeacon(tmpRetryItem);
2402
+ }
2403
+ }
2404
+
2029
2405
  // Check Beacon heartbeat timeouts
2030
2406
  let tmpHeartbeatTimeout = this.fable.settings.UltravisorBeaconHeartbeatTimeoutMs || 60000;
2031
2407
  let tmpBeaconIDs = Object.keys(this._Beacons);
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Ultravisor Beacon Run Manager
3
+ *
4
+ * Owns the hub-assigned RunID namespace. Clients call startRun() with
5
+ * an optional IdempotencyKey; the manager returns an existing RunID on
6
+ * a duplicate key or mints a fresh one otherwise. The generated RunID
7
+ * shape is "rn-<hub-instance>-<epoch-ms>-<counter>" so the hub prefix
8
+ * cannot collide with any client-space identifier.
9
+ *
10
+ * All work items submitted via /Beacon/Work/Enqueue reference the
11
+ * RunID assigned here; retold-labs and other submitter stacks adopt
12
+ * this value as their phases.jsonl run_id field.
13
+ *
14
+ * @module Ultravisor-Beacon-RunManager
15
+ */
16
+
17
+ const libPictService = require('pict-serviceproviderbase');
18
+ const libCrypto = require('crypto');
19
+
20
+ class UltravisorBeaconRunManager extends libPictService
21
+ {
22
+ constructor(pPict, pOptions, pServiceHash)
23
+ {
24
+ super(pPict, pOptions, pServiceHash);
25
+
26
+ this.serviceType = 'UltravisorBeaconRunManager';
27
+
28
+ this._HubInstanceID = (this.fable.settings && this.fable.settings.UltravisorHubInstanceID)
29
+ || this._shortHostSlug();
30
+ this._RunCounter = 0;
31
+
32
+ // In-memory idempotency cache to short-circuit DB hits for hot
33
+ // submitters replaying the same key. Authoritative store is
34
+ // the BeaconRun table.
35
+ this._IdempotencyCache = {};
36
+ }
37
+
38
+ _shortHostSlug()
39
+ {
40
+ try
41
+ {
42
+ let tmpHost = require('os').hostname() || 'hub';
43
+ return tmpHost.replace(/[^a-zA-Z0-9]/g, '').slice(0, 12).toLowerCase() || 'hub';
44
+ }
45
+ catch (pError)
46
+ {
47
+ return 'hub';
48
+ }
49
+ }
50
+
51
+ _getStore()
52
+ {
53
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconQueueStore;
54
+ if (!tmpMap) return null;
55
+ let tmpStore = Object.values(tmpMap)[0];
56
+ return (tmpStore && tmpStore.isEnabled()) ? tmpStore : null;
57
+ }
58
+
59
+ _mintRunID()
60
+ {
61
+ this._RunCounter++;
62
+ return `rn-${this._HubInstanceID}-${Date.now()}-${this._RunCounter}`;
63
+ }
64
+
65
+ _guid()
66
+ {
67
+ if (typeof libCrypto.randomUUID === 'function')
68
+ {
69
+ return libCrypto.randomUUID();
70
+ }
71
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) =>
72
+ {
73
+ let r = Math.random() * 16 | 0;
74
+ let v = c === 'x' ? r : (r & 0x3 | 0x8);
75
+ return v.toString(16);
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Start a run. If pInfo.IdempotencyKey matches an active run, the
81
+ * existing run is returned instead of a new one being created.
82
+ *
83
+ * @param {object} pInfo - { IdempotencyKey?, SubmitterTag?, Metadata? }
84
+ * @returns {object} run record
85
+ */
86
+ startRun(pInfo)
87
+ {
88
+ let tmpInfo = pInfo || {};
89
+ let tmpStore = this._getStore();
90
+
91
+ if (tmpInfo.IdempotencyKey)
92
+ {
93
+ let tmpCached = this._IdempotencyCache[tmpInfo.IdempotencyKey];
94
+ if (tmpCached) return tmpCached;
95
+
96
+ if (tmpStore)
97
+ {
98
+ let tmpExisting = tmpStore.getRunByIdempotencyKey(tmpInfo.IdempotencyKey);
99
+ if (tmpExisting)
100
+ {
101
+ this._IdempotencyCache[tmpInfo.IdempotencyKey] = tmpExisting;
102
+ return tmpExisting;
103
+ }
104
+ }
105
+ }
106
+
107
+ let tmpRun = {
108
+ GUIDBeaconRun: this._guid(),
109
+ RunID: this._mintRunID(),
110
+ IdempotencyKey: tmpInfo.IdempotencyKey || '',
111
+ SubmitterTag: tmpInfo.SubmitterTag || '',
112
+ State: 'Active',
113
+ StartedAt: new Date().toISOString(),
114
+ Metadata: tmpInfo.Metadata || {}
115
+ };
116
+
117
+ if (tmpStore)
118
+ {
119
+ let tmpSaved = tmpStore.insertRun(tmpRun);
120
+ if (tmpSaved) tmpRun = tmpSaved;
121
+ }
122
+ if (tmpInfo.IdempotencyKey)
123
+ {
124
+ this._IdempotencyCache[tmpInfo.IdempotencyKey] = tmpRun;
125
+ }
126
+ this.log.info(`BeaconRunManager: started run [${tmpRun.RunID}] (tag=${tmpRun.SubmitterTag || 'none'}, idempotency=${tmpRun.IdempotencyKey || 'none'}).`);
127
+ return tmpRun;
128
+ }
129
+
130
+ getRun(pRunID)
131
+ {
132
+ let tmpStore = this._getStore();
133
+ return tmpStore ? tmpStore.getRunByRunID(pRunID) : null;
134
+ }
135
+
136
+ endRun(pRunID, pFinalState)
137
+ {
138
+ let tmpStore = this._getStore();
139
+ if (!tmpStore) return false;
140
+ let tmpRun = tmpStore.getRunByRunID(pRunID);
141
+ if (!tmpRun) return false;
142
+ let tmpState = pFinalState || 'Ended';
143
+ tmpStore.updateRunState(pRunID, tmpState,
144
+ { EndedAt: new Date().toISOString() });
145
+ if (tmpRun.IdempotencyKey)
146
+ {
147
+ delete this._IdempotencyCache[tmpRun.IdempotencyKey];
148
+ }
149
+ return true;
150
+ }
151
+
152
+ cancelRun(pRunID, pReason)
153
+ {
154
+ let tmpStore = this._getStore();
155
+ if (!tmpStore) return false;
156
+ let tmpRun = tmpStore.getRunByRunID(pRunID);
157
+ if (!tmpRun) return false;
158
+ let tmpNow = new Date().toISOString();
159
+ tmpStore.updateRunState(pRunID, 'Canceled',
160
+ { CanceledAt: tmpNow, EndedAt: tmpNow, CancelReason: pReason || '' });
161
+ if (tmpRun.IdempotencyKey)
162
+ {
163
+ delete this._IdempotencyCache[tmpRun.IdempotencyKey];
164
+ }
165
+ return true;
166
+ }
167
+ }
168
+
169
+ module.exports = UltravisorBeaconRunManager;