@camstack/addon-pipeline 1.2.134 → 1.2.136

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 (37) hide show
  1. package/dist/{addon-utils-Cg_Sl2UR.js → addon-utils-CKf8kbwh.js} +1 -1
  2. package/dist/audio-analyzer/index.js +3 -3
  3. package/dist/audio-analyzer/index.mjs +2 -2
  4. package/dist/detection-pipeline/index.js +5 -5
  5. package/dist/detection-pipeline/index.mjs +3 -3
  6. package/dist/{dist-BnwP1K09.js → dist-BzLiQ8Ue.js} +612 -25
  7. package/dist/{dist-Dsn9NGO3.mjs → dist-C1dXLLQq.mjs} +595 -26
  8. package/dist/{event-loop-stall-monitor-CRdmHBpl.mjs → event-loop-stall-monitor-CU-xxX5c.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-DRpAtCOy.js → event-loop-stall-monitor-DjV4Bow7.js} +1 -1
  10. package/dist/{lazy-sharp-Div0sK_l.js → lazy-sharp-DxWmhTqk.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-C3Q8YXd_.js → process-memory-CZK3D3kM.js} +1 -1
  16. package/dist/{process-memory-B-zF-wcr.mjs → process-memory-CaEwi5Cd.mjs} +1 -1
  17. package/dist/recorder/index.js +703 -358
  18. package/dist/recorder/index.mjs +702 -357
  19. package/dist/{segment-demux-js-BOIExWSt.js → segment-demux-js-BbpqygHL.js} +1 -1
  20. package/dist/{segment-demux-js-BIyVwpSp.mjs → segment-demux-js-njRO85gd.mjs} +1 -1
  21. package/dist/session-decode/decode-worker-child.js +2 -2
  22. package/dist/session-decode/decode-worker-child.mjs +1 -1
  23. package/dist/stream-broker/_stub.js +2 -2
  24. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CASELLGa.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CLtMmF1E.mjs} +3 -3
  25. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqplWP1i.mjs +26 -0
  26. 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 +26 -0
  27. package/dist/stream-broker/demux-worker-child.js +1 -1
  28. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  29. package/dist/stream-broker/{hostInit-BjhhoD3z.mjs → hostInit-CP7Xhd8J.mjs} +3 -3
  30. package/dist/stream-broker/index.js +105 -7
  31. package/dist/stream-broker/index.mjs +105 -7
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-Bo2fIjZ2.js → worker-protocol-DvTZBvh2.js} +1 -1
  34. package/dist/{worker-protocol-CaQCRtsw.mjs → worker-protocol-DxsVx0G7.mjs} +1 -1
  35. package/package.json +8 -3
  36. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BF-ziHjH.mjs +0 -26
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-77rGxv1J.mjs +0 -26
@@ -1,7 +1,7 @@
1
- const require_dist = require("../dist-BnwP1K09.js");
1
+ const require_dist = require("../dist-BzLiQ8Ue.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
3
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
4
- const require_addon_utils = require("../addon-utils-Cg_Sl2UR.js");
4
+ const require_addon_utils = require("../addon-utils-CKf8kbwh.js");
5
5
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let node_child_process = require("node:child_process");
@@ -237,81 +237,6 @@ var EventMap = class {
237
237
  }
238
238
  };
239
239
  //#endregion
240
- //#region src/recorder/redundant-segments.ts
241
- /**
242
- * Return the indices (into `segs`) of segments safe to delete: those fully
243
- * contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
244
- * ascending, then by end DESCENDING, guarantees the containing (longer) segment
245
- * is seen first and kept, and any shorter segment nested inside it is removed.
246
- */
247
- function selectRedundantSegments(segs) {
248
- if (segs.length < 2) return [];
249
- const order = segs.map((s, i) => ({
250
- start: s.startMs,
251
- end: s.startMs + s.durMs,
252
- i
253
- })).toSorted((a, b) => a.start - b.start || b.end - a.end);
254
- const remove = [];
255
- let coverStart = Number.NEGATIVE_INFINITY;
256
- let coverEnd = Number.NEGATIVE_INFINITY;
257
- for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
258
- else {
259
- coverStart = seg.start;
260
- coverEnd = seg.end;
261
- }
262
- return remove;
263
- }
264
- //#endregion
265
- //#region src/recorder/janitor.ts
266
- /** Group an array by a string key. */
267
- function groupBy(items, key) {
268
- const out = /* @__PURE__ */ new Map();
269
- for (const it of items) {
270
- const k = key(it);
271
- const arr = out.get(k);
272
- if (arr) arr.push(it);
273
- else out.set(k, [it]);
274
- }
275
- return out;
276
- }
277
- async function runRedundancyJanitor(deps) {
278
- let files = 0;
279
- let bytes = 0;
280
- for (const deviceId of deps.deviceIds) {
281
- const byProfile = groupBy(deps.segmentsFor(deviceId), (r) => r.profile);
282
- for (const [profile, rows] of byProfile) {
283
- const sorted = [...rows].toSorted((a, b) => a.startMs - b.startMs);
284
- const redundant = selectRedundantSegments(sorted);
285
- if (redundant.length === 0) continue;
286
- const victims = redundant.map((i) => sorted[i]);
287
- for (const [location, vrows] of groupBy(victims, (v) => v.locationId)) try {
288
- const reclaimed = await deps.evict(location, vrows);
289
- files += vrows.length;
290
- bytes += reclaimed;
291
- } catch (err) {
292
- deps.logger.warn("janitor: evict failed (skipping)", {
293
- tags: { deviceId },
294
- meta: {
295
- deviceId,
296
- profile,
297
- location,
298
- count: vrows.length,
299
- error: err instanceof Error ? err.message : String(err)
300
- }
301
- });
302
- }
303
- }
304
- }
305
- if (files > 0) deps.logger.info("janitor: removed redundant recording segments", { meta: {
306
- files,
307
- bytes
308
- } });
309
- return {
310
- files,
311
- bytes
312
- };
313
- }
314
- //#endregion
315
240
  //#region src/recorder/init-info.ts
316
241
  /** Walk sibling boxes in [from, to), yielding [type, start, end]. */
317
242
  function* boxes$1(buf, view, from, to) {
@@ -1291,69 +1216,6 @@ var RecordingIndex = class {
1291
1216
  for (const m of this.byDevice.values()) for (const s of m.values()) if (s.locationId === locationId) out.push(s);
1292
1217
  return out.toSorted((a, b) => a.startMs - b.startMs);
1293
1218
  }
1294
- /**
1295
- * All segments across a SET of storage locations (one eviction DOMAIN — the
1296
- * locations that alias one physical filesystem root), oldest-first across the
1297
- * whole pool. This is the correct unit for byte-eviction: two location ids over
1298
- * the same disk must be reclaimed as ONE oldest-first stream, never partitioned.
1299
- */
1300
- segmentsOnLocations(locationIds) {
1301
- const out = [];
1302
- for (const m of this.byDevice.values()) for (const s of m.values()) if (locationIds.has(s.locationId)) out.push(s);
1303
- return out.toSorted((a, b) => a.startMs - b.startMs);
1304
- }
1305
- /** Aggregate accounting across a SET of storage locations (one eviction domain). */
1306
- accountingForLocations(locationIds) {
1307
- let bytes = 0;
1308
- let count = 0;
1309
- let oldestMs = Infinity;
1310
- let newestMs = -Infinity;
1311
- for (const m of this.byDevice.values()) for (const s of m.values()) {
1312
- if (!locationIds.has(s.locationId)) continue;
1313
- bytes += s.bytes;
1314
- count += 1;
1315
- if (s.startMs < oldestMs) oldestMs = s.startMs;
1316
- if (s.startMs > newestMs) newestMs = s.startMs;
1317
- }
1318
- if (count === 0) return {
1319
- bytes: 0,
1320
- count: 0,
1321
- oldestMs: null,
1322
- newestMs: null
1323
- };
1324
- return {
1325
- bytes,
1326
- count,
1327
- oldestMs,
1328
- newestMs
1329
- };
1330
- }
1331
- /** Aggregate accounting for a storage location across every device. */
1332
- accountingForLocation(locationId) {
1333
- let bytes = 0;
1334
- let count = 0;
1335
- let oldestMs = Infinity;
1336
- let newestMs = -Infinity;
1337
- for (const m of this.byDevice.values()) for (const s of m.values()) {
1338
- if (s.locationId !== locationId) continue;
1339
- bytes += s.bytes;
1340
- count += 1;
1341
- if (s.startMs < oldestMs) oldestMs = s.startMs;
1342
- if (s.startMs > newestMs) newestMs = s.startMs;
1343
- }
1344
- if (count === 0) return {
1345
- bytes: 0,
1346
- count: 0,
1347
- oldestMs: null,
1348
- newestMs: null
1349
- };
1350
- return {
1351
- bytes,
1352
- count,
1353
- oldestMs,
1354
- newestMs
1355
- };
1356
- }
1357
1219
  /** Binary-search a start-sorted slice for the segment containing epochMs. */
1358
1220
  segmentAtIn(segs, epochMs) {
1359
1221
  if (segs.length === 0) return null;
@@ -2256,12 +2118,36 @@ var StorageEvictableProvider = class {
2256
2118
  for (const id of domain) if (!this.deps.isReadOnly(id)) writable.add(id);
2257
2119
  return writable;
2258
2120
  }
2121
+ /**
2122
+ * How many bytes this domain holds — ONE aggregation, no rows returned.
2123
+ *
2124
+ * The storage-pressure sweep asks this per managed location every 60 s, and
2125
+ * two locations aliasing one root make it twice a minute. It used to be a
2126
+ * walk over every indexed segment on the node (7.1 M rows live), which is
2127
+ * also why the whole archive had to stay resident to be walked.
2128
+ */
2259
2129
  async getEvictableUsage(input) {
2260
- return { bytes: this.deps.index.accountingForLocations(this.domainOf(input.locationId)).bytes };
2130
+ const archive = this.deps.archive();
2131
+ if (archive === null) {
2132
+ this.deps.logger.warn("recorder: evictable usage unavailable — no footage archive to measure, reporting 0 evictable bytes", { meta: { locationId: input.locationId } });
2133
+ return { bytes: 0 };
2134
+ }
2135
+ return { bytes: (await archive.accountingForLocations(this.domainOf(input.locationId))).bytes };
2261
2136
  }
2262
2137
  async evict(input) {
2263
- const onDomain = this.deps.index.segmentsOnLocations(this.domainOf(input.locationId));
2264
- const victims = selectEvictionsByBytes(onDomain, input.targetBytes);
2138
+ const archive = this.deps.archive();
2139
+ if (archive === null) {
2140
+ this.deps.logger.warn("recorder: disk-pressure eviction skipped — no footage archive to pick the oldest from", { meta: {
2141
+ locationId: input.locationId,
2142
+ targetBytes: input.targetBytes
2143
+ } });
2144
+ return {
2145
+ reclaimedBytes: 0,
2146
+ exhausted: true
2147
+ };
2148
+ }
2149
+ const oldest = await archive.oldestSegments(this.domainOf(input.locationId), input.targetBytes);
2150
+ const victims = selectEvictionsByBytes(oldest.rows, input.targetBytes);
2265
2151
  const byLocation = /* @__PURE__ */ new Map();
2266
2152
  for (const v of victims) {
2267
2153
  const group = byLocation.get(v.locationId);
@@ -2271,7 +2157,7 @@ var StorageEvictableProvider = class {
2271
2157
  let reclaimedBytes = 0;
2272
2158
  for (const [locationId, rows] of byLocation) reclaimedBytes += await this.deps.store.evict(locationId, rows);
2273
2159
  if (reclaimedBytes > 0 && this.deps.onEvicted) this.deps.onEvicted(summarizeEvictionsByDevice(victims));
2274
- const exhausted = victims.length >= onDomain.length;
2160
+ const exhausted = oldest.exhausted && victims.length >= oldest.rows.length;
2275
2161
  return {
2276
2162
  reclaimedBytes,
2277
2163
  exhausted
@@ -3068,6 +2954,7 @@ var CENSUS_MAX_WINDOW_MS = 10 * 6e4;
3068
2954
  var CENSUSED_PROCEDURES = new Set([
3069
2955
  "query",
3070
2956
  "count",
2957
+ "aggregate",
3071
2958
  "get",
3072
2959
  "histogram",
3073
2960
  "getAll"
@@ -5558,7 +5445,7 @@ function buildRecordingProvider(deps) {
5558
5445
  locationId: first.id,
5559
5446
  locationIds: ids,
5560
5447
  root: first.root,
5561
- usedBytes: deps.index.accountingForLocations(new Set(ids)).bytes,
5448
+ usedBytes: (await deps.archiveAccounting?.(new Set(ids)))?.bytes ?? 0,
5562
5449
  availableBytes: cap.availableBytes,
5563
5450
  totalBytes: cap.totalBytes
5564
5451
  };
@@ -6338,6 +6225,7 @@ Object.freeze({
6338
6225
  "listProfiles",
6339
6226
  "listRuntimeNodes"
6340
6227
  ],
6228
+ "log-channels": ["list"],
6341
6229
  "log-destination": ["query"],
6342
6230
  "login-method": ["getLoginMethods"],
6343
6231
  "mqtt-broker": ["listBrokers"],
@@ -7088,61 +6976,6 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
7088
6976
  }
7089
6977
  /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
7090
6978
  var HYDRATE_CHUNK = 2e4;
7091
- /**
7092
- * Device walks in flight at once in {@link hydrateDevicesFromStorage}.
7093
- *
7094
- * The walk is `readdir` latency on a network/FUSE share, not CPU: measured on
7095
- * the live hub, 24 cold hour directories cost 31 s one at a time and 16.7 s
7096
- * eight at a time. Four is the conservative point on that curve — enough to
7097
- * hide most of the latency, few enough that the boot walk never becomes the
7098
- * reason a live SegmentWriter waits. libuv's default threadpool is 4, so a
7099
- * higher number here mostly queues.
7100
- */
7101
- var HYDRATE_DEVICE_CONCURRENCY = 4;
7102
- /**
7103
- * Walk the archive for a whole fleet: bounded-parallel, and each camera becomes
7104
- * authoritative the moment ITS OWN walk lands (D167).
7105
- *
7106
- * Two things this fixes, both measured on the live hub on 2026-08-15:
7107
- *
7108
- * - The caller was `for (const id of ids) await hydrateDeviceFromStorage(...)`,
7109
- * so seven cameras' 357 s of `readdir` WAS the 362 s wall clock. Nothing in
7110
- * the walk contends for CPU — it is share latency — so overlapping is free.
7111
- * - `markHydratedAll` ran in a SECOND loop after every device. A camera whose
7112
- * walk finished at minute 1 still reported `hydrationOf → 'unknown'` until
7113
- * minute 6, and every read of it in that window paid for an on-demand walk
7114
- * over rows already in the index, competing with the walk still running for
7115
- * the others.
7116
- *
7117
- * A device whose walk throws is NOT marked: `unknown` is the honest answer, and
7118
- * it is what keeps the read path walking that window on demand.
7119
- */
7120
- async function hydrateDevicesFromStorage(api, index, deviceIds, locations, logger, options = {}) {
7121
- const concurrency = Math.max(1, options.concurrency ?? HYDRATE_DEVICE_CONCURRENCY);
7122
- const queue = [...deviceIds];
7123
- const walkOptions = {
7124
- ...options.yieldBetween ? { yieldBetween: options.yieldBetween } : {},
7125
- ...options.chunkSize !== void 0 ? { chunkSize: options.chunkSize } : {}
7126
- };
7127
- const lane = async () => {
7128
- for (;;) {
7129
- const deviceId = queue.shift();
7130
- if (deviceId === void 0) return;
7131
- try {
7132
- options.onDeviceStarted?.(deviceId);
7133
- await hydrateDeviceFromStorage(api, index, deviceId, locations, logger, walkOptions);
7134
- index.markHydratedAll(deviceId);
7135
- options.onDeviceHydrated?.(deviceId);
7136
- } catch (err) {
7137
- logger.warn("recorder: device hydrate failed — window stays unknown, reads will walk it", {
7138
- tags: { deviceId },
7139
- meta: { error: require_dist.errMsg(err) }
7140
- });
7141
- }
7142
- }
7143
- };
7144
- await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, lane));
7145
- }
7146
6979
  var macrotask$1 = () => new Promise((resolve) => {
7147
6980
  setImmediate(resolve);
7148
6981
  });
@@ -7713,7 +7546,7 @@ var PlacementService = class {
7713
7546
  limits.push(capacity.availableBytes - floor);
7714
7547
  }
7715
7548
  if (location.maxUsedGb !== void 0) {
7716
- const used = this.deps.usedOnLocation?.(location.id) ?? 0;
7549
+ const used = await this.deps.usedOnLocation?.(location.id) ?? 0;
7717
7550
  limits.push(location.maxUsedGb * 1e9 - used);
7718
7551
  }
7719
7552
  out.push({
@@ -8901,6 +8734,38 @@ var RESTART_MAX_MS = 1e4;
8901
8734
  /** A process that ran at least this long is "stable" → reset the restart count,
8902
8735
  * so sporadic blips over a long lifetime never accumulate into a give-up. */
8903
8736
  var STABLE_RUN_MS = 3e4;
8737
+ /**
8738
+ * The window the SECOND budget counts over — and it counts EVERY restart,
8739
+ * whatever that run lasted.
8740
+ *
8741
+ * `STABLE_RUN_MS` above is a reset, and a reset is a blind spot: measured on
8742
+ * 2026-08-27, `SegmentWriter restarting` fired **1 814 times in 12 h** and
8743
+ * almost every `ranMs` was over 30 s (122 620 / 165 255 / 345 420 / 394 522 ms
8744
+ * are four consecutive real samples), so `attempt` never left 1, `onGaveUp`
8745
+ * never fired, and `recoverWriterGaveUp` never ran. The storm could have lasted
8746
+ * forever without a single line saying anything was wrong.
8747
+ *
8748
+ * A window cannot be zeroed by a long run, which is the whole point. Modelled
8749
+ * on `CrashSupervisor` (kernel, D6): timestamps age out of `windowMs`, and the
8750
+ * count inside it is the budget.
8751
+ */
8752
+ var RESTART_WINDOW_MS = 60 * 6e4;
8753
+ /**
8754
+ * Restarts inside {@link RESTART_WINDOW_MS} before the writer stops respawning
8755
+ * and escalates to the controller.
8756
+ *
8757
+ * 15/hour, from the measured distribution. Per writer over the same 12 h:
8758
+ * 1441/high 549 (**45.8/h**), 615/high 241 (20.1/h), 618/high 217 (18.1/h),
8759
+ * 592/high 211 (17.6/h), 590/high 195 (16.3/h) — every one of them a storm and
8760
+ * every one of them silent — against `low` profiles at 4–15 per 12 h (≤1.3/h),
8761
+ * which is what a healthy writer looks like. 15 separates them with room for a
8762
+ * camera that blips hourly and is otherwise fine.
8763
+ *
8764
+ * It never shadows {@link MAX_RESTARTS}: 10 CONSECUTIVE rapid failures happen
8765
+ * inside ~90 s, so the rapid breaker still trips first on a hard failure. This
8766
+ * one exists for the slow storm the rapid breaker structurally cannot see.
8767
+ */
8768
+ var MAX_RESTARTS_IN_WINDOW = 15;
8904
8769
  /** Grace between the SIGTERM in stop() and the escalated SIGKILL. */
8905
8770
  var KILL_GRACE_MS = 500;
8906
8771
  /** Supervises one passthrough ffmpeg for a single (camera, profile). */
@@ -8910,6 +8775,12 @@ var SegmentWriter = class {
8910
8775
  proc = null;
8911
8776
  stopped = false;
8912
8777
  restarts = 0;
8778
+ /**
8779
+ * Epoch-ms of every termination still inside {@link RESTART_WINDOW_MS}.
8780
+ * Replaced, never mutated in place, so a reader can never observe a
8781
+ * half-pruned window.
8782
+ */
8783
+ restartWindow = [];
8913
8784
  restartTimer = null;
8914
8785
  startedAt = 0;
8915
8786
  exitWait = null;
@@ -8943,10 +8814,11 @@ var SegmentWriter = class {
8943
8814
  settled = true;
8944
8815
  this.onTermination(termination, stderrTail);
8945
8816
  };
8946
- proc.on("exit", (code) => {
8817
+ proc.on("exit", (code, signal) => {
8947
8818
  terminate({
8948
8819
  reason: "exit",
8949
- exitCode: typeof code === "number" ? code : null
8820
+ exitCode: typeof code === "number" ? code : null,
8821
+ signal: typeof signal === "string" ? signal : null
8950
8822
  });
8951
8823
  });
8952
8824
  proc.on("error", (raw) => {
@@ -8962,32 +8834,51 @@ var SegmentWriter = class {
8962
8834
  /**
8963
8835
  * The ONE restart policy, reached from both `exit` and `error`.
8964
8836
  *
8965
- * `deps.logger` is the device-scoped child the controller binds
8966
- * (`logger.withTags({ deviceId })`), so every line below carries
8967
- * `tags: { deviceId }` "why is 617 worse than 615" is always asked per
8968
- * camera, and a writer that stops recording one camera must be greppable by
8969
- * that camera.
8837
+ * Every line below carries `tags: { deviceId }` from `deps.deviceId` "why
8838
+ * is 617 worse than 615" is always asked per camera, and a writer that stops
8839
+ * recording one camera must be greppable by that camera.
8970
8840
  */
8971
8841
  onTermination(termination, stderrTail) {
8972
8842
  this.resolveExit?.();
8973
8843
  this.resolveExit = null;
8974
8844
  this.proc = null;
8975
8845
  if (this.stopped) return;
8976
- const ranMs = Date.now() - this.startedAt;
8846
+ const now = Date.now();
8847
+ const ranMs = now - this.startedAt;
8977
8848
  if (ranMs >= STABLE_RUN_MS) this.restarts = 0;
8978
- if (termination.reason === "spawn-error") {
8979
- this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", { meta: {
8849
+ this.restartWindow = [...this.restartWindow.filter((at) => at > now - RESTART_WINDOW_MS), now];
8850
+ const restartsInWindow = this.restartWindow.length;
8851
+ if (termination.reason === "exit") this.deps.logger.warn("SegmentWriter ffmpeg exited", {
8852
+ tags: { deviceId: this.deps.deviceId },
8853
+ meta: {
8980
8854
  outDir: this.cfg.outDir,
8981
- errorCode: termination.errorCode,
8982
- error: termination.message,
8983
- permanent: termination.permanent
8984
- } });
8985
- if (termination.permanent) {
8986
- this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", { meta: {
8855
+ exitCode: termination.exitCode,
8856
+ signal: termination.signal,
8857
+ ranMs,
8858
+ restartsInWindow,
8859
+ windowMs: RESTART_WINDOW_MS,
8860
+ stderrTail: stderrTail.join(" | ")
8861
+ }
8862
+ });
8863
+ if (termination.reason === "spawn-error") {
8864
+ this.deps.logger.warn("SegmentWriter ffmpeg spawn failed", {
8865
+ tags: { deviceId: this.deps.deviceId },
8866
+ meta: {
8987
8867
  outDir: this.cfg.outDir,
8988
8868
  errorCode: termination.errorCode,
8989
- error: termination.message
8990
- } });
8869
+ error: termination.message,
8870
+ permanent: termination.permanent
8871
+ }
8872
+ });
8873
+ if (termination.permanent) {
8874
+ this.deps.logger.error("SegmentWriter giving up: ffmpeg cannot be executed on this node", {
8875
+ tags: { deviceId: this.deps.deviceId },
8876
+ meta: {
8877
+ outDir: this.cfg.outDir,
8878
+ errorCode: termination.errorCode,
8879
+ error: termination.message
8880
+ }
8881
+ });
8991
8882
  this.stopped = true;
8992
8883
  this.deps.onGaveUp?.();
8993
8884
  return;
@@ -8995,25 +8886,51 @@ var SegmentWriter = class {
8995
8886
  this.deps.onResourcePressure?.();
8996
8887
  }
8997
8888
  if (this.restarts >= MAX_RESTARTS) {
8998
- this.deps.logger.warn("SegmentWriter giving up after max restarts", { meta: {
8999
- outDir: this.cfg.outDir,
9000
- reason: termination.reason,
9001
- code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
9002
- stderrTail: stderrTail.join(" | ")
9003
- } });
8889
+ this.deps.logger.warn("SegmentWriter giving up after max restarts", {
8890
+ tags: { deviceId: this.deps.deviceId },
8891
+ meta: {
8892
+ outDir: this.cfg.outDir,
8893
+ reason: termination.reason,
8894
+ code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8895
+ stderrTail: stderrTail.join(" | ")
8896
+ }
8897
+ });
8898
+ this.stopped = true;
8899
+ this.deps.onGaveUp?.();
8900
+ return;
8901
+ }
8902
+ if (restartsInWindow >= MAX_RESTARTS_IN_WINDOW) {
8903
+ this.deps.logger.warn("SegmentWriter giving up: restart storm", {
8904
+ tags: { deviceId: this.deps.deviceId },
8905
+ meta: {
8906
+ outDir: this.cfg.outDir,
8907
+ restartsInWindow,
8908
+ windowMs: RESTART_WINDOW_MS,
8909
+ budget: MAX_RESTARTS_IN_WINDOW,
8910
+ attempt: this.restarts,
8911
+ ranMs,
8912
+ reason: termination.reason,
8913
+ code: termination.reason === "exit" ? termination.exitCode : termination.errorCode,
8914
+ stderrTail: stderrTail.join(" | ")
8915
+ }
8916
+ });
9004
8917
  this.stopped = true;
9005
8918
  this.deps.onGaveUp?.();
9006
8919
  return;
9007
8920
  }
9008
8921
  this.restarts++;
9009
8922
  const delayMs = Math.min(RESTART_BASE_MS * 2 ** (this.restarts - 1), RESTART_MAX_MS);
9010
- this.deps.logger.info("SegmentWriter restarting", { meta: {
9011
- outDir: this.cfg.outDir,
9012
- attempt: this.restarts,
9013
- delayMs,
9014
- ranMs,
9015
- reason: termination.reason
9016
- } });
8923
+ this.deps.logger.info("SegmentWriter restarting", {
8924
+ tags: { deviceId: this.deps.deviceId },
8925
+ meta: {
8926
+ outDir: this.cfg.outDir,
8927
+ attempt: this.restarts,
8928
+ restartsInWindow,
8929
+ delayMs,
8930
+ ranMs,
8931
+ reason: termination.reason
8932
+ }
8933
+ });
9017
8934
  this.restartTimer = setTimeout(() => {
9018
8935
  this.restartTimer = null;
9019
8936
  this.start();
@@ -10277,6 +10194,7 @@ var RecordingController = class {
10277
10194
  }, {
10278
10195
  spawn: this.deps.spawn,
10279
10196
  logger: deviceLog,
10197
+ deviceId,
10280
10198
  onGaveUp: () => {
10281
10199
  this.recoverWriterGaveUp(deviceId, profile);
10282
10200
  },
@@ -10700,6 +10618,209 @@ function buildRecordingExportProvider(deps) {
10700
10618
  */
10701
10619
  var resolveRecordingHubHostname = require_hub_hostname.resolveHubHostname;
10702
10620
  //#endregion
10621
+ //#region src/recorder/redundant-segments.ts
10622
+ /**
10623
+ * Return the indices (into `segs`) of segments safe to delete: those fully
10624
+ * contained within a kept segment `[coverStart, coverEnd]`. Sorting by start
10625
+ * ascending, then by end DESCENDING, guarantees the containing (longer) segment
10626
+ * is seen first and kept, and any shorter segment nested inside it is removed.
10627
+ */
10628
+ function selectRedundantSegments(segs) {
10629
+ if (segs.length < 2) return [];
10630
+ const order = segs.map((s, i) => ({
10631
+ start: s.startMs,
10632
+ end: s.startMs + s.durMs,
10633
+ i
10634
+ })).toSorted((a, b) => a.start - b.start || b.end - a.end);
10635
+ const remove = [];
10636
+ let coverStart = Number.NEGATIVE_INFINITY;
10637
+ let coverEnd = Number.NEGATIVE_INFINITY;
10638
+ for (const seg of order) if (seg.start >= coverStart && seg.end <= coverEnd && seg.end > seg.start) remove.push(seg.i);
10639
+ else {
10640
+ coverStart = seg.start;
10641
+ coverEnd = seg.end;
10642
+ }
10643
+ return remove;
10644
+ }
10645
+ //#endregion
10646
+ //#region src/recorder/addon/redundancy-sweep.ts
10647
+ /**
10648
+ * Periodic redundancy sweep — the only thing that removes duplicated footage.
10649
+ *
10650
+ * WHY IT EXISTS AGAIN
10651
+ * ───────────────────
10652
+ * Every `SegmentWriter` restart re-dials the broker with `withRecordingIntent`,
10653
+ * and the broker answers a RECORDING dial with its pre-roll ring (≈10 s). So a
10654
+ * restart does not resume at the live edge: it re-writes ~10 s of media that is
10655
+ * already on disk. At the 2026-08-27 measurement — **1 814 restarts in 12 h** —
10656
+ * that is ~5 hours of duplicated `high`-profile footage per day, on a fuse
10657
+ * share that was already at 90 % full.
10658
+ *
10659
+ * The pruner that used to remove it, `runRedundancyJanitor`, was reachable ONLY
10660
+ * from `runFullArchiveWalk`, which was reachable only from
10661
+ * `scheduleDeferredFullWalk`, which had no caller at all — so on 2026-08-27
10662
+ * (commit `9308428b4`) all three were deleted together as dead code. That was
10663
+ * correct about the walk and left the system with **no duplicate pruner
10664
+ * whatsoever**. The precedent for letting this class of waste run unattended is
10665
+ * the 179 GB of stranded staging segments in `staging-reconcile.ts`.
10666
+ *
10667
+ * WHAT IS DIFFERENT FROM THE ONE THAT WAS DELETED
10668
+ * ───────────────────────────────────────────────
10669
+ * The old janitor ran once, after a walk of the WHOLE archive, and asked the
10670
+ * in-RAM index for `segments(deviceId)` — a full copy plus a sort of every row
10671
+ * the device owns. At the live shape (7.1 M rows) that copy alone was measured
10672
+ * at ~560 MB of transient heap, which is precisely the cost D248 had just
10673
+ * finished removing from the pressure sweep. Reconnecting it in that shape
10674
+ * would have traded one regression for another.
10675
+ *
10676
+ * So this one is bounded by a LOOKBACK WINDOW, not by the archive: duplicates
10677
+ * are made by a restart, restarts are now, and `segmentsStartingIn` slices the
10678
+ * sorted view instead of copying it. Cost grows with footage RECORDED in the
10679
+ * window — the invariant the recorder is supposed to satisfy — never with rows
10680
+ * already indexed.
10681
+ *
10682
+ * WHAT IT WILL NOT DO
10683
+ * ───────────────────
10684
+ * It never deletes a segment that contributes unique coverage:
10685
+ * {@link selectRedundantSegments} selects only segments fully contained inside
10686
+ * another segment that is KEPT, and staggered partial overlaps are always kept.
10687
+ * The delete itself is `SegmentStore.evict` — the same audited path
10688
+ * disk-pressure eviction uses, so a removed segment is de-indexed exactly as an
10689
+ * evicted one is. A failure for one (device, profile, location) is logged and
10690
+ * never aborts the pass.
10691
+ */
10692
+ /** The profiles a recorder writes. Same set `staging-reconcile` sweeps. */
10693
+ var SWEPT_PROFILES = [
10694
+ "high",
10695
+ "mid",
10696
+ "low"
10697
+ ];
10698
+ /** Group rows by the location that owns them. `evict` is per-location. */
10699
+ function byLocation(rows) {
10700
+ const out = /* @__PURE__ */ new Map();
10701
+ for (const row of rows) {
10702
+ const found = out.get(row.locationId);
10703
+ if (found) found.push(row);
10704
+ else out.set(row.locationId, [row]);
10705
+ }
10706
+ return out;
10707
+ }
10708
+ /**
10709
+ * Owns its timer and a single-flight guard, the same shape the retention sweep
10710
+ * and the export janitor already use — a slow pass is never overlapped by the
10711
+ * next tick.
10712
+ */
10713
+ var RedundancySweeper = class {
10714
+ deps;
10715
+ timer = null;
10716
+ sweeping = false;
10717
+ constructor(deps) {
10718
+ this.deps = deps;
10719
+ }
10720
+ /**
10721
+ * Arm the sweep on its interval. Idempotent.
10722
+ *
10723
+ * Deliberately NOT run immediately: boot is when the writers are attaching,
10724
+ * the staging reconcile is relocating thousands of files over shfs and the
10725
+ * first viewer is painting. A duplicate that has waited an hour can wait
10726
+ * another one; the write path cannot.
10727
+ */
10728
+ start() {
10729
+ if (this.timer !== null) return;
10730
+ this.timer = setInterval(() => {
10731
+ this.sweep();
10732
+ }, this.deps.intervalMs);
10733
+ this.timer.unref?.();
10734
+ }
10735
+ /** Stop the timer. Idempotent. An in-flight pass still completes. */
10736
+ stop() {
10737
+ if (this.timer !== null) {
10738
+ clearInterval(this.timer);
10739
+ this.timer = null;
10740
+ }
10741
+ }
10742
+ /**
10743
+ * One pass. Never throws: a device-list read failure aborts THIS pass
10744
+ * (logged), a per-location evict failure is logged and the pass continues.
10745
+ */
10746
+ async sweep() {
10747
+ const empty = {
10748
+ files: 0,
10749
+ bytes: 0,
10750
+ devices: 0
10751
+ };
10752
+ if (this.sweeping) {
10753
+ this.deps.logger.debug("recorder: redundancy sweep already running — skipping this tick");
10754
+ return empty;
10755
+ }
10756
+ this.sweeping = true;
10757
+ try {
10758
+ let deviceIds;
10759
+ try {
10760
+ deviceIds = await this.deps.deviceIds();
10761
+ } catch (err) {
10762
+ this.deps.logger.warn("recorder: redundancy sweep could not read the device list", { meta: { error: require_dist.errMsg(err) } });
10763
+ return empty;
10764
+ }
10765
+ const toMs = (this.deps.now ?? Date.now)();
10766
+ const fromMs = toMs - this.deps.lookbackMs;
10767
+ let files = 0;
10768
+ let bytes = 0;
10769
+ let devices = 0;
10770
+ for (const deviceId of deviceIds) {
10771
+ let removedHere = 0;
10772
+ for (const profile of SWEPT_PROFILES) {
10773
+ const rows = this.deps.segmentsInWindow(deviceId, profile, fromMs, toMs);
10774
+ const redundant = selectRedundantSegments(rows);
10775
+ if (redundant.length === 0) continue;
10776
+ const victims = redundant.map((i) => rows[i]).filter((row) => row !== void 0);
10777
+ for (const [locationId, group] of byLocation(victims)) try {
10778
+ const reclaimed = await this.deps.evict(locationId, group);
10779
+ files += group.length;
10780
+ bytes += reclaimed;
10781
+ removedHere += group.length;
10782
+ } catch (err) {
10783
+ this.deps.logger.warn("recorder: redundancy sweep could not evict duplicates", {
10784
+ tags: { deviceId },
10785
+ meta: {
10786
+ profile,
10787
+ locationId,
10788
+ count: group.length,
10789
+ error: require_dist.errMsg(err)
10790
+ }
10791
+ });
10792
+ }
10793
+ }
10794
+ if (removedHere > 0) {
10795
+ devices += 1;
10796
+ this.deps.logger.info("recorder: removed duplicated segments for device", {
10797
+ tags: { deviceId },
10798
+ meta: {
10799
+ files: removedHere,
10800
+ fromMs,
10801
+ toMs
10802
+ }
10803
+ });
10804
+ }
10805
+ }
10806
+ this.deps.logger.info("recorder: redundancy sweep complete", { meta: {
10807
+ devices,
10808
+ files,
10809
+ bytes,
10810
+ lookbackMs: this.deps.lookbackMs,
10811
+ deviceCount: deviceIds.length
10812
+ } });
10813
+ return {
10814
+ files,
10815
+ bytes,
10816
+ devices
10817
+ };
10818
+ } finally {
10819
+ this.sweeping = false;
10820
+ }
10821
+ }
10822
+ };
10823
+ //#endregion
10703
10824
  //#region src/recorder/addon/retention-sweep.ts
10704
10825
  /**
10705
10826
  * Periodic footage-retention sweep (recording-spec §6).
@@ -11092,6 +11213,14 @@ var HOUR_MS$1 = 36e5;
11092
11213
  * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
11093
11214
  */
11094
11215
  var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
11216
+ /**
11217
+ * The four aggregate columns exist so "how much footage is on this disk" is a
11218
+ * `SUM`/`MIN`/`MAX` over ~19 k hour rows instead of a walk over ~7.1 M in-RAM
11219
+ * segment rows. They are DERIVED from `paths` — never a second authority — and
11220
+ * {@link hourAggregate} is the only place that derives them, so a row loaded
11221
+ * from a database that predates these columns is corrected in the mirror the
11222
+ * moment it is read and repaired on disk by {@link SegmentHourLedger.repairAggregates}.
11223
+ */
11095
11224
  var RECORDING_SEGMENT_HOURS_COLUMNS = [
11096
11225
  {
11097
11226
  name: "key",
@@ -11123,15 +11252,85 @@ var RECORDING_SEGMENT_HOURS_COLUMNS = [
11123
11252
  name: "paths",
11124
11253
  type: "TEXT",
11125
11254
  notNull: true
11255
+ },
11256
+ {
11257
+ name: "bytes",
11258
+ type: "INTEGER",
11259
+ notNull: true,
11260
+ defaultValue: 0
11261
+ },
11262
+ {
11263
+ name: "segments",
11264
+ type: "INTEGER",
11265
+ notNull: true,
11266
+ defaultValue: 0
11267
+ },
11268
+ {
11269
+ name: "minStartMs",
11270
+ type: "INTEGER",
11271
+ notNull: true,
11272
+ defaultValue: 0
11273
+ },
11274
+ {
11275
+ name: "maxStartMs",
11276
+ type: "INTEGER",
11277
+ notNull: true,
11278
+ defaultValue: 0
11126
11279
  }
11127
11280
  ];
11128
- var RECORDING_SEGMENT_HOURS_INDEXES = [{
11129
- name: "idx_recorder_segment_hours_device",
11130
- columns: ["deviceId"]
11131
- }, {
11132
- name: "idx_recorder_segment_hours_hour",
11133
- columns: ["hourStartMs"]
11134
- }];
11281
+ var RECORDING_SEGMENT_HOURS_INDEXES = [
11282
+ {
11283
+ name: "idx_recorder_segment_hours_device",
11284
+ columns: ["deviceId"]
11285
+ },
11286
+ {
11287
+ name: "idx_recorder_segment_hours_hour",
11288
+ columns: ["hourStartMs"]
11289
+ },
11290
+ (
11291
+ /** The eviction read: one oldest-first stream across a SET of location ids.
11292
+ * Location leads so `locationId IN (…)` is a range scan and `hourStartMs`
11293
+ * supplies the order without a sort. */
11294
+ {
11295
+ name: "idx_recorder_segment_hours_loc_hour",
11296
+ columns: ["locationId", "hourStartMs"]
11297
+ })
11298
+ ];
11299
+ /**
11300
+ * Bytes / count / oldest / newest for one hour's paths.
11301
+ *
11302
+ * Every field of a segment path encodes its own facts (`parseSegmentPath`), so
11303
+ * this is a derivation, not a measurement: it can never disagree with the paths
11304
+ * it was computed from. A path the parser refuses contributes nothing — it is
11305
+ * not a segment, and counting it would inflate a disk report.
11306
+ */
11307
+ function hourAggregate(paths) {
11308
+ let bytes = 0;
11309
+ let segments = 0;
11310
+ let minStartMs = 0;
11311
+ let maxStartMs = 0;
11312
+ for (const path of paths) {
11313
+ const p = parseSegmentPath(path);
11314
+ if (p === null) continue;
11315
+ bytes += p.bytes;
11316
+ if (segments === 0 || p.startMs < minStartMs) minStartMs = p.startMs;
11317
+ if (segments === 0 || p.startMs > maxStartMs) maxStartMs = p.startMs;
11318
+ segments += 1;
11319
+ }
11320
+ return {
11321
+ bytes,
11322
+ segments,
11323
+ minStartMs,
11324
+ maxStartMs
11325
+ };
11326
+ }
11327
+ /** Build the persisted row for an hour from its paths — the ONE constructor. */
11328
+ function segmentHourRow(base) {
11329
+ return {
11330
+ ...base,
11331
+ ...hourAggregate(base.paths)
11332
+ };
11333
+ }
11135
11334
  /**
11136
11335
  * The largest share of a device's held hours a single walk may delete.
11137
11336
  *
@@ -11143,6 +11342,49 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [{
11143
11342
  * `readdir` over an unmounted root deletes all of it.
11144
11343
  */
11145
11344
  var LEDGER_PRUNE_MAX_SHARE = .5;
11345
+ /** Backstop on the oldest-first probe. A bounded read must stay bounded even if
11346
+ * a caller asks for a target no archive can cover. */
11347
+ var OLDEST_MAX_PAGES = 16;
11348
+ /** Phase-one columns: everything the accumulation needs and nothing fat. */
11349
+ var OLDEST_PROBE_COLUMNS = [
11350
+ "hourStartMs",
11351
+ "bytes",
11352
+ "locationId"
11353
+ ];
11354
+ /** Aggregate-repair probe: the primary key always comes back, so this asks for
11355
+ * the predicate column alone. */
11356
+ var REPAIR_PROBE_COLUMNS = ["segments"];
11357
+ /** Rows repaired per boot. The live archive is ~19 k hour rows. */
11358
+ var REPAIR_MAX_ROWS = 5e4;
11359
+ /** The four scalars an accounting answer is made of, asked in one statement. */
11360
+ var ACCOUNTING_FIELDS = [
11361
+ {
11362
+ as: "bytes",
11363
+ field: "bytes",
11364
+ op: "sum"
11365
+ },
11366
+ {
11367
+ as: "segments",
11368
+ field: "segments",
11369
+ op: "sum"
11370
+ },
11371
+ {
11372
+ as: "oldestMs",
11373
+ field: "minStartMs",
11374
+ op: "min"
11375
+ },
11376
+ {
11377
+ as: "newestMs",
11378
+ field: "maxStartMs",
11379
+ op: "max"
11380
+ }
11381
+ ];
11382
+ var EMPTY_ACCOUNTING = {
11383
+ bytes: 0,
11384
+ count: 0,
11385
+ oldestMs: null,
11386
+ newestMs: null
11387
+ };
11146
11388
  function hourStartMs(startMs) {
11147
11389
  return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
11148
11390
  }
@@ -11178,7 +11420,11 @@ function rowToValue(row) {
11178
11420
  profile: row.profile,
11179
11421
  locationId: row.locationId,
11180
11422
  hourStartMs: row.hourStartMs,
11181
- paths: JSON.stringify(row.paths)
11423
+ paths: JSON.stringify(row.paths),
11424
+ bytes: row.bytes,
11425
+ segments: row.segments,
11426
+ minStartMs: row.minStartMs,
11427
+ maxStartMs: row.maxStartMs
11182
11428
  };
11183
11429
  }
11184
11430
  function recordToRow(key, data) {
@@ -11193,14 +11439,14 @@ function recordToRow(key, data) {
11193
11439
  return null;
11194
11440
  }
11195
11441
  if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
11196
- return {
11442
+ return segmentHourRow({
11197
11443
  key,
11198
11444
  deviceId,
11199
11445
  profile,
11200
11446
  locationId,
11201
11447
  hourStartMs: hour,
11202
11448
  paths: raw
11203
- };
11449
+ });
11204
11450
  }
11205
11451
  var SPEC = {
11206
11452
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
@@ -11216,9 +11462,15 @@ var SPEC = {
11216
11462
  var SegmentHourLedger = class {
11217
11463
  ledger;
11218
11464
  logger;
11465
+ /** Held for the two ARCHIVE reads below, which are queries rather than mirror
11466
+ * lookups. Every DECISION path (`recordSegment`, `dropSegments`, the prune
11467
+ * refusals) still answers from the mirror alone — D49 is about gates, and an
11468
+ * accounting number is not a gate. */
11469
+ store;
11219
11470
  isLocationUsable;
11220
11471
  constructor(deps) {
11221
11472
  this.logger = deps.logger;
11473
+ this.store = deps.store;
11222
11474
  this.isLocationUsable = deps.isLocationUsable ?? (() => true);
11223
11475
  this.ledger = new DurableLedger({
11224
11476
  spec: SPEC,
@@ -11253,19 +11505,178 @@ var SegmentHourLedger = class {
11253
11505
  hydrateIndex(index) {
11254
11506
  for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
11255
11507
  }
11508
+ /**
11509
+ * Bytes / count / oldest / newest across a SET of storage locations, as ONE
11510
+ * `SUM`/`COUNT`/`MIN`/`MAX` over the hour rows.
11511
+ *
11512
+ * The set is the eviction DOMAIN — the location ids that alias one physical
11513
+ * root. It goes into a single statement as `locationId IN (…)`, never a loop
11514
+ * per location: two ids over one disk are one pool, and asking twice is how
11515
+ * an answer about a disk gets partitioned.
11516
+ */
11517
+ async accountingForLocations(locationIds) {
11518
+ const ids = [...locationIds];
11519
+ if (ids.length === 0) return EMPTY_ACCOUNTING;
11520
+ const out = await this.store.aggregate.query({
11521
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11522
+ fields: ACCOUNTING_FIELDS,
11523
+ filter: { whereIn: { locationId: ids } }
11524
+ });
11525
+ const segments = out.values["segments"];
11526
+ if (out.count === 0 || segments === null || segments === void 0) return EMPTY_ACCOUNTING;
11527
+ return {
11528
+ bytes: out.values["bytes"] ?? 0,
11529
+ count: segments,
11530
+ oldestMs: out.values["oldestMs"] ?? null,
11531
+ newestMs: out.values["newestMs"] ?? null
11532
+ };
11533
+ }
11534
+ /**
11535
+ * The OLDEST segments across a set of storage locations — enough of them to
11536
+ * cover `targetBytes`, and no more.
11537
+ *
11538
+ * ## The constraint this read exists to keep
11539
+ *
11540
+ * Locations aliasing one physical root must be reclaimed as ONE oldest-first
11541
+ * stream. That is not a property of the CALLER here, it is a property of the
11542
+ * STATEMENT: every id of the domain rides in a single
11543
+ * `locationId IN (…) ORDER BY hourStartMs ASC`. A query per location plus a
11544
+ * merge would be the age-partition data-loss bug rebuilt in a second place.
11545
+ *
11546
+ * ## Why two phases
11547
+ *
11548
+ * Phase one reads only the small columns and walks oldest-first until the
11549
+ * accumulated bytes cover the target — normally one page. `paths` is by far
11550
+ * the fattest column in the row and phase one never touches it. Phase two
11551
+ * fetches paths for exactly the hours phase one chose.
11552
+ *
11553
+ * `exhausted` means the stream ran out before the target was met — the
11554
+ * caller's stop condition, and the one thing a bounded read must still be
11555
+ * able to say truthfully.
11556
+ */
11557
+ async oldestSegments(locationIds, targetBytes) {
11558
+ const ids = [...locationIds];
11559
+ if (ids.length === 0 || targetBytes <= 0) return {
11560
+ rows: [],
11561
+ exhausted: true
11562
+ };
11563
+ const chosen = [];
11564
+ let covered = 0;
11565
+ let offset = 0;
11566
+ let exhausted = false;
11567
+ for (let page = 0; page < OLDEST_MAX_PAGES && covered < targetBytes; page++) {
11568
+ const records = await this.store.query.query({
11569
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11570
+ columns: OLDEST_PROBE_COLUMNS,
11571
+ filter: {
11572
+ whereIn: { locationId: ids },
11573
+ orderBy: {
11574
+ field: "hourStartMs",
11575
+ direction: "asc"
11576
+ },
11577
+ limit: 32,
11578
+ offset
11579
+ }
11580
+ });
11581
+ for (const record of records) {
11582
+ chosen.push(record.id);
11583
+ const bytes = record.data["bytes"];
11584
+ covered += typeof bytes === "number" ? bytes : 0;
11585
+ }
11586
+ offset += records.length;
11587
+ if (records.length < 32) {
11588
+ exhausted = true;
11589
+ break;
11590
+ }
11591
+ }
11592
+ if (chosen.length === 0) return {
11593
+ rows: [],
11594
+ exhausted: true
11595
+ };
11596
+ const full = await this.store.query.query({
11597
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11598
+ filter: {
11599
+ whereIn: { key: chosen },
11600
+ limit: chosen.length
11601
+ }
11602
+ });
11603
+ const rows = [];
11604
+ for (const record of full) {
11605
+ const hour = recordToRow(record.id, record.data);
11606
+ if (hour === null) continue;
11607
+ for (const path of hour.paths) {
11608
+ const parsed = parseSegmentPath(path);
11609
+ if (parsed === null) continue;
11610
+ rows.push({
11611
+ deviceId: parsed.deviceId,
11612
+ profile: parsed.profile,
11613
+ startMs: parsed.startMs,
11614
+ durMs: parsed.durMs,
11615
+ bytes: parsed.bytes,
11616
+ path,
11617
+ locationId: hour.locationId
11618
+ });
11619
+ }
11620
+ }
11621
+ return {
11622
+ rows: rows.toSorted((a, b) => a.startMs - b.startMs),
11623
+ exhausted
11624
+ };
11625
+ }
11626
+ /**
11627
+ * Rewrite, once, the rows whose aggregate columns are still the schema
11628
+ * default.
11629
+ *
11630
+ * `declareCollection` adds a column additively, so every row written before
11631
+ * the aggregate columns existed holds `segments = 0` — and an hour row with
11632
+ * no segments cannot exist any other way (`dropSegments` forgets an hour that
11633
+ * empties). So `segments = 0` IS the backfill predicate, and after one pass
11634
+ * it selects nothing. A partial repair finishes on the next boot; until then
11635
+ * the affected disks UNDER-report, which prunes less and never more.
11636
+ */
11637
+ async repairAggregates() {
11638
+ let repaired = 0;
11639
+ try {
11640
+ const stale = await this.store.query.query({
11641
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11642
+ columns: REPAIR_PROBE_COLUMNS,
11643
+ filter: {
11644
+ where: { segments: 0 },
11645
+ limit: REPAIR_MAX_ROWS
11646
+ }
11647
+ });
11648
+ for (const record of stale) {
11649
+ const held = this.ledger.get(record.id);
11650
+ if (held === void 0 || held.segments === 0) continue;
11651
+ await this.ledger.put(held);
11652
+ repaired += 1;
11653
+ }
11654
+ } catch (err) {
11655
+ this.logger.warn("recorder: segment-hour aggregate repair failed — disks under-report", { meta: {
11656
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11657
+ error: String(err)
11658
+ } });
11659
+ return repaired;
11660
+ }
11661
+ if (repaired > 0) this.logger.info("recorder: segment-hour aggregate columns backfilled", { meta: {
11662
+ rows: repaired,
11663
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION
11664
+ } });
11665
+ return repaired;
11666
+ }
11256
11667
  /** Write-behind: a just-finalized segment joins its hour row. */
11257
11668
  async recordSegment(row) {
11258
11669
  const { key, hourStartMs: hour } = hourOf(row);
11259
11670
  const held = this.ledger.get(key);
11260
11671
  if (held !== void 0 && held.paths.includes(row.path)) return;
11261
- await this.ledger.put({
11672
+ await this.ledger.put(segmentHourRow({
11262
11673
  key,
11263
11674
  deviceId: row.deviceId,
11264
11675
  profile: row.profile,
11265
11676
  locationId: row.locationId,
11266
11677
  hourStartMs: hour,
11267
11678
  paths: held === void 0 ? [row.path] : [...held.paths, row.path]
11268
- });
11679
+ }));
11269
11680
  }
11270
11681
  /**
11271
11682
  * Eviction confirmed these paths are gone from disk. Drop them; forget the
@@ -11291,10 +11702,10 @@ var SegmentHourLedger = class {
11291
11702
  continue;
11292
11703
  }
11293
11704
  if (next.length === held.paths.length) continue;
11294
- await this.ledger.put({
11705
+ await this.ledger.put(segmentHourRow({
11295
11706
  ...held,
11296
11707
  paths: next
11297
- });
11708
+ }));
11298
11709
  }
11299
11710
  }
11300
11711
  /**
@@ -11321,18 +11732,18 @@ var SegmentHourLedger = class {
11321
11732
  for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
11322
11733
  const { key, hourStartMs: hour } = hourOf(seg);
11323
11734
  const existing = fromIndex.get(key);
11324
- if (existing === void 0) fromIndex.set(key, {
11735
+ if (existing === void 0) fromIndex.set(key, segmentHourRow({
11325
11736
  key,
11326
11737
  deviceId: seg.deviceId,
11327
11738
  profile: seg.profile,
11328
11739
  locationId: seg.locationId,
11329
11740
  hourStartMs: hour,
11330
11741
  paths: [seg.path]
11331
- });
11332
- else fromIndex.set(key, {
11742
+ }));
11743
+ else fromIndex.set(key, segmentHourRow({
11333
11744
  ...existing,
11334
11745
  paths: [...existing.paths, seg.path]
11335
- });
11746
+ }));
11336
11747
  }
11337
11748
  const refused = this.refusedPrunes(walked, fromIndex);
11338
11749
  for (const row of this.ledger.snapshot()) {
@@ -11346,10 +11757,10 @@ var SegmentHourLedger = class {
11346
11757
  }
11347
11758
  if (additiveOnly) {
11348
11759
  const union = uniquePaths([...disk.paths, ...row.paths]);
11349
- if (!samePaths(union, row.paths)) await this.ledger.put({
11760
+ if (!samePaths(union, row.paths)) await this.ledger.put(segmentHourRow({
11350
11761
  ...row,
11351
11762
  paths: union
11352
- });
11763
+ }));
11353
11764
  continue;
11354
11765
  }
11355
11766
  if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
@@ -11970,22 +12381,26 @@ var EXPORT_SWEEP_INTERVAL_MS = 5 * 6e4;
11970
12381
  * hourly cadence keeps the storage.list/evict cost negligible while honouring
11971
12382
  * the recording-spec §6 periodic retention. */
11972
12383
  var RETENTION_SWEEP_INTERVAL_MS = 60 * 6e4;
11973
- /**
11974
- * How long after boot the FULL-archive index walk starts.
11975
- *
11976
- * The walk is a recursive `readdir` per device over the whole recordings
11977
- * subtree — minutes of network-FS latency on a real archive (measured: 240 s
11978
- * for ONE 43 017-entry device, 2026-08-15) and it is the read model for
11979
- * playback/retention/footprint, NOT the write path. Every read self-hydrates
11980
- * the window it is asked about, and the whole-archive consumers
11981
- * (retention sweep, redundancy janitor, disk-pressure eviction) only ever
11982
- * under-report while the index is partial — so the walk waits out the boot
11983
- * storm (writers attaching, staging reconcile relocating, first viewer paint)
11984
- * instead of competing with it. Five minutes because the storm is measured in
11985
- * seconds-to-a-minute and the consumers re-run on their own cadences anyway
11986
- * (retention hourly, eviction on pressure).
11987
- */
11988
- var FULL_WALK_DEFER_MS = 5 * 6e4;
12384
+ /** How often the redundancy sweep removes footage a writer restart duplicated. */
12385
+ var REDUNDANCY_SWEEP_INTERVAL_MS = 60 * 6e4;
12386
+ /**
12387
+ * How far back each redundancy pass looks.
12388
+ *
12389
+ * Twice the interval, so a pass that is skipped (single-flight) or delayed by a
12390
+ * slow predecessor still covers the window its predecessor was supposed to.
12391
+ * It is deliberately NOT "the whole archive": duplicates are made by a writer
12392
+ * restart, restarts are recent, and a full-archive selection is the ~560 MB
12393
+ * transient copy D248 removed. See `redundancy-sweep.ts`.
12394
+ */
12395
+ var REDUNDANCY_SWEEP_LOOKBACK_MS = 2 * REDUNDANCY_SWEEP_INTERVAL_MS;
12396
+ /** What the per-volume usage row reports when the hour ledger is unavailable:
12397
+ * nothing measured, rather than a total counted off a partial RAM index. */
12398
+ var EMPTY_ARCHIVE_ACCOUNTING = {
12399
+ bytes: 0,
12400
+ count: 0,
12401
+ oldestMs: null,
12402
+ newestMs: null
12403
+ };
11989
12404
  /** Grace after a completed download before a `deleteAfterDownload` file is removed. */
11990
12405
  var EXPORT_DELETE_GRACE_MS = 6e4;
11991
12406
  /** Only `.mp4` export ids matching this shape are servable (traversal guard). */
@@ -12124,17 +12539,15 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12124
12539
  exportEngine = null;
12125
12540
  /** Periodic export-expiry sweep timer. Cleared on shutdown. */
12126
12541
  exportSweepTimer = null;
12127
- /** Timer arming the deferred full-archive index walk. Null once fired. */
12128
- fullWalkTimer = null;
12129
- /** Single-flight guard for the full-archive walk (onHubReachable can re-enter
12130
- * on a hub reconnect while a deferred walk is still running). */
12131
- fullWalkInFlight = false;
12132
12542
  /** The `recording` cap provider — retained so the periodic retention sweep can
12133
12543
  * call its `pruneFootage`. Null until built in `onInitialize`. */
12134
12544
  recordingProvider = null;
12135
12545
  /** Periodic footage-retention sweep (owns its timer + single-flight guard).
12136
12546
  * Constructed + started in `onHubReachable`, stopped on shutdown. */
12137
12547
  retentionSweeper = null;
12548
+ /** Periodic duplicate-footage sweep — the ONLY pruner of media a writer
12549
+ * restart re-wrote. Constructed + started in `onHubReachable`. */
12550
+ redundancySweeper = null;
12138
12551
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
12139
12552
  storageMigrationLeaseId = null;
12140
12553
  /**
@@ -12295,7 +12708,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12295
12708
  opsLog,
12296
12709
  alert: (input) => this.emitPlacementAlert(input),
12297
12710
  capacityFor: (location) => this.locationCapacity(location.root),
12298
- usedOnLocation: (locationId) => this.index.accountingForLocation(locationId).bytes,
12711
+ usedOnLocation: async (locationId) => (await this.segmentHours?.accountingForLocations(new Set([locationId])))?.bytes ?? 0,
12299
12712
  dailyBytesFor: (deviceId, profile) => this.dailyBytesFor(deviceId, profile)
12300
12713
  });
12301
12714
  this.controller = new RecordingController({
@@ -12407,6 +12820,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12407
12820
  locations: () => this.resolvedLocations,
12408
12821
  refreshLocations: () => this.refreshLocations(),
12409
12822
  capacity: (root) => this.locationCapacity(root),
12823
+ archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
12410
12824
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
12411
12825
  reconcileHourLedger: async (deviceId) => {
12412
12826
  await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
@@ -12437,7 +12851,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12437
12851
  const recordingProvider = buildRecordingProvider(recordingDeps);
12438
12852
  this.recordingProvider = recordingProvider;
12439
12853
  const evictableProvider = new StorageEvictableProvider({
12440
- index: this.index,
12854
+ archive: () => this.segmentHours,
12855
+ logger: this.ctx.logger,
12441
12856
  store: this.segmentStore,
12442
12857
  resolveDomain: (locationId) => this.locationDomain(locationId),
12443
12858
  isReadOnly: (locationId) => this.resolvedLocations.find((l) => l.id === locationId)?.readOnly === true,
@@ -12621,10 +13036,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12621
13036
  }
12622
13037
  this.retentionSweeper?.stop();
12623
13038
  this.retentionSweeper = null;
12624
- if (this.fullWalkTimer !== null) {
12625
- clearTimeout(this.fullWalkTimer);
12626
- this.fullWalkTimer = null;
12627
- }
13039
+ this.redundancySweeper?.stop();
13040
+ this.redundancySweeper = null;
12628
13041
  this.exportEngine = null;
12629
13042
  this.recordingProvider = null;
12630
13043
  }
@@ -12736,6 +13149,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12736
13149
  try {
12737
13150
  await this.segmentHours?.load();
12738
13151
  this.segmentHours?.hydrateIndex(this.index);
13152
+ this.segmentHours?.repairAggregates();
12739
13153
  } catch (err) {
12740
13154
  this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: require_dist.errMsg(err) } });
12741
13155
  }
@@ -12763,6 +13177,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12763
13177
  intervalMs: RETENTION_SWEEP_INTERVAL_MS
12764
13178
  });
12765
13179
  this.retentionSweeper?.start();
13180
+ const store = this.segmentStore;
13181
+ if (store && this.redundancySweeper === null) this.redundancySweeper = new RedundancySweeper({
13182
+ deviceIds: async () => [...(await readDeviceConfigs(this.configStore())).keys()],
13183
+ segmentsInWindow: (deviceId, profile, fromMs, toMs) => this.index.segmentsStartingIn(deviceId, profile, fromMs, toMs),
13184
+ evict: (locationId, rows) => store.evict(locationId, rows),
13185
+ logger: this.ctx.logger,
13186
+ intervalMs: REDUNDANCY_SWEEP_INTERVAL_MS,
13187
+ lookbackMs: REDUNDANCY_SWEEP_LOOKBACK_MS
13188
+ });
13189
+ this.redundancySweeper?.start();
12766
13190
  }
12767
13191
  /**
12768
13192
  * Rebuild the sensor trigger's reverse index from the persisted configs.
@@ -13224,85 +13648,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13224
13648
  } });
13225
13649
  }
13226
13650
  /**
13227
- * Opt-in fleet walk — NOT armed at boot (2026-08-22: starved scrub/play).
13228
- * Kept so an operator path can still request a full reconcile later.
13229
- *
13230
- * ⚠ That operator path DOES NOT EXIST YET. This is the only caller of
13231
- * `runFullArchiveWalk`, so today the whole walk is unreachable — `tsc` was
13232
- * flagging it as unused and it was one of the errors keeping this package
13233
- * red. Made public rather than deleted, because deleting it would have taken
13234
- * the 100-line walk with it; but a comment promising a path nobody built is
13235
- * exactly the leftover that reads as verification. Wire it or drop it.
13236
- */
13237
- scheduleDeferredFullWalk(deviceIds) {
13238
- if (this.fullWalkTimer !== null || this.fullWalkInFlight) return;
13239
- this.fullWalkTimer = setTimeout(() => {
13240
- this.fullWalkTimer = null;
13241
- this.runFullArchiveWalk(deviceIds);
13242
- }, FULL_WALK_DEFER_MS);
13243
- this.fullWalkTimer.unref?.();
13244
- this.ctx.logger.info("recorder: full-archive index walk deferred", { meta: {
13245
- deferMs: FULL_WALK_DEFER_MS,
13246
- deviceCount: deviceIds.length
13247
- } });
13248
- }
13249
- /**
13250
- * The deferred boot walk itself: hydrate every configured device from the
13251
- * recordings roots, then run the two consumers that need the COMPLETE index
13252
- * (the eviction-domain span log and the redundancy janitor). Bounded-parallel
13253
- * per device, and each camera is marked hydrated at ITS OWN completion — not
13254
- * after the fleet (D167). Never overlaps itself.
13255
- */
13256
- async runFullArchiveWalk(deviceIds) {
13257
- if (this.fullWalkInFlight) return;
13258
- this.fullWalkInFlight = true;
13259
- const hydrateStartedMs = Date.now();
13260
- try {
13261
- await hydrateDevicesFromStorage(this.ctx.api, this.index, deviceIds, this.resolvedLocations, this.ctx.logger);
13262
- this.ctx.logger.info("recorder: hydrated index from storage", { meta: {
13263
- deviceCount: deviceIds.length,
13264
- locationCount: this.resolvedLocations.length,
13265
- ms: Date.now() - hydrateStartedMs
13266
- } });
13267
- try {
13268
- await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
13269
- } catch (err) {
13270
- this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: require_dist.errMsg(err) } });
13271
- }
13272
- const loggedDomains = /* @__PURE__ */ new Set();
13273
- for (const loc of this.resolvedLocations) {
13274
- const domain = this.locationDomain(loc.id);
13275
- const domainKey = [...domain].toSorted().join("+");
13276
- if (loggedDomains.has(domainKey)) continue;
13277
- loggedDomains.add(domainKey);
13278
- const acct = this.index.accountingForLocations(domain);
13279
- this.ctx.logger.info("recorder: eviction domain footage span at hydrate", { meta: {
13280
- locationIds: [...domain].toSorted(),
13281
- root: loc.root,
13282
- count: acct.count,
13283
- bytes: acct.bytes,
13284
- oldestMs: acct.oldestMs,
13285
- newestMs: acct.newestMs
13286
- } });
13287
- }
13288
- const store = this.segmentStore;
13289
- if (store) try {
13290
- await runRedundancyJanitor({
13291
- deviceIds,
13292
- segmentsFor: (deviceId, profile) => this.index.segments(deviceId, profile),
13293
- evict: (location, rows) => store.evict(location, rows),
13294
- logger: this.ctx.logger
13295
- });
13296
- } catch (err) {
13297
- this.ctx.logger.warn("recorder: redundancy janitor failed", { meta: { error: require_dist.errMsg(err) } });
13298
- }
13299
- } catch (err) {
13300
- this.ctx.logger.warn("recorder: full-archive walk failed", { meta: { error: require_dist.errMsg(err) } });
13301
- } finally {
13302
- this.fullWalkInFlight = false;
13303
- }
13304
- }
13305
- /**
13306
13651
  * Declare the three row collections that replaced the `recordingConfigs`,
13307
13652
  * `recordingExports` and `recordingOpsLog` blob keys. Idempotent — a
13308
13653
  * re-declaration of the same shape is a no-op in the engine.