@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.
@@ -1,4 +1,4 @@
1
- import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-D6iC--Gh.mjs";
1
+ import { $ as audioMetricsCapability, A as NcSnoozeInputSchema, At as errMsg, B as SCENE_DEFAULT_ANCHOR_THRESHOLD, Bt as boolean, C as NC_TAXONOMY, D as NcRuleSchema, Dt as vectorDimFromBase64, E as NcRulePatchSchema, Et as systemEventFilterApplies, F as OpsLogEntrySchema, Ft as isDeviceScopedCap, G as TimelapseRuleInputSchema, Gt as partialRecord, H as SCENE_DIVERGED, Ht as literal, It as nodePin, J as TrackSourceSchema, Jt as unknown, K as TimelapseRulePatchSchema, Kt as record, L as RECORDING_EXPORT_MAX_READ_BYTES, Lt as sleep, M as NcSnoozeSuppressedSchema, Mt as DeviceType, N as NcSystemEventKindSchema, Nt as createEvent, O as NcRuleTargetSchema, Ot as videoclipsCapability, P as NcTaxonomySchema, Pt as hydrateSchema, Q as assertTimelapseCadences, R as RetrainStatusSchema, Rt as _enum, St as readTimelapseGeneratedAt, T as NcRuleInputSchema, Tt as subKindsOf, U as SceneMonitorSchema, Ut as number, V as SCENE_DEFAULT_UNCOVERED_POLICY, Vt as discriminatedUnion, W as TIMELAPSE_DENSE_FLOOR_SEC, Wt as object, X as addonWidgetsSourceCapability, Y as VISIT_MERGE_GAP_MS, Yt as EventCategory, Z as alarmPanelCapability, _ as MACRO_LABELS, a as COCO_TO_MACRO, at as deriveRecordingMode, b as NC_CONDITION_CATALOG, bt as plateGalleryCapability, ct as evaluateSensorEdge, d as DeclaredDevices, et as audioModeOf, f as EVENT_KIND_BY_CAP, ft as isDetectionMacroClass, g as LabelAttributionSchema, gt as notificationRulesCapability, h as FailureCounters, ht as kebabToCamel, i as CLUSTER_MODEL_SCOPED_STEPS, it as defineCustomActions, j as NcSnoozeSchema, jt as BaseAddon, k as NcScheduleSchema, kt as zoneAnalyticsCapability, l as DETECTION_MACRO_CLASSES, lt as faceGalleryCapability, m as FULL_IMAGE_BBOX, mt as isSourceCap, n as AnalyticsGroupRecordSchema, nt as cosineSimilarity$1, o as DEFAULT_EVENT_COLOR, p as EVENT_PAD_MS, pt as isScheduleActive, q as TimelapseRuleSchema, qt as string, r as BaseDevice, rt as customAction, st as encodeVectorBase64, t as AnalyticsGroupDetailSchema, tt as buildEventKindDescriptor, ut as failureContributionCapability, v as MediaFileKindEnum, vt as pickClusterStepModels, w as NcConditionDescriptorSchema, wt as sceneMonitorCapability, x as NC_DEFAULT_SNOOZE_MINUTES, xt as readDeviceStateFrom, y as NC_ALARM_SYSTEM_EVENT_KINDS, yt as pipelineAnalyticsCapability, zt as array } from "../dist-DqNo8CCq.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -107,6 +107,7 @@ function actionTtlMs(subject) {
107
107
  case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
108
108
  case "sequence": return DEFAULT_ACTION_TTL_MS;
109
109
  case "addon-update": return DEFAULT_ACTION_TTL_MS;
110
+ case "server-update": return DEFAULT_ACTION_TTL_MS;
110
111
  }
111
112
  }
112
113
  var NcActionTokenRegistry = class {
@@ -176,9 +177,9 @@ var NcActionTokenRegistry = class {
176
177
  /**
177
178
  * What a spent grant DID, in the operator's own terms.
178
179
  *
179
- * Per `kind`, because the three families answer different questions and one
180
+ * Per `kind`, because the families answer different questions and one
180
181
  * generic "Done" would leave a snooze indistinguishable from an open gate on
181
- * the page a tap lands on. The switch is exhaustive by construction — a fourth
182
+ * the page a tap lands on. The switch is exhaustive by construction — a new
182
183
  * grant family will not compile until it has a sentence here.
183
184
  */
184
185
  function describeGrant(grant) {
@@ -187,6 +188,7 @@ function describeGrant(grant) {
187
188
  case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
188
189
  case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
189
190
  case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
191
+ case "server-update": return `Server update on ${grant.nodeId}`;
190
192
  }
191
193
  }
192
194
  /** The audit line's `meta`, per family. A snooze that logged a `sequence` it
@@ -222,6 +224,10 @@ function grantMeta(grant) {
222
224
  nodeId: grant.nodeId,
223
225
  packages: grant.packages
224
226
  };
227
+ case "server-update": return {
228
+ ...common,
229
+ nodeId: grant.nodeId
230
+ };
225
231
  }
226
232
  }
227
233
  /** What the human standing in front of the page is told. */
@@ -5508,6 +5514,37 @@ function buildAddonUpdateButtons(input) {
5508
5514
  }];
5509
5515
  }
5510
5516
  //#endregion
5517
+ //#region src/notification-center/server-update-buttons.ts
5518
+ /** The id this button travels under — in the callback and in the audit line. */
5519
+ var SERVER_UPDATE_BUTTON_ID = "server-update-apply";
5520
+ function serverUpdateButtonRefusal(subject, recipient) {
5521
+ if (subject.kind !== "server-update-available") return "not-a-server-update";
5522
+ if (subject.nodeId === void 0) return "several-nodes";
5523
+ if (recipient.ownerUserId === void 0) return "unowned-target";
5524
+ return null;
5525
+ }
5526
+ /**
5527
+ * The button, or none.
5528
+ *
5529
+ * Returns an array rather than a nullable, so the caller composes it with the
5530
+ * other families by spreading — and so "which buttons survived" has one shape.
5531
+ */
5532
+ function buildServerUpdateButtons(input) {
5533
+ const subject = input.subject;
5534
+ if (serverUpdateButtonRefusal(subject, input.recipient) !== null) return [];
5535
+ const nodeId = subject.nodeId ?? "";
5536
+ return [{
5537
+ id: SERVER_UPDATE_BUTTON_ID,
5538
+ label: input.label({ nodeId }),
5539
+ url: input.mintUrl({
5540
+ actionId: SERVER_UPDATE_BUTTON_ID,
5541
+ nodeId
5542
+ }),
5543
+ destructive: true,
5544
+ mode: "background"
5545
+ }];
5546
+ }
5547
+ //#endregion
5511
5548
  //#region src/notification-center/audio-rule-matcher.ts
5512
5549
  /**
5513
5550
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8589,6 +8626,13 @@ var en_default = {
8589
8626
  "action.addon-update.partial": "{{nodeId}}: {{applied}} updated, {{skipped}} already current, {{failed}} failed",
8590
8627
  "action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8591
8628
  "action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
8629
+ "button.server-update.apply": "Update server on {{nodeId}}",
8630
+ "action.server-update.title": "Server update",
8631
+ "action.server-update.started": "Updating server on {{nodeId}}…",
8632
+ "action.server-update.restarting": "Server update staged on {{nodeId}} — the node is restarting. You will hear when it comes back.",
8633
+ "action.server-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
8634
+ "action.server-update.unknown": "Could not read whether {{nodeId}} still needs a server update — nothing was changed. Open CamStack.",
8635
+ "action.server-update.failed": "Server update on {{nodeId}} was refused — nothing was changed. Open CamStack.",
8592
8636
  "summary.title": "Summary — {{rule}}",
8593
8637
  "summary.body.window": "{{from}}→{{to}}",
8594
8638
  "summary.body.events": {
@@ -8827,6 +8871,13 @@ var it_default = {
8827
8871
  "action.addon-update.partial": "{{nodeId}}: {{applied}} aggiornati, {{skipped}} già aggiornati, {{failed}} falliti",
8828
8872
  "action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8829
8873
  "action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
8874
+ "button.server-update.apply": "Aggiorna server su {{nodeId}}",
8875
+ "action.server-update.title": "Aggiornamento server",
8876
+ "action.server-update.started": "Aggiornamento server su {{nodeId}}…",
8877
+ "action.server-update.restarting": "Aggiornamento server predisposto su {{nodeId}} — il nodo si riavvia. Ti avviseremo quando torna.",
8878
+ "action.server-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
8879
+ "action.server-update.unknown": "Non è stato possibile leggere se {{nodeId}} ha ancora bisogno di un aggiornamento server — non è stato cambiato nulla. Apri CamStack.",
8880
+ "action.server-update.failed": "L'aggiornamento server su {{nodeId}} è stato rifiutato — non è stato cambiato nulla. Apri CamStack.",
8830
8881
  "summary.title": "Riepilogo — {{rule}}",
8831
8882
  "summary.body.window": "{{from}}→{{to}}",
8832
8883
  "summary.body.events": {
@@ -20367,6 +20418,41 @@ var NotificationCenter = class NotificationCenter {
20367
20418
  packages: systemEvent?.packageNames ?? []
20368
20419
  }
20369
20420
  });
20421
+ const serverRefusal = systemEvent === void 0 ? "not-a-server-update" : serverUpdateButtonRefusal(systemEvent, recipient);
20422
+ const serverButtons = systemEvent === void 0 ? [] : buildServerUpdateButtons({
20423
+ subject: systemEvent,
20424
+ recipient,
20425
+ label: ({ nodeId }) => this.texts.text({
20426
+ key: "button.server-update.apply",
20427
+ vars: { nodeId }
20428
+ }),
20429
+ mintUrl: ({ actionId, nodeId }) => {
20430
+ const id = randomUUID();
20431
+ drafts.push({
20432
+ id,
20433
+ ttlMs: actionTtlMs({ kind: "server-update" }),
20434
+ grant: (expiresAt) => ({
20435
+ kind: "server-update",
20436
+ id,
20437
+ nodeId,
20438
+ deviceId: input.deviceId,
20439
+ actionId,
20440
+ targetId: input.targetId,
20441
+ expiresAt
20442
+ })
20443
+ });
20444
+ return id;
20445
+ }
20446
+ });
20447
+ if (serverRefusal === "several-nodes" || serverRefusal === "unowned-target") this.logger.info("server-update notification carries no apply button", {
20448
+ tags: { deviceId: input.deviceId },
20449
+ meta: {
20450
+ ruleId: input.ruleId,
20451
+ reason: serverRefusal,
20452
+ targetId: input.targetId,
20453
+ nodeIds: systemEvent?.nodeIds ?? []
20454
+ }
20455
+ });
20370
20456
  if (alarmKind !== null && panel === null) this.logger.info("alarm notification carries no buttons — this node owns no panel", {
20371
20457
  tags: { deviceId: input.deviceId },
20372
20458
  meta: {
@@ -20378,7 +20464,8 @@ var NotificationCenter = class NotificationCenter {
20378
20464
  ...sequenceButtons,
20379
20465
  ...snoozeButtons,
20380
20466
  ...alarmButtons,
20381
- ...updateButtons
20467
+ ...updateButtons,
20468
+ ...serverButtons
20382
20469
  ];
20383
20470
  if (out.length === 0) return [];
20384
20471
  const now = this.now();
@@ -20500,6 +20587,7 @@ var NotificationCenter = class NotificationCenter {
20500
20587
  case "alarm": return this.applyGrantedAlarm(grant);
20501
20588
  case "sequence": return this.runGrantedSequence(grant);
20502
20589
  case "addon-update": return this.applyGrantedAddonUpdate(grant);
20590
+ case "server-update": return this.applyGrantedServerUpdate(grant);
20503
20591
  }
20504
20592
  }
20505
20593
  /**
@@ -20610,6 +20698,80 @@ var NotificationCenter = class NotificationCenter {
20610
20698
  if (applied.length === 0 && failed.length > 0) throw new Error(`no package updated on ${grant.nodeId} (${failed.join(", ")})`);
20611
20699
  }
20612
20700
  /**
20701
+ * Stage the server update one notification was about.
20702
+ *
20703
+ * The shape matches the addon-update runner, with one extra constraint: the
20704
+ * **start announcement leaves BEFORE apply**. Applying the hub kills this
20705
+ * process; a tap that went into silence because the process died mid-apply
20706
+ * is worse than no button. The reboot itself is confirmed by the existing
20707
+ * `server-updated` event on reconnect — this method does not wait for it.
20708
+ *
20709
+ * Throws when nothing was staged and something went wrong, so the plane's
20710
+ * line says the run failed rather than reporting a spent token as a success.
20711
+ */
20712
+ async applyGrantedServerUpdate(grant) {
20713
+ const apply = this.deps.applyServerUpdate;
20714
+ const check = this.deps.checkServerUpdate;
20715
+ if (apply === void 0 || check === void 0) throw new Error("no server updater is wired on this node");
20716
+ let stillAvailable;
20717
+ try {
20718
+ stillAvailable = await check(grant.nodeId);
20719
+ } catch (err) {
20720
+ this.logger.warn("server update button changed nothing — could not read whether an update is still needed", { meta: {
20721
+ nodeId: grant.nodeId,
20722
+ error: String(err)
20723
+ } });
20724
+ await this.announceServerUpdate(grant, "unknown", { nodeId: grant.nodeId });
20725
+ throw err instanceof Error ? err : new Error(String(err));
20726
+ }
20727
+ if (!stillAvailable) {
20728
+ this.logger.info("server update button applied nothing — already current", { meta: { nodeId: grant.nodeId } });
20729
+ await this.announceServerUpdate(grant, "uptodate", { nodeId: grant.nodeId });
20730
+ return;
20731
+ }
20732
+ await this.announceServerUpdate(grant, "started", { nodeId: grant.nodeId });
20733
+ let result;
20734
+ try {
20735
+ result = await apply({ nodeId: grant.nodeId });
20736
+ } catch (err) {
20737
+ this.logger.warn("server update from a notification button threw", { meta: {
20738
+ nodeId: grant.nodeId,
20739
+ error: String(err)
20740
+ } });
20741
+ await this.announceServerUpdate(grant, "failed", { nodeId: grant.nodeId });
20742
+ throw err instanceof Error ? err : new Error(String(err));
20743
+ }
20744
+ if (!result.ok) {
20745
+ this.logger.warn("server update from a notification button was refused", { meta: {
20746
+ nodeId: grant.nodeId,
20747
+ error: result.error
20748
+ } });
20749
+ await this.announceServerUpdate(grant, "failed", { nodeId: grant.nodeId });
20750
+ throw new Error(result.error);
20751
+ }
20752
+ this.logger.info("server update staged from a notification button", { meta: { nodeId: grant.nodeId } });
20753
+ await this.announceServerUpdate(grant, "restarting", { nodeId: grant.nodeId });
20754
+ }
20755
+ /**
20756
+ * One sentence about a server-update tap, to the target the notification
20757
+ * went to. Best-effort — `deliverAnnouncement` never throws — because an
20758
+ * announcement that failed must not turn a staging that HAPPENED into a run
20759
+ * the plane reports as failed.
20760
+ */
20761
+ async announceServerUpdate(grant, sentence, vars) {
20762
+ const targetId = grant.targetId;
20763
+ if (targetId === void 0) return;
20764
+ await this.dispatcher.deliverAnnouncement({
20765
+ reason: `server-update-${sentence}`,
20766
+ targetId,
20767
+ title: this.texts.text({ key: "action.server-update.title" }),
20768
+ body: this.texts.text({
20769
+ key: `action.server-update.${sentence}`,
20770
+ vars
20771
+ })
20772
+ });
20773
+ }
20774
+ /**
20613
20775
  * One sentence about an update tap, to the target the notification went to.
20614
20776
  *
20615
20777
  * Best-effort by construction — `deliverAnnouncement` never throws and never
@@ -26073,7 +26235,7 @@ var DEFAULT_MEDIA_LOCATION = "eventMedia";
26073
26235
  * whose contract is disposal.** The gallery's row lives on the data volume;
26074
26236
  * its blob belongs there too, and now does. See D197.
26075
26237
  */
26076
- var GALLERY_MEDIA_LOCATION = "galleryMedia";
26238
+ var GALLERY_MEDIA_LOCATION = "addon-data";
26077
26239
  /**
26078
26240
  * The location a new blob is written to, derived from its owner kind.
26079
26241
  *
@@ -26970,7 +27132,7 @@ var MediaStore = class {
26970
27132
  for (const row of rows) {
26971
27133
  const data = row.data;
26972
27134
  const from = mediaRowLocation(data);
26973
- if (from === "galleryMedia") continue;
27135
+ if (from === "addon-data") continue;
26974
27136
  const relativePath = String(data["path"] ?? "");
26975
27137
  const deviceId = Number(data["deviceId"]);
26976
27138
  if (relativePath.length === 0) continue;
@@ -35913,6 +36075,7 @@ async function runMediaReclaim(input) {
35913
36075
  byteBudget -= report.bytesReclaimed;
35914
36076
  nextCursors.set(key, report.stoppedBy === "exhausted" ? 0 : report.nextCursor);
35915
36077
  devices.push(report);
36078
+ input.onDevice?.(report);
35916
36079
  if (report.examined > 0 || report.refused > 0) input.deps.logger.info("media reclaim window", {
35917
36080
  tags: { deviceId },
35918
36081
  meta: {
@@ -35947,33 +36110,10 @@ async function runMediaReclaim(input) {
35947
36110
  cursors: nextCursors
35948
36111
  };
35949
36112
  }
35950
- //#endregion
35951
- //#region src/pipeline-analytics/retention/media-reclaim-actions.ts
35952
- /**
35953
- * The operator button for the debug-media reclaim.
35954
- *
35955
- * The mechanism ships armed with nothing: **arming it is the operator's
35956
- * call**. `mode` omitted means `report` — it counts, logs one line per camera
35957
- * and returns rows/bytes per device, and deletes nothing. `reclaim` is a word
35958
- * that has to be typed.
35959
- *
35960
- * Everything about the shape is borrowed from `orphan-audit-actions.ts`, which
35961
- * earned it: dry-run default, explicit pacing, explicit blast radius, and a
35962
- * report you can read instead of a log line you have to find.
35963
- */
35964
- /**
35965
- * `report` counts; `reclaim` deletes.
35966
- *
35967
- * `.default('report')` is load-bearing: the settings form sends `{}` for a
35968
- * button with no fields, and the safe reading of an unstated intent is the one
35969
- * that destroys nothing.
35970
- */
35971
- var MediaReclaimActionModeSchema = _enum(["report", "reclaim"]).default("report");
35972
- var MediaReclaimScopeSchema = _enum(["motion-still", "track-filmstrip"]);
35973
36113
  var MediaReclaimActionInputSchema = object({
35974
- mode: MediaReclaimActionModeSchema,
36114
+ mode: _enum(["report", "reclaim"]).default("report"),
35975
36115
  /** Which populations to walk. Omitted = both. */
35976
- scopes: array(MediaReclaimScopeSchema).min(1).optional(),
36116
+ scopes: array(_enum(["motion-still", "track-filmstrip"])).min(1).optional(),
35977
36117
  /** Restrict to these cameras. Omitted = every camera in retention scope. */
35978
36118
  deviceIds: array(number().int()).min(1).optional(),
35979
36119
  /** Start every device from the top instead of resuming its cursor. */
@@ -36000,39 +36140,28 @@ var MediaReclaimActionInputSchema = object({
36000
36140
  */
36001
36141
  graceMinutes: number().int().min(1).max(10080).optional()
36002
36142
  });
36003
- var MediaReclaimDeviceReportSchema = object({
36004
- deviceId: number(),
36005
- scope: MediaReclaimScopeSchema,
36006
- examined: number(),
36007
- eligible: number(),
36008
- reclaimed: number(),
36009
- bytesReclaimed: number(),
36010
- /** Rows kept because the owning track is pinned (`staging`/`favourited`). */
36011
- held: number(),
36012
- /** Rows kept because the owner could not be shown to be a motion event. */
36013
- unresolved: number(),
36014
- withinGrace: number(),
36015
- /** Deletes that REFUSED — the blob survived or the volume could not answer.
36016
- * The row was kept and the cursor did not pass it. */
36017
- refused: number(),
36018
- stoppedBy: string(),
36019
- /** Where the NEXT pass resumes. `0` = this device+scope was walked to the end. */
36020
- nextCursor: number()
36143
+ var MediaReclaimStartedSchema = object({
36144
+ started: boolean(),
36145
+ alreadyRunning: boolean()
36021
36146
  });
36022
- var mediaReclaimActions = defineCustomActions({ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, object({
36023
- /** `false` when a pass was already in flight — nothing was done. */
36024
- ran: boolean(),
36025
- mode: _enum(["report", "reclaim"]),
36026
- devices: array(MediaReclaimDeviceReportSchema),
36147
+ var MediaReclaimStatusSchema = object({
36148
+ running: boolean(),
36149
+ mode: _enum(["report", "reclaim"]).nullable(),
36027
36150
  totalExamined: number(),
36028
36151
  totalEligible: number(),
36029
36152
  totalReclaimed: number(),
36030
36153
  totalBytesReclaimed: number(),
36031
36154
  totalRefused: number(),
36032
- durationMs: number(),
36033
- /** Every device+scope reported `exhausted` — there is nothing left to do. */
36034
- complete: boolean()
36035
- }), { auth: "admin" }) });
36155
+ devicesDone: number(),
36156
+ complete: boolean().nullable(),
36157
+ startedAtMs: number().nullable(),
36158
+ finishedAtMs: number().nullable(),
36159
+ error: string().nullable()
36160
+ });
36161
+ var mediaReclaimActions = defineCustomActions({
36162
+ "retention.mediaReclaim": customAction(MediaReclaimActionInputSchema, MediaReclaimStartedSchema, { auth: "admin" }),
36163
+ "retention.mediaReclaimStatus": customAction(object({}), MediaReclaimStatusSchema, { auth: "admin" })
36164
+ });
36036
36165
  var OrphanAuditActionInputSchema = object({
36037
36166
  mode: _enum(["report", "reclaim"]).default("report"),
36038
36167
  /**
@@ -41087,6 +41216,7 @@ function createLocationAwareMediaStorage(deps) {
41087
41216
  const roots = /* @__PURE__ */ new Map();
41088
41217
  let defaultBypassInvalidated = false;
41089
41218
  const rootOf = async (locationId) => {
41219
+ if (deps.galleryLocationId !== void 0 && deps.galleryRoot !== void 0 && locationId === deps.galleryLocationId) return deps.galleryRoot;
41090
41220
  const cached = roots.get(locationId);
41091
41221
  if (cached !== void 0) return cached;
41092
41222
  const root = await deps.resolveRoot(locationId);
@@ -43593,7 +43723,7 @@ var PhotoAnalysisSessions = class {
43593
43723
  * a magnet that recognises every degraded probe as its owner, and 22 of 50
43594
43724
  * live samples were enrolled that way on 2026-08-18;
43595
43725
  * 3. both blobs are written under `ownerKind: 'identity'`, which is what puts
43596
- * them on `galleryMedia` — the location a footage wipe cannot reach (D197);
43726
+ * them in the addon data dir — the location a footage wipe cannot reach (D197);
43597
43727
  * 4. the row is written last, and if it does not land BOTH blobs are reclaimed.
43598
43728
  * An orphan under `identity` is exempt from every sweep this repo has, so
43599
43729
  * it would otherwise live forever.
@@ -64112,6 +64242,20 @@ var TIMELAPSE_TRACK_QUERY_LIMIT = 2e3;
64112
64242
  * window rather than a random slice of it.
64113
64243
  */
64114
64244
  var TIMELAPSE_EVENT_QUERY_LIMIT = 1e4;
64245
+ var IDLE_MEDIA_RECLAIM = {
64246
+ running: false,
64247
+ mode: null,
64248
+ totalExamined: 0,
64249
+ totalEligible: 0,
64250
+ totalReclaimed: 0,
64251
+ totalBytesReclaimed: 0,
64252
+ totalRefused: 0,
64253
+ devicesDone: 0,
64254
+ complete: null,
64255
+ startedAtMs: null,
64256
+ finishedAtMs: null,
64257
+ error: null
64258
+ };
64115
64259
  var PIPELINE_ANALYTICS_ADDON_ID = "pipeline-analytics";
64116
64260
  /** Artefact index ⇄ shelf reconcile cadence. Hourly: the two only drift on the
64117
64261
  * bounds' own timescale (14 days / 2000 entries / 2 GiB). */
@@ -65227,9 +65371,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65227
65371
  * In RAM like the audit's: the pass is resumable from zero at no cost
65228
65372
  * beyond re-walking a head that is already empty. */
65229
65373
  mediaReclaimCursors = /* @__PURE__ */ new Map();
65230
- /** Single-flight latch — two concurrent passes would double the disk
65231
- * pressure the pacing exists to avoid. */
65232
- mediaReclaimRunning = false;
65374
+ /** Live state of the detached debug-media reclaim — see `reclaimDebugMedia`. */
65375
+ mediaReclaimState = { ...IDLE_MEDIA_RECLAIM };
65233
65376
  /**
65234
65377
  * The vector-index leg's resume cursor, kept in its OWN field.
65235
65378
  *
@@ -65669,6 +65812,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
65669
65812
  const routed = createLocationAwareMediaStorage({
65670
65813
  base: storage,
65671
65814
  defaultLocation: "eventMedia",
65815
+ galleryLocationId: GALLERY_MEDIA_LOCATION,
65816
+ galleryRoot: path.join(this.ctx.dataDir, "gallery"),
65672
65817
  resolveRoot: (locationId) => this.ctx.api.storage.resolve.query({
65673
65818
  location: locationId,
65674
65819
  relativePath: ""
@@ -66345,6 +66490,26 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
66345
66490
  };
66346
66491
  }
66347
66492
  },
66493
+ checkServerUpdate: async (nodeId) => {
66494
+ const result = await this.ctx.api.serverManagement.checkServerUpdate.mutate({ nodeId });
66495
+ if (result.error) throw new Error(result.error);
66496
+ return result.updateAvailable;
66497
+ },
66498
+ applyServerUpdate: async ({ nodeId }) => {
66499
+ try {
66500
+ const result = await this.ctx.api.serverManagement.applyServerUpdate.mutate({ nodeId });
66501
+ if (!result.accepted) return {
66502
+ ok: false,
66503
+ error: result.message
66504
+ };
66505
+ return { ok: true };
66506
+ } catch (err) {
66507
+ return {
66508
+ ok: false,
66509
+ error: errMsg(err)
66510
+ };
66511
+ }
66512
+ },
66348
66513
  readDeviceStates: async (ids) => {
66349
66514
  const out = /* @__PURE__ */ new Map();
66350
66515
  for (const id of ids) try {
@@ -67277,19 +67442,6 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
67277
67442
  async reconcileGalleryMediaLocation() {
67278
67443
  const mediaStore = this.mediaStore;
67279
67444
  if (!mediaStore) return;
67280
- try {
67281
- await this.ctx.api.storage.resolve.query({
67282
- location: GALLERY_MEDIA_LOCATION,
67283
- relativePath: ""
67284
- });
67285
- } catch (err) {
67286
- 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: {
67287
- location: GALLERY_MEDIA_LOCATION,
67288
- error: errMsg(err),
67289
- 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."
67290
- } });
67291
- return;
67292
- }
67293
67445
  try {
67294
67446
  const result = await mediaStore.relocateExemptMediaToGallery();
67295
67447
  if (result.relocated === 0 && result.missing === 0 && result.failed === 0) return;
@@ -72358,42 +72510,58 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72358
72510
  "embedding.reembedFaces": async (input) => this.runFaceReembedPass(input),
72359
72511
  "embedding.reembedFacesStatus": async () => this.getFaceReembedStatus(),
72360
72512
  "retention.orphanAudit": async (input) => this.runOrphanAuditNow(input),
72361
- "retention.mediaReclaim": async (input) => this.runMediaReclaimNow(input)
72513
+ "retention.mediaReclaim": async (input) => this.reclaimDebugMedia(input),
72514
+ "retention.mediaReclaimStatus": async () => this.getMediaReclaimStatus()
72362
72515
  };
72363
72516
  }
72364
72517
  /**
72365
- * One operator-driven debug-media reclaim pass — the entry point behind
72366
- * `retention.mediaReclaim`.
72367
- *
72368
- * DRY RUN BY DEFAULT. Draining the standing 111 GB means running this with
72369
- * `mode: 'reclaim'` repeatedly until `complete` is true; each run resumes
72370
- * from the per-device cursors this method keeps, and a run that is cut short
72371
- * has still only forgotten rows whose blobs it proved gone.
72518
+ * Start a debug-media reclaim pass. DETACHED.
72372
72519
  *
72373
- * Single-flight: a second call while a pass is in flight returns
72374
- * `ran: false` rather than doubling the disk pressure the pacing exists to
72375
- * avoid.
72520
+ * Returns `{ started }` immediately. The walk is tens of minutes at 2 MB/s;
72521
+ * awaiting it is a timeout, not a verdict. Poll {@link getMediaReclaimStatus}.
72522
+ * Dry-run unless `mode: 'reclaim'`. A second call while one is in flight is
72523
+ * `alreadyRunning`, not a second walk on the same spindle.
72376
72524
  */
72377
- async runMediaReclaimNow(rawInput) {
72525
+ async reclaimDebugMedia(rawInput) {
72378
72526
  const input = MediaReclaimActionInputSchema.parse(rawInput);
72527
+ if (this.mediaReclaimState.running) {
72528
+ this.ctx.logger.info("debug-media reclaim already running — request ignored");
72529
+ return {
72530
+ started: false,
72531
+ alreadyRunning: true
72532
+ };
72533
+ }
72379
72534
  const mediaStore = this.mediaStore;
72380
72535
  const eventStore = this.eventStore;
72381
72536
  const trackStore = this.trackStore;
72382
- const empty = {
72383
- ran: false,
72537
+ if (!mediaStore || !eventStore || !trackStore) {
72538
+ this.ctx.logger.warn("reclaimDebugMedia: analytics stores unavailable");
72539
+ return {
72540
+ started: false,
72541
+ alreadyRunning: false
72542
+ };
72543
+ }
72544
+ this.mediaReclaimState = {
72545
+ ...IDLE_MEDIA_RECLAIM,
72546
+ running: true,
72384
72547
  mode: input.mode,
72385
- devices: [],
72386
- totalExamined: 0,
72387
- totalEligible: 0,
72388
- totalReclaimed: 0,
72389
- totalBytesReclaimed: 0,
72390
- totalRefused: 0,
72391
- durationMs: 0,
72392
- complete: false
72548
+ startedAtMs: Date.now()
72549
+ };
72550
+ this.ctx.logger.info("debug-media reclaim starting", { meta: {
72551
+ mode: input.mode,
72552
+ restart: input.restart === true
72553
+ } });
72554
+ this.executeMediaReclaim(input, mediaStore, eventStore, trackStore);
72555
+ return {
72556
+ started: true,
72557
+ alreadyRunning: false
72393
72558
  };
72394
- if (!mediaStore || !eventStore || !trackStore) return empty;
72395
- if (this.mediaReclaimRunning) return empty;
72396
- this.mediaReclaimRunning = true;
72559
+ }
72560
+ /**
72561
+ * The walk itself. Called DETACHED from {@link reclaimDebugMedia}; never
72562
+ * await this from an RPC.
72563
+ */
72564
+ async executeMediaReclaim(input, mediaStore, eventStore, trackStore) {
72397
72565
  try {
72398
72566
  const limits = {
72399
72567
  mode: input.mode,
@@ -72411,6 +72579,19 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72411
72579
  scopes,
72412
72580
  limits,
72413
72581
  cursors: input.restart === true ? /* @__PURE__ */ new Map() : this.mediaReclaimCursors,
72582
+ onDevice: (device) => {
72583
+ const s = this.mediaReclaimState;
72584
+ if (!s.running) return;
72585
+ this.mediaReclaimState = {
72586
+ ...s,
72587
+ totalExamined: s.totalExamined + device.examined,
72588
+ totalEligible: s.totalEligible + device.eligible,
72589
+ totalReclaimed: s.totalReclaimed + device.reclaimed,
72590
+ totalBytesReclaimed: s.totalBytesReclaimed + device.bytesReclaimed,
72591
+ totalRefused: s.totalRefused + device.refused,
72592
+ devicesDone: s.devicesDone + 1
72593
+ };
72594
+ },
72414
72595
  deps: {
72415
72596
  page: ({ deviceId, scope, afterMs, limit }) => mediaStore.pageByKindForDevice({
72416
72597
  deviceId,
@@ -72437,15 +72618,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
72437
72618
  bytesReclaimed: report.totalBytesReclaimed,
72438
72619
  detail: `debug-media reclaim (${scopes.join("+")}): ${report.totalReclaimed} rows, ${report.totalRefused} refused, complete=${report.complete}`
72439
72620
  });
72440
- return {
72441
- ran: true,
72442
- ...report,
72443
- devices: [...report.devices]
72621
+ this.mediaReclaimState = {
72622
+ ...this.mediaReclaimState,
72623
+ running: false,
72624
+ totalExamined: report.totalExamined,
72625
+ totalEligible: report.totalEligible,
72626
+ totalReclaimed: report.totalReclaimed,
72627
+ totalBytesReclaimed: report.totalBytesReclaimed,
72628
+ totalRefused: report.totalRefused,
72629
+ complete: report.complete,
72630
+ finishedAtMs: Date.now()
72444
72631
  };
72445
- } finally {
72446
- this.mediaReclaimRunning = false;
72632
+ this.ctx.logger.info("debug-media reclaim finished", { meta: {
72633
+ mode: report.mode,
72634
+ examined: report.totalExamined,
72635
+ reclaimed: report.totalReclaimed,
72636
+ bytesReclaimed: report.totalBytesReclaimed,
72637
+ complete: report.complete
72638
+ } });
72639
+ } catch (err) {
72640
+ this.mediaReclaimState = {
72641
+ ...this.mediaReclaimState,
72642
+ running: false,
72643
+ finishedAtMs: Date.now(),
72644
+ error: err instanceof Error ? err.message : String(err),
72645
+ complete: false
72646
+ };
72647
+ this.ctx.logger.warn("debug-media reclaim failed", { meta: { error: String(err) } });
72447
72648
  }
72448
72649
  }
72650
+ /** Progress of the detached reclaim — the only way to observe a long pass. */
72651
+ async getMediaReclaimStatus() {
72652
+ return { ...this.mediaReclaimState };
72653
+ }
72449
72654
  /** Progress of the detached rebuild — the only way to observe a long pass. */
72450
72655
  async getObjectEmbeddingRebuildStatus() {
72451
72656
  const state = this.rebuildState;
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CnB4y-xY.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-rrVQlXbu.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.154",
3
+ "version": "1.2.156",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",
@@ -110,13 +110,6 @@
110
110
  "cardinality": "multi",
111
111
  "description": "Detection snapshots, thumbnails and crops.",
112
112
  "defaultsTo": "recordings"
113
- },
114
- {
115
- "id": "galleryMedia",
116
- "displayName": "Enrolled Gallery",
117
- "cardinality": "single",
118
- "description": "Enrolled face and plate reference crops, and scene reference photographs. Deliberately NOT on the recordings volume: these images survive every retention sweep, so they must also survive a footage wipe, a quota eviction and a recordings disk swap. Small — a few KB per enrolled sample.",
119
- "defaultRoot": "data"
120
113
  }
121
114
  ],
122
115
  "icon": "assets/icon.svg",