@camstack/addon-post-analysis 1.2.127 → 1.2.129

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-BarcMD8R.js");
5
+ const require_dist = require("../dist-DFeeUt_t.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");
@@ -27217,6 +27217,18 @@ var FaceGalleryProvider = class {
27217
27217
  * This module names that state (`model-mismatch`) so the UI can say "this face
27218
27218
  * is in an old feature space" instead of showing 0% and letting the operator
27219
27219
  * conclude the person is not there.
27220
+ *
27221
+ * ## `buildFaceRescoreReport` stays pure — the CALLER persists
27222
+ *
27223
+ * This function never touches the store; every input is passed in and nothing
27224
+ * is read or written (see its own doc below). The operator asked for this
27225
+ * exact recompute, though, and wants the answer to stick — so the ACTION that
27226
+ * wraps this report (`rescoreFaceForTrack` in `pipeline-analytics/index.ts`)
27227
+ * writes the result onto `Face.suggestedIdentityId` / `suggestedMatchScore`,
27228
+ * through `decideRescorePersistence` / `persistRescoreResult` below. That is
27229
+ * still not a background job — it is the SAME explicitly-requested call,
27230
+ * writing once. A `model-mismatch` report (`comparable: false`) is never
27231
+ * written: see `persistRescoreResult`.
27220
27232
  */
27221
27233
  /** The empty report every "cannot score" path returns, so shape never varies. */
27222
27234
  function emptyReport(verdict, input, comparable) {
@@ -27292,6 +27304,52 @@ function buildFaceRescoreReport(input) {
27292
27304
  ...best !== void 0 && second !== void 0 ? { runnerUpGap: best.score - second.score } : {}
27293
27305
  };
27294
27306
  }
27307
+ /**
27308
+ * What `face.rescoreTrack` should PERSIST for a COMPARABLE report, decided as
27309
+ * its own pure function so the rule is directly testable — separated from the
27310
+ * class that owns the store I/O (`rescoreFaceForTrack` in
27311
+ * `pipeline-analytics/index.ts`), which only wires this answer to
27312
+ * `FaceStore.setSuggestion`.
27313
+ *
27314
+ * `null` covers every verdict OTHER than `auto-assignable` / `suggestion`, and
27315
+ * also an `auto-assignable` / `suggestion` verdict whose best-ranked row is
27316
+ * not itself ELIGIBLE (`identities` is ranked by raw score across every
27317
+ * comparable identity, eligible or not — see `buildFaceRescoreReport`). Every
27318
+ * one of those is a genuine "nothing to suggest right now": never an
27319
+ * assignment, and never the winner of an `ambiguous` verdict — the matcher
27320
+ * refused to pick one, so this function does not pick one either.
27321
+ *
27322
+ * Callers MUST gate on `report.comparable` before calling this — a
27323
+ * `model-mismatch` report has no verdict worth acting on; see
27324
+ * {@link persistRescoreResult}, which does that gating and is the one thing
27325
+ * `rescoreFaceForTrack` actually calls.
27326
+ */
27327
+ function decideRescorePersistence(report) {
27328
+ if (report.verdict !== "auto-assignable" && report.verdict !== "suggestion") return null;
27329
+ const candidate = report.identities.find((row) => row.eligible);
27330
+ return candidate === void 0 ? null : {
27331
+ identityId: candidate.identityId,
27332
+ score: candidate.score
27333
+ };
27334
+ }
27335
+ /**
27336
+ * Persist `face.rescoreTrack`'s result onto the SUGGESTED tier — the ONE
27337
+ * write this explicitly-requested action makes, and the only place that
27338
+ * decides whether it happens at all.
27339
+ *
27340
+ * A non-comparable report (`model-mismatch`) short-circuits BEFORE
27341
+ * `ports.setSuggestion` is ever called: a cosine across model spaces is
27342
+ * well-formed noise, and persisting it would look exactly like a fresh,
27343
+ * meaningful read to every future reader of the row. This is the single most
27344
+ * load-bearing branch in this file — the regression it guards against writes
27345
+ * garbage that reads as data.
27346
+ */
27347
+ async function persistRescoreResult(faceId, report, ports) {
27348
+ if (!report.comparable) return "not-comparable";
27349
+ const candidate = decideRescorePersistence(report);
27350
+ await ports.setSuggestion(faceId, candidate);
27351
+ return candidate !== null ? "written" : "cleared";
27352
+ }
27295
27353
  //#endregion
27296
27354
  //#region src/pipeline-analytics/face-rescore-actions.ts
27297
27355
  /**
@@ -27392,8 +27450,10 @@ var FaceRescoreResultSchema = require_dist.object({
27392
27450
  /** The face's current assignment, so the UI can mark "already this person". */
27393
27451
  assigned: require_dist.boolean(),
27394
27452
  assignedIdentityId: require_dist.string().optional(),
27395
- /** What the track has been SHOWING until now — persisted at detection time.
27396
- * Returned so the UI can put the stale number beside the fresh one. */
27453
+ /** What the track has been SHOWING until now — read BEFORE this call
27454
+ * writes, so it is whatever a previous track close or a previous
27455
+ * `face.rescoreTrack` last persisted. Returned so the UI can put the stale
27456
+ * number beside the fresh one. */
27397
27457
  storedSuggestedIdentityId: require_dist.string().optional(),
27398
27458
  storedSuggestedMatchScore: require_dist.number().optional(),
27399
27459
  verdict: FaceRescoreVerdictSchema,
@@ -27410,9 +27470,12 @@ var FaceRescoreResultSchema = require_dist.object({
27410
27470
  });
27411
27471
  var faceRescoreActions = require_dist.defineCustomActions({
27412
27472
  /**
27413
- * Read-only by default. `admin` because the report names every enrolled
27414
- * person and how well an arbitrary face matches them — the gallery's shape,
27415
- * which is not a viewer-level fact.
27473
+ * Persists its own result onto the SUGGESTED tier (`suggestedIdentityId` /
27474
+ * `suggestedMatchScore`) — never an assignment, never automatic; see
27475
+ * `rescoreFaceForTrack` in `pipeline-analytics/index.ts` and
27476
+ * `persistRescoreResult` in `face-rescore.ts`. `admin` because the report
27477
+ * also names every enrolled person and how well an arbitrary face matches
27478
+ * them — the gallery's shape, which is not a viewer-level fact.
27416
27479
  */
27417
27480
  "face.rescoreTrack": require_dist.customAction(FaceRescoreInputSchema, FaceRescoreResultSchema, {
27418
27481
  kind: "mutation",
@@ -29407,6 +29470,51 @@ var FrameProcessor = class {
29407
29470
  }
29408
29471
  };
29409
29472
  //#endregion
29473
+ //#region src/pipeline-analytics/replay/frame-processor-pass.ts
29474
+ /**
29475
+ * Run a FRESH `FrameProcessor` over a fixed, ordered sequence of frames and
29476
+ * return the tracks it produced.
29477
+ *
29478
+ * No `TrackStore`, no bus, no media — a track here is a summary built
29479
+ * ENTIRELY from `FrameProcessor.process()`'s in-memory return value.
29480
+ */
29481
+ function runFrameProcessorPass(input) {
29482
+ const processor = new FrameProcessor(input.deviceId, {}, {}, {}, input.source);
29483
+ if (input.zones !== void 0) processor.setZones(input.zones);
29484
+ if (input.detectionRules !== void 0) processor.setDetectionRules(input.detectionRules);
29485
+ if (input.zoneMembershipMinOverlap !== void 0) processor.setZoneMembershipMinOverlap(input.zoneMembershipMinOverlap);
29486
+ const accum = /* @__PURE__ */ new Map();
29487
+ for (const { timestamp, frame } of input.frames) {
29488
+ const result = processor.process({
29489
+ timestamp,
29490
+ frame
29491
+ });
29492
+ for (const td of result.tracked) {
29493
+ const existing = accum.get(td.trackId);
29494
+ const matched = td.matchedThisFrame ? 1 : 0;
29495
+ if (existing === void 0) accum.set(td.trackId, {
29496
+ className: td.className,
29497
+ firstSeenMs: timestamp,
29498
+ lastSeenMs: timestamp,
29499
+ bbox: { ...td.bbox },
29500
+ framesMatched: matched
29501
+ });
29502
+ else {
29503
+ existing.className = td.className;
29504
+ existing.lastSeenMs = timestamp;
29505
+ existing.framesMatched += matched;
29506
+ }
29507
+ }
29508
+ }
29509
+ return { tracks: [...accum.values()].map((t) => ({
29510
+ className: t.className,
29511
+ firstSeenMs: t.firstSeenMs,
29512
+ lastSeenMs: t.lastSeenMs,
29513
+ bbox: t.bbox,
29514
+ framesMatched: t.framesMatched
29515
+ })) };
29516
+ }
29517
+ //#endregion
29410
29518
  //#region src/pipeline-analytics/pipeline/semantic-detail-bbox.ts
29411
29519
  /** Which box the semantic best-shot should crop: a plate/face DETAIL box
29412
29520
  * when we have one, otherwise the subject box. */
@@ -31823,11 +31931,13 @@ var MediaStore = class {
31823
31931
  store;
31824
31932
  logger;
31825
31933
  writeGate;
31934
+ resolveEventMediaLocation;
31826
31935
  constructor(deps) {
31827
31936
  this.storage = deps.storage;
31828
31937
  this.store = deps.store;
31829
31938
  this.logger = deps.logger;
31830
31939
  this.writeGate = deps.writeGate;
31940
+ this.resolveEventMediaLocation = deps.resolveEventMediaLocation;
31831
31941
  }
31832
31942
  gated(mutation) {
31833
31943
  return this.writeGate ? this.writeGate.run(mutation) : mutation();
@@ -31848,7 +31958,8 @@ var MediaStore = class {
31848
31958
  async putUnchecked(params) {
31849
31959
  const key = buildKey(params);
31850
31960
  const path = buildPath(params);
31851
- const location = locationForOwnerKind(params.ownerKind);
31961
+ const staticLocation = locationForOwnerKind(params.ownerKind);
31962
+ const location = staticLocation === "eventMedia" && this.resolveEventMediaLocation ? await this.resolveEventMediaLocation(params.deviceId) : staticLocation;
31852
31963
  const record = {
31853
31964
  deviceId: params.deviceId,
31854
31965
  ownerKind: params.ownerKind,
@@ -41541,6 +41652,21 @@ var MediaCaptureLogAggregator = class {
41541
41652
  }
41542
41653
  };
41543
41654
  //#endregion
41655
+ //#region src/pipeline-analytics/media-locations.ts
41656
+ /**
41657
+ * Picks the location a new `eventMedia` blob should be written to, or `null`
41658
+ * when none is writable — the caller MUST refuse the write rather than fall
41659
+ * back to a read-only location, which would fail far downstream as an opaque
41660
+ * filesystem error instead of a named storage decision.
41661
+ */
41662
+ function pickWritableMediaLocation(locations, currentDefaultId) {
41663
+ const writable = locations.filter((l) => l.enabled && !l.readOnly);
41664
+ if (writable.length === 0) return null;
41665
+ const keep = writable.find((l) => l.id === currentDefaultId);
41666
+ if (keep) return keep.id;
41667
+ return writable.toSorted((a, b) => b.headroomBytes - a.headroomBytes)[0]?.id ?? null;
41668
+ }
41669
+ //#endregion
41544
41670
  //#region src/pipeline-analytics/media-relocate-engine.ts
41545
41671
  var PAGE_SIZE = 200;
41546
41672
  var DEFAULT_THROTTLE_MBPS = 40;
@@ -47611,6 +47737,37 @@ var FaceStore = class {
47611
47737
  }
47612
47738
  }
47613
47739
  /**
47740
+ * Persist a `face.rescoreTrack` result onto the SUGGESTED tier — the ONE
47741
+ * write that explicitly-requested action makes. `suggestion: null` means
47742
+ * the recompute found no candidate worth offering and CLEARS both columns
47743
+ * (a genuine "the gallery has nothing for this face right now" result, not
47744
+ * a no-op left at whatever they held before). Never touches `assigned` /
47745
+ * `recognizedIdentityId` — a suggestion is never an assignment. The caller
47746
+ * is responsible for never calling this with a non-comparable (`model-
47747
+ * mismatch`) report; this method persists whatever it is given.
47748
+ */
47749
+ async setSuggestion(faceId, suggestion) {
47750
+ const data = suggestion === null ? {
47751
+ suggestedIdentityId: null,
47752
+ suggestedMatchScore: null
47753
+ } : {
47754
+ suggestedIdentityId: suggestion.identityId,
47755
+ suggestedMatchScore: suggestion.score
47756
+ };
47757
+ try {
47758
+ await this.store.update.mutate({
47759
+ collection: FACES_COLLECTION,
47760
+ id: faceId,
47761
+ data
47762
+ });
47763
+ } catch (err) {
47764
+ this.logger.warn("FaceStore.setSuggestion failed", { meta: {
47765
+ faceId,
47766
+ error: String(err)
47767
+ } });
47768
+ }
47769
+ }
47770
+ /**
47614
47771
  * `TrackScopedStore` contract for the retention cascade: delete the buffered
47615
47772
  * face rows of the given tracks that are NOT assigned. Reuses the EXACT
47616
47773
  * `assigned` exemption that `prune`/`pruneAll` apply (see the
@@ -57034,6 +57191,144 @@ function rowFromMetadata(id, metadata) {
57034
57191
  };
57035
57192
  }
57036
57193
  //#endregion
57194
+ //#region src/pipeline-analytics/store/settings-raw-mirror.ts
57195
+ /** How long a mirrored entry is served before a refresh is started behind the
57196
+ * next reader. See the module doc for why this matches the per-kind caches'
57197
+ * own window rather than introducing a second staleness bound. */
57198
+ var RAW_SETTINGS_STALE_MS = 5e3;
57199
+ /**
57200
+ * How long a FAILED refresh is not retried.
57201
+ *
57202
+ * A refresh that throws leaves `fetchedAt` where it was, so without a
57203
+ * cooldown every subsequent reader would start another attempt — a broken
57204
+ * settings store would be hammered at frame rate by the very mechanism built
57205
+ * to stop that.
57206
+ */
57207
+ var RAW_SETTINGS_MISS_COOLDOWN_MS = 5e3;
57208
+ /**
57209
+ * The addon's per-device raw blob (`pipeline-analytics:addon-devices`), kept
57210
+ * fresh OFF every per-kind settings cache's hot path.
57211
+ *
57212
+ * `resolve` is COLD-awaiting and single-flight: a burst of settings kinds
57213
+ * (or frames) missing on the same device in the same tick costs ONE read,
57214
+ * not one per caller — the exact failure this class exists to remove.
57215
+ */
57216
+ var RawDeviceBlobMirror = class {
57217
+ deps;
57218
+ staleMs;
57219
+ missCooldownMs;
57220
+ entries = /* @__PURE__ */ new Map();
57221
+ inFlight = /* @__PURE__ */ new Map();
57222
+ now;
57223
+ constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
57224
+ this.deps = deps;
57225
+ this.staleMs = staleMs;
57226
+ this.missCooldownMs = missCooldownMs;
57227
+ this.now = deps.now ?? (() => Date.now());
57228
+ }
57229
+ /**
57230
+ * The device's raw stored blob.
57231
+ *
57232
+ * COLD awaits — the first settings kind to touch a device has to get the
57233
+ * real answer. WARM returns immediately, stale or not, and starts the
57234
+ * refresh behind the caller.
57235
+ */
57236
+ async resolve(deviceId) {
57237
+ const entry = this.entries.get(deviceId);
57238
+ if (entry === void 0) return this.load(deviceId);
57239
+ if (this.now() - entry.fetchedAt >= this.staleMs) this.load(deviceId);
57240
+ return entry.value;
57241
+ }
57242
+ /** Drop a device's entry. Called by every in-process writer of this
57243
+ * device's row, so an operator's own change is never served stale. */
57244
+ invalidate(deviceId) {
57245
+ this.entries.delete(deviceId);
57246
+ }
57247
+ invalidateAll() {
57248
+ this.entries.clear();
57249
+ }
57250
+ async load(deviceId) {
57251
+ const pending = this.inFlight.get(deviceId);
57252
+ if (pending !== void 0) return pending;
57253
+ const run = this.deps.read(deviceId).then((value) => {
57254
+ this.entries.set(deviceId, {
57255
+ value,
57256
+ fetchedAt: this.now()
57257
+ });
57258
+ return value;
57259
+ }).catch((err) => {
57260
+ this.deps.onRefreshFailed?.(deviceId, err);
57261
+ const existing = this.entries.get(deviceId);
57262
+ if (existing !== void 0) {
57263
+ existing.fetchedAt = this.now() - this.staleMs + this.missCooldownMs;
57264
+ return existing.value;
57265
+ }
57266
+ return {};
57267
+ }).finally(() => {
57268
+ this.inFlight.delete(deviceId);
57269
+ });
57270
+ this.inFlight.set(deviceId, run);
57271
+ return run;
57272
+ }
57273
+ };
57274
+ /**
57275
+ * The addon's ONE `addon_settings` row (`pipeline-analytics:addon-settings`),
57276
+ * kept fresh OFF every per-kind settings cache's hot path. Same shape as
57277
+ * {@link RawDeviceBlobMirror}, singleton rather than keyed — there is exactly
57278
+ * one addon store per process.
57279
+ */
57280
+ var RawGlobalStoreMirror = class {
57281
+ deps;
57282
+ staleMs;
57283
+ missCooldownMs;
57284
+ entry = null;
57285
+ inFlight = null;
57286
+ now;
57287
+ constructor(deps, staleMs = RAW_SETTINGS_STALE_MS, missCooldownMs = RAW_SETTINGS_MISS_COOLDOWN_MS) {
57288
+ this.deps = deps;
57289
+ this.staleMs = staleMs;
57290
+ this.missCooldownMs = missCooldownMs;
57291
+ this.now = deps.now ?? (() => Date.now());
57292
+ }
57293
+ async resolve() {
57294
+ const entry = this.entry;
57295
+ if (entry === null) return this.load();
57296
+ if (this.now() - entry.fetchedAt >= this.staleMs) this.load();
57297
+ return entry.value;
57298
+ }
57299
+ /** Drop the mirrored store. Called by every in-process global-settings
57300
+ * writer (`onConfigChanged`), so an operator's own change is never served
57301
+ * stale. */
57302
+ invalidate() {
57303
+ this.entry = null;
57304
+ }
57305
+ async load() {
57306
+ if (this.inFlight !== null) return this.inFlight;
57307
+ const run = this.deps.read().then((value) => {
57308
+ this.entry = {
57309
+ value,
57310
+ fetchedAt: this.now()
57311
+ };
57312
+ return value;
57313
+ }).catch((err) => {
57314
+ this.deps.onRefreshFailed?.(err);
57315
+ const existing = this.entry;
57316
+ if (existing !== null) {
57317
+ this.entry = {
57318
+ value: existing.value,
57319
+ fetchedAt: this.now() - this.staleMs + this.missCooldownMs
57320
+ };
57321
+ return existing.value;
57322
+ }
57323
+ return {};
57324
+ }).finally(() => {
57325
+ this.inFlight = null;
57326
+ });
57327
+ this.inFlight = run;
57328
+ return run;
57329
+ }
57330
+ };
57331
+ //#endregion
57037
57332
  //#region src/pipeline-analytics/track-retention-sweep.ts
57038
57333
  /**
57039
57334
  * Sweep every device with persisted tracks: skip retention-disabled devices,
@@ -58090,6 +58385,14 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58090
58385
  /** Event-media relocation engine (entity-routing Phase 4). */
58091
58386
  mediaRelocate = null;
58092
58387
  mediaLocationStorage = null;
58388
+ /**
58389
+ * Cached pick for NEW `eventMedia` writes (`pickWritableMediaLocation`).
58390
+ * Resolved once, never per blob (no-RPC-per-blob, same discipline as
58391
+ * `mediaLocationStorage`'s own root cache) — dropped together with it by
58392
+ * `refreshStorageLocationsForMigration`, since a location's `readOnly` /
58393
+ * `enabled` flags only change via operator reconfig.
58394
+ */
58395
+ eventMediaWriteLocationCache = null;
58093
58396
  mediaWriteGate = new MediaWriteGate();
58094
58397
  storageMigrationLeaseId = null;
58095
58398
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
@@ -58489,6 +58792,33 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
58489
58792
  audioEpisodeTracks = /* @__PURE__ */ new Map();
58490
58793
  audioEpisodeLastPersistMs = /* @__PURE__ */ new Map();
58491
58794
  settingsCaches = new DeviceSettingsCacheHub();
58795
+ /**
58796
+ * The addon-level store and per-device blob, mirrored ONCE and shared by
58797
+ * every settings kind above — see `store/settings-raw-mirror.ts`. Before
58798
+ * this, each kind's cache miss called `resolveGlobalStore()` +
58799
+ * `readDeviceBlob()` directly, so eight kinds expiring in the same window
58800
+ * paid for the same two reads eight times: `get
58801
+ * pipeline-analytics:addon-settings` at 547–694/min, `get
58802
+ * pipeline-analytics:addon-devices` at ~700/min, live 2026-08-26. Invalidated
58803
+ * by every in-process writer: `applyDeviceSettingsPatch` and the retired-
58804
+ * setting purges drop the device mirror, `onConfigChanged` (this process's
58805
+ * own `updateGlobalSettings`) drops the global mirror.
58806
+ */
58807
+ rawDeviceBlobMirror = new RawDeviceBlobMirror({
58808
+ read: (deviceId) => this.readDeviceBlob(deviceId),
58809
+ onRefreshFailed: (deviceId, err) => {
58810
+ this.ctx.logger.debug("raw device-blob mirror refresh failed — serving last value", {
58811
+ tags: { deviceId },
58812
+ meta: { error: err instanceof Error ? err.message : String(err) }
58813
+ });
58814
+ }
58815
+ });
58816
+ rawGlobalStoreMirror = new RawGlobalStoreMirror({
58817
+ read: () => this.resolveGlobalStore(),
58818
+ onRefreshFailed: (err) => {
58819
+ this.ctx.logger.debug("raw global-store mirror refresh failed — serving last value", { meta: { error: err instanceof Error ? err.message : String(err) } });
58820
+ }
58821
+ });
58492
58822
  /** Avoids a store read every ~30 Hz audio frame. */
58493
58823
  audioSettingsCache = this.settingsCaches.registerDeviceCache("audio-detection", SETTINGS_CACHE_TTL_MS);
58494
58824
  sensitivitySettingsCache = this.settingsCaches.registerDeviceCache("detection-sensitivity", SETTINGS_CACHE_TTL_MS);
@@ -59208,6 +59538,53 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59208
59538
  this.mediaLocationStorage = routed;
59209
59539
  return routed;
59210
59540
  }
59541
+ /**
59542
+ * Resolves which `eventMedia` location a NEW footage-class blob should be
59543
+ * written to (`MediaStoreDeps.resolveEventMediaLocation`). Reflects
59544
+ * `config.readOnly` — an operator draining a location marks it read-only,
59545
+ * and new writes must never land there (the relocate mover, exposed as
59546
+ * `relocateMedia`, empties what is already on it in the background).
59547
+ *
59548
+ * Cached after the first successful resolution — this is a per-blob write
59549
+ * path, and the write-side provider is a declared no-RPC-per-blob bypass
59550
+ * (D-note in `location-aware-media-storage.ts`); re-querying the storage
59551
+ * cap on every blob would undo that. Dropped by
59552
+ * `refreshStorageLocationsForMigration`, same trigger as
59553
+ * `mediaLocationStorage.invalidateRoots()`.
59554
+ *
59555
+ * Degrades to the bare `eventMedia` type ref (pre-2026-08-26 behaviour)
59556
+ * when the storage cap itself is unreachable — a transient RPC failure (or
59557
+ * a test harness that never wires `api.storage`) must not turn every media
59558
+ * write into a hard failure the way "no writable location" does.
59559
+ */
59560
+ async resolveEventMediaWriteLocation(api, logger, deviceId) {
59561
+ const cached = this.eventMediaWriteLocationCache;
59562
+ if (cached !== null) return cached;
59563
+ let locations;
59564
+ try {
59565
+ locations = await api.storage.listLocations.query({ type: DEFAULT_MEDIA_LOCATION });
59566
+ } catch (err) {
59567
+ logger.debug("pipeline-analytics: eventMedia location list unavailable — writing through the default ref", { meta: { error: require_dist.errMsg(err) } });
59568
+ this.eventMediaWriteLocationCache = DEFAULT_MEDIA_LOCATION;
59569
+ return DEFAULT_MEDIA_LOCATION;
59570
+ }
59571
+ const picked = pickWritableMediaLocation(locations.map((l) => ({
59572
+ id: l.id,
59573
+ readOnly: l.config["readOnly"] === true,
59574
+ enabled: l.enabled !== false,
59575
+ headroomBytes: l.capacity?.availableBytes ?? 0
59576
+ })), locations.find((l) => l.isDefault)?.id ?? "eventMedia");
59577
+ if (picked === null) {
59578
+ const seen = locations.length > 0 ? locations.map((l) => l.id).join(", ") : "(none registered)";
59579
+ logger.error("pipeline-analytics: no writable eventMedia storage location — refusing the write instead of failing far downstream as a filesystem error", {
59580
+ tags: { deviceId },
59581
+ meta: { locationsSeen: seen }
59582
+ });
59583
+ throw new Error(`no writable eventMedia storage location (seen: ${seen})`);
59584
+ }
59585
+ this.eventMediaWriteLocationCache = picked;
59586
+ return picked;
59587
+ }
59211
59588
  /** Constructs every SQLite-backed store plus the stationary/package-drop/
59212
59589
  * sensor plumbing, in the exact pre-S7 order. Returns the non-null bundle
59213
59590
  * so the rest of boot consumes the stores without re-narrowing. */
@@ -59266,7 +59643,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
59266
59643
  storage,
59267
59644
  store: api.settingsStore,
59268
59645
  logger: logger.child("MediaStore"),
59269
- writeGate: this.mediaWriteGate
59646
+ writeGate: this.mediaWriteGate,
59647
+ resolveEventMediaLocation: (deviceId) => this.resolveEventMediaWriteLocation(api, logger, deviceId)
59270
59648
  });
59271
59649
  this.mediaStore = mediaStore;
59272
59650
  const retrainFrames = new RetrainFrameStore({
@@ -60299,6 +60677,21 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60299
60677
  ...input.nodeId !== void 0 ? { nodeId: input.nodeId } : {}
60300
60678
  });
60301
60679
  }
60680
+ /**
60681
+ * The FrameProcessor pass of a replay run — see the cap doc on
60682
+ * `runReplayFrameProcessor` for why this is not the `processFrame` method
60683
+ * this addon deliberately does not have.
60684
+ *
60685
+ * `runFrameProcessorPass` is pure w.r.t. this addon's own state: it
60686
+ * constructs a FRESH `FrameProcessor` and does not touch `this.trackStore`,
60687
+ * `this.stationaryRegistry` or any other instance field — unlike
60688
+ * `getOrCreateProcessor` (the production path), which wires both.
60689
+ * `scripts/check-replay-isolation.ts` holds that boundary on the
60690
+ * `replay/` directory this delegates into.
60691
+ */
60692
+ async runReplayFrameProcessor(input) {
60693
+ return runFrameProcessorPass(input);
60694
+ }
60302
60695
  async listRetrainAnnotations(input) {
60303
60696
  return this.requireRetrain().listAnnotations(input.trackId);
60304
60697
  }
@@ -60574,6 +60967,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60574
60967
  this.forgetDeviceProcessors(data.deviceId);
60575
60968
  this.levelStateByDevice.delete(data.deviceId);
60576
60969
  this.settingsCaches.clearDevice(data.deviceId);
60970
+ this.rawDeviceBlobMirror.invalidate(data.deviceId);
60577
60971
  }
60578
60972
  }
60579
60973
  /** `DeviceUnregistered` handler — subscription topology lives in
@@ -60598,6 +60992,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
60598
60992
  }
60599
60993
  this.audioMarkers?.forgetDevice(deviceId);
60600
60994
  this.settingsCaches.clearDevice(deviceId);
60995
+ this.rawDeviceBlobMirror.invalidate(deviceId);
60601
60996
  this.bindingCache?.invalidate(deviceId);
60602
60997
  this.detectionCoverage?.forget(deviceId);
60603
60998
  this.zoneAnalytics?.forgetDevice(deviceId);
@@ -61026,6 +61421,17 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61026
61421
  }] : []
61027
61422
  ];
61028
61423
  }
61424
+ /**
61425
+ * `BaseAddon.updateGlobalSettings` calls this after the write lands —
61426
+ * gated to the LOCAL node's own write (a sibling-node write returns before
61427
+ * reaching it), which is exactly the scope `rawGlobalStoreMirror` needs to
61428
+ * invalidate: the mirror only ever answers for THIS process, so it only
61429
+ * needs to drop on THIS process's own write (D224).
61430
+ */
61431
+ async onConfigChanged() {
61432
+ await super.onConfigChanged();
61433
+ this.rawGlobalStoreMirror.invalidate();
61434
+ }
61029
61435
  async onShutdown() {
61030
61436
  this.shuttingDown = true;
61031
61437
  this.sceneEngine?.stop();
@@ -61081,6 +61487,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61081
61487
  this.gatedNodeByDevice.clear();
61082
61488
  this.levelStateByDevice.clear();
61083
61489
  this.settingsCaches.clearAll();
61490
+ this.rawDeviceBlobMirror.invalidateAll();
61491
+ this.rawGlobalStoreMirror.invalidate();
61084
61492
  this.confirmationDetectorStepByNode.clear();
61085
61493
  this.trackStore?.clearAll();
61086
61494
  this.stationaryRegistry = null;
@@ -61793,10 +62201,16 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
61793
62201
  * auto-assigning — see `__tests__/global-settings-cascade.spec.ts`).
61794
62202
  * `resolveGlobalStore()` is the sanctioned BaseAddon read (per-node
61795
62203
  * projection applied; a raw `readAddonStore` would leak `@node` keys if a
61796
- * perNode field is ever declared). */
62204
+ * perNode field is ever declared).
62205
+ *
62206
+ * Both raw reads go through `rawGlobalStoreMirror` / `rawDeviceBlobMirror`
62207
+ * rather than straight to the store: this method is the ONE place all
62208
+ * eight settings kinds funnel through on a cache miss, so calling the raw
62209
+ * reads directly here means eight kinds expiring in the same window each
62210
+ * pay for the same two reads — see `store/settings-raw-mirror.ts`. */
61797
62211
  async readDeviceSettings(deviceId, resolve) {
61798
- const globalRaw = this.ctxIfReady !== null ? await this.resolveGlobalStore() : {};
61799
- const deviceRaw = await this.readDeviceBlob(deviceId);
62212
+ const globalRaw = this.ctxIfReady !== null ? await this.rawGlobalStoreMirror.resolve() : {};
62213
+ const deviceRaw = await this.rawDeviceBlobMirror.resolve(deviceId);
61800
62214
  return resolve({
61801
62215
  ...globalRaw,
61802
62216
  ...deviceRaw
@@ -63467,6 +63881,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63467
63881
  });
63468
63882
  }
63469
63883
  await settings.writeDeviceStore(deviceId, plan.patch);
63884
+ this.rawDeviceBlobMirror.invalidate(deviceId);
63470
63885
  } catch (err) {
63471
63886
  this.retiredSettingsChecked.delete(deviceId);
63472
63887
  this.ctx.logger.warn("retired retention settings migration failed — will retry", {
@@ -63538,6 +63953,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
63538
63953
  deviceId
63539
63954
  });
63540
63955
  await settings.writeDeviceStore(deviceId, plan.patch);
63956
+ this.rawDeviceBlobMirror.invalidate(deviceId);
63541
63957
  } catch (err) {
63542
63958
  this.storedSuggestionCheckedDevices.delete(deviceId);
63543
63959
  this.ctx.logger.warn("face suggestion threshold purge failed — will retry", {
@@ -65225,6 +65641,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65225
65641
  * See `face-rescore.ts` for why the number already on the track cannot answer
65226
65642
  * that: it was computed during live detection and persisted at track close,
65227
65643
  * so every enrolment made since is invisible to it.
65644
+ *
65645
+ * The report's result is PERSISTED (`persistRescoreResult`, below) onto
65646
+ * `Face.suggestedIdentityId` / `suggestedMatchScore` — the SAME columns the
65647
+ * live recognizer's suggestion band writes, so a reader never has to know
65648
+ * whether a suggestion came from track close or from this button. Still
65649
+ * never a background job: this write happens once, only because the
65650
+ * operator asked THIS question right now.
65228
65651
  */
65229
65652
  async rescoreFaceForTrack(input) {
65230
65653
  const api = this.ctx.api;
@@ -65267,6 +65690,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65267
65690
  clusterModelId,
65268
65691
  params
65269
65692
  });
65693
+ const persisted = await persistRescoreResult(faceId, report, { setSuggestion: (id, suggestion) => faceStore.setSuggestion(id, suggestion) });
65270
65694
  this.ctx.logger.info("face rescore", {
65271
65695
  tags: { deviceId: input.deviceId },
65272
65696
  meta: {
@@ -65280,7 +65704,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65280
65704
  best: report.identities[0]?.name ?? null,
65281
65705
  bestScore: report.identities[0]?.score ?? null,
65282
65706
  storedSuggestion: face.suggestedMatchScore ?? null,
65283
- reembedded: reembed.succeeded
65707
+ reembedded: reembed.succeeded,
65708
+ persisted
65284
65709
  }
65285
65710
  });
65286
65711
  return {
@@ -65684,6 +66109,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65684
66109
  async refreshStorageLocationsForMigration(input) {
65685
66110
  if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("event-media storage migration lease does not match");
65686
66111
  this.mediaLocationStorage?.invalidateRoots();
66112
+ this.eventMediaWriteLocationCache = null;
65687
66113
  return { refreshed: true };
65688
66114
  }
65689
66115
  async startStorageMigrationMove(input) {
@@ -65697,6 +66123,32 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
65697
66123
  async cancelStorageMigrationMove(input) {
65698
66124
  return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
65699
66125
  }
66126
+ /**
66127
+ * Move event media between locations — the OPERATOR's mover, no lease.
66128
+ * Twin of `recording.relocateFootage`: same engine (`this.mediaRelocate`)
66129
+ * the lease-gated coordinator move uses, runnable in the background
66130
+ * without pausing anything. `startStorageMigrationMove` above stays
66131
+ * lease-gated for the orchestrated migration; this method must never touch
66132
+ * `storageMigrationLeaseId`.
66133
+ */
66134
+ async relocateMedia(input) {
66135
+ if (!this.mediaRelocate) throw new Error("media relocation unavailable");
66136
+ const jobId = this.mediaRelocate.start(input);
66137
+ this.ctx.logger.info("pipeline-analytics: media relocate job enqueued", { meta: {
66138
+ jobId,
66139
+ to: input.toLocationId
66140
+ } });
66141
+ return { jobId };
66142
+ }
66143
+ /** Every relocate job this addon knows about, newest first (in RAM: the
66144
+ * move is resumable, so a lost list costs nothing but the display). */
66145
+ async listRelocateMediaJobs() {
66146
+ return this.mediaRelocate?.list() ?? [];
66147
+ }
66148
+ /** Cancel a running or queued relocate job. */
66149
+ async cancelRelocateMedia(input) {
66150
+ return { cancelled: this.mediaRelocate?.cancel(input.jobId) ?? false };
66151
+ }
65700
66152
  async listOpsLog(input) {
65701
66153
  return this.queryFacade.listOpsLog(input);
65702
66154
  }
@@ -66090,6 +66542,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
66090
66542
  }
66091
66543
  async applyDeviceSettingsPatch(input) {
66092
66544
  await this.updateDeviceSettings(input.deviceId, input.patch);
66545
+ this.rawDeviceBlobMirror.invalidate(input.deviceId);
66093
66546
  this.audioSettingsCache.delete(input.deviceId);
66094
66547
  this.sensitivitySettingsCache.delete(input.deviceId);
66095
66548
  this.trackingSettingsCache.delete(input.deviceId);