@camstack/addon-pipeline 1.2.144 → 1.2.146

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 (35) hide show
  1. package/dist/audio-analyzer/index.js +2 -2
  2. package/dist/audio-analyzer/index.mjs +2 -2
  3. package/dist/detection-pipeline/index.js +33 -32
  4. package/dist/detection-pipeline/index.mjs +5 -4
  5. package/dist/{dist-Dpy1KP1q.js → dist-DML3mpn9.js} +6 -6
  6. package/dist/{dist-uX-YDOrc.mjs → dist-cZopp8Xm.mjs} +6 -6
  7. package/dist/event-loop-stall-monitor-DXLNBMkY.mjs +138 -0
  8. package/dist/event-loop-stall-monitor-DaUBcb13.js +149 -0
  9. package/dist/{lazy-sharp-zDl97I3e.js → lazy-sharp-BzXIjfN8.js} +1 -1
  10. package/dist/{event-loop-stall-monitor-6YKQeg7_.mjs → local-frame-registry-Ctv_TzmF.mjs} +2 -139
  11. package/dist/{event-loop-stall-monitor-D947d97M.js → local-frame-registry-DO8-LuSo.js} +1 -150
  12. package/dist/motion-wasm/index.js +2 -2
  13. package/dist/motion-wasm/index.mjs +1 -1
  14. package/dist/{node-DuMWMV01.mjs → node-Br6p9KvD.mjs} +1 -1
  15. package/dist/{node-v9ZAGK2b.js → node-OvVqe9eh.js} +1 -1
  16. package/dist/pipeline-runner/index.js +14 -13
  17. package/dist/pipeline-runner/index.mjs +5 -4
  18. package/dist/{process-memory-CNISjywZ.mjs → process-memory-DX25Z60Q.mjs} +1 -1
  19. package/dist/{process-memory-ClAfQ1Nl.js → process-memory-NWKrKfmi.js} +1 -1
  20. package/dist/recorder/index.js +563 -41
  21. package/dist/recorder/index.mjs +563 -41
  22. package/dist/{segment-demux-js-Bpedn-T8.js → segment-demux-js-CerfMroc.js} +1 -1
  23. package/dist/{segment-demux-js-CCMG51zw.mjs → segment-demux-js-DGmFQ1Zj.mjs} +1 -1
  24. package/dist/session-decode/decode-worker-child.js +2 -2
  25. package/dist/session-decode/decode-worker-child.mjs +1 -1
  26. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-MRLUZgkR.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DQNgiTjz.mjs} +3 -3
  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-w0ytBmjx.mjs → hostInit-NShcJE25.mjs} +3 -3
  30. package/dist/stream-broker/index.js +3 -3
  31. package/dist/stream-broker/index.mjs +3 -3
  32. package/dist/stream-broker/remoteEntry.js +1 -1
  33. package/dist/{worker-protocol-Cd-G92Qt.mjs → worker-protocol-CUCQ1IPR.mjs} +1 -1
  34. package/dist/{worker-protocol-Cn5x8dbW.js → worker-protocol-Dt3zWCQY.js} +1 -1
  35. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
- const require_dist = require("../dist-Dpy1KP1q.js");
2
- const require_node = require("../node-v9ZAGK2b.js");
1
+ const require_dist = require("../dist-DML3mpn9.js");
2
+ const require_node = require("../node-OvVqe9eh.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
5
+ const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
5
6
  const require_retire_root_keys = require("../retire-root-keys-KE6D6Xh_.js");
6
7
  let node_crypto = require("node:crypto");
7
8
  let node_fs = require("node:fs");
@@ -1072,6 +1073,45 @@ var RecordingIndex = class {
1072
1073
  };
1073
1074
  }
1074
1075
  /**
1076
+ * How many materialised sorted views exist and how many row slots they hold.
1077
+ *
1078
+ * The question this answers: **what is in `large_object_space`?** Production
1079
+ * on 2026-08-29 read ~490 MB there, stable across four samples five minutes
1080
+ * apart — retained, not churn — against 110 MB on hub-main with a comparable
1081
+ * total heap. V8 only puts an object there when it does not fit a normal
1082
+ * page (~256 KB and up), and a sorted view is the recorder's obvious
1083
+ * candidate: one `SegmentRow[]` per (device, profile-or-all), materialised
1084
+ * on first read and then kept live. At ~296 k archive rows a single
1085
+ * all-profiles view is a contiguous array of hundreds of thousands of
1086
+ * pointers — megabytes, in one allocation, per device.
1087
+ *
1088
+ * Slots, not bytes: the array's own allocation is `rowSlots × 8 B` on a
1089
+ * 64-bit heap, and the rows it points at are counted once in `byDevice`
1090
+ * regardless. Reporting a byte estimate would be inventing precision this
1091
+ * cannot have; the slot count is the thing that is actually known and it is
1092
+ * what a reader needs to decide whether the views are the 490 MB.
1093
+ */
1094
+ sortedViewCensus() {
1095
+ let views = 0;
1096
+ let rowSlots = 0;
1097
+ let largestRows = 0;
1098
+ let largestDeviceId = 0;
1099
+ for (const [deviceId, byKey] of this.sortedByDevice) for (const rows of byKey.values()) {
1100
+ views += 1;
1101
+ rowSlots += rows.length;
1102
+ if (rows.length > largestRows) {
1103
+ largestRows = rows.length;
1104
+ largestDeviceId = deviceId;
1105
+ }
1106
+ }
1107
+ return {
1108
+ views,
1109
+ rowSlots,
1110
+ largestRows,
1111
+ largestDeviceId
1112
+ };
1113
+ }
1114
+ /**
1075
1115
  * Ordered insert of ONE NEW row into every live view it belongs to (the
1076
1116
  * all-profiles view and its own profile's view). Views not yet materialised
1077
1117
  * stay unmaterialised — their first read pays the one build. PRECONDITION:
@@ -2029,7 +2069,7 @@ var SegmentStore = class SegmentStore {
2029
2069
  return counts;
2030
2070
  };
2031
2071
  const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
2032
- const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
2072
+ const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
2033
2073
  let bucketsRemoved = 0;
2034
2074
  let handled = 0;
2035
2075
  for (const [bucketDir, bucketRows] of byBucket) {
@@ -2167,7 +2207,7 @@ var SegmentStore = class SegmentStore {
2167
2207
  return indexedInBucket === indexedVictims.size;
2168
2208
  }
2169
2209
  };
2170
- var macrotask$2 = () => new Promise((resolve) => {
2210
+ var macrotask$3 = () => new Promise((resolve) => {
2171
2211
  setImmediate(resolve);
2172
2212
  });
2173
2213
  //#endregion
@@ -7195,7 +7235,7 @@ async function resolveRecordingsLocations(api, logger) {
7195
7235
  * (best-effort), so one bad volume never blocks the rest of the hydrate.
7196
7236
  */
7197
7237
  async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
7198
- const yieldBetween = options.yieldBetween ?? macrotask$1;
7238
+ const yieldBetween = options.yieldBetween ?? macrotask$2;
7199
7239
  const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
7200
7240
  for (const [root, aliases] of aliasesByRoot(locations)) {
7201
7241
  const deviceDir = node_path.default.join(root, String(deviceId));
@@ -7263,7 +7303,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
7263
7303
  }
7264
7304
  /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
7265
7305
  var HYDRATE_CHUNK = 2e4;
7266
- var macrotask$1 = () => new Promise((resolve) => {
7306
+ var macrotask$2 = () => new Promise((resolve) => {
7267
7307
  setImmediate(resolve);
7268
7308
  });
7269
7309
  /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
@@ -7289,9 +7329,56 @@ var HOUR_MS$2 = 36e5;
7289
7329
  *
7290
7330
  * Hours already walked are skipped, so a drag across an hour costs nothing the
7291
7331
  * second time.
7292
- */
7293
- async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger) {
7332
+ *
7333
+ * ## The ledger is the first stop for a CLOSED hour (D287)
7334
+ *
7335
+ * Boot no longer replays the archive into the index, so a ledger-known hour
7336
+ * arrives here `unknown` and pays ONE point read of its hour row instead of a
7337
+ * `readdir` — measured at ~650 ms per hour directory on the live share under
7338
+ * load, against the array the scrub feeder is competing for. Hours the ledger
7339
+ * does not know, and every hour if the read fails, fall through to the walk
7340
+ * exactly as before: the failure direction is toward the disk, never toward an
7341
+ * hour that renders empty because nobody read it.
7342
+ *
7343
+ * **The live hour is never served from the ledger.** It is the one hour whose
7344
+ * durable row is knowably behind the disk: a segment finalized after the last
7345
+ * persist and before a crash is on the array and not in the row. Boot used to
7346
+ * mark every ledger hour `hydrated`, which made that tail invisible to
7347
+ * playback and the timeline FOREVER, not merely under-reported. Excluding the
7348
+ * live hour costs exactly one hour-directory read per process — the walk marks
7349
+ * it, so the second reader pays nothing — and it is what turns a lost tail
7350
+ * back into a recoverable one.
7351
+ *
7352
+ * A CLOSED hour already walked is skipped, which is what makes that exclusion
7353
+ * affordable: without it, every window touching the live hour would re-walk
7354
+ * all of its hours. The past is immutable, so re-reading a closed hour
7355
+ * directory can only ever return what the index already holds; eviction
7356
+ * removes rows through the index directly.
7357
+ */
7358
+ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
7294
7359
  if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
7360
+ const liveHour = Math.floor(Date.now() / HOUR_MS$2) * HOUR_MS$2;
7361
+ if (ledger !== void 0) {
7362
+ const startedMs = Date.now();
7363
+ const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
7364
+ let segments = 0;
7365
+ let served = 0;
7366
+ for (const hour of hours) {
7367
+ if (hour.hourStartMs >= liveHour) continue;
7368
+ index.hydrateHour(deviceId, hour.locationId, hour.hourStartMs, hour.paths);
7369
+ segments += hour.paths.length;
7370
+ served += 1;
7371
+ }
7372
+ if (served > 0) logger.info("recorder: hydrated a window from the hour ledger", {
7373
+ tags: { deviceId },
7374
+ meta: {
7375
+ hours: served,
7376
+ segments,
7377
+ ms: Date.now() - startedMs
7378
+ }
7379
+ });
7380
+ if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
7381
+ }
7295
7382
  for (const [root, aliases] of aliasesByRoot(locations)) {
7296
7383
  const deviceDir = node_path.default.join(root, String(deviceId));
7297
7384
  let profiles;
@@ -7310,16 +7397,26 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
7310
7397
  const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
7311
7398
  const reuseBefore = index.hydrateReuseStats();
7312
7399
  let hoursRead = 0;
7313
- for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
7314
- const rel = hourDirRelPath(deviceId, profile, hour);
7315
- let files;
7316
- try {
7317
- files = await node_fs.promises.readdir(node_path.default.join(root, rel));
7318
- } catch {
7319
- continue;
7400
+ let repaired = 0;
7401
+ for (let hour = first; hour < toMs; hour += HOUR_MS$2) {
7402
+ if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$2) === "hydrated") continue;
7403
+ for (const profile of profiles) {
7404
+ const rel = hourDirRelPath(deviceId, profile, hour);
7405
+ let files;
7406
+ try {
7407
+ files = await node_fs.promises.readdir(node_path.default.join(root, rel));
7408
+ } catch {
7409
+ continue;
7410
+ }
7411
+ hoursRead += 1;
7412
+ const locationId = locationForHydratedProfile(aliases, profile).id;
7413
+ const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
7414
+ index.hydrateHour(deviceId, locationId, hour, relPaths);
7415
+ if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$2 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
7416
+ repaired += 1;
7417
+ await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
7418
+ }
7320
7419
  }
7321
- hoursRead += 1;
7322
- index.hydrateHour(deviceId, locationForHydratedProfile(aliases, profile).id, hour, files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`));
7323
7420
  }
7324
7421
  index.markHydrated(deviceId, fromMs, toMs);
7325
7422
  if (hoursRead === 0) continue;
@@ -7331,6 +7428,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
7331
7428
  meta: {
7332
7429
  root,
7333
7430
  hoursRead,
7431
+ repaired,
7334
7432
  reused,
7335
7433
  allocated
7336
7434
  }
@@ -11612,6 +11710,9 @@ var DurableLedger = class DurableLedger {
11612
11710
  //#endregion
11613
11711
  //#region src/recorder/addon/segment-hour-ledger.ts
11614
11712
  var HOUR_MS$1 = 36e5;
11713
+ var macrotask$1 = () => new Promise((resolve) => {
11714
+ setImmediate(resolve);
11715
+ });
11615
11716
  /**
11616
11717
  * @durable class=mirror owner=recorder
11617
11718
  * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
@@ -11759,6 +11860,7 @@ function hourAggregate(paths) {
11759
11860
  function segmentHourRow(base) {
11760
11861
  return {
11761
11862
  ...base,
11863
+ pathsResident: true,
11762
11864
  ...hourAggregate(base.paths)
11763
11865
  };
11764
11866
  }
@@ -11814,6 +11916,44 @@ var OLDEST_PROBE_COLUMNS = [
11814
11916
  var REPAIR_PROBE_COLUMNS = ["segments"];
11815
11917
  /** Rows repaired per boot. The live archive is ~19 k hour rows. */
11816
11918
  var REPAIR_MAX_ROWS = 5e4;
11919
+ /**
11920
+ * The boot projection (D287): everything the recorder's always-resident tier
11921
+ * needs, and deliberately NOT `paths`.
11922
+ *
11923
+ * `paths` is ~55 B per segment and the archive is the only thing that grows:
11924
+ * ~16 MB of JSON today, a projected ~460 MB at 28 TB of today's bitrate mix
11925
+ * and ~5.9 GB at a low-bitrate mix — one tRPC response, parsed on the main
11926
+ * thread, before the index exists. The summary half is O(writers × days) and
11927
+ * stays ~10–15 MB at any archive size.
11928
+ */
11929
+ var SEGMENT_HOUR_SUMMARY_COLUMNS = [
11930
+ "deviceId",
11931
+ "profile",
11932
+ "locationId",
11933
+ "hourStartMs",
11934
+ "bytes",
11935
+ "segments",
11936
+ "minStartMs",
11937
+ "maxStartMs"
11938
+ ];
11939
+ /** Summary rows folded into the mirror between yields. */
11940
+ var LOAD_CHUNK = 5e3;
11941
+ /** Hour rows materialised per point-read page. One page covers a day of one
11942
+ * camera's three profiles with room to spare. */
11943
+ var HOUR_DETAIL_PAGE = 64;
11944
+ /**
11945
+ * The live tail materialised at boot: the current hour and the one before it.
11946
+ *
11947
+ * `recordSegment` must gate from RAM (D49), and the hour it appends to at boot
11948
+ * is exactly the hour the previous process was writing. Two hours × the active
11949
+ * writers is ~60 rows — one point-read page — and it is what makes the write
11950
+ * path's materialise a case that essentially never happens.
11951
+ */
11952
+ var LIVE_TAIL_HOURS = 2;
11953
+ /** Rows the one-shot aggregate repair may materialise in a single boot. The
11954
+ * backfill is bounded so it cannot re-become the whole-archive read D287
11955
+ * removed; a partial pass finishes on the next boot. */
11956
+ var REPAIR_MAX_MATERIALIZE = 2e3;
11817
11957
  /** The four scalars an accounting answer is made of, asked in one statement. */
11818
11958
  var ACCOUNTING_FIELDS = [
11819
11959
  {
@@ -11906,6 +12046,44 @@ function recordToRow(key, data) {
11906
12046
  paths: raw
11907
12047
  });
11908
12048
  }
12049
+ /** The paths of a summary row: one shared empty array, never a per-row
12050
+ * allocation, and never mistakable for "this hour holds nothing". */
12051
+ var NO_PATHS = Object.freeze([]);
12052
+ function finiteOr0(raw) {
12053
+ const n = Number(raw);
12054
+ return Number.isFinite(n) ? n : 0;
12055
+ }
12056
+ /**
12057
+ * One row of the boot projection ({@link SEGMENT_HOUR_SUMMARY_COLUMNS}).
12058
+ *
12059
+ * The identity half is validated exactly as {@link recordToRow} validates it —
12060
+ * a row that cannot say which camera, profile, location and hour it is about
12061
+ * is skipped, because a summary keyed on garbage would refuse the write path
12062
+ * for an hour that is perfectly fine. The aggregate half is taken as the
12063
+ * database wrote it: a row that predates the aggregate columns reports zeros,
12064
+ * which UNDER-reports its disk (prunes less, never more) until
12065
+ * {@link SegmentHourLedger.repairAggregates} rewrites it.
12066
+ */
12067
+ function summaryToRow(key, data) {
12068
+ const deviceId = Number(data["deviceId"]);
12069
+ const profile = data["profile"];
12070
+ const locationId = data["locationId"];
12071
+ const hour = Number(data["hourStartMs"]);
12072
+ if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour)) return null;
12073
+ return {
12074
+ key,
12075
+ deviceId,
12076
+ profile,
12077
+ locationId,
12078
+ hourStartMs: hour,
12079
+ paths: NO_PATHS,
12080
+ pathsResident: false,
12081
+ bytes: finiteOr0(data["bytes"]),
12082
+ segments: finiteOr0(data["segments"]),
12083
+ minStartMs: finiteOr0(data["minStartMs"]),
12084
+ maxStartMs: finiteOr0(data["maxStartMs"])
12085
+ };
12086
+ }
11909
12087
  var SPEC = {
11910
12088
  collection: RECORDING_SEGMENT_HOURS_COLLECTION,
11911
12089
  columns: RECORDING_SEGMENT_HOURS_COLUMNS,
@@ -11926,10 +12104,18 @@ var SegmentHourLedger = class {
11926
12104
  * accounting number is not a gate. */
11927
12105
  store;
11928
12106
  isLocationUsable;
12107
+ now;
12108
+ loadChunk;
12109
+ yieldBetween;
12110
+ rowCap;
11929
12111
  constructor(deps) {
11930
12112
  this.logger = deps.logger;
11931
12113
  this.store = deps.store;
11932
12114
  this.isLocationUsable = deps.isLocationUsable ?? (() => true);
12115
+ this.now = deps.now ?? (() => Date.now());
12116
+ this.loadChunk = deps.loadPacing?.chunkSize ?? LOAD_CHUNK;
12117
+ this.yieldBetween = deps.loadPacing?.yieldBetween ?? macrotask$1;
12118
+ this.rowCap = deps.loadPacing?.rowCap ?? 25e4;
11933
12119
  this.ledger = new DurableLedger({
11934
12120
  spec: SPEC,
11935
12121
  store: deps.store,
@@ -11942,26 +12128,270 @@ var SegmentHourLedger = class {
11942
12128
  declare() {
11943
12129
  return this.ledger.declare();
11944
12130
  }
12131
+ /**
12132
+ * Boot load — **T1 only** (D287).
12133
+ *
12134
+ * One projected query over {@link SEGMENT_HOUR_SUMMARY_COLUMNS}, folded into
12135
+ * the mirror in chunks with a yield between them, then one point-read page
12136
+ * that materialises the live tail so the write path can gate from RAM.
12137
+ *
12138
+ * It replaces `DurableLedger.load`, which fetches whole rows and therefore
12139
+ * the whole archive's path strings, and it keeps that primitive's two rules:
12140
+ * a failed read RETURNS WHAT IS ALREADY MIRRORED rather than clearing it,
12141
+ * and a malformed row is skipped rather than repaired.
12142
+ *
12143
+ * It deliberately does NOT replay the archive into {@link RecordingIndex}.
12144
+ * `hydrated` now means "the rows are resident", so a ledger-known hour is
12145
+ * merely *hydratable from T3*: the first read of it pays one point read
12146
+ * ({@link detailForWindow}), and an hour whose tail the ledger is missing is
12147
+ * no longer permanently invisible to playback.
12148
+ */
11945
12149
  async load() {
11946
- const rows = await this.ledger.load();
12150
+ const startedMs = this.now();
12151
+ let records;
12152
+ try {
12153
+ records = await this.store.query.query({
12154
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12155
+ columns: SEGMENT_HOUR_SUMMARY_COLUMNS,
12156
+ filter: { limit: this.rowCap }
12157
+ });
12158
+ } catch (err) {
12159
+ this.logger.warn("recorder: segment-hour ledger load failed — keeping the hours already held", { meta: {
12160
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12161
+ held: this.ledger.size,
12162
+ error: String(err)
12163
+ } });
12164
+ return this.ledger.snapshot();
12165
+ }
12166
+ const queriedMs = this.now();
12167
+ let skipped = 0;
12168
+ for (let i = 0; i < records.length; i += this.loadChunk) {
12169
+ const end = Math.min(i + this.loadChunk, records.length);
12170
+ for (let j = i; j < end; j += 1) {
12171
+ const record = records[j];
12172
+ const row = summaryToRow(record.id, record.data);
12173
+ if (row === null) {
12174
+ skipped += 1;
12175
+ continue;
12176
+ }
12177
+ this.ledger.stage(row);
12178
+ }
12179
+ if (end < records.length) await this.yieldBetween();
12180
+ }
12181
+ if (skipped > 0) this.logger.warn("recorder: segment-hour summaries skipped as malformed — they gate NOTHING", { meta: {
12182
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12183
+ skipped
12184
+ } });
12185
+ if (records.length >= this.rowCap) this.logger.warn("recorder: segment-hour ledger load hit its row cap — hours past the cap are UNKNOWN to this process and will be walked from disk", { meta: {
12186
+ limit: this.rowCap,
12187
+ loaded: records.length
12188
+ } });
12189
+ const tail = await this.materializeLiveTail();
11947
12190
  this.logger.info("recorder: segment-hour ledger loaded", { meta: {
11948
- hours: rows.length,
12191
+ hours: this.ledger.size,
12192
+ pathsLoaded: false,
12193
+ liveTailHours: tail,
12194
+ skipped,
12195
+ queryMs: queriedMs - startedMs,
12196
+ ms: this.now() - startedMs,
11949
12197
  collection: RECORDING_SEGMENT_HOURS_COLLECTION
11950
12198
  } });
11951
- return rows;
12199
+ return this.ledger.snapshot();
11952
12200
  }
12201
+ /** Every hour the ledger knows — summaries included. A row whose
12202
+ * `pathsResident` is false carries real aggregates and empty `paths`. */
11953
12203
  snapshot() {
11954
12204
  return this.ledger.snapshot();
11955
12205
  }
12206
+ /** Does the ledger hold a row for this hour at all? Pure RAM — the gate the
12207
+ * storage-walk repair consults before it upserts a rebuilt hour. */
12208
+ knowsHour(deviceId, profile, locationId, hour) {
12209
+ return this.ledger.has(segmentHourKey(deviceId, profile, locationId, hour));
12210
+ }
12211
+ /**
12212
+ * The hours of `[fromMs, toMs)` the ledger knows, with their paths — the
12213
+ * cold-hour detail read that replaces the boot replay (D287).
12214
+ *
12215
+ * One bounded point read against the settings store, never the media array:
12216
+ * a `readdir` of one hour directory was measured at ~650 ms under load on
12217
+ * the live share, and the array is the resource the scrub feeder is
12218
+ * competing for. Hours the ledger does NOT know are absent from the result,
12219
+ * and the caller walks those from disk exactly as it always did.
12220
+ *
12221
+ * **A read that fails returns what it already had** (D49). The caller sees
12222
+ * fewer hours, the index stays `unknown` for the rest, and the window falls
12223
+ * through to the walk — nothing is destroyed and nothing renders as an
12224
+ * empty hour that was merely unread.
12225
+ */
12226
+ async detailForWindow(deviceId, fromMs, toMs) {
12227
+ const first = hourStartMs(fromMs);
12228
+ const out = [];
12229
+ const wanted = [];
12230
+ for (const row of this.ledger.snapshot()) {
12231
+ if (row.deviceId !== deviceId) continue;
12232
+ if (row.hourStartMs < first || row.hourStartMs >= toMs) continue;
12233
+ if (row.pathsResident) {
12234
+ out.push({
12235
+ locationId: row.locationId,
12236
+ hourStartMs: row.hourStartMs,
12237
+ paths: row.paths
12238
+ });
12239
+ continue;
12240
+ }
12241
+ wanted.push(row.key);
12242
+ }
12243
+ for (const row of await this.materialize(wanted, deviceId)) out.push({
12244
+ locationId: row.locationId,
12245
+ hourStartMs: row.hourStartMs,
12246
+ paths: row.paths
12247
+ });
12248
+ return out;
12249
+ }
11956
12250
  /**
11957
- * Seed {@link RecordingIndex} from the mirrored hour rows.
12251
+ * Adopt an hour the storage walk rebuilt and the ledger never held.
11958
12252
  *
11959
- * Hours present in the ledger become `hydrated` so a seek in those windows
11960
- * does not pay for a disk walk. Hours the ledger never held stay `unknown`
11961
- * — that is the fail-toward-walk direction.
12253
+ * The reader repairs the durable index it just reconstructed, so the next
12254
+ * cold read of this hour is a point read instead of another `readdir`
12255
+ * (`docs/design/2026-08-29-scrypted-nvr-recording-model.md` §4.2). Two
12256
+ * constraints, both enforced by the caller and re-stated here because
12257
+ * getting either wrong turns a READ into durable damage: the hour must be
12258
+ * CLOSED (a live hour's directory races the writer, so a walk of it is a
12259
+ * lower bound, not the truth), and the ledger must hold no row for it (an
12260
+ * existing row is the authority; replacing it with a walk's view is the
12261
+ * D148 prune this ledger refuses to do on its own evidence).
12262
+ */
12263
+ async adoptRebuiltHour(deviceId, profile, locationId, hour, paths) {
12264
+ const key = segmentHourKey(deviceId, profile, locationId, hour);
12265
+ if (paths.length === 0 || this.ledger.has(key)) return;
12266
+ const row = segmentHourRow({
12267
+ key,
12268
+ deviceId,
12269
+ profile,
12270
+ locationId,
12271
+ hourStartMs: hour,
12272
+ paths: uniquePaths(paths)
12273
+ });
12274
+ await this.ledger.put(row);
12275
+ this.logger.info("recorder: adopted a closed hour the disk walk rebuilt and the ledger never held", {
12276
+ tags: { deviceId },
12277
+ meta: {
12278
+ profile,
12279
+ locationId,
12280
+ hourStartMs: hour,
12281
+ segments: row.segments,
12282
+ bytes: row.bytes
12283
+ }
12284
+ });
12285
+ }
12286
+ /**
12287
+ * Fetch whole rows for `keys` and promote them in the mirror.
12288
+ *
12289
+ * Paged and yielding: an export window can name thousands of hours, and one
12290
+ * `whereIn` over all of them would rebuild the very whole-archive response
12291
+ * D287 removed. A page that FAILS ends the materialise and returns what
12292
+ * landed — partial detail is real detail, and the caller's fallback (a disk
12293
+ * walk, or refusing a write) is strictly safer than a guess.
12294
+ *
12295
+ * A row the store returns but this cannot parse is EVICTED from the mirror
12296
+ * rather than left as a summary that can never become resident: it re-seeds
12297
+ * cold on the next write, which is `DurableLedger`'s rule 4 and the only
12298
+ * escape from an hour permanently frozen against its own writer.
11962
12299
  */
11963
- hydrateIndex(index) {
11964
- for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
12300
+ async materialize(keys, deviceId) {
12301
+ const out = [];
12302
+ if (keys.length === 0) return out;
12303
+ const tags = deviceId === void 0 ? {} : { tags: { deviceId } };
12304
+ for (let i = 0; i < keys.length; i += HOUR_DETAIL_PAGE) {
12305
+ const page = keys.slice(i, i + HOUR_DETAIL_PAGE);
12306
+ let records;
12307
+ try {
12308
+ records = await this.store.query.query({
12309
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12310
+ filter: {
12311
+ whereIn: { key: page },
12312
+ limit: page.length
12313
+ }
12314
+ });
12315
+ } catch (err) {
12316
+ this.logger.warn("recorder: hour-detail point read failed — these hours stay summaries, and the caller walks the disk or declines the write", {
12317
+ ...tags,
12318
+ meta: {
12319
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
12320
+ asked: page.length,
12321
+ materialized: out.length,
12322
+ error: String(err)
12323
+ }
12324
+ });
12325
+ return out;
12326
+ }
12327
+ for (const record of records) {
12328
+ const row = recordToRow(record.id, record.data);
12329
+ if (row === null) {
12330
+ this.ledger.evict(record.id);
12331
+ this.logger.warn("recorder: hour row is unparseable — dropping the summary so the hour re-seeds cold instead of freezing its writer", {
12332
+ ...tags,
12333
+ meta: { key: record.id }
12334
+ });
12335
+ continue;
12336
+ }
12337
+ this.ledger.stage(row);
12338
+ out.push(row);
12339
+ }
12340
+ if (i + HOUR_DETAIL_PAGE < keys.length) await this.yieldBetween();
12341
+ }
12342
+ return out;
12343
+ }
12344
+ /**
12345
+ * Materialise every hour these devices hold, and report how many stayed
12346
+ * summaries.
12347
+ *
12348
+ * The reconcile is the one path that DELETES durable rows, so it may not run
12349
+ * against a mirror that only half knows what it holds. This is a whole-device
12350
+ * read and it is deliberately confined here: `reconcileFromIndex` follows an
12351
+ * operator-armed rescan of that same device — a recursive `readdir` over
12352
+ * >1M files — so a paged point read alongside it is noise, and it is the one
12353
+ * caller whose correctness depends on completeness.
12354
+ */
12355
+ async materializeDevices(deviceIds) {
12356
+ const keys = [];
12357
+ for (const row of this.ledger.snapshot()) if (!row.pathsResident && deviceIds.has(row.deviceId)) keys.push(row.key);
12358
+ if (keys.length === 0) return;
12359
+ await this.materialize(keys);
12360
+ const stillSummary = /* @__PURE__ */ new Map();
12361
+ for (const row of this.ledger.snapshot()) {
12362
+ if (row.pathsResident || !deviceIds.has(row.deviceId)) continue;
12363
+ stillSummary.set(row.deviceId, (stillSummary.get(row.deviceId) ?? 0) + 1);
12364
+ }
12365
+ for (const [deviceId, unreadable] of stillSummary) this.logger.warn("recorder: hours without readable detail take NO part in this reconcile — they are neither pruned nor unioned", {
12366
+ tags: { deviceId },
12367
+ meta: {
12368
+ asked: keys.length,
12369
+ unreadable
12370
+ }
12371
+ });
12372
+ }
12373
+ /** The hour a write path may touch: current + previous. Materialised at boot
12374
+ * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
12375
+ async materializeLiveTail() {
12376
+ const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$1;
12377
+ const keys = [];
12378
+ for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
12379
+ return (await this.materialize(keys)).length;
12380
+ }
12381
+ /**
12382
+ * Promote one HELD row to paths-resident, or answer `null`.
12383
+ *
12384
+ * `null` means "this hour is known and its detail could not be had" — the
12385
+ * only honest answer to a failed point read, and every caller turns it into
12386
+ * "do nothing". It is deliberately distinct from `this.ledger.get(key) ===
12387
+ * undefined`, which means "no such hour" and is what licenses a fresh row.
12388
+ */
12389
+ async resident(key, deviceId) {
12390
+ const held = this.ledger.get(key);
12391
+ if (held !== void 0 && held.pathsResident) return held;
12392
+ await this.materialize([key], deviceId);
12393
+ const after = this.ledger.get(key);
12394
+ return after !== void 0 && after.pathsResident ? after : null;
11965
12395
  }
11966
12396
  /**
11967
12397
  * Bytes / count / oldest / newest across a SET of storage locations, as ONE
@@ -12062,6 +12492,7 @@ var SegmentHourLedger = class {
12062
12492
  for (const record of full) {
12063
12493
  const hour = recordToRow(record.id, record.data);
12064
12494
  if (hour === null) continue;
12495
+ this.ledger.stage(hour);
12065
12496
  for (const path of hour.paths) {
12066
12497
  const parsed = parseSegmentPath(path);
12067
12498
  if (parsed === null) continue;
@@ -12091,6 +12522,12 @@ var SegmentHourLedger = class {
12091
12522
  * empties). So `segments = 0` IS the backfill predicate, and after one pass
12092
12523
  * it selects nothing. A partial repair finishes on the next boot; until then
12093
12524
  * the affected disks UNDER-report, which prunes less and never more.
12525
+ *
12526
+ * Since D287 the boot load carries no `paths`, and `paths` is the authority
12527
+ * this repair derives from — so the stale rows are MATERIALISED first, at
12528
+ * most {@link REPAIR_MAX_MATERIALIZE} per boot. That bound is the point: an
12529
+ * unbounded materialise here would be the whole-archive read this stage
12530
+ * removed, wearing a different name.
12094
12531
  */
12095
12532
  async repairAggregates() {
12096
12533
  let repaired = 0;
@@ -12103,9 +12540,15 @@ var SegmentHourLedger = class {
12103
12540
  limit: REPAIR_MAX_ROWS
12104
12541
  }
12105
12542
  });
12543
+ const pending = stale.map((record) => record.id).filter((key) => this.ledger.get(key)?.pathsResident === false);
12544
+ if (pending.length > REPAIR_MAX_MATERIALIZE) this.logger.info("recorder: segment-hour aggregate backfill is bounded this boot — the rest is repaired on the next one", { meta: {
12545
+ pending: pending.length,
12546
+ perBoot: REPAIR_MAX_MATERIALIZE
12547
+ } });
12548
+ await this.materialize(pending.slice(0, REPAIR_MAX_MATERIALIZE));
12106
12549
  for (const record of stale) {
12107
12550
  const held = this.ledger.get(record.id);
12108
- if (held === void 0 || held.segments === 0) continue;
12551
+ if (held === void 0 || !held.pathsResident || held.segments === 0) continue;
12109
12552
  await this.ledger.put(held);
12110
12553
  repaired += 1;
12111
12554
  }
@@ -12133,8 +12576,7 @@ var SegmentHourLedger = class {
12133
12576
  */
12134
12577
  async recordSegment(row) {
12135
12578
  const { key, hourStartMs: hour } = hourOf(row);
12136
- const held = this.ledger.get(key);
12137
- if (held === void 0) {
12579
+ if (!this.ledger.has(key)) {
12138
12580
  await this.ledger.put(segmentHourRow({
12139
12581
  key,
12140
12582
  deviceId: row.deviceId,
@@ -12145,6 +12587,17 @@ var SegmentHourLedger = class {
12145
12587
  }));
12146
12588
  return;
12147
12589
  }
12590
+ const held = await this.resident(key, row.deviceId);
12591
+ if (held === null) {
12592
+ this.logger.warn("recorder: hour detail unavailable — this finalized segment is NOT in the durable hour row yet", {
12593
+ tags: { deviceId: row.deviceId },
12594
+ meta: {
12595
+ key,
12596
+ path: row.path
12597
+ }
12598
+ });
12599
+ return;
12600
+ }
12148
12601
  if (held.paths.includes(row.path)) return;
12149
12602
  await this.ledger.put(appendSegmentPath(held, row.path));
12150
12603
  }
@@ -12156,16 +12609,29 @@ var SegmentHourLedger = class {
12156
12609
  const doomed = /* @__PURE__ */ new Map();
12157
12610
  for (const row of rows) {
12158
12611
  const { key } = hourOf(row);
12159
- let set = doomed.get(key);
12160
- if (!set) {
12161
- set = /* @__PURE__ */ new Set();
12162
- doomed.set(key, set);
12612
+ const entry = doomed.get(key);
12613
+ if (entry === void 0) {
12614
+ doomed.set(key, {
12615
+ deviceId: row.deviceId,
12616
+ paths: new Set([row.path])
12617
+ });
12618
+ continue;
12163
12619
  }
12164
- set.add(row.path);
12620
+ entry.paths.add(row.path);
12165
12621
  }
12166
- for (const [key, paths] of doomed) {
12167
- const held = this.ledger.get(key);
12168
- if (held === void 0) continue;
12622
+ for (const [key, { deviceId, paths }] of doomed) {
12623
+ if (!this.ledger.has(key)) continue;
12624
+ const held = await this.resident(key, deviceId);
12625
+ if (held === null) {
12626
+ this.logger.warn("recorder: hour detail unavailable — these evicted paths stay in the durable hour row until a walk or the next eviction confirms them", {
12627
+ tags: { deviceId },
12628
+ meta: {
12629
+ key,
12630
+ paths: paths.size
12631
+ }
12632
+ });
12633
+ continue;
12634
+ }
12169
12635
  const next = held.paths.filter((p) => !paths.has(p));
12170
12636
  if (next.length === 0) {
12171
12637
  await this.ledger.forget(key);
@@ -12194,10 +12660,17 @@ var SegmentHourLedger = class {
12194
12660
  * And a walk this ledger does not TRUST prunes nothing at all — see
12195
12661
  * {@link refusedPrunes}. What such a walk FOUND is still real, so its paths
12196
12662
  * are unioned in; what it did not find decides nothing (D49).
12663
+ *
12664
+ * Since D287 the walked devices' hours are MATERIALISED first: every
12665
+ * comparison below reads `row.paths`, and a summary's empty `paths` would
12666
+ * read as "the ledger holds nothing here" — which is the union collapsing to
12667
+ * the walk's own view, i.e. the prune the refusal machinery exists to
12668
+ * prevent. An hour whose detail could not be read is left additive-only.
12197
12669
  */
12198
12670
  async reconcileFromIndex(index, deviceIds, nowMs) {
12199
12671
  const walked = new Set(deviceIds);
12200
12672
  const currentHour = hourStartMs(nowMs);
12673
+ await this.materializeDevices(walked);
12201
12674
  const fromIndex = /* @__PURE__ */ new Map();
12202
12675
  for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
12203
12676
  const { key, hourStartMs: hour } = hourOf(seg);
@@ -12215,6 +12688,7 @@ var SegmentHourLedger = class {
12215
12688
  const refused = this.refusedPrunes(walked, fromIndex);
12216
12689
  for (const row of this.ledger.snapshot()) {
12217
12690
  if (!walked.has(row.deviceId)) continue;
12691
+ if (!row.pathsResident) continue;
12218
12692
  const disk = fromIndex.get(row.key);
12219
12693
  const additiveOnly = row.hourStartMs === currentHour || refused.devices.has(row.deviceId) || refused.locations.has(locationKey(row.deviceId, row.locationId));
12220
12694
  if (disk === void 0) {
@@ -12892,6 +13366,16 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
12892
13366
  isRecordingNode = true;
12893
13367
  index = new RecordingIndex();
12894
13368
  /**
13369
+ * Stops the GC-attributing stall monitor armed in {@link onInitialize}.
13370
+ *
13371
+ * REUSED, not re-implemented, and not promoted: `startEventLoopStallMonitor`
13372
+ * already ships inside `@camstack/addon-pipeline` — the same bundle as this
13373
+ * addon — and moving it into `@camstack/system` would duplicate a mechanism
13374
+ * that already exists (forbidden) or relocate it across a package boundary,
13375
+ * which is a refactor with a slower ship chain, not a measurement.
13376
+ */
13377
+ stopStallMonitor = null;
13378
+ /**
12895
13379
  * Durable hour-directory copy of {@link index}. Null on an inert
12896
13380
  * (non-recording) node. Boot reseeds the RAM index from this; the
12897
13381
  * deferred archive walk reconciles it against disk.
@@ -13059,6 +13543,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13059
13543
  } });
13060
13544
  }
13061
13545
  async onInitialize() {
13546
+ this.stopStallMonitor = require_event_loop_stall_monitor.startEventLoopStallMonitor(this.ctx.logger);
13062
13547
  const raw = this.ctx.kernel.localNodeId ?? this.ctx.id;
13063
13548
  this.nodeId = raw.includes("/") ? raw.split("/")[0] : raw;
13064
13549
  const configuredNode = this.config.recordingNodeId;
@@ -13302,7 +13787,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13302
13787
  },
13303
13788
  calendar: this.calendar,
13304
13789
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
13305
- hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
13790
+ hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0),
13306
13791
  onConfigChanged: async (deviceId) => {
13307
13792
  await this.refreshSensorTriggerBindings();
13308
13793
  await (this.controller?.onConfigChanged(deviceId) ?? Promise.resolve());
@@ -13401,7 +13886,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13401
13886
  state: exportsState,
13402
13887
  engine: this.exportEngine,
13403
13888
  index: { segmentsInWindow: async (deviceId, profile, fromMs, toMs) => {
13404
- if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger);
13889
+ if (this.index.hydrationOf(deviceId, fromMs, toMs) !== "hydrated") await hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
13405
13890
  return this.index.segments(deviceId, profile);
13406
13891
  } },
13407
13892
  now: () => Date.now(),
@@ -13498,6 +13983,8 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13498
13983
  };
13499
13984
  }
13500
13985
  async onShutdown() {
13986
+ this.stopStallMonitor?.();
13987
+ this.stopStallMonitor = null;
13501
13988
  this.eventUnsub?.();
13502
13989
  this.eventUnsub = null;
13503
13990
  this.audioTriggerUnsub?.();
@@ -13628,7 +14115,6 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13628
14115
  }
13629
14116
  try {
13630
14117
  await this.segmentHours?.load();
13631
- this.segmentHours?.hydrateIndex(this.index);
13632
14118
  this.segmentHours?.repairAggregates();
13633
14119
  } catch (err) {
13634
14120
  this.ctx.logger.warn("recorder: segment-hour ledger load failed — hours stay unknown until a read walks them", { meta: { error: require_dist.errMsg(err) } });
@@ -13764,6 +14250,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13764
14250
  }
13765
14251
  if (this.exportSweepTimer === null) {
13766
14252
  const sweep = () => {
14253
+ this.logIndexCensus();
13767
14254
  sweepExpiredExports(Date.now(), this.exportJanitorDeps()).catch((err) => {
13768
14255
  this.ctx.logger.warn("recorder: export expiry sweep failed", { meta: { error: require_dist.errMsg(err) } });
13769
14256
  });
@@ -13774,6 +14261,41 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
13774
14261
  }
13775
14262
  }
13776
14263
  /**
14264
+ * What the RAM index is actually holding, on the 5-minute cadence the
14265
+ * `[mem]` heartbeat already samples on.
14266
+ *
14267
+ * `RecordingIndex` has exposed `sortedViewStats` and `hydrateReuseStats`
14268
+ * since the churn work and NOTHING has ever read them — so the two numbers
14269
+ * that would have told us where the recorder's heap goes have never appeared
14270
+ * in a log line. Production on 2026-08-29 reads ~490 MB in
14271
+ * `large_object_space`, stable across four samples five minutes apart
14272
+ * (retained, not churn) against 110 MB on hub-main; the sorted views are the
14273
+ * shape that lands there, and `rowSlots × 8 B` is the arithmetic that either
14274
+ * accounts for it or rules it out.
14275
+ *
14276
+ * `tags: { deviceId }` names the camera with the LARGEST view, because
14277
+ * "which camera" is the only form the follow-up question ever takes. Absent
14278
+ * when nothing is materialised — a device id of 0 is not a camera.
14279
+ */
14280
+ logIndexCensus() {
14281
+ const census = this.index.sortedViewCensus();
14282
+ const views = this.index.sortedViewStats();
14283
+ const reuse = this.index.hydrateReuseStats();
14284
+ this.ctx.logger.info("recorder: index census", {
14285
+ ...census.largestDeviceId > 0 ? { tags: { deviceId: census.largestDeviceId } } : {},
14286
+ meta: {
14287
+ sortedViews: census.views,
14288
+ rowSlots: census.rowSlots,
14289
+ largestViewRows: census.largestRows,
14290
+ fullRebuilds: views.fullRebuilds,
14291
+ incrementalInserts: views.incrementalInserts,
14292
+ rowsReused: reuse.rowsReused,
14293
+ rowsAllocated: reuse.rowsAllocated,
14294
+ ledgerHours: this.segmentHours?.snapshot().length ?? 0
14295
+ }
14296
+ });
14297
+ }
14298
+ /**
13777
14299
  * One-time boot cleanup of the retired sprite-sheet scrub-thumbnail dirs
13778
14300
  * (`<locationRoot>/.thumbs`). Scrub previews now use the WebCodecs keyframe
13779
14301
  * packs + the on-demand still route, so the sheets are dead weight on the
@@ -14157,7 +14679,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
14157
14679
  const started = now;
14158
14680
  const liveHour = Math.floor(now / HOUR_MS) * HOUR_MS;
14159
14681
  for (const deviceId of deviceIds) try {
14160
- await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger);
14682
+ await hydrateWindowFromStorage(this.index, deviceId, liveHour, liveHour + HOUR_MS, this.resolvedLocations, this.ctx.logger, this.segmentHours ?? void 0);
14161
14683
  } catch {}
14162
14684
  this.ctx.logger.info("recorder: today warmed", { meta: {
14163
14685
  devices: deviceIds.length,