ultravisor 1.0.23 → 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,123 @@
1
+ # Follow-up: Migrate per-action config into `BeaconActionDefault`
2
+
3
+ **Status:** deferred — table and service are live; rows need populating.
4
+
5
+ ## Why this exists
6
+
7
+ Per-action defaults (timeout, retry policy, priority, expected-wait
8
+ baseline) live in scattered Fable settings today. The queueing work
9
+ introduced a durable `BeaconActionDefault` table and a resolver
10
+ ([`Ultravisor-Beacon-ActionDefaults.cjs`](../../source/services/Ultravisor-Beacon-ActionDefaults.cjs))
11
+ that an operator can tune live, without a hub restart.
12
+
13
+ The resolver already falls back in this order:
14
+
15
+ 1. Per-request `Settings` override (`maxRetries`, `timeoutMs`, etc.)
16
+ 2. Per-action row (`Capability`, `Action`)
17
+ 3. Per-capability wildcard row (`Capability`, `Action=""`)
18
+ 4. Fable-setting compatibility shim
19
+ (`UltravisorBeaconWorkItemTimeoutMs`,
20
+ `UltravisorBeaconHeartbeatMs`)
21
+ 5. Hard defaults in `HARD_DEFAULTS`
22
+
23
+ So today everything still works via step 4–5. This follow-up replaces
24
+ the shim with real rows and retires the Fable settings.
25
+
26
+ ## What's in the table today
27
+
28
+ Schema at [`Ultravisor-BeaconQueue.json`](../../source/datamodel/Ultravisor-BeaconQueue.json)
29
+ (the `BeaconActionDefault` entry). Columns:
30
+
31
+ | Column | Notes |
32
+ |---|---|
33
+ | `Capability` | required |
34
+ | `Action` | `""` = wildcard for the capability |
35
+ | `TimeoutMs` | overrides `UltravisorBeaconWorkItemTimeoutMs` |
36
+ | `MaxAttempts` | 1 = no retry |
37
+ | `RetryBackoffMs` | multiplied by attempt number |
38
+ | `DefaultPriority` | scheduler sort key |
39
+ | `ExpectedWaitP95Ms` | powers the `queue_wait` dimension of the health score |
40
+ | `HeartbeatExpectedMs` | powers the `event_freshness` dimension |
41
+ | `MinSamplesForBaseline` | min samples before `ActionDefaults.recomputeWaitBaseline` commits a p95 |
42
+
43
+ ## Work to do
44
+
45
+ ### 1. Inventory today's scattered settings
46
+
47
+ Grep the ultravisor tree for the settings that currently drive
48
+ per-action behavior:
49
+
50
+ ```sh
51
+ grep -rn 'UltravisorBeaconWorkItemTimeoutMs\|UltravisorBeaconHeartbeatMs\|UltravisorBeaconAffinityTTLMs' \
52
+ /Users/steven/Code/retold/modules/apps/ultravisor/source
53
+ ```
54
+
55
+ Also check the consumer code for per-action constants in task-type
56
+ definitions (`source/services/tasks/**`) and capability providers
57
+ (`source/beacon/providers/**`).
58
+
59
+ ### 2. Seed the table at startup
60
+
61
+ Write `source/services/tasks/extension/Ultravisor-BeaconActionDefault-Seed.cjs`
62
+ (or similar) that runs once on hub boot and upserts a row per
63
+ (Capability, Action) pair the hub knows about. Source of truth is
64
+ the action catalog on the coordinator
65
+ ([`Ultravisor-Beacon-Coordinator.cjs`](../../source/services/Ultravisor-Beacon-Coordinator.cjs)
66
+ `_ActionCatalog`) — iterate it and call
67
+ `store.upsertActionDefault({ Capability, Action, ...hard_defaults })`
68
+ only when no row exists. Use `getActionDefault` first so operator-set
69
+ rows aren't clobbered on every restart.
70
+
71
+ The seed should **not** overwrite existing rows — it's a bootstrap,
72
+ not a reset.
73
+
74
+ ### 3. Admin endpoints (optional, small)
75
+
76
+ If the `/queue` UI wants live knobs, add:
77
+
78
+ - `GET /Beacon/ActionDefaults` → `store.listActionDefaults()`
79
+ - `PUT /Beacon/ActionDefaults/:Capability/:Action` → upsert
80
+ (call `store.upsertActionDefault` + `defaults.invalidate()`)
81
+
82
+ Both gated by `_requireSession`.
83
+
84
+ ### 4. Wire up the wait-baseline learner
85
+
86
+ `ActionDefaults.recomputeWaitBaseline(cap, action)` is implemented
87
+ but not called anywhere yet. Trigger it on a slow interval (every 5
88
+ minutes) from the scheduler, iterating capabilities that have seen
89
+ ≥ `MinSamplesForBaseline` completions since last recompute. Add a
90
+ counter on the coordinator to track completions per capability so we
91
+ don't recompute on no new data.
92
+
93
+ Rough hook point: end of `UltravisorBeaconScheduler._summaryTick`, or
94
+ a dedicated slower interval.
95
+
96
+ ### 5. Retire the Fable-setting shim
97
+
98
+ Once (2) has been in place for at least one production cycle, remove
99
+ the `_fableSettingFallback` in
100
+ [`Ultravisor-Beacon-ActionDefaults.cjs`](../../source/services/Ultravisor-Beacon-ActionDefaults.cjs).
101
+ Grep-clean the retired setting names from the codebase in the same PR
102
+ so nobody reintroduces them. This is the step that makes the
103
+ migration irreversible — don't do it in the same PR as the seeder.
104
+
105
+ ## Verification
106
+
107
+ 1. Wipe `~/.local/share/ultravisor/beacon/beacon-queue.sqlite` (or
108
+ wherever the store's DB path resolves for the deployment).
109
+ 2. Boot the hub. Confirm `BeaconActionDefault` has one row per known
110
+ `Capability:Action` pair with the expected defaults.
111
+ 3. `UPDATE BeaconActionDefault SET TimeoutMs = 1 WHERE Capability =
112
+ 'Shell'`. Enqueue a Shell work item. The next
113
+ `BeaconActionDefaults._Cache` window (≤ 10s) flips it; next
114
+ work item picks up `TimeoutMs=1` without a hub restart.
115
+ 4. Run 30 Shell work items with genuinely varying queue waits; confirm
116
+ `ExpectedWaitP95Ms` updates on its own after the baseline learner
117
+ tick.
118
+
119
+ ## Non-goals
120
+
121
+ - UI for editing action defaults (defer until the `/queue` view is
122
+ shipped and operators are asking for it).
123
+ - Multi-tenant defaults (single-ultravisor-instance only).
@@ -0,0 +1,128 @@
1
+ # Follow-up: Phase emission for `/Beacon/Work/Dispatch` (sync path)
2
+
3
+ **Status:** deferred — the synchronous dispatch path bypasses the
4
+ scheduler and therefore never populates `Settings.QueueMetadata`.
5
+ Workers routed via `/Beacon/Work/Dispatch` still write their own
6
+ phases, but the hub-owned `queue_wait` / `worker_spinup` /
7
+ `asset_capture` records are skipped.
8
+
9
+ ## Background
10
+
11
+ There are three dispatch paths in the hub today:
12
+
13
+ | Path | Route | Queue/metadata? | Phases? |
14
+ |---|---|---|---|
15
+ | Async queue | `POST /Beacon/Work/Enqueue` | Yes (via Scheduler) | Yes — beacon-side emits all three |
16
+ | Polling claim | `POST /Beacon/Work/Poll` | Yes (Coordinator stamps metadata in `_sanitizeWorkItemForBeacon`) | Yes — same |
17
+ | **Direct dispatch** | `POST /Beacon/Work/Dispatch` | **No** — calls `Coordinator.dispatchAndWait` which builds the work item inline and blocks the caller on the response | **No** — Settings.QueueMetadata is never set |
18
+
19
+ The direct-dispatch path is used for synchronous RPC-style calls
20
+ where the caller wants the result inline. It's rare but real — a
21
+ handful of retold-labs call sites and any external integration that
22
+ chose the sync contract for simplicity.
23
+
24
+ ## Why this is separate from the main work
25
+
26
+ `dispatchAndWait` constructs the work item in a hot request handler
27
+ and resolves to the caller via `_DirectDispatchCallbacks`. It
28
+ intentionally skips the scheduler tick because the caller is
29
+ blocked waiting. Retrofitting it requires either:
30
+
31
+ 1. Routing it through the same scheduler pass (adds latency — the
32
+ whole point of direct dispatch is "don't queue"), or
33
+ 2. Populating `QueueMetadata` inline in the Coordinator when building
34
+ the work item and calling the beacon-side phase emit path directly.
35
+
36
+ Option 2 is correct. The queue wait for this path is near-zero by
37
+ definition, so the `queue_wait` record will almost always be `0ms`,
38
+ but the `worker_spinup` and `asset_capture` records still have
39
+ real durations worth capturing.
40
+
41
+ ## Work to do
42
+
43
+ ### 1. Populate `QueueMetadata` inline in `dispatchAndWait`
44
+
45
+ **File:** [`Ultravisor-Beacon-Coordinator.cjs`](../../source/services/Ultravisor-Beacon-Coordinator.cjs)
46
+
47
+ Find `dispatchAndWait` (search for the method definition). Where it
48
+ constructs the work item, set `Settings.QueueMetadata` the same way
49
+ `_sanitizeWorkItemForBeacon` does it now, but with the sync-path
50
+ timestamps:
51
+
52
+ ```javascript
53
+ let tmpNowIso = new Date().toISOString();
54
+ tmpWorkItem.EnqueuedAt = tmpNowIso;
55
+ tmpWorkItem.DispatchedAt = tmpNowIso;
56
+ tmpWorkItem.QueueWaitMs = 0;
57
+ tmpWorkItem.AttemptNumber = 1;
58
+ tmpWorkItem.RunID = pWorkItemInfo.RunID || '';
59
+ tmpWorkItem.Settings = tmpWorkItem.Settings || {};
60
+ tmpWorkItem.Settings.QueueMetadata = {
61
+ RunID: tmpWorkItem.RunID,
62
+ WorkItemHash: tmpWorkItem.WorkItemHash,
63
+ EnqueuedAt: tmpNowIso,
64
+ DispatchedAt: tmpNowIso,
65
+ QueueWaitMs: 0,
66
+ AttemptNumber: 1,
67
+ HubInstanceID: (this.fable.settings && this.fable.settings.UltravisorHubInstanceID) || '',
68
+ DispatchPath: 'sync'
69
+ };
70
+ ```
71
+
72
+ Note the extra `DispatchPath: 'sync'` marker — useful for analytics
73
+ that want to separate sync-call latency from async-queue latency.
74
+
75
+ ### 2. Stream the same metadata through `DispatchStream`
76
+
77
+ **File:** [`Ultravisor-API-Server.cjs`](../../source/web_server/Ultravisor-API-Server.cjs)
78
+
79
+ `POST /Beacon/Work/DispatchStream` is the binary-framed variant. It
80
+ calls the coordinator's streaming-dispatch machinery, which also
81
+ builds a work item inline. Same fix pattern: set `QueueMetadata` on
82
+ the work item before the first beacon frame is sent. Look for the
83
+ method that parallels `dispatchAndWait` for streams (grep for
84
+ `_StreamDispatchHandlers`).
85
+
86
+ ### 3. Add a `POST /Beacon/Work/Dispatch` body field for `RunID`
87
+
88
+ The sync path doesn't call `/Beacon/Run/Start` before dispatching —
89
+ it's a one-shot. Either:
90
+
91
+ - Accept an optional `RunID` in the request body; if absent, mint one
92
+ on the fly via `RunManager.startRun({ SubmitterTag: 'sync' })` so
93
+ the sync call still lands in `BeaconRun` for observability; **or**
94
+ - Skip run registration for sync calls entirely and emit phases with
95
+ `RunID=""`.
96
+
97
+ The first option keeps the `/queue` UI's event stream complete. Lean
98
+ toward (a).
99
+
100
+ ### 4. Accept that retold-labs capability handlers already DTRT
101
+
102
+ Once (1) lands, `Settings.QueueMetadata` is populated on every work
103
+ item regardless of dispatch path.
104
+ [`RetoldLabs-BeaconSetup.cjs`](../../../retold-labs/source/RetoldLabs-BeaconSetup.cjs)
105
+ already checks `pWorkItem.Settings.QueueMetadata` before emitting;
106
+ the sync path will automatically start producing phases with no
107
+ retold-labs changes needed.
108
+
109
+ ## Verification
110
+
111
+ 1. Issue `POST /Beacon/Work/Dispatch` with `{ Capability: "Shell",
112
+ Action: "Execute", Settings: { Command: "sleep 1" } }`.
113
+ 2. The response comes back synchronously (same as today).
114
+ 3. In the beacon's staging dir, the resulting `phases.jsonl`
115
+ contains `queue_wait` (duration ≤ 5ms), `worker_spinup`,
116
+ worker-emitted `run_start` / `phase` / `run_end`, and
117
+ `asset_capture`.
118
+ 4. The `BeaconRun` table has a new row with `SubmitterTag='sync'`.
119
+ 5. The `BeaconWorkItemEvent` table has the `dispatched` event
120
+ with `Payload.Path='sync'` (add this marker in step 1 if you want
121
+ to separate it from poll-path dispatches in analytics).
122
+
123
+ ## Non-goals
124
+
125
+ - Retrofitting existing direct-dispatch callers to switch to async
126
+ enqueue. Sync contracts exist for a reason — let callers choose.
127
+ - Differentiating `queue_wait` reason for sync vs. async in the UI.
128
+ 0ms queue waits tell the right story on their own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultravisor",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "description": "Cyclic process execution with ai integration.",
5
5
  "main": "source/Ultravisor.cjs",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/stevenvelozo/ultravisor#readme",
29
29
  "dependencies": {
30
+ "better-sqlite3": "^11.10.0",
30
31
  "cron": "^4.4.0",
31
32
  "orator": "^6.0.4",
32
33
  "orator-authentication": "^1.0.0",
@@ -35,6 +36,7 @@
35
36
  "pict-service-commandlineutility": "^1.0.19",
36
37
  "pict-serviceproviderbase": "^1.0.4",
37
38
  "ultravisor-beacon": "^0.0.11",
39
+ "ultravisor-file-stream": "^0.0.1",
38
40
  "ws": "^8.20.0"
39
41
  },
40
42
  "devDependencies": {
@@ -8,6 +8,10 @@ module.exports = (
8
8
  HypervisorState: require('./services/Ultravisor-Hypervisor-State.cjs'),
9
9
  Hypervisor: require('./services/Ultravisor-Hypervisor.cjs'),
10
10
  BeaconCoordinator: require('./services/Ultravisor-Beacon-Coordinator.cjs'),
11
+ BeaconQueueStore: require('./services/persistence/Ultravisor-Beacon-QueueStore.cjs'),
12
+ BeaconRunManager: require('./services/Ultravisor-Beacon-RunManager.cjs'),
13
+ BeaconActionDefaults: require('./services/Ultravisor-Beacon-ActionDefaults.cjs'),
14
+ BeaconScheduler: require('./services/Ultravisor-Beacon-Scheduler.cjs'),
11
15
  BeaconService: require('ultravisor-beacon'),
12
16
  BeaconCapabilityProvider: require('ultravisor-beacon/source/Ultravisor-Beacon-CapabilityProvider.cjs'),
13
17
  BeaconProviderRegistry: require('ultravisor-beacon/source/Ultravisor-Beacon-ProviderRegistry.cjs'),
@@ -0,0 +1,165 @@
1
+ {
2
+ "Tables":
3
+ {
4
+ "BeaconRun":
5
+ {
6
+ "TableName": "BeaconRun",
7
+ "Domain": "Default",
8
+ "Description": "Hub-owned run lifecycle record. One row per submitter-visible run; work items link back via RunID.",
9
+ "Columns":
10
+ [
11
+ { "Column": "IDBeaconRun", "DataType": "ID" },
12
+ { "Column": "GUIDBeaconRun", "DataType": "GUID", "Size": "36" },
13
+ { "Column": "CreateDate", "DataType": "DateTime" },
14
+ { "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
15
+ { "Column": "UpdateDate", "DataType": "DateTime" },
16
+ { "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
17
+ { "Column": "Deleted", "DataType": "Boolean" },
18
+ { "Column": "DeleteDate", "DataType": "DateTime" },
19
+ { "Column": "DeletingIDUser", "DataType": "Numeric", "Size": "int" },
20
+ { "Column": "RunID", "DataType": "String", "Size": "128" },
21
+ { "Column": "IdempotencyKey", "DataType": "String", "Size": "256" },
22
+ { "Column": "SubmitterTag", "DataType": "String", "Size": "256" },
23
+ { "Column": "State", "DataType": "String", "Size": "32" },
24
+ { "Column": "StartedAt", "DataType": "DateTime" },
25
+ { "Column": "EndedAt", "DataType": "DateTime" },
26
+ { "Column": "CanceledAt", "DataType": "DateTime" },
27
+ { "Column": "CancelReason", "DataType": "String", "Size": "512" },
28
+ { "Column": "Metadata", "DataType": "String", "Size": "8192" }
29
+ ]
30
+ },
31
+ "BeaconWorkItem":
32
+ {
33
+ "TableName": "BeaconWorkItem",
34
+ "Domain": "Default",
35
+ "Description": "Mutable work item state — one row per dispatchable unit of work. Event history lives in BeaconWorkItemEvent.",
36
+ "Columns":
37
+ [
38
+ { "Column": "IDBeaconWorkItem", "DataType": "ID" },
39
+ { "Column": "GUIDBeaconWorkItem", "DataType": "GUID", "Size": "36" },
40
+ { "Column": "CreateDate", "DataType": "DateTime" },
41
+ { "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
42
+ { "Column": "UpdateDate", "DataType": "DateTime" },
43
+ { "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
44
+ { "Column": "Deleted", "DataType": "Boolean" },
45
+ { "Column": "DeleteDate", "DataType": "DateTime" },
46
+ { "Column": "DeletingIDUser", "DataType": "Numeric", "Size": "int" },
47
+ { "Column": "WorkItemHash", "DataType": "String", "Size": "256" },
48
+ { "Column": "RunID", "DataType": "String", "Size": "128" },
49
+ { "Column": "RunHash", "DataType": "String", "Size": "256" },
50
+ { "Column": "NodeHash", "DataType": "String", "Size": "256" },
51
+ { "Column": "OperationHash", "DataType": "String", "Size": "256" },
52
+ { "Column": "Capability", "DataType": "String", "Size": "128" },
53
+ { "Column": "Action", "DataType": "String", "Size": "128" },
54
+ { "Column": "Settings", "DataType": "String", "Size": "65535" },
55
+ { "Column": "AffinityKey", "DataType": "String", "Size": "256" },
56
+ { "Column": "Priority", "DataType": "Numeric", "Size": "int" },
57
+ { "Column": "Status", "DataType": "String", "Size": "32" },
58
+ { "Column": "AssignedBeaconID", "DataType": "String", "Size": "128" },
59
+ { "Column": "TimeoutMs", "DataType": "Numeric", "Size": "int" },
60
+ { "Column": "MaxAttempts", "DataType": "Numeric", "Size": "int" },
61
+ { "Column": "AttemptNumber", "DataType": "Numeric", "Size": "int" },
62
+ { "Column": "RetryBackoffMs", "DataType": "Numeric", "Size": "int" },
63
+ { "Column": "EnqueuedAt", "DataType": "DateTime" },
64
+ { "Column": "AssignedAt", "DataType": "DateTime" },
65
+ { "Column": "DispatchedAt", "DataType": "DateTime" },
66
+ { "Column": "StartedAt", "DataType": "DateTime" },
67
+ { "Column": "CompletedAt", "DataType": "DateTime" },
68
+ { "Column": "CanceledAt", "DataType": "DateTime" },
69
+ { "Column": "CancelRequested", "DataType": "Boolean" },
70
+ { "Column": "CancelReason", "DataType": "String", "Size": "512" },
71
+ { "Column": "LastError", "DataType": "String", "Size": "4096" },
72
+ { "Column": "LastEventAt", "DataType": "DateTime" },
73
+ { "Column": "QueueWaitMs", "DataType": "Numeric", "Size": "int" },
74
+ { "Column": "Health", "DataType": "String", "Size": "32" },
75
+ { "Column": "HealthLabel", "DataType": "String", "Size": "16" },
76
+ { "Column": "HealthReason", "DataType": "String", "Size": "128" },
77
+ { "Column": "HealthComputedAt", "DataType": "DateTime" },
78
+ { "Column": "Result", "DataType": "String", "Size": "65535" }
79
+ ]
80
+ },
81
+ "BeaconWorkItemEvent":
82
+ {
83
+ "TableName": "BeaconWorkItemEvent",
84
+ "Domain": "Default",
85
+ "Description": "Append-only audit trail of state transitions for a work item. Each row is one transition.",
86
+ "Columns":
87
+ [
88
+ { "Column": "IDBeaconWorkItemEvent", "DataType": "ID" },
89
+ { "Column": "CreateDate", "DataType": "DateTime" },
90
+ { "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
91
+ { "Column": "WorkItemHash", "DataType": "String", "Size": "256" },
92
+ { "Column": "RunID", "DataType": "String", "Size": "128" },
93
+ { "Column": "EventType", "DataType": "String", "Size": "32" },
94
+ { "Column": "FromStatus", "DataType": "String", "Size": "32" },
95
+ { "Column": "ToStatus", "DataType": "String", "Size": "32" },
96
+ { "Column": "BeaconID", "DataType": "String", "Size": "128" },
97
+ { "Column": "Payload", "DataType": "String", "Size": "8192" }
98
+ ]
99
+ },
100
+ "BeaconWorkItemAttempt":
101
+ {
102
+ "TableName": "BeaconWorkItemAttempt",
103
+ "Domain": "Default",
104
+ "Description": "One row per execution attempt. On retry a new attempt row is created and WorkItem.AttemptNumber increments.",
105
+ "Columns":
106
+ [
107
+ { "Column": "IDBeaconWorkItemAttempt", "DataType": "ID" },
108
+ { "Column": "CreateDate", "DataType": "DateTime" },
109
+ { "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
110
+ { "Column": "UpdateDate", "DataType": "DateTime" },
111
+ { "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
112
+ { "Column": "WorkItemHash", "DataType": "String", "Size": "256" },
113
+ { "Column": "AttemptNumber", "DataType": "Numeric", "Size": "int" },
114
+ { "Column": "BeaconID", "DataType": "String", "Size": "128" },
115
+ { "Column": "DispatchedAt", "DataType": "DateTime" },
116
+ { "Column": "StartedAt", "DataType": "DateTime" },
117
+ { "Column": "CompletedAt", "DataType": "DateTime" },
118
+ { "Column": "Outcome", "DataType": "String", "Size": "32" },
119
+ { "Column": "ErrorMessage", "DataType": "String", "Size": "4096" },
120
+ { "Column": "DurationMs", "DataType": "Numeric", "Size": "int" }
121
+ ]
122
+ },
123
+ "BeaconAffinityBinding":
124
+ {
125
+ "TableName": "BeaconAffinityBinding",
126
+ "Domain": "Default",
127
+ "Description": "Affinity lock — routes future work with the same AffinityKey to a specific beacon until TTL expires.",
128
+ "Columns":
129
+ [
130
+ { "Column": "IDBeaconAffinityBinding", "DataType": "ID" },
131
+ { "Column": "CreateDate", "DataType": "DateTime" },
132
+ { "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
133
+ { "Column": "UpdateDate", "DataType": "DateTime" },
134
+ { "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
135
+ { "Column": "AffinityKey", "DataType": "String", "Size": "256" },
136
+ { "Column": "BeaconID", "DataType": "String", "Size": "128" },
137
+ { "Column": "ExpiresAt", "DataType": "DateTime" },
138
+ { "Column": "ClearedAt", "DataType": "DateTime" }
139
+ ]
140
+ },
141
+ "BeaconActionDefault":
142
+ {
143
+ "TableName": "BeaconActionDefault",
144
+ "Domain": "Default",
145
+ "Description": "Per-capability/action defaults — timeout, retry, priority, expected-wait baseline. Replaces scattered Fable settings.",
146
+ "Columns":
147
+ [
148
+ { "Column": "IDBeaconActionDefault", "DataType": "ID" },
149
+ { "Column": "CreateDate", "DataType": "DateTime" },
150
+ { "Column": "CreatingIDUser", "DataType": "Numeric", "Size": "int" },
151
+ { "Column": "UpdateDate", "DataType": "DateTime" },
152
+ { "Column": "UpdatingIDUser", "DataType": "Numeric", "Size": "int" },
153
+ { "Column": "Capability", "DataType": "String", "Size": "128" },
154
+ { "Column": "Action", "DataType": "String", "Size": "128" },
155
+ { "Column": "TimeoutMs", "DataType": "Numeric", "Size": "int" },
156
+ { "Column": "MaxAttempts", "DataType": "Numeric", "Size": "int" },
157
+ { "Column": "RetryBackoffMs", "DataType": "Numeric", "Size": "int" },
158
+ { "Column": "DefaultPriority", "DataType": "Numeric", "Size": "int" },
159
+ { "Column": "ExpectedWaitP95Ms", "DataType": "Numeric", "Size": "int" },
160
+ { "Column": "HeartbeatExpectedMs", "DataType": "Numeric", "Size": "int" },
161
+ { "Column": "MinSamplesForBaseline", "DataType": "Numeric", "Size": "int" }
162
+ ]
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Ultravisor Beacon Action Defaults
3
+ *
4
+ * Resolves per-action runtime defaults (timeout, retry policy, priority,
5
+ * expected-wait baseline) from the BeaconActionDefault table. Live
6
+ * config changes land here instead of in Fable settings, which means
7
+ * an operator can tune a slow capability without restarting the hub.
8
+ *
9
+ * Fallback order for any single field: per-request Settings → per-action
10
+ * row → per-capability row (Action="") → Fable setting → hard default.
11
+ * The last two levels are the compatibility shim while we migrate the
12
+ * scattered settings into rows.
13
+ *
14
+ * @module Ultravisor-Beacon-ActionDefaults
15
+ */
16
+
17
+ const libPictService = require('pict-serviceproviderbase');
18
+
19
+ const HARD_DEFAULTS = {
20
+ TimeoutMs: 300000,
21
+ MaxAttempts: 1,
22
+ RetryBackoffMs: 5000,
23
+ DefaultPriority: 0,
24
+ ExpectedWaitP95Ms: 0,
25
+ HeartbeatExpectedMs: 60000,
26
+ MinSamplesForBaseline: 20
27
+ };
28
+
29
+ class UltravisorBeaconActionDefaults extends libPictService
30
+ {
31
+ constructor(pPict, pOptions, pServiceHash)
32
+ {
33
+ super(pPict, pOptions, pServiceHash);
34
+ this.serviceType = 'UltravisorBeaconActionDefaults';
35
+ this._Cache = {};
36
+ this._CacheTTLMs = 10000;
37
+ }
38
+
39
+ _getStore()
40
+ {
41
+ let tmpMap = this.fable.servicesMap && this.fable.servicesMap.UltravisorBeaconQueueStore;
42
+ if (!tmpMap) return null;
43
+ let tmpStore = Object.values(tmpMap)[0];
44
+ return (tmpStore && tmpStore.isEnabled()) ? tmpStore : null;
45
+ }
46
+
47
+ _cacheKey(pCap, pAction)
48
+ {
49
+ return `${pCap}||${pAction || ''}`;
50
+ }
51
+
52
+ _fableSettingFallback(pKey)
53
+ {
54
+ let tmpSettings = this.fable.settings || {};
55
+ // Legacy settings names kept during the migration period.
56
+ if (pKey === 'TimeoutMs' && tmpSettings.UltravisorBeaconWorkItemTimeoutMs)
57
+ {
58
+ return tmpSettings.UltravisorBeaconWorkItemTimeoutMs;
59
+ }
60
+ if (pKey === 'HeartbeatExpectedMs' && tmpSettings.UltravisorBeaconHeartbeatMs)
61
+ {
62
+ return tmpSettings.UltravisorBeaconHeartbeatMs;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ invalidate()
68
+ {
69
+ this._Cache = {};
70
+ }
71
+
72
+ resolve(pCapability, pAction)
73
+ {
74
+ let tmpKey = this._cacheKey(pCapability, pAction);
75
+ let tmpCached = this._Cache[tmpKey];
76
+ let tmpNow = Date.now();
77
+ if (tmpCached && (tmpNow - tmpCached.At) < this._CacheTTLMs)
78
+ {
79
+ return tmpCached.Value;
80
+ }
81
+
82
+ let tmpStore = this._getStore();
83
+ let tmpSpecific = null;
84
+ let tmpWildcard = null;
85
+ if (tmpStore)
86
+ {
87
+ tmpSpecific = tmpStore.getActionDefault(pCapability, pAction || '');
88
+ if (!tmpSpecific && pAction)
89
+ {
90
+ tmpWildcard = tmpStore.getActionDefault(pCapability, '');
91
+ }
92
+ }
93
+
94
+ let tmpResolved = {};
95
+ for (let tmpField of Object.keys(HARD_DEFAULTS))
96
+ {
97
+ let tmpVal = null;
98
+ if (tmpSpecific && tmpSpecific[tmpField] != null && tmpSpecific[tmpField] !== 0)
99
+ {
100
+ tmpVal = tmpSpecific[tmpField];
101
+ }
102
+ else if (tmpWildcard && tmpWildcard[tmpField] != null && tmpWildcard[tmpField] !== 0)
103
+ {
104
+ tmpVal = tmpWildcard[tmpField];
105
+ }
106
+ else
107
+ {
108
+ tmpVal = this._fableSettingFallback(tmpField);
109
+ }
110
+ if (tmpVal == null) tmpVal = HARD_DEFAULTS[tmpField];
111
+ tmpResolved[tmpField] = tmpVal;
112
+ }
113
+
114
+ this._Cache[tmpKey] = { At: tmpNow, Value: tmpResolved };
115
+ return tmpResolved;
116
+ }
117
+
118
+ applyToWorkItem(pWorkItem, pRequestSettings)
119
+ {
120
+ let tmpResolved = this.resolve(pWorkItem.Capability, pWorkItem.Action);
121
+ let tmpSettings = pRequestSettings || {};
122
+
123
+ pWorkItem.TimeoutMs = pWorkItem.TimeoutMs
124
+ || parseInt(tmpSettings.timeoutMs, 10)
125
+ || parseInt(tmpSettings.TimeoutMs, 10)
126
+ || tmpResolved.TimeoutMs;
127
+
128
+ pWorkItem.MaxAttempts = pWorkItem.MaxAttempts
129
+ || parseInt(tmpSettings.maxRetries, 10)
130
+ || parseInt(tmpSettings.MaxAttempts, 10)
131
+ || tmpResolved.MaxAttempts;
132
+
133
+ pWorkItem.RetryBackoffMs = pWorkItem.RetryBackoffMs
134
+ || parseInt(tmpSettings.retryBackoffMs, 10)
135
+ || parseInt(tmpSettings.RetryBackoffMs, 10)
136
+ || tmpResolved.RetryBackoffMs;
137
+
138
+ if (pWorkItem.Priority == null)
139
+ {
140
+ let tmpPri = parseInt(tmpSettings.priority, 10);
141
+ if (isNaN(tmpPri)) tmpPri = parseInt(tmpSettings.Priority, 10);
142
+ if (isNaN(tmpPri)) tmpPri = tmpResolved.DefaultPriority;
143
+ pWorkItem.Priority = tmpPri;
144
+ }
145
+
146
+ return pWorkItem;
147
+ }
148
+
149
+ /**
150
+ * Compute a live p95 estimate from the last N completed samples.
151
+ * Used when no ExpectedWaitP95Ms is set on the row yet. Stored
152
+ * back into the row so future lookups are fast.
153
+ */
154
+ recomputeWaitBaseline(pCapability, pAction, pMinSamples)
155
+ {
156
+ let tmpStore = this._getStore();
157
+ if (!tmpStore) return null;
158
+ let tmpMin = pMinSamples || HARD_DEFAULTS.MinSamplesForBaseline;
159
+ let tmpSamples = tmpStore.queueWaitSamples(pCapability, pAction || '', 500);
160
+ if (tmpSamples.length < tmpMin) return null;
161
+ tmpSamples.sort((a, b) => a - b);
162
+ let tmpIdx = Math.max(0, Math.floor(tmpSamples.length * 0.95) - 1);
163
+ let tmpP95 = tmpSamples[tmpIdx];
164
+ tmpStore.upsertActionDefault({
165
+ Capability: pCapability,
166
+ Action: pAction || '',
167
+ ExpectedWaitP95Ms: tmpP95
168
+ });
169
+ this.invalidate();
170
+ return tmpP95;
171
+ }
172
+ }
173
+
174
+ module.exports = UltravisorBeaconActionDefaults;