@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.
@@ -1,4 +1,4 @@
1
- import { $ as isDetectionMacroClass, At as unknown, B as alarmPanelCapability, C as NcSnoozeSchema, Ct as discriminatedUnion, D as OpsLogEntrySchema, Dt as partialRecord, E as NcTaxonomySchema, Et as object, F as TimelapseRuleInputSchema, G as cosineSimilarity$1, H as audioMetricsCapability, I as TimelapseRulePatchSchema, J as deriveRecordingMode, K as customAction, L as TimelapseRuleSchema, M as SCENE_DIVERGED, N as SceneMonitorSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as record, P as TIMELAPSE_DENSE_FLOOR_SEC, R as TrackSourceSchema, S as NcSnoozeInputSchema, St as boolean, T as NcSystemEventKindSchema, Tt as number, U as audioModeOf, V as assertTimelapseCadences, W as buildEventKindDescriptor, X as encodeVectorBase64, Z as faceGalleryCapability, _ as NcRuleInputSchema, _t as isDeviceScopedCap, at as readDeviceStateFrom, b as NcRuleTargetSchema, bt as _enum, c as EVENT_PAD_MS, ct as subKindsOf, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as zoneAnalyticsCapability, et as isScheduleActive, f as NC_CONDITION_CATALOG, ft as errMsg, g as NcConditionDescriptorSchema, gt as hydrateSchema, h as NC_TAXONOMY, ht as createEvent, i as DETECTION_MACRO_CLASSES, it as plateGalleryCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as EventCategory, k as RetrainStatusSchema, kt as string, l as LabelAttributionSchema, lt as vectorDimFromBase64, mt as DeviceType, n as DEFAULT_EVENT_COLOR, nt as notificationRulesCapability, o as DeclaredDevices, ot as readTimelapseGeneratedAt, p as NC_DEFAULT_SNOOZE_MINUTES, pt as BaseAddon, q as defineCustomActions, rt as pipelineAnalyticsCapability, s as EVENT_KIND_BY_CAP, st as sceneMonitorCapability, t as BaseDevice, tt as kebabToCamel, u as MACRO_LABELS, ut as videoclipsCapability, v as NcRulePatchSchema, vt as nodePin, w as NcSnoozeSuppressedSchema, wt as literal, x as NcScheduleSchema, xt as array, y as NcRuleSchema, yt as sleep, z as addonWidgetsSourceCapability } from "../dist-CYfG8ulU.mjs";
1
+ import { $ as isDetectionMacroClass, At as unknown, B as alarmPanelCapability, C as NcSnoozeSchema, Ct as discriminatedUnion, D as OpsLogEntrySchema, Dt as partialRecord, E as NcTaxonomySchema, Et as object, F as TimelapseRuleInputSchema, G as cosineSimilarity$1, H as audioMetricsCapability, I as TimelapseRulePatchSchema, J as deriveRecordingMode, K as customAction, L as TimelapseRuleSchema, M as SCENE_DIVERGED, N as SceneMonitorSchema, O as RECORDING_EXPORT_MAX_READ_BYTES, Ot as record, P as TIMELAPSE_DENSE_FLOOR_SEC, R as TrackSourceSchema, S as NcSnoozeInputSchema, St as boolean, T as NcSystemEventKindSchema, Tt as number, U as audioModeOf, V as assertTimelapseCadences, W as buildEventKindDescriptor, X as encodeVectorBase64, Z as faceGalleryCapability, _ as NcRuleInputSchema, _t as isDeviceScopedCap, at as readDeviceStateFrom, b as NcRuleTargetSchema, bt as _enum, c as EVENT_PAD_MS, ct as subKindsOf, d as NC_ALARM_SYSTEM_EVENT_KINDS, dt as zoneAnalyticsCapability, et as isScheduleActive, f as NC_CONDITION_CATALOG, ft as errMsg, g as NcConditionDescriptorSchema, gt as hydrateSchema, h as NC_TAXONOMY, ht as createEvent, i as DETECTION_MACRO_CLASSES, it as plateGalleryCapability, j as SCENE_DEFAULT_ANCHOR_THRESHOLD, jt as EventCategory, k as RetrainStatusSchema, kt as string, l as LabelAttributionSchema, lt as vectorDimFromBase64, mt as DeviceType, n as DEFAULT_EVENT_COLOR, nt as notificationRulesCapability, o as DeclaredDevices, ot as readTimelapseGeneratedAt, p as NC_DEFAULT_SNOOZE_MINUTES, pt as BaseAddon, q as defineCustomActions, rt as pipelineAnalyticsCapability, s as EVENT_KIND_BY_CAP, st as sceneMonitorCapability, t as BaseDevice, tt as kebabToCamel, u as MACRO_LABELS, ut as videoclipsCapability, v as NcRulePatchSchema, vt as nodePin, w as NcSnoozeSuppressedSchema, wt as literal, x as NcScheduleSchema, xt as array, y as NcRuleSchema, yt as sleep, z as addonWidgetsSourceCapability } from "../dist-CxeFHSSV.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -4815,6 +4815,7 @@ var SceneMonitorProvider = class {
4815
4815
  direction,
4816
4816
  verdict: result.verdict,
4817
4817
  latched,
4818
+ emit: monitor.emit,
4818
4819
  condition: result.currentCondition,
4819
4820
  confidence: result.lastConfidence,
4820
4821
  timestamp: result.checkedAt
@@ -6479,6 +6480,110 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
6479
6480
  if (totalMaskPixels === 0) return 0;
6480
6481
  return insidePolygon / totalMaskPixels;
6481
6482
  }
6483
+ var NcSceneStateCache = class {
6484
+ deps;
6485
+ entries = /* @__PURE__ */ new Map();
6486
+ noted = /* @__PURE__ */ new Map();
6487
+ ids = [];
6488
+ now;
6489
+ constructor(deps) {
6490
+ this.deps = deps;
6491
+ this.now = deps.now ?? (() => Date.now());
6492
+ }
6493
+ /**
6494
+ * The scene ids worth reading — recomputed from the rule set, so a cluster
6495
+ * with no scene-gated rule does no work at all and logs nothing.
6496
+ */
6497
+ setWatched(ids) {
6498
+ this.ids = [...new Set(ids)].sort();
6499
+ }
6500
+ watched() {
6501
+ return this.ids;
6502
+ }
6503
+ /**
6504
+ * "This scene, in this process, is in this state, NOW."
6505
+ *
6506
+ * Synchronous by contract — the caller is the scene provider's own edge tap
6507
+ * and the next rule evaluation must already see it.
6508
+ */
6509
+ note(observation) {
6510
+ this.noted.set(observation.sceneId, {
6511
+ observation,
6512
+ at: this.now()
6513
+ });
6514
+ }
6515
+ /** Hot-path read. No I/O, no await — this is called per event. */
6516
+ get(sceneId) {
6517
+ const now = this.now();
6518
+ const noted = this.noted.get(sceneId);
6519
+ if (noted !== void 0) {
6520
+ if (now - noted.at <= 3e5) return noted.observation;
6521
+ this.noted.delete(sceneId);
6522
+ }
6523
+ const entry = this.entries.get(sceneId);
6524
+ if (entry === void 0) return void 0;
6525
+ if (now - entry.at > 3e5) return void 0;
6526
+ return entry.observation;
6527
+ }
6528
+ /**
6529
+ * Re-read every watched scene. Never throws: a gate that could fail the
6530
+ * evaluation tick would take the notification pipeline with it.
6531
+ *
6532
+ * On failure the previous entries are KEPT — their timestamps are not
6533
+ * touched, so they age out on their own rather than vanishing at once.
6534
+ */
6535
+ refresh() {
6536
+ if (this.ids.length === 0) {
6537
+ this.entries = /* @__PURE__ */ new Map();
6538
+ this.noted.clear();
6539
+ return;
6540
+ }
6541
+ let observations;
6542
+ try {
6543
+ observations = this.deps.readScenes();
6544
+ } catch (err) {
6545
+ this.deps.logger.warn("scene-state refresh failed — keeping the previous states until they age out", { meta: {
6546
+ watched: this.ids.length,
6547
+ error: String(err)
6548
+ } });
6549
+ return;
6550
+ }
6551
+ const at = this.now();
6552
+ const wanted = new Set(this.ids);
6553
+ const next = /* @__PURE__ */ new Map();
6554
+ for (const observation of observations) {
6555
+ if (!wanted.has(observation.sceneId)) continue;
6556
+ next.set(observation.sceneId, {
6557
+ observation,
6558
+ at
6559
+ });
6560
+ }
6561
+ this.entries = next;
6562
+ for (const sceneId of this.ids) {
6563
+ if (next.has(sceneId)) continue;
6564
+ this.deps.logger.info("scene gate: no state for a watched scene", { meta: { sceneId } });
6565
+ }
6566
+ }
6567
+ };
6568
+ /**
6569
+ * Does this observation satisfy the rule's scene condition?
6570
+ *
6571
+ * Exported and pure so the semantics can be tested without a cache, a provider
6572
+ * or a rule — and so there is exactly ONE place that decides what `latched`
6573
+ * absent means. See `NcSceneConditionSchema` for the reasoning behind each
6574
+ * branch.
6575
+ */
6576
+ function sceneConditionSatisfied(condition, observation) {
6577
+ if (condition.latched ?? observation.emit === "latched") return observation.latched === (condition.requiredState === "diverged");
6578
+ return observation.verdict === condition.requiredState;
6579
+ }
6580
+ /** One-line "why", for the suppression log. Never a bare boolean: a drop the
6581
+ * operator cannot explain is the failure this whole feature is one misstep
6582
+ * away from. */
6583
+ function sceneGateReason(condition, observation) {
6584
+ if (observation === void 0) return "no mirrored state for this scene";
6585
+ return condition.latched ?? observation.emit === "latched" ? `latched=${observation.latched} (wanted ${condition.requiredState})` : `verdict=${observation.verdict} (wanted ${condition.requiredState})`;
6586
+ }
6482
6587
  //#endregion
6483
6588
  //#region src/notification-center/zone-ownership.ts
6484
6589
  var UNCHANGED = { kind: "unchanged" };
@@ -6815,6 +6920,7 @@ function presentConditionIds(c) {
6815
6920
  if (c.packagePhase !== void 0 && c.packagePhase !== "both") ids.push("packagePhase");
6816
6921
  if (c.customZones !== void 0 && c.customZones.length > 0) ids.push("customZones");
6817
6922
  if (c.occupancy !== void 0) ids.push("occupancy");
6923
+ if (c.scene !== void 0) ids.push("scene");
6818
6924
  return ids;
6819
6925
  }
6820
6926
  /**
@@ -6994,7 +7100,7 @@ function matchesSystemEvent(condition, subject) {
6994
7100
  if (condition.packageNames !== void 0 && (subject.packageName === void 0 || !condition.packageNames.includes(subject.packageName))) return false;
6995
7101
  return true;
6996
7102
  }
6997
- function evaluateRule(rule, subject, deviceState, zoneOwners) {
7103
+ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
6998
7104
  if (subject.kind === "system-event") {
6999
7105
  if (rule.delivery !== "system-event") return fail("delivery");
7000
7106
  const condition = rule.conditions.systemEvent;
@@ -7035,6 +7141,11 @@ function evaluateRule(rule, subject, deviceState, zoneOwners) {
7035
7141
  if (current === void 0) return fail("deviceState");
7036
7142
  if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
7037
7143
  }
7144
+ if (c.scene !== void 0) {
7145
+ const observation = sceneStates?.(c.scene.sceneId);
7146
+ if (observation === void 0) return fail("scene");
7147
+ if (!sceneConditionSatisfied(c.scene, observation)) return fail("scene");
7148
+ }
7038
7149
  if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
7039
7150
  if (c.source !== void 0 && c.source !== "any") {
7040
7151
  if ((subject.source ?? "pipeline") !== c.source) return fail("source");
@@ -17623,6 +17734,22 @@ var NotificationCenter = class NotificationCenter {
17623
17734
  /** States the `deviceState` rule gate reads. Refreshed on the reload tick;
17624
17735
  * read with no I/O on the persist hook. */
17625
17736
  deviceStates;
17737
+ /** Scene verdicts + latches the `scene` rule gate reads. Same contract as
17738
+ * {@link deviceStates}: refreshed on the reload tick and on the provider's
17739
+ * in-process edge tap, read with no I/O on the persist hook (D49). */
17740
+ sceneStates;
17741
+ /**
17742
+ * The last scene-gate answer REPORTED per rule, so the suppression line is
17743
+ * written on CHANGE rather than on every event.
17744
+ *
17745
+ * A branch that drops work silently must log it, and a gate that logged on
17746
+ * every detection on a busy camera would be a line nobody reads — which is
17747
+ * the same silence by a different route.
17748
+ */
17749
+ sceneGateReported = /* @__PURE__ */ new Map();
17750
+ /** The watched scene id set, as last REPORTED — so the line is written when it
17751
+ * changes and not on every reload tick. `null` = never reported. */
17752
+ sceneWatchSignature = null;
17626
17753
  /** WHO each device id is (name + type). The system-event intake reads it with
17627
17754
  * no I/O; the reload tick refreshes it — see `device-directory.ts`. */
17628
17755
  deviceDirectory;
@@ -18022,6 +18149,11 @@ var NotificationCenter = class NotificationCenter {
18022
18149
  readStates: deps.readDeviceStates ?? (async () => /* @__PURE__ */ new Map()),
18023
18150
  ...deps.now !== void 0 ? { now: deps.now } : {}
18024
18151
  });
18152
+ this.sceneStates = new NcSceneStateCache({
18153
+ logger: this.logger.child("scene-state"),
18154
+ readScenes: deps.readSceneStates ?? (() => []),
18155
+ ...deps.now !== void 0 ? { now: deps.now } : {}
18156
+ });
18025
18157
  this.deviceDirectory = new NcDeviceDirectory({
18026
18158
  logger: this.logger.child("device-directory"),
18027
18159
  listDevices: deps.listDeviceIdentities ?? (async () => {
@@ -18221,6 +18353,7 @@ var NotificationCenter = class NotificationCenter {
18221
18353
  await this.deviceDirectory.refresh();
18222
18354
  await this.hydrateLiveness();
18223
18355
  await this.zoneOwners.refresh();
18356
+ this.sceneStates.refresh();
18224
18357
  this.evaluationActive = true;
18225
18358
  await this.outbox.load();
18226
18359
  await this.seedCooldowns();
@@ -18985,6 +19118,73 @@ var NotificationCenter = class NotificationCenter {
18985
19118
  if (identity.isCamera && identity.detectionActive === false) return "detection-off";
18986
19119
  }
18987
19120
  /**
19121
+ * SAY that the scene gate suppressed this rule.
19122
+ *
19123
+ * A branch that drops work silently logs it — the three-hour media blackout
19124
+ * produced not one line, and a gate is the same shape of failure: from the
19125
+ * operator's side "the rule stopped notifying" and "the pipeline stopped
19126
+ * working" look identical. `rule did not match` covers it at DEBUG, which is
19127
+ * not a level this installation runs at.
19128
+ *
19129
+ * On CHANGE only, per rule: the alternative is one line per detection on a
19130
+ * busy camera, which is silence by a different route. The `deviceId` tag is
19131
+ * the SUBJECT's — that is the camera whose notification went missing, and the
19132
+ * question is always asked per-camera. The SCENE's own device is named in the
19133
+ * meta beside its id, because the two are frequently different cameras.
19134
+ */
19135
+ reportSceneSuppression(rule, subject) {
19136
+ const condition = rule.conditions.scene;
19137
+ if (condition === void 0) return;
19138
+ const observation = this.sceneStates.get(condition.sceneId);
19139
+ const reason = sceneGateReason(condition, observation);
19140
+ if (this.sceneGateReported.get(rule.id) === reason) return;
19141
+ this.sceneGateReported.set(rule.id, reason);
19142
+ this.logger.info("rule suppressed by its scene condition", {
19143
+ tags: { deviceId: subject.deviceId },
19144
+ meta: {
19145
+ ruleId: rule.id,
19146
+ rule: rule.name,
19147
+ sceneId: condition.sceneId,
19148
+ sceneLabel: observation?.label,
19149
+ sceneDeviceId: observation?.deviceId ?? condition.deviceId,
19150
+ requiredState: condition.requiredState,
19151
+ reason
19152
+ }
19153
+ });
19154
+ }
19155
+ /**
19156
+ * A confirmed scene transition, straight from the provider in this process.
19157
+ *
19158
+ * Synchronous on purpose and for the same reason {@link onAlarmPublish} is:
19159
+ * the reload tick would otherwise be the only writer, and a rule gated on
19160
+ * "while the bin is out" would keep its old answer for up to thirty seconds
19161
+ * after the bin came back. The tick stays as reconciliation.
19162
+ *
19163
+ * A DIRECT CALL, not a bus subscription. `EnrichmentSceneStateChanged` is
19164
+ * still emitted for the badge and for anyone else, but D8 events are
19165
+ * droppable telemetry and a gate fed by droppable telemetry is a gate that is
19166
+ * sometimes wrong and never says so.
19167
+ *
19168
+ * This never OPENS a gate that the mirror had shut for lack of an entry: it
19169
+ * writes an observation the caller actually made. It is `refresh` that
19170
+ * removes entries, and it never empties on a failed read (D49).
19171
+ */
19172
+ onSceneEdge(observation) {
19173
+ this.sceneStates.note(observation);
19174
+ }
19175
+ /**
19176
+ * Reconcile the scene mirror now.
19177
+ *
19178
+ * The scene provider is constructed AFTER this centre starts (it needs the
19179
+ * media store and the embedding encoder), so the boot refresh reads an empty
19180
+ * provider. The addon calls this once the provider has hydrated, rather than
19181
+ * leaving every scene-gated rule shut for a whole reload tick after every
19182
+ * respawn.
19183
+ */
19184
+ refreshSceneStates() {
19185
+ this.sceneStates.refresh();
19186
+ }
19187
+ /**
18988
19188
  * Everything the panel reports, on every publish.
18989
19189
  *
18990
19190
  * Synchronous on purpose. The state half refreshes the `deviceState` gate's
@@ -19283,8 +19483,9 @@ var NotificationCenter = class NotificationCenter {
19283
19483
  let noOwnerReported = false;
19284
19484
  const rejections = [];
19285
19485
  for (const rule of candidates) {
19286
- const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id));
19486
+ const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
19287
19487
  if (!evaluation.matched) {
19488
+ if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
19288
19489
  rejections.push(`${rule.name}:${evaluation.failedCondition ?? "unknown"}`);
19289
19490
  outcomes.push({
19290
19491
  ruleId: rule.id,
@@ -19309,6 +19510,7 @@ var NotificationCenter = class NotificationCenter {
19309
19510
  });
19310
19511
  continue;
19311
19512
  }
19513
+ this.sceneGateReported.delete(rule.id);
19312
19514
  this.runRuleActions(rule, subject, kind, controls);
19313
19515
  const admission = this.admitToGroup(rule, subject, now);
19314
19516
  if (admission.outcome === "unchanged") {
@@ -19940,10 +20142,13 @@ var NotificationCenter = class NotificationCenter {
19940
20142
  this.reportAudioWatch(audioWatch);
19941
20143
  const gated = [];
19942
20144
  const zoneScoped = [];
20145
+ const sceneIds = [];
19943
20146
  for (const rule of this.rules.list()) {
19944
20147
  if (!rule.enabled) continue;
19945
20148
  const ds = rule.conditions.deviceState;
19946
20149
  if (ds !== void 0) gated.push(ds.deviceId);
20150
+ const scene = rule.conditions.scene;
20151
+ if (scene !== void 0) sceneIds.push(scene.sceneId);
19947
20152
  const zones = rule.conditions.zones;
19948
20153
  const devices = rule.conditions.devices;
19949
20154
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
@@ -19952,6 +20157,15 @@ var NotificationCenter = class NotificationCenter {
19952
20157
  if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
19953
20158
  this.deviceStates.setWatched(gated);
19954
20159
  this.zoneOwners.setWatched(zoneScoped);
20160
+ this.sceneStates.setWatched(sceneIds);
20161
+ const signature = [...new Set(sceneIds)].sort().join(",");
20162
+ if (signature !== this.sceneWatchSignature) {
20163
+ this.sceneWatchSignature = signature;
20164
+ this.logger.info("scene gate watched set", { meta: {
20165
+ scenes: this.sceneStates.watched(),
20166
+ gatedRules: sceneIds.length
20167
+ } });
20168
+ }
19955
20169
  }
19956
20170
  /**
19957
20171
  * Feed every excluded sensor's mirrored state back to the panel.
@@ -20088,6 +20302,7 @@ var NotificationCenter = class NotificationCenter {
20088
20302
  await this.deviceStates.refresh();
20089
20303
  await this.deviceDirectory.refresh();
20090
20304
  await this.zoneOwners.refresh();
20305
+ this.sceneStates.refresh();
20091
20306
  this.observeAlarmContacts();
20092
20307
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
20093
20308
  }
@@ -20385,12 +20600,17 @@ var NotificationCenter = class NotificationCenter {
20385
20600
  const fresh = await this.deps.readDeviceZoneIds(zoneScope);
20386
20601
  zoneLookup = (id) => fresh.get(id) ?? this.zoneOwners.get(id);
20387
20602
  } catch {}
20603
+ let sceneLookup = (id) => this.sceneStates.get(id);
20604
+ if (rule.conditions.scene !== void 0 && this.deps.readSceneStates !== void 0) try {
20605
+ const fresh = new Map(this.deps.readSceneStates().map((o) => [o.sceneId, o]));
20606
+ sceneLookup = (id) => fresh.get(id) ?? this.sceneStates.get(id);
20607
+ } catch {}
20388
20608
  const occupancy = rule.conditions.occupancy;
20389
- if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup);
20609
+ if (rule.delivery === "device-event" && occupancy !== void 0) return this.occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup);
20390
20610
  const results = [];
20391
20611
  for (const subject of subjects.sort((a, b) => b.timestamp - a.timestamp)) {
20392
20612
  if (results.length >= TEST_RULE_MAX_RESULTS) break;
20393
- const evaluation = evaluateRule(rule, subject, lookup, zoneLookup);
20613
+ const evaluation = evaluateRule(rule, subject, lookup, zoneLookup, sceneLookup);
20394
20614
  results.push({
20395
20615
  recordId: subject.recordId,
20396
20616
  recordKind: subject.kind === "track-end" ? "track" : subject.kind === "audio-event" || subject.kind === "audio-window" ? "object-event" : subject.kind === "occupancy-event" ? "device-event" : subject.kind,
@@ -20414,7 +20634,7 @@ var NotificationCenter = class NotificationCenter {
20414
20634
  * from the ordinary `evaluateRule` on the ordinary subject — this method
20415
20635
  * chooses the cameras and writes the sentence, nothing else.
20416
20636
  */
20417
- async occupancyDryRun(rule, occupancy, lookup, zoneLookup) {
20637
+ async occupancyDryRun(rule, occupancy, lookup, zoneLookup, sceneLookup) {
20418
20638
  const read = this.deps.readOccupancySnapshots;
20419
20639
  if (read === void 0) return [];
20420
20640
  const now = this.now();
@@ -20457,7 +20677,7 @@ var NotificationCenter = class NotificationCenter {
20457
20677
  });
20458
20678
  continue;
20459
20679
  }
20460
- const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup);
20680
+ const evaluation = evaluateRule(rule, probe.subject, lookup, zoneLookup, sceneLookup);
20461
20681
  results.push({
20462
20682
  ...common,
20463
20683
  recordId: probe.subject.recordId,
@@ -20712,6 +20932,10 @@ function planRuleTest(input) {
20712
20932
  conditionId: "deviceState",
20713
20933
  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.`
20714
20934
  });
20935
+ if (c.scene !== void 0) notes.push({
20936
+ conditionId: "scene",
20937
+ 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.`
20938
+ });
20715
20939
  if (c.customZones !== void 0 && c.customZones.length > 0) notes.push({
20716
20940
  conditionId: "customZones",
20717
20941
  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."
@@ -23220,6 +23444,51 @@ var embeddingActions = defineCustomActions({
23220
23444
  "embedding.rebuildStatus": customAction(NoInputSchema, RebuildStatusSchema),
23221
23445
  "embedding.wipe": customAction(NoInputSchema, WipeResultSchema, { auth: "admin" })
23222
23446
  });
23447
+ var OrphanAuditActionInputSchema = object({
23448
+ mode: _enum(["report", "reclaim"]).default("report"),
23449
+ /**
23450
+ * Start every scope from the top instead of resuming its persisted cursor.
23451
+ *
23452
+ * What draining a backlog wants, and never the default — the cursor exists
23453
+ * so a 6-hourly pass makes progress instead of re-walking the same head.
23454
+ */
23455
+ restart: boolean().optional(),
23456
+ /** Rows read per statement. Smaller = more yields = a gentler pass. */
23457
+ pageSize: number().int().min(50).max(5e3).optional(),
23458
+ /** Rows EXAMINED per collection in this pass — the pacing bound. */
23459
+ maxRowsPerScope: number().int().min(1).max(2e5).optional(),
23460
+ /** Rows DELETED per collection in this pass — the blast-radius bound. */
23461
+ maxReclaimPerScope: number().int().min(1).max(1e5).optional(),
23462
+ /**
23463
+ * Minutes of immunity for a recent row. Protective ONLY — raising it can
23464
+ * exclusively save rows, never select them, which is what keeps this off
23465
+ * the one-age-keyed-sweep guard's books.
23466
+ */
23467
+ graceMinutes: number().int().min(1).max(10080).optional()
23468
+ });
23469
+ var OrphanAuditScopeReportSchema = object({
23470
+ collection: string(),
23471
+ examined: number(),
23472
+ orphans: number(),
23473
+ reclaimed: number(),
23474
+ bytesReclaimed: number(),
23475
+ exempt: number(),
23476
+ withinGrace: number(),
23477
+ unresolvable: number(),
23478
+ stoppedBy: string(),
23479
+ /** Where the NEXT pass resumes. `0` = the scope was walked to the end. */
23480
+ nextCursor: number()
23481
+ });
23482
+ var orphanAuditActions = defineCustomActions({ "retention.orphanAudit": customAction(OrphanAuditActionInputSchema, object({
23483
+ /** `false` when a pass was already in flight — nothing was done. */
23484
+ ran: boolean(),
23485
+ mode: _enum(["report", "reclaim"]),
23486
+ scopes: array(OrphanAuditScopeReportSchema),
23487
+ totalOrphans: number(),
23488
+ totalReclaimed: number(),
23489
+ totalBytesReclaimed: number(),
23490
+ durationMs: number()
23491
+ }), { auth: "admin" }) });
23223
23492
  //#endregion
23224
23493
  //#region src/pipeline-analytics/map-with-concurrency.ts
23225
23494
  /**
@@ -25957,6 +26226,72 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
25957
26226
  }
25958
26227
  //#endregion
25959
26228
  //#region src/pipeline-analytics/pipeline/delete-track-cascade.ts
26229
+ var EMPTY_CASCADE_TALLY = {
26230
+ tracks: 0,
26231
+ events: 0,
26232
+ media: 0,
26233
+ faces: 0,
26234
+ plates: 0,
26235
+ embeddings: 0,
26236
+ bytesReclaimed: 0
26237
+ };
26238
+ /** Fold a leaf store's receipts into a running tally. */
26239
+ function addLeafCounts(tally, counts) {
26240
+ let next = tally;
26241
+ for (const c of counts) {
26242
+ const bytesReclaimed = next.bytesReclaimed + c.bytes;
26243
+ switch (c.family) {
26244
+ case "events":
26245
+ next = {
26246
+ ...next,
26247
+ events: next.events + c.rows,
26248
+ bytesReclaimed
26249
+ };
26250
+ break;
26251
+ case "media":
26252
+ next = {
26253
+ ...next,
26254
+ media: next.media + c.rows,
26255
+ bytesReclaimed
26256
+ };
26257
+ break;
26258
+ case "faces":
26259
+ next = {
26260
+ ...next,
26261
+ faces: next.faces + c.rows,
26262
+ bytesReclaimed
26263
+ };
26264
+ break;
26265
+ case "plates":
26266
+ next = {
26267
+ ...next,
26268
+ plates: next.plates + c.rows,
26269
+ bytesReclaimed
26270
+ };
26271
+ break;
26272
+ case "embeddings":
26273
+ next = {
26274
+ ...next,
26275
+ embeddings: next.embeddings + c.rows,
26276
+ bytesReclaimed
26277
+ };
26278
+ break;
26279
+ }
26280
+ }
26281
+ return next;
26282
+ }
26283
+ /** Sum two tallies (per-track results into a per-device one). */
26284
+ function mergeCascadeTallies(a, b) {
26285
+ return {
26286
+ tracks: a.tracks + b.tracks,
26287
+ events: a.events + b.events,
26288
+ media: a.media + b.media,
26289
+ faces: a.faces + b.faces,
26290
+ plates: a.plates + b.plates,
26291
+ embeddings: a.embeddings + b.embeddings,
26292
+ bytesReclaimed: a.bytesReclaimed + b.bytesReclaimed
26293
+ };
26294
+ }
25960
26295
  /**
25961
26296
  * Extensible whole-track deletion cascade. Runs every registered
25962
26297
  * `TrackScopedStore` (each removes its ephemeral rows + owned media for the
@@ -25973,8 +26308,16 @@ function decideZeroMediaPolicy(input, config = DEFAULT_ZERO_MEDIA_POLICY_CONFIG)
25973
26308
  * ]
25974
26309
  */
25975
26310
  async function cascadeDeleteTracks(registry, trackStore, trackIds) {
25976
- for (const store of registry) await store.deleteByTracks(trackIds);
25977
- for (const trackId of trackIds) await trackStore.deletePersisted(trackId);
26311
+ let tally = EMPTY_CASCADE_TALLY;
26312
+ for (const store of registry) tally = addLeafCounts(tally, await store.deleteByTracks(trackIds));
26313
+ for (const trackId of trackIds) {
26314
+ await trackStore.deletePersisted(trackId);
26315
+ tally = {
26316
+ ...tally,
26317
+ tracks: tally.tracks + 1
26318
+ };
26319
+ }
26320
+ return tally;
25978
26321
  }
25979
26322
  /**
25980
26323
  * The one registry, in the one order — leaves first, root deleted internally
@@ -26005,14 +26348,28 @@ function buildAnalyticsCascadeRegistry(stores) {
26005
26348
  * embeddings) is opted in by the CALLER's registry, not by this legacy shim.
26006
26349
  */
26007
26350
  async function cascadeDeleteTrack(stores, trackId) {
26008
- await cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
26351
+ return cascadeDeleteTracks([{ deleteByTracks: async (ids) => {
26352
+ let events = 0;
26353
+ let media = 0;
26009
26354
  for (const id of ids) {
26010
26355
  const eventIds = await stores.eventStore.deleteByTrack(id);
26011
- if (eventIds.length > 0) await stores.mediaStore.deleteForEvents([...eventIds]);
26356
+ events += eventIds.length;
26357
+ if (eventIds.length > 0) media += await stores.mediaStore.deleteForEvents([...eventIds]);
26012
26358
  }
26013
- } }, { deleteByTracks: async (ids) => {
26014
- await stores.mediaStore.deleteForTracks([...ids]);
26015
- } }], stores.trackStore, [trackId]);
26359
+ return [{
26360
+ family: "events",
26361
+ rows: events,
26362
+ bytes: 0
26363
+ }, {
26364
+ family: "media",
26365
+ rows: media,
26366
+ bytes: 0
26367
+ }];
26368
+ } }, { deleteByTracks: async (ids) => [{
26369
+ family: "media",
26370
+ rows: await stores.mediaStore.deleteForTracks([...ids]),
26371
+ bytes: 0
26372
+ }] }], stores.trackStore, [trackId]);
26016
26373
  }
26017
26374
  /**
26018
26375
  * Widened batch whole-track deletion — the ONE engine behind the three §5 entry
@@ -26023,9 +26380,10 @@ async function cascadeDeleteTrack(stores, trackId) {
26023
26380
  */
26024
26381
  async function runTrackCascadeBatch(deps, trackIds) {
26025
26382
  let deleted = 0;
26383
+ let tally = EMPTY_CASCADE_TALLY;
26026
26384
  const failed = [];
26027
26385
  for (const trackId of trackIds) try {
26028
- await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]);
26386
+ tally = mergeCascadeTallies(tally, await cascadeDeleteTracks(deps.registry, deps.trackStore, [trackId]));
26029
26387
  deps.onTrackCleanup(deps.deviceId, trackId);
26030
26388
  deleted += 1;
26031
26389
  } catch (err) {
@@ -26034,7 +26392,8 @@ async function runTrackCascadeBatch(deps, trackIds) {
26034
26392
  }
26035
26393
  return {
26036
26394
  deleted,
26037
- failed
26395
+ failed,
26396
+ tally
26038
26397
  };
26039
26398
  }
26040
26399
  //#endregion
@@ -33657,21 +34016,33 @@ var MediaStore = class {
33657
34016
  * Best-effort per row; returns the number of index rows removed.
33658
34017
  */
33659
34018
  async deleteForOwner(ownerKind, ownerIds) {
34019
+ return (await this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds))).rows;
34020
+ }
34021
+ /**
34022
+ * The same delete, reporting the BYTES it released as well as the row count.
34023
+ *
34024
+ * `sizeBytes` is already on the index row, so the number costs nothing — it
34025
+ * was simply never returned, which is why every retention ops-log row this
34026
+ * addon has ever written says `bytesReclaimed: 0` (D161).
34027
+ */
34028
+ async deleteForOwnerCounted(ownerKind, ownerIds) {
33660
34029
  return this.gated(() => this.deleteForOwnerUnchecked(ownerKind, ownerIds));
33661
34030
  }
33662
34031
  async deleteForOwnerUnchecked(ownerKind, ownerIds) {
33663
- let removed = 0;
34032
+ let rows = 0;
34033
+ let bytes = 0;
33664
34034
  for (const ownerId of ownerIds) {
33665
- const rows = await this.store.query.query({
34035
+ const found = await this.store.query.query({
33666
34036
  collection: MEDIA_COLLECTION,
33667
34037
  filter: { where: {
33668
34038
  ownerKind,
33669
34039
  ownerId
33670
34040
  } }
33671
34041
  });
33672
- for (const row of rows) {
34042
+ for (const row of found) {
33673
34043
  const rowData = row.data;
33674
34044
  const path = String(rowData["path"] ?? "");
34045
+ const sizeBytes = Number(rowData["sizeBytes"]);
33675
34046
  try {
33676
34047
  if (path) await this.storage.delete({
33677
34048
  location: mediaRowLocation(rowData),
@@ -33683,7 +34054,8 @@ var MediaStore = class {
33683
34054
  collection: MEDIA_COLLECTION,
33684
34055
  key: row.id
33685
34056
  });
33686
- removed++;
34057
+ rows++;
34058
+ if (Number.isFinite(sizeBytes)) bytes += sizeBytes;
33687
34059
  } catch (err) {
33688
34060
  this.logger.debug("media delete-for-owner failed", { meta: {
33689
34061
  key: row.id,
@@ -33692,7 +34064,10 @@ var MediaStore = class {
33692
34064
  }
33693
34065
  }
33694
34066
  }
33695
- return removed;
34067
+ return {
34068
+ rows,
34069
+ bytes
34070
+ };
33696
34071
  }
33697
34072
  /**
33698
34073
  * On-demand footprint aggregation for the events-management UI: sum
@@ -33831,11 +34206,20 @@ var MediaStore = class {
33831
34206
  * batch.
33832
34207
  */
33833
34208
  async deleteByTracks(trackIds) {
33834
- await this.gated(async () => {
33835
- await this.deleteForOwnerUnchecked("track", trackIds);
33836
- await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
34209
+ const own = await this.gated(async () => {
34210
+ const tracks = await this.deleteForOwnerUnchecked("track", trackIds);
34211
+ const faces = await this.deleteForOwnerUnchecked("face", trackIds.map((id) => FACE_MEDIA_OWNER_PREFIX + id));
34212
+ return {
34213
+ rows: tracks.rows + faces.rows,
34214
+ bytes: tracks.bytes + faces.bytes
34215
+ };
33837
34216
  });
33838
- await this.deleteForOwner("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
34217
+ const plates = await this.deleteForOwnerCounted("plate", trackIds.map((id) => PLATE_MEDIA_OWNER_PREFIX + id));
34218
+ return [{
34219
+ family: "media",
34220
+ rows: own.rows + plates.rows,
34221
+ bytes: own.bytes + plates.bytes
34222
+ }];
33839
34223
  }
33840
34224
  /**
33841
34225
  * A stable page of media INDEX rows for the orphan audit — no blobs, no
@@ -38273,11 +38657,21 @@ var EventStore = class {
38273
38657
  error: String(err)
38274
38658
  } });
38275
38659
  }
38660
+ let mediaRows = 0;
38276
38661
  if (eventIds.length > 0 && this.media !== void 0) try {
38277
- await this.media.deleteForEvents(eventIds);
38662
+ mediaRows = await this.media.deleteForEvents(eventIds);
38278
38663
  } catch (err) {
38279
38664
  this.logger.warn("EventStore.deleteByTracks: event media delete failed", { meta: { error: String(err) } });
38280
38665
  }
38666
+ return [{
38667
+ family: "events",
38668
+ rows: eventIds.length,
38669
+ bytes: 0
38670
+ }, {
38671
+ family: "media",
38672
+ rows: mediaRows,
38673
+ bytes: 0
38674
+ }];
38281
38675
  }
38282
38676
  };
38283
38677
  function slimMotion(id, data) {
@@ -38668,6 +39062,7 @@ var FaceStore = class {
38668
39062
  * aborts the batch. (Crop media of these rows is removed by `MediaStore`.)
38669
39063
  */
38670
39064
  async deleteByTracks(trackIds) {
39065
+ let removed = 0;
38671
39066
  for (const trackId of trackIds) try {
38672
39067
  const eligible = (await this.store.query.query({
38673
39068
  collection: FACES_COLLECTION,
@@ -38678,6 +39073,7 @@ var FaceStore = class {
38678
39073
  collection: FACES_COLLECTION,
38679
39074
  key: row.id
38680
39075
  });
39076
+ removed++;
38681
39077
  } catch (err) {
38682
39078
  this.logger.warn("FaceStore.deleteByTracks delete failed", { meta: {
38683
39079
  faceId: row.id,
@@ -38690,6 +39086,11 @@ var FaceStore = class {
38690
39086
  error: String(err)
38691
39087
  } });
38692
39088
  }
39089
+ return [{
39090
+ family: "faces",
39091
+ rows: removed,
39092
+ bytes: 0
39093
+ }];
38693
39094
  }
38694
39095
  /** Delete a single buffered face row by id (its crop media is removed by the
38695
39096
  * caller — the FaceStore owns rows, not blobs). Best-effort. */
@@ -39625,6 +40026,7 @@ var PlateStore = class {
39625
40026
  * the batch. (Crop media of these rows is removed by `MediaStore`.)
39626
40027
  */
39627
40028
  async deleteByTracks(trackIds) {
40029
+ let removed = 0;
39628
40030
  for (const trackId of trackIds) try {
39629
40031
  const eligible = (await this.store.query.query({
39630
40032
  collection: PLATES_COLLECTION,
@@ -39635,6 +40037,7 @@ var PlateStore = class {
39635
40037
  collection: PLATES_COLLECTION,
39636
40038
  key: row.id
39637
40039
  });
40040
+ removed++;
39638
40041
  } catch (err) {
39639
40042
  this.logger.warn("PlateStore.deleteByTracks delete failed", { meta: {
39640
40043
  plateId: row.id,
@@ -39647,6 +40050,11 @@ var PlateStore = class {
39647
40050
  error: String(err)
39648
40051
  } });
39649
40052
  }
40053
+ return [{
40054
+ family: "plates",
40055
+ rows: removed,
40056
+ bytes: 0
40057
+ }];
39650
40058
  }
39651
40059
  /**
39652
40060
  * Hold the unassigned plate buffer to `maxPerDevice` newest reads per camera.
@@ -40768,6 +41176,36 @@ var TrackStore = class {
40768
41176
  dropActive(trackId) {
40769
41177
  this.active.delete(trackId);
40770
41178
  }
41179
+ /**
41180
+ * Close a still-ACTIVE track NOW: persist its row, then drop the in-RAM
41181
+ * entry. The early-close counterpart of {@link expireStale}, for a caller
41182
+ * that knows the track is over before its TTL says so.
41183
+ *
41184
+ * **This exists because {@link dropActive} orphans everything the track
41185
+ * owns.** A track's media, object events and CLIP vector are keyed by trackId
41186
+ * and written throughout its life; the ROW is what retention cascades from,
41187
+ * and it is written only here or at expiry. Forgetting a lived-in track
41188
+ * therefore leaves a full media set — key frame, thumbnail, first/last frame,
41189
+ * the snapshot filmstrip — with no owner any sweep can ever reach. Measured on
41190
+ * the live hub 2026-08-14: 74,700 such media rows, 18.9 GB, growing
41191
+ * ~1.15 GB/day, plus 23,296 object events and 4,543 CLIP vectors (D161).
41192
+ *
41193
+ * `dropActive` is still correct for a birth RETRACTED before it lived —
41194
+ * nothing has been written under that id yet. It is never correct for a track
41195
+ * that ran.
41196
+ *
41197
+ * Returns whether an active entry was found. THROWS if the persist fails: the
41198
+ * caller must log it, because a swallowed failure here IS the leak.
41199
+ */
41200
+ async closeActive(trackId) {
41201
+ const t = this.active.get(trackId);
41202
+ if (t === void 0) return false;
41203
+ t.active = false;
41204
+ const record = cloneTrack(t);
41205
+ await this.persistCompleted(record);
41206
+ this.active.delete(trackId);
41207
+ return true;
41208
+ }
40771
41209
  /** Delete the persisted track row (keyed by trackId) and drop the in-RAM
40772
41210
  * active entry if present. Used by the whole-track deletion cascade. */
40773
41211
  async deletePersisted(trackId) {
@@ -42118,6 +42556,7 @@ async function auditScope(scope, deps, limits, cursor) {
42118
42556
  } });
42119
42557
  }
42120
42558
  }
42559
+ if (deps.breathe) await deps.breathe();
42121
42560
  if (ceilingHit) {
42122
42561
  stoppedBy = "reclaim-ceiling";
42123
42562
  offset += rows.length - reclaimedInPage;
@@ -46893,7 +47332,11 @@ var ObjectEmbeddingStore = class {
46893
47332
  * breaks recognition (design §2/§4).
46894
47333
  */
46895
47334
  async deleteByTracks(trackIds) {
46896
- if (trackIds.length === 0) return;
47335
+ if (trackIds.length === 0) return [{
47336
+ family: "embeddings",
47337
+ rows: 0,
47338
+ bytes: 0
47339
+ }];
46897
47340
  try {
46898
47341
  await this.vectorStore.deleteByIds.mutate({
46899
47342
  index: OBJECT_CLIP_INDEX,
@@ -46904,7 +47347,17 @@ var ObjectEmbeddingStore = class {
46904
47347
  count: trackIds.length,
46905
47348
  error: String(err)
46906
47349
  } });
47350
+ return [{
47351
+ family: "embeddings",
47352
+ rows: 0,
47353
+ bytes: 0
47354
+ }];
46907
47355
  }
47356
+ return [{
47357
+ family: "embeddings",
47358
+ rows: trackIds.length,
47359
+ bytes: 0
47360
+ }];
46908
47361
  }
46909
47362
  };
46910
47363
  /** Build the vector-store metadata for one embedding. */
@@ -47778,7 +48231,8 @@ var NO_PUBLICATION = {
47778
48231
  var customActions = {
47779
48232
  ...ncActions,
47780
48233
  ...embeddingActions,
47781
- ...debugActions
48234
+ ...debugActions,
48235
+ ...orphanAuditActions
47782
48236
  };
47783
48237
  /**
47784
48238
  * Assist threshold when the caller does not pin one. Packages have their own
@@ -48756,7 +49210,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
48756
49210
  customActions: {
48757
49211
  ...ncActions,
48758
49212
  ...embeddingActions,
48759
- ...debugActions
49213
+ ...debugActions,
49214
+ ...orphanAuditActions
48760
49215
  },
48761
49216
  actionHandlers: {
48762
49217
  ...ncHandlers,
@@ -49474,6 +49929,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
49474
49929
  }
49475
49930
  return out;
49476
49931
  },
49932
+ readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
49933
+ sceneId: monitor.id,
49934
+ deviceId,
49935
+ label: monitor.label,
49936
+ verdict: monitor.verdict,
49937
+ latched: monitor.latched,
49938
+ emit: monitor.emit
49939
+ })),
49477
49940
  readOccupancySnapshots: async (deviceIds) => {
49478
49941
  const za = this.zoneAnalytics;
49479
49942
  if (za === null) return /* @__PURE__ */ new Map();
@@ -50272,6 +50735,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50272
50735
  data: payload
50273
50736
  });
50274
50737
  },
50738
+ onSceneEdge: (edge) => {
50739
+ this.notificationCenter?.onSceneEdge({
50740
+ sceneId: edge.monitorId,
50741
+ deviceId: edge.deviceId,
50742
+ label: edge.monitorLabel,
50743
+ verdict: edge.verdict,
50744
+ latched: edge.latched,
50745
+ emit: edge.emit
50746
+ });
50747
+ },
50275
50748
  currentCondition: async (deviceId) => resolveCondition({
50276
50749
  ...await this.sceneConditionContext(deviceId),
50277
50750
  now: /* @__PURE__ */ new Date()
@@ -50280,6 +50753,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
50280
50753
  });
50281
50754
  provider.hydrate();
50282
50755
  this.sceneProvider = provider;
50756
+ this.notificationCenter?.refreshSceneStates();
50283
50757
  if (!this.isPostProcessingNode) {
50284
50758
  log.info("scene evaluation INERT on this node — CRUD only");
50285
50759
  return;
@@ -52741,7 +53215,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
52741
53215
  };
52742
53216
  if (plausible) this.stationaryRegistry?.promote(entry);
52743
53217
  processor.dropTrack(track.trackId);
52744
- this.trackStore?.dropActive(track.trackId);
53218
+ this.trackStore?.closeActive(track.trackId).catch((err) => {
53219
+ this.ctx.logger.warn("stationary promotion: closing the source track failed — its media, events and vector are now unowned", {
53220
+ tags: { deviceId },
53221
+ meta: {
53222
+ deviceId,
53223
+ trackId: track.trackId,
53224
+ entryId: entry.id,
53225
+ error: String(err)
53226
+ }
53227
+ });
53228
+ });
52745
53229
  const faceEnd = this.faceRecognizer?.onTrackEnd(deviceId, track.trackId);
52746
53230
  const dropKeyFrame = () => {
52747
53231
  this.residents.dropKeyFrameKey(track.trackId);
@@ -53331,19 +53815,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53331
53815
  * stops at a per-scope row ceiling, RESUMES from a persisted cursor next run,
53332
53816
  * and refuses to start if the previous run has not finished.
53333
53817
  */
53334
- async runOrphanAuditPass() {
53335
- if (this.shuttingDown) return;
53818
+ async runOrphanAuditPass(overrides) {
53819
+ if (this.shuttingDown) return null;
53336
53820
  if (this.orphanAuditRunning) {
53337
53821
  this.ctx.logger.info("analytics orphan audit skipped — previous run still in flight");
53338
- return;
53822
+ return null;
53339
53823
  }
53340
53824
  const trackStore = this.trackStore;
53341
53825
  const eventStore = this.eventStore;
53342
53826
  const mediaStore = this.mediaStore;
53343
- if (!trackStore || !eventStore || !mediaStore) return;
53827
+ if (!trackStore || !eventStore || !mediaStore) return null;
53344
53828
  this.orphanAuditRunning = true;
53345
53829
  try {
53346
- const limits = await this.orphanAuditLimits();
53830
+ const limits = await this.orphanAuditLimits(overrides);
53347
53831
  const deps = {
53348
53832
  page: async (collection, offset, limit) => {
53349
53833
  if (collection === "pipeline-analytics:media") return mediaStore.pageForAudit(offset, limit);
@@ -53375,13 +53859,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53375
53859
  });
53376
53860
  },
53377
53861
  now: () => Date.now(),
53378
- logger: this.ctx.logger
53862
+ logger: this.ctx.logger,
53863
+ breathe: () => new Promise((resolve) => setImmediate(resolve))
53379
53864
  };
53380
53865
  const { report, cursors } = await runOrphanAudit({
53381
53866
  scopes: buildOrphanScopes(),
53382
53867
  deps,
53383
53868
  limits,
53384
- cursors: this.orphanAuditCursors
53869
+ cursors: overrides?.restart === true ? /* @__PURE__ */ new Map() : this.orphanAuditCursors
53385
53870
  });
53386
53871
  this.orphanAuditCursors = cursors;
53387
53872
  for (const scope of report.scopes) {
@@ -53416,8 +53901,10 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53416
53901
  bytesReclaimed: report.totalBytesReclaimed,
53417
53902
  detail: `orphan audit (${report.mode}): ${report.totalOrphans} orphaned rows across ${report.scopes.length} collections, ${report.totalReclaimed} reclaimed in ${report.durationMs}ms`
53418
53903
  });
53904
+ return report;
53419
53905
  } catch (err) {
53420
53906
  this.ctx.logger.warn("analytics orphan audit failed", { meta: { error: String(err) } });
53907
+ return null;
53421
53908
  } finally {
53422
53909
  this.orphanAuditRunning = false;
53423
53910
  }
@@ -53480,15 +53967,65 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53480
53967
  * raising them is raising the blast radius of a bug, and that should take a
53481
53968
  * release.
53482
53969
  */
53483
- async orphanAuditLimits() {
53970
+ async orphanAuditLimits(overrides) {
53484
53971
  const mode = await this.orphanAuditModeState.get();
53485
53972
  const pageSize = await this.orphanAuditPageSizeState.get();
53973
+ const graceMinutes = overrides?.graceMinutes;
53974
+ return {
53975
+ mode: overrides !== void 0 ? overrides.mode ?? "report" : mode === "reclaim" ? "reclaim" : "report",
53976
+ graceMs: graceMinutes === void 0 ? DEFAULT_ORPHAN_LIMITS.graceMs : graceMinutes * 6e4,
53977
+ pageSize: overrides?.pageSize ?? pageSize,
53978
+ maxRowsPerScope: overrides?.maxRowsPerScope ?? DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
53979
+ maxReclaimPerScope: overrides?.maxReclaimPerScope ?? DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
53980
+ };
53981
+ }
53982
+ /**
53983
+ * One operator-driven orphan-audit pass — the entry point behind
53984
+ * `retention.orphanAudit`.
53985
+ *
53986
+ * DRY RUN BY DEFAULT: an input with no `mode` reports and deletes nothing,
53987
+ * whatever `orphanAuditMode` is set to. Reclaiming is a word the caller has to
53988
+ * type. Draining the standing 18.9 GB means running this with
53989
+ * `mode: 'reclaim'` repeatedly until `totalOrphans` reaches 0 (D161).
53990
+ */
53991
+ async runOrphanAuditNow(overrides) {
53992
+ const report = await this.runOrphanAuditPass(overrides);
53993
+ if (report === null) return {
53994
+ ran: false,
53995
+ mode: overrides.mode ?? "report",
53996
+ scopes: [],
53997
+ totalOrphans: 0,
53998
+ totalReclaimed: 0,
53999
+ totalBytesReclaimed: 0,
54000
+ durationMs: 0
54001
+ };
54002
+ this.ctx.logger.info("analytics orphan audit (operator)", { meta: {
54003
+ mode: report.mode,
54004
+ orphans: report.totalOrphans,
54005
+ reclaimed: report.totalReclaimed,
54006
+ bytesReclaimed: report.totalBytesReclaimed,
54007
+ durationMs: report.durationMs,
54008
+ restart: overrides.restart === true
54009
+ } });
53486
54010
  return {
53487
- mode: mode === "reclaim" ? "reclaim" : "report",
53488
- graceMs: DEFAULT_ORPHAN_LIMITS.graceMs,
53489
- pageSize,
53490
- maxRowsPerScope: DEFAULT_ORPHAN_LIMITS.maxRowsPerScope,
53491
- maxReclaimPerScope: DEFAULT_ORPHAN_LIMITS.maxReclaimPerScope
54011
+ ran: true,
54012
+ mode: report.mode,
54013
+ scopes: report.scopes.map((scope) => ({
54014
+ collection: scope.collection,
54015
+ examined: scope.examined,
54016
+ orphans: scope.orphans,
54017
+ reclaimed: scope.reclaimed,
54018
+ bytesReclaimed: scope.bytesReclaimed,
54019
+ exempt: scope.exempt,
54020
+ withinGrace: scope.withinGrace,
54021
+ unresolvable: scope.unresolvable,
54022
+ stoppedBy: scope.stoppedBy,
54023
+ nextCursor: scope.nextCursor
54024
+ })),
54025
+ totalOrphans: report.totalOrphans,
54026
+ totalReclaimed: report.totalReclaimed,
54027
+ totalBytesReclaimed: report.totalBytesReclaimed,
54028
+ durationMs: report.durationMs
53492
54029
  };
53493
54030
  }
53494
54031
  /**
@@ -53688,7 +54225,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53688
54225
  indexed
53689
54226
  };
53690
54227
  },
53691
- "embedding.wipe": async () => this.wipeObjectEmbeddings()
54228
+ "embedding.wipe": async () => this.wipeObjectEmbeddings(),
54229
+ "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input)
53692
54230
  };
53693
54231
  }
53694
54232
  /** Progress of the detached rebuild — the only way to observe a long pass. */
@@ -53947,8 +54485,14 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53947
54485
  * Track-centric time-based retention (design §5.1). Drains every persisted
53948
54486
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
53949
54487
  * widened cascade — enrolled faces/plates + identity media are exempt (design
53950
- * §4). `tracks` is the authoritative count; leaf families are best-effort 0
53951
- * under the void `TrackScopedStore` contract (see cap doc).
54488
+ * §4).
54489
+ *
54490
+ * **Every family count here is REAL.** It used to return `tracks` and a
54491
+ * literal 0 for every leaf, because `TrackScopedStore.deleteByTracks` returned
54492
+ * `void` — so `bytesReclaimed: 0` went onto all 3,732 ops-log rows this addon
54493
+ * had ever written and 18.9 GB of leaked media read as "nothing to reclaim"
54494
+ * (D161). The bytes ride {@link lastPruneBytes} rather than
54495
+ * `TrackCascadeCounts`, which is a cap output shape.
53952
54496
  */
53953
54497
  async pruneTracksBefore(input) {
53954
54498
  const empty = {
@@ -53959,26 +54503,32 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53959
54503
  plates: 0,
53960
54504
  embeddings: 0
53961
54505
  };
54506
+ this.lastPruneBytes = 0;
53962
54507
  const cascade = this.buildTrackCascadeRegistry();
53963
54508
  if (!cascade) return empty;
54509
+ let tally = EMPTY_CASCADE_TALLY;
53964
54510
  const { deleted: totalDeleted, skipped } = await drainTracksBefore({
53965
54511
  stopped: () => this.shuttingDown,
53966
54512
  listIdsBefore: (offset, limit) => cascade.trackStore.listIdsBefore(input.deviceId, input.cutoffMs, limit, offset),
53967
- cascade: (ids) => runTrackCascadeBatch({
53968
- registry: cascade.registry,
53969
- trackStore: cascade.trackStore,
53970
- deviceId: input.deviceId,
53971
- onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
53972
- onFailure: (trackId, err) => {
53973
- this.ctx.logger.debug("pruneTracksBefore track failed", {
53974
- tags: { deviceId: input.deviceId },
53975
- meta: {
53976
- trackId,
53977
- error: String(err)
53978
- }
53979
- });
53980
- }
53981
- }, ids)
54513
+ cascade: async (ids) => {
54514
+ const result = await runTrackCascadeBatch({
54515
+ registry: cascade.registry,
54516
+ trackStore: cascade.trackStore,
54517
+ deviceId: input.deviceId,
54518
+ onTrackCleanup: (deviceId, trackId) => this.clearLiveTrackState(deviceId, trackId),
54519
+ onFailure: (trackId, err) => {
54520
+ this.ctx.logger.debug("pruneTracksBefore track failed", {
54521
+ tags: { deviceId: input.deviceId },
54522
+ meta: {
54523
+ trackId,
54524
+ error: String(err)
54525
+ }
54526
+ });
54527
+ }
54528
+ }, ids);
54529
+ tally = mergeCascadeTallies(tally, result.tally);
54530
+ return { deleted: result.deleted };
54531
+ }
53982
54532
  });
53983
54533
  if (skipped > 0) this.ctx.logger.warn("analytics track retention skipped undeletable rows", {
53984
54534
  tags: { deviceId: input.deviceId },
@@ -53989,20 +54539,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
53989
54539
  pruned: totalDeleted
53990
54540
  }
53991
54541
  });
54542
+ this.lastPruneBytes = tally.bytesReclaimed;
53992
54543
  if (totalDeleted > 0) this.ctx.logger.info("analytics track retention prune", {
53993
54544
  tags: { deviceId: input.deviceId },
53994
54545
  meta: {
53995
54546
  deviceId: input.deviceId,
53996
54547
  cutoffMs: input.cutoffMs,
53997
- tracks: totalDeleted
54548
+ tracks: totalDeleted,
54549
+ events: tally.events,
54550
+ media: tally.media,
54551
+ faces: tally.faces,
54552
+ plates: tally.plates,
54553
+ embeddings: tally.embeddings,
54554
+ bytesReclaimed: tally.bytesReclaimed
53998
54555
  }
53999
54556
  });
54000
54557
  return {
54001
54558
  ...empty,
54002
- tracks: totalDeleted
54559
+ tracks: totalDeleted,
54560
+ events: tally.events,
54561
+ media: tally.media,
54562
+ faces: tally.faces,
54563
+ plates: tally.plates,
54564
+ embeddings: tally.embeddings
54003
54565
  };
54004
54566
  }
54005
54567
  /**
54568
+ * Bytes released by the most recent {@link pruneTracksBefore}.
54569
+ *
54570
+ * A field rather than a return value because `TrackCascadeCounts` is a cap
54571
+ * OUTPUT shape and widening it is a codegen change; the ops-log row already
54572
+ * has a `bytesReclaimed` column and this is what fills it. Reset to 0 at the
54573
+ * top of every prune, so a stale value can never be reported as a fresh one.
54574
+ */
54575
+ lastPruneBytes = 0;
54576
+ /**
54006
54577
  * Operator "clean slate" for a device (design §5.3): prune EVERY persisted
54007
54578
  * track via the same cascade as `pruneTracksBefore` with `cutoffMs = now`.
54008
54579
  * Enrolled gallery + identity media are exempt.
@@ -54056,8 +54627,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
54056
54627
  reason: "retention",
54057
54628
  deviceId,
54058
54629
  itemsAffected: counts.tracks,
54059
- bytesReclaimed: 0,
54060
- detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media`
54630
+ bytesReclaimed: this.lastPruneBytes,
54631
+ detail: `track retention cascade: ${counts.tracks} tracks, ${counts.events} events, ${counts.media} media, ${counts.faces} faces, ${counts.plates} plates, ${counts.embeddings} vectors`
54061
54632
  });
54062
54633
  return counts;
54063
54634
  },