@camstack/system 1.2.57 → 1.2.59

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 (69) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +2 -2
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +2 -2
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +2 -2
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +2 -2
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  17. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  19. package/dist/builtins/doorbell/binding-mirror.d.ts +65 -0
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +56 -11
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.js +248 -26
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +248 -26
  23. package/dist/builtins/hub-forwarder/index.js +1 -1
  24. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  25. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  26. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/loki-logging/index.js +1 -1
  32. package/dist/builtins/loki-logging/index.mjs +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  34. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  35. package/dist/builtins/platform-probe/index.js +1 -1
  36. package/dist/builtins/platform-probe/index.mjs +1 -1
  37. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  38. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  39. package/dist/builtins/snapshot/index.js +2 -2
  40. package/dist/builtins/snapshot/index.mjs +2 -2
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +2 -2
  42. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +2 -2
  43. package/dist/builtins/sqlite-storage/index.d.ts +3 -0
  44. package/dist/builtins/sqlite-storage/index.js +8 -0
  45. package/dist/builtins/sqlite-storage/index.mjs +2 -2
  46. package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +94 -0
  47. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +85 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +445 -2
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +438 -3
  51. package/dist/builtins/sqlite-storage/wal-maintenance.d.ts +113 -0
  52. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/system-config/system-config.addon.js +1 -1
  55. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  56. package/dist/builtins/winston-logging/index.js +1 -1
  57. package/dist/builtins/winston-logging/index.mjs +1 -1
  58. package/dist/{dist-DhL_zGRT.mjs → dist-BqJJWCS8.mjs} +994 -34
  59. package/dist/{dist-BupxnZOi.js → dist-CV6XTApk.js} +1005 -33
  60. package/dist/index.js +190 -42
  61. package/dist/index.mjs +191 -43
  62. package/dist/kernel/addon-installer.d.ts +55 -7
  63. package/dist/kernel/addon-manifest.d.ts +36 -16
  64. package/dist/kernel/transport/frame-codec.d.ts +43 -2
  65. package/dist/{manifest-python-deps-Ck4-9K9m.mjs → manifest-python-deps-CX5tGRqf.mjs} +95 -10
  66. package/dist/{manifest-python-deps-BqE5j0-O.js → manifest-python-deps-d4acYXVV.js} +95 -10
  67. package/package.json +1 -1
  68. package/dist/{model-download-service-hf0ookyy.js → model-download-service-Cpc6SWzV.js} +1 -1
  69. package/dist/{model-download-service-Cp9f4dk6.mjs → model-download-service-D8B-4ktF.mjs} +1 -1
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
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 { B as kebabToCamel, Ct as asString$1, Dt as expandCapMethods, Ft as scopeKey, G as looseSchema, I as isArrayOutputSchema, J as objectInputDeclaresAddonId, L as isCollectionArrayMethod, Lt as EventCategory$1, Mt as parseJsonUnknown$1, N as extractNestedAddonId, Nt as readinessKey, Pt as resolveCapMount, R as isObjectInput, St as asNumber, Tt as emitDownForOwnedCaps, V as lifecycleJobSchema, W as logLevelAtMost, Z as procedureAuthKey, c as METHOD_ACCESS_MAP, ft as DATAPLANE_SECRET_HEADER$1, h as addonSettingsCapability, jt as parseJsonObject, l as RUNTIME_DEFAULTS, mt as DEVICE_STATUS_METHOD, pt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, t as ALL_CAPABILITY_DEFINITIONS, ut as errMsg$1, vt as ReadinessRegistry, wt as createEvent, xt as asJsonObject$1, yt as ReadinessTimeoutError, z as isVoidInput } from "./dist-DhL_zGRT.mjs";
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-Cp9f4dk6.mjs";
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-Ck4-9K9m.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-BqJJWCS8.mjs";
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
+ 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";
6
6
  import { FilesystemStorageAddon, t as FilesystemStorageProvider } from "./builtins/sqlite-storage/filesystem-storage.addon.mjs";
7
- import { SqliteSettingsAddon, t as SqliteSettingsBackend } from "./builtins/sqlite-storage/sqlite-settings.addon.mjs";
7
+ import { SqliteSettingsAddon, o as SqliteSettingsBackend } from "./builtins/sqlite-storage/sqlite-settings.addon.mjs";
8
8
  import { ConfigStore, DeviceStore } from "./builtins/sqlite-storage/index.mjs";
9
9
  import { t as formatLogLine } from "./formatter-B7qW8bPJ.mjs";
10
10
  import { WinstonDestination, WinstonLoggingAddon } from "./builtins/winston-logging/index.mjs";
@@ -30,12 +30,12 @@ import { t as CustomActionRegistry } from "./custom-action-registry-F__gp_VX.mjs
30
30
  import * as v8 from "node:v8";
31
31
  import { PYTHON_VERSION, buildBinaryPath, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements } from "@camstack/types/node";
32
32
  import { z } from "zod";
33
+ import { X509Certificate, createHash, randomUUID, timingSafeEqual } from "node:crypto";
33
34
  import { request } from "node:http";
34
35
  import * as fs$17 from "node:fs";
35
36
  import { accessSync, constants, existsSync, mkdirSync, readFileSync } from "node:fs";
36
37
  import * as path$39 from "node:path";
37
38
  import { dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
38
- import { X509Certificate, createHash, randomUUID, timingSafeEqual } from "node:crypto";
39
39
  import { asJsonObject, errMsg, parseJsonUnknown } from "@camstack/types/addon";
40
40
  import { execFile, spawn } from "node:child_process";
41
41
  import * as util$10 from "node:util";
@@ -3223,6 +3223,21 @@ async function installPackageFromNpm(packageName, targetDir) {
3223
3223
  //#region src/kernel/addon-manifest.ts
3224
3224
  var MANIFEST_FILENAME = "manifest.json";
3225
3225
  var MANIFEST_VERSION = 1;
3226
+ /**
3227
+ * Is `p` a directory, FOLLOWING symlinks?
3228
+ *
3229
+ * `Dirent.isDirectory()` answers false for a symlink, and dev installs
3230
+ * (`installSymlink`) point `addonsDir/@camstack/<pkg>` straight at the
3231
+ * workspace source. Trusting the dirent there finds zero addons on disk, which
3232
+ * with pruning would empty a developer's manifest on the next boot.
3233
+ */
3234
+ function isDirLike(p) {
3235
+ try {
3236
+ return fs$17.statSync(p).isDirectory();
3237
+ } catch {
3238
+ return false;
3239
+ }
3240
+ }
3226
3241
  var AddonManifest = class {
3227
3242
  manifestPath;
3228
3243
  cache = null;
@@ -3312,35 +3327,48 @@ var AddonManifest = class {
3312
3327
  this.cache = null;
3313
3328
  }
3314
3329
  /**
3315
- * Reconcile the manifest with what is actually present on disk.
3330
+ * Reconcile the manifest against what is actually present on disk.
3316
3331
  *
3317
- * For every `@scope/<pkg>` directory under `addonsDir` that has a
3318
- * readable `package.json` but NO manifest entry yet, add one with
3319
- * source `'seed'`. Existing entries are left untouched (their source,
3320
- * version and timestamps are authoritative — a later install/update
3321
- * overwrites them through `upsert`).
3332
+ * **The directory is the truth.** It is what `AddonLoader` runs; a manifest
3333
+ * that disagrees with it is a lie the update flow and the admin UI then
3334
+ * repeat. Three corrections, all idempotent and safe on every boot:
3322
3335
  *
3323
- * Why this exists: addons baked into the Docker/Electron image are
3324
- * copied straight into `addonsDir` by the container entrypoint (the
3325
- * launcher's `ensureRequiredPackages` then sees `package.json` already
3326
- * present and skips, never calling `upsert`). Without this reconcile
3327
- * those seeded addons stay absent from the manifest, so `applyUpdate`
3328
- * rejects them with "not currently tracked in manifest" — which is
3329
- * exactly what broke "Update all" on a fresh image. Idempotent and
3330
- * safe to run on every boot.
3336
+ * - **added** an on-disk addon with no entry gets one, source `'seed'`.
3337
+ * Addons baked into the Docker/Electron image are copied straight into
3338
+ * `addonsDir` by the container entrypoint (`ensureRequiredPackages` then
3339
+ * sees `package.json` present and skips, never calling `upsert`), so they
3340
+ * would otherwise stay untracked and `applyUpdate` would reject them with
3341
+ * "not currently tracked in manifest" — what broke "Update all" on a
3342
+ * fresh image.
3343
+ * - **version-corrected** an entry whose version disagrees with the
3344
+ * installed `package.json` adopts the DISK version. Provenance (`source`,
3345
+ * `installedAt`) is NOT derivable from disk and stays authoritative here;
3346
+ * `updatedAt` is left alone because the correction is not an update, and
3347
+ * stamping `now` would claim an install that never happened.
3348
+ * - **pruned** — an entry naming a directory that does not exist. The live
3349
+ * hub carried three (`@camstack/system 1.2.29`, `addon-viewer-ui 1.1.39`,
3350
+ * `addon-agent-ui 1.2.4`); `@camstack/system` in particular is
3351
+ * host-provided and deliberately NOT seeded under `addonsDir`
3352
+ * ([D45]), so its entry advertised a copy that must not exist.
3353
+ * An entry holding a `lastBackupDir` is NEVER pruned: that pointer is the
3354
+ * only reference to a pre-update backup, and the launcher's backup sweep
3355
+ * reclaims anything the manifest does not point at.
3331
3356
  *
3332
- * Returns the number of entries added.
3357
+ * `onChange` receives one record per correction, so the caller can log
3358
+ * exactly what moved. Returns the total number of changes.
3333
3359
  */
3334
- reconcileFromDisk(addonsDir) {
3360
+ reconcileFromDisk(addonsDir, onChange) {
3335
3361
  if (!fs$17.existsSync(addonsDir)) return 0;
3336
3362
  const m = this.read();
3337
- let added = 0;
3363
+ let changes = 0;
3338
3364
  const now = (/* @__PURE__ */ new Date()).toISOString();
3365
+ /** Package name → version, for every ADDON package actually installed. */
3366
+ const onDisk = /* @__PURE__ */ new Map();
3339
3367
  for (const scope of fs$17.readdirSync(addonsDir, { withFileTypes: true })) {
3340
- if (!scope.isDirectory() || !scope.name.startsWith("@")) continue;
3368
+ if (!scope.name.startsWith("@") || !isDirLike(path$39.join(addonsDir, scope.name))) continue;
3341
3369
  const scopeDir = path$39.join(addonsDir, scope.name);
3342
3370
  for (const pkg of fs$17.readdirSync(scopeDir, { withFileTypes: true })) {
3343
- if (!pkg.isDirectory()) continue;
3371
+ if (!isDirLike(path$39.join(scopeDir, pkg.name))) continue;
3344
3372
  const pkgJsonPath = path$39.join(scopeDir, pkg.name, "package.json");
3345
3373
  if (!fs$17.existsSync(pkgJsonPath)) continue;
3346
3374
  let pkgJson;
@@ -3354,22 +3382,57 @@ var AddonManifest = class {
3354
3382
  if (!pkgJson.name || !pkgJson.version) continue;
3355
3383
  const addons = pkgJson.camstack?.addons;
3356
3384
  if (!Array.isArray(addons) || addons.length === 0) continue;
3357
- if (m.addons[pkgJson.name] != null) continue;
3358
- m.addons[pkgJson.name] = {
3359
- name: pkgJson.name,
3360
- version: pkgJson.version,
3385
+ onDisk.set(pkgJson.name, pkgJson.version);
3386
+ }
3387
+ }
3388
+ for (const [name, version] of onDisk) {
3389
+ const prev = m.addons[name];
3390
+ if (prev == null) {
3391
+ m.addons[name] = {
3392
+ name,
3393
+ version,
3361
3394
  source: "seed",
3362
3395
  installedAt: now,
3363
3396
  updatedAt: now
3364
3397
  };
3365
- added++;
3398
+ changes++;
3399
+ onChange?.({
3400
+ kind: "added",
3401
+ name,
3402
+ to: version
3403
+ });
3404
+ continue;
3405
+ }
3406
+ if (prev.version !== version) {
3407
+ m.addons[name] = {
3408
+ ...prev,
3409
+ version
3410
+ };
3411
+ changes++;
3412
+ onChange?.({
3413
+ kind: "version-corrected",
3414
+ name,
3415
+ from: prev.version,
3416
+ to: version
3417
+ });
3366
3418
  }
3367
3419
  }
3368
- if (added > 0) {
3420
+ for (const [name, entry] of Object.entries(m.addons)) {
3421
+ if (onDisk.has(name)) continue;
3422
+ if (entry.lastBackupDir) continue;
3423
+ delete m.addons[name];
3424
+ changes++;
3425
+ onChange?.({
3426
+ kind: "pruned",
3427
+ name,
3428
+ from: entry.version
3429
+ });
3430
+ }
3431
+ if (changes > 0) {
3369
3432
  m.updatedAt = now;
3370
3433
  this.write(m);
3371
3434
  }
3372
- return added;
3435
+ return changes;
3373
3436
  }
3374
3437
  /**
3375
3438
  * Migration helper: if the manifest is empty but the addons directory
@@ -3752,6 +3815,34 @@ var noopLogger = {
3752
3815
  return noopLogger;
3753
3816
  }
3754
3817
  };
3818
+ /**
3819
+ * A staging tree younger than this may belong to an install still running —
3820
+ * possibly in another process. Only older trees are residue worth sweeping.
3821
+ * Generous on purpose: an addon's runtime-dependency `npm install` on a slow
3822
+ * host is minutes, and the cost of leaving residue one deploy longer is disk,
3823
+ * while the cost of sweeping a live one is a FAILED deploy.
3824
+ */
3825
+ var STAGING_RESIDUE_MIN_AGE_MS = 30 * 6e4;
3826
+ /**
3827
+ * Pick the staging entries that are safe to delete.
3828
+ *
3829
+ * `installFromTgz` used to clear the WHOLE `.staging` root before creating its
3830
+ * own dir. Two deploys arriving within one install's lifetime — the normal case
3831
+ * when the hub propagates two packages back to back — meant the second one
3832
+ * deleted the first one's tree out from under its running `npm install`:
3833
+ *
3834
+ * npm error code ENOENT
3835
+ * npm error path …/.staging/install-14820-2026-08-07T12-28-28-875Z/package/package.json
3836
+ *
3837
+ * which the propagation then reported as `runtime dependency install failed`.
3838
+ * Observed on the Mac desktop agent and on little-unraid on 2026-08-07.
3839
+ *
3840
+ * So a sweep only removes what cannot be live: not a tree this process is
3841
+ * building, and not one young enough to belong to anybody's in-flight install.
3842
+ */
3843
+ function selectSweepableStaging(entries, inFlight, minAgeMs = STAGING_RESIDUE_MIN_AGE_MS) {
3844
+ return entries.filter((entry) => !inFlight.has(entry.name) && entry.ageMs >= minAgeMs).map((entry) => entry.name);
3845
+ }
3755
3846
  var AddonInstaller = class AddonInstaller {
3756
3847
  addonsDir;
3757
3848
  registry;
@@ -3768,6 +3859,11 @@ var AddonInstaller = class AddonInstaller {
3768
3859
  * `getAddonInstallSource`).
3769
3860
  */
3770
3861
  manifest;
3862
+ /**
3863
+ * Basenames under `.staging` this process is currently building. Never swept
3864
+ * — see {@link selectSweepableStaging}.
3865
+ */
3866
+ inFlightStaging = /* @__PURE__ */ new Set();
3771
3867
  constructor(config, logger = noopLogger) {
3772
3868
  this.addonsDir = config.addonsDir;
3773
3869
  this.registry = config.registry;
@@ -3926,19 +4022,34 @@ var AddonInstaller = class AddonInstaller {
3926
4022
  }
3927
4023
  }
3928
4024
  /**
3929
- * Reconcile the install manifest with the addons actually present on
3930
- * disk. Seeded addons (baked into the image and copied verbatim by the
3931
- * container entrypoint) never pass through an `install*` codepath, so
3932
- * they are missing from `manifest.json` and `applyUpdate` would reject
3933
- * them with "not currently tracked in manifest". Call this once after
3934
- * `ensureRequiredPackages` so every on-disk addon is updatable.
4025
+ * Reconcile the install manifest against the addons actually present on
4026
+ * disk: register seeded addons, correct a version that disagrees with the
4027
+ * installed `package.json`, and drop entries naming a directory that does
4028
+ * not exist. Call once after `ensureRequiredPackages`, before any update
4029
+ * can be in flight. See `AddonManifest.reconcileFromDisk` for the rules.
3935
4030
  *
3936
- * Returns the number of manifest entries added.
4031
+ * Every correction is logged by name — a manifest that silently disagreed
4032
+ * with disk is what made "@camstack/system 1.2.29" read as installed on a
4033
+ * hub that deliberately has no copy of it.
4034
+ *
4035
+ * Returns the number of changes.
3937
4036
  */
3938
4037
  reconcileManifest() {
3939
- const added = this.manifest.reconcileFromDisk(this.addonsDir);
3940
- if (added > 0) this.logger.info(`Manifest reconciled — ${added} seeded addon(s) registered`);
3941
- return added;
4038
+ const changes = this.manifest.reconcileFromDisk(this.addonsDir, (c) => {
4039
+ switch (c.kind) {
4040
+ case "added":
4041
+ this.logger.info(`Manifest — registered seeded addon ${c.name}@${c.to}`);
4042
+ break;
4043
+ case "version-corrected":
4044
+ this.logger.info(`Manifest — ${c.name} recorded ${c.from} but disk has ${c.to}; adopting disk`);
4045
+ break;
4046
+ case "pruned":
4047
+ this.logger.info(`Manifest — dropped ${c.name}@${c.from}: no install directory on disk`);
4048
+ break;
4049
+ }
4050
+ });
4051
+ if (changes > 0) this.logger.info(`Manifest reconciled — ${changes} correction(s)`);
4052
+ return changes;
3942
4053
  }
3943
4054
  /**
3944
4055
  * Install a single addon — on-demand installer used by AddonPackageService
@@ -4176,6 +4287,40 @@ var AddonInstaller = class AddonInstaller {
4176
4287
  } });
4177
4288
  });
4178
4289
  }
4290
+ /**
4291
+ * Best-effort removal of `.staging` residue left by a CRASHED earlier deploy.
4292
+ *
4293
+ * Deliberately not `sweepGraveyard`: an evicted `.evicting/*` dir is dead by
4294
+ * construction, but a `.staging/*` dir may be an install still running — in
4295
+ * this process or another one. Deleting one of those fails a live deploy with
4296
+ * an ENOENT on its own package.json, which is what happened on 2026-08-07.
4297
+ */
4298
+ async sweepStagingResidue(stagingRoot) {
4299
+ let names;
4300
+ try {
4301
+ names = fs$17.readdirSync(stagingRoot);
4302
+ } catch {
4303
+ return;
4304
+ }
4305
+ const now = Date.now();
4306
+ const ages = [];
4307
+ for (const name of names) try {
4308
+ const stat = fs$17.statSync(path$39.join(stagingRoot, name));
4309
+ ages.push({
4310
+ name,
4311
+ ageMs: now - stat.mtimeMs
4312
+ });
4313
+ } catch {}
4314
+ const sweepable = selectSweepableStaging(ages, this.inFlightStaging);
4315
+ if (sweepable.length > 0) this.logger.info("sweeping staging residue from a crashed earlier deploy", { meta: {
4316
+ stagingRoot,
4317
+ entries: sweepable
4318
+ } });
4319
+ await Promise.all(sweepable.map((name) => fs$17.promises.rm(path$39.join(stagingRoot, name), {
4320
+ recursive: true,
4321
+ force: true
4322
+ }).catch(() => void 0)));
4323
+ }
4179
4324
  /** Best-effort removal of leftover `.evicting/*` dirs from earlier deploys. */
4180
4325
  async sweepGraveyard(graveyard) {
4181
4326
  let entries;
@@ -4193,9 +4338,11 @@ var AddonInstaller = class AddonInstaller {
4193
4338
  async installFromTgz(tgzPath) {
4194
4339
  const stagingRoot = path$39.join(this.addonsDir, ".staging");
4195
4340
  ensureDir(stagingRoot);
4196
- await this.sweepGraveyard(stagingRoot);
4197
- const stagingDir = path$39.join(stagingRoot, `install-${process.pid}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`);
4341
+ await this.sweepStagingResidue(stagingRoot);
4342
+ const stagingName = `install-${process.pid}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
4343
+ const stagingDir = path$39.join(stagingRoot, stagingName);
4198
4344
  ensureDir(stagingDir);
4345
+ this.inFlightStaging.add(stagingName);
4199
4346
  try {
4200
4347
  await execFileAsync("tar", [
4201
4348
  "-xzf",
@@ -4250,6 +4397,7 @@ var AddonInstaller = class AddonInstaller {
4250
4397
  version: pkgView.version
4251
4398
  };
4252
4399
  } finally {
4400
+ this.inFlightStaging.delete(stagingName);
4253
4401
  await fs$17.promises.rm(stagingDir, {
4254
4402
  recursive: true,
4255
4403
  force: true
@@ -66,6 +66,37 @@ export interface BackupSweepResult {
66
66
  /** Backup dirs deliberately retained (rollback target / last copy). */
67
67
  readonly kept: readonly string[];
68
68
  }
69
+ /**
70
+ * A staging tree younger than this may belong to an install still running —
71
+ * possibly in another process. Only older trees are residue worth sweeping.
72
+ * Generous on purpose: an addon's runtime-dependency `npm install` on a slow
73
+ * host is minutes, and the cost of leaving residue one deploy longer is disk,
74
+ * while the cost of sweeping a live one is a FAILED deploy.
75
+ */
76
+ export declare const STAGING_RESIDUE_MIN_AGE_MS: number;
77
+ export interface StagingEntryAge {
78
+ readonly name: string;
79
+ /** Age of the entry's mtime, in ms. */
80
+ readonly ageMs: number;
81
+ }
82
+ /**
83
+ * Pick the staging entries that are safe to delete.
84
+ *
85
+ * `installFromTgz` used to clear the WHOLE `.staging` root before creating its
86
+ * own dir. Two deploys arriving within one install's lifetime — the normal case
87
+ * when the hub propagates two packages back to back — meant the second one
88
+ * deleted the first one's tree out from under its running `npm install`:
89
+ *
90
+ * npm error code ENOENT
91
+ * npm error path …/.staging/install-14820-2026-08-07T12-28-28-875Z/package/package.json
92
+ *
93
+ * which the propagation then reported as `runtime dependency install failed`.
94
+ * Observed on the Mac desktop agent and on little-unraid on 2026-08-07.
95
+ *
96
+ * So a sweep only removes what cannot be live: not a tree this process is
97
+ * building, and not one young enough to belong to anybody's in-flight install.
98
+ */
99
+ export declare function selectSweepableStaging(entries: readonly StagingEntryAge[], inFlight: ReadonlySet<string>, minAgeMs?: number): readonly string[];
69
100
  export declare class AddonInstaller {
70
101
  readonly addonsDir: string;
71
102
  private readonly registry;
@@ -82,6 +113,11 @@ export declare class AddonInstaller {
82
113
  * `getAddonInstallSource`).
83
114
  */
84
115
  readonly manifest: AddonManifest;
116
+ /**
117
+ * Basenames under `.staging` this process is currently building. Never swept
118
+ * — see {@link selectSweepableStaging}.
119
+ */
120
+ private readonly inFlightStaging;
85
121
  constructor(config: AddonInstallerConfig, logger?: IScopedLogger);
86
122
  /**
87
123
  * Where a bootstrapped npm lives when the host has none on PATH (packaged
@@ -154,14 +190,17 @@ export declare class AddonInstaller {
154
190
  */
155
191
  ensureRequiredPackages(packages?: readonly string[]): Promise<void>;
156
192
  /**
157
- * Reconcile the install manifest with the addons actually present on
158
- * disk. Seeded addons (baked into the image and copied verbatim by the
159
- * container entrypoint) never pass through an `install*` codepath, so
160
- * they are missing from `manifest.json` and `applyUpdate` would reject
161
- * them with "not currently tracked in manifest". Call this once after
162
- * `ensureRequiredPackages` so every on-disk addon is updatable.
193
+ * Reconcile the install manifest against the addons actually present on
194
+ * disk: register seeded addons, correct a version that disagrees with the
195
+ * installed `package.json`, and drop entries naming a directory that does
196
+ * not exist. Call once after `ensureRequiredPackages`, before any update
197
+ * can be in flight. See `AddonManifest.reconcileFromDisk` for the rules.
198
+ *
199
+ * Every correction is logged by name — a manifest that silently disagreed
200
+ * with disk is what made "@camstack/system 1.2.29" read as installed on a
201
+ * hub that deliberately has no copy of it.
163
202
  *
164
- * Returns the number of manifest entries added.
203
+ * Returns the number of changes.
165
204
  */
166
205
  reconcileManifest(): number;
167
206
  /**
@@ -252,6 +291,15 @@ export declare class AddonInstaller {
252
291
  * on the next deploy.
253
292
  */
254
293
  private evictInstallDir;
294
+ /**
295
+ * Best-effort removal of `.staging` residue left by a CRASHED earlier deploy.
296
+ *
297
+ * Deliberately not `sweepGraveyard`: an evicted `.evicting/*` dir is dead by
298
+ * construction, but a `.staging/*` dir may be an install still running — in
299
+ * this process or another one. Deleting one of those fails a live deploy with
300
+ * an ENOENT on its own package.json, which is what happened on 2026-08-07.
301
+ */
302
+ private sweepStagingResidue;
255
303
  /** Best-effort removal of leftover `.evicting/*` dirs from earlier deploys. */
256
304
  private sweepGraveyard;
257
305
  /** Install addon from a tgz file (uploaded or downloaded) */
@@ -23,6 +23,15 @@ export interface AddonManifestEntry {
23
23
  */
24
24
  meta?: Record<string, unknown>;
25
25
  }
26
+ /** One correction `reconcileFromDisk` made to bring the manifest back to disk truth. */
27
+ export interface ManifestReconcileChange {
28
+ readonly kind: 'added' | 'version-corrected' | 'pruned';
29
+ readonly name: string;
30
+ /** Version the manifest claimed before the change. Absent for `added`. */
31
+ readonly from?: string;
32
+ /** Version now recorded. Absent for `pruned`. */
33
+ readonly to?: string;
34
+ }
26
35
  export interface AddonManifestFile {
27
36
  /**
28
37
  * Schema version. Increment whenever the on-disk shape changes
@@ -67,26 +76,37 @@ export declare class AddonManifest {
67
76
  */
68
77
  invalidate(): void;
69
78
  /**
70
- * Reconcile the manifest with what is actually present on disk.
79
+ * Reconcile the manifest against what is actually present on disk.
71
80
  *
72
- * For every `@scope/<pkg>` directory under `addonsDir` that has a
73
- * readable `package.json` but NO manifest entry yet, add one with
74
- * source `'seed'`. Existing entries are left untouched (their source,
75
- * version and timestamps are authoritative — a later install/update
76
- * overwrites them through `upsert`).
81
+ * **The directory is the truth.** It is what `AddonLoader` runs; a manifest
82
+ * that disagrees with it is a lie the update flow and the admin UI then
83
+ * repeat. Three corrections, all idempotent and safe on every boot:
77
84
  *
78
- * Why this exists: addons baked into the Docker/Electron image are
79
- * copied straight into `addonsDir` by the container entrypoint (the
80
- * launcher's `ensureRequiredPackages` then sees `package.json` already
81
- * present and skips, never calling `upsert`). Without this reconcile
82
- * those seeded addons stay absent from the manifest, so `applyUpdate`
83
- * rejects them with "not currently tracked in manifest" — which is
84
- * exactly what broke "Update all" on a fresh image. Idempotent and
85
- * safe to run on every boot.
85
+ * - **added** an on-disk addon with no entry gets one, source `'seed'`.
86
+ * Addons baked into the Docker/Electron image are copied straight into
87
+ * `addonsDir` by the container entrypoint (`ensureRequiredPackages` then
88
+ * sees `package.json` present and skips, never calling `upsert`), so they
89
+ * would otherwise stay untracked and `applyUpdate` would reject them with
90
+ * "not currently tracked in manifest" — what broke "Update all" on a
91
+ * fresh image.
92
+ * - **version-corrected** an entry whose version disagrees with the
93
+ * installed `package.json` adopts the DISK version. Provenance (`source`,
94
+ * `installedAt`) is NOT derivable from disk and stays authoritative here;
95
+ * `updatedAt` is left alone because the correction is not an update, and
96
+ * stamping `now` would claim an install that never happened.
97
+ * - **pruned** — an entry naming a directory that does not exist. The live
98
+ * hub carried three (`@camstack/system 1.2.29`, `addon-viewer-ui 1.1.39`,
99
+ * `addon-agent-ui 1.2.4`); `@camstack/system` in particular is
100
+ * host-provided and deliberately NOT seeded under `addonsDir`
101
+ * ([D45]), so its entry advertised a copy that must not exist.
102
+ * An entry holding a `lastBackupDir` is NEVER pruned: that pointer is the
103
+ * only reference to a pre-update backup, and the launcher's backup sweep
104
+ * reclaims anything the manifest does not point at.
86
105
  *
87
- * Returns the number of entries added.
106
+ * `onChange` receives one record per correction, so the caller can log
107
+ * exactly what moved. Returns the total number of changes.
88
108
  */
89
- reconcileFromDisk(addonsDir: string): number;
109
+ reconcileFromDisk(addonsDir: string, onChange?: (change: ManifestReconcileChange) => void): number;
90
110
  /**
91
111
  * Migration helper: if the manifest is empty but the addons directory
92
112
  * has packages with `.install-source` markers (the legacy per-addon
@@ -1,7 +1,48 @@
1
1
  /** Encode a value as a length-prefixed (4-byte big-endian) MsgPack frame. */
2
2
  export declare function encodeFrame(value: unknown): Buffer;
3
- /** Reassembles length-prefixed MsgPack frames from a byte stream. */
3
+ /**
4
+ * Reassembles length-prefixed MsgPack frames from a byte stream.
5
+ *
6
+ * **Why this is a chunk LIST and not a growing Buffer.** The original
7
+ * accumulator did `this.buf = Buffer.concat([this.buf, chunk])` on every
8
+ * socket `data` event. A frame of N bytes delivered in C-byte pieces
9
+ * therefore copied ~N²/2C bytes: a 4 MiB native crop arriving in 64 KiB
10
+ * chunks is ~128 MiB of memcpy plus 64 intermediate Buffer allocations,
11
+ * synchronously, on the thread that also serves every tRPC request. Media
12
+ * transits this router constantly, and hub-main was measured busy 82.2 s out
13
+ * of 120 with 22 uninterrupted blocks ≥1 s.
14
+ *
15
+ * Here the pending chunks are kept as-is and joined ONCE, when a frame is
16
+ * complete — and not even then in the common case, because a frame that
17
+ * arrived whole inside one chunk is sliced out with zero copying. Total work
18
+ * is linear in the bytes received.
19
+ *
20
+ * The wire contract is unchanged: 4-byte big-endian length, then that many
21
+ * MsgPack bytes; a partial frame is buffered; several frames in one chunk are
22
+ * all returned; a frame spanning any number of chunks is reassembled. There
23
+ * is still no upper bound on a declared length — an oversized prefix parks
24
+ * bytes in `chunks` until the peer completes or the socket closes, exactly as
25
+ * before. Bounding it is a transport-protocol change, not a decoder cleanup.
26
+ */
4
27
  export declare class FrameDecoder {
5
- private buf;
28
+ /** Unconsumed bytes, oldest first. `chunks[0]` starts at a frame boundary. */
29
+ private chunks;
30
+ /** Sum of `chunks[i].byteLength` — tracked so length checks cost nothing. */
31
+ private buffered;
6
32
  push(chunk: Buffer): unknown[];
33
+ /**
34
+ * Read the frame length without consuming it. The prefix itself can land
35
+ * across a chunk boundary (a 3-byte first chunk is a real socket event), so
36
+ * the slow path walks bytes until it has all four.
37
+ */
38
+ private readLength;
39
+ /**
40
+ * Consume and return exactly `n` contiguous bytes.
41
+ *
42
+ * Three cases, cheapest first: the head chunk already holds the whole frame
43
+ * (no copy at all, just a view); the head chunk holds more than the frame
44
+ * (a view plus a rebased head); the frame spans chunks (ONE `Buffer.concat`
45
+ * sized to the frame — the single copy this class is allowed).
46
+ */
47
+ private take;
7
48
  }