@camstack/addon-pipeline 1.2.255 → 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 (31) 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-DRTO2E6G.mjs → default-detection-model-A5DRjEr3.mjs} +1 -1
  4. package/dist/{default-detection-model-CCFY8YLF.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-BS-l_k7M.mjs → dist-MJ4ji7um.mjs} +43 -1
  8. package/dist/{dist-CqqhxcYH.js → dist-lqqUXDfs.js} +43 -1
  9. package/dist/motion-wasm/index.js +1 -1
  10. package/dist/motion-wasm/index.mjs +1 -1
  11. package/dist/{node-bdHZWpo1.js → node-80-uOMr8.js} +1 -1
  12. package/dist/{node-DjvGLcbe.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-CrDzHWbQ.mjs → process-memory-Cl3ubMxY.mjs} +1 -1
  16. package/dist/{process-memory-hQeELS5T.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-CaDmuf08.js → segment-demux-js-CWwlKQ3M.js} +1 -1
  20. package/dist/{segment-demux-js-BnYubI9B.mjs → segment-demux-js-gIj82O9k.mjs} +1 -1
  21. package/dist/stream-broker/_stub.js +1 -1
  22. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-qftSJVIp.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_ui_mf_2_library__loadShare__.js-BXK7jbJm.mjs +26 -0
  24. package/dist/stream-broker/demux-worker-child.js +1 -1
  25. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  26. package/dist/stream-broker/{hostInit-BbuA-Tci.mjs → hostInit-BRnsXyTM.mjs} +3 -3
  27. package/dist/stream-broker/index.js +3 -3
  28. package/dist/stream-broker/index.mjs +3 -3
  29. package/dist/stream-broker/remoteEntry.js +1 -1
  30. package/package.json +1 -1
  31. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-D8WW_JG-.mjs +0 -26
@@ -1,5 +1,5 @@
1
- import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-BS-l_k7M.mjs";
2
- import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DjvGLcbe.mjs";
1
+ import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-MJ4ji7um.mjs";
2
+ import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DdXmHLK4.mjs";
3
3
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
4
4
  import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-D2VT97-U.mjs";
5
5
  import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
@@ -814,14 +814,14 @@ function parseSegmentPath(relPath) {
814
814
  //#region src/recorder/recording-index.ts
815
815
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
816
816
  * already gives us — so "have I walked this?" costs one Set lookup. */
817
- var HOUR_MS$8 = 36e5;
817
+ var HOUR_MS$9 = 36e5;
818
818
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
819
819
  * the retention window into the set. */
820
820
  var ALL_HOURS = Symbol("all-hours");
821
821
  /** The hour buckets a half-open span touches. */
822
822
  function* hourBuckets(fromMs, toMs) {
823
- const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
824
- for (let h = first; h < toMs; h += HOUR_MS$8) yield h;
823
+ const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
824
+ for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
825
825
  }
826
826
  /**
827
827
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
@@ -1115,7 +1115,7 @@ var RecordingIndex = class {
1115
1115
  changed = true;
1116
1116
  }
1117
1117
  if (changed) this.invalidateSorted(deviceId);
1118
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$8);
1118
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
1119
1119
  }
1120
1120
  /**
1121
1121
  * Insert/replace one finalized segment (idempotent by path).
@@ -1761,11 +1761,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
1761
1761
  const p2 = (n) => String(n).padStart(2, "0");
1762
1762
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
1763
1763
  }
1764
- var HOUR_MS$7 = 36e5;
1764
+ var HOUR_MS$8 = 36e5;
1765
1765
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
1766
1766
  const verify = options?.verifyClosedHours === true;
1767
1767
  if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
1768
- const liveHour = Math.floor(Date.now() / HOUR_MS$7) * HOUR_MS$7;
1768
+ const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
1769
1769
  /** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
1770
1770
  const claimed = /* @__PURE__ */ new Map();
1771
1771
  const claimKey = (locationId, hour) => `${locationId}:${hour}`;
@@ -1815,15 +1815,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1815
1815
  });
1816
1816
  continue;
1817
1817
  }
1818
- const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
1818
+ const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
1819
1819
  const reuseBefore = index.hydrateReuseStats();
1820
1820
  let hoursRead = 0;
1821
1821
  let repaired = 0;
1822
1822
  let forgottenSegments = 0;
1823
1823
  let forgottenHours = 0;
1824
1824
  let sealed = 0;
1825
- for (let hour = first; hour < toMs; hour += HOUR_MS$7) {
1826
- if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$7) === "hydrated") continue;
1825
+ for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
1826
+ if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
1827
1827
  for (const profile of profiles) {
1828
1828
  const rel = hourDirRelPath(deviceId, profile, hour);
1829
1829
  let files;
@@ -1836,7 +1836,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1836
1836
  const locationId = locationForHydratedProfile(aliases, profile).id;
1837
1837
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
1838
1838
  index.hydrateHour(deviceId, locationId, hour, relPaths);
1839
- if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$7 > Date.now()) continue;
1839
+ if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
1840
1840
  const claimedHere = claimed.get(claimKey(locationId, hour));
1841
1841
  const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
1842
1842
  if (outcome === null) continue;
@@ -1919,7 +1919,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
1919
1919
  */
1920
1920
  var DEFAULT_THROTTLE_MBPS = 40;
1921
1921
  /** The durable index's bucket, and therefore this mover's batch. */
1922
- var HOUR_MS$6 = 36e5;
1922
+ var HOUR_MS$7 = 36e5;
1923
1923
  /** Progress callback cadence (files) — cheap enough to fire often. */
1924
1924
  var PROGRESS_EVERY_FILES = 25;
1925
1925
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -2616,7 +2616,7 @@ function profilesOfClass(footageClass) {
2616
2616
  function groupByHour(rows) {
2617
2617
  const out = /* @__PURE__ */ new Map();
2618
2618
  for (const row of rows) {
2619
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2619
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2620
2620
  const found = out.get(key);
2621
2621
  if (found) found.push(row);
2622
2622
  else out.set(key, [row]);
@@ -3844,7 +3844,7 @@ async function profileHasFootage(root, deviceId, profile) {
3844
3844
  //#endregion
3845
3845
  //#region src/recorder/addon/calendar-index.ts
3846
3846
  var DAY_MS$3 = 864e5;
3847
- var HOUR_MS$5 = 36e5;
3847
+ var HOUR_MS$6 = 36e5;
3848
3848
  /**
3849
3849
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3850
3850
  *
@@ -3882,8 +3882,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3882
3882
  * deploy gap). Unknown hours still fall through to one directory read.
3883
3883
  */
3884
3884
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3885
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3886
- const hourEnd = hour + HOUR_MS$5;
3885
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3886
+ const hourEnd = hour + HOUR_MS$6;
3887
3887
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3888
3888
  const rows = [];
3889
3889
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -4042,7 +4042,7 @@ var CalendarIndex = class {
4042
4042
  }));
4043
4043
  const days = /* @__PURE__ */ new Set();
4044
4044
  for (const { day, hours } of perDay) for (const h of hours) {
4045
- const at = day + h * HOUR_MS$5;
4045
+ const at = day + h * HOUR_MS$6;
4046
4046
  if (at < fromMs || at >= toMs) continue;
4047
4047
  days.add(localDayStart(at, tzOffsetMinutes));
4048
4048
  }
@@ -4104,7 +4104,7 @@ var CalendarIndex = class {
4104
4104
  const day = utcDayOf(utcDayMs);
4105
4105
  for (let h = 0; h < 24; h++) {
4106
4106
  await this.awaitQuiet();
4107
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
4107
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
4108
4108
  }
4109
4109
  }
4110
4110
  /**
@@ -4125,7 +4125,7 @@ var CalendarIndex = class {
4125
4125
  */
4126
4126
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
4127
4127
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
4128
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
4128
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
4129
4129
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
4130
4130
  if (fromIndex !== null && fromIndex !== void 0) return {
4131
4131
  segments: fromIndex,
@@ -4133,7 +4133,7 @@ var CalendarIndex = class {
4133
4133
  };
4134
4134
  const key = `${deviceId}:${profile}:${hour}`;
4135
4135
  const now = this.deps.now?.() ?? Date.now();
4136
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
4136
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
4137
4137
  const cached = this.hourSegs.get(key);
4138
4138
  if (cached !== void 0) {
4139
4139
  if (!isCurrentHour) return {
@@ -4197,7 +4197,7 @@ var CalendarIndex = class {
4197
4197
  */
4198
4198
  async dayRanges(deviceId, profile, utcDayMs) {
4199
4199
  const day = utcDayOf(utcDayMs);
4200
- 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);
4200
+ 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);
4201
4201
  }
4202
4202
  /**
4203
4203
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -4211,9 +4211,9 @@ var CalendarIndex = class {
4211
4211
  */
4212
4212
  async rangesIn(deviceId, profile, fromMs, toMs) {
4213
4213
  if (toMs <= fromMs) return [];
4214
- const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4214
+ const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
4215
4215
  const hours = [];
4216
- for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
4216
+ for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
4217
4217
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
4218
4218
  const ranges = [];
4219
4219
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4765,7 +4765,7 @@ function handleStoreCensusAction(census, logger, input) {
4765
4765
  }
4766
4766
  //#endregion
4767
4767
  //#region src/recorder/addon/directory-handler.ts
4768
- var HOUR_MS$4 = 36e5;
4768
+ var HOUR_MS$5 = 36e5;
4769
4769
  /** A window wider than this is refused: it is a client bug, and answering it
4770
4770
  * would walk the archive on the read path — the thing this replaces. */
4771
4771
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4812,9 +4812,9 @@ function withDirectoryRoute(deps, next) {
4812
4812
  return;
4813
4813
  }
4814
4814
  const rows = [];
4815
- const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4815
+ const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4816
4816
  let hydrated = true;
4817
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4817
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4818
4818
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4819
4819
  if (!complete) hydrated = false;
4820
4820
  for (const s of segments) {
@@ -8565,7 +8565,7 @@ var DurableLedger = class DurableLedger {
8565
8565
  };
8566
8566
  //#endregion
8567
8567
  //#region src/recorder/addon/segment-hour-ledger.ts
8568
- var HOUR_MS$3 = 36e5;
8568
+ var HOUR_MS$4 = 36e5;
8569
8569
  /** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
8570
8570
  function minOf(a, b) {
8571
8571
  return a === null ? b : Math.min(a, b);
@@ -8704,6 +8704,27 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
8704
8704
  ]
8705
8705
  })
8706
8706
  ];
8707
+ /**
8708
+ * How far into an hour a writer's first finalize may land and the writer still
8709
+ * be the party that KNOWS what the hour holds (D521).
8710
+ *
8711
+ * A seal is a claim of completeness. A writer that was running across the hour
8712
+ * boundary produces its first in-hour segment within ONE segment length of the
8713
+ * boundary — the segment that straddles it belongs to the previous hour by its
8714
+ * `startMs`. So the grace is one segment length at the longest anyone
8715
+ * configures here (60 s), plus 30 s for a keyframe-cut segment overrunning its
8716
+ * nominal `-segment_time` (ffmpeg cuts on the next GOP, never on the clock).
8717
+ *
8718
+ * It is a guess, and this is the shape of being wrong in each direction.
8719
+ * TIGHTER: a normal rollover stops sealing, every hour of the archive is walked
8720
+ * once by its first reader, and D451's "at rest nothing is unsealed" is undone
8721
+ * — at ~3.6 ms per hour directory cold, affordable but pointless. LOOSER: a
8722
+ * runner respawn inside the window seals an hour it entered late, which is
8723
+ * exactly the 2026-09-17 defect; the observed hole there was ~29 minutes, so
8724
+ * anything under a few minutes would have caught it and 90 s is the smallest
8725
+ * number that does not fire on a legitimate rollover.
8726
+ */
8727
+ var WRITER_HOUR_ENTRY_GRACE_MS = 9e4;
8707
8728
  /** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
8708
8729
  var EMPTY_HOUR_AGGREGATE = {
8709
8730
  bytes: 0,
@@ -8924,7 +8945,7 @@ var EMPTY_ACCOUNTING = {
8924
8945
  newestMs: null
8925
8946
  };
8926
8947
  function hourStartMs(startMs) {
8927
- return Math.floor(startMs / HOUR_MS$3) * HOUR_MS$3;
8948
+ return Math.floor(startMs / HOUR_MS$4) * HOUR_MS$4;
8928
8949
  }
8929
8950
  function segmentHourKey(deviceId, profile, locationId, hour) {
8930
8951
  return `${deviceId}|${profile}|${locationId}|${hour}`;
@@ -8941,6 +8962,11 @@ function hourOf(row) {
8941
8962
  function writerKey(deviceId, profile) {
8942
8963
  return `${deviceId}|${profile}`;
8943
8964
  }
8965
+ /** One writer's hour, WITHOUT the location — the identity a loss is reported
8966
+ * against by a party that only knows the camera, the profile and the clock. */
8967
+ function writerHourKey(deviceId, profile, hour) {
8968
+ return `${deviceId}|${profile}|${hour}`;
8969
+ }
8944
8970
  function detailOf(row) {
8945
8971
  return {
8946
8972
  locationId: row.locationId,
@@ -9087,6 +9113,13 @@ var SegmentHourLedger = class SegmentHourLedger {
9087
9113
  writerHours = /* @__PURE__ */ new Map();
9088
9114
  /** Hour keys a write was REFUSED for in this process — never sealed by it. */
9089
9115
  refused = /* @__PURE__ */ new Set();
9116
+ /**
9117
+ * `${deviceId}|${profile}|${hourStartMs}` a LOSS was reported for, location
9118
+ * left out on purpose: the party that sees the loss (the playlist watcher)
9119
+ * knows the camera and the profile and has no business knowing which disk
9120
+ * the recorder happens to be placing that hour on.
9121
+ */
9122
+ refusedWriterHours = /* @__PURE__ */ new Set();
9090
9123
  constructor(deps) {
9091
9124
  this.logger = deps.logger;
9092
9125
  this.store = deps.store;
@@ -9297,6 +9330,10 @@ var SegmentHourLedger = class SegmentHourLedger {
9297
9330
  *
9298
9331
  * `null` when the row is known and its detail could not be read: nothing
9299
9332
  * is written from a row whose paths are not resident.
9333
+ *
9334
+ * @seal-authority closed-hour-listing — a directory read of an hour that is
9335
+ * OVER is the one complete answer there is; the caller guarantees the hour is
9336
+ * closed (D451, D521).
9300
9337
  */
9301
9338
  async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
9302
9339
  const key = segmentHourKey(deviceId, profile, locationId, hour);
@@ -9381,11 +9418,65 @@ var SegmentHourLedger = class SegmentHourLedger {
9381
9418
  markRefused(row) {
9382
9419
  this.refused.add(hourOf(row).key);
9383
9420
  }
9421
+ /**
9422
+ * Segments a writer produced that never reached {@link recordSegment} — the
9423
+ * watcher's `rolledOff`, the one moment the system KNOWS work was skipped.
9424
+ *
9425
+ * Every hour the loss spans is refused the seal, whatever else this process
9426
+ * believes about them: the ownership grace answers "was I here", and this
9427
+ * answers "did I get everything while I was". Camera 615's respawned writer
9428
+ * passed the first question for the hour it entered at the top and still held
9429
+ * a row short by 175 segments.
9430
+ *
9431
+ * Returns how many hours were marked, so the caller's line reports what its
9432
+ * report bought rather than that it was made.
9433
+ */
9434
+ markHoursRefused(span) {
9435
+ if (!Number.isFinite(span.fromMs) || !Number.isFinite(span.toMs)) return 0;
9436
+ const first = hourStartMs(Math.min(span.fromMs, span.toMs));
9437
+ const last = hourStartMs(Math.max(span.fromMs, span.toMs));
9438
+ let marked = 0;
9439
+ for (let hour = first; hour <= last && marked < 24; hour += HOUR_MS$4) {
9440
+ this.refusedWriterHours.add(writerHourKey(span.deviceId, span.profile, hour));
9441
+ marked += 1;
9442
+ }
9443
+ if (marked === 0) return 0;
9444
+ this.logger.warn("recorder: segments this writer produced never reached the hour ledger — these hours stay UNSEALED and a reader walks them", {
9445
+ tags: { deviceId: span.deviceId },
9446
+ meta: {
9447
+ profile: span.profile,
9448
+ fromMs: first,
9449
+ toMs: last,
9450
+ hours: marked,
9451
+ segments: span.count,
9452
+ boundHours: 24
9453
+ }
9454
+ });
9455
+ return marked;
9456
+ }
9384
9457
  /** One write: the row as held, flagged sealed. Serialised on its key so it
9385
- * cannot land over an append that read the row before it. */
9458
+ * cannot land over an append that read the row before it.
9459
+ *
9460
+ * @seal-authority writer-owned-hour — the writer that LEAVES the hour, and
9461
+ * only when it entered within {@link WRITER_HOUR_ENTRY_GRACE_MS} of the
9462
+ * hour's start and no loss was reported against it (D521). */
9386
9463
  async sealHour(target) {
9387
9464
  await this.serialized(target.key, async () => {
9388
- if (this.refused.has(target.key)) {
9465
+ const enteredAfterMs = target.entryMs - target.hourStartMs;
9466
+ if (enteredAfterMs > 9e4) {
9467
+ this.logger.warn("recorder: hour left unsealed — this writer entered it late, so it cannot claim the hour is complete", {
9468
+ tags: { deviceId: target.deviceId },
9469
+ meta: {
9470
+ profile: target.profile,
9471
+ locationId: target.locationId,
9472
+ hourStartMs: target.hourStartMs,
9473
+ enteredAfterMs,
9474
+ graceMs: WRITER_HOUR_ENTRY_GRACE_MS
9475
+ }
9476
+ });
9477
+ return;
9478
+ }
9479
+ if (this.refused.has(target.key) || this.refusedWriterHours.has(writerHourKey(target.deviceId, target.profile, target.hourStartMs))) {
9389
9480
  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", {
9390
9481
  tags: { deviceId: target.deviceId },
9391
9482
  meta: {
@@ -9405,9 +9496,15 @@ var SegmentHourLedger = class SegmentHourLedger {
9405
9496
  });
9406
9497
  }
9407
9498
  /**
9408
- * Remember which hour this writer is on; when it MOVES FORWARD, seal the
9409
- * hour it left. Backwards is a late finalize (a recovered orphan, a replay)
9410
- * into an hour already accounted for, and seals nothing.
9499
+ * Remember which hour this writer is on — and how far into it this process
9500
+ * ARRIVED ({@link WriterHour.entryMs}, the first finalize's own start) —
9501
+ * and when it MOVES FORWARD, seal the hour it left. Backwards is a late
9502
+ * finalize (a recovered orphan, a replay) into an hour already accounted for,
9503
+ * and seals nothing.
9504
+ *
9505
+ * The entry stamp is kept from the FIRST finalize into the hour and never
9506
+ * refreshed: the question the seal has to answer is "was this writer here
9507
+ * when the hour began", and the last segment of the hour cannot answer it.
9411
9508
  */
9412
9509
  noteWriter(next) {
9413
9510
  const id = writerKey(next.deviceId, next.profile);
@@ -9524,7 +9621,7 @@ var SegmentHourLedger = class SegmentHourLedger {
9524
9621
  /** The hour a write path may touch: current + previous. Materialised at boot
9525
9622
  * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
9526
9623
  async materializeLiveTail() {
9527
- const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$3;
9624
+ const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$4;
9528
9625
  const keys = [];
9529
9626
  for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
9530
9627
  return (await this.materialize(keys)).length;
@@ -10032,7 +10129,8 @@ var SegmentHourLedger = class SegmentHourLedger {
10032
10129
  deviceId: row.deviceId,
10033
10130
  profile: row.profile,
10034
10131
  locationId: row.locationId,
10035
- hourStartMs: hour
10132
+ hourStartMs: hour,
10133
+ entryMs: row.startMs
10036
10134
  });
10037
10135
  await this.serialized(key, async () => {
10038
10136
  if (!this.ledger.has(key)) {
@@ -10418,7 +10516,7 @@ function locationKey(deviceId, locationId) {
10418
10516
  * Work it does not finish is left for the next pass and REPORTED, never
10419
10517
  * silently dropped.
10420
10518
  */
10421
- var HOUR_MS$2 = 36e5;
10519
+ var HOUR_MS$3 = 36e5;
10422
10520
  /**
10423
10521
  * Group resolved locations by PHYSICAL root, so two aliases of one disk are
10424
10522
  * probed once and a `true` from each is not mistaken for the file existing in
@@ -10527,7 +10625,7 @@ var GhostLocationSweeper = class {
10527
10625
  refused: null
10528
10626
  };
10529
10627
  }
10530
- const liveTailFrom = this.deps.now() - HOUR_MS$2;
10628
+ const liveTailFrom = this.deps.now() - HOUR_MS$3;
10531
10629
  let examined = 0;
10532
10630
  let probes = 0;
10533
10631
  let repointedHours = 0;
@@ -11109,7 +11207,7 @@ function createRecordingConfigMirror(deps) {
11109
11207
  }
11110
11208
  //#endregion
11111
11209
  //#region src/recorder/addon/ledger-walk-reconcile.ts
11112
- var HOUR_MS$1 = 36e5;
11210
+ var HOUR_MS$2 = 36e5;
11113
11211
  /** Every reason at zero, so a reason that never fired reports as zero rather
11114
11212
  * than absent and the summary shape is constant between passes. */
11115
11213
  function zeroReasons() {
@@ -11205,7 +11303,7 @@ var LedgerWalkReconciler = class {
11205
11303
  ledger,
11206
11304
  apply,
11207
11305
  anchor: anchor.volumeId,
11208
- liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$1,
11306
+ liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$2,
11209
11307
  maxHours: this.deps.maxHours ?? 4096,
11210
11308
  byDevice: /* @__PURE__ */ new Map(),
11211
11309
  skippedByReason: zeroReasons(),
@@ -11838,6 +11936,240 @@ function pendingMovesSignature(moves) {
11838
11936
  return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
11839
11937
  }
11840
11938
  //#endregion
11939
+ //#region src/recorder/addon/hour-adoption-sweep.ts
11940
+ var HOUR_MS$1 = 36e5;
11941
+ /** The profile trees a camera directory is allowed to have. Everything else
11942
+ * under it (`events/`, the retired `strips/`) has no hour tree worth probing. */
11943
+ var PROFILES = [
11944
+ "high",
11945
+ "mid",
11946
+ "low"
11947
+ ];
11948
+ function isTwoDigit(name) {
11949
+ return /^\d{2}$/.test(name);
11950
+ }
11951
+ /** One directory level in a stable order. Lexicographic, not numeric: it is the
11952
+ * CURSOR's order, and zero-padded path components (`09`, `17`) already sort
11953
+ * chronologically — device ids do not, and do not need to. */
11954
+ function inCursorOrder(names) {
11955
+ return [...names].toSorted();
11956
+ }
11957
+ /**
11958
+ * One add-only pass over one location's hour directories.
11959
+ *
11960
+ * Never throws: a directory that cannot be read is counted and the pass
11961
+ * continues; a precondition failure is a named refusal and the pass does
11962
+ * nothing at all.
11963
+ */
11964
+ var HourAdoptionSweeper = class {
11965
+ deps;
11966
+ /** Where the last bounded pass stopped (`rel` of the last hour it walked).
11967
+ * In RAM: a restart re-walks from the top, which costs listings and can
11968
+ * never cost footage. */
11969
+ cursor = null;
11970
+ constructor(deps) {
11971
+ this.deps = deps;
11972
+ }
11973
+ async sweep(request) {
11974
+ const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
11975
+ if (location === void 0) return this.refusedReport(request.locationId, "location-unknown");
11976
+ if (!locationMayBeRead(location)) return this.refusedReport(location.id, "location-unreadable");
11977
+ const ledger = this.deps.ledger();
11978
+ if (ledger === null) {
11979
+ 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 } });
11980
+ return this.refusedReport(location.id, "no-ledger");
11981
+ }
11982
+ const liveTailFrom = hourStartMs(this.deps.now()) - 1 * HOUR_MS$1;
11983
+ const maxHours = this.deps.maxHours ?? 512;
11984
+ const resume = this.cursor;
11985
+ const byDevice = /* @__PURE__ */ new Map();
11986
+ let listings = 0;
11987
+ let hoursAdopted = 0;
11988
+ let hoursRefusedByLedger = 0;
11989
+ let hoursSkippedLive = 0;
11990
+ let hoursSkippedEmpty = 0;
11991
+ let listingErrors = 0;
11992
+ let segmentsAdded = 0;
11993
+ let hoursCreated = 0;
11994
+ let bounded = false;
11995
+ let last = null;
11996
+ for await (const candidate of this.candidates(location, () => {
11997
+ listingErrors += 1;
11998
+ })) {
11999
+ if (resume !== null && candidate.rel <= resume) continue;
12000
+ if (candidate.hourStartMs >= liveTailFrom) {
12001
+ hoursSkippedLive += 1;
12002
+ continue;
12003
+ }
12004
+ if (listings >= maxHours) {
12005
+ bounded = true;
12006
+ break;
12007
+ }
12008
+ let names;
12009
+ try {
12010
+ names = await this.deps.listDir(`${location.root}/${candidate.rel}`);
12011
+ } catch (err) {
12012
+ listingErrors += 1;
12013
+ this.deps.logger.debug("recorder: hour adoption could not list an hour directory", {
12014
+ tags: { deviceId: candidate.deviceId },
12015
+ meta: {
12016
+ locationId: location.id,
12017
+ rel: candidate.rel,
12018
+ error: errMsg(err)
12019
+ }
12020
+ });
12021
+ continue;
12022
+ }
12023
+ listings += 1;
12024
+ last = candidate.rel;
12025
+ if (listings % 8 === 0) await this.deps.pace();
12026
+ const paths = (names ?? []).filter((name) => name.endsWith(".m4s")).map((name) => `${candidate.rel}/${name}`);
12027
+ if (paths.length === 0) {
12028
+ hoursSkippedEmpty += 1;
12029
+ continue;
12030
+ }
12031
+ const outcome = await ledger.adoptWalkedHour(candidate.deviceId, candidate.profile, location.id, candidate.hourStartMs, paths);
12032
+ if (outcome === null) {
12033
+ hoursRefusedByLedger += 1;
12034
+ continue;
12035
+ }
12036
+ hoursAdopted += 1;
12037
+ segmentsAdded += outcome.added;
12038
+ if (outcome.created) hoursCreated += 1;
12039
+ const tally = byDevice.get(candidate.deviceId) ?? {
12040
+ hoursAdopted: 0,
12041
+ segmentsAdded: 0,
12042
+ hoursCreated: 0
12043
+ };
12044
+ tally.hoursAdopted += 1;
12045
+ tally.segmentsAdded += outcome.added;
12046
+ if (outcome.created) tally.hoursCreated += 1;
12047
+ byDevice.set(candidate.deviceId, tally);
12048
+ 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", {
12049
+ tags: { deviceId: candidate.deviceId },
12050
+ meta: {
12051
+ locationId: location.id,
12052
+ profile: candidate.profile,
12053
+ hourStartMs: candidate.hourStartMs,
12054
+ added: outcome.added,
12055
+ created: outcome.created,
12056
+ onDisk: paths.length
12057
+ }
12058
+ });
12059
+ }
12060
+ this.cursor = bounded ? last ?? resume : null;
12061
+ const report = {
12062
+ locationId: location.id,
12063
+ refused: null,
12064
+ listings,
12065
+ hoursAdopted,
12066
+ hoursRefusedByLedger,
12067
+ hoursSkippedLive,
12068
+ hoursSkippedEmpty,
12069
+ listingErrors,
12070
+ segmentsAdded,
12071
+ hoursCreated,
12072
+ bounded,
12073
+ byDevice: [...byDevice.entries()].map(([deviceId, tally]) => ({
12074
+ deviceId,
12075
+ ...tally
12076
+ })).toSorted((a, b) => a.deviceId - b.deviceId)
12077
+ };
12078
+ this.report(report);
12079
+ return report;
12080
+ }
12081
+ /**
12082
+ * Every hour directory under this location, in cursor order.
12083
+ *
12084
+ * Sorted at each level so the cursor is a total order over a tree two passes
12085
+ * see differently: a camera added between passes appears in its place rather
12086
+ * than at the end, and is picked up by the pass after the cursor resets.
12087
+ */
12088
+ async *candidates(location, onError) {
12089
+ const devices = await this.list(location.root, onError);
12090
+ for (const device of inCursorOrder(devices.filter((name) => /^\d+$/.test(name)))) {
12091
+ const deviceId = Number(device);
12092
+ if (!Number.isSafeInteger(deviceId)) continue;
12093
+ const present = new Set(await this.list(`${location.root}/${device}`, onError));
12094
+ for (const profile of PROFILES) {
12095
+ if (!present.has(profile)) continue;
12096
+ const base = `${device}/${profile}`;
12097
+ 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))) {
12098
+ const dayDir = `${base}/${year}/${month}/${day}`;
12099
+ for (const hour of inCursorOrder((await this.list(`${location.root}/${dayDir}`, onError)).filter(isTwoDigit))) {
12100
+ const startMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour));
12101
+ if (!Number.isFinite(startMs)) continue;
12102
+ yield {
12103
+ deviceId,
12104
+ profile,
12105
+ hourStartMs: startMs,
12106
+ rel: `${dayDir}/${hour}`
12107
+ };
12108
+ }
12109
+ }
12110
+ }
12111
+ }
12112
+ }
12113
+ /** One directory's names; `[]` for a directory that is not there, and `[]`
12114
+ * plus a counted error for one that could not be read. */
12115
+ async list(absDir, onError) {
12116
+ try {
12117
+ return await this.deps.listDir(absDir) ?? [];
12118
+ } catch (err) {
12119
+ onError();
12120
+ this.deps.logger.debug("recorder: hour adoption could not list a directory", { meta: {
12121
+ dir: absDir,
12122
+ error: errMsg(err)
12123
+ } });
12124
+ return [];
12125
+ }
12126
+ }
12127
+ refusedReport(locationId, refused) {
12128
+ return {
12129
+ locationId,
12130
+ refused,
12131
+ listings: 0,
12132
+ hoursAdopted: 0,
12133
+ hoursRefusedByLedger: 0,
12134
+ hoursSkippedLive: 0,
12135
+ hoursSkippedEmpty: 0,
12136
+ listingErrors: 0,
12137
+ segmentsAdded: 0,
12138
+ hoursCreated: 0,
12139
+ bounded: false,
12140
+ byDevice: []
12141
+ };
12142
+ }
12143
+ /** One line per camera that gained something, then one for the pass — a pass
12144
+ * that found nothing must still be distinguishable from one that never ran. */
12145
+ report(report) {
12146
+ for (const device of report.byDevice) {
12147
+ if (device.segmentsAdded === 0 && device.hoursCreated === 0) continue;
12148
+ this.deps.logger.info("recorder: hour adoption — camera summary", {
12149
+ tags: { deviceId: device.deviceId },
12150
+ meta: {
12151
+ locationId: report.locationId,
12152
+ hoursAdopted: device.hoursAdopted,
12153
+ segmentsAdded: device.segmentsAdded,
12154
+ hoursCreated: device.hoursCreated
12155
+ }
12156
+ });
12157
+ }
12158
+ this.deps.logger.info("recorder: hour adoption pass complete", { meta: {
12159
+ locationId: report.locationId,
12160
+ listings: report.listings,
12161
+ hoursAdopted: report.hoursAdopted,
12162
+ hoursRefusedByLedger: report.hoursRefusedByLedger,
12163
+ hoursSkippedLive: report.hoursSkippedLive,
12164
+ hoursSkippedEmpty: report.hoursSkippedEmpty,
12165
+ listingErrors: report.listingErrors,
12166
+ segmentsAdded: report.segmentsAdded,
12167
+ hoursCreated: report.hoursCreated,
12168
+ bounded: report.bounded
12169
+ } });
12170
+ }
12171
+ };
12172
+ //#endregion
11841
12173
  //#region src/recorder/addon/periodic-pass.ts
11842
12174
  function startPeriodicPass(deps) {
11843
12175
  const now = () => deps.now?.() ?? Date.now();
@@ -14197,11 +14529,16 @@ function planFinalizations(body, processed, forceFinal = false) {
14197
14529
  durMs
14198
14530
  });
14199
14531
  }
14532
+ const front = entries.length > 0 ? parseEpochStartMs(entries[0].segPath) : null;
14533
+ const longestDurMs = entries.reduce((longest, entry) => Math.max(longest, Math.round(entry.durSeconds * 1e3)), 0);
14534
+ const spanKnown = rolledOff > 0 && front !== null && longestDurMs > 0;
14200
14535
  return {
14201
14536
  toHandle,
14202
14537
  from: hw,
14203
14538
  processed: Math.max(hw, finalizable),
14204
- rolledOff
14539
+ rolledOff,
14540
+ rolledOffFromMs: spanKnown ? front - rolledOff * longestDurMs : null,
14541
+ rolledOffToMs: spanKnown ? front : null
14205
14542
  };
14206
14543
  }
14207
14544
  /**
@@ -14229,11 +14566,24 @@ function startSegmentWatcher(deps) {
14229
14566
  return;
14230
14567
  }
14231
14568
  const plan = planFinalizations(body, processed, forceFinal);
14232
- if (plan.rolledOff > 0) deps.logger.warn("segment playlist rolled past entries this watcher had not handled", { meta: {
14233
- rolledOff: plan.rolledOff,
14234
- processed,
14235
- from: plan.from
14236
- } });
14569
+ if (plan.rolledOff > 0) {
14570
+ deps.logger.warn("segment playlist rolled past entries this watcher had not handled", {
14571
+ tags: { deviceId: deps.deviceId },
14572
+ meta: {
14573
+ rolledOff: plan.rolledOff,
14574
+ processed,
14575
+ from: plan.from,
14576
+ fromMs: plan.rolledOffFromMs,
14577
+ toMs: plan.rolledOffToMs
14578
+ }
14579
+ });
14580
+ deps.onSegmentsMissed?.({
14581
+ deviceId: deps.deviceId,
14582
+ count: plan.rolledOff,
14583
+ fromMs: plan.rolledOffFromMs,
14584
+ toMs: plan.rolledOffToMs
14585
+ });
14586
+ }
14237
14587
  let index = plan.from;
14238
14588
  for (const { entry, durMs } of plan.toHandle) {
14239
14589
  if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
@@ -14243,11 +14593,14 @@ function startSegmentWatcher(deps) {
14243
14593
  waitedMs,
14244
14594
  boundMs: 3e4
14245
14595
  })) {
14246
- deps.logger.warn("segment file never landed — skipping", { meta: {
14247
- seg: entry.segPath,
14248
- waitedMs,
14249
- boundMs: PENDING_GRACE_MS
14250
- } });
14596
+ deps.logger.warn("segment file never landed — skipping", {
14597
+ tags: { deviceId: deps.deviceId },
14598
+ meta: {
14599
+ seg: entry.segPath,
14600
+ waitedMs,
14601
+ boundMs: PENDING_GRACE_MS
14602
+ }
14603
+ });
14251
14604
  pendingIndex = -1;
14252
14605
  pendingSinceMs = 0;
14253
14606
  index++;
@@ -16085,8 +16438,12 @@ var RecordingController = class {
16085
16438
  writer,
16086
16439
  watcher: startSegmentWatcher({
16087
16440
  outDir,
16441
+ deviceId,
16088
16442
  writerStartedMs,
16089
16443
  now: () => this.now(),
16444
+ onSegmentsMissed: (missed) => {
16445
+ this.deps.onSegmentsMissed?.(missed, profile);
16446
+ },
16090
16447
  intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
16091
16448
  onFinalized: async (startMs, durMs, flatAbsPath) => {
16092
16449
  this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
@@ -17428,6 +17785,20 @@ var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
17428
17785
  * hung an SSH session on 2026-08-29.
17429
17786
  */
17430
17787
  var LEDGER_WALK_PAUSE_MS = 100;
17788
+ /**
17789
+ * How often the ADD-only hour adoption pass runs (D521).
17790
+ *
17791
+ * Daily, for the same reason the ghost sweep is daily: it spends `readdir`
17792
+ * syscalls on the array every live read competes for, and the damage it repairs
17793
+ * is made by a writer teardown — an event measured in days, not minutes. It
17794
+ * does NOT run at boot (`startPeriodicPass` fires on the interval only), which
17795
+ * is when the staging reconcile, the ledger load and the first retention sweep
17796
+ * are already contending for that array.
17797
+ */
17798
+ var HOUR_ADOPTION_INTERVAL_MS = 1440 * 6e4;
17799
+ /** An adoption pass longer than this is reported with its duration. Bounded
17800
+ * work (512 hour listings, paced) — minutes is normal, an hour is not. */
17801
+ var HOUR_ADOPTION_SLOW_AFTER_MS = 10 * 6e4;
17431
17802
  /** What the per-volume usage row reports when the hour ledger is unavailable:
17432
17803
  * nothing measured, rather than a total counted off a partial RAM index. */
17433
17804
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -17652,6 +18023,7 @@ var RecorderV2Addon = class extends BaseAddon {
17652
18023
  /** Daily ghost-location reconcile: durable hour rows stamped with a location
17653
18024
  * the storage record no longer has. Armed in `onHubReachable`. */
17654
18025
  ghostLocationPass = null;
18026
+ hourAdoptionPass = null;
17655
18027
  /** Reversible migration pause lease; never reflected in RecordingConfig. */
17656
18028
  storageMigrationLeaseId = null;
17657
18029
  /**
@@ -17893,6 +18265,27 @@ var RecorderV2Addon = class extends BaseAddon {
17893
18265
  onWriterClosed: async (deviceId, profile) => {
17894
18266
  await this.segmentHours?.sealWriter(deviceId, profile);
17895
18267
  },
18268
+ onSegmentsMissed: (missed, profile) => {
18269
+ const ledger = this.segmentHours;
18270
+ if (ledger === null) return;
18271
+ if (missed.fromMs === null || missed.toMs === null) {
18272
+ this.ctx.logger.warn("recorder: segments were missed but the playlist named no span — NO hour could be held unsealed for them", {
18273
+ tags: { deviceId: missed.deviceId },
18274
+ meta: {
18275
+ profile,
18276
+ segments: missed.count
18277
+ }
18278
+ });
18279
+ return;
18280
+ }
18281
+ ledger.markHoursRefused({
18282
+ deviceId: missed.deviceId,
18283
+ profile,
18284
+ fromMs: missed.fromMs,
18285
+ toMs: missed.toMs,
18286
+ count: missed.count
18287
+ });
18288
+ },
17896
18289
  spawn,
17897
18290
  costRegistry: this.costRegistry,
17898
18291
  segmentSeconds: this.config.segmentSeconds,
@@ -18258,6 +18651,8 @@ var RecorderV2Addon = class extends BaseAddon {
18258
18651
  this.redundancySweeper = null;
18259
18652
  this.ghostLocationPass?.stop();
18260
18653
  this.ghostLocationPass = null;
18654
+ this.hourAdoptionPass?.stop();
18655
+ this.hourAdoptionPass = null;
18261
18656
  this.exportEngine = null;
18262
18657
  this.recordingProvider = null;
18263
18658
  }
@@ -18509,6 +18904,32 @@ var RecorderV2Addon = class extends BaseAddon {
18509
18904
  logger: this.ctx.logger
18510
18905
  });
18511
18906
  }
18907
+ if (this.hourAdoptionPass === null) {
18908
+ const adopter = new HourAdoptionSweeper({
18909
+ ledger: () => this.segmentHours,
18910
+ locations: () => this.resolvedLocations,
18911
+ listDir: async (absDir) => {
18912
+ try {
18913
+ return await promises.readdir(absDir);
18914
+ } catch (err) {
18915
+ if (err.code === "ENOENT") return null;
18916
+ throw err;
18917
+ }
18918
+ },
18919
+ logger: this.ctx.logger,
18920
+ now: () => Date.now(),
18921
+ pace: () => sleep$1(LEDGER_WALK_PAUSE_MS)
18922
+ });
18923
+ this.hourAdoptionPass = startPeriodicPass({
18924
+ name: "hour-adoption",
18925
+ intervalMs: HOUR_ADOPTION_INTERVAL_MS,
18926
+ slowAfterMs: HOUR_ADOPTION_SLOW_AFTER_MS,
18927
+ run: async () => {
18928
+ for (const location of this.resolvedLocations) await adopter.sweep({ locationId: location.id });
18929
+ },
18930
+ logger: this.ctx.logger
18931
+ });
18932
+ }
18512
18933
  }
18513
18934
  /**
18514
18935
  * Print one window of detection-trigger health.