@camstack/addon-notifiers 1.2.41 → 1.2.43

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.
package/dist/addon.js CHANGED
@@ -5946,6 +5946,40 @@ var BaseAddon = class {
5946
5946
  deviceSettingsSchema() {
5947
5947
  return null;
5948
5948
  }
5949
+ /**
5950
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
5951
+ * ARE the configuration of its integration.
5952
+ *
5953
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
5954
+ * operator should find on the addon's integration page (System →
5955
+ * Integrations → <name>) rather than only in the cluster-wide list of every
5956
+ * addon. Empty (the default) means the addon has no integration-level
5957
+ * settings and no such surface is offered — this is opt-in, because whether
5958
+ * an addon's configuration IS its integration's configuration depends on the
5959
+ * nature of the integration.
5960
+ *
5961
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
5962
+ * the ONE global schema, in the ONE addon store, written by the ONE
5963
+ * `updateGlobalSettings` path. There is deliberately no
5964
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
5965
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
5966
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
5967
+ *
5968
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
5969
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
5970
+ * removed with the reason recorded at
5971
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
5972
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
5973
+ * marker sprinkled across sections also has to borrow a field that already
5974
+ * means something else; borrowing `section.tab` put the literal word
5975
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
5976
+ * GROUP this visually" and cannot also mean "where this lives" (D269
5977
+ * supersedes D268). One declaration, in one place, next to the schema whose
5978
+ * ids it names.
5979
+ */
5980
+ integrationSettingSections() {
5981
+ return [];
5982
+ }
5949
5983
  async getGlobalSettings(overlay, cap, nodeId) {
5950
5984
  const schema = this.globalSettingsSchema(cap);
5951
5985
  if (!schema) return { sections: [] };
@@ -5956,6 +5990,55 @@ var BaseAddon = class {
5956
5990
  } : projected);
5957
5991
  }
5958
5992
  /**
5993
+ * The integration-level view of this addon's settings: exactly the sections
5994
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
5995
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
5996
+ *
5997
+ * Returns `null` when the addon declared nothing — an addon that opts out has
5998
+ * no integration settings surface at all, rather than an empty one that reads
5999
+ * as a failed load.
6000
+ *
6001
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
6002
+ * and not in whichever UI happens to render this:
6003
+ *
6004
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
6005
+ * shown here is the same field, with the same bare key, that the addon's
6006
+ * own page shows. There is no integration-specific writer — callers save
6007
+ * through `updateGlobalSettings` — so a second store key is unreachable,
6008
+ * not merely discouraged.
6009
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
6010
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
6011
+ * such a field silently picked would be a wrong answer for the operator
6012
+ * who opened the page (D266).
6013
+ * 3. **No silent typo.** A declared id that names no section throws. The
6014
+ * alternative — skip it — turns a rename into a surface that quietly
6015
+ * empties, which looks exactly like an addon with nothing to configure.
6016
+ */
6017
+ async getIntegrationSettings(nodeId) {
6018
+ const declared = this.integrationSettingSections();
6019
+ if (declared.length === 0) return null;
6020
+ const schema = this.globalSettingsSchema();
6021
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6022
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6023
+ const sections = [];
6024
+ for (const id of declared) {
6025
+ const section = byId.get(id);
6026
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6027
+ const fields = dropPerNodeFields(section.fields);
6028
+ if (fields.length === 0) continue;
6029
+ sections.push({
6030
+ ...section,
6031
+ fields
6032
+ });
6033
+ }
6034
+ if (sections.length === 0) return null;
6035
+ const projected = await this.resolveGlobalStore(nodeId);
6036
+ return hydrateSchema({
6037
+ ...schema,
6038
+ sections
6039
+ }, projected);
6040
+ }
6041
+ /**
5959
6042
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
5960
6043
  * every `perNode: true` field carries THAT node's scoped value on its bare
5961
6044
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6259,6 +6342,41 @@ var BaseAddon = class {
6259
6342
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6260
6343
  * don't declare `perNode` and are excluded by the `in` narrowing.
6261
6344
  */
6345
+ /**
6346
+ * The same fields with every `perNode: true` one removed, recursing into layout
6347
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6348
+ * with no child is dropped rather than rendered empty.
6349
+ *
6350
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6351
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6352
+ */
6353
+ function dropPerNodeFields(fields) {
6354
+ const kept = [];
6355
+ for (const field of fields) {
6356
+ if (field.type === "group") {
6357
+ const inner = dropPerNodeFields(field.fields);
6358
+ if (inner.length > 0) kept.push({
6359
+ ...field,
6360
+ fields: inner
6361
+ });
6362
+ continue;
6363
+ }
6364
+ if (field.type === "sub-tabs") {
6365
+ const tabs = field.tabs.map((tab) => ({
6366
+ ...tab,
6367
+ fields: dropPerNodeFields(tab.fields)
6368
+ })).filter((tab) => tab.fields.length > 0);
6369
+ if (tabs.length > 0) kept.push({
6370
+ ...field,
6371
+ tabs
6372
+ });
6373
+ continue;
6374
+ }
6375
+ if ("perNode" in field && field.perNode === true) continue;
6376
+ kept.push(field);
6377
+ }
6378
+ return kept;
6379
+ }
6262
6380
  function collectPerNodeFieldKeys(fields) {
6263
6381
  const collected = [];
6264
6382
  for (const field of fields) {
@@ -9550,6 +9668,9 @@ method(object({
9550
9668
  kind: "mutation",
9551
9669
  auth: "admin"
9552
9670
  }), method(object({
9671
+ addonId: string(),
9672
+ nodeId: string().optional()
9673
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9553
9674
  addonId: string(),
9554
9675
  deviceId: number(),
9555
9676
  nodeId: string().optional()
@@ -13256,6 +13377,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13256
13377
  limit: number().optional(),
13257
13378
  tags: record(string(), string()).optional()
13258
13379
  }), array(LogEntrySchema).readonly());
13380
+ /**
13381
+ * `failure-contribution` — the capability an addon reports its OWN losses
13382
+ * through, per camera, with the denominator attached. It stores nothing.
13383
+ *
13384
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13385
+ *
13386
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13387
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13388
+ * copied: the contributor reports what it already knows, hub-main adds only
13389
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13390
+ * somebody to forget to edit.
13391
+ *
13392
+ * They are not merged, because their invariants are opposites:
13393
+ *
13394
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13395
+ * claim a camera cost nothing, which is a measurement nobody made;
13396
+ * - a `failure-contribution` zero is the **most valuable value on the
13397
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13398
+ * and it is exactly what an absent entry cannot say.
13399
+ *
13400
+ * Putting a loss counter on a cost entry would also break the reconciliation
13401
+ * that gives `load-contribution` its point: contributions are subtracted from
13402
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13403
+ * has no process.
13404
+ *
13405
+ * ## Why not a log line, since the counters already exist
13406
+ *
13407
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13408
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13409
+ * ends in a log line, and a log line is the thing the operator asked to stop
13410
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13411
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13412
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13413
+ * media blackout were both diagnosed. The counters stay; this is where they can
13414
+ * be READ.
13415
+ *
13416
+ * ## The rate is served with its denominator or not at all
13417
+ *
13418
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13419
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13420
+ * than yesterday" and was **flat across twelve hours** once divided by the
13421
+ * successes on the same path. A surface that publishes only the numerator
13422
+ * reproduces that mistake on every read.
13423
+ *
13424
+ * ## Shape
13425
+ *
13426
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13427
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13428
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13429
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13430
+ * a forked runner's entries reach hub-main over transport that already exists.
13431
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13432
+ * result through `system.getFailureContributions`.
13433
+ */
13434
+ var FailureReasonCountSchema = object({
13435
+ /**
13436
+ * Why the attempt did not land, in the contributor's own vocabulary —
13437
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13438
+ * strings that already appear in this repo's logs and, where one exists, the
13439
+ * same string the per-track `previewMissReason` records (D276): a second
13440
+ * vocabulary for the same loss would make the row and the counter
13441
+ * un-joinable.
13442
+ */
13443
+ reason: string(),
13444
+ count: number().int().nonnegative()
13445
+ });
13446
+ var FailureContributionSchema = object({
13447
+ /**
13448
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13449
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13450
+ * `unit` free: the families are owned by different addons and a shared enum
13451
+ * is a central list that rots invisibly.
13452
+ */
13453
+ family: string(),
13454
+ /**
13455
+ * The NUMERIC device id — the same value every log line carries as
13456
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13457
+ * cannot name the camera must not emit the entry, because a fleet total
13458
+ * cannot answer the only question anybody asks of this surface.
13459
+ */
13460
+ deviceId: number().int().positive(),
13461
+ /**
13462
+ * A second dimension inside the family: the model / step id for an inference
13463
+ * timeout, so "which camera AND which model" is one read. Absent when the
13464
+ * family has a single variant.
13465
+ */
13466
+ variant: string().optional(),
13467
+ /**
13468
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13469
+ * differencing two reads must drop the interval when it changes, because the
13470
+ * counter restarted from zero in a respawned runner. Same discipline as
13471
+ * `LoadContribution.startedAtMs`.
13472
+ */
13473
+ sinceMs: number(),
13474
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13475
+ atMs: number(),
13476
+ /**
13477
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13478
+ * window. A failure count published without it is the mistake this schema
13479
+ * exists to make impossible.
13480
+ */
13481
+ attempts: number().int().nonnegative(),
13482
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13483
+ succeeded: number().int().nonnegative(),
13484
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13485
+ reasons: array(FailureReasonCountSchema).readonly()
13486
+ });
13487
+ method(_void(), array(FailureContributionSchema).readonly());
13259
13488
  var LoadContributionSchema = object({
13260
13489
  role: _enum([
13261
13490
  "decode",
@@ -17782,6 +18011,20 @@ var TrackSchema = object({
17782
18011
  * `=== true` and render nothing otherwise — never infer "no rider".
17783
18012
  */
17784
18013
  hasRider: boolean().optional(),
18014
+ /**
18015
+ * WHY this track ended without a NATIVE best-shot tile
18016
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18017
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18018
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18019
+ * the late-keyFrame upgrade when a native tile lands after all. The
18020
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18021
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18022
+ *
18023
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18024
+ * that predates the field, and every track whose tile landed native all
18025
+ * omit it. Render nothing when absent.
18026
+ */
18027
+ previewMissReason: string().optional(),
17785
18028
  ...TrackFlagFields,
17786
18029
  ...TrackRetrainFields
17787
18030
  });
@@ -27008,6 +27251,13 @@ var LoggingSettingsPatchSchema = object({
27008
27251
  * anyone but its owner.
27009
27252
  */
27010
27253
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27254
+ /**
27255
+ * One per-camera failure counter, plus WHO reported it.
27256
+ *
27257
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27258
+ * the hub as it enumerates providers, never by the contributor.
27259
+ */
27260
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
27011
27261
  var GetLoggingSettingsInputSchema = object({
27012
27262
  scopeNodeId: string().optional(),
27013
27263
  /**
@@ -27066,7 +27316,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27066
27316
  }), method(_void(), SiteLocationStatusSchema, {
27067
27317
  kind: "mutation",
27068
27318
  auth: "admin"
27069
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27319
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27070
27320
  kind: "mutation",
27071
27321
  auth: "admin"
27072
27322
  });
@@ -27980,6 +28230,12 @@ Object.freeze({
27980
28230
  addonId: null,
27981
28231
  access: "view"
27982
28232
  },
28233
+ "addonSettings.getIntegrationSettings": {
28234
+ capName: "addon-settings",
28235
+ capScope: "system",
28236
+ addonId: null,
28237
+ access: "view"
28238
+ },
27983
28239
  "addonSettings.updateDeviceSettings": {
27984
28240
  capName: "addon-settings",
27985
28241
  capScope: "system",
@@ -29642,6 +29898,12 @@ Object.freeze({
29642
29898
  addonId: null,
29643
29899
  access: "create"
29644
29900
  },
29901
+ "failureContribution.list": {
29902
+ capName: "failure-contribution",
29903
+ capScope: "system",
29904
+ addonId: null,
29905
+ access: "view"
29906
+ },
29645
29907
  "fanControl.setDirection": {
29646
29908
  capName: "fan-control",
29647
29909
  capScope: "device",
@@ -32948,6 +33210,12 @@ Object.freeze({
32948
33210
  addonId: null,
32949
33211
  access: "create"
32950
33212
  },
33213
+ "system.getFailureContributions": {
33214
+ capName: "system",
33215
+ capScope: "system",
33216
+ addonId: null,
33217
+ access: "view"
33218
+ },
32951
33219
  "system.getLoadContributions": {
32952
33220
  capName: "system",
32953
33221
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -5919,6 +5919,40 @@ var BaseAddon = class {
5919
5919
  deviceSettingsSchema() {
5920
5920
  return null;
5921
5921
  }
5922
+ /**
5923
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
5924
+ * ARE the configuration of its integration.
5925
+ *
5926
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
5927
+ * operator should find on the addon's integration page (System →
5928
+ * Integrations → <name>) rather than only in the cluster-wide list of every
5929
+ * addon. Empty (the default) means the addon has no integration-level
5930
+ * settings and no such surface is offered — this is opt-in, because whether
5931
+ * an addon's configuration IS its integration's configuration depends on the
5932
+ * nature of the integration.
5933
+ *
5934
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
5935
+ * the ONE global schema, in the ONE addon store, written by the ONE
5936
+ * `updateGlobalSettings` path. There is deliberately no
5937
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
5938
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
5939
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
5940
+ *
5941
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
5942
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
5943
+ * removed with the reason recorded at
5944
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
5945
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
5946
+ * marker sprinkled across sections also has to borrow a field that already
5947
+ * means something else; borrowing `section.tab` put the literal word
5948
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
5949
+ * GROUP this visually" and cannot also mean "where this lives" (D269
5950
+ * supersedes D268). One declaration, in one place, next to the schema whose
5951
+ * ids it names.
5952
+ */
5953
+ integrationSettingSections() {
5954
+ return [];
5955
+ }
5922
5956
  async getGlobalSettings(overlay, cap, nodeId) {
5923
5957
  const schema = this.globalSettingsSchema(cap);
5924
5958
  if (!schema) return { sections: [] };
@@ -5929,6 +5963,55 @@ var BaseAddon = class {
5929
5963
  } : projected);
5930
5964
  }
5931
5965
  /**
5966
+ * The integration-level view of this addon's settings: exactly the sections
5967
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
5968
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
5969
+ *
5970
+ * Returns `null` when the addon declared nothing — an addon that opts out has
5971
+ * no integration settings surface at all, rather than an empty one that reads
5972
+ * as a failed load.
5973
+ *
5974
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
5975
+ * and not in whichever UI happens to render this:
5976
+ *
5977
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
5978
+ * shown here is the same field, with the same bare key, that the addon's
5979
+ * own page shows. There is no integration-specific writer — callers save
5980
+ * through `updateGlobalSettings` — so a second store key is unreachable,
5981
+ * not merely discouraged.
5982
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
5983
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
5984
+ * such a field silently picked would be a wrong answer for the operator
5985
+ * who opened the page (D266).
5986
+ * 3. **No silent typo.** A declared id that names no section throws. The
5987
+ * alternative — skip it — turns a rename into a surface that quietly
5988
+ * empties, which looks exactly like an addon with nothing to configure.
5989
+ */
5990
+ async getIntegrationSettings(nodeId) {
5991
+ const declared = this.integrationSettingSections();
5992
+ if (declared.length === 0) return null;
5993
+ const schema = this.globalSettingsSchema();
5994
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
5995
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
5996
+ const sections = [];
5997
+ for (const id of declared) {
5998
+ const section = byId.get(id);
5999
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6000
+ const fields = dropPerNodeFields(section.fields);
6001
+ if (fields.length === 0) continue;
6002
+ sections.push({
6003
+ ...section,
6004
+ fields
6005
+ });
6006
+ }
6007
+ if (sections.length === 0) return null;
6008
+ const projected = await this.resolveGlobalStore(nodeId);
6009
+ return hydrateSchema({
6010
+ ...schema,
6011
+ sections
6012
+ }, projected);
6013
+ }
6014
+ /**
5932
6015
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
5933
6016
  * every `perNode: true` field carries THAT node's scoped value on its bare
5934
6017
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6232,6 +6315,41 @@ var BaseAddon = class {
6232
6315
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6233
6316
  * don't declare `perNode` and are excluded by the `in` narrowing.
6234
6317
  */
6318
+ /**
6319
+ * The same fields with every `perNode: true` one removed, recursing into layout
6320
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6321
+ * with no child is dropped rather than rendered empty.
6322
+ *
6323
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6324
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6325
+ */
6326
+ function dropPerNodeFields(fields) {
6327
+ const kept = [];
6328
+ for (const field of fields) {
6329
+ if (field.type === "group") {
6330
+ const inner = dropPerNodeFields(field.fields);
6331
+ if (inner.length > 0) kept.push({
6332
+ ...field,
6333
+ fields: inner
6334
+ });
6335
+ continue;
6336
+ }
6337
+ if (field.type === "sub-tabs") {
6338
+ const tabs = field.tabs.map((tab) => ({
6339
+ ...tab,
6340
+ fields: dropPerNodeFields(tab.fields)
6341
+ })).filter((tab) => tab.fields.length > 0);
6342
+ if (tabs.length > 0) kept.push({
6343
+ ...field,
6344
+ tabs
6345
+ });
6346
+ continue;
6347
+ }
6348
+ if ("perNode" in field && field.perNode === true) continue;
6349
+ kept.push(field);
6350
+ }
6351
+ return kept;
6352
+ }
6235
6353
  function collectPerNodeFieldKeys(fields) {
6236
6354
  const collected = [];
6237
6355
  for (const field of fields) {
@@ -9523,6 +9641,9 @@ method(object({
9523
9641
  kind: "mutation",
9524
9642
  auth: "admin"
9525
9643
  }), method(object({
9644
+ addonId: string(),
9645
+ nodeId: string().optional()
9646
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9526
9647
  addonId: string(),
9527
9648
  deviceId: number(),
9528
9649
  nodeId: string().optional()
@@ -13229,6 +13350,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13229
13350
  limit: number().optional(),
13230
13351
  tags: record(string(), string()).optional()
13231
13352
  }), array(LogEntrySchema).readonly());
13353
+ /**
13354
+ * `failure-contribution` — the capability an addon reports its OWN losses
13355
+ * through, per camera, with the denominator attached. It stores nothing.
13356
+ *
13357
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13358
+ *
13359
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13360
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13361
+ * copied: the contributor reports what it already knows, hub-main adds only
13362
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13363
+ * somebody to forget to edit.
13364
+ *
13365
+ * They are not merged, because their invariants are opposites:
13366
+ *
13367
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13368
+ * claim a camera cost nothing, which is a measurement nobody made;
13369
+ * - a `failure-contribution` zero is the **most valuable value on the
13370
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13371
+ * and it is exactly what an absent entry cannot say.
13372
+ *
13373
+ * Putting a loss counter on a cost entry would also break the reconciliation
13374
+ * that gives `load-contribution` its point: contributions are subtracted from
13375
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13376
+ * has no process.
13377
+ *
13378
+ * ## Why not a log line, since the counters already exist
13379
+ *
13380
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13381
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13382
+ * ends in a log line, and a log line is the thing the operator asked to stop
13383
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13384
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13385
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13386
+ * media blackout were both diagnosed. The counters stay; this is where they can
13387
+ * be READ.
13388
+ *
13389
+ * ## The rate is served with its denominator or not at all
13390
+ *
13391
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13392
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13393
+ * than yesterday" and was **flat across twelve hours** once divided by the
13394
+ * successes on the same path. A surface that publishes only the numerator
13395
+ * reproduces that mistake on every read.
13396
+ *
13397
+ * ## Shape
13398
+ *
13399
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13400
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13401
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13402
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13403
+ * a forked runner's entries reach hub-main over transport that already exists.
13404
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13405
+ * result through `system.getFailureContributions`.
13406
+ */
13407
+ var FailureReasonCountSchema = object({
13408
+ /**
13409
+ * Why the attempt did not land, in the contributor's own vocabulary —
13410
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13411
+ * strings that already appear in this repo's logs and, where one exists, the
13412
+ * same string the per-track `previewMissReason` records (D276): a second
13413
+ * vocabulary for the same loss would make the row and the counter
13414
+ * un-joinable.
13415
+ */
13416
+ reason: string(),
13417
+ count: number().int().nonnegative()
13418
+ });
13419
+ var FailureContributionSchema = object({
13420
+ /**
13421
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13422
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13423
+ * `unit` free: the families are owned by different addons and a shared enum
13424
+ * is a central list that rots invisibly.
13425
+ */
13426
+ family: string(),
13427
+ /**
13428
+ * The NUMERIC device id — the same value every log line carries as
13429
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13430
+ * cannot name the camera must not emit the entry, because a fleet total
13431
+ * cannot answer the only question anybody asks of this surface.
13432
+ */
13433
+ deviceId: number().int().positive(),
13434
+ /**
13435
+ * A second dimension inside the family: the model / step id for an inference
13436
+ * timeout, so "which camera AND which model" is one read. Absent when the
13437
+ * family has a single variant.
13438
+ */
13439
+ variant: string().optional(),
13440
+ /**
13441
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13442
+ * differencing two reads must drop the interval when it changes, because the
13443
+ * counter restarted from zero in a respawned runner. Same discipline as
13444
+ * `LoadContribution.startedAtMs`.
13445
+ */
13446
+ sinceMs: number(),
13447
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13448
+ atMs: number(),
13449
+ /**
13450
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13451
+ * window. A failure count published without it is the mistake this schema
13452
+ * exists to make impossible.
13453
+ */
13454
+ attempts: number().int().nonnegative(),
13455
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13456
+ succeeded: number().int().nonnegative(),
13457
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13458
+ reasons: array(FailureReasonCountSchema).readonly()
13459
+ });
13460
+ method(_void(), array(FailureContributionSchema).readonly());
13232
13461
  var LoadContributionSchema = object({
13233
13462
  role: _enum([
13234
13463
  "decode",
@@ -17755,6 +17984,20 @@ var TrackSchema = object({
17755
17984
  * `=== true` and render nothing otherwise — never infer "no rider".
17756
17985
  */
17757
17986
  hasRider: boolean().optional(),
17987
+ /**
17988
+ * WHY this track ended without a NATIVE best-shot tile
17989
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
17990
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
17991
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
17992
+ * the late-keyFrame upgrade when a native tile lands after all. The
17993
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
17994
+ * tile is a face/plate stand-in, a raster crop, or an icon.
17995
+ *
17996
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
17997
+ * that predates the field, and every track whose tile landed native all
17998
+ * omit it. Render nothing when absent.
17999
+ */
18000
+ previewMissReason: string().optional(),
17758
18001
  ...TrackFlagFields,
17759
18002
  ...TrackRetrainFields
17760
18003
  });
@@ -26981,6 +27224,13 @@ var LoggingSettingsPatchSchema = object({
26981
27224
  * anyone but its owner.
26982
27225
  */
26983
27226
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27227
+ /**
27228
+ * One per-camera failure counter, plus WHO reported it.
27229
+ *
27230
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27231
+ * the hub as it enumerates providers, never by the contributor.
27232
+ */
27233
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
26984
27234
  var GetLoggingSettingsInputSchema = object({
26985
27235
  scopeNodeId: string().optional(),
26986
27236
  /**
@@ -27039,7 +27289,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27039
27289
  }), method(_void(), SiteLocationStatusSchema, {
27040
27290
  kind: "mutation",
27041
27291
  auth: "admin"
27042
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27292
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27043
27293
  kind: "mutation",
27044
27294
  auth: "admin"
27045
27295
  });
@@ -27953,6 +28203,12 @@ Object.freeze({
27953
28203
  addonId: null,
27954
28204
  access: "view"
27955
28205
  },
28206
+ "addonSettings.getIntegrationSettings": {
28207
+ capName: "addon-settings",
28208
+ capScope: "system",
28209
+ addonId: null,
28210
+ access: "view"
28211
+ },
27956
28212
  "addonSettings.updateDeviceSettings": {
27957
28213
  capName: "addon-settings",
27958
28214
  capScope: "system",
@@ -29615,6 +29871,12 @@ Object.freeze({
29615
29871
  addonId: null,
29616
29872
  access: "create"
29617
29873
  },
29874
+ "failureContribution.list": {
29875
+ capName: "failure-contribution",
29876
+ capScope: "system",
29877
+ addonId: null,
29878
+ access: "view"
29879
+ },
29618
29880
  "fanControl.setDirection": {
29619
29881
  capName: "fan-control",
29620
29882
  capScope: "device",
@@ -32921,6 +33183,12 @@ Object.freeze({
32921
33183
  addonId: null,
32922
33184
  access: "create"
32923
33185
  },
33186
+ "system.getFailureContributions": {
33187
+ capName: "system",
33188
+ capScope: "system",
33189
+ addonId: null,
33190
+ access: "view"
33191
+ },
32924
33192
  "system.getLoadContributions": {
32925
33193
  capName: "system",
32926
33194
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-notifiers",
3
- "version": "1.2.41",
3
+ "version": "1.2.43",
4
4
  "description": "System notifiers addon for CamStack — a `notification-output` collection provider hosting per-kind notifier adapters (ntfy, pushover, gotify, telegram, discord, webhook, zentik).",
5
5
  "keywords": [
6
6
  "camstack",