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.
@@ -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;