@camstack/addon-pipeline 1.2.254 → 1.2.256

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 (32) 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-BegEf29d.mjs → default-detection-model-A5DRjEr3.mjs} +1 -1
  4. package/dist/{default-detection-model-DQj3yiG8.js → default-detection-model-Bbw4nC11.js} +1 -1
  5. package/dist/detection-pipeline/index.js +4 -4
  6. package/dist/detection-pipeline/index.mjs +4 -4
  7. package/dist/{dist-CkGvx3Fy.mjs → dist-MJ4ji7um.mjs} +284 -25
  8. package/dist/{dist-DQsOgAJp.js → dist-lqqUXDfs.js} +284 -25
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-Bg7LGOQ3.js → node-80-uOMr8.js} +1 -1
  12. package/dist/{node-Dwlty0jj.mjs → node-DdXmHLK4.mjs} +1 -1
  13. package/dist/pipeline-runner/index.js +3 -3
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/{process-memory-CzjYHqAz.mjs → process-memory-Cl3ubMxY.mjs} +1 -1
  16. package/dist/{process-memory-COrQw_pf.js → process-memory-Dac9TXlX.js} +1 -1
  17. package/dist/recorder/index.js +472 -51
  18. package/dist/recorder/index.mjs +472 -51
  19. package/dist/{segment-demux-js-BIrnzZiP.js → segment-demux-js-CWwlKQ3M.js} +1 -1
  20. package/dist/{segment-demux-js-BAoEDsxT.mjs → segment-demux-js-gIj82O9k.mjs} +1 -1
  21. package/dist/stream-broker/_stub.js +2 -2
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-rumvXmC-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-pOLAb3B1.mjs} +3 -3
  23. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-k114uFcJ.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CPFjd94C.mjs} +1 -1
  24. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BXK7jbJm.mjs +26 -0
  25. package/dist/stream-broker/demux-worker-child.js +1 -1
  26. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  27. package/dist/stream-broker/{hostInit-CFFft0gC.mjs → hostInit-BRnsXyTM.mjs} +3 -3
  28. package/dist/stream-broker/index.js +3 -3
  29. package/dist/stream-broker/index.mjs +3 -3
  30. package/dist/stream-broker/remoteEntry.js +1 -1
  31. package/package.json +1 -1
  32. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BHtcf-Mo.mjs +0 -26
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../chunk-emK7D4bc.js");
6
- const require_dist = require("../dist-DQsOgAJp.js");
7
- const require_node = require("../node-Bg7LGOQ3.js");
6
+ const require_dist = require("../dist-lqqUXDfs.js");
7
+ const require_node = require("../node-80-uOMr8.js");
8
8
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
9
9
  const require_restream_intent = require("../restream-intent-VVJiUAkh.js");
10
10
  const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
@@ -820,14 +820,14 @@ function parseSegmentPath(relPath) {
820
820
  //#region src/recorder/recording-index.ts
821
821
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
822
822
  * already gives us — so "have I walked this?" costs one Set lookup. */
823
- var HOUR_MS$8 = 36e5;
823
+ var HOUR_MS$9 = 36e5;
824
824
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
825
825
  * the retention window into the set. */
826
826
  var ALL_HOURS = Symbol("all-hours");
827
827
  /** The hour buckets a half-open span touches. */
828
828
  function* hourBuckets(fromMs, toMs) {
829
- const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
830
- for (let h = first; h < toMs; h += HOUR_MS$8) yield h;
829
+ const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
830
+ for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
831
831
  }
832
832
  /**
833
833
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
@@ -1121,7 +1121,7 @@ var RecordingIndex = class {
1121
1121
  changed = true;
1122
1122
  }
1123
1123
  if (changed) this.invalidateSorted(deviceId);
1124
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$8);
1124
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1125
1125
  }
1126
1126
  /**
1127
1127
  * Insert/replace one finalized segment (idempotent by path).
@@ -1767,11 +1767,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
1767
1767
  const p2 = (n) => String(n).padStart(2, "0");
1768
1768
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1769
1769
  }
1770
- var HOUR_MS$7 = 36e5;
1770
+ var HOUR_MS$8 = 36e5;
1771
1771
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
1772
1772
  const verify = options?.verifyClosedHours === true;
1773
1773
  if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1774
- const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1774
+ const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
1775
1775
  /** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
1776
1776
  const claimed = /* @__PURE__ */ new Map();
1777
1777
  const claimKey = (locationId, hour) => `${locationId}:${hour}`;
@@ -1821,15 +1821,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1821
1821
  });
1822
1822
  continue;
1823
1823
  }
1824
- const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
1824
+ const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
1825
1825
  const reuseBefore = index.hydrateReuseStats();
1826
1826
  let hoursRead = 0;
1827
1827
  let repaired = 0;
1828
1828
  let forgottenSegments = 0;
1829
1829
  let forgottenHours = 0;
1830
1830
  let sealed = 0;
1831
- for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1832
- if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1831
+ for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
1832
+ if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
1833
1833
  for (const profile of profiles) {
1834
1834
  const rel = hourDirRelPath(deviceId, profile, hour);
1835
1835
  let files;
@@ -1842,7 +1842,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1842
1842
  const locationId = locationForHydratedProfile(aliases, profile).id;
1843
1843
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1844
1844
  index.hydrateHour(deviceId, locationId, hour, relPaths);
1845
- if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
1845
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
1846
1846
  const claimedHere = claimed.get(claimKey(locationId, hour));
1847
1847
  const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
1848
1848
  if (outcome === null) continue;
@@ -1925,7 +1925,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1925
1925
  */
1926
1926
  var DEFAULT_THROTTLE_MBPS = 40;
1927
1927
  /** The durable index's bucket, and therefore this mover's batch. */
1928
- var HOUR_MS$6 = 36e5;
1928
+ var HOUR_MS$7 = 36e5;
1929
1929
  /** Progress callback cadence (files) — cheap enough to fire often. */
1930
1930
  var PROGRESS_EVERY_FILES = 25;
1931
1931
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -2622,7 +2622,7 @@ function profilesOfClass(footageClass) {
2622
2622
  function groupByHour(rows) {
2623
2623
  const out = /* @__PURE__ */ new Map();
2624
2624
  for (const row of rows) {
2625
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2625
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2626
2626
  const found = out.get(key);
2627
2627
  if (found) found.push(row);
2628
2628
  else out.set(key, [row]);
@@ -3850,7 +3850,7 @@ async function profileHasFootage(root, deviceId, profile) {
3850
3850
  //#endregion
3851
3851
  //#region src/recorder/addon/calendar-index.ts
3852
3852
  var DAY_MS$3 = 864e5;
3853
- var HOUR_MS$5 = 36e5;
3853
+ var HOUR_MS$6 = 36e5;
3854
3854
  /**
3855
3855
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3856
3856
  *
@@ -3888,8 +3888,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3888
3888
  * deploy gap). Unknown hours still fall through to one directory read.
3889
3889
  */
3890
3890
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3891
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3892
- const hourEnd = hour + HOUR_MS$5;
3891
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3892
+ const hourEnd = hour + HOUR_MS$6;
3893
3893
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3894
3894
  const rows = [];
3895
3895
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -4048,7 +4048,7 @@ var CalendarIndex = class {
4048
4048
  }));
4049
4049
  const days = /* @__PURE__ */ new Set();
4050
4050
  for (const { day, hours } of perDay) for (const h of hours) {
4051
- const at = day + h * HOUR_MS$5;
4051
+ const at = day + h * HOUR_MS$6;
4052
4052
  if (at < fromMs || at >= toMs) continue;
4053
4053
  days.add(localDayStart(at, tzOffsetMinutes));
4054
4054
  }
@@ -4110,7 +4110,7 @@ var CalendarIndex = class {
4110
4110
  const day = utcDayOf(utcDayMs);
4111
4111
  for (let h = 0; h < 24; h++) {
4112
4112
  await this.awaitQuiet();
4113
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
4113
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
4114
4114
  }
4115
4115
  }
4116
4116
  /**
@@ -4131,7 +4131,7 @@ var CalendarIndex = class {
4131
4131
  */
4132
4132
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
4133
4133
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
4134
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
4134
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4135
4135
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
4136
4136
  if (fromIndex !== null && fromIndex !== void 0) return {
4137
4137
  segments: fromIndex,
@@ -4139,7 +4139,7 @@ var CalendarIndex = class {
4139
4139
  };
4140
4140
  const key = `${deviceId}:${profile}:${hour}`;
4141
4141
  const now = this.deps.now?.() ?? Date.now();
4142
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
4142
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4143
4143
  const cached = this.hourSegs.get(key);
4144
4144
  if (cached !== void 0) {
4145
4145
  if (!isCurrentHour) return {
@@ -4203,7 +4203,7 @@ var CalendarIndex = class {
4203
4203
  */
4204
4204
  async dayRanges(deviceId, profile, utcDayMs) {
4205
4205
  const day = utcDayOf(utcDayMs);
4206
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$5), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
4206
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$6), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
4207
4207
  }
4208
4208
  /**
4209
4209
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -4217,9 +4217,9 @@ var CalendarIndex = class {
4217
4217
  */
4218
4218
  async rangesIn(deviceId, profile, fromMs, toMs) {
4219
4219
  if (toMs <= fromMs) return [];
4220
- const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4220
+ const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4221
4221
  const hours = [];
4222
- for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
4222
+ for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4223
4223
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
4224
4224
  const ranges = [];
4225
4225
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4772,7 +4772,7 @@ function handleStoreCensusAction(census, logger, input) {
4772
4772
  }
4773
4773
  //#endregion
4774
4774
  //#region src/recorder/addon/directory-handler.ts
4775
- var HOUR_MS$4 = 36e5;
4775
+ var HOUR_MS$5 = 36e5;
4776
4776
  /** A window wider than this is refused: it is a client bug, and answering it
4777
4777
  * would walk the archive on the read path — the thing this replaces. */
4778
4778
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4819,9 +4819,9 @@ function withDirectoryRoute(deps, next) {
4819
4819
  return;
4820
4820
  }
4821
4821
  const rows = [];
4822
- const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4822
+ const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4823
4823
  let hydrated = true;
4824
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4824
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4825
4825
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4826
4826
  if (!complete) hydrated = false;
4827
4827
  for (const s of segments) {
@@ -8572,7 +8572,7 @@ var DurableLedger = class DurableLedger {
8572
8572
  };
8573
8573
  //#endregion
8574
8574
  //#region src/recorder/addon/segment-hour-ledger.ts
8575
- var HOUR_MS$3 = 36e5;
8575
+ var HOUR_MS$4 = 36e5;
8576
8576
  /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8577
8577
  function minOf(a, b) {
8578
8578
  return a === null ? b : Math.min(a, b);
@@ -8711,6 +8711,27 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8711
8711
  ]
8712
8712
  })
8713
8713
  ];
8714
+ /**
8715
+ * How far into an hour a writer's first finalize may land and the writer still
8716
+ * be the party that KNOWS what the hour holds (D521).
8717
+ *
8718
+ * A seal is a claim of completeness. A writer that was running across the hour
8719
+ * boundary produces its first in-hour segment within ONE segment length of the
8720
+ * boundary — the segment that straddles it belongs to the previous hour by its
8721
+ * `startMs`. So the grace is one segment length at the longest anyone
8722
+ * configures here (60 s), plus 30 s for a keyframe-cut segment overrunning its
8723
+ * nominal `-segment_time` (ffmpeg cuts on the next GOP, never on the clock).
8724
+ *
8725
+ * It is a guess, and this is the shape of being wrong in each direction.
8726
+ * TIGHTER: a normal rollover stops sealing, every hour of the archive is walked
8727
+ * once by its first reader, and D451's "at rest nothing is unsealed" is undone
8728
+ * — at ~3.6 ms per hour directory cold, affordable but pointless. LOOSER: a
8729
+ * runner respawn inside the window seals an hour it entered late, which is
8730
+ * exactly the 2026-09-17 defect; the observed hole there was ~29 minutes, so
8731
+ * anything under a few minutes would have caught it and 90 s is the smallest
8732
+ * number that does not fire on a legitimate rollover.
8733
+ */
8734
+ var WRITER_HOUR_ENTRY_GRACE_MS = 9e4;
8714
8735
  /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
8715
8736
  var EMPTY_HOUR_AGGREGATE = {
8716
8737
  bytes: 0,
@@ -8931,7 +8952,7 @@ var EMPTY_ACCOUNTING = {
8931
8952
  newestMs: null
8932
8953
  };
8933
8954
  function hourStartMs(startMs) {
8934
- return Math.floor(startMs / HOUR_MS$3) * HOUR_MS$3;
8955
+ return Math.floor(startMs / HOUR_MS$4) * HOUR_MS$4;
8935
8956
  }
8936
8957
  function segmentHourKey(deviceId, profile, locationId, hour) {
8937
8958
  return `${deviceId}|${profile}|${locationId}|${hour}`;
@@ -8948,6 +8969,11 @@ function hourOf(row) {
8948
8969
  function writerKey(deviceId, profile) {
8949
8970
  return `${deviceId}|${profile}`;
8950
8971
  }
8972
+ /** One writer's hour, WITHOUT the location — the identity a loss is reported
8973
+ * against by a party that only knows the camera, the profile and the clock. */
8974
+ function writerHourKey(deviceId, profile, hour) {
8975
+ return `${deviceId}|${profile}|${hour}`;
8976
+ }
8951
8977
  function detailOf(row) {
8952
8978
  return {
8953
8979
  locationId: row.locationId,
@@ -9094,6 +9120,13 @@ var SegmentHourLedger = class SegmentHourLedger {
9094
9120
  writerHours = /* @__PURE__ */ new Map();
9095
9121
  /** Hour keys a write was REFUSED for in this process — never sealed by it. */
9096
9122
  refused = /* @__PURE__ */ new Set();
9123
+ /**
9124
+ * `${deviceId}|${profile}|${hourStartMs}` a LOSS was reported for, location
9125
+ * left out on purpose: the party that sees the loss (the playlist watcher)
9126
+ * knows the camera and the profile and has no business knowing which disk
9127
+ * the recorder happens to be placing that hour on.
9128
+ */
9129
+ refusedWriterHours = /* @__PURE__ */ new Set();
9097
9130
  constructor(deps) {
9098
9131
  this.logger = deps.logger;
9099
9132
  this.store = deps.store;
@@ -9304,6 +9337,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9304
9337
  *
9305
9338
  * `null` when the row is known and its detail could not be read: nothing
9306
9339
  * is written from a row whose paths are not resident.
9340
+ *
9341
+ * @seal-authority closed-hour-listing — a directory read of an hour that is
9342
+ * OVER is the one complete answer there is; the caller guarantees the hour is
9343
+ * closed (D451, D521).
9307
9344
  */
9308
9345
  async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
9309
9346
  const key = segmentHourKey(deviceId, profile, locationId, hour);
@@ -9388,11 +9425,65 @@ var SegmentHourLedger = class SegmentHourLedger {
9388
9425
  markRefused(row) {
9389
9426
  this.refused.add(hourOf(row).key);
9390
9427
  }
9428
+ /**
9429
+ * Segments a writer produced that never reached {@link recordSegment} — the
9430
+ * watcher's `rolledOff`, the one moment the system KNOWS work was skipped.
9431
+ *
9432
+ * Every hour the loss spans is refused the seal, whatever else this process
9433
+ * believes about them: the ownership grace answers "was I here", and this
9434
+ * answers "did I get everything while I was". Camera 615's respawned writer
9435
+ * passed the first question for the hour it entered at the top and still held
9436
+ * a row short by 175 segments.
9437
+ *
9438
+ * Returns how many hours were marked, so the caller's line reports what its
9439
+ * report bought rather than that it was made.
9440
+ */
9441
+ markHoursRefused(span) {
9442
+ if (!Number.isFinite(span.fromMs) || !Number.isFinite(span.toMs)) return 0;
9443
+ const first = hourStartMs(Math.min(span.fromMs, span.toMs));
9444
+ const last = hourStartMs(Math.max(span.fromMs, span.toMs));
9445
+ let marked = 0;
9446
+ for (let hour = first; hour <= last && marked < 24; hour += HOUR_MS$4) {
9447
+ this.refusedWriterHours.add(writerHourKey(span.deviceId, span.profile, hour));
9448
+ marked += 1;
9449
+ }
9450
+ if (marked === 0) return 0;
9451
+ this.logger.warn("recorder: segments this writer produced never reached the hour ledger — these hours stay UNSEALED and a reader walks them", {
9452
+ tags: { deviceId: span.deviceId },
9453
+ meta: {
9454
+ profile: span.profile,
9455
+ fromMs: first,
9456
+ toMs: last,
9457
+ hours: marked,
9458
+ segments: span.count,
9459
+ boundHours: 24
9460
+ }
9461
+ });
9462
+ return marked;
9463
+ }
9391
9464
  /** One write: the row as held, flagged sealed. Serialised on its key so it
9392
- * cannot land over an append that read the row before it. */
9465
+ * cannot land over an append that read the row before it.
9466
+ *
9467
+ * @seal-authority writer-owned-hour — the writer that LEAVES the hour, and
9468
+ * only when it entered within {@link WRITER_HOUR_ENTRY_GRACE_MS} of the
9469
+ * hour's start and no loss was reported against it (D521). */
9393
9470
  async sealHour(target) {
9394
9471
  await this.serialized(target.key, async () => {
9395
- if (this.refused.has(target.key)) {
9472
+ const enteredAfterMs = target.entryMs - target.hourStartMs;
9473
+ if (enteredAfterMs > 9e4) {
9474
+ this.logger.warn("recorder: hour left unsealed — this writer entered it late, so it cannot claim the hour is complete", {
9475
+ tags: { deviceId: target.deviceId },
9476
+ meta: {
9477
+ profile: target.profile,
9478
+ locationId: target.locationId,
9479
+ hourStartMs: target.hourStartMs,
9480
+ enteredAfterMs,
9481
+ graceMs: WRITER_HOUR_ENTRY_GRACE_MS
9482
+ }
9483
+ });
9484
+ return;
9485
+ }
9486
+ if (this.refused.has(target.key) || this.refusedWriterHours.has(writerHourKey(target.deviceId, target.profile, target.hourStartMs))) {
9396
9487
  this.logger.warn("recorder: hour left unsealed — a ledger write for it was refused, so its row is short and a reader must walk it", {
9397
9488
  tags: { deviceId: target.deviceId },
9398
9489
  meta: {
@@ -9412,9 +9503,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9412
9503
  });
9413
9504
  }
9414
9505
  /**
9415
- * Remember which hour this writer is on; when it MOVES FORWARD, seal the
9416
- * hour it left. Backwards is a late finalize (a recovered orphan, a replay)
9417
- * into an hour already accounted for, and seals nothing.
9506
+ * Remember which hour this writer is on — and how far into it this process
9507
+ * ARRIVED ({@link WriterHour.entryMs}, the first finalize's own start) —
9508
+ * and when it MOVES FORWARD, seal the hour it left. Backwards is a late
9509
+ * finalize (a recovered orphan, a replay) into an hour already accounted for,
9510
+ * and seals nothing.
9511
+ *
9512
+ * The entry stamp is kept from the FIRST finalize into the hour and never
9513
+ * refreshed: the question the seal has to answer is "was this writer here
9514
+ * when the hour began", and the last segment of the hour cannot answer it.
9418
9515
  */
9419
9516
  noteWriter(next) {
9420
9517
  const id = writerKey(next.deviceId, next.profile);
@@ -9531,7 +9628,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9531
9628
  /** The hour a write path may touch: current + previous. Materialised at boot
9532
9629
  * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
9533
9630
  async materializeLiveTail() {
9534
- const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$3;
9631
+ const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$4;
9535
9632
  const keys = [];
9536
9633
  for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
9537
9634
  return (await this.materialize(keys)).length;
@@ -10039,7 +10136,8 @@ var SegmentHourLedger = class SegmentHourLedger {
10039
10136
  deviceId: row.deviceId,
10040
10137
  profile: row.profile,
10041
10138
  locationId: row.locationId,
10042
- hourStartMs: hour
10139
+ hourStartMs: hour,
10140
+ entryMs: row.startMs
10043
10141
  });
10044
10142
  await this.serialized(key, async () => {
10045
10143
  if (!this.ledger.has(key)) {
@@ -10425,7 +10523,7 @@ function locationKey(deviceId, locationId) {
10425
10523
  * Work it does not finish is left for the next pass and REPORTED, never
10426
10524
  * silently dropped.
10427
10525
  */
10428
- var HOUR_MS$2 = 36e5;
10526
+ var HOUR_MS$3 = 36e5;
10429
10527
  /**
10430
10528
  * Group resolved locations by PHYSICAL root, so two aliases of one disk are
10431
10529
  * probed once and a `true` from each is not mistaken for the file existing in
@@ -10534,7 +10632,7 @@ var GhostLocationSweeper = class {
10534
10632
  refused: null
10535
10633
  };
10536
10634
  }
10537
- const liveTailFrom = this.deps.now() - HOUR_MS$2;
10635
+ const liveTailFrom = this.deps.now() - HOUR_MS$3;
10538
10636
  let examined = 0;
10539
10637
  let probes = 0;
10540
10638
  let repointedHours = 0;
@@ -11116,7 +11214,7 @@ function createRecordingConfigMirror(deps) {
11116
11214
  }
11117
11215
  //#endregion
11118
11216
  //#region src/recorder/addon/ledger-walk-reconcile.ts
11119
- var HOUR_MS$1 = 36e5;
11217
+ var HOUR_MS$2 = 36e5;
11120
11218
  /** Every reason at zero, so a reason that never fired reports as zero rather
11121
11219
  * than absent and the summary shape is constant between passes. */
11122
11220
  function zeroReasons() {
@@ -11212,7 +11310,7 @@ var LedgerWalkReconciler = class {
11212
11310
  ledger,
11213
11311
  apply,
11214
11312
  anchor: anchor.volumeId,
11215
- liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$1,
11313
+ liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$2,
11216
11314
  maxHours: this.deps.maxHours ?? 4096,
11217
11315
  byDevice: /* @__PURE__ */ new Map(),
11218
11316
  skippedByReason: zeroReasons(),
@@ -11845,6 +11943,240 @@ function pendingMovesSignature(moves) {
11845
11943
  return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
11846
11944
  }
11847
11945
  //#endregion
11946
+ //#region src/recorder/addon/hour-adoption-sweep.ts
11947
+ var HOUR_MS$1 = 36e5;
11948
+ /** The profile trees a camera directory is allowed to have. Everything else
11949
+ * under it (`events/`, the retired `strips/`) has no hour tree worth probing. */
11950
+ var PROFILES = [
11951
+ "high",
11952
+ "mid",
11953
+ "low"
11954
+ ];
11955
+ function isTwoDigit(name) {
11956
+ return /^\d{2}$/.test(name);
11957
+ }
11958
+ /** One directory level in a stable order. Lexicographic, not numeric: it is the
11959
+ * CURSOR's order, and zero-padded path components (`09`, `17`) already sort
11960
+ * chronologically — device ids do not, and do not need to. */
11961
+ function inCursorOrder(names) {
11962
+ return [...names].toSorted();
11963
+ }
11964
+ /**
11965
+ * One add-only pass over one location's hour directories.
11966
+ *
11967
+ * Never throws: a directory that cannot be read is counted and the pass
11968
+ * continues; a precondition failure is a named refusal and the pass does
11969
+ * nothing at all.
11970
+ */
11971
+ var HourAdoptionSweeper = class {
11972
+ deps;
11973
+ /** Where the last bounded pass stopped (`rel` of the last hour it walked).
11974
+ * In RAM: a restart re-walks from the top, which costs listings and can
11975
+ * never cost footage. */
11976
+ cursor = null;
11977
+ constructor(deps) {
11978
+ this.deps = deps;
11979
+ }
11980
+ async sweep(request) {
11981
+ const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
11982
+ if (location === void 0) return this.refusedReport(request.locationId, "location-unknown");
11983
+ if (!locationMayBeRead(location)) return this.refusedReport(location.id, "location-unreadable");
11984
+ const ledger = this.deps.ledger();
11985
+ if (ledger === null) {
11986
+ this.deps.logger.warn("recorder: hour adoption REFUSED — this node holds no durable hour ledger, so there is nothing to adopt into", { meta: { locationId: location.id } });
11987
+ return this.refusedReport(location.id, "no-ledger");
11988
+ }
11989
+ const liveTailFrom = hourStartMs(this.deps.now()) - 1 * HOUR_MS$1;
11990
+ const maxHours = this.deps.maxHours ?? 512;
11991
+ const resume = this.cursor;
11992
+ const byDevice = /* @__PURE__ */ new Map();
11993
+ let listings = 0;
11994
+ let hoursAdopted = 0;
11995
+ let hoursRefusedByLedger = 0;
11996
+ let hoursSkippedLive = 0;
11997
+ let hoursSkippedEmpty = 0;
11998
+ let listingErrors = 0;
11999
+ let segmentsAdded = 0;
12000
+ let hoursCreated = 0;
12001
+ let bounded = false;
12002
+ let last = null;
12003
+ for await (const candidate of this.candidates(location, () => {
12004
+ listingErrors += 1;
12005
+ })) {
12006
+ if (resume !== null && candidate.rel <= resume) continue;
12007
+ if (candidate.hourStartMs >= liveTailFrom) {
12008
+ hoursSkippedLive += 1;
12009
+ continue;
12010
+ }
12011
+ if (listings >= maxHours) {
12012
+ bounded = true;
12013
+ break;
12014
+ }
12015
+ let names;
12016
+ try {
12017
+ names = await this.deps.listDir(`${location.root}/${candidate.rel}`);
12018
+ } catch (err) {
12019
+ listingErrors += 1;
12020
+ this.deps.logger.debug("recorder: hour adoption could not list an hour directory", {
12021
+ tags: { deviceId: candidate.deviceId },
12022
+ meta: {
12023
+ locationId: location.id,
12024
+ rel: candidate.rel,
12025
+ error: require_dist.errMsg(err)
12026
+ }
12027
+ });
12028
+ continue;
12029
+ }
12030
+ listings += 1;
12031
+ last = candidate.rel;
12032
+ if (listings % 8 === 0) await this.deps.pace();
12033
+ const paths = (names ?? []).filter((name) => name.endsWith(".m4s")).map((name) => `${candidate.rel}/${name}`);
12034
+ if (paths.length === 0) {
12035
+ hoursSkippedEmpty += 1;
12036
+ continue;
12037
+ }
12038
+ const outcome = await ledger.adoptWalkedHour(candidate.deviceId, candidate.profile, location.id, candidate.hourStartMs, paths);
12039
+ if (outcome === null) {
12040
+ hoursRefusedByLedger += 1;
12041
+ continue;
12042
+ }
12043
+ hoursAdopted += 1;
12044
+ segmentsAdded += outcome.added;
12045
+ if (outcome.created) hoursCreated += 1;
12046
+ const tally = byDevice.get(candidate.deviceId) ?? {
12047
+ hoursAdopted: 0,
12048
+ segmentsAdded: 0,
12049
+ hoursCreated: 0
12050
+ };
12051
+ tally.hoursAdopted += 1;
12052
+ tally.segmentsAdded += outcome.added;
12053
+ if (outcome.created) tally.hoursCreated += 1;
12054
+ byDevice.set(candidate.deviceId, tally);
12055
+ if (outcome.added > 0) this.deps.logger.warn("recorder: adopted segments the durable hour row did not name — this hour was under-reported to every reader", {
12056
+ tags: { deviceId: candidate.deviceId },
12057
+ meta: {
12058
+ locationId: location.id,
12059
+ profile: candidate.profile,
12060
+ hourStartMs: candidate.hourStartMs,
12061
+ added: outcome.added,
12062
+ created: outcome.created,
12063
+ onDisk: paths.length
12064
+ }
12065
+ });
12066
+ }
12067
+ this.cursor = bounded ? last ?? resume : null;
12068
+ const report = {
12069
+ locationId: location.id,
12070
+ refused: null,
12071
+ listings,
12072
+ hoursAdopted,
12073
+ hoursRefusedByLedger,
12074
+ hoursSkippedLive,
12075
+ hoursSkippedEmpty,
12076
+ listingErrors,
12077
+ segmentsAdded,
12078
+ hoursCreated,
12079
+ bounded,
12080
+ byDevice: [...byDevice.entries()].map(([deviceId, tally]) => ({
12081
+ deviceId,
12082
+ ...tally
12083
+ })).toSorted((a, b) => a.deviceId - b.deviceId)
12084
+ };
12085
+ this.report(report);
12086
+ return report;
12087
+ }
12088
+ /**
12089
+ * Every hour directory under this location, in cursor order.
12090
+ *
12091
+ * Sorted at each level so the cursor is a total order over a tree two passes
12092
+ * see differently: a camera added between passes appears in its place rather
12093
+ * than at the end, and is picked up by the pass after the cursor resets.
12094
+ */
12095
+ async *candidates(location, onError) {
12096
+ const devices = await this.list(location.root, onError);
12097
+ for (const device of inCursorOrder(devices.filter((name) => /^\d+$/.test(name)))) {
12098
+ const deviceId = Number(device);
12099
+ if (!Number.isSafeInteger(deviceId)) continue;
12100
+ const present = new Set(await this.list(`${location.root}/${device}`, onError));
12101
+ for (const profile of PROFILES) {
12102
+ if (!present.has(profile)) continue;
12103
+ const base = `${device}/${profile}`;
12104
+ for (const year of inCursorOrder((await this.list(`${location.root}/${base}`, onError)).filter((n) => /^\d{4}$/.test(n)))) for (const month of inCursorOrder((await this.list(`${location.root}/${base}/${year}`, onError)).filter(isTwoDigit))) for (const day of inCursorOrder((await this.list(`${location.root}/${base}/${year}/${month}`, onError)).filter(isTwoDigit))) {
12105
+ const dayDir = `${base}/${year}/${month}/${day}`;
12106
+ for (const hour of inCursorOrder((await this.list(`${location.root}/${dayDir}`, onError)).filter(isTwoDigit))) {
12107
+ const startMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour));
12108
+ if (!Number.isFinite(startMs)) continue;
12109
+ yield {
12110
+ deviceId,
12111
+ profile,
12112
+ hourStartMs: startMs,
12113
+ rel: `${dayDir}/${hour}`
12114
+ };
12115
+ }
12116
+ }
12117
+ }
12118
+ }
12119
+ }
12120
+ /** One directory's names; `[]` for a directory that is not there, and `[]`
12121
+ * plus a counted error for one that could not be read. */
12122
+ async list(absDir, onError) {
12123
+ try {
12124
+ return await this.deps.listDir(absDir) ?? [];
12125
+ } catch (err) {
12126
+ onError();
12127
+ this.deps.logger.debug("recorder: hour adoption could not list a directory", { meta: {
12128
+ dir: absDir,
12129
+ error: require_dist.errMsg(err)
12130
+ } });
12131
+ return [];
12132
+ }
12133
+ }
12134
+ refusedReport(locationId, refused) {
12135
+ return {
12136
+ locationId,
12137
+ refused,
12138
+ listings: 0,
12139
+ hoursAdopted: 0,
12140
+ hoursRefusedByLedger: 0,
12141
+ hoursSkippedLive: 0,
12142
+ hoursSkippedEmpty: 0,
12143
+ listingErrors: 0,
12144
+ segmentsAdded: 0,
12145
+ hoursCreated: 0,
12146
+ bounded: false,
12147
+ byDevice: []
12148
+ };
12149
+ }
12150
+ /** One line per camera that gained something, then one for the pass — a pass
12151
+ * that found nothing must still be distinguishable from one that never ran. */
12152
+ report(report) {
12153
+ for (const device of report.byDevice) {
12154
+ if (device.segmentsAdded === 0 && device.hoursCreated === 0) continue;
12155
+ this.deps.logger.info("recorder: hour adoption — camera summary", {
12156
+ tags: { deviceId: device.deviceId },
12157
+ meta: {
12158
+ locationId: report.locationId,
12159
+ hoursAdopted: device.hoursAdopted,
12160
+ segmentsAdded: device.segmentsAdded,
12161
+ hoursCreated: device.hoursCreated
12162
+ }
12163
+ });
12164
+ }
12165
+ this.deps.logger.info("recorder: hour adoption pass complete", { meta: {
12166
+ locationId: report.locationId,
12167
+ listings: report.listings,
12168
+ hoursAdopted: report.hoursAdopted,
12169
+ hoursRefusedByLedger: report.hoursRefusedByLedger,
12170
+ hoursSkippedLive: report.hoursSkippedLive,
12171
+ hoursSkippedEmpty: report.hoursSkippedEmpty,
12172
+ listingErrors: report.listingErrors,
12173
+ segmentsAdded: report.segmentsAdded,
12174
+ hoursCreated: report.hoursCreated,
12175
+ bounded: report.bounded
12176
+ } });
12177
+ }
12178
+ };
12179
+ //#endregion
11848
12180
  //#region src/recorder/addon/periodic-pass.ts
11849
12181
  function startPeriodicPass(deps) {
11850
12182
  const now = () => deps.now?.() ?? Date.now();
@@ -14204,11 +14536,16 @@ function planFinalizations(body, processed, forceFinal = false) {
14204
14536
  durMs
14205
14537
  });
14206
14538
  }
14539
+ const front = entries.length > 0 ? parseEpochStartMs(entries[0].segPath) : null;
14540
+ const longestDurMs = entries.reduce((longest, entry) => Math.max(longest, Math.round(entry.durSeconds * 1e3)), 0);
14541
+ const spanKnown = rolledOff > 0 && front !== null && longestDurMs > 0;
14207
14542
  return {
14208
14543
  toHandle,
14209
14544
  from: hw,
14210
14545
  processed: Math.max(hw, finalizable),
14211
- rolledOff
14546
+ rolledOff,
14547
+ rolledOffFromMs: spanKnown ? front - rolledOff * longestDurMs : null,
14548
+ rolledOffToMs: spanKnown ? front : null
14212
14549
  };
14213
14550
  }
14214
14551
  /**
@@ -14236,11 +14573,24 @@ function startSegmentWatcher(deps) {
14236
14573
  return;
14237
14574
  }
14238
14575
  const plan = planFinalizations(body, processed, forceFinal);
14239
- if (plan.rolledOff > 0) deps.logger.warn("segment playlist rolled past entries this watcher had not handled", { meta: {
14240
- rolledOff: plan.rolledOff,
14241
- processed,
14242
- from: plan.from
14243
- } });
14576
+ if (plan.rolledOff > 0) {
14577
+ deps.logger.warn("segment playlist rolled past entries this watcher had not handled", {
14578
+ tags: { deviceId: deps.deviceId },
14579
+ meta: {
14580
+ rolledOff: plan.rolledOff,
14581
+ processed,
14582
+ from: plan.from,
14583
+ fromMs: plan.rolledOffFromMs,
14584
+ toMs: plan.rolledOffToMs
14585
+ }
14586
+ });
14587
+ deps.onSegmentsMissed?.({
14588
+ deviceId: deps.deviceId,
14589
+ count: plan.rolledOff,
14590
+ fromMs: plan.rolledOffFromMs,
14591
+ toMs: plan.rolledOffToMs
14592
+ });
14593
+ }
14244
14594
  let index = plan.from;
14245
14595
  for (const { entry, durMs } of plan.toHandle) {
14246
14596
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -14250,11 +14600,14 @@ function startSegmentWatcher(deps) {
14250
14600
  waitedMs,
14251
14601
  boundMs: 3e4
14252
14602
  })) {
14253
- deps.logger.warn("segment file never landed — skipping", { meta: {
14254
- seg: entry.segPath,
14255
- waitedMs,
14256
- boundMs: PENDING_GRACE_MS
14257
- } });
14603
+ deps.logger.warn("segment file never landed — skipping", {
14604
+ tags: { deviceId: deps.deviceId },
14605
+ meta: {
14606
+ seg: entry.segPath,
14607
+ waitedMs,
14608
+ boundMs: PENDING_GRACE_MS
14609
+ }
14610
+ });
14258
14611
  pendingIndex = -1;
14259
14612
  pendingSinceMs = 0;
14260
14613
  index++;
@@ -16092,8 +16445,12 @@ var RecordingController = class {
16092
16445
  writer,
16093
16446
  watcher: startSegmentWatcher({
16094
16447
  outDir,
16448
+ deviceId,
16095
16449
  writerStartedMs,
16096
16450
  now: () => this.now(),
16451
+ onSegmentsMissed: (missed) => {
16452
+ this.deps.onSegmentsMissed?.(missed, profile);
16453
+ },
16097
16454
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
16098
16455
  onFinalized: async (startMs, durMs, flatAbsPath) => {
16099
16456
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
@@ -17435,6 +17792,20 @@ var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
17435
17792
  * hung an SSH session on 2026-08-29.
17436
17793
  */
17437
17794
  var LEDGER_WALK_PAUSE_MS = 100;
17795
+ /**
17796
+ * How often the ADD-only hour adoption pass runs (D521).
17797
+ *
17798
+ * Daily, for the same reason the ghost sweep is daily: it spends `readdir`
17799
+ * syscalls on the array every live read competes for, and the damage it repairs
17800
+ * is made by a writer teardown — an event measured in days, not minutes. It
17801
+ * does NOT run at boot (`startPeriodicPass` fires on the interval only), which
17802
+ * is when the staging reconcile, the ledger load and the first retention sweep
17803
+ * are already contending for that array.
17804
+ */
17805
+ var HOUR_ADOPTION_INTERVAL_MS = 1440 * 6e4;
17806
+ /** An adoption pass longer than this is reported with its duration. Bounded
17807
+ * work (512 hour listings, paced) — minutes is normal, an hour is not. */
17808
+ var HOUR_ADOPTION_SLOW_AFTER_MS = 10 * 6e4;
17438
17809
  /** What the per-volume usage row reports when the hour ledger is unavailable:
17439
17810
  * nothing measured, rather than a total counted off a partial RAM index. */
17440
17811
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -17659,6 +18030,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17659
18030
  /** Daily ghost-location reconcile: durable hour rows stamped with a location
17660
18031
  * the storage record no longer has. Armed in `onHubReachable`. */
17661
18032
  ghostLocationPass = null;
18033
+ hourAdoptionPass = null;
17662
18034
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
17663
18035
  storageMigrationLeaseId = null;
17664
18036
  /**
@@ -17900,6 +18272,27 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
17900
18272
  onWriterClosed: async (deviceId, profile) => {
17901
18273
  await this.segmentHours?.sealWriter(deviceId, profile);
17902
18274
  },
18275
+ onSegmentsMissed: (missed, profile) => {
18276
+ const ledger = this.segmentHours;
18277
+ if (ledger === null) return;
18278
+ if (missed.fromMs === null || missed.toMs === null) {
18279
+ this.ctx.logger.warn("recorder: segments were missed but the playlist named no span — NO hour could be held unsealed for them", {
18280
+ tags: { deviceId: missed.deviceId },
18281
+ meta: {
18282
+ profile,
18283
+ segments: missed.count
18284
+ }
18285
+ });
18286
+ return;
18287
+ }
18288
+ ledger.markHoursRefused({
18289
+ deviceId: missed.deviceId,
18290
+ profile,
18291
+ fromMs: missed.fromMs,
18292
+ toMs: missed.toMs,
18293
+ count: missed.count
18294
+ });
18295
+ },
17903
18296
  spawn: node_child_process.spawn,
17904
18297
  costRegistry: this.costRegistry,
17905
18298
  segmentSeconds: this.config.segmentSeconds,
@@ -18265,6 +18658,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18265
18658
  this.redundancySweeper = null;
18266
18659
  this.ghostLocationPass?.stop();
18267
18660
  this.ghostLocationPass = null;
18661
+ this.hourAdoptionPass?.stop();
18662
+ this.hourAdoptionPass = null;
18268
18663
  this.exportEngine = null;
18269
18664
  this.recordingProvider = null;
18270
18665
  }
@@ -18516,6 +18911,32 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
18516
18911
  logger: this.ctx.logger
18517
18912
  });
18518
18913
  }
18914
+ if (this.hourAdoptionPass === null) {
18915
+ const adopter = new HourAdoptionSweeper({
18916
+ ledger: () => this.segmentHours,
18917
+ locations: () => this.resolvedLocations,
18918
+ listDir: async (absDir) => {
18919
+ try {
18920
+ return await node_fs.promises.readdir(absDir);
18921
+ } catch (err) {
18922
+ if (err.code === "ENOENT") return null;
18923
+ throw err;
18924
+ }
18925
+ },
18926
+ logger: this.ctx.logger,
18927
+ now: () => Date.now(),
18928
+ pace: () => require_dist.sleep(LEDGER_WALK_PAUSE_MS)
18929
+ });
18930
+ this.hourAdoptionPass = startPeriodicPass({
18931
+ name: "hour-adoption",
18932
+ intervalMs: HOUR_ADOPTION_INTERVAL_MS,
18933
+ slowAfterMs: HOUR_ADOPTION_SLOW_AFTER_MS,
18934
+ run: async () => {
18935
+ for (const location of this.resolvedLocations) await adopter.sweep({ locationId: location.id });
18936
+ },
18937
+ logger: this.ctx.logger
18938
+ });
18939
+ }
18519
18940
  }
18520
18941
  /**
18521
18942
  * Print one window of detection-trigger health.