@camstack/addon-pipeline 1.2.76 → 1.2.78

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/{addon-utils-CTpQfjSR.js → addon-utils-C-XbUkiG.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-CZLjObZZ.js → dist-BdVCXl5n.js} +429 -44
  7. package/dist/{dist-zksfWnEA.mjs → dist-CsP_DikG.mjs} +429 -44
  8. package/dist/{event-loop-stall-monitor-CcBQAI28.js → event-loop-stall-monitor-BOu8lGee.js} +1 -1
  9. package/dist/{event-loop-stall-monitor-BuZA3loB.mjs → event-loop-stall-monitor-C3cvE_Xk.mjs} +1 -1
  10. package/dist/{lazy-sharp-SWR_D1Um.js → lazy-sharp-1LkmyWqV.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 +655 -46
  16. package/dist/recorder/index.mjs +654 -45
  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-Dd6XOtVn.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DUi-lR4R.mjs} +3 -3
  21. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-1QMyGZMB.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-Dg08SxUW.mjs +26 -0
  23. package/dist/stream-broker/{hostInit-COc_Q_xX.mjs → hostInit-DEtsjgBO.mjs} +3 -3
  24. package/dist/stream-broker/index.js +411 -93
  25. package/dist/stream-broker/index.mjs +411 -93
  26. package/dist/stream-broker/remoteEntry.js +1 -1
  27. package/dist/{worker-protocol-DAQ1iZFK.js → worker-protocol-B4fPjmXk.js} +1 -1
  28. package/dist/{worker-protocol-DyX_HbaJ.mjs → worker-protocol-BRwzX3f_.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-RYTOS6B-.mjs +0 -26
  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-DfsLsn2e.mjs +0 -26
@@ -1,6 +1,6 @@
1
- const require_dist = require("../dist-CZLjObZZ.js");
1
+ const require_dist = require("../dist-BdVCXl5n.js");
2
2
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
3
- const require_addon_utils = require("../addon-utils-CTpQfjSR.js");
3
+ const require_addon_utils = require("../addon-utils-C-XbUkiG.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_child_process = require("node:child_process");
6
6
  let node_path = require("node:path");
@@ -390,7 +390,21 @@ async function readMfraTable(absPath, fileBytes) {
390
390
  /** How long a table stays served. Directory asks are for recent footage far
391
391
  * more often than not, and a bounded window keeps this store O(day), not
392
392
  * O(uptime): ~30 bytes a segment ⇒ a day across 20 cameras is a few MB. */
393
- var RETAIN_MS = 26 * 36e5;
393
+ var MFRA_RETAIN_MS = 26 * 36e5;
394
+ /**
395
+ * Whether a segment is recent enough for a captured table to ever be served.
396
+ *
397
+ * The gate the FINALIZE path must ask BEFORE reading the file. A live segment
398
+ * always passes; a recovered one almost never does, and capturing it is pure
399
+ * loss twice over — the table is unservable, and the read is a real `open` +
400
+ * two `read`s on the libuv threadpool. The D148 boot reconcile fired 122 400 of
401
+ * them, unawaited and unbounded, behind a 4-thread pool that the live
402
+ * SegmentWriters' own finalize I/O has to share: that is the "segment file
403
+ * never landed" (>30 s) on four cameras at once.
404
+ */
405
+ function isMfraTableServable(startMs, nowMs) {
406
+ return startMs >= nowMs - MFRA_RETAIN_MS;
407
+ }
394
408
  /**
395
409
  * The RAM store the directory reads. Keys are `(deviceId, profile, startMs)`
396
410
  * — exactly how a directory row identifies a segment. Insertion is
@@ -406,7 +420,16 @@ var MfraTableStore = class {
406
420
  key(deviceId, profile, startMs) {
407
421
  return `${deviceId}:${profile}:${startMs}`;
408
422
  }
423
+ /**
424
+ * Keep one table, unless it is already past the serve window.
425
+ *
426
+ * The out-of-window REJECT is not belt-and-braces: `prune` walks the
427
+ * insertion-ordered prefix and stops at the first live entry, so an expired
428
+ * entry appended AFTER a live one is never reachable again. A bulk recovery
429
+ * appends 122 400 of exactly that shape and the store grows without bound.
430
+ */
409
431
  record(deviceId, profile, startMs, table) {
432
+ if (!isMfraTableServable(startMs, this.now())) return;
410
433
  this.tables.set(this.key(deviceId, profile, startMs), {
411
434
  startMs,
412
435
  table
@@ -427,7 +450,7 @@ var MfraTableStore = class {
427
450
  }
428
451
  /** Insertion order is time order, so the expired prefix is contiguous. */
429
452
  prune() {
430
- const cutoff = this.now() - RETAIN_MS;
453
+ const cutoff = this.now() - MFRA_RETAIN_MS;
431
454
  for (const [key, entry] of this.tables) {
432
455
  if (entry.startMs >= cutoff) break;
433
456
  this.tables.delete(key);
@@ -626,6 +649,38 @@ var RecordingIndex = class {
626
649
  segments(deviceId, profile) {
627
650
  return [...this.sortedSegments(deviceId, profile)];
628
651
  }
652
+ /** True when `relPath` is currently indexed for `deviceId`. O(1). */
653
+ hasSegment(deviceId, relPath) {
654
+ return this.byDevice.get(deviceId)?.has(relPath) === true;
655
+ }
656
+ /**
657
+ * Indexed-segment count per HOUR-BUCKET directory (`<dev>/<profile>/Y/M/D/H`)
658
+ * for one (device, profile), in ONE pass over the device's rows.
659
+ *
660
+ * Exists because `SegmentStore.evict` has to answer "does this eviction cover
661
+ * every segment the bucket holds?" for every bucket in the round, and it used
662
+ * to answer it with `segments(deviceId, profile)` — a full COPY of the
663
+ * device-profile archive plus a full `startsWith` scan, PER BUCKET. That is
664
+ * O(buckets x archive), and D148 is what made it bite: before it, a retention
665
+ * round touched a handful of buckets at the retention edge; after it, the
666
+ * recovered backlog made ~8 900 sparse buckets across six weeks evictable in
667
+ * ONE round. Measured on that shape (1.63 M live rows + 122 400 victims):
668
+ * 182 596 ms of uninterrupted main-thread CPU, with no log line, which is the
669
+ * silent 100%-CPU phase the operator saw. One pass answers all of them.
670
+ */
671
+ bucketCounts(deviceId, profile) {
672
+ const out = /* @__PURE__ */ new Map();
673
+ const m = this.byDevice.get(deviceId);
674
+ if (!m) return out;
675
+ for (const s of m.values()) {
676
+ if (s.profile !== profile) continue;
677
+ const cut = s.path.lastIndexOf("/");
678
+ if (cut <= 0) continue;
679
+ const bucketDir = s.path.slice(0, cut);
680
+ out.set(bucketDir, (out.get(bucketDir) ?? 0) + 1);
681
+ }
682
+ return out;
683
+ }
629
684
  /** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). */
630
685
  accounting(deviceId) {
631
686
  const m = this.byDevice.get(deviceId);
@@ -1258,6 +1313,7 @@ var SegmentStore = class SegmentStore {
1258
1313
  async evict(location, rows) {
1259
1314
  let reclaimed = 0;
1260
1315
  const removed = [];
1316
+ const startedMs = this.deps.now?.() ?? Date.now();
1261
1317
  const byBucket = /* @__PURE__ */ new Map();
1262
1318
  for (const r of rows) {
1263
1319
  const cut = r.path.lastIndexOf("/");
@@ -1266,13 +1322,29 @@ var SegmentStore = class SegmentStore {
1266
1322
  if (group) group.push(r);
1267
1323
  else byBucket.set(bucketDir, [r]);
1268
1324
  }
1325
+ const census = /* @__PURE__ */ new Map();
1326
+ const countsFor = (deviceId, profile) => {
1327
+ const key = `${deviceId}:${profile}`;
1328
+ const cached = census.get(key);
1329
+ if (cached) return cached;
1330
+ const counts = this.deps.index.bucketCounts(deviceId, profile);
1331
+ census.set(key, counts);
1332
+ return counts;
1333
+ };
1334
+ const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
1335
+ const yieldBetween = this.deps.yieldBetween ?? macrotask$2;
1336
+ let bucketsRemoved = 0;
1337
+ let handled = 0;
1269
1338
  for (const [bucketDir, bucketRows] of byBucket) {
1270
- if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows)) try {
1339
+ if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows, countsFor)) try {
1271
1340
  await this.deps.removeDir(location, bucketDir);
1341
+ bucketsRemoved += 1;
1272
1342
  for (const r of bucketRows) {
1273
1343
  removed.push(r.path);
1274
1344
  reclaimed += r.bytes;
1275
1345
  }
1346
+ handled += 1;
1347
+ if (handled % chunkSize === 0) await yieldBetween();
1276
1348
  continue;
1277
1349
  } catch (err) {
1278
1350
  this.deps.logger.warn("bucket removeDir failed — falling back to per-file deletes", {
@@ -1293,15 +1365,47 @@ var SegmentStore = class SegmentStore {
1293
1365
  error: err
1294
1366
  });
1295
1367
  }
1368
+ handled += 1;
1369
+ if (handled % chunkSize === 0) await yieldBetween();
1296
1370
  }
1297
1371
  if (removed.length > 0) {
1298
1372
  this.deps.index.removeSegments(removed);
1299
- for (const r of rows) this.deps.onEvicted?.(r.deviceId, r.startMs);
1373
+ this.notifyEvicted(rows);
1300
1374
  }
1301
1375
  await this.pruneEmptyAncestors(location, [...byBucket.keys()]);
1376
+ this.deps.logger.info("recorder: eviction round complete", { meta: {
1377
+ location,
1378
+ rows: rows.length,
1379
+ buckets: byBucket.size,
1380
+ bucketsRemoved,
1381
+ removed: removed.length,
1382
+ reclaimed,
1383
+ ms: (this.deps.now?.() ?? Date.now()) - startedMs
1384
+ } });
1302
1385
  return reclaimed;
1303
1386
  }
1304
1387
  /**
1388
+ * Tell the calendar cache what went, ONCE PER (device, UTC day).
1389
+ *
1390
+ * `CalendarIndex.invalidate` drops a whole UTC day, so a per-ROW call repeats
1391
+ * the same work for every segment of that day — and its hour-list sweep is
1392
+ * O(cached hours), in one uninterrupted synchronous block with no await in
1393
+ * it. Measured over a 122 400-row round with 5 000 cached hours: 15 285 ms of
1394
+ * frozen event loop, scaling linearly with how long the session has been
1395
+ * scrubbing. Deduped it is ~190 calls and unmeasurable.
1396
+ */
1397
+ notifyEvicted(rows) {
1398
+ const notify = this.deps.onEvicted;
1399
+ if (!notify) return;
1400
+ const seen = /* @__PURE__ */ new Set();
1401
+ for (const r of rows) {
1402
+ const key = `${r.deviceId}:${Math.floor(r.startMs / SegmentStore.DAY_MS)}`;
1403
+ if (seen.has(key)) continue;
1404
+ seen.add(key);
1405
+ notify(r.deviceId, r.startMs);
1406
+ }
1407
+ }
1408
+ /**
1305
1409
  * Walk up from each emptied hour directory removing ancestors that are now
1306
1410
  * empty, so that **a directory's existence means footage**.
1307
1411
  *
@@ -1336,22 +1440,37 @@ var SegmentStore = class SegmentStore {
1336
1440
  }
1337
1441
  /** Hour of one UTC hour-bucket in ms. */
1338
1442
  static HOUR_MS = 36e5;
1443
+ static DAY_MS = 864e5;
1444
+ /** Buckets between yields. Small enough that one chunk is milliseconds of
1445
+ * work now the per-bucket archive scan is gone. */
1446
+ static DEFAULT_EVICT_CHUNK = 25;
1339
1447
  /**
1340
1448
  * True when `victims` cover EVERY indexed segment of `bucketDir` AND the
1341
1449
  * bucket's hour has fully elapsed. The current-hour guard matters because
1342
1450
  * `onFinalized` relocates new segments INTO the current bucket concurrently —
1343
1451
  * an rm racing that rename would orphan a just-indexed segment.
1452
+ *
1453
+ * The cover test is a COUNT against the round's one-pass census plus an O(1)
1454
+ * indexed-ness check per victim, never a scan of the device archive: a
1455
+ * bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names exactly one
1456
+ * (device, profile), and the victims of a bucket are unique paths. Equal
1457
+ * counts with every victim indexed therefore means the same set.
1344
1458
  */
1345
- bucketFullyEvicted(bucketDir, victims) {
1459
+ bucketFullyEvicted(bucketDir, victims, countsFor) {
1346
1460
  const first = victims[0];
1347
1461
  if (!first) return false;
1348
1462
  const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
1349
1463
  if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
1350
- const victimPaths = new Set(victims.map((v) => v.path));
1351
- const indexed = this.deps.index.segments(first.deviceId, first.profile).filter((s) => s.path.startsWith(`${bucketDir}/`));
1352
- return indexed.length === victimPaths.size && indexed.every((s) => victimPaths.has(s.path));
1464
+ const indexedInBucket = countsFor(first.deviceId, first.profile).get(bucketDir) ?? 0;
1465
+ if (indexedInBucket === 0) return false;
1466
+ const indexedVictims = /* @__PURE__ */ new Set();
1467
+ for (const v of victims) if (this.deps.index.hasSegment(v.deviceId, v.path)) indexedVictims.add(v.path);
1468
+ return indexedInBucket === indexedVictims.size;
1353
1469
  }
1354
1470
  };
1471
+ var macrotask$2 = () => new Promise((resolve) => {
1472
+ setImmediate(resolve);
1473
+ });
1355
1474
  //#endregion
1356
1475
  //#region src/recorder/still/still-source.ts
1357
1476
  /**
@@ -1710,7 +1829,7 @@ async function availabilityProfile(root, deviceId) {
1710
1829
  * NOT profiles (`events/`, plus the `strips/` trees the retired filmstrip tier
1711
1830
  * left on disk); a walker that trusts readdir would offer them as playable
1712
1831
  * profiles. */
1713
- var KNOWN_PROFILES = new Set([
1832
+ var KNOWN_PROFILES$1 = new Set([
1714
1833
  "high",
1715
1834
  "mid",
1716
1835
  "low"
@@ -1718,7 +1837,7 @@ var KNOWN_PROFILES = new Set([
1718
1837
  /** `<root>/<deviceId>/` PROFILE subdirectories. Empty when absent. */
1719
1838
  async function profilesOf(root, deviceId) {
1720
1839
  try {
1721
- return (await node_fs.promises.readdir(node_path.default.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES.has(e));
1840
+ return (await node_fs.promises.readdir(node_path.default.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES$1.has(e));
1722
1841
  } catch {
1723
1842
  return [];
1724
1843
  }
@@ -2012,7 +2131,7 @@ var CalendarIndex = class {
2012
2131
  const day = utcDayOf(atMs);
2013
2132
  this.byDevice.get(deviceId)?.delete(day);
2014
2133
  const prefix = `${deviceId}:`;
2015
- for (const key of [...this.hourSegs.keys()]) {
2134
+ for (const key of this.hourSegs.keys()) {
2016
2135
  if (!key.startsWith(prefix)) continue;
2017
2136
  if (utcDayOf(Number(key.slice(key.lastIndexOf(":") + 1))) === day) this.hourSegs.delete(key);
2018
2137
  }
@@ -4839,9 +4958,12 @@ async function resolveRecordingsLocations(api, logger) {
4839
4958
  * (no footage yet) and any per-location failure are warned + skipped
4840
4959
  * (best-effort), so one bad volume never blocks the rest of the hydrate.
4841
4960
  */
4842
- async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger) {
4961
+ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger, options = {}) {
4962
+ const yieldBetween = options.yieldBetween ?? macrotask$1;
4963
+ const chunkSize = options.chunkSize ?? HYDRATE_CHUNK;
4843
4964
  for (const [root, aliases] of aliasesByRoot(locations)) {
4844
4965
  const deviceDir = node_path.default.join(root, String(deviceId));
4966
+ const startedMs = Date.now();
4845
4967
  let entries;
4846
4968
  try {
4847
4969
  entries = await node_fs.promises.readdir(deviceDir, { recursive: true });
@@ -4855,17 +4977,48 @@ async function hydrateDeviceFromStorage(_api, index, deviceId, locations, logger
4855
4977
  });
4856
4978
  continue;
4857
4979
  }
4980
+ const walkedMs = Date.now();
4981
+ const locationForProfile = /* @__PURE__ */ new Map();
4858
4982
  const pathsByLocation = /* @__PURE__ */ new Map();
4859
- for (const entry of entries.filter((entry) => entry.endsWith(".m4s"))) {
4983
+ let handled = 0;
4984
+ for (const entry of entries) {
4985
+ if (!entry.endsWith(".m4s")) continue;
4860
4986
  const relative = `${deviceId}/${entry.split(node_path.default.sep).join("/")}`;
4861
- const location = locationForHydratedProfile(aliases, relative.split("/")[1] ?? "");
4862
- const paths = pathsByLocation.get(location.id);
4987
+ const profile = relative.split("/")[1] ?? "";
4988
+ let locationId = locationForProfile.get(profile);
4989
+ if (locationId === void 0) {
4990
+ locationId = locationForHydratedProfile(aliases, profile).id;
4991
+ locationForProfile.set(profile, locationId);
4992
+ }
4993
+ const paths = pathsByLocation.get(locationId);
4863
4994
  if (paths) paths.push(relative);
4864
- else pathsByLocation.set(location.id, [relative]);
4995
+ else pathsByLocation.set(locationId, [relative]);
4996
+ handled += 1;
4997
+ if (handled % chunkSize === 0) await yieldBetween();
4998
+ }
4999
+ let indexed = 0;
5000
+ for (const [locationId, relPaths] of pathsByLocation) {
5001
+ index.hydrateDevice(deviceId, locationId, relPaths);
5002
+ indexed += relPaths.length;
4865
5003
  }
4866
- for (const [locationId, relPaths] of pathsByLocation) index.hydrateDevice(deviceId, locationId, relPaths);
5004
+ logger.info("recorder: hydrated a device from one recordings root", {
5005
+ tags: { deviceId },
5006
+ meta: {
5007
+ root,
5008
+ entries: entries.length,
5009
+ indexed,
5010
+ locationIds: [...pathsByLocation.keys()],
5011
+ walkMs: walkedMs - startedMs,
5012
+ ms: Date.now() - startedMs
5013
+ }
5014
+ });
4867
5015
  }
4868
5016
  }
5017
+ /** Entries classified between yields in {@link hydrateDeviceFromStorage}. */
5018
+ var HYDRATE_CHUNK = 2e4;
5019
+ var macrotask$1 = () => new Promise((resolve) => {
5020
+ setImmediate(resolve);
5021
+ });
4869
5022
  /** `<deviceId>/<profile>/YYYY/MM/DD/HH` for an hour bucket (paths are UTC). */
4870
5023
  function hourDirRelPath(deviceId, profile, hourStartMs) {
4871
5024
  const d = new Date(hourStartMs);
@@ -5665,26 +5818,29 @@ var PENDING_GRACE_MS = 3e4;
5665
5818
  * segment of every recording would be classified as a previous run's leftover
5666
5819
  * and dropped.
5667
5820
  */
5668
- var PREVIOUS_RUN_SKEW_MS = 1e3;
5821
+ var PREVIOUS_RUN_SKEW_MS$1 = 1e3;
5669
5822
  /**
5670
5823
  * Decide whether a missing flat file is a late flush or a previous run's entry.
5671
5824
  *
5672
- * ffmpeg's `-segment_list` file is not cleared between runs and a fresh watcher
5673
- * starts at `processed = 0`, so the first passes after every attach replay the
5674
- * PREVIOUS run's playlist. Those entries were relocated into the bucket tree
5675
- * long ago; their absence is the system working. Over 2026-08-10..13 they were
5676
- * 83% of all retries (median age 1 615 s, max 17.4 h) and produced 68 of the
5677
- * 122 `never landed` warnings a warning that reads like data loss for a file
5678
- * that was correctly filed hours earlier.
5825
+ * ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
5826
+ * instantly: a fresh watcher starts at `processed = 0` and, in the window
5827
+ * between spawn and that truncation, reads the PREVIOUS run's playlist body.
5828
+ * Those entries were relocated into the bucket tree long ago; their absence is
5829
+ * the system working. Over 2026-08-10..13 they were 83% of all retries (median
5830
+ * age 1 615 s, max 17.4 h) and produced 68 of the 122 `never landed` warnings —
5831
+ * a warning that reads like data loss for a file that was correctly filed hours
5832
+ * earlier.
5679
5833
  *
5680
- * Note what this deliberately does NOT do: it does not skip a previous run's
5681
- * entry whose file is STILL THERE. That case is real crash recovery — an
5682
- * unclean stop leaves un-relocated segments in `.rec-tmp` and the playlist is
5683
- * the only record of their durations so the caller stats first and only asks
5684
- * this question once the file is known to be absent.
5834
+ * What this is NOT is crash recovery, and an earlier version of this comment
5835
+ * claimed it was. Once ffmpeg truncates, the previous run's un-relocated
5836
+ * segments are named by NOTHING, so no amount of care in this function can
5837
+ * reach them on 2026-08-14 that had stranded 179 GB across the fleet. They
5838
+ * are recovered from the DIRECTORY instead, by `staging-reconcile.ts`, which
5839
+ * the addon runs once at boot. This function only has to be right about the
5840
+ * entries a playlist does name.
5685
5841
  */
5686
5842
  function classifyMissingEntry(input) {
5687
- return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS ? "stale-previous-run" : "pending";
5843
+ return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS$1 ? "stale-previous-run" : "pending";
5688
5844
  }
5689
5845
  /** Whether a pending entry has outlived its grace. Pure — the caller clocks it. */
5690
5846
  function shouldGiveUpOnPending(input) {
@@ -6946,20 +7102,30 @@ var RetentionSweeper = class {
6946
7102
  this.deps.logger.warn("recorder: retention sweep could not read device configs", { meta: { error: require_dist.errMsg(err) } });
6947
7103
  return;
6948
7104
  }
7105
+ const withPolicy = [...configs].filter(([, config]) => hasRetentionPolicy(config));
7106
+ const devicesSkipped = configs.size - withPolicy.length;
7107
+ this.deps.logger.info("recorder: retention sweep starting", { meta: {
7108
+ devices: withPolicy.map(([deviceId]) => deviceId),
7109
+ skipped: devicesSkipped
7110
+ } });
6949
7111
  let devicesSwept = 0;
6950
- let devicesSkipped = 0;
6951
7112
  let bucketsPruned = 0;
6952
7113
  let bytesReclaimed = 0;
6953
- for (const [deviceId, config] of configs) {
6954
- if (!hasRetentionPolicy(config)) {
6955
- devicesSkipped += 1;
6956
- continue;
6957
- }
7114
+ for (const [deviceId] of withPolicy) {
7115
+ const startedMs = Date.now();
6958
7116
  try {
6959
7117
  const result = await this.deps.prune(deviceId);
6960
7118
  devicesSwept += 1;
6961
7119
  bucketsPruned += result.deletedBuckets;
6962
7120
  bytesReclaimed += result.reclaimedBytes;
7121
+ this.deps.logger.info("recorder: retention prune complete for device", {
7122
+ tags: { deviceId },
7123
+ meta: {
7124
+ deletedBuckets: result.deletedBuckets,
7125
+ reclaimedBytes: result.reclaimedBytes,
7126
+ ms: Date.now() - startedMs
7127
+ }
7128
+ });
6963
7129
  } catch (err) {
6964
7130
  this.deps.logger.warn("recorder: retention prune failed for device", {
6965
7131
  tags: { deviceId },
@@ -6979,6 +7145,402 @@ var RetentionSweeper = class {
6979
7145
  }
6980
7146
  };
6981
7147
  //#endregion
7148
+ //#region src/recorder/loop-pacer.ts
7149
+ var DEFAULT_BUSY_LAG_MS = 25;
7150
+ var DEFAULT_BACKOFF_STEP_MS = 25;
7151
+ var DEFAULT_MAX_BACKOFF_MS = 250;
7152
+ var LoopPacer = class {
7153
+ deps;
7154
+ consecutive = 0;
7155
+ hops = 0;
7156
+ contendedHops = 0;
7157
+ pausedMs = 0;
7158
+ maxLagMs = 0;
7159
+ constructor(deps) {
7160
+ this.deps = deps;
7161
+ }
7162
+ /**
7163
+ * One paced yield. Always hands the loop back at least once; sleeps longer
7164
+ * while the previous hop shows the loop is contended, and forgets the
7165
+ * back-off as soon as one hop comes back clean.
7166
+ */
7167
+ async hop() {
7168
+ const busyLagMs = this.deps.busyLagMs ?? DEFAULT_BUSY_LAG_MS;
7169
+ const stepMs = this.deps.backoffStepMs ?? DEFAULT_BACKOFF_STEP_MS;
7170
+ const maxMs = this.deps.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
7171
+ const before = this.deps.now();
7172
+ await this.deps.sleep(0);
7173
+ const lagMs = this.deps.now() - before;
7174
+ this.hops += 1;
7175
+ if (lagMs > this.maxLagMs) this.maxLagMs = lagMs;
7176
+ if (lagMs < busyLagMs) {
7177
+ this.consecutive = 0;
7178
+ return;
7179
+ }
7180
+ this.consecutive += 1;
7181
+ this.contendedHops += 1;
7182
+ const pauseMs = Math.min(stepMs * this.consecutive, maxMs);
7183
+ this.pausedMs += pauseMs;
7184
+ await this.deps.sleep(pauseMs);
7185
+ }
7186
+ stats() {
7187
+ return {
7188
+ hops: this.hops,
7189
+ contendedHops: this.contendedHops,
7190
+ pausedMs: this.pausedMs,
7191
+ maxLagMs: this.maxLagMs
7192
+ };
7193
+ }
7194
+ };
7195
+ /** The production pacer: real clock, real timers. */
7196
+ function nodeLoopPacer() {
7197
+ return new LoopPacer({
7198
+ now: () => Date.now(),
7199
+ sleep: (ms) => new Promise((resolve) => {
7200
+ setTimeout(resolve, ms);
7201
+ })
7202
+ });
7203
+ }
7204
+ //#endregion
7205
+ //#region src/recorder/addon/staging-reconcile.ts
7206
+ /**
7207
+ * Recover `.rec-tmp` segments that a writer restart orphaned.
7208
+ *
7209
+ * WHY THIS EXISTS
7210
+ * ───────────────
7211
+ * The segment watcher's ONLY record of what it still owes is ffmpeg's
7212
+ * `-segment_list live.m3u8`. ffmpeg opens that file in TRUNCATE mode on every
7213
+ * spawn, so a writer respawn destroys it. Every segment written but not yet
7214
+ * relocated at that instant becomes permanently unreachable:
7215
+ *
7216
+ * - no playlist names it, so no watcher will ever finalize it;
7217
+ * - `parseSegmentPath` cannot see it — staging is flat and dot-prefixed, kept
7218
+ * deliberately out of the hydrate walk — so retention never counts it;
7219
+ * - the one `.rec-tmp` sweep that exists (`sweepUnassignedStaging`) touches
7220
+ * only locations with ZERO placement assignments, never the live one.
7221
+ *
7222
+ * So the footage is not deleted and not kept: it is stranded, and it accrues
7223
+ * for as long as the install runs.
7224
+ *
7225
+ * `segment-watcher.ts` carries a comment asserting the opposite — that the
7226
+ * playlist "is NOT cleared between runs", which is what its `stale-previous-run`
7227
+ * classification and its documented "that is the crash recovery" path are built
7228
+ * on. The live filesystem refutes it, and a leftover that describes the right
7229
+ * design reads as verification: on 2026-08-14 `/recordings/.rec-tmp/590/high/`
7230
+ * held a `live.m3u8` of 50 entries, every one named at or after that writer's
7231
+ * 20:28:31 start, beside **5 863 older `.m4s` files referenced by nobody** —
7232
+ * the oldest from 2026-07-04. Fleet total: 179 GB, on an array at 90% full.
7233
+ *
7234
+ * WHAT THIS DOES NOT DO
7235
+ * ─────────────────────
7236
+ * It never deletes. Recovery is the same `SegmentStore.onFinalized` the live
7237
+ * path uses — stat, relocate into the UTC bucket, index — so a recovered
7238
+ * segment is indistinguishable from one that was never orphaned, and a segment
7239
+ * it cannot place stays exactly where it is for the next attempt.
7240
+ */
7241
+ /**
7242
+ * How far before the writer's own start a segment may be named and still count
7243
+ * as belonging to THIS run.
7244
+ *
7245
+ * Mirrors `segment-watcher.PREVIOUS_RUN_SKEW_MS`, and for the same reason:
7246
+ * ffmpeg names segments with whole-second `%s`, so the first segment of a run
7247
+ * carries the second already in progress when the process spawned. Without the
7248
+ * tolerance a recovery pass would reach for the live writer's opening segment —
7249
+ * the one file in the directory that is still being written.
7250
+ */
7251
+ var PREVIOUS_RUN_SKEW_MS = 1e3;
7252
+ /**
7253
+ * How much longer than nominal an inter-segment gap may be and still be read as
7254
+ * one segment's duration.
7255
+ *
7256
+ * ffmpeg cuts on keyframes, so a real segment overshoots `-segment_time` by up
7257
+ * to a GOP; a gap of twice nominal is still plausibly one segment. Anything
7258
+ * wider is a RUN BOUNDARY — the writer was down in between — and the honest
7259
+ * duration is the nominal one. Trusting the gap there would paint a solid
7260
+ * timeline bar across hours of footage that does not exist, which is a worse
7261
+ * failure than under-reporting by a few seconds.
7262
+ */
7263
+ var MAX_GAP_FACTOR = 2;
7264
+ /**
7265
+ * Decide which staged files belong to a previous run, and how long each ran.
7266
+ *
7267
+ * Pure — the caller does the I/O. The gate is the file's own name against the
7268
+ * writer's start: a segment named before this ffmpeg spawned cannot be one this
7269
+ * ffmpeg is writing, whatever the playlist says.
7270
+ */
7271
+ function planStagingRecovery(input) {
7272
+ const nominalMs = input.segmentSeconds * 1e3;
7273
+ const cutoffMs = input.writerStartedMs - PREVIOUS_RUN_SKEW_MS;
7274
+ const owned = input.playlistNames;
7275
+ const candidates = [];
7276
+ for (const name of input.names) {
7277
+ const startMs = parseEpochStartMs(name);
7278
+ if (startMs === null) continue;
7279
+ if (startMs >= cutoffMs) continue;
7280
+ if (owned?.has(name) === true) continue;
7281
+ candidates.push({
7282
+ name,
7283
+ startMs
7284
+ });
7285
+ }
7286
+ candidates.sort((a, b) => a.startMs - b.startMs);
7287
+ const maxGapMs = nominalMs * MAX_GAP_FACTOR;
7288
+ return candidates.map((c, i) => {
7289
+ const next = candidates[i + 1];
7290
+ const gapMs = next === void 0 ? 0 : next.startMs - c.startMs;
7291
+ const durMs = gapMs > 0 && gapMs <= maxGapMs ? gapMs : nominalMs;
7292
+ return {
7293
+ name: c.name,
7294
+ startMs: c.startMs,
7295
+ durMs
7296
+ };
7297
+ });
7298
+ }
7299
+ /** The real filesystem. The only production value of {@link StagingIo}. */
7300
+ var nodeStagingIo = {
7301
+ listDir: (dir) => node_fs.promises.readdir(dir),
7302
+ readPlaylist: (dir) => node_fs.promises.readFile(node_path.default.join(dir, "live.m3u8"), "utf8").catch(() => null)
7303
+ };
7304
+ /** The only `<deviceId>/` subdirectories of staging that hold segments. */
7305
+ var KNOWN_PROFILES = new Set([
7306
+ "high",
7307
+ "mid",
7308
+ "low"
7309
+ ]);
7310
+ /** Default orphans per chunk. 590/high alone had 5 863 of them. */
7311
+ var DEFAULT_CHUNK_SIZE = 100;
7312
+ /**
7313
+ * Orphans between progress lines.
7314
+ *
7315
+ * A directory of 5 863 files takes minutes over shfs and used to report only
7316
+ * when it finished, so "is it still going, and how far in" had no answer at
7317
+ * all — the exact shape of the blackout this pass was written to end.
7318
+ */
7319
+ var PROGRESS_EVERY = 1e3;
7320
+ var macrotask = () => new Promise((resolve) => {
7321
+ setTimeout(resolve, 0);
7322
+ });
7323
+ /**
7324
+ * Relocate every previous-run segment left in one staging dir.
7325
+ *
7326
+ * Chunked with a yield between batches: this runs alongside a live recorder and
7327
+ * a backlog is measured in thousands of files over shfs/FUSE — a single
7328
+ * uninterrupted loop is exactly the event-loop stall the recorder has already
7329
+ * paid for once (D128).
7330
+ *
7331
+ * A failure is per-segment and never aborts the pass: the file stays in staging
7332
+ * and the next attach retries it. Every drop is logged with the segment name —
7333
+ * silence reads as "never happened", and this whole class of loss went
7334
+ * unnoticed for six weeks precisely because nothing said anything.
7335
+ */
7336
+ async function recoverStagingOrphans(dir, input, deps) {
7337
+ let names;
7338
+ try {
7339
+ names = await deps.listDir(dir);
7340
+ } catch (err) {
7341
+ deps.logger.warn("recorder: staging recovery could not list the staging dir", { meta: {
7342
+ dir,
7343
+ error: err instanceof Error ? err.message : String(err)
7344
+ } });
7345
+ return {
7346
+ found: 0,
7347
+ recovered: 0,
7348
+ failed: 0,
7349
+ aborted: false
7350
+ };
7351
+ }
7352
+ let playlistNames;
7353
+ const body = await deps.readPlaylist(dir).catch(() => null);
7354
+ if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
7355
+ const plan = planStagingRecovery({
7356
+ ...input,
7357
+ names,
7358
+ playlistNames
7359
+ });
7360
+ if (plan.length === 0) return {
7361
+ found: 0,
7362
+ recovered: 0,
7363
+ failed: 0,
7364
+ aborted: false
7365
+ };
7366
+ deps.logger.info("recorder: staging recovery starting on a directory", { meta: {
7367
+ dir,
7368
+ found: plan.length,
7369
+ staged: names.length,
7370
+ oldestMs: plan[0]?.startMs,
7371
+ newestMs: plan[plan.length - 1]?.startMs
7372
+ } });
7373
+ const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
7374
+ const yieldBetween = deps.yieldBetween ?? macrotask;
7375
+ let recovered = 0;
7376
+ let failed = 0;
7377
+ let aborted = false;
7378
+ for (let i = 0; i < plan.length; i++) {
7379
+ const orphan = plan[i];
7380
+ if (deps.shouldStop?.() === true) {
7381
+ aborted = true;
7382
+ deps.logger.warn("recorder: staging recovery stopped short by shutdown", { meta: {
7383
+ dir,
7384
+ recovered,
7385
+ failed,
7386
+ remaining: plan.length - i
7387
+ } });
7388
+ break;
7389
+ }
7390
+ try {
7391
+ await deps.finalize(orphan, `${dir}/${orphan.name}`);
7392
+ recovered += 1;
7393
+ } catch (err) {
7394
+ failed += 1;
7395
+ deps.logger.warn("recorder: staging recovery could not relocate an orphaned segment", { meta: {
7396
+ dir,
7397
+ seg: orphan.name,
7398
+ startMs: orphan.startMs,
7399
+ durMs: orphan.durMs,
7400
+ error: err instanceof Error ? err.message : String(err)
7401
+ } });
7402
+ }
7403
+ if ((i + 1) % PROGRESS_EVERY === 0 && i + 1 < plan.length) deps.logger.info("recorder: staging recovery progress", { meta: {
7404
+ dir,
7405
+ done: i + 1,
7406
+ found: plan.length,
7407
+ recovered,
7408
+ failed
7409
+ } });
7410
+ if ((i + 1) % chunkSize === 0 && i + 1 < plan.length) await yieldBetween();
7411
+ }
7412
+ deps.logger.info("recorder: recovered segments a writer restart had orphaned in staging", { meta: {
7413
+ dir,
7414
+ found: plan.length,
7415
+ recovered,
7416
+ failed,
7417
+ aborted,
7418
+ oldestMs: plan[0]?.startMs,
7419
+ newestMs: plan[plan.length - 1]?.startMs
7420
+ } });
7421
+ return {
7422
+ found: plan.length,
7423
+ recovered,
7424
+ failed,
7425
+ aborted
7426
+ };
7427
+ }
7428
+ /**
7429
+ * Boot reconcile: relocate every previous-run segment stranded in staging, on
7430
+ * every location, for every camera — attached or not.
7431
+ *
7432
+ * WHY BOOT, AND NOT ATTACH
7433
+ * ────────────────────────
7434
+ * Orphans are created by a writer restart, so by construction they all predate
7435
+ * the runner that finds them; boot is when the whole backlog is visible at
7436
+ * once. Attach looked like the natural hook and is the wrong one twice over: it
7437
+ * only ever reaches cameras that are currently recording (device 1438 had a
7438
+ * staging backlog while attached to nothing), and it puts unbounded filesystem
7439
+ * work on the path that must start recording NOW — measured, it kept microtasks
7440
+ * in flight across `RecordingController.stop()` and made the runner's 5 s
7441
+ * SIGTERM-grace test fail under load.
7442
+ *
7443
+ * `bootMs` is the cutoff, and it is what makes this safe to run while writers
7444
+ * are spawning: a live writer's segments are all named after boot, so the plan
7445
+ * cannot select one. Nothing is deleted, and a segment that cannot be placed is
7446
+ * left exactly where it is for the next boot.
7447
+ */
7448
+ async function recoverAllStagedOrphans(deps) {
7449
+ let dirs = 0;
7450
+ let found = 0;
7451
+ let recovered = 0;
7452
+ let failed = 0;
7453
+ let aborted = false;
7454
+ const startedMs = Date.now();
7455
+ const injectedYield = deps.yieldBetween;
7456
+ const pacer = injectedYield === void 0 ? nodeLoopPacer() : null;
7457
+ const yieldBetween = injectedYield ?? (() => pacer === null ? macrotask() : pacer.hop());
7458
+ const locations = deps.locations();
7459
+ deps.logger.info("recorder: staging boot reconcile starting", { meta: {
7460
+ locations: locations.map((l) => l.id),
7461
+ bootMs: deps.bootMs
7462
+ } });
7463
+ for (const location of locations) {
7464
+ const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
7465
+ let deviceDirs;
7466
+ try {
7467
+ deviceDirs = await deps.listDir(stagingRoot);
7468
+ } catch {
7469
+ continue;
7470
+ }
7471
+ for (const deviceDir of deviceDirs) {
7472
+ if (aborted) break;
7473
+ const deviceId = Number(deviceDir);
7474
+ if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
7475
+ let profileDirs;
7476
+ try {
7477
+ profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
7478
+ } catch {
7479
+ continue;
7480
+ }
7481
+ const segmentSeconds = await deps.segmentSecondsFor(deviceId);
7482
+ for (const profile of profileDirs) {
7483
+ if (aborted) break;
7484
+ if (!KNOWN_PROFILES.has(profile)) continue;
7485
+ const dir = `${stagingRoot}/${deviceDir}/${profile}`;
7486
+ dirs += 1;
7487
+ const report = await recoverStagingOrphans(dir, {
7488
+ writerStartedMs: deps.bootMs,
7489
+ segmentSeconds
7490
+ }, {
7491
+ listDir: deps.listDir,
7492
+ readPlaylist: deps.readPlaylist,
7493
+ finalize: (orphan, flatAbsPath) => deps.finalize({
7494
+ deviceId,
7495
+ profile,
7496
+ locationId: location.id,
7497
+ locationRoot: location.root,
7498
+ flatAbsPath,
7499
+ startMs: orphan.startMs,
7500
+ durMs: orphan.durMs
7501
+ }),
7502
+ logger: withDeviceTag(deps.logger, deviceId),
7503
+ chunkSize: deps.chunkSize,
7504
+ yieldBetween,
7505
+ shouldStop: deps.shouldStop
7506
+ });
7507
+ found += report.found;
7508
+ recovered += report.recovered;
7509
+ failed += report.failed;
7510
+ if (report.aborted) aborted = true;
7511
+ }
7512
+ }
7513
+ }
7514
+ deps.logger.info("recorder: staging boot reconcile complete", { meta: {
7515
+ dirs,
7516
+ found,
7517
+ recovered,
7518
+ failed,
7519
+ aborted,
7520
+ ms: Date.now() - startedMs,
7521
+ ...pacer === null ? {} : { pacing: pacer.stats() }
7522
+ } });
7523
+ return {
7524
+ dirs,
7525
+ found,
7526
+ recovered,
7527
+ failed,
7528
+ aborted
7529
+ };
7530
+ }
7531
+ /** Bind `tags: { deviceId }` onto every line a per-dir pass writes. */
7532
+ function withDeviceTag(logger, deviceId) {
7533
+ const tag = (extras) => ({
7534
+ ...typeof extras === "object" && extras !== null ? extras : {},
7535
+ tags: { deviceId }
7536
+ });
7537
+ return {
7538
+ info: (message, extras) => logger.info(message, tag(extras)),
7539
+ warn: (message, extras) => logger.warn(message, tag(extras)),
7540
+ debug: (message, extras) => logger.debug(message, tag(extras))
7541
+ };
7542
+ }
7543
+ //#endregion
6982
7544
  //#region src/recorder/addon/index.ts
6983
7545
  /**
6984
7546
  * recorder addon SHELL — recording + storage-evictable providers on the
@@ -7166,6 +7728,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7166
7728
  this.calendar.invalidate(deviceId, atMs);
7167
7729
  },
7168
7730
  onIndexed: (row, absPath) => {
7731
+ if (!isMfraTableServable(row.startMs, Date.now())) return;
7169
7732
  readMfraTable(absPath, row.bytes).then((table) => {
7170
7733
  this.mfraTables.record(row.deviceId, row.profile, row.startMs, table);
7171
7734
  }).catch((err) => {
@@ -7183,13 +7746,18 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7183
7746
  if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
7184
7747
  await node_fs.promises.rmdir(node_path.default.join(loc.root, relDir));
7185
7748
  },
7186
- logger: { warn: (message, extras) => {
7187
- if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
7188
- tags: extras.tags,
7189
- meta: { error: extras.error }
7190
- });
7191
- else this.ctx.logger.warn(message, { meta: { extras } });
7192
- } }
7749
+ logger: {
7750
+ warn: (message, extras) => {
7751
+ if (isSegmentStoreLogExtras(extras)) this.ctx.logger.warn(message, {
7752
+ tags: extras.tags,
7753
+ meta: { error: extras.error }
7754
+ });
7755
+ else this.ctx.logger.warn(message, { meta: { extras } });
7756
+ },
7757
+ info: (message, extras) => {
7758
+ this.ctx.logger.info(message, { meta: extras.meta });
7759
+ }
7760
+ }
7193
7761
  };
7194
7762
  this.segmentStore = new SegmentStore(segmentStoreDeps);
7195
7763
  const brokerHandle = this.ctx.useCapability("stream-broker", brokerScope(ingestOwner.ownerNodeId));
@@ -7516,11 +8084,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7516
8084
  force: true
7517
8085
  }).catch(() => {});
7518
8086
  await this.sweepUnassignedStaging();
8087
+ const stagingBootMs = Date.now();
7519
8088
  try {
7520
8089
  await this.controller?.start();
7521
8090
  } catch (err) {
7522
8091
  this.ctx.logger.warn("recorder: controller start failed", { meta: { error: require_dist.errMsg(err) } });
7523
8092
  }
8093
+ this.recoverStagedOrphans(stagingBootMs);
7524
8094
  if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
7525
8095
  eventBus: this.ctx.eventBus,
7526
8096
  map: this.eventMap,
@@ -7825,6 +8395,45 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
7825
8395
  }
7826
8396
  }
7827
8397
  /**
8398
+ * Boot reconcile for `.rec-tmp`: relocate every segment a previous runner's
8399
+ * writers left stranded, on every location, for every camera.
8400
+ *
8401
+ * The counterpart to {@link sweepUnassignedStaging} above, which only ever
8402
+ * looks at locations nothing is assigned to — so the LIVE root, the one that
8403
+ * actually accumulates, had no pass at all (D148). Nothing here deletes: each
8404
+ * orphan goes through the same `SegmentStore.onFinalized` a live segment
8405
+ * does, and one it cannot place is left on disk for the next boot.
8406
+ */
8407
+ async recoverStagedOrphans(bootMs) {
8408
+ try {
8409
+ await recoverAllStagedOrphans({
8410
+ locations: () => this.resolvedLocations.map((l) => ({
8411
+ id: l.id,
8412
+ root: l.root
8413
+ })),
8414
+ listDir: nodeStagingIo.listDir,
8415
+ readPlaylist: nodeStagingIo.readPlaylist,
8416
+ finalize: async (input) => {
8417
+ const store = this.segmentStore;
8418
+ if (store === null) throw new Error("recorder is shutting down");
8419
+ await store.onFinalized(input);
8420
+ },
8421
+ logger: this.ctx.logger,
8422
+ bootMs,
8423
+ segmentSecondsFor: async (deviceId) => {
8424
+ try {
8425
+ return (await loadDeviceConfig(this.configStore(), deviceId)).segmentSeconds ?? this.config.segmentSeconds;
8426
+ } catch {
8427
+ return this.config.segmentSeconds;
8428
+ }
8429
+ },
8430
+ shouldStop: () => this.segmentStore === null
8431
+ });
8432
+ } catch (err) {
8433
+ this.ctx.logger.warn("recorder: staging boot reconcile failed", { meta: { error: require_dist.errMsg(err) } });
8434
+ }
8435
+ }
8436
+ /**
7828
8437
  * Surface a storage-placement fault on the liveness/alert surface. STRICTLY
7829
8438
  * best-effort: the alerts cap is a nice-to-have and an unmounted disk must
7830
8439
  * still be shouted about in the log even when it is unreachable.