@camstack/addon-pipeline 1.2.153 → 1.2.155

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 (38) 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 +5 -5
  4. package/dist/detection-pipeline/index.mjs +4 -4
  5. package/dist/{dist-Dsritt4-.mjs → dist-CBRky_dz.mjs} +304 -4
  6. package/dist/{dist-DnVnWACi.js → dist-CmrtJ8RB.js} +304 -4
  7. package/dist/{lazy-sharp-DZWYKdV2.js → lazy-sharp-BODx7w1t.js} +1 -1
  8. package/dist/{local-frame-registry-BpFLRfbw.js → local-frame-registry-BRG0GTmK.js} +1 -1
  9. package/dist/{local-frame-registry-Yf-uIxbS.mjs → local-frame-registry-ByKcWWPU.mjs} +1 -1
  10. package/dist/motion-wasm/index.js +2 -2
  11. package/dist/motion-wasm/index.mjs +1 -1
  12. package/dist/{node-C5xmFvjS.js → node-BSeMUwxJ.js} +1 -1
  13. package/dist/{node-CNGxkfes.mjs → node-CQzPpwIR.mjs} +1 -1
  14. package/dist/pipeline-runner/index.js +5 -5
  15. package/dist/pipeline-runner/index.mjs +4 -4
  16. package/dist/{process-memory-DX_KCfdx.mjs → process-memory-DQX-DbHx.mjs} +1 -1
  17. package/dist/{process-memory-COT8rA8L.js → process-memory-DVmHBraW.js} +1 -1
  18. package/dist/recorder/index.js +667 -100
  19. package/dist/recorder/index.mjs +667 -100
  20. package/dist/{segment-demux-js-CTFSl-b9.mjs → segment-demux-js-DGX1iGLL.mjs} +1 -1
  21. package/dist/{segment-demux-js-Dwg9Jq5_.js → segment-demux-js-DmqnujVx.js} +1 -1
  22. package/dist/session-decode/decode-worker-child.js +2 -2
  23. package/dist/session-decode/decode-worker-child.mjs +1 -1
  24. package/dist/stream-broker/_stub.js +2 -2
  25. package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-ZucA907q.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DvfM0ofY.mjs} +3 -3
  26. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-lMPlG5w5.mjs +26 -0
  27. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CSNuIp4J.mjs +26 -0
  28. package/dist/stream-broker/demux-worker-child.js +1 -1
  29. package/dist/stream-broker/demux-worker-child.mjs +1 -1
  30. package/dist/stream-broker/{hostInit-BqUcM0Fh.mjs → hostInit-DQGeMfoN.mjs} +3 -3
  31. package/dist/stream-broker/index.js +3 -3
  32. package/dist/stream-broker/index.mjs +3 -3
  33. package/dist/stream-broker/remoteEntry.js +1 -1
  34. package/dist/{worker-protocol-CDBQXOIu.mjs → worker-protocol-C7jOjCsc.mjs} +1 -1
  35. package/dist/{worker-protocol-BsvaPb-G.js → worker-protocol-S2nAdf6B.js} +1 -1
  36. package/package.json +1 -1
  37. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B14Zc1oq.mjs +0 -26
  38. package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DO6LkN6o.mjs +0 -26
@@ -1,5 +1,5 @@
1
- const require_dist = require("../dist-DnVnWACi.js");
2
- const require_node = require("../node-C5xmFvjS.js");
1
+ const require_dist = require("../dist-CmrtJ8RB.js");
2
+ const require_node = require("../node-BSeMUwxJ.js");
3
3
  const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
4
4
  const require_restream_intent = require("../restream-intent-Cv9x3jmu.js");
5
5
  const require_event_loop_stall_monitor = require("../event-loop-stall-monitor-DaUBcb13.js");
@@ -608,7 +608,7 @@ function qualifyObjectTrigger(data) {
608
608
  * la riga arrivi entro il primo minuto di un deploy rotto, abbastanza lungo
609
609
  * perché a 4 eventi/s il campione sia significativo. */
610
610
  var OBJECT_TRIGGER_HEALTH_WINDOW_MS = 6e4;
611
- function emptyTally$1() {
611
+ function emptyTally$2() {
612
612
  return {
613
613
  objectEvents: 0,
614
614
  qualified: 0,
@@ -672,7 +672,7 @@ var ObjectTriggerHealth = class {
672
672
  bucket(key) {
673
673
  const existing = this.buckets.get(key);
674
674
  if (existing !== void 0) return existing;
675
- const fresh = emptyTally$1();
675
+ const fresh = emptyTally$2();
676
676
  this.buckets.set(key, fresh);
677
677
  return fresh;
678
678
  }
@@ -822,14 +822,14 @@ function parseSegmentPath(relPath) {
822
822
  //#region src/recorder/recording-index.ts
823
823
  /** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
824
824
  * already gives us — so "have I walked this?" costs one Set lookup. */
825
- var HOUR_MS$7 = 36e5;
825
+ var HOUR_MS$8 = 36e5;
826
826
  /** A device walked end-to-end. Cheaper and clearer than inserting every hour of
827
827
  * the retention window into the set. */
828
828
  var ALL_HOURS = Symbol("all-hours");
829
829
  /** The hour buckets a half-open span touches. */
830
830
  function* hourBuckets(fromMs, toMs) {
831
- const first = Math.floor(fromMs / HOUR_MS$7) * HOUR_MS$7;
832
- for (let h = first; h < toMs; h += HOUR_MS$7) yield h;
831
+ const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
832
+ for (let h = first; h < toMs; h += HOUR_MS$8) yield h;
833
833
  }
834
834
  /**
835
835
  * Index of the first element whose `startMs` is GREATER than `startMs` (upper
@@ -1123,7 +1123,7 @@ var RecordingIndex = class {
1123
1123
  changed = true;
1124
1124
  }
1125
1125
  if (changed) this.invalidateSorted(deviceId);
1126
- this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$7);
1126
+ this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$8);
1127
1127
  }
1128
1128
  /**
1129
1129
  * Insert/replace one finalized segment (idempotent by path).
@@ -1241,35 +1241,6 @@ var RecordingIndex = class {
1241
1241
  hasSegment(deviceId, relPath) {
1242
1242
  return this.byDevice.get(deviceId)?.has(relPath) === true;
1243
1243
  }
1244
- /**
1245
- * Indexed-segment count per HOUR-BUCKET directory (`<dev>/<profile>/Y/M/D/H`)
1246
- * for one (device, profile), in ONE pass over the device's rows.
1247
- *
1248
- * Exists because `SegmentStore.evict` has to answer "does this eviction cover
1249
- * every segment the bucket holds?" for every bucket in the round, and it used
1250
- * to answer it with `segments(deviceId, profile)` — a full COPY of the
1251
- * device-profile archive plus a full `startsWith` scan, PER BUCKET. That is
1252
- * O(buckets x archive), and D148 is what made it bite: before it, a retention
1253
- * round touched a handful of buckets at the retention edge; after it, the
1254
- * recovered backlog made ~8 900 sparse buckets across six weeks evictable in
1255
- * ONE round. Measured on that shape (1.63 M live rows + 122 400 victims):
1256
- * 182 596 ms of uninterrupted main-thread CPU, with no log line, which is the
1257
- * silent 100%-CPU phase the operator saw. One pass answers all of them.
1258
- * @residency archive-shaped
1259
- */
1260
- bucketCounts(deviceId, profile) {
1261
- const out = /* @__PURE__ */ new Map();
1262
- const m = this.byDevice.get(deviceId);
1263
- if (!m) return out;
1264
- for (const s of m.values()) {
1265
- if (s.profile !== profile) continue;
1266
- const cut = s.path.lastIndexOf("/");
1267
- if (cut <= 0) continue;
1268
- const bucketDir = s.path.slice(0, cut);
1269
- out.set(bucketDir, (out.get(bucketDir) ?? 0) + 1);
1270
- }
1271
- return out;
1272
- }
1273
1244
  /** Aggregate accounting for a device across ALL its locations (use `accountingForLocation` for one location). @residency archive-shaped */
1274
1245
  accounting(deviceId) {
1275
1246
  const m = this.byDevice.get(deviceId);
@@ -1601,7 +1572,7 @@ function resolveEvictionDomain(locations, locationId, realpath = node_fs.realpat
1601
1572
  */
1602
1573
  var DEFAULT_THROTTLE_MBPS = 40;
1603
1574
  /** The durable index's bucket, and therefore this mover's batch. */
1604
- var HOUR_MS$6 = 36e5;
1575
+ var HOUR_MS$7 = 36e5;
1605
1576
  /** Progress callback cadence (files) — cheap enough to fire often. */
1606
1577
  var PROGRESS_EVERY_FILES = 25;
1607
1578
  /** Free space a destination must keep when it declares no `config.minFreePercent`
@@ -1610,6 +1581,25 @@ var DEFAULT_MIN_FREE_PERCENT$1 = 10;
1610
1581
  /** Prefix every destination-full error carries, so the coordinator can report
1611
1582
  * "drain stalled: destination full" instead of a raw ENOSPC string. */
1612
1583
  var DESTINATION_FULL_PREFIX = "destination full";
1584
+ /**
1585
+ * Ghost rows one job may FORGET, as a share of its own census — and the floor
1586
+ * below which the share is not the bound.
1587
+ *
1588
+ * D148 is why this is bounded at all: the ledger forgets only on a trusted walk
1589
+ * or an eviction, so that a transient read error cannot erase an operator's
1590
+ * index of real footage. A per-path ENOENT on a path derived from the row is
1591
+ * strong evidence about THAT path (D296) and weak evidence about a mount: a
1592
+ * root that vanished answers ENOENT for every path it is asked about, and looks
1593
+ * exactly like a location that is genuinely empty. The bound is what makes the
1594
+ * difference survivable — past it the mover keeps moving, keeps skipping, and
1595
+ * REPORTS, so an operator gets a reconcile to act on instead of an archive that
1596
+ * quietly emptied itself.
1597
+ *
1598
+ * 17.5 % of `recordingsLow:default` was ghosts on 2026-08-30 (11 074 of
1599
+ * 63 346), so a quarter admits a real reconcile of that size in one pass while
1600
+ * refusing a whole-location erase.
1601
+ */
1602
+ var GHOST_FORGET_SHARE = .25;
1613
1603
  function snapshot(j) {
1614
1604
  return {
1615
1605
  jobId: j.jobId,
@@ -1622,6 +1612,7 @@ function snapshot(j) {
1622
1612
  bytesMoved: j.bytesMoved,
1623
1613
  filesTotal: j.filesTotal,
1624
1614
  rowsReconciled: j.rowsReconciled,
1615
+ rowsForgotten: j.rowsForgotten,
1625
1616
  startedAt: j.startedAt,
1626
1617
  finishedAt: j.finishedAt,
1627
1618
  error: j.error
@@ -1764,6 +1755,9 @@ var RelocateEngine = class {
1764
1755
  bytesMoved: 0,
1765
1756
  filesTotal: null,
1766
1757
  rowsReconciled: 0,
1758
+ rowsForgotten: 0,
1759
+ ghostsKept: 0,
1760
+ budgetAnnounced: false,
1767
1761
  startedAt: this.deps.now(),
1768
1762
  finishedAt: null,
1769
1763
  error: null,
@@ -1918,7 +1912,9 @@ var RelocateEngine = class {
1918
1912
  archiveSegments: job.filesTotal,
1919
1913
  seen,
1920
1914
  moved: job.filesMoved,
1921
- rowsReconciled: job.rowsReconciled
1915
+ rowsReconciled: job.rowsReconciled,
1916
+ rowsForgotten: job.rowsForgotten,
1917
+ ghostsKept: job.ghostsKept
1922
1918
  };
1923
1919
  if (archive === null) {
1924
1920
  this.deps.logger.warn("relocate: coverage UNKNOWN — with no durable ledger this node cannot say what the source still holds", {
@@ -2063,6 +2059,81 @@ var RelocateEngine = class {
2063
2059
  return fixed;
2064
2060
  }
2065
2061
  /**
2062
+ * Forget the rows of one hour whose source file this mover CONFIRMED absent
2063
+ * — durably, under a bound, and never in silence.
2064
+ *
2065
+ * ## Why this observation is trustworthy enough to act on (D148)
2066
+ *
2067
+ * D148 lets the ledger forget only on a trusted walk or an eviction, so that
2068
+ * a fallible read can never delete an operator's index of real footage. This
2069
+ * is neither, and it is stronger than the walk it is not: the mover built the
2070
+ * absolute path from the ROW's own fields and `stat`ed exactly that path, so
2071
+ * the answer is per-path (D296) rather than "a directory listing came back
2072
+ * short" — the failure mode the walk-only rule exists to refuse. It is also
2073
+ * the same channel eviction already uses: `dropSegments` refuses an hour
2074
+ * whose detail it cannot read rather than emptying it, so a failed point read
2075
+ * here still changes nothing (D49).
2076
+ *
2077
+ * ## Why it is nevertheless BOUNDED
2078
+ *
2079
+ * Because per-path evidence is strong about a path and weak about a MOUNT. A
2080
+ * root that has gone away answers ENOENT for everything, and is
2081
+ * indistinguishable from a location that is genuinely empty — with the
2082
+ * archive as the difference. So the budget caps what one run may erase; past
2083
+ * it the mover keeps moving and keeps skipping, and the operator gets a
2084
+ * reported reconcile instead of an archive that emptied itself. A job with no
2085
+ * census forgets NOTHING: a coverage that cannot be stated is not one to
2086
+ * delete on (D295).
2087
+ */
2088
+ async forgetGhosts(job, missing, from) {
2089
+ if (missing.length === 0) return;
2090
+ const ledger = this.deps.ledger?.() ?? null;
2091
+ const budget = this.ghostForgetBudget(job);
2092
+ const allowed = ledger === null ? 0 : Math.max(0, Math.min(missing.length, budget - job.rowsForgotten));
2093
+ if (allowed > 0 && ledger !== null) {
2094
+ await ledger.dropSegments(missing.slice(0, allowed));
2095
+ job.rowsForgotten += allowed;
2096
+ }
2097
+ missing.forEach((row, i) => {
2098
+ this.deps.logger.warn("relocate skipped missing source", {
2099
+ tags: { deviceId: row.deviceId },
2100
+ meta: {
2101
+ jobId: job.jobId,
2102
+ path: row.path,
2103
+ locationId: from.id,
2104
+ forgotten: i < allowed
2105
+ }
2106
+ });
2107
+ });
2108
+ const kept = missing.length - allowed;
2109
+ if (kept === 0) return;
2110
+ job.ghostsKept += kept;
2111
+ if (job.budgetAnnounced) return;
2112
+ job.budgetAnnounced = true;
2113
+ this.deps.logger.warn("relocate: ghost-row forget BUDGET reached — the durable rows are KEPT", {
2114
+ tags: { deviceId: missing[0].deviceId },
2115
+ meta: {
2116
+ jobId: job.jobId,
2117
+ fromLocationId: from.id,
2118
+ budget,
2119
+ forgotten: job.rowsForgotten,
2120
+ stillMissing: kept,
2121
+ archiveSegments: job.filesTotal,
2122
+ why: ledger === null ? "no durable hour ledger on this node — a RAM-only mover may not erase durable rows" : "a root that went away answers ENOENT for everything; past the bound this is reported, not deleted"
2123
+ }
2124
+ });
2125
+ }
2126
+ /**
2127
+ * How many ghost rows THIS job may forget: the operator/test override, else a
2128
+ * share of the census it reported itself against, else nothing.
2129
+ */
2130
+ ghostForgetBudget(job) {
2131
+ const override = this.deps.ghostForgetBudget;
2132
+ if (override !== void 0) return override;
2133
+ if (job.filesTotal === null) return 0;
2134
+ return Math.max(64, Math.ceil(job.filesTotal * GHOST_FORGET_SHARE));
2135
+ }
2136
+ /**
2066
2137
  * Move ONE hour of one (device, profile): copy+verify every file, re-point
2067
2138
  * the durable hour row, delete the sources, re-point the RAM index.
2068
2139
  *
@@ -2075,6 +2146,8 @@ var RelocateEngine = class {
2075
2146
  */
2076
2147
  async moveHour(job, rows, from, to, samePhysicalRoot, sleep, bytesPerMs) {
2077
2148
  const movable = [];
2149
+ /** Rows of this hour whose source file the mover CONFIRMED absent. */
2150
+ const missing = [];
2078
2151
  let copiedBytes = 0;
2079
2152
  if (samePhysicalRoot) movable.push(...rows);
2080
2153
  else for (const row of rows) {
@@ -2090,16 +2163,11 @@ var RelocateEngine = class {
2090
2163
  } catch (err) {
2091
2164
  if (!isEnoent(err)) throw err;
2092
2165
  this.deps.index.removeSegments([row.path]);
2093
- this.deps.logger.warn("relocate skipped missing source", {
2094
- tags: { deviceId: row.deviceId },
2095
- meta: {
2096
- path: row.path,
2097
- locationId: from.id
2098
- }
2099
- });
2166
+ missing.push(row);
2100
2167
  }
2101
2168
  await sleep(pacedBytes / bytesPerMs);
2102
2169
  }
2170
+ await this.forgetGhosts(job, missing, from);
2103
2171
  if (movable.length === 0) return;
2104
2172
  if (job.cancelRequested) return;
2105
2173
  const ledger = this.deps.ledger?.() ?? null;
@@ -2209,7 +2277,7 @@ function profilesOfClass(footageClass) {
2209
2277
  function groupByHour(rows) {
2210
2278
  const out = /* @__PURE__ */ new Map();
2211
2279
  for (const row of rows) {
2212
- const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$6)}`;
2280
+ const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
2213
2281
  const found = out.get(key);
2214
2282
  if (found) found.push(row);
2215
2283
  else out.set(key, [row]);
@@ -2397,21 +2465,12 @@ var SegmentStore = class SegmentStore {
2397
2465
  if (group) group.push(r);
2398
2466
  else byBucket.set(bucketDir, [r]);
2399
2467
  }
2400
- const census = /* @__PURE__ */ new Map();
2401
- const countsFor = (deviceId, profile) => {
2402
- const key = `${deviceId}:${profile}`;
2403
- const cached = census.get(key);
2404
- if (cached) return cached;
2405
- const counts = this.deps.index.bucketCounts(deviceId, profile);
2406
- census.set(key, counts);
2407
- return counts;
2408
- };
2409
2468
  const chunkSize = this.deps.evictChunkSize ?? SegmentStore.DEFAULT_EVICT_CHUNK;
2410
2469
  const yieldBetween = this.deps.yieldBetween ?? macrotask$3;
2411
2470
  let bucketsRemoved = 0;
2412
2471
  let handled = 0;
2413
2472
  for (const [bucketDir, bucketRows] of byBucket) {
2414
- if (bucketDir !== "" && this.bucketFullyEvicted(bucketDir, bucketRows, countsFor)) try {
2473
+ if (bucketDir !== "" && await this.bucketFullyEvicted(bucketRows, location)) try {
2415
2474
  await this.deps.removeDir(location, bucketDir);
2416
2475
  bucketsRemoved += 1;
2417
2476
  for (const r of bucketRows) {
@@ -2522,27 +2581,37 @@ var SegmentStore = class SegmentStore {
2522
2581
  * work now the per-bucket archive scan is gone. */
2523
2582
  static DEFAULT_EVICT_CHUNK = 25;
2524
2583
  /**
2525
- * True when `victims` cover EVERY indexed segment of `bucketDir` AND the
2526
- * bucket's hour has fully elapsed. The current-hour guard matters because
2527
- * `onFinalized` relocates new segments INTO the current bucket concurrently —
2528
- * an rm racing that rename would orphan a just-indexed segment.
2584
+ * True when `victims` cover EVERY segment the ARCHIVE says the bucket holds,
2585
+ * AND the bucket's hour has fully elapsed.
2586
+ *
2587
+ * The current-hour guard matters because `onFinalized` relocates new segments
2588
+ * INTO the current bucket concurrently — an rm racing that rename would
2589
+ * orphan a just-indexed segment.
2590
+ *
2591
+ * The cover test is against `SegmentHourLedger`'s own hour row, never
2592
+ * `RecordingIndex`. A bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names
2593
+ * exactly one durable hour row: "every path that row lists is a victim" is
2594
+ * the same question the `rm -rf` answers, asked of the authority for what
2595
+ * EXISTS (D295). Asked of the resident index it read EQUAL whenever the hour
2596
+ * was partly resident — both sides short by the same rows — and the directory
2597
+ * went with every file the round never named, leaving their durable rows
2598
+ * behind as ghosts.
2529
2599
  *
2530
- * The cover test is a COUNT against the round's one-pass census plus an O(1)
2531
- * indexed-ness check per victim, never a scan of the device archive: a
2532
- * bucketDir is `<dev>/<profile>/Y/M/D/H`, so it names exactly one
2533
- * (device, profile), and the victims of a bucket are unique paths. Equal
2534
- * counts with every victim indexed therefore means the same set.
2600
+ * An unanswerable archive returns `false`: per-file deletes are slower and
2601
+ * always correct, and the alternative is a recursive delete computed from a
2602
+ * fraction of the truth.
2535
2603
  */
2536
- bucketFullyEvicted(bucketDir, victims, countsFor) {
2604
+ async bucketFullyEvicted(victims, location) {
2537
2605
  const first = victims[0];
2538
2606
  if (!first) return false;
2539
2607
  const bucketStartMs = Math.floor(first.startMs / SegmentStore.HOUR_MS) * SegmentStore.HOUR_MS;
2540
2608
  if ((this.deps.now?.() ?? Date.now()) < bucketStartMs + SegmentStore.HOUR_MS) return false;
2541
- const indexedInBucket = countsFor(first.deviceId, first.profile).get(bucketDir) ?? 0;
2542
- if (indexedInBucket === 0) return false;
2543
- const indexedVictims = /* @__PURE__ */ new Set();
2544
- for (const v of victims) if (this.deps.index.hasSegment(v.deviceId, v.path)) indexedVictims.add(v.path);
2545
- return indexedInBucket === indexedVictims.size;
2609
+ const ask = this.deps.archiveHourPaths;
2610
+ if (ask === void 0) return false;
2611
+ const held = await ask(first.deviceId, first.profile, location, bucketStartMs);
2612
+ if (held === null || held.length === 0) return false;
2613
+ const doomed = new Set(victims.map((v) => v.path));
2614
+ return held.every((p) => doomed.has(p));
2546
2615
  }
2547
2616
  };
2548
2617
  var macrotask$3 = () => new Promise((resolve) => {
@@ -3220,7 +3289,7 @@ async function profileHasFootage(root, deviceId, profile) {
3220
3289
  //#endregion
3221
3290
  //#region src/recorder/addon/calendar-index.ts
3222
3291
  var DAY_MS$3 = 864e5;
3223
- var HOUR_MS$5 = 36e5;
3292
+ var HOUR_MS$6 = 36e5;
3224
3293
  /**
3225
3294
  * UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
3226
3295
  *
@@ -3258,8 +3327,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
3258
3327
  * deploy gap). Unknown hours still fall through to one directory read.
3259
3328
  */
3260
3329
  function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
3261
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3262
- const hourEnd = hour + HOUR_MS$5;
3330
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3331
+ const hourEnd = hour + HOUR_MS$6;
3263
3332
  if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
3264
3333
  const rows = [];
3265
3334
  for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
@@ -3416,7 +3485,7 @@ var CalendarIndex = class {
3416
3485
  }));
3417
3486
  const days = /* @__PURE__ */ new Set();
3418
3487
  for (const { day, hours } of perDay) for (const h of hours) {
3419
- const at = day + h * HOUR_MS$5;
3488
+ const at = day + h * HOUR_MS$6;
3420
3489
  if (at < fromMs || at >= toMs) continue;
3421
3490
  days.add(localDayStart(at, tzOffsetMinutes));
3422
3491
  }
@@ -3478,7 +3547,7 @@ var CalendarIndex = class {
3478
3547
  const day = utcDayOf(utcDayMs);
3479
3548
  for (let h = 0; h < 24; h++) {
3480
3549
  await this.awaitQuiet();
3481
- await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$5, true);
3550
+ await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
3482
3551
  }
3483
3552
  }
3484
3553
  /**
@@ -3499,7 +3568,7 @@ var CalendarIndex = class {
3499
3568
  */
3500
3569
  async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
3501
3570
  if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
3502
- const hour = Math.floor(hourStartMs / HOUR_MS$5) * HOUR_MS$5;
3571
+ const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
3503
3572
  const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
3504
3573
  if (fromIndex !== null && fromIndex !== void 0) return {
3505
3574
  segments: fromIndex,
@@ -3507,7 +3576,7 @@ var CalendarIndex = class {
3507
3576
  };
3508
3577
  const key = `${deviceId}:${profile}:${hour}`;
3509
3578
  const now = this.deps.now?.() ?? Date.now();
3510
- const isCurrentHour = hour === Math.floor(now / HOUR_MS$5) * HOUR_MS$5;
3579
+ const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
3511
3580
  const cached = this.hourSegs.get(key);
3512
3581
  if (cached !== void 0) {
3513
3582
  if (!isCurrentHour) return {
@@ -3571,7 +3640,7 @@ var CalendarIndex = class {
3571
3640
  */
3572
3641
  async dayRanges(deviceId, profile, utcDayMs) {
3573
3642
  const day = utcDayOf(utcDayMs);
3574
- return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$5), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
3643
+ return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$6), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
3575
3644
  }
3576
3645
  /**
3577
3646
  * Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
@@ -3585,9 +3654,9 @@ var CalendarIndex = class {
3585
3654
  */
3586
3655
  async rangesIn(deviceId, profile, fromMs, toMs) {
3587
3656
  if (toMs <= fromMs) return [];
3588
- const first = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
3657
+ const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
3589
3658
  const hours = [];
3590
- for (let hour = first; hour < toMs; hour += HOUR_MS$5) hours.push(hour);
3659
+ for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
3591
3660
  const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
3592
3661
  const ranges = [];
3593
3662
  for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
@@ -4045,7 +4114,7 @@ function handleStoreCensusAction(census, logger, input) {
4045
4114
  }
4046
4115
  //#endregion
4047
4116
  //#region src/recorder/addon/directory-handler.ts
4048
- var HOUR_MS$4 = 36e5;
4117
+ var HOUR_MS$5 = 36e5;
4049
4118
  /** A window wider than this is refused: it is a client bug, and answering it
4050
4119
  * would walk the archive on the read path — the thing this replaces. */
4051
4120
  var MAX_WINDOW_MS = 32 * 864e5;
@@ -4092,9 +4161,9 @@ function withDirectoryRoute(deps, next) {
4092
4161
  return;
4093
4162
  }
4094
4163
  const rows = [];
4095
- const firstHour = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
4164
+ const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
4096
4165
  let hydrated = true;
4097
- for (let hour = firstHour; hour < toMs; hour += HOUR_MS$4) {
4166
+ for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
4098
4167
  const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
4099
4168
  if (!complete) hydrated = false;
4100
4169
  for (const s of segments) {
@@ -6623,6 +6692,53 @@ function buildRecordingProvider(deps) {
6623
6692
  bytes: census.bytes
6624
6693
  };
6625
6694
  },
6695
+ /**
6696
+ * Walk one location's claimed hours and diff them against the disk (D319).
6697
+ *
6698
+ * Refuses rather than guessing when the reconciler is not wired on this
6699
+ * node: a report whose numbers came from nowhere is worse than no report,
6700
+ * and `location-unknown` is the honest shape for "nothing here can answer".
6701
+ */
6702
+ reconcileLedgerAgainstDisk: async (input) => {
6703
+ const reconciler = deps.ledgerWalk;
6704
+ if (reconciler === void 0) {
6705
+ deps.logger.warn("recorder: ledger walk unavailable on this node — no durable hour ledger is wired here", { meta: { locationId: input.locationId } });
6706
+ return {
6707
+ locationId: input.locationId,
6708
+ applied: input.apply === true,
6709
+ refused: "no-ledger",
6710
+ archiveSegments: null,
6711
+ archiveBytes: null,
6712
+ hoursClaimed: 0,
6713
+ hoursWalked: 0,
6714
+ hoursMissing: 0,
6715
+ listings: 0,
6716
+ segmentsClaimed: 0,
6717
+ ghostSegments: 0,
6718
+ ghostBytes: 0,
6719
+ ghostHoursWhole: 0,
6720
+ forgottenSegments: 0,
6721
+ forgottenBytes: 0,
6722
+ orphanFiles: 0,
6723
+ orphanSample: [],
6724
+ hoursSkipped: 0,
6725
+ skippedByReason: {
6726
+ "live-tail": 0,
6727
+ "listing-error": 0,
6728
+ "path-mismatch": 0,
6729
+ "durable-refused": 0
6730
+ },
6731
+ bounded: false,
6732
+ byDevice: []
6733
+ };
6734
+ }
6735
+ return reconciler.run({
6736
+ locationId: input.locationId,
6737
+ ...input.apply !== void 0 ? { apply: input.apply } : {},
6738
+ ...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
6739
+ ...input.profiles !== void 0 ? { profiles: input.profiles } : {}
6740
+ });
6741
+ },
6626
6742
  cancelRelocateJob: async ({ jobId }) => ({ cancelled: deps.relocate?.cancel(jobId) ?? false }),
6627
6743
  planStorageRebalance: (input) => runRebalance(deps, input, false),
6628
6744
  startStorageRebalance: (input) => runRebalance(deps, input, true),
@@ -7121,7 +7237,7 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
7121
7237
  const p2 = (n) => String(n).padStart(2, "0");
7122
7238
  return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
7123
7239
  }
7124
- var HOUR_MS$3 = 36e5;
7240
+ var HOUR_MS$4 = 36e5;
7125
7241
  /**
7126
7242
  * Hydrate ONLY the hours a window spans, instead of walking the archive.
7127
7243
  *
@@ -7166,7 +7282,7 @@ var HOUR_MS$3 = 36e5;
7166
7282
  */
7167
7283
  async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger) {
7168
7284
  if (index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
7169
- const liveHour = Math.floor(Date.now() / HOUR_MS$3) * HOUR_MS$3;
7285
+ const liveHour = Math.floor(Date.now() / HOUR_MS$4) * HOUR_MS$4;
7170
7286
  if (ledger !== void 0) {
7171
7287
  const startedMs = Date.now();
7172
7288
  const hours = await ledger.detailForWindow(deviceId, fromMs, toMs);
@@ -7203,12 +7319,12 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
7203
7319
  });
7204
7320
  continue;
7205
7321
  }
7206
- const first = Math.floor(fromMs / HOUR_MS$3) * HOUR_MS$3;
7322
+ const first = Math.floor(fromMs / HOUR_MS$4) * HOUR_MS$4;
7207
7323
  const reuseBefore = index.hydrateReuseStats();
7208
7324
  let hoursRead = 0;
7209
7325
  let repaired = 0;
7210
- for (let hour = first; hour < toMs; hour += HOUR_MS$3) {
7211
- if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$3) === "hydrated") continue;
7326
+ for (let hour = first; hour < toMs; hour += HOUR_MS$4) {
7327
+ if (hour < liveHour && index.hydrationOf(deviceId, hour, hour + HOUR_MS$4) === "hydrated") continue;
7212
7328
  for (const profile of profiles) {
7213
7329
  const rel = hourDirRelPath(deviceId, profile, hour);
7214
7330
  let files;
@@ -7221,7 +7337,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
7221
7337
  const locationId = locationForHydratedProfile(aliases, profile).id;
7222
7338
  const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
7223
7339
  index.hydrateHour(deviceId, locationId, hour, relPaths);
7224
- if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$3 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
7340
+ if (ledger !== void 0 && relPaths.length > 0 && hour + HOUR_MS$4 <= Date.now() && !ledger.knowsHour(deviceId, profile, locationId, hour)) {
7225
7341
  repaired += 1;
7226
7342
  await ledger.adoptRebuiltHour(deviceId, profile, locationId, hour, relPaths);
7227
7343
  }
@@ -7485,7 +7601,7 @@ var DurableLedger = class DurableLedger {
7485
7601
  };
7486
7602
  //#endregion
7487
7603
  //#region src/recorder/addon/segment-hour-ledger.ts
7488
- var HOUR_MS$2 = 36e5;
7604
+ var HOUR_MS$3 = 36e5;
7489
7605
  var macrotask$1 = () => new Promise((resolve) => {
7490
7606
  setImmediate(resolve);
7491
7607
  });
@@ -7798,7 +7914,7 @@ var EMPTY_ACCOUNTING = {
7798
7914
  newestMs: null
7799
7915
  };
7800
7916
  function hourStartMs(startMs) {
7801
- return Math.floor(startMs / HOUR_MS$2) * HOUR_MS$2;
7917
+ return Math.floor(startMs / HOUR_MS$3) * HOUR_MS$3;
7802
7918
  }
7803
7919
  function segmentHourKey(deviceId, profile, locationId, hour) {
7804
7920
  return `${deviceId}|${profile}|${locationId}|${hour}`;
@@ -8257,7 +8373,7 @@ var SegmentHourLedger = class SegmentHourLedger {
8257
8373
  /** The hour a write path may touch: current + previous. Materialised at boot
8258
8374
  * so {@link recordSegment}'s dedup gate answers from RAM (D49). */
8259
8375
  async materializeLiveTail() {
8260
- const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$2;
8376
+ const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$3;
8261
8377
  const keys = [];
8262
8378
  for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
8263
8379
  return (await this.materialize(keys)).length;
@@ -9047,7 +9163,7 @@ function locationKey(deviceId, locationId) {
9047
9163
  * Work it does not finish is left for the next pass and REPORTED, never
9048
9164
  * silently dropped.
9049
9165
  */
9050
- var HOUR_MS$1 = 36e5;
9166
+ var HOUR_MS$2 = 36e5;
9051
9167
  /**
9052
9168
  * Group resolved locations by PHYSICAL root, so two aliases of one disk are
9053
9169
  * probed once and a `true` from each is not mistaken for the file existing in
@@ -9069,7 +9185,7 @@ function ghostProbeRoots(locations, realpath) {
9069
9185
  aliases
9070
9186
  })).toSorted((a, b) => a.key.localeCompare(b.key));
9071
9187
  }
9072
- function emptyTally() {
9188
+ function emptyTally$1() {
9073
9189
  return {
9074
9190
  repointedHours: 0,
9075
9191
  repointedSegments: 0,
@@ -9084,7 +9200,7 @@ function emptyTally() {
9084
9200
  }
9085
9201
  /** Every reason at zero — so a reason that never fired is REPORTED as zero
9086
9202
  * rather than absent, and the summary shape does not change between passes. */
9087
- function zeroReasons() {
9203
+ function zeroReasons$1() {
9088
9204
  return {
9089
9205
  "live-tail": 0,
9090
9206
  "detail-unavailable": 0,
@@ -9110,7 +9226,7 @@ var GhostLocationSweeper = class {
9110
9226
  async sweep() {
9111
9227
  const maxHours = this.deps.maxHoursPerPass ?? 16;
9112
9228
  const maxProbes = this.deps.maxProbesPerPass ?? 4e3;
9113
- const skippedByReason = zeroReasons();
9229
+ const skippedByReason = zeroReasons$1();
9114
9230
  const byDevice = /* @__PURE__ */ new Map();
9115
9231
  const locations = this.deps.locations();
9116
9232
  const roots = ghostProbeRoots(locations, this.deps.realpath);
@@ -9156,7 +9272,7 @@ var GhostLocationSweeper = class {
9156
9272
  refused: null
9157
9273
  };
9158
9274
  }
9159
- const liveTailFrom = this.deps.now() - HOUR_MS$1;
9275
+ const liveTailFrom = this.deps.now() - HOUR_MS$2;
9160
9276
  let examined = 0;
9161
9277
  let probes = 0;
9162
9278
  let repointedHours = 0;
@@ -9168,7 +9284,7 @@ var GhostLocationSweeper = class {
9168
9284
  const tallyOf = (deviceId) => {
9169
9285
  const held = byDevice.get(deviceId);
9170
9286
  if (held) return held;
9171
- const fresh = emptyTally();
9287
+ const fresh = emptyTally$1();
9172
9288
  byDevice.set(deviceId, fresh);
9173
9289
  return fresh;
9174
9290
  };
@@ -9493,6 +9609,7 @@ Object.freeze({
9493
9609
  "pipeline-analytics": {
9494
9610
  "getAudioEvents": { "limit": 1e3 },
9495
9611
  "getKeyEvents": { "limit": 50 },
9612
+ "getKeyEventsBatch": { "limit": 50 },
9496
9613
  "getMotionEvents": { "limit": 1e3 },
9497
9614
  "getObjectEvents": { "limit": 1e3 },
9498
9615
  "getSensorEvents": { "limit": 1e3 },
@@ -9696,6 +9813,405 @@ function withLastKnownConfig(deps) {
9696
9813
  };
9697
9814
  }
9698
9815
  //#endregion
9816
+ //#region src/recorder/addon/ledger-walk-reconcile.ts
9817
+ var HOUR_MS$1 = 36e5;
9818
+ /** Every reason at zero, so a reason that never fired reports as zero rather
9819
+ * than absent and the summary shape is constant between passes. */
9820
+ function zeroReasons() {
9821
+ return {
9822
+ "live-tail": 0,
9823
+ "listing-error": 0,
9824
+ "path-mismatch": 0,
9825
+ "durable-refused": 0
9826
+ };
9827
+ }
9828
+ function emptyTally() {
9829
+ return {
9830
+ hoursWalked: 0,
9831
+ hoursMissing: 0,
9832
+ ghostSegments: 0,
9833
+ ghostBytes: 0,
9834
+ forgottenSegments: 0,
9835
+ orphanFiles: 0
9836
+ };
9837
+ }
9838
+ /** The basename of a segment path — what a listing returns. */
9839
+ function fileName(relPath) {
9840
+ const cut = relPath.lastIndexOf("/");
9841
+ return cut === -1 ? relPath : relPath.slice(cut + 1);
9842
+ }
9843
+ /** Group one archive page into whole hours. A page always carries complete hour
9844
+ * rows (`streamSegments` expands them whole), so no hour is ever split. */
9845
+ function claimsOf(rows) {
9846
+ const byHour = /* @__PURE__ */ new Map();
9847
+ for (const row of rows) {
9848
+ const hour = hourStartMs(row.startMs);
9849
+ const key = `${row.deviceId}|${row.profile}|${hour}`;
9850
+ const held = byHour.get(key);
9851
+ if (held) held.push(row);
9852
+ else byHour.set(key, [row]);
9853
+ }
9854
+ const out = [];
9855
+ for (const group of byHour.values()) {
9856
+ const first = group[0];
9857
+ if (first === void 0) continue;
9858
+ out.push({
9859
+ deviceId: first.deviceId,
9860
+ profile: first.profile,
9861
+ hourStartMs: hourStartMs(first.startMs),
9862
+ rows: group
9863
+ });
9864
+ }
9865
+ return out.toSorted((a, b) => a.hourStartMs - b.hourStartMs || a.deviceId - b.deviceId);
9866
+ }
9867
+ /**
9868
+ * One reconcile pass over one location's claimed hours.
9869
+ *
9870
+ * Never throws: a per-hour failure is a counted refusal and the pass continues;
9871
+ * a precondition failure is a named refusal and the pass does nothing at all.
9872
+ */
9873
+ var LedgerWalkReconciler = class {
9874
+ deps;
9875
+ constructor(deps) {
9876
+ this.deps = deps;
9877
+ }
9878
+ async run(request) {
9879
+ const apply = request.apply === true;
9880
+ const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
9881
+ if (location === void 0) return this.refusedReport(request.locationId, apply, "location-unknown");
9882
+ if ((location.enabled ?? true) && !location.readOnly) {
9883
+ this.deps.logger.warn("recorder: ledger walk REFUSED — the source is still a write target, so a directory listing is a lower bound, not the truth", { meta: {
9884
+ locationId: location.id,
9885
+ root: location.root,
9886
+ enabled: location.enabled ?? true,
9887
+ readOnly: location.readOnly,
9888
+ remedy: "set the location readOnly (or disable it) and re-run; a LIVE location is reconciled by the mover, under its bound (D318)"
9889
+ } });
9890
+ return this.refusedReport(location.id, apply, "source-writable");
9891
+ }
9892
+ const ledger = this.deps.ledger();
9893
+ if (ledger === null) {
9894
+ this.deps.logger.warn("recorder: ledger walk REFUSED — this node holds no durable hour ledger, so there is no archive to reconcile", { meta: { locationId: location.id } });
9895
+ return this.refusedReport(location.id, apply, "no-ledger");
9896
+ }
9897
+ const anchor = await this.readAnchor(location);
9898
+ if (!anchor.ok) return this.refusedReport(location.id, apply, anchor.refusal);
9899
+ const scope = {
9900
+ locationId: location.id,
9901
+ ...request.deviceId !== void 0 ? { deviceId: request.deviceId } : {},
9902
+ ...request.profiles !== void 0 && request.profiles.length > 0 ? { profiles: request.profiles } : {}
9903
+ };
9904
+ const census = await ledger.census(scope);
9905
+ if (census === null) {
9906
+ this.deps.logger.warn("recorder: ledger walk REFUSED — the archive could not say what this location holds, so no walk of it can be reported against anything", { meta: { locationId: location.id } });
9907
+ return this.refusedReport(location.id, apply, "archive-unreadable");
9908
+ }
9909
+ const state = {
9910
+ location,
9911
+ ledger,
9912
+ apply,
9913
+ anchor: anchor.volumeId,
9914
+ liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$1,
9915
+ maxHours: this.deps.maxHours ?? 4096,
9916
+ byDevice: /* @__PURE__ */ new Map(),
9917
+ skippedByReason: zeroReasons(),
9918
+ orphanSample: [],
9919
+ hoursClaimed: 0,
9920
+ hoursWalked: 0,
9921
+ hoursMissing: 0,
9922
+ listings: 0,
9923
+ segmentsClaimed: 0,
9924
+ ghostSegments: 0,
9925
+ ghostBytes: 0,
9926
+ ghostHoursWhole: 0,
9927
+ forgottenSegments: 0,
9928
+ forgottenBytes: 0,
9929
+ orphanFiles: 0,
9930
+ hoursSkipped: 0,
9931
+ bounded: false,
9932
+ refused: null
9933
+ };
9934
+ await this.walk(state, scope);
9935
+ const report = {
9936
+ locationId: location.id,
9937
+ applied: apply,
9938
+ refused: state.refused,
9939
+ archiveSegments: census.segments,
9940
+ archiveBytes: census.bytes,
9941
+ hoursClaimed: state.hoursClaimed,
9942
+ hoursWalked: state.hoursWalked,
9943
+ hoursMissing: state.hoursMissing,
9944
+ listings: state.listings,
9945
+ segmentsClaimed: state.segmentsClaimed,
9946
+ ghostSegments: state.ghostSegments,
9947
+ ghostBytes: state.ghostBytes,
9948
+ ghostHoursWhole: state.ghostHoursWhole,
9949
+ forgottenSegments: state.forgottenSegments,
9950
+ forgottenBytes: state.forgottenBytes,
9951
+ orphanFiles: state.orphanFiles,
9952
+ orphanSample: [...state.orphanSample],
9953
+ hoursSkipped: state.hoursSkipped,
9954
+ skippedByReason: { ...state.skippedByReason },
9955
+ bounded: state.bounded,
9956
+ byDevice: [...state.byDevice.entries()].map(([deviceId, tally]) => ({
9957
+ deviceId,
9958
+ ...tally
9959
+ })).toSorted((a, b) => a.deviceId - b.deviceId)
9960
+ };
9961
+ this.reportPass(report);
9962
+ return report;
9963
+ }
9964
+ /** Walk the archive page by page, one hour at a time. */
9965
+ async walk(state, scope) {
9966
+ for await (const page of state.ledger.streamSegments(scope)) for (const claim of claimsOf(page)) {
9967
+ if (state.hoursClaimed >= state.maxHours) {
9968
+ state.bounded = true;
9969
+ return;
9970
+ }
9971
+ state.hoursClaimed += 1;
9972
+ state.segmentsClaimed += claim.rows.length;
9973
+ if (!await this.reconcileHour(state, claim)) return;
9974
+ }
9975
+ }
9976
+ /**
9977
+ * Diff ONE hour, and forget what it proved absent.
9978
+ *
9979
+ * `false` ends the whole pass — the only thing that returns it is an anchor
9980
+ * that stopped agreeing, and every listing taken after that point is about a
9981
+ * filesystem this pass did not start on.
9982
+ */
9983
+ async reconcileHour(state, claim) {
9984
+ const tally = this.tallyOf(state, claim.deviceId);
9985
+ if (claim.hourStartMs >= state.liveTailFrom) {
9986
+ this.skip(state, claim, "live-tail", `hourStartMs=${claim.hourStartMs}`);
9987
+ return true;
9988
+ }
9989
+ const relDir = hourDirRelPath(claim.deviceId, claim.profile, claim.hourStartMs);
9990
+ for (const row of claim.rows) if (parseSegmentPath(row.path) === null || row.path !== `${relDir}/${fileName(row.path)}`) {
9991
+ this.skip(state, claim, "path-mismatch", row.path);
9992
+ return true;
9993
+ }
9994
+ let listing;
9995
+ try {
9996
+ listing = await this.deps.listHour(state.location.root, relDir);
9997
+ } catch (err) {
9998
+ this.skip(state, claim, "listing-error", require_dist.errMsg(err));
9999
+ return true;
10000
+ }
10001
+ state.listings += 1;
10002
+ if (state.listings % 8 === 0) await this.deps.pace();
10003
+ state.hoursWalked += 1;
10004
+ tally.hoursWalked += 1;
10005
+ const diff = this.diff(claim, listing);
10006
+ if (diff.missingDirectory) {
10007
+ state.hoursMissing += 1;
10008
+ tally.hoursMissing += 1;
10009
+ }
10010
+ if (diff.orphans.length > 0) {
10011
+ state.orphanFiles += diff.orphans.length;
10012
+ tally.orphanFiles += diff.orphans.length;
10013
+ for (const orphan of diff.orphans) {
10014
+ if (state.orphanSample.length >= 50) break;
10015
+ state.orphanSample.push(`${relDir}/${orphan}`);
10016
+ }
10017
+ this.deps.logger.warn("recorder: files under a claimed hour that the durable ledger does NOT name — reported, never deleted", {
10018
+ tags: { deviceId: claim.deviceId },
10019
+ meta: {
10020
+ locationId: state.location.id,
10021
+ profile: claim.profile,
10022
+ hourStartMs: claim.hourStartMs,
10023
+ orphans: diff.orphans.length,
10024
+ sample: diff.orphans.slice(0, 5)
10025
+ }
10026
+ });
10027
+ }
10028
+ if (diff.ghosts.length === 0) return true;
10029
+ const ghostBytes = diff.ghosts.reduce((sum, row) => sum + row.bytes, 0);
10030
+ state.ghostSegments += diff.ghosts.length;
10031
+ state.ghostBytes += ghostBytes;
10032
+ tally.ghostSegments += diff.ghosts.length;
10033
+ tally.ghostBytes += ghostBytes;
10034
+ if (diff.ghosts.length === claim.rows.length) state.ghostHoursWhole += 1;
10035
+ const anchor = await this.readAnchor(state.location);
10036
+ if (!anchor.ok || anchor.volumeId !== state.anchor) {
10037
+ state.refused = anchor.ok ? "anchor-moved" : anchor.refusal;
10038
+ this.deps.logger.warn("recorder: ledger walk STOPPED — the volume under this location is not the one the pass started on, so every listing since is about another filesystem and NOTHING more is forgotten", {
10039
+ tags: { deviceId: claim.deviceId },
10040
+ meta: {
10041
+ locationId: state.location.id,
10042
+ root: state.location.root,
10043
+ startedOn: state.anchor,
10044
+ foundNow: anchor.ok ? anchor.volumeId : anchor.refusal,
10045
+ forgottenBeforeStop: state.forgottenSegments
10046
+ }
10047
+ });
10048
+ return false;
10049
+ }
10050
+ this.deps.logger.warn("recorder: durable hour row claims segments the directory does not hold", {
10051
+ tags: { deviceId: claim.deviceId },
10052
+ meta: {
10053
+ locationId: state.location.id,
10054
+ profile: claim.profile,
10055
+ hourStartMs: claim.hourStartMs,
10056
+ claimed: claim.rows.length,
10057
+ ghosts: diff.ghosts.length,
10058
+ ghostBytes,
10059
+ wholeHour: diff.ghosts.length === claim.rows.length,
10060
+ missingDirectory: diff.missingDirectory,
10061
+ apply: state.apply
10062
+ }
10063
+ });
10064
+ if (!state.apply) return true;
10065
+ await state.ledger.dropSegments(diff.ghosts);
10066
+ if (!await this.forgetLanded(state, claim, diff.ghosts)) {
10067
+ this.skip(state, claim, "durable-refused", "the ledger still lists a dropped path");
10068
+ return true;
10069
+ }
10070
+ this.deps.applyToIndex(diff.ghosts.map((row) => row.path));
10071
+ state.forgottenSegments += diff.ghosts.length;
10072
+ state.forgottenBytes += ghostBytes;
10073
+ tally.forgottenSegments += diff.ghosts.length;
10074
+ return true;
10075
+ }
10076
+ /** Did the durable drop actually land? */
10077
+ async forgetLanded(state, claim, ghosts) {
10078
+ const { deviceId, profile, hourStartMs: hour } = claim;
10079
+ if (!state.ledger.knowsHour(deviceId, profile, state.location.id, hour)) return true;
10080
+ const held = await state.ledger.hourPaths(deviceId, profile, state.location.id, hour);
10081
+ if (held === null) return false;
10082
+ const remaining = new Set(held);
10083
+ return ghosts.every((row) => !remaining.has(row.path));
10084
+ }
10085
+ /**
10086
+ * What the hour row claims, against what the directory holds — BOTH ways.
10087
+ *
10088
+ * A missing directory is an empty listing here, which makes every claim a
10089
+ * ghost. That is only admissible because the caller re-reads the volume
10090
+ * anchor before acting on it.
10091
+ */
10092
+ diff(claim, listing) {
10093
+ const present = new Set((listing ?? []).filter((name) => name.endsWith(".m4s")));
10094
+ const claimed = new Set(claim.rows.map((row) => fileName(row.path)));
10095
+ return {
10096
+ ghosts: claim.rows.filter((row) => !present.has(fileName(row.path))),
10097
+ orphans: [...present].filter((name) => !claimed.has(name)).toSorted(),
10098
+ missingDirectory: listing === null
10099
+ };
10100
+ }
10101
+ /** The volume anchor, or the refusal its absence produces. */
10102
+ async readAnchor(location) {
10103
+ let marker;
10104
+ try {
10105
+ marker = await this.deps.anchorOf(location.root);
10106
+ } catch (err) {
10107
+ this.deps.logger.warn("recorder: ledger walk cannot read the volume marker — an unreadable root decides nothing", { meta: {
10108
+ locationId: location.id,
10109
+ root: location.root,
10110
+ error: require_dist.errMsg(err)
10111
+ } });
10112
+ return {
10113
+ ok: false,
10114
+ refusal: "anchor-unreadable"
10115
+ };
10116
+ }
10117
+ if (marker === null) {
10118
+ this.deps.logger.warn("recorder: ledger walk has NO volume marker at this root — a mountpoint directory without its disk answers ENOENT for every hour, which is indistinguishable from an empty location", { meta: {
10119
+ locationId: location.id,
10120
+ root: location.root
10121
+ } });
10122
+ return {
10123
+ ok: false,
10124
+ refusal: "anchor-absent"
10125
+ };
10126
+ }
10127
+ return {
10128
+ ok: true,
10129
+ volumeId: marker
10130
+ };
10131
+ }
10132
+ tallyOf(state, deviceId) {
10133
+ const held = state.byDevice.get(deviceId);
10134
+ if (held) return held;
10135
+ const fresh = emptyTally();
10136
+ state.byDevice.set(deviceId, fresh);
10137
+ return fresh;
10138
+ }
10139
+ skip(state, claim, reason, detail) {
10140
+ state.hoursSkipped += 1;
10141
+ state.skippedByReason[reason] += 1;
10142
+ this.deps.logger.debug("recorder: ledger walk left an hour exactly as it found it", {
10143
+ tags: { deviceId: claim.deviceId },
10144
+ meta: {
10145
+ locationId: state.location.id,
10146
+ profile: claim.profile,
10147
+ hourStartMs: claim.hourStartMs,
10148
+ reason,
10149
+ detail
10150
+ }
10151
+ });
10152
+ }
10153
+ refusedReport(locationId, applied, refused) {
10154
+ return {
10155
+ locationId,
10156
+ applied,
10157
+ refused,
10158
+ archiveSegments: null,
10159
+ archiveBytes: null,
10160
+ hoursClaimed: 0,
10161
+ hoursWalked: 0,
10162
+ hoursMissing: 0,
10163
+ listings: 0,
10164
+ segmentsClaimed: 0,
10165
+ ghostSegments: 0,
10166
+ ghostBytes: 0,
10167
+ ghostHoursWhole: 0,
10168
+ forgottenSegments: 0,
10169
+ forgottenBytes: 0,
10170
+ orphanFiles: 0,
10171
+ orphanSample: [],
10172
+ hoursSkipped: 0,
10173
+ skippedByReason: zeroReasons(),
10174
+ bounded: false,
10175
+ byDevice: []
10176
+ };
10177
+ }
10178
+ /** One line per camera, then one for the pass. */
10179
+ reportPass(report) {
10180
+ for (const device of report.byDevice) this.deps.logger.info("recorder: ledger walk — camera summary", {
10181
+ tags: { deviceId: device.deviceId },
10182
+ meta: {
10183
+ locationId: report.locationId,
10184
+ hoursWalked: device.hoursWalked,
10185
+ hoursMissing: device.hoursMissing,
10186
+ ghostSegments: device.ghostSegments,
10187
+ ghostBytes: device.ghostBytes,
10188
+ forgottenSegments: device.forgottenSegments,
10189
+ orphanFiles: device.orphanFiles
10190
+ }
10191
+ });
10192
+ this.deps.logger.info("recorder: ledger walk complete", { meta: {
10193
+ locationId: report.locationId,
10194
+ applied: report.applied,
10195
+ refused: report.refused,
10196
+ archiveSegments: report.archiveSegments,
10197
+ segmentsClaimed: report.segmentsClaimed,
10198
+ hoursClaimed: report.hoursClaimed,
10199
+ hoursWalked: report.hoursWalked,
10200
+ hoursMissing: report.hoursMissing,
10201
+ listings: report.listings,
10202
+ ghostSegments: report.ghostSegments,
10203
+ ghostBytes: report.ghostBytes,
10204
+ ghostHoursWhole: report.ghostHoursWhole,
10205
+ forgottenSegments: report.forgottenSegments,
10206
+ forgottenBytes: report.forgottenBytes,
10207
+ orphanFiles: report.orphanFiles,
10208
+ hoursSkipped: report.hoursSkipped,
10209
+ skippedByReason: report.skippedByReason,
10210
+ bounded: report.bounded
10211
+ } });
10212
+ }
10213
+ };
10214
+ //#endregion
9699
10215
  //#region src/recorder/addon/location-capacity.ts
9700
10216
  /** Human-readable failure text for a thrown value of unknown shape. */
9701
10217
  function reasonOf(err) {
@@ -15056,6 +15572,17 @@ var GHOST_LOCATION_SWEEP_SLOW_AFTER_MS = 10 * 6e4;
15056
15572
  /** Awaited every `GHOST_PROBE_BATCH` probes. The janitor's brake: it keeps a
15057
15573
  * reconcile pass off the array's critical path rather than racing it. */
15058
15574
  var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
15575
+ /**
15576
+ * Awaited every `WALK_HOUR_BATCH` hour listings by the ledger↔disk walk (D319).
15577
+ *
15578
+ * Twice the ghost sweep's brake, because the syscall between brakes is far
15579
+ * heavier: that pass paces `storage.exists` on one path, this one paces a
15580
+ * `readdir` of a whole hour directory — measured at ~650 ms on the live share
15581
+ * under load. The walk is operator-armed and one-off, so the right trade is to
15582
+ * be slower and invisible rather than fast and felt: a `find` over this array
15583
+ * hung an SSH session on 2026-08-29.
15584
+ */
15585
+ var LEDGER_WALK_PAUSE_MS = 100;
15059
15586
  /** What the per-volume usage row reports when the hour ledger is unavailable:
15060
15587
  * nothing measured, rather than a total counted off a partial RAM index. */
15061
15588
  var EMPTY_ARCHIVE_ACCOUNTING = {
@@ -15331,6 +15858,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
15331
15858
  await node_fs.promises.unlink(fromAbs);
15332
15859
  }
15333
15860
  },
15861
+ archiveHourPaths: async (deviceId, profile, locationId, hourStartMs) => await this.segmentHours?.hourPaths(deviceId, profile, locationId, hourStartMs) ?? null,
15334
15862
  removeDir: async (locationId, relDir) => {
15335
15863
  const loc = this.resolvedLocations.find((l) => l.id === locationId);
15336
15864
  if (!loc) throw new Error(`recorder: unknown recordings location ${locationId}`);
@@ -15520,6 +16048,7 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
15520
16048
  capacity: (root) => this.locationCapacity(root),
15521
16049
  archiveAccounting: (locationIds) => this.segmentHours?.accountingForLocations(locationIds) ?? Promise.resolve(EMPTY_ARCHIVE_ACCOUNTING),
15522
16050
  archive: () => this.segmentHours,
16051
+ ledgerWalk: this.ledgerWalkReconciler(),
15523
16052
  hydrateDevice: (deviceId, locations) => hydrateDeviceFromStorage(this.ctx.api, this.index, deviceId, locations, this.ctx.logger),
15524
16053
  reconcileHourLedger: async (deviceId) => {
15525
16054
  await this.segmentHours?.reconcileFromIndex(this.index, [deviceId], Date.now());
@@ -15768,6 +16297,44 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
15768
16297
  return resolveEvictionDomain(this.resolvedLocations, locationId);
15769
16298
  }
15770
16299
  /**
16300
+ * The operator's "does my ledger tell the truth?" pass (D319).
16301
+ *
16302
+ * Built once and handed to the provider, but every dependency it holds is a
16303
+ * THUNK: `this.segmentHours` is null until the collection declares, and
16304
+ * `this.resolvedLocations` is re-resolved whenever a disk appears. A
16305
+ * reconciler that captured either would answer for the boot instant forever.
16306
+ *
16307
+ * `listHour` is a SHALLOW `readdir` of exactly one hour directory — never
16308
+ * `recursive: true`, which is the call that takes minutes over `/recordings`
16309
+ * and the reason a shell `find` hung an SSH session. A directory that is not
16310
+ * there answers `null`; anything else throws, and a throw decides nothing.
16311
+ *
16312
+ * `anchorOf` is the placement volume marker, which lives ON the disk: it is
16313
+ * the only thing here that can tell a location whose hours were deleted from
16314
+ * a mountpoint whose disk went away.
16315
+ */
16316
+ ledgerWalkReconciler() {
16317
+ return new LedgerWalkReconciler({
16318
+ ledger: () => this.segmentHours,
16319
+ locations: () => this.resolvedLocations,
16320
+ listHour: async (root, relDir) => {
16321
+ try {
16322
+ return await node_fs.promises.readdir(node_path.default.join(root, relDir));
16323
+ } catch (err) {
16324
+ if (err.code === "ENOENT") return null;
16325
+ throw err;
16326
+ }
16327
+ },
16328
+ anchorOf: async (root) => (await readLocationMarker(root))?.volumeId ?? null,
16329
+ applyToIndex: (paths) => {
16330
+ this.index.removeSegments(paths);
16331
+ },
16332
+ logger: this.ctx.logger,
16333
+ now: () => Date.now(),
16334
+ pace: () => require_dist.sleep(LEDGER_WALK_PAUSE_MS)
16335
+ });
16336
+ }
16337
+ /**
15771
16338
  * Boot hydrate: once the hub is reachable (`ctx.api.*` safe), resolve the
15772
16339
  * recordings locations and rebuild the in-memory index from `storage.list`
15773
16340
  * for every device that has a persisted config. The list result is the