@camstack/addon-post-analysis 1.2.76 → 1.2.77

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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-BsBjSNIo.js");
5
+ const require_dist = require("../dist-Bs6oboqZ.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -4821,6 +4821,7 @@ var SceneMonitorProvider = class {
4821
4821
  direction,
4822
4822
  verdict: result.verdict,
4823
4823
  latched,
4824
+ emit: monitor.emit,
4824
4825
  condition: result.currentCondition,
4825
4826
  confidence: result.lastConfidence,
4826
4827
  timestamp: result.checkedAt
@@ -6485,6 +6486,110 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
6485
6486
  if (totalMaskPixels === 0) return 0;
6486
6487
  return insidePolygon / totalMaskPixels;
6487
6488
  }
6489
+ var NcSceneStateCache = class {
6490
+ deps;
6491
+ entries = /* @__PURE__ */ new Map();
6492
+ noted = /* @__PURE__ */ new Map();
6493
+ ids = [];
6494
+ now;
6495
+ constructor(deps) {
6496
+ this.deps = deps;
6497
+ this.now = deps.now ?? (() => Date.now());
6498
+ }
6499
+ /**
6500
+ * The scene ids worth reading — recomputed from the rule set, so a cluster
6501
+ * with no scene-gated rule does no work at all and logs nothing.
6502
+ */
6503
+ setWatched(ids) {
6504
+ this.ids = [...new Set(ids)].sort();
6505
+ }
6506
+ watched() {
6507
+ return this.ids;
6508
+ }
6509
+ /**
6510
+ * "This scene, in this process, is in this state, NOW."
6511
+ *
6512
+ * Synchronous by contract — the caller is the scene provider's own edge tap
6513
+ * and the next rule evaluation must already see it.
6514
+ */
6515
+ note(observation) {
6516
+ this.noted.set(observation.sceneId, {
6517
+ observation,
6518
+ at: this.now()
6519
+ });
6520
+ }
6521
+ /** Hot-path read. No I/O, no await — this is called per event. */
6522
+ get(sceneId) {
6523
+ const now = this.now();
6524
+ const noted = this.noted.get(sceneId);
6525
+ if (noted !== void 0) {
6526
+ if (now - noted.at <= 3e5) return noted.observation;
6527
+ this.noted.delete(sceneId);
6528
+ }
6529
+ const entry = this.entries.get(sceneId);
6530
+ if (entry === void 0) return void 0;
6531
+ if (now - entry.at > 3e5) return void 0;
6532
+ return entry.observation;
6533
+ }
6534
+ /**
6535
+ * Re-read every watched scene. Never throws: a gate that could fail the
6536
+ * evaluation tick would take the notification pipeline with it.
6537
+ *
6538
+ * On failure the previous entries are KEPT — their timestamps are not
6539
+ * touched, so they age out on their own rather than vanishing at once.
6540
+ */
6541
+ refresh() {
6542
+ if (this.ids.length === 0) {
6543
+ this.entries = /* @__PURE__ */ new Map();
6544
+ this.noted.clear();
6545
+ return;
6546
+ }
6547
+ let observations;
6548
+ try {
6549
+ observations = this.deps.readScenes();
6550
+ } catch (err) {
6551
+ this.deps.logger.warn("scene-state refresh failed — keeping the previous states until they age out", { meta: {
6552
+ watched: this.ids.length,
6553
+ error: String(err)
6554
+ } });
6555
+ return;
6556
+ }
6557
+ const at = this.now();
6558
+ const wanted = new Set(this.ids);
6559
+ const next = /* @__PURE__ */ new Map();
6560
+ for (const observation of observations) {
6561
+ if (!wanted.has(observation.sceneId)) continue;
6562
+ next.set(observation.sceneId, {
6563
+ observation,
6564
+ at
6565
+ });
6566
+ }
6567
+ this.entries = next;
6568
+ for (const sceneId of this.ids) {
6569
+ if (next.has(sceneId)) continue;
6570
+ this.deps.logger.info("scene gate: no state for a watched scene", { meta: { sceneId } });
6571
+ }
6572
+ }
6573
+ };
6574
+ /**
6575
+ * Does this observation satisfy the rule's scene condition?
6576
+ *
6577
+ * Exported and pure so the semantics can be tested without a cache, a provider
6578
+ * or a rule — and so there is exactly ONE place that decides what `latched`
6579
+ * absent means. See `NcSceneConditionSchema` for the reasoning behind each
6580
+ * branch.
6581
+ */
6582
+ function sceneConditionSatisfied(condition, observation) {
6583
+ if (condition.latched ?? observation.emit === "latched") return observation.latched === (condition.requiredState === "diverged");
6584
+ return observation.verdict === condition.requiredState;
6585
+ }
6586
+ /** One-line "why", for the suppression log. Never a bare boolean: a drop the
6587
+ * operator cannot explain is the failure this whole feature is one misstep
6588
+ * away from. */
6589
+ function sceneGateReason(condition, observation) {
6590
+ if (observation === void 0) return "no mirrored state for this scene";
6591
+ return condition.latched ?? observation.emit === "latched" ? `latched=${observation.latched} (wanted ${condition.requiredState})` : `verdict=${observation.verdict} (wanted ${condition.requiredState})`;
6592
+ }
6488
6593
  //#endregion
6489
6594
  //#region src/notification-center/zone-ownership.ts
6490
6595
  var UNCHANGED = { kind: "unchanged" };
@@ -6821,6 +6926,7 @@ function presentConditionIds(c) {
6821
6926
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
6822
6927
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
6823
6928
  if (c.occupancy !== void 0) ids.push("occupancy");
6929
+ if (c.scene !== void 0) ids.push("scene");
6824
6930
  return ids;
6825
6931
  }
6826
6932
  /**
@@ -7000,7 +7106,7 @@ function matchesSystemEvent(condition, subject) {
7000
7106
  if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
7001
7107
  return true;
7002
7108
  }
7003
- function evaluateRule(rule, subject, deviceState, zoneOwners) {
7109
+ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
7004
7110
  if (subject.kind === "system-event") {
7005
7111
  if (rule.delivery !== "system-event") return fail("delivery");
7006
7112
  const condition = rule.conditions.systemEvent;
@@ -7041,6 +7147,11 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
7041
7147
  if (current === void 0) return fail("deviceState");
7042
7148
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
7043
7149
  }
7150
+ if (c.scene !== void 0) {
7151
+ const observation = sceneStates?.(c.scene.sceneId);
7152
+ if (observation === void 0) return fail("scene");
7153
+ if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
7154
+ }
7044
7155
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
7045
7156
  if (c.source !== void 0 && c.source !== "any") {
7046
7157
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -17644,6 +17755,22 @@ var NotificationCenter = class NotificationCenter {
17644
17755
  /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
17645
17756
  * read with no I/O on the persist hook. */
17646
17757
  deviceStates;
17758
+ /** Scene verdicts + latches the `scene` rule gate reads. Same contract as
17759
+ * {@link deviceStates}: refreshed on the reload tick and on the provider's
17760
+ * in-process edge tap, read with no I/O on the persist hook (D49). */
17761
+ sceneStates;
17762
+ /**
17763
+ * The last scene-gate answer REPORTED per rule, so the suppression line is
17764
+ * written on CHANGE rather than on every event.
17765
+ *
17766
+ * A branch that drops work silently must log it, and a gate that logged on
17767
+ * every detection on a busy camera would be a line nobody reads — which is
17768
+ * the same silence by a different route.
17769
+ */
17770
+ sceneGateReported = /* @__PURE__ */ new Map();
17771
+ /** The watched scene id set, as last REPORTED — so the line is written when it
17772
+ * changes and not on every reload tick. `null` = never reported. */
17773
+ sceneWatchSignature = null;
17647
17774
  /** WHO each device id is (name + type). The system-event intake reads it with
17648
17775
  * no I/O; the reload tick refreshes it — see `device-directory.ts`. */
17649
17776
  deviceDirectory;
@@ -18043,6 +18170,11 @@ var NotificationCenter = class NotificationCenter {
18043
18170
  readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
18044
18171
  ...deps.now !== void 0 ? { now: deps.now } : {}
18045
18172
  });
18173
+ this.sceneStates = new NcSceneStateCache({
18174
+ logger: this.logger.child("scene-state"),
18175
+ readScenes: deps.readSceneStates ?? (() => []),
18176
+ ...deps.now !== void 0 ? { now: deps.now } : {}
18177
+ });
18046
18178
  this.deviceDirectory = new NcDeviceDirectory({
18047
18179
  logger: this.logger.child("device-directory"),
18048
18180
  listDevices: deps.listDeviceIdentities ?? (async () => {
@@ -18242,6 +18374,7 @@ var NotificationCenter = class NotificationCenter {
18242
18374
  await this.deviceDirectory.refresh();
18243
18375
  await this.hydrateLiveness();
18244
18376
  await this.zoneOwners.refresh();
18377
+ this.sceneStates.refresh();
18245
18378
  this.evaluationActive = true;
18246
18379
  await this.outbox.load();
18247
18380
  await this.seedCooldowns();
@@ -19006,6 +19139,73 @@ var NotificationCenter = class NotificationCenter {
19006
19139
  if (identity.isCamera && identity.detectionActive === false) return "detection-off";
19007
19140
  }
19008
19141
  /**
19142
+ * SAY that the scene gate suppressed this rule.
19143
+ *
19144
+ * A branch that drops work silently logs it — the three-hour media blackout
19145
+ * produced not one line, and a gate is the same shape of failure: from the
19146
+ * operator's side "the rule stopped notifying" and "the pipeline stopped
19147
+ * working" look identical. `rule did not match` covers it at DEBUG, which is
19148
+ * not a level this installation runs at.
19149
+ *
19150
+ * On CHANGE only, per rule: the alternative is one line per detection on a
19151
+ * busy camera, which is silence by a different route. The `deviceId` tag is
19152
+ * the SUBJECT's — that is the camera whose notification went missing, and the
19153
+ * question is always asked per-camera. The SCENE's own device is named in the
19154
+ * meta beside its id, because the two are frequently different cameras.
19155
+ */
19156
+ reportSceneSuppression(rule, subject) {
19157
+ const condition = rule.conditions.scene;
19158
+ if (condition === void 0) return;
19159
+ const observation = this.sceneStates.get(condition.sceneId);
19160
+ const reason = sceneGateReason(condition, observation);
19161
+ if (this.sceneGateReported.get(rule.id) === reason) return;
19162
+ this.sceneGateReported.set(rule.id, reason);
19163
+ this.logger.info("rule suppressed by its scene condition", {
19164
+ tags: { deviceId: subject.deviceId },
19165
+ meta: {
19166
+ ruleId: rule.id,
19167
+ rule: rule.name,
19168
+ sceneId: condition.sceneId,
19169
+ sceneLabel: observation?.label,
19170
+ sceneDeviceId: observation?.deviceId ?? condition.deviceId,
19171
+ requiredState: condition.requiredState,
19172
+ reason
19173
+ }
19174
+ });
19175
+ }
19176
+ /**
19177
+ * A confirmed scene transition, straight from the provider in this process.
19178
+ *
19179
+ * Synchronous on purpose and for the same reason {@link onAlarmPublish} is:
19180
+ * the reload tick would otherwise be the only writer, and a rule gated on
19181
+ * "while the bin is out" would keep its old answer for up to thirty seconds
19182
+ * after the bin came back. The tick stays as reconciliation.
19183
+ *
19184
+ * A DIRECT CALL, not a bus subscription. `EnrichmentSceneStateChanged` is
19185
+ * still emitted for the badge and for anyone else, but D8 events are
19186
+ * droppable telemetry and a gate fed by droppable telemetry is a gate that is
19187
+ * sometimes wrong and never says so.
19188
+ *
19189
+ * This never OPENS a gate that the mirror had shut for lack of an entry: it
19190
+ * writes an observation the caller actually made. It is `refresh` that
19191
+ * removes entries, and it never empties on a failed read (D49).
19192
+ */
19193
+ onSceneEdge(observation) {
19194
+ this.sceneStates.note(observation);
19195
+ }
19196
+ /**
19197
+ * Reconcile the scene mirror now.
19198
+ *
19199
+ * The scene provider is constructed AFTER this centre starts (it needs the
19200
+ * media store and the embedding encoder), so the boot refresh reads an empty
19201
+ * provider. The addon calls this once the provider has hydrated, rather than
19202
+ * leaving every scene-gated rule shut for a whole reload tick after every
19203
+ * respawn.
19204
+ */
19205
+ refreshSceneStates() {
19206
+ this.sceneStates.refresh();
19207
+ }
19208
+ /**
19009
19209
  * Everything the panel reports, on every publish.
19010
19210
  *
19011
19211
  * Synchronous on purpose. The state half refreshes the `deviceState` gate's
@@ -19304,8 +19504,9 @@ var NotificationCenter = class NotificationCenter {
19304
19504
  let noOwnerReported = false;
19305
19505
  const rejections = [];
19306
19506
  for (const rule of candidates) {
19307
- const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id));
19507
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
19308
19508
  if (!evaluation.matched) {
19509
+ if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
19309
19510
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
19310
19511
  outcomes.push({
19311
19512
  ruleId: rule.id,
@@ -19330,6 +19531,7 @@ var NotificationCenter = class NotificationCenter {
19330
19531
  });
19331
19532
  continue;
19332
19533
  }
19534
+ this.sceneGateReported.delete(rule.id);
19333
19535
  this.runRuleActions(rule, subject, kind, controls);
19334
19536
  const admission = this.admitToGroup(rule, subject, now);
19335
19537
  if (admission.outcome === "unchanged") {
@@ -19961,10 +20163,13 @@ var NotificationCenter = class NotificationCenter {
19961
20163
  this.reportAudioWatch(audioWatch);
19962
20164
  const gated = [];
19963
20165
  const zoneScoped = [];
20166
+ const sceneIds = [];
19964
20167
  for (const rule of this.rules.list()) {
19965
20168
  if (!rule.enabled) continue;
19966
20169
  const ds = rule.conditions.deviceState;
19967
20170
  if (ds !== void 0) gated.push(ds.deviceId);
20171
+ const scene = rule.conditions.scene;
20172
+ if (scene !== void 0) sceneIds.push(scene.sceneId);
19968
20173
  const zones = rule.conditions.zones;
19969
20174
  const devices = rule.conditions.devices;
19970
20175
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
@@ -19973,6 +20178,15 @@ var NotificationCenter = class NotificationCenter {
19973
20178
  if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
19974
20179
  this.deviceStates.setWatched(gated);
19975
20180
  this.zoneOwners.setWatched(zoneScoped);
20181
+ this.sceneStates.setWatched(sceneIds);
20182
+ const signature = [...new Set(sceneIds)].sort().join(",");
20183
+ if (signature !== this.sceneWatchSignature) {
20184
+ this.sceneWatchSignature = signature;
20185
+ this.logger.info("scene gate watched set", { meta: {
20186
+ scenes: this.sceneStates.watched(),
20187
+ gatedRules: sceneIds.length
20188
+ } });
20189
+ }
19976
20190
  }
19977
20191
  /**
19978
20192
  * Feed every excluded sensor's mirrored state back to the panel.
@@ -20109,6 +20323,7 @@ var NotificationCenter = class NotificationCenter {
20109
20323
  await this.deviceStates.refresh();
20110
20324
  await this.deviceDirectory.refresh();
20111
20325
  await this.zoneOwners.refresh();
20326
+ this.sceneStates.refresh();
20112
20327
  this.observeAlarmContacts();
20113
20328
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
20114
20329
  }
@@ -20406,12 +20621,17 @@ var NotificationCenter = class NotificationCenter {
20406
20621
  const fresh = await this.deps.readDeviceZoneIds(zoneScope);
20407
20622
  zoneLookup = (id) => fresh.get(id) ?? this.zoneOwners.get(id);
20408
20623
  } catch {}
20624
+ let sceneLookup = (id) => this.sceneStates.get(id);
20625
+ if (rule.conditions.scene !== void 0 && this.deps.readSceneStates !== void 0) try {
20626
+ const fresh = new Map(this.deps.readSceneStates().map((o) => [o.sceneId, o]));
20627
+ sceneLookup = (id) => fresh.get(id) ?? this.sceneStates.get(id);
20628
+ } catch {}
20409
20629
  const occupancy = rule.conditions.occupancy;
20410
- if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup);
20630
+ if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup);
20411
20631
  const results = [];
20412
20632
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
20413
20633
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
20414
- const evaluation = evaluateRule(rule, subject, lookup, zoneLookup);
20634
+ const evaluation = evaluateRule(rule, subject, lookup, zoneLookup, sceneLookup);
20415
20635
  results.push({
20416
20636
  recordId: subject.recordId,
20417
20637
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" || subject.kind === "audio-window" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -20435,7 +20655,7 @@ var NotificationCenter = class NotificationCenter {
20435
20655
  * from the ordinary `evaluateRule` on the ordinary subject — this method
20436
20656
  * chooses the cameras and writes the sentence, nothing else.
20437
20657
  */
20438
- async occupancyDryRun(rule, occupancy, lookup, zoneLookup) {
20658
+ async occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup) {
20439
20659
  const read = this.deps.readOccupancySnapshots;
20440
20660
  if (read === void 0) return [];
20441
20661
  const now = this.now();
@@ -20478,7 +20698,7 @@ var NotificationCenter = class NotificationCenter {
20478
20698
  });
20479
20699
  continue;
20480
20700
  }
20481
- const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup);
20701
+ const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup, sceneLookup);
20482
20702
  results.push({
20483
20703
  ...common,
20484
20704
  recordId: probe.subject.recordId,
@@ -20733,6 +20953,10 @@ function planRuleTest(input) {
20733
20953
  conditionId: "deviceState",
20734
20954
  note: `This rule only fires while device #${c.deviceState.deviceId} is in one of its chosen states. That gate reads the REAL device and cannot be simulated — put the device in that state first, or the test fails closed exactly as production would.`
20735
20955
  });
20956
+ if (c.scene !== void 0) notes.push({
20957
+ conditionId: "scene",
20958
+ note: `This rule only fires while scene ${c.scene.sceneId} is ${c.scene.requiredState}. That gate reads the scene's REAL state and cannot be simulated — the test answers with whatever the scene is right now, and fails closed while it cannot judge or has not been read yet, exactly as production would.`
20959
+ });
20736
20960
  if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
20737
20961
  conditionId: "customZones",
20738
20962
  note: "The subject is placed over the first drawn polygon so the condition passes. A rule that needs a specific polygon should be checked in the simulator, where the box can be moved."
@@ -23242,6 +23466,88 @@ var embeddingActions = require_dist.defineCustomActions({
23242
23466
  "embedding.wipe": require_dist.customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
23243
23467
  });
23244
23468
  //#endregion
23469
+ //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
23470
+ /**
23471
+ * The operator button for the orphan audit.
23472
+ *
23473
+ * The audit engine, its five bounds and its resumable cursor already existed
23474
+ * and had run in `report` mode for weeks; what it had no way to be was
23475
+ * *asked*. Its only trigger was a 6-hourly timer reading a durable setting, so
23476
+ * draining a standing backlog meant flipping a persistent mode, waiting up to
23477
+ * six hours, and remembering to flip it back — with nothing returned to look
23478
+ * at. On 2026-08-14 the backlog was 74,700 media rows / 18.9 GB plus 23,296
23479
+ * object events and 4,543 CLIP vectors (D161), and that is not a thing to
23480
+ * drain by leaving a switch on.
23481
+ *
23482
+ * Three properties this action does NOT leave to the caller's care:
23483
+ *
23484
+ * 1. **Dry run is the default.** `mode` omitted means `report`: it counts,
23485
+ * logs and returns, and deletes nothing — regardless of what the durable
23486
+ * `orphanAuditMode` setting says. `reclaim` is a word that has to be
23487
+ * typed. The engine's own default is the same for the same reason.
23488
+ * 2. **It is paced.** `pageSize` bounds one read, `maxRowsPerScope` bounds
23489
+ * one pass, `maxReclaimPerScope` bounds the damage, and the engine yields
23490
+ * the thread between pages — `better-sqlite3` is synchronous and shares
23491
+ * hub-main with every live frame.
23492
+ * 3. **It answers.** The report comes back per collection with rows AND
23493
+ * bytes, so "did it do anything" is not a question anyone has to infer
23494
+ * from a log line.
23495
+ */
23496
+ /**
23497
+ * `report` counts; `reclaim` deletes.
23498
+ *
23499
+ * `.default('report')` is load-bearing, not decoration: the settings form
23500
+ * sends `{}` for a button with no fields, and the safe interpretation of an
23501
+ * unstated intent here is the one that destroys nothing.
23502
+ */
23503
+ var OrphanAuditActionModeSchema = require_dist._enum(["report", "reclaim"]).default("report");
23504
+ var OrphanAuditActionInputSchema = require_dist.object({
23505
+ mode: OrphanAuditActionModeSchema,
23506
+ /**
23507
+ * Start every scope from the top instead of resuming its persisted cursor.
23508
+ *
23509
+ * What draining a backlog wants, and never the default — the cursor exists
23510
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
23511
+ */
23512
+ restart: require_dist.boolean().optional(),
23513
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
23514
+ pageSize: require_dist.number().int().min(50).max(5e3).optional(),
23515
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
23516
+ maxRowsPerScope: require_dist.number().int().min(1).max(2e5).optional(),
23517
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
23518
+ maxReclaimPerScope: require_dist.number().int().min(1).max(1e5).optional(),
23519
+ /**
23520
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
23521
+ * exclusively save rows, never select them, which is what keeps this off
23522
+ * the one-age-keyed-sweep guard's books.
23523
+ */
23524
+ graceMinutes: require_dist.number().int().min(1).max(10080).optional()
23525
+ });
23526
+ var OrphanAuditScopeReportSchema = require_dist.object({
23527
+ collection: require_dist.string(),
23528
+ examined: require_dist.number(),
23529
+ orphans: require_dist.number(),
23530
+ reclaimed: require_dist.number(),
23531
+ bytesReclaimed: require_dist.number(),
23532
+ exempt: require_dist.number(),
23533
+ withinGrace: require_dist.number(),
23534
+ unresolvable: require_dist.number(),
23535
+ stoppedBy: require_dist.string(),
23536
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
23537
+ nextCursor: require_dist.number()
23538
+ });
23539
+ var OrphanAuditActionResultSchema = require_dist.object({
23540
+ /** `false` when a pass was already in flight — nothing was done. */
23541
+ ran: require_dist.boolean(),
23542
+ mode: require_dist._enum(["report", "reclaim"]),
23543
+ scopes: require_dist.array(OrphanAuditScopeReportSchema),
23544
+ totalOrphans: require_dist.number(),
23545
+ totalReclaimed: require_dist.number(),
23546
+ totalBytesReclaimed: require_dist.number(),
23547
+ durationMs: require_dist.number()
23548
+ });
23549
+ var orphanAuditActions = require_dist.defineCustomActions({ "retention.orphanAudit": require_dist.customAction(OrphanAuditActionInputSchema, OrphanAuditActionResultSchema, { auth: "admin" }) });
23550
+ //#endregion
23245
23551
  //#region src/pipeline-analytics/map-with-concurrency.ts
23246
23552
  /**
23247
23553
  * Apply `mapper` to every item with at most `limit` in flight at once.
@@ -25978,6 +26284,72 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
25978
26284
  }
25979
26285
  //#endregion
25980
26286
  //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
26287
+ var EMPTY_CASCADE_TALLY = {
26288
+ tracks: 0,
26289
+ events: 0,
26290
+ media: 0,
26291
+ faces: 0,
26292
+ plates: 0,
26293
+ embeddings: 0,
26294
+ bytesReclaimed: 0
26295
+ };
26296
+ /** Fold a leaf store's receipts into a running tally. */
26297
+ function addLeafCounts(tally, counts) {
26298
+ let next = tally;
26299
+ for (const c of counts) {
26300
+ const bytesReclaimed = next.bytesReclaimed + c.bytes;
26301
+ switch (c.family) {
26302
+ case "events":
26303
+ next = {
26304
+ ...next,
26305
+ events: next.events + c.rows,
26306
+ bytesReclaimed
26307
+ };
26308
+ break;
26309
+ case "media":
26310
+ next = {
26311
+ ...next,
26312
+ media: next.media + c.rows,
26313
+ bytesReclaimed
26314
+ };
26315
+ break;
26316
+ case "faces":
26317
+ next = {
26318
+ ...next,
26319
+ faces: next.faces + c.rows,
26320
+ bytesReclaimed
26321
+ };
26322
+ break;
26323
+ case "plates":
26324
+ next = {
26325
+ ...next,
26326
+ plates: next.plates + c.rows,
26327
+ bytesReclaimed
26328
+ };
26329
+ break;
26330
+ case "embeddings":
26331
+ next = {
26332
+ ...next,
26333
+ embeddings: next.embeddings + c.rows,
26334
+ bytesReclaimed
26335
+ };
26336
+ break;
26337
+ }
26338
+ }
26339
+ return next;
26340
+ }
26341
+ /** Sum two tallies (per-track results into a per-device one). */
26342
+ function mergeCascadeTallies(a, b) {
26343
+ return {
26344
+ tracks: a.tracks + b.tracks,
26345
+ events: a.events + b.events,
26346
+ media: a.media + b.media,
26347
+ faces: a.faces + b.faces,
26348
+ plates: a.plates + b.plates,
26349
+ embeddings: a.embeddings + b.embeddings,
26350
+ bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
26351
+ };
26352
+ }
25981
26353
  /**
25982
26354
  * Extensible whole-track deletion cascade. Runs every registered
25983
26355
  * `TrackScopedStore` (each removes its ephemeral rows + owned media for the
@@ -25994,8 +26366,16 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
25994
26366
  * ]
25995
26367
  */
25996
26368
  async function cascadeDeleteTracks(registry, trackStore, trackIds) {
25997
- for (const store of registry) await store.deleteByTracks(trackIds);
25998
- for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
26369
+ let tally = EMPTY_CASCADE_TALLY;
26370
+ for (const store of registry) tally = addLeafCounts(tally, await store.deleteByTracks(trackIds));
26371
+ for (const trackId of trackIds) {
26372
+ await trackStore.deletePersisted(trackId);
26373
+ tally = {
26374
+ ...tally,
26375
+ tracks: tally.tracks + 1
26376
+ };
26377
+ }
26378
+ return tally;
25999
26379
  }
26000
26380
  /**
26001
26381
  * The one registry, in the one order — leaves first, root deleted internally
@@ -26026,14 +26406,28 @@ function buildAnalyticsCascadeRegistry(stores) {
26026
26406
  * embeddings) is opted in by the CALLER's registry, not by this legacy shim.
26027
26407
  */
26028
26408
  async function cascadeDeleteTrack(stores, trackId) {
26029
- await cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
26409
+ return cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
26410
+ let events = 0;
26411
+ let media = 0;
26030
26412
  for (const id of ids) {
26031
26413
  const eventIds = await stores.eventStore.deleteByTrack(id);
26032
- if (eventIds.length > 0) await stores.mediaStore.deleteForEvents([...eventIds]);
26414
+ events += eventIds.length;
26415
+ if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
26033
26416
  }
26034
- } }, { deleteByTracks: async (ids) => {
26035
- await stores.mediaStore.deleteForTracks([...ids]);
26036
- } }], stores.trackStore, [trackId]);
26417
+ return [{
26418
+ family: "events",
26419
+ rows: events,
26420
+ bytes: 0
26421
+ }, {
26422
+ family: "media",
26423
+ rows: media,
26424
+ bytes: 0
26425
+ }];
26426
+ } }, { deleteByTracks: async (ids) => [{
26427
+ family: "media",
26428
+ rows: await stores.mediaStore.deleteForTracks([...ids]),
26429
+ bytes: 0
26430
+ }] }], stores.trackStore, [trackId]);
26037
26431
  }
26038
26432
  /**
26039
26433
  * Widened batch whole-track deletion — the ONE engine behind the three §5 entry
@@ -26044,9 +26438,10 @@ async function cascadeDeleteTrack(stores, trackId) {
26044
26438
  */
26045
26439
  async function runTrackCascadeBatch(deps, trackIds) {
26046
26440
  let deleted = 0;
26441
+ let tally = EMPTY_CASCADE_TALLY;
26047
26442
  const failed = [];
26048
26443
  for (const trackId of trackIds) try {
26049
- await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
26444
+ tally = mergeCascadeTallies(tally, await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]));
26050
26445
  deps.onTrackCleanup(deps.deviceId, trackId);
26051
26446
  deleted += 1;
26052
26447
  } catch (err) {
@@ -26055,7 +26450,8 @@ async function runTrackCascadeBatch(deps, trackIds) {
26055
26450
  }
26056
26451
  return {
26057
26452
  deleted,
26058
- failed
26453
+ failed,
26454
+ tally
26059
26455
  };
26060
26456
  }
26061
26457
  //#endregion
@@ -33678,21 +34074,33 @@ var MediaStore = class {
33678
34074
  * Best-effort per row; returns the number of index rows removed.
33679
34075
  */
33680
34076
  async deleteForOwner(ownerKind, ownerIds) {
34077
+ return (await this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds))).rows;
34078
+ }
34079
+ /**
34080
+ * The same delete, reporting the BYTES it released as well as the row count.
34081
+ *
34082
+ * `sizeBytes` is already on the index row, so the number costs nothing — it
34083
+ * was simply never returned, which is why every retention ops-log row this
34084
+ * addon has ever written says `bytesReclaimed: 0` (D161).
34085
+ */
34086
+ async deleteForOwnerCounted(ownerKind, ownerIds) {
33681
34087
  return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
33682
34088
  }
33683
34089
  async deleteForOwnerUnchecked(ownerKind, ownerIds) {
33684
- let removed = 0;
34090
+ let rows = 0;
34091
+ let bytes = 0;
33685
34092
  for (const ownerId of ownerIds) {
33686
- const rows = await this.store.query.query({
34093
+ const found = await this.store.query.query({
33687
34094
  collection: MEDIA_COLLECTION,
33688
34095
  filter: { where: {
33689
34096
  ownerKind,
33690
34097
  ownerId
33691
34098
  } }
33692
34099
  });
33693
- for (const row of rows) {
34100
+ for (const row of found) {
33694
34101
  const rowData = row.data;
33695
34102
  const path = String(rowData["path"] ?? "");
34103
+ const sizeBytes = Number(rowData["sizeBytes"]);
33696
34104
  try {
33697
34105
  if (path) await this.storage.delete({
33698
34106
  location: mediaRowLocation(rowData),
@@ -33704,7 +34112,8 @@ var MediaStore = class {
33704
34112
  collection: MEDIA_COLLECTION,
33705
34113
  key: row.id
33706
34114
  });
33707
- removed++;
34115
+ rows++;
34116
+ if (Number.isFinite(sizeBytes)) bytes += sizeBytes;
33708
34117
  } catch (err) {
33709
34118
  this.logger.debug("media delete-for-owner failed", { meta: {
33710
34119
  key: row.id,
@@ -33713,7 +34122,10 @@ var MediaStore = class {
33713
34122
  }
33714
34123
  }
33715
34124
  }
33716
- return removed;
34125
+ return {
34126
+ rows,
34127
+ bytes
34128
+ };
33717
34129
  }
33718
34130
  /**
33719
34131
  * On-demand footprint aggregation for the events-management UI: sum
@@ -33852,11 +34264,20 @@ var MediaStore = class {
33852
34264
  * batch.
33853
34265
  */
33854
34266
  async deleteByTracks(trackIds) {
33855
- await this.gated(async () => {
33856
- await this.deleteForOwnerUnchecked("track", trackIds);
33857
- await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
34267
+ const own = await this.gated(async () => {
34268
+ const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
34269
+ const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
34270
+ return {
34271
+ rows: tracks.rows + faces.rows,
34272
+ bytes: tracks.bytes + faces.bytes
34273
+ };
33858
34274
  });
33859
- await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
34275
+ const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
34276
+ return [{
34277
+ family: "media",
34278
+ rows: own.rows + plates.rows,
34279
+ bytes: own.bytes + plates.bytes
34280
+ }];
33860
34281
  }
33861
34282
  /**
33862
34283
  * A stable page of media INDEX rows for the orphan audit — no blobs, no
@@ -38294,11 +38715,21 @@ var EventStore = class {
38294
38715
  error: String(err)
38295
38716
  } });
38296
38717
  }
38718
+ let mediaRows = 0;
38297
38719
  if (eventIds.length > 0 && this.media !== void 0) try {
38298
- await this.media.deleteForEvents(eventIds);
38720
+ mediaRows = await this.media.deleteForEvents(eventIds);
38299
38721
  } catch (err) {
38300
38722
  this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
38301
38723
  }
38724
+ return [{
38725
+ family: "events",
38726
+ rows: eventIds.length,
38727
+ bytes: 0
38728
+ }, {
38729
+ family: "media",
38730
+ rows: mediaRows,
38731
+ bytes: 0
38732
+ }];
38302
38733
  }
38303
38734
  };
38304
38735
  function slimMotion(id, data) {
@@ -38689,6 +39120,7 @@ var FaceStore = class {
38689
39120
  * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
38690
39121
  */
38691
39122
  async deleteByTracks(trackIds) {
39123
+ let removed = 0;
38692
39124
  for (const trackId of trackIds) try {
38693
39125
  const eligible = (await this.store.query.query({
38694
39126
  collection: FACES_COLLECTION,
@@ -38699,6 +39131,7 @@ var FaceStore = class {
38699
39131
  collection: FACES_COLLECTION,
38700
39132
  key: row.id
38701
39133
  });
39134
+ removed++;
38702
39135
  } catch (err) {
38703
39136
  this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
38704
39137
  faceId: row.id,
@@ -38711,6 +39144,11 @@ var FaceStore = class {
38711
39144
  error: String(err)
38712
39145
  } });
38713
39146
  }
39147
+ return [{
39148
+ family: "faces",
39149
+ rows: removed,
39150
+ bytes: 0
39151
+ }];
38714
39152
  }
38715
39153
  /** Delete a single buffered face row by id (its crop media is removed by the
38716
39154
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
@@ -39646,6 +40084,7 @@ var PlateStore = class {
39646
40084
  * the batch. (Crop media of these rows is removed by `MediaStore`.)
39647
40085
  */
39648
40086
  async deleteByTracks(trackIds) {
40087
+ let removed = 0;
39649
40088
  for (const trackId of trackIds) try {
39650
40089
  const eligible = (await this.store.query.query({
39651
40090
  collection: PLATES_COLLECTION,
@@ -39656,6 +40095,7 @@ var PlateStore = class {
39656
40095
  collection: PLATES_COLLECTION,
39657
40096
  key: row.id
39658
40097
  });
40098
+ removed++;
39659
40099
  } catch (err) {
39660
40100
  this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
39661
40101
  plateId: row.id,
@@ -39668,6 +40108,11 @@ var PlateStore = class {
39668
40108
  error: String(err)
39669
40109
  } });
39670
40110
  }
40111
+ return [{
40112
+ family: "plates",
40113
+ rows: removed,
40114
+ bytes: 0
40115
+ }];
39671
40116
  }
39672
40117
  /**
39673
40118
  * Hold the unassigned plate buffer to `maxPerDevice` newest reads per camera.
@@ -40789,6 +41234,36 @@ var TrackStore = class {
40789
41234
  dropActive(trackId) {
40790
41235
  this.active.delete(trackId);
40791
41236
  }
41237
+ /**
41238
+ * Close a still-ACTIVE track NOW: persist its row, then drop the in-RAM
41239
+ * entry. The early-close counterpart of {@link expireStale}, for a caller
41240
+ * that knows the track is over before its TTL says so.
41241
+ *
41242
+ * **This exists because {@link dropActive} orphans everything the track
41243
+ * owns.** A track's media, object events and CLIP vector are keyed by trackId
41244
+ * and written throughout its life; the ROW is what retention cascades from,
41245
+ * and it is written only here or at expiry. Forgetting a lived-in track
41246
+ * therefore leaves a full media set — key frame, thumbnail, first/last frame,
41247
+ * the snapshot filmstrip — with no owner any sweep can ever reach. Measured on
41248
+ * the live hub 2026-08-14: 74,700 such media rows, 18.9 GB, growing
41249
+ * ~1.15 GB/day, plus 23,296 object events and 4,543 CLIP vectors (D161).
41250
+ *
41251
+ * `dropActive` is still correct for a birth RETRACTED before it lived —
41252
+ * nothing has been written under that id yet. It is never correct for a track
41253
+ * that ran.
41254
+ *
41255
+ * Returns whether an active entry was found. THROWS if the persist fails: the
41256
+ * caller must log it, because a swallowed failure here IS the leak.
41257
+ */
41258
+ async closeActive(trackId) {
41259
+ const t = this.active.get(trackId);
41260
+ if (t === void 0) return false;
41261
+ t.active = false;
41262
+ const record = cloneTrack(t);
41263
+ await this.persistCompleted(record);
41264
+ this.active.delete(trackId);
41265
+ return true;
41266
+ }
40792
41267
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
40793
41268
  * active entry if present. Used by the whole-track deletion cascade. */
40794
41269
  async deletePersisted(trackId) {
@@ -42139,6 +42614,7 @@ async function auditScope(scope, deps, limits, cursor) {
42139
42614
  } });
42140
42615
  }
42141
42616
  }
42617
+ if (deps.breathe) await deps.breathe();
42142
42618
  if (ceilingHit) {
42143
42619
  stoppedBy = "reclaim-ceiling";
42144
42620
  offset += rows.length - reclaimedInPage;
@@ -46914,7 +47390,11 @@ var ObjectEmbeddingStore = class {
46914
47390
  * breaks recognition (design §2/§4).
46915
47391
  */
46916
47392
  async deleteByTracks(trackIds) {
46917
- if (trackIds.length === 0) return;
47393
+ if (trackIds.length === 0) return [{
47394
+ family: "embeddings",
47395
+ rows: 0,
47396
+ bytes: 0
47397
+ }];
46918
47398
  try {
46919
47399
  await this.vectorStore.deleteByIds.mutate({
46920
47400
  index: OBJECT_CLIP_INDEX,
@@ -46925,7 +47405,17 @@ var ObjectEmbeddingStore = class {
46925
47405
  count: trackIds.length,
46926
47406
  error: String(err)
46927
47407
  } });
47408
+ return [{
47409
+ family: "embeddings",
47410
+ rows: 0,
47411
+ bytes: 0
47412
+ }];
46928
47413
  }
47414
+ return [{
47415
+ family: "embeddings",
47416
+ rows: trackIds.length,
47417
+ bytes: 0
47418
+ }];
46929
47419
  }
46930
47420
  };
46931
47421
  /** Build the vector-store metadata for one embedding. */
@@ -47799,7 +48289,8 @@ var NO_PUBLICATION = {
47799
48289
  var customActions = {
47800
48290
  ...ncActions,
47801
48291
  ...embeddingActions,
47802
- ...debugActions
48292
+ ...debugActions,
48293
+ ...orphanAuditActions
47803
48294
  };
47804
48295
  /**
47805
48296
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -48777,7 +49268,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
48777
49268
  customActions: {
48778
49269
  ...ncActions,
48779
49270
  ...embeddingActions,
48780
- ...debugActions
49271
+ ...debugActions,
49272
+ ...orphanAuditActions
48781
49273
  },
48782
49274
  actionHandlers: {
48783
49275
  ...ncHandlers,
@@ -49495,6 +49987,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
49495
49987
  }
49496
49988
  return out;
49497
49989
  },
49990
+ readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
49991
+ sceneId: monitor.id,
49992
+ deviceId,
49993
+ label: monitor.label,
49994
+ verdict: monitor.verdict,
49995
+ latched: monitor.latched,
49996
+ emit: monitor.emit
49997
+ })),
49498
49998
  readOccupancySnapshots: async (deviceIds) => {
49499
49999
  const za = this.zoneAnalytics;
49500
50000
  if (za === null) return /* @__PURE__ */ new Map();
@@ -50293,6 +50793,16 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50293
50793
  data: payload
50294
50794
  });
50295
50795
  },
50796
+ onSceneEdge: (edge) => {
50797
+ this.notificationCenter?.onSceneEdge({
50798
+ sceneId: edge.monitorId,
50799
+ deviceId: edge.deviceId,
50800
+ label: edge.monitorLabel,
50801
+ verdict: edge.verdict,
50802
+ latched: edge.latched,
50803
+ emit: edge.emit
50804
+ });
50805
+ },
50296
50806
  currentCondition: async (deviceId) => resolveCondition({
50297
50807
  ...await this.sceneConditionContext(deviceId),
50298
50808
  now: /* @__PURE__ */ new Date()
@@ -50301,6 +50811,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
50301
50811
  });
50302
50812
  provider.hydrate();
50303
50813
  this.sceneProvider = provider;
50814
+ this.notificationCenter?.refreshSceneStates();
50304
50815
  if (!this.isPostProcessingNode) {
50305
50816
  log.info("scene evaluation INERT on this node — CRUD only");
50306
50817
  return;
@@ -52762,7 +53273,17 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
52762
53273
  };
52763
53274
  if (plausible) this.stationaryRegistry?.promote(entry);
52764
53275
  processor.dropTrack(track.trackId);
52765
- this.trackStore?.dropActive(track.trackId);
53276
+ this.trackStore?.closeActive(track.trackId).catch((err) => {
53277
+ this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
53278
+ tags: { deviceId },
53279
+ meta: {
53280
+ deviceId,
53281
+ trackId: track.trackId,
53282
+ entryId: entry.id,
53283
+ error: String(err)
53284
+ }
53285
+ });
53286
+ });
52766
53287
  const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
52767
53288
  const dropKeyFrame = () => {
52768
53289
  this.residents.dropKeyFrameKey(track.trackId);
@@ -53352,19 +53873,19 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53352
53873
  * stops at a per-scope row ceiling, RESUMES from a persisted cursor next run,
53353
53874
  * and refuses to start if the previous run has not finished.
53354
53875
  */
53355
- async runOrphanAuditPass() {
53356
- if (this.shuttingDown) return;
53876
+ async runOrphanAuditPass(overrides) {
53877
+ if (this.shuttingDown) return null;
53357
53878
  if (this.orphanAuditRunning) {
53358
53879
  this.ctx.logger.info("analytics orphan audit skipped — previous run still in flight");
53359
- return;
53880
+ return null;
53360
53881
  }
53361
53882
  const trackStore = this.trackStore;
53362
53883
  const eventStore = this.eventStore;
53363
53884
  const mediaStore = this.mediaStore;
53364
- if (!trackStore || !eventStore || !mediaStore) return;
53885
+ if (!trackStore || !eventStore || !mediaStore) return null;
53365
53886
  this.orphanAuditRunning = true;
53366
53887
  try {
53367
- const limits = await this.orphanAuditLimits();
53888
+ const limits = await this.orphanAuditLimits(overrides);
53368
53889
  const deps = {
53369
53890
  page: async (collection, offset, limit) => {
53370
53891
  if (collection === "pipeline-analytics:media") return mediaStore.pageForAudit(offset, limit);
@@ -53396,13 +53917,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53396
53917
  });
53397
53918
  },
53398
53919
  now: () => Date.now(),
53399
- logger: this.ctx.logger
53920
+ logger: this.ctx.logger,
53921
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
53400
53922
  };
53401
53923
  const { report, cursors } = await runOrphanAudit({
53402
53924
  scopes: buildOrphanScopes(),
53403
53925
  deps,
53404
53926
  limits,
53405
- cursors: this.orphanAuditCursors
53927
+ cursors: overrides?.restart === true ? /* @__PURE__ */ new Map() : this.orphanAuditCursors
53406
53928
  });
53407
53929
  this.orphanAuditCursors = cursors;
53408
53930
  for (const scope of report.scopes) {
@@ -53437,8 +53959,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53437
53959
  bytesReclaimed: report.totalBytesReclaimed,
53438
53960
  detail: `orphan audit (${report.mode}): ${report.totalOrphans} orphaned rows across ${report.scopes.length} collections, ${report.totalReclaimed} reclaimed in ${report.durationMs}ms`
53439
53961
  });
53962
+ return report;
53440
53963
  } catch (err) {
53441
53964
  this.ctx.logger.warn("analytics orphan audit failed", { meta: { error: String(err) } });
53965
+ return null;
53442
53966
  } finally {
53443
53967
  this.orphanAuditRunning = false;
53444
53968
  }
@@ -53501,15 +54025,65 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53501
54025
  * raising them is raising the blast radius of a bug, and that should take a
53502
54026
  * release.
53503
54027
  */
53504
- async orphanAuditLimits() {
54028
+ async orphanAuditLimits(overrides) {
53505
54029
  const mode = await this.orphanAuditModeState.get();
53506
54030
  const pageSize = await this.orphanAuditPageSizeState.get();
54031
+ const graceMinutes = overrides?.graceMinutes;
53507
54032
  return {
53508
- mode: mode === "reclaim" ? "reclaim" : "report",
53509
- graceMs: DEFAULT_ORPHAN_LIMITS.graceMs,
53510
- pageSize,
53511
- maxRowsPerScope: DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
53512
- maxReclaimPerScope: DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
54033
+ mode: overrides !== void 0 ? overrides.mode ?? "report" : mode === "reclaim" ? "reclaim" : "report",
54034
+ graceMs: graceMinutes === void 0 ? DEFAULT_ORPHAN_LIMITS.graceMs : graceMinutes * 6e4,
54035
+ pageSize: overrides?.pageSize ?? pageSize,
54036
+ maxRowsPerScope: overrides?.maxRowsPerScope ?? DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
54037
+ maxReclaimPerScope: overrides?.maxReclaimPerScope ?? DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
54038
+ };
54039
+ }
54040
+ /**
54041
+ * One operator-driven orphan-audit pass — the entry point behind
54042
+ * `retention.orphanAudit`.
54043
+ *
54044
+ * DRY RUN BY DEFAULT: an input with no `mode` reports and deletes nothing,
54045
+ * whatever `orphanAuditMode` is set to. Reclaiming is a word the caller has to
54046
+ * type. Draining the standing 18.9 GB means running this with
54047
+ * `mode: 'reclaim'` repeatedly until `totalOrphans` reaches 0 (D161).
54048
+ */
54049
+ async runOrphanAuditNow(overrides) {
54050
+ const report = await this.runOrphanAuditPass(overrides);
54051
+ if (report === null) return {
54052
+ ran: false,
54053
+ mode: overrides.mode ?? "report",
54054
+ scopes: [],
54055
+ totalOrphans: 0,
54056
+ totalReclaimed: 0,
54057
+ totalBytesReclaimed: 0,
54058
+ durationMs: 0
54059
+ };
54060
+ this.ctx.logger.info("analytics orphan audit (operator)", { meta: {
54061
+ mode: report.mode,
54062
+ orphans: report.totalOrphans,
54063
+ reclaimed: report.totalReclaimed,
54064
+ bytesReclaimed: report.totalBytesReclaimed,
54065
+ durationMs: report.durationMs,
54066
+ restart: overrides.restart === true
54067
+ } });
54068
+ return {
54069
+ ran: true,
54070
+ mode: report.mode,
54071
+ scopes: report.scopes.map((scope) => ({
54072
+ collection: scope.collection,
54073
+ examined: scope.examined,
54074
+ orphans: scope.orphans,
54075
+ reclaimed: scope.reclaimed,
54076
+ bytesReclaimed: scope.bytesReclaimed,
54077
+ exempt: scope.exempt,
54078
+ withinGrace: scope.withinGrace,
54079
+ unresolvable: scope.unresolvable,
54080
+ stoppedBy: scope.stoppedBy,
54081
+ nextCursor: scope.nextCursor
54082
+ })),
54083
+ totalOrphans: report.totalOrphans,
54084
+ totalReclaimed: report.totalReclaimed,
54085
+ totalBytesReclaimed: report.totalBytesReclaimed,
54086
+ durationMs: report.durationMs
53513
54087
  };
53514
54088
  }
53515
54089
  /**
@@ -53709,7 +54283,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53709
54283
  indexed
53710
54284
  };
53711
54285
  },
53712
- "embedding.wipe": async () => this.wipeObjectEmbeddings()
54286
+ "embedding.wipe": async () => this.wipeObjectEmbeddings(),
54287
+ "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
53713
54288
  };
53714
54289
  }
53715
54290
  /** Progress of the detached rebuild — the only way to observe a long pass. */
@@ -53968,8 +54543,14 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53968
54543
  * Track-centric time-based retention (design §5.1). Drains every persisted
53969
54544
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
53970
54545
  * widened cascade — enrolled faces/plates + identity media are exempt (design
53971
- * §4). `tracks` is the authoritative count; leaf families are best-effort 0
53972
- * under the void `TrackScopedStore` contract (see cap doc).
54546
+ * §4).
54547
+ *
54548
+ * **Every family count here is REAL.** It used to return `tracks` and a
54549
+ * literal 0 for every leaf, because `TrackScopedStore.deleteByTracks` returned
54550
+ * `void` — so `bytesReclaimed: 0` went onto all 3,732 ops-log rows this addon
54551
+ * had ever written and 18.9 GB of leaked media read as "nothing to reclaim"
54552
+ * (D161). The bytes ride {@link lastPruneBytes} rather than
54553
+ * `TrackCascadeCounts`, which is a cap output shape.
53973
54554
  */
53974
54555
  async pruneTracksBefore(input) {
53975
54556
  const empty = {
@@ -53980,26 +54561,32 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
53980
54561
  plates: 0,
53981
54562
  embeddings: 0
53982
54563
  };
54564
+ this.lastPruneBytes = 0;
53983
54565
  const cascade = this.buildTrackCascadeRegistry();
53984
54566
  if (!cascade) return empty;
54567
+ let tally = EMPTY_CASCADE_TALLY;
53985
54568
  const { deleted: totalDeleted, skipped } = await drainTracksBefore({
53986
54569
  stopped: () => this.shuttingDown,
53987
54570
  listIdsBefore: (offset, limit) => cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, limit, offset),
53988
- cascade: (ids) => runTrackCascadeBatch({
53989
- registry: cascade.registry,
53990
- trackStore: cascade.trackStore,
53991
- deviceId: input.deviceId,
53992
- onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
53993
- onFailure: (trackId, err) => {
53994
- this.ctx.logger.debug("pruneTracksBefore track failed", {
53995
- tags: { deviceId: input.deviceId },
53996
- meta: {
53997
- trackId,
53998
- error: String(err)
53999
- }
54000
- });
54001
- }
54002
- }, ids)
54571
+ cascade: async (ids) => {
54572
+ const result = await runTrackCascadeBatch({
54573
+ registry: cascade.registry,
54574
+ trackStore: cascade.trackStore,
54575
+ deviceId: input.deviceId,
54576
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
54577
+ onFailure: (trackId, err) => {
54578
+ this.ctx.logger.debug("pruneTracksBefore track failed", {
54579
+ tags: { deviceId: input.deviceId },
54580
+ meta: {
54581
+ trackId,
54582
+ error: String(err)
54583
+ }
54584
+ });
54585
+ }
54586
+ }, ids);
54587
+ tally = mergeCascadeTallies(tally, result.tally);
54588
+ return { deleted: result.deleted };
54589
+ }
54003
54590
  });
54004
54591
  if (skipped > 0) this.ctx.logger.warn("analytics track retention skipped undeletable rows", {
54005
54592
  tags: { deviceId: input.deviceId },
@@ -54010,20 +54597,41 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54010
54597
  pruned: totalDeleted
54011
54598
  }
54012
54599
  });
54600
+ this.lastPruneBytes = tally.bytesReclaimed;
54013
54601
  if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
54014
54602
  tags: { deviceId: input.deviceId },
54015
54603
  meta: {
54016
54604
  deviceId: input.deviceId,
54017
54605
  cutoffMs: input.cutoffMs,
54018
- tracks: totalDeleted
54606
+ tracks: totalDeleted,
54607
+ events: tally.events,
54608
+ media: tally.media,
54609
+ faces: tally.faces,
54610
+ plates: tally.plates,
54611
+ embeddings: tally.embeddings,
54612
+ bytesReclaimed: tally.bytesReclaimed
54019
54613
  }
54020
54614
  });
54021
54615
  return {
54022
54616
  ...empty,
54023
- tracks: totalDeleted
54617
+ tracks: totalDeleted,
54618
+ events: tally.events,
54619
+ media: tally.media,
54620
+ faces: tally.faces,
54621
+ plates: tally.plates,
54622
+ embeddings: tally.embeddings
54024
54623
  };
54025
54624
  }
54026
54625
  /**
54626
+ * Bytes released by the most recent {@link pruneTracksBefore}.
54627
+ *
54628
+ * A field rather than a return value because `TrackCascadeCounts` is a cap
54629
+ * OUTPUT shape and widening it is a codegen change; the ops-log row already
54630
+ * has a `bytesReclaimed` column and this is what fills it. Reset to 0 at the
54631
+ * top of every prune, so a stale value can never be reported as a fresh one.
54632
+ */
54633
+ lastPruneBytes = 0;
54634
+ /**
54027
54635
  * Operator "clean slate" for a device (design §5.3): prune EVERY persisted
54028
54636
  * track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
54029
54637
  * Enrolled gallery + identity media are exempt.
@@ -54077,8 +54685,8 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
54077
54685
  reason: "retention",
54078
54686
  deviceId,
54079
54687
  itemsAffected: counts.tracks,
54080
- bytesReclaimed: 0,
54081
- detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
54688
+ bytesReclaimed: this.lastPruneBytes,
54689
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media, ${counts.faces} faces, ${counts.plates} plates, ${counts.embeddings} vectors`
54082
54690
  });
54083
54691
  return counts;
54084
54692
  },