@camstack/system 1.2.60 → 1.2.62

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.
Files changed (58) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-aggregation.d.ts +49 -2
  14. package/dist/builtins/device-manager/device-manager.addon.js +60 -5
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +60 -5
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  18. package/dist/builtins/hub-forwarder/index.js +1 -1
  19. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  24. package/dist/builtins/local-network/local-network.addon.js +1 -1
  25. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  26. package/dist/builtins/loki-logging/index.js +1 -1
  27. package/dist/builtins/loki-logging/index.mjs +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  30. package/dist/builtins/platform-probe/index.js +1 -1
  31. package/dist/builtins/platform-probe/index.mjs +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  34. package/dist/builtins/snapshot/index.js +1 -1
  35. package/dist/builtins/snapshot/index.mjs +1 -1
  36. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  38. package/dist/builtins/sqlite-storage/prefix-range.d.ts +50 -0
  39. package/dist/builtins/sqlite-storage/query-bounds.d.ts +116 -0
  40. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +12 -0
  41. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +38 -2
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +281 -19
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +281 -19
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-Cis_iV-u.js → dist-DgPUarcs.js} +636 -3
  51. package/dist/{dist-C5XpOjbI.mjs → dist-DnKpFaP1.mjs} +636 -3
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +337 -12
  54. package/dist/index.mjs +328 -12
  55. package/dist/kernel/addon-residue.d.ts +49 -0
  56. package/dist/kernel/heap-watch.d.ts +48 -1
  57. package/dist/kernel/index.d.ts +2 -0
  58. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { startHeapWatch, buildHeapSample, HEAP_WATCH_INTERVAL_MS, HEAP_WATCH_WARN_RATIO, } from './kernel/heap-watch.js';
2
- export type { HeapSample, HeapWatchSink } from './kernel/heap-watch.js';
1
+ export { startHeapWatch, buildHeapSample, createV8Reclaimer, shouldReclaim, strandedMb, HEAP_WATCH_INTERVAL_MS, HEAP_WATCH_WARN_RATIO, HEAP_RECLAIM_TRIGGER_MB, HEAP_RECLAIM_MIN_INTERVAL_MS, } from './kernel/heap-watch.js';
2
+ export type { HeapSample, HeapWatchSink, HeapReclaimer, HeapReclaimOptions, } from './kernel/heap-watch.js';
3
3
  export { ensureBinary, downloadBinary, findInPath, getPlatformInfo, buildBinaryPath, ensureFfmpeg, getFfmpegDownloadUrl, ensurePython, installPythonPackages, installPythonRequirements, getPythonDownloadUrl, PYTHON_VERSION, } from '@camstack/types/node';
4
4
  export type { PlatformInfo } from '@camstack/types/node';
5
5
  export { ALL_CAPABILITY_DEFINITIONS, METHOD_ACCESS_MAP, expandCapMethods, isArrayOutputSchema, isCollectionArrayMethod, addonSettingsCapability, DEVICE_SETTINGS_CONTRIBUTION_METHODS, DEVICE_STATUS_METHOD, } from '@camstack/types';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-Cek0wNdY.js");
3
- const require_dist = require("./dist-Cis_iV-u.js");
3
+ const require_dist = require("./dist-DgPUarcs.js");
4
4
  const require_model_download_service = require("./model-download-service-Cpc6SWzV.js");
5
5
  const require_manifest_python_deps = require("./manifest-python-deps-d4acYXVV.js");
6
6
  const require_resource_monitor = require("./resource-monitor-DNNomR-i.js");
@@ -30,6 +30,8 @@ require("./builtins/device-manager/index.js");
30
30
  const require_custom_action_registry = require("./custom-action-registry-jY0NOZK8.js");
31
31
  let node_v8 = require("node:v8");
32
32
  node_v8 = require_chunk.__toESM(node_v8);
33
+ let node_vm = require("node:vm");
34
+ node_vm = require_chunk.__toESM(node_vm);
33
35
  let _camstack_types_node = require("@camstack/types/node");
34
36
  let zod = require("zod");
35
37
  let node_crypto = require("node:crypto");
@@ -43,8 +45,6 @@ let _camstack_types_addon = require("@camstack/types/addon");
43
45
  let node_child_process = require("node:child_process");
44
46
  let node_util = require("node:util");
45
47
  node_util = require_chunk.__toESM(node_util);
46
- let node_vm = require("node:vm");
47
- node_vm = require_chunk.__toESM(node_vm);
48
48
  let node_os = require("node:os");
49
49
  node_os = require_chunk.__toESM(node_os);
50
50
  let node_fs_promises = require("node:fs/promises");
@@ -60,13 +60,15 @@ path = require_chunk.__toESM(path, 1);
60
60
  let module$1 = require("module");
61
61
  //#region src/kernel/heap-watch.ts
62
62
  /**
63
- * heap-watch — hub-main's own memory, on the record.
63
+ * heap-watch — hub-main's own memory, on the record, and returned to the OS.
64
64
  *
65
65
  * ## Why this exists
66
66
  *
67
- * hub-main is launched with `--max-old-space-size=1536` (docker-entrypoint) and
68
- * has logged NOTHING about its own heap. When it died on 2026-08-01/02 the only
69
- * trace was V8's own stderr line:
67
+ * hub-main used to be launched with `--max-old-space-size=1536` (docker-entrypoint;
68
+ * the cap was removed on 2026-08-02, so the live ceiling is now V8's own default —
69
+ * 4288MB in this container and this module reads it at runtime rather than
70
+ * assuming a number). It logged NOTHING about its own heap. When it died on
71
+ * 2026-08-01/02 the only trace was V8's own stderr line:
70
72
  *
71
73
  * FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
72
74
  *
@@ -94,6 +96,41 @@ let module$1 = require("module");
94
96
  * `--max-old-space-size` and are most of the gap between a 1.5GB cap and the
95
97
  * ~2.05GB RSS observed live: a diagnosis that only looks at the JS heap
96
98
  * misreads that process by 500MB.
99
+ *
100
+ * ## And then the heartbeat explained the "off-heap leak", which was not one
101
+ *
102
+ * 2026-08-08, measured on the live hub over 1019 consecutive samples and seven
103
+ * separate boots: **hub-main's RSS is a HIGH-WATER MARK, not a leak.** In every
104
+ * one of those 1019 samples RSS was >= the running maximum of
105
+ * `heapTotal + external`, never once below it, and
106
+ * `RSS_peak - peak(heapTotal + external)` landed between 874MB and 1639MB on
107
+ * every boot — a roughly constant native floor on top of a number that only
108
+ * ever goes up. RSS tracked the highest point the process ever reached and
109
+ * stayed there: 3.3GB -> 4.5GB -> 5.2GB, and 7.8GB on the worst boot.
110
+ *
111
+ * Two things push that peak, and neither is a retained object:
112
+ *
113
+ * - the JS heap sawtooths to 2.0-2.8GB because the ceiling is 4288MB and
114
+ * nothing creates pressure before then;
115
+ * - `arrayBuffers` accumulates DEAD Buffers — up to 3.65GB — because V8 only
116
+ * sweeps them on a major GC. Eleven collapses were captured, and every
117
+ * single one coincides with `heapUsed` dropping to its ~650MB floor. No code
118
+ * released a reference at 13:23:03; a mark-compact ran.
119
+ *
120
+ * At the peak V8 has committed the pages, and **an ordinary major GC does not
121
+ * give them back.** Proven on the production hub: `smaps` showed 10,819 mapped
122
+ * 256KB pages (V8's `kRegularPageSize`) holding 2704MB while V8 itself reported
123
+ * `total_physical_size` of only 846MB — ~7,800 pages committed, resident, and
124
+ * outside V8's own accounting. One `gc({ flavor: 'last-resort' })` took 417ms
125
+ * and returned RSS 3355MB -> 1237MB, pages 10,819 -> 2,963.
126
+ *
127
+ * So the fix for the "~3GB/2h off-heap growth" is not a retainer hunt. It is to
128
+ * ask V8 for the pages back when the process is visibly holding more than it is
129
+ * using — which is what `shouldReclaim` decides and what the heartbeat now does.
130
+ *
131
+ * The cost is a 250-450ms stop-the-world on the hub's router process, bounded to
132
+ * once per `HEAP_RECLAIM_MIN_INTERVAL_MS`. The alternative, measured, is a
133
+ * ratchet to 7.8GB and the OOM that ends it.
97
134
  */
98
135
  /** Heartbeat period. Slow enough to be free, fast enough that the sample before
99
136
  * a fatal is recent. */
@@ -101,6 +138,20 @@ var HEAP_WATCH_INTERVAL_MS = 6e4;
101
138
  /** Warn above this share of the heap ceiling. Chosen so the warning precedes
102
139
  * the fatal by more than one interval under realistic growth. */
103
140
  var HEAP_WATCH_WARN_RATIO = .8;
141
+ /**
142
+ * Reclaim once the process holds this many MB beyond what it is USING.
143
+ *
144
+ * Live calibration, both readings from the same hub minutes apart: 2491MB
145
+ * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
146
+ * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
147
+ * collecting against it would buy nothing and cost a stall every minute. 1536
148
+ * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
149
+ * anything, so it triggers on the real condition and stops as soon as it is fixed.
150
+ */
151
+ var HEAP_RECLAIM_TRIGGER_MB = 1536;
152
+ /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
153
+ * minutes keeps the worst case under 0.1% of the router's time. */
154
+ var HEAP_RECLAIM_MIN_INTERVAL_MS = 6e5;
104
155
  var MB = (bytes) => Math.round(bytes / 1048576);
105
156
  /**
106
157
  * Build a sample from raw readings. Pure so the thresholds are testable without
@@ -123,6 +174,47 @@ var consoleSink = {
123
174
  info: (line) => console.log(line),
124
175
  warn: (line) => console.warn(line)
125
176
  };
177
+ /**
178
+ * RSS the process is holding but not using.
179
+ *
180
+ * The subtrahend is heapUSED, not heapTotal, deliberately: the pages V8
181
+ * committed at a sawtooth peak and no longer needs are counted inside heapTotal,
182
+ * and they are precisely what this number exists to see.
183
+ */
184
+ function strandedMb(s) {
185
+ return s.rssMb - s.heapUsedMb - s.externalMb;
186
+ }
187
+ /** Whether it is worth paying a stop-the-world to get those pages back. */
188
+ function shouldReclaim(s, triggerMb = HEAP_RECLAIM_TRIGGER_MB) {
189
+ return strandedMb(s) > triggerMb;
190
+ }
191
+ function isGcFunction(value) {
192
+ return typeof value === "function";
193
+ }
194
+ /**
195
+ * Build the reclaimer without requiring `--expose-gc` on argv.
196
+ *
197
+ * The flag is turned on, the function captured, and the flag turned straight
198
+ * back off — a global `gc` left reachable by application code is a footgun, and
199
+ * requiring an argv change would mean this cannot ship without a new image.
200
+ * Verified against the live hub before being written: 266ms, RSS 1453 -> 1237.
201
+ *
202
+ * Returns undefined if V8 declines, so the caller keeps a heartbeat either way.
203
+ */
204
+ function createV8Reclaimer() {
205
+ try {
206
+ node_v8.setFlagsFromString("--expose-gc");
207
+ const gc = node_vm.runInNewContext("gc");
208
+ node_v8.setFlagsFromString("--no-expose-gc");
209
+ if (!isGcFunction(gc)) return void 0;
210
+ return () => gc({
211
+ execution: "sync",
212
+ flavor: "last-resort"
213
+ });
214
+ } catch {
215
+ return;
216
+ }
217
+ }
126
218
  function format$1(label, s) {
127
219
  return `[mem] ${label} rss=${s.rssMb}MB heapUsed=${s.heapUsedMb}MB heapTotal=${s.heapTotalMb}MB heapLimit=${s.heapLimitMb}MB used=${Math.round(s.usedRatio * 100)}% external=${s.externalMb}MB arrayBuffers=${s.arrayBuffersMb}MB`;
128
220
  }
@@ -132,13 +224,38 @@ function format$1(label, s) {
132
224
  * `unref`ed so it can never hold the process open, and every read is wrapped:
133
225
  * a diagnostic that can crash the process it watches is worse than none.
134
226
  */
135
- function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS) {
227
+ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS, reclaimOptions) {
228
+ const readMemory = reclaimOptions?.readMemory ?? (() => process.memoryUsage());
229
+ const now = reclaimOptions?.now ?? (() => Date.now());
230
+ const triggerMb = reclaimOptions?.triggerMb ?? 1536;
231
+ const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
232
+ let lastReclaimAt = Number.NEGATIVE_INFINITY;
233
+ const read = () => {
234
+ const limit = reclaimOptions?.heapLimitBytes ?? node_v8.getHeapStatistics().heap_size_limit;
235
+ return buildHeapSample(readMemory(), limit);
236
+ };
237
+ const maybeReclaim = (sample) => {
238
+ if (reclaimOptions === void 0) return;
239
+ if (!shouldReclaim(sample, triggerMb)) return;
240
+ if (now() - lastReclaimAt < minIntervalMs) return;
241
+ lastReclaimAt = now();
242
+ const startedAt = now();
243
+ try {
244
+ reclaimOptions.reclaim();
245
+ } catch (error) {
246
+ sink.warn(`[mem] reclaim failed — ${error instanceof Error ? error.message : String(error)}`);
247
+ return;
248
+ }
249
+ const after = read();
250
+ sink.info(`[mem] reclaim ${label} stranded=${strandedMb(sample)}MB rss=${sample.rssMb}MB→${after.rssMb}MB freed=${sample.rssMb - after.rssMb}MB arrayBuffers=${sample.arrayBuffersMb}MB→${after.arrayBuffersMb}MB took=${now() - startedAt}ms`);
251
+ };
136
252
  const tick = () => {
137
253
  try {
138
- const sample = buildHeapSample(process.memoryUsage(), node_v8.getHeapStatistics().heap_size_limit);
254
+ const sample = read();
139
255
  const line = format$1(label, sample);
140
256
  if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
141
257
  else sink.info(line);
258
+ maybeReclaim(sample);
142
259
  } catch {}
143
260
  };
144
261
  const timer = setInterval(tick, intervalMs);
@@ -3239,7 +3356,12 @@ var MANIFEST_VERSION = 1;
3239
3356
  * workspace source. Trusting the dirent there finds zero addons on disk, which
3240
3357
  * with pruning would empty a developer's manifest on the next boot.
3241
3358
  */
3242
- function isDirLike(p) {
3359
+ /** `@camstack/system` → `system`: the directory a package must live in. */
3360
+ function packageDirName$1(declaredName) {
3361
+ const slash = declaredName.lastIndexOf("/");
3362
+ return slash === -1 ? declaredName : declaredName.slice(slash + 1);
3363
+ }
3364
+ function isDirLike$1(p) {
3243
3365
  try {
3244
3366
  return node_fs.statSync(p).isDirectory();
3245
3367
  } catch {
@@ -3373,10 +3495,10 @@ var AddonManifest = class {
3373
3495
  /** Package name → version, for every ADDON package actually installed. */
3374
3496
  const onDisk = /* @__PURE__ */ new Map();
3375
3497
  for (const scope of node_fs.readdirSync(addonsDir, { withFileTypes: true })) {
3376
- if (!scope.name.startsWith("@") || !isDirLike(node_path.join(addonsDir, scope.name))) continue;
3498
+ if (!scope.name.startsWith("@") || !isDirLike$1(node_path.join(addonsDir, scope.name))) continue;
3377
3499
  const scopeDir = node_path.join(addonsDir, scope.name);
3378
3500
  for (const pkg of node_fs.readdirSync(scopeDir, { withFileTypes: true })) {
3379
- if (!isDirLike(node_path.join(scopeDir, pkg.name))) continue;
3501
+ if (!isDirLike$1(node_path.join(scopeDir, pkg.name))) continue;
3380
3502
  const pkgJsonPath = node_path.join(scopeDir, pkg.name, "package.json");
3381
3503
  if (!node_fs.existsSync(pkgJsonPath)) continue;
3382
3504
  let pkgJson;
@@ -3390,6 +3512,7 @@ var AddonManifest = class {
3390
3512
  if (!pkgJson.name || !pkgJson.version) continue;
3391
3513
  const addons = pkgJson.camstack?.addons;
3392
3514
  if (!Array.isArray(addons) || addons.length === 0) continue;
3515
+ if (packageDirName$1(pkgJson.name) !== pkg.name) continue;
3393
3516
  onDisk.set(pkgJson.name, pkgJson.version);
3394
3517
  }
3395
3518
  }
@@ -4907,6 +5030,199 @@ function isDirEntry(entry, parentDir) {
4907
5030
  return false;
4908
5031
  }
4909
5032
  //#endregion
5033
+ //#region src/kernel/addon-residue.ts
5034
+ /**
5035
+ * Residue quarantine for the ONE addon root.
5036
+ *
5037
+ * ── The bug class this closes ──────────────────────────────────────────────
5038
+ * `<dataDir>/addons/@camstack/<pkg>` is the only directory a node loads addons
5039
+ * from. Renaming a package inside it does NOT archive it — the directory name
5040
+ * is decoration, and three independent consumers key on the `package.json`
5041
+ * they find inside:
5042
+ *
5043
+ * - `AddonLoader.loadFromDirectory` walks `@scope/*` and loads anything with
5044
+ * a `package.json`; its skip list is keyed on the PACKAGE name, so
5045
+ * `@camstack/system.bak-pre-unshadow` was never skipped;
5046
+ * - the agent's `resolveAddonPackageDirs` scans on the same rule;
5047
+ * - `AddonManifest.reconcileFromDisk` does `onDisk.set(pkgJson.name, …)`,
5048
+ * so the backup's version becomes the version the manifest — and every
5049
+ * surface reading it — REPORTS.
5050
+ *
5051
+ * Measured 2026-08-07/08 on the live cluster:
5052
+ *
5053
+ * little-unraid `@camstack/system.bak-pre-unshadow` — no package.json left,
5054
+ * so not loadable, but the agent main process held five live
5055
+ * mappings of `better_sqlite3.node` out of it (D87).
5056
+ * macmini `@camstack/system.bak-pre-1272-1786129145` — still declares
5057
+ * `@camstack/system@1.2.3` (17 days old, 97 MB). The data root
5058
+ * has NO `addons/@camstack/system` directory at all, yet the
5059
+ * manifest reports `@camstack/system 1.2.3` — read straight
5060
+ * out of the backup. The live process still had the backup's
5061
+ * `better_sqlite3.node` mapped `txt` against a 1.2.61 closure.
5062
+ *
5063
+ * ── Why quarantine and not delete ─────────────────────────────────────────
5064
+ * The same evening, a cleanup that DELETED what it judged to be a shadow copy
5065
+ * removed the only builtins an agent had and gutted the node. So this never
5066
+ * deletes: it renames out of the scanned root into `.quarantine/`, logs every
5067
+ * move by name, reason and both paths, and leaves the bytes recoverable. A
5068
+ * silent sweep reads as "nothing was here", which is the failure mode that
5069
+ * made the original one unobservable.
5070
+ *
5071
+ * `.quarantine` sits beside `.staging` / `.backups` / `.evicting` under the
5072
+ * addon root: dot-prefixed, carrying no `package.json` at its top level, so no
5073
+ * scanner descends into it.
5074
+ */
5075
+ /** Where residue is moved to, relative to the addon root. */
5076
+ var QUARANTINE_DIRNAME = ".quarantine";
5077
+ /**
5078
+ * A backup marker: a `.bak` / `.old` / `.orig` segment, not merely those
5079
+ * letters. `.backups` — the installer's own rollback store — must NOT match,
5080
+ * and it is also excluded by the dot rule below; the pattern stays tight
5081
+ * anyway so the two guards are independent.
5082
+ */
5083
+ var BACKUP_MARKER = /\.(bak|old|orig)(?![a-z])/i;
5084
+ /** Installer-owned stores and node's own tree. Never candidates. */
5085
+ function isReservedDir(dirName) {
5086
+ return dirName.startsWith(".") || dirName === "node_modules";
5087
+ }
5088
+ /** `@camstack/system` → `system`; `foo` → `foo`. */
5089
+ function packageDirName(declaredName) {
5090
+ const slash = declaredName.lastIndexOf("/");
5091
+ return slash === -1 ? declaredName : declaredName.slice(slash + 1);
5092
+ }
5093
+ /**
5094
+ * Is this directory residue? Returns the verdict, or null to keep it.
5095
+ *
5096
+ * A directory with no `package.json` is NOT residue on that ground alone —
5097
+ * `models/`, a half-finished copy, a scratch dir are all absence, not a
5098
+ * rename. Only a backup marker condemns one.
5099
+ */
5100
+ function classifyAddonDir(candidate) {
5101
+ const { dirName, declaredName } = candidate;
5102
+ if (isReservedDir(dirName)) return null;
5103
+ if (BACKUP_MARKER.test(dirName)) return {
5104
+ dirName,
5105
+ declaredName,
5106
+ reason: "backup-residue"
5107
+ };
5108
+ if (declaredName !== null && packageDirName(declaredName) !== dirName) return {
5109
+ dirName,
5110
+ declaredName,
5111
+ reason: "name-mismatch"
5112
+ };
5113
+ return null;
5114
+ }
5115
+ function selectAddonResidue(candidates) {
5116
+ const out = [];
5117
+ for (const candidate of candidates) {
5118
+ const verdict = classifyAddonDir(candidate);
5119
+ if (verdict !== null) out.push(verdict);
5120
+ }
5121
+ return out;
5122
+ }
5123
+ function readDeclaredName(dir) {
5124
+ try {
5125
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(dir, "package.json"), "utf-8"));
5126
+ if (typeof parsed !== "object" || parsed === null) return null;
5127
+ const name = parsed.name;
5128
+ return typeof name === "string" ? name : null;
5129
+ } catch {
5130
+ return null;
5131
+ }
5132
+ }
5133
+ function readDeclaredVersion(dir) {
5134
+ try {
5135
+ const parsed = JSON.parse(node_fs.readFileSync(node_path.join(dir, "package.json"), "utf-8"));
5136
+ if (typeof parsed !== "object" || parsed === null) return null;
5137
+ const version = parsed.version;
5138
+ return typeof version === "string" ? version : null;
5139
+ } catch {
5140
+ return null;
5141
+ }
5142
+ }
5143
+ function isDirLike(p) {
5144
+ try {
5145
+ return node_fs.statSync(p).isDirectory();
5146
+ } catch {
5147
+ return false;
5148
+ }
5149
+ }
5150
+ /** `<scope>__<dir>`, suffixed until free — two sweeps must not overwrite. */
5151
+ function freeQuarantineTarget(store, scope, dirName) {
5152
+ const base = scope === null ? dirName : `${scope.replace("@", "")}__${dirName}`;
5153
+ let candidate = node_path.join(store, base);
5154
+ let n = 1;
5155
+ while (node_fs.existsSync(candidate)) {
5156
+ candidate = node_path.join(store, `${base}-${n}`);
5157
+ n += 1;
5158
+ }
5159
+ return candidate;
5160
+ }
5161
+ /**
5162
+ * Move every residue directory under `addonsDir` into `addonsDir/.quarantine`.
5163
+ *
5164
+ * Runs at boot, BEFORE anything scans the root or seeds into it — a residue
5165
+ * that is still present when the loader walks has already been installed.
5166
+ * Never throws, never deletes; a move that fails is reported and left in
5167
+ * place, which is strictly better than a half-moved package.
5168
+ */
5169
+ function quarantineAddonResidue(addonsDir, log) {
5170
+ const quarantined = [];
5171
+ const failed = [];
5172
+ if (!node_fs.existsSync(addonsDir)) return {
5173
+ quarantined,
5174
+ failed
5175
+ };
5176
+ const store = node_path.join(addonsDir, QUARANTINE_DIRNAME);
5177
+ const scan = (parent, scope) => {
5178
+ let names;
5179
+ try {
5180
+ names = node_fs.readdirSync(parent);
5181
+ } catch {
5182
+ return;
5183
+ }
5184
+ for (const dirName of names) {
5185
+ const full = node_path.join(parent, dirName);
5186
+ if (!isDirLike(full)) continue;
5187
+ if (scope === null && dirName.startsWith("@")) {
5188
+ scan(full, dirName);
5189
+ continue;
5190
+ }
5191
+ const verdict = classifyAddonDir({
5192
+ dirName,
5193
+ declaredName: readDeclaredName(full)
5194
+ });
5195
+ if (verdict === null) continue;
5196
+ const version = readDeclaredVersion(full);
5197
+ const to = freeQuarantineTarget(store, scope, dirName);
5198
+ try {
5199
+ node_fs.mkdirSync(store, { recursive: true });
5200
+ node_fs.renameSync(full, to);
5201
+ } catch (err) {
5202
+ const error = err instanceof Error ? err.message : String(err);
5203
+ failed.push({
5204
+ ...verdict,
5205
+ from: full,
5206
+ error
5207
+ });
5208
+ log(`[addons] residue quarantine FAILED for ${full} (${verdict.reason}) — ${error}. It stays on the addon root and will be loaded, scanned and reported as an install.`);
5209
+ continue;
5210
+ }
5211
+ quarantined.push({
5212
+ ...verdict,
5213
+ from: full,
5214
+ to
5215
+ });
5216
+ log(`[addons] quarantined ${verdict.reason} ${full} → ${to} (declares ${verdict.declaredName ?? "no package name"}${version === null ? "" : `@${version}`}). A renamed package dir is INSTALLED, not archived — nothing was deleted.`);
5217
+ }
5218
+ };
5219
+ scan(addonsDir, null);
5220
+ return {
5221
+ quarantined,
5222
+ failed
5223
+ };
5224
+ }
5225
+ //#endregion
4910
5226
  //#region src/kernel/workspace-detect.ts
4911
5227
  /**
4912
5228
  * Detect whether we're running inside the monorepo workspace.
@@ -94566,6 +94882,8 @@ exports.FsStorageBackend = FsStorageBackend;
94566
94882
  exports.HEALTH_MONITOR_GRACE_PERIOD_MS = HEALTH_MONITOR_GRACE_PERIOD_MS;
94567
94883
  exports.HEALTH_MONITOR_RETRY_INTERVALS_MS = HEALTH_MONITOR_RETRY_INTERVALS_MS;
94568
94884
  exports.HEALTH_MONITOR_TICK_MS = HEALTH_MONITOR_TICK_MS;
94885
+ exports.HEAP_RECLAIM_MIN_INTERVAL_MS = HEAP_RECLAIM_MIN_INTERVAL_MS;
94886
+ exports.HEAP_RECLAIM_TRIGGER_MB = HEAP_RECLAIM_TRIGGER_MB;
94569
94887
  exports.HEAP_WATCH_INTERVAL_MS = HEAP_WATCH_INTERVAL_MS;
94570
94888
  exports.HEAP_WATCH_WARN_RATIO = HEAP_WATCH_WARN_RATIO;
94571
94889
  exports.HUB_CAP_FWD_ACTION = require_manifest_python_deps.HUB_CAP_FWD_ACTION;
@@ -94604,6 +94922,7 @@ Object.defineProperty(exports, "PYTHON_VERSION", {
94604
94922
  exports.PipelineRunner = PipelineRunner;
94605
94923
  exports.PipelineValidator = PipelineValidator;
94606
94924
  exports.PythonEnvManager = PythonEnvManager;
94925
+ exports.QUARANTINE_DIRNAME = QUARANTINE_DIRNAME;
94607
94926
  exports.RESTART_MARKER_FILE = RESTART_MARKER_FILE;
94608
94927
  exports.RUNTIME_DEFAULTS = require_dist.RUNTIME_DEFAULTS;
94609
94928
  exports.ReadinessRegistry = require_dist.ReadinessRegistry;
@@ -94656,6 +94975,7 @@ exports.capActionName = require_manifest_python_deps.capActionName;
94656
94975
  exports.capActionSuffix = require_manifest_python_deps.capActionSuffix;
94657
94976
  exports.capBareAction = require_manifest_python_deps.capBareAction;
94658
94977
  exports.capServiceName = require_manifest_python_deps.capServiceName;
94978
+ exports.classifyAddonDir = classifyAddonDir;
94659
94979
  exports.classifyCapRoute = require_manifest_python_deps.classifyCapRoute;
94660
94980
  exports.clearPendingRestart = clearPendingRestart;
94661
94981
  exports.clusterEventTopic = require_manifest_python_deps.clusterEventTopic;
@@ -94687,6 +95007,7 @@ exports.createUdsEventBridge = require_manifest_python_deps.createUdsEventBridge
94687
95007
  exports.createUdsEventBus = require_manifest_python_deps.createUdsEventBus;
94688
95008
  exports.createUdsLogger = require_manifest_python_deps.createUdsLogger;
94689
95009
  exports.createUdsLoggerWithControl = require_manifest_python_deps.createUdsLoggerWithControl;
95010
+ exports.createV8Reclaimer = createV8Reclaimer;
94690
95011
  exports.deleteModelFromDisk = require_model_download_service.deleteModelFromDisk;
94691
95012
  exports.deriveAgentListenPort = deriveAgentListenPort;
94692
95013
  exports.describeProviderKindDrift = describeProviderKindDrift;
@@ -94796,6 +95117,7 @@ exports.parseCapAction = require_manifest_python_deps.parseCapAction;
94796
95117
  exports.parseRangeHeader = require_model_download_service.parseRangeHeader;
94797
95118
  exports.parseTokenizedUrl = require_model_download_service.parseTokenizedUrl;
94798
95119
  exports.proxyToUpstream = proxyToUpstream;
95120
+ exports.quarantineAddonResidue = quarantineAddonResidue;
94799
95121
  exports.readPendingRestart = readPendingRestart;
94800
95122
  exports.readinessKey = require_dist.readinessKey;
94801
95123
  exports.registerEventBusService = require_manifest_python_deps.registerEventBusService;
@@ -94806,10 +95128,13 @@ exports.runNpm = require_manifest_python_deps.runNpm;
94806
95128
  exports.scheduleSelfRestart = scheduleSelfRestart;
94807
95129
  exports.scopeKey = require_dist.scopeKey;
94808
95130
  exports.scopesAllowDeviceCap = scopesAllowDeviceCap;
95131
+ exports.selectAddonResidue = selectAddonResidue;
94809
95132
  exports.serializeTypedArrays = require_manifest_python_deps.serializeTypedArrays;
94810
95133
  exports.setHubConnected = require_manifest_python_deps.setHubConnected;
94811
95134
  exports.setNodeEventInterest = require_manifest_python_deps.setNodeEventInterest;
95135
+ exports.shouldReclaim = shouldReclaim;
94812
95136
  exports.startHeapWatch = startHeapWatch;
95137
+ exports.strandedMb = strandedMb;
94813
95138
  exports.stripCamstackDeps = stripCamstackDeps;
94814
95139
  exports.subscribePassthrough = require_manifest_python_deps.subscribePassthrough;
94815
95140
  exports.udsChildLogToWorkerEntry = require_manifest_python_deps.udsChildLogToWorkerEntry;