@camstack/addon-provider-unifi 0.2.45 → 0.2.47

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.
Files changed (3) hide show
  1. package/dist/addon.js +313 -181
  2. package/dist/addon.mjs +313 -181
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -8003,6 +8003,21 @@ var RelocateJobSchema = object({
8003
8003
  bytesMoved: number().int(),
8004
8004
  /** Total files discovered up front; null while (or when) unknown. */
8005
8005
  filesTotal: number().int().nullable(),
8006
+ /**
8007
+ * Rows this run CORRECTED while moving them — a durable mutation the move
8008
+ * made that nobody asked for, so it is reported where the operator reads the
8009
+ * job rather than only in a log line.
8010
+ *
8011
+ * A footage segment records its byte count in its own NAME, and the durable
8012
+ * hour row derives its aggregates from those names. A file that does not
8013
+ * match its name therefore makes the ledger's sums — and with them quota and
8014
+ * pressure eviction — wrong by the difference, and only a rename can fix it.
8015
+ * On 2026-08-30 one such row also stalled a 110 749-file drain permanently.
8016
+ *
8017
+ * Absent on lanes where the question has no meaning: a media blob's size is
8018
+ * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8019
+ */
8020
+ rowsReconciled: number().int().nonnegative().optional(),
8006
8021
  startedAt: number(),
8007
8022
  finishedAt: number().nullable(),
8008
8023
  error: string().nullable()
@@ -8071,14 +8086,42 @@ var RelocateMediaInputSchema = object({
8071
8086
  /** Omitted = `move`, the pre-existing behaviour. */
8072
8087
  mode: MediaRelocateModeSchema.optional()
8073
8088
  });
8074
- /** How many rows still carry NO `locationId` — the population a repoint would
8075
- * silently re-aim at a disk that does not hold their bytes. Zero is the only
8076
- * value that permits a non-blocking `eventMedia` cutover. */
8077
- var UnstampedEventMediaCountSchema = object({
8078
- media: number().int().nonnegative(),
8079
- retrainFrames: number().int().nonnegative(),
8080
- total: number().int().nonnegative()
8089
+ /**
8090
+ * The unstamped population of ONE collection — split, because the gate and the
8091
+ * operator ask two different questions and only one of them has to be cheap.
8092
+ *
8093
+ * `present` is the GATE: "is there at least one row that would be orphaned by a
8094
+ * repoint". It is a single indexed seek to the first matching row, so it stays
8095
+ * answerable on a saturated disk and answers in O(log n) precisely in the state
8096
+ * that matters — after a seal, when the population is empty.
8097
+ *
8098
+ * `rows` is the NUMBER, for the refusal message and the operator's sense of
8099
+ * scale. It is a second, indexed `COUNT(*)`, and `null` means **not
8100
+ * measurable** — never zero. `{ present: true, rows: null }` is a legitimate
8101
+ * and useful answer: "there are some, and this read could not say how many"
8102
+ * still refuses the cutover, which is the whole job.
8103
+ */
8104
+ var UnstampedRowsSchema = object({
8105
+ present: boolean(),
8106
+ rows: number().int().nonnegative().nullable()
8081
8107
  });
8108
+ /**
8109
+ * How many rows still carry NO `locationId` — the population a repoint would
8110
+ * silently re-aim at a disk that does not hold their bytes.
8111
+ *
8112
+ * **`null` = the count could not be taken**, and it is NOT permission to cut
8113
+ * over. The gate opens on a measured absence and on nothing else; an unread
8114
+ * collection and an empty one are different facts, and this repo has already
8115
+ * paid for conflating them (`RelocateResidueSchema`, D295).
8116
+ */
8117
+ var UnstampedEventMediaCountSchema = object({
8118
+ media: UnstampedRowsSchema,
8119
+ retrainFrames: UnstampedRowsSchema,
8120
+ /** True when EITHER collection holds one. The refusal reads this. */
8121
+ anyPresent: boolean(),
8122
+ /** Sum across both, or `null` when either lane could not be counted. */
8123
+ total: number().int().nonnegative().nullable()
8124
+ }).nullable();
8082
8125
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
8083
8126
  /** The independently selectable logical storage classes — every class
8084
8127
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -8189,6 +8232,10 @@ var StorageMigrationMoveProgressSchema = object({
8189
8232
  /** The archive census — the **M** of "N of M" (D295). `null` = unknowable. */
8190
8233
  filesTotal: number().int().nonnegative().nullable(),
8191
8234
  bytesMoved: number().int().nonnegative(),
8235
+ /** Rows the mover corrected while moving them — see `RelocateJob`. Absent on
8236
+ * a lane that cannot reconcile. A migration that silently rewrote durable
8237
+ * rows would be the same failure as one that silently skipped them. */
8238
+ rowsReconciled: number().int().nonnegative().optional(),
8192
8239
  /** The MOVER's start, not the migration's: a drain restarted after an addon
8193
8240
  * crash gets a new mover, and a rate computed from the migration's start
8194
8241
  * would silently average in the time nothing was running. */
@@ -13137,6 +13184,114 @@ method(object({
13137
13184
  height: number()
13138
13185
  }), EmbeddingResultSchema, { auth: "admin" }), method(object({ text: string() }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" });
13139
13186
  /**
13187
+ * `failure-contribution` — the capability an addon reports its OWN losses
13188
+ * through, per camera, with the denominator attached. It stores nothing.
13189
+ *
13190
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13191
+ *
13192
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13193
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13194
+ * copied: the contributor reports what it already knows, hub-main adds only
13195
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13196
+ * somebody to forget to edit.
13197
+ *
13198
+ * They are not merged, because their invariants are opposites:
13199
+ *
13200
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13201
+ * claim a camera cost nothing, which is a measurement nobody made;
13202
+ * - a `failure-contribution` zero is the **most valuable value on the
13203
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13204
+ * and it is exactly what an absent entry cannot say.
13205
+ *
13206
+ * Putting a loss counter on a cost entry would also break the reconciliation
13207
+ * that gives `load-contribution` its point: contributions are subtracted from
13208
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13209
+ * has no process.
13210
+ *
13211
+ * ## Why not a log line, since the counters already exist
13212
+ *
13213
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13214
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13215
+ * ends in a log line, and a log line is the thing the operator asked to stop
13216
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13217
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13218
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13219
+ * media blackout were both diagnosed. The counters stay; this is where they can
13220
+ * be READ.
13221
+ *
13222
+ * ## The rate is served with its denominator or not at all
13223
+ *
13224
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13225
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13226
+ * than yesterday" and was **flat across twelve hours** once divided by the
13227
+ * successes on the same path. A surface that publishes only the numerator
13228
+ * reproduces that mistake on every read.
13229
+ *
13230
+ * ## Shape
13231
+ *
13232
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13233
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13234
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13235
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13236
+ * a forked runner's entries reach hub-main over transport that already exists.
13237
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13238
+ * result through `system.getFailureContributions`.
13239
+ */
13240
+ var FailureReasonCountSchema = object({
13241
+ /**
13242
+ * Why the attempt did not land, in the contributor's own vocabulary —
13243
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13244
+ * strings that already appear in this repo's logs and, where one exists, the
13245
+ * same string the per-track `previewMissReason` records (D276): a second
13246
+ * vocabulary for the same loss would make the row and the counter
13247
+ * un-joinable.
13248
+ */
13249
+ reason: string(),
13250
+ count: number().int().nonnegative()
13251
+ });
13252
+ var FailureContributionSchema = object({
13253
+ /**
13254
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13255
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13256
+ * `unit` free: the families are owned by different addons and a shared enum
13257
+ * is a central list that rots invisibly.
13258
+ */
13259
+ family: string(),
13260
+ /**
13261
+ * The NUMERIC device id — the same value every log line carries as
13262
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13263
+ * cannot name the camera must not emit the entry, because a fleet total
13264
+ * cannot answer the only question anybody asks of this surface.
13265
+ */
13266
+ deviceId: number().int().positive(),
13267
+ /**
13268
+ * A second dimension inside the family: the model / step id for an inference
13269
+ * timeout, so "which camera AND which model" is one read. Absent when the
13270
+ * family has a single variant.
13271
+ */
13272
+ variant: string().optional(),
13273
+ /**
13274
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13275
+ * differencing two reads must drop the interval when it changes, because the
13276
+ * counter restarted from zero in a respawned runner. Same discipline as
13277
+ * `LoadContribution.startedAtMs`.
13278
+ */
13279
+ sinceMs: number(),
13280
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13281
+ atMs: number(),
13282
+ /**
13283
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13284
+ * window. A failure count published without it is the mistake this schema
13285
+ * exists to make impossible.
13286
+ */
13287
+ attempts: number().int().nonnegative(),
13288
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13289
+ succeeded: number().int().nonnegative(),
13290
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13291
+ reasons: array(FailureReasonCountSchema).readonly()
13292
+ });
13293
+ method(_void(), array(FailureContributionSchema).readonly());
13294
+ /**
13140
13295
  * filesystem-browse — per-node capability for browsing the node's local
13141
13296
  * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
13142
13297
  * are sandboxed to operator-configured allowed roots (D115). Used by the
@@ -13658,6 +13813,68 @@ method(LlmGenerateBaseInputSchema, LlmGenerateResultSchema, { kind: "mutation" }
13658
13813
  kind: "mutation",
13659
13814
  auth: "admin"
13660
13815
  });
13816
+ var LoadContributionSchema = object({
13817
+ role: _enum([
13818
+ "decode",
13819
+ "transcode",
13820
+ "recording",
13821
+ "streaming",
13822
+ "detection"
13823
+ ]),
13824
+ /**
13825
+ * The NUMERIC device id — the same value every log line carries as
13826
+ * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13827
+ * camera (a shared pool), NOT that the contributor forgot to look it up: a
13828
+ * contributor that cannot name its camera must not emit the entry at all,
13829
+ * because an unnamed per-camera entry is indistinguishable from a shared one
13830
+ * and would quietly turn one camera's cost into everybody's.
13831
+ */
13832
+ deviceId: number().int().positive().nullable(),
13833
+ attribution: _enum([
13834
+ "measured",
13835
+ "accounted",
13836
+ "unattributable"
13837
+ ]),
13838
+ /**
13839
+ * What ONE entry is, in the contributor's own words — `615/high`,
13840
+ * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13841
+ * family and inventing a common one would lose the only information that
13842
+ * makes two entries for the same camera distinguishable.
13843
+ */
13844
+ unit: string(),
13845
+ /**
13846
+ * The OS process this cost lives in, when there is one. Present so a
13847
+ * consumer can (a) tell two generations of the same unit apart across a
13848
+ * restart, and (b) subtract claimed processes from the node's process
13849
+ * snapshot to see what NOBODY claimed. Absent for an entry that owns no
13850
+ * process of its own.
13851
+ */
13852
+ pid: number().int().positive().optional(),
13853
+ /**
13854
+ * When this generation started. The pid's incarnation marker: a consumer
13855
+ * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
13856
+ * window when this changes, because the counter restarted from zero in a new
13857
+ * process.
13858
+ */
13859
+ startedAtMs: number().optional(),
13860
+ /**
13861
+ * CUMULATIVE CPU seconds this unit has consumed since it started — user +
13862
+ * system, read from the child's own `/proc/<pid>/stat` at the moment the
13863
+ * contribution is asked for.
13864
+ *
13865
+ * Cumulative and not a rate on purpose: a rate needs a window, a window
13866
+ * needs a sampler, and a new per-node sampler is the defect half of
13867
+ * `docs/architecture/load-ledger.md` documents. A counter can be differenced
13868
+ * by whoever already keeps a history; a rate cannot be un-averaged.
13869
+ *
13870
+ * Absent — never zero — on a node with no `/proc`, on a read failure, and on
13871
+ * an entry with no process.
13872
+ */
13873
+ cpuSeconds: number().optional(),
13874
+ /** Resident bytes of this unit's process, same source and same rules. */
13875
+ rssBytes: number().optional()
13876
+ });
13877
+ method(_void(), array(LoadContributionSchema).readonly());
13661
13878
  /**
13662
13879
  * `log-channels` — the capability an addon DECLARES its diagnostic channels
13663
13880
  * through. It stores nothing.
@@ -13734,176 +13951,6 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13734
13951
  tags: record(string(), string()).optional()
13735
13952
  }), array(LogEntrySchema).readonly());
13736
13953
  /**
13737
- * `failure-contribution` — the capability an addon reports its OWN losses
13738
- * through, per camera, with the denominator attached. It stores nothing.
13739
- *
13740
- * ## The twin of `load-contribution`, and why it is a twin and not a field
13741
- *
13742
- * `load-contribution` answers *what did this camera COST*. This answers *what
13743
- * did this camera LOSE*. The reporting discipline is identical and deliberately
13744
- * copied: the contributor reports what it already knows, hub-main adds only
13745
- * `addonId`, nothing needs global knowledge, and there is no central list for
13746
- * somebody to forget to edit.
13747
- *
13748
- * They are not merged, because their invariants are opposites:
13749
- *
13750
- * - a `load-contribution` measurement is **absent, never zero** — a zero would
13751
- * claim a camera cost nothing, which is a measurement nobody made;
13752
- * - a `failure-contribution` zero is the **most valuable value on the
13753
- * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13754
- * and it is exactly what an absent entry cannot say.
13755
- *
13756
- * Putting a loss counter on a cost entry would also break the reconciliation
13757
- * that gives `load-contribution` its point: contributions are subtracted from
13758
- * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13759
- * has no process.
13760
- *
13761
- * ## Why not a log line, since the counters already exist
13762
- *
13763
- * Several of these paths already counted themselves — `CaptureScheduler`'s
13764
- * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13765
- * ends in a log line, and a log line is the thing the operator asked to stop
13766
- * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13767
- * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13768
- * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13769
- * media blackout were both diagnosed. The counters stay; this is where they can
13770
- * be READ.
13771
- *
13772
- * ## The rate is served with its denominator or not at all
13773
- *
13774
- * Every entry carries `attempts` and `succeeded`. A miss count alone is
13775
- * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13776
- * than yesterday" and was **flat across twelve hours** once divided by the
13777
- * successes on the same path. A surface that publishes only the numerator
13778
- * reproduces that mistake on every read.
13779
- *
13780
- * ## Shape
13781
- *
13782
- * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13783
- * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13784
- * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13785
- * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13786
- * a forked runner's entries reach hub-main over transport that already exists.
13787
- * No new UDS message, no second registry (D3). The operator reads the assembled
13788
- * result through `system.getFailureContributions`.
13789
- */
13790
- var FailureReasonCountSchema = object({
13791
- /**
13792
- * Why the attempt did not land, in the contributor's own vocabulary —
13793
- * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13794
- * strings that already appear in this repo's logs and, where one exists, the
13795
- * same string the per-track `previewMissReason` records (D276): a second
13796
- * vocabulary for the same loss would make the row and the counter
13797
- * un-joinable.
13798
- */
13799
- reason: string(),
13800
- count: number().int().nonnegative()
13801
- });
13802
- var FailureContributionSchema = object({
13803
- /**
13804
- * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13805
- * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13806
- * `unit` free: the families are owned by different addons and a shared enum
13807
- * is a central list that rots invisibly.
13808
- */
13809
- family: string(),
13810
- /**
13811
- * The NUMERIC device id — the same value every log line carries as
13812
- * `tags.deviceId`. Never nullable and never absent: a contributor that
13813
- * cannot name the camera must not emit the entry, because a fleet total
13814
- * cannot answer the only question anybody asks of this surface.
13815
- */
13816
- deviceId: number().int().positive(),
13817
- /**
13818
- * A second dimension inside the family: the model / step id for an inference
13819
- * timeout, so "which camera AND which model" is one read. Absent when the
13820
- * family has a single variant.
13821
- */
13822
- variant: string().optional(),
13823
- /**
13824
- * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13825
- * differencing two reads must drop the interval when it changes, because the
13826
- * counter restarted from zero in a respawned runner. Same discipline as
13827
- * `LoadContribution.startedAtMs`.
13828
- */
13829
- sinceMs: number(),
13830
- /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13831
- atMs: number(),
13832
- /**
13833
- * THE DENOMINATOR — every attempt on this path for this camera in the
13834
- * window. A failure count published without it is the mistake this schema
13835
- * exists to make impossible.
13836
- */
13837
- attempts: number().int().nonnegative(),
13838
- /** Attempts that landed. `attempts - succeeded` is the loss. */
13839
- succeeded: number().int().nonnegative(),
13840
- /** The loss, partitioned. Sums to `attempts - succeeded`. */
13841
- reasons: array(FailureReasonCountSchema).readonly()
13842
- });
13843
- method(_void(), array(FailureContributionSchema).readonly());
13844
- var LoadContributionSchema = object({
13845
- role: _enum([
13846
- "decode",
13847
- "transcode",
13848
- "recording",
13849
- "streaming",
13850
- "detection"
13851
- ]),
13852
- /**
13853
- * The NUMERIC device id — the same value every log line carries as
13854
- * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13855
- * camera (a shared pool), NOT that the contributor forgot to look it up: a
13856
- * contributor that cannot name its camera must not emit the entry at all,
13857
- * because an unnamed per-camera entry is indistinguishable from a shared one
13858
- * and would quietly turn one camera's cost into everybody's.
13859
- */
13860
- deviceId: number().int().positive().nullable(),
13861
- attribution: _enum([
13862
- "measured",
13863
- "accounted",
13864
- "unattributable"
13865
- ]),
13866
- /**
13867
- * What ONE entry is, in the contributor's own words — `615/high`,
13868
- * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13869
- * family and inventing a common one would lose the only information that
13870
- * makes two entries for the same camera distinguishable.
13871
- */
13872
- unit: string(),
13873
- /**
13874
- * The OS process this cost lives in, when there is one. Present so a
13875
- * consumer can (a) tell two generations of the same unit apart across a
13876
- * restart, and (b) subtract claimed processes from the node's process
13877
- * snapshot to see what NOBODY claimed. Absent for an entry that owns no
13878
- * process of its own.
13879
- */
13880
- pid: number().int().positive().optional(),
13881
- /**
13882
- * When this generation started. The pid's incarnation marker: a consumer
13883
- * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
13884
- * window when this changes, because the counter restarted from zero in a new
13885
- * process.
13886
- */
13887
- startedAtMs: number().optional(),
13888
- /**
13889
- * CUMULATIVE CPU seconds this unit has consumed since it started — user +
13890
- * system, read from the child's own `/proc/<pid>/stat` at the moment the
13891
- * contribution is asked for.
13892
- *
13893
- * Cumulative and not a rate on purpose: a rate needs a window, a window
13894
- * needs a sampler, and a new per-node sampler is the defect half of
13895
- * `docs/architecture/load-ledger.md` documents. A counter can be differenced
13896
- * by whoever already keeps a history; a rate cannot be un-averaged.
13897
- *
13898
- * Absent — never zero — on a node with no `/proc`, on a read failure, and on
13899
- * an entry with no process.
13900
- */
13901
- cpuSeconds: number().optional(),
13902
- /** Resident bytes of this unit's process, same source and same rules. */
13903
- rssBytes: number().optional()
13904
- });
13905
- method(_void(), array(LoadContributionSchema).readonly());
13906
- /**
13907
13954
  * `login-method` — collection cap through which auth addons contribute
13908
13955
  * their pre-auth login surfaces to the login page. This is the SINGLE,
13909
13956
  * generic mechanism that supersedes the dead `auth.listProviders` reader:
@@ -18641,12 +18688,53 @@ var MediaFileKindEnum = _enum([
18641
18688
  "keyFrameSmall",
18642
18689
  "thumbnailSmall"
18643
18690
  ]);
18691
+ /**
18692
+ * One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
18693
+ * ARE — never the bytes themselves.
18694
+ *
18695
+ * ## Why `url` and not `base64`
18696
+ *
18697
+ * Measured on the live hub 2026-08-30: `getTrackMedia {trackId, deviceId}`
18698
+ * with no `kinds` returned 6 rows / **3 597 219 B**, of which `keyFrame` alone
18699
+ * was **2 824 077 B** — one full-resolution frame, base64, so +33 % on the
18700
+ * wire. Forty events is ~144 MB. Every byte of it was read off disk,
18701
+ * base64-encoded, held whole in a unary tRPC envelope, and materialised in
18702
+ * hub-main's heap on the way past — for an `<img>` that would have cached it.
18703
+ *
18704
+ * `url` points at the `event-media` data plane
18705
+ * (`/addon/<addonId>/event-media/<storedKey>`), which serves the same blob
18706
+ * with an ETag and `Cache-Control: immutable`, honours conditional GETs, can
18707
+ * render a `?variant=thumb`, and streams. The hub gate in front of it requires
18708
+ * a bearer or the session cookie (`access: 'authenticated'`), so the bytes are
18709
+ * no less protected than they were inside a `view`-level cap response — see
18710
+ * `data-plane-access.ts` for the rule and the one gap it does not close
18711
+ * (per-device scoping).
18712
+ *
18713
+ * The URL is built from the row's **stored** key, which is not always its
18714
+ * published `kind`: a track's face/plate crop is stored as `crop` under
18715
+ * `('face'|'plate', '<prefix>-<trackId>')` and published as
18716
+ * `faceCrop`/`plateCrop`. `MediaStore.getByKey` knows only the stored key.
18717
+ *
18718
+ * ## `base64` is TRANSITIONAL and is going away
18719
+ *
18720
+ * It is still populated for one reason: the deployed viewer's track-detail
18721
+ * HERO tile reads it (`use-track-media-entry.ts` → `parseMediaFiles`, which
18722
+ * REQUIRES the field), and a row without it parses as a FAILED read — the red
18723
+ * triangle — not as absence. Removing the field before that viewer ships is an
18724
+ * outage, not a cleanup. Once the viewer takes its hero bytes from `url`,
18725
+ * delete this line and the `withBytes` pass-through in
18726
+ * `analytics-query-facade.ts`; nothing else reads it.
18727
+ */
18644
18728
  var MediaFileSchema = object({
18645
18729
  key: string(),
18646
18730
  kind: MediaFileKindEnum,
18647
- base64: string(),
18648
18731
  sizeBytes: number(),
18649
18732
  timestamp: number()
18733
+ }).extend({
18734
+ /** `/addon/<addonId>/event-media/<encoded stored key>`. Always present. */
18735
+ url: string(),
18736
+ /** @deprecated Transitional — see the schema docblock. Use {@link url}. */
18737
+ base64: string()
18650
18738
  });
18651
18739
  /**
18652
18740
  * One media row WITHOUT its bytes.
@@ -18658,7 +18746,9 @@ var MediaFileSchema = object({
18658
18746
  * blocks the whole view.
18659
18747
  *
18660
18748
  * `sizeBytes` is carried because it is what lets a client decide between the
18661
- * stored blob and a `?variant=thumb` rendering without fetching either.
18749
+ * stored blob and a `?variant=thumb` rendering without fetching either, and
18750
+ * `url` because a client that had to build the plane path itself is a second
18751
+ * copy of a route — the embed, the viewer and the admin UI each grew one.
18662
18752
  */
18663
18753
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
18664
18754
  /**
@@ -19347,6 +19437,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19347
19437
  }), array(MediaFileSchema).readonly()), method(object({
19348
19438
  trackId: string(),
19349
19439
  deviceId: number()
19440
+ }), array(MediaFileInfoSchema).readonly()), method(object({
19441
+ eventId: string(),
19442
+ deviceId: number()
19350
19443
  }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
19351
19444
  kind: "mutation",
19352
19445
  auth: "admin"
@@ -24354,10 +24447,24 @@ var FaceClusterSchema = object({
24354
24447
  size: number().int(),
24355
24448
  cohesion: number()
24356
24449
  });
24450
+ /**
24451
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
24452
+ * are — never the bytes.
24453
+ *
24454
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
24455
+ * track/event contract) is still populated because a deployed viewer requires
24456
+ * the field to parse a row at all; this method has no such reader. Its ONE
24457
+ * caller is the admin UI's detail modal, which was building
24458
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
24459
+ * dialog already rendering its key FRAME from the `event-media` plane.
24460
+ *
24461
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
24462
+ * media key directly, so this needed no new plane and no new access decision.
24463
+ */
24357
24464
  var MediaFileLiteSchema$1 = object({
24358
24465
  key: string(),
24359
24466
  kind: string(),
24360
- base64: string(),
24467
+ url: string(),
24361
24468
  sizeBytes: number(),
24362
24469
  timestamp: number()
24363
24470
  });
@@ -27379,10 +27486,24 @@ var PlateInfoSchema = object({
27379
27486
  */
27380
27487
  cropUrl: string().optional()
27381
27488
  });
27489
+ /**
27490
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
27491
+ * are — never the bytes.
27492
+ *
27493
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
27494
+ * track/event contract) is still populated because a deployed viewer requires
27495
+ * the field to parse a row at all; this method has no such reader. Its ONE
27496
+ * caller is the admin UI's detail modal, which was building
27497
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
27498
+ * dialog already rendering its key FRAME from the `event-media` plane.
27499
+ *
27500
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
27501
+ * media key directly, so this needed no new plane and no new access decision.
27502
+ */
27382
27503
  var MediaFileLiteSchema = object({
27383
27504
  key: string(),
27384
27505
  kind: string(),
27385
- base64: string(),
27506
+ url: string(),
27386
27507
  sizeBytes: number(),
27387
27508
  timestamp: number()
27388
27509
  });
@@ -35279,6 +35400,12 @@ Object.freeze({
35279
35400
  addonId: null,
35280
35401
  access: "view"
35281
35402
  },
35403
+ "pipelineAnalytics.listEventMedia": {
35404
+ capName: "pipeline-analytics",
35405
+ capScope: "device",
35406
+ addonId: null,
35407
+ access: "view"
35408
+ },
35282
35409
  "pipelineAnalytics.listGroups": {
35283
35410
  capName: "pipeline-analytics",
35284
35411
  capScope: "device",
@@ -38902,6 +39029,11 @@ Object.freeze({
38902
39029
  form: "array",
38903
39030
  optional: false
38904
39031
  }],
39032
+ "pipelineAnalytics.listEventMedia": [{
39033
+ name: "deviceId",
39034
+ form: "single",
39035
+ optional: false
39036
+ }],
38905
39037
  "pipelineAnalytics.listGroups": [{
38906
39038
  name: "deviceIds",
38907
39039
  form: "array",
package/dist/addon.mjs CHANGED
@@ -8002,6 +8002,21 @@ var RelocateJobSchema = object({
8002
8002
  bytesMoved: number().int(),
8003
8003
  /** Total files discovered up front; null while (or when) unknown. */
8004
8004
  filesTotal: number().int().nullable(),
8005
+ /**
8006
+ * Rows this run CORRECTED while moving them — a durable mutation the move
8007
+ * made that nobody asked for, so it is reported where the operator reads the
8008
+ * job rather than only in a log line.
8009
+ *
8010
+ * A footage segment records its byte count in its own NAME, and the durable
8011
+ * hour row derives its aggregates from those names. A file that does not
8012
+ * match its name therefore makes the ledger's sums — and with them quota and
8013
+ * pressure eviction — wrong by the difference, and only a rename can fix it.
8014
+ * On 2026-08-30 one such row also stalled a 110 749-file drain permanently.
8015
+ *
8016
+ * Absent on lanes where the question has no meaning: a media blob's size is
8017
+ * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8018
+ */
8019
+ rowsReconciled: number().int().nonnegative().optional(),
8005
8020
  startedAt: number(),
8006
8021
  finishedAt: number().nullable(),
8007
8022
  error: string().nullable()
@@ -8070,14 +8085,42 @@ var RelocateMediaInputSchema = object({
8070
8085
  /** Omitted = `move`, the pre-existing behaviour. */
8071
8086
  mode: MediaRelocateModeSchema.optional()
8072
8087
  });
8073
- /** How many rows still carry NO `locationId` — the population a repoint would
8074
- * silently re-aim at a disk that does not hold their bytes. Zero is the only
8075
- * value that permits a non-blocking `eventMedia` cutover. */
8076
- var UnstampedEventMediaCountSchema = object({
8077
- media: number().int().nonnegative(),
8078
- retrainFrames: number().int().nonnegative(),
8079
- total: number().int().nonnegative()
8088
+ /**
8089
+ * The unstamped population of ONE collection — split, because the gate and the
8090
+ * operator ask two different questions and only one of them has to be cheap.
8091
+ *
8092
+ * `present` is the GATE: "is there at least one row that would be orphaned by a
8093
+ * repoint". It is a single indexed seek to the first matching row, so it stays
8094
+ * answerable on a saturated disk and answers in O(log n) precisely in the state
8095
+ * that matters — after a seal, when the population is empty.
8096
+ *
8097
+ * `rows` is the NUMBER, for the refusal message and the operator's sense of
8098
+ * scale. It is a second, indexed `COUNT(*)`, and `null` means **not
8099
+ * measurable** — never zero. `{ present: true, rows: null }` is a legitimate
8100
+ * and useful answer: "there are some, and this read could not say how many"
8101
+ * still refuses the cutover, which is the whole job.
8102
+ */
8103
+ var UnstampedRowsSchema = object({
8104
+ present: boolean(),
8105
+ rows: number().int().nonnegative().nullable()
8080
8106
  });
8107
+ /**
8108
+ * How many rows still carry NO `locationId` — the population a repoint would
8109
+ * silently re-aim at a disk that does not hold their bytes.
8110
+ *
8111
+ * **`null` = the count could not be taken**, and it is NOT permission to cut
8112
+ * over. The gate opens on a measured absence and on nothing else; an unread
8113
+ * collection and an empty one are different facts, and this repo has already
8114
+ * paid for conflating them (`RelocateResidueSchema`, D295).
8115
+ */
8116
+ var UnstampedEventMediaCountSchema = object({
8117
+ media: UnstampedRowsSchema,
8118
+ retrainFrames: UnstampedRowsSchema,
8119
+ /** True when EITHER collection holds one. The refusal reads this. */
8120
+ anyPresent: boolean(),
8121
+ /** Sum across both, or `null` when either lane could not be counted. */
8122
+ total: number().int().nonnegative().nullable()
8123
+ }).nullable();
8081
8124
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
8082
8125
  /** The independently selectable logical storage classes — every class
8083
8126
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -8188,6 +8231,10 @@ var StorageMigrationMoveProgressSchema = object({
8188
8231
  /** The archive census — the **M** of "N of M" (D295). `null` = unknowable. */
8189
8232
  filesTotal: number().int().nonnegative().nullable(),
8190
8233
  bytesMoved: number().int().nonnegative(),
8234
+ /** Rows the mover corrected while moving them — see `RelocateJob`. Absent on
8235
+ * a lane that cannot reconcile. A migration that silently rewrote durable
8236
+ * rows would be the same failure as one that silently skipped them. */
8237
+ rowsReconciled: number().int().nonnegative().optional(),
8191
8238
  /** The MOVER's start, not the migration's: a drain restarted after an addon
8192
8239
  * crash gets a new mover, and a rate computed from the migration's start
8193
8240
  * would silently average in the time nothing was running. */
@@ -13136,6 +13183,114 @@ method(object({
13136
13183
  height: number()
13137
13184
  }), EmbeddingResultSchema, { auth: "admin" }), method(object({ text: string() }), EmbeddingResultSchema, { auth: "admin" }), method(_void(), EmbeddingInfoSchema, { auth: "admin" });
13138
13185
  /**
13186
+ * `failure-contribution` — the capability an addon reports its OWN losses
13187
+ * through, per camera, with the denominator attached. It stores nothing.
13188
+ *
13189
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13190
+ *
13191
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13192
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13193
+ * copied: the contributor reports what it already knows, hub-main adds only
13194
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13195
+ * somebody to forget to edit.
13196
+ *
13197
+ * They are not merged, because their invariants are opposites:
13198
+ *
13199
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13200
+ * claim a camera cost nothing, which is a measurement nobody made;
13201
+ * - a `failure-contribution` zero is the **most valuable value on the
13202
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13203
+ * and it is exactly what an absent entry cannot say.
13204
+ *
13205
+ * Putting a loss counter on a cost entry would also break the reconciliation
13206
+ * that gives `load-contribution` its point: contributions are subtracted from
13207
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13208
+ * has no process.
13209
+ *
13210
+ * ## Why not a log line, since the counters already exist
13211
+ *
13212
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13213
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13214
+ * ends in a log line, and a log line is the thing the operator asked to stop
13215
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13216
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13217
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13218
+ * media blackout were both diagnosed. The counters stay; this is where they can
13219
+ * be READ.
13220
+ *
13221
+ * ## The rate is served with its denominator or not at all
13222
+ *
13223
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13224
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13225
+ * than yesterday" and was **flat across twelve hours** once divided by the
13226
+ * successes on the same path. A surface that publishes only the numerator
13227
+ * reproduces that mistake on every read.
13228
+ *
13229
+ * ## Shape
13230
+ *
13231
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13232
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13233
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13234
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13235
+ * a forked runner's entries reach hub-main over transport that already exists.
13236
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13237
+ * result through `system.getFailureContributions`.
13238
+ */
13239
+ var FailureReasonCountSchema = object({
13240
+ /**
13241
+ * Why the attempt did not land, in the contributor's own vocabulary —
13242
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13243
+ * strings that already appear in this repo's logs and, where one exists, the
13244
+ * same string the per-track `previewMissReason` records (D276): a second
13245
+ * vocabulary for the same loss would make the row and the counter
13246
+ * un-joinable.
13247
+ */
13248
+ reason: string(),
13249
+ count: number().int().nonnegative()
13250
+ });
13251
+ var FailureContributionSchema = object({
13252
+ /**
13253
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13254
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13255
+ * `unit` free: the families are owned by different addons and a shared enum
13256
+ * is a central list that rots invisibly.
13257
+ */
13258
+ family: string(),
13259
+ /**
13260
+ * The NUMERIC device id — the same value every log line carries as
13261
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13262
+ * cannot name the camera must not emit the entry, because a fleet total
13263
+ * cannot answer the only question anybody asks of this surface.
13264
+ */
13265
+ deviceId: number().int().positive(),
13266
+ /**
13267
+ * A second dimension inside the family: the model / step id for an inference
13268
+ * timeout, so "which camera AND which model" is one read. Absent when the
13269
+ * family has a single variant.
13270
+ */
13271
+ variant: string().optional(),
13272
+ /**
13273
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13274
+ * differencing two reads must drop the interval when it changes, because the
13275
+ * counter restarted from zero in a respawned runner. Same discipline as
13276
+ * `LoadContribution.startedAtMs`.
13277
+ */
13278
+ sinceMs: number(),
13279
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13280
+ atMs: number(),
13281
+ /**
13282
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13283
+ * window. A failure count published without it is the mistake this schema
13284
+ * exists to make impossible.
13285
+ */
13286
+ attempts: number().int().nonnegative(),
13287
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13288
+ succeeded: number().int().nonnegative(),
13289
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13290
+ reasons: array(FailureReasonCountSchema).readonly()
13291
+ });
13292
+ method(_void(), array(FailureContributionSchema).readonly());
13293
+ /**
13139
13294
  * filesystem-browse — per-node capability for browsing the node's local
13140
13295
  * filesystem. Reads are unconfined (whole filesystem, from `/` down); WRITES
13141
13296
  * are sandboxed to operator-configured allowed roots (D115). Used by the
@@ -13657,6 +13812,68 @@ method(LlmGenerateBaseInputSchema, LlmGenerateResultSchema, { kind: "mutation" }
13657
13812
  kind: "mutation",
13658
13813
  auth: "admin"
13659
13814
  });
13815
+ var LoadContributionSchema = object({
13816
+ role: _enum([
13817
+ "decode",
13818
+ "transcode",
13819
+ "recording",
13820
+ "streaming",
13821
+ "detection"
13822
+ ]),
13823
+ /**
13824
+ * The NUMERIC device id — the same value every log line carries as
13825
+ * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13826
+ * camera (a shared pool), NOT that the contributor forgot to look it up: a
13827
+ * contributor that cannot name its camera must not emit the entry at all,
13828
+ * because an unnamed per-camera entry is indistinguishable from a shared one
13829
+ * and would quietly turn one camera's cost into everybody's.
13830
+ */
13831
+ deviceId: number().int().positive().nullable(),
13832
+ attribution: _enum([
13833
+ "measured",
13834
+ "accounted",
13835
+ "unattributable"
13836
+ ]),
13837
+ /**
13838
+ * What ONE entry is, in the contributor's own words — `615/high`,
13839
+ * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13840
+ * family and inventing a common one would lose the only information that
13841
+ * makes two entries for the same camera distinguishable.
13842
+ */
13843
+ unit: string(),
13844
+ /**
13845
+ * The OS process this cost lives in, when there is one. Present so a
13846
+ * consumer can (a) tell two generations of the same unit apart across a
13847
+ * restart, and (b) subtract claimed processes from the node's process
13848
+ * snapshot to see what NOBODY claimed. Absent for an entry that owns no
13849
+ * process of its own.
13850
+ */
13851
+ pid: number().int().positive().optional(),
13852
+ /**
13853
+ * When this generation started. The pid's incarnation marker: a consumer
13854
+ * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
13855
+ * window when this changes, because the counter restarted from zero in a new
13856
+ * process.
13857
+ */
13858
+ startedAtMs: number().optional(),
13859
+ /**
13860
+ * CUMULATIVE CPU seconds this unit has consumed since it started — user +
13861
+ * system, read from the child's own `/proc/<pid>/stat` at the moment the
13862
+ * contribution is asked for.
13863
+ *
13864
+ * Cumulative and not a rate on purpose: a rate needs a window, a window
13865
+ * needs a sampler, and a new per-node sampler is the defect half of
13866
+ * `docs/architecture/load-ledger.md` documents. A counter can be differenced
13867
+ * by whoever already keeps a history; a rate cannot be un-averaged.
13868
+ *
13869
+ * Absent — never zero — on a node with no `/proc`, on a read failure, and on
13870
+ * an entry with no process.
13871
+ */
13872
+ cpuSeconds: number().optional(),
13873
+ /** Resident bytes of this unit's process, same source and same rules. */
13874
+ rssBytes: number().optional()
13875
+ });
13876
+ method(_void(), array(LoadContributionSchema).readonly());
13660
13877
  /**
13661
13878
  * `log-channels` — the capability an addon DECLARES its diagnostic channels
13662
13879
  * through. It stores nothing.
@@ -13733,176 +13950,6 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13733
13950
  tags: record(string(), string()).optional()
13734
13951
  }), array(LogEntrySchema).readonly());
13735
13952
  /**
13736
- * `failure-contribution` — the capability an addon reports its OWN losses
13737
- * through, per camera, with the denominator attached. It stores nothing.
13738
- *
13739
- * ## The twin of `load-contribution`, and why it is a twin and not a field
13740
- *
13741
- * `load-contribution` answers *what did this camera COST*. This answers *what
13742
- * did this camera LOSE*. The reporting discipline is identical and deliberately
13743
- * copied: the contributor reports what it already knows, hub-main adds only
13744
- * `addonId`, nothing needs global knowledge, and there is no central list for
13745
- * somebody to forget to edit.
13746
- *
13747
- * They are not merged, because their invariants are opposites:
13748
- *
13749
- * - a `load-contribution` measurement is **absent, never zero** — a zero would
13750
- * claim a camera cost nothing, which is a measurement nobody made;
13751
- * - a `failure-contribution` zero is the **most valuable value on the
13752
- * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13753
- * and it is exactly what an absent entry cannot say.
13754
- *
13755
- * Putting a loss counter on a cost entry would also break the reconciliation
13756
- * that gives `load-contribution` its point: contributions are subtracted from
13757
- * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13758
- * has no process.
13759
- *
13760
- * ## Why not a log line, since the counters already exist
13761
- *
13762
- * Several of these paths already counted themselves — `CaptureScheduler`'s
13763
- * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13764
- * ends in a log line, and a log line is the thing the operator asked to stop
13765
- * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13766
- * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13767
- * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13768
- * media blackout were both diagnosed. The counters stay; this is where they can
13769
- * be READ.
13770
- *
13771
- * ## The rate is served with its denominator or not at all
13772
- *
13773
- * Every entry carries `attempts` and `succeeded`. A miss count alone is
13774
- * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13775
- * than yesterday" and was **flat across twelve hours** once divided by the
13776
- * successes on the same path. A surface that publishes only the numerator
13777
- * reproduces that mistake on every read.
13778
- *
13779
- * ## Shape
13780
- *
13781
- * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13782
- * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13783
- * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13784
- * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13785
- * a forked runner's entries reach hub-main over transport that already exists.
13786
- * No new UDS message, no second registry (D3). The operator reads the assembled
13787
- * result through `system.getFailureContributions`.
13788
- */
13789
- var FailureReasonCountSchema = object({
13790
- /**
13791
- * Why the attempt did not land, in the contributor's own vocabulary —
13792
- * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13793
- * strings that already appear in this repo's logs and, where one exists, the
13794
- * same string the per-track `previewMissReason` records (D276): a second
13795
- * vocabulary for the same loss would make the row and the counter
13796
- * un-joinable.
13797
- */
13798
- reason: string(),
13799
- count: number().int().nonnegative()
13800
- });
13801
- var FailureContributionSchema = object({
13802
- /**
13803
- * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13804
- * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13805
- * `unit` free: the families are owned by different addons and a shared enum
13806
- * is a central list that rots invisibly.
13807
- */
13808
- family: string(),
13809
- /**
13810
- * The NUMERIC device id — the same value every log line carries as
13811
- * `tags.deviceId`. Never nullable and never absent: a contributor that
13812
- * cannot name the camera must not emit the entry, because a fleet total
13813
- * cannot answer the only question anybody asks of this surface.
13814
- */
13815
- deviceId: number().int().positive(),
13816
- /**
13817
- * A second dimension inside the family: the model / step id for an inference
13818
- * timeout, so "which camera AND which model" is one read. Absent when the
13819
- * family has a single variant.
13820
- */
13821
- variant: string().optional(),
13822
- /**
13823
- * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13824
- * differencing two reads must drop the interval when it changes, because the
13825
- * counter restarted from zero in a respawned runner. Same discipline as
13826
- * `LoadContribution.startedAtMs`.
13827
- */
13828
- sinceMs: number(),
13829
- /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13830
- atMs: number(),
13831
- /**
13832
- * THE DENOMINATOR — every attempt on this path for this camera in the
13833
- * window. A failure count published without it is the mistake this schema
13834
- * exists to make impossible.
13835
- */
13836
- attempts: number().int().nonnegative(),
13837
- /** Attempts that landed. `attempts - succeeded` is the loss. */
13838
- succeeded: number().int().nonnegative(),
13839
- /** The loss, partitioned. Sums to `attempts - succeeded`. */
13840
- reasons: array(FailureReasonCountSchema).readonly()
13841
- });
13842
- method(_void(), array(FailureContributionSchema).readonly());
13843
- var LoadContributionSchema = object({
13844
- role: _enum([
13845
- "decode",
13846
- "transcode",
13847
- "recording",
13848
- "streaming",
13849
- "detection"
13850
- ]),
13851
- /**
13852
- * The NUMERIC device id — the same value every log line carries as
13853
- * `tags.deviceId`. `null` means this cost genuinely belongs to no single
13854
- * camera (a shared pool), NOT that the contributor forgot to look it up: a
13855
- * contributor that cannot name its camera must not emit the entry at all,
13856
- * because an unnamed per-camera entry is indistinguishable from a shared one
13857
- * and would quietly turn one camera's cost into everybody's.
13858
- */
13859
- deviceId: number().int().positive().nullable(),
13860
- attribution: _enum([
13861
- "measured",
13862
- "accounted",
13863
- "unattributable"
13864
- ]),
13865
- /**
13866
- * What ONE entry is, in the contributor's own words — `615/high`,
13867
- * `617/native`, `cuda:0 shared pool`. Free text because the unit differs per
13868
- * family and inventing a common one would lose the only information that
13869
- * makes two entries for the same camera distinguishable.
13870
- */
13871
- unit: string(),
13872
- /**
13873
- * The OS process this cost lives in, when there is one. Present so a
13874
- * consumer can (a) tell two generations of the same unit apart across a
13875
- * restart, and (b) subtract claimed processes from the node's process
13876
- * snapshot to see what NOBODY claimed. Absent for an entry that owns no
13877
- * process of its own.
13878
- */
13879
- pid: number().int().positive().optional(),
13880
- /**
13881
- * When this generation started. The pid's incarnation marker: a consumer
13882
- * differencing {@link LoadContributionSchema.shape.cpuSeconds} must drop the
13883
- * window when this changes, because the counter restarted from zero in a new
13884
- * process.
13885
- */
13886
- startedAtMs: number().optional(),
13887
- /**
13888
- * CUMULATIVE CPU seconds this unit has consumed since it started — user +
13889
- * system, read from the child's own `/proc/<pid>/stat` at the moment the
13890
- * contribution is asked for.
13891
- *
13892
- * Cumulative and not a rate on purpose: a rate needs a window, a window
13893
- * needs a sampler, and a new per-node sampler is the defect half of
13894
- * `docs/architecture/load-ledger.md` documents. A counter can be differenced
13895
- * by whoever already keeps a history; a rate cannot be un-averaged.
13896
- *
13897
- * Absent — never zero — on a node with no `/proc`, on a read failure, and on
13898
- * an entry with no process.
13899
- */
13900
- cpuSeconds: number().optional(),
13901
- /** Resident bytes of this unit's process, same source and same rules. */
13902
- rssBytes: number().optional()
13903
- });
13904
- method(_void(), array(LoadContributionSchema).readonly());
13905
- /**
13906
13953
  * `login-method` — collection cap through which auth addons contribute
13907
13954
  * their pre-auth login surfaces to the login page. This is the SINGLE,
13908
13955
  * generic mechanism that supersedes the dead `auth.listProviders` reader:
@@ -18640,12 +18687,53 @@ var MediaFileKindEnum = _enum([
18640
18687
  "keyFrameSmall",
18641
18688
  "thumbnailSmall"
18642
18689
  ]);
18690
+ /**
18691
+ * One media row ON THE WIRE: what it is, how big it is, and WHERE ITS BYTES
18692
+ * ARE — never the bytes themselves.
18693
+ *
18694
+ * ## Why `url` and not `base64`
18695
+ *
18696
+ * Measured on the live hub 2026-08-30: `getTrackMedia {trackId, deviceId}`
18697
+ * with no `kinds` returned 6 rows / **3 597 219 B**, of which `keyFrame` alone
18698
+ * was **2 824 077 B** — one full-resolution frame, base64, so +33 % on the
18699
+ * wire. Forty events is ~144 MB. Every byte of it was read off disk,
18700
+ * base64-encoded, held whole in a unary tRPC envelope, and materialised in
18701
+ * hub-main's heap on the way past — for an `<img>` that would have cached it.
18702
+ *
18703
+ * `url` points at the `event-media` data plane
18704
+ * (`/addon/<addonId>/event-media/<storedKey>`), which serves the same blob
18705
+ * with an ETag and `Cache-Control: immutable`, honours conditional GETs, can
18706
+ * render a `?variant=thumb`, and streams. The hub gate in front of it requires
18707
+ * a bearer or the session cookie (`access: 'authenticated'`), so the bytes are
18708
+ * no less protected than they were inside a `view`-level cap response — see
18709
+ * `data-plane-access.ts` for the rule and the one gap it does not close
18710
+ * (per-device scoping).
18711
+ *
18712
+ * The URL is built from the row's **stored** key, which is not always its
18713
+ * published `kind`: a track's face/plate crop is stored as `crop` under
18714
+ * `('face'|'plate', '<prefix>-<trackId>')` and published as
18715
+ * `faceCrop`/`plateCrop`. `MediaStore.getByKey` knows only the stored key.
18716
+ *
18717
+ * ## `base64` is TRANSITIONAL and is going away
18718
+ *
18719
+ * It is still populated for one reason: the deployed viewer's track-detail
18720
+ * HERO tile reads it (`use-track-media-entry.ts` → `parseMediaFiles`, which
18721
+ * REQUIRES the field), and a row without it parses as a FAILED read — the red
18722
+ * triangle — not as absence. Removing the field before that viewer ships is an
18723
+ * outage, not a cleanup. Once the viewer takes its hero bytes from `url`,
18724
+ * delete this line and the `withBytes` pass-through in
18725
+ * `analytics-query-facade.ts`; nothing else reads it.
18726
+ */
18643
18727
  var MediaFileSchema = object({
18644
18728
  key: string(),
18645
18729
  kind: MediaFileKindEnum,
18646
- base64: string(),
18647
18730
  sizeBytes: number(),
18648
18731
  timestamp: number()
18732
+ }).extend({
18733
+ /** `/addon/<addonId>/event-media/<encoded stored key>`. Always present. */
18734
+ url: string(),
18735
+ /** @deprecated Transitional — see the schema docblock. Use {@link url}. */
18736
+ base64: string()
18649
18737
  });
18650
18738
  /**
18651
18739
  * One media row WITHOUT its bytes.
@@ -18657,7 +18745,9 @@ var MediaFileSchema = object({
18657
18745
  * blocks the whole view.
18658
18746
  *
18659
18747
  * `sizeBytes` is carried because it is what lets a client decide between the
18660
- * stored blob and a `?variant=thumb` rendering without fetching either.
18748
+ * stored blob and a `?variant=thumb` rendering without fetching either, and
18749
+ * `url` because a client that had to build the plane path itself is a second
18750
+ * copy of a route — the embed, the viewer and the admin UI each grew one.
18661
18751
  */
18662
18752
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
18663
18753
  /**
@@ -19346,6 +19436,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19346
19436
  }), array(MediaFileSchema).readonly()), method(object({
19347
19437
  trackId: string(),
19348
19438
  deviceId: number()
19439
+ }), array(MediaFileInfoSchema).readonly()), method(object({
19440
+ eventId: string(),
19441
+ deviceId: number()
19349
19442
  }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
19350
19443
  kind: "mutation",
19351
19444
  auth: "admin"
@@ -24353,10 +24446,24 @@ var FaceClusterSchema = object({
24353
24446
  size: number().int(),
24354
24447
  cohesion: number()
24355
24448
  });
24449
+ /**
24450
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
24451
+ * are — never the bytes.
24452
+ *
24453
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
24454
+ * track/event contract) is still populated because a deployed viewer requires
24455
+ * the field to parse a row at all; this method has no such reader. Its ONE
24456
+ * caller is the admin UI's detail modal, which was building
24457
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
24458
+ * dialog already rendering its key FRAME from the `event-media` plane.
24459
+ *
24460
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
24461
+ * media key directly, so this needed no new plane and no new access decision.
24462
+ */
24356
24463
  var MediaFileLiteSchema$1 = object({
24357
24464
  key: string(),
24358
24465
  kind: string(),
24359
- base64: string(),
24466
+ url: string(),
24360
24467
  sizeBytes: number(),
24361
24468
  timestamp: number()
24362
24469
  });
@@ -27378,10 +27485,24 @@ var PlateInfoSchema = object({
27378
27485
  */
27379
27486
  cropUrl: string().optional()
27380
27487
  });
27488
+ /**
27489
+ * One gallery media row: what the crop is, how big it is, and WHERE its bytes
27490
+ * are — never the bytes.
27491
+ *
27492
+ * `base64` was deleted here rather than deprecated. `MediaFile.base64` (the
27493
+ * track/event contract) is still populated because a deployed viewer requires
27494
+ * the field to parse a row at all; this method has no such reader. Its ONE
27495
+ * caller is the admin UI's detail modal, which was building
27496
+ * `data:image/jpeg;base64,…` from a row whose `key` sat right beside it, in a
27497
+ * dialog already rendering its key FRAME from the `event-media` plane.
27498
+ *
27499
+ * `url` is `/addon/<addonId>/event-media/<encoded key>`. The plane resolves a
27500
+ * media key directly, so this needed no new plane and no new access decision.
27501
+ */
27381
27502
  var MediaFileLiteSchema = object({
27382
27503
  key: string(),
27383
27504
  kind: string(),
27384
- base64: string(),
27505
+ url: string(),
27385
27506
  sizeBytes: number(),
27386
27507
  timestamp: number()
27387
27508
  });
@@ -35278,6 +35399,12 @@ Object.freeze({
35278
35399
  addonId: null,
35279
35400
  access: "view"
35280
35401
  },
35402
+ "pipelineAnalytics.listEventMedia": {
35403
+ capName: "pipeline-analytics",
35404
+ capScope: "device",
35405
+ addonId: null,
35406
+ access: "view"
35407
+ },
35281
35408
  "pipelineAnalytics.listGroups": {
35282
35409
  capName: "pipeline-analytics",
35283
35410
  capScope: "device",
@@ -38901,6 +39028,11 @@ Object.freeze({
38901
39028
  form: "array",
38902
39029
  optional: false
38903
39030
  }],
39031
+ "pipelineAnalytics.listEventMedia": [{
39032
+ name: "deviceId",
39033
+ form: "single",
39034
+ optional: false
39035
+ }],
38904
39036
  "pipelineAnalytics.listGroups": [{
38905
39037
  name: "deviceIds",
38906
39038
  form: "array",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-unifi",
3
- "version": "0.2.45",
3
+ "version": "0.2.47",
4
4
  "description": "UniFi Network controller device-provider addon for CamStack — local-controller infra switches/APs (as containers) + network-client presence. NO cameras/Protect.",
5
5
  "keywords": [
6
6
  "camstack",