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