@camstack/addon-pipeline 1.2.89 → 1.2.93

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 (30) hide show
  1. package/dist/{addon-utils-yc-chXuC.js → addon-utils-CTRpEiSd.js} +1 -1
  2. package/dist/audio-analyzer/index.js +2 -2
  3. package/dist/audio-analyzer/index.mjs +1 -1
  4. package/dist/detection-pipeline/index.js +4 -4
  5. package/dist/detection-pipeline/index.mjs +2 -2
  6. package/dist/{dist-BJYwWEZ9.mjs → dist-6POVIYmC.mjs} +91 -4
  7. package/dist/{dist-n4Nbnxx5.js → dist-D9To_r_d.js} +96 -3
  8. package/dist/{event-loop-stall-monitor-BiPffk0D.mjs → event-loop-stall-monitor-C-VvWOxS.mjs} +1 -1
  9. package/dist/{event-loop-stall-monitor-DG7QY00k.js → event-loop-stall-monitor-CGacbzXq.js} +1 -1
  10. package/dist/{lazy-sharp-EoSOHgoj.js → lazy-sharp-C3zOnmiU.js} +1 -1
  11. package/dist/motion-wasm/index.js +2 -2
  12. package/dist/motion-wasm/index.mjs +1 -1
  13. package/dist/pipeline-runner/index.js +4 -4
  14. package/dist/pipeline-runner/index.mjs +3 -3
  15. package/dist/recorder/index.js +680 -37
  16. package/dist/recorder/index.mjs +679 -36
  17. package/dist/session-decode/decode-worker-child.js +2 -2
  18. package/dist/session-decode/decode-worker-child.mjs +1 -1
  19. package/dist/stream-broker/_stub.js +2 -2
  20. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-CsrMZYLB.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-C2woB6bY.mjs} +2 -2
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Dut56EeQ.mjs +26 -0
  22. package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DgivrPsH.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DsIi4G5Q.mjs} +1 -1
  23. package/dist/stream-broker/{hostInit-DoFovQmc.mjs → hostInit-VCURcZLt.mjs} +2 -2
  24. package/dist/stream-broker/index.js +194 -30
  25. package/dist/stream-broker/index.mjs +194 -30
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DjuxhGo3.js → worker-protocol-Bq28qobd.js} +1 -1
  28. package/dist/{worker-protocol-XZ1e36rM.mjs → worker-protocol-PzAKg8mC.mjs} +1 -1
  29. package/package.json +1 -1
  30. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DNSELPGi.mjs +0 -26
@@ -1,4 +1,4 @@
1
- import { At as number, Ft as EventCategory, I as deriveRecordingMode, Mt as record, Nt as string, S as RecordingConfigSchema, St as selectAssignedProfileSlots, Tt as array, X as recordingCapability, Z as recordingExportCapability, et as storageEvictableCapability, f as EVENT_PAD_MS, ht as hydrateSchema, it as errMsg, jt as object, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, pt as DeviceType, ut as BaseAddon, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES, yt as nodePin } from "../dist-BJYwWEZ9.mjs";
1
+ import { $ as resolveRecordingProfiles, Ct as selectAssignedProfileSlots, Et as array, I as deriveRecordingMode, It as EventCategory, Mt as object, Nt as record, Pt as string, S as RecordingConfigSchema, X as recordingCapability, Z as recordingExportCapability, at as errMsg, bt as nodePin, dt as BaseAddon, f as EVENT_PAD_MS, gt as hydrateSchema, jt as number, l as DEFAULT_EVENTS_BAND_BUFFER_SEC, m as ExportRecordSchema, mt as DeviceType, tt as storageEvictableCapability, v as OpsLogEntrySchema, x as RECORDING_EXPORT_MAX_READ_BYTES } from "../dist-6POVIYmC.mjs";
2
2
  import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
3
3
  import { n as createFileDataPlaneHandler, s as parseRangeHeader, t as contentTypeFor } from "../addon-utils-A2S9D7pu.mjs";
4
4
  import { randomUUID } from "node:crypto";
@@ -506,14 +506,14 @@ function parseSegmentPath(relPath) {
506
506
  //#region src/recorder/recording-index.ts
507
507
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
508
508
  * already gives us — so "have I walked this?" costs one Set lookup. */
509
- var HOUR_MS$4 = 36e5;
509
+ var HOUR_MS$5 = 36e5;
510
510
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
511
511
  * the retention window into the set. */
512
512
  var ALL_HOURS = Symbol("all-hours");
513
513
  /** The hour buckets a half-open span touches. */
514
514
  function* hourBuckets(fromMs, toMs) {
515
- const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
516
- for (let h = first; h < toMs; h += HOUR_MS$4) yield h;
515
+ const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
516
+ for (let h = first; h < toMs; h += HOUR_MS$5) yield h;
517
517
  }
518
518
  var RecordingIndex = class {
519
519
  byDevice = /* @__PURE__ */ new Map();
@@ -629,7 +629,7 @@ var RecordingIndex = class {
629
629
  });
630
630
  }
631
631
  this.invalidateSorted(deviceId);
632
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$4);
632
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$5);
633
633
  }
634
634
  /** Insert/replace one finalized segment (idempotent by path). */
635
635
  addSegment(s) {
@@ -1068,6 +1068,7 @@ var RelocateEngine = class {
1068
1068
  deviceId: input.deviceId ?? null,
1069
1069
  profiles: input.profiles ? [...input.profiles] : [],
1070
1070
  footageClass: input.footageClass,
1071
+ sinceMs: input.sinceMs ?? null,
1071
1072
  throttleMbps: input.throttleMbps ?? DEFAULT_THROTTLE_MBPS,
1072
1073
  entities: input.entities ? [...input.entities] : ["segments"],
1073
1074
  filesMoved: 0,
@@ -1128,7 +1129,7 @@ var RelocateEngine = class {
1128
1129
  const bytesPerMs = job.throttleMbps * 1024 * 1024 / 1e3;
1129
1130
  try {
1130
1131
  const samePhysicalRoot = await rootsPhysicallySame(from.root, to.root);
1131
- const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).sort((a, b) => a.startMs - b.startMs);
1132
+ const segRows = job.entities.includes("segments") === false ? [] : this.deps.index.segmentsOnLocation(from.id).filter((r) => job.deviceId === null || r.deviceId === job.deviceId).filter((r) => job.profiles.length === 0 || job.profiles.includes(r.profile)).filter((r) => profileBelongsToClass(r.profile, job.footageClass)).filter((r) => job.sinceMs === null || r.startMs >= job.sinceMs).sort((a, b) => a.startMs - b.startMs);
1132
1133
  job.filesTotal = segRows.length;
1133
1134
  for (const row of segRows) {
1134
1135
  if (job.cancelRequested) break;
@@ -1164,9 +1165,26 @@ var RelocateEngine = class {
1164
1165
  }
1165
1166
  }
1166
1167
  /** Move one segment file and re-point its index row to the target location
1167
- * (same location-relative path — the layout is identical on every root). */
1168
+ * (same location-relative path — the layout is identical on every root).
1169
+ * A source that is already gone (evicted, never flushed, ENOSPC) is dropped
1170
+ * from the index and skipped — one ghost file must not abort a drain. */
1168
1171
  async moveSegment(row, from, to, samePhysicalRoot) {
1169
- if (!samePhysicalRoot) await moveFile(path.join(from.root, row.path), path.join(to.root, row.path), row.bytes);
1172
+ if (!samePhysicalRoot) try {
1173
+ await moveFile(path.join(from.root, row.path), path.join(to.root, row.path), row.bytes);
1174
+ } catch (err) {
1175
+ if (isEnoent(err)) {
1176
+ this.deps.index.removeSegments([row.path]);
1177
+ this.deps.logger.warn("relocate skipped missing source", {
1178
+ tags: { deviceId: row.deviceId },
1179
+ meta: {
1180
+ path: row.path,
1181
+ locationId: from.id
1182
+ }
1183
+ });
1184
+ return;
1185
+ }
1186
+ throw err;
1187
+ }
1170
1188
  this.deps.index.removeSegments([row.path]);
1171
1189
  this.deps.index.addSegment({
1172
1190
  ...row,
@@ -1174,6 +1192,9 @@ var RelocateEngine = class {
1174
1192
  });
1175
1193
  }
1176
1194
  };
1195
+ function isEnoent(err) {
1196
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1197
+ }
1177
1198
  /** Logical migration classes are profile-derived, never location-derived:
1178
1199
  * high+mid are `recordings`; low is `recordingsLow`. */
1179
1200
  var profileBelongsToClass = (profile, footageClass) => {
@@ -1370,6 +1391,8 @@ var SegmentStore = class SegmentStore {
1370
1391
  if (removed.length > 0) {
1371
1392
  this.deps.index.removeSegments(removed);
1372
1393
  this.notifyEvicted(rows);
1394
+ const gone = new Set(removed);
1395
+ this.deps.onDeindexed?.(rows.filter((r) => gone.has(r.path)));
1373
1396
  }
1374
1397
  await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
1375
1398
  this.deps.logger.info("recorder: eviction round complete", { meta: {
@@ -1935,7 +1958,7 @@ async function profileHasFootage(root, deviceId, profile) {
1935
1958
  //#endregion
1936
1959
  //#region src/recorder/addon/calendar-index.ts
1937
1960
  var DAY_MS$3 = 864e5;
1938
- var HOUR_MS$3 = 36e5;
1961
+ var HOUR_MS$4 = 36e5;
1939
1962
  /**
1940
1963
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
1941
1964
  *
@@ -2108,7 +2131,7 @@ var CalendarIndex = class {
2108
2131
  }));
2109
2132
  const days = /* @__PURE__ */ new Set();
2110
2133
  for (const { day, hours } of perDay) for (const h of hours) {
2111
- const at = day + h * HOUR_MS$3;
2134
+ const at = day + h * HOUR_MS$4;
2112
2135
  if (at < fromMs || at >= toMs) continue;
2113
2136
  days.add(localDayStart(at, tzOffsetMinutes));
2114
2137
  }
@@ -2170,7 +2193,7 @@ var CalendarIndex = class {
2170
2193
  const day = utcDayOf(utcDayMs);
2171
2194
  for (let h = 0; h < 24; h++) {
2172
2195
  await this.awaitQuiet();
2173
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$3, true);
2196
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$4, true);
2174
2197
  }
2175
2198
  }
2176
2199
  /**
@@ -2191,10 +2214,10 @@ var CalendarIndex = class {
2191
2214
  */
2192
2215
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
2193
2216
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
2194
- const hour = Math.floor(hourStartMs / HOUR_MS$3) * HOUR_MS$3;
2217
+ const hour = Math.floor(hourStartMs / HOUR_MS$4) * HOUR_MS$4;
2195
2218
  const key = `${deviceId}:${profile}:${hour}`;
2196
2219
  const now = this.deps.now?.() ?? Date.now();
2197
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$3) * HOUR_MS$3;
2220
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$4) * HOUR_MS$4;
2198
2221
  const cached = this.hourSegs.get(key);
2199
2222
  if (cached !== void 0) {
2200
2223
  if (!isCurrentHour) return {
@@ -2258,7 +2281,7 @@ var CalendarIndex = class {
2258
2281
  */
2259
2282
  async dayRanges(deviceId, profile, utcDayMs) {
2260
2283
  const day = utcDayOf(utcDayMs);
2261
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$3), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
2284
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$4), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
2262
2285
  }
2263
2286
  /**
2264
2287
  * Warm the days either side of the one being viewed, in the BACKGROUND.
@@ -2436,7 +2459,7 @@ function activeModeForConfig(config) {
2436
2459
  }
2437
2460
  //#endregion
2438
2461
  //#region src/recorder/addon/directory-handler.ts
2439
- var HOUR_MS$2 = 36e5;
2462
+ var HOUR_MS$3 = 36e5;
2440
2463
  /** A window wider than this is refused: it is a client bug, and answering it
2441
2464
  * would walk the archive on the read path — the thing this replaces. */
2442
2465
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -2483,9 +2506,9 @@ function withDirectoryRoute(deps, next) {
2483
2506
  return;
2484
2507
  }
2485
2508
  const rows = [];
2486
- const firstHour = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
2509
+ const firstHour = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
2487
2510
  let hydrated = true;
2488
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$2) {
2511
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$3) {
2489
2512
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
2490
2513
  if (!complete) hydrated = false;
2491
2514
  for (const s of segments) {
@@ -4210,6 +4233,7 @@ function buildRecordingProvider(deps) {
4210
4233
  rescanStorage: async ({ deviceId }) => {
4211
4234
  const locations = await deps.refreshLocations();
4212
4235
  await deps.hydrateDevice(deviceId, locations);
4236
+ await deps.reconcileHourLedger?.(deviceId);
4213
4237
  const config = await loadDeviceConfig(deps.configStore, deviceId);
4214
4238
  await deps.opsLog?.append({
4215
4239
  op: "rescan",
@@ -5073,7 +5097,11 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5073
5097
  try {
5074
5098
  entries = await promises.readdir(deviceDir, { recursive: true });
5075
5099
  } catch (err) {
5076
- if (err.code !== "ENOENT") logger.warn("recorder: hydrateDevice walk failed for location", {
5100
+ if (err.code === "ENOENT") {
5101
+ for (const location of aliases) index.hydrateDevice(deviceId, location.id, []);
5102
+ continue;
5103
+ }
5104
+ logger.warn("recorder: hydrateDevice walk failed for location", {
5077
5105
  tags: { deviceId },
5078
5106
  meta: {
5079
5107
  locationIds: aliases.map((location) => location.id),
@@ -5101,6 +5129,7 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
5101
5129
  handled += 1;
5102
5130
  if (handled % chunkSize === 0) await yieldBetween();
5103
5131
  }
5132
+ for (const location of aliases) if (!pathsByLocation.has(location.id)) pathsByLocation.set(location.id, []);
5104
5133
  let indexed = 0;
5105
5134
  for (const [locationId, relPaths] of pathsByLocation) {
5106
5135
  index.hydrateDevice(deviceId, locationId, relPaths);
@@ -5185,7 +5214,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
5185
5214
  const p2 = (n) => String(n).padStart(2, "0");
5186
5215
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
5187
5216
  }
5188
- var HOUR_MS$1 = 36e5;
5217
+ var HOUR_MS$2 = 36e5;
5189
5218
  /**
5190
5219
  * Hydrate ONLY the hours a window spans, instead of walking the archive.
5191
5220
  *
@@ -5220,8 +5249,8 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
5220
5249
  });
5221
5250
  continue;
5222
5251
  }
5223
- const first = Math.floor(fromMs / HOUR_MS$1) * HOUR_MS$1;
5224
- for (let hour = first; hour < toMs; hour += HOUR_MS$1) for (const profile of profiles) {
5252
+ const first = Math.floor(fromMs / HOUR_MS$2) * HOUR_MS$2;
5253
+ for (let hour = first; hour < toMs; hour += HOUR_MS$2) for (const profile of profiles) {
5225
5254
  const rel = hourDirRelPath(deviceId, profile, hour);
5226
5255
  let files;
5227
5256
  try {
@@ -6560,6 +6589,13 @@ var RecordingController = class {
6560
6589
  * records again.
6561
6590
  */
6562
6591
  unrecordableReported = /* @__PURE__ */ new Set();
6592
+ /**
6593
+ * Profiles the last attach skipped (stream not published / getStream threw).
6594
+ * `evaluateDevice` retries only these on the converge tick so a dead `low`
6595
+ * cannot keep `high` dark, without a `listAllProfileSlots` RPC on every
6596
+ * healthy device every 30s.
6597
+ */
6598
+ skippedProfiles = /* @__PURE__ */ new Map();
6563
6599
  restore = null;
6564
6600
  /** Cheap, never-delayed: is any pinned writer producing nothing? */
6565
6601
  livenessPass = null;
@@ -6766,7 +6802,10 @@ var RecordingController = class {
6766
6802
  return;
6767
6803
  }
6768
6804
  this.unrecordableReported.delete(deviceId);
6769
- if (this.active.has(deviceId)) return;
6805
+ if (this.active.has(deviceId)) {
6806
+ if (this.skippedProfiles.has(deviceId)) await this.fillMissingProfiles(deviceId, config);
6807
+ return;
6808
+ }
6770
6809
  if (this.attaching.has(deviceId)) return;
6771
6810
  this.attaching.add(deviceId);
6772
6811
  const attaching = this.performAttach(deviceId, config);
@@ -6817,8 +6856,12 @@ var RecordingController = class {
6817
6856
  /**
6818
6857
  * The attach body — extracted so the {@link attaching} in-flight guard in
6819
6858
  * `evaluateDevice` wraps it cleanly. Resolves the profiles, spawns a
6820
- * SegmentWriter + watcher per profile, and records the set in `active`. A throw
6821
- * (broker not ready / no sources) rolls back partial attaches and re-throws.
6859
+ * SegmentWriter + watcher per profile, and records the set in `active`.
6860
+ *
6861
+ * Per-profile: a throw from one slot is logged and skipped. Rolling back the
6862
+ * whole device when a single stream was dead (618 `derived` on `low`) left
6863
+ * ZERO footage for hours. If every profile fails, throw so the restore queue
6864
+ * retries the device.
6822
6865
  */
6823
6866
  async performAttach(deviceId, config) {
6824
6867
  this.restore?.add(deviceId, true);
@@ -6826,38 +6869,106 @@ var RecordingController = class {
6826
6869
  if (profiles.length === 0) throw new Error(`recorder controller: no assigned broker sources for device ${deviceId}`);
6827
6870
  const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6828
6871
  const recordings = [];
6829
- try {
6830
- for (const profile of profiles) recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6872
+ const failed = [];
6873
+ for (const profile of profiles) try {
6874
+ recordings.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6831
6875
  } catch (err) {
6832
- for (const r of recordings) await this.teardownProfile(deviceId, r);
6833
- throw err;
6876
+ failed.push(profile);
6877
+ this.deps.logger.warn("recorder: profile attach failed — recording the remaining profiles", {
6878
+ tags: { deviceId },
6879
+ meta: {
6880
+ profile,
6881
+ error: errMsg(err)
6882
+ }
6883
+ });
6834
6884
  }
6885
+ if (recordings.length === 0) throw new Error(`recorder controller: no profile attached for device ${deviceId}` + (failed.length > 0 ? ` (failed: ${failed.join(",")})` : ""));
6835
6886
  if (this.paused) {
6836
6887
  await Promise.all(recordings.map((recording) => this.teardownProfile(deviceId, recording)));
6837
6888
  return;
6838
6889
  }
6839
6890
  this.active.set(deviceId, recordings);
6891
+ if (failed.length > 0) this.skippedProfiles.set(deviceId, new Set(failed));
6892
+ else this.skippedProfiles.delete(deviceId);
6840
6893
  this.deps.logger.info("recorder pinned broker source(s) for recording", {
6841
6894
  tags: { deviceId },
6842
6895
  meta: {
6843
- profiles,
6896
+ profiles: recordings.map((r) => r.profile),
6897
+ failedProfiles: failed,
6844
6898
  segmentSeconds,
6845
6899
  pipelineKeys: recordings.map((r) => r.pipelineKey)
6846
6900
  }
6847
6901
  });
6848
6902
  }
6849
6903
  /**
6904
+ * Retry profiles the last attach skipped, without tearing down writers that
6905
+ * are already producing. Called from `evaluateDevice` when the device is
6906
+ * already in `active` and {@link skippedProfiles} is non-empty. Failures stay
6907
+ * logged; the next converge tick retries.
6908
+ */
6909
+ async fillMissingProfiles(deviceId, config) {
6910
+ if (this.attaching.has(deviceId)) return;
6911
+ const current = this.active.get(deviceId);
6912
+ const skipped = this.skippedProfiles.get(deviceId);
6913
+ if (!current || !skipped || skipped.size === 0) return;
6914
+ this.attaching.add(deviceId);
6915
+ const filling = this.runFillMissingProfiles(deviceId, config, current, skipped);
6916
+ this.attachingTasks.set(deviceId, filling);
6917
+ try {
6918
+ await filling;
6919
+ } finally {
6920
+ this.attachingTasks.delete(deviceId);
6921
+ this.attaching.delete(deviceId);
6922
+ }
6923
+ }
6924
+ async runFillMissingProfiles(deviceId, config, current, skipped) {
6925
+ const have = new Set(current.map((r) => r.profile));
6926
+ const segmentSeconds = config.segmentSeconds ?? this.deps.segmentSeconds;
6927
+ const added = [];
6928
+ for (const profile of [...skipped]) {
6929
+ if (have.has(profile)) {
6930
+ skipped.delete(profile);
6931
+ continue;
6932
+ }
6933
+ try {
6934
+ added.push(await this.attachProfile(deviceId, profile, segmentSeconds));
6935
+ skipped.delete(profile);
6936
+ } catch (err) {
6937
+ this.deps.logger.warn("recorder: profile attach retry failed — keeping the healthy profiles", {
6938
+ tags: { deviceId },
6939
+ meta: {
6940
+ profile,
6941
+ error: errMsg(err)
6942
+ }
6943
+ });
6944
+ }
6945
+ }
6946
+ if (skipped.size === 0) this.skippedProfiles.delete(deviceId);
6947
+ if (added.length === 0) return;
6948
+ const existing = this.active.get(deviceId);
6949
+ if (!existing || this.paused || this.stopped) {
6950
+ await Promise.all(added.map((recording) => this.teardownProfile(deviceId, recording)));
6951
+ return;
6952
+ }
6953
+ this.active.set(deviceId, [...existing, ...added]);
6954
+ this.deps.logger.info("recorder pinned additional broker source(s) for recording", {
6955
+ tags: { deviceId },
6956
+ meta: {
6957
+ profiles: added.map((r) => r.profile),
6958
+ pipelineKeys: added.map((r) => r.pipelineKey)
6959
+ }
6960
+ });
6961
+ }
6962
+ /**
6850
6963
  * Pick which profiles to record. The broker enumerates assigned profile slots,
6851
6964
  * deduped by physical source (`selectAssignedProfileSlots`) so the same camera
6852
- * encoder is never recorded twice. An operator `override` DISABLES the
6853
- * unselected profiles (assigned ∩ selection); a selection matching none of the
6854
- * assigned sources is ignored (record every assigned source minimum of 1).
6965
+ * encoder is never recorded twice. Default is high+low among those slots
6966
+ * (`resolveRecordingProfiles`); an operator `override` DISABLES the
6967
+ * unselected profiles. A selection matching none of the assigned sources
6968
+ * falls back to the default/assigned set (minimum of 1 when anything is assigned).
6855
6969
  */
6856
6970
  async resolveProfiles(deviceId, override) {
6857
- const assigned = selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile);
6858
- if (!override || override.length === 0) return assigned;
6859
- const selected = assigned.filter((p) => override.includes(p));
6860
- return selected.length > 0 ? selected : assigned;
6971
+ return resolveRecordingProfiles(selectAssignedProfileSlots(await this.deps.brokerCall(() => withDeadline(this.deps.api.streamBroker.listAllProfileSlots.query(void 0, nodePin(this.deps.ownerNodeId)), ATTACH_RPC_TIMEOUT_MS, "listAllProfileSlots")), deviceId).map((slot) => slot.profile), override);
6861
6972
  }
6862
6973
  /**
6863
6974
  * Attach one (device, profile): acquire the broker source, mkdir the staging
@@ -6987,6 +7098,7 @@ var RecordingController = class {
6987
7098
  const recordings = this.active.get(deviceId);
6988
7099
  if (!recordings) return;
6989
7100
  this.active.delete(deviceId);
7101
+ this.skippedProfiles.delete(deviceId);
6990
7102
  for (const r of recordings) this.lastSegmentAt.delete(idleKey(deviceId, r.profile));
6991
7103
  await Promise.all(recordings.map((r) => this.teardownProfile(deviceId, r)));
6992
7104
  this.deps.logger.info("recorder unpinned broker source(s) — recording stopped", {
@@ -7039,6 +7151,503 @@ function makeBrokerCall(handle) {
7039
7151
  return (fn) => handle.call(fn, BROKER_READY_TIMEOUT_MS);
7040
7152
  }
7041
7153
  //#endregion
7154
+ //#region src/durable/durable-ledger.ts
7155
+ /** Default reseed cap — every current consumer's row set is installation-bounded. */
7156
+ var DEFAULT_LOAD_LIMIT = 1e5;
7157
+ var DurableLedger = class DurableLedger {
7158
+ mirror = /* @__PURE__ */ new Map();
7159
+ spec;
7160
+ store;
7161
+ logger;
7162
+ constructor(deps) {
7163
+ this.spec = deps.spec;
7164
+ this.store = deps.store;
7165
+ this.logger = deps.logger;
7166
+ }
7167
+ /**
7168
+ * Register the collection. MUST run at boot, before any read or write: the
7169
+ * SQLite backend answers 412 for an undeclared collection and takes the whole
7170
+ * runner down with it (the addon-ai boot-crash lesson).
7171
+ */
7172
+ static declare(store, spec) {
7173
+ return store.declareCollection.mutate({
7174
+ collection: spec.collection,
7175
+ columns: [...spec.columns],
7176
+ ...spec.indexes !== void 0 ? { indexes: [...spec.indexes] } : {}
7177
+ });
7178
+ }
7179
+ declare() {
7180
+ return DurableLedger.declare(this.store, this.spec);
7181
+ }
7182
+ /** The collection this ledger owns — for the caller's own log lines. */
7183
+ get collection() {
7184
+ return this.spec.collection;
7185
+ }
7186
+ /**
7187
+ * Boot reseed. Replaces the mirror with what the store holds and returns the
7188
+ * rows, so a caller that must hydrate something else (a watcher, a registry)
7189
+ * gets them without a second read.
7190
+ *
7191
+ * **A failure returns what is already mirrored** rather than clearing it —
7192
+ * see contract rule 2. The count is worth logging out loud at the call site:
7193
+ * "loaded 0" after a container recreate is the one line that explains a
7194
+ * silent flood.
7195
+ */
7196
+ async load() {
7197
+ try {
7198
+ const records = await this.store.query.query({
7199
+ collection: this.spec.collection,
7200
+ filter: { limit: this.spec.loadLimit ?? DEFAULT_LOAD_LIMIT }
7201
+ });
7202
+ const next = /* @__PURE__ */ new Map();
7203
+ let skipped = 0;
7204
+ for (const record of records) {
7205
+ const row = this.spec.fromRecord(record.id, record.data);
7206
+ if (row === null) {
7207
+ skipped += 1;
7208
+ continue;
7209
+ }
7210
+ next.set(this.spec.keyOf(row), row);
7211
+ }
7212
+ this.mirror.clear();
7213
+ for (const [key, row] of next) this.mirror.set(key, row);
7214
+ if (skipped > 0) this.logger.warn("durable rows skipped as malformed — they gate NOTHING", { meta: {
7215
+ collection: this.spec.collection,
7216
+ skipped
7217
+ } });
7218
+ return [...this.mirror.values()];
7219
+ } catch (err) {
7220
+ this.logger.warn("durable load failed — keeping the state already in memory", { meta: {
7221
+ collection: this.spec.collection,
7222
+ error: String(err),
7223
+ held: this.mirror.size
7224
+ } });
7225
+ return [...this.mirror.values()];
7226
+ }
7227
+ }
7228
+ /** Every mirrored row, insertion-ordered. */
7229
+ snapshot() {
7230
+ return [...this.mirror.values()];
7231
+ }
7232
+ /** The row currently held for a key, if any. Pure RAM — never I/O. */
7233
+ get(key) {
7234
+ return this.mirror.get(key);
7235
+ }
7236
+ has(key) {
7237
+ return this.mirror.has(key);
7238
+ }
7239
+ get size() {
7240
+ return this.mirror.size;
7241
+ }
7242
+ /**
7243
+ * Judge ONE observation against what the ledger already accepted, and advance
7244
+ * it.
7245
+ *
7246
+ * Synchronous on purpose: the verdict is a function of the in-RAM mirror
7247
+ * alone, so a decision can never be gated on an I/O that might fail (D49).
7248
+ * The durable write is kicked off behind it and its failure changes no
7249
+ * verdict.
7250
+ *
7251
+ * `no-flip` leaves the held row UNTOUCHED — including any timestamp it
7252
+ * carries, which therefore means "when this key last CHANGED", not "when it
7253
+ * last spoke". That is the timestamp anyone reading the table wants.
7254
+ */
7255
+ observe(row) {
7256
+ const equalFact = this.spec.equalFact;
7257
+ if (equalFact === void 0) throw new Error(`DurableLedger(${this.spec.collection}): observe() requires the spec to declare equalFact`);
7258
+ const key = this.spec.keyOf(row);
7259
+ const held = this.mirror.get(key);
7260
+ if (held !== void 0 && equalFact(held, row)) return "no-flip";
7261
+ this.mirror.set(key, row);
7262
+ this.persist(row);
7263
+ return held === void 0 ? "seeded" : "flip";
7264
+ }
7265
+ /**
7266
+ * Upsert a row without a verdict — the write path for a ledger whose owner
7267
+ * has already decided the value changed.
7268
+ *
7269
+ * The order of the mirror advance and the durable write is the spec's
7270
+ * {@link DurableWriteMode}, not the call site's: two call sites that
7271
+ * disagreed about it would be two different durability guarantees on one
7272
+ * collection.
7273
+ */
7274
+ async put(row) {
7275
+ const key = this.spec.keyOf(row);
7276
+ if (this.spec.writeMode === "write-behind") {
7277
+ this.mirror.set(key, row);
7278
+ await this.persist(row);
7279
+ return;
7280
+ }
7281
+ await this.store.set.mutate({
7282
+ collection: this.spec.collection,
7283
+ key,
7284
+ value: this.spec.toValue(row)
7285
+ });
7286
+ this.mirror.set(key, row);
7287
+ }
7288
+ /**
7289
+ * Advance the mirror WITHOUT persisting, for an owner that deliberately
7290
+ * coalesces its writes.
7291
+ *
7292
+ * The stationary registry is the reason this exists: a parked car is
7293
+ * re-confirmed on every processed frame (5–30 Hz), and persisting each
7294
+ * confirmation would offer thousands of commits a day to the checkpoint
7295
+ * lottery (D96) to maintain a handful of rows. It stages the advance and
7296
+ * flushes on its 5-minute sweep — 288 writes a day instead of ~10⁶.
7297
+ *
7298
+ * **The cost is stated, not hidden**: a staged value that is never flushed
7299
+ * is lost on a crash. An owner may only stage a field whose staleness its
7300
+ * own TTL absorbs. Anything that GATES work must go through {@link put} or
7301
+ * {@link observe}.
7302
+ */
7303
+ stage(row) {
7304
+ this.mirror.set(this.spec.keyOf(row), row);
7305
+ }
7306
+ /**
7307
+ * Drop a key from the MIRROR only — the durable row survives.
7308
+ *
7309
+ * What a scope-unbind needs: this process stops holding the value, and a
7310
+ * rebind reloads it from the store. Deliberately distinct from
7311
+ * {@link forget}, which deletes; conflating the two is how an unbind turns
7312
+ * into a wipe.
7313
+ */
7314
+ evict(key) {
7315
+ this.mirror.delete(key);
7316
+ }
7317
+ /**
7318
+ * Drop one key, mirror and row. Best-effort on the durable half: a failed
7319
+ * delete leaves a row that the next load will re-mirror, which is a stale
7320
+ * value rather than a lost one.
7321
+ */
7322
+ async forget(key) {
7323
+ this.mirror.delete(key);
7324
+ try {
7325
+ await this.store.delete.mutate({
7326
+ collection: this.spec.collection,
7327
+ key
7328
+ });
7329
+ } catch (err) {
7330
+ this.logger.debug("durable delete failed", { meta: {
7331
+ collection: this.spec.collection,
7332
+ key,
7333
+ error: String(err)
7334
+ } });
7335
+ }
7336
+ }
7337
+ /**
7338
+ * Drop every mirrored key NOT in `activeKeys`. Returns how many rows went.
7339
+ *
7340
+ * **The caller must hold an AUTHORITATIVE active set.** A prune driven by a
7341
+ * fallible read is work destroyed on an error (D49/D130) — that is why this
7342
+ * is a method a feature opts into rather than a policy the primitive runs.
7343
+ * Best-effort per row: a failed delete keeps the key (retried next prune)
7344
+ * rather than aborting the sweep.
7345
+ */
7346
+ async pruneExcept(activeKeys) {
7347
+ let pruned = 0;
7348
+ for (const key of [...this.mirror.keys()]) {
7349
+ if (activeKeys.has(key)) continue;
7350
+ try {
7351
+ await this.store.delete.mutate({
7352
+ collection: this.spec.collection,
7353
+ key
7354
+ });
7355
+ this.mirror.delete(key);
7356
+ pruned += 1;
7357
+ } catch (err) {
7358
+ this.logger.debug("durable prune delete failed", { meta: {
7359
+ collection: this.spec.collection,
7360
+ key,
7361
+ error: String(err)
7362
+ } });
7363
+ }
7364
+ }
7365
+ return pruned;
7366
+ }
7367
+ /**
7368
+ * Write-behind durable upsert. Best-effort and logged, never thrown at the
7369
+ * decision path: the mirror already holds the truth for this process, and the
7370
+ * worst a lost write can do is one silent re-seed after the next restart.
7371
+ */
7372
+ async persist(row) {
7373
+ const deviceId = this.spec.deviceIdOf?.(row);
7374
+ try {
7375
+ await this.store.set.mutate({
7376
+ collection: this.spec.collection,
7377
+ key: this.spec.keyOf(row),
7378
+ value: this.spec.toValue(row)
7379
+ });
7380
+ } catch (err) {
7381
+ this.logger.warn("durable persist failed — this key may re-seed on boot", {
7382
+ ...deviceId !== void 0 ? { tags: { deviceId } } : {},
7383
+ meta: {
7384
+ collection: this.spec.collection,
7385
+ key: this.spec.keyOf(row),
7386
+ error: String(err)
7387
+ }
7388
+ });
7389
+ }
7390
+ }
7391
+ };
7392
+ //#endregion
7393
+ //#region src/recorder/addon/segment-hour-ledger.ts
7394
+ var HOUR_MS$1 = 36e5;
7395
+ /**
7396
+ * @durable class=mirror owner=recorder
7397
+ * write="write-behind on every finalized segment (hour-row upsert); eviction drops paths; the archive walk reconciles disk into the row set"
7398
+ * retention="a row goes when the walk or eviction confirms the hour is empty — never by age, never from the ledger alone (D148)"
7399
+ */
7400
+ var RECORDING_SEGMENT_HOURS_COLLECTION = "recorder:segment-hours";
7401
+ var RECORDING_SEGMENT_HOURS_COLUMNS = [
7402
+ {
7403
+ name: "key",
7404
+ type: "TEXT",
7405
+ primaryKey: true,
7406
+ notNull: true
7407
+ },
7408
+ {
7409
+ name: "deviceId",
7410
+ type: "INTEGER",
7411
+ notNull: true
7412
+ },
7413
+ {
7414
+ name: "profile",
7415
+ type: "TEXT",
7416
+ notNull: true
7417
+ },
7418
+ {
7419
+ name: "locationId",
7420
+ type: "TEXT",
7421
+ notNull: true
7422
+ },
7423
+ {
7424
+ name: "hourStartMs",
7425
+ type: "INTEGER",
7426
+ notNull: true
7427
+ },
7428
+ {
7429
+ name: "paths",
7430
+ type: "TEXT",
7431
+ notNull: true
7432
+ }
7433
+ ];
7434
+ var RECORDING_SEGMENT_HOURS_INDEXES = [{
7435
+ name: "idx_recorder_segment_hours_device",
7436
+ columns: ["deviceId"]
7437
+ }, {
7438
+ name: "idx_recorder_segment_hours_hour",
7439
+ columns: ["hourStartMs"]
7440
+ }];
7441
+ function hourStartMs(startMs) {
7442
+ return Math.floor(startMs / HOUR_MS$1) * HOUR_MS$1;
7443
+ }
7444
+ function segmentHourKey(deviceId, profile, locationId, hour) {
7445
+ return `${deviceId}|${profile}|${locationId}|${hour}`;
7446
+ }
7447
+ function hourOf(row) {
7448
+ const hour = hourStartMs(row.startMs);
7449
+ return {
7450
+ hourStartMs: hour,
7451
+ key: segmentHourKey(row.deviceId, row.profile, row.locationId, hour)
7452
+ };
7453
+ }
7454
+ function uniquePaths(paths) {
7455
+ const seen = /* @__PURE__ */ new Set();
7456
+ const out = [];
7457
+ for (const p of paths) {
7458
+ if (seen.has(p)) continue;
7459
+ seen.add(p);
7460
+ out.push(p);
7461
+ }
7462
+ return out;
7463
+ }
7464
+ function samePaths(a, b) {
7465
+ if (a.length !== b.length) return false;
7466
+ const set = new Set(a);
7467
+ for (const p of b) if (!set.has(p)) return false;
7468
+ return true;
7469
+ }
7470
+ function rowToValue(row) {
7471
+ return {
7472
+ deviceId: row.deviceId,
7473
+ profile: row.profile,
7474
+ locationId: row.locationId,
7475
+ hourStartMs: row.hourStartMs,
7476
+ paths: JSON.stringify(row.paths)
7477
+ };
7478
+ }
7479
+ function recordToRow(key, data) {
7480
+ const deviceId = Number(data["deviceId"]);
7481
+ const profile = data["profile"];
7482
+ const locationId = data["locationId"];
7483
+ const hour = Number(data["hourStartMs"]);
7484
+ let raw = data["paths"];
7485
+ if (typeof raw === "string") try {
7486
+ raw = JSON.parse(raw);
7487
+ } catch {
7488
+ return null;
7489
+ }
7490
+ if (!Number.isFinite(deviceId) || deviceId <= 0 || typeof profile !== "string" || profile.length === 0 || typeof locationId !== "string" || locationId.length === 0 || !Number.isFinite(hour) || !Array.isArray(raw) || !raw.every((p) => typeof p === "string" && p.length > 0)) return null;
7491
+ return {
7492
+ key,
7493
+ deviceId,
7494
+ profile,
7495
+ locationId,
7496
+ hourStartMs: hour,
7497
+ paths: raw
7498
+ };
7499
+ }
7500
+ var SPEC = {
7501
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION,
7502
+ columns: RECORDING_SEGMENT_HOURS_COLUMNS,
7503
+ indexes: RECORDING_SEGMENT_HOURS_INDEXES,
7504
+ writeMode: "write-behind",
7505
+ loadLimit: 1e5,
7506
+ keyOf: (row) => row.key,
7507
+ toValue: rowToValue,
7508
+ fromRecord: recordToRow,
7509
+ deviceIdOf: (row) => row.deviceId
7510
+ };
7511
+ var SegmentHourLedger = class {
7512
+ ledger;
7513
+ logger;
7514
+ constructor(deps) {
7515
+ this.logger = deps.logger;
7516
+ this.ledger = new DurableLedger({
7517
+ spec: SPEC,
7518
+ store: deps.store,
7519
+ logger: deps.logger
7520
+ });
7521
+ }
7522
+ static declare(store) {
7523
+ return DurableLedger.declare(store, SPEC);
7524
+ }
7525
+ declare() {
7526
+ return this.ledger.declare();
7527
+ }
7528
+ async load() {
7529
+ const rows = await this.ledger.load();
7530
+ this.logger.info("recorder: segment-hour ledger loaded", { meta: {
7531
+ hours: rows.length,
7532
+ collection: RECORDING_SEGMENT_HOURS_COLLECTION
7533
+ } });
7534
+ return rows;
7535
+ }
7536
+ snapshot() {
7537
+ return this.ledger.snapshot();
7538
+ }
7539
+ /**
7540
+ * Seed {@link RecordingIndex} from the mirrored hour rows.
7541
+ *
7542
+ * Hours present in the ledger become `hydrated` so a seek in those windows
7543
+ * does not pay for a disk walk. Hours the ledger never held stay `unknown`
7544
+ * — that is the fail-toward-walk direction.
7545
+ */
7546
+ hydrateIndex(index) {
7547
+ for (const hour of this.ledger.snapshot()) index.hydrateHour(hour.deviceId, hour.locationId, hour.hourStartMs, hour.paths);
7548
+ }
7549
+ /** Write-behind: a just-finalized segment joins its hour row. */
7550
+ async recordSegment(row) {
7551
+ const { key, hourStartMs: hour } = hourOf(row);
7552
+ const held = this.ledger.get(key);
7553
+ if (held !== void 0 && held.paths.includes(row.path)) return;
7554
+ await this.ledger.put({
7555
+ key,
7556
+ deviceId: row.deviceId,
7557
+ profile: row.profile,
7558
+ locationId: row.locationId,
7559
+ hourStartMs: hour,
7560
+ paths: held === void 0 ? [row.path] : [...held.paths, row.path]
7561
+ });
7562
+ }
7563
+ /**
7564
+ * Eviction confirmed these paths are gone from disk. Drop them; forget the
7565
+ * hour when it holds nothing.
7566
+ */
7567
+ async dropSegments(rows) {
7568
+ const doomed = /* @__PURE__ */ new Map();
7569
+ for (const row of rows) {
7570
+ const { key } = hourOf(row);
7571
+ let set = doomed.get(key);
7572
+ if (!set) {
7573
+ set = /* @__PURE__ */ new Set();
7574
+ doomed.set(key, set);
7575
+ }
7576
+ set.add(row.path);
7577
+ }
7578
+ for (const [key, paths] of doomed) {
7579
+ const held = this.ledger.get(key);
7580
+ if (held === void 0) continue;
7581
+ const next = held.paths.filter((p) => !paths.has(p));
7582
+ if (next.length === 0) {
7583
+ await this.ledger.forget(key);
7584
+ continue;
7585
+ }
7586
+ if (next.length === held.paths.length) continue;
7587
+ await this.ledger.put({
7588
+ ...held,
7589
+ paths: next
7590
+ });
7591
+ }
7592
+ }
7593
+ /**
7594
+ * After a disk walk of `deviceIds`, make the ledger match the index — with
7595
+ * two D148 exceptions:
7596
+ *
7597
+ * - an hour the walk did not see is dropped ONLY if it is in the past;
7598
+ * the current hour may still hold a live writer whose file the readdir
7599
+ * raced;
7600
+ * - the current hour UNIONs ledger paths with index paths rather than
7601
+ * replacing, for the same race.
7602
+ *
7603
+ * Devices not in `deviceIds` are left untouched (a partial walk must not
7604
+ * prune cameras it did not look at).
7605
+ */
7606
+ async reconcileFromIndex(index, deviceIds, nowMs) {
7607
+ const walked = new Set(deviceIds);
7608
+ const currentHour = hourStartMs(nowMs);
7609
+ const fromIndex = /* @__PURE__ */ new Map();
7610
+ for (const deviceId of deviceIds) for (const seg of index.segments(deviceId)) {
7611
+ const { key, hourStartMs: hour } = hourOf(seg);
7612
+ const existing = fromIndex.get(key);
7613
+ if (existing === void 0) fromIndex.set(key, {
7614
+ key,
7615
+ deviceId: seg.deviceId,
7616
+ profile: seg.profile,
7617
+ locationId: seg.locationId,
7618
+ hourStartMs: hour,
7619
+ paths: [seg.path]
7620
+ });
7621
+ else fromIndex.set(key, {
7622
+ ...existing,
7623
+ paths: [...existing.paths, seg.path]
7624
+ });
7625
+ }
7626
+ for (const row of this.ledger.snapshot()) {
7627
+ if (!walked.has(row.deviceId)) continue;
7628
+ const disk = fromIndex.get(row.key);
7629
+ if (disk === void 0) {
7630
+ if (row.hourStartMs === currentHour) continue;
7631
+ await this.ledger.forget(row.key);
7632
+ continue;
7633
+ }
7634
+ if (row.hourStartMs === currentHour) {
7635
+ const union = uniquePaths([...disk.paths, ...row.paths]);
7636
+ if (!samePaths(union, row.paths)) await this.ledger.put({
7637
+ ...row,
7638
+ paths: union
7639
+ });
7640
+ continue;
7641
+ }
7642
+ if (!samePaths(row.paths, disk.paths)) await this.ledger.put(disk);
7643
+ }
7644
+ for (const [key, disk] of fromIndex) {
7645
+ if (this.ledger.has(key)) continue;
7646
+ await this.ledger.put(disk);
7647
+ }
7648
+ }
7649
+ };
7650
+ //#endregion
7042
7651
  //#region src/recorder/addon/recording-export-provider.ts
7043
7652
  /** Content type of every rendered export — the engine only ever writes MP4. */
7044
7653
  var EXPORT_CONTENT_TYPE = "video/mp4";
@@ -7860,6 +8469,12 @@ var RecorderV2Addon = class extends BaseAddon {
7860
8469
  isRecordingNode = true;
7861
8470
  index = new RecordingIndex();
7862
8471
  /**
8472
+ * Durable hour-directory copy of {@link index}. Null on an inert
8473
+ * (non-recording) node. Boot reseeds the RAM index from this; the
8474
+ * deferred archive walk reconciles it against disk.
8475
+ */
8476
+ segmentHours = null;
8477
+ /**
7863
8478
  * The calendar rungs — which days, which hours, and a day's exact ranges.
7864
8479
  *
7865
8480
  * Separate from `RecordingIndex` on purpose: that one is the SEGMENT model
@@ -7931,6 +8546,16 @@ var RecorderV2Addon = class extends BaseAddon {
7931
8546
  } });
7932
8547
  return { providers: [] };
7933
8548
  }
8549
+ this.segmentHours = new SegmentHourLedger({
8550
+ store: this.ctx.api.settingsStore,
8551
+ logger: this.ctx.logger
8552
+ });
8553
+ try {
8554
+ await this.segmentHours.declare();
8555
+ } catch (err) {
8556
+ this.ctx.logger.warn("recorder: segment-hour ledger declare failed — index stays RAM-only this boot", { meta: { error: errMsg(err) } });
8557
+ this.segmentHours = null;
8558
+ }
7934
8559
  const ingestOwner = await this.resolveIngestOwner();
7935
8560
  const consumerHostname = this.nodeId === ingestOwner.ownerNodeId ? void 0 : ingestOwner.reachableHost ?? resolveRecordingHubHostname(this.config.recordingHubHostname, process.env["CAMSTACK_HUB_URL"]);
7936
8561
  if (this.nodeId !== ingestOwner.ownerNodeId) {
@@ -7967,7 +8592,11 @@ var RecorderV2Addon = class extends BaseAddon {
7967
8592
  onEvicted: (deviceId, atMs) => {
7968
8593
  this.calendar.invalidate(deviceId, atMs);
7969
8594
  },
8595
+ onDeindexed: (rows) => {
8596
+ this.segmentHours?.dropSegments(rows);
8597
+ },
7970
8598
  onIndexed: (row, absPath) => {
8599
+ this.segmentHours?.recordSegment(row);
7971
8600
  if (!isMfraTableServable(row.startMs, Date.now())) return;
7972
8601
  readMfraTable(absPath, row.bytes).then((table) => {
7973
8602
  this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
@@ -8086,6 +8715,9 @@ var RecorderV2Addon = class extends BaseAddon {
8086
8715
  refreshLocations: () => this.refreshLocations(),
8087
8716
  capacity: (root) => this.locationCapacity(root),
8088
8717
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
8718
+ reconcileHourLedger: async (deviceId) => {
8719
+ await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
8720
+ },
8089
8721
  calendar: this.calendar,
8090
8722
  mfraFor: (deviceId, profile, startMs) => this.mfraTables.get(deviceId, profile, startMs),
8091
8723
  hydrateWindow: (deviceId, fromMs, toMs) => hydrateWindowFromStorage(this.index, deviceId, fromMs, toMs, this.resolvedLocations, this.ctx.logger),
@@ -8344,6 +8976,12 @@ var RecorderV2Addon = class extends BaseAddon {
8344
8976
  this.controller?.onTrigger(deviceId, "motion", atMs);
8345
8977
  }
8346
8978
  });
8979
+ try {
8980
+ await this.segmentHours?.load();
8981
+ this.segmentHours?.hydrateIndex(this.index);
8982
+ } catch (err) {
8983
+ this.ctx.logger.warn("recorder: segment-hour ledger load failed — falling through to the archive walk", { meta: { error: errMsg(err) } });
8984
+ }
8347
8985
  let configs;
8348
8986
  try {
8349
8987
  configs = await readDeviceConfigs(this.configStore());
@@ -8831,6 +9469,11 @@ var RecorderV2Addon = class extends BaseAddon {
8831
9469
  locationCount: this.resolvedLocations.length,
8832
9470
  ms: Date.now() - hydrateStartedMs
8833
9471
  } });
9472
+ try {
9473
+ await this.segmentHours?.reconcileFromIndex(this.index, deviceIds, Date.now());
9474
+ } catch (err) {
9475
+ this.ctx.logger.warn("recorder: segment-hour ledger reconcile failed — walk remains the authority", { meta: { error: errMsg(err) } });
9476
+ }
8834
9477
  const loggedDomains = /* @__PURE__ */ new Set();
8835
9478
  for (const loc of this.resolvedLocations) {
8836
9479
  const domain = this.locationDomain(loc.id);