@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.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
2
- import { $ as procedureAuthKey, Ct as asJsonObject$1, Dt as emitDownForOwnedCaps, Et as createEvent, Ft as readinessKey, H as kebabToCamel, It as resolveCapMount, K as logLevelAtMost, L as isArrayOutputSchema, Lt as scopeKey, Nt as parseJsonObject, P as extractNestedAddonId, Pt as parseJsonUnknown$1, R as isCollectionArrayMethod, Tt as asString$1, U as lifecycleJobSchema, V as isVoidInput, X as objectInputDeclaresAddonId, bt as ReadinessRegistry, c as METHOD_ACCESS_MAP, ft as errMsg$1, gt as DEVICE_STATUS_METHOD, h as addonSettingsCapability, ht as DEVICE_SETTINGS_CONTRIBUTION_METHODS, kt as expandCapMethods, l as RUNTIME_DEFAULTS, mt as DATAPLANE_SECRET_HEADER$1, q as looseSchema, t as ALL_CAPABILITY_DEFINITIONS, wt as asNumber, xt as ReadinessTimeoutError, z as isObjectInput, zt as EventCategory$1 } from "./dist-C5XpOjbI.mjs";
2
+ import { $ as procedureAuthKey, Ct as asJsonObject$1, Dt as emitDownForOwnedCaps, Et as createEvent, Ft as readinessKey, H as kebabToCamel, It as resolveCapMount, K as logLevelAtMost, L as isArrayOutputSchema, Lt as scopeKey, Nt as parseJsonObject, P as extractNestedAddonId, Pt as parseJsonUnknown$1, R as isCollectionArrayMethod, Tt as asString$1, U as lifecycleJobSchema, V as isVoidInput, X as objectInputDeclaresAddonId, bt as ReadinessRegistry, c as METHOD_ACCESS_MAP, ft as errMsg$1, gt as DEVICE_STATUS_METHOD, h as addonSettingsCapability, ht as DEVICE_SETTINGS_CONTRIBUTION_METHODS, kt as expandCapMethods, l as RUNTIME_DEFAULTS, mt as DATAPLANE_SECRET_HEADER$1, q as looseSchema, t as ALL_CAPABILITY_DEFINITIONS, wt as asNumber, xt as ReadinessTimeoutError, z as isObjectInput, zt as EventCategory$1 } from "./dist-DnKpFaP1.mjs";
3
3
  import { a as downloadModel, c as getModelFilePath, d as contentTypeFor, f as createAuthenticatedFileServer, h as resolveFilePath, i as downloadFile, l as isModelDownloaded, m as parseTokenizedUrl, n as collectModelFiles, o as ensureModel, p as parseRangeHeader, r as deleteModelFromDisk, s as fetchJson, t as ModelDownloadService, u as createFileDataPlaneHandler } from "./model-download-service-D8B-4ktF.mjs";
4
4
  import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, Ct as resolveAddonClass, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, St as runNpm, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as createKernelHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as installManifestNativeDeps, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getBrokerEventBus, dt as capBareAction, et as buildUdsNativeCapProxy, f as getMoleculerEventStats, ft as capServiceName, g as AddonDepsManager, gt as DeviceRegistry, h as subscribePassthrough, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as setNodeEventInterest, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as registerEventBusService, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as clusterEventTopic, ut as capActionSuffix, v as resolveHwAccel, vt as CapabilityUnavailableError, w as buildLinkChain, wt as createAddonDataPlaneFacility, x as getCapUsageRegistry, xt as resolveNpmInvocation, y as CapUsageRegistry, yt as copyBundledNativeModules, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-CX5tGRqf.mjs";
5
5
  import { n as getSinglePidStats, t as getPidStats } from "./resource-monitor-BkP504Vq.mjs";
@@ -28,6 +28,7 @@ import { DeviceManagerAddon } from "./builtins/device-manager/device-manager.add
28
28
  import "./builtins/device-manager/index.mjs";
29
29
  import { t as CustomActionRegistry } from "./custom-action-registry-F__gp_VX.mjs";
30
30
  import * as v8 from "node:v8";
31
+ import * as vm from "node:vm";
31
32
  import { PYTHON_VERSION, buildBinaryPath, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements } from "@camstack/types/node";
32
33
  import { z } from "zod";
33
34
  import { X509Certificate, createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -40,7 +41,6 @@ import { asJsonObject, errMsg, parseJsonUnknown } from "@camstack/types/addon";
40
41
  import { execFile, spawn } from "node:child_process";
41
42
  import * as util$10 from "node:util";
42
43
  import { promisify } from "node:util";
43
- import * as vm from "node:vm";
44
44
  import * as os$18 from "node:os";
45
45
  import { lstat, readdir, readlink, realpath } from "node:fs/promises";
46
46
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -52,13 +52,15 @@ import "path";
52
52
  import { createRequire } from "module";
53
53
  //#region src/kernel/heap-watch.ts
54
54
  /**
55
- * heap-watch — hub-main's own memory, on the record.
55
+ * heap-watch — hub-main's own memory, on the record, and returned to the OS.
56
56
  *
57
57
  * ## Why this exists
58
58
  *
59
- * hub-main is launched with `--max-old-space-size=1536` (docker-entrypoint) and
60
- * has logged NOTHING about its own heap. When it died on 2026-08-01/02 the only
61
- * trace was V8's own stderr line:
59
+ * hub-main used to be launched with `--max-old-space-size=1536` (docker-entrypoint;
60
+ * the cap was removed on 2026-08-02, so the live ceiling is now V8's own default —
61
+ * 4288MB in this container and this module reads it at runtime rather than
62
+ * assuming a number). It logged NOTHING about its own heap. When it died on
63
+ * 2026-08-01/02 the only trace was V8's own stderr line:
62
64
  *
63
65
  * FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
64
66
  *
@@ -86,6 +88,41 @@ import { createRequire } from "module";
86
88
  * `--max-old-space-size` and are most of the gap between a 1.5GB cap and the
87
89
  * ~2.05GB RSS observed live: a diagnosis that only looks at the JS heap
88
90
  * misreads that process by 500MB.
91
+ *
92
+ * ## And then the heartbeat explained the "off-heap leak", which was not one
93
+ *
94
+ * 2026-08-08, measured on the live hub over 1019 consecutive samples and seven
95
+ * separate boots: **hub-main's RSS is a HIGH-WATER MARK, not a leak.** In every
96
+ * one of those 1019 samples RSS was >= the running maximum of
97
+ * `heapTotal + external`, never once below it, and
98
+ * `RSS_peak - peak(heapTotal + external)` landed between 874MB and 1639MB on
99
+ * every boot — a roughly constant native floor on top of a number that only
100
+ * ever goes up. RSS tracked the highest point the process ever reached and
101
+ * stayed there: 3.3GB -> 4.5GB -> 5.2GB, and 7.8GB on the worst boot.
102
+ *
103
+ * Two things push that peak, and neither is a retained object:
104
+ *
105
+ * - the JS heap sawtooths to 2.0-2.8GB because the ceiling is 4288MB and
106
+ * nothing creates pressure before then;
107
+ * - `arrayBuffers` accumulates DEAD Buffers — up to 3.65GB — because V8 only
108
+ * sweeps them on a major GC. Eleven collapses were captured, and every
109
+ * single one coincides with `heapUsed` dropping to its ~650MB floor. No code
110
+ * released a reference at 13:23:03; a mark-compact ran.
111
+ *
112
+ * At the peak V8 has committed the pages, and **an ordinary major GC does not
113
+ * give them back.** Proven on the production hub: `smaps` showed 10,819 mapped
114
+ * 256KB pages (V8's `kRegularPageSize`) holding 2704MB while V8 itself reported
115
+ * `total_physical_size` of only 846MB — ~7,800 pages committed, resident, and
116
+ * outside V8's own accounting. One `gc({ flavor: 'last-resort' })` took 417ms
117
+ * and returned RSS 3355MB -> 1237MB, pages 10,819 -> 2,963.
118
+ *
119
+ * So the fix for the "~3GB/2h off-heap growth" is not a retainer hunt. It is to
120
+ * ask V8 for the pages back when the process is visibly holding more than it is
121
+ * using — which is what `shouldReclaim` decides and what the heartbeat now does.
122
+ *
123
+ * The cost is a 250-450ms stop-the-world on the hub's router process, bounded to
124
+ * once per `HEAP_RECLAIM_MIN_INTERVAL_MS`. The alternative, measured, is a
125
+ * ratchet to 7.8GB and the OOM that ends it.
89
126
  */
90
127
  /** Heartbeat period. Slow enough to be free, fast enough that the sample before
91
128
  * a fatal is recent. */
@@ -93,6 +130,20 @@ var HEAP_WATCH_INTERVAL_MS = 6e4;
93
130
  /** Warn above this share of the heap ceiling. Chosen so the warning precedes
94
131
  * the fatal by more than one interval under realistic growth. */
95
132
  var HEAP_WATCH_WARN_RATIO = .8;
133
+ /**
134
+ * Reclaim once the process holds this many MB beyond what it is USING.
135
+ *
136
+ * Live calibration, both readings from the same hub minutes apart: 2491MB
137
+ * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
138
+ * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
139
+ * collecting against it would buy nothing and cost a stall every minute. 1536
140
+ * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
141
+ * anything, so it triggers on the real condition and stops as soon as it is fixed.
142
+ */
143
+ var HEAP_RECLAIM_TRIGGER_MB = 1536;
144
+ /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
145
+ * minutes keeps the worst case under 0.1% of the router's time. */
146
+ var HEAP_RECLAIM_MIN_INTERVAL_MS = 6e5;
96
147
  var MB = (bytes) => Math.round(bytes / 1048576);
97
148
  /**
98
149
  * Build a sample from raw readings. Pure so the thresholds are testable without
@@ -115,6 +166,47 @@ var consoleSink = {
115
166
  info: (line) => console.log(line),
116
167
  warn: (line) => console.warn(line)
117
168
  };
169
+ /**
170
+ * RSS the process is holding but not using.
171
+ *
172
+ * The subtrahend is heapUSED, not heapTotal, deliberately: the pages V8
173
+ * committed at a sawtooth peak and no longer needs are counted inside heapTotal,
174
+ * and they are precisely what this number exists to see.
175
+ */
176
+ function strandedMb(s) {
177
+ return s.rssMb - s.heapUsedMb - s.externalMb;
178
+ }
179
+ /** Whether it is worth paying a stop-the-world to get those pages back. */
180
+ function shouldReclaim(s, triggerMb = HEAP_RECLAIM_TRIGGER_MB) {
181
+ return strandedMb(s) > triggerMb;
182
+ }
183
+ function isGcFunction(value) {
184
+ return typeof value === "function";
185
+ }
186
+ /**
187
+ * Build the reclaimer without requiring `--expose-gc` on argv.
188
+ *
189
+ * The flag is turned on, the function captured, and the flag turned straight
190
+ * back off — a global `gc` left reachable by application code is a footgun, and
191
+ * requiring an argv change would mean this cannot ship without a new image.
192
+ * Verified against the live hub before being written: 266ms, RSS 1453 -> 1237.
193
+ *
194
+ * Returns undefined if V8 declines, so the caller keeps a heartbeat either way.
195
+ */
196
+ function createV8Reclaimer() {
197
+ try {
198
+ v8.setFlagsFromString("--expose-gc");
199
+ const gc = vm.runInNewContext("gc");
200
+ v8.setFlagsFromString("--no-expose-gc");
201
+ if (!isGcFunction(gc)) return void 0;
202
+ return () => gc({
203
+ execution: "sync",
204
+ flavor: "last-resort"
205
+ });
206
+ } catch {
207
+ return;
208
+ }
209
+ }
118
210
  function format$1(label, s) {
119
211
  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`;
120
212
  }
@@ -124,13 +216,38 @@ function format$1(label, s) {
124
216
  * `unref`ed so it can never hold the process open, and every read is wrapped:
125
217
  * a diagnostic that can crash the process it watches is worse than none.
126
218
  */
127
- function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS) {
219
+ function startHeapWatch(label = "hub-main", sink = consoleSink, intervalMs = HEAP_WATCH_INTERVAL_MS, reclaimOptions) {
220
+ const readMemory = reclaimOptions?.readMemory ?? (() => process.memoryUsage());
221
+ const now = reclaimOptions?.now ?? (() => Date.now());
222
+ const triggerMb = reclaimOptions?.triggerMb ?? 1536;
223
+ const minIntervalMs = reclaimOptions?.minIntervalMs ?? 6e5;
224
+ let lastReclaimAt = Number.NEGATIVE_INFINITY;
225
+ const read = () => {
226
+ const limit = reclaimOptions?.heapLimitBytes ?? v8.getHeapStatistics().heap_size_limit;
227
+ return buildHeapSample(readMemory(), limit);
228
+ };
229
+ const maybeReclaim = (sample) => {
230
+ if (reclaimOptions === void 0) return;
231
+ if (!shouldReclaim(sample, triggerMb)) return;
232
+ if (now() - lastReclaimAt < minIntervalMs) return;
233
+ lastReclaimAt = now();
234
+ const startedAt = now();
235
+ try {
236
+ reclaimOptions.reclaim();
237
+ } catch (error) {
238
+ sink.warn(`[mem] reclaim failed — ${error instanceof Error ? error.message : String(error)}`);
239
+ return;
240
+ }
241
+ const after = read();
242
+ 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`);
243
+ };
128
244
  const tick = () => {
129
245
  try {
130
- const sample = buildHeapSample(process.memoryUsage(), v8.getHeapStatistics().heap_size_limit);
246
+ const sample = read();
131
247
  const line = format$1(label, sample);
132
248
  if (sample.nearLimit) sink.warn(`${line} — APPROACHING HEAP LIMIT`);
133
249
  else sink.info(line);
250
+ maybeReclaim(sample);
134
251
  } catch {}
135
252
  };
136
253
  const timer = setInterval(tick, intervalMs);
@@ -3231,7 +3348,12 @@ var MANIFEST_VERSION = 1;
3231
3348
  * workspace source. Trusting the dirent there finds zero addons on disk, which
3232
3349
  * with pruning would empty a developer's manifest on the next boot.
3233
3350
  */
3234
- function isDirLike(p) {
3351
+ /** `@camstack/system` → `system`: the directory a package must live in. */
3352
+ function packageDirName$1(declaredName) {
3353
+ const slash = declaredName.lastIndexOf("/");
3354
+ return slash === -1 ? declaredName : declaredName.slice(slash + 1);
3355
+ }
3356
+ function isDirLike$1(p) {
3235
3357
  try {
3236
3358
  return fs$17.statSync(p).isDirectory();
3237
3359
  } catch {
@@ -3365,10 +3487,10 @@ var AddonManifest = class {
3365
3487
  /** Package name → version, for every ADDON package actually installed. */
3366
3488
  const onDisk = /* @__PURE__ */ new Map();
3367
3489
  for (const scope of fs$17.readdirSync(addonsDir, { withFileTypes: true })) {
3368
- if (!scope.name.startsWith("@") || !isDirLike(path$39.join(addonsDir, scope.name))) continue;
3490
+ if (!scope.name.startsWith("@") || !isDirLike$1(path$39.join(addonsDir, scope.name))) continue;
3369
3491
  const scopeDir = path$39.join(addonsDir, scope.name);
3370
3492
  for (const pkg of fs$17.readdirSync(scopeDir, { withFileTypes: true })) {
3371
- if (!isDirLike(path$39.join(scopeDir, pkg.name))) continue;
3493
+ if (!isDirLike$1(path$39.join(scopeDir, pkg.name))) continue;
3372
3494
  const pkgJsonPath = path$39.join(scopeDir, pkg.name, "package.json");
3373
3495
  if (!fs$17.existsSync(pkgJsonPath)) continue;
3374
3496
  let pkgJson;
@@ -3382,6 +3504,7 @@ var AddonManifest = class {
3382
3504
  if (!pkgJson.name || !pkgJson.version) continue;
3383
3505
  const addons = pkgJson.camstack?.addons;
3384
3506
  if (!Array.isArray(addons) || addons.length === 0) continue;
3507
+ if (packageDirName$1(pkgJson.name) !== pkg.name) continue;
3385
3508
  onDisk.set(pkgJson.name, pkgJson.version);
3386
3509
  }
3387
3510
  }
@@ -4899,6 +5022,199 @@ function isDirEntry(entry, parentDir) {
4899
5022
  return false;
4900
5023
  }
4901
5024
  //#endregion
5025
+ //#region src/kernel/addon-residue.ts
5026
+ /**
5027
+ * Residue quarantine for the ONE addon root.
5028
+ *
5029
+ * ── The bug class this closes ──────────────────────────────────────────────
5030
+ * `<dataDir>/addons/@camstack/<pkg>` is the only directory a node loads addons
5031
+ * from. Renaming a package inside it does NOT archive it — the directory name
5032
+ * is decoration, and three independent consumers key on the `package.json`
5033
+ * they find inside:
5034
+ *
5035
+ * - `AddonLoader.loadFromDirectory` walks `@scope/*` and loads anything with
5036
+ * a `package.json`; its skip list is keyed on the PACKAGE name, so
5037
+ * `@camstack/system.bak-pre-unshadow` was never skipped;
5038
+ * - the agent's `resolveAddonPackageDirs` scans on the same rule;
5039
+ * - `AddonManifest.reconcileFromDisk` does `onDisk.set(pkgJson.name, …)`,
5040
+ * so the backup's version becomes the version the manifest — and every
5041
+ * surface reading it — REPORTS.
5042
+ *
5043
+ * Measured 2026-08-07/08 on the live cluster:
5044
+ *
5045
+ * little-unraid `@camstack/system.bak-pre-unshadow` — no package.json left,
5046
+ * so not loadable, but the agent main process held five live
5047
+ * mappings of `better_sqlite3.node` out of it (D87).
5048
+ * macmini `@camstack/system.bak-pre-1272-1786129145` — still declares
5049
+ * `@camstack/system@1.2.3` (17 days old, 97 MB). The data root
5050
+ * has NO `addons/@camstack/system` directory at all, yet the
5051
+ * manifest reports `@camstack/system 1.2.3` — read straight
5052
+ * out of the backup. The live process still had the backup's
5053
+ * `better_sqlite3.node` mapped `txt` against a 1.2.61 closure.
5054
+ *
5055
+ * ── Why quarantine and not delete ─────────────────────────────────────────
5056
+ * The same evening, a cleanup that DELETED what it judged to be a shadow copy
5057
+ * removed the only builtins an agent had and gutted the node. So this never
5058
+ * deletes: it renames out of the scanned root into `.quarantine/`, logs every
5059
+ * move by name, reason and both paths, and leaves the bytes recoverable. A
5060
+ * silent sweep reads as "nothing was here", which is the failure mode that
5061
+ * made the original one unobservable.
5062
+ *
5063
+ * `.quarantine` sits beside `.staging` / `.backups` / `.evicting` under the
5064
+ * addon root: dot-prefixed, carrying no `package.json` at its top level, so no
5065
+ * scanner descends into it.
5066
+ */
5067
+ /** Where residue is moved to, relative to the addon root. */
5068
+ var QUARANTINE_DIRNAME = ".quarantine";
5069
+ /**
5070
+ * A backup marker: a `.bak` / `.old` / `.orig` segment, not merely those
5071
+ * letters. `.backups` — the installer's own rollback store — must NOT match,
5072
+ * and it is also excluded by the dot rule below; the pattern stays tight
5073
+ * anyway so the two guards are independent.
5074
+ */
5075
+ var BACKUP_MARKER = /\.(bak|old|orig)(?![a-z])/i;
5076
+ /** Installer-owned stores and node's own tree. Never candidates. */
5077
+ function isReservedDir(dirName) {
5078
+ return dirName.startsWith(".") || dirName === "node_modules";
5079
+ }
5080
+ /** `@camstack/system` → `system`; `foo` → `foo`. */
5081
+ function packageDirName(declaredName) {
5082
+ const slash = declaredName.lastIndexOf("/");
5083
+ return slash === -1 ? declaredName : declaredName.slice(slash + 1);
5084
+ }
5085
+ /**
5086
+ * Is this directory residue? Returns the verdict, or null to keep it.
5087
+ *
5088
+ * A directory with no `package.json` is NOT residue on that ground alone —
5089
+ * `models/`, a half-finished copy, a scratch dir are all absence, not a
5090
+ * rename. Only a backup marker condemns one.
5091
+ */
5092
+ function classifyAddonDir(candidate) {
5093
+ const { dirName, declaredName } = candidate;
5094
+ if (isReservedDir(dirName)) return null;
5095
+ if (BACKUP_MARKER.test(dirName)) return {
5096
+ dirName,
5097
+ declaredName,
5098
+ reason: "backup-residue"
5099
+ };
5100
+ if (declaredName !== null && packageDirName(declaredName) !== dirName) return {
5101
+ dirName,
5102
+ declaredName,
5103
+ reason: "name-mismatch"
5104
+ };
5105
+ return null;
5106
+ }
5107
+ function selectAddonResidue(candidates) {
5108
+ const out = [];
5109
+ for (const candidate of candidates) {
5110
+ const verdict = classifyAddonDir(candidate);
5111
+ if (verdict !== null) out.push(verdict);
5112
+ }
5113
+ return out;
5114
+ }
5115
+ function readDeclaredName(dir) {
5116
+ try {
5117
+ const parsed = JSON.parse(fs$17.readFileSync(path$39.join(dir, "package.json"), "utf-8"));
5118
+ if (typeof parsed !== "object" || parsed === null) return null;
5119
+ const name = parsed.name;
5120
+ return typeof name === "string" ? name : null;
5121
+ } catch {
5122
+ return null;
5123
+ }
5124
+ }
5125
+ function readDeclaredVersion(dir) {
5126
+ try {
5127
+ const parsed = JSON.parse(fs$17.readFileSync(path$39.join(dir, "package.json"), "utf-8"));
5128
+ if (typeof parsed !== "object" || parsed === null) return null;
5129
+ const version = parsed.version;
5130
+ return typeof version === "string" ? version : null;
5131
+ } catch {
5132
+ return null;
5133
+ }
5134
+ }
5135
+ function isDirLike(p) {
5136
+ try {
5137
+ return fs$17.statSync(p).isDirectory();
5138
+ } catch {
5139
+ return false;
5140
+ }
5141
+ }
5142
+ /** `<scope>__<dir>`, suffixed until free — two sweeps must not overwrite. */
5143
+ function freeQuarantineTarget(store, scope, dirName) {
5144
+ const base = scope === null ? dirName : `${scope.replace("@", "")}__${dirName}`;
5145
+ let candidate = path$39.join(store, base);
5146
+ let n = 1;
5147
+ while (fs$17.existsSync(candidate)) {
5148
+ candidate = path$39.join(store, `${base}-${n}`);
5149
+ n += 1;
5150
+ }
5151
+ return candidate;
5152
+ }
5153
+ /**
5154
+ * Move every residue directory under `addonsDir` into `addonsDir/.quarantine`.
5155
+ *
5156
+ * Runs at boot, BEFORE anything scans the root or seeds into it — a residue
5157
+ * that is still present when the loader walks has already been installed.
5158
+ * Never throws, never deletes; a move that fails is reported and left in
5159
+ * place, which is strictly better than a half-moved package.
5160
+ */
5161
+ function quarantineAddonResidue(addonsDir, log) {
5162
+ const quarantined = [];
5163
+ const failed = [];
5164
+ if (!fs$17.existsSync(addonsDir)) return {
5165
+ quarantined,
5166
+ failed
5167
+ };
5168
+ const store = path$39.join(addonsDir, QUARANTINE_DIRNAME);
5169
+ const scan = (parent, scope) => {
5170
+ let names;
5171
+ try {
5172
+ names = fs$17.readdirSync(parent);
5173
+ } catch {
5174
+ return;
5175
+ }
5176
+ for (const dirName of names) {
5177
+ const full = path$39.join(parent, dirName);
5178
+ if (!isDirLike(full)) continue;
5179
+ if (scope === null && dirName.startsWith("@")) {
5180
+ scan(full, dirName);
5181
+ continue;
5182
+ }
5183
+ const verdict = classifyAddonDir({
5184
+ dirName,
5185
+ declaredName: readDeclaredName(full)
5186
+ });
5187
+ if (verdict === null) continue;
5188
+ const version = readDeclaredVersion(full);
5189
+ const to = freeQuarantineTarget(store, scope, dirName);
5190
+ try {
5191
+ fs$17.mkdirSync(store, { recursive: true });
5192
+ fs$17.renameSync(full, to);
5193
+ } catch (err) {
5194
+ const error = err instanceof Error ? err.message : String(err);
5195
+ failed.push({
5196
+ ...verdict,
5197
+ from: full,
5198
+ error
5199
+ });
5200
+ 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.`);
5201
+ continue;
5202
+ }
5203
+ quarantined.push({
5204
+ ...verdict,
5205
+ from: full,
5206
+ to
5207
+ });
5208
+ 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.`);
5209
+ }
5210
+ };
5211
+ scan(addonsDir, null);
5212
+ return {
5213
+ quarantined,
5214
+ failed
5215
+ };
5216
+ }
5217
+ //#endregion
4902
5218
  //#region src/kernel/workspace-detect.ts
4903
5219
  /**
4904
5220
  * Detect whether we're running inside the monorepo workspace.
@@ -94510,4 +94826,4 @@ var LifecycleJobEngine = class {
94510
94826
  }
94511
94827
  };
94512
94828
  //#endregion
94513
- export { AGENT_CAP_FWD_ACTION, AGENT_CAP_FWD_SERVICE, AGENT_READINESS_SERVICE_NAME, ALL_CAPABILITY_DEFINITIONS, AddonApiFactory, AddonDepsManager, AddonEngineManager, AddonHealthMonitor, AddonInstaller, AddonLoader, AddonManifest, AddonRouteRegistry, AlertCenterAddon, ApiKeyManager, AuthManager, CLUSTER_SECRET_MISMATCH_TYPE, CLUSTER_SECRET_REJECTED_EXIT_CODE, CORE_CAP_SERVICE_NAME, CapRouteError, CapRouteResolver, CapUsageRegistry, CapabilityHandle, CapabilityRegistry, CapabilityUnavailableError, ConfigManager, ConfigStore, ConsoleDestination, ConsoleLoggingAddon, CoreBlocksAddon, CustomActionRegistry, DEFAULT_DATA_PATH, DEVICE_SETTINGS_CONTRIBUTION_METHODS, DEVICE_STATUS_METHOD, DataPlaneRegistry, DeviceManagerAddon, DeviceRegistry, DeviceStore, EVENT_TOPIC_PREFIX, EngineManagerResolver, EventBus, FeatureManager, FilesystemStorageAddon, FilesystemStorageProvider, FrameDecoder, FsStorageBackend, HEALTH_MONITOR_GRACE_PERIOD_MS, HEALTH_MONITOR_RETRY_INTERVALS_MS, HEALTH_MONITOR_TICK_MS, HEAP_WATCH_INTERVAL_MS, HEAP_WATCH_WARN_RATIO, HUB_CAP_FWD_ACTION, HUB_CAP_FWD_SERVICE, HubForwarderAddon, HubForwarderDestination, HubLogForwarder, HubNodeRegistry, INFRA_CAPABILITIES, IntegrationRegistry, JobJournal, LifecycleJobEngine, LifecycleStateMachine, LivenessMonitorAddon, LocalAuthAddon, LocalChildClient, LocalChildRegistry, LogManager, LogRingBuffer, LokiDestination, LokiLoggingAddon, METHOD_ACCESS_MAP, ModelDownloadService, NATIVE_PROVIDER_SERVICE_INFIX, NATIVE_SCAN_DEPTH, NativeMetricsAddon, NativeMetricsProvider, NetworkQualityTracker, NotificationService, PYTHON_VERSION, PipelineRunner, PipelineValidator, PythonEnvManager, RESTART_MARKER_FILE, RUNTIME_DEFAULTS, ReadinessRegistry, ReadinessTimeoutError, ReplEngine, RingBuffer, ScopedLogger, ScopedTokenManager, SocketChannel, SqliteSettingsAddon, SqliteSettingsBackend, StagingArea, StorageLocationManager, StorageManager, StorageOrchestratorAddon, StorageOrchestratorService, SystemConfigAddon, SystemEventBus, TRADITIONAL_NATIVE_PACKAGES, ToastService, UDS_NO_ROUTE_PREFIX, UdsLocalTransportClient, UdsLocalTransportServer, UserManager, WinstonDestination, WinstonLoggingAddon, __resetCapUsageRegistryForTests, adaptBrokerToCluster, addonSettingsCapability, bootstrapSchema, brokerCallForCap, brokerTransportLink, buildBinaryPath, buildCapRouters, buildHeapSample, buildLinkChain, buildNativeCapProxy, buildNodeManifest, buildStorageLocationRegistry, buildUdsNativeCapProxy, builderMountedCapNames, callRegisterNodeWithRetry, callWithServiceDiscovery, capActionName, capActionSuffix, capBareAction, capServiceName, classifyCapRoute, clearPendingRestart, clusterEventTopic, clusterSecretMatches, collectModelFiles, contentTypeFor, copyDirRecursive, copyExtraFileDirs, createAddonContext, createAddonDataPlaneFacility, createAddonService, createAuthenticatedFileServer, createBroker, createBrokerDeviceManagerApi, createCoreCapService, createFileDataPlaneHandler, createHubCapForwardService, createHubService, createKernelHwAccel, createLocalTransport, createParentUnownedCallHandler, createProcessService, createReadinessService, createReadinessServiceForRegistry, createScopedProcessManager, createStreamProbeBrokerService, createUdsAddonContext, createUdsEventBridge, createUdsEventBus, createUdsLogger, createUdsLoggerWithControl, deleteModelFromDisk, deriveAgentListenPort, describeProviderKindDrift, detectWorkspacePackagesDir, downloadBinary, downloadFile, downloadModel, emitDownForOwnedCaps, encodeFrame, ensureAddonNativePrebuilds, ensureBinary, ensureDir, ensureFfmpeg, ensureLibraryBuilt, ensureModel, ensureNativePrebuilds, ensurePython, ensureTlsCert, expandCapMethods, fetchJson, findInPath, formatLogLine, getBrokerEventBus, getCapUsageRegistry, getFfmpegDownloadUrl, getModelFilePath, getMoleculerEventStats, getOrInitReadinessRegistry, getOrInitReadinessRegistryForClient, getPidStats, getPlatformInfo, getPythonDownloadUrl, getRestartMarkerPath, getSinglePidStats, getWorkerDeviceRegistry, hasDotNode, hashClusterSecret, installManifestNativeDeps, installManifestPythonDeps, installPackageFromNpm, installPythonPackages, installPythonRequirements, ipcChildLink, ipcParentLink, isAddonDeploySource, isArrayOutputSchema, isClusterSecretMismatchError, isCollectionArrayMethod, isInfraCapability, isModelDownloaded, isSourceNewer, loadTlsCert, localEndpointPath, localProviderLink, mountNativeCapService, parseCapAction, parseRangeHeader, parseTokenizedUrl, proxyToUpstream, readPendingRestart, readinessKey, registerEventBusService, resolveFilePath, resolveHwAccel, resolveNpmInvocation, runNpm, scheduleSelfRestart, scopeKey, scopesAllowDeviceCap, serializeTypedArrays, setHubConnected, setNodeEventInterest, startHeapWatch, stripCamstackDeps, subscribePassthrough, udsChildLogToWorkerEntry, validateProviderRegistrations, writePendingRestart };
94829
+ export { AGENT_CAP_FWD_ACTION, AGENT_CAP_FWD_SERVICE, AGENT_READINESS_SERVICE_NAME, ALL_CAPABILITY_DEFINITIONS, AddonApiFactory, AddonDepsManager, AddonEngineManager, AddonHealthMonitor, AddonInstaller, AddonLoader, AddonManifest, AddonRouteRegistry, AlertCenterAddon, ApiKeyManager, AuthManager, CLUSTER_SECRET_MISMATCH_TYPE, CLUSTER_SECRET_REJECTED_EXIT_CODE, CORE_CAP_SERVICE_NAME, CapRouteError, CapRouteResolver, CapUsageRegistry, CapabilityHandle, CapabilityRegistry, CapabilityUnavailableError, ConfigManager, ConfigStore, ConsoleDestination, ConsoleLoggingAddon, CoreBlocksAddon, CustomActionRegistry, DEFAULT_DATA_PATH, DEVICE_SETTINGS_CONTRIBUTION_METHODS, DEVICE_STATUS_METHOD, DataPlaneRegistry, DeviceManagerAddon, DeviceRegistry, DeviceStore, EVENT_TOPIC_PREFIX, EngineManagerResolver, EventBus, FeatureManager, FilesystemStorageAddon, FilesystemStorageProvider, FrameDecoder, FsStorageBackend, HEALTH_MONITOR_GRACE_PERIOD_MS, HEALTH_MONITOR_RETRY_INTERVALS_MS, HEALTH_MONITOR_TICK_MS, HEAP_RECLAIM_MIN_INTERVAL_MS, HEAP_RECLAIM_TRIGGER_MB, HEAP_WATCH_INTERVAL_MS, HEAP_WATCH_WARN_RATIO, HUB_CAP_FWD_ACTION, HUB_CAP_FWD_SERVICE, HubForwarderAddon, HubForwarderDestination, HubLogForwarder, HubNodeRegistry, INFRA_CAPABILITIES, IntegrationRegistry, JobJournal, LifecycleJobEngine, LifecycleStateMachine, LivenessMonitorAddon, LocalAuthAddon, LocalChildClient, LocalChildRegistry, LogManager, LogRingBuffer, LokiDestination, LokiLoggingAddon, METHOD_ACCESS_MAP, ModelDownloadService, NATIVE_PROVIDER_SERVICE_INFIX, NATIVE_SCAN_DEPTH, NativeMetricsAddon, NativeMetricsProvider, NetworkQualityTracker, NotificationService, PYTHON_VERSION, PipelineRunner, PipelineValidator, PythonEnvManager, QUARANTINE_DIRNAME, RESTART_MARKER_FILE, RUNTIME_DEFAULTS, ReadinessRegistry, ReadinessTimeoutError, ReplEngine, RingBuffer, ScopedLogger, ScopedTokenManager, SocketChannel, SqliteSettingsAddon, SqliteSettingsBackend, StagingArea, StorageLocationManager, StorageManager, StorageOrchestratorAddon, StorageOrchestratorService, SystemConfigAddon, SystemEventBus, TRADITIONAL_NATIVE_PACKAGES, ToastService, UDS_NO_ROUTE_PREFIX, UdsLocalTransportClient, UdsLocalTransportServer, UserManager, WinstonDestination, WinstonLoggingAddon, __resetCapUsageRegistryForTests, adaptBrokerToCluster, addonSettingsCapability, bootstrapSchema, brokerCallForCap, brokerTransportLink, buildBinaryPath, buildCapRouters, buildHeapSample, buildLinkChain, buildNativeCapProxy, buildNodeManifest, buildStorageLocationRegistry, buildUdsNativeCapProxy, builderMountedCapNames, callRegisterNodeWithRetry, callWithServiceDiscovery, capActionName, capActionSuffix, capBareAction, capServiceName, classifyAddonDir, classifyCapRoute, clearPendingRestart, clusterEventTopic, clusterSecretMatches, collectModelFiles, contentTypeFor, copyDirRecursive, copyExtraFileDirs, createAddonContext, createAddonDataPlaneFacility, createAddonService, createAuthenticatedFileServer, createBroker, createBrokerDeviceManagerApi, createCoreCapService, createFileDataPlaneHandler, createHubCapForwardService, createHubService, createKernelHwAccel, createLocalTransport, createParentUnownedCallHandler, createProcessService, createReadinessService, createReadinessServiceForRegistry, createScopedProcessManager, createStreamProbeBrokerService, createUdsAddonContext, createUdsEventBridge, createUdsEventBus, createUdsLogger, createUdsLoggerWithControl, createV8Reclaimer, deleteModelFromDisk, deriveAgentListenPort, describeProviderKindDrift, detectWorkspacePackagesDir, downloadBinary, downloadFile, downloadModel, emitDownForOwnedCaps, encodeFrame, ensureAddonNativePrebuilds, ensureBinary, ensureDir, ensureFfmpeg, ensureLibraryBuilt, ensureModel, ensureNativePrebuilds, ensurePython, ensureTlsCert, expandCapMethods, fetchJson, findInPath, formatLogLine, getBrokerEventBus, getCapUsageRegistry, getFfmpegDownloadUrl, getModelFilePath, getMoleculerEventStats, getOrInitReadinessRegistry, getOrInitReadinessRegistryForClient, getPidStats, getPlatformInfo, getPythonDownloadUrl, getRestartMarkerPath, getSinglePidStats, getWorkerDeviceRegistry, hasDotNode, hashClusterSecret, installManifestNativeDeps, installManifestPythonDeps, installPackageFromNpm, installPythonPackages, installPythonRequirements, ipcChildLink, ipcParentLink, isAddonDeploySource, isArrayOutputSchema, isClusterSecretMismatchError, isCollectionArrayMethod, isInfraCapability, isModelDownloaded, isSourceNewer, loadTlsCert, localEndpointPath, localProviderLink, mountNativeCapService, parseCapAction, parseRangeHeader, parseTokenizedUrl, proxyToUpstream, quarantineAddonResidue, readPendingRestart, readinessKey, registerEventBusService, resolveFilePath, resolveHwAccel, resolveNpmInvocation, runNpm, scheduleSelfRestart, scopeKey, scopesAllowDeviceCap, selectAddonResidue, serializeTypedArrays, setHubConnected, setNodeEventInterest, shouldReclaim, startHeapWatch, strandedMb, stripCamstackDeps, subscribePassthrough, udsChildLogToWorkerEntry, validateProviderRegistrations, writePendingRestart };
@@ -0,0 +1,49 @@
1
+ /** Where residue is moved to, relative to the addon root. */
2
+ export declare const QUARANTINE_DIRNAME = ".quarantine";
3
+ export type ResidueReason =
4
+ /** The directory name carries a backup marker — `.bak…`, `.old…`, `.orig…`. */
5
+ 'backup-residue'
6
+ /** The directory declares a package whose name is not this directory. */
7
+ | 'name-mismatch';
8
+ export interface AddonDirCandidate {
9
+ /** Directory basename under the addon root (or under its `@scope`). */
10
+ readonly dirName: string;
11
+ /** `name` from the directory's `package.json`; null when absent/unreadable. */
12
+ readonly declaredName: string | null;
13
+ }
14
+ export interface ResidueVerdict extends AddonDirCandidate {
15
+ readonly reason: ResidueReason;
16
+ }
17
+ /**
18
+ * Is this directory residue? Returns the verdict, or null to keep it.
19
+ *
20
+ * A directory with no `package.json` is NOT residue on that ground alone —
21
+ * `models/`, a half-finished copy, a scratch dir are all absence, not a
22
+ * rename. Only a backup marker condemns one.
23
+ */
24
+ export declare function classifyAddonDir(candidate: AddonDirCandidate): ResidueVerdict | null;
25
+ export declare function selectAddonResidue(candidates: readonly AddonDirCandidate[]): readonly ResidueVerdict[];
26
+ export interface QuarantinedEntry extends ResidueVerdict {
27
+ /** Absolute path the residue occupied under the addon root. */
28
+ readonly from: string;
29
+ /** Absolute path it now occupies under `.quarantine/`. */
30
+ readonly to: string;
31
+ }
32
+ export interface ResidueQuarantineFailure extends ResidueVerdict {
33
+ readonly from: string;
34
+ readonly error: string;
35
+ }
36
+ export interface ResidueQuarantineResult {
37
+ readonly quarantined: readonly QuarantinedEntry[];
38
+ /** Residue that could not be moved. Reported, never fatal. */
39
+ readonly failed: readonly ResidueQuarantineFailure[];
40
+ }
41
+ /**
42
+ * Move every residue directory under `addonsDir` into `addonsDir/.quarantine`.
43
+ *
44
+ * Runs at boot, BEFORE anything scans the root or seeds into it — a residue
45
+ * that is still present when the loader walks has already been installed.
46
+ * Never throws, never deletes; a move that fails is reported and left in
47
+ * place, which is strictly better than a half-moved package.
48
+ */
49
+ export declare function quarantineAddonResidue(addonsDir: string, log: (message: string) => void): ResidueQuarantineResult;
@@ -4,6 +4,20 @@ export declare const HEAP_WATCH_INTERVAL_MS = 60000;
4
4
  /** Warn above this share of the heap ceiling. Chosen so the warning precedes
5
5
  * the fatal by more than one interval under realistic growth. */
6
6
  export declare const HEAP_WATCH_WARN_RATIO = 0.8;
7
+ /**
8
+ * Reclaim once the process holds this many MB beyond what it is USING.
9
+ *
10
+ * Live calibration, both readings from the same hub minutes apart: 2491MB
11
+ * stranded before the reclaim, 438MB after. 438MB is this process's irreducible
12
+ * native floor (sqlite pages, libvips, sharp, thread stacks, node's own text) —
13
+ * collecting against it would buy nothing and cost a stall every minute. 1536
14
+ * sits clear of that floor and still fires ~1.1GB before the ratchet is worth
15
+ * anything, so it triggers on the real condition and stops as soon as it is fixed.
16
+ */
17
+ export declare const HEAP_RECLAIM_TRIGGER_MB = 1536;
18
+ /** Floor on how often the stop-the-world may run. 250-450ms measured, so ten
19
+ * minutes keeps the worst case under 0.1% of the router's time. */
20
+ export declare const HEAP_RECLAIM_MIN_INTERVAL_MS = 600000;
7
21
  export interface HeapSample {
8
22
  readonly rssMb: number;
9
23
  readonly heapUsedMb: number;
@@ -27,10 +41,43 @@ export interface HeapWatchSink {
27
41
  readonly info: (line: string) => void;
28
42
  readonly warn: (line: string) => void;
29
43
  }
44
+ /**
45
+ * RSS the process is holding but not using.
46
+ *
47
+ * The subtrahend is heapUSED, not heapTotal, deliberately: the pages V8
48
+ * committed at a sawtooth peak and no longer needs are counted inside heapTotal,
49
+ * and they are precisely what this number exists to see.
50
+ */
51
+ export declare function strandedMb(s: HeapSample): number;
52
+ /** Whether it is worth paying a stop-the-world to get those pages back. */
53
+ export declare function shouldReclaim(s: HeapSample, triggerMb?: number): boolean;
54
+ /** A full collection that also asks V8 to hand the pages back to the OS. */
55
+ export type HeapReclaimer = () => void;
56
+ /**
57
+ * Build the reclaimer without requiring `--expose-gc` on argv.
58
+ *
59
+ * The flag is turned on, the function captured, and the flag turned straight
60
+ * back off — a global `gc` left reachable by application code is a footgun, and
61
+ * requiring an argv change would mean this cannot ship without a new image.
62
+ * Verified against the live hub before being written: 266ms, RSS 1453 -> 1237.
63
+ *
64
+ * Returns undefined if V8 declines, so the caller keeps a heartbeat either way.
65
+ */
66
+ export declare function createV8Reclaimer(): HeapReclaimer | undefined;
67
+ /** Wiring for the reclaim pass. `readMemory`/`heapLimitBytes` exist so a test
68
+ * can reach a gigabyte-scale state without allocating one. */
69
+ export interface HeapReclaimOptions {
70
+ readonly reclaim: HeapReclaimer;
71
+ readonly readMemory?: () => NodeJS.MemoryUsage;
72
+ readonly heapLimitBytes?: number;
73
+ readonly triggerMb?: number;
74
+ readonly minIntervalMs?: number;
75
+ readonly now?: () => number;
76
+ }
30
77
  /**
31
78
  * Start the heartbeat. Returns a stop function.
32
79
  *
33
80
  * `unref`ed so it can never hold the process open, and every read is wrapped:
34
81
  * a diagnostic that can crash the process it watches is worse than none.
35
82
  */
36
- export declare function startHeapWatch(label?: string, sink?: HeapWatchSink, intervalMs?: number): () => void;
83
+ export declare function startHeapWatch(label?: string, sink?: HeapWatchSink, intervalMs?: number, reclaimOptions?: HeapReclaimOptions): () => void;
@@ -5,6 +5,8 @@ export type { AddonInstallerConfig, InstallSource, InstalledPackage } from './ad
5
5
  export { AddonInstaller } from './addon-installer.js';
6
6
  export type { AddonInstallSource, AddonManifestEntry, AddonManifestFile } from './addon-manifest.js';
7
7
  export { AddonManifest } from './addon-manifest.js';
8
+ export type { AddonDirCandidate, QuarantinedEntry, ResidueQuarantineResult, ResidueReason, ResidueVerdict, } from './addon-residue.js';
9
+ export { QUARANTINE_DIRNAME, classifyAddonDir, quarantineAddonResidue, selectAddonResidue, } from './addon-residue.js';
8
10
  export { copyDirRecursive, ensureDir, stripCamstackDeps, copyExtraFileDirs, ensureLibraryBuilt, installPackageFromNpm, isSourceNewer, } from './fs-utils.js';
9
11
  export { detectWorkspacePackagesDir } from './workspace-detect.js';
10
12
  export { RESTART_MARKER_FILE, clearPendingRestart, getRestartMarkerPath, readPendingRestart, scheduleSelfRestart, writePendingRestart, } from './restart-coordinator.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.60",
3
+ "version": "1.2.62",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",