@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DeMgxNFC.js");
5
+ const require_dist = require("../dist-CXY233l4.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -112,6 +112,8 @@ function actionTtlMs(subject) {
112
112
  case "snooze": return SNOOZE_ACTION_TTL_MS;
113
113
  case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
114
114
  case "sequence": return DEFAULT_ACTION_TTL_MS;
115
+ case "addon-update": return DEFAULT_ACTION_TTL_MS;
116
+ case "server-update": return DEFAULT_ACTION_TTL_MS;
115
117
  }
116
118
  }
117
119
  var NcActionTokenRegistry = class {
@@ -181,9 +183,9 @@ var NcActionTokenRegistry = class {
181
183
  /**
182
184
  * What a spent grant DID, in the operator's own terms.
183
185
  *
184
- * Per `kind`, because the three families answer different questions and one
186
+ * Per `kind`, because the families answer different questions and one
185
187
  * generic "Done" would leave a snooze indistinguishable from an open gate on
186
- * the page a tap lands on. The switch is exhaustive by construction — a fourth
188
+ * the page a tap lands on. The switch is exhaustive by construction — a new
187
189
  * grant family will not compile until it has a sentence here.
188
190
  */
189
191
  function describeGrant(grant) {
@@ -191,6 +193,8 @@ function describeGrant(grant) {
191
193
  case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
192
194
  case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
193
195
  case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
196
+ case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
197
+ case "server-update": return `Server update on ${grant.nodeId}`;
194
198
  }
195
199
  }
196
200
  /** The audit line's `meta`, per family. A snooze that logged a `sequence` it
@@ -221,6 +225,15 @@ function grantMeta(grant) {
221
225
  mode: grant.mode ?? null,
222
226
  panelDeviceId: grant.panelDeviceId
223
227
  };
228
+ case "addon-update": return {
229
+ ...common,
230
+ nodeId: grant.nodeId,
231
+ packages: grant.packages
232
+ };
233
+ case "server-update": return {
234
+ ...common,
235
+ nodeId: grant.nodeId
236
+ };
224
237
  }
225
238
  }
226
239
  /** What the human standing in front of the page is told. */
@@ -4731,6 +4744,40 @@ var SceneMonitorProvider = class {
4731
4744
  async listScenes(input) {
4732
4745
  return this.statusFor(input.deviceId);
4733
4746
  }
4747
+ /**
4748
+ * `listScenes` for a SET, in one round trip.
4749
+ *
4750
+ * `/scenes` renders every camera and re-reads them behind the push slice on a
4751
+ * 30 s safety net. Fanned out client-side that was one request per camera to
4752
+ * read `statusFor`, which is an in-memory merge this provider already holds —
4753
+ * all cost, no work. The work here is identical; only the transport
4754
+ * collapses.
4755
+ *
4756
+ * **Every requested id gets a row.** A camera `statusFor` cannot answer for
4757
+ * is `status: null`, which is NOT `monitors: []`: this feature's whole
4758
+ * failure mode is silence reading as "nothing configured", and a batch that
4759
+ * dropped the row would have made an unreachable camera invisible instead of
4760
+ * reported.
4761
+ */
4762
+ async listScenesBatch(input) {
4763
+ return input.deviceIds.map((deviceId) => {
4764
+ try {
4765
+ return {
4766
+ deviceId,
4767
+ status: this.statusFor(deviceId)
4768
+ };
4769
+ } catch (err) {
4770
+ this.deps.logger.warn("scene status could not be read", {
4771
+ tags: { deviceId },
4772
+ meta: { error: String(err) }
4773
+ });
4774
+ return {
4775
+ deviceId,
4776
+ status: null
4777
+ };
4778
+ }
4779
+ });
4780
+ }
4734
4781
  async getStatus(input) {
4735
4782
  return this.statusFor(input.deviceId);
4736
4783
  }
@@ -5413,6 +5460,98 @@ function buildAlarmButtons(input) {
5413
5460
  }];
5414
5461
  }
5415
5462
  //#endregion
5463
+ //#region src/notification-center/addon-update-apply.ts
5464
+ /**
5465
+ * The frozen packages that STILL have an update, in the order the row listed
5466
+ * them.
5467
+ *
5468
+ * Order matters: the notification's body listed them in that order, and an
5469
+ * operator watching addons restart should see them go in the order they read.
5470
+ */
5471
+ function pendingPackages(frozen, updatable) {
5472
+ const available = new Set(updatable);
5473
+ return frozen.filter((name) => available.has(name));
5474
+ }
5475
+ /**
5476
+ * `done` only when every package the button promised was applied and nothing
5477
+ * was skipped or failed — the ONE case where a single number is the whole
5478
+ * truth. Anything else states all three counts, because a sentence that hides
5479
+ * one of them is a sentence that reports work it did not do.
5480
+ */
5481
+ function addonUpdateSentence(report) {
5482
+ if (report.applied.length === 0 && report.failed.length === 0) return "uptodate";
5483
+ if (report.applied.length === report.requested && report.failed.length === 0) return "done";
5484
+ return "partial";
5485
+ }
5486
+ //#endregion
5487
+ //#region src/notification-center/addon-update-buttons.ts
5488
+ /** The id this button travels under — in the callback and in the audit line. */
5489
+ var ADDON_UPDATE_BUTTON_ID = "addon-update-apply";
5490
+ function addonUpdateButtonRefusal(subject, recipient) {
5491
+ if (subject.kind !== "addon-update-available") return "not-an-addon-update";
5492
+ if ((subject.packageNames ?? []).length === 0) return "no-packages";
5493
+ if (subject.nodeId === void 0) return "several-nodes";
5494
+ if (recipient.ownerUserId === void 0) return "unowned-target";
5495
+ return null;
5496
+ }
5497
+ /**
5498
+ * The button, or none.
5499
+ *
5500
+ * Returns an array rather than a nullable, so the caller composes it with the
5501
+ * other families by spreading — and so "which buttons survived" has one shape.
5502
+ */
5503
+ function buildAddonUpdateButtons(input) {
5504
+ const subject = input.subject;
5505
+ if (addonUpdateButtonRefusal(subject, input.recipient) !== null) return [];
5506
+ const packages = subject.packageNames ?? [];
5507
+ const nodeId = subject.nodeId ?? "";
5508
+ return [{
5509
+ id: ADDON_UPDATE_BUTTON_ID,
5510
+ label: input.label({
5511
+ count: packages.length,
5512
+ nodeId
5513
+ }),
5514
+ url: input.mintUrl({
5515
+ actionId: ADDON_UPDATE_BUTTON_ID,
5516
+ packages,
5517
+ nodeId
5518
+ }),
5519
+ destructive: true,
5520
+ mode: "background"
5521
+ }];
5522
+ }
5523
+ //#endregion
5524
+ //#region src/notification-center/server-update-buttons.ts
5525
+ /** The id this button travels under — in the callback and in the audit line. */
5526
+ var SERVER_UPDATE_BUTTON_ID = "server-update-apply";
5527
+ function serverUpdateButtonRefusal(subject, recipient) {
5528
+ if (subject.kind !== "server-update-available") return "not-a-server-update";
5529
+ if (subject.nodeId === void 0) return "several-nodes";
5530
+ if (recipient.ownerUserId === void 0) return "unowned-target";
5531
+ return null;
5532
+ }
5533
+ /**
5534
+ * The button, or none.
5535
+ *
5536
+ * Returns an array rather than a nullable, so the caller composes it with the
5537
+ * other families by spreading — and so "which buttons survived" has one shape.
5538
+ */
5539
+ function buildServerUpdateButtons(input) {
5540
+ const subject = input.subject;
5541
+ if (serverUpdateButtonRefusal(subject, input.recipient) !== null) return [];
5542
+ const nodeId = subject.nodeId ?? "";
5543
+ return [{
5544
+ id: SERVER_UPDATE_BUTTON_ID,
5545
+ label: input.label({ nodeId }),
5546
+ url: input.mintUrl({
5547
+ actionId: SERVER_UPDATE_BUTTON_ID,
5548
+ nodeId
5549
+ }),
5550
+ destructive: true,
5551
+ mode: "background"
5552
+ }];
5553
+ }
5554
+ //#endregion
5416
5555
  //#region src/notification-center/audio-rule-matcher.ts
5417
5556
  /**
5418
5557
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8478,6 +8617,29 @@ var en_default = {
8478
8617
  "button.alarm.arm.night": "Arm night",
8479
8618
  "button.alarm.arm.vacation": "Arm vacation",
8480
8619
  "button.alarm.arm.custom_bypass": "Arm custom",
8620
+ "button.addon-update.apply": {
8621
+ "one": "Update {{count}} addon on {{nodeId}}",
8622
+ "other": "Update {{count}} addons on {{nodeId}}"
8623
+ },
8624
+ "action.addon-update.title": "Addon update",
8625
+ "action.addon-update.started": {
8626
+ "one": "Updating {{count}} addon on {{nodeId}}…",
8627
+ "other": "Updating {{count}} addons on {{nodeId}}…"
8628
+ },
8629
+ "action.addon-update.done": {
8630
+ "one": "{{applied}} addon updated on {{nodeId}}",
8631
+ "other": "{{applied}} addons updated on {{nodeId}}"
8632
+ },
8633
+ "action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
8634
+ "action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8635
+ "action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
8636
+ "button.server-update.apply": "Update server on {{nodeId}}",
8637
+ "action.server-update.title": "Server update",
8638
+ "action.server-update.started": "Updating server on {{nodeId}}…",
8639
+ "action.server-update.restarting": "Server update staged on {{nodeId}} — the node is restarting. You will hear when it comes back.",
8640
+ "action.server-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8641
+ "action.server-update.unknown": "Could not read whether {{nodeId}} still needs a server update — nothing was changed. Open CamStack.",
8642
+ "action.server-update.failed": "Server update on {{nodeId}} was refused — nothing was changed. Open CamStack.",
8481
8643
  "summary.title": "Summary — {{rule}}",
8482
8644
  "summary.body.window": "{{from}}→{{to}}",
8483
8645
  "summary.body.events": {
@@ -8700,6 +8862,29 @@ var it_default = {
8700
8862
  "button.alarm.arm.night": "Arma notte",
8701
8863
  "button.alarm.arm.vacation": "Arma vacanza",
8702
8864
  "button.alarm.arm.custom_bypass": "Arma personalizzato",
8865
+ "button.addon-update.apply": {
8866
+ "one": "Aggiorna {{count}} addon su {{nodeId}}",
8867
+ "other": "Aggiorna {{count}} addon su {{nodeId}}"
8868
+ },
8869
+ "action.addon-update.title": "Aggiornamento addon",
8870
+ "action.addon-update.started": {
8871
+ "one": "Aggiornamento di {{count}} addon su {{nodeId}}…",
8872
+ "other": "Aggiornamento di {{count}} addon su {{nodeId}}…"
8873
+ },
8874
+ "action.addon-update.done": {
8875
+ "one": "{{applied}} addon aggiornato su {{nodeId}}",
8876
+ "other": "{{applied}} addon aggiornati su {{nodeId}}"
8877
+ },
8878
+ "action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
8879
+ "action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8880
+ "action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
8881
+ "button.server-update.apply": "Aggiorna server su {{nodeId}}",
8882
+ "action.server-update.title": "Aggiornamento server",
8883
+ "action.server-update.started": "Aggiornamento server su {{nodeId}}…",
8884
+ "action.server-update.restarting": "Aggiornamento server predisposto su {{nodeId}} — il nodo si riavvia. Ti avviseremo quando torna.",
8885
+ "action.server-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8886
+ "action.server-update.unknown": "Non è stato possibile leggere se {{nodeId}} ha ancora bisogno di un aggiornamento server — non è stato cambiato nulla. Apri CamStack.",
8887
+ "action.server-update.failed": "L'aggiornamento server su {{nodeId}} è stato rifiutato — non è stato cambiato nulla. Apri CamStack.",
8703
8888
  "summary.title": "Riepilogo — {{rule}}",
8704
8889
  "summary.body.window": "{{from}}→{{to}}",
8705
8890
  "summary.body.events": {
@@ -9427,7 +9612,7 @@ var NcDispatcher = class {
9427
9612
  targetId: target.id,
9428
9613
  ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
9429
9614
  ...entry.payload.subject.className !== void 0 ? { className: entry.payload.subject.className } : {},
9430
- ...entry.payload.subject.systemEvent !== void 0 ? { systemEventKind: entry.payload.subject.systemEvent.kind } : {}
9615
+ ...entry.payload.subject.systemEvent !== void 0 ? { systemEvent: entry.payload.subject.systemEvent } : {}
9431
9616
  });
9432
9617
  } catch (err) {
9433
9618
  this.deps.logger.warn("notification buttons could not be minted — sending without them", {
@@ -20176,7 +20361,7 @@ var NotificationCenter = class NotificationCenter {
20176
20361
  return id;
20177
20362
  }
20178
20363
  });
20179
- const alarmKind = alarmButtonKind(input.systemEventKind);
20364
+ const alarmKind = alarmButtonKind(input.systemEvent?.kind);
20180
20365
  const panel = this.alarmPanel;
20181
20366
  const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
20182
20367
  kind: alarmKind,
@@ -20212,6 +20397,84 @@ var NotificationCenter = class NotificationCenter {
20212
20397
  return id;
20213
20398
  }
20214
20399
  });
20400
+ const systemEvent = input.systemEvent;
20401
+ const recipient = input.ownerUserId !== void 0 ? { ownerUserId: input.ownerUserId } : {};
20402
+ const updateRefusal = systemEvent === void 0 ? "not-an-addon-update" : addonUpdateButtonRefusal(systemEvent, recipient);
20403
+ const updateButtons = systemEvent === void 0 ? [] : buildAddonUpdateButtons({
20404
+ subject: systemEvent,
20405
+ recipient,
20406
+ label: ({ count, nodeId }) => this.texts.text({
20407
+ key: "button.addon-update.apply",
20408
+ vars: {
20409
+ count: String(count),
20410
+ nodeId
20411
+ },
20412
+ count
20413
+ }),
20414
+ mintUrl: ({ actionId, packages, nodeId }) => {
20415
+ const id = (0, node_crypto.randomUUID)();
20416
+ drafts.push({
20417
+ id,
20418
+ ttlMs: actionTtlMs({ kind: "addon-update" }),
20419
+ grant: (expiresAt) => ({
20420
+ kind: "addon-update",
20421
+ id,
20422
+ packages,
20423
+ nodeId,
20424
+ deviceId: input.deviceId,
20425
+ actionId,
20426
+ targetId: input.targetId,
20427
+ expiresAt
20428
+ })
20429
+ });
20430
+ return id;
20431
+ }
20432
+ });
20433
+ if (updateRefusal === "several-nodes" || updateRefusal === "unowned-target") this.logger.info("addon-update notification carries no apply button", {
20434
+ tags: { deviceId: input.deviceId },
20435
+ meta: {
20436
+ ruleId: input.ruleId,
20437
+ reason: updateRefusal,
20438
+ targetId: input.targetId,
20439
+ nodeIds: systemEvent?.nodeIds ?? [],
20440
+ packages: systemEvent?.packageNames ?? []
20441
+ }
20442
+ });
20443
+ const serverRefusal = systemEvent === void 0 ? "not-a-server-update" : serverUpdateButtonRefusal(systemEvent, recipient);
20444
+ const serverButtons = systemEvent === void 0 ? [] : buildServerUpdateButtons({
20445
+ subject: systemEvent,
20446
+ recipient,
20447
+ label: ({ nodeId }) => this.texts.text({
20448
+ key: "button.server-update.apply",
20449
+ vars: { nodeId }
20450
+ }),
20451
+ mintUrl: ({ actionId, nodeId }) => {
20452
+ const id = (0, node_crypto.randomUUID)();
20453
+ drafts.push({
20454
+ id,
20455
+ ttlMs: actionTtlMs({ kind: "server-update" }),
20456
+ grant: (expiresAt) => ({
20457
+ kind: "server-update",
20458
+ id,
20459
+ nodeId,
20460
+ deviceId: input.deviceId,
20461
+ actionId,
20462
+ targetId: input.targetId,
20463
+ expiresAt
20464
+ })
20465
+ });
20466
+ return id;
20467
+ }
20468
+ });
20469
+ if (serverRefusal === "several-nodes" || serverRefusal === "unowned-target") this.logger.info("server-update notification carries no apply button", {
20470
+ tags: { deviceId: input.deviceId },
20471
+ meta: {
20472
+ ruleId: input.ruleId,
20473
+ reason: serverRefusal,
20474
+ targetId: input.targetId,
20475
+ nodeIds: systemEvent?.nodeIds ?? []
20476
+ }
20477
+ });
20215
20478
  if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
20216
20479
  tags: { deviceId: input.deviceId },
20217
20480
  meta: {
@@ -20222,7 +20485,9 @@ var NotificationCenter = class NotificationCenter {
20222
20485
  const out = [
20223
20486
  ...sequenceButtons,
20224
20487
  ...snoozeButtons,
20225
- ...alarmButtons
20488
+ ...alarmButtons,
20489
+ ...updateButtons,
20490
+ ...serverButtons
20226
20491
  ];
20227
20492
  if (out.length === 0) return [];
20228
20493
  const now = this.now();
@@ -20343,7 +20608,213 @@ var NotificationCenter = class NotificationCenter {
20343
20608
  case "snooze": return this.applyGrantedSnooze(grant);
20344
20609
  case "alarm": return this.applyGrantedAlarm(grant);
20345
20610
  case "sequence": return this.runGrantedSequence(grant);
20611
+ case "addon-update": return this.applyGrantedAddonUpdate(grant);
20612
+ case "server-update": return this.applyGrantedServerUpdate(grant);
20613
+ }
20614
+ }
20615
+ /**
20616
+ * Apply the addon updates one notification was about.
20617
+ *
20618
+ * The whole shape of this method is the answer to "a button that reports
20619
+ * success for work it did not do":
20620
+ *
20621
+ * 1. **The cheap question first.** Ask the registry what is still out of
20622
+ * date on that node. A read that FAILS changes nothing and says so — it
20623
+ * is never read as "everything is current" and never as "apply the frozen
20624
+ * list anyway".
20625
+ * 2. **Nothing to do is an outcome, not a success.** A frozen list the
20626
+ * operator already applied (from the Addons page, from auto-update, or by
20627
+ * tapping the copy of this notification that went to their other phone)
20628
+ * produces one announcement and zero `updatePackage` calls.
20629
+ * 3. **The operator hears twice.** A background POST returns while the
20630
+ * updates run for minutes, so the tap is acknowledged BEFORE the first
20631
+ * apply and the verdict follows it. Tapping into silence is worse than no
20632
+ * button.
20633
+ *
20634
+ * The announcements go back to `grant.targetId` — the delivery this button
20635
+ * was minted for, which is the only addressee a tap can honestly name (a tap
20636
+ * identifies nobody; same reasoning as the snooze grant's frozen `userId`).
20637
+ *
20638
+ * Throws only when NOTHING was applied and something went wrong, so the
20639
+ * plane's line says the run failed rather than reporting a spent token as a
20640
+ * success.
20641
+ */
20642
+ async applyGrantedAddonUpdate(grant) {
20643
+ const apply = this.deps.applyAddonUpdate;
20644
+ const list = this.deps.listAddonUpdates;
20645
+ if (apply === void 0 || list === void 0) throw new Error("no addon updater is wired on this node");
20646
+ let updatable;
20647
+ try {
20648
+ updatable = await list(grant.nodeId);
20649
+ } catch (err) {
20650
+ this.logger.warn("addon update button changed nothing — could not read what needs updating", { meta: {
20651
+ nodeId: grant.nodeId,
20652
+ packages: grant.packages,
20653
+ error: String(err)
20654
+ } });
20655
+ await this.announceAddonUpdate(grant, "unknown", { nodeId: grant.nodeId });
20656
+ throw err instanceof Error ? err : new Error(String(err));
20346
20657
  }
20658
+ const pending = pendingPackages(grant.packages, updatable);
20659
+ const skipped = grant.packages.filter((name) => !pending.includes(name));
20660
+ if (pending.length === 0) {
20661
+ this.logger.info("addon update button applied nothing — every package is already current", { meta: {
20662
+ nodeId: grant.nodeId,
20663
+ packages: grant.packages
20664
+ } });
20665
+ await this.announceAddonUpdate(grant, "uptodate", { nodeId: grant.nodeId }, {
20666
+ requested: grant.packages.length,
20667
+ applied: [],
20668
+ skipped,
20669
+ failed: []
20670
+ });
20671
+ return;
20672
+ }
20673
+ await this.announceAddonUpdate(grant, "started", {
20674
+ count: String(pending.length),
20675
+ nodeId: grant.nodeId
20676
+ }, void 0, pending.length);
20677
+ const applied = [];
20678
+ const failed = [];
20679
+ for (const packageName of pending) try {
20680
+ const result = await apply({
20681
+ packageName,
20682
+ nodeId: grant.nodeId
20683
+ });
20684
+ if (result.ok) applied.push(packageName);
20685
+ else {
20686
+ failed.push(packageName);
20687
+ this.logger.warn("addon update from a notification button failed", { meta: {
20688
+ nodeId: grant.nodeId,
20689
+ packageName,
20690
+ error: result.error
20691
+ } });
20692
+ }
20693
+ } catch (err) {
20694
+ failed.push(packageName);
20695
+ this.logger.warn("addon update from a notification button threw", { meta: {
20696
+ nodeId: grant.nodeId,
20697
+ packageName,
20698
+ error: String(err)
20699
+ } });
20700
+ }
20701
+ const report = {
20702
+ requested: grant.packages.length,
20703
+ applied,
20704
+ skipped,
20705
+ failed
20706
+ };
20707
+ this.logger.info("addon updates applied from a notification button", { meta: {
20708
+ nodeId: grant.nodeId,
20709
+ requested: report.requested,
20710
+ applied: applied.length,
20711
+ skipped: skipped.length,
20712
+ failed
20713
+ } });
20714
+ await this.announceAddonUpdate(grant, addonUpdateSentence(report), {
20715
+ nodeId: grant.nodeId,
20716
+ applied: String(applied.length),
20717
+ skipped: String(skipped.length),
20718
+ failed: String(failed.length)
20719
+ }, report, applied.length);
20720
+ if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
20721
+ }
20722
+ /**
20723
+ * Stage the server update one notification was about.
20724
+ *
20725
+ * The shape matches the addon-update runner, with one extra constraint: the
20726
+ * **start announcement leaves BEFORE apply**. Applying the hub kills this
20727
+ * process; a tap that went into silence because the process died mid-apply
20728
+ * is worse than no button. The reboot itself is confirmed by the existing
20729
+ * `server-updated` event on reconnect — this method does not wait for it.
20730
+ *
20731
+ * Throws when nothing was staged and something went wrong, so the plane's
20732
+ * line says the run failed rather than reporting a spent token as a success.
20733
+ */
20734
+ async applyGrantedServerUpdate(grant) {
20735
+ const apply = this.deps.applyServerUpdate;
20736
+ const check = this.deps.checkServerUpdate;
20737
+ if (apply === void 0 || check === void 0) throw new Error("no server updater is wired on this node");
20738
+ let stillAvailable;
20739
+ try {
20740
+ stillAvailable = await check(grant.nodeId);
20741
+ } catch (err) {
20742
+ this.logger.warn("server update button changed nothing — could not read whether an update is still needed", { meta: {
20743
+ nodeId: grant.nodeId,
20744
+ error: String(err)
20745
+ } });
20746
+ await this.announceServerUpdate(grant, "unknown", { nodeId: grant.nodeId });
20747
+ throw err instanceof Error ? err : new Error(String(err));
20748
+ }
20749
+ if (!stillAvailable) {
20750
+ this.logger.info("server update button applied nothing — already current", { meta: { nodeId: grant.nodeId } });
20751
+ await this.announceServerUpdate(grant, "uptodate", { nodeId: grant.nodeId });
20752
+ return;
20753
+ }
20754
+ await this.announceServerUpdate(grant, "started", { nodeId: grant.nodeId });
20755
+ let result;
20756
+ try {
20757
+ result = await apply({ nodeId: grant.nodeId });
20758
+ } catch (err) {
20759
+ this.logger.warn("server update from a notification button threw", { meta: {
20760
+ nodeId: grant.nodeId,
20761
+ error: String(err)
20762
+ } });
20763
+ await this.announceServerUpdate(grant, "failed", { nodeId: grant.nodeId });
20764
+ throw err instanceof Error ? err : new Error(String(err));
20765
+ }
20766
+ if (!result.ok) {
20767
+ this.logger.warn("server update from a notification button was refused", { meta: {
20768
+ nodeId: grant.nodeId,
20769
+ error: result.error
20770
+ } });
20771
+ await this.announceServerUpdate(grant, "failed", { nodeId: grant.nodeId });
20772
+ throw new Error(result.error);
20773
+ }
20774
+ this.logger.info("server update staged from a notification button", { meta: { nodeId: grant.nodeId } });
20775
+ await this.announceServerUpdate(grant, "restarting", { nodeId: grant.nodeId });
20776
+ }
20777
+ /**
20778
+ * One sentence about a server-update tap, to the target the notification
20779
+ * went to. Best-effort — `deliverAnnouncement` never throws — because an
20780
+ * announcement that failed must not turn a staging that HAPPENED into a run
20781
+ * the plane reports as failed.
20782
+ */
20783
+ async announceServerUpdate(grant, sentence, vars) {
20784
+ const targetId = grant.targetId;
20785
+ if (targetId === void 0) return;
20786
+ await this.dispatcher.deliverAnnouncement({
20787
+ reason: `server-update-${sentence}`,
20788
+ targetId,
20789
+ title: this.texts.text({ key: "action.server-update.title" }),
20790
+ body: this.texts.text({
20791
+ key: `action.server-update.${sentence}`,
20792
+ vars
20793
+ })
20794
+ });
20795
+ }
20796
+ /**
20797
+ * One sentence about an update tap, to the target the notification went to.
20798
+ *
20799
+ * Best-effort by construction — `deliverAnnouncement` never throws and never
20800
+ * retries — because an announcement that failed must not turn an update that
20801
+ * HAPPENED into a run the plane reports as failed.
20802
+ */
20803
+ async announceAddonUpdate(grant, sentence, vars, report, count) {
20804
+ const targetId = grant.targetId;
20805
+ if (targetId === void 0) return;
20806
+ const body = this.texts.text({
20807
+ key: `action.addon-update.${sentence}`,
20808
+ vars,
20809
+ ...count !== void 0 ? { count } : {}
20810
+ });
20811
+ const failedNames = report?.failed ?? [];
20812
+ await this.dispatcher.deliverAnnouncement({
20813
+ reason: `addon-update-${sentence}`,
20814
+ targetId,
20815
+ title: this.texts.text({ key: "action.addon-update.title" }),
20816
+ body: failedNames.length > 0 ? `${body}\n${failedNames.join(", ")}` : body
20817
+ });
20347
20818
  }
20348
20819
  async runGrantedSequence(grant) {
20349
20820
  if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
@@ -25786,7 +26257,7 @@ var DEFAULT_MEDIA_LOCATION = "eventMedia";
25786
26257
  * whose contract is disposal.** The gallery's row lives on the data volume;
25787
26258
  * its blob belongs there too, and now does. See D197.
25788
26259
  */
25789
- var GALLERY_MEDIA_LOCATION = "galleryMedia";
26260
+ var GALLERY_MEDIA_LOCATION = "addon-data";
25790
26261
  /**
25791
26262
  * The location a new blob is written to, derived from its owner kind.
25792
26263
  *
@@ -26683,7 +27154,7 @@ var MediaStore = class {
26683
27154
  for (const row of rows) {
26684
27155
  const data = row.data;
26685
27156
  const from = mediaRowLocation(data);
26686
- if (from === "galleryMedia") continue;
27157
+ if (from === "addon-data") continue;
26687
27158
  const relativePath = String(data["path"] ?? "");
26688
27159
  const deviceId = Number(data["deviceId"]);
26689
27160
  if (relativePath.length === 0) continue;
@@ -29060,30 +29531,57 @@ var LEGACY_FACE_MODEL_ID = "arcface-r100";
29060
29531
  */
29061
29532
  var FACES_COLLECTION = "pipeline-analytics:faces";
29062
29533
  /**
29063
- * Rows per page for the two whole-collection sweeps below.
29064
- *
29065
- * A face row is one of the two WIDEST row shapes in the hub's database: it
29066
- * carries a JSON `embedding` on every single row, measured at **10.8 KB
29067
- * average** where a motion event is 78 bytes. Both sweeps used to read the
29068
- * collection in ONE statement with no `limit`.
29534
+ * Rows per page for `pruneCapOverflow`, the ONE remaining walk of this
29535
+ * collection.
29069
29536
  *
29070
- * To be exact about what that did and did not cause: this table holds **372
29071
- * rows / 4.0 MB** on the live hub, so it was *not* the read that killed
29072
- * hub-main on 2026-08-07 (that was the multi-camera track feed — see
29073
- * `RECENT_FANOUT_ROW_BUDGET` in `track-store.ts`). It is a full scan whose cost
29074
- * grows with a table nothing else bounds, which is the same defect one incident
29075
- * earlier. `pruneCapOverflow` is what keeps the row count small — and it was
29076
- * itself one of the two unbounded readers, so the bound and the thing enforcing
29077
- * it were the same loop.
29537
+ * It is a walk that DOES WORK: the capacity bound has to consider every row to
29538
+ * decide which ones leave, and it runs on the maintenance pass, not on a
29539
+ * request. That is the shape D320 permits. What it must not do is drag the
29540
+ * whole row across for four scalars — see the projection there.
29078
29541
  *
29079
- * The engine now refuses an unbounded read (`query-bounds.ts`), so paging here
29080
- * is not belt-and-braces: without it these two methods would silently see only
29081
- * the newest N rows and the buffer would grow forever.
29542
+ * The two READS that used to page here do not any more. They asked a question
29543
+ * ("the newest N faces that are still unassigned"), the question is a
29544
+ * predicate, and a predicate answers it in one statement whose cost is the
29545
+ * ANSWER rather than the table.
29082
29546
  *
29083
- * 1 000 keeps peak transfer around 11 MB while staying under the engine's own
29084
- * cap, so the page is always the caller's bound and never the engine's.
29547
+ * The engine refuses an unbounded read (`query-bounds.ts`) and truncates a
29548
+ * limitless one at `SETTINGS_QUERY_DEFAULT_ROW_CAP` with a WARN nobody can act
29549
+ * on, so every read below carries an explicit `limit`.
29085
29550
  */
29086
29551
  var FACE_SWEEP_PAGE = 1e3;
29552
+ /**
29553
+ * The widest window a single gallery read may materialise before it REFUSES.
29554
+ *
29555
+ * It bounds only the reads whose size the CALLER did not name: an unbounded
29556
+ * `listAllRecentFaces`, and the `suggestionConfidence` order, which cannot stop
29557
+ * early because the most certain suggestion may be the oldest row (`face-sort.ts`).
29558
+ * A caller's own `limit` is honoured verbatim at any table size — asking for
29559
+ * 500 and getting 500 is being served, not being truncated.
29560
+ *
29561
+ * 10 000 because that is above what `pruneCapOverflow` actually permits to
29562
+ * exist: 29 cameras × (50 ordinary + 200 suggested) = 7 250 unassigned rows,
29563
+ * plus whatever the operator has enrolled. It sits under the engine's
29564
+ * `SETTINGS_QUERY_HARD_ROW_CAP` (20 000) so the engine never truncates first —
29565
+ * this bound is the one that speaks, and it speaks by throwing.
29566
+ *
29567
+ * The alternative was to answer with the rows it managed to read. A short list
29568
+ * and a complete one are the same array, and a gallery that quietly stops
29569
+ * showing a face is exactly the failure this whole change exists to avoid.
29570
+ */
29571
+ var FACE_WINDOW_MAX_ROWS = 1e4;
29572
+ /**
29573
+ * A gallery read whose window is wider than {@link FACE_WINDOW_MAX_ROWS}.
29574
+ *
29575
+ * Distinct type so a router can map it to a 400 and an operator can be told
29576
+ * *"the buffer is too large to rank in one pass — narrow the window"* instead
29577
+ * of being shown a page that silently lost its tail.
29578
+ */
29579
+ var FaceWindowTooWideError = class extends Error {
29580
+ constructor(rows) {
29581
+ 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`);
29582
+ this.name = "FaceWindowTooWideError";
29583
+ }
29584
+ };
29087
29585
  /** How many evicted face ids one per-camera eviction line names before it
29088
29586
  * stops — enough to find the rows, not enough to turn a log line into a page. */
29089
29587
  var SUGGESTED_EVICTION_IDS_LOGGED = 10;
@@ -29185,13 +29683,128 @@ var FACE_COLUMNS = [
29185
29683
  type: "REAL"
29186
29684
  }
29187
29685
  ];
29188
- var FACE_INDEXES = [{
29189
- name: "idx_faces_device_ts",
29190
- columns: ["deviceId", "timestamp"]
29191
- }, {
29192
- name: "idx_faces_track",
29193
- columns: ["trackId"]
29194
- }];
29686
+ /**
29687
+ * What every gallery query orders by, and what the three of them filter on.
29688
+ *
29689
+ * Before these existed, EVERY read of this collection was
29690
+ * `SCAN … | USE TEMP B-TREE FOR ORDER BY` — a full table scan plus a sort of
29691
+ * the whole table, to hand back the newest 500 rows. Measured with
29692
+ * `EXPLAIN QUERY PLAN` over a 20 000-row copy of this exact schema:
29693
+ *
29694
+ * | query | before | after |
29695
+ * | --- | --- | --- |
29696
+ * | newest N | `SCAN + TEMP B-TREE` | `SCAN USING idx_faces_ts` |
29697
+ * | newest N unassigned | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
29698
+ * | newest N recognized | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_assigned_ts (assigned=?)` |
29699
+ * | faces of one identity | `SCAN + TEMP B-TREE` | `SEARCH USING idx_faces_identity_ts (recognizedIdentityId=?)` |
29700
+ *
29701
+ * The sort disappearing is the point: with an ordered index the statement stops
29702
+ * at the `LIMIT`, so the cost is the ANSWER. Without it the cost is the table,
29703
+ * and this table is bounded only by `pruneCapOverflow` — it went from 372 rows
29704
+ * (2026-08-25) to 1 612 (2026-08-31) while a UI polled it every 30 seconds.
29705
+ *
29706
+ * `idx_faces_ts` is NOT redundant beside `idx_faces_assigned_ts`: a composite
29707
+ * leading on `assigned` cannot serve an unfiltered `ORDER BY timestamp`.
29708
+ * Three b-trees over a few thousand narrow rows is tens of KB, against one
29709
+ * UPSERT per track that produces a face — the write rate this pays at.
29710
+ */
29711
+ var FACE_INDEXES = [
29712
+ {
29713
+ name: "idx_faces_device_ts",
29714
+ columns: ["deviceId", "timestamp"]
29715
+ },
29716
+ {
29717
+ name: "idx_faces_track",
29718
+ columns: ["trackId"]
29719
+ },
29720
+ {
29721
+ name: "idx_faces_ts",
29722
+ columns: ["timestamp"]
29723
+ },
29724
+ {
29725
+ name: "idx_faces_assigned_ts",
29726
+ columns: ["assigned", "timestamp"]
29727
+ },
29728
+ {
29729
+ name: "idx_faces_identity_ts",
29730
+ columns: ["recognizedIdentityId", "timestamp"]
29731
+ }
29732
+ ];
29733
+ /**
29734
+ * The columns a {@link Face} is built from — every declared column EXCEPT the
29735
+ * drained legacy `embedding`, derived mechanically so a column added tomorrow
29736
+ * arrives without anyone remembering this list.
29737
+ *
29738
+ * Mechanical on purpose. A hand-written projection is how a field silently
29739
+ * stops arriving: the row comes back missing the key, `rowToFace` reads
29740
+ * `undefined`, and "the database says nothing" is indistinguishable from "I did
29741
+ * not ask". The engine REFUSES a column it cannot serve
29742
+ * (`resolveProjection`) — the projection is the one part of a query that is not
29743
+ * allowed to be forgiving — so a stale name here is a throw, not a wrong value.
29744
+ *
29745
+ * `embedding` is the only exclusion, and it is the whole reason to project:
29746
+ * it is a `notNull` JSON column on the widest row shape in the database, it is
29747
+ * dropped by `rowToFace` on arrival, and {@link Face} has no field for it. A
29748
+ * caller that wants floats asks by name ({@link FaceStore.getWithEmbedding},
29749
+ * {@link FaceStore.loadEmbeddings}). Since D304 drained it to `[]` the saving
29750
+ * is small — 23 B/row, measured on the live hub — but the column is still
29751
+ * declared, still `notNull`, and an unconverted row still carries ~10.8 KB of
29752
+ * it. Projecting is what keeps that a fact about ONE row rather than about
29753
+ * every gallery poll.
29754
+ */
29755
+ var FACE_LIST_COLUMNS = FACE_COLUMNS.map((c) => c.name).filter((name) => name !== "embedding" && name !== "id");
29756
+ /**
29757
+ * The four scalars `pruneCapOverflow` decides on.
29758
+ *
29759
+ * Its docblock has always said *"only four scalars per row survive the page"*.
29760
+ * They survived the PAGE; the query still dragged every column — the embedding
29761
+ * included — across the RPC boundary and through V8 first. A leftover that
29762
+ * describes the right design reads as verification, so the projection now makes
29763
+ * the sentence true.
29764
+ */
29765
+ var FACE_CAP_COLUMNS = [
29766
+ "deviceId",
29767
+ "timestamp",
29768
+ "assigned",
29769
+ "suggestedIdentityId"
29770
+ ];
29771
+ /** One camera reference per identity — see {@link FaceStore.listCameraRefsForIdentity}. */
29772
+ var FACE_REF_COLUMNS = ["deviceId"];
29773
+ /**
29774
+ * A gallery view as a STORE PREDICATE.
29775
+ *
29776
+ * Every one of the four is expressible, and that is the whole finding behind
29777
+ * this change. They were applied in JS, over pages the store had already
29778
+ * chosen, which is why `limit` counted rows that PASSED and the read had to
29779
+ * sweep until enough had: with `filter: 'unassigned'` the newest N rows may be
29780
+ * entirely assigned while matches wait just beyond them. Pushed down, `limit`
29781
+ * IS the store's limit, and the sweep has nothing left to do.
29782
+ *
29783
+ * **Two of them name null, and null is the trap.** `recognizedIdentityId` is a
29784
+ * nullable TEXT column, so "has an identity" is `IS NOT NULL` — which
29785
+ * `whereNot: { field: null }` compiles to since D312, and which before D312
29786
+ * compiled to `(expr IS NULL OR expr != NULL)`: TRUE for a NULL row and NULL
29787
+ * for every other, i.e. precisely the rows it was asked to exclude. A predicate
29788
+ * that silently matched nothing would report "no recognized faces" forever and
29789
+ * every assertion about the rows returned would agree with it. Verified against
29790
+ * the live hub before shipping: 1 612 rows, `where {assigned:false}` → 1 444,
29791
+ * `whereNot {recognizedIdentityId:null}` → 247.
29792
+ *
29793
+ * `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
29794
+ * the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
29795
+ * plain equality is correct and no null semantics are involved.
29796
+ */
29797
+ function faceFilterPredicate(filter) {
29798
+ switch (filter) {
29799
+ case "unassigned": return { where: { assigned: false } };
29800
+ case "recognized": return {
29801
+ where: { assigned: false },
29802
+ whereNot: { recognizedIdentityId: null }
29803
+ };
29804
+ case "identified": return { whereNot: { recognizedIdentityId: null } };
29805
+ case "all": return {};
29806
+ }
29807
+ }
29195
29808
  /**
29196
29809
  * `since` / `until` as a store predicate.
29197
29810
  *
@@ -29226,6 +29839,7 @@ function rowToFace(id, raw) {
29226
29839
  return {
29227
29840
  ...rest,
29228
29841
  id,
29842
+ embeddingModelId: data.embeddingModelId ?? void 0,
29229
29843
  assigned: Boolean(data.assigned),
29230
29844
  mediaKey: data.mediaKey ?? void 0,
29231
29845
  recognizedIdentityId: data.recognizedIdentityId ?? void 0,
@@ -29454,16 +30068,21 @@ var FaceStore = class {
29454
30068
  */
29455
30069
  async pruneCapOverflow(input) {
29456
30070
  /**
29457
- * Paged, not one statement. Only four scalars per row survive the page —
29458
- * the `embedding` string that dominates the row is dropped as soon as the
29459
- * page goes out of scope, so peak heap is one page rather than the whole
29460
- * face table. The prune still considers EVERY row, which is what makes it a
29461
- * capacity bound rather than a bound on the newest page.
30071
+ * Paged, not one statement, and PROJECTED to the four scalars the decision
30072
+ * needs — this sentence used to be true only of what survived the page,
30073
+ * while the query still dragged every column, `embedding` included, across
30074
+ * the RPC boundary and through V8 first.
30075
+ *
30076
+ * This is the one walk of the collection that remains, and D320 permits it:
30077
+ * it must touch every row to ACT on it, and it runs on the maintenance
30078
+ * pass, not on a request. The prune still considers EVERY row, which is
30079
+ * what makes it a capacity bound rather than a bound on the newest page.
29462
30080
  */
29463
30081
  const byDevice = /* @__PURE__ */ new Map();
29464
30082
  for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29465
30083
  const rows = await this.store.query.query({
29466
30084
  collection: FACES_COLLECTION,
30085
+ columns: FACE_CAP_COLUMNS,
29467
30086
  filter: {
29468
30087
  orderBy: {
29469
30088
  field: "timestamp",
@@ -29757,29 +30376,38 @@ var FaceStore = class {
29757
30376
  * - 'recognized' → !assigned && recognizedIdentityId != null
29758
30377
  * - 'identified' → recognizedIdentityId != null, including manual assignments
29759
30378
  *
29760
- * `deviceIds`, `since` and `until` are pushed INTO the query — they narrow
29761
- * what is read, not what is returned. `filter` and the ordering cannot be
29762
- * (the first is a computed predicate, the second is a contract this backend
29763
- * does not implement — see `face-sort.ts`), so they are applied here.
29764
- *
29765
- * **`limit` counts rows that PASSED the filter, never rows that were
29766
- * discarded.** That is the whole reason this pages instead of asking the
29767
- * store for `limit` rows: with `filter:'unassigned'` the newest N rows may be
29768
- * entirely assigned while matches wait just beyond them.
29769
- *
29770
- * The previous answer was to fetch the WHOLE collection and slice after
29771
- * filtering. That is the read that killed hub-main (`query-bounds.ts`) —
29772
- * every face row carries a JSON `embedding`, so "all rows" is the widest
29773
- * table in the database materialised at once, to render a gallery of at most
29774
- * a few hundred tiles.
29775
- *
29776
- * So: PAGE. Under the default `timestamp` order the store's paging already
29777
- * emits rows in the answer's order, so the sweep stops the moment enough
29778
- * MATCHES exist and the common case costs one page. Under
29779
- * `suggestionConfidence` it cannot — the most certain suggestion may be the
29780
- * oldest row — so the sweep walks the window and folds each page through
29781
- * `rankAndCap`, which holds the RETAINED set to `limit` even though the read
29782
- * is wider. Narrow the read with `since` / `until`.
30379
+ * **Every part of the question is now a store predicate** — `deviceIds`,
30380
+ * `since` / `until` AND `filter` ({@link faceFilterPredicate}) — so `limit`
30381
+ * is the STORE's limit and one statement is the whole answer.
30382
+ *
30383
+ * That is the change. This used to page: `filter` was a JS predicate applied
30384
+ * to rows the store had already chosen, so `limit` counted rows that PASSED
30385
+ * and the read had to sweep until enough had, up to `FACE_SWEEP_MAX_PAGES`
30386
+ * (1 000) × `FACE_SWEEP_PAGE` (1 000) — a million rows, `offset`-paged,
30387
+ * behind a UI polling every 30 seconds. On the live hub that meant reading
30388
+ * all 1 612 rows over 2 round trips to produce a 60-row `recognized` page,
30389
+ * every 30 s, per open tab, and growing with a table only `pruneCapOverflow`
30390
+ * bounds. It is the same family as `MediaRelocateEngine.pageRows`, which put
30391
+ * a ~16 s standing queue in front of every settings-store call in the cluster
30392
+ * (D320) — smaller, on a longer timer, and the same shape.
30393
+ *
30394
+ * `offset` is gone with it, not merely unnecessary: the recognizer UPSERTs
30395
+ * into this collection continuously, so page two at `offset: N` starts N rows
30396
+ * past a set that may have gained or lost its first N (D295 rule 1, D320 R3).
30397
+ *
30398
+ * ## What still cannot be pushed, and what that costs
30399
+ *
30400
+ * The `suggestionConfidence` ORDER stays here. `ORDER BY suggestedMatchScore`
30401
+ * would let the backend decide where a scoreless row goes — SQLite collates
30402
+ * NULL first on ASC and last on DESC — and the contract is that it sorts LAST
30403
+ * in BOTH directions (`face-sort.ts`). So that order reads the whole FILTERED
30404
+ * window and ranks it. Filtered, which is the improvement: the window is the
30405
+ * unassigned buffer, not the table.
30406
+ *
30407
+ * A window it cannot read whole REFUSES ({@link FaceWindowTooWideError}).
30408
+ * Ranking the part that fit is exactly "most certain among the newest N",
30409
+ * which is the defect that order was added to remove, and it is invisible in
30410
+ * the rows returned.
29783
30411
  *
29784
30412
  * An **empty `deviceIds` reads nothing at all**: `[]` is a request for no
29785
30413
  * cameras, not an omission (the `deviceManager.listFleet` contract).
@@ -29788,74 +30416,86 @@ var FaceStore = class {
29788
30416
  if (input.deviceIds !== void 0 && input.deviceIds.length === 0) return [];
29789
30417
  const order = resolveFaceSort(input);
29790
30418
  const pagingServesTheOrder = sortIsServedByTimestampPaging(order);
29791
- const filterMode = input.filter ?? "all";
29792
- const matches = (f) => {
29793
- if (filterMode === "unassigned") return !f.assigned;
29794
- if (filterMode === "recognized") return !f.assigned && f.recognizedIdentityId != null;
29795
- if (filterMode === "identified") return f.recognizedIdentityId != null;
29796
- return true;
29797
- };
29798
- const storeFilter = {
29799
- ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
29800
- ...buildTimestampWindow(input),
29801
- orderBy: {
29802
- field: "timestamp",
29803
- direction: pagingServesTheOrder ? order.direction : "desc"
30419
+ /**
30420
+ * The caller's `limit` bounds the READ only when the store's ordering is
30421
+ * already the answer's. Under `suggestionConfidence` the most certain
30422
+ * suggestion may be the oldest row, so the read is the window and `limit`
30423
+ * bounds only what is RETAINED (`rankAndCap`).
30424
+ */
30425
+ const callerBounded = pagingServesTheOrder && input.limit !== void 0;
30426
+ const readLimit = callerBounded ? input.limit ?? 0 : 10001;
30427
+ const rows = await this.store.query.query({
30428
+ collection: FACES_COLLECTION,
30429
+ columns: FACE_LIST_COLUMNS,
30430
+ filter: {
30431
+ ...input.deviceIds !== void 0 ? { whereIn: { deviceId: [...new Set(input.deviceIds)] } } : {},
30432
+ ...buildTimestampWindow(input),
30433
+ ...faceFilterPredicate(input.filter ?? "all"),
30434
+ orderBy: {
30435
+ field: "timestamp",
30436
+ direction: pagingServesTheOrder ? order.direction : "desc"
30437
+ },
30438
+ limit: readLimit
29804
30439
  }
29805
- };
29806
- let result = [];
29807
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29808
- const rows = await this.store.query.query({
29809
- collection: FACES_COLLECTION,
29810
- filter: {
29811
- ...storeFilter,
29812
- limit: FACE_SWEEP_PAGE,
29813
- offset: page * FACE_SWEEP_PAGE
29814
- }
29815
- });
29816
- if (rows.length === 0) break;
29817
- const matched = [];
29818
- for (const r of rows) {
29819
- const face = rowToFace(r.id, r.data);
29820
- if (matches(face)) matched.push(face);
29821
- }
29822
- if (pagingServesTheOrder) {
29823
- const merged = [...result, ...matched];
29824
- if (input.limit !== void 0 && merged.length >= input.limit) return merged.slice(0, input.limit);
29825
- result = merged;
29826
- } else result = rankAndCap([...result, ...matched], order, input.limit);
29827
- if (rows.length < 1e3) break;
29828
- }
29829
- return pagingServesTheOrder ? result : rankAndCap(result, order, input.limit);
30440
+ });
30441
+ if (!callerBounded && rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
30442
+ const faces = rows.map((r) => rowToFace(r.id, r.data));
30443
+ return pagingServesTheOrder ? faces : rankAndCap(faces, order, input.limit);
29830
30444
  }
29831
- /** One real face row per camera currently attributed to an identity. */
30445
+ /**
30446
+ * One real face row per camera currently attributed to an identity.
30447
+ *
30448
+ * `recognizedIdentityId` is indexed (`idx_faces_identity_ts`), so this is a
30449
+ * bounded index walk over the identity's own rows rather than the
30450
+ * `offset`-paged sweep of the whole collection it used to be. It projects
30451
+ * `deviceId` alone — the answer is a device id and a row id, and the row id
30452
+ * is the record key.
30453
+ */
29832
30454
  async listCameraRefsForIdentity(identityId) {
30455
+ const rows = await this.identityRows(identityId, FACE_REF_COLUMNS);
29833
30456
  const byDevice = /* @__PURE__ */ new Map();
29834
- for (let page = 0; page < FACE_SWEEP_MAX_PAGES; page++) {
29835
- const rows = await this.store.query.query({
29836
- collection: FACES_COLLECTION,
29837
- filter: {
29838
- where: { recognizedIdentityId: identityId },
29839
- orderBy: {
29840
- field: "timestamp",
29841
- direction: "desc"
29842
- },
29843
- limit: FACE_SWEEP_PAGE,
29844
- offset: page * FACE_SWEEP_PAGE
29845
- }
29846
- });
29847
- if (rows.length === 0) break;
29848
- for (const row of rows) {
29849
- const deviceId = Number(row.data["deviceId"]);
29850
- if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
29851
- }
29852
- if (rows.length < 1e3) break;
30457
+ for (const row of rows) {
30458
+ const deviceId = Number(row.data["deviceId"]);
30459
+ if (Number.isInteger(deviceId) && !byDevice.has(deviceId)) byDevice.set(deviceId, row.id);
29853
30460
  }
29854
30461
  return [...byDevice].map(([deviceId, faceId]) => ({
29855
30462
  deviceId,
29856
30463
  faceId
29857
30464
  }));
29858
30465
  }
30466
+ /**
30467
+ * Every face row currently attributed to an identity, id only.
30468
+ *
30469
+ * `deleteIdentity` detaches these before the identity goes. It used to get
30470
+ * them by listing the WHOLE collection with `filter: 'all'` and no limit and
30471
+ * comparing in JS — which past `SETTINGS_QUERY_DEFAULT_ROW_CAP` would have
30472
+ * detached the rows that fit and left the rest pointing at a deleted
30473
+ * identity, silently, with a WARN in the engine that no caller can see.
30474
+ */
30475
+ async listFaceIdsForIdentity(identityId) {
30476
+ return (await this.identityRows(identityId, FACE_REF_COLUMNS)).map((r) => r.id);
30477
+ }
30478
+ /**
30479
+ * The rows of one identity, newest first, bounded and REFUSING rather than
30480
+ * narrowing. Shared by the two callers above so they cannot disagree about
30481
+ * what "this identity's faces" means.
30482
+ */
30483
+ async identityRows(identityId, columns) {
30484
+ const rows = await this.store.query.query({
30485
+ collection: FACES_COLLECTION,
30486
+ columns,
30487
+ filter: {
30488
+ where: { recognizedIdentityId: identityId },
30489
+ orderBy: {
30490
+ field: "timestamp",
30491
+ direction: "desc"
30492
+ },
30493
+ limit: 10001
30494
+ }
30495
+ });
30496
+ if (rows.length > 1e4) throw new FaceWindowTooWideError(rows.length);
30497
+ return rows;
30498
+ }
29859
30499
  };
29860
30500
  //#endregion
29861
30501
  //#region src/pipeline-analytics/face-gallery-provider.ts
@@ -29972,8 +30612,7 @@ var FaceGalleryProvider = class {
29972
30612
  });
29973
30613
  }
29974
30614
  async deleteIdentity(input) {
29975
- const faces = await this.faceStore.listAllRecentFaces({ filter: "all" });
29976
- for (const f of faces) if (f.recognizedIdentityId === input.id) await this.unassignFace({ faceId: f.id });
30615
+ for (const faceId of await this.faceStore.listFaceIdsForIdentity(input.id)) await this.unassignFace({ faceId });
29977
30616
  await this.mediaStore.deleteForOwner("identity", [input.id]);
29978
30617
  await this.identityStore.deleteIdentity(input.id);
29979
30618
  this.refreshGallery();
@@ -30046,8 +30685,7 @@ var FaceGalleryProvider = class {
30046
30685
  sortBy: input?.sortBy,
30047
30686
  sortDirection: input?.sortDirection
30048
30687
  });
30049
- const identities = await this.identityStore.listIdentities();
30050
- const nameMap = new Map(identities.map((i) => [i.id, i.name]));
30688
+ const nameMap = await this.identityStore.namesFor(faces.flatMap((f) => f.recognizedIdentityId != null ? [f.recognizedIdentityId] : []));
30051
30689
  const includeCrops = resolveIncludeCrops(input);
30052
30690
  return mapWithConcurrency(faces, FACE_CROP_FETCH_CONCURRENCY, async (face) => {
30053
30691
  const base64 = includeCrops ? await this.faceCropBase64(face) : void 0;
@@ -30083,8 +30721,7 @@ var FaceGalleryProvider = class {
30083
30721
  const face = await this.faceStore.get("face-" + input.trackId);
30084
30722
  if (!face) return null;
30085
30723
  if (face.deviceId !== input.deviceId) return null;
30086
- const identities = await this.identityStore.listIdentities();
30087
- const nameMap = new Map(identities.map((i) => [i.id, i.name]));
30724
+ const nameMap = await this.identityStore.namesFor(face.recognizedIdentityId != null ? [face.recognizedIdentityId] : []);
30088
30725
  const identityName = face.recognizedIdentityId ? nameMap.get(face.recognizedIdentityId) : void 0;
30089
30726
  const cropUrl = this.cropUrlFor(face);
30090
30727
  return {
@@ -34903,9 +35540,14 @@ var PlateGalleryProvider = class {
34903
35540
  ...cropUrl !== void 0 ? { cropUrl } : {}
34904
35541
  };
34905
35542
  }
34906
- async vehicleNameMap() {
34907
- const vehicles = await this.vehicleStore.listVehicles();
34908
- return new Map(vehicles.map((v) => [v.id, v.name]));
35543
+ /**
35544
+ * Names for the vehicles THESE PLATES reference — not the collection.
35545
+ *
35546
+ * A page of unassigned reads references none, and then this costs no round
35547
+ * trip at all. See `VehicleStore.namesFor`.
35548
+ */
35549
+ async vehicleNameMap(plates) {
35550
+ return this.vehicleStore.namesFor(plates.flatMap((p) => p.recognizedVehicleId != null ? [p.recognizedVehicleId] : []));
34909
35551
  }
34910
35552
  /**
34911
35553
  * `includeCrops` defaults to `true` — the pre-existing behaviour, kept so no
@@ -34920,14 +35562,14 @@ var PlateGalleryProvider = class {
34920
35562
  ...input?.deviceId !== void 0 ? { deviceId: input.deviceId } : {},
34921
35563
  ...input?.limit !== void 0 ? { limit: input.limit } : {}
34922
35564
  });
34923
- const nameMap = await this.vehicleNameMap();
35565
+ const nameMap = await this.vehicleNameMap(rows);
34924
35566
  const includeCrops = resolveIncludeCrops(input);
34925
35567
  return Promise.all(rows.map((p) => this.toPlateInfo(p, nameMap, { includeCrops })));
34926
35568
  }
34927
35569
  async getPlateByTrack(input) {
34928
35570
  const plate = await this.plateStore.get(`plate-${input.trackId}`);
34929
35571
  if (!plate || plate.deviceId !== input.deviceId) return null;
34930
- const nameMap = await this.vehicleNameMap();
35572
+ const nameMap = await this.vehicleNameMap([plate]);
34931
35573
  return this.toPlateInfo(plate, nameMap, { includeCrops: false });
34932
35574
  }
34933
35575
  async getPlateMedia(input) {
@@ -34940,7 +35582,7 @@ var PlateGalleryProvider = class {
34940
35582
  dist: plateDistance(input.text, p.text)
34941
35583
  })).filter((x) => x.dist <= maxDistance).toSorted((a, b) => a.dist - b.dist || b.p.timestamp - a.p.timestamp);
34942
35584
  const limited = input.limit !== void 0 ? scored.slice(0, input.limit) : scored;
34943
- const nameMap = await this.vehicleNameMap();
35585
+ const nameMap = await this.vehicleNameMap(limited.map((x) => x.p));
34944
35586
  const includeCrops = input.includeCrops ?? true;
34945
35587
  return Promise.all(limited.map((x) => this.toPlateInfo(x.p, nameMap, { includeCrops })));
34946
35588
  }
@@ -35015,8 +35657,7 @@ var PlateGalleryProvider = class {
35015
35657
  await this.vehicleStore.renameVehicle(input.id, input.name);
35016
35658
  }
35017
35659
  async deleteVehicle(input) {
35018
- const plates = await this.plateStore.listAllRecentPlates({});
35019
- for (const p of plates) if (p.recognizedVehicleId === input.id) await this.unassignPlate({ plateId: p.id });
35660
+ for (const plateId of await this.plateStore.listPlateIdsForVehicle(input.id)) await this.unassignPlate({ plateId });
35020
35661
  await this.mediaStore.deleteForOwner("vehicle", [input.id]);
35021
35662
  await this.vehicleStore.deleteVehicle(input.id);
35022
35663
  this.refreshGallery();
@@ -35459,6 +36100,7 @@ async function runMediaReclaim(input) {
35459
36100
  byteBudget -= report.bytesReclaimed;
35460
36101
  nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
35461
36102
  devices.push(report);
36103
+ input.onDevice?.(report);
35462
36104
  if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
35463
36105
  tags: { deviceId },
35464
36106
  meta: {
@@ -35498,14 +36140,12 @@ async function runMediaReclaim(input) {
35498
36140
  /**
35499
36141
  * The operator button for the debug-media reclaim.
35500
36142
  *
35501
- * The mechanism ships armed with nothing: **arming it is the operator's
35502
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
35503
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35504
- * that has to be typed.
36143
+ * The pass is tens of minutes. The button STARTS it and returns
36144
+ * `{ started, alreadyRunning }` immediately — the same shape as the CLIP
36145
+ * rebuild button. Progress is `retention.mediaReclaimStatus`. Awaiting the
36146
+ * walk is how this door hit the 60 s UDS deadline.
35505
36147
  *
35506
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35507
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35508
- * report you can read instead of a log line you have to find.
36148
+ * `mode` omitted means `report`. `reclaim` is a word that has to be typed.
35509
36149
  */
35510
36150
  /**
35511
36151
  * `report` counts; `reclaim` deletes.
@@ -35546,40 +36186,28 @@ var MediaReclaimActionInputSchema = require_dist.object({
35546
36186
  */
35547
36187
  graceMinutes: require_dist.number().int().min(1).max(10080).optional()
35548
36188
  });
35549
- var MediaReclaimDeviceReportSchema = require_dist.object({
35550
- deviceId: require_dist.number(),
35551
- scope: MediaReclaimScopeSchema,
35552
- examined: require_dist.number(),
35553
- eligible: require_dist.number(),
35554
- reclaimed: require_dist.number(),
35555
- bytesReclaimed: require_dist.number(),
35556
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
35557
- held: require_dist.number(),
35558
- /** Rows kept because the owner could not be shown to be a motion event. */
35559
- unresolved: require_dist.number(),
35560
- withinGrace: require_dist.number(),
35561
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
35562
- * The row was kept and the cursor did not pass it. */
35563
- refused: require_dist.number(),
35564
- stoppedBy: require_dist.string(),
35565
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
35566
- nextCursor: require_dist.number()
36189
+ var MediaReclaimStartedSchema = require_dist.object({
36190
+ started: require_dist.boolean(),
36191
+ alreadyRunning: require_dist.boolean()
35567
36192
  });
35568
- var MediaReclaimActionResultSchema = require_dist.object({
35569
- /** `false` when a pass was already in flight — nothing was done. */
35570
- ran: require_dist.boolean(),
35571
- mode: require_dist._enum(["report", "reclaim"]),
35572
- devices: require_dist.array(MediaReclaimDeviceReportSchema),
36193
+ var MediaReclaimStatusSchema = require_dist.object({
36194
+ running: require_dist.boolean(),
36195
+ mode: require_dist._enum(["report", "reclaim"]).nullable(),
35573
36196
  totalExamined: require_dist.number(),
35574
36197
  totalEligible: require_dist.number(),
35575
36198
  totalReclaimed: require_dist.number(),
35576
36199
  totalBytesReclaimed: require_dist.number(),
35577
36200
  totalRefused: require_dist.number(),
35578
- durationMs: require_dist.number(),
35579
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
35580
- complete: require_dist.boolean()
36201
+ devicesDone: require_dist.number(),
36202
+ complete: require_dist.boolean().nullable(),
36203
+ startedAtMs: require_dist.number().nullable(),
36204
+ finishedAtMs: require_dist.number().nullable(),
36205
+ error: require_dist.string().nullable()
36206
+ });
36207
+ var mediaReclaimActions = require_dist.defineCustomActions({
36208
+ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimStartedSchema, { auth: "admin" }),
36209
+ "retention.mediaReclaimStatus": require_dist.customAction(require_dist.object({}), MediaReclaimStatusSchema, { auth: "admin" })
35581
36210
  });
35582
- var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
35583
36211
  //#endregion
35584
36212
  //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
35585
36213
  /**
@@ -40672,6 +41300,7 @@ function createLocationAwareMediaStorage(deps) {
40672
41300
  const roots = /* @__PURE__ */ new Map();
40673
41301
  let defaultBypassInvalidated = false;
40674
41302
  const rootOf = async (locationId) => {
41303
+ if (deps.galleryLocationId !== void 0 && deps.galleryRoot !== void 0 && locationId === deps.galleryLocationId) return deps.galleryRoot;
40675
41304
  const cached = roots.get(locationId);
40676
41305
  if (cached !== void 0) return cached;
40677
41306
  const root = await deps.resolveRoot(locationId);
@@ -43178,7 +43807,7 @@ var PhotoAnalysisSessions = class {
43178
43807
  * a magnet that recognises every degraded probe as its owner, and 22 of 50
43179
43808
  * live samples were enrolled that way on 2026-08-18;
43180
43809
  * 3. both blobs are written under `ownerKind: 'identity'`, which is what puts
43181
- * them on `galleryMedia` — the location a footage wipe cannot reach (D197);
43810
+ * them in the addon data dir — the location a footage wipe cannot reach (D197);
43182
43811
  * 4. the row is written last, and if it does not land BOTH blobs are reclaimed.
43183
43812
  * An orphan under `identity` is exempt from every sweep this repo has, so
43184
43813
  * it would otherwise live forever.
@@ -44910,6 +45539,45 @@ function pickWritableMediaLocation(locations, currentDefaultId) {
44910
45539
  }
44911
45540
  //#endregion
44912
45541
  //#region src/pipeline-analytics/media-relocate-engine.ts
45542
+ /**
45543
+ * Event-media relocation engine (entity-routing spec, Phase 4) — the
45544
+ * analytics sibling of the recorder's footage mover. Each addon moves ONLY
45545
+ * what it owns (addons never touch each other's files).
45546
+ *
45547
+ * Per row: read blob from its CURRENT location → write to the target →
45548
+ * stamp `row.locationId` → delete the old blob. The row update lands between
45549
+ * the two blob operations, so a crash leaves the row pointing at a location
45550
+ * that still holds valid bytes (the old blob is only deleted after the row
45551
+ * points away from it). Rows already at the target are skipped — a re-run
45552
+ * RESUMES.
45553
+ *
45554
+ * Paging: timestamp-cursor over the media collection, with a seen-id set
45555
+ * bridging equal-timestamp boundaries. The PASS pages because it must visit
45556
+ * every row to act on it; that is not true of every question asked about this
45557
+ * collection, and `countUnstamped` no longer pages — see its docblock, and the
45558
+ * 60 s timeout that shape cost.
45559
+ *
45560
+ * (This header used to claim `WHERE locationId IS NULL` was inexpressible
45561
+ * through the store's filter. It was expressible, and that was the problem:
45562
+ * `where: { locationId: null }` compiled to `= NULL`, which matches nothing
45563
+ * and reports it as `0`. The filter layer now compiles it to `IS NULL`.)
45564
+ *
45565
+ * ── Three passes, one engine (see `MediaRelocateModeSchema`) ──────────────
45566
+ *
45567
+ * `move` is the historical pass. `seal` and `gallery` are the SAME walk with a
45568
+ * different row predicate and a different per-row action, deliberately not a
45569
+ * second mover: the walk, the cursor, the tie-bridging and the failure
45570
+ * accounting are the hard parts and they are already right here.
45571
+ *
45572
+ * - `seal` exists because a row with `locationId: NULL` means "wherever
45573
+ * `eventMedia` points NOW" (`media-store.ts:396`). A repoint moves that
45574
+ * pointer and every NULL row starts reading from the new disk while its
45575
+ * bytes are on the old one. `seal` is a ROW-ONLY pass — no blob is read,
45576
+ * written or deleted — that nails those rows to the location they are
45577
+ * actually on before anything repoints.
45578
+ * - `gallery` is the inverse selection of `move`: only the retention-exempt
45579
+ * enrolled-gallery rows, which `move` skips on purpose (D197).
45580
+ */
44913
45581
  var PAGE_SIZE = 200;
44914
45582
  var DEFAULT_THROTTLE_MBPS = 40;
44915
45583
  /** What a pass touches, reported on the job so an ops-log row says which of the
@@ -44954,6 +45622,59 @@ var LOCATION_ID_FIELD = "locationId";
44954
45622
  */
44955
45623
  var SEAL_BATCH_SIZE = 500;
44956
45624
  /**
45625
+ * Rows one CENSUS may visit before it answers `null`.
45626
+ *
45627
+ * A census is the read-only question behind `storageMigration.residue` — "how
45628
+ * much would a pass still act on". It is NOT the pass: a pass walks the whole
45629
+ * collection because it has to touch every row, and pays for that walk once,
45630
+ * under an operator's explicit start.
45631
+ *
45632
+ * 20 000 rows is 100 round trips at {@link PAGE_SIZE} — ~26 ms of engine time
45633
+ * on a healthy store, and a ceiling that cannot become the cluster's queue. A
45634
+ * collection larger than this is one a WALK cannot answer questions about; the
45635
+ * honest reply is "I could not answer" (D295), and the structural fix is an
45636
+ * indexed `COUNT(*)`, which needs `locationId` sealed first and is deliberately
45637
+ * not smuggled in behind a bound.
45638
+ */
45639
+ var MEDIA_CENSUS_ROW_BUDGET = 2e4;
45640
+ /**
45641
+ * Wall clock one CENSUS may spend before it answers `null`.
45642
+ *
45643
+ * The row budget bounds a healthy store; this one bounds a sick store, where
45644
+ * 100 round trips at a 16 s queue depth is 27 minutes. Read through
45645
+ * `deps.now`, so it is deterministic under test and does not need a timer.
45646
+ */
45647
+ var MEDIA_CENSUS_DEADLINE_MS = 2e4;
45648
+ /**
45649
+ * What one census may spend, and what it has spent.
45650
+ *
45651
+ * Explicit rather than folded into the `stop` callback: `stop` means "someone
45652
+ * asked me to stop" and the budget means "I am not allowed to continue", and a
45653
+ * pass passes `null` here because a pass is allowed to walk the whole
45654
+ * collection. Two concepts, two parameters — the walk that ran unbounded for
45655
+ * a day did so because ONE `stop: () => false` stood for both.
45656
+ */
45657
+ var CensusBudget = class {
45658
+ now;
45659
+ maxRows;
45660
+ rows = 0;
45661
+ deadlineAt;
45662
+ constructor(now, maxRows, maxMs) {
45663
+ this.now = now;
45664
+ this.maxRows = maxRows;
45665
+ this.deadlineAt = now() + maxMs;
45666
+ }
45667
+ get rowsVisited() {
45668
+ return this.rows;
45669
+ }
45670
+ /** Visit one row. `true` means the budget is spent and the census must stop. */
45671
+ spend() {
45672
+ this.rows += 1;
45673
+ if (this.rows > this.maxRows) return true;
45674
+ return this.rows % PAGE_SIZE === 0 && this.now() >= this.deadlineAt;
45675
+ }
45676
+ };
45677
+ /**
44957
45678
  * True when the row names NO location — the population `seal` exists for.
44958
45679
  *
44959
45680
  * Deliberately NOT `mediaRowLocation(data) === DEFAULT_MEDIA_LOCATION`: that
@@ -45010,9 +45731,26 @@ function selectsRow(mode, toLocationId, data, isRetrain) {
45010
45731
  var MediaRelocateEngine = class {
45011
45732
  deps;
45012
45733
  jobs = /* @__PURE__ */ new Map();
45734
+ /** Censuses in flight, keyed by the QUESTION — see {@link countSelectable}. */
45735
+ censuses = /* @__PURE__ */ new Map();
45013
45736
  constructor(deps) {
45014
45737
  this.deps = deps;
45015
45738
  }
45739
+ /**
45740
+ * Retire every census in flight. They answer `null`, and stop reading.
45741
+ *
45742
+ * @returns how many were retired.
45743
+ */
45744
+ cancelCensuses(reason) {
45745
+ const retired = this.censuses.size;
45746
+ if (retired === 0) return 0;
45747
+ for (const run of this.censuses.values()) run.abort();
45748
+ this.deps.logger.info("media relocate: standing censuses retired", { meta: {
45749
+ retired,
45750
+ reason
45751
+ } });
45752
+ return retired;
45753
+ }
45016
45754
  list() {
45017
45755
  return [...this.jobs.values()].sort((a, b) => b.startedAt - a.startedAt).map(snapshot);
45018
45756
  }
@@ -45111,6 +45849,7 @@ var MediaRelocateEngine = class {
45111
45849
  }
45112
45850
  start(input) {
45113
45851
  for (const j of this.jobs.values()) if (j.state === "running") throw new Error(`a media relocation is already running (${j.jobId})`);
45852
+ this.cancelCensuses("a relocate pass started");
45114
45853
  const job = {
45115
45854
  jobId: this.deps.newId(),
45116
45855
  state: "running",
@@ -45181,9 +45920,31 @@ var MediaRelocateEngine = class {
45181
45920
  * must not be allowed to make (D295).
45182
45921
  */
45183
45922
  async countSelectable(input) {
45184
- return this.countSelected(input.mode ?? "move", input.toLocationId, () => false);
45923
+ const mode = input.mode ?? "move";
45924
+ const key = `${mode}|${input.toLocationId}`;
45925
+ const running = this.censuses.get(key);
45926
+ if (running !== void 0) return running.promise;
45927
+ let aborted = false;
45928
+ const budget = new CensusBudget(this.deps.now, MEDIA_CENSUS_ROW_BUDGET, MEDIA_CENSUS_DEADLINE_MS);
45929
+ const promise = this.countSelected(mode, input.toLocationId, () => aborted, budget).finally(() => {
45930
+ this.censuses.delete(key);
45931
+ });
45932
+ this.censuses.set(key, {
45933
+ promise,
45934
+ abort: () => {
45935
+ aborted = true;
45936
+ }
45937
+ });
45938
+ return promise;
45185
45939
  }
45186
- async countSelected(mode, toLocationId, stop) {
45940
+ /**
45941
+ * @param stop someone asked this walk to stop (an operator cancel, or
45942
+ * {@link cancelCensuses}). Answers `null`.
45943
+ * @param budget what this walk is allowed to spend, or `null` for a PASS —
45944
+ * a pass walks the whole collection because it must touch every row, and
45945
+ * an operator started it explicitly.
45946
+ */
45947
+ async countSelected(mode, toLocationId, stop, budget) {
45187
45948
  try {
45188
45949
  if (mode === "seal") {
45189
45950
  let sealable = 0;
@@ -45200,7 +45961,25 @@ var MediaRelocateEngine = class {
45200
45961
  for (const collection of collectionsFor(mode)) {
45201
45962
  const isRetrain = collection.name === RETRAIN_FRAMES_COLLECTION;
45202
45963
  for await (const row of this.pageRows(collection.name, collection.order)) {
45203
- if (stop()) return null;
45964
+ if (stop()) {
45965
+ this.deps.logger.warn("media relocate: census stopped before it finished — reporting the residue as unknown, not as empty", { meta: {
45966
+ mode,
45967
+ toLocationId,
45968
+ collection: collection.name
45969
+ } });
45970
+ return null;
45971
+ }
45972
+ if (budget !== null && budget.spend()) {
45973
+ this.deps.logger.warn("media relocate: census exceeded its budget — this collection is larger than a walk can answer", { meta: {
45974
+ mode,
45975
+ toLocationId,
45976
+ collection: collection.name,
45977
+ rowsVisited: budget.rowsVisited,
45978
+ rowBudget: MEDIA_CENSUS_ROW_BUDGET,
45979
+ deadlineMs: MEDIA_CENSUS_DEADLINE_MS
45980
+ } });
45981
+ return null;
45982
+ }
45204
45983
  if (selectsRow(mode, toLocationId, row.data, isRetrain)) rows++;
45205
45984
  }
45206
45985
  }
@@ -45226,7 +46005,7 @@ var MediaRelocateEngine = class {
45226
46005
  * rows in silence reads as "there were none", which is how a migration
45227
46006
  * gets believed. */
45228
46007
  let pathlessRows = 0;
45229
- job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested))?.rows ?? null;
46008
+ job.filesTotal = (await this.countSelected(job.mode, job.toLocationId, () => job.cancelRequested, null))?.rows ?? null;
45230
46009
  if (job.mode === "seal") {
45231
46010
  for (const collection of collectionsFor(job.mode)) {
45232
46011
  if (job.cancelRequested) break;
@@ -52755,6 +53534,50 @@ var IdentityStore = class {
52755
53534
  key: id
52756
53535
  });
52757
53536
  }
53537
+ /**
53538
+ * `identityId → name` for exactly the identities asked about.
53539
+ *
53540
+ * The decorating half of a face or plate page needs a NAME per referenced
53541
+ * identity, and it used to get it by reading the whole `identities`
53542
+ * collection — unbounded, unprojected, on every 30 s poll of
53543
+ * `faceGallery.listRecentFaces`, and again on every `getFaceByTrack`. The
53544
+ * table is small (4 rows on the live hub on 2026-08-31) so the bytes were
53545
+ * never the problem; the SHAPE was. It is a second question whose cost is
53546
+ * proportional to a collection, asked on a timer, to answer a question about
53547
+ * a page — and it carried no `limit`, so past
53548
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` it would have handed back a name map
53549
+ * missing entries, shape-identical to a complete one (D320).
53550
+ *
53551
+ * **An empty request costs no round trip at all.** With
53552
+ * `filter: 'unassigned'` — the view the operator lives in — a page
53553
+ * references no identity, and the honest number of queries for that is zero.
53554
+ * `whereIn` with an empty list matches nothing, so asking anyway would be an
53555
+ * RPC to be told what the caller already knew.
53556
+ *
53557
+ * A cache belongs in the owner of the fact, and this store IS the owner —
53558
+ * but a name here changes only when the operator renames, through this same
53559
+ * store, and the read is now one indexed `IN` over at most a page's worth of
53560
+ * ids. There is nothing left to cache that would not be a second authority
53561
+ * over a fact one round trip away (D224).
53562
+ */
53563
+ async namesFor(identityIds) {
53564
+ const ids = [...new Set(identityIds)];
53565
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
53566
+ const rows = await this.store.query.query({
53567
+ collection: IDENTITIES_COLLECTION,
53568
+ columns: ["name"],
53569
+ filter: {
53570
+ whereIn: { id: ids },
53571
+ limit: ids.length
53572
+ }
53573
+ });
53574
+ const names = /* @__PURE__ */ new Map();
53575
+ for (const r of rows) {
53576
+ const name = r.data["name"];
53577
+ if (typeof name === "string") names.set(r.id, name);
53578
+ }
53579
+ return names;
53580
+ }
52758
53581
  async listIdentities() {
52759
53582
  return (await this.store.query.query({
52760
53583
  collection: IDENTITIES_COLLECTION,
@@ -53296,13 +54119,55 @@ var PLATE_COLUMNS = [
53296
54119
  type: "JSON"
53297
54120
  }
53298
54121
  ];
53299
- var PLATE_INDEXES = [{
53300
- name: "idx_plates_device_ts",
53301
- columns: ["deviceId", "timestamp"]
53302
- }, {
53303
- name: "idx_plates_track",
53304
- columns: ["trackId"]
53305
- }];
54122
+ /**
54123
+ * What the gallery orders by, and what the vehicle-scoped read filters on.
54124
+ *
54125
+ * The face twin's `EXPLAIN QUERY PLAN` measurement applies verbatim: without an
54126
+ * index on `timestamp`, `ORDER BY timestamp DESC LIMIT 500` is
54127
+ * `SCAN + USE TEMP B-TREE FOR ORDER BY` — the read was already BOUNDED at 200,
54128
+ * and still sorted the whole table to produce the newest N. A bound caps what
54129
+ * comes back; only an ordered index caps what is read.
54130
+ */
54131
+ var PLATE_INDEXES = [
54132
+ {
54133
+ name: "idx_plates_device_ts",
54134
+ columns: ["deviceId", "timestamp"]
54135
+ },
54136
+ {
54137
+ name: "idx_plates_track",
54138
+ columns: ["trackId"]
54139
+ },
54140
+ {
54141
+ name: "idx_plates_ts",
54142
+ columns: ["timestamp"]
54143
+ },
54144
+ {
54145
+ name: "idx_plates_vehicle_ts",
54146
+ columns: ["recognizedVehicleId", "timestamp"]
54147
+ }
54148
+ ];
54149
+ /**
54150
+ * The widest plate window a single read may materialise before it REFUSES.
54151
+ *
54152
+ * The corpus reads (`searchPlates`, `suggestPlateClusters`) are genuinely
54153
+ * corpus-shaped — fuzzy text distance is not a predicate — so they cannot be
54154
+ * narrowed the way the gallery list was. What they CAN do is stop lying: they
54155
+ * carried no `limit` at all, so the engine imposed
54156
+ * `SETTINGS_QUERY_DEFAULT_ROW_CAP` and handed back the first 2 000 rows with a
54157
+ * WARN no caller can see. A search over the newest 2 000 of 50 000 plates and a
54158
+ * search over all of them are the same array (D320 R3).
54159
+ *
54160
+ * 10 000 mirrors the faces ceiling and stays under the engine's hard cap so the
54161
+ * engine never truncates first.
54162
+ */
54163
+ var PLATE_WINDOW_MAX_ROWS = 1e4;
54164
+ /** A corpus read whose window is wider than {@link PLATE_WINDOW_MAX_ROWS}. */
54165
+ var PlateWindowTooWideError = class extends Error {
54166
+ constructor(rows) {
54167
+ super(`plate window exceeds ${String(PLATE_WINDOW_MAX_ROWS)} rows (read ${String(rows)}) — refusing to answer with the part that fit`);
54168
+ this.name = "PlateWindowTooWideError";
54169
+ }
54170
+ };
53306
54171
  var PlateStore = class {
53307
54172
  store;
53308
54173
  logger;
@@ -53367,18 +54232,53 @@ var PlateStore = class {
53367
54232
  }
53368
54233
  })).map((r) => this.normalizeRow(r));
53369
54234
  }
53370
- /** List ALL recent plate reads across devices, newest first. */
54235
+ /**
54236
+ * List ALL recent plate reads across devices, newest first.
54237
+ *
54238
+ * The corpus read, for the two passes whose question is not a predicate
54239
+ * (fuzzy text search, text clustering). It carries an explicit `limit` and
54240
+ * REFUSES past it rather than handing back the part that fit: without one the
54241
+ * engine imposed its own cap and truncated silently, and a search over the
54242
+ * newest 2 000 plates looks exactly like a search over all of them.
54243
+ */
53371
54244
  async listAllRecentPlates(input) {
53372
- return (await this.store.query.query({
54245
+ const bounded = input.limit !== void 0;
54246
+ const rows = await this.store.query.query({
53373
54247
  collection: PLATES_COLLECTION,
53374
54248
  filter: {
53375
54249
  orderBy: {
53376
54250
  field: "timestamp",
53377
54251
  direction: "desc"
53378
54252
  },
53379
- ...input.limit !== void 0 ? { limit: input.limit } : {}
54253
+ limit: bounded ? input.limit ?? 0 : 10001
53380
54254
  }
53381
- })).map((r) => this.normalizeRow(r));
54255
+ });
54256
+ if (!bounded && rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
54257
+ return rows.map((r) => this.normalizeRow(r));
54258
+ }
54259
+ /**
54260
+ * Every plate row currently attributed to a vehicle, id only.
54261
+ *
54262
+ * `deleteVehicle` detaches these before the vehicle goes. It used to list the
54263
+ * WHOLE buffer and compare in JS — the `deleteIdentity` defect verbatim, and
54264
+ * past the engine's row cap it would detach the plates that fit and leave the
54265
+ * rest pointing at a vehicle that no longer exists.
54266
+ */
54267
+ async listPlateIdsForVehicle(vehicleId) {
54268
+ const rows = await this.store.query.query({
54269
+ collection: PLATES_COLLECTION,
54270
+ columns: ["deviceId"],
54271
+ filter: {
54272
+ where: { recognizedVehicleId: vehicleId },
54273
+ orderBy: {
54274
+ field: "timestamp",
54275
+ direction: "desc"
54276
+ },
54277
+ limit: 10001
54278
+ }
54279
+ });
54280
+ if (rows.length > 1e4) throw new PlateWindowTooWideError(rows.length);
54281
+ return rows.map((r) => r.id);
53382
54282
  }
53383
54283
  async get(plateId) {
53384
54284
  const raw = await this.store.get.query({
@@ -56038,6 +56938,38 @@ var VehicleStore = class {
56038
56938
  key: id
56039
56939
  });
56040
56940
  }
56941
+ /**
56942
+ * `vehicleId → name` for exactly the vehicles asked about — the plate twin of
56943
+ * `IdentityStore.namesFor`, and it exists for the same reason.
56944
+ *
56945
+ * The decorating half of a plate page needs a NAME per referenced vehicle,
56946
+ * and it got it by reading the whole `vehicles` collection: unbounded,
56947
+ * unprojected, on every 30 s poll of `plateGallery.listPlates` and again on
56948
+ * every `getPlateByTrack` and `searchPlates`. The live table holds **0 rows**
56949
+ * — the bytes were never the point. The shape was: a question proportional to
56950
+ * a collection, on a timer, to decorate a page.
56951
+ *
56952
+ * An empty request costs no round trip: `whereIn` over an empty list matches
56953
+ * nothing, so asking would be an RPC to be told what the caller knew.
56954
+ */
56955
+ async namesFor(vehicleIds) {
56956
+ const ids = [...new Set(vehicleIds)];
56957
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
56958
+ const rows = await this.store.query.query({
56959
+ collection: VEHICLES_COLLECTION,
56960
+ columns: ["name"],
56961
+ filter: {
56962
+ whereIn: { id: ids },
56963
+ limit: ids.length
56964
+ }
56965
+ });
56966
+ const names = /* @__PURE__ */ new Map();
56967
+ for (const r of rows) {
56968
+ const name = r.data["name"];
56969
+ if (typeof name === "string") names.set(r.id, name);
56970
+ }
56971
+ return names;
56972
+ }
56041
56973
  async listVehicles() {
56042
56974
  return (await this.store.query.query({
56043
56975
  collection: VEHICLES_COLLECTION,
@@ -63009,6 +63941,50 @@ var ZoneAnalyticsProvider = class {
63009
63941
  return this.snapshots.get(deviceId) ?? await this.hydrateFromRegistry(deviceId);
63010
63942
  }
63011
63943
  /**
63944
+ * The same snapshot, for a SET of cameras, in one round trip.
63945
+ *
63946
+ * The Events page's Stationary section polls this every 15 s for every
63947
+ * selected camera. Fanned out client-side that is one browser → hub →
63948
+ * post-analysis round trip per camera to read a `Map.get`; the answer costs
63949
+ * nothing and the transport costs everything. The per-device work here is
63950
+ * unchanged — the same live snapshot, the same parked-registry hydration when
63951
+ * there is no live one — and it runs concurrently inside the owner.
63952
+ *
63953
+ * **Three outcomes stay three.** A camera with a reading, a camera with none
63954
+ * yet (`snapshot: null`, `read: 'read'`), and a camera the owner could not
63955
+ * answer for (`read: 'unreadable'`). The hydration reaches a durable store
63956
+ * and can throw; letting that throw become `snapshot: null` would render an
63957
+ * unread camera as a camera with nothing parked, which is a definite claim
63958
+ * nobody made.
63959
+ */
63960
+ async getCurrentSnapshotBatch({ deviceIds }) {
63961
+ return Promise.all(deviceIds.map(async (deviceId) => {
63962
+ const live = this.snapshots.get(deviceId);
63963
+ if (live !== void 0) return {
63964
+ deviceId,
63965
+ read: "read",
63966
+ snapshot: live
63967
+ };
63968
+ try {
63969
+ return {
63970
+ deviceId,
63971
+ read: "read",
63972
+ snapshot: await this.hydrateFromRegistry(deviceId)
63973
+ };
63974
+ } catch (err) {
63975
+ this.ctx.logger.warn("occupancy snapshot could not be read", {
63976
+ tags: { deviceId },
63977
+ meta: { error: String(err) }
63978
+ });
63979
+ return {
63980
+ deviceId,
63981
+ read: "unreadable",
63982
+ snapshot: null
63983
+ };
63984
+ }
63985
+ }));
63986
+ }
63987
+ /**
63012
63988
  * The cameras a LIVE snapshot exists for.
63013
63989
  *
63014
63990
  * The Notification Center's occupancy dry-run ("as of now, would this rule
@@ -63351,6 +64327,20 @@ var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
63351
64327
  * window rather than a random slice of it.
63352
64328
  */
63353
64329
  var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
64330
+ var IDLE_MEDIA_RECLAIM = {
64331
+ running: false,
64332
+ mode: null,
64333
+ totalExamined: 0,
64334
+ totalEligible: 0,
64335
+ totalReclaimed: 0,
64336
+ totalBytesReclaimed: 0,
64337
+ totalRefused: 0,
64338
+ devicesDone: 0,
64339
+ complete: null,
64340
+ startedAtMs: null,
64341
+ finishedAtMs: null,
64342
+ error: null
64343
+ };
63354
64344
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
63355
64345
  /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
63356
64346
  * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
@@ -64466,9 +65456,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64466
65456
  * In RAM like the audit's: the pass is resumable from zero at no cost
64467
65457
  * beyond re-walking a head that is already empty. */
64468
65458
  mediaReclaimCursors = /* @__PURE__ */ new Map();
64469
- /** Single-flight latch — two concurrent passes would double the disk
64470
- * pressure the pacing exists to avoid. */
64471
- mediaReclaimRunning = false;
65459
+ /** Live state of the detached debug-media reclaim — see `reclaimDebugMedia`. */
65460
+ mediaReclaimState = { ...IDLE_MEDIA_RECLAIM };
64472
65461
  /**
64473
65462
  * The vector-index leg's resume cursor, kept in its OWN field.
64474
65463
  *
@@ -64908,6 +65897,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
64908
65897
  const routed = createLocationAwareMediaStorage({
64909
65898
  base: storage,
64910
65899
  defaultLocation: "eventMedia",
65900
+ galleryLocationId: GALLERY_MEDIA_LOCATION,
65901
+ galleryRoot: node_path.default.join(this.ctx.dataDir, "gallery"),
64911
65902
  resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
64912
65903
  location: locationId,
64913
65904
  relativePath: ""
@@ -65567,6 +66558,43 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65567
66558
  },
65568
66559
  resolveArtifactUrl: async (artifactId) => await this.ncArtifactPlane?.mintUrl(artifactId) ?? null,
65569
66560
  mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
66561
+ listAddonUpdates: async (nodeId) => {
66562
+ return (await this.ctx.api.addons.listUpdates.query({ nodeId })).filter((update) => !update.isSystem).map((update) => update.name);
66563
+ },
66564
+ applyAddonUpdate: async ({ packageName, nodeId }) => {
66565
+ try {
66566
+ await this.ctx.api.addons.updatePackage.mutate({
66567
+ name: packageName,
66568
+ nodeId
66569
+ });
66570
+ return { ok: true };
66571
+ } catch (err) {
66572
+ return {
66573
+ ok: false,
66574
+ error: require_dist.errMsg(err)
66575
+ };
66576
+ }
66577
+ },
66578
+ checkServerUpdate: async (nodeId) => {
66579
+ const result = await this.ctx.api.serverManagement.checkServerUpdate.mutate({ nodeId });
66580
+ if (result.error) throw new Error(result.error);
66581
+ return result.updateAvailable;
66582
+ },
66583
+ applyServerUpdate: async ({ nodeId }) => {
66584
+ try {
66585
+ const result = await this.ctx.api.serverManagement.applyServerUpdate.mutate({ nodeId });
66586
+ if (!result.accepted) return {
66587
+ ok: false,
66588
+ error: result.message
66589
+ };
66590
+ return { ok: true };
66591
+ } catch (err) {
66592
+ return {
66593
+ ok: false,
66594
+ error: require_dist.errMsg(err)
66595
+ };
66596
+ }
66597
+ },
65570
66598
  readDeviceStates: async (ids) => {
65571
66599
  const out = /* @__PURE__ */ new Map();
65572
66600
  for (const id of ids) try {
@@ -66499,19 +67527,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66499
67527
  async reconcileGalleryMediaLocation() {
66500
67528
  const mediaStore = this.mediaStore;
66501
67529
  if (!mediaStore) return;
66502
- try {
66503
- await this.ctx.api.storage.resolve.query({
66504
- location: GALLERY_MEDIA_LOCATION,
66505
- relativePath: ""
66506
- });
66507
- } catch (err) {
66508
- 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: {
66509
- location: GALLERY_MEDIA_LOCATION,
66510
- error: require_dist.errMsg(err),
66511
- 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."
66512
- } });
66513
- return;
66514
- }
66515
67530
  try {
66516
67531
  const result = await mediaStore.relocateExemptMediaToGallery();
66517
67532
  if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
@@ -70526,6 +71541,36 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
70526
71541
  return [];
70527
71542
  }
70528
71543
  }
71544
+ /**
71545
+ * `getKeyEvents` for a SET of cameras, in one round trip.
71546
+ *
71547
+ * The Detection Intelligence events feed asked this of every selected camera
71548
+ * on a 30 s timer — N browser → hub → post-analysis round trips for N
71549
+ * independent, already-indexed store queries. The queries are unchanged and
71550
+ * still run per camera (each is `where deviceId` + a `firstSeen` range
71551
+ * against its own index, and `limit` stays PER CAMERA so a busy camera cannot
71552
+ * starve a quiet one of its rows); what collapses is the transport.
71553
+ *
71554
+ * Every requested id gets a row. `getKeyEvents` already degrades to `[]`
71555
+ * rather than throwing, so a per-camera failure is reported exactly as it was
71556
+ * before this method existed — this does not make it worse, and the row
71557
+ * carries the `deviceId` the single method's output never did.
71558
+ */
71559
+ async getKeyEventsBatch(input) {
71560
+ return Promise.all(input.deviceIds.map(async (deviceId) => {
71561
+ return {
71562
+ deviceId,
71563
+ events: await this.getKeyEvents({
71564
+ deviceId,
71565
+ since: input.since,
71566
+ until: input.until,
71567
+ ...input.limit !== void 0 ? { limit: input.limit } : {},
71568
+ ...input.minImportance !== void 0 ? { minImportance: input.minImportance } : {},
71569
+ ...input.classFilter !== void 0 ? { classFilter: input.classFilter } : {}
71570
+ })
71571
+ };
71572
+ }));
71573
+ }
70529
71574
  async getAudioEvents(input) {
70530
71575
  return this.queryFacade.getAudioEvents(input);
70531
71576
  }
@@ -71550,42 +72595,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71550
72595
  "embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
71551
72596
  "embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
71552
72597
  "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
71553
- "retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
72598
+ "retention.mediaReclaim": async (input) => this.reclaimDebugMedia(input),
72599
+ "retention.mediaReclaimStatus": async () => this.getMediaReclaimStatus()
71554
72600
  };
71555
72601
  }
71556
72602
  /**
71557
- * One operator-driven debug-media reclaim pass — the entry point behind
71558
- * `retention.mediaReclaim`.
72603
+ * Start a debug-media reclaim pass. DETACHED.
71559
72604
  *
71560
- * DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
71561
- * `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
71562
- * from the per-device cursors this method keeps, and a run that is cut short
71563
- * has still only forgotten rows whose blobs it proved gone.
71564
- *
71565
- * Single-flight: a second call while a pass is in flight returns
71566
- * `ran: false` rather than doubling the disk pressure the pacing exists to
71567
- * avoid.
72605
+ * Returns `{ started }` immediately. The walk is tens of minutes at 2 MB/s;
72606
+ * awaiting it is a timeout, not a verdict. Poll {@link getMediaReclaimStatus}.
72607
+ * Dry-run unless `mode: 'reclaim'`. A second call while one is in flight is
72608
+ * `alreadyRunning`, not a second walk on the same spindle.
71568
72609
  */
71569
- async runMediaReclaimNow(rawInput) {
72610
+ async reclaimDebugMedia(rawInput) {
71570
72611
  const input = MediaReclaimActionInputSchema.parse(rawInput);
72612
+ if (this.mediaReclaimState.running) {
72613
+ this.ctx.logger.info("debug-media reclaim already running — request ignored");
72614
+ return {
72615
+ started: false,
72616
+ alreadyRunning: true
72617
+ };
72618
+ }
71571
72619
  const mediaStore = this.mediaStore;
71572
72620
  const eventStore = this.eventStore;
71573
72621
  const trackStore = this.trackStore;
71574
- const empty = {
71575
- ran: false,
72622
+ if (!mediaStore || !eventStore || !trackStore) {
72623
+ this.ctx.logger.warn("reclaimDebugMedia: analytics stores unavailable");
72624
+ return {
72625
+ started: false,
72626
+ alreadyRunning: false
72627
+ };
72628
+ }
72629
+ this.mediaReclaimState = {
72630
+ ...IDLE_MEDIA_RECLAIM,
72631
+ running: true,
71576
72632
  mode: input.mode,
71577
- devices: [],
71578
- totalExamined: 0,
71579
- totalEligible: 0,
71580
- totalReclaimed: 0,
71581
- totalBytesReclaimed: 0,
71582
- totalRefused: 0,
71583
- durationMs: 0,
71584
- complete: false
72633
+ startedAtMs: Date.now()
72634
+ };
72635
+ this.ctx.logger.info("debug-media reclaim starting", { meta: {
72636
+ mode: input.mode,
72637
+ restart: input.restart === true
72638
+ } });
72639
+ this.executeMediaReclaim(input, mediaStore, eventStore, trackStore);
72640
+ return {
72641
+ started: true,
72642
+ alreadyRunning: false
71585
72643
  };
71586
- if (!mediaStore || !eventStore || !trackStore) return empty;
71587
- if (this.mediaReclaimRunning) return empty;
71588
- this.mediaReclaimRunning = true;
72644
+ }
72645
+ /**
72646
+ * The walk itself. Called DETACHED from {@link reclaimDebugMedia}; never
72647
+ * await this from an RPC.
72648
+ */
72649
+ async executeMediaReclaim(input, mediaStore, eventStore, trackStore) {
71589
72650
  try {
71590
72651
  const limits = {
71591
72652
  mode: input.mode,
@@ -71603,6 +72664,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71603
72664
  scopes,
71604
72665
  limits,
71605
72666
  cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
72667
+ onDevice: (device) => {
72668
+ const s = this.mediaReclaimState;
72669
+ if (!s.running) return;
72670
+ this.mediaReclaimState = {
72671
+ ...s,
72672
+ totalExamined: s.totalExamined + device.examined,
72673
+ totalEligible: s.totalEligible + device.eligible,
72674
+ totalReclaimed: s.totalReclaimed + device.reclaimed,
72675
+ totalBytesReclaimed: s.totalBytesReclaimed + device.bytesReclaimed,
72676
+ totalRefused: s.totalRefused + device.refused,
72677
+ devicesDone: s.devicesDone + 1
72678
+ };
72679
+ },
71606
72680
  deps: {
71607
72681
  page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
71608
72682
  deviceId,
@@ -71629,15 +72703,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
71629
72703
  bytesReclaimed: report.totalBytesReclaimed,
71630
72704
  detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
71631
72705
  });
71632
- return {
71633
- ran: true,
71634
- ...report,
71635
- devices: [...report.devices]
72706
+ this.mediaReclaimState = {
72707
+ ...this.mediaReclaimState,
72708
+ running: false,
72709
+ totalExamined: report.totalExamined,
72710
+ totalEligible: report.totalEligible,
72711
+ totalReclaimed: report.totalReclaimed,
72712
+ totalBytesReclaimed: report.totalBytesReclaimed,
72713
+ totalRefused: report.totalRefused,
72714
+ complete: report.complete,
72715
+ finishedAtMs: Date.now()
71636
72716
  };
71637
- } finally {
71638
- this.mediaReclaimRunning = false;
72717
+ this.ctx.logger.info("debug-media reclaim finished", { meta: {
72718
+ mode: report.mode,
72719
+ examined: report.totalExamined,
72720
+ reclaimed: report.totalReclaimed,
72721
+ bytesReclaimed: report.totalBytesReclaimed,
72722
+ complete: report.complete
72723
+ } });
72724
+ } catch (err) {
72725
+ this.mediaReclaimState = {
72726
+ ...this.mediaReclaimState,
72727
+ running: false,
72728
+ finishedAtMs: Date.now(),
72729
+ error: err instanceof Error ? err.message : String(err),
72730
+ complete: false
72731
+ };
72732
+ this.ctx.logger.warn("debug-media reclaim failed", { meta: { error: String(err) } });
71639
72733
  }
71640
72734
  }
72735
+ /** Progress of the detached reclaim — the only way to observe a long pass. */
72736
+ async getMediaReclaimStatus() {
72737
+ return { ...this.mediaReclaimState };
72738
+ }
71641
72739
  /** Progress of the detached rebuild — the only way to observe a long pass. */
71642
72740
  async getObjectEmbeddingRebuildStatus() {
71643
72741
  const state = this.rebuildState;