@camstack/addon-pipeline-orchestrator 1.2.55 → 1.2.57

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 (23) hide show
  1. package/assets/viewer/.viewer-version +1 -1
  2. package/assets/viewer/_expo/static/js/web/{Clipboard-dbfb8d2b9b663367bf606c4e4156c5ed.js → Clipboard-2a9c34a2d9e4522a4b07afe082f73a11.js} +8 -8
  3. package/assets/viewer/_expo/static/js/web/{Haptics-3fc6898fa899589be5efd6c4561b70a1.js → Haptics-d691ef5c23765594fc76d7d0159e12a9.js} +3 -3
  4. package/assets/viewer/_expo/static/js/web/{index-f066fed82c1011c63951f43a4c81626f.js → index-cdddfaafe98f9abb2eed34c2d3ea7fce.js} +80 -78
  5. package/assets/viewer/_expo/static/js/web/{node-fs-3444d48a73ceb72dd0e6a45d4ba686f4.js → node-fs-fbe6ecbf78f47e60bf5606ef62613a4d.js} +1 -1
  6. package/assets/viewer/_expo/static/js/web/{node-path-31d2d4f03c0bb8e91685e0b2170248e7.js → node-path-88d7caa558342a85e438efa999e4aecd.js} +1 -1
  7. package/assets/viewer/embed/assets/{MaskShapeCanvas-DI4BY7W2-BPL877bq.js → MaskShapeCanvas-DI4BY7W2-BG9b15XX.js} +1 -1
  8. package/assets/viewer/embed/assets/{MotionZonesSettings-NcxxQN8r-9o1vTDhU.js → MotionZonesSettings-NcxxQN8r-6tRLDb49.js} +1 -1
  9. package/assets/viewer/embed/assets/{PrivacyMaskSettings-APgPLF7p-BgXKxbFU.js → PrivacyMaskSettings-APgPLF7p-Bbna4XaY.js} +1 -1
  10. package/assets/viewer/embed/assets/index-IodqkuCI.js +116 -0
  11. package/assets/viewer/embed/index.html +1 -1
  12. package/assets/viewer/index.html +1 -1
  13. package/dist/_stub.js +2 -2
  14. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DQ-9VJzz.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-D4ElH3_I.mjs} +3 -3
  15. package/dist/{_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B5aOYEp_.mjs → _virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CoB82Qob.mjs} +1 -1
  16. package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DUDj4-xm.mjs +26 -0
  17. package/dist/{hostInit-DEefQ-oR.mjs → hostInit-CDG9B2az.mjs} +3 -3
  18. package/dist/index.js +596 -12
  19. package/dist/index.mjs +596 -12
  20. package/dist/remoteEntry.js +1 -1
  21. package/package.json +1 -1
  22. package/assets/viewer/embed/assets/index-CN50_nGz.js +0 -116
  23. package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dea44qi5.mjs +0 -26
package/dist/index.js CHANGED
@@ -24,12 +24,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  enumerable: true
25
25
  }) : target, mod));
26
26
  //#endregion
27
+ let node_crypto = require("node:crypto");
27
28
  let node_fs = require("node:fs");
28
29
  node_fs = __toESM(node_fs);
29
30
  let node_path = require("node:path");
30
31
  node_path = __toESM(node_path);
31
32
  let node_url = require("node:url");
32
- //#region ../types/dist/event-category-Cv9dO26A.mjs
33
+ //#region ../types/dist/event-category-Bxo5yJjt.mjs
33
34
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
34
35
  EventCategory["SystemBoot"] = "system.boot";
35
36
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -236,6 +237,33 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
236
237
  EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
237
238
  EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
238
239
  /**
240
+ * A node the orchestrator would otherwise place cameras on has NO usable
241
+ * inference device: the operator enabled one or more accelerators there and
242
+ * the live probe reports every one of them unavailable. Emitted once per
243
+ * TRANSITION into that state (never per dispatch), and the node is dropped
244
+ * from the placement candidate set for as long as it holds.
245
+ *
246
+ * This exists because the state was previously invisible: little-unraid
247
+ * absorbed 283k inference errors in a day while still being handed cameras,
248
+ * and nothing in the system said so.
249
+ *
250
+ * A node with no accelerators configured at all is NOT this — its devices
251
+ * are `disabled`, not `unavailable`, and the runner's default CPU pool
252
+ * serves it exactly as before.
253
+ */
254
+ EventCategory["PipelineNodeInferenceUnavailable"] = "pipeline.node-inference-unavailable";
255
+ /**
256
+ * A camera has an OPEN detection session and has produced no detection at
257
+ * all for longer than the blind threshold — the camera is being decoded and
258
+ * inferred and is returning nothing. Emitted once per transition into blind,
259
+ * per camera.
260
+ *
261
+ * The failure it reports: a 1h43 detection blackout on the entrance camera
262
+ * that nobody noticed, because "a camera that detects nothing" and "a quiet
263
+ * camera" produce byte-identical silence.
264
+ */
265
+ EventCategory["PipelineDetectionBlind"] = "pipeline.detection-blind";
266
+ /**
239
267
  * Per-camera pipeline config was mutated by the orchestrator
240
268
  * (3-level settings change via `setAgentAddonDefaults` /
241
269
  * `setCameraStepToggle` / `setCameraPipelineForAgent` or a
@@ -5400,6 +5428,12 @@ Object.fromEntries([
5400
5428
  icon: "shapes",
5401
5429
  order: 38
5402
5430
  },
5431
+ {
5432
+ id: "scenes",
5433
+ label: "Scenes",
5434
+ icon: "scan-eye",
5435
+ order: 36
5436
+ },
5403
5437
  {
5404
5438
  id: "analytics",
5405
5439
  label: "Analytics",
@@ -14927,6 +14961,8 @@ var NcSystemEventKindSchema = _enum([
14927
14961
  "stream-offline",
14928
14962
  "node-online",
14929
14963
  "node-offline",
14964
+ "node-inference-unavailable",
14965
+ "detection-blind",
14930
14966
  "addon-update-available",
14931
14967
  "server-update-available",
14932
14968
  "alarm-triggered",
@@ -15698,7 +15734,50 @@ var NcRuleInputSchema = object({
15698
15734
  * tests every zone the track visited and a `crossing` condition can no longer
15699
15735
  * be satisfied, because a closed track carries no crossing.
15700
15736
  */
15701
- waitForEnhancement: boolean().optional()
15737
+ waitForEnhancement: boolean().optional(),
15738
+ /**
15739
+ * GROUP a burst of subjects into ONE notification that grows.
15740
+ *
15741
+ * Seconds of quiet after the last matching subject before the burst is
15742
+ * considered over. While it is open, the first subject enqueues immediately —
15743
+ * **exactly as today, with no added latency** — and every real growth (a new
15744
+ * subject, or a name confirmed on one already in it) REPLACES that
15745
+ * notification with an updated one naming everybody. The push carries the
15746
+ * group's own coalescing tag, so the phone replaces rather than stacks.
15747
+ *
15748
+ * `0` / absent = off, and off is today's behaviour byte for byte.
15749
+ *
15750
+ * ### Why an idle cutoff and not a window
15751
+ *
15752
+ * The measured seven-person arrival on device 590 spans 110 s with every
15753
+ * internal gap under 30 s. A 12 s fixed window cuts it into three groups; an
15754
+ * idle cutoff holds it as one and ends it when the arrival actually ends.
15755
+ * 30 is Frigate's shipped value for the same decision.
15756
+ *
15757
+ * ### What it replaces
15758
+ *
15759
+ * The blind cooldown, which collapses a burst by DISCARDING it. Measured on
15760
+ * device 615 / *Persona su Uscio* over six days: 116 qualifying tracks → 74
15761
+ * notifications, **44 (37.9%) suppressed outright**, 23 of them overlapping a
15762
+ * track that did fire and 7 carrying a confirmed identity nobody heard about.
15763
+ * A group collapses the same volume by MERGING, so the cooldown becomes a
15764
+ * budget over GROUPS — which is what it always meant — and a growth is never
15765
+ * throttled by the window its own first member spent.
15766
+ *
15767
+ * ### Interaction with {@link waitForEnhancement}
15768
+ *
15769
+ * They compose, and the order matters. `waitForEnhancement` defers the rule to
15770
+ * TRACK CLOSE, so with both set the group is opened by the first member to
15771
+ * CLOSE — already carrying its name — and grows as later members close. That
15772
+ * is later, and complete. With grouping alone the group opens on the first
15773
+ * object event and picks up names as they are confirmed, through the growth
15774
+ * path. Neither combination fires twice for one subject.
15775
+ *
15776
+ * `.optional()` and deliberately NOT `.default()`: a Zod default does not run
15777
+ * on the addon cap path, so absent must keep meaning what it meant before this
15778
+ * field existed.
15779
+ */
15780
+ groupIdleSec: number().int().min(0).max(600).optional()
15702
15781
  });
15703
15782
  /**
15704
15783
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -16624,7 +16703,7 @@ var TrackEnvelopeSchema = object({
16624
16703
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16625
16704
  * keeps every scalar the list surfaces actually render (ids, class(es),
16626
16705
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16627
- * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
16706
+ * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16628
16707
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16629
16708
  * `getTrack`. Mirrors the event-store `projection` convention
16630
16709
  * (`getObjectEvents` et al.).
@@ -16911,6 +16990,28 @@ var TrackSchema = object({
16911
16990
  * `=== true` and render nothing otherwise, never infer "no face".
16912
16991
  */
16913
16992
  hasFace: boolean().optional(),
16993
+ /**
16994
+ * This subject CONTAINS a folded rider — a person the rider-pairing step
16995
+ * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16996
+ * so the passage is tracked once and as a VEHICLE.
16997
+ *
16998
+ * It exists because the fold's record was dishonest. D34 and the code both
16999
+ * said "the person is not lost — it is reported so both entities stay on the
17000
+ * record"; in fact the pair went into a per-processor RAM field behind an
17001
+ * accessor nobody called, and every durable surface said `vehicle`, full
17002
+ * stop. This is the composition note that makes the row true.
17003
+ *
17004
+ * A COMPOSITION, never a class and never a label. "This vehicle contains a
17005
+ * person" is not an answer to "what is this" — both label tiers would refuse
17006
+ * a macro token anyway (D89), and correctly. Nothing here changes what the
17007
+ * subject IS: a cyclist stays one vehicle track, occupancy still counts one,
17008
+ * and a `person` rule still does not fire for someone cycling past.
17009
+ *
17010
+ * **Absent ≠ false**, exactly like {@link hasFace}: every row written before
17011
+ * the column, and every hub that predates the field, omits it. Test
17012
+ * `=== true` and render nothing otherwise — never infer "no rider".
17013
+ */
17014
+ hasRider: boolean().optional(),
16914
17015
  ...TrackFlagFields,
16915
17016
  ...TrackRetrainFields
16916
17017
  });
@@ -25607,9 +25708,33 @@ method(object({
25607
25708
  * settings-contribution methods. `status.kind:'push'` — the engine pushes on
25608
25709
  * every hysteresis flip / availability change; consumers never poll.
25609
25710
  */
25610
- /** Extensible condition tag. Seeded 'day' | 'night'; open by design so more can
25611
- * be added without a wire break (matching falls back to any-condition refs). */
25711
+ /** Extensible condition tag. Seeded 'day' | 'ir' (the two variants the operator
25712
+ * captures) plus 'night' | 'dawn' | 'dusk' from the resolver's sun-times band.
25713
+ * Open by design so more can be added without a wire break.
25714
+ *
25715
+ * Matching does NOT fall back across conditions: cross-condition cosines are
25716
+ * not comparable, so "I have never seen this scene in this light" is reported
25717
+ * as `unknown`, never guessed. A day reference scored against an IR frame
25718
+ * collapses the cosine and would latch a false alarm every single night. */
25612
25719
  var SceneConditionSchema = string();
25720
+ /** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
25721
+ * `unknown` = we cannot judge (no reference for this condition, encoder model
25722
+ * changed, view shifted, no snapshot). `unknown` is a real value, not a null,
25723
+ * and never counts toward hysteresis in either direction. */
25724
+ var SceneVerdictSchema = _enum([
25725
+ "matched",
25726
+ "diverged",
25727
+ "unknown"
25728
+ ]);
25729
+ /** Why a scene cannot judge. Named, because this feature's failure mode is
25730
+ * silence that reads as "nothing has happened". */
25731
+ var SceneUnavailableSchema = _enum([
25732
+ "no-reference-for-condition",
25733
+ "view-shifted",
25734
+ "no-vision-profile",
25735
+ "encoder-model-changed",
25736
+ "no-snapshot"
25737
+ ]);
25613
25738
  /** One captured reference — condition-tagged, model-version-gated. `embedding`
25614
25739
  * is `number[]` (Float32Array does NOT survive MsgPack/UDS). */
25615
25740
  var SceneReferenceSchema = object({
@@ -25617,7 +25742,14 @@ var SceneReferenceSchema = object({
25617
25742
  modelId: string(),
25618
25743
  condition: SceneConditionSchema,
25619
25744
  capturedAt: number(),
25620
- thumbnailMediaId: string().optional()
25745
+ thumbnailMediaId: string().optional(),
25746
+ /** Whole-frame (downscaled) embedding captured alongside the ROI crop. The
25747
+ * anti-view-shift anchor: a bumped camera, a PTZ preset or a re-aim makes the
25748
+ * normalized rect frame a different piece of world, and the scene would
25749
+ * diverge forever with a perfectly plausible cosine. Checked LAZILY, only
25750
+ * when hysteresis is about to flip — one extra encode per candidate
25751
+ * transition, not per poll. */
25752
+ anchorEmbedding: array(number()).optional()
25621
25753
  });
25622
25754
  var SceneMonitorStateSchema = object({
25623
25755
  id: string(),
@@ -25639,6 +25771,25 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
25639
25771
  profileId: string().optional(),
25640
25772
  hysteresisCount: number().int().positive()
25641
25773
  })]);
25774
+ var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
25775
+ /**
25776
+ * Vision-model adjudication of a candidate flip. Field names deliberately
25777
+ * mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
25778
+ *
25779
+ * `onTimeout` defaults to **'hold'**, the OPPOSITE of `NcConfirmGate`'s
25780
+ * fail-open: a notification suppressed is the worse error there, but a vision
25781
+ * model that timed out has not told us the bin is gone, and a latch is a
25782
+ * stateful claim that costs the operator a trip to reset.
25783
+ */
25784
+ var SceneConfirmSchema = object({
25785
+ enabled: boolean().default(false),
25786
+ prompt: string().min(1).max(1e3),
25787
+ profileId: string().optional(),
25788
+ timeoutMs: number().int().min(1e3).max(2e4).default(8e3),
25789
+ maxImagePx: number().int().min(64).max(2048).default(448),
25790
+ /** What a timeout / unavailable model means for the PENDING flip. */
25791
+ onTimeout: _enum(["flip", "hold"]).default("hold")
25792
+ });
25642
25793
  var SceneMonitorSchema = object({
25643
25794
  id: string(),
25644
25795
  label: string(),
@@ -25657,7 +25808,41 @@ var SceneMonitorSchema = object({
25657
25808
  lastConfidence: number().nullable(),
25658
25809
  currentCondition: SceneConditionSchema.nullable(),
25659
25810
  availability: _enum(["ok", "unavailable"]),
25660
- unavailableReason: string().nullable()
25811
+ unavailableReason: string().nullable(),
25812
+ /** Which state is "the initial screen". `null` until the first capture. */
25813
+ baselineStateId: string().nullable(),
25814
+ /** Which boolean drives notification rules and any export. */
25815
+ emit: _enum(["latched", "live"]).default("latched"),
25816
+ /** Live: does the region match the baseline RIGHT NOW. */
25817
+ verdict: SceneVerdictSchema,
25818
+ /** Has it been `diverged` at least once since `armedAt` — the operator's boolean. */
25819
+ latched: boolean(),
25820
+ /** Last reset (or creation). */
25821
+ armedAt: number(),
25822
+ divergedAt: number().nullable(),
25823
+ restoredAt: number().nullable(),
25824
+ /** A check is only COUNTED when the device has been quiet this long. Motion
25825
+ * during the window DISCARDS the observation — a car pulling up in front of
25826
+ * the bin must not be able to spend hysteresis credit. */
25827
+ quietSeconds: number().int().min(0).max(3600).default(60),
25828
+ /** An observation only advances the pending count when it is at least this
25829
+ * far from the previously counted one, so N agreeing checks span real time
25830
+ * rather than N adjacent polls inside one occlusion. */
25831
+ minObservationSpacingSec: number().int().min(0).max(3600).default(120),
25832
+ /** Vision-model adjudication of a candidate flip. Similarity primary only. */
25833
+ confirm: SceneConfirmSchema.optional(),
25834
+ /** Whole-frame anchor cosine below which a flip is REFUSED as `view-shifted`. */
25835
+ anchorThreshold: number().min(0).max(1).default(SCENE_DEFAULT_ANCHOR_THRESHOLD),
25836
+ /** Clear the latch on its own when the scene matches again? Default false —
25837
+ * `restoredAt` and the `scene-restored` edge are recorded regardless, so an
25838
+ * automation can react to the bin coming back without the operator's own
25839
+ * alarm silently clearing itself. */
25840
+ autoRestore: boolean().default(false),
25841
+ /** Named cause when `verdict === 'unknown'`. */
25842
+ unavailable: SceneUnavailableSchema.nullable(),
25843
+ /** Conditions that have at least one comparable reference — the coverage line
25844
+ * ("day ✓ · ir ✓ · dusk ✗") that turns a silent fallback into a visible fact. */
25845
+ coveredConditions: array(SceneConditionSchema)
25661
25846
  });
25662
25847
  var SceneMonitorStatusSchema = object({
25663
25848
  monitors: array(SceneMonitorSchema),
@@ -25690,7 +25875,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSche
25690
25875
  "both"
25691
25876
  ]).optional(),
25692
25877
  checkIntervalSec: number().optional(),
25693
- check: SceneCheckSchema.optional()
25878
+ check: SceneCheckSchema.optional(),
25879
+ emit: _enum(["latched", "live"]).optional(),
25880
+ quietSeconds: number().int().min(0).max(3600).optional(),
25881
+ minObservationSpacingSec: number().int().min(0).max(3600).optional(),
25882
+ anchorThreshold: number().min(0).max(1).optional(),
25883
+ autoRestore: boolean().optional(),
25884
+ /** `null` clears the vision-model adjudicator. */
25885
+ confirm: SceneConfirmSchema.nullable().optional()
25694
25886
  })
25695
25887
  }), _void(), {
25696
25888
  kind: "mutation",
@@ -25727,6 +25919,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSche
25727
25919
  }), _void(), {
25728
25920
  kind: "mutation",
25729
25921
  auth: "admin"
25922
+ }), method(object({
25923
+ deviceId: number(),
25924
+ monitorId: string(),
25925
+ /** Defaults to TRUE at the provider seam — see `SCENE_RESET_RECAPTURES`. */
25926
+ recapture: boolean().optional()
25927
+ }), _void(), {
25928
+ kind: "mutation",
25929
+ auth: "admin"
25730
25930
  });
25731
25931
  /**
25732
25932
  * Per-stage gating mode applied to the zones a rule references.
@@ -31104,6 +31304,12 @@ Object.freeze({
31104
31304
  addonId: null,
31105
31305
  access: "create"
31106
31306
  },
31307
+ "sceneMonitor.resetScene": {
31308
+ capName: "scene-monitor",
31309
+ capScope: "device",
31310
+ addonId: null,
31311
+ access: "delete"
31312
+ },
31107
31313
  "sceneMonitor.updateScene": {
31108
31314
  capName: "scene-monitor",
31109
31315
  capScope: "device",
@@ -33786,6 +33992,11 @@ Object.freeze({
33786
33992
  form: "single",
33787
33993
  optional: false
33788
33994
  }],
33995
+ "sceneMonitor.resetScene": [{
33996
+ name: "deviceId",
33997
+ form: "single",
33998
+ optional: false
33999
+ }],
33789
34000
  "sceneMonitor.updateScene": [{
33790
34001
  name: "deviceId",
33791
34002
  form: "single",
@@ -34805,6 +35016,15 @@ function resolveInferenceDeviceEligibility(probed, stored, canRunRoot) {
34805
35016
  excluded
34806
35017
  };
34807
35018
  }
35019
+ function resolveNodeInferenceUsability(eligibility) {
35020
+ const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
35021
+ const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
35022
+ return {
35023
+ usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
35024
+ unavailableKeys,
35025
+ eligibleKeys
35026
+ };
35027
+ }
34808
35028
  /**
34809
35029
  * Step-tree device jump (phase 1): the attach-payload roster of a node's
34810
35030
  * enabled∧available inference devices with the balancer knobs (`weight`,
@@ -34823,6 +35043,67 @@ function buildInferenceDeviceRoster(eligible, caps) {
34823
35043
  })).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
34824
35044
  }
34825
35045
  //#endregion
35046
+ //#region src/node-inference-usability-mirror.ts
35047
+ var NodeInferenceUsabilityMirror = class {
35048
+ state = /* @__PURE__ */ new Map();
35049
+ /**
35050
+ * Fold one observation in and report whether the caller should act.
35051
+ * Never throws.
35052
+ */
35053
+ observe(nodeId, usable) {
35054
+ const prev = this.state.get(nodeId);
35055
+ if (usable) {
35056
+ this.state.set(nodeId, {
35057
+ usable: true,
35058
+ armed: false
35059
+ });
35060
+ return prev !== void 0 && !prev.usable ? "recovered" : null;
35061
+ }
35062
+ if (prev === void 0) {
35063
+ this.state.set(nodeId, {
35064
+ usable: true,
35065
+ armed: true
35066
+ });
35067
+ return null;
35068
+ }
35069
+ if (!prev.usable) {
35070
+ this.state.set(nodeId, {
35071
+ usable: false,
35072
+ armed: true
35073
+ });
35074
+ return null;
35075
+ }
35076
+ if (!prev.armed) {
35077
+ this.state.set(nodeId, {
35078
+ usable: true,
35079
+ armed: true
35080
+ });
35081
+ return null;
35082
+ }
35083
+ this.state.set(nodeId, {
35084
+ usable: false,
35085
+ armed: true
35086
+ });
35087
+ return "became-unusable";
35088
+ }
35089
+ /** Can this node be given cameras? Unknown nodes answer YES. */
35090
+ isUsable(nodeId) {
35091
+ return this.state.get(nodeId)?.usable ?? true;
35092
+ }
35093
+ /** Nodes currently excluded — for the placement log and diagnostics. */
35094
+ unusableNodeIds() {
35095
+ const out = [];
35096
+ for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
35097
+ return out.toSorted();
35098
+ }
35099
+ forget(nodeId) {
35100
+ this.state.delete(nodeId);
35101
+ }
35102
+ reset() {
35103
+ this.state.clear();
35104
+ }
35105
+ };
35106
+ //#endregion
34826
35107
  //#region src/orchestrator-types.ts
34827
35108
  var PHASE_MODE_VALUES = new Set([
34828
35109
  "disabled",
@@ -36333,6 +36614,69 @@ var LoadShedController = class LoadShedController {
36333
36614
  }
36334
36615
  }
36335
36616
  };
36617
+ var DEFAULT_DETECTION_BLIND_CONFIG = {
36618
+ thresholdMs: 60 * 6e4,
36619
+ frameFreshnessMs: 5 * 6e4
36620
+ };
36621
+ var DetectionBlindMonitor = class {
36622
+ config;
36623
+ state = /* @__PURE__ */ new Map();
36624
+ constructor(config = DEFAULT_DETECTION_BLIND_CONFIG) {
36625
+ this.config = config;
36626
+ }
36627
+ /**
36628
+ * One inference frame arrived for a device. `hadDetection` is false for a
36629
+ * frame that returned nothing — which is the whole point: an empty frame
36630
+ * still proves the pipeline is alive, and it is the ONLY evidence that
36631
+ * distinguishes blind from detached.
36632
+ */
36633
+ noteFrame(deviceId, hadDetection, now) {
36634
+ const prev = this.state.get(deviceId);
36635
+ if (hadDetection) {
36636
+ this.state.set(deviceId, {
36637
+ lastFrameAt: now,
36638
+ lastDetectionAt: now,
36639
+ reported: false
36640
+ });
36641
+ return;
36642
+ }
36643
+ this.state.set(deviceId, {
36644
+ lastFrameAt: now,
36645
+ lastDetectionAt: prev?.lastDetectionAt ?? now,
36646
+ reported: prev?.reported ?? false
36647
+ });
36648
+ }
36649
+ /**
36650
+ * Devices that have JUST crossed into blind. Call from an existing periodic
36651
+ * sweep; it allocates nothing on the healthy path.
36652
+ */
36653
+ sweep(now) {
36654
+ const out = [];
36655
+ for (const [deviceId, s] of this.state) {
36656
+ if (s.reported) continue;
36657
+ if (now - s.lastFrameAt > this.config.frameFreshnessMs) continue;
36658
+ const blindForMs = now - s.lastDetectionAt;
36659
+ if (blindForMs < this.config.thresholdMs) continue;
36660
+ this.state.set(deviceId, {
36661
+ ...s,
36662
+ reported: true
36663
+ });
36664
+ out.push({
36665
+ deviceId,
36666
+ blindForMs,
36667
+ thresholdMs: this.config.thresholdMs
36668
+ });
36669
+ }
36670
+ return out;
36671
+ }
36672
+ /** Drop a device (detach / unbind), so a re-attach starts a fresh clock. */
36673
+ forget(deviceId) {
36674
+ this.state.delete(deviceId);
36675
+ }
36676
+ reset() {
36677
+ this.state.clear();
36678
+ }
36679
+ };
36336
36680
  //#endregion
36337
36681
  //#region src/agent-load-service.ts
36338
36682
  var AgentLoadService = class AgentLoadService {
@@ -38585,6 +38929,7 @@ var DetectionWiringController = class {
38585
38929
  if (!this.deps.ctx()) return;
38586
38930
  const { deviceId, frame, frameHandle, capturedAt } = payload;
38587
38931
  this.deps.watchdog()?.noteSignal(deviceId, "detection");
38932
+ this.deps.blindMonitor()?.noteFrame(deviceId, frame.detections.length > 0, Date.now());
38588
38933
  if (frame.detections.length === 0 && (frame.discarded?.length ?? 0) === 0) return;
38589
38934
  this.deps.eventBus.emit({
38590
38935
  id: `detection-${deviceId}-${Date.now()}`,
@@ -40372,6 +40717,34 @@ var PlacementService = class {
40372
40717
  constructor(deps) {
40373
40718
  this.deps = deps;
40374
40719
  }
40720
+ /**
40721
+ * Drop nodes that cannot infer from a candidate set.
40722
+ *
40723
+ * Fails OPEN in one specific way that matters: if the filter would leave NO
40724
+ * candidate at all, the unfiltered set is used. Placing a camera on a node
40725
+ * that might fail its inference is bad; refusing to place it anywhere
40726
+ * because every node is currently mid-probe is worse, and that is the exact
40727
+ * shape a cold cluster start has.
40728
+ */
40729
+ filterInferenceUsable(nodes, deviceId) {
40730
+ const usable = nodes.filter((nodeId) => this.deps.isNodeInferenceUsable(nodeId));
40731
+ if (usable.length === nodes.length) return nodes;
40732
+ if (usable.length === 0) {
40733
+ this.deps.ctx().logger.warn("every candidate node reports no usable inference device — placing anyway", {
40734
+ tags: { deviceId },
40735
+ meta: { nodes: [...nodes] }
40736
+ });
40737
+ return nodes;
40738
+ }
40739
+ this.deps.ctx().logger.info("nodes dropped from placement — no usable inference device", {
40740
+ tags: { deviceId },
40741
+ meta: {
40742
+ dropped: nodes.filter((n) => !usable.includes(n)),
40743
+ kept: usable
40744
+ }
40745
+ });
40746
+ return usable;
40747
+ }
40375
40748
  async dispatchCamera(runnerConfig) {
40376
40749
  if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
40377
40750
  if (this.deps.maintenancePaused?.() === true) return {
@@ -40386,7 +40759,7 @@ var PlacementService = class {
40386
40759
  };
40387
40760
  const preferredAgent = await this.deps.readPipelinePin(runnerConfig.deviceId);
40388
40761
  const loads = await this.deps.loadService.collectAgentLoad({ onlyEnabled: true });
40389
- const eligible = this.deps.topology.detectionEligibleNodes(runnerConfig.deviceId);
40762
+ const eligible = this.filterInferenceUsable(this.deps.topology.detectionEligibleNodes(runnerConfig.deviceId), runnerConfig.deviceId);
40390
40763
  const nodeCaps = await this.deps.settingsStore.buildNodeCaps();
40391
40764
  const motionRoleCandidate = pickMotionRolePlacement({
40392
40765
  isAnalyzer: runnerConfig.motionSources.includes("analyzer"),
@@ -44259,7 +44632,10 @@ var SessionDispatchController = class {
44259
44632
  await this.deps.reconcilePlacementFromRunners();
44260
44633
  const preferredAgent = await this.deps.readPipelinePin(deviceId);
44261
44634
  const loads = await this.agentLoads.get(CLUSTER_KEY);
44262
- const eligible = this.deps.topology.detectionEligibleNodes(deviceId);
44635
+ const allEligible = this.deps.topology.detectionEligibleNodes(deviceId);
44636
+ const usableEligible = allEligible.filter((n) => this.deps.isNodeInferenceUsable(n));
44637
+ const eligible = usableEligible.length > 0 ? usableEligible : allEligible;
44638
+ if (usableEligible.length > 0 && usableEligible.length < allEligible.length) log.info("nodes dropped from session placement — no usable inference device", { meta: { dropped: allEligible.filter((n) => !usableEligible.includes(n)) } });
44263
44639
  const decision = balance({
44264
44640
  nodes: loads,
44265
44641
  preferredAgent,
@@ -45181,6 +45557,78 @@ var ZonesProvider = class {
45181
45557
  //#endregion
45182
45558
  //#region src/orchestrator-bootstrap.ts
45183
45559
  /**
45560
+ * `orchestrator-bootstrap.ts` — the controller-construction body of
45561
+ * `onInitialize` (S11 Task 4, last extraction of the S11 series).
45562
+ *
45563
+ * `buildOrchestratorControllers(deps)` constructs all ~16 controllers the
45564
+ * addon owns, in the EXACT order `onInitialize` built them in before this
45565
+ * extraction (ledger → topology → loadService → audio → settingsStore →
45566
+ * loadShed → placement → session → deviceConfig → cameraStatusService →
45567
+ * reconcile → [settings load + localNodeId resolve] → nodeLifecycle →
45568
+ * [event subs + readiness seed] → pipelineWatchdog → [timers + legacy
45569
+ * migration] → zoneRulesProvider → zonesProvider →
45570
+ * [wireOrchestratorSubscriptions + eager reconcile] → detectionWiring).
45571
+ * Every dep closure is carried over byte-for-byte — only `this.X` became
45572
+ * either a local variable (for a controller already constructed earlier in
45573
+ * THIS function) or a `deps.X` accessor (for addon-owned state/methods that
45574
+ * stay on `PipelineOrchestratorAddon`, or for a controller referenced
45575
+ * before its own construction point — a genuine forward ref, always via a
45576
+ * LAZY closure, exactly as it was in `onInitialize`).
45577
+ *
45578
+ * Three things could not simply become local variables, because an EXISTING
45579
+ * (unmoved) addon method reads the REAL class field synchronously, mid-
45580
+ * construction, not through a lazy closure:
45581
+ * - `applyRuntimeSettings` (stays on the addon) reads `this.topology!`
45582
+ * and `this.reconcile!` directly — called synchronously from the
45583
+ * settings-load block below. `deps.setTopology`/`deps.setReconcile`
45584
+ * progressively sync the REAL addon fields the instant each is built,
45585
+ * so `applyRuntimeSettings` sees them.
45586
+ * - `subscribeToAudioAnalyzerReadiness` (stays on the addon) reads
45587
+ * `this.nodeLifecycle!.readinessRegistry` AND `this.audio!` directly —
45588
+ * called synchronously right after `nodeLifecycle` is built (readiness
45589
+ * seed + the `AgentOnline` handler). `deps.setNodeLifecycle` /
45590
+ * `deps.setAudio` cover it the same way. This one bit LIVE: `audio` is
45591
+ * constructed early (fourth) but `subscribeToAudioAnalyzerReadiness`
45592
+ * wasn't called until much later (after `nodeLifecycle` exists), so the
45593
+ * old code never noticed `this.audio` was still unsynced — until
45594
+ * `ReadinessRegistry.onReadyState`'s `queueMicrotask` hydration replay
45595
+ * (an already-`ready` cap fires the handler once, asynchronously, on
45596
+ * subscribe) won the race against the `await buildOrchestratorControllers(...)`
45597
+ * continuation in `onInitialize` that assigns `this.audio` — both are
45598
+ * microtasks, and the replay's `queueMicrotask` was enqueued (during
45599
+ * this still-synchronous function body) strictly before this function's
45600
+ * `return` resolves the outer promise, so it always runs first. Fixed
45601
+ * by syncing `this.audio` the instant it's constructed, exactly like
45602
+ * `topology`/`reconcile`/`nodeLifecycle` — no lazy closure ever reads a
45603
+ * controller before this function has assigned its real field.
45604
+ * Every other controller-to-controller reference in this function — even
45605
+ * ones that look like a forward ref (e.g. `loadShed`'s `attach` closure
45606
+ * onto `placement`, built one step later) — is a LAZY closure that is
45607
+ * never invoked synchronously during construction (only later, off
45608
+ * event-bus subscriptions, timers, or RPC handlers), so a plain local
45609
+ * `let` variable captured by reference is sufficient and exactly
45610
+ * preserves the original semantics: see each field's inline comment in
45611
+ * `index.ts` for the original construction-order reasoning, unchanged
45612
+ * here.
45613
+ *
45614
+ * `globalSettings` is a purely local variable — read/written ONLY by
45615
+ * closures built inside this same function (the one exception,
45616
+ * `deviceConfig`'s `setGlobalSettings`, is ALSO built here, so the local
45617
+ * variable stays the single source of truth for every reader). No code
45618
+ * outside this closure network ever read the former `this.globalSettings`
45619
+ * class field, so it isn't part of the returned `OrchestratorControllers`
45620
+ * — the field was removed from the class entirely (S11 Task 4 cleanup).
45621
+ * `localNodeId` IS returned — `index.ts` still reads `this.localNodeId`
45622
+ * from a couple of unrelated cap methods (`dumpDiagnostics`,
45623
+ * `getDecoderAssignments`), so the class field is populated once, from the
45624
+ * returned `OrchestratorControllers`, after this function returns.
45625
+ * `failoverPolicy` is the opposite case from `globalSettings` —
45626
+ * `applyRuntimeSettings` (unmoved) writes the REAL field on every future
45627
+ * settings change, so `nodeLifecycle`'s `failoverPolicy` closure MUST keep
45628
+ * reading through `deps.failoverPolicy()` (never a local snapshot) to see
45629
+ * those later updates.
45630
+ */
45631
+ /**
45184
45632
  * Construct every orchestrator controller, wire the agent-lifecycle event
45185
45633
  * subscriptions + readiness seed + detection-wiring subscriptions, and
45186
45634
  * kick the eager reconcile — the ENTIRE former `onInitialize` body between
@@ -45339,6 +45787,7 @@ async function buildOrchestratorControllers(deps) {
45339
45787
  localNodeId: () => localNodeId,
45340
45788
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
45341
45789
  getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
45790
+ isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
45342
45791
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
45343
45792
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
45344
45793
  inferenceRotation,
@@ -45368,6 +45817,7 @@ async function buildOrchestratorControllers(deps) {
45368
45817
  listZones: async (deviceId) => await zonesProvider?.listZones({ deviceId }) ?? [],
45369
45818
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
45370
45819
  getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
45820
+ isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
45371
45821
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
45372
45822
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
45373
45823
  inferenceRotation,
@@ -45526,7 +45976,34 @@ async function buildOrchestratorControllers(deps) {
45526
45976
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
45527
45977
  });
45528
45978
  pipelineWatchdog.start(deps.watchdogIntervalMs);
45529
- const pendingRetryTimer = setInterval(() => void reconcile.retryPendingDispatches(), PENDING_RETRY_INTERVAL_MS);
45979
+ const blindMonitor = new DetectionBlindMonitor();
45980
+ const pendingRetryTimer = setInterval(() => {
45981
+ reconcile.retryPendingDispatches();
45982
+ for (const blind of blindMonitor.sweep(Date.now())) {
45983
+ deps.ctx().logger.error("camera is being analysed and detecting NOTHING", {
45984
+ tags: { deviceId: blind.deviceId },
45985
+ meta: {
45986
+ blindForMs: blind.blindForMs,
45987
+ thresholdMs: blind.thresholdMs
45988
+ }
45989
+ });
45990
+ deps.ctx().eventBus.emit({
45991
+ id: (0, node_crypto.randomUUID)(),
45992
+ timestamp: /* @__PURE__ */ new Date(),
45993
+ source: {
45994
+ type: "device",
45995
+ id: blind.deviceId,
45996
+ deviceId: blind.deviceId
45997
+ },
45998
+ category: EventCategory.PipelineDetectionBlind,
45999
+ data: {
46000
+ deviceId: blind.deviceId,
46001
+ blindForMs: blind.blindForMs,
46002
+ thresholdMs: blind.thresholdMs
46003
+ }
46004
+ });
46005
+ }
46006
+ }, PENDING_RETRY_INTERVAL_MS);
45530
46007
  const autoRebalanceTimer = setInterval(() => void placement.runAutoRebalance(), AUTO_REBALANCE_INTERVAL_MS);
45531
46008
  deps.ctx().logger.info("Pipeline orchestrator detection-wiring subscriptions installed");
45532
46009
  migrateLegacyFlagsToBindings({
@@ -45653,6 +46130,7 @@ async function buildOrchestratorControllers(deps) {
45653
46130
  loadShed,
45654
46131
  nodeStress,
45655
46132
  watchdog: () => pipelineWatchdog,
46133
+ blindMonitor: () => blindMonitor,
45656
46134
  api: () => deps.ctx().api ?? null,
45657
46135
  eventBus: deps.ctx().eventBus,
45658
46136
  logger: deps.ctx().logger,
@@ -46230,6 +46708,48 @@ function deriveRuntimeSettings(config) {
46230
46708
  //#endregion
46231
46709
  //#region src/index.ts
46232
46710
  /**
46711
+ * addon-pipeline-orchestrator — hub-side camera-to-agent load balancer.
46712
+ *
46713
+ * Owns the control plane for the distributed detection pipeline. Tracks
46714
+ * which `addon-pipeline-runner` instance is responsible for each camera,
46715
+ * queries each runner's capacity + hardware profile, and dispatches
46716
+ * `attachCamera` / `detachCamera` cap calls to the appropriate runner via
46717
+ * the Moleculer broker. Persists manual pins via the per-device settings
46718
+ * store so user overrides survive hub restarts.
46719
+ *
46720
+ * Strictly control plane — never sees a frame, never runs inference, never
46721
+ * drains queues. The runner does that work locally on each vision node.
46722
+ *
46723
+ * ## Dispatch entrypoints
46724
+ *
46725
+ * - **`dispatchCamera(runnerConfig)`** — in-process only (not in the cap
46726
+ * def). Called by `DetectionWiringService` with a freshly-built
46727
+ * `RunnerCameraConfig`. Runs the L1/L2/L3 balancer, picks an agent, then
46728
+ * issues `broker.call('pipeline-runner.pipeline-runner.attachCamera', ...)`.
46729
+ * Caches the config so future rebalance / pinning can re-dispatch without
46730
+ * the caller rebuilding it.
46731
+ *
46732
+ * - **`assignCamera({deviceId, agentNodeId})`** — cap method. Persists a
46733
+ * manual `preferredAgent` pin to per-device settings and re-dispatches
46734
+ * immediately if the camera is already attached.
46735
+ *
46736
+ * - **`unassignCamera({deviceId})`** — cap method. Clears the pin,
46737
+ * detaches the camera from its current agent.
46738
+ *
46739
+ * - **`rebalance()`** — cap method. Walks every cached camera config and
46740
+ * re-runs the balancer; migrates cameras whose current agent is no
46741
+ * longer optimal. Pinned cameras are never migrated.
46742
+ *
46743
+ * ## Runner dispatch
46744
+ *
46745
+ * All runner calls go through `this.ctx.api.pipelineRunner.*` (the generated
46746
+ * tRPC cap router). The router extracts `nodeId` from the input and
46747
+ * routes transparently to the local runner (direct in-process call) or
46748
+ * to a remote agent (via Moleculer). Node discovery is driven by
46749
+ * `EventCategory.AgentOnline/AgentOffline` events forwarded by
46750
+ * `agent-registry.service.ts`.
46751
+ */
46752
+ /**
46233
46753
  * The FULL action catalog, under the name the HUB HARVESTS.
46234
46754
  *
46235
46755
  * The hub's forked-addon harvest imports this entry module and reads the
@@ -46488,6 +47008,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
46488
47008
  detachOn: (nodeId, deviceId) => this.detachOn(nodeId, deviceId),
46489
47009
  readPipelinePin: (deviceId) => this.readPipelinePin(deviceId),
46490
47010
  getEligibleInferenceDevices: (nodeId) => this.resolveEligibleInferenceDevices(nodeId),
47011
+ isNodeInferenceUsable: (nodeId) => this.isNodeInferenceUsable(nodeId),
46491
47012
  getInferenceDeviceCaps: (nodeId) => this.getInferenceDeviceCaps(nodeId),
46492
47013
  readPipelineDevicePin: (deviceId) => this.readPipelineDevicePin(deviceId),
46493
47014
  subscribeToAudioAnalyzerReadiness: (nodeId) => this.subscribeToAudioAnalyzerReadiness(nodeId),
@@ -47144,11 +47665,66 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
47144
47665
  const stored = (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {};
47145
47666
  const { probed } = await this.probeNodeInferenceDevices(nodeId);
47146
47667
  const catalog = await this.settingsStore.getCatalogForNode(nodeId);
47147
- const { eligible, excluded } = resolveInferenceDeviceEligibility(probed, stored, catalog ? makeRootCapabilityGuard(catalog) : void 0);
47668
+ const eligibility = resolveInferenceDeviceEligibility(probed, stored, catalog ? makeRootCapabilityGuard(catalog) : void 0);
47669
+ const { eligible, excluded } = eligibility;
47148
47670
  this.logInferenceDeviceShrinkage(nodeId, eligible, excluded);
47671
+ this.noteNodeInferenceUsability(nodeId, resolveNodeInferenceUsability(eligibility));
47149
47672
  return eligible;
47150
47673
  }
47151
47674
  /**
47675
+ * Fold one usability observation into the placement mirror and announce the
47676
+ * transition.
47677
+ *
47678
+ * This is called from the SAME read the dispatcher already makes, plus the
47679
+ * session controller's background refresher — so a node's usability is
47680
+ * re-observed on the cadence placement itself runs at, without a timer of
47681
+ * its own. The mirror is what makes the answer readable synchronously by
47682
+ * `detectionEligibleNodes`' callers; see its own docblock for why it refuses
47683
+ * to act on a single bad read.
47684
+ *
47685
+ * The system event is emitted ONLY on the transition into unusable — the
47686
+ * kind is `node-inference-unavailable` and it is authorable in a rule.
47687
+ * Recovery is logged and deliberately has no event: a notification saying
47688
+ * the broken thing is no longer broken is a second rule nobody asked for.
47689
+ */
47690
+ noteNodeInferenceUsability(nodeId, usability) {
47691
+ const transition = this.nodeInferenceUsability.observe(nodeId, usability.usable);
47692
+ if (transition === null) return;
47693
+ if (transition === "recovered") {
47694
+ this.ctx.logger.info("node inference recovered — back in the placement candidate set", {
47695
+ tags: { nodeId },
47696
+ meta: { eligible: usability.eligibleKeys }
47697
+ });
47698
+ return;
47699
+ }
47700
+ this.ctx.logger.error("node has NO usable inference device — removed from the placement candidate set", {
47701
+ tags: { nodeId },
47702
+ meta: {
47703
+ unavailable: usability.unavailableKeys,
47704
+ eligible: usability.eligibleKeys
47705
+ }
47706
+ });
47707
+ this.ctx.eventBus.emit({
47708
+ id: (0, node_crypto.randomUUID)(),
47709
+ timestamp: /* @__PURE__ */ new Date(),
47710
+ source: {
47711
+ type: "addon",
47712
+ id: "pipeline-orchestrator",
47713
+ addonId: "pipeline-orchestrator"
47714
+ },
47715
+ category: EventCategory.PipelineNodeInferenceUnavailable,
47716
+ data: {
47717
+ nodeId,
47718
+ unavailableDeviceKeys: usability.unavailableKeys
47719
+ }
47720
+ });
47721
+ }
47722
+ /** Can this node be given cameras? Read synchronously by the two dispatch
47723
+ * paths before they hand `eligibleNodes` to the balancer. */
47724
+ isNodeInferenceUsable(nodeId) {
47725
+ return this.nodeInferenceUsability.isUsable(nodeId);
47726
+ }
47727
+ /**
47152
47728
  * Per-node signature of the last logged eligible/excluded split, so
47153
47729
  * {@link logInferenceDeviceShrinkage} fires on CHANGE only. This runs on
47154
47730
  * every camera dispatch; without the gate it would be one line per dispatch
@@ -47156,6 +47732,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
47156
47732
  */
47157
47733
  lastInferenceEligibilitySignature = /* @__PURE__ */ new Map();
47158
47734
  /**
47735
+ * Which nodes can still infer. Refreshed by
47736
+ * {@link resolveEligibleInferenceDevices}; read synchronously by both
47737
+ * dispatch paths so a node whose every enabled accelerator vanished stops
47738
+ * receiving cameras. little-unraid was handed cameras for a whole day in
47739
+ * exactly that state.
47740
+ */
47741
+ nodeInferenceUsability = new NodeInferenceUsabilityMirror();
47742
+ /**
47159
47743
  * Say out loud which of a node's inference devices were dropped from the
47160
47744
  * candidate set, and why.
47161
47745
  *