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
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const libFS = require('fs');
|
|
13
13
|
const libPath = require('path');
|
|
14
|
+
const libFileStream = require('ultravisor-file-stream');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Recursively sort all object keys alphabetically (deep).
|
|
@@ -424,6 +425,86 @@ module.exports =
|
|
|
424
425
|
}
|
|
425
426
|
},
|
|
426
427
|
|
|
428
|
+
// ── chunked-write ─────────────────────────────────────────
|
|
429
|
+
// Multi-chunk file writer for large transfers (model pushes across
|
|
430
|
+
// the beacon mesh). Thin adapter over `ultravisor-file-stream`'s
|
|
431
|
+
// writeChunk primitive — this task type just resolves the staging
|
|
432
|
+
// path, delegates to the library, and translates the return value
|
|
433
|
+
// into task-event shape.
|
|
434
|
+
{
|
|
435
|
+
Definition: require('./definitions/chunked-write.json'),
|
|
436
|
+
Execute: function (pTask, pResolvedSettings, pExecutionContext, fCallback)
|
|
437
|
+
{
|
|
438
|
+
let tmpFileLocation = pResolvedSettings.FilePath || '';
|
|
439
|
+
if (!tmpFileLocation)
|
|
440
|
+
{
|
|
441
|
+
return fCallback(null, { EventToFire: 'Error', Outputs: {}, Log: ['ChunkedWrite: no FilePath specified.'] });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let tmpFilePath = pTask.resolveFilePath(tmpFileLocation, pExecutionContext.StagingPath);
|
|
445
|
+
|
|
446
|
+
let tmpResult = libFileStream.writeChunk(
|
|
447
|
+
Object.assign({}, pResolvedSettings, { TargetPath: tmpFilePath }));
|
|
448
|
+
|
|
449
|
+
let tmpChunkIndex = parseInt(pResolvedSettings.ChunkIndex, 10) || 0;
|
|
450
|
+
let tmpTotalChunks = parseInt(pResolvedSettings.TotalChunks, 10) || 0;
|
|
451
|
+
let tmpOffset = parseInt(pResolvedSettings.Offset, 10) || 0;
|
|
452
|
+
|
|
453
|
+
if (tmpResult.Status === 'Sha256Mismatch')
|
|
454
|
+
{
|
|
455
|
+
return fCallback(null, {
|
|
456
|
+
EventToFire: 'Sha256Mismatch',
|
|
457
|
+
Outputs:
|
|
458
|
+
{
|
|
459
|
+
FilePath: tmpFilePath,
|
|
460
|
+
PartialFilePath: tmpResult.Result ? tmpResult.Result.PartialFilePath : tmpFilePath + '.part',
|
|
461
|
+
BytesWritten: tmpResult.Result ? tmpResult.Result.BytesWritten : 0,
|
|
462
|
+
TotalBytesOnDisk: tmpResult.Result ? tmpResult.Result.TotalBytesOnDisk : 0,
|
|
463
|
+
IsComplete: false,
|
|
464
|
+
Sha256Verified: false,
|
|
465
|
+
ChunkIndex: tmpChunkIndex
|
|
466
|
+
},
|
|
467
|
+
Log: [ `ChunkedWrite: ${tmpResult.Error}` ]
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (tmpResult.Status !== 'Success')
|
|
471
|
+
{
|
|
472
|
+
return fCallback(null, {
|
|
473
|
+
EventToFire: 'Error',
|
|
474
|
+
Outputs: {},
|
|
475
|
+
Log: [ `ChunkedWrite: ${tmpResult.Error || 'unknown error'}` ]
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let tmpR = tmpResult.Result;
|
|
480
|
+
let tmpEvent = tmpR.IsComplete ? 'WriteComplete' : 'ChunkWritten';
|
|
481
|
+
let tmpLogSuffix = tmpR.IsComplete
|
|
482
|
+
? `final chunk ${tmpChunkIndex}`
|
|
483
|
+
+ (tmpTotalChunks ? `/${tmpTotalChunks}` : '')
|
|
484
|
+
+ ` @ offset ${tmpOffset} (${tmpR.BytesWritten} B); ${tmpR.TotalBytesOnDisk} B total; `
|
|
485
|
+
+ (pResolvedSettings.Sha256 ? `sha256 ${tmpR.Sha256Verified ? 'verified' : 'unverified'}; ` : '')
|
|
486
|
+
+ `renamed ${tmpFilePath}.part -> ${tmpFilePath}`
|
|
487
|
+
: `chunk ${tmpChunkIndex}`
|
|
488
|
+
+ (tmpTotalChunks ? `/${tmpTotalChunks}` : '')
|
|
489
|
+
+ ` @ offset ${tmpOffset} (${tmpR.BytesWritten} B, ${tmpR.TotalBytesOnDisk} B total on disk)`;
|
|
490
|
+
|
|
491
|
+
return fCallback(null, {
|
|
492
|
+
EventToFire: tmpEvent,
|
|
493
|
+
Outputs:
|
|
494
|
+
{
|
|
495
|
+
FilePath: tmpR.TargetPath,
|
|
496
|
+
PartialFilePath: tmpR.PartialFilePath,
|
|
497
|
+
BytesWritten: tmpR.BytesWritten,
|
|
498
|
+
TotalBytesOnDisk: tmpR.TotalBytesOnDisk,
|
|
499
|
+
IsComplete: tmpR.IsComplete,
|
|
500
|
+
Sha256Verified: tmpR.Sha256Verified,
|
|
501
|
+
ChunkIndex: tmpChunkIndex
|
|
502
|
+
},
|
|
503
|
+
Log: [ `ChunkedWrite: ${tmpLogSuffix}` ]
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
|
|
427
508
|
// ── read-file-buffered ────────────────────────────────────
|
|
428
509
|
{
|
|
429
510
|
Definition: require('./definitions/read-file-buffered.json'),
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Hash": "chunked-write",
|
|
3
|
+
"Type": "chunked-write",
|
|
4
|
+
"Name": "Chunked Write",
|
|
5
|
+
"Description": "Writes one chunk of a large file at a given byte offset. Intended for multi-chunk transfers (e.g. multi-GB model pushes across the beacon mesh). Chunks are written to a .part sidecar file; the final chunk triggers fsync, optional sha256 verification, and atomic rename to the target path.",
|
|
6
|
+
"Category": "file-io",
|
|
7
|
+
"Capability": "File System",
|
|
8
|
+
"Action": "ChunkedWrite",
|
|
9
|
+
"Tier": "Platform",
|
|
10
|
+
"EventInputs": [{ "Name": "BeginChunkWrite", "Description": "Triggers writing this chunk" }],
|
|
11
|
+
"EventOutputs": [
|
|
12
|
+
{ "Name": "ChunkWritten", "Description": "Fires after a non-final chunk is written" },
|
|
13
|
+
{ "Name": "WriteComplete", "Description": "Fires after the final chunk is written, fsynced, verified, and the .part file has been renamed to the target path" },
|
|
14
|
+
{ "Name": "Sha256Mismatch", "Description": "Fires when IsFinal=true and the computed sha256 of the completed .part file does not match the Sha256 input", "IsError": true },
|
|
15
|
+
{ "Name": "Error", "Description": "Fires on write/fsync/rename failure", "IsError": true }
|
|
16
|
+
],
|
|
17
|
+
"SettingsInputs": [
|
|
18
|
+
{ "Name": "FilePath", "DataType": "String", "Required": true, "Description": "Final path the completed file will live at. Chunks are written to <FilePath>.part until the final chunk renames it." },
|
|
19
|
+
{ "Name": "Content", "DataType": "String", "Required": true, "Description": "This chunk's bytes. If ContentEncoding=\"base64\", interpreted as base64 (for binary transport over JSON). Otherwise written as-is per Encoding." },
|
|
20
|
+
{ "Name": "ContentEncoding", "DataType": "String", "Required": false, "Default": "base64", "Description": "How to interpret Content on the wire. \"base64\" (default) is safe for binary; \"utf8\" for text." },
|
|
21
|
+
{ "Name": "Offset", "DataType": "Number", "Required": true, "Description": "Byte offset where this chunk begins in the final file. Zero for the first chunk; subsequent chunks advance by the decoded byte length of the prior chunk's content." },
|
|
22
|
+
{ "Name": "ChunkIndex", "DataType": "Number", "Required": false, "Description": "Zero-based index of this chunk in the sequence (informational; used in logs)" },
|
|
23
|
+
{ "Name": "TotalChunks", "DataType": "Number", "Required": false, "Description": "Total chunk count the sender expects to send (informational; used in logs)" },
|
|
24
|
+
{ "Name": "IsFinal", "DataType": "Boolean", "Required": false, "Description": "True for the last chunk in the sequence. Triggers fsync, optional sha256 verify, and atomic rename <FilePath>.part -> <FilePath>." },
|
|
25
|
+
{ "Name": "Sha256", "DataType": "String", "Required": false, "Description": "Hex-encoded sha256 of the complete final file. When set and IsFinal=true, the task verifies before renaming; mismatch fires Sha256Mismatch and deletes the .part file." },
|
|
26
|
+
{ "Name": "CreateDirectory", "DataType": "Boolean", "Required": false, "Default": true, "Description": "If true (default), the target directory is created recursively when missing." }
|
|
27
|
+
],
|
|
28
|
+
"StateOutputs": [
|
|
29
|
+
{ "Name": "FilePath", "DataType": "String", "Description": "The fully resolved absolute final path" },
|
|
30
|
+
{ "Name": "PartialFilePath", "DataType": "String", "Description": "The <FilePath>.part file in progress (or the finalized file after IsFinal)" },
|
|
31
|
+
{ "Name": "BytesWritten", "DataType": "Number", "Description": "Bytes written for this chunk" },
|
|
32
|
+
{ "Name": "TotalBytesOnDisk", "DataType": "Number", "Description": "Current size of the .part file (or the final file if complete)" },
|
|
33
|
+
{ "Name": "IsComplete", "DataType": "Boolean", "Description": "True only after the final chunk successfully renamed the .part file to FilePath" },
|
|
34
|
+
{ "Name": "Sha256Verified", "DataType": "Boolean", "Description": "True if IsFinal=true, Sha256 was provided, and verification passed" },
|
|
35
|
+
{ "Name": "ChunkIndex", "DataType": "Number", "Description": "Echo of input ChunkIndex, for convenience in downstream wiring" }
|
|
36
|
+
],
|
|
37
|
+
"DefaultSettings": { "FilePath": "", "Content": "", "ContentEncoding": "base64", "Offset": 0, "ChunkIndex": 0, "TotalChunks": 0, "IsFinal": false, "Sha256": "", "CreateDirectory": true }
|
|
38
|
+
}
|
|
@@ -28,6 +28,9 @@ class UltravisorAPIServer extends libPictService
|
|
|
28
28
|
this._WebSocketSubscriptions = {};
|
|
29
29
|
// Map of BeaconID -> WebSocket for beacon worker connections
|
|
30
30
|
this._BeaconWebSockets = {};
|
|
31
|
+
// Set of WebSocket clients subscribed to the queue.* topic
|
|
32
|
+
// (broadcast by UltravisorBeaconScheduler).
|
|
33
|
+
this._QueueSubscribers = new Set();
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/**
|
|
@@ -956,6 +959,42 @@ class UltravisorAPIServer extends libPictService
|
|
|
956
959
|
}.bind(this)
|
|
957
960
|
);
|
|
958
961
|
|
|
962
|
+
// --- Retry from checkpoint ---
|
|
963
|
+
// Re-dispatches the failed node in a completed/errored operation.
|
|
964
|
+
// All prior node outputs are preserved; only the failed node re-runs.
|
|
965
|
+
this._OratorServer.post
|
|
966
|
+
(
|
|
967
|
+
'/Operation/:RunHash/Retry',
|
|
968
|
+
function (pRequest, pResponse, fNext)
|
|
969
|
+
{
|
|
970
|
+
let tmpBody = pRequest.body || {};
|
|
971
|
+
let tmpEngine = this._getService('UltravisorExecutionEngine');
|
|
972
|
+
let tmpRunHash = pRequest.params.RunHash;
|
|
973
|
+
|
|
974
|
+
tmpEngine.retryFromCheckpoint(tmpRunHash,
|
|
975
|
+
{
|
|
976
|
+
NodeHash: tmpBody.NodeHash || null,
|
|
977
|
+
SettingsOverrides: tmpBody.SettingsOverrides || {}
|
|
978
|
+
},
|
|
979
|
+
function (pError, pContext)
|
|
980
|
+
{
|
|
981
|
+
if (pError)
|
|
982
|
+
{
|
|
983
|
+
pResponse.send(400, { Error: pError.message });
|
|
984
|
+
return fNext();
|
|
985
|
+
}
|
|
986
|
+
pResponse.send({
|
|
987
|
+
Success: true,
|
|
988
|
+
Status: pContext.Status,
|
|
989
|
+
Hash: pContext.Hash,
|
|
990
|
+
RetryNode: tmpBody.NodeHash || '(auto-detected)',
|
|
991
|
+
WaitingTasks: Object.keys(pContext.WaitingTasks || {})
|
|
992
|
+
});
|
|
993
|
+
return fNext();
|
|
994
|
+
});
|
|
995
|
+
}.bind(this)
|
|
996
|
+
);
|
|
997
|
+
|
|
959
998
|
// --- Operation Resume (for value-input tasks) ---
|
|
960
999
|
this._OratorServer.post
|
|
961
1000
|
(
|
|
@@ -1480,6 +1519,267 @@ class UltravisorAPIServer extends libPictService
|
|
|
1480
1519
|
}.bind(this)
|
|
1481
1520
|
);
|
|
1482
1521
|
|
|
1522
|
+
// --- Run Lifecycle: Start a hub-owned run ---
|
|
1523
|
+
this._OratorServer.post
|
|
1524
|
+
(
|
|
1525
|
+
'/Beacon/Run/Start',
|
|
1526
|
+
function (pRequest, pResponse, fNext)
|
|
1527
|
+
{
|
|
1528
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
1529
|
+
if (!tmpSession) { return; }
|
|
1530
|
+
|
|
1531
|
+
let tmpRunManager = this._getService('UltravisorBeaconRunManager');
|
|
1532
|
+
if (!tmpRunManager)
|
|
1533
|
+
{
|
|
1534
|
+
pResponse.send(500, { Error: 'BeaconRunManager service not available.' });
|
|
1535
|
+
return fNext();
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
let tmpBody = pRequest.body || {};
|
|
1539
|
+
let tmpIdempotency = pRequest.headers['x-idempotency-key'] || tmpBody.IdempotencyKey || '';
|
|
1540
|
+
|
|
1541
|
+
let tmpRun = tmpRunManager.startRun({
|
|
1542
|
+
IdempotencyKey: tmpIdempotency,
|
|
1543
|
+
SubmitterTag: tmpBody.SubmitterTag || '',
|
|
1544
|
+
Metadata: tmpBody.Metadata || {}
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
pResponse.send({
|
|
1548
|
+
Success: true,
|
|
1549
|
+
RunID: tmpRun.RunID,
|
|
1550
|
+
State: tmpRun.State,
|
|
1551
|
+
StartedAt: tmpRun.StartedAt,
|
|
1552
|
+
IdempotencyKey: tmpRun.IdempotencyKey || ''
|
|
1553
|
+
});
|
|
1554
|
+
return fNext();
|
|
1555
|
+
}.bind(this)
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
// --- Run Lifecycle: End a run explicitly ---
|
|
1559
|
+
this._OratorServer.post
|
|
1560
|
+
(
|
|
1561
|
+
'/Beacon/Run/:RunID/End',
|
|
1562
|
+
function (pRequest, pResponse, fNext)
|
|
1563
|
+
{
|
|
1564
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
1565
|
+
if (!tmpSession) { return; }
|
|
1566
|
+
|
|
1567
|
+
let tmpRunManager = this._getService('UltravisorBeaconRunManager');
|
|
1568
|
+
if (!tmpRunManager)
|
|
1569
|
+
{
|
|
1570
|
+
pResponse.send(500, { Error: 'BeaconRunManager service not available.' });
|
|
1571
|
+
return fNext();
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
let tmpBody = pRequest.body || {};
|
|
1575
|
+
let tmpState = tmpBody.State || 'Ended';
|
|
1576
|
+
let tmpOK = tmpRunManager.endRun(pRequest.params.RunID, tmpState);
|
|
1577
|
+
if (!tmpOK)
|
|
1578
|
+
{
|
|
1579
|
+
pResponse.send(404, { Error: `Run [${pRequest.params.RunID}] not found.` });
|
|
1580
|
+
return fNext();
|
|
1581
|
+
}
|
|
1582
|
+
pResponse.send({ Success: true, RunID: pRequest.params.RunID, State: tmpState });
|
|
1583
|
+
return fNext();
|
|
1584
|
+
}.bind(this)
|
|
1585
|
+
);
|
|
1586
|
+
|
|
1587
|
+
// --- Async Enqueue: returns the WorkItemHash immediately ---
|
|
1588
|
+
this._OratorServer.post
|
|
1589
|
+
(
|
|
1590
|
+
'/Beacon/Work/Enqueue',
|
|
1591
|
+
function (pRequest, pResponse, fNext)
|
|
1592
|
+
{
|
|
1593
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
1594
|
+
if (!tmpSession) { return; }
|
|
1595
|
+
|
|
1596
|
+
let tmpCoordinator = this._getService('UltravisorBeaconCoordinator');
|
|
1597
|
+
if (!tmpCoordinator)
|
|
1598
|
+
{
|
|
1599
|
+
pResponse.send(500, { Error: 'BeaconCoordinator service not available.' });
|
|
1600
|
+
return fNext();
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
let tmpBody = pRequest.body || {};
|
|
1604
|
+
if (!tmpBody.Capability)
|
|
1605
|
+
{
|
|
1606
|
+
pResponse.send(400, { Error: 'Capability is required.' });
|
|
1607
|
+
return fNext();
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// If the client didn't supply a RunID, mint one on the fly so
|
|
1611
|
+
// every enqueue ends up attached to a durable run record.
|
|
1612
|
+
let tmpRunID = tmpBody.RunID || '';
|
|
1613
|
+
if (!tmpRunID)
|
|
1614
|
+
{
|
|
1615
|
+
let tmpRunManager = this._getService('UltravisorBeaconRunManager');
|
|
1616
|
+
if (tmpRunManager)
|
|
1617
|
+
{
|
|
1618
|
+
let tmpIdempotency = pRequest.headers['x-idempotency-key'] || '';
|
|
1619
|
+
let tmpRun = tmpRunManager.startRun({
|
|
1620
|
+
IdempotencyKey: tmpIdempotency,
|
|
1621
|
+
SubmitterTag: tmpBody.SubmitterTag || '',
|
|
1622
|
+
Metadata: tmpBody.Metadata || {}
|
|
1623
|
+
});
|
|
1624
|
+
tmpRunID = tmpRun.RunID;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
let tmpWorkItemInfo = {
|
|
1629
|
+
RunID: tmpRunID,
|
|
1630
|
+
RunHash: tmpBody.RunHash || '',
|
|
1631
|
+
NodeHash: tmpBody.NodeHash || '',
|
|
1632
|
+
OperationHash: tmpBody.OperationHash || '',
|
|
1633
|
+
Capability: tmpBody.Capability,
|
|
1634
|
+
Action: tmpBody.Action || 'Execute',
|
|
1635
|
+
Settings: tmpBody.Settings || {},
|
|
1636
|
+
AffinityKey: tmpBody.AffinityKey || '',
|
|
1637
|
+
TimeoutMs: parseInt(tmpBody.TimeoutMs, 10) || 0,
|
|
1638
|
+
Priority: (tmpBody.Priority != null) ? parseInt(tmpBody.Priority, 10) : null
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
let tmpWorkItem = tmpCoordinator.enqueueWorkItem(tmpWorkItemInfo);
|
|
1642
|
+
pResponse.send({
|
|
1643
|
+
Success: true,
|
|
1644
|
+
RunID: tmpRunID,
|
|
1645
|
+
WorkItemHash: tmpWorkItem.WorkItemHash,
|
|
1646
|
+
Status: tmpWorkItem.Status,
|
|
1647
|
+
EnqueuedAt: tmpWorkItem.EnqueuedAt,
|
|
1648
|
+
Priority: tmpWorkItem.Priority
|
|
1649
|
+
});
|
|
1650
|
+
return fNext();
|
|
1651
|
+
}.bind(this)
|
|
1652
|
+
);
|
|
1653
|
+
|
|
1654
|
+
// --- Work Item Cancellation ---
|
|
1655
|
+
this._OratorServer.post
|
|
1656
|
+
(
|
|
1657
|
+
'/Beacon/Work/:WorkItemHash/Cancel',
|
|
1658
|
+
function (pRequest, pResponse, fNext)
|
|
1659
|
+
{
|
|
1660
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
1661
|
+
if (!tmpSession) { return; }
|
|
1662
|
+
|
|
1663
|
+
let tmpScheduler = this._getService('UltravisorBeaconScheduler');
|
|
1664
|
+
if (!tmpScheduler)
|
|
1665
|
+
{
|
|
1666
|
+
pResponse.send(500, { Error: 'BeaconScheduler service not available.' });
|
|
1667
|
+
return fNext();
|
|
1668
|
+
}
|
|
1669
|
+
let tmpBody = pRequest.body || {};
|
|
1670
|
+
let tmpResult = tmpScheduler.requestCancel(pRequest.params.WorkItemHash,
|
|
1671
|
+
tmpBody.Reason || 'cancel requested');
|
|
1672
|
+
if (tmpResult.Error === 'not found')
|
|
1673
|
+
{
|
|
1674
|
+
pResponse.send(404, { Error: `Work item [${pRequest.params.WorkItemHash}] not found.` });
|
|
1675
|
+
return fNext();
|
|
1676
|
+
}
|
|
1677
|
+
pResponse.send(Object.assign({ Success: !tmpResult.Error, WorkItemHash: pRequest.params.WorkItemHash }, tmpResult));
|
|
1678
|
+
return fNext();
|
|
1679
|
+
}.bind(this)
|
|
1680
|
+
);
|
|
1681
|
+
|
|
1682
|
+
// --- Work Item Reorder (Upcoming bucket) ---
|
|
1683
|
+
this._OratorServer.post
|
|
1684
|
+
(
|
|
1685
|
+
'/Beacon/Work/:WorkItemHash/Reorder',
|
|
1686
|
+
function (pRequest, pResponse, fNext)
|
|
1687
|
+
{
|
|
1688
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
1689
|
+
if (!tmpSession) { return; }
|
|
1690
|
+
|
|
1691
|
+
let tmpScheduler = this._getService('UltravisorBeaconScheduler');
|
|
1692
|
+
if (!tmpScheduler)
|
|
1693
|
+
{
|
|
1694
|
+
pResponse.send(500, { Error: 'BeaconScheduler service not available.' });
|
|
1695
|
+
return fNext();
|
|
1696
|
+
}
|
|
1697
|
+
let tmpBody = pRequest.body || {};
|
|
1698
|
+
let tmpDirection = tmpBody.Direction;
|
|
1699
|
+
if (tmpDirection !== 'up' && tmpDirection !== 'down')
|
|
1700
|
+
{
|
|
1701
|
+
pResponse.send(400, { Error: 'Direction must be "up" or "down".' });
|
|
1702
|
+
return fNext();
|
|
1703
|
+
}
|
|
1704
|
+
let tmpResult = tmpScheduler.reorderWorkItem(pRequest.params.WorkItemHash, tmpDirection);
|
|
1705
|
+
if (tmpResult.Error === 'not found')
|
|
1706
|
+
{
|
|
1707
|
+
pResponse.send(404, { Error: `Work item [${pRequest.params.WorkItemHash}] not found.` });
|
|
1708
|
+
return fNext();
|
|
1709
|
+
}
|
|
1710
|
+
if (!tmpResult.Reordered)
|
|
1711
|
+
{
|
|
1712
|
+
// 409 for "can't reorder in this state" (at edge, not Upcoming, etc.)
|
|
1713
|
+
pResponse.send(409, tmpResult);
|
|
1714
|
+
return fNext();
|
|
1715
|
+
}
|
|
1716
|
+
pResponse.send(Object.assign({ Success: true }, tmpResult));
|
|
1717
|
+
return fNext();
|
|
1718
|
+
}.bind(this)
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
// --- Per-Work-Item Event Log ---
|
|
1722
|
+
this._OratorServer.get
|
|
1723
|
+
(
|
|
1724
|
+
'/Beacon/Work/:WorkItemHash/Events',
|
|
1725
|
+
function (pRequest, pResponse, fNext)
|
|
1726
|
+
{
|
|
1727
|
+
let tmpSession = this._requireSession(pRequest, pResponse, fNext);
|
|
1728
|
+
if (!tmpSession) { return; }
|
|
1729
|
+
|
|
1730
|
+
let tmpStore = this._getService('UltravisorBeaconQueueStore');
|
|
1731
|
+
if (!tmpStore || !tmpStore.isEnabled || !tmpStore.isEnabled())
|
|
1732
|
+
{
|
|
1733
|
+
pResponse.send(503, { Error: 'BeaconQueueStore service not available.' });
|
|
1734
|
+
return fNext();
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
let tmpHash = pRequest.params.WorkItemHash;
|
|
1738
|
+
let tmpLimit = pRequest.query ? parseInt(pRequest.query.limit, 10) || 500 : 500;
|
|
1739
|
+
let tmpItem = tmpStore.getWorkItemByHash(tmpHash);
|
|
1740
|
+
if (!tmpItem)
|
|
1741
|
+
{
|
|
1742
|
+
pResponse.send(404, { Error: `Work item [${tmpHash}] not found.` });
|
|
1743
|
+
return fNext();
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
let tmpEvents = tmpStore.listEventsForWorkItem(tmpHash, tmpLimit);
|
|
1747
|
+
pResponse.send({
|
|
1748
|
+
WorkItemHash: tmpHash,
|
|
1749
|
+
Events: tmpEvents
|
|
1750
|
+
});
|
|
1751
|
+
return fNext();
|
|
1752
|
+
}.bind(this)
|
|
1753
|
+
);
|
|
1754
|
+
|
|
1755
|
+
// --- Queue Snapshot (buckets + summary + items) ---
|
|
1756
|
+
this._OratorServer.get
|
|
1757
|
+
(
|
|
1758
|
+
'/Beacon/Queue',
|
|
1759
|
+
function (pRequest, pResponse, fNext)
|
|
1760
|
+
{
|
|
1761
|
+
let tmpScheduler = this._getService('UltravisorBeaconScheduler');
|
|
1762
|
+
let tmpStore = this._getService('UltravisorBeaconQueueStore');
|
|
1763
|
+
let tmpSummary = tmpScheduler ? tmpScheduler.summarize() : null;
|
|
1764
|
+
let tmpBucket = pRequest.query ? pRequest.query.bucket : null;
|
|
1765
|
+
let tmpLimit = pRequest.query ? parseInt(pRequest.query.limit, 10) || 200 : 200;
|
|
1766
|
+
|
|
1767
|
+
let tmpItems = tmpScheduler ? tmpScheduler.listBuckets(tmpBucket, tmpLimit) : [];
|
|
1768
|
+
let tmpHistorical = null;
|
|
1769
|
+
if (tmpStore && tmpStore.isEnabled && tmpStore.isEnabled()
|
|
1770
|
+
&& pRequest.query && pRequest.query.include === 'history')
|
|
1771
|
+
{
|
|
1772
|
+
tmpHistorical = tmpStore.listWorkItems({ Limit: tmpLimit, OrderBy: 'IDBeaconWorkItem DESC' });
|
|
1773
|
+
}
|
|
1774
|
+
pResponse.send({
|
|
1775
|
+
Summary: tmpSummary,
|
|
1776
|
+
Items: tmpItems,
|
|
1777
|
+
History: tmpHistorical
|
|
1778
|
+
});
|
|
1779
|
+
return fNext();
|
|
1780
|
+
}.bind(this)
|
|
1781
|
+
);
|
|
1782
|
+
|
|
1483
1783
|
// --- Direct Dispatch (synchronous) ---
|
|
1484
1784
|
this._OratorServer.post
|
|
1485
1785
|
(
|
|
@@ -2002,6 +2302,30 @@ class UltravisorAPIServer extends libPictService
|
|
|
2002
2302
|
{
|
|
2003
2303
|
this._handleBeaconWSDeregister(pWebSocket, tmpData);
|
|
2004
2304
|
}
|
|
2305
|
+
else if (tmpData.Action === 'QueueSubscribe')
|
|
2306
|
+
{
|
|
2307
|
+
this._QueueSubscribers.add(pWebSocket);
|
|
2308
|
+
pWebSocket._QueueSubscribed = true;
|
|
2309
|
+
// Send current summary immediately so the UI
|
|
2310
|
+
// doesn't wait a full tick to populate.
|
|
2311
|
+
let tmpSched = this._getService('UltravisorBeaconScheduler');
|
|
2312
|
+
if (tmpSched && typeof tmpSched.summarize === 'function')
|
|
2313
|
+
{
|
|
2314
|
+
try
|
|
2315
|
+
{
|
|
2316
|
+
pWebSocket.send(JSON.stringify({
|
|
2317
|
+
Topic: 'queue.summary',
|
|
2318
|
+
Payload: tmpSched.summarize()
|
|
2319
|
+
}));
|
|
2320
|
+
}
|
|
2321
|
+
catch (pErr) { /* ignore */ }
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
else if (tmpData.Action === 'QueueUnsubscribe')
|
|
2325
|
+
{
|
|
2326
|
+
this._QueueSubscribers.delete(pWebSocket);
|
|
2327
|
+
pWebSocket._QueueSubscribed = false;
|
|
2328
|
+
}
|
|
2005
2329
|
}.bind(this));
|
|
2006
2330
|
|
|
2007
2331
|
pWebSocket.on('close',
|
|
@@ -2009,6 +2333,7 @@ class UltravisorAPIServer extends libPictService
|
|
|
2009
2333
|
{
|
|
2010
2334
|
this._unsubscribeClient(pWebSocket);
|
|
2011
2335
|
this._cleanupBeaconWS(pWebSocket);
|
|
2336
|
+
this._QueueSubscribers.delete(pWebSocket);
|
|
2012
2337
|
}.bind(this));
|
|
2013
2338
|
}.bind(this));
|
|
2014
2339
|
|
|
@@ -2029,9 +2354,38 @@ class UltravisorAPIServer extends libPictService
|
|
|
2029
2354
|
this.pushWorkItemToBeacon.bind(this));
|
|
2030
2355
|
}
|
|
2031
2356
|
|
|
2357
|
+
// Wire the scheduler's broadcast hook to the queue.* WebSocket topic.
|
|
2358
|
+
let tmpScheduler = this._getService('UltravisorBeaconScheduler');
|
|
2359
|
+
if (tmpScheduler && typeof tmpScheduler.setBroadcastHandler === 'function')
|
|
2360
|
+
{
|
|
2361
|
+
tmpScheduler.setBroadcastHandler(
|
|
2362
|
+
this._broadcastQueueTopic.bind(this));
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2032
2365
|
this.log.info('Ultravisor WebSocket: execution event WebSocket server initialized.');
|
|
2033
2366
|
}
|
|
2034
2367
|
|
|
2368
|
+
/**
|
|
2369
|
+
* Fan out a queue.* topic payload to all subscribed WebSocket clients.
|
|
2370
|
+
*
|
|
2371
|
+
* @param {string} pTopic - e.g. "queue.enqueued" / "queue.dispatched" / ...
|
|
2372
|
+
* @param {object} pPayload - topic-specific JSON body
|
|
2373
|
+
*/
|
|
2374
|
+
_broadcastQueueTopic(pTopic, pPayload)
|
|
2375
|
+
{
|
|
2376
|
+
if (!this._QueueSubscribers || this._QueueSubscribers.size === 0) return;
|
|
2377
|
+
let tmpMessage = JSON.stringify({ Topic: pTopic, Payload: pPayload });
|
|
2378
|
+
this._QueueSubscribers.forEach(
|
|
2379
|
+
function (pClient)
|
|
2380
|
+
{
|
|
2381
|
+
if (pClient.readyState === libWebSocket.OPEN)
|
|
2382
|
+
{
|
|
2383
|
+
try { pClient.send(tmpMessage); }
|
|
2384
|
+
catch (pErr) { /* best effort */ }
|
|
2385
|
+
}
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2035
2389
|
/**
|
|
2036
2390
|
* Handle an execution event from the manifest service and broadcast
|
|
2037
2391
|
* it to all WebSocket clients subscribed to that RunHash.
|