@camstack/addon-ai 0.4.30 → 0.4.32

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
@@ -6076,6 +6076,40 @@ var BaseAddon = class {
6076
6076
  deviceSettingsSchema() {
6077
6077
  return null;
6078
6078
  }
6079
+ /**
6080
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
6081
+ * ARE the configuration of its integration.
6082
+ *
6083
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
6084
+ * operator should find on the addon's integration page (System →
6085
+ * Integrations → <name>) rather than only in the cluster-wide list of every
6086
+ * addon. Empty (the default) means the addon has no integration-level
6087
+ * settings and no such surface is offered — this is opt-in, because whether
6088
+ * an addon's configuration IS its integration's configuration depends on the
6089
+ * nature of the integration.
6090
+ *
6091
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
6092
+ * the ONE global schema, in the ONE addon store, written by the ONE
6093
+ * `updateGlobalSettings` path. There is deliberately no
6094
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
6095
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
6096
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
6097
+ *
6098
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
6099
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
6100
+ * removed with the reason recorded at
6101
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
6102
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
6103
+ * marker sprinkled across sections also has to borrow a field that already
6104
+ * means something else; borrowing `section.tab` put the literal word
6105
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
6106
+ * GROUP this visually" and cannot also mean "where this lives" (D269
6107
+ * supersedes D268). One declaration, in one place, next to the schema whose
6108
+ * ids it names.
6109
+ */
6110
+ integrationSettingSections() {
6111
+ return [];
6112
+ }
6079
6113
  async getGlobalSettings(overlay, cap, nodeId) {
6080
6114
  const schema = this.globalSettingsSchema(cap);
6081
6115
  if (!schema) return { sections: [] };
@@ -6086,6 +6120,55 @@ var BaseAddon = class {
6086
6120
  } : projected);
6087
6121
  }
6088
6122
  /**
6123
+ * The integration-level view of this addon's settings: exactly the sections
6124
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
6125
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
6126
+ *
6127
+ * Returns `null` when the addon declared nothing — an addon that opts out has
6128
+ * no integration settings surface at all, rather than an empty one that reads
6129
+ * as a failed load.
6130
+ *
6131
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
6132
+ * and not in whichever UI happens to render this:
6133
+ *
6134
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
6135
+ * shown here is the same field, with the same bare key, that the addon's
6136
+ * own page shows. There is no integration-specific writer — callers save
6137
+ * through `updateGlobalSettings` — so a second store key is unreachable,
6138
+ * not merely discouraged.
6139
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
6140
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
6141
+ * such a field silently picked would be a wrong answer for the operator
6142
+ * who opened the page (D266).
6143
+ * 3. **No silent typo.** A declared id that names no section throws. The
6144
+ * alternative — skip it — turns a rename into a surface that quietly
6145
+ * empties, which looks exactly like an addon with nothing to configure.
6146
+ */
6147
+ async getIntegrationSettings(nodeId) {
6148
+ const declared = this.integrationSettingSections();
6149
+ if (declared.length === 0) return null;
6150
+ const schema = this.globalSettingsSchema();
6151
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6152
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6153
+ const sections = [];
6154
+ for (const id of declared) {
6155
+ const section = byId.get(id);
6156
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6157
+ const fields = dropPerNodeFields(section.fields);
6158
+ if (fields.length === 0) continue;
6159
+ sections.push({
6160
+ ...section,
6161
+ fields
6162
+ });
6163
+ }
6164
+ if (sections.length === 0) return null;
6165
+ const projected = await this.resolveGlobalStore(nodeId);
6166
+ return hydrateSchema({
6167
+ ...schema,
6168
+ sections
6169
+ }, projected);
6170
+ }
6171
+ /**
6089
6172
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
6090
6173
  * every `perNode: true` field carries THAT node's scoped value on its bare
6091
6174
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6389,6 +6472,41 @@ var BaseAddon = class {
6389
6472
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6390
6473
  * don't declare `perNode` and are excluded by the `in` narrowing.
6391
6474
  */
6475
+ /**
6476
+ * The same fields with every `perNode: true` one removed, recursing into layout
6477
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6478
+ * with no child is dropped rather than rendered empty.
6479
+ *
6480
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6481
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6482
+ */
6483
+ function dropPerNodeFields(fields) {
6484
+ const kept = [];
6485
+ for (const field of fields) {
6486
+ if (field.type === "group") {
6487
+ const inner = dropPerNodeFields(field.fields);
6488
+ if (inner.length > 0) kept.push({
6489
+ ...field,
6490
+ fields: inner
6491
+ });
6492
+ continue;
6493
+ }
6494
+ if (field.type === "sub-tabs") {
6495
+ const tabs = field.tabs.map((tab) => ({
6496
+ ...tab,
6497
+ fields: dropPerNodeFields(tab.fields)
6498
+ })).filter((tab) => tab.fields.length > 0);
6499
+ if (tabs.length > 0) kept.push({
6500
+ ...field,
6501
+ tabs
6502
+ });
6503
+ continue;
6504
+ }
6505
+ if ("perNode" in field && field.perNode === true) continue;
6506
+ kept.push(field);
6507
+ }
6508
+ return kept;
6509
+ }
6392
6510
  function collectPerNodeFieldKeys(fields) {
6393
6511
  const collected = [];
6394
6512
  for (const field of fields) {
@@ -9601,6 +9719,9 @@ method(object({
9601
9719
  kind: "mutation",
9602
9720
  auth: "admin"
9603
9721
  }), method(object({
9722
+ addonId: string(),
9723
+ nodeId: string().optional()
9724
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9604
9725
  addonId: string(),
9605
9726
  deviceId: number$1(),
9606
9727
  nodeId: string().optional()
@@ -13379,6 +13500,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13379
13500
  limit: number$1().optional(),
13380
13501
  tags: record(string(), string()).optional()
13381
13502
  }), array(LogEntrySchema).readonly());
13503
+ /**
13504
+ * `failure-contribution` — the capability an addon reports its OWN losses
13505
+ * through, per camera, with the denominator attached. It stores nothing.
13506
+ *
13507
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13508
+ *
13509
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13510
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13511
+ * copied: the contributor reports what it already knows, hub-main adds only
13512
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13513
+ * somebody to forget to edit.
13514
+ *
13515
+ * They are not merged, because their invariants are opposites:
13516
+ *
13517
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13518
+ * claim a camera cost nothing, which is a measurement nobody made;
13519
+ * - a `failure-contribution` zero is the **most valuable value on the
13520
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13521
+ * and it is exactly what an absent entry cannot say.
13522
+ *
13523
+ * Putting a loss counter on a cost entry would also break the reconciliation
13524
+ * that gives `load-contribution` its point: contributions are subtracted from
13525
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13526
+ * has no process.
13527
+ *
13528
+ * ## Why not a log line, since the counters already exist
13529
+ *
13530
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13531
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13532
+ * ends in a log line, and a log line is the thing the operator asked to stop
13533
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13534
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13535
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13536
+ * media blackout were both diagnosed. The counters stay; this is where they can
13537
+ * be READ.
13538
+ *
13539
+ * ## The rate is served with its denominator or not at all
13540
+ *
13541
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13542
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13543
+ * than yesterday" and was **flat across twelve hours** once divided by the
13544
+ * successes on the same path. A surface that publishes only the numerator
13545
+ * reproduces that mistake on every read.
13546
+ *
13547
+ * ## Shape
13548
+ *
13549
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13550
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13551
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13552
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13553
+ * a forked runner's entries reach hub-main over transport that already exists.
13554
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13555
+ * result through `system.getFailureContributions`.
13556
+ */
13557
+ var FailureReasonCountSchema = object({
13558
+ /**
13559
+ * Why the attempt did not land, in the contributor's own vocabulary —
13560
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13561
+ * strings that already appear in this repo's logs and, where one exists, the
13562
+ * same string the per-track `previewMissReason` records (D276): a second
13563
+ * vocabulary for the same loss would make the row and the counter
13564
+ * un-joinable.
13565
+ */
13566
+ reason: string(),
13567
+ count: number$1().int().nonnegative()
13568
+ });
13569
+ var FailureContributionSchema = object({
13570
+ /**
13571
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13572
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13573
+ * `unit` free: the families are owned by different addons and a shared enum
13574
+ * is a central list that rots invisibly.
13575
+ */
13576
+ family: string(),
13577
+ /**
13578
+ * The NUMERIC device id — the same value every log line carries as
13579
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13580
+ * cannot name the camera must not emit the entry, because a fleet total
13581
+ * cannot answer the only question anybody asks of this surface.
13582
+ */
13583
+ deviceId: number$1().int().positive(),
13584
+ /**
13585
+ * A second dimension inside the family: the model / step id for an inference
13586
+ * timeout, so "which camera AND which model" is one read. Absent when the
13587
+ * family has a single variant.
13588
+ */
13589
+ variant: string().optional(),
13590
+ /**
13591
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13592
+ * differencing two reads must drop the interval when it changes, because the
13593
+ * counter restarted from zero in a respawned runner. Same discipline as
13594
+ * `LoadContribution.startedAtMs`.
13595
+ */
13596
+ sinceMs: number$1(),
13597
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13598
+ atMs: number$1(),
13599
+ /**
13600
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13601
+ * window. A failure count published without it is the mistake this schema
13602
+ * exists to make impossible.
13603
+ */
13604
+ attempts: number$1().int().nonnegative(),
13605
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13606
+ succeeded: number$1().int().nonnegative(),
13607
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13608
+ reasons: array(FailureReasonCountSchema).readonly()
13609
+ });
13610
+ method(_void(), array(FailureContributionSchema).readonly());
13382
13611
  var LoadContributionSchema = object({
13383
13612
  role: _enum([
13384
13613
  "decode",
@@ -17891,6 +18120,20 @@ var TrackSchema = object({
17891
18120
  * `=== true` and render nothing otherwise — never infer "no rider".
17892
18121
  */
17893
18122
  hasRider: boolean().optional(),
18123
+ /**
18124
+ * WHY this track ended without a NATIVE best-shot tile
18125
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18126
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18127
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18128
+ * the late-keyFrame upgrade when a native tile lands after all. The
18129
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18130
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18131
+ *
18132
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18133
+ * that predates the field, and every track whose tile landed native all
18134
+ * omit it. Render nothing when absent.
18135
+ */
18136
+ previewMissReason: string().optional(),
17894
18137
  ...TrackFlagFields,
17895
18138
  ...TrackRetrainFields
17896
18139
  });
@@ -27117,6 +27360,13 @@ var LoggingSettingsPatchSchema = object({
27117
27360
  * anyone but its owner.
27118
27361
  */
27119
27362
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27363
+ /**
27364
+ * One per-camera failure counter, plus WHO reported it.
27365
+ *
27366
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27367
+ * the hub as it enumerates providers, never by the contributor.
27368
+ */
27369
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
27120
27370
  var GetLoggingSettingsInputSchema = object({
27121
27371
  scopeNodeId: string().optional(),
27122
27372
  /**
@@ -27175,7 +27425,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27175
27425
  }), method(_void(), SiteLocationStatusSchema, {
27176
27426
  kind: "mutation",
27177
27427
  auth: "admin"
27178
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27428
+ }), 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, {
27179
27429
  kind: "mutation",
27180
27430
  auth: "admin"
27181
27431
  });
@@ -28089,6 +28339,12 @@ Object.freeze({
28089
28339
  addonId: null,
28090
28340
  access: "view"
28091
28341
  },
28342
+ "addonSettings.getIntegrationSettings": {
28343
+ capName: "addon-settings",
28344
+ capScope: "system",
28345
+ addonId: null,
28346
+ access: "view"
28347
+ },
28092
28348
  "addonSettings.updateDeviceSettings": {
28093
28349
  capName: "addon-settings",
28094
28350
  capScope: "system",
@@ -29751,6 +30007,12 @@ Object.freeze({
29751
30007
  addonId: null,
29752
30008
  access: "create"
29753
30009
  },
30010
+ "failureContribution.list": {
30011
+ capName: "failure-contribution",
30012
+ capScope: "system",
30013
+ addonId: null,
30014
+ access: "view"
30015
+ },
29754
30016
  "fanControl.setDirection": {
29755
30017
  capName: "fan-control",
29756
30018
  capScope: "device",
@@ -33057,6 +33319,12 @@ Object.freeze({
33057
33319
  addonId: null,
33058
33320
  access: "create"
33059
33321
  },
33322
+ "system.getFailureContributions": {
33323
+ capName: "system",
33324
+ capScope: "system",
33325
+ addonId: null,
33326
+ access: "view"
33327
+ },
33060
33328
  "system.getLoadContributions": {
33061
33329
  capName: "system",
33062
33330
  capScope: "system",
package/dist/addon.mjs CHANGED
@@ -6103,6 +6103,40 @@ var BaseAddon = class {
6103
6103
  deviceSettingsSchema() {
6104
6104
  return null;
6105
6105
  }
6106
+ /**
6107
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
6108
+ * ARE the configuration of its integration.
6109
+ *
6110
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
6111
+ * operator should find on the addon's integration page (System →
6112
+ * Integrations → <name>) rather than only in the cluster-wide list of every
6113
+ * addon. Empty (the default) means the addon has no integration-level
6114
+ * settings and no such surface is offered — this is opt-in, because whether
6115
+ * an addon's configuration IS its integration's configuration depends on the
6116
+ * nature of the integration.
6117
+ *
6118
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
6119
+ * the ONE global schema, in the ONE addon store, written by the ONE
6120
+ * `updateGlobalSettings` path. There is deliberately no
6121
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
6122
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
6123
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
6124
+ *
6125
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
6126
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
6127
+ * removed with the reason recorded at
6128
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
6129
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
6130
+ * marker sprinkled across sections also has to borrow a field that already
6131
+ * means something else; borrowing `section.tab` put the literal word
6132
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
6133
+ * GROUP this visually" and cannot also mean "where this lives" (D269
6134
+ * supersedes D268). One declaration, in one place, next to the schema whose
6135
+ * ids it names.
6136
+ */
6137
+ integrationSettingSections() {
6138
+ return [];
6139
+ }
6106
6140
  async getGlobalSettings(overlay, cap, nodeId) {
6107
6141
  const schema = this.globalSettingsSchema(cap);
6108
6142
  if (!schema) return { sections: [] };
@@ -6113,6 +6147,55 @@ var BaseAddon = class {
6113
6147
  } : projected);
6114
6148
  }
6115
6149
  /**
6150
+ * The integration-level view of this addon's settings: exactly the sections
6151
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
6152
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
6153
+ *
6154
+ * Returns `null` when the addon declared nothing — an addon that opts out has
6155
+ * no integration settings surface at all, rather than an empty one that reads
6156
+ * as a failed load.
6157
+ *
6158
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
6159
+ * and not in whichever UI happens to render this:
6160
+ *
6161
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
6162
+ * shown here is the same field, with the same bare key, that the addon's
6163
+ * own page shows. There is no integration-specific writer — callers save
6164
+ * through `updateGlobalSettings` — so a second store key is unreachable,
6165
+ * not merely discouraged.
6166
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
6167
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
6168
+ * such a field silently picked would be a wrong answer for the operator
6169
+ * who opened the page (D266).
6170
+ * 3. **No silent typo.** A declared id that names no section throws. The
6171
+ * alternative — skip it — turns a rename into a surface that quietly
6172
+ * empties, which looks exactly like an addon with nothing to configure.
6173
+ */
6174
+ async getIntegrationSettings(nodeId) {
6175
+ const declared = this.integrationSettingSections();
6176
+ if (declared.length === 0) return null;
6177
+ const schema = this.globalSettingsSchema();
6178
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6179
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6180
+ const sections = [];
6181
+ for (const id of declared) {
6182
+ const section = byId.get(id);
6183
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6184
+ const fields = dropPerNodeFields(section.fields);
6185
+ if (fields.length === 0) continue;
6186
+ sections.push({
6187
+ ...section,
6188
+ fields
6189
+ });
6190
+ }
6191
+ if (sections.length === 0) return null;
6192
+ const projected = await this.resolveGlobalStore(nodeId);
6193
+ return hydrateSchema({
6194
+ ...schema,
6195
+ sections
6196
+ }, projected);
6197
+ }
6198
+ /**
6116
6199
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
6117
6200
  * every `perNode: true` field carries THAT node's scoped value on its bare
6118
6201
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6416,6 +6499,41 @@ var BaseAddon = class {
6416
6499
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6417
6500
  * don't declare `perNode` and are excluded by the `in` narrowing.
6418
6501
  */
6502
+ /**
6503
+ * The same fields with every `perNode: true` one removed, recursing into layout
6504
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6505
+ * with no child is dropped rather than rendered empty.
6506
+ *
6507
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6508
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6509
+ */
6510
+ function dropPerNodeFields(fields) {
6511
+ const kept = [];
6512
+ for (const field of fields) {
6513
+ if (field.type === "group") {
6514
+ const inner = dropPerNodeFields(field.fields);
6515
+ if (inner.length > 0) kept.push({
6516
+ ...field,
6517
+ fields: inner
6518
+ });
6519
+ continue;
6520
+ }
6521
+ if (field.type === "sub-tabs") {
6522
+ const tabs = field.tabs.map((tab) => ({
6523
+ ...tab,
6524
+ fields: dropPerNodeFields(tab.fields)
6525
+ })).filter((tab) => tab.fields.length > 0);
6526
+ if (tabs.length > 0) kept.push({
6527
+ ...field,
6528
+ tabs
6529
+ });
6530
+ continue;
6531
+ }
6532
+ if ("perNode" in field && field.perNode === true) continue;
6533
+ kept.push(field);
6534
+ }
6535
+ return kept;
6536
+ }
6419
6537
  function collectPerNodeFieldKeys(fields) {
6420
6538
  const collected = [];
6421
6539
  for (const field of fields) {
@@ -9628,6 +9746,9 @@ method(object({
9628
9746
  kind: "mutation",
9629
9747
  auth: "admin"
9630
9748
  }), method(object({
9749
+ addonId: string(),
9750
+ nodeId: string().optional()
9751
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9631
9752
  addonId: string(),
9632
9753
  deviceId: number$1(),
9633
9754
  nodeId: string().optional()
@@ -13406,6 +13527,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13406
13527
  limit: number$1().optional(),
13407
13528
  tags: record(string(), string()).optional()
13408
13529
  }), array(LogEntrySchema).readonly());
13530
+ /**
13531
+ * `failure-contribution` — the capability an addon reports its OWN losses
13532
+ * through, per camera, with the denominator attached. It stores nothing.
13533
+ *
13534
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13535
+ *
13536
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13537
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13538
+ * copied: the contributor reports what it already knows, hub-main adds only
13539
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13540
+ * somebody to forget to edit.
13541
+ *
13542
+ * They are not merged, because their invariants are opposites:
13543
+ *
13544
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13545
+ * claim a camera cost nothing, which is a measurement nobody made;
13546
+ * - a `failure-contribution` zero is the **most valuable value on the
13547
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13548
+ * and it is exactly what an absent entry cannot say.
13549
+ *
13550
+ * Putting a loss counter on a cost entry would also break the reconciliation
13551
+ * that gives `load-contribution` its point: contributions are subtracted from
13552
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13553
+ * has no process.
13554
+ *
13555
+ * ## Why not a log line, since the counters already exist
13556
+ *
13557
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13558
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13559
+ * ends in a log line, and a log line is the thing the operator asked to stop
13560
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13561
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13562
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13563
+ * media blackout were both diagnosed. The counters stay; this is where they can
13564
+ * be READ.
13565
+ *
13566
+ * ## The rate is served with its denominator or not at all
13567
+ *
13568
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13569
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13570
+ * than yesterday" and was **flat across twelve hours** once divided by the
13571
+ * successes on the same path. A surface that publishes only the numerator
13572
+ * reproduces that mistake on every read.
13573
+ *
13574
+ * ## Shape
13575
+ *
13576
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13577
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13578
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13579
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13580
+ * a forked runner's entries reach hub-main over transport that already exists.
13581
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13582
+ * result through `system.getFailureContributions`.
13583
+ */
13584
+ var FailureReasonCountSchema = object({
13585
+ /**
13586
+ * Why the attempt did not land, in the contributor's own vocabulary —
13587
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13588
+ * strings that already appear in this repo's logs and, where one exists, the
13589
+ * same string the per-track `previewMissReason` records (D276): a second
13590
+ * vocabulary for the same loss would make the row and the counter
13591
+ * un-joinable.
13592
+ */
13593
+ reason: string(),
13594
+ count: number$1().int().nonnegative()
13595
+ });
13596
+ var FailureContributionSchema = object({
13597
+ /**
13598
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13599
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13600
+ * `unit` free: the families are owned by different addons and a shared enum
13601
+ * is a central list that rots invisibly.
13602
+ */
13603
+ family: string(),
13604
+ /**
13605
+ * The NUMERIC device id — the same value every log line carries as
13606
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13607
+ * cannot name the camera must not emit the entry, because a fleet total
13608
+ * cannot answer the only question anybody asks of this surface.
13609
+ */
13610
+ deviceId: number$1().int().positive(),
13611
+ /**
13612
+ * A second dimension inside the family: the model / step id for an inference
13613
+ * timeout, so "which camera AND which model" is one read. Absent when the
13614
+ * family has a single variant.
13615
+ */
13616
+ variant: string().optional(),
13617
+ /**
13618
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13619
+ * differencing two reads must drop the interval when it changes, because the
13620
+ * counter restarted from zero in a respawned runner. Same discipline as
13621
+ * `LoadContribution.startedAtMs`.
13622
+ */
13623
+ sinceMs: number$1(),
13624
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13625
+ atMs: number$1(),
13626
+ /**
13627
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13628
+ * window. A failure count published without it is the mistake this schema
13629
+ * exists to make impossible.
13630
+ */
13631
+ attempts: number$1().int().nonnegative(),
13632
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13633
+ succeeded: number$1().int().nonnegative(),
13634
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13635
+ reasons: array(FailureReasonCountSchema).readonly()
13636
+ });
13637
+ method(_void(), array(FailureContributionSchema).readonly());
13409
13638
  var LoadContributionSchema = object({
13410
13639
  role: _enum([
13411
13640
  "decode",
@@ -17918,6 +18147,20 @@ var TrackSchema = object({
17918
18147
  * `=== true` and render nothing otherwise — never infer "no rider".
17919
18148
  */
17920
18149
  hasRider: boolean().optional(),
18150
+ /**
18151
+ * WHY this track ended without a NATIVE best-shot tile
18152
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18153
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18154
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18155
+ * the late-keyFrame upgrade when a native tile lands after all. The
18156
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18157
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18158
+ *
18159
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18160
+ * that predates the field, and every track whose tile landed native all
18161
+ * omit it. Render nothing when absent.
18162
+ */
18163
+ previewMissReason: string().optional(),
17921
18164
  ...TrackFlagFields,
17922
18165
  ...TrackRetrainFields
17923
18166
  });
@@ -27144,6 +27387,13 @@ var LoggingSettingsPatchSchema = object({
27144
27387
  * anyone but its owner.
27145
27388
  */
27146
27389
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27390
+ /**
27391
+ * One per-camera failure counter, plus WHO reported it.
27392
+ *
27393
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27394
+ * the hub as it enumerates providers, never by the contributor.
27395
+ */
27396
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
27147
27397
  var GetLoggingSettingsInputSchema = object({
27148
27398
  scopeNodeId: string().optional(),
27149
27399
  /**
@@ -27202,7 +27452,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27202
27452
  }), method(_void(), SiteLocationStatusSchema, {
27203
27453
  kind: "mutation",
27204
27454
  auth: "admin"
27205
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27455
+ }), 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, {
27206
27456
  kind: "mutation",
27207
27457
  auth: "admin"
27208
27458
  });
@@ -28116,6 +28366,12 @@ Object.freeze({
28116
28366
  addonId: null,
28117
28367
  access: "view"
28118
28368
  },
28369
+ "addonSettings.getIntegrationSettings": {
28370
+ capName: "addon-settings",
28371
+ capScope: "system",
28372
+ addonId: null,
28373
+ access: "view"
28374
+ },
28119
28375
  "addonSettings.updateDeviceSettings": {
28120
28376
  capName: "addon-settings",
28121
28377
  capScope: "system",
@@ -29778,6 +30034,12 @@ Object.freeze({
29778
30034
  addonId: null,
29779
30035
  access: "create"
29780
30036
  },
30037
+ "failureContribution.list": {
30038
+ capName: "failure-contribution",
30039
+ capScope: "system",
30040
+ addonId: null,
30041
+ access: "view"
30042
+ },
29781
30043
  "fanControl.setDirection": {
29782
30044
  capName: "fan-control",
29783
30045
  capScope: "device",
@@ -33084,6 +33346,12 @@ Object.freeze({
33084
33346
  addonId: null,
33085
33347
  access: "create"
33086
33348
  },
33349
+ "system.getFailureContributions": {
33350
+ capName: "system",
33351
+ capScope: "system",
33352
+ addonId: null,
33353
+ access: "view"
33354
+ },
33087
33355
  "system.getLoadContributions": {
33088
33356
  capName: "system",
33089
33357
  capScope: "system",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-ai",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "AI addon for CamStack — the `llm` collection provider (cloud, LAN, and camstack-managed local llama.cpp profiles) plus the per-node `llm-runtime` managed executor.",
5
5
  "keywords": [
6
6
  "camstack",