@camstack/addon-pipeline 1.2.135 → 1.2.137

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 (41) hide show
  1. package/dist/{addon-utils-CKf8kbwh.js → addon-utils-C6b5Tnia.js} +3 -3
  2. package/dist/audio-analyzer/index.js +5 -5
  3. package/dist/audio-analyzer/index.mjs +4 -4
  4. package/dist/detection-pipeline/index.js +55 -17
  5. package/dist/detection-pipeline/index.mjs +53 -15
  6. package/dist/{dist-BzLiQ8Ue.js → dist-4EMKvxCp.js} +275 -25
  7. package/dist/{dist-C1dXLLQq.mjs → dist-g0Or5_pR.mjs} +270 -26
  8. package/dist/{event-loop-stall-monitor-CU-xxX5c.mjs → event-loop-stall-monitor-CskL9Lgu.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-DjV4Bow7.js → event-loop-stall-monitor-DDm07Ei6.js} +1 -1
  10. package/dist/{lazy-sharp-DxWmhTqk.js → lazy-sharp-BlA9PxWk.js} +3 -3
  11. package/dist/motion-wasm/index.js +3 -3
  12. package/dist/motion-wasm/index.mjs +3 -3
  13. package/dist/node-BOHn8gpk.js +721 -0
  14. package/dist/node-Bo1g8hLO.mjs +690 -0
  15. package/dist/pipeline-runner/index.js +37 -11
  16. package/dist/pipeline-runner/index.mjs +37 -11
  17. package/dist/{process-memory-CZK3D3kM.js → process-memory-C3084DOt.js} +2 -2
  18. package/dist/{process-memory-CaEwi5Cd.mjs → process-memory-DmrHWybb.mjs} +2 -2
  19. package/dist/recorder/index.js +382 -35
  20. package/dist/recorder/index.mjs +382 -35
  21. package/dist/{segment-demux-js-BbpqygHL.js → segment-demux-js-BrQ_A0oi.js} +1 -1
  22. package/dist/{segment-demux-js-njRO85gd.mjs → segment-demux-js-C-a2pghM.mjs} +1 -1
  23. package/dist/session-decode/decode-worker-child.js +2 -2
  24. package/dist/session-decode/decode-worker-child.mjs +2 -2
  25. package/dist/stream-broker/_stub.js +2 -2
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C14Qqrqn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-AH6OJ3hJ.mjs} +3 -3
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CQyiEd0l.mjs +26 -0
  28. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BrTM_tsb.mjs +26 -0
  29. package/dist/stream-broker/demux-worker-child.js +1 -1
  30. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  31. package/dist/stream-broker/{hostInit-kjsveCjG.mjs → hostInit-XNyn-FTM.mjs} +3 -3
  32. package/dist/stream-broker/index.js +88 -515
  33. package/dist/stream-broker/index.mjs +85 -510
  34. package/dist/stream-broker/remoteEntry.js +1 -1
  35. package/dist/{worker-protocol-DvTZBvh2.js → worker-protocol-BLfaD5uC.js} +1 -1
  36. package/dist/{worker-protocol-DxsVx0G7.mjs → worker-protocol-vc8WWhjx.mjs} +1 -1
  37. package/package.json +17 -3
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs +0 -26
  39. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DKMXG57X.mjs +0 -26
  40. package/dist/{addon-utils-CgBCF-mE.mjs → addon-utils-C8KTuPdE.mjs} +2 -2
  41. package/dist/{lazy-sharp-BtT5x-qP.mjs → lazy-sharp-DXsqwpph.mjs} +1 -1
@@ -1,13 +1,14 @@
1
- const require_dist = require("../dist-BzLiQ8Ue.js");
1
+ const require_dist = require("../dist-4EMKvxCp.js");
2
+ const require_node = require("../node-BOHn8gpk.js");
2
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
4
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
4
- const require_addon_utils = require("../addon-utils-CKf8kbwh.js");
5
+ const require_addon_utils = require("../addon-utils-C6b5Tnia.js");
5
6
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
6
7
  let node_crypto = require("node:crypto");
7
- let node_child_process = require("node:child_process");
8
+ let node_fs = require("node:fs");
8
9
  let node_path = require("node:path");
9
10
  node_path = require_dist.__toESM(node_path);
10
- let node_fs = require("node:fs");
11
+ let node_child_process = require("node:child_process");
11
12
  let node_fs_promises = require("node:fs/promises");
12
13
  //#region src/recorder/event-capture.ts
13
14
  function markerFromMotion(data, pad) {
@@ -6225,6 +6226,7 @@ Object.freeze({
6225
6226
  "listProfiles",
6226
6227
  "listRuntimeNodes"
6227
6228
  ],
6229
+ "load-contribution": ["list"],
6228
6230
  "log-channels": ["list"],
6229
6231
  "log-destination": ["query"],
6230
6232
  "login-method": ["getLoginMethods"],
@@ -8734,6 +8736,38 @@ var RESTART_MAX_MS = 1e4;
8734
8736
  /** A process that ran at least this long is "stable" → reset the restart count,
8735
8737
  * so sporadic blips over a long lifetime never accumulate into a give-up. */
8736
8738
  var STABLE_RUN_MS = 3e4;
8739
+ /**
8740
+ * The window the SECOND budget counts over — and it counts EVERY restart,
8741
+ * whatever that run lasted.
8742
+ *
8743
+ * `STABLE_RUN_MS` above is a reset, and a reset is a blind spot: measured on
8744
+ * 2026-08-27, `SegmentWriter restarting` fired **1 814 times in 12 h** and
8745
+ * almost every `ranMs` was over 30 s (122 620 / 165 255 / 345 420 / 394 522 ms
8746
+ * are four consecutive real samples), so `attempt` never left 1, `onGaveUp`
8747
+ * never fired, and `recoverWriterGaveUp` never ran. The storm could have lasted
8748
+ * forever without a single line saying anything was wrong.
8749
+ *
8750
+ * A window cannot be zeroed by a long run, which is the whole point. Modelled
8751
+ * on `CrashSupervisor` (kernel, D6): timestamps age out of `windowMs`, and the
8752
+ * count inside it is the budget.
8753
+ */
8754
+ var RESTART_WINDOW_MS = 60 * 6e4;
8755
+ /**
8756
+ * Restarts inside {@link RESTART_WINDOW_MS} before the writer stops respawning
8757
+ * and escalates to the controller.
8758
+ *
8759
+ * 15/hour, from the measured distribution. Per writer over the same 12 h:
8760
+ * 1441/high 549 (**45.8/h**), 615/high 241 (20.1/h), 618/high 217 (18.1/h),
8761
+ * 592/high 211 (17.6/h), 590/high 195 (16.3/h) — every one of them a storm and
8762
+ * every one of them silent — against `low` profiles at 4–15 per 12 h (≤1.3/h),
8763
+ * which is what a healthy writer looks like. 15 separates them with room for a
8764
+ * camera that blips hourly and is otherwise fine.
8765
+ *
8766
+ * It never shadows {@link MAX_RESTARTS}: 10 CONSECUTIVE rapid failures happen
8767
+ * inside ~90 s, so the rapid breaker still trips first on a hard failure. This
8768
+ * one exists for the slow storm the rapid breaker structurally cannot see.
8769
+ */
8770
+ var MAX_RESTARTS_IN_WINDOW = 15;
8737
8771
  /** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
8738
8772
  var KILL_GRACE_MS = 500;
8739
8773
  /** Supervises one passthrough ffmpeg for a single (camera, profile). */
@@ -8743,10 +8777,22 @@ var SegmentWriter = class {
8743
8777
  proc = null;
8744
8778
  stopped = false;
8745
8779
  restarts = 0;
8780
+ /**
8781
+ * Epoch-ms of every termination still inside {@link RESTART_WINDOW_MS}.
8782
+ * Replaced, never mutated in place, so a reader can never observe a
8783
+ * half-pruned window.
8784
+ */
8785
+ restartWindow = [];
8746
8786
  restartTimer = null;
8747
8787
  startedAt = 0;
8748
8788
  exitWait = null;
8749
8789
  resolveExit = null;
8790
+ /**
8791
+ * The cost claim for the CURRENT ffmpeg generation. Taken in `start()` and
8792
+ * released in `onTermination()` — the two edges of one process, so a restart
8793
+ * storm cannot accumulate claims for children that are already gone.
8794
+ */
8795
+ costClaim = require_node.NO_COST_CLAIM;
8750
8796
  constructor(cfg, deps) {
8751
8797
  this.cfg = cfg;
8752
8798
  this.deps = deps;
@@ -8760,6 +8806,7 @@ var SegmentWriter = class {
8760
8806
  this.resolveExit = resolve;
8761
8807
  });
8762
8808
  this.startedAt = Date.now();
8809
+ this.costClaim = this.deps.claimCost?.(proc.pid) ?? require_node.NO_COST_CLAIM;
8763
8810
  const stderrTail = [];
8764
8811
  proc.stderr?.on("data", (chunk) => {
8765
8812
  const text = chunk instanceof Buffer ? chunk.toString("utf8") : String(chunk);
@@ -8776,10 +8823,11 @@ var SegmentWriter = class {
8776
8823
  settled = true;
8777
8824
  this.onTermination(termination, stderrTail);
8778
8825
  };
8779
- proc.on("exit", (code) => {
8826
+ proc.on("exit", (code, signal) => {
8780
8827
  terminate({
8781
8828
  reason: "exit",
8782
- exitCode: typeof code === "number" ? code : null
8829
+ exitCode: typeof code === "number" ? code : null,
8830
+ signal: typeof signal === "string" ? signal : null
8783
8831
  });
8784
8832
  });
8785
8833
  proc.on("error", (raw) => {
@@ -8795,32 +8843,53 @@ var SegmentWriter = class {
8795
8843
  /**
8796
8844
  * The ONE restart policy, reached from both `exit` and `error`.
8797
8845
  *
8798
- * `deps.logger` is the device-scoped child the controller binds
8799
- * (`logger.withTags({ deviceId })`), so every line below carries
8800
- * `tags: { deviceId }` "why is 617 worse than 615" is always asked per
8801
- * camera, and a writer that stops recording one camera must be greppable by
8802
- * that camera.
8846
+ * Every line below carries `tags: { deviceId }` from `deps.deviceId` "why
8847
+ * is 617 worse than 615" is always asked per camera, and a writer that stops
8848
+ * recording one camera must be greppable by that camera.
8803
8849
  */
8804
8850
  onTermination(termination, stderrTail) {
8851
+ this.costClaim.release();
8852
+ this.costClaim = require_node.NO_COST_CLAIM;
8805
8853
  this.resolveExit?.();
8806
8854
  this.resolveExit = null;
8807
8855
  this.proc = null;
8808
8856
  if (this.stopped) return;
8809
- const ranMs = Date.now() - this.startedAt;
8857
+ const now = Date.now();
8858
+ const ranMs = now - this.startedAt;
8810
8859
  if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
8811
- if (termination.reason === "spawn-error") {
8812
- this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", { meta: {
8860
+ this.restartWindow = [...this.restartWindow.filter((at) => at > now - RESTART_WINDOW_MS), now];
8861
+ const restartsInWindow = this.restartWindow.length;
8862
+ if (termination.reason === "exit") this.deps.logger.warn("SegmentWriter ffmpeg exited", {
8863
+ tags: { deviceId: this.deps.deviceId },
8864
+ meta: {
8813
8865
  outDir: this.cfg.outDir,
8814
- errorCode: termination.errorCode,
8815
- error: termination.message,
8816
- permanent: termination.permanent
8817
- } });
8818
- if (termination.permanent) {
8819
- this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", { meta: {
8866
+ exitCode: termination.exitCode,
8867
+ signal: termination.signal,
8868
+ ranMs,
8869
+ restartsInWindow,
8870
+ windowMs: RESTART_WINDOW_MS,
8871
+ stderrTail: stderrTail.join(" | ")
8872
+ }
8873
+ });
8874
+ if (termination.reason === "spawn-error") {
8875
+ this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", {
8876
+ tags: { deviceId: this.deps.deviceId },
8877
+ meta: {
8820
8878
  outDir: this.cfg.outDir,
8821
8879
  errorCode: termination.errorCode,
8822
- error: termination.message
8823
- } });
8880
+ error: termination.message,
8881
+ permanent: termination.permanent
8882
+ }
8883
+ });
8884
+ if (termination.permanent) {
8885
+ this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", {
8886
+ tags: { deviceId: this.deps.deviceId },
8887
+ meta: {
8888
+ outDir: this.cfg.outDir,
8889
+ errorCode: termination.errorCode,
8890
+ error: termination.message
8891
+ }
8892
+ });
8824
8893
  this.stopped = true;
8825
8894
  this.deps.onGaveUp?.();
8826
8895
  return;
@@ -8828,25 +8897,51 @@ var SegmentWriter = class {
8828
8897
  this.deps.onResourcePressure?.();
8829
8898
  }
8830
8899
  if (this.restarts >= MAX_RESTARTS) {
8831
- this.deps.logger.warn("SegmentWriter giving up after max restarts", { meta: {
8832
- outDir: this.cfg.outDir,
8833
- reason: termination.reason,
8834
- code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8835
- stderrTail: stderrTail.join(" | ")
8836
- } });
8900
+ this.deps.logger.warn("SegmentWriter giving up after max restarts", {
8901
+ tags: { deviceId: this.deps.deviceId },
8902
+ meta: {
8903
+ outDir: this.cfg.outDir,
8904
+ reason: termination.reason,
8905
+ code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8906
+ stderrTail: stderrTail.join(" | ")
8907
+ }
8908
+ });
8909
+ this.stopped = true;
8910
+ this.deps.onGaveUp?.();
8911
+ return;
8912
+ }
8913
+ if (restartsInWindow >= MAX_RESTARTS_IN_WINDOW) {
8914
+ this.deps.logger.warn("SegmentWriter giving up: restart storm", {
8915
+ tags: { deviceId: this.deps.deviceId },
8916
+ meta: {
8917
+ outDir: this.cfg.outDir,
8918
+ restartsInWindow,
8919
+ windowMs: RESTART_WINDOW_MS,
8920
+ budget: MAX_RESTARTS_IN_WINDOW,
8921
+ attempt: this.restarts,
8922
+ ranMs,
8923
+ reason: termination.reason,
8924
+ code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8925
+ stderrTail: stderrTail.join(" | ")
8926
+ }
8927
+ });
8837
8928
  this.stopped = true;
8838
8929
  this.deps.onGaveUp?.();
8839
8930
  return;
8840
8931
  }
8841
8932
  this.restarts++;
8842
8933
  const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
8843
- this.deps.logger.info("SegmentWriter restarting", { meta: {
8844
- outDir: this.cfg.outDir,
8845
- attempt: this.restarts,
8846
- delayMs,
8847
- ranMs,
8848
- reason: termination.reason
8849
- } });
8934
+ this.deps.logger.info("SegmentWriter restarting", {
8935
+ tags: { deviceId: this.deps.deviceId },
8936
+ meta: {
8937
+ outDir: this.cfg.outDir,
8938
+ attempt: this.restarts,
8939
+ restartsInWindow,
8940
+ delayMs,
8941
+ ranMs,
8942
+ reason: termination.reason
8943
+ }
8944
+ });
8850
8945
  this.restartTimer = setTimeout(() => {
8851
8946
  this.restartTimer = null;
8852
8947
  this.start();
@@ -10110,6 +10205,13 @@ var RecordingController = class {
10110
10205
  }, {
10111
10206
  spawn: this.deps.spawn,
10112
10207
  logger: deviceLog,
10208
+ deviceId,
10209
+ claimCost: (pid) => this.deps.costRegistry?.claim({
10210
+ role: "recording",
10211
+ deviceId,
10212
+ unit: `${deviceId}/${profile}`,
10213
+ pid
10214
+ }) ?? require_node.NO_COST_CLAIM,
10113
10215
  onGaveUp: () => {
10114
10216
  this.recoverWriterGaveUp(deviceId, profile);
10115
10217
  },
@@ -10533,6 +10635,209 @@ function buildRecordingExportProvider(deps) {
10533
10635
  */
10534
10636
  var resolveRecordingHubHostname = require_hub_hostname.resolveHubHostname;
10535
10637
  //#endregion
10638
+ //#region src/recorder/redundant-segments.ts
10639
+ /**
10640
+ * Return the indices (into `segs`) of segments safe to delete: those fully
10641
+ * contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
10642
+ * ascending, then by end DESCENDING, guarantees the containing (longer) segment
10643
+ * is seen first and kept, and any shorter segment nested inside it is removed.
10644
+ */
10645
+ function selectRedundantSegments(segs) {
10646
+ if (segs.length < 2) return [];
10647
+ const order = segs.map((s, i) => ({
10648
+ start: s.startMs,
10649
+ end: s.startMs + s.durMs,
10650
+ i
10651
+ })).toSorted((a, b) => a.start - b.start || b.end - a.end);
10652
+ const remove = [];
10653
+ let coverStart = Number.NEGATIVE_INFINITY;
10654
+ let coverEnd = Number.NEGATIVE_INFINITY;
10655
+ for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
10656
+ else {
10657
+ coverStart = seg.start;
10658
+ coverEnd = seg.end;
10659
+ }
10660
+ return remove;
10661
+ }
10662
+ //#endregion
10663
+ //#region src/recorder/addon/redundancy-sweep.ts
10664
+ /**
10665
+ * Periodic redundancy sweep — the only thing that removes duplicated footage.
10666
+ *
10667
+ * WHY IT EXISTS AGAIN
10668
+ * ───────────────────
10669
+ * Every `SegmentWriter` restart re-dials the broker with `withRecordingIntent`,
10670
+ * and the broker answers a RECORDING dial with its pre-roll ring (≈10 s). So a
10671
+ * restart does not resume at the live edge: it re-writes ~10 s of media that is
10672
+ * already on disk. At the 2026-08-27 measurement — **1 814 restarts in 12 h** —
10673
+ * that is ~5 hours of duplicated `high`-profile footage per day, on a fuse
10674
+ * share that was already at 90 % full.
10675
+ *
10676
+ * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
10677
+ * from `runFullArchiveWalk`, which was reachable only from
10678
+ * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
10679
+ * (commit `9308428b4`) all three were deleted together as dead code. That was
10680
+ * correct about the walk and left the system with **no duplicate pruner
10681
+ * whatsoever**. The precedent for letting this class of waste run unattended is
10682
+ * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
10683
+ *
10684
+ * WHAT IS DIFFERENT FROM THE ONE THAT WAS DELETED
10685
+ * ───────────────────────────────────────────────
10686
+ * The old janitor ran once, after a walk of the WHOLE archive, and asked the
10687
+ * in-RAM index for `segments(deviceId)` — a full copy plus a sort of every row
10688
+ * the device owns. At the live shape (7.1 M rows) that copy alone was measured
10689
+ * at ~560 MB of transient heap, which is precisely the cost D248 had just
10690
+ * finished removing from the pressure sweep. Reconnecting it in that shape
10691
+ * would have traded one regression for another.
10692
+ *
10693
+ * So this one is bounded by a LOOKBACK WINDOW, not by the archive: duplicates
10694
+ * are made by a restart, restarts are now, and `segmentsStartingIn` slices the
10695
+ * sorted view instead of copying it. Cost grows with footage RECORDED in the
10696
+ * window — the invariant the recorder is supposed to satisfy — never with rows
10697
+ * already indexed.
10698
+ *
10699
+ * WHAT IT WILL NOT DO
10700
+ * ───────────────────
10701
+ * It never deletes a segment that contributes unique coverage:
10702
+ * {@link selectRedundantSegments} selects only segments fully contained inside
10703
+ * another segment that is KEPT, and staggered partial overlaps are always kept.
10704
+ * The delete itself is `SegmentStore.evict` — the same audited path
10705
+ * disk-pressure eviction uses, so a removed segment is de-indexed exactly as an
10706
+ * evicted one is. A failure for one (device, profile, location) is logged and
10707
+ * never aborts the pass.
10708
+ */
10709
+ /** The profiles a recorder writes. Same set `staging-reconcile` sweeps. */
10710
+ var SWEPT_PROFILES = [
10711
+ "high",
10712
+ "mid",
10713
+ "low"
10714
+ ];
10715
+ /** Group rows by the location that owns them. `evict` is per-location. */
10716
+ function byLocation(rows) {
10717
+ const out = /* @__PURE__ */ new Map();
10718
+ for (const row of rows) {
10719
+ const found = out.get(row.locationId);
10720
+ if (found) found.push(row);
10721
+ else out.set(row.locationId, [row]);
10722
+ }
10723
+ return out;
10724
+ }
10725
+ /**
10726
+ * Owns its timer and a single-flight guard, the same shape the retention sweep
10727
+ * and the export janitor already use — a slow pass is never overlapped by the
10728
+ * next tick.
10729
+ */
10730
+ var RedundancySweeper = class {
10731
+ deps;
10732
+ timer = null;
10733
+ sweeping = false;
10734
+ constructor(deps) {
10735
+ this.deps = deps;
10736
+ }
10737
+ /**
10738
+ * Arm the sweep on its interval. Idempotent.
10739
+ *
10740
+ * Deliberately NOT run immediately: boot is when the writers are attaching,
10741
+ * the staging reconcile is relocating thousands of files over shfs and the
10742
+ * first viewer is painting. A duplicate that has waited an hour can wait
10743
+ * another one; the write path cannot.
10744
+ */
10745
+ start() {
10746
+ if (this.timer !== null) return;
10747
+ this.timer = setInterval(() => {
10748
+ this.sweep();
10749
+ }, this.deps.intervalMs);
10750
+ this.timer.unref?.();
10751
+ }
10752
+ /** Stop the timer. Idempotent. An in-flight pass still completes. */
10753
+ stop() {
10754
+ if (this.timer !== null) {
10755
+ clearInterval(this.timer);
10756
+ this.timer = null;
10757
+ }
10758
+ }
10759
+ /**
10760
+ * One pass. Never throws: a device-list read failure aborts THIS pass
10761
+ * (logged), a per-location evict failure is logged and the pass continues.
10762
+ */
10763
+ async sweep() {
10764
+ const empty = {
10765
+ files: 0,
10766
+ bytes: 0,
10767
+ devices: 0
10768
+ };
10769
+ if (this.sweeping) {
10770
+ this.deps.logger.debug("recorder: redundancy sweep already running — skipping this tick");
10771
+ return empty;
10772
+ }
10773
+ this.sweeping = true;
10774
+ try {
10775
+ let deviceIds;
10776
+ try {
10777
+ deviceIds = await this.deps.deviceIds();
10778
+ } catch (err) {
10779
+ this.deps.logger.warn("recorder: redundancy sweep could not read the device list", { meta: { error: require_dist.errMsg(err) } });
10780
+ return empty;
10781
+ }
10782
+ const toMs = (this.deps.now ?? Date.now)();
10783
+ const fromMs = toMs - this.deps.lookbackMs;
10784
+ let files = 0;
10785
+ let bytes = 0;
10786
+ let devices = 0;
10787
+ for (const deviceId of deviceIds) {
10788
+ let removedHere = 0;
10789
+ for (const profile of SWEPT_PROFILES) {
10790
+ const rows = this.deps.segmentsInWindow(deviceId, profile, fromMs, toMs);
10791
+ const redundant = selectRedundantSegments(rows);
10792
+ if (redundant.length === 0) continue;
10793
+ const victims = redundant.map((i) => rows[i]).filter((row) => row !== void 0);
10794
+ for (const [locationId, group] of byLocation(victims)) try {
10795
+ const reclaimed = await this.deps.evict(locationId, group);
10796
+ files += group.length;
10797
+ bytes += reclaimed;
10798
+ removedHere += group.length;
10799
+ } catch (err) {
10800
+ this.deps.logger.warn("recorder: redundancy sweep could not evict duplicates", {
10801
+ tags: { deviceId },
10802
+ meta: {
10803
+ profile,
10804
+ locationId,
10805
+ count: group.length,
10806
+ error: require_dist.errMsg(err)
10807
+ }
10808
+ });
10809
+ }
10810
+ }
10811
+ if (removedHere > 0) {
10812
+ devices += 1;
10813
+ this.deps.logger.info("recorder: removed duplicated segments for device", {
10814
+ tags: { deviceId },
10815
+ meta: {
10816
+ files: removedHere,
10817
+ fromMs,
10818
+ toMs
10819
+ }
10820
+ });
10821
+ }
10822
+ }
10823
+ this.deps.logger.info("recorder: redundancy sweep complete", { meta: {
10824
+ devices,
10825
+ files,
10826
+ bytes,
10827
+ lookbackMs: this.deps.lookbackMs,
10828
+ deviceCount: deviceIds.length
10829
+ } });
10830
+ return {
10831
+ files,
10832
+ bytes,
10833
+ devices
10834
+ };
10835
+ } finally {
10836
+ this.sweeping = false;
10837
+ }
10838
+ }
10839
+ };
10840
+ //#endregion
10536
10841
  //#region src/recorder/addon/retention-sweep.ts
10537
10842
  /**
10538
10843
  * Periodic footage-retention sweep (recording-spec §6).
@@ -12093,6 +12398,18 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
12093
12398
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
12094
12399
  * the recording-spec §6 periodic retention. */
12095
12400
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
12401
+ /** How often the redundancy sweep removes footage a writer restart duplicated. */
12402
+ var REDUNDANCY_SWEEP_INTERVAL_MS = 60 * 6e4;
12403
+ /**
12404
+ * How far back each redundancy pass looks.
12405
+ *
12406
+ * Twice the interval, so a pass that is skipped (single-flight) or delayed by a
12407
+ * slow predecessor still covers the window its predecessor was supposed to.
12408
+ * It is deliberately NOT "the whole archive": duplicates are made by a writer
12409
+ * restart, restarts are recent, and a full-archive selection is the ~560 MB
12410
+ * transient copy D248 removed. See `redundancy-sweep.ts`.
12411
+ */
12412
+ var REDUNDANCY_SWEEP_LOOKBACK_MS = 2 * REDUNDANCY_SWEEP_INTERVAL_MS;
12096
12413
  /** What the per-volume usage row reports when the hour ledger is unavailable:
12097
12414
  * nothing measured, rather than a total counted off a partial RAM index. */
12098
12415
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -12118,6 +12435,16 @@ function isSegmentStoreLogExtras(extras) {
12118
12435
  }
12119
12436
  var RecorderV2Addon = class extends require_dist.BaseAddon {
12120
12437
  nodeId = "hub";
12438
+ /**
12439
+ * The children this addon started, claimed at the spawn site and released at
12440
+ * their exit. It is what `load-contribution` reports: the recorder knows it
12441
+ * ran an ffmpeg for camera 615 profile `high`, so nobody else has to work it
12442
+ * out from a process table.
12443
+ *
12444
+ * One registry per PROCESS, and it dies with the process — which is correct,
12445
+ * because those children were this process's children.
12446
+ */
12447
+ costRegistry = new require_node.ChildCostRegistry();
12121
12448
  /** True when THIS node is the designated recording node (`recordingNodeId`).
12122
12449
  * Every other node stays fully inert — registers no providers, serves no
12123
12450
  * playback, runs no controller. Resolved in `onInitialize`. */
@@ -12245,6 +12572,9 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12245
12572
  /** Periodic footage-retention sweep (owns its timer + single-flight guard).
12246
12573
  * Constructed + started in `onHubReachable`, stopped on shutdown. */
12247
12574
  retentionSweeper = null;
12575
+ /** Periodic duplicate-footage sweep — the ONLY pruner of media a writer
12576
+ * restart re-wrote. Constructed + started in `onHubReachable`. */
12577
+ redundancySweeper = null;
12248
12578
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
12249
12579
  storageMigrationLeaseId = null;
12250
12580
  /**
@@ -12461,6 +12791,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12461
12791
  placeProfile: (deviceId, profile) => this.placeProfile(deviceId, profile),
12462
12792
  onPlacementTick: (deviceIds) => this.recomputePlacement(deviceIds),
12463
12793
  spawn: node_child_process.spawn,
12794
+ costRegistry: this.costRegistry,
12464
12795
  segmentSeconds: this.config.segmentSeconds,
12465
12796
  watchIntervalMs: this.config.watchIntervalMs,
12466
12797
  ...consumerHostname !== void 0 ? { consumerHostname } : {},
@@ -12709,6 +13040,10 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12709
13040
  {
12710
13041
  capability: require_dist.recordingExportCapability,
12711
13042
  provider: exportProvider
13043
+ },
13044
+ {
13045
+ capability: require_dist.loadContributionCapability,
13046
+ provider: { list: () => this.costRegistry.contributions() }
12712
13047
  }
12713
13048
  ],
12714
13049
  customActions: recorderDebugActions,
@@ -12733,6 +13068,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12733
13068
  }
12734
13069
  this.retentionSweeper?.stop();
12735
13070
  this.retentionSweeper = null;
13071
+ this.redundancySweeper?.stop();
13072
+ this.redundancySweeper = null;
12736
13073
  this.exportEngine = null;
12737
13074
  this.recordingProvider = null;
12738
13075
  }
@@ -12872,6 +13209,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12872
13209
  intervalMs: RETENTION_SWEEP_INTERVAL_MS
12873
13210
  });
12874
13211
  this.retentionSweeper?.start();
13212
+ const store = this.segmentStore;
13213
+ if (store && this.redundancySweeper === null) this.redundancySweeper = new RedundancySweeper({
13214
+ deviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
13215
+ segmentsInWindow: (deviceId, profile, fromMs, toMs) => this.index.segmentsStartingIn(deviceId, profile, fromMs, toMs),
13216
+ evict: (locationId, rows) => store.evict(locationId, rows),
13217
+ logger: this.ctx.logger,
13218
+ intervalMs: REDUNDANCY_SWEEP_INTERVAL_MS,
13219
+ lookbackMs: REDUNDANCY_SWEEP_LOOKBACK_MS
13220
+ });
13221
+ this.redundancySweeper?.start();
12875
13222
  }
12876
13223
  /**
12877
13224
  * Rebuild the sensor trigger's reverse index from the persisted configs.