@camstack/addon-model-studio 1.1.51 → 1.1.53

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 (18) hide show
  1. package/dist/{MotionZonesSettings-C0cFDSjW.mjs → MotionZonesSettings-AeW640XH.mjs} +2 -2
  2. package/dist/{PrivacyMaskSettings-B0xou5VT.mjs → PrivacyMaskSettings-Bc5IUpOt.mjs} +4 -4
  3. package/dist/{SceneMonitorEditor-CM4NQvb5.mjs → SceneMonitorEditor-Z1wgLkGn.mjs} +3 -3
  4. package/dist/_stub.js +11 -11
  5. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-Co3sb2DR.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-DrOZLd7Y.mjs} +4 -4
  6. package/dist/_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-D7NsUbjR.mjs +26 -0
  7. package/dist/{hostInit-CK6zsVKu.mjs → hostInit-CtltIlNM.mjs} +3 -3
  8. package/dist/model-studio.addon.js +269 -1
  9. package/dist/model-studio.addon.mjs +269 -1
  10. package/dist/{player-overlays-p43mMwWT.mjs → player-overlays-D7aqrgP-.mjs} +1 -1
  11. package/dist/remoteEntry.js +1 -1
  12. package/dist/{responsive-BWVkSjgC.mjs → responsive-C3yBoqZG.mjs} +1 -1
  13. package/dist/{square-DNWB0Fvw.mjs → square-3DVti3qZ.mjs} +1 -1
  14. package/dist/{trash-2-BKgeax2l.mjs → trash-2-6U_XP_Un.mjs} +1 -1
  15. package/dist/{use-device-snapshot-CkQG2lkA.mjs → use-device-snapshot-DkFs-9vc.mjs} +1 -1
  16. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-DOi9vT32.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-Ckw-iokV.mjs} +1 -1
  17. package/package.json +1 -1
  18. package/dist/_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CE4txY-m.mjs +0 -26
@@ -6440,6 +6440,40 @@ var BaseAddon = class {
6440
6440
  deviceSettingsSchema() {
6441
6441
  return null;
6442
6442
  }
6443
+ /**
6444
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
6445
+ * ARE the configuration of its integration.
6446
+ *
6447
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
6448
+ * operator should find on the addon's integration page (System →
6449
+ * Integrations → <name>) rather than only in the cluster-wide list of every
6450
+ * addon. Empty (the default) means the addon has no integration-level
6451
+ * settings and no such surface is offered — this is opt-in, because whether
6452
+ * an addon's configuration IS its integration's configuration depends on the
6453
+ * nature of the integration.
6454
+ *
6455
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
6456
+ * the ONE global schema, in the ONE addon store, written by the ONE
6457
+ * `updateGlobalSettings` path. There is deliberately no
6458
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
6459
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
6460
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
6461
+ *
6462
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
6463
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
6464
+ * removed with the reason recorded at
6465
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
6466
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
6467
+ * marker sprinkled across sections also has to borrow a field that already
6468
+ * means something else; borrowing `section.tab` put the literal word
6469
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
6470
+ * GROUP this visually" and cannot also mean "where this lives" (D269
6471
+ * supersedes D268). One declaration, in one place, next to the schema whose
6472
+ * ids it names.
6473
+ */
6474
+ integrationSettingSections() {
6475
+ return [];
6476
+ }
6443
6477
  async getGlobalSettings(overlay, cap, nodeId) {
6444
6478
  const schema = this.globalSettingsSchema(cap);
6445
6479
  if (!schema) return { sections: [] };
@@ -6450,6 +6484,55 @@ var BaseAddon = class {
6450
6484
  } : projected);
6451
6485
  }
6452
6486
  /**
6487
+ * The integration-level view of this addon's settings: exactly the sections
6488
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
6489
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
6490
+ *
6491
+ * Returns `null` when the addon declared nothing — an addon that opts out has
6492
+ * no integration settings surface at all, rather than an empty one that reads
6493
+ * as a failed load.
6494
+ *
6495
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
6496
+ * and not in whichever UI happens to render this:
6497
+ *
6498
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
6499
+ * shown here is the same field, with the same bare key, that the addon's
6500
+ * own page shows. There is no integration-specific writer — callers save
6501
+ * through `updateGlobalSettings` — so a second store key is unreachable,
6502
+ * not merely discouraged.
6503
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
6504
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
6505
+ * such a field silently picked would be a wrong answer for the operator
6506
+ * who opened the page (D266).
6507
+ * 3. **No silent typo.** A declared id that names no section throws. The
6508
+ * alternative — skip it — turns a rename into a surface that quietly
6509
+ * empties, which looks exactly like an addon with nothing to configure.
6510
+ */
6511
+ async getIntegrationSettings(nodeId) {
6512
+ const declared = this.integrationSettingSections();
6513
+ if (declared.length === 0) return null;
6514
+ const schema = this.globalSettingsSchema();
6515
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6516
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6517
+ const sections = [];
6518
+ for (const id of declared) {
6519
+ const section = byId.get(id);
6520
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6521
+ const fields = dropPerNodeFields(section.fields);
6522
+ if (fields.length === 0) continue;
6523
+ sections.push({
6524
+ ...section,
6525
+ fields
6526
+ });
6527
+ }
6528
+ if (sections.length === 0) return null;
6529
+ const projected = await this.resolveGlobalStore(nodeId);
6530
+ return hydrateSchema({
6531
+ ...schema,
6532
+ sections
6533
+ }, projected);
6534
+ }
6535
+ /**
6453
6536
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
6454
6537
  * every `perNode: true` field carries THAT node's scoped value on its bare
6455
6538
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6753,6 +6836,41 @@ var BaseAddon = class {
6753
6836
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6754
6837
  * don't declare `perNode` and are excluded by the `in` narrowing.
6755
6838
  */
6839
+ /**
6840
+ * The same fields with every `perNode: true` one removed, recursing into layout
6841
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6842
+ * with no child is dropped rather than rendered empty.
6843
+ *
6844
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6845
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6846
+ */
6847
+ function dropPerNodeFields(fields) {
6848
+ const kept = [];
6849
+ for (const field of fields) {
6850
+ if (field.type === "group") {
6851
+ const inner = dropPerNodeFields(field.fields);
6852
+ if (inner.length > 0) kept.push({
6853
+ ...field,
6854
+ fields: inner
6855
+ });
6856
+ continue;
6857
+ }
6858
+ if (field.type === "sub-tabs") {
6859
+ const tabs = field.tabs.map((tab) => ({
6860
+ ...tab,
6861
+ fields: dropPerNodeFields(tab.fields)
6862
+ })).filter((tab) => tab.fields.length > 0);
6863
+ if (tabs.length > 0) kept.push({
6864
+ ...field,
6865
+ tabs
6866
+ });
6867
+ continue;
6868
+ }
6869
+ if ("perNode" in field && field.perNode === true) continue;
6870
+ kept.push(field);
6871
+ }
6872
+ return kept;
6873
+ }
6756
6874
  function collectPerNodeFieldKeys(fields) {
6757
6875
  const collected = [];
6758
6876
  for (const field of fields) {
@@ -9983,6 +10101,9 @@ method(object({
9983
10101
  kind: "mutation",
9984
10102
  auth: "admin"
9985
10103
  }), method(object({
10104
+ addonId: string(),
10105
+ nodeId: string().optional()
10106
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9986
10107
  addonId: string(),
9987
10108
  deviceId: number(),
9988
10109
  nodeId: string().optional()
@@ -13705,6 +13826,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13705
13826
  limit: number().optional(),
13706
13827
  tags: record(string(), string()).optional()
13707
13828
  }), array(LogEntrySchema).readonly());
13829
+ /**
13830
+ * `failure-contribution` — the capability an addon reports its OWN losses
13831
+ * through, per camera, with the denominator attached. It stores nothing.
13832
+ *
13833
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13834
+ *
13835
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13836
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13837
+ * copied: the contributor reports what it already knows, hub-main adds only
13838
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13839
+ * somebody to forget to edit.
13840
+ *
13841
+ * They are not merged, because their invariants are opposites:
13842
+ *
13843
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13844
+ * claim a camera cost nothing, which is a measurement nobody made;
13845
+ * - a `failure-contribution` zero is the **most valuable value on the
13846
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13847
+ * and it is exactly what an absent entry cannot say.
13848
+ *
13849
+ * Putting a loss counter on a cost entry would also break the reconciliation
13850
+ * that gives `load-contribution` its point: contributions are subtracted from
13851
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13852
+ * has no process.
13853
+ *
13854
+ * ## Why not a log line, since the counters already exist
13855
+ *
13856
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13857
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13858
+ * ends in a log line, and a log line is the thing the operator asked to stop
13859
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13860
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13861
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13862
+ * media blackout were both diagnosed. The counters stay; this is where they can
13863
+ * be READ.
13864
+ *
13865
+ * ## The rate is served with its denominator or not at all
13866
+ *
13867
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13868
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13869
+ * than yesterday" and was **flat across twelve hours** once divided by the
13870
+ * successes on the same path. A surface that publishes only the numerator
13871
+ * reproduces that mistake on every read.
13872
+ *
13873
+ * ## Shape
13874
+ *
13875
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13876
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13877
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13878
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13879
+ * a forked runner's entries reach hub-main over transport that already exists.
13880
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13881
+ * result through `system.getFailureContributions`.
13882
+ */
13883
+ var FailureReasonCountSchema = object({
13884
+ /**
13885
+ * Why the attempt did not land, in the contributor's own vocabulary —
13886
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13887
+ * strings that already appear in this repo's logs and, where one exists, the
13888
+ * same string the per-track `previewMissReason` records (D276): a second
13889
+ * vocabulary for the same loss would make the row and the counter
13890
+ * un-joinable.
13891
+ */
13892
+ reason: string(),
13893
+ count: number().int().nonnegative()
13894
+ });
13895
+ var FailureContributionSchema = object({
13896
+ /**
13897
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13898
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13899
+ * `unit` free: the families are owned by different addons and a shared enum
13900
+ * is a central list that rots invisibly.
13901
+ */
13902
+ family: string(),
13903
+ /**
13904
+ * The NUMERIC device id — the same value every log line carries as
13905
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13906
+ * cannot name the camera must not emit the entry, because a fleet total
13907
+ * cannot answer the only question anybody asks of this surface.
13908
+ */
13909
+ deviceId: number().int().positive(),
13910
+ /**
13911
+ * A second dimension inside the family: the model / step id for an inference
13912
+ * timeout, so "which camera AND which model" is one read. Absent when the
13913
+ * family has a single variant.
13914
+ */
13915
+ variant: string().optional(),
13916
+ /**
13917
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13918
+ * differencing two reads must drop the interval when it changes, because the
13919
+ * counter restarted from zero in a respawned runner. Same discipline as
13920
+ * `LoadContribution.startedAtMs`.
13921
+ */
13922
+ sinceMs: number(),
13923
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13924
+ atMs: number(),
13925
+ /**
13926
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13927
+ * window. A failure count published without it is the mistake this schema
13928
+ * exists to make impossible.
13929
+ */
13930
+ attempts: number().int().nonnegative(),
13931
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13932
+ succeeded: number().int().nonnegative(),
13933
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13934
+ reasons: array(FailureReasonCountSchema).readonly()
13935
+ });
13936
+ method(_void(), array(FailureContributionSchema).readonly());
13708
13937
  var LoadContributionSchema = object({
13709
13938
  role: _enum([
13710
13939
  "decode",
@@ -18223,6 +18452,20 @@ var TrackSchema = object({
18223
18452
  * `=== true` and render nothing otherwise — never infer "no rider".
18224
18453
  */
18225
18454
  hasRider: boolean().optional(),
18455
+ /**
18456
+ * WHY this track ended without a NATIVE best-shot tile
18457
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18458
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18459
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18460
+ * the late-keyFrame upgrade when a native tile lands after all. The
18461
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18462
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18463
+ *
18464
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18465
+ * that predates the field, and every track whose tile landed native all
18466
+ * omit it. Render nothing when absent.
18467
+ */
18468
+ previewMissReason: string().optional(),
18226
18469
  ...TrackFlagFields,
18227
18470
  ...TrackRetrainFields
18228
18471
  });
@@ -27449,6 +27692,13 @@ var LoggingSettingsPatchSchema = object({
27449
27692
  * anyone but its owner.
27450
27693
  */
27451
27694
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27695
+ /**
27696
+ * One per-camera failure counter, plus WHO reported it.
27697
+ *
27698
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27699
+ * the hub as it enumerates providers, never by the contributor.
27700
+ */
27701
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
27452
27702
  var GetLoggingSettingsInputSchema = object({
27453
27703
  scopeNodeId: string().optional(),
27454
27704
  /**
@@ -27507,7 +27757,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27507
27757
  }), method(_void(), SiteLocationStatusSchema, {
27508
27758
  kind: "mutation",
27509
27759
  auth: "admin"
27510
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27760
+ }), 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, {
27511
27761
  kind: "mutation",
27512
27762
  auth: "admin"
27513
27763
  });
@@ -28421,6 +28671,12 @@ Object.freeze({
28421
28671
  addonId: null,
28422
28672
  access: "view"
28423
28673
  },
28674
+ "addonSettings.getIntegrationSettings": {
28675
+ capName: "addon-settings",
28676
+ capScope: "system",
28677
+ addonId: null,
28678
+ access: "view"
28679
+ },
28424
28680
  "addonSettings.updateDeviceSettings": {
28425
28681
  capName: "addon-settings",
28426
28682
  capScope: "system",
@@ -30083,6 +30339,12 @@ Object.freeze({
30083
30339
  addonId: null,
30084
30340
  access: "create"
30085
30341
  },
30342
+ "failureContribution.list": {
30343
+ capName: "failure-contribution",
30344
+ capScope: "system",
30345
+ addonId: null,
30346
+ access: "view"
30347
+ },
30086
30348
  "fanControl.setDirection": {
30087
30349
  capName: "fan-control",
30088
30350
  capScope: "device",
@@ -33389,6 +33651,12 @@ Object.freeze({
33389
33651
  addonId: null,
33390
33652
  access: "create"
33391
33653
  },
33654
+ "system.getFailureContributions": {
33655
+ capName: "system",
33656
+ capScope: "system",
33657
+ addonId: null,
33658
+ access: "view"
33659
+ },
33392
33660
  "system.getLoadContributions": {
33393
33661
  capName: "system",
33394
33662
  capScope: "system",
@@ -6419,6 +6419,40 @@ var BaseAddon = class {
6419
6419
  deviceSettingsSchema() {
6420
6420
  return null;
6421
6421
  }
6422
+ /**
6423
+ * INTEGRATION-LEVEL SETTINGS — declare which of this addon's global sections
6424
+ * ARE the configuration of its integration.
6425
+ *
6426
+ * Return the `ConfigSection.id`s, from {@link globalSettingsSchema}, that an
6427
+ * operator should find on the addon's integration page (System →
6428
+ * Integrations → <name>) rather than only in the cluster-wide list of every
6429
+ * addon. Empty (the default) means the addon has no integration-level
6430
+ * settings and no such surface is offered — this is opt-in, because whether
6431
+ * an addon's configuration IS its integration's configuration depends on the
6432
+ * nature of the integration.
6433
+ *
6434
+ * WHAT THIS IS NOT. It is not a scope. The selected sections keep living in
6435
+ * the ONE global schema, in the ONE addon store, written by the ONE
6436
+ * `updateGlobalSettings` path. There is deliberately no
6437
+ * `updateIntegrationSettings`: a second write path is how a surface acquires
6438
+ * a second store key, and this repo has shipped that twice (`btmPath@hub`,
6439
+ * D266). Selecting sections cannot introduce a key that selecting cannot.
6440
+ *
6441
+ * WHY IT IS A LIST OF SECTION IDS AND NOT A MARKER ON THE SECTION.
6442
+ * `ConfigFieldBase` used to carry `scope?: 'device' | 'global'` and it was
6443
+ * removed with the reason recorded at
6444
+ * `packages/types/src/interfaces/config-ui.ts:249` — *"a field's scope is
6445
+ * determined by WHICH schema it lives in, not by a field-level marker."* A
6446
+ * marker sprinkled across sections also has to borrow a field that already
6447
+ * means something else; borrowing `section.tab` put the literal word
6448
+ * "integration" into an operator-facing tab bar, because `tab` means "how to
6449
+ * GROUP this visually" and cannot also mean "where this lives" (D269
6450
+ * supersedes D268). One declaration, in one place, next to the schema whose
6451
+ * ids it names.
6452
+ */
6453
+ integrationSettingSections() {
6454
+ return [];
6455
+ }
6422
6456
  async getGlobalSettings(overlay, cap, nodeId) {
6423
6457
  const schema = this.globalSettingsSchema(cap);
6424
6458
  if (!schema) return { sections: [] };
@@ -6429,6 +6463,55 @@ var BaseAddon = class {
6429
6463
  } : projected);
6430
6464
  }
6431
6465
  /**
6466
+ * The integration-level view of this addon's settings: exactly the sections
6467
+ * named by {@link integrationSettingSections}, hydrated from the SAME store
6468
+ * `getGlobalSettings` reads, and narrowed to cluster-scoped fields.
6469
+ *
6470
+ * Returns `null` when the addon declared nothing — an addon that opts out has
6471
+ * no integration settings surface at all, rather than an empty one that reads
6472
+ * as a failed load.
6473
+ *
6474
+ * Three properties hold BY CONSTRUCTION, which is why they are here in core
6475
+ * and not in whichever UI happens to render this:
6476
+ *
6477
+ * 1. **One key.** The payload is a SUBSET of the global schema, so a field
6478
+ * shown here is the same field, with the same bare key, that the addon's
6479
+ * own page shows. There is no integration-specific writer — callers save
6480
+ * through `updateGlobalSettings` — so a second store key is unreachable,
6481
+ * not merely discouraged.
6482
+ * 2. **No node scope.** `perNode: true` fields are DROPPED. Their store key
6483
+ * is `<key>@<nodeId>` and an integration is not a node; whichever node
6484
+ * such a field silently picked would be a wrong answer for the operator
6485
+ * who opened the page (D266).
6486
+ * 3. **No silent typo.** A declared id that names no section throws. The
6487
+ * alternative — skip it — turns a rename into a surface that quietly
6488
+ * empties, which looks exactly like an addon with nothing to configure.
6489
+ */
6490
+ async getIntegrationSettings(nodeId) {
6491
+ const declared = this.integrationSettingSections();
6492
+ if (declared.length === 0) return null;
6493
+ const schema = this.globalSettingsSchema();
6494
+ if (!schema) throw new Error(`${this.constructor.name}: integrationSettingSections() names [${declared.join(", ")}] but globalSettingsSchema() returns null.`);
6495
+ const byId = new Map(schema.sections.map((section) => [section.id, section]));
6496
+ const sections = [];
6497
+ for (const id of declared) {
6498
+ const section = byId.get(id);
6499
+ if (!section) throw new Error(`${this.constructor.name}: integrationSettingSections() names unknown section "${id}". Known sections: [${[...byId.keys()].join(", ")}].`);
6500
+ const fields = dropPerNodeFields(section.fields);
6501
+ if (fields.length === 0) continue;
6502
+ sections.push({
6503
+ ...section,
6504
+ fields
6505
+ });
6506
+ }
6507
+ if (sections.length === 0) return null;
6508
+ const projected = await this.resolveGlobalStore(nodeId);
6509
+ return hydrateSchema({
6510
+ ...schema,
6511
+ sections
6512
+ }, projected);
6513
+ }
6514
+ /**
6432
6515
  * The raw addon store PROJECTED onto the target node's bare per-node keys:
6433
6516
  * every `perNode: true` field carries THAT node's scoped value on its bare
6434
6517
  * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
@@ -6732,6 +6815,41 @@ var BaseAddon = class {
6732
6815
  * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6733
6816
  * don't declare `perNode` and are excluded by the `in` narrowing.
6734
6817
  */
6818
+ /**
6819
+ * The same fields with every `perNode: true` one removed, recursing into layout
6820
+ * containers exactly as {@link collectPerNodeFieldKeys} does. A container left
6821
+ * with no child is dropped rather than rendered empty.
6822
+ *
6823
+ * Used by `getIntegrationSettings`: an integration is not a node, so a field
6824
+ * whose store key is `<key>@<nodeId>` has no node to belong to there.
6825
+ */
6826
+ function dropPerNodeFields(fields) {
6827
+ const kept = [];
6828
+ for (const field of fields) {
6829
+ if (field.type === "group") {
6830
+ const inner = dropPerNodeFields(field.fields);
6831
+ if (inner.length > 0) kept.push({
6832
+ ...field,
6833
+ fields: inner
6834
+ });
6835
+ continue;
6836
+ }
6837
+ if (field.type === "sub-tabs") {
6838
+ const tabs = field.tabs.map((tab) => ({
6839
+ ...tab,
6840
+ fields: dropPerNodeFields(tab.fields)
6841
+ })).filter((tab) => tab.fields.length > 0);
6842
+ if (tabs.length > 0) kept.push({
6843
+ ...field,
6844
+ tabs
6845
+ });
6846
+ continue;
6847
+ }
6848
+ if ("perNode" in field && field.perNode === true) continue;
6849
+ kept.push(field);
6850
+ }
6851
+ return kept;
6852
+ }
6735
6853
  function collectPerNodeFieldKeys(fields) {
6736
6854
  const collected = [];
6737
6855
  for (const field of fields) {
@@ -9962,6 +10080,9 @@ method(object({
9962
10080
  kind: "mutation",
9963
10081
  auth: "admin"
9964
10082
  }), method(object({
10083
+ addonId: string(),
10084
+ nodeId: string().optional()
10085
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9965
10086
  addonId: string(),
9966
10087
  deviceId: number(),
9967
10088
  nodeId: string().optional()
@@ -13684,6 +13805,114 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13684
13805
  limit: number().optional(),
13685
13806
  tags: record(string(), string()).optional()
13686
13807
  }), array(LogEntrySchema).readonly());
13808
+ /**
13809
+ * `failure-contribution` — the capability an addon reports its OWN losses
13810
+ * through, per camera, with the denominator attached. It stores nothing.
13811
+ *
13812
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13813
+ *
13814
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13815
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13816
+ * copied: the contributor reports what it already knows, hub-main adds only
13817
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13818
+ * somebody to forget to edit.
13819
+ *
13820
+ * They are not merged, because their invariants are opposites:
13821
+ *
13822
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13823
+ * claim a camera cost nothing, which is a measurement nobody made;
13824
+ * - a `failure-contribution` zero is the **most valuable value on the
13825
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13826
+ * and it is exactly what an absent entry cannot say.
13827
+ *
13828
+ * Putting a loss counter on a cost entry would also break the reconciliation
13829
+ * that gives `load-contribution` its point: contributions are subtracted from
13830
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13831
+ * has no process.
13832
+ *
13833
+ * ## Why not a log line, since the counters already exist
13834
+ *
13835
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13836
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13837
+ * ends in a log line, and a log line is the thing the operator asked to stop
13838
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13839
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13840
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13841
+ * media blackout were both diagnosed. The counters stay; this is where they can
13842
+ * be READ.
13843
+ *
13844
+ * ## The rate is served with its denominator or not at all
13845
+ *
13846
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13847
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13848
+ * than yesterday" and was **flat across twelve hours** once divided by the
13849
+ * successes on the same path. A surface that publishes only the numerator
13850
+ * reproduces that mistake on every read.
13851
+ *
13852
+ * ## Shape
13853
+ *
13854
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13855
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13856
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13857
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13858
+ * a forked runner's entries reach hub-main over transport that already exists.
13859
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13860
+ * result through `system.getFailureContributions`.
13861
+ */
13862
+ var FailureReasonCountSchema = object({
13863
+ /**
13864
+ * Why the attempt did not land, in the contributor's own vocabulary —
13865
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13866
+ * strings that already appear in this repo's logs and, where one exists, the
13867
+ * same string the per-track `previewMissReason` records (D276): a second
13868
+ * vocabulary for the same loss would make the row and the counter
13869
+ * un-joinable.
13870
+ */
13871
+ reason: string(),
13872
+ count: number().int().nonnegative()
13873
+ });
13874
+ var FailureContributionSchema = object({
13875
+ /**
13876
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13877
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13878
+ * `unit` free: the families are owned by different addons and a shared enum
13879
+ * is a central list that rots invisibly.
13880
+ */
13881
+ family: string(),
13882
+ /**
13883
+ * The NUMERIC device id — the same value every log line carries as
13884
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13885
+ * cannot name the camera must not emit the entry, because a fleet total
13886
+ * cannot answer the only question anybody asks of this surface.
13887
+ */
13888
+ deviceId: number().int().positive(),
13889
+ /**
13890
+ * A second dimension inside the family: the model / step id for an inference
13891
+ * timeout, so "which camera AND which model" is one read. Absent when the
13892
+ * family has a single variant.
13893
+ */
13894
+ variant: string().optional(),
13895
+ /**
13896
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13897
+ * differencing two reads must drop the interval when it changes, because the
13898
+ * counter restarted from zero in a respawned runner. Same discipline as
13899
+ * `LoadContribution.startedAtMs`.
13900
+ */
13901
+ sinceMs: number(),
13902
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13903
+ atMs: number(),
13904
+ /**
13905
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13906
+ * window. A failure count published without it is the mistake this schema
13907
+ * exists to make impossible.
13908
+ */
13909
+ attempts: number().int().nonnegative(),
13910
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13911
+ succeeded: number().int().nonnegative(),
13912
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13913
+ reasons: array(FailureReasonCountSchema).readonly()
13914
+ });
13915
+ method(_void(), array(FailureContributionSchema).readonly());
13687
13916
  var LoadContributionSchema = object({
13688
13917
  role: _enum([
13689
13918
  "decode",
@@ -18202,6 +18431,20 @@ var TrackSchema = object({
18202
18431
  * `=== true` and render nothing otherwise — never infer "no rider".
18203
18432
  */
18204
18433
  hasRider: boolean().optional(),
18434
+ /**
18435
+ * WHY this track ended without a NATIVE best-shot tile
18436
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18437
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18438
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18439
+ * the late-keyFrame upgrade when a native tile lands after all. The
18440
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18441
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18442
+ *
18443
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18444
+ * that predates the field, and every track whose tile landed native all
18445
+ * omit it. Render nothing when absent.
18446
+ */
18447
+ previewMissReason: string().optional(),
18205
18448
  ...TrackFlagFields,
18206
18449
  ...TrackRetrainFields
18207
18450
  });
@@ -27428,6 +27671,13 @@ var LoggingSettingsPatchSchema = object({
27428
27671
  * anyone but its owner.
27429
27672
  */
27430
27673
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
27674
+ /**
27675
+ * One per-camera failure counter, plus WHO reported it.
27676
+ *
27677
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
27678
+ * the hub as it enumerates providers, never by the contributor.
27679
+ */
27680
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
27431
27681
  var GetLoggingSettingsInputSchema = object({
27432
27682
  scopeNodeId: string().optional(),
27433
27683
  /**
@@ -27486,7 +27736,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
27486
27736
  }), method(_void(), SiteLocationStatusSchema, {
27487
27737
  kind: "mutation",
27488
27738
  auth: "admin"
27489
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
27739
+ }), 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, {
27490
27740
  kind: "mutation",
27491
27741
  auth: "admin"
27492
27742
  });
@@ -28400,6 +28650,12 @@ Object.freeze({
28400
28650
  addonId: null,
28401
28651
  access: "view"
28402
28652
  },
28653
+ "addonSettings.getIntegrationSettings": {
28654
+ capName: "addon-settings",
28655
+ capScope: "system",
28656
+ addonId: null,
28657
+ access: "view"
28658
+ },
28403
28659
  "addonSettings.updateDeviceSettings": {
28404
28660
  capName: "addon-settings",
28405
28661
  capScope: "system",
@@ -30062,6 +30318,12 @@ Object.freeze({
30062
30318
  addonId: null,
30063
30319
  access: "create"
30064
30320
  },
30321
+ "failureContribution.list": {
30322
+ capName: "failure-contribution",
30323
+ capScope: "system",
30324
+ addonId: null,
30325
+ access: "view"
30326
+ },
30065
30327
  "fanControl.setDirection": {
30066
30328
  capName: "fan-control",
30067
30329
  capScope: "device",
@@ -33368,6 +33630,12 @@ Object.freeze({
33368
33630
  addonId: null,
33369
33631
  access: "create"
33370
33632
  },
33633
+ "system.getFailureContributions": {
33634
+ capName: "system",
33635
+ capScope: "system",
33636
+ addonId: null,
33637
+ access: "view"
33638
+ },
33371
33639
  "system.getLoadContributions": {
33372
33640
  capName: "system",
33373
33641
  capScope: "system",