@camstack/addon-pipeline 1.2.214 → 1.2.217

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 (39) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/{default-detection-model-C2vYSX3O.mjs → default-detection-model-BwNEV8Wl.mjs} +1 -1
  4. package/dist/{default-detection-model-f0PImZnh.js → default-detection-model-DzzuINe-.js} +1 -1
  5. package/dist/detection-pipeline/index.js +6 -7
  6. package/dist/detection-pipeline/index.mjs +5 -6
  7. package/dist/{dist-DoHcimdu.js → dist-D7hxiBnC.js} +237 -16
  8. package/dist/{dist-DttqKvsQ.mjs → dist-DgeXZfwn.mjs} +237 -16
  9. package/dist/{lazy-sharp-BeeQGCUu.js → lazy-sharp-BSkbiuz5.js} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-NtM7__8q.mjs → node-C9GcBrwk.mjs} +1 -1
  13. package/dist/{node-6uF72wri.js → node-DDhmPcOx.js} +1 -1
  14. package/dist/pipeline-runner/index.js +541 -26
  15. package/dist/pipeline-runner/index.mjs +539 -24
  16. package/dist/{process-memory-CddpCDWL.mjs → process-memory-Dw3CGS3D.mjs} +1 -1
  17. package/dist/{process-memory-BRdyl5fE.js → process-memory-ENqmiiis.js} +1 -1
  18. package/dist/recorder/index.js +158 -47
  19. package/dist/recorder/index.mjs +158 -47
  20. package/dist/{segment-demux-js-CsFtPVzH.mjs → segment-demux-js-BGbKlotF.mjs} +1 -1
  21. package/dist/{segment-demux-js-CkJPdyDH.js → segment-demux-js-sLs8mbZQ.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +61 -180
  23. package/dist/session-decode/decode-worker-child.mjs +60 -179
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CbBa4iV-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BULotDCb.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-yJ2CyixO.mjs +26 -0
  27. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Uxkwkgy5.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DICVQkNi.mjs} +1 -1
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-CagSUJH7.mjs → hostInit-_RQxF72h.mjs} +3 -3
  31. package/dist/stream-broker/index.js +20 -21
  32. package/dist/stream-broker/index.mjs +20 -21
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-BWO2WNjw.js → worker-protocol-DGql9R4g.js} +1 -1
  35. package/dist/{worker-protocol-BKwkLZWT.mjs → worker-protocol-SzmaLNnP.mjs} +1 -1
  36. package/package.json +1 -1
  37. package/dist/event-loop-stall-monitor-DXLNBMkY.mjs +0 -138
  38. package/dist/event-loop-stall-monitor-DaUBcb13.js +0 -149
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-0MF-KL1v.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { mt as parseProcStatus } from "./dist-DttqKvsQ.mjs";
1
+ import { mt as parseProcStatus } from "./dist-DgeXZfwn.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import { execFile } from "node:child_process";
4
4
  //#region src/shared/node-topology-platform.ts
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DoHcimdu.js");
1
+ const require_dist = require("./dist-D7hxiBnC.js");
2
2
  let node_fs = require("node:fs");
3
3
  node_fs = require_dist.__toESM(node_fs);
4
4
  let node_child_process = require("node:child_process");
@@ -2,11 +2,10 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DoHcimdu.js");
6
- const require_node = require("../node-6uF72wri.js");
5
+ const require_dist = require("../dist-D7hxiBnC.js");
6
+ const require_node = require("../node-DDhmPcOx.js");
7
7
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
8
8
  const require_restream_intent = require("../restream-intent-B1Difcnq.js");
9
- const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
10
9
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
11
10
  let node_crypto = require("node:crypto");
12
11
  let node_fs = require("node:fs");
@@ -1334,27 +1333,6 @@ var RecordingIndex = class {
1334
1333
  return [...out.values()];
1335
1334
  }
1336
1335
  /**
1337
- * WHICH devices hold footage on any of `locationIds` — the census, without
1338
- * the archive.
1339
- *
1340
- * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1341
- * location: a full multi-device COPY of every row on the disk plus a sort of
1342
- * it (294 k rows on the live hub), to read one number off each row and throw
1343
- * the array away. This visits each device only until its first matching row.
1344
- * Same membership rule as before — a device is listed when at least one of
1345
- * its rows sits on one of the given locations, so footage stranded on a
1346
- * location id that no longer resolves stays invisible here exactly as it was.
1347
- * @residency archive-shaped
1348
- */
1349
- deviceIdsOnLocations(locationIds) {
1350
- const out = /* @__PURE__ */ new Set();
1351
- for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1352
- out.add(deviceId);
1353
- break;
1354
- }
1355
- return out;
1356
- }
1357
- /**
1358
1336
  * Every RESIDENT segment on a storage location, across devices, oldest-first.
1359
1337
  *
1360
1338
  * ## The one that bit (D295)
@@ -6867,6 +6845,81 @@ function buildRecordingDeviceSchema() {
6867
6845
  }]
6868
6846
  }] };
6869
6847
  }
6848
+ //#endregion
6849
+ //#region src/recorder/addon/device-storage-usage.ts
6850
+ /**
6851
+ * Gap between the per-camera sum and the per-volume total that is worth
6852
+ * saying out loud, as a share of the volume total.
6853
+ *
6854
+ * The two are aggregations of the SAME hour rows over the same location set —
6855
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
6856
+ * agree to the byte. A few tenths of a percent is a write landing between the
6857
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
6858
+ * rebuilt somewhere new.
6859
+ */
6860
+ var USAGE_MISMATCH_SHARE = .02;
6861
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
6862
+ * warn on a single in-flight segment. */
6863
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
6864
+ /**
6865
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
6866
+ * first (deviceId breaks a tie, so two consecutive calls compare).
6867
+ *
6868
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
6869
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
6870
+ * the same question with the wrong quantity and look exactly like a correct
6871
+ * answer, which is how this shipped in the first place.
6872
+ */
6873
+ function buildDeviceUsage(input) {
6874
+ if (input.archive === null) {
6875
+ input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
6876
+ return {
6877
+ devices: [],
6878
+ totalUsedBytes: 0,
6879
+ read: "unavailable"
6880
+ };
6881
+ }
6882
+ const devices = [];
6883
+ let totalUsedBytes = 0;
6884
+ for (const [deviceId, archived] of input.archive) {
6885
+ const resident = input.resident(deviceId);
6886
+ totalUsedBytes += archived.bytes;
6887
+ devices.push({
6888
+ deviceId,
6889
+ usedBytes: archived.bytes,
6890
+ oldestMs: archived.oldestMs,
6891
+ segments: archived.segments,
6892
+ residentBytes: resident.bytes,
6893
+ residentOldestMs: resident.oldestMs
6894
+ });
6895
+ }
6896
+ return {
6897
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
6898
+ totalUsedBytes,
6899
+ read: "archive"
6900
+ };
6901
+ }
6902
+ /**
6903
+ * Say it when the per-camera sum and the per-volume total disagree.
6904
+ *
6905
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
6906
+ * the same rows counted two ways, so a gap means one of the two reads saw a
6907
+ * fraction of the archive — and the whole point of D418 is that a page which
6908
+ * shows both without comparing them lets a 97% gap read as a fact about the
6909
+ * disks.
6910
+ */
6911
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
6912
+ if (read !== "archive") return;
6913
+ if (volumeBytes === 0) return;
6914
+ const gap = Math.abs(volumeBytes - deviceBytes);
6915
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
6916
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
6917
+ logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
6918
+ deviceBytes,
6919
+ volumeBytes,
6920
+ gap
6921
+ } });
6922
+ }
6870
6923
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
6871
6924
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
6872
6925
  function sourceKey(source) {
@@ -7454,18 +7507,15 @@ function buildRecordingProvider(deps) {
7454
7507
  getPlaybackManifest: ({ deviceId, fromMs, toMs }) => buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs),
7455
7508
  getStorageUsage: async () => {
7456
7509
  const locations = deps.locations();
7457
- const devices = [];
7458
- const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
7459
- let totalUsedBytes = 0;
7460
- for (const deviceId of seenDevices) {
7461
- const accounting = deps.index.accounting(deviceId);
7462
- totalUsedBytes += accounting.bytes;
7463
- devices.push({
7464
- deviceId,
7465
- usedBytes: accounting.bytes,
7466
- oldestMs: accounting.oldestMs
7467
- });
7468
- }
7510
+ const locationIds = new Set(locations.map((l) => l.id));
7511
+ const deviceUsage = buildDeviceUsage({
7512
+ locationIds,
7513
+ archive: deps.archiveDeviceUsage?.(locationIds) ?? null,
7514
+ resident: (deviceId) => deps.index.accounting(deviceId),
7515
+ logger: deps.logger
7516
+ });
7517
+ const devices = deviceUsage.devices;
7518
+ const totalUsedBytes = deviceUsage.totalUsedBytes;
7469
7519
  const byPhysical = /* @__PURE__ */ new Map();
7470
7520
  for (const loc of locations) {
7471
7521
  const key = physicalRootKey(loc.root);
@@ -7494,11 +7544,12 @@ function buildRecordingProvider(deps) {
7494
7544
  totalBytes: cap.totalBytes
7495
7545
  };
7496
7546
  }));
7497
- devices.sort((a, b) => b.usedBytes - a.usedBytes);
7547
+ warnOnUsageMismatch(deps.logger, totalUsedBytes, locationUsages.reduce((sum, l) => sum + l.usedBytes, 0), deviceUsage.read);
7498
7548
  return {
7499
7549
  nodeId: deps.nodeId,
7500
7550
  totalUsedBytes,
7501
- devices,
7551
+ devices: [...devices],
7552
+ devicesRead: deviceUsage.read,
7502
7553
  locations: locationUsages
7503
7554
  };
7504
7555
  },
@@ -8392,6 +8443,14 @@ var DurableLedger = class DurableLedger {
8392
8443
  //#endregion
8393
8444
  //#region src/recorder/addon/segment-hour-ledger.ts
8394
8445
  var HOUR_MS$3 = 36e5;
8446
+ /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8447
+ function minOf(a, b) {
8448
+ return a === null ? b : Math.min(a, b);
8449
+ }
8450
+ /** `Math.max` with the same rule as {@link minOf}. */
8451
+ function maxOf(a, b) {
8452
+ return a === null ? b : Math.max(a, b);
8453
+ }
8395
8454
  var macrotask$1 = () => new Promise((resolve) => {
8396
8455
  setImmediate(resolve);
8397
8456
  });
@@ -9042,7 +9101,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9042
9101
  *
9043
9102
  * The reader repairs the durable index it just reconstructed, so the next
9044
9103
  * cold read of this hour is a point read instead of another `readdir`
9045
- * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
9104
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9046
9105
  * constraints, both enforced by the caller and re-stated here because
9047
9106
  * getting either wrong turns a READ into durable damage: the hour must be
9048
9107
  * CLOSED (a live hour's directory races the writer, so a walk of it is a
@@ -9275,6 +9334,53 @@ var SegmentHourLedger = class SegmentHourLedger {
9275
9334
  for (const row of this.ledger.snapshot()) if (locationIds.has(row.locationId)) out.add(row.deviceId);
9276
9335
  return out;
9277
9336
  }
9337
+ /**
9338
+ * Bytes / segments / oldest / newest per CAMERA on these locations — the
9339
+ * per-camera half of the storage page, answered by the ARCHIVE (D418).
9340
+ *
9341
+ * ## Why the T1 fold and not `census({ deviceId })` per camera
9342
+ *
9343
+ * `census` is the right authority for a SCOPE, and it cannot express this
9344
+ * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
9345
+ * "camera 615 across the three resolvable volumes" is three aggregates, and
9346
+ * `census({ deviceId })` alone would silently count footage stranded on a
9347
+ * location id that no longer resolves — bytes no volume row on the same page
9348
+ * accounts for. Per camera per volume that is `cameras × volumes` SQL
9349
+ * aggregates for one operator click, and it grows with the fleet.
9350
+ *
9351
+ * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9352
+ * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9353
+ * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9354
+ * is write-behind, so it is never BEHIND the store. The cost is one pass
9355
+ * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9356
+ * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9357
+ * i.e. it does NOT scale with the segment count — and it is capped for good
9358
+ * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9359
+ *
9360
+ * ## What an empty answer means
9361
+ *
9362
+ * The same thing it means for {@link devicesWithFootage}: "the mirror knows
9363
+ * no hour here" — which is also what a ledger whose load failed reports. The
9364
+ * caller must not render that as "no camera has footage"; `getStorageUsage`
9365
+ * cross-checks the per-camera sum against the volume aggregate and SAYS so
9366
+ * when the two disagree.
9367
+ */
9368
+ usageByDevice(locationIds) {
9369
+ const out = /* @__PURE__ */ new Map();
9370
+ if (locationIds.size === 0) return out;
9371
+ for (const row of this.ledger.snapshot()) {
9372
+ if (!locationIds.has(row.locationId)) continue;
9373
+ const empty = row.segments === 0;
9374
+ const prev = out.get(row.deviceId);
9375
+ out.set(row.deviceId, {
9376
+ bytes: (prev?.bytes ?? 0) + row.bytes,
9377
+ segments: (prev?.segments ?? 0) + row.segments,
9378
+ oldestMs: empty ? prev?.oldestMs ?? null : minOf(prev?.oldestMs ?? null, row.minStartMs),
9379
+ newestMs: empty ? prev?.newestMs ?? null : maxOf(prev?.newestMs ?? null, row.maxStartMs)
9380
+ });
9381
+ }
9382
+ return out;
9383
+ }
9278
9384
  async oldestSegmentsScoped(locationIds, deviceIds, targetBytes) {
9279
9385
  const ids = [...locationIds];
9280
9386
  if (ids.length === 0 || targetBytes <= 0) return {
@@ -14534,7 +14640,7 @@ var RecordingController = class {
14534
14640
  /**
14535
14641
  * The ADAPTIVE attach bound. A transient `spawn ffmpeg` failure (EAGAIN) is
14536
14642
  * the host saying it cannot fork right now, and the answer is to attach
14537
- * FEWER cameras at once — not to serialise them, which `e2fa17a2b` removed
14643
+ * FEWER cameras at once — not to serialise them, which removed
14538
14644
  * for good reason. Every bounded-parallel attach in this file reads it.
14539
14645
  */
14540
14646
  spawnPressure;
@@ -16039,7 +16145,7 @@ function selectRedundantSegments(segs) {
16039
16145
  * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
16040
16146
  * from `runFullArchiveWalk`, which was reachable only from
16041
16147
  * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
16042
- * (commit `9308428b4`) all three were deleted together as dead code. That was
16148
+ * all three were deleted together as dead code. That was
16043
16149
  * correct about the walk and left the system with **no duplicate pruner
16044
16150
  * whatsoever**. The precedent for letting this class of waste run unattended is
16045
16151
  * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
@@ -16983,11 +17089,15 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
16983
17089
  /**
16984
17090
  * Stops the GC-attributing stall monitor armed in {@link onInitialize}.
16985
17091
  *
16986
- * REUSED, not re-implemented, and not promoted: `startEventLoopStallMonitor`
16987
- * already ships inside `@camstack/addon-pipeline` — the same bundle as this
16988
- * addon — and moving it into `@camstack/system` would duplicate a mechanism
16989
- * that already exists (forbidden) or relocate it across a package boundary,
16990
- * which is a refactor with a slower ship chain, not a measurement.
17092
+ * REUSED, never re-implemented. It used to live next door in this bundle;
17093
+ * on 2026-09-09 it moved to `@camstack/system/addon-utils` (the subpath, never
17094
+ * the barrel — that one drags the kernel into the runner) because two more
17095
+ * processes that do NOT share this bundle needed it — `pipeline-analytics` and
17096
+ * hub-main — and a per-bundle copy is how two diagnostics end up disagreeing
17097
+ * about the same freeze. The cost of the move is a slower ship chain: this
17098
+ * import is host-resolved, so a `camstack deploy` of this addon onto a hub
17099
+ * whose server closure predates the export fails to load. Deploy order is
17100
+ * closure first, addons second. See D417.
16991
17101
  */
16992
17102
  stopStallMonitor = null;
16993
17103
  /**
@@ -17188,7 +17298,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17188
17298
  } });
17189
17299
  }
17190
17300
  async onInitialize() {
17191
- this.stopStallMonitor = require_event_loop_stall_monitor.startEventLoopStallMonitor(this.ctx.logger);
17301
+ this.stopStallMonitor = (0, _camstack_system_addon_utils.startEventLoopStallMonitor)(this.ctx.logger);
17192
17302
  const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
17193
17303
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
17194
17304
  const configuredNode = this.config.recordingNodeId;
@@ -17446,6 +17556,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17446
17556
  refreshLocations: () => this.refreshLocations(),
17447
17557
  capacity: (root) => this.locationCapacity(root),
17448
17558
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
17559
+ archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
17449
17560
  archive: () => this.segmentHours,
17450
17561
  ledgerWalk: this.ledgerWalkReconciler(),
17451
17562
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
@@ -1,15 +1,14 @@
1
- import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-DttqKvsQ.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-NtM7__8q.mjs";
1
+ import { At as storageEvictableCapability, Ct as recordingSignalCapability, D as RecordingSignalStatusSchema, E as RecordingConfigSchema, Et as resolveLocationMode, Gt as DeviceFeature, H as deriveBatteryPresence, It as errMsg, Jt as hydrateSchema, Kt as DeviceType, Nt as stripRetiredBandPreBufferSec, Ot as resolveRecordingProfiles, P as batteryCapability, Q as evictionPolicyOfLocation, Qt as nodePin, R as customAction, St as recordingExportCapability, T as RECORDING_EXPORT_MAX_READ_BYTES, Ut as BaseAddon, V as defineCustomActions, W as deriveRecordingMode, Y as evaluateSensorEdge, Z as evictionPolicyForMode, an as boolean, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dn as preprocess, dt as modeMayWrite, et as gbToBytes, fn as record, g as ExportRecordSchema, hn as EventCategory, i as BatteryStatusSchema, in as array, it as loadContributionCapability, jt as storageOccupancyCapability, ln as number, lt as mayWriteToLocation, nn as sleep$1, pn as string, rn as _enum, rt as isFirstLevelMacroClass, tn as selectAssignedProfileSlots, un as object, ut as migrateLegacyDeviceSignalConfig, x as OpsLogEntrySchema, xt as recordingCapability } from "../dist-DgeXZfwn.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-C9GcBrwk.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-DWe2kInY.mjs";
5
- import { n as startEventLoopStallMonitor } from "../event-loop-stall-monitor-DXLNBMkY.mjs";
6
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
7
6
  import { randomUUID } from "node:crypto";
8
7
  import { createReadStream, promises, realpathSync } from "node:fs";
9
8
  import path from "node:path";
10
9
  import { spawn } from "node:child_process";
11
10
  import { open } from "node:fs/promises";
12
- import { contentTypeFor, createFileDataPlaneHandler, parseRangeHeader } from "@camstack/system/addon-utils";
11
+ import { contentTypeFor, createFileDataPlaneHandler, parseRangeHeader, startEventLoopStallMonitor } from "@camstack/system/addon-utils";
13
12
  //#region src/recorder/event-capture.ts
14
13
  /**
15
14
  * La sottoscrizione motion del recorder: bus → `onTrigger(deviceId, atMs)`.
@@ -1329,27 +1328,6 @@ var RecordingIndex = class {
1329
1328
  return [...out.values()];
1330
1329
  }
1331
1330
  /**
1332
- * WHICH devices hold footage on any of `locationIds` — the census, without
1333
- * the archive.
1334
- *
1335
- * `getStorageUsage` used to answer this with `segmentsOnLocation(id)` per
1336
- * location: a full multi-device COPY of every row on the disk plus a sort of
1337
- * it (294 k rows on the live hub), to read one number off each row and throw
1338
- * the array away. This visits each device only until its first matching row.
1339
- * Same membership rule as before — a device is listed when at least one of
1340
- * its rows sits on one of the given locations, so footage stranded on a
1341
- * location id that no longer resolves stays invisible here exactly as it was.
1342
- * @residency archive-shaped
1343
- */
1344
- deviceIdsOnLocations(locationIds) {
1345
- const out = /* @__PURE__ */ new Set();
1346
- for (const [deviceId, m] of this.byDevice) for (const s of m.values()) if (locationIds.has(s.locationId)) {
1347
- out.add(deviceId);
1348
- break;
1349
- }
1350
- return out;
1351
- }
1352
- /**
1353
1331
  * Every RESIDENT segment on a storage location, across devices, oldest-first.
1354
1332
  *
1355
1333
  * ## The one that bit (D295)
@@ -6861,6 +6839,81 @@ function buildRecordingDeviceSchema() {
6861
6839
  }]
6862
6840
  }] };
6863
6841
  }
6842
+ //#endregion
6843
+ //#region src/recorder/addon/device-storage-usage.ts
6844
+ /**
6845
+ * Gap between the per-camera sum and the per-volume total that is worth
6846
+ * saying out loud, as a share of the volume total.
6847
+ *
6848
+ * The two are aggregations of the SAME hour rows over the same location set —
6849
+ * one folded per camera, one summed in SQL — so in a healthy recorder they
6850
+ * agree to the byte. A few tenths of a percent is a write landing between the
6851
+ * two reads; 97% (60.5 GB against 1.9 TB) is the defect this file closes,
6852
+ * rebuilt somewhere new.
6853
+ */
6854
+ var USAGE_MISMATCH_SHARE = .02;
6855
+ /** Floor under {@link USAGE_MISMATCH_SHARE} so a nearly empty disk does not
6856
+ * warn on a single in-flight segment. */
6857
+ var USAGE_MISMATCH_FLOOR_BYTES = 1e9;
6858
+ /**
6859
+ * One row per camera the ARCHIVE holds footage for on `locationIds`, biggest
6860
+ * first (deviceId breaks a tie, so two consecutive calls compare).
6861
+ *
6862
+ * A node with no ledger REFUSES (D295 rule 3): empty rows, `read:
6863
+ * 'unavailable'`, and a line saying so. Falling back to the index would answer
6864
+ * the same question with the wrong quantity and look exactly like a correct
6865
+ * answer, which is how this shipped in the first place.
6866
+ */
6867
+ function buildDeviceUsage(input) {
6868
+ if (input.archive === null) {
6869
+ input.logger.warn("recorder: no hour ledger on this node — the per-camera storage rows are UNANSWERED, not empty", { meta: { locations: [...input.locationIds].toSorted().join(",") } });
6870
+ return {
6871
+ devices: [],
6872
+ totalUsedBytes: 0,
6873
+ read: "unavailable"
6874
+ };
6875
+ }
6876
+ const devices = [];
6877
+ let totalUsedBytes = 0;
6878
+ for (const [deviceId, archived] of input.archive) {
6879
+ const resident = input.resident(deviceId);
6880
+ totalUsedBytes += archived.bytes;
6881
+ devices.push({
6882
+ deviceId,
6883
+ usedBytes: archived.bytes,
6884
+ oldestMs: archived.oldestMs,
6885
+ segments: archived.segments,
6886
+ residentBytes: resident.bytes,
6887
+ residentOldestMs: resident.oldestMs
6888
+ });
6889
+ }
6890
+ return {
6891
+ devices: devices.toSorted((a, b) => b.usedBytes - a.usedBytes || a.deviceId - b.deviceId),
6892
+ totalUsedBytes,
6893
+ read: "archive"
6894
+ };
6895
+ }
6896
+ /**
6897
+ * Say it when the per-camera sum and the per-volume total disagree.
6898
+ *
6899
+ * The "I saw N of M" rule of D295, applied to a REPORT: these two numbers are
6900
+ * the same rows counted two ways, so a gap means one of the two reads saw a
6901
+ * fraction of the archive — and the whole point of D418 is that a page which
6902
+ * shows both without comparing them lets a 97% gap read as a fact about the
6903
+ * disks.
6904
+ */
6905
+ function warnOnUsageMismatch(logger, deviceBytes, volumeBytes, read) {
6906
+ if (read !== "archive") return;
6907
+ if (volumeBytes === 0) return;
6908
+ const gap = Math.abs(volumeBytes - deviceBytes);
6909
+ if (gap <= USAGE_MISMATCH_FLOOR_BYTES) return;
6910
+ if (gap <= volumeBytes * USAGE_MISMATCH_SHARE) return;
6911
+ logger.warn("recorder: the per-camera storage sum disagrees with the per-volume total — one of the two reads saw a fraction of the archive", { meta: {
6912
+ deviceBytes,
6913
+ volumeBytes,
6914
+ gap
6915
+ } });
6916
+ }
6864
6917
  /** The placement-store key for one (camera, profile). Mirrors `assignmentKey`
6865
6918
  * in `placement-store.ts`; kept local so this module stays dependency-free. */
6866
6919
  function sourceKey(source) {
@@ -7448,18 +7501,15 @@ function buildRecordingProvider(deps) {
7448
7501
  getPlaybackManifest: ({ deviceId, fromMs, toMs }) => buildPlaybackManifest(deps, codecCache, deviceId, fromMs, toMs),
7449
7502
  getStorageUsage: async () => {
7450
7503
  const locations = deps.locations();
7451
- const devices = [];
7452
- const seenDevices = deps.index.deviceIdsOnLocations(new Set(locations.map((l) => l.id)));
7453
- let totalUsedBytes = 0;
7454
- for (const deviceId of seenDevices) {
7455
- const accounting = deps.index.accounting(deviceId);
7456
- totalUsedBytes += accounting.bytes;
7457
- devices.push({
7458
- deviceId,
7459
- usedBytes: accounting.bytes,
7460
- oldestMs: accounting.oldestMs
7461
- });
7462
- }
7504
+ const locationIds = new Set(locations.map((l) => l.id));
7505
+ const deviceUsage = buildDeviceUsage({
7506
+ locationIds,
7507
+ archive: deps.archiveDeviceUsage?.(locationIds) ?? null,
7508
+ resident: (deviceId) => deps.index.accounting(deviceId),
7509
+ logger: deps.logger
7510
+ });
7511
+ const devices = deviceUsage.devices;
7512
+ const totalUsedBytes = deviceUsage.totalUsedBytes;
7463
7513
  const byPhysical = /* @__PURE__ */ new Map();
7464
7514
  for (const loc of locations) {
7465
7515
  const key = physicalRootKey(loc.root);
@@ -7488,11 +7538,12 @@ function buildRecordingProvider(deps) {
7488
7538
  totalBytes: cap.totalBytes
7489
7539
  };
7490
7540
  }));
7491
- devices.sort((a, b) => b.usedBytes - a.usedBytes);
7541
+ warnOnUsageMismatch(deps.logger, totalUsedBytes, locationUsages.reduce((sum, l) => sum + l.usedBytes, 0), deviceUsage.read);
7492
7542
  return {
7493
7543
  nodeId: deps.nodeId,
7494
7544
  totalUsedBytes,
7495
- devices,
7545
+ devices: [...devices],
7546
+ devicesRead: deviceUsage.read,
7496
7547
  locations: locationUsages
7497
7548
  };
7498
7549
  },
@@ -8386,6 +8437,14 @@ var DurableLedger = class DurableLedger {
8386
8437
  //#endregion
8387
8438
  //#region src/recorder/addon/segment-hour-ledger.ts
8388
8439
  var HOUR_MS$3 = 36e5;
8440
+ /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8441
+ function minOf(a, b) {
8442
+ return a === null ? b : Math.min(a, b);
8443
+ }
8444
+ /** `Math.max` with the same rule as {@link minOf}. */
8445
+ function maxOf(a, b) {
8446
+ return a === null ? b : Math.max(a, b);
8447
+ }
8389
8448
  var macrotask$1 = () => new Promise((resolve) => {
8390
8449
  setImmediate(resolve);
8391
8450
  });
@@ -9036,7 +9095,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9036
9095
  *
9037
9096
  * The reader repairs the durable index it just reconstructed, so the next
9038
9097
  * cold read of this hour is a point read instead of another `readdir`
9039
- * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
9098
+ * (`docs/design/2026-08-29-reference-nvr-recording-model.md` §4.2). Two
9040
9099
  * constraints, both enforced by the caller and re-stated here because
9041
9100
  * getting either wrong turns a READ into durable damage: the hour must be
9042
9101
  * CLOSED (a live hour's directory races the writer, so a walk of it is a
@@ -9269,6 +9328,53 @@ var SegmentHourLedger = class SegmentHourLedger {
9269
9328
  for (const row of this.ledger.snapshot()) if (locationIds.has(row.locationId)) out.add(row.deviceId);
9270
9329
  return out;
9271
9330
  }
9331
+ /**
9332
+ * Bytes / segments / oldest / newest per CAMERA on these locations — the
9333
+ * per-camera half of the storage page, answered by the ARCHIVE (D418).
9334
+ *
9335
+ * ## Why the T1 fold and not `census({ deviceId })` per camera
9336
+ *
9337
+ * `census` is the right authority for a SCOPE, and it cannot express this
9338
+ * scope: {@link SegmentHourLedger.predicate} takes ONE `locationId`, so
9339
+ * "camera 615 across the three resolvable volumes" is three aggregates, and
9340
+ * `census({ deviceId })` alone would silently count footage stranded on a
9341
+ * location id that no longer resolves — bytes no volume row on the same page
9342
+ * accounts for. Per camera per volume that is `cameras × volumes` SQL
9343
+ * aggregates for one operator click, and it grows with the fleet.
9344
+ *
9345
+ * The T1 hour summary answers all of them in ONE pass with no I/O, exactly
9346
+ * as {@link devicesWithFootage} and {@link profilesWithFootage} already do,
9347
+ * and it is the same rows `accountingForLocations` sums in SQL — the mirror
9348
+ * is write-behind, so it is never BEHIND the store. The cost is one pass
9349
+ * over the hour rows the mirror holds — O(writers × profiles × hours), ~19 k
9350
+ * on the live hub (see {@link REPAIR_MAX_ROWS}) against 98 445 segments,
9351
+ * i.e. it does NOT scale with the segment count — and it is capped for good
9352
+ * by {@link SEGMENT_HOUR_SUMMARY_LIMIT}, the mirror's own ceiling.
9353
+ *
9354
+ * ## What an empty answer means
9355
+ *
9356
+ * The same thing it means for {@link devicesWithFootage}: "the mirror knows
9357
+ * no hour here" — which is also what a ledger whose load failed reports. The
9358
+ * caller must not render that as "no camera has footage"; `getStorageUsage`
9359
+ * cross-checks the per-camera sum against the volume aggregate and SAYS so
9360
+ * when the two disagree.
9361
+ */
9362
+ usageByDevice(locationIds) {
9363
+ const out = /* @__PURE__ */ new Map();
9364
+ if (locationIds.size === 0) return out;
9365
+ for (const row of this.ledger.snapshot()) {
9366
+ if (!locationIds.has(row.locationId)) continue;
9367
+ const empty = row.segments === 0;
9368
+ const prev = out.get(row.deviceId);
9369
+ out.set(row.deviceId, {
9370
+ bytes: (prev?.bytes ?? 0) + row.bytes,
9371
+ segments: (prev?.segments ?? 0) + row.segments,
9372
+ oldestMs: empty ? prev?.oldestMs ?? null : minOf(prev?.oldestMs ?? null, row.minStartMs),
9373
+ newestMs: empty ? prev?.newestMs ?? null : maxOf(prev?.newestMs ?? null, row.maxStartMs)
9374
+ });
9375
+ }
9376
+ return out;
9377
+ }
9272
9378
  async oldestSegmentsScoped(locationIds, deviceIds, targetBytes) {
9273
9379
  const ids = [...locationIds];
9274
9380
  if (ids.length === 0 || targetBytes <= 0) return {
@@ -14528,7 +14634,7 @@ var RecordingController = class {
14528
14634
  /**
14529
14635
  * The ADAPTIVE attach bound. A transient `spawn ffmpeg` failure (EAGAIN) is
14530
14636
  * the host saying it cannot fork right now, and the answer is to attach
14531
- * FEWER cameras at once — not to serialise them, which `e2fa17a2b` removed
14637
+ * FEWER cameras at once — not to serialise them, which removed
14532
14638
  * for good reason. Every bounded-parallel attach in this file reads it.
14533
14639
  */
14534
14640
  spawnPressure;
@@ -16033,7 +16139,7 @@ function selectRedundantSegments(segs) {
16033
16139
  * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
16034
16140
  * from `runFullArchiveWalk`, which was reachable only from
16035
16141
  * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
16036
- * (commit `9308428b4`) all three were deleted together as dead code. That was
16142
+ * all three were deleted together as dead code. That was
16037
16143
  * correct about the walk and left the system with **no duplicate pruner
16038
16144
  * whatsoever**. The precedent for letting this class of waste run unattended is
16039
16145
  * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
@@ -16977,11 +17083,15 @@ var RecorderV2Addon = class extends BaseAddon {
16977
17083
  /**
16978
17084
  * Stops the GC-attributing stall monitor armed in {@link onInitialize}.
16979
17085
  *
16980
- * REUSED, not re-implemented, and not promoted: `startEventLoopStallMonitor`
16981
- * already ships inside `@camstack/addon-pipeline` — the same bundle as this
16982
- * addon — and moving it into `@camstack/system` would duplicate a mechanism
16983
- * that already exists (forbidden) or relocate it across a package boundary,
16984
- * which is a refactor with a slower ship chain, not a measurement.
17086
+ * REUSED, never re-implemented. It used to live next door in this bundle;
17087
+ * on 2026-09-09 it moved to `@camstack/system/addon-utils` (the subpath, never
17088
+ * the barrel — that one drags the kernel into the runner) because two more
17089
+ * processes that do NOT share this bundle needed it — `pipeline-analytics` and
17090
+ * hub-main — and a per-bundle copy is how two diagnostics end up disagreeing
17091
+ * about the same freeze. The cost of the move is a slower ship chain: this
17092
+ * import is host-resolved, so a `camstack deploy` of this addon onto a hub
17093
+ * whose server closure predates the export fails to load. Deploy order is
17094
+ * closure first, addons second. See D417.
16985
17095
  */
16986
17096
  stopStallMonitor = null;
16987
17097
  /**
@@ -17440,6 +17550,7 @@ var RecorderV2Addon = class extends BaseAddon {
17440
17550
  refreshLocations: () => this.refreshLocations(),
17441
17551
  capacity: (root) => this.locationCapacity(root),
17442
17552
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
17553
+ archiveDeviceUsage: (locationIds) => this.segmentHours?.usageByDevice(locationIds) ?? null,
17443
17554
  archive: () => this.segmentHours,
17444
17555
  ledgerWalk: this.ledgerWalkReconciler(),
17445
17556
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
@@ -1,5 +1,5 @@
1
1
  import { r as __toESM, t as __commonJSMin } from "./chunk-DnnnRqeS.mjs";
2
- import { It as errMsg } from "./dist-DttqKvsQ.mjs";
2
+ import { It as errMsg } from "./dist-DgeXZfwn.mjs";
3
3
  import { spawn } from "node:child_process";
4
4
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
5
5
  var require_mp4box_all = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -1,4 +1,4 @@
1
- const require_dist = require("./dist-DoHcimdu.js");
1
+ const require_dist = require("./dist-D7hxiBnC.js");
2
2
  let node_child_process = require("node:child_process");
3
3
  //#region ../../node_modules/mp4box/dist/mp4box.all.js
4
4
  var require_mp4box_all = /* @__PURE__ */ require_dist.__commonJSMin(((exports) => {