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.
- package/docs/queue-followups/03-config-migration.md +123 -0
- package/docs/queue-followups/04-direct-dispatch-phase-emission.md +128 -0
- package/package.json +3 -1
- package/source/Ultravisor.cjs +4 -0
- package/source/datamodel/Ultravisor-BeaconQueue.json +165 -0
- package/source/services/Ultravisor-Beacon-ActionDefaults.cjs +174 -0
- package/source/services/Ultravisor-Beacon-Coordinator.cjs +382 -6
- package/source/services/Ultravisor-Beacon-RunManager.cjs +169 -0
- package/source/services/Ultravisor-Beacon-Scheduler.cjs +789 -0
- package/source/services/Ultravisor-ExecutionEngine.cjs +242 -6
- package/source/services/Ultravisor-ExecutionManifest.cjs +1 -0
- package/source/services/persistence/Ultravisor-Beacon-QueueStore.cjs +886 -0
- package/source/services/tasks/file-system/Ultravisor-TaskConfigs-FileSystem.cjs +81 -0
- package/source/services/tasks/file-system/definitions/chunked-write.json +38 -0
- package/source/web_server/Ultravisor-API-Server.cjs +354 -0
- package/test/Ultravisor_BeaconQueue_tests.js +502 -0
- package/test/Ultravisor_tests.js +132 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ultravisor Beacon Scheduler
|
|
3
|
+
*
|
|
4
|
+
* Drives the queue forward between ticks:
|
|
5
|
+
* - Promotes Queued work items to Dispatched by matching available
|
|
6
|
+
* beacons to pending items (affinity → priority → FIFO EnqueuedAt).
|
|
7
|
+
* - Recomputes Health and HealthLabel for every non-terminal item
|
|
8
|
+
* using the min-of-dimensions formula (weakest link wins).
|
|
9
|
+
* - Broadcasts queue.* deltas + a rolled-up queue.summary snapshot
|
|
10
|
+
* to any subscribed WebSocket client.
|
|
11
|
+
*
|
|
12
|
+
* All mutations flow through the QueueStore so durability and the
|
|
13
|
+
* in-memory view stay aligned. The Coordinator remains the sole
|
|
14
|
+
* owner of _WorkQueue/_Beacons state; the scheduler calls into it for
|
|
15
|
+
* assignment and asks the store for historical queries.
|
|
16
|
+
*
|
|
17
|
+
* @module Ultravisor-Beacon-Scheduler
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const libPictService = require('pict-serviceproviderbase');
|
|
21
|
+
|
|
22
|
+
// Accept both the new queueing-era status names (Completed/Failed/Canceled)
|
|
23
|
+
// and the legacy Coordinator names (Complete/Error/Timeout) so an in-flight
|
|
24
|
+
// upgrade doesn't drop items.
|
|
25
|
+
const TERMINAL_STATUSES = new Set(['Completed', 'Complete', 'Failed', 'Error', 'Timeout', 'Canceled']);
|
|
26
|
+
const NONTERMINAL_STATUSES = ['Queued', 'Pending', 'Assigned', 'Dispatched', 'Running'];
|
|
27
|
+
|
|
28
|
+
// Health label thresholds (on the 0.0..1.0 score)
|
|
29
|
+
const THRESHOLD_HEALTHY = 0.6;
|
|
30
|
+
const THRESHOLD_UNCERTAIN = 0.3;
|
|
31
|
+
|
|
32
|
+
// Items younger than this have no meaningful health signal yet.
|
|
33
|
+
const FRESH_GRACE_MS = 2000;
|
|
34
|
+
|
|
35
|
+
class UltravisorBeaconScheduler extends libPictService
|
|
36
|
+
{
|
|
37
|
+
constructor(pPict, pOptions, pServiceHash)
|
|
38
|
+
{
|
|
39
|
+
super(pPict, pOptions, pServiceHash);
|
|
40
|
+
|
|
41
|
+
this.serviceType = 'UltravisorBeaconScheduler';
|
|
42
|
+
|
|
43
|
+
this._DispatchTickMs = (this.fable.settings && this.fable.settings.UltravisorSchedulerDispatchTickMs) || 500;
|
|
44
|
+
this._HealthTickMs = (this.fable.settings && this.fable.settings.UltravisorSchedulerHealthTickMs) || 5000;
|
|
45
|
+
this._SummaryTickMs = (this.fable.settings && this.fable.settings.UltravisorSchedulerSummaryTickMs) || 1000;
|
|
46
|
+
|
|
47
|
+
this._DispatchInterval = null;
|
|
48
|
+
this._HealthInterval = null;
|
|
49
|
+
this._SummaryInterval = null;
|
|
50
|
+
this._Running = false;
|
|
51
|
+
|
|
52
|
+
this._BroadcastHandler = null;
|
|
53
|
+
|
|
54
|
+
// Last-broadcast snapshots so we only emit health deltas that move the label
|
|
55
|
+
// or move the score by more than DELTA threshold.
|
|
56
|
+
this._LastHealthBroadcast = {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setBroadcastHandler(fHandler)
|
|
60
|
+
{
|
|
61
|
+
this._BroadcastHandler = (typeof fHandler === 'function') ? fHandler : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_broadcast(pTopic, pPayload)
|
|
65
|
+
{
|
|
66
|
+
if (!this._BroadcastHandler) return;
|
|
67
|
+
try { this._BroadcastHandler(pTopic, pPayload); }
|
|
68
|
+
catch (pError) { this.log.warn(`Scheduler broadcast failed (${pTopic}): ${pError.message}`); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_getCoordinator()
|
|
72
|
+
{
|
|
73
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconCoordinator;
|
|
74
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_getStore()
|
|
78
|
+
{
|
|
79
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconQueueStore;
|
|
80
|
+
if (!tmpMap) return null;
|
|
81
|
+
let tmpStore = Object.values(tmpMap)[0];
|
|
82
|
+
return (tmpStore && tmpStore.isEnabled()) ? tmpStore : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_getDefaults()
|
|
86
|
+
{
|
|
87
|
+
let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconActionDefaults;
|
|
88
|
+
return tmpMap ? Object.values(tmpMap)[0] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ====================================================================
|
|
92
|
+
// Lifecycle
|
|
93
|
+
// ====================================================================
|
|
94
|
+
|
|
95
|
+
start()
|
|
96
|
+
{
|
|
97
|
+
if (this._Running) return;
|
|
98
|
+
this._Running = true;
|
|
99
|
+
|
|
100
|
+
this._DispatchInterval = setInterval(() => this._dispatchTick(), this._DispatchTickMs);
|
|
101
|
+
this._HealthInterval = setInterval(() => this._healthTick(), this._HealthTickMs);
|
|
102
|
+
this._SummaryInterval = setInterval(() => this._summaryTick(), this._SummaryTickMs);
|
|
103
|
+
|
|
104
|
+
this.log.info(`BeaconScheduler: started (dispatch=${this._DispatchTickMs}ms health=${this._HealthTickMs}ms summary=${this._SummaryTickMs}ms).`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
stop()
|
|
108
|
+
{
|
|
109
|
+
if (!this._Running) return;
|
|
110
|
+
this._Running = false;
|
|
111
|
+
if (this._DispatchInterval) { clearInterval(this._DispatchInterval); this._DispatchInterval = null; }
|
|
112
|
+
if (this._HealthInterval) { clearInterval(this._HealthInterval); this._HealthInterval = null; }
|
|
113
|
+
if (this._SummaryInterval) { clearInterval(this._SummaryInterval); this._SummaryInterval = null; }
|
|
114
|
+
this.log.info('BeaconScheduler: stopped.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ====================================================================
|
|
118
|
+
// Dispatch tick — promote Queued → Dispatched
|
|
119
|
+
// ====================================================================
|
|
120
|
+
|
|
121
|
+
_dispatchTick()
|
|
122
|
+
{
|
|
123
|
+
let tmpCoordinator = this._getCoordinator();
|
|
124
|
+
if (!tmpCoordinator) return;
|
|
125
|
+
|
|
126
|
+
let tmpQueue = tmpCoordinator._WorkQueue || {};
|
|
127
|
+
let tmpPending = [];
|
|
128
|
+
for (let tmpHash of Object.keys(tmpQueue))
|
|
129
|
+
{
|
|
130
|
+
let tmpItem = tmpQueue[tmpHash];
|
|
131
|
+
if (tmpItem.Status === 'Queued' || tmpItem.Status === 'Pending')
|
|
132
|
+
{
|
|
133
|
+
tmpPending.push(tmpItem);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (tmpPending.length === 0) return;
|
|
137
|
+
|
|
138
|
+
tmpPending.sort(this._compareDispatchPriority);
|
|
139
|
+
|
|
140
|
+
for (let tmpItem of tmpPending)
|
|
141
|
+
{
|
|
142
|
+
if (tmpItem.CancelRequested)
|
|
143
|
+
{
|
|
144
|
+
this._markCanceled(tmpItem, tmpCoordinator, 'Canceled before dispatch');
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let tmpBeacon = this._pickBeacon(tmpItem, tmpCoordinator);
|
|
148
|
+
if (!tmpBeacon) continue;
|
|
149
|
+
this._dispatchItemToBeacon(tmpItem, tmpBeacon, tmpCoordinator);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_compareDispatchPriority(pA, pB)
|
|
154
|
+
{
|
|
155
|
+
// Affinity-bound first (already Status=Assigned by coordinator)
|
|
156
|
+
let tmpAAssigned = pA.Status === 'Assigned' ? 1 : 0;
|
|
157
|
+
let tmpBAssigned = pB.Status === 'Assigned' ? 1 : 0;
|
|
158
|
+
if (tmpAAssigned !== tmpBAssigned) return tmpBAssigned - tmpAAssigned;
|
|
159
|
+
|
|
160
|
+
// Then higher priority
|
|
161
|
+
let tmpAPri = pA.Priority || 0;
|
|
162
|
+
let tmpBPri = pB.Priority || 0;
|
|
163
|
+
if (tmpAPri !== tmpBPri) return tmpBPri - tmpAPri;
|
|
164
|
+
|
|
165
|
+
// Then FIFO on EnqueuedAt (CreatedAt fallback)
|
|
166
|
+
let tmpATs = pA.EnqueuedAt || pA.CreatedAt || '';
|
|
167
|
+
let tmpBTs = pB.EnqueuedAt || pB.CreatedAt || '';
|
|
168
|
+
if (tmpATs < tmpBTs) return -1;
|
|
169
|
+
if (tmpATs > tmpBTs) return 1;
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_pickBeacon(pItem, pCoordinator)
|
|
174
|
+
{
|
|
175
|
+
if (pItem.AssignedBeaconID)
|
|
176
|
+
{
|
|
177
|
+
let tmpBeacon = pCoordinator._Beacons[pItem.AssignedBeaconID];
|
|
178
|
+
if (tmpBeacon && this._beaconCanTake(tmpBeacon, pItem))
|
|
179
|
+
{
|
|
180
|
+
return tmpBeacon;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let tmpBestBeacon = null;
|
|
186
|
+
let tmpBestLoad = Infinity;
|
|
187
|
+
let tmpBeacons = pCoordinator._Beacons || {};
|
|
188
|
+
for (let tmpID of Object.keys(tmpBeacons))
|
|
189
|
+
{
|
|
190
|
+
let tmpBeacon = tmpBeacons[tmpID];
|
|
191
|
+
if (!this._beaconCanTake(tmpBeacon, pItem)) continue;
|
|
192
|
+
let tmpLoad = (tmpBeacon.CurrentWorkItems || []).length;
|
|
193
|
+
if (tmpLoad < tmpBestLoad)
|
|
194
|
+
{
|
|
195
|
+
tmpBestLoad = tmpLoad;
|
|
196
|
+
tmpBestBeacon = tmpBeacon;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return tmpBestBeacon;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_beaconCanTake(pBeacon, pItem)
|
|
203
|
+
{
|
|
204
|
+
if (!pBeacon) return false;
|
|
205
|
+
if (pBeacon.Status && pBeacon.Status !== 'Online' && pBeacon.Status !== 'Active') return false;
|
|
206
|
+
|
|
207
|
+
let tmpCaps = pBeacon.Capabilities || [];
|
|
208
|
+
let tmpCapName = pItem.Capability || 'Shell';
|
|
209
|
+
let tmpHasCap = false;
|
|
210
|
+
for (let tmpCap of tmpCaps)
|
|
211
|
+
{
|
|
212
|
+
if (typeof tmpCap === 'string' && tmpCap === tmpCapName) { tmpHasCap = true; break; }
|
|
213
|
+
if (tmpCap && tmpCap.Capability === tmpCapName) { tmpHasCap = true; break; }
|
|
214
|
+
}
|
|
215
|
+
if (!tmpHasCap) return false;
|
|
216
|
+
|
|
217
|
+
let tmpMax = pBeacon.MaxConcurrent || 1;
|
|
218
|
+
let tmpActive = (pBeacon.CurrentWorkItems || []).length;
|
|
219
|
+
return tmpActive < tmpMax;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_dispatchItemToBeacon(pItem, pBeacon, pCoordinator)
|
|
223
|
+
{
|
|
224
|
+
let tmpNow = Date.now();
|
|
225
|
+
let tmpNowIso = new Date(tmpNow).toISOString();
|
|
226
|
+
let tmpEnqueuedMs = pItem.EnqueuedAt ? Date.parse(pItem.EnqueuedAt) : tmpNow;
|
|
227
|
+
let tmpQueueWaitMs = Math.max(0, tmpNow - tmpEnqueuedMs);
|
|
228
|
+
|
|
229
|
+
let tmpFromStatus = pItem.Status;
|
|
230
|
+
pItem.Status = 'Dispatched';
|
|
231
|
+
pItem.AssignedBeaconID = pBeacon.BeaconID;
|
|
232
|
+
pItem.AssignedAt = pItem.AssignedAt || tmpNowIso;
|
|
233
|
+
pItem.DispatchedAt = tmpNowIso;
|
|
234
|
+
pItem.QueueWaitMs = tmpQueueWaitMs;
|
|
235
|
+
pItem.LastEventAt = tmpNowIso;
|
|
236
|
+
pItem.AttemptNumber = (pItem.AttemptNumber || 0) + 1;
|
|
237
|
+
|
|
238
|
+
// Pre-compute QueueMetadata envelope so the submitter side
|
|
239
|
+
// (retold-labs capability handlers) can emit phases.jsonl
|
|
240
|
+
// without having to call back to the hub.
|
|
241
|
+
pItem.Settings = pItem.Settings || {};
|
|
242
|
+
pItem.Settings.QueueMetadata = {
|
|
243
|
+
RunID: pItem.RunID || '',
|
|
244
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
245
|
+
EnqueuedAt: pItem.EnqueuedAt || tmpNowIso,
|
|
246
|
+
DispatchedAt: tmpNowIso,
|
|
247
|
+
QueueWaitMs: tmpQueueWaitMs,
|
|
248
|
+
AttemptNumber: pItem.AttemptNumber,
|
|
249
|
+
HubInstanceID: (this.fable.settings && this.fable.settings.UltravisorHubInstanceID) || ''
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Track beacon load.
|
|
253
|
+
if (!pBeacon.CurrentWorkItems) pBeacon.CurrentWorkItems = [];
|
|
254
|
+
if (pBeacon.CurrentWorkItems.indexOf(pItem.WorkItemHash) === -1)
|
|
255
|
+
{
|
|
256
|
+
pBeacon.CurrentWorkItems.push(pItem.WorkItemHash);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let tmpStore = this._getStore();
|
|
260
|
+
if (tmpStore)
|
|
261
|
+
{
|
|
262
|
+
tmpStore.updateWorkItem(pItem.WorkItemHash, {
|
|
263
|
+
Status: 'Dispatched',
|
|
264
|
+
AssignedBeaconID: pBeacon.BeaconID,
|
|
265
|
+
AssignedAt: pItem.AssignedAt,
|
|
266
|
+
DispatchedAt: pItem.DispatchedAt,
|
|
267
|
+
QueueWaitMs: tmpQueueWaitMs,
|
|
268
|
+
AttemptNumber: pItem.AttemptNumber,
|
|
269
|
+
LastEventAt: pItem.LastEventAt,
|
|
270
|
+
Settings: pItem.Settings
|
|
271
|
+
});
|
|
272
|
+
tmpStore.appendEvent({
|
|
273
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
274
|
+
RunID: pItem.RunID,
|
|
275
|
+
EventType: 'dispatched',
|
|
276
|
+
FromStatus: tmpFromStatus,
|
|
277
|
+
ToStatus: 'Dispatched',
|
|
278
|
+
BeaconID: pBeacon.BeaconID,
|
|
279
|
+
Payload: { QueueWaitMs: tmpQueueWaitMs }
|
|
280
|
+
});
|
|
281
|
+
tmpStore.insertAttempt({
|
|
282
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
283
|
+
AttemptNumber: pItem.AttemptNumber,
|
|
284
|
+
BeaconID: pBeacon.BeaconID,
|
|
285
|
+
DispatchedAt: pItem.DispatchedAt,
|
|
286
|
+
Outcome: 'Dispatched'
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.log.info(`BeaconScheduler: dispatched [${pItem.WorkItemHash}] to beacon [${pBeacon.BeaconID}] (queue_wait=${tmpQueueWaitMs}ms, attempt=${pItem.AttemptNumber}).`);
|
|
291
|
+
|
|
292
|
+
// Try WebSocket push first; fall back to HTTP poll pickup.
|
|
293
|
+
let tmpPushed = false;
|
|
294
|
+
if (typeof pCoordinator._WorkItemPushHandler === 'function'
|
|
295
|
+
&& typeof pCoordinator._sanitizeWorkItemForBeacon === 'function')
|
|
296
|
+
{
|
|
297
|
+
try
|
|
298
|
+
{
|
|
299
|
+
let tmpSanitized = pCoordinator._sanitizeWorkItemForBeacon(pItem);
|
|
300
|
+
tmpPushed = !!pCoordinator._WorkItemPushHandler(pBeacon.BeaconID, tmpSanitized);
|
|
301
|
+
}
|
|
302
|
+
catch (pErr)
|
|
303
|
+
{
|
|
304
|
+
this.log.warn(`BeaconScheduler: push handler threw: ${pErr.message}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this._broadcast('queue.dispatched', {
|
|
309
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
310
|
+
RunID: pItem.RunID,
|
|
311
|
+
BeaconID: pBeacon.BeaconID,
|
|
312
|
+
Capability: pItem.Capability,
|
|
313
|
+
Action: pItem.Action,
|
|
314
|
+
QueueWaitMs: tmpQueueWaitMs,
|
|
315
|
+
AttemptNumber: pItem.AttemptNumber,
|
|
316
|
+
Pushed: tmpPushed,
|
|
317
|
+
DispatchedAt: pItem.DispatchedAt
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_markCanceled(pItem, pCoordinator, pReason)
|
|
322
|
+
{
|
|
323
|
+
let tmpNowIso = new Date().toISOString();
|
|
324
|
+
let tmpFromStatus = pItem.Status;
|
|
325
|
+
pItem.Status = 'Canceled';
|
|
326
|
+
pItem.CanceledAt = pItem.CanceledAt || tmpNowIso;
|
|
327
|
+
pItem.CancelReason = pItem.CancelReason || pReason || '';
|
|
328
|
+
pItem.LastEventAt = tmpNowIso;
|
|
329
|
+
|
|
330
|
+
let tmpStore = this._getStore();
|
|
331
|
+
if (tmpStore)
|
|
332
|
+
{
|
|
333
|
+
tmpStore.updateWorkItem(pItem.WorkItemHash, {
|
|
334
|
+
Status: 'Canceled',
|
|
335
|
+
CanceledAt: pItem.CanceledAt,
|
|
336
|
+
CancelReason: pItem.CancelReason,
|
|
337
|
+
LastEventAt: tmpNowIso
|
|
338
|
+
});
|
|
339
|
+
tmpStore.appendEvent({
|
|
340
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
341
|
+
RunID: pItem.RunID,
|
|
342
|
+
EventType: 'canceled',
|
|
343
|
+
FromStatus: tmpFromStatus,
|
|
344
|
+
ToStatus: 'Canceled',
|
|
345
|
+
Payload: { Reason: pItem.CancelReason }
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
this._broadcast('queue.canceled', {
|
|
350
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
351
|
+
RunID: pItem.RunID,
|
|
352
|
+
Reason: pItem.CancelReason
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ====================================================================
|
|
357
|
+
// Health tick — recompute Health/HealthLabel on every non-terminal item
|
|
358
|
+
// ====================================================================
|
|
359
|
+
|
|
360
|
+
_healthTick()
|
|
361
|
+
{
|
|
362
|
+
let tmpCoordinator = this._getCoordinator();
|
|
363
|
+
if (!tmpCoordinator) return;
|
|
364
|
+
let tmpQueue = tmpCoordinator._WorkQueue || {};
|
|
365
|
+
for (let tmpHash of Object.keys(tmpQueue))
|
|
366
|
+
{
|
|
367
|
+
let tmpItem = tmpQueue[tmpHash];
|
|
368
|
+
if (TERMINAL_STATUSES.has(tmpItem.Status)) continue;
|
|
369
|
+
this._updateHealth(tmpItem);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Compute the min-of-dimensions health score for a single item.
|
|
375
|
+
* Public on the instance so the API server can request an
|
|
376
|
+
* on-demand refresh for the /queue view.
|
|
377
|
+
*/
|
|
378
|
+
computeHealth(pItem)
|
|
379
|
+
{
|
|
380
|
+
let tmpNow = Date.now();
|
|
381
|
+
let tmpReasons = [];
|
|
382
|
+
let tmpScore = 1.0;
|
|
383
|
+
let tmpUnknown = false;
|
|
384
|
+
|
|
385
|
+
let tmpDefaults = this._getDefaults();
|
|
386
|
+
let tmpResolved = tmpDefaults
|
|
387
|
+
? tmpDefaults.resolve(pItem.Capability, pItem.Action)
|
|
388
|
+
: { HeartbeatExpectedMs: 60000, ExpectedWaitP95Ms: 0, MinSamplesForBaseline: 20 };
|
|
389
|
+
|
|
390
|
+
// Dimension 1: retry burn — always applicable
|
|
391
|
+
let tmpMaxAttempts = pItem.MaxAttempts || 1;
|
|
392
|
+
let tmpAttemptNum = pItem.AttemptNumber || 0;
|
|
393
|
+
if (tmpMaxAttempts > 0)
|
|
394
|
+
{
|
|
395
|
+
let tmpRetryScore = Math.max(0, 1 - (tmpAttemptNum / tmpMaxAttempts));
|
|
396
|
+
if (tmpRetryScore < tmpScore)
|
|
397
|
+
{
|
|
398
|
+
tmpScore = tmpRetryScore;
|
|
399
|
+
tmpReasons = ['retry_burn'];
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (pItem.Status === 'Queued' || pItem.Status === 'Pending' || pItem.Status === 'Assigned')
|
|
404
|
+
{
|
|
405
|
+
let tmpEnq = pItem.EnqueuedAt ? Date.parse(pItem.EnqueuedAt) : tmpNow;
|
|
406
|
+
let tmpWait = Math.max(0, tmpNow - tmpEnq);
|
|
407
|
+
|
|
408
|
+
// Need a baseline to judge wait health.
|
|
409
|
+
if (tmpResolved.ExpectedWaitP95Ms > 0)
|
|
410
|
+
{
|
|
411
|
+
let tmpCeiling = tmpResolved.ExpectedWaitP95Ms * 3;
|
|
412
|
+
let tmpWaitScore = Math.max(0, Math.min(1, 1 - (tmpWait / tmpCeiling)));
|
|
413
|
+
if (tmpWaitScore < tmpScore)
|
|
414
|
+
{
|
|
415
|
+
tmpScore = tmpWaitScore;
|
|
416
|
+
tmpReasons = ['queue_wait'];
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
else
|
|
420
|
+
{
|
|
421
|
+
// No baseline yet. If we also have no signal from
|
|
422
|
+
// retry burn (first attempt), we can't judge — Unknown.
|
|
423
|
+
if (tmpAttemptNum <= 1) tmpUnknown = true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else if (pItem.Status === 'Dispatched' || pItem.Status === 'Running')
|
|
427
|
+
{
|
|
428
|
+
let tmpStarted = pItem.StartedAt ? Date.parse(pItem.StartedAt)
|
|
429
|
+
: (pItem.DispatchedAt ? Date.parse(pItem.DispatchedAt) : tmpNow);
|
|
430
|
+
let tmpElapsed = Math.max(0, tmpNow - tmpStarted);
|
|
431
|
+
|
|
432
|
+
if (tmpElapsed < FRESH_GRACE_MS && !pItem.LastEventAt)
|
|
433
|
+
{
|
|
434
|
+
tmpUnknown = true;
|
|
435
|
+
}
|
|
436
|
+
else
|
|
437
|
+
{
|
|
438
|
+
// Dimension 2: timeout proximity
|
|
439
|
+
let tmpTimeout = pItem.TimeoutMs || 300000;
|
|
440
|
+
let tmpTimeoutScore = Math.max(0, 1 - (tmpElapsed / tmpTimeout));
|
|
441
|
+
if (tmpTimeoutScore < tmpScore)
|
|
442
|
+
{
|
|
443
|
+
tmpScore = tmpTimeoutScore;
|
|
444
|
+
tmpReasons = ['timeout_proximity'];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Dimension 3: event freshness
|
|
448
|
+
let tmpLastEvent = pItem.LastEventAt
|
|
449
|
+
? Date.parse(pItem.LastEventAt)
|
|
450
|
+
: tmpStarted;
|
|
451
|
+
let tmpSinceEvent = Math.max(0, tmpNow - tmpLastEvent);
|
|
452
|
+
let tmpHeartbeat = tmpResolved.HeartbeatExpectedMs || 60000;
|
|
453
|
+
let tmpFreshnessScore = Math.max(0, Math.min(1, 1 - (tmpSinceEvent / (tmpHeartbeat * 2))));
|
|
454
|
+
if (tmpFreshnessScore < tmpScore)
|
|
455
|
+
{
|
|
456
|
+
tmpScore = tmpFreshnessScore;
|
|
457
|
+
tmpReasons = ['event_freshness'];
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (tmpUnknown)
|
|
463
|
+
{
|
|
464
|
+
return { Score: null, Label: 'Unknown', Reason: 'insufficient_signal' };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let tmpLabel;
|
|
468
|
+
if (tmpScore >= THRESHOLD_HEALTHY) tmpLabel = 'Healthy';
|
|
469
|
+
else if (tmpScore >= THRESHOLD_UNCERTAIN) tmpLabel = 'Uncertain';
|
|
470
|
+
else tmpLabel = 'Unhealthy';
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
Score: Number(tmpScore.toFixed(4)),
|
|
474
|
+
Label: tmpLabel,
|
|
475
|
+
Reason: tmpReasons[0] || 'nominal'
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_updateHealth(pItem)
|
|
480
|
+
{
|
|
481
|
+
let tmpHealth = this.computeHealth(pItem);
|
|
482
|
+
let tmpNowIso = new Date().toISOString();
|
|
483
|
+
|
|
484
|
+
let tmpPrevLabel = pItem.HealthLabel || 'Unknown';
|
|
485
|
+
let tmpPrevScore = (pItem.Health == null) ? null : Number(pItem.Health);
|
|
486
|
+
|
|
487
|
+
pItem.Health = tmpHealth.Score;
|
|
488
|
+
pItem.HealthLabel = tmpHealth.Label;
|
|
489
|
+
pItem.HealthReason = tmpHealth.Reason;
|
|
490
|
+
pItem.HealthComputedAt = tmpNowIso;
|
|
491
|
+
|
|
492
|
+
let tmpStore = this._getStore();
|
|
493
|
+
if (tmpStore)
|
|
494
|
+
{
|
|
495
|
+
tmpStore.updateWorkItem(pItem.WorkItemHash, {
|
|
496
|
+
Health: tmpHealth.Score,
|
|
497
|
+
HealthLabel: tmpHealth.Label,
|
|
498
|
+
HealthReason: tmpHealth.Reason,
|
|
499
|
+
HealthComputedAt: tmpNowIso
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Only broadcast on label change or meaningful score delta.
|
|
504
|
+
let tmpLabelChanged = (tmpPrevLabel !== tmpHealth.Label);
|
|
505
|
+
let tmpScoreDelta = (tmpPrevScore != null && tmpHealth.Score != null)
|
|
506
|
+
? Math.abs(tmpPrevScore - tmpHealth.Score)
|
|
507
|
+
: 1;
|
|
508
|
+
let tmpShouldBroadcast = tmpLabelChanged || (tmpScoreDelta >= 0.1);
|
|
509
|
+
if (!tmpShouldBroadcast) return;
|
|
510
|
+
|
|
511
|
+
this._LastHealthBroadcast[pItem.WorkItemHash] = { Label: tmpHealth.Label, Score: tmpHealth.Score };
|
|
512
|
+
this._broadcast('queue.health', {
|
|
513
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
514
|
+
RunID: pItem.RunID,
|
|
515
|
+
Status: pItem.Status,
|
|
516
|
+
Health: tmpHealth.Score,
|
|
517
|
+
HealthLabel: tmpHealth.Label,
|
|
518
|
+
HealthReason: tmpHealth.Reason
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ====================================================================
|
|
523
|
+
// Summary tick — bucket counts + p50/p95 per capability
|
|
524
|
+
// ====================================================================
|
|
525
|
+
|
|
526
|
+
_summaryTick()
|
|
527
|
+
{
|
|
528
|
+
let tmpSummary = this.summarize();
|
|
529
|
+
this._broadcast('queue.summary', tmpSummary);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
summarize()
|
|
533
|
+
{
|
|
534
|
+
let tmpCoordinator = this._getCoordinator();
|
|
535
|
+
let tmpQueue = tmpCoordinator ? (tmpCoordinator._WorkQueue || {}) : {};
|
|
536
|
+
|
|
537
|
+
let tmpBuckets = { Upcoming: 0, InProgress: 0, Stalled: 0, Completed: 0, Errored: 0 };
|
|
538
|
+
let tmpByCapability = {};
|
|
539
|
+
|
|
540
|
+
for (let tmpHash of Object.keys(tmpQueue))
|
|
541
|
+
{
|
|
542
|
+
let tmpItem = tmpQueue[tmpHash];
|
|
543
|
+
let tmpBucket = this.bucketFor(tmpItem);
|
|
544
|
+
tmpBuckets[tmpBucket] = (tmpBuckets[tmpBucket] || 0) + 1;
|
|
545
|
+
|
|
546
|
+
let tmpCapKey = `${tmpItem.Capability || 'Shell'}|${tmpItem.Action || ''}`;
|
|
547
|
+
if (!tmpByCapability[tmpCapKey])
|
|
548
|
+
{
|
|
549
|
+
tmpByCapability[tmpCapKey] = {
|
|
550
|
+
Capability: tmpItem.Capability || 'Shell',
|
|
551
|
+
Action: tmpItem.Action || '',
|
|
552
|
+
Queued: 0, Running: 0, Stalled: 0
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
let tmpRow = tmpByCapability[tmpCapKey];
|
|
556
|
+
if (tmpBucket === 'Upcoming') tmpRow.Queued++;
|
|
557
|
+
else if (tmpBucket === 'InProgress') tmpRow.Running++;
|
|
558
|
+
else if (tmpBucket === 'Stalled') tmpRow.Stalled++;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return {
|
|
562
|
+
At: new Date().toISOString(),
|
|
563
|
+
Buckets: tmpBuckets,
|
|
564
|
+
ByCapability: Object.values(tmpByCapability)
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
bucketFor(pItem)
|
|
569
|
+
{
|
|
570
|
+
let tmpStatus = pItem.Status;
|
|
571
|
+
if (tmpStatus === 'Queued' || tmpStatus === 'Pending' || tmpStatus === 'Assigned')
|
|
572
|
+
{
|
|
573
|
+
return 'Upcoming';
|
|
574
|
+
}
|
|
575
|
+
if (tmpStatus === 'Completed' || tmpStatus === 'Complete') return 'Completed';
|
|
576
|
+
if (tmpStatus === 'Failed' || tmpStatus === 'Error'
|
|
577
|
+
|| tmpStatus === 'Timeout' || tmpStatus === 'Canceled') return 'Errored';
|
|
578
|
+
if (tmpStatus === 'Dispatched' || tmpStatus === 'Running')
|
|
579
|
+
{
|
|
580
|
+
return (pItem.HealthLabel === 'Unhealthy') ? 'Stalled' : 'InProgress';
|
|
581
|
+
}
|
|
582
|
+
return 'Upcoming';
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ====================================================================
|
|
586
|
+
// Broadcast hooks for Coordinator / API server to call on events we
|
|
587
|
+
// don't directly observe (completion, failure, explicit enqueue).
|
|
588
|
+
// ====================================================================
|
|
589
|
+
|
|
590
|
+
notifyEnqueued(pItem)
|
|
591
|
+
{
|
|
592
|
+
this._broadcast('queue.enqueued', {
|
|
593
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
594
|
+
RunID: pItem.RunID,
|
|
595
|
+
Capability: pItem.Capability,
|
|
596
|
+
Action: pItem.Action,
|
|
597
|
+
Priority: pItem.Priority,
|
|
598
|
+
EnqueuedAt: pItem.EnqueuedAt,
|
|
599
|
+
AffinityKey: pItem.AffinityKey || ''
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
notifyCompleted(pItem, pDurationMs)
|
|
604
|
+
{
|
|
605
|
+
this._broadcast('queue.completed', {
|
|
606
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
607
|
+
RunID: pItem.RunID,
|
|
608
|
+
BeaconID: pItem.AssignedBeaconID,
|
|
609
|
+
DurationMs: pDurationMs || 0,
|
|
610
|
+
CompletedAt: pItem.CompletedAt
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
notifyFailed(pItem, pError)
|
|
615
|
+
{
|
|
616
|
+
this._broadcast('queue.failed', {
|
|
617
|
+
WorkItemHash: pItem.WorkItemHash,
|
|
618
|
+
RunID: pItem.RunID,
|
|
619
|
+
BeaconID: pItem.AssignedBeaconID,
|
|
620
|
+
Attempt: pItem.AttemptNumber,
|
|
621
|
+
Error: pError || pItem.LastError || 'unknown'
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ====================================================================
|
|
626
|
+
// Cancellation
|
|
627
|
+
// ====================================================================
|
|
628
|
+
|
|
629
|
+
requestCancel(pWorkItemHash, pReason)
|
|
630
|
+
{
|
|
631
|
+
let tmpCoordinator = this._getCoordinator();
|
|
632
|
+
if (!tmpCoordinator) return { Canceled: false, Error: 'coordinator unavailable' };
|
|
633
|
+
let tmpItem = tmpCoordinator._WorkQueue[pWorkItemHash];
|
|
634
|
+
if (!tmpItem) return { Canceled: false, Error: 'not found' };
|
|
635
|
+
|
|
636
|
+
if (tmpItem.Status === 'Queued' || tmpItem.Status === 'Pending' || tmpItem.Status === 'Assigned')
|
|
637
|
+
{
|
|
638
|
+
this._markCanceled(tmpItem, tmpCoordinator, pReason);
|
|
639
|
+
return { Canceled: true, Status: 'Canceled' };
|
|
640
|
+
}
|
|
641
|
+
if (tmpItem.Status === 'Dispatched' || tmpItem.Status === 'Running')
|
|
642
|
+
{
|
|
643
|
+
tmpItem.CancelRequested = true;
|
|
644
|
+
tmpItem.CancelReason = pReason || 'cancel requested';
|
|
645
|
+
let tmpStore = this._getStore();
|
|
646
|
+
if (tmpStore)
|
|
647
|
+
{
|
|
648
|
+
tmpStore.updateWorkItem(pWorkItemHash, {
|
|
649
|
+
CancelRequested: true,
|
|
650
|
+
CancelReason: tmpItem.CancelReason
|
|
651
|
+
});
|
|
652
|
+
tmpStore.appendEvent({
|
|
653
|
+
WorkItemHash: pWorkItemHash,
|
|
654
|
+
RunID: tmpItem.RunID,
|
|
655
|
+
EventType: 'cancel_requested',
|
|
656
|
+
FromStatus: tmpItem.Status,
|
|
657
|
+
ToStatus: tmpItem.Status,
|
|
658
|
+
Payload: { Reason: tmpItem.CancelReason }
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
this._broadcast('queue.cancel_requested', {
|
|
662
|
+
WorkItemHash: pWorkItemHash,
|
|
663
|
+
RunID: tmpItem.RunID,
|
|
664
|
+
Reason: tmpItem.CancelReason
|
|
665
|
+
});
|
|
666
|
+
return { Canceled: false, CancelRequested: true, Status: tmpItem.Status };
|
|
667
|
+
}
|
|
668
|
+
return { Canceled: false, Status: tmpItem.Status, Error: 'terminal status' };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ====================================================================
|
|
672
|
+
// Reorder (move one slot up or down within the Upcoming bucket)
|
|
673
|
+
// ====================================================================
|
|
674
|
+
//
|
|
675
|
+
// Sorting here is `Priority DESC, then FIFO EnqueuedAt ASC` (see
|
|
676
|
+
// _compareDispatchPriority). To "move up" we need to land ahead of
|
|
677
|
+
// the neighbor regardless of tie behavior, so we bump our Priority
|
|
678
|
+
// to neighbor.Priority ± 1. "Down" mirrors. This drifts Priority
|
|
679
|
+
// numbers slightly over many reorders but stays O(1) per click and
|
|
680
|
+
// never reshuffles other items.
|
|
681
|
+
//
|
|
682
|
+
// Reordering is only sensible for Upcoming work — once dispatched,
|
|
683
|
+
// a worker already has it. Pause/resume (separate feature) covers
|
|
684
|
+
// the running-slot case.
|
|
685
|
+
reorderWorkItem(pWorkItemHash, pDirection)
|
|
686
|
+
{
|
|
687
|
+
let tmpCoordinator = this._getCoordinator();
|
|
688
|
+
if (!tmpCoordinator) return { Reordered: false, Error: 'coordinator unavailable' };
|
|
689
|
+
let tmpItem = tmpCoordinator._WorkQueue[pWorkItemHash];
|
|
690
|
+
if (!tmpItem) return { Reordered: false, Error: 'not found' };
|
|
691
|
+
if (this.bucketFor(tmpItem) !== 'Upcoming')
|
|
692
|
+
{
|
|
693
|
+
return { Reordered: false, Error: 'only Upcoming items can be reordered' };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
let tmpUpcoming = this.listBuckets('Upcoming', 5000);
|
|
697
|
+
tmpUpcoming.sort(this._compareDispatchPriority);
|
|
698
|
+
let tmpIdx = -1;
|
|
699
|
+
for (let i = 0; i < tmpUpcoming.length; i++)
|
|
700
|
+
{
|
|
701
|
+
if (tmpUpcoming[i].WorkItemHash === pWorkItemHash) { tmpIdx = i; break; }
|
|
702
|
+
}
|
|
703
|
+
if (tmpIdx < 0) return { Reordered: false, Error: 'not in Upcoming' };
|
|
704
|
+
|
|
705
|
+
let tmpNeighborIdx;
|
|
706
|
+
if (pDirection === 'up')
|
|
707
|
+
{
|
|
708
|
+
if (tmpIdx === 0) return { Reordered: false, Error: 'already at top' };
|
|
709
|
+
tmpNeighborIdx = tmpIdx - 1;
|
|
710
|
+
}
|
|
711
|
+
else if (pDirection === 'down')
|
|
712
|
+
{
|
|
713
|
+
if (tmpIdx === tmpUpcoming.length - 1) return { Reordered: false, Error: 'already at bottom' };
|
|
714
|
+
tmpNeighborIdx = tmpIdx + 1;
|
|
715
|
+
}
|
|
716
|
+
else
|
|
717
|
+
{
|
|
718
|
+
return { Reordered: false, Error: 'invalid direction' };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
let tmpNeighborHash = tmpUpcoming[tmpNeighborIdx].WorkItemHash;
|
|
722
|
+
let tmpNeighbor = tmpCoordinator._WorkQueue[tmpNeighborHash];
|
|
723
|
+
if (!tmpNeighbor) return { Reordered: false, Error: 'neighbor missing' };
|
|
724
|
+
|
|
725
|
+
let tmpOldPriority = tmpItem.Priority || 0;
|
|
726
|
+
let tmpNewPriority = (pDirection === 'up')
|
|
727
|
+
? ((tmpNeighbor.Priority || 0) + 1)
|
|
728
|
+
: ((tmpNeighbor.Priority || 0) - 1);
|
|
729
|
+
tmpItem.Priority = tmpNewPriority;
|
|
730
|
+
|
|
731
|
+
let tmpStore = this._getStore();
|
|
732
|
+
if (tmpStore)
|
|
733
|
+
{
|
|
734
|
+
tmpStore.updateWorkItem(pWorkItemHash, { Priority: tmpNewPriority });
|
|
735
|
+
tmpStore.appendEvent({
|
|
736
|
+
WorkItemHash: pWorkItemHash,
|
|
737
|
+
RunID: tmpItem.RunID,
|
|
738
|
+
EventType: 'reordered',
|
|
739
|
+
FromStatus: tmpItem.Status,
|
|
740
|
+
ToStatus: tmpItem.Status,
|
|
741
|
+
Payload: { Direction: pDirection, OldPriority: tmpOldPriority, NewPriority: tmpNewPriority }
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Two broadcasts: a targeted reorder event so the UI can update
|
|
746
|
+
// the one row's Priority badge immediately, plus a full summary
|
|
747
|
+
// so any client that sorts by Priority re-renders the list in
|
|
748
|
+
// the new order without waiting for the periodic summary tick.
|
|
749
|
+
this._broadcast('queue.reordered', {
|
|
750
|
+
WorkItemHash: pWorkItemHash,
|
|
751
|
+
Priority: tmpNewPriority,
|
|
752
|
+
Direction: pDirection
|
|
753
|
+
});
|
|
754
|
+
this._broadcast('queue.summary', this.summarize());
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
Reordered: true,
|
|
758
|
+
WorkItemHash: pWorkItemHash,
|
|
759
|
+
OldPriority: tmpOldPriority,
|
|
760
|
+
NewPriority: tmpNewPriority
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ====================================================================
|
|
765
|
+
// Dispatcher aid for external callers (API /queue endpoint etc.)
|
|
766
|
+
// ====================================================================
|
|
767
|
+
|
|
768
|
+
listBuckets(pBucket, pLimit)
|
|
769
|
+
{
|
|
770
|
+
let tmpCoordinator = this._getCoordinator();
|
|
771
|
+
let tmpQueue = tmpCoordinator ? (tmpCoordinator._WorkQueue || {}) : {};
|
|
772
|
+
let tmpLimit = Math.max(1, Math.min(parseInt(pLimit, 10) || 500, 5000));
|
|
773
|
+
let tmpOut = [];
|
|
774
|
+
for (let tmpHash of Object.keys(tmpQueue))
|
|
775
|
+
{
|
|
776
|
+
let tmpItem = tmpQueue[tmpHash];
|
|
777
|
+
let tmpBucket = this.bucketFor(tmpItem);
|
|
778
|
+
if (pBucket && tmpBucket !== pBucket) continue;
|
|
779
|
+
tmpOut.push(Object.assign({ Bucket: tmpBucket }, tmpItem));
|
|
780
|
+
if (tmpOut.length >= tmpLimit) break;
|
|
781
|
+
}
|
|
782
|
+
return tmpOut;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
get TERMINAL_STATUSES() { return TERMINAL_STATUSES; }
|
|
786
|
+
get NONTERMINAL_STATUSES() { return NONTERMINAL_STATUSES; }
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
module.exports = UltravisorBeaconScheduler;
|