@camstack/addon-post-analysis 1.2.154 → 1.2.156

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-Ds6x9Agm.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");
@@ -113,6 +113,7 @@ function actionTtlMs(subject) {
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
115
  case "addon-update": return DEFAULT_ACTION_TTL_MS;
116
+ case "server-update": return DEFAULT_ACTION_TTL_MS;
116
117
  }
117
118
  }
118
119
  var NcActionTokenRegistry = class {
@@ -182,9 +183,9 @@ var NcActionTokenRegistry = class {
182
183
  /**
183
184
  * What a spent grant DID, in the operator's own terms.
184
185
  *
185
- * Per `kind`, because the three families answer different questions and one
186
+ * Per `kind`, because the families answer different questions and one
186
187
  * generic "Done" would leave a snooze indistinguishable from an open gate on
187
- * 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
188
189
  * grant family will not compile until it has a sentence here.
189
190
  */
190
191
  function describeGrant(grant) {
@@ -193,6 +194,7 @@ function describeGrant(grant) {
193
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(", ")}`;
194
195
  case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
195
196
  case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
197
+ case "server-update": return `Server update on ${grant.nodeId}`;
196
198
  }
197
199
  }
198
200
  /** The audit line's `meta`, per family. A snooze that logged a `sequence` it
@@ -228,6 +230,10 @@ function grantMeta(grant) {
228
230
  nodeId: grant.nodeId,
229
231
  packages: grant.packages
230
232
  };
233
+ case "server-update": return {
234
+ ...common,
235
+ nodeId: grant.nodeId
236
+ };
231
237
  }
232
238
  }
233
239
  /** What the human standing in front of the page is told. */
@@ -5515,6 +5521,37 @@ function buildAddonUpdateButtons(input) {
5515
5521
  }];
5516
5522
  }
5517
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
5518
5555
  //#region src/notification-center/audio-rule-matcher.ts
5519
5556
  /**
5520
5557
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8596,6 +8633,13 @@ var en_default = {
8596
8633
  "action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
8597
8634
  "action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8598
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.",
8599
8643
  "summary.title": "Summary — {{rule}}",
8600
8644
  "summary.body.window": "{{from}}→{{to}}",
8601
8645
  "summary.body.events": {
@@ -8834,6 +8878,13 @@ var it_default = {
8834
8878
  "action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
8835
8879
  "action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8836
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.",
8837
8888
  "summary.title": "Riepilogo — {{rule}}",
8838
8889
  "summary.body.window": "{{from}}→{{to}}",
8839
8890
  "summary.body.events": {
@@ -20389,6 +20440,41 @@ var NotificationCenter = class NotificationCenter {
20389
20440
  packages: systemEvent?.packageNames ?? []
20390
20441
  }
20391
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
+ });
20392
20478
  if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
20393
20479
  tags: { deviceId: input.deviceId },
20394
20480
  meta: {
@@ -20400,7 +20486,8 @@ var NotificationCenter = class NotificationCenter {
20400
20486
  ...sequenceButtons,
20401
20487
  ...snoozeButtons,
20402
20488
  ...alarmButtons,
20403
- ...updateButtons
20489
+ ...updateButtons,
20490
+ ...serverButtons
20404
20491
  ];
20405
20492
  if (out.length === 0) return [];
20406
20493
  const now = this.now();
@@ -20522,6 +20609,7 @@ var NotificationCenter = class NotificationCenter {
20522
20609
  case "alarm": return this.applyGrantedAlarm(grant);
20523
20610
  case "sequence": return this.runGrantedSequence(grant);
20524
20611
  case "addon-update": return this.applyGrantedAddonUpdate(grant);
20612
+ case "server-update": return this.applyGrantedServerUpdate(grant);
20525
20613
  }
20526
20614
  }
20527
20615
  /**
@@ -20632,6 +20720,80 @@ var NotificationCenter = class NotificationCenter {
20632
20720
  if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
20633
20721
  }
20634
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
+ /**
20635
20797
  * One sentence about an update tap, to the target the notification went to.
20636
20798
  *
20637
20799
  * Best-effort by construction — `deliverAnnouncement` never throws and never
@@ -26095,7 +26257,7 @@ var DEFAULT_MEDIA_LOCATION = "eventMedia";
26095
26257
  * whose contract is disposal.** The gallery's row lives on the data volume;
26096
26258
  * its blob belongs there too, and now does. See D197.
26097
26259
  */
26098
- var GALLERY_MEDIA_LOCATION = "galleryMedia";
26260
+ var GALLERY_MEDIA_LOCATION = "addon-data";
26099
26261
  /**
26100
26262
  * The location a new blob is written to, derived from its owner kind.
26101
26263
  *
@@ -26992,7 +27154,7 @@ var MediaStore = class {
26992
27154
  for (const row of rows) {
26993
27155
  const data = row.data;
26994
27156
  const from = mediaRowLocation(data);
26995
- if (from === "galleryMedia") continue;
27157
+ if (from === "addon-data") continue;
26996
27158
  const relativePath = String(data["path"] ?? "");
26997
27159
  const deviceId = Number(data["deviceId"]);
26998
27160
  if (relativePath.length === 0) continue;
@@ -35938,6 +36100,7 @@ async function runMediaReclaim(input) {
35938
36100
  byteBudget -= report.bytesReclaimed;
35939
36101
  nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
35940
36102
  devices.push(report);
36103
+ input.onDevice?.(report);
35941
36104
  if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
35942
36105
  tags: { deviceId },
35943
36106
  meta: {
@@ -35977,14 +36140,12 @@ async function runMediaReclaim(input) {
35977
36140
  /**
35978
36141
  * The operator button for the debug-media reclaim.
35979
36142
  *
35980
- * The mechanism ships armed with nothing: **arming it is the operator's
35981
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
35982
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35983
- * 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.
35984
36147
  *
35985
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35986
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35987
- * 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.
35988
36149
  */
35989
36150
  /**
35990
36151
  * `report` counts; `reclaim` deletes.
@@ -36025,40 +36186,28 @@ var MediaReclaimActionInputSchema = require_dist.object({
36025
36186
  */
36026
36187
  graceMinutes: require_dist.number().int().min(1).max(10080).optional()
36027
36188
  });
36028
- var MediaReclaimDeviceReportSchema = require_dist.object({
36029
- deviceId: require_dist.number(),
36030
- scope: MediaReclaimScopeSchema,
36031
- examined: require_dist.number(),
36032
- eligible: require_dist.number(),
36033
- reclaimed: require_dist.number(),
36034
- bytesReclaimed: require_dist.number(),
36035
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
36036
- held: require_dist.number(),
36037
- /** Rows kept because the owner could not be shown to be a motion event. */
36038
- unresolved: require_dist.number(),
36039
- withinGrace: require_dist.number(),
36040
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
36041
- * The row was kept and the cursor did not pass it. */
36042
- refused: require_dist.number(),
36043
- stoppedBy: require_dist.string(),
36044
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
36045
- nextCursor: require_dist.number()
36189
+ var MediaReclaimStartedSchema = require_dist.object({
36190
+ started: require_dist.boolean(),
36191
+ alreadyRunning: require_dist.boolean()
36046
36192
  });
36047
- var MediaReclaimActionResultSchema = require_dist.object({
36048
- /** `false` when a pass was already in flight — nothing was done. */
36049
- ran: require_dist.boolean(),
36050
- mode: require_dist._enum(["report", "reclaim"]),
36051
- devices: require_dist.array(MediaReclaimDeviceReportSchema),
36193
+ var MediaReclaimStatusSchema = require_dist.object({
36194
+ running: require_dist.boolean(),
36195
+ mode: require_dist._enum(["report", "reclaim"]).nullable(),
36052
36196
  totalExamined: require_dist.number(),
36053
36197
  totalEligible: require_dist.number(),
36054
36198
  totalReclaimed: require_dist.number(),
36055
36199
  totalBytesReclaimed: require_dist.number(),
36056
36200
  totalRefused: require_dist.number(),
36057
- durationMs: require_dist.number(),
36058
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
36059
- 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" })
36060
36210
  });
36061
- var mediaReclaimActions = require_dist.defineCustomActions({ "retention.mediaReclaim": require_dist.customAction(MediaReclaimActionInputSchema, MediaReclaimActionResultSchema, { auth: "admin" }) });
36062
36211
  //#endregion
36063
36212
  //#region src/pipeline-analytics/retention/orphan-audit-actions.ts
36064
36213
  /**
@@ -41151,6 +41300,7 @@ function createLocationAwareMediaStorage(deps) {
41151
41300
  const roots = /* @__PURE__ */ new Map();
41152
41301
  let defaultBypassInvalidated = false;
41153
41302
  const rootOf = async (locationId) => {
41303
+ if (deps.galleryLocationId !== void 0 && deps.galleryRoot !== void 0 && locationId === deps.galleryLocationId) return deps.galleryRoot;
41154
41304
  const cached = roots.get(locationId);
41155
41305
  if (cached !== void 0) return cached;
41156
41306
  const root = await deps.resolveRoot(locationId);
@@ -43657,7 +43807,7 @@ var PhotoAnalysisSessions = class {
43657
43807
  * a magnet that recognises every degraded probe as its owner, and 22 of 50
43658
43808
  * live samples were enrolled that way on 2026-08-18;
43659
43809
  * 3. both blobs are written under `ownerKind: 'identity'`, which is what puts
43660
- * 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);
43661
43811
  * 4. the row is written last, and if it does not land BOTH blobs are reclaimed.
43662
43812
  * An orphan under `identity` is exempt from every sweep this repo has, so
43663
43813
  * it would otherwise live forever.
@@ -64177,6 +64327,20 @@ var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
64177
64327
  * window rather than a random slice of it.
64178
64328
  */
64179
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
+ };
64180
64344
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
64181
64345
  /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
64182
64346
  * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
@@ -65292,9 +65456,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65292
65456
  * In RAM like the audit's: the pass is resumable from zero at no cost
65293
65457
  * beyond re-walking a head that is already empty. */
65294
65458
  mediaReclaimCursors = /* @__PURE__ */ new Map();
65295
- /** Single-flight latch — two concurrent passes would double the disk
65296
- * pressure the pacing exists to avoid. */
65297
- mediaReclaimRunning = false;
65459
+ /** Live state of the detached debug-media reclaim — see `reclaimDebugMedia`. */
65460
+ mediaReclaimState = { ...IDLE_MEDIA_RECLAIM };
65298
65461
  /**
65299
65462
  * The vector-index leg's resume cursor, kept in its OWN field.
65300
65463
  *
@@ -65734,6 +65897,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65734
65897
  const routed = createLocationAwareMediaStorage({
65735
65898
  base: storage,
65736
65899
  defaultLocation: "eventMedia",
65900
+ galleryLocationId: GALLERY_MEDIA_LOCATION,
65901
+ galleryRoot: node_path.default.join(this.ctx.dataDir, "gallery"),
65737
65902
  resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
65738
65903
  location: locationId,
65739
65904
  relativePath: ""
@@ -66410,6 +66575,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66410
66575
  };
66411
66576
  }
66412
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
+ },
66413
66598
  readDeviceStates: async (ids) => {
66414
66599
  const out = /* @__PURE__ */ new Map();
66415
66600
  for (const id of ids) try {
@@ -67342,19 +67527,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
67342
67527
  async reconcileGalleryMediaLocation() {
67343
67528
  const mediaStore = this.mediaStore;
67344
67529
  if (!mediaStore) return;
67345
- try {
67346
- await this.ctx.api.storage.resolve.query({
67347
- location: GALLERY_MEDIA_LOCATION,
67348
- relativePath: ""
67349
- });
67350
- } catch (err) {
67351
- 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: {
67352
- location: GALLERY_MEDIA_LOCATION,
67353
- error: require_dist.errMsg(err),
67354
- 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."
67355
- } });
67356
- return;
67357
- }
67358
67530
  try {
67359
67531
  const result = await mediaStore.relocateExemptMediaToGallery();
67360
67532
  if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
@@ -72423,42 +72595,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
72423
72595
  "embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
72424
72596
  "embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
72425
72597
  "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
72426
- "retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
72598
+ "retention.mediaReclaim": async (input) => this.reclaimDebugMedia(input),
72599
+ "retention.mediaReclaimStatus": async () => this.getMediaReclaimStatus()
72427
72600
  };
72428
72601
  }
72429
72602
  /**
72430
- * One operator-driven debug-media reclaim pass — the entry point behind
72431
- * `retention.mediaReclaim`.
72432
- *
72433
- * DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
72434
- * `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
72435
- * from the per-device cursors this method keeps, and a run that is cut short
72436
- * has still only forgotten rows whose blobs it proved gone.
72603
+ * Start a debug-media reclaim pass. DETACHED.
72437
72604
  *
72438
- * Single-flight: a second call while a pass is in flight returns
72439
- * `ran: false` rather than doubling the disk pressure the pacing exists to
72440
- * 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.
72441
72609
  */
72442
- async runMediaReclaimNow(rawInput) {
72610
+ async reclaimDebugMedia(rawInput) {
72443
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
+ }
72444
72619
  const mediaStore = this.mediaStore;
72445
72620
  const eventStore = this.eventStore;
72446
72621
  const trackStore = this.trackStore;
72447
- const empty = {
72448
- 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,
72449
72632
  mode: input.mode,
72450
- devices: [],
72451
- totalExamined: 0,
72452
- totalEligible: 0,
72453
- totalReclaimed: 0,
72454
- totalBytesReclaimed: 0,
72455
- totalRefused: 0,
72456
- durationMs: 0,
72457
- complete: false
72633
+ startedAtMs: Date.now()
72458
72634
  };
72459
- if (!mediaStore || !eventStore || !trackStore) return empty;
72460
- if (this.mediaReclaimRunning) return empty;
72461
- this.mediaReclaimRunning = true;
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
72643
+ };
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) {
72462
72650
  try {
72463
72651
  const limits = {
72464
72652
  mode: input.mode,
@@ -72476,6 +72664,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
72476
72664
  scopes,
72477
72665
  limits,
72478
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
+ },
72479
72680
  deps: {
72480
72681
  page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
72481
72682
  deviceId,
@@ -72502,15 +72703,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
72502
72703
  bytesReclaimed: report.totalBytesReclaimed,
72503
72704
  detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
72504
72705
  });
72505
- return {
72506
- ran: true,
72507
- ...report,
72508
- 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()
72509
72716
  };
72510
- } finally {
72511
- 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) } });
72512
72733
  }
72513
72734
  }
72735
+ /** Progress of the detached reclaim — the only way to observe a long pass. */
72736
+ async getMediaReclaimStatus() {
72737
+ return { ...this.mediaReclaimState };
72738
+ }
72514
72739
  /** Progress of the detached rebuild — the only way to observe a long pass. */
72515
72740
  async getObjectEmbeddingRebuildStatus() {
72516
72741
  const state = this.rebuildState;