ultravisor 1.3.2 → 1.3.3

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.
@@ -1,10 +1,12 @@
1
1
  # Persistence via retold-databeacon
2
2
 
3
- **Status: planned + foundations in progress.** This doc is the cross-session plan
4
- for routing ultravisor's queue and manifest persistence through retold-databeacon
5
- instead of the specialized `ultravisor-queue-beacon` and `ultravisor-manifest-beacon`
6
- modules. It captures the architectural decision, the work breakdown, and the
7
- hand-off state so a fresh context can resume from here.
3
+ **Status: shipped. Legacy modules removed 2026-05-03.** This doc is the
4
+ cross-session plan for routing ultravisor's queue and manifest persistence
5
+ through retold-databeacon instead of the specialized `ultravisor-queue-beacon`
6
+ and `ultravisor-manifest-beacon` modules (now deleted from the monorepo
7
+ see [What changed and why](#what-changed-and-why) for the historical context).
8
+ It captures the architectural decision, the work breakdown, and the hand-off
9
+ state so a fresh context can resume from here.
8
10
 
9
11
  If you are picking this up cold, read this doc top-to-bottom before touching
10
12
  code — the decisions below are load-bearing and the cross-module dependencies
@@ -12,15 +14,18 @@ are not obvious from the source.
12
14
 
13
15
  ## What changed and why
14
16
 
15
- Before this redesign:
16
- - `ultravisor-queue-beacon` advertises capability `QueuePersistence` with
17
- `QP_*` actions. Ships a `QueuePersistenceProviderBase` and a default
17
+ Before this redesign (the modules described here have since been deleted from
18
+ the monorepo on 2026-05-03 kept here for historical context):
19
+ - `ultravisor-queue-beacon` advertised capability `QueuePersistence` with
20
+ `QP_*` actions. Shipped a `QueuePersistenceProviderBase` and a default
18
21
  `MemoryQueuePersistenceProvider`.
19
- - `ultravisor-manifest-beacon` advertises `ManifestStore` with `MS_*` actions.
20
- Ships a `ManifestStoreProviderBase` and `MemoryManifestStoreProvider`.
22
+ - `ultravisor-manifest-beacon` advertised `ManifestStore` with `MS_*` actions.
23
+ Shipped a `ManifestStoreProviderBase` and `MemoryManifestStoreProvider`.
21
24
  - `Ultravisor-QueuePersistenceBridge.cjs` and
22
- `Ultravisor-ManifestStoreBridge.cjs` dispatch into the corresponding
23
- capability when a beacon is connected, fall back to local storage otherwise.
25
+ `Ultravisor-ManifestStoreBridge.cjs` dispatched into the corresponding
26
+ capability when a beacon was connected, fell back to local storage otherwise.
27
+ Both bridges still exist inside ultravisor — they now translate `QP_*` /
28
+ `MS_*` semantics into `MeadowProxy` calls against retold-databeacon.
24
29
  - `bootstrap-flush` (already shipped) replays locally-buffered writes into a
25
30
  newly-connected beacon via per-beacon HWM tracking persisted to
26
31
  `<DataPath>/persistence-bridge-hwm.json`.
@@ -36,10 +41,12 @@ The redesign:
36
41
  `Request` calls** (Method + Path + Body) instead of dispatching to a
37
42
  specialized capability. The schema (table names, column shapes) is owned
38
43
  by ultravisor; retold-databeacon is a generic gateway.
39
- - **The two specialized beacon modules are not deleted.** They stay as the
40
- reference implementation of the Provider pattern, useful for embedded /
41
- niche deployments that don't want retold-databeacon's REST surface. They
42
- are no longer the lab's recommended path.
44
+ - **The two specialized beacon modules have been deleted (2026-05-03).**
45
+ They were marked legacy throughout Session 4 and the migration path proved
46
+ out across all retold-databeacon-supported engines (mysql / mssql / postgres
47
+ / sqlite). Embedded deployments that prefer in-process Provider-pattern
48
+ persistence can still subclass the bridges' fallback path; the QP/MS
49
+ capability surfaces themselves are gone from the monorepo.
43
50
  - **The lab's UV detail view gains a "persistence databeacon" picker.** The
44
51
  operator picks a running retold-databeacon (which itself has an
45
52
  engine+database picker via `lab-engine-database-picker`); ultravisor's
@@ -1120,21 +1127,31 @@ engine-coverage work — same files get touched.
1120
1127
 
1121
1128
  #### Legacy beacon deprecation
1122
1129
 
1123
- `ultravisor-queue-beacon` and `ultravisor-manifest-beacon` are still
1124
- selectable in the lab's beacon-create form. They stay as-is in the
1125
- codebase (they're the reference Provider implementations and useful for
1126
- embedded deployments that don't want retold-databeacon's REST surface).
1127
- Session 4 just makes the recommended path obvious to operators:
1128
-
1129
- - `Service-BeaconTypeRegistry.js` — append `(legacy)` to the
1130
- `DisplayName` of both types and add a `Deprecated: true` field on
1131
- the public descriptor.
1132
- - `PictView-Lab-Beacons.js` — when the form's BeaconType dropdown
1133
- shows a deprecated type, render a tooltip / inline note: "Legacy
1134
- type. New deployments should use `retold-databeacon` + the lab's
1135
- Persistence assignment for queue / manifest persistence."
1136
- - The seed-dataset and other paths that filter by `BeaconType ===
1137
- 'retold-databeacon'` already do the right thing; no other changes.
1130
+ Originally Session 4 just labeled `ultravisor-queue-beacon` and
1131
+ `ultravisor-manifest-beacon` as `(legacy)` in the lab UI while keeping
1132
+ the modules selectable. As of 2026-05-03 the modules have been deleted
1133
+ outright the migration path proved out across every retold-databeacon
1134
+ engine and there are no remaining users.
1135
+
1136
+ What's gone:
1137
+
1138
+ - The two module directories (`modules/apps/ultravisor-queue-beacon`,
1139
+ `modules/apps/ultravisor-manifest-beacon`).
1140
+ - Manifest entries in `Retold-Modules-Manifest.json` and
1141
+ `modules/Include-Retold-Module-List.sh`.
1142
+ - Lab registry entries `Service-BeaconTypeRegistry.js`'s
1143
+ `SCANNED_MODULES` no longer scans them and `DEPRECATED_BEACON_TYPES`
1144
+ is now an empty Set (kept for future per-module deprecations).
1145
+
1146
+ What stayed:
1147
+
1148
+ - `Ultravisor-QueuePersistenceBridge.cjs` and
1149
+ `Ultravisor-ManifestStoreBridge.cjs` inside ultravisor — they still
1150
+ dispatch `QP_*` / `MS_*` semantics, just into MeadowProxy now.
1151
+ - The `LEGACY_TOOLTIP` constant + per-stanza
1152
+ `retoldBeacon.deprecated: true` plumbing in
1153
+ `Service-BeaconTypeRegistry.js` — useful infrastructure for whatever
1154
+ the next deprecation needs.
1138
1155
 
1139
1156
  #### Test-fable cleanup (tangential hygiene)
1140
1157
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultravisor",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Cyclic process execution with ai integration.",
5
5
  "main": "source/Ultravisor.cjs",
6
6
  "bin": {
@@ -1,5 +1,6 @@
1
1
  const libPictService = require('pict-serviceproviderbase');
2
2
  const libStatus = require('./Ultravisor-Status.cjs');
3
+ const libOutputStore = require('./Ultravisor-OutputStore.cjs');
3
4
 
4
5
  /**
5
6
  * Event-driven graph executor for Ultravisor operations.
@@ -449,21 +450,17 @@ class UltravisorExecutionEngine extends libPictService
449
450
  tmpStateManager.setAddress(tmpWaitingInfo.OutputAddress, pValue, tmpContext, pNodeHash);
450
451
  }
451
452
 
452
- // Store in task outputs
453
- if (!tmpContext.TaskOutputs[pNodeHash])
454
- {
455
- tmpContext.TaskOutputs[pNodeHash] = {};
456
- }
457
-
458
- // If pValue is a structured object, merge all keys into TaskOutputs (beacon results)
459
- // If pValue is a scalar, store as InputValue (backward compat for value-input)
453
+ // Store in task outputs. Routes through OutputStore so beacon
454
+ // results that arrive with a multi-megabyte field (typed-op
455
+ // `Result` etc.) get lifted to the run's staging dir instead of
456
+ // pinning the payload in the manifest.
460
457
  if (typeof pValue === 'object' && pValue !== null && !Array.isArray(pValue))
461
458
  {
462
- Object.assign(tmpContext.TaskOutputs[pNodeHash], pValue);
459
+ libOutputStore.mergeAndLift(tmpContext, pNodeHash, pValue, { Fable: this.fable, Logger: this.log });
463
460
  }
464
461
  else
465
462
  {
466
- tmpContext.TaskOutputs[pNodeHash].InputValue = pValue;
463
+ libOutputStore.mergeAndLift(tmpContext, pNodeHash, { InputValue: pValue }, { Fable: this.fable, Logger: this.log });
467
464
  }
468
465
 
469
466
  // Update the task's ElapsedMs to include the time spent waiting for input
@@ -1060,12 +1057,8 @@ class UltravisorExecutionEngine extends libPictService
1060
1057
  `Intermediate event [${pIntermediateEventName}] from [${pNodeHash}]`, 1);
1061
1058
  }
1062
1059
 
1063
- // Store the intermediate outputs
1064
- if (!pContext.TaskOutputs[pNodeHash])
1065
- {
1066
- pContext.TaskOutputs[pNodeHash] = {};
1067
- }
1068
- Object.assign(pContext.TaskOutputs[pNodeHash], pIntermediateOutputs);
1060
+ // Store the intermediate outputs (lift large fields per OutputStore policy)
1061
+ libOutputStore.mergeAndLift(pContext, pNodeHash, pIntermediateOutputs, { Fable: this.fable, Logger: this.log });
1069
1062
 
1070
1063
  // Find downstream nodes for this intermediate event
1071
1064
  let tmpDownstreamEvents = this._getDownstreamEvents(pNodeHash, pIntermediateEventName, pContext);
@@ -1167,14 +1160,10 @@ class UltravisorExecutionEngine extends libPictService
1167
1160
  return fCallback(null);
1168
1161
  }
1169
1162
 
1170
- // Store outputs in TaskOutputs
1163
+ // Store outputs in TaskOutputs (lift large fields per OutputStore policy)
1171
1164
  if (pResult.Outputs && typeof(pResult.Outputs) === 'object')
1172
1165
  {
1173
- if (!pContext.TaskOutputs[pNodeHash])
1174
- {
1175
- pContext.TaskOutputs[pNodeHash] = {};
1176
- }
1177
- Object.assign(pContext.TaskOutputs[pNodeHash], pResult.Outputs);
1166
+ libOutputStore.mergeAndLift(pContext, pNodeHash, pResult.Outputs, { Fable: this.fable, Logger: this.log });
1178
1167
  }
1179
1168
 
1180
1169
  // Store any state writes from the result
@@ -1313,9 +1302,17 @@ class UltravisorExecutionEngine extends libPictService
1313
1302
  let tmpSourcePortName = this._extractPortName(tmpConn.SourcePortHash, tmpPortLabelMap);
1314
1303
  let tmpTargetPortName = this._extractPortName(tmpConn.TargetPortHash, tmpPortLabelMap);
1315
1304
 
1316
- // Read the source value from the source node's outputs
1305
+ // Read the source value from the source node's outputs.
1306
+ // Large outputs are stored as $$ref objects pointing into the
1307
+ // run's staging dir — materialize before the value reaches a
1308
+ // downstream task handler so the State edge contract stays
1309
+ // transparent (handlers never see a ref).
1317
1310
  let tmpSourceNodeOutputs = pContext.TaskOutputs[tmpConn.SourceNodeHash] || {};
1318
1311
  let tmpSourceValue = tmpSourceNodeOutputs[tmpSourcePortName];
1312
+ if (libOutputStore.isOutputRef(tmpSourceValue))
1313
+ {
1314
+ tmpSourceValue = libOutputStore.materializeRefValue(pContext.StagingPath, tmpSourceValue, this.log);
1315
+ }
1319
1316
 
1320
1317
  // Apply template if defined
1321
1318
  if (tmpConn.Data && tmpConn.Data.Template && typeof(tmpConn.Data.Template) === 'string')
@@ -0,0 +1,317 @@
1
+ const libFS = require('fs');
2
+ const libPath = require('path');
3
+
4
+ /**
5
+ * Bound the in-memory size of TaskOutputs by lifting large per-port
6
+ * payloads to disk and replacing them in the manifest with a small
7
+ * reference object.
8
+ *
9
+ * The fix the lab needed: typed-op pipelines (Pull -> Intersect ->
10
+ * Comprehension -> Write at 100K rows) were emitting ~30 MB of JSON-
11
+ * stringified `Result` per node. UV held those strings in TaskOutputs
12
+ * and serialized the full manifest twice per snapshot (disk + sync
13
+ * /Trigger response), which saturated the V8 heap inside Builtin_
14
+ * JsonParse on sustained stress.
15
+ *
16
+ * Splitting the two jobs TaskOutputs was secretly doing — short-lived
17
+ * State-edge transport vs. durable manifest record — by lifting only
18
+ * the transport payload, breaks the link between manifest size and
19
+ * payload size while preserving the State-edge contract: handlers
20
+ * still see materialized values, never refs.
21
+ *
22
+ * Lift on write, materialize on read. Pure functions; the caller owns
23
+ * the staging path.
24
+ */
25
+
26
+ const REF_KEY = '$$ref';
27
+ const REF_DIR = 'outputs';
28
+ const DEFAULT_THRESHOLD_BYTES = 1024 * 1024;
29
+ const STREAM_CHUNK_BYTES = 64 * 1024;
30
+
31
+ /**
32
+ * Resolve the byte threshold for lifting. Order: env var, program
33
+ * config, default. Env var wins so an operator can hot-tune in a
34
+ * stuck container without rebuilding. Returns the default for any
35
+ * non-positive or unparseable value.
36
+ */
37
+ function getThresholdBytes(pFable)
38
+ {
39
+ let tmpEnv = process.env.UV_OUTPUT_STORE_THRESHOLD_BYTES;
40
+ if (tmpEnv)
41
+ {
42
+ let tmpParsed = parseInt(tmpEnv, 10);
43
+ if (!isNaN(tmpParsed) && tmpParsed > 0)
44
+ {
45
+ return tmpParsed;
46
+ }
47
+ }
48
+ if (pFable && pFable.ProgramConfiguration
49
+ && typeof(pFable.ProgramConfiguration.UltravisorOutputStoreThresholdBytes) === 'number'
50
+ && pFable.ProgramConfiguration.UltravisorOutputStoreThresholdBytes > 0)
51
+ {
52
+ return pFable.ProgramConfiguration.UltravisorOutputStoreThresholdBytes;
53
+ }
54
+ return DEFAULT_THRESHOLD_BYTES;
55
+ }
56
+
57
+ /**
58
+ * True for the {$$ref, Bytes} shape produced by liftValue. Used by
59
+ * read-side materialization and by callers that want to special-case
60
+ * ref-bearing entries (e.g. the optional inline-follow on /Manifest).
61
+ */
62
+ function isOutputRef(pValue)
63
+ {
64
+ return pValue !== null
65
+ && typeof(pValue) === 'object'
66
+ && typeof(pValue[REF_KEY]) === 'string';
67
+ }
68
+
69
+ /**
70
+ * Decide whether a single value is large enough to lift. Strings and
71
+ * Buffers carry the bulk of typed-op payloads — they're the only
72
+ * shapes worth measuring without paying the JSON.stringify cost we're
73
+ * trying to avoid. Objects/Arrays under threshold stay inline; if a
74
+ * future workload produces a multi-megabyte object literal, lift the
75
+ * producer side first (cheaper) before reaching for a structural
76
+ * size walk here.
77
+ */
78
+ function _shouldLift(pValue, pThresholdBytes)
79
+ {
80
+ if (typeof(pValue) === 'string')
81
+ {
82
+ return Buffer.byteLength(pValue, 'utf8') >= pThresholdBytes;
83
+ }
84
+ if (Buffer.isBuffer(pValue))
85
+ {
86
+ return pValue.length >= pThresholdBytes;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * Sanitize a path segment so node hashes / port names map to safe
93
+ * filesystem names. Anything outside [A-Za-z0-9_-] becomes '_'.
94
+ */
95
+ function _safeSegment(pValue)
96
+ {
97
+ let tmpStr = String(pValue == null ? '' : pValue);
98
+ if (tmpStr === '')
99
+ {
100
+ return '_';
101
+ }
102
+ return tmpStr.replace(/[^A-Za-z0-9_\-]/g, '_');
103
+ }
104
+
105
+ /**
106
+ * Stream a string or Buffer to disk in fixed-size chunks. Avoids the
107
+ * implicit Buffer.from copy that writeFileSync does for large strings
108
+ * (which is half the OOM blast radius — string + buffer alive at the
109
+ * same time). Peak overhead is one chunk regardless of payload size.
110
+ */
111
+ function _streamWriteSync(pAbsolutePath, pValue)
112
+ {
113
+ let tmpFd = libFS.openSync(pAbsolutePath, 'w');
114
+ try
115
+ {
116
+ if (typeof(pValue) === 'string')
117
+ {
118
+ let tmpOffset = 0;
119
+ let tmpLen = pValue.length;
120
+ while (tmpOffset < tmpLen)
121
+ {
122
+ let tmpEnd = Math.min(tmpOffset + STREAM_CHUNK_BYTES, tmpLen);
123
+ let tmpChunkBuf = Buffer.from(pValue.slice(tmpOffset, tmpEnd), 'utf8');
124
+ libFS.writeSync(tmpFd, tmpChunkBuf, 0, tmpChunkBuf.length);
125
+ tmpOffset = tmpEnd;
126
+ }
127
+ }
128
+ else if (Buffer.isBuffer(pValue))
129
+ {
130
+ let tmpOffset = 0;
131
+ while (tmpOffset < pValue.length)
132
+ {
133
+ let tmpEnd = Math.min(tmpOffset + STREAM_CHUNK_BYTES, pValue.length);
134
+ libFS.writeSync(tmpFd, pValue, tmpOffset, tmpEnd - tmpOffset);
135
+ tmpOffset = tmpEnd;
136
+ }
137
+ }
138
+ else
139
+ {
140
+ throw new Error('OutputStore: _streamWriteSync expects a string or Buffer');
141
+ }
142
+ }
143
+ finally
144
+ {
145
+ libFS.closeSync(tmpFd);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Lift one value to disk and return the ref object that should
151
+ * replace it in TaskOutputs. The ref's path is relative to the run's
152
+ * staging dir; readers join it back with the same staging dir so a
153
+ * staging folder can be moved without rewriting refs.
154
+ */
155
+ function liftValue(pStagingPath, pNodeHash, pPort, pValue)
156
+ {
157
+ let tmpRelDir = libPath.join(REF_DIR, _safeSegment(pNodeHash));
158
+ let tmpAbsDir = libPath.resolve(pStagingPath, tmpRelDir);
159
+ libFS.mkdirSync(tmpAbsDir, { recursive: true });
160
+
161
+ let tmpFileName = _safeSegment(pPort) + '.json';
162
+ let tmpAbsFile = libPath.resolve(tmpAbsDir, tmpFileName);
163
+ let tmpRelFile = libPath.join(tmpRelDir, tmpFileName);
164
+
165
+ _streamWriteSync(tmpAbsFile, pValue);
166
+
167
+ let tmpBytes;
168
+ if (typeof(pValue) === 'string')
169
+ {
170
+ tmpBytes = Buffer.byteLength(pValue, 'utf8');
171
+ }
172
+ else
173
+ {
174
+ tmpBytes = pValue.length;
175
+ }
176
+
177
+ let tmpRef = {};
178
+ tmpRef[REF_KEY] = tmpRelFile.split(libPath.sep).join('/');
179
+ tmpRef.Bytes = tmpBytes;
180
+ return tmpRef;
181
+ }
182
+
183
+ /**
184
+ * Read a ref's contents back from disk. Returns the original value
185
+ * (utf8 string — the only shape we lift today) or undefined when the
186
+ * staging file is gone (e.g. retention sweep ran, or a reloaded
187
+ * manifest references a folder that was cleaned).
188
+ *
189
+ * Non-ref values pass through unchanged so callers can wrap any read
190
+ * site without branching.
191
+ */
192
+ function materializeRefValue(pStagingPath, pValue, pLogger)
193
+ {
194
+ if (!isOutputRef(pValue))
195
+ {
196
+ return pValue;
197
+ }
198
+ if (!pStagingPath)
199
+ {
200
+ if (pLogger) pLogger.warn(`OutputStore: cannot materialize ref [${pValue[REF_KEY]}] without a staging path.`);
201
+ return undefined;
202
+ }
203
+ let tmpAbsFile = libPath.resolve(pStagingPath, pValue[REF_KEY]);
204
+ if (!libFS.existsSync(tmpAbsFile))
205
+ {
206
+ if (pLogger) pLogger.warn(`OutputStore: ref payload missing on disk [${tmpAbsFile}].`);
207
+ return undefined;
208
+ }
209
+ try
210
+ {
211
+ return libFS.readFileSync(tmpAbsFile, 'utf8');
212
+ }
213
+ catch (pError)
214
+ {
215
+ if (pLogger) pLogger.warn(`OutputStore: failed to read ref payload [${tmpAbsFile}]: ${pError.message}`);
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Merge a set of new fields into TaskOutputs[<nodeHash>], lifting any
222
+ * field whose serialized size meets or exceeds the threshold. Falls
223
+ * back to inline storage on any IO failure so a flaky disk doesn't
224
+ * turn into lost outputs.
225
+ *
226
+ * This is the one entry point the engine uses for all three places
227
+ * that write to TaskOutputs (resume, intermediate event, task
228
+ * complete). Centralizing avoids the "I forgot to lift in this code
229
+ * path" bug the original conflated design encouraged.
230
+ */
231
+ function mergeAndLift(pContext, pNodeHash, pNewFields, pOptions)
232
+ {
233
+ if (!pNewFields || typeof(pNewFields) !== 'object')
234
+ {
235
+ return;
236
+ }
237
+ if (!pContext.TaskOutputs)
238
+ {
239
+ pContext.TaskOutputs = {};
240
+ }
241
+ if (!pContext.TaskOutputs[pNodeHash])
242
+ {
243
+ pContext.TaskOutputs[pNodeHash] = {};
244
+ }
245
+
246
+ let tmpTarget = pContext.TaskOutputs[pNodeHash];
247
+ let tmpFable = (pOptions && pOptions.Fable) || null;
248
+ let tmpLogger = (pOptions && pOptions.Logger) || null;
249
+ let tmpThreshold = getThresholdBytes(tmpFable);
250
+
251
+ let tmpKeys = Object.keys(pNewFields);
252
+ for (let i = 0; i < tmpKeys.length; i++)
253
+ {
254
+ let tmpKey = tmpKeys[i];
255
+ let tmpVal = pNewFields[tmpKey];
256
+
257
+ if (pContext.StagingPath && _shouldLift(tmpVal, tmpThreshold))
258
+ {
259
+ try
260
+ {
261
+ tmpTarget[tmpKey] = liftValue(pContext.StagingPath, pNodeHash, tmpKey, tmpVal);
262
+ continue;
263
+ }
264
+ catch (pError)
265
+ {
266
+ if (tmpLogger) tmpLogger.warn(`OutputStore: lift failed for [${pNodeHash}.${tmpKey}], falling back to inline: ${pError.message}`);
267
+ }
268
+ }
269
+ tmpTarget[tmpKey] = tmpVal;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Walk a TaskOutputs map and inline every ref into its materialized
275
+ * value. Used by the optional /Manifest?inline=outputs follow path —
276
+ * regular manifest reads return refs as-is.
277
+ */
278
+ function inlineAllRefs(pStagingPath, pTaskOutputs, pLogger)
279
+ {
280
+ if (!pTaskOutputs || typeof(pTaskOutputs) !== 'object')
281
+ {
282
+ return pTaskOutputs;
283
+ }
284
+ let tmpResult = {};
285
+ let tmpNodeKeys = Object.keys(pTaskOutputs);
286
+ for (let i = 0; i < tmpNodeKeys.length; i++)
287
+ {
288
+ let tmpNodeHash = tmpNodeKeys[i];
289
+ let tmpEntry = pTaskOutputs[tmpNodeHash];
290
+ if (!tmpEntry || typeof(tmpEntry) !== 'object')
291
+ {
292
+ tmpResult[tmpNodeHash] = tmpEntry;
293
+ continue;
294
+ }
295
+ let tmpInlined = {};
296
+ let tmpFieldKeys = Object.keys(tmpEntry);
297
+ for (let j = 0; j < tmpFieldKeys.length; j++)
298
+ {
299
+ let tmpField = tmpFieldKeys[j];
300
+ tmpInlined[tmpField] = materializeRefValue(pStagingPath, tmpEntry[tmpField], pLogger);
301
+ }
302
+ tmpResult[tmpNodeHash] = tmpInlined;
303
+ }
304
+ return tmpResult;
305
+ }
306
+
307
+ module.exports =
308
+ {
309
+ REF_KEY: REF_KEY,
310
+ DEFAULT_THRESHOLD_BYTES: DEFAULT_THRESHOLD_BYTES,
311
+ getThresholdBytes: getThresholdBytes,
312
+ isOutputRef: isOutputRef,
313
+ liftValue: liftValue,
314
+ materializeRefValue: materializeRefValue,
315
+ mergeAndLift: mergeAndLift,
316
+ inlineAllRefs: inlineAllRefs
317
+ };
@@ -1,4 +1,5 @@
1
1
  const libPictService = require('pict-serviceproviderbase');
2
+ const libOutputStore = require('./Ultravisor-OutputStore.cjs');
2
3
 
3
4
  /**
4
5
  * Manages three-level state (Global, Operation, Task) and provides
@@ -67,9 +68,9 @@ class UltravisorStateManager extends libPictService
67
68
  this.log.warn(`UltravisorStateManager: resolveAddress for Task.X requires a current node hash.`);
68
69
  return undefined;
69
70
  }
70
- return this._resolveFromObject(
71
+ return this._materializeIfRef(pExecutionContext, this._resolveFromObject(
71
72
  pExecutionContext.TaskOutputs[pCurrentNodeHash] || {},
72
- tmpRemainder);
73
+ tmpRemainder));
73
74
 
74
75
  case 'TaskOutput':
75
76
  {
@@ -77,14 +78,19 @@ class UltravisorStateManager extends libPictService
77
78
  let tmpSecondDot = tmpRemainder.indexOf('.');
78
79
  if (tmpSecondDot < 0)
79
80
  {
80
- // Just TaskOutput.{NodeHash} -- return the whole output object
81
+ // Just TaskOutput.{NodeHash} -- return the whole output object.
82
+ // Whole-node return ships ref shapes through to the
83
+ // caller — single-port reads materialize, but a
84
+ // "give me the whole bag" caller has to handle refs
85
+ // itself (or use Task.X / TaskOutput.X.Y to get the
86
+ // materialized leaf).
81
87
  return pExecutionContext.TaskOutputs[tmpRemainder] || {};
82
88
  }
83
89
  let tmpNodeHash = tmpRemainder.substring(0, tmpSecondDot);
84
90
  let tmpPath = tmpRemainder.substring(tmpSecondDot + 1);
85
- return this._resolveFromObject(
91
+ return this._materializeIfRef(pExecutionContext, this._resolveFromObject(
86
92
  pExecutionContext.TaskOutputs[tmpNodeHash] || {},
87
- tmpPath);
93
+ tmpPath));
88
94
  }
89
95
 
90
96
  case 'Output':
@@ -248,6 +254,20 @@ class UltravisorStateManager extends libPictService
248
254
  this._Manyfest.setValueAtAddress(pObject, pPath, pValue);
249
255
  return true;
250
256
  }
257
+
258
+ // Resolve $$ref payloads to their materialized value when reading
259
+ // from TaskOutputs. Per-leaf reads (Task.X, TaskOutput.X.Y) go
260
+ // through this; whole-node reads (TaskOutput.X) deliberately do not
261
+ // — see the case-handler comment.
262
+ _materializeIfRef(pExecutionContext, pValue)
263
+ {
264
+ if (!libOutputStore.isOutputRef(pValue))
265
+ {
266
+ return pValue;
267
+ }
268
+ return libOutputStore.materializeRefValue(
269
+ pExecutionContext.StagingPath, pValue, this.log);
270
+ }
251
271
  }
252
272
 
253
273
  module.exports = UltravisorStateManager;
@@ -8,6 +8,7 @@ const libOratorServiceServerRestify = require(`orator-serviceserver-restify`);
8
8
  const libOratorAuthentication = require('orator-authentication');
9
9
  const libWebSocket = require('ws');
10
10
  const libStatus = require('../services/Ultravisor-Status.cjs');
11
+ const libOutputStore = require('../services/Ultravisor-OutputStore.cjs');
11
12
 
12
13
  // Strip a manifest down to the JSON-serializable shape the wire
13
14
  // expects. The in-memory ExecutionContext can carry closures in
@@ -41,6 +42,18 @@ function _cleanManifestForWire(pManifest)
41
42
  };
42
43
  }
43
44
 
45
+ // Optional follow-the-refs pass for /Manifest/:RunHash?inline=outputs.
46
+ // Default response ships ref shapes; this helper is invoked only when
47
+ // the caller explicitly asks for materialized payloads. Reads files
48
+ // synchronously off the staging dir — fine for the rare inspection
49
+ // case but not something to call from a hot path.
50
+ function _inlineIfRequested(pCleanManifest, pStagingPath, pInlineOutputs, pLogger)
51
+ {
52
+ if (!pInlineOutputs || !pCleanManifest) return pCleanManifest;
53
+ pCleanManifest.TaskOutputs = libOutputStore.inlineAllRefs(pStagingPath, pCleanManifest.TaskOutputs, pLogger);
54
+ return pCleanManifest;
55
+ }
56
+
44
57
  // Lightweight query-string parser. Restify's queryParser plugin is
45
58
  // not enabled on this server (mounting it would change the request
46
59
  // shape for every existing handler), so the few routes that need
@@ -1895,11 +1908,19 @@ class UltravisorAPIServer extends libPictService
1895
1908
  function (pRequest, pResponse, fNext)
1896
1909
  {
1897
1910
  let tmpHash = pRequest.params.RunHash;
1911
+ // Default ships TaskOutputs with ref shapes inline. The
1912
+ // `?inline=outputs` flag follows refs to materialized
1913
+ // values for UIs that need to see the full payload —
1914
+ // this is the rare case (e.g. raw inspection in the
1915
+ // manifest viewer) and bypasses the OOM guard, so
1916
+ // callers should know what they're asking for.
1917
+ let tmpQuery = _parseQueryString(pRequest.url);
1918
+ let tmpInlineOutputs = (tmpQuery.inline === 'outputs');
1898
1919
  let tmpManifest = this._getService('UltravisorExecutionManifest');
1899
1920
  let tmpRun = tmpManifest ? tmpManifest.getRun(tmpHash) : null;
1900
1921
  if (tmpRun)
1901
1922
  {
1902
- pResponse.send(_cleanManifestForWire(tmpRun));
1923
+ pResponse.send(_inlineIfRequested(_cleanManifestForWire(tmpRun), tmpRun.StagingPath, tmpInlineOutputs, this.log));
1903
1924
  return fNext();
1904
1925
  }
1905
1926
  // Not in memory — try the bridge (beacon-backed history).
@@ -1913,7 +1934,8 @@ class UltravisorAPIServer extends libPictService
1913
1934
  {
1914
1935
  if (pResult && pResult.Success && pResult.Manifest)
1915
1936
  {
1916
- pResponse.send(_cleanManifestForWire(pResult.Manifest));
1937
+ let tmpClean = _cleanManifestForWire(pResult.Manifest);
1938
+ pResponse.send(_inlineIfRequested(tmpClean, pResult.Manifest.StagingPath, tmpInlineOutputs, this.log));
1917
1939
  }
1918
1940
  else
1919
1941
  {
@@ -1,10 +1,12 @@
1
1
  # Persistence via retold-databeacon
2
2
 
3
- **Status: planned + foundations in progress.** This doc is the cross-session plan
4
- for routing ultravisor's queue and manifest persistence through retold-databeacon
5
- instead of the specialized `ultravisor-queue-beacon` and `ultravisor-manifest-beacon`
6
- modules. It captures the architectural decision, the work breakdown, and the
7
- hand-off state so a fresh context can resume from here.
3
+ **Status: shipped. Legacy modules removed 2026-05-03.** This doc is the
4
+ cross-session plan for routing ultravisor's queue and manifest persistence
5
+ through retold-databeacon instead of the specialized `ultravisor-queue-beacon`
6
+ and `ultravisor-manifest-beacon` modules (now deleted from the monorepo
7
+ see [What changed and why](#what-changed-and-why) for the historical context).
8
+ It captures the architectural decision, the work breakdown, and the hand-off
9
+ state so a fresh context can resume from here.
8
10
 
9
11
  If you are picking this up cold, read this doc top-to-bottom before touching
10
12
  code — the decisions below are load-bearing and the cross-module dependencies
@@ -12,15 +14,18 @@ are not obvious from the source.
12
14
 
13
15
  ## What changed and why
14
16
 
15
- Before this redesign:
16
- - `ultravisor-queue-beacon` advertises capability `QueuePersistence` with
17
- `QP_*` actions. Ships a `QueuePersistenceProviderBase` and a default
17
+ Before this redesign (the modules described here have since been deleted from
18
+ the monorepo on 2026-05-03 kept here for historical context):
19
+ - `ultravisor-queue-beacon` advertised capability `QueuePersistence` with
20
+ `QP_*` actions. Shipped a `QueuePersistenceProviderBase` and a default
18
21
  `MemoryQueuePersistenceProvider`.
19
- - `ultravisor-manifest-beacon` advertises `ManifestStore` with `MS_*` actions.
20
- Ships a `ManifestStoreProviderBase` and `MemoryManifestStoreProvider`.
22
+ - `ultravisor-manifest-beacon` advertised `ManifestStore` with `MS_*` actions.
23
+ Shipped a `ManifestStoreProviderBase` and `MemoryManifestStoreProvider`.
21
24
  - `Ultravisor-QueuePersistenceBridge.cjs` and
22
- `Ultravisor-ManifestStoreBridge.cjs` dispatch into the corresponding
23
- capability when a beacon is connected, fall back to local storage otherwise.
25
+ `Ultravisor-ManifestStoreBridge.cjs` dispatched into the corresponding
26
+ capability when a beacon was connected, fell back to local storage otherwise.
27
+ Both bridges still exist inside ultravisor — they now translate `QP_*` /
28
+ `MS_*` semantics into `MeadowProxy` calls against retold-databeacon.
24
29
  - `bootstrap-flush` (already shipped) replays locally-buffered writes into a
25
30
  newly-connected beacon via per-beacon HWM tracking persisted to
26
31
  `<DataPath>/persistence-bridge-hwm.json`.
@@ -36,10 +41,12 @@ The redesign:
36
41
  `Request` calls** (Method + Path + Body) instead of dispatching to a
37
42
  specialized capability. The schema (table names, column shapes) is owned
38
43
  by ultravisor; retold-databeacon is a generic gateway.
39
- - **The two specialized beacon modules are not deleted.** They stay as the
40
- reference implementation of the Provider pattern, useful for embedded /
41
- niche deployments that don't want retold-databeacon's REST surface. They
42
- are no longer the lab's recommended path.
44
+ - **The two specialized beacon modules have been deleted (2026-05-03).**
45
+ They were marked legacy throughout Session 4 and the migration path proved
46
+ out across all retold-databeacon-supported engines (mysql / mssql / postgres
47
+ / sqlite). Embedded deployments that prefer in-process Provider-pattern
48
+ persistence can still subclass the bridges' fallback path; the QP/MS
49
+ capability surfaces themselves are gone from the monorepo.
43
50
  - **The lab's UV detail view gains a "persistence databeacon" picker.** The
44
51
  operator picks a running retold-databeacon (which itself has an
45
52
  engine+database picker via `lab-engine-database-picker`); ultravisor's
@@ -1120,21 +1127,31 @@ engine-coverage work — same files get touched.
1120
1127
 
1121
1128
  #### Legacy beacon deprecation
1122
1129
 
1123
- `ultravisor-queue-beacon` and `ultravisor-manifest-beacon` are still
1124
- selectable in the lab's beacon-create form. They stay as-is in the
1125
- codebase (they're the reference Provider implementations and useful for
1126
- embedded deployments that don't want retold-databeacon's REST surface).
1127
- Session 4 just makes the recommended path obvious to operators:
1128
-
1129
- - `Service-BeaconTypeRegistry.js` — append `(legacy)` to the
1130
- `DisplayName` of both types and add a `Deprecated: true` field on
1131
- the public descriptor.
1132
- - `PictView-Lab-Beacons.js` — when the form's BeaconType dropdown
1133
- shows a deprecated type, render a tooltip / inline note: "Legacy
1134
- type. New deployments should use `retold-databeacon` + the lab's
1135
- Persistence assignment for queue / manifest persistence."
1136
- - The seed-dataset and other paths that filter by `BeaconType ===
1137
- 'retold-databeacon'` already do the right thing; no other changes.
1130
+ Originally Session 4 just labeled `ultravisor-queue-beacon` and
1131
+ `ultravisor-manifest-beacon` as `(legacy)` in the lab UI while keeping
1132
+ the modules selectable. As of 2026-05-03 the modules have been deleted
1133
+ outright the migration path proved out across every retold-databeacon
1134
+ engine and there are no remaining users.
1135
+
1136
+ What's gone:
1137
+
1138
+ - The two module directories (`modules/apps/ultravisor-queue-beacon`,
1139
+ `modules/apps/ultravisor-manifest-beacon`).
1140
+ - Manifest entries in `Retold-Modules-Manifest.json` and
1141
+ `modules/Include-Retold-Module-List.sh`.
1142
+ - Lab registry entries `Service-BeaconTypeRegistry.js`'s
1143
+ `SCANNED_MODULES` no longer scans them and `DEPRECATED_BEACON_TYPES`
1144
+ is now an empty Set (kept for future per-module deprecations).
1145
+
1146
+ What stayed:
1147
+
1148
+ - `Ultravisor-QueuePersistenceBridge.cjs` and
1149
+ `Ultravisor-ManifestStoreBridge.cjs` inside ultravisor — they still
1150
+ dispatch `QP_*` / `MS_*` semantics, just into MeadowProxy now.
1151
+ - The `LEGACY_TOOLTIP` constant + per-stanza
1152
+ `retoldBeacon.deprecated: true` plumbing in
1153
+ `Service-BeaconTypeRegistry.js` — useful infrastructure for whatever
1154
+ the next deprecation needs.
1138
1155
 
1139
1156
  #### Test-fable cleanup (tangential hygiene)
1140
1157