@camstack/addon-post-analysis 1.2.153 → 1.2.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-DDIu0Q1W.mjs";
1
+ import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-DqNo8CCq.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";
@@ -106,6 +106,8 @@ function actionTtlMs(subject) {
106
106
  case "snooze": return SNOOZE_ACTION_TTL_MS;
107
107
  case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
108
108
  case "sequence": return DEFAULT_ACTION_TTL_MS;
109
+ case "addon-update": return DEFAULT_ACTION_TTL_MS;
110
+ case "server-update": return DEFAULT_ACTION_TTL_MS;
109
111
  }
110
112
  }
111
113
  var NcActionTokenRegistry = class {
@@ -175,9 +177,9 @@ var NcActionTokenRegistry = class {
175
177
  /**
176
178
  * What a spent grant DID, in the operator's own terms.
177
179
  *
178
- * Per `kind`, because the three families answer different questions and one
180
+ * Per `kind`, because the families answer different questions and one
179
181
  * generic "Done" would leave a snooze indistinguishable from an open gate on
180
- * the page a tap lands on. The switch is exhaustive by construction — a fourth
182
+ * the page a tap lands on. The switch is exhaustive by construction — a new
181
183
  * grant family will not compile until it has a sentence here.
182
184
  */
183
185
  function describeGrant(grant) {
@@ -185,6 +187,8 @@ function describeGrant(grant) {
185
187
  case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
186
188
  case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
187
189
  case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
190
+ case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
191
+ case "server-update": return `Server update on ${grant.nodeId}`;
188
192
  }
189
193
  }
190
194
  /** The audit line's `meta`, per family. A snooze that logged a `sequence` it
@@ -215,6 +219,15 @@ function grantMeta(grant) {
215
219
  mode: grant.mode ?? null,
216
220
  panelDeviceId: grant.panelDeviceId
217
221
  };
222
+ case "addon-update": return {
223
+ ...common,
224
+ nodeId: grant.nodeId,
225
+ packages: grant.packages
226
+ };
227
+ case "server-update": return {
228
+ ...common,
229
+ nodeId: grant.nodeId
230
+ };
218
231
  }
219
232
  }
220
233
  /** What the human standing in front of the page is told. */
@@ -4724,6 +4737,40 @@ var SceneMonitorProvider = class {
4724
4737
  async listScenes(input) {
4725
4738
  return this.statusFor(input.deviceId);
4726
4739
  }
4740
+ /**
4741
+ * `listScenes` for a SET, in one round trip.
4742
+ *
4743
+ * `/scenes` renders every camera and re-reads them behind the push slice on a
4744
+ * 30 s safety net. Fanned out client-side that was one request per camera to
4745
+ * read `statusFor`, which is an in-memory merge this provider already holds —
4746
+ * all cost, no work. The work here is identical; only the transport
4747
+ * collapses.
4748
+ *
4749
+ * **Every requested id gets a row.** A camera `statusFor` cannot answer for
4750
+ * is `status: null`, which is NOT `monitors: []`: this feature's whole
4751
+ * failure mode is silence reading as "nothing configured", and a batch that
4752
+ * dropped the row would have made an unreachable camera invisible instead of
4753
+ * reported.
4754
+ */
4755
+ async listScenesBatch(input) {
4756
+ return input.deviceIds.map((deviceId) => {
4757
+ try {
4758
+ return {
4759
+ deviceId,
4760
+ status: this.statusFor(deviceId)
4761
+ };
4762
+ } catch (err) {
4763
+ this.deps.logger.warn("scene status could not be read", {
4764
+ tags: { deviceId },
4765
+ meta: { error: String(err) }
4766
+ });
4767
+ return {
4768
+ deviceId,
4769
+ status: null
4770
+ };
4771
+ }
4772
+ });
4773
+ }
4727
4774
  async getStatus(input) {
4728
4775
  return this.statusFor(input.deviceId);
4729
4776
  }
@@ -5406,6 +5453,98 @@ function buildAlarmButtons(input) {
5406
5453
  }];
5407
5454
  }
5408
5455
  //#endregion
5456
+ //#region src/notification-center/addon-update-apply.ts
5457
+ /**
5458
+ * The frozen packages that STILL have an update, in the order the row listed
5459
+ * them.
5460
+ *
5461
+ * Order matters: the notification's body listed them in that order, and an
5462
+ * operator watching addons restart should see them go in the order they read.
5463
+ */
5464
+ function pendingPackages(frozen, updatable) {
5465
+ const available = new Set(updatable);
5466
+ return frozen.filter((name) => available.has(name));
5467
+ }
5468
+ /**
5469
+ * `done` only when every package the button promised was applied and nothing
5470
+ * was skipped or failed — the ONE case where a single number is the whole
5471
+ * truth. Anything else states all three counts, because a sentence that hides
5472
+ * one of them is a sentence that reports work it did not do.
5473
+ */
5474
+ function addonUpdateSentence(report) {
5475
+ if (report.applied.length === 0 && report.failed.length === 0) return "uptodate";
5476
+ if (report.applied.length === report.requested && report.failed.length === 0) return "done";
5477
+ return "partial";
5478
+ }
5479
+ //#endregion
5480
+ //#region src/notification-center/addon-update-buttons.ts
5481
+ /** The id this button travels under — in the callback and in the audit line. */
5482
+ var ADDON_UPDATE_BUTTON_ID = "addon-update-apply";
5483
+ function addonUpdateButtonRefusal(subject, recipient) {
5484
+ if (subject.kind !== "addon-update-available") return "not-an-addon-update";
5485
+ if ((subject.packageNames ?? []).length === 0) return "no-packages";
5486
+ if (subject.nodeId === void 0) return "several-nodes";
5487
+ if (recipient.ownerUserId === void 0) return "unowned-target";
5488
+ return null;
5489
+ }
5490
+ /**
5491
+ * The button, or none.
5492
+ *
5493
+ * Returns an array rather than a nullable, so the caller composes it with the
5494
+ * other families by spreading — and so "which buttons survived" has one shape.
5495
+ */
5496
+ function buildAddonUpdateButtons(input) {
5497
+ const subject = input.subject;
5498
+ if (addonUpdateButtonRefusal(subject, input.recipient) !== null) return [];
5499
+ const packages = subject.packageNames ?? [];
5500
+ const nodeId = subject.nodeId ?? "";
5501
+ return [{
5502
+ id: ADDON_UPDATE_BUTTON_ID,
5503
+ label: input.label({
5504
+ count: packages.length,
5505
+ nodeId
5506
+ }),
5507
+ url: input.mintUrl({
5508
+ actionId: ADDON_UPDATE_BUTTON_ID,
5509
+ packages,
5510
+ nodeId
5511
+ }),
5512
+ destructive: true,
5513
+ mode: "background"
5514
+ }];
5515
+ }
5516
+ //#endregion
5517
+ //#region src/notification-center/server-update-buttons.ts
5518
+ /** The id this button travels under — in the callback and in the audit line. */
5519
+ var SERVER_UPDATE_BUTTON_ID = "server-update-apply";
5520
+ function serverUpdateButtonRefusal(subject, recipient) {
5521
+ if (subject.kind !== "server-update-available") return "not-a-server-update";
5522
+ if (subject.nodeId === void 0) return "several-nodes";
5523
+ if (recipient.ownerUserId === void 0) return "unowned-target";
5524
+ return null;
5525
+ }
5526
+ /**
5527
+ * The button, or none.
5528
+ *
5529
+ * Returns an array rather than a nullable, so the caller composes it with the
5530
+ * other families by spreading — and so "which buttons survived" has one shape.
5531
+ */
5532
+ function buildServerUpdateButtons(input) {
5533
+ const subject = input.subject;
5534
+ if (serverUpdateButtonRefusal(subject, input.recipient) !== null) return [];
5535
+ const nodeId = subject.nodeId ?? "";
5536
+ return [{
5537
+ id: SERVER_UPDATE_BUTTON_ID,
5538
+ label: input.label({ nodeId }),
5539
+ url: input.mintUrl({
5540
+ actionId: SERVER_UPDATE_BUTTON_ID,
5541
+ nodeId
5542
+ }),
5543
+ destructive: true,
5544
+ mode: "background"
5545
+ }];
5546
+ }
5547
+ //#endregion
5409
5548
  //#region src/notification-center/audio-rule-matcher.ts
5410
5549
  /**
5411
5550
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8471,6 +8610,29 @@ var en_default = {
8471
8610
  "button.alarm.arm.night": "Arm night",
8472
8611
  "button.alarm.arm.vacation": "Arm vacation",
8473
8612
  "button.alarm.arm.custom_bypass": "Arm custom",
8613
+ "button.addon-update.apply": {
8614
+ "one": "Update {{count}} addon on {{nodeId}}",
8615
+ "other": "Update {{count}} addons on {{nodeId}}"
8616
+ },
8617
+ "action.addon-update.title": "Addon update",
8618
+ "action.addon-update.started": {
8619
+ "one": "Updating {{count}} addon on {{nodeId}}…",
8620
+ "other": "Updating {{count}} addons on {{nodeId}}…"
8621
+ },
8622
+ "action.addon-update.done": {
8623
+ "one": "{{applied}} addon updated on {{nodeId}}",
8624
+ "other": "{{applied}} addons updated on {{nodeId}}"
8625
+ },
8626
+ "action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
8627
+ "action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8628
+ "action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
8629
+ "button.server-update.apply": "Update server on {{nodeId}}",
8630
+ "action.server-update.title": "Server update",
8631
+ "action.server-update.started": "Updating server on {{nodeId}}…",
8632
+ "action.server-update.restarting": "Server update staged on {{nodeId}} — the node is restarting. You will hear when it comes back.",
8633
+ "action.server-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8634
+ "action.server-update.unknown": "Could not read whether {{nodeId}} still needs a server update — nothing was changed. Open CamStack.",
8635
+ "action.server-update.failed": "Server update on {{nodeId}} was refused — nothing was changed. Open CamStack.",
8474
8636
  "summary.title": "Summary — {{rule}}",
8475
8637
  "summary.body.window": "{{from}}→{{to}}",
8476
8638
  "summary.body.events": {
@@ -8693,6 +8855,29 @@ var it_default = {
8693
8855
  "button.alarm.arm.night": "Arma notte",
8694
8856
  "button.alarm.arm.vacation": "Arma vacanza",
8695
8857
  "button.alarm.arm.custom_bypass": "Arma personalizzato",
8858
+ "button.addon-update.apply": {
8859
+ "one": "Aggiorna {{count}} addon su {{nodeId}}",
8860
+ "other": "Aggiorna {{count}} addon su {{nodeId}}"
8861
+ },
8862
+ "action.addon-update.title": "Aggiornamento addon",
8863
+ "action.addon-update.started": {
8864
+ "one": "Aggiornamento di {{count}} addon su {{nodeId}}…",
8865
+ "other": "Aggiornamento di {{count}} addon su {{nodeId}}…"
8866
+ },
8867
+ "action.addon-update.done": {
8868
+ "one": "{{applied}} addon aggiornato su {{nodeId}}",
8869
+ "other": "{{applied}} addon aggiornati su {{nodeId}}"
8870
+ },
8871
+ "action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
8872
+ "action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8873
+ "action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
8874
+ "button.server-update.apply": "Aggiorna server su {{nodeId}}",
8875
+ "action.server-update.title": "Aggiornamento server",
8876
+ "action.server-update.started": "Aggiornamento server su {{nodeId}}…",
8877
+ "action.server-update.restarting": "Aggiornamento server predisposto su {{nodeId}} — il nodo si riavvia. Ti avviseremo quando torna.",
8878
+ "action.server-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8879
+ "action.server-update.unknown": "Non è stato possibile leggere se {{nodeId}} ha ancora bisogno di un aggiornamento server — non è stato cambiato nulla. Apri CamStack.",
8880
+ "action.server-update.failed": "L'aggiornamento server su {{nodeId}} è stato rifiutato — non è stato cambiato nulla. Apri CamStack.",
8696
8881
  "summary.title": "Riepilogo — {{rule}}",
8697
8882
  "summary.body.window": "{{from}}→{{to}}",
8698
8883
  "summary.body.events": {
@@ -9420,7 +9605,7 @@ var NcDispatcher = class {
9420
9605
  targetId: target.id,
9421
9606
  ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
9422
9607
  ...entry.payload.subject.className !== void 0 ? { className: entry.payload.subject.className } : {},
9423
- ...entry.payload.subject.systemEvent !== void 0 ? { systemEventKind: entry.payload.subject.systemEvent.kind } : {}
9608
+ ...entry.payload.subject.systemEvent !== void 0 ? { systemEvent: entry.payload.subject.systemEvent } : {}
9424
9609
  });
9425
9610
  } catch (err) {
9426
9611
  this.deps.logger.warn("notification buttons could not be minted — sending without them", {
@@ -20154,7 +20339,7 @@ var NotificationCenter = class NotificationCenter {
20154
20339
  return id;
20155
20340
  }
20156
20341
  });
20157
- const alarmKind = alarmButtonKind(input.systemEventKind);
20342
+ const alarmKind = alarmButtonKind(input.systemEvent?.kind);
20158
20343
  const panel = this.alarmPanel;
20159
20344
  const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
20160
20345
  kind: alarmKind,
@@ -20190,6 +20375,84 @@ var NotificationCenter = class NotificationCenter {
20190
20375
  return id;
20191
20376
  }
20192
20377
  });
20378
+ const systemEvent = input.systemEvent;
20379
+ const recipient = input.ownerUserId !== void 0 ? { ownerUserId: input.ownerUserId } : {};
20380
+ const updateRefusal = systemEvent === void 0 ? "not-an-addon-update" : addonUpdateButtonRefusal(systemEvent, recipient);
20381
+ const updateButtons = systemEvent === void 0 ? [] : buildAddonUpdateButtons({
20382
+ subject: systemEvent,
20383
+ recipient,
20384
+ label: ({ count, nodeId }) => this.texts.text({
20385
+ key: "button.addon-update.apply",
20386
+ vars: {
20387
+ count: String(count),
20388
+ nodeId
20389
+ },
20390
+ count
20391
+ }),
20392
+ mintUrl: ({ actionId, packages, nodeId }) => {
20393
+ const id = randomUUID();
20394
+ drafts.push({
20395
+ id,
20396
+ ttlMs: actionTtlMs({ kind: "addon-update" }),
20397
+ grant: (expiresAt) => ({
20398
+ kind: "addon-update",
20399
+ id,
20400
+ packages,
20401
+ nodeId,
20402
+ deviceId: input.deviceId,
20403
+ actionId,
20404
+ targetId: input.targetId,
20405
+ expiresAt
20406
+ })
20407
+ });
20408
+ return id;
20409
+ }
20410
+ });
20411
+ if (updateRefusal === "several-nodes" || updateRefusal === "unowned-target") this.logger.info("addon-update notification carries no apply button", {
20412
+ tags: { deviceId: input.deviceId },
20413
+ meta: {
20414
+ ruleId: input.ruleId,
20415
+ reason: updateRefusal,
20416
+ targetId: input.targetId,
20417
+ nodeIds: systemEvent?.nodeIds ?? [],
20418
+ packages: systemEvent?.packageNames ?? []
20419
+ }
20420
+ });
20421
+ const serverRefusal = systemEvent === void 0 ? "not-a-server-update" : serverUpdateButtonRefusal(systemEvent, recipient);
20422
+ const serverButtons = systemEvent === void 0 ? [] : buildServerUpdateButtons({
20423
+ subject: systemEvent,
20424
+ recipient,
20425
+ label: ({ nodeId }) => this.texts.text({
20426
+ key: "button.server-update.apply",
20427
+ vars: { nodeId }
20428
+ }),
20429
+ mintUrl: ({ actionId, nodeId }) => {
20430
+ const id = randomUUID();
20431
+ drafts.push({
20432
+ id,
20433
+ ttlMs: actionTtlMs({ kind: "server-update" }),
20434
+ grant: (expiresAt) => ({
20435
+ kind: "server-update",
20436
+ id,
20437
+ nodeId,
20438
+ deviceId: input.deviceId,
20439
+ actionId,
20440
+ targetId: input.targetId,
20441
+ expiresAt
20442
+ })
20443
+ });
20444
+ return id;
20445
+ }
20446
+ });
20447
+ if (serverRefusal === "several-nodes" || serverRefusal === "unowned-target") this.logger.info("server-update notification carries no apply button", {
20448
+ tags: { deviceId: input.deviceId },
20449
+ meta: {
20450
+ ruleId: input.ruleId,
20451
+ reason: serverRefusal,
20452
+ targetId: input.targetId,
20453
+ nodeIds: systemEvent?.nodeIds ?? []
20454
+ }
20455
+ });
20193
20456
  if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
20194
20457
  tags: { deviceId: input.deviceId },
20195
20458
  meta: {
@@ -20200,7 +20463,9 @@ var NotificationCenter = class NotificationCenter {
20200
20463
  const out = [
20201
20464
  ...sequenceButtons,
20202
20465
  ...snoozeButtons,
20203
- ...alarmButtons
20466
+ ...alarmButtons,
20467
+ ...updateButtons,
20468
+ ...serverButtons
20204
20469
  ];
20205
20470
  if (out.length === 0) return [];
20206
20471
  const now = this.now();
@@ -20321,7 +20586,213 @@ var NotificationCenter = class NotificationCenter {
20321
20586
  case "snooze": return this.applyGrantedSnooze(grant);
20322
20587
  case "alarm": return this.applyGrantedAlarm(grant);
20323
20588
  case "sequence": return this.runGrantedSequence(grant);
20589
+ case "addon-update": return this.applyGrantedAddonUpdate(grant);
20590
+ case "server-update": return this.applyGrantedServerUpdate(grant);
20591
+ }
20592
+ }
20593
+ /**
20594
+ * Apply the addon updates one notification was about.
20595
+ *
20596
+ * The whole shape of this method is the answer to "a button that reports
20597
+ * success for work it did not do":
20598
+ *
20599
+ * 1. **The cheap question first.** Ask the registry what is still out of
20600
+ * date on that node. A read that FAILS changes nothing and says so — it
20601
+ * is never read as "everything is current" and never as "apply the frozen
20602
+ * list anyway".
20603
+ * 2. **Nothing to do is an outcome, not a success.** A frozen list the
20604
+ * operator already applied (from the Addons page, from auto-update, or by
20605
+ * tapping the copy of this notification that went to their other phone)
20606
+ * produces one announcement and zero `updatePackage` calls.
20607
+ * 3. **The operator hears twice.** A background POST returns while the
20608
+ * updates run for minutes, so the tap is acknowledged BEFORE the first
20609
+ * apply and the verdict follows it. Tapping into silence is worse than no
20610
+ * button.
20611
+ *
20612
+ * The announcements go back to `grant.targetId` — the delivery this button
20613
+ * was minted for, which is the only addressee a tap can honestly name (a tap
20614
+ * identifies nobody; same reasoning as the snooze grant's frozen `userId`).
20615
+ *
20616
+ * Throws only when NOTHING was applied and something went wrong, so the
20617
+ * plane's line says the run failed rather than reporting a spent token as a
20618
+ * success.
20619
+ */
20620
+ async applyGrantedAddonUpdate(grant) {
20621
+ const apply = this.deps.applyAddonUpdate;
20622
+ const list = this.deps.listAddonUpdates;
20623
+ if (apply === void 0 || list === void 0) throw new Error("no addon updater is wired on this node");
20624
+ let updatable;
20625
+ try {
20626
+ updatable = await list(grant.nodeId);
20627
+ } catch (err) {
20628
+ this.logger.warn("addon update button changed nothing — could not read what needs updating", { meta: {
20629
+ nodeId: grant.nodeId,
20630
+ packages: grant.packages,
20631
+ error: String(err)
20632
+ } });
20633
+ await this.announceAddonUpdate(grant, "unknown", { nodeId: grant.nodeId });
20634
+ throw err instanceof Error ? err : new Error(String(err));
20635
+ }
20636
+ const pending = pendingPackages(grant.packages, updatable);
20637
+ const skipped = grant.packages.filter((name) => !pending.includes(name));
20638
+ if (pending.length === 0) {
20639
+ this.logger.info("addon update button applied nothing — every package is already current", { meta: {
20640
+ nodeId: grant.nodeId,
20641
+ packages: grant.packages
20642
+ } });
20643
+ await this.announceAddonUpdate(grant, "uptodate", { nodeId: grant.nodeId }, {
20644
+ requested: grant.packages.length,
20645
+ applied: [],
20646
+ skipped,
20647
+ failed: []
20648
+ });
20649
+ return;
20650
+ }
20651
+ await this.announceAddonUpdate(grant, "started", {
20652
+ count: String(pending.length),
20653
+ nodeId: grant.nodeId
20654
+ }, void 0, pending.length);
20655
+ const applied = [];
20656
+ const failed = [];
20657
+ for (const packageName of pending) try {
20658
+ const result = await apply({
20659
+ packageName,
20660
+ nodeId: grant.nodeId
20661
+ });
20662
+ if (result.ok) applied.push(packageName);
20663
+ else {
20664
+ failed.push(packageName);
20665
+ this.logger.warn("addon update from a notification button failed", { meta: {
20666
+ nodeId: grant.nodeId,
20667
+ packageName,
20668
+ error: result.error
20669
+ } });
20670
+ }
20671
+ } catch (err) {
20672
+ failed.push(packageName);
20673
+ this.logger.warn("addon update from a notification button threw", { meta: {
20674
+ nodeId: grant.nodeId,
20675
+ packageName,
20676
+ error: String(err)
20677
+ } });
20678
+ }
20679
+ const report = {
20680
+ requested: grant.packages.length,
20681
+ applied,
20682
+ skipped,
20683
+ failed
20684
+ };
20685
+ this.logger.info("addon updates applied from a notification button", { meta: {
20686
+ nodeId: grant.nodeId,
20687
+ requested: report.requested,
20688
+ applied: applied.length,
20689
+ skipped: skipped.length,
20690
+ failed
20691
+ } });
20692
+ await this.announceAddonUpdate(grant, addonUpdateSentence(report), {
20693
+ nodeId: grant.nodeId,
20694
+ applied: String(applied.length),
20695
+ skipped: String(skipped.length),
20696
+ failed: String(failed.length)
20697
+ }, report, applied.length);
20698
+ if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
20699
+ }
20700
+ /**
20701
+ * Stage the server update one notification was about.
20702
+ *
20703
+ * The shape matches the addon-update runner, with one extra constraint: the
20704
+ * **start announcement leaves BEFORE apply**. Applying the hub kills this
20705
+ * process; a tap that went into silence because the process died mid-apply
20706
+ * is worse than no button. The reboot itself is confirmed by the existing
20707
+ * `server-updated` event on reconnect — this method does not wait for it.
20708
+ *
20709
+ * Throws when nothing was staged and something went wrong, so the plane's
20710
+ * line says the run failed rather than reporting a spent token as a success.
20711
+ */
20712
+ async applyGrantedServerUpdate(grant) {
20713
+ const apply = this.deps.applyServerUpdate;
20714
+ const check = this.deps.checkServerUpdate;
20715
+ if (apply === void 0 || check === void 0) throw new Error("no server updater is wired on this node");
20716
+ let stillAvailable;
20717
+ try {
20718
+ stillAvailable = await check(grant.nodeId);
20719
+ } catch (err) {
20720
+ this.logger.warn("server update button changed nothing — could not read whether an update is still needed", { meta: {
20721
+ nodeId: grant.nodeId,
20722
+ error: String(err)
20723
+ } });
20724
+ await this.announceServerUpdate(grant, "unknown", { nodeId: grant.nodeId });
20725
+ throw err instanceof Error ? err : new Error(String(err));
20726
+ }
20727
+ if (!stillAvailable) {
20728
+ this.logger.info("server update button applied nothing — already current", { meta: { nodeId: grant.nodeId } });
20729
+ await this.announceServerUpdate(grant, "uptodate", { nodeId: grant.nodeId });
20730
+ return;
20731
+ }
20732
+ await this.announceServerUpdate(grant, "started", { nodeId: grant.nodeId });
20733
+ let result;
20734
+ try {
20735
+ result = await apply({ nodeId: grant.nodeId });
20736
+ } catch (err) {
20737
+ this.logger.warn("server update from a notification button threw", { meta: {
20738
+ nodeId: grant.nodeId,
20739
+ error: String(err)
20740
+ } });
20741
+ await this.announceServerUpdate(grant, "failed", { nodeId: grant.nodeId });
20742
+ throw err instanceof Error ? err : new Error(String(err));
20743
+ }
20744
+ if (!result.ok) {
20745
+ this.logger.warn("server update from a notification button was refused", { meta: {
20746
+ nodeId: grant.nodeId,
20747
+ error: result.error
20748
+ } });
20749
+ await this.announceServerUpdate(grant, "failed", { nodeId: grant.nodeId });
20750
+ throw new Error(result.error);
20324
20751
  }
20752
+ this.logger.info("server update staged from a notification button", { meta: { nodeId: grant.nodeId } });
20753
+ await this.announceServerUpdate(grant, "restarting", { nodeId: grant.nodeId });
20754
+ }
20755
+ /**
20756
+ * One sentence about a server-update tap, to the target the notification
20757
+ * went to. Best-effort — `deliverAnnouncement` never throws — because an
20758
+ * announcement that failed must not turn a staging that HAPPENED into a run
20759
+ * the plane reports as failed.
20760
+ */
20761
+ async announceServerUpdate(grant, sentence, vars) {
20762
+ const targetId = grant.targetId;
20763
+ if (targetId === void 0) return;
20764
+ await this.dispatcher.deliverAnnouncement({
20765
+ reason: `server-update-${sentence}`,
20766
+ targetId,
20767
+ title: this.texts.text({ key: "action.server-update.title" }),
20768
+ body: this.texts.text({
20769
+ key: `action.server-update.${sentence}`,
20770
+ vars
20771
+ })
20772
+ });
20773
+ }
20774
+ /**
20775
+ * One sentence about an update tap, to the target the notification went to.
20776
+ *
20777
+ * Best-effort by construction — `deliverAnnouncement` never throws and never
20778
+ * retries — because an announcement that failed must not turn an update that
20779
+ * HAPPENED into a run the plane reports as failed.
20780
+ */
20781
+ async announceAddonUpdate(grant, sentence, vars, report, count) {
20782
+ const targetId = grant.targetId;
20783
+ if (targetId === void 0) return;
20784
+ const body = this.texts.text({
20785
+ key: `action.addon-update.${sentence}`,
20786
+ vars,
20787
+ ...count !== void 0 ? { count } : {}
20788
+ });
20789
+ const failedNames = report?.failed ?? [];
20790
+ await this.dispatcher.deliverAnnouncement({
20791
+ reason: `addon-update-${sentence}`,
20792
+ targetId,
20793
+ title: this.texts.text({ key: "action.addon-update.title" }),
20794
+ body: failedNames.length > 0 ? `${body}\n${failedNames.join(", ")}` : body
20795
+ });
20325
20796
  }
20326
20797
  async runGrantedSequence(grant) {
20327
20798
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
@@ -25764,7 +26235,7 @@ var DEFAULT_MEDIA_LOCATION = "eventMedia";
25764
26235
  * whose contract is disposal.** The gallery's row lives on the data volume;
25765
26236
  * its blob belongs there too, and now does. See D197.
25766
26237
  */
25767
- var GALLERY_MEDIA_LOCATION = "galleryMedia";
26238
+ var GALLERY_MEDIA_LOCATION = "addon-data";
25768
26239
  /**
25769
26240
  * The location a new blob is written to, derived from its owner kind.
25770
26241
  *
@@ -26661,7 +27132,7 @@ var MediaStore = class {
26661
27132
  for (const row of rows) {
26662
27133
  const data = row.data;
26663
27134
  const from = mediaRowLocation(data);
26664
- if (from === "galleryMedia") continue;
27135
+ if (from === "addon-data") continue;
26665
27136
  const relativePath = String(data["path"] ?? "");
26666
27137
  const deviceId = Number(data["deviceId"]);
26667
27138
  if (relativePath.length === 0) continue;
@@ -29037,30 +29508,57 @@ var LEGACY_FACE_MODEL_ID = "arcface-r100";
29037
29508
  */
29038
29509
  var FACES_COLLECTION = "pipeline-analytics:faces";
29039
29510
  /**
29040
- * Rows per page for the two whole-collection sweeps below.
29511
+ * Rows per page for `pruneCapOverflow`, the ONE remaining walk of this
29512
+ * collection.
29041
29513
  *
29042
- * A face row is one of the two WIDEST row shapes in the hub's database: it
29043
- * carries a JSON `embedding` on every single row, measured at **10.8 KB
29044
- * average** where a motion event is 78 bytes. Both sweeps used to read the
29045
- * collection in ONE statement with no `limit`.
29514
+ * It is a walk that DOES WORK: the capacity bound has to consider every row to
29515
+ * decide which ones leave, and it runs on the maintenance pass, not on a
29516
+ * request. That is the shape D320 permits. What it must not do is drag the
29517
+ * whole row across for four scalars — see the projection there.
29046
29518
  *
29047
- * To be exact about what that did and did not cause: this table holds **372
29048
- * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
29049
- * hub-main on 2026-08-07 (that was the multi-camera track feed — see
29050
- * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
29051
- * grows with a table nothing else bounds, which is the same defect one incident
29052
- * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
29053
- * itself one of the two unbounded readers, so the bound and the thing enforcing
29054
- * it were the same loop.
29519
+ * The two READS that used to page here do not any more. They asked a question
29520
+ * ("the newest N faces that are still unassigned"), the question is a
29521
+ * predicate, and a predicate answers it in one statement whose cost is the
29522
+ * ANSWER rather than the table.
29055
29523
  *
29056
- * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
29057
- * is not belt-and-braces: without it these two methods would silently see only
29058
- * the newest N rows and the buffer would grow forever.
29059
- *
29060
- * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
29061
- * cap, so the page is always the caller's bound and never the engine's.
29524
+ * The engine refuses an unbounded read (`query-bounds.ts`) and truncates a
29525
+ * limitless one at `SETTINGS_QUERY_DEFAULT_ROW_CAP` with a WARN nobody can act
29526
+ * on, so every read below carries an explicit `limit`.
29062
29527
  */
29063
29528
  var FACE_SWEEP_PAGE = 1e3;
29529
+ /**
29530
+ * The widest window a single gallery read may materialise before it REFUSES.
29531
+ *
29532
+ * It bounds only the reads whose size the CALLER did not name: an unbounded
29533
+ * `listAllRecentFaces`, and the `suggestionConfidence` order, which cannot stop
29534
+ * early because the most certain suggestion may be the oldest row (`face-sort.ts`).
29535
+ * A caller's own `limit` is honoured verbatim at any table size — asking for
29536
+ * 500 and getting 500 is being served, not being truncated.
29537
+ *
29538
+ * 10 000 because that is above what `pruneCapOverflow` actually permits to
29539
+ * exist: 29 cameras × (50 ordinary + 200 suggested) = 7 250 unassigned rows,
29540
+ * plus whatever the operator has enrolled. It sits under the engine's
29541
+ * `SETTINGS_QUERY_HARD_ROW_CAP` (20 000) so the engine never truncates first —
29542
+ * this bound is the one that speaks, and it speaks by throwing.
29543
+ *
29544
+ * The alternative was to answer with the rows it managed to read. A short list
29545
+ * and a complete one are the same array, and a gallery that quietly stops
29546
+ * showing a face is exactly the failure this whole change exists to avoid.
29547
+ */
29548
+ var FACE_WINDOW_MAX_ROWS = 1e4;
29549
+ /**
29550
+ * A gallery read whose window is wider than {@link FACE_WINDOW_MAX_ROWS}.
29551
+ *
29552
+ * Distinct type so a router can map it to a 400 and an operator can be told
29553
+ * *"the buffer is too large to rank in one pass — narrow the window"* instead
29554
+ * of being shown a page that silently lost its tail.
29555
+ */
29556
+ var FaceWindowTooWideError = class extends Error {
29557
+ constructor(rows) {
29558
+ super(`face window exceeds ${String(FACE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit; narrow with deviceIds / since / until, or pass an explicit limit`);
29559
+ this.name = "FaceWindowTooWideError";
29560
+ }
29561
+ };
29064
29562
  /** How many evicted face ids one per-camera eviction line names before it
29065
29563
  * stops — enough to find the rows, not enough to turn a log line into a page. */
29066
29564
  var SUGGESTED_EVICTION_IDS_LOGGED = 10;
@@ -29162,13 +29660,128 @@ var FACE_COLUMNS = [
29162
29660
  type: "REAL"
29163
29661
  }
29164
29662
  ];
29165
- var FACE_INDEXES = [{
29166
- name: "idx_faces_device_ts",
29167
- columns: ["deviceId", "timestamp"]
29168
- }, {
29169
- name: "idx_faces_track",
29170
- columns: ["trackId"]
29171
- }];
29663
+ /**
29664
+ * What every gallery query orders by, and what the three of them filter on.
29665
+ *
29666
+ * Before these existed, EVERY read of this collection was
29667
+ * `SCAN … | USE TEMP B-TREE FOR ORDER BY` — a full table scan plus a sort of
29668
+ * the whole table, to hand back the newest 500 rows. Measured with
29669
+ * `EXPLAIN QUERY PLAN` over a 20 000-row copy of this exact schema:
29670
+ *
29671
+ * | query | before | after |
29672
+ * | --- | --- | --- |
29673
+ * | newest N | `SCAN + TEMP B-TREE` | `SCAN USING idx_faces_ts` |
29674
+ * | newest N unassigned | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
29675
+ * | newest N recognized | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
29676
+ * | faces of one identity | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_identity_ts (recognizedIdentityId=?)` |
29677
+ *
29678
+ * The sort disappearing is the point: with an ordered index the statement stops
29679
+ * at the `LIMIT`, so the cost is the ANSWER. Without it the cost is the table,
29680
+ * and this table is bounded only by `pruneCapOverflow` — it went from 372 rows
29681
+ * (2026-08-25) to 1 612 (2026-08-31) while a UI polled it every 30 seconds.
29682
+ *
29683
+ * `idx_faces_ts` is NOT redundant beside `idx_faces_assigned_ts`: a composite
29684
+ * leading on `assigned` cannot serve an unfiltered `ORDER BY timestamp`.
29685
+ * Three b-trees over a few thousand narrow rows is tens of KB, against one
29686
+ * UPSERT per track that produces a face — the write rate this pays at.
29687
+ */
29688
+ var FACE_INDEXES = [
29689
+ {
29690
+ name: "idx_faces_device_ts",
29691
+ columns: ["deviceId", "timestamp"]
29692
+ },
29693
+ {
29694
+ name: "idx_faces_track",
29695
+ columns: ["trackId"]
29696
+ },
29697
+ {
29698
+ name: "idx_faces_ts",
29699
+ columns: ["timestamp"]
29700
+ },
29701
+ {
29702
+ name: "idx_faces_assigned_ts",
29703
+ columns: ["assigned", "timestamp"]
29704
+ },
29705
+ {
29706
+ name: "idx_faces_identity_ts",
29707
+ columns: ["recognizedIdentityId", "timestamp"]
29708
+ }
29709
+ ];
29710
+ /**
29711
+ * The columns a {@link Face} is built from — every declared column EXCEPT the
29712
+ * drained legacy `embedding`, derived mechanically so a column added tomorrow
29713
+ * arrives without anyone remembering this list.
29714
+ *
29715
+ * Mechanical on purpose. A hand-written projection is how a field silently
29716
+ * stops arriving: the row comes back missing the key, `rowToFace` reads
29717
+ * `undefined`, and "the database says nothing" is indistinguishable from "I did
29718
+ * not ask". The engine REFUSES a column it cannot serve
29719
+ * (`resolveProjection`) — the projection is the one part of a query that is not
29720
+ * allowed to be forgiving — so a stale name here is a throw, not a wrong value.
29721
+ *
29722
+ * `embedding` is the only exclusion, and it is the whole reason to project:
29723
+ * it is a `notNull` JSON column on the widest row shape in the database, it is
29724
+ * dropped by `rowToFace` on arrival, and {@link Face} has no field for it. A
29725
+ * caller that wants floats asks by name ({@link FaceStore.getWithEmbedding},
29726
+ * {@link FaceStore.loadEmbeddings}). Since D304 drained it to `[]` the saving
29727
+ * is small — 23 B/row, measured on the live hub — but the column is still
29728
+ * declared, still `notNull`, and an unconverted row still carries ~10.8 KB of
29729
+ * it. Projecting is what keeps that a fact about ONE row rather than about
29730
+ * every gallery poll.
29731
+ */
29732
+ var FACE_LIST_COLUMNS = FACE_COLUMNS.map((c) => c.name).filter((name) => name !== "embedding" && name !== "id");
29733
+ /**
29734
+ * The four scalars `pruneCapOverflow` decides on.
29735
+ *
29736
+ * Its docblock has always said *"only four scalars per row survive the page"*.
29737
+ * They survived the PAGE; the query still dragged every column — the embedding
29738
+ * included — across the RPC boundary and through V8 first. A leftover that
29739
+ * describes the right design reads as verification, so the projection now makes
29740
+ * the sentence true.
29741
+ */
29742
+ var FACE_CAP_COLUMNS = [
29743
+ "deviceId",
29744
+ "timestamp",
29745
+ "assigned",
29746
+ "suggestedIdentityId"
29747
+ ];
29748
+ /** One camera reference per identity — see {@link FaceStore.listCameraRefsForIdentity}. */
29749
+ var FACE_REF_COLUMNS = ["deviceId"];
29750
+ /**
29751
+ * A gallery view as a STORE PREDICATE.
29752
+ *
29753
+ * Every one of the four is expressible, and that is the whole finding behind
29754
+ * this change. They were applied in JS, over pages the store had already
29755
+ * chosen, which is why `limit` counted rows that PASSED and the read had to
29756
+ * sweep until enough had: with `filter: 'unassigned'` the newest N rows may be
29757
+ * entirely assigned while matches wait just beyond them. Pushed down, `limit`
29758
+ * IS the store's limit, and the sweep has nothing left to do.
29759
+ *
29760
+ * **Two of them name null, and null is the trap.** `recognizedIdentityId` is a
29761
+ * nullable TEXT column, so "has an identity" is `IS NOT NULL` — which
29762
+ * `whereNot: { field: null }` compiles to since D312, and which before D312
29763
+ * compiled to `(expr IS NULL OR expr != NULL)`: TRUE for a NULL row and NULL
29764
+ * for every other, i.e. precisely the rows it was asked to exclude. A predicate
29765
+ * that silently matched nothing would report "no recognized faces" forever and
29766
+ * every assertion about the rows returned would agree with it. Verified against
29767
+ * the live hub before shipping: 1 612 rows, `where {assigned:false}` → 1 444,
29768
+ * `whereNot {recognizedIdentityId:null}` → 247.
29769
+ *
29770
+ * `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
29771
+ * the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
29772
+ * plain equality is correct and no null semantics are involved.
29773
+ */
29774
+ function faceFilterPredicate(filter) {
29775
+ switch (filter) {
29776
+ case "unassigned": return { where: { assigned: false } };
29777
+ case "recognized": return {
29778
+ where: { assigned: false },
29779
+ whereNot: { recognizedIdentityId: null }
29780
+ };
29781
+ case "identified": return { whereNot: { recognizedIdentityId: null } };
29782
+ case "all": return {};
29783
+ }
29784
+ }
29172
29785
  /**
29173
29786
  * `since` / `until` as a store predicate.
29174
29787
  *
@@ -29203,6 +29816,7 @@ function rowToFace(id, raw) {
29203
29816
  return {
29204
29817
  ...rest,
29205
29818
  id,
29819
+ embeddingModelId: data.embeddingModelId ?? void 0,
29206
29820
  assigned: Boolean(data.assigned),
29207
29821
  mediaKey: data.mediaKey ?? void 0,
29208
29822
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
@@ -29431,16 +30045,21 @@ var FaceStore = class {
29431
30045
  */
29432
30046
  async pruneCapOverflow(input) {
29433
30047
  /**
29434
- * Paged, not one statement. Only four scalars per row survive the page —
29435
- * the `embedding` string that dominates the row is dropped as soon as the
29436
- * page goes out of scope, so peak heap is one page rather than the whole
29437
- * face table. The prune still considers EVERY row, which is what makes it a
29438
- * capacity bound rather than a bound on the newest page.
30048
+ * Paged, not one statement, and PROJECTED to the four scalars the decision
30049
+ * needs — this sentence used to be true only of what survived the page,
30050
+ * while the query still dragged every column, `embedding` included, across
30051
+ * the RPC boundary and through V8 first.
30052
+ *
30053
+ * This is the one walk of the collection that remains, and D320 permits it:
30054
+ * it must touch every row to ACT on it, and it runs on the maintenance
30055
+ * pass, not on a request. The prune still considers EVERY row, which is
30056
+ * what makes it a capacity bound rather than a bound on the newest page.
29439
30057
  */
29440
30058
  const byDevice = /* @__PURE__ */ new Map();
29441
30059
  for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29442
30060
  const rows = await this.store.query.query({
29443
30061
  collection: FACES_COLLECTION,
30062
+ columns: FACE_CAP_COLUMNS,
29444
30063
  filter: {
29445
30064
  orderBy: {
29446
30065
  field: "timestamp",
@@ -29734,29 +30353,38 @@ var FaceStore = class {
29734
30353
  * - 'recognized' → !assigned && recognizedIdentityId != null
29735
30354
  * - 'identified' → recognizedIdentityId != null, including manual assignments
29736
30355
  *
29737
- * `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
29738
- * what is read, not what is returned. `filter` and the ordering cannot be
29739
- * (the first is a computed predicate, the second is a contract this backend
29740
- * does not implement — see `face-sort.ts`), so they are applied here.
29741
- *
29742
- * **`limit` counts rows that PASSED the filter, never rows that were
29743
- * discarded.** That is the whole reason this pages instead of asking the
29744
- * store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
29745
- * entirely assigned while matches wait just beyond them.
29746
- *
29747
- * The previous answer was to fetch the WHOLE collection and slice after
29748
- * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
29749
- * every face row carries a JSON `embedding`, so "all rows" is the widest
29750
- * table in the database materialised at once, to render a gallery of at most
29751
- * a few hundred tiles.
29752
- *
29753
- * So: PAGE. Under the default `timestamp` order the store's paging already
29754
- * emits rows in the answer's order, so the sweep stops the moment enough
29755
- * MATCHES exist and the common case costs one page. Under
29756
- * `suggestionConfidence` it cannot — the most certain suggestion may be the
29757
- * oldest row — so the sweep walks the window and folds each page through
29758
- * `rankAndCap`, which holds the RETAINED set to `limit` even though the read
29759
- * is wider. Narrow the read with `since` / `until`.
30356
+ * **Every part of the question is now a store predicate** — `deviceIds`,
30357
+ * `since` / `until` AND `filter` ({@link faceFilterPredicate}) — so `limit`
30358
+ * is the STORE's limit and one statement is the whole answer.
30359
+ *
30360
+ * That is the change. This used to page: `filter` was a JS predicate applied
30361
+ * to rows the store had already chosen, so `limit` counted rows that PASSED
30362
+ * and the read had to sweep until enough had, up to `FACE_SWEEP_MAX_PAGES`
30363
+ * (1 000) × `FACE_SWEEP_PAGE` (1 000) — a million rows, `offset`-paged,
30364
+ * behind a UI polling every 30 seconds. On the live hub that meant reading
30365
+ * all 1 612 rows over 2 round trips to produce a 60-row `recognized` page,
30366
+ * every 30 s, per open tab, and growing with a table only `pruneCapOverflow`
30367
+ * bounds. It is the same family as `MediaRelocateEngine.pageRows`, which put
30368
+ * a ~16 s standing queue in front of every settings-store call in the cluster
30369
+ * (D320) — smaller, on a longer timer, and the same shape.
30370
+ *
30371
+ * `offset` is gone with it, not merely unnecessary: the recognizer UPSERTs
30372
+ * into this collection continuously, so page two at `offset: N` starts N rows
30373
+ * past a set that may have gained or lost its first N (D295 rule 1, D320 R3).
30374
+ *
30375
+ * ## What still cannot be pushed, and what that costs
30376
+ *
30377
+ * The `suggestionConfidence` ORDER stays here. `ORDER BY suggestedMatchScore`
30378
+ * would let the backend decide where a scoreless row goes — SQLite collates
30379
+ * NULL first on ASC and last on DESC — and the contract is that it sorts LAST
30380
+ * in BOTH directions (`face-sort.ts`). So that order reads the whole FILTERED
30381
+ * window and ranks it. Filtered, which is the improvement: the window is the
30382
+ * unassigned buffer, not the table.
30383
+ *
30384
+ * A window it cannot read whole REFUSES ({@link FaceWindowTooWideError}).
30385
+ * Ranking the part that fit is exactly "most certain among the newest N",
30386
+ * which is the defect that order was added to remove, and it is invisible in
30387
+ * the rows returned.
29760
30388
  *
29761
30389
  * An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
29762
30390
  * cameras, not an omission (the `deviceManager.listFleet` contract).
@@ -29765,74 +30393,86 @@ var FaceStore = class {
29765
30393
  if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
29766
30394
  const order = resolveFaceSort(input);
29767
30395
  const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
29768
- const filterMode = input.filter ?? "all";
29769
- const matches = (f) => {
29770
- if (filterMode === "unassigned") return !f.assigned;
29771
- if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
29772
- if (filterMode === "identified") return f.recognizedIdentityId != null;
29773
- return true;
29774
- };
29775
- const storeFilter = {
29776
- ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
29777
- ...buildTimestampWindow(input),
29778
- orderBy: {
29779
- field: "timestamp",
29780
- direction: pagingServesTheOrder ? order.direction : "desc"
30396
+ /**
30397
+ * The caller's `limit` bounds the READ only when the store's ordering is
30398
+ * already the answer's. Under `suggestionConfidence` the most certain
30399
+ * suggestion may be the oldest row, so the read is the window and `limit`
30400
+ * bounds only what is RETAINED (`rankAndCap`).
30401
+ */
30402
+ const callerBounded = pagingServesTheOrder && input.limit !== void 0;
30403
+ const readLimit = callerBounded ? input.limit ?? 0 : 10001;
30404
+ const rows = await this.store.query.query({
30405
+ collection: FACES_COLLECTION,
30406
+ columns: FACE_LIST_COLUMNS,
30407
+ filter: {
30408
+ ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
30409
+ ...buildTimestampWindow(input),
30410
+ ...faceFilterPredicate(input.filter ?? "all"),
30411
+ orderBy: {
30412
+ field: "timestamp",
30413
+ direction: pagingServesTheOrder ? order.direction : "desc"
30414
+ },
30415
+ limit: readLimit
29781
30416
  }
29782
- };
29783
- let result = [];
29784
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29785
- const rows = await this.store.query.query({
29786
- collection: FACES_COLLECTION,
29787
- filter: {
29788
- ...storeFilter,
29789
- limit: FACE_SWEEP_PAGE,
29790
- offset: page * FACE_SWEEP_PAGE
29791
- }
29792
- });
29793
- if (rows.length === 0) break;
29794
- const matched = [];
29795
- for (const r of rows) {
29796
- const face = rowToFace(r.id, r.data);
29797
- if (matches(face)) matched.push(face);
29798
- }
29799
- if (pagingServesTheOrder) {
29800
- const merged = [...result, ...matched];
29801
- if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
29802
- result = merged;
29803
- } else result = rankAndCap([...result, ...matched], order, input.limit);
29804
- if (rows.length < 1e3) break;
29805
- }
29806
- return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
30417
+ });
30418
+ if (!callerBounded && rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
30419
+ const faces = rows.map((r) => rowToFace(r.id, r.data));
30420
+ return pagingServesTheOrder ? faces : rankAndCap(faces, order, input.limit);
29807
30421
  }
29808
- /** One real face row per camera currently attributed to an identity. */
30422
+ /**
30423
+ * One real face row per camera currently attributed to an identity.
30424
+ *
30425
+ * `recognizedIdentityId` is indexed (`idx_faces_identity_ts`), so this is a
30426
+ * bounded index walk over the identity's own rows rather than the
30427
+ * `offset`-paged sweep of the whole collection it used to be. It projects
30428
+ * `deviceId` alone — the answer is a device id and a row id, and the row id
30429
+ * is the record key.
30430
+ */
29809
30431
  async listCameraRefsForIdentity(identityId) {
30432
+ const rows = await this.identityRows(identityId, FACE_REF_COLUMNS);
29810
30433
  const byDevice = /* @__PURE__ */ new Map();
29811
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29812
- const rows = await this.store.query.query({
29813
- collection: FACES_COLLECTION,
29814
- filter: {
29815
- where: { recognizedIdentityId: identityId },
29816
- orderBy: {
29817
- field: "timestamp",
29818
- direction: "desc"
29819
- },
29820
- limit: FACE_SWEEP_PAGE,
29821
- offset: page * FACE_SWEEP_PAGE
29822
- }
29823
- });
29824
- if (rows.length === 0) break;
29825
- for (const row of rows) {
29826
- const deviceId = Number(row.data["deviceId"]);
29827
- if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
29828
- }
29829
- if (rows.length < 1e3) break;
30434
+ for (const row of rows) {
30435
+ const deviceId = Number(row.data["deviceId"]);
30436
+ if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
29830
30437
  }
29831
30438
  return [...byDevice].map(([deviceId, faceId]) => ({
29832
30439
  deviceId,
29833
30440
  faceId
29834
30441
  }));
29835
30442
  }
30443
+ /**
30444
+ * Every face row currently attributed to an identity, id only.
30445
+ *
30446
+ * `deleteIdentity` detaches these before the identity goes. It used to get
30447
+ * them by listing the WHOLE collection with `filter: 'all'` and no limit and
30448
+ * comparing in JS — which past `SETTINGS_QUERY_DEFAULT_ROW_CAP` would have
30449
+ * detached the rows that fit and left the rest pointing at a deleted
30450
+ * identity, silently, with a WARN in the engine that no caller can see.
30451
+ */
30452
+ async listFaceIdsForIdentity(identityId) {
30453
+ return (await this.identityRows(identityId, FACE_REF_COLUMNS)).map((r) => r.id);
30454
+ }
30455
+ /**
30456
+ * The rows of one identity, newest first, bounded and REFUSING rather than
30457
+ * narrowing. Shared by the two callers above so they cannot disagree about
30458
+ * what "this identity's faces" means.
30459
+ */
30460
+ async identityRows(identityId, columns) {
30461
+ const rows = await this.store.query.query({
30462
+ collection: FACES_COLLECTION,
30463
+ columns,
30464
+ filter: {
30465
+ where: { recognizedIdentityId: identityId },
30466
+ orderBy: {
30467
+ field: "timestamp",
30468
+ direction: "desc"
30469
+ },
30470
+ limit: 10001
30471
+ }
30472
+ });
30473
+ if (rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
30474
+ return rows;
30475
+ }
29836
30476
  };
29837
30477
  //#endregion
29838
30478
  //#region src/pipeline-analytics/face-gallery-provider.ts
@@ -29949,8 +30589,7 @@ var FaceGalleryProvider = class {
29949
30589
  });
29950
30590
  }
29951
30591
  async deleteIdentity(input) {
29952
- const faces = await this.faceStore.listAllRecentFaces({ filter: "all" });
29953
- for (const f of faces) if (f.recognizedIdentityId === input.id) await this.unassignFace({ faceId: f.id });
30592
+ for (const faceId of await this.faceStore.listFaceIdsForIdentity(input.id)) await this.unassignFace({ faceId });
29954
30593
  await this.mediaStore.deleteForOwner("identity", [input.id]);
29955
30594
  await this.identityStore.deleteIdentity(input.id);
29956
30595
  this.refreshGallery();
@@ -30023,8 +30662,7 @@ var FaceGalleryProvider = class {
30023
30662
  sortBy: input?.sortBy,
30024
30663
  sortDirection: input?.sortDirection
30025
30664
  });
30026
- const identities = await this.identityStore.listIdentities();
30027
- const nameMap = new Map(identities.map((i) => [i.id, i.name]));
30665
+ const nameMap = await this.identityStore.namesFor(faces.flatMap((f) => f.recognizedIdentityId != null ? [f.recognizedIdentityId] : []));
30028
30666
  const includeCrops = resolveIncludeCrops(input);
30029
30667
  return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
30030
30668
  const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
@@ -30060,8 +30698,7 @@ var FaceGalleryProvider = class {
30060
30698
  const face = await this.faceStore.get("face-" + input.trackId);
30061
30699
  if (!face) return null;
30062
30700
  if (face.deviceId !== input.deviceId) return null;
30063
- const identities = await this.identityStore.listIdentities();
30064
- const nameMap = new Map(identities.map((i) => [i.id, i.name]));
30701
+ const nameMap = await this.identityStore.namesFor(face.recognizedIdentityId != null ? [face.recognizedIdentityId] : []);
30065
30702
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
30066
30703
  const cropUrl = this.cropUrlFor(face);
30067
30704
  return {
@@ -34878,9 +35515,14 @@ var PlateGalleryProvider = class {
34878
35515
  ...cropUrl !== void 0 ? { cropUrl } : {}
34879
35516
  };
34880
35517
  }
34881
- async vehicleNameMap() {
34882
- const vehicles = await this.vehicleStore.listVehicles();
34883
- return new Map(vehicles.map((v) => [v.id, v.name]));
35518
+ /**
35519
+ * Names for the vehicles THESE PLATES reference — not the collection.
35520
+ *
35521
+ * A page of unassigned reads references none, and then this costs no round
35522
+ * trip at all. See `VehicleStore.namesFor`.
35523
+ */
35524
+ async vehicleNameMap(plates) {
35525
+ return this.vehicleStore.namesFor(plates.flatMap((p) => p.recognizedVehicleId != null ? [p.recognizedVehicleId] : []));
34884
35526
  }
34885
35527
  /**
34886
35528
  * `includeCrops` defaults to `true` — the pre-existing behaviour, kept so no
@@ -34895,14 +35537,14 @@ var PlateGalleryProvider = class {
34895
35537
  ...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
34896
35538
  ...input?.limit !== void 0 ? { limit: input.limit } : {}
34897
35539
  });
34898
- const nameMap = await this.vehicleNameMap();
35540
+ const nameMap = await this.vehicleNameMap(rows);
34899
35541
  const includeCrops = resolveIncludeCrops(input);
34900
35542
  return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap, { includeCrops })));
34901
35543
  }
34902
35544
  async getPlateByTrack(input) {
34903
35545
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
34904
35546
  if (!plate || plate.deviceId !== input.deviceId) return null;
34905
- const nameMap = await this.vehicleNameMap();
35547
+ const nameMap = await this.vehicleNameMap([plate]);
34906
35548
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34907
35549
  }
34908
35550
  async getPlateMedia(input) {
@@ -34915,7 +35557,7 @@ var PlateGalleryProvider = class {
34915
35557
  dist: plateDistance(input.text, p.text)
34916
35558
  })).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
34917
35559
  const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
34918
- const nameMap = await this.vehicleNameMap();
35560
+ const nameMap = await this.vehicleNameMap(limited.map((x) => x.p));
34919
35561
  const includeCrops = input.includeCrops ?? true;
34920
35562
  return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap, { includeCrops })));
34921
35563
  }
@@ -34990,8 +35632,7 @@ var PlateGalleryProvider = class {
34990
35632
  await this.vehicleStore.renameVehicle(input.id, input.name);
34991
35633
  }
34992
35634
  async deleteVehicle(input) {
34993
- const plates = await this.plateStore.listAllRecentPlates({});
34994
- for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
35635
+ for (const plateId of await this.plateStore.listPlateIdsForVehicle(input.id)) await this.unassignPlate({ plateId });
34995
35636
  await this.mediaStore.deleteForOwner("vehicle", [input.id]);
34996
35637
  await this.vehicleStore.deleteVehicle(input.id);
34997
35638
  this.refreshGallery();
@@ -35434,6 +36075,7 @@ async function runMediaReclaim(input) {
35434
36075
  byteBudget -= report.bytesReclaimed;
35435
36076
  nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
35436
36077
  devices.push(report);
36078
+ input.onDevice?.(report);
35437
36079
  if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
35438
36080
  tags: { deviceId },
35439
36081
  meta: {
@@ -35468,33 +36110,10 @@ async function runMediaReclaim(input) {
35468
36110
  cursors: nextCursors
35469
36111
  };
35470
36112
  }
35471
- //#endregion
35472
- //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35473
- /**
35474
- * The operator button for the debug-media reclaim.
35475
- *
35476
- * The mechanism ships armed with nothing: **arming it is the operator's
35477
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
35478
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35479
- * that has to be typed.
35480
- *
35481
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35482
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35483
- * report you can read instead of a log line you have to find.
35484
- */
35485
- /**
35486
- * `report` counts; `reclaim` deletes.
35487
- *
35488
- * `.default('report')` is load-bearing: the settings form sends `{}` for a
35489
- * button with no fields, and the safe reading of an unstated intent is the one
35490
- * that destroys nothing.
35491
- */
35492
- var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
35493
- var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
35494
36113
  var MediaReclaimActionInputSchema = object({
35495
- mode: MediaReclaimActionModeSchema,
36114
+ mode: _enum(["report", "reclaim"]).default("report"),
35496
36115
  /** Which populations to walk. Omitted = both. */
35497
- scopes: array(MediaReclaimScopeSchema).min(1).optional(),
36116
+ scopes: array(_enum(["motion-still", "track-filmstrip"])).min(1).optional(),
35498
36117
  /** Restrict to these cameras. Omitted = every camera in retention scope. */
35499
36118
  deviceIds: array(number().int()).min(1).optional(),
35500
36119
  /** Start every device from the top instead of resuming its cursor. */
@@ -35521,39 +36140,28 @@ var MediaReclaimActionInputSchema = object({
35521
36140
  */
35522
36141
  graceMinutes: number().int().min(1).max(10080).optional()
35523
36142
  });
35524
- var MediaReclaimDeviceReportSchema = object({
35525
- deviceId: number(),
35526
- scope: MediaReclaimScopeSchema,
35527
- examined: number(),
35528
- eligible: number(),
35529
- reclaimed: number(),
35530
- bytesReclaimed: number(),
35531
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35532
- held: number(),
35533
- /** Rows kept because the owner could not be shown to be a motion event. */
35534
- unresolved: number(),
35535
- withinGrace: number(),
35536
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
35537
- * The row was kept and the cursor did not pass it. */
35538
- refused: number(),
35539
- stoppedBy: string(),
35540
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35541
- nextCursor: number()
36143
+ var MediaReclaimStartedSchema = object({
36144
+ started: boolean(),
36145
+ alreadyRunning: boolean()
35542
36146
  });
35543
- var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
35544
- /** `false` when a pass was already in flight — nothing was done. */
35545
- ran: boolean(),
35546
- mode: _enum(["report", "reclaim"]),
35547
- devices: array(MediaReclaimDeviceReportSchema),
36147
+ var MediaReclaimStatusSchema = object({
36148
+ running: boolean(),
36149
+ mode: _enum(["report", "reclaim"]).nullable(),
35548
36150
  totalExamined: number(),
35549
36151
  totalEligible: number(),
35550
36152
  totalReclaimed: number(),
35551
36153
  totalBytesReclaimed: number(),
35552
36154
  totalRefused: number(),
35553
- durationMs: number(),
35554
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
35555
- complete: boolean()
35556
- }), { auth: "admin" }) });
36155
+ devicesDone: number(),
36156
+ complete: boolean().nullable(),
36157
+ startedAtMs: number().nullable(),
36158
+ finishedAtMs: number().nullable(),
36159
+ error: string().nullable()
36160
+ });
36161
+ var mediaReclaimActions = defineCustomActions({
36162
+ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, MediaReclaimStartedSchema, { auth: "admin" }),
36163
+ "retention.mediaReclaimStatus": customAction(object({}), MediaReclaimStatusSchema, { auth: "admin" })
36164
+ });
35557
36165
  var OrphanAuditActionInputSchema = object({
35558
36166
  mode: _enum(["report", "reclaim"]).default("report"),
35559
36167
  /**
@@ -40608,6 +41216,7 @@ function createLocationAwareMediaStorage(deps) {
40608
41216
  const roots = /* @__PURE__ */ new Map();
40609
41217
  let defaultBypassInvalidated = false;
40610
41218
  const rootOf = async (locationId) => {
41219
+ if (deps.galleryLocationId !== void 0 && deps.galleryRoot !== void 0 && locationId === deps.galleryLocationId) return deps.galleryRoot;
40611
41220
  const cached = roots.get(locationId);
40612
41221
  if (cached !== void 0) return cached;
40613
41222
  const root = await deps.resolveRoot(locationId);
@@ -43114,7 +43723,7 @@ var PhotoAnalysisSessions = class {
43114
43723
  * a magnet that recognises every degraded probe as its owner, and 22 of 50
43115
43724
  * live samples were enrolled that way on 2026-08-18;
43116
43725
  * 3. both blobs are written under `ownerKind: 'identity'`, which is what puts
43117
- * them on `galleryMedia` — the location a footage wipe cannot reach (D197);
43726
+ * them in the addon data dir — the location a footage wipe cannot reach (D197);
43118
43727
  * 4. the row is written last, and if it does not land BOTH blobs are reclaimed.
43119
43728
  * An orphan under `identity` is exempt from every sweep this repo has, so
43120
43729
  * it would otherwise live forever.
@@ -44845,6 +45454,45 @@ function pickWritableMediaLocation(locations, currentDefaultId) {
44845
45454
  }
44846
45455
  //#endregion
44847
45456
  //#region src/pipeline-analytics/media-relocate-engine.ts
45457
+ /**
45458
+ * Event-media relocation engine (entity-routing spec, Phase 4) — the
45459
+ * analytics sibling of the recorder's footage mover. Each addon moves ONLY
45460
+ * what it owns (addons never touch each other's files).
45461
+ *
45462
+ * Per row: read blob from its CURRENT location → write to the target →
45463
+ * stamp `row.locationId` → delete the old blob. The row update lands between
45464
+ * the two blob operations, so a crash leaves the row pointing at a location
45465
+ * that still holds valid bytes (the old blob is only deleted after the row
45466
+ * points away from it). Rows already at the target are skipped — a re-run
45467
+ * RESUMES.
45468
+ *
45469
+ * Paging: timestamp-cursor over the media collection, with a seen-id set
45470
+ * bridging equal-timestamp boundaries. The PASS pages because it must visit
45471
+ * every row to act on it; that is not true of every question asked about this
45472
+ * collection, and `countUnstamped` no longer pages — see its docblock, and the
45473
+ * 60 s timeout that shape cost.
45474
+ *
45475
+ * (This header used to claim `WHERE locationId IS NULL` was inexpressible
45476
+ * through the store's filter. It was expressible, and that was the problem:
45477
+ * `where: { locationId: null }` compiled to `= NULL`, which matches nothing
45478
+ * and reports it as `0`. The filter layer now compiles it to `IS NULL`.)
45479
+ *
45480
+ * ── Three passes, one engine (see `MediaRelocateModeSchema`) ──────────────
45481
+ *
45482
+ * `move` is the historical pass. `seal` and `gallery` are the SAME walk with a
45483
+ * different row predicate and a different per-row action, deliberately not a
45484
+ * second mover: the walk, the cursor, the tie-bridging and the failure
45485
+ * accounting are the hard parts and they are already right here.
45486
+ *
45487
+ * - `seal` exists because a row with `locationId: NULL` means "wherever
45488
+ * `eventMedia` points NOW" (`media-store.ts:396`). A repoint moves that
45489
+ * pointer and every NULL row starts reading from the new disk while its
45490
+ * bytes are on the old one. `seal` is a ROW-ONLY pass — no blob is read,
45491
+ * written or deleted — that nails those rows to the location they are
45492
+ * actually on before anything repoints.
45493
+ * - `gallery` is the inverse selection of `move`: only the retention-exempt
45494
+ * enrolled-gallery rows, which `move` skips on purpose (D197).
45495
+ */
44848
45496
  var PAGE_SIZE = 200;
44849
45497
  var DEFAULT_THROTTLE_MBPS = 40;
44850
45498
  /** What a pass touches, reported on the job so an ops-log row says which of the
@@ -44889,6 +45537,59 @@ var LOCATION_ID_FIELD = "locationId";
44889
45537
  */
44890
45538
  var SEAL_BATCH_SIZE = 500;
44891
45539
  /**
45540
+ * Rows one CENSUS may visit before it answers `null`.
45541
+ *
45542
+ * A census is the read-only question behind `storageMigration.residue` — "how
45543
+ * much would a pass still act on". It is NOT the pass: a pass walks the whole
45544
+ * collection because it has to touch every row, and pays for that walk once,
45545
+ * under an operator's explicit start.
45546
+ *
45547
+ * 20 000 rows is 100 round trips at {@link PAGE_SIZE} — ~26 ms of engine time
45548
+ * on a healthy store, and a ceiling that cannot become the cluster's queue. A
45549
+ * collection larger than this is one a WALK cannot answer questions about; the
45550
+ * honest reply is "I could not answer" (D295), and the structural fix is an
45551
+ * indexed `COUNT(*)`, which needs `locationId` sealed first and is deliberately
45552
+ * not smuggled in behind a bound.
45553
+ */
45554
+ var MEDIA_CENSUS_ROW_BUDGET = 2e4;
45555
+ /**
45556
+ * Wall clock one CENSUS may spend before it answers `null`.
45557
+ *
45558
+ * The row budget bounds a healthy store; this one bounds a sick store, where
45559
+ * 100 round trips at a 16 s queue depth is 27 minutes. Read through
45560
+ * `deps.now`, so it is deterministic under test and does not need a timer.
45561
+ */
45562
+ var MEDIA_CENSUS_DEADLINE_MS = 2e4;
45563
+ /**
45564
+ * What one census may spend, and what it has spent.
45565
+ *
45566
+ * Explicit rather than folded into the `stop` callback: `stop` means "someone
45567
+ * asked me to stop" and the budget means "I am not allowed to continue", and a
45568
+ * pass passes `null` here because a pass is allowed to walk the whole
45569
+ * collection. Two concepts, two parameters — the walk that ran unbounded for
45570
+ * a day did so because ONE `stop: () => false` stood for both.
45571
+ */
45572
+ var CensusBudget = class {
45573
+ now;
45574
+ maxRows;
45575
+ rows = 0;
45576
+ deadlineAt;
45577
+ constructor(now, maxRows, maxMs) {
45578
+ this.now = now;
45579
+ this.maxRows = maxRows;
45580
+ this.deadlineAt = now() + maxMs;
45581
+ }
45582
+ get rowsVisited() {
45583
+ return this.rows;
45584
+ }
45585
+ /** Visit one row. `true` means the budget is spent and the census must stop. */
45586
+ spend() {
45587
+ this.rows += 1;
45588
+ if (this.rows > this.maxRows) return true;
45589
+ return this.rows % PAGE_SIZE === 0 && this.now() >= this.deadlineAt;
45590
+ }
45591
+ };
45592
+ /**
44892
45593
  * True when the row names NO location — the population `seal` exists for.
44893
45594
  *
44894
45595
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -44945,9 +45646,26 @@ function selectsRow(mode, toLocationId, data, isRetrain) {
44945
45646
  var MediaRelocateEngine = class {
44946
45647
  deps;
44947
45648
  jobs = /* @__PURE__ */ new Map();
45649
+ /** Censuses in flight, keyed by the QUESTION — see {@link countSelectable}. */
45650
+ censuses = /* @__PURE__ */ new Map();
44948
45651
  constructor(deps) {
44949
45652
  this.deps = deps;
44950
45653
  }
45654
+ /**
45655
+ * Retire every census in flight. They answer `null`, and stop reading.
45656
+ *
45657
+ * @returns how many were retired.
45658
+ */
45659
+ cancelCensuses(reason) {
45660
+ const retired = this.censuses.size;
45661
+ if (retired === 0) return 0;
45662
+ for (const run of this.censuses.values()) run.abort();
45663
+ this.deps.logger.info("media relocate: standing censuses retired", { meta: {
45664
+ retired,
45665
+ reason
45666
+ } });
45667
+ return retired;
45668
+ }
44951
45669
  list() {
44952
45670
  return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
44953
45671
  }
@@ -45046,6 +45764,7 @@ var MediaRelocateEngine = class {
45046
45764
  }
45047
45765
  start(input) {
45048
45766
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
45767
+ this.cancelCensuses("a relocate pass started");
45049
45768
  const job = {
45050
45769
  jobId: this.deps.newId(),
45051
45770
  state: "running",
@@ -45116,9 +45835,31 @@ var MediaRelocateEngine = class {
45116
45835
  * must not be allowed to make (D295).
45117
45836
  */
45118
45837
  async countSelectable(input) {
45119
- return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
45838
+ const mode = input.mode ?? "move";
45839
+ const key = `${mode}|${input.toLocationId}`;
45840
+ const running = this.censuses.get(key);
45841
+ if (running !== void 0) return running.promise;
45842
+ let aborted = false;
45843
+ const budget = new CensusBudget(this.deps.now, MEDIA_CENSUS_ROW_BUDGET, MEDIA_CENSUS_DEADLINE_MS);
45844
+ const promise = this.countSelected(mode, input.toLocationId, () => aborted, budget).finally(() => {
45845
+ this.censuses.delete(key);
45846
+ });
45847
+ this.censuses.set(key, {
45848
+ promise,
45849
+ abort: () => {
45850
+ aborted = true;
45851
+ }
45852
+ });
45853
+ return promise;
45120
45854
  }
45121
- async countSelected(mode, toLocationId, stop) {
45855
+ /**
45856
+ * @param stop someone asked this walk to stop (an operator cancel, or
45857
+ * {@link cancelCensuses}). Answers `null`.
45858
+ * @param budget what this walk is allowed to spend, or `null` for a PASS —
45859
+ * a pass walks the whole collection because it must touch every row, and
45860
+ * an operator started it explicitly.
45861
+ */
45862
+ async countSelected(mode, toLocationId, stop, budget) {
45122
45863
  try {
45123
45864
  if (mode === "seal") {
45124
45865
  let sealable = 0;
@@ -45135,7 +45876,25 @@ var MediaRelocateEngine = class {
45135
45876
  for (const collection of collectionsFor(mode)) {
45136
45877
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
45137
45878
  for await (const row of this.pageRows(collection.name, collection.order)) {
45138
- if (stop()) return null;
45879
+ if (stop()) {
45880
+ this.deps.logger.warn("media relocate: census stopped before it finished — reporting the residue as unknown, not as empty", { meta: {
45881
+ mode,
45882
+ toLocationId,
45883
+ collection: collection.name
45884
+ } });
45885
+ return null;
45886
+ }
45887
+ if (budget !== null && budget.spend()) {
45888
+ this.deps.logger.warn("media relocate: census exceeded its budget — this collection is larger than a walk can answer", { meta: {
45889
+ mode,
45890
+ toLocationId,
45891
+ collection: collection.name,
45892
+ rowsVisited: budget.rowsVisited,
45893
+ rowBudget: MEDIA_CENSUS_ROW_BUDGET,
45894
+ deadlineMs: MEDIA_CENSUS_DEADLINE_MS
45895
+ } });
45896
+ return null;
45897
+ }
45139
45898
  if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
45140
45899
  }
45141
45900
  }
@@ -45161,7 +45920,7 @@ var MediaRelocateEngine = class {
45161
45920
  * rows in silence reads as "there were none", which is how a migration
45162
45921
  * gets believed. */
45163
45922
  let pathlessRows = 0;
45164
- job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
45923
+ job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested, null))?.rows ?? null;
45165
45924
  if (job.mode === "seal") {
45166
45925
  for (const collection of collectionsFor(job.mode)) {
45167
45926
  if (job.cancelRequested) break;
@@ -52690,6 +53449,50 @@ var IdentityStore = class {
52690
53449
  key: id
52691
53450
  });
52692
53451
  }
53452
+ /**
53453
+ * `identityId → name` for exactly the identities asked about.
53454
+ *
53455
+ * The decorating half of a face or plate page needs a NAME per referenced
53456
+ * identity, and it used to get it by reading the whole `identities`
53457
+ * collection — unbounded, unprojected, on every 30 s poll of
53458
+ * `faceGallery.listRecentFaces`, and again on every `getFaceByTrack`. The
53459
+ * table is small (4 rows on the live hub on 2026-08-31) so the bytes were
53460
+ * never the problem; the SHAPE was. It is a second question whose cost is
53461
+ * proportional to a collection, asked on a timer, to answer a question about
53462
+ * a page — and it carried no `limit`, so past
53463
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` it would have handed back a name map
53464
+ * missing entries, shape-identical to a complete one (D320).
53465
+ *
53466
+ * **An empty request costs no round trip at all.** With
53467
+ * `filter: 'unassigned'` — the view the operator lives in — a page
53468
+ * references no identity, and the honest number of queries for that is zero.
53469
+ * `whereIn` with an empty list matches nothing, so asking anyway would be an
53470
+ * RPC to be told what the caller already knew.
53471
+ *
53472
+ * A cache belongs in the owner of the fact, and this store IS the owner —
53473
+ * but a name here changes only when the operator renames, through this same
53474
+ * store, and the read is now one indexed `IN` over at most a page's worth of
53475
+ * ids. There is nothing left to cache that would not be a second authority
53476
+ * over a fact one round trip away (D224).
53477
+ */
53478
+ async namesFor(identityIds) {
53479
+ const ids = [...new Set(identityIds)];
53480
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
53481
+ const rows = await this.store.query.query({
53482
+ collection: IDENTITIES_COLLECTION,
53483
+ columns: ["name"],
53484
+ filter: {
53485
+ whereIn: { id: ids },
53486
+ limit: ids.length
53487
+ }
53488
+ });
53489
+ const names = /* @__PURE__ */ new Map();
53490
+ for (const r of rows) {
53491
+ const name = r.data["name"];
53492
+ if (typeof name === "string") names.set(r.id, name);
53493
+ }
53494
+ return names;
53495
+ }
52693
53496
  async listIdentities() {
52694
53497
  return (await this.store.query.query({
52695
53498
  collection: IDENTITIES_COLLECTION,
@@ -53231,13 +54034,55 @@ var PLATE_COLUMNS = [
53231
54034
  type: "JSON"
53232
54035
  }
53233
54036
  ];
53234
- var PLATE_INDEXES = [{
53235
- name: "idx_plates_device_ts",
53236
- columns: ["deviceId", "timestamp"]
53237
- }, {
53238
- name: "idx_plates_track",
53239
- columns: ["trackId"]
53240
- }];
54037
+ /**
54038
+ * What the gallery orders by, and what the vehicle-scoped read filters on.
54039
+ *
54040
+ * The face twin's `EXPLAIN QUERY PLAN` measurement applies verbatim: without an
54041
+ * index on `timestamp`, `ORDER BY timestamp DESC LIMIT 500` is
54042
+ * `SCAN + USE TEMP B-TREE FOR ORDER BY` — the read was already BOUNDED at 200,
54043
+ * and still sorted the whole table to produce the newest N. A bound caps what
54044
+ * comes back; only an ordered index caps what is read.
54045
+ */
54046
+ var PLATE_INDEXES = [
54047
+ {
54048
+ name: "idx_plates_device_ts",
54049
+ columns: ["deviceId", "timestamp"]
54050
+ },
54051
+ {
54052
+ name: "idx_plates_track",
54053
+ columns: ["trackId"]
54054
+ },
54055
+ {
54056
+ name: "idx_plates_ts",
54057
+ columns: ["timestamp"]
54058
+ },
54059
+ {
54060
+ name: "idx_plates_vehicle_ts",
54061
+ columns: ["recognizedVehicleId", "timestamp"]
54062
+ }
54063
+ ];
54064
+ /**
54065
+ * The widest plate window a single read may materialise before it REFUSES.
54066
+ *
54067
+ * The corpus reads (`searchPlates`, `suggestPlateClusters`) are genuinely
54068
+ * corpus-shaped — fuzzy text distance is not a predicate — so they cannot be
54069
+ * narrowed the way the gallery list was. What they CAN do is stop lying: they
54070
+ * carried no `limit` at all, so the engine imposed
54071
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` and handed back the first 2 000 rows with a
54072
+ * WARN no caller can see. A search over the newest 2 000 of 50 000 plates and a
54073
+ * search over all of them are the same array (D320 R3).
54074
+ *
54075
+ * 10 000 mirrors the faces ceiling and stays under the engine's hard cap so the
54076
+ * engine never truncates first.
54077
+ */
54078
+ var PLATE_WINDOW_MAX_ROWS = 1e4;
54079
+ /** A corpus read whose window is wider than {@link PLATE_WINDOW_MAX_ROWS}. */
54080
+ var PlateWindowTooWideError = class extends Error {
54081
+ constructor(rows) {
54082
+ super(`plate window exceeds ${String(PLATE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit`);
54083
+ this.name = "PlateWindowTooWideError";
54084
+ }
54085
+ };
53241
54086
  var PlateStore = class {
53242
54087
  store;
53243
54088
  logger;
@@ -53302,18 +54147,53 @@ var PlateStore = class {
53302
54147
  }
53303
54148
  })).map((r) => this.normalizeRow(r));
53304
54149
  }
53305
- /** List ALL recent plate reads across devices, newest first. */
54150
+ /**
54151
+ * List ALL recent plate reads across devices, newest first.
54152
+ *
54153
+ * The corpus read, for the two passes whose question is not a predicate
54154
+ * (fuzzy text search, text clustering). It carries an explicit `limit` and
54155
+ * REFUSES past it rather than handing back the part that fit: without one the
54156
+ * engine imposed its own cap and truncated silently, and a search over the
54157
+ * newest 2 000 plates looks exactly like a search over all of them.
54158
+ */
53306
54159
  async listAllRecentPlates(input) {
53307
- return (await this.store.query.query({
54160
+ const bounded = input.limit !== void 0;
54161
+ const rows = await this.store.query.query({
53308
54162
  collection: PLATES_COLLECTION,
53309
54163
  filter: {
53310
54164
  orderBy: {
53311
54165
  field: "timestamp",
53312
54166
  direction: "desc"
53313
54167
  },
53314
- ...input.limit !== void 0 ? { limit: input.limit } : {}
54168
+ limit: bounded ? input.limit ?? 0 : 10001
53315
54169
  }
53316
- })).map((r) => this.normalizeRow(r));
54170
+ });
54171
+ if (!bounded && rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
54172
+ return rows.map((r) => this.normalizeRow(r));
54173
+ }
54174
+ /**
54175
+ * Every plate row currently attributed to a vehicle, id only.
54176
+ *
54177
+ * `deleteVehicle` detaches these before the vehicle goes. It used to list the
54178
+ * WHOLE buffer and compare in JS — the `deleteIdentity` defect verbatim, and
54179
+ * past the engine's row cap it would detach the plates that fit and leave the
54180
+ * rest pointing at a vehicle that no longer exists.
54181
+ */
54182
+ async listPlateIdsForVehicle(vehicleId) {
54183
+ const rows = await this.store.query.query({
54184
+ collection: PLATES_COLLECTION,
54185
+ columns: ["deviceId"],
54186
+ filter: {
54187
+ where: { recognizedVehicleId: vehicleId },
54188
+ orderBy: {
54189
+ field: "timestamp",
54190
+ direction: "desc"
54191
+ },
54192
+ limit: 10001
54193
+ }
54194
+ });
54195
+ if (rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
54196
+ return rows.map((r) => r.id);
53317
54197
  }
53318
54198
  async get(plateId) {
53319
54199
  const raw = await this.store.get.query({
@@ -55973,6 +56853,38 @@ var VehicleStore = class {
55973
56853
  key: id
55974
56854
  });
55975
56855
  }
56856
+ /**
56857
+ * `vehicleId → name` for exactly the vehicles asked about — the plate twin of
56858
+ * `IdentityStore.namesFor`, and it exists for the same reason.
56859
+ *
56860
+ * The decorating half of a plate page needs a NAME per referenced vehicle,
56861
+ * and it got it by reading the whole `vehicles` collection: unbounded,
56862
+ * unprojected, on every 30 s poll of `plateGallery.listPlates` and again on
56863
+ * every `getPlateByTrack` and `searchPlates`. The live table holds **0 rows**
56864
+ * — the bytes were never the point. The shape was: a question proportional to
56865
+ * a collection, on a timer, to decorate a page.
56866
+ *
56867
+ * An empty request costs no round trip: `whereIn` over an empty list matches
56868
+ * nothing, so asking would be an RPC to be told what the caller knew.
56869
+ */
56870
+ async namesFor(vehicleIds) {
56871
+ const ids = [...new Set(vehicleIds)];
56872
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
56873
+ const rows = await this.store.query.query({
56874
+ collection: VEHICLES_COLLECTION,
56875
+ columns: ["name"],
56876
+ filter: {
56877
+ whereIn: { id: ids },
56878
+ limit: ids.length
56879
+ }
56880
+ });
56881
+ const names = /* @__PURE__ */ new Map();
56882
+ for (const r of rows) {
56883
+ const name = r.data["name"];
56884
+ if (typeof name === "string") names.set(r.id, name);
56885
+ }
56886
+ return names;
56887
+ }
55976
56888
  async listVehicles() {
55977
56889
  return (await this.store.query.query({
55978
56890
  collection: VEHICLES_COLLECTION,
@@ -62944,6 +63856,50 @@ var ZoneAnalyticsProvider = class {
62944
63856
  return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
62945
63857
  }
62946
63858
  /**
63859
+ * The same snapshot, for a SET of cameras, in one round trip.
63860
+ *
63861
+ * The Events page's Stationary section polls this every 15 s for every
63862
+ * selected camera. Fanned out client-side that is one browser → hub →
63863
+ * post-analysis round trip per camera to read a `Map.get`; the answer costs
63864
+ * nothing and the transport costs everything. The per-device work here is
63865
+ * unchanged — the same live snapshot, the same parked-registry hydration when
63866
+ * there is no live one — and it runs concurrently inside the owner.
63867
+ *
63868
+ * **Three outcomes stay three.** A camera with a reading, a camera with none
63869
+ * yet (`snapshot: null`, `read: 'read'`), and a camera the owner could not
63870
+ * answer for (`read: 'unreadable'`). The hydration reaches a durable store
63871
+ * and can throw; letting that throw become `snapshot: null` would render an
63872
+ * unread camera as a camera with nothing parked, which is a definite claim
63873
+ * nobody made.
63874
+ */
63875
+ async getCurrentSnapshotBatch({ deviceIds }) {
63876
+ return Promise.all(deviceIds.map(async (deviceId) => {
63877
+ const live = this.snapshots.get(deviceId);
63878
+ if (live !== void 0) return {
63879
+ deviceId,
63880
+ read: "read",
63881
+ snapshot: live
63882
+ };
63883
+ try {
63884
+ return {
63885
+ deviceId,
63886
+ read: "read",
63887
+ snapshot: await this.hydrateFromRegistry(deviceId)
63888
+ };
63889
+ } catch (err) {
63890
+ this.ctx.logger.warn("occupancy snapshot could not be read", {
63891
+ tags: { deviceId },
63892
+ meta: { error: String(err) }
63893
+ });
63894
+ return {
63895
+ deviceId,
63896
+ read: "unreadable",
63897
+ snapshot: null
63898
+ };
63899
+ }
63900
+ }));
63901
+ }
63902
+ /**
62947
63903
  * The cameras a LIVE snapshot exists for.
62948
63904
  *
62949
63905
  * The Notification Center's occupancy dry-run ("as of now, would this rule
@@ -63286,6 +64242,20 @@ var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
63286
64242
  * window rather than a random slice of it.
63287
64243
  */
63288
64244
  var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
64245
+ var IDLE_MEDIA_RECLAIM = {
64246
+ running: false,
64247
+ mode: null,
64248
+ totalExamined: 0,
64249
+ totalEligible: 0,
64250
+ totalReclaimed: 0,
64251
+ totalBytesReclaimed: 0,
64252
+ totalRefused: 0,
64253
+ devicesDone: 0,
64254
+ complete: null,
64255
+ startedAtMs: null,
64256
+ finishedAtMs: null,
64257
+ error: null
64258
+ };
63289
64259
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
63290
64260
  /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
63291
64261
  * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
@@ -64401,9 +65371,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64401
65371
  * In RAM like the audit's: the pass is resumable from zero at no cost
64402
65372
  * beyond re-walking a head that is already empty. */
64403
65373
  mediaReclaimCursors = /* @__PURE__ */ new Map();
64404
- /** Single-flight latch — two concurrent passes would double the disk
64405
- * pressure the pacing exists to avoid. */
64406
- mediaReclaimRunning = false;
65374
+ /** Live state of the detached debug-media reclaim — see `reclaimDebugMedia`. */
65375
+ mediaReclaimState = { ...IDLE_MEDIA_RECLAIM };
64407
65376
  /**
64408
65377
  * The vector-index leg's resume cursor, kept in its OWN field.
64409
65378
  *
@@ -64843,6 +65812,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
64843
65812
  const routed = createLocationAwareMediaStorage({
64844
65813
  base: storage,
64845
65814
  defaultLocation: "eventMedia",
65815
+ galleryLocationId: GALLERY_MEDIA_LOCATION,
65816
+ galleryRoot: path.join(this.ctx.dataDir, "gallery"),
64846
65817
  resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
64847
65818
  location: locationId,
64848
65819
  relativePath: ""
@@ -65502,6 +66473,43 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65502
66473
  },
65503
66474
  resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
65504
66475
  mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
66476
+ listAddonUpdates: async (nodeId) => {
66477
+ return (await this.ctx.api.addons.listUpdates.query({ nodeId })).filter((update) => !update.isSystem).map((update) => update.name);
66478
+ },
66479
+ applyAddonUpdate: async ({ packageName, nodeId }) => {
66480
+ try {
66481
+ await this.ctx.api.addons.updatePackage.mutate({
66482
+ name: packageName,
66483
+ nodeId
66484
+ });
66485
+ return { ok: true };
66486
+ } catch (err) {
66487
+ return {
66488
+ ok: false,
66489
+ error: errMsg(err)
66490
+ };
66491
+ }
66492
+ },
66493
+ checkServerUpdate: async (nodeId) => {
66494
+ const result = await this.ctx.api.serverManagement.checkServerUpdate.mutate({ nodeId });
66495
+ if (result.error) throw new Error(result.error);
66496
+ return result.updateAvailable;
66497
+ },
66498
+ applyServerUpdate: async ({ nodeId }) => {
66499
+ try {
66500
+ const result = await this.ctx.api.serverManagement.applyServerUpdate.mutate({ nodeId });
66501
+ if (!result.accepted) return {
66502
+ ok: false,
66503
+ error: result.message
66504
+ };
66505
+ return { ok: true };
66506
+ } catch (err) {
66507
+ return {
66508
+ ok: false,
66509
+ error: errMsg(err)
66510
+ };
66511
+ }
66512
+ },
65505
66513
  readDeviceStates: async (ids) => {
65506
66514
  const out = /* @__PURE__ */ new Map();
65507
66515
  for (const id of ids) try {
@@ -66434,19 +67442,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66434
67442
  async reconcileGalleryMediaLocation() {
66435
67443
  const mediaStore = this.mediaStore;
66436
67444
  if (!mediaStore) return;
66437
- try {
66438
- await this.ctx.api.storage.resolve.query({
66439
- location: GALLERY_MEDIA_LOCATION,
66440
- relativePath: ""
66441
- });
66442
- } catch (err) {
66443
- this.ctx.logger.error("gallery media location is NOT available — enrolling a face, a plate or a scene reference will FAIL until it is", { meta: {
66444
- location: GALLERY_MEDIA_LOCATION,
66445
- error: errMsg(err),
66446
- hint: "the declaration is seeded when a storage-provider registers; restart this node so the manifest is re-read (D197). Refusing the write is deliberate — the alternative is the footage volume, which is where the last gallery was lost."
66447
- } });
66448
- return;
66449
- }
66450
67445
  try {
66451
67446
  const result = await mediaStore.relocateExemptMediaToGallery();
66452
67447
  if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
@@ -70461,6 +71456,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
70461
71456
  return [];
70462
71457
  }
70463
71458
  }
71459
+ /**
71460
+ * `getKeyEvents` for a SET of cameras, in one round trip.
71461
+ *
71462
+ * The Detection Intelligence events feed asked this of every selected camera
71463
+ * on a 30 s timer — N browser → hub → post-analysis round trips for N
71464
+ * independent, already-indexed store queries. The queries are unchanged and
71465
+ * still run per camera (each is `where deviceId` + a `firstSeen` range
71466
+ * against its own index, and `limit` stays PER CAMERA so a busy camera cannot
71467
+ * starve a quiet one of its rows); what collapses is the transport.
71468
+ *
71469
+ * Every requested id gets a row. `getKeyEvents` already degrades to `[]`
71470
+ * rather than throwing, so a per-camera failure is reported exactly as it was
71471
+ * before this method existed — this does not make it worse, and the row
71472
+ * carries the `deviceId` the single method's output never did.
71473
+ */
71474
+ async getKeyEventsBatch(input) {
71475
+ return Promise.all(input.deviceIds.map(async (deviceId) => {
71476
+ return {
71477
+ deviceId,
71478
+ events: await this.getKeyEvents({
71479
+ deviceId,
71480
+ since: input.since,
71481
+ until: input.until,
71482
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
71483
+ ...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
71484
+ ...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
71485
+ })
71486
+ };
71487
+ }));
71488
+ }
70464
71489
  async getAudioEvents(input) {
70465
71490
  return this.queryFacade.getAudioEvents(input);
70466
71491
  }
@@ -71485,42 +72510,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71485
72510
  "embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
71486
72511
  "embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
71487
72512
  "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
71488
- "retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
72513
+ "retention.mediaReclaim": async (input) => this.reclaimDebugMedia(input),
72514
+ "retention.mediaReclaimStatus": async () => this.getMediaReclaimStatus()
71489
72515
  };
71490
72516
  }
71491
72517
  /**
71492
- * One operator-driven debug-media reclaim pass — the entry point behind
71493
- * `retention.mediaReclaim`.
71494
- *
71495
- * DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
71496
- * `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
71497
- * from the per-device cursors this method keeps, and a run that is cut short
71498
- * has still only forgotten rows whose blobs it proved gone.
72518
+ * Start a debug-media reclaim pass. DETACHED.
71499
72519
  *
71500
- * Single-flight: a second call while a pass is in flight returns
71501
- * `ran: false` rather than doubling the disk pressure the pacing exists to
71502
- * avoid.
72520
+ * Returns `{ started }` immediately. The walk is tens of minutes at 2 MB/s;
72521
+ * awaiting it is a timeout, not a verdict. Poll {@link getMediaReclaimStatus}.
72522
+ * Dry-run unless `mode: 'reclaim'`. A second call while one is in flight is
72523
+ * `alreadyRunning`, not a second walk on the same spindle.
71503
72524
  */
71504
- async runMediaReclaimNow(rawInput) {
72525
+ async reclaimDebugMedia(rawInput) {
71505
72526
  const input = MediaReclaimActionInputSchema.parse(rawInput);
72527
+ if (this.mediaReclaimState.running) {
72528
+ this.ctx.logger.info("debug-media reclaim already running — request ignored");
72529
+ return {
72530
+ started: false,
72531
+ alreadyRunning: true
72532
+ };
72533
+ }
71506
72534
  const mediaStore = this.mediaStore;
71507
72535
  const eventStore = this.eventStore;
71508
72536
  const trackStore = this.trackStore;
71509
- const empty = {
71510
- ran: false,
72537
+ if (!mediaStore || !eventStore || !trackStore) {
72538
+ this.ctx.logger.warn("reclaimDebugMedia: analytics stores unavailable");
72539
+ return {
72540
+ started: false,
72541
+ alreadyRunning: false
72542
+ };
72543
+ }
72544
+ this.mediaReclaimState = {
72545
+ ...IDLE_MEDIA_RECLAIM,
72546
+ running: true,
71511
72547
  mode: input.mode,
71512
- devices: [],
71513
- totalExamined: 0,
71514
- totalEligible: 0,
71515
- totalReclaimed: 0,
71516
- totalBytesReclaimed: 0,
71517
- totalRefused: 0,
71518
- durationMs: 0,
71519
- complete: false
72548
+ startedAtMs: Date.now()
72549
+ };
72550
+ this.ctx.logger.info("debug-media reclaim starting", { meta: {
72551
+ mode: input.mode,
72552
+ restart: input.restart === true
72553
+ } });
72554
+ this.executeMediaReclaim(input, mediaStore, eventStore, trackStore);
72555
+ return {
72556
+ started: true,
72557
+ alreadyRunning: false
71520
72558
  };
71521
- if (!mediaStore || !eventStore || !trackStore) return empty;
71522
- if (this.mediaReclaimRunning) return empty;
71523
- this.mediaReclaimRunning = true;
72559
+ }
72560
+ /**
72561
+ * The walk itself. Called DETACHED from {@link reclaimDebugMedia}; never
72562
+ * await this from an RPC.
72563
+ */
72564
+ async executeMediaReclaim(input, mediaStore, eventStore, trackStore) {
71524
72565
  try {
71525
72566
  const limits = {
71526
72567
  mode: input.mode,
@@ -71538,6 +72579,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71538
72579
  scopes,
71539
72580
  limits,
71540
72581
  cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
72582
+ onDevice: (device) => {
72583
+ const s = this.mediaReclaimState;
72584
+ if (!s.running) return;
72585
+ this.mediaReclaimState = {
72586
+ ...s,
72587
+ totalExamined: s.totalExamined + device.examined,
72588
+ totalEligible: s.totalEligible + device.eligible,
72589
+ totalReclaimed: s.totalReclaimed + device.reclaimed,
72590
+ totalBytesReclaimed: s.totalBytesReclaimed + device.bytesReclaimed,
72591
+ totalRefused: s.totalRefused + device.refused,
72592
+ devicesDone: s.devicesDone + 1
72593
+ };
72594
+ },
71541
72595
  deps: {
71542
72596
  page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
71543
72597
  deviceId,
@@ -71564,15 +72618,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
71564
72618
  bytesReclaimed: report.totalBytesReclaimed,
71565
72619
  detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
71566
72620
  });
71567
- return {
71568
- ran: true,
71569
- ...report,
71570
- devices: [...report.devices]
72621
+ this.mediaReclaimState = {
72622
+ ...this.mediaReclaimState,
72623
+ running: false,
72624
+ totalExamined: report.totalExamined,
72625
+ totalEligible: report.totalEligible,
72626
+ totalReclaimed: report.totalReclaimed,
72627
+ totalBytesReclaimed: report.totalBytesReclaimed,
72628
+ totalRefused: report.totalRefused,
72629
+ complete: report.complete,
72630
+ finishedAtMs: Date.now()
71571
72631
  };
71572
- } finally {
71573
- this.mediaReclaimRunning = false;
72632
+ this.ctx.logger.info("debug-media reclaim finished", { meta: {
72633
+ mode: report.mode,
72634
+ examined: report.totalExamined,
72635
+ reclaimed: report.totalReclaimed,
72636
+ bytesReclaimed: report.totalBytesReclaimed,
72637
+ complete: report.complete
72638
+ } });
72639
+ } catch (err) {
72640
+ this.mediaReclaimState = {
72641
+ ...this.mediaReclaimState,
72642
+ running: false,
72643
+ finishedAtMs: Date.now(),
72644
+ error: err instanceof Error ? err.message : String(err),
72645
+ complete: false
72646
+ };
72647
+ this.ctx.logger.warn("debug-media reclaim failed", { meta: { error: String(err) } });
71574
72648
  }
71575
72649
  }
72650
+ /** Progress of the detached reclaim — the only way to observe a long pass. */
72651
+ async getMediaReclaimStatus() {
72652
+ return { ...this.mediaReclaimState };
72653
+ }
71576
72654
  /** Progress of the detached rebuild — the only way to observe a long pass. */
71577
72655
  async getObjectEmbeddingRebuildStatus() {
71578
72656
  const state = this.rebuildState;