@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.mjs CHANGED
@@ -1,7 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- //#region ../types/dist/event-category-Cv9dO26A.mjs
5
+ //#region ../types/dist/event-category-Bxo5yJjt.mjs
5
6
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
6
7
  EventCategory["SystemBoot"] = "system.boot";
7
8
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -208,6 +209,33 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
208
209
  EventCategory["PipelineCameraAssigned"] = "pipeline.camera-assigned";
209
210
  EventCategory["PipelineCameraUnassigned"] = "pipeline.camera-unassigned";
210
211
  /**
212
+ * A node the orchestrator would otherwise place cameras on has NO usable
213
+ * inference device: the operator enabled one or more accelerators there and
214
+ * the live probe reports every one of them unavailable. Emitted once per
215
+ * TRANSITION into that state (never per dispatch), and the node is dropped
216
+ * from the placement candidate set for as long as it holds.
217
+ *
218
+ * This exists because the state was previously invisible: little-unraid
219
+ * absorbed 283k inference errors in a day while still being handed cameras,
220
+ * and nothing in the system said so.
221
+ *
222
+ * A node with no accelerators configured at all is NOT this — its devices
223
+ * are `disabled`, not `unavailable`, and the runner's default CPU pool
224
+ * serves it exactly as before.
225
+ */
226
+ EventCategory["PipelineNodeInferenceUnavailable"] = "pipeline.node-inference-unavailable";
227
+ /**
228
+ * A camera has an OPEN detection session and has produced no detection at
229
+ * all for longer than the blind threshold — the camera is being decoded and
230
+ * inferred and is returning nothing. Emitted once per transition into blind,
231
+ * per camera.
232
+ *
233
+ * The failure it reports: a 1h43 detection blackout on the entrance camera
234
+ * that nobody noticed, because "a camera that detects nothing" and "a quiet
235
+ * camera" produce byte-identical silence.
236
+ */
237
+ EventCategory["PipelineDetectionBlind"] = "pipeline.detection-blind";
238
+ /**
211
239
  * Per-camera pipeline config was mutated by the orchestrator
212
240
  * (3-level settings change via `setAgentAddonDefaults` /
213
241
  * `setCameraStepToggle` / `setCameraPipelineForAgent` or a
@@ -5372,6 +5400,12 @@ Object.fromEntries([
5372
5400
  icon: "shapes",
5373
5401
  order: 38
5374
5402
  },
5403
+ {
5404
+ id: "scenes",
5405
+ label: "Scenes",
5406
+ icon: "scan-eye",
5407
+ order: 36
5408
+ },
5375
5409
  {
5376
5410
  id: "analytics",
5377
5411
  label: "Analytics",
@@ -14899,6 +14933,8 @@ var NcSystemEventKindSchema = _enum([
14899
14933
  "stream-offline",
14900
14934
  "node-online",
14901
14935
  "node-offline",
14936
+ "node-inference-unavailable",
14937
+ "detection-blind",
14902
14938
  "addon-update-available",
14903
14939
  "server-update-available",
14904
14940
  "alarm-triggered",
@@ -15670,7 +15706,50 @@ var NcRuleInputSchema = object({
15670
15706
  * tests every zone the track visited and a `crossing` condition can no longer
15671
15707
  * be satisfied, because a closed track carries no crossing.
15672
15708
  */
15673
- waitForEnhancement: boolean().optional()
15709
+ waitForEnhancement: boolean().optional(),
15710
+ /**
15711
+ * GROUP a burst of subjects into ONE notification that grows.
15712
+ *
15713
+ * Seconds of quiet after the last matching subject before the burst is
15714
+ * considered over. While it is open, the first subject enqueues immediately —
15715
+ * **exactly as today, with no added latency** — and every real growth (a new
15716
+ * subject, or a name confirmed on one already in it) REPLACES that
15717
+ * notification with an updated one naming everybody. The push carries the
15718
+ * group's own coalescing tag, so the phone replaces rather than stacks.
15719
+ *
15720
+ * `0` / absent = off, and off is today's behaviour byte for byte.
15721
+ *
15722
+ * ### Why an idle cutoff and not a window
15723
+ *
15724
+ * The measured seven-person arrival on device 590 spans 110 s with every
15725
+ * internal gap under 30 s. A 12 s fixed window cuts it into three groups; an
15726
+ * idle cutoff holds it as one and ends it when the arrival actually ends.
15727
+ * 30 is Frigate's shipped value for the same decision.
15728
+ *
15729
+ * ### What it replaces
15730
+ *
15731
+ * The blind cooldown, which collapses a burst by DISCARDING it. Measured on
15732
+ * device 615 / *Persona su Uscio* over six days: 116 qualifying tracks → 74
15733
+ * notifications, **44 (37.9%) suppressed outright**, 23 of them overlapping a
15734
+ * track that did fire and 7 carrying a confirmed identity nobody heard about.
15735
+ * A group collapses the same volume by MERGING, so the cooldown becomes a
15736
+ * budget over GROUPS — which is what it always meant — and a growth is never
15737
+ * throttled by the window its own first member spent.
15738
+ *
15739
+ * ### Interaction with {@link waitForEnhancement}
15740
+ *
15741
+ * They compose, and the order matters. `waitForEnhancement` defers the rule to
15742
+ * TRACK CLOSE, so with both set the group is opened by the first member to
15743
+ * CLOSE — already carrying its name — and grows as later members close. That
15744
+ * is later, and complete. With grouping alone the group opens on the first
15745
+ * object event and picks up names as they are confirmed, through the growth
15746
+ * path. Neither combination fires twice for one subject.
15747
+ *
15748
+ * `.optional()` and deliberately NOT `.default()`: a Zod default does not run
15749
+ * on the addon cap path, so absent must keep meaning what it meant before this
15750
+ * field existed.
15751
+ */
15752
+ groupIdleSec: number().int().min(0).max(600).optional()
15674
15753
  });
15675
15754
  /**
15676
15755
  * Partial patch for `updateRule` — any subset of the input fields, plus the
@@ -16596,7 +16675,7 @@ var TrackEnvelopeSchema = object({
16596
16675
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
16597
16676
  * keeps every scalar the list surfaces actually render (ids, class(es),
16598
16677
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
16599
- * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
16678
+ * zonesVisited, bestEventId, envelope, hasFace, hasRider) and returns `positions` /
16600
16679
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
16601
16680
  * `getTrack`. Mirrors the event-store `projection` convention
16602
16681
  * (`getObjectEvents` et al.).
@@ -16883,6 +16962,28 @@ var TrackSchema = object({
16883
16962
  * `=== true` and render nothing otherwise, never infer "no face".
16884
16963
  */
16885
16964
  hasFace: boolean().optional(),
16965
+ /**
16966
+ * This subject CONTAINS a folded rider — a person the rider-pairing step
16967
+ * ([D34](../decisions/adr-0034.md)) removed from the frame BEFORE the tracker,
16968
+ * so the passage is tracked once and as a VEHICLE.
16969
+ *
16970
+ * It exists because the fold's record was dishonest. D34 and the code both
16971
+ * said "the person is not lost — it is reported so both entities stay on the
16972
+ * record"; in fact the pair went into a per-processor RAM field behind an
16973
+ * accessor nobody called, and every durable surface said `vehicle`, full
16974
+ * stop. This is the composition note that makes the row true.
16975
+ *
16976
+ * A COMPOSITION, never a class and never a label. "This vehicle contains a
16977
+ * person" is not an answer to "what is this" — both label tiers would refuse
16978
+ * a macro token anyway (D89), and correctly. Nothing here changes what the
16979
+ * subject IS: a cyclist stays one vehicle track, occupancy still counts one,
16980
+ * and a `person` rule still does not fire for someone cycling past.
16981
+ *
16982
+ * **Absent ≠ false**, exactly like {@link hasFace}: every row written before
16983
+ * the column, and every hub that predates the field, omits it. Test
16984
+ * `=== true` and render nothing otherwise — never infer "no rider".
16985
+ */
16986
+ hasRider: boolean().optional(),
16886
16987
  ...TrackFlagFields,
16887
16988
  ...TrackRetrainFields
16888
16989
  });
@@ -25579,9 +25680,33 @@ method(object({
25579
25680
  * settings-contribution methods. `status.kind:'push'` — the engine pushes on
25580
25681
  * every hysteresis flip / availability change; consumers never poll.
25581
25682
  */
25582
- /** Extensible condition tag. Seeded 'day' | 'night'; open by design so more can
25583
- * be added without a wire break (matching falls back to any-condition refs). */
25683
+ /** Extensible condition tag. Seeded 'day' | 'ir' (the two variants the operator
25684
+ * captures) plus 'night' | 'dawn' | 'dusk' from the resolver's sun-times band.
25685
+ * Open by design so more can be added without a wire break.
25686
+ *
25687
+ * Matching does NOT fall back across conditions: cross-condition cosines are
25688
+ * not comparable, so "I have never seen this scene in this light" is reported
25689
+ * as `unknown`, never guessed. A day reference scored against an IR frame
25690
+ * collapses the cosine and would latch a false alarm every single night. */
25584
25691
  var SceneConditionSchema = string();
25692
+ /** `matched` = the baseline is what we see; `diverged` = it demonstrably is not;
25693
+ * `unknown` = we cannot judge (no reference for this condition, encoder model
25694
+ * changed, view shifted, no snapshot). `unknown` is a real value, not a null,
25695
+ * and never counts toward hysteresis in either direction. */
25696
+ var SceneVerdictSchema = _enum([
25697
+ "matched",
25698
+ "diverged",
25699
+ "unknown"
25700
+ ]);
25701
+ /** Why a scene cannot judge. Named, because this feature's failure mode is
25702
+ * silence that reads as "nothing has happened". */
25703
+ var SceneUnavailableSchema = _enum([
25704
+ "no-reference-for-condition",
25705
+ "view-shifted",
25706
+ "no-vision-profile",
25707
+ "encoder-model-changed",
25708
+ "no-snapshot"
25709
+ ]);
25585
25710
  /** One captured reference — condition-tagged, model-version-gated. `embedding`
25586
25711
  * is `number[]` (Float32Array does NOT survive MsgPack/UDS). */
25587
25712
  var SceneReferenceSchema = object({
@@ -25589,7 +25714,14 @@ var SceneReferenceSchema = object({
25589
25714
  modelId: string(),
25590
25715
  condition: SceneConditionSchema,
25591
25716
  capturedAt: number(),
25592
- thumbnailMediaId: string().optional()
25717
+ thumbnailMediaId: string().optional(),
25718
+ /** Whole-frame (downscaled) embedding captured alongside the ROI crop. The
25719
+ * anti-view-shift anchor: a bumped camera, a PTZ preset or a re-aim makes the
25720
+ * normalized rect frame a different piece of world, and the scene would
25721
+ * diverge forever with a perfectly plausible cosine. Checked LAZILY, only
25722
+ * when hysteresis is about to flip — one extra encode per candidate
25723
+ * transition, not per poll. */
25724
+ anchorEmbedding: array(number()).optional()
25593
25725
  });
25594
25726
  var SceneMonitorStateSchema = object({
25595
25727
  id: string(),
@@ -25611,6 +25743,25 @@ var SceneCheckSchema = discriminatedUnion("mode", [object({
25611
25743
  profileId: string().optional(),
25612
25744
  hysteresisCount: number().int().positive()
25613
25745
  })]);
25746
+ var SCENE_DEFAULT_ANCHOR_THRESHOLD = .85;
25747
+ /**
25748
+ * Vision-model adjudication of a candidate flip. Field names deliberately
25749
+ * mirror `NcConfirmSchema` so an operator meets one vocabulary, not two.
25750
+ *
25751
+ * `onTimeout` defaults to **'hold'**, the OPPOSITE of `NcConfirmGate`'s
25752
+ * fail-open: a notification suppressed is the worse error there, but a vision
25753
+ * model that timed out has not told us the bin is gone, and a latch is a
25754
+ * stateful claim that costs the operator a trip to reset.
25755
+ */
25756
+ var SceneConfirmSchema = object({
25757
+ enabled: boolean().default(false),
25758
+ prompt: string().min(1).max(1e3),
25759
+ profileId: string().optional(),
25760
+ timeoutMs: number().int().min(1e3).max(2e4).default(8e3),
25761
+ maxImagePx: number().int().min(64).max(2048).default(448),
25762
+ /** What a timeout / unavailable model means for the PENDING flip. */
25763
+ onTimeout: _enum(["flip", "hold"]).default("hold")
25764
+ });
25614
25765
  var SceneMonitorSchema = object({
25615
25766
  id: string(),
25616
25767
  label: string(),
@@ -25629,7 +25780,41 @@ var SceneMonitorSchema = object({
25629
25780
  lastConfidence: number().nullable(),
25630
25781
  currentCondition: SceneConditionSchema.nullable(),
25631
25782
  availability: _enum(["ok", "unavailable"]),
25632
- unavailableReason: string().nullable()
25783
+ unavailableReason: string().nullable(),
25784
+ /** Which state is "the initial screen". `null` until the first capture. */
25785
+ baselineStateId: string().nullable(),
25786
+ /** Which boolean drives notification rules and any export. */
25787
+ emit: _enum(["latched", "live"]).default("latched"),
25788
+ /** Live: does the region match the baseline RIGHT NOW. */
25789
+ verdict: SceneVerdictSchema,
25790
+ /** Has it been `diverged` at least once since `armedAt` — the operator's boolean. */
25791
+ latched: boolean(),
25792
+ /** Last reset (or creation). */
25793
+ armedAt: number(),
25794
+ divergedAt: number().nullable(),
25795
+ restoredAt: number().nullable(),
25796
+ /** A check is only COUNTED when the device has been quiet this long. Motion
25797
+ * during the window DISCARDS the observation — a car pulling up in front of
25798
+ * the bin must not be able to spend hysteresis credit. */
25799
+ quietSeconds: number().int().min(0).max(3600).default(60),
25800
+ /** An observation only advances the pending count when it is at least this
25801
+ * far from the previously counted one, so N agreeing checks span real time
25802
+ * rather than N adjacent polls inside one occlusion. */
25803
+ minObservationSpacingSec: number().int().min(0).max(3600).default(120),
25804
+ /** Vision-model adjudication of a candidate flip. Similarity primary only. */
25805
+ confirm: SceneConfirmSchema.optional(),
25806
+ /** Whole-frame anchor cosine below which a flip is REFUSED as `view-shifted`. */
25807
+ anchorThreshold: number().min(0).max(1).default(SCENE_DEFAULT_ANCHOR_THRESHOLD),
25808
+ /** Clear the latch on its own when the scene matches again? Default false —
25809
+ * `restoredAt` and the `scene-restored` edge are recorded regardless, so an
25810
+ * automation can react to the bin coming back without the operator's own
25811
+ * alarm silently clearing itself. */
25812
+ autoRestore: boolean().default(false),
25813
+ /** Named cause when `verdict === 'unknown'`. */
25814
+ unavailable: SceneUnavailableSchema.nullable(),
25815
+ /** Conditions that have at least one comparable reference — the coverage line
25816
+ * ("day ✓ · ir ✓ · dusk ✗") that turns a silent fallback into a visible fact. */
25817
+ coveredConditions: array(SceneConditionSchema)
25633
25818
  });
25634
25819
  var SceneMonitorStatusSchema = object({
25635
25820
  monitors: array(SceneMonitorSchema),
@@ -25662,7 +25847,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSche
25662
25847
  "both"
25663
25848
  ]).optional(),
25664
25849
  checkIntervalSec: number().optional(),
25665
- check: SceneCheckSchema.optional()
25850
+ check: SceneCheckSchema.optional(),
25851
+ emit: _enum(["latched", "live"]).optional(),
25852
+ quietSeconds: number().int().min(0).max(3600).optional(),
25853
+ minObservationSpacingSec: number().int().min(0).max(3600).optional(),
25854
+ anchorThreshold: number().min(0).max(1).optional(),
25855
+ autoRestore: boolean().optional(),
25856
+ /** `null` clears the vision-model adjudicator. */
25857
+ confirm: SceneConfirmSchema.nullable().optional()
25666
25858
  })
25667
25859
  }), _void(), {
25668
25860
  kind: "mutation",
@@ -25699,6 +25891,14 @@ DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSche
25699
25891
  }), _void(), {
25700
25892
  kind: "mutation",
25701
25893
  auth: "admin"
25894
+ }), method(object({
25895
+ deviceId: number(),
25896
+ monitorId: string(),
25897
+ /** Defaults to TRUE at the provider seam — see `SCENE_RESET_RECAPTURES`. */
25898
+ recapture: boolean().optional()
25899
+ }), _void(), {
25900
+ kind: "mutation",
25901
+ auth: "admin"
25702
25902
  });
25703
25903
  /**
25704
25904
  * Per-stage gating mode applied to the zones a rule references.
@@ -31076,6 +31276,12 @@ Object.freeze({
31076
31276
  addonId: null,
31077
31277
  access: "create"
31078
31278
  },
31279
+ "sceneMonitor.resetScene": {
31280
+ capName: "scene-monitor",
31281
+ capScope: "device",
31282
+ addonId: null,
31283
+ access: "delete"
31284
+ },
31079
31285
  "sceneMonitor.updateScene": {
31080
31286
  capName: "scene-monitor",
31081
31287
  capScope: "device",
@@ -33758,6 +33964,11 @@ Object.freeze({
33758
33964
  form: "single",
33759
33965
  optional: false
33760
33966
  }],
33967
+ "sceneMonitor.resetScene": [{
33968
+ name: "deviceId",
33969
+ form: "single",
33970
+ optional: false
33971
+ }],
33761
33972
  "sceneMonitor.updateScene": [{
33762
33973
  name: "deviceId",
33763
33974
  form: "single",
@@ -34777,6 +34988,15 @@ function resolveInferenceDeviceEligibility(probed, stored, canRunRoot) {
34777
34988
  excluded
34778
34989
  };
34779
34990
  }
34991
+ function resolveNodeInferenceUsability(eligibility) {
34992
+ const eligibleKeys = Object.keys(eligibility.eligible).toSorted();
34993
+ const unavailableKeys = eligibility.excluded.filter((e) => e.reason === "unavailable").map((e) => e.key).toSorted();
34994
+ return {
34995
+ usable: eligibleKeys.length > 0 || unavailableKeys.length === 0,
34996
+ unavailableKeys,
34997
+ eligibleKeys
34998
+ };
34999
+ }
34780
35000
  /**
34781
35001
  * Step-tree device jump (phase 1): the attach-payload roster of a node's
34782
35002
  * enabled∧available inference devices with the balancer knobs (`weight`,
@@ -34795,6 +35015,67 @@ function buildInferenceDeviceRoster(eligible, caps) {
34795
35015
  })).toSorted((a, b) => a.deviceKey < b.deviceKey ? -1 : a.deviceKey > b.deviceKey ? 1 : 0);
34796
35016
  }
34797
35017
  //#endregion
35018
+ //#region src/node-inference-usability-mirror.ts
35019
+ var NodeInferenceUsabilityMirror = class {
35020
+ state = /* @__PURE__ */ new Map();
35021
+ /**
35022
+ * Fold one observation in and report whether the caller should act.
35023
+ * Never throws.
35024
+ */
35025
+ observe(nodeId, usable) {
35026
+ const prev = this.state.get(nodeId);
35027
+ if (usable) {
35028
+ this.state.set(nodeId, {
35029
+ usable: true,
35030
+ armed: false
35031
+ });
35032
+ return prev !== void 0 && !prev.usable ? "recovered" : null;
35033
+ }
35034
+ if (prev === void 0) {
35035
+ this.state.set(nodeId, {
35036
+ usable: true,
35037
+ armed: true
35038
+ });
35039
+ return null;
35040
+ }
35041
+ if (!prev.usable) {
35042
+ this.state.set(nodeId, {
35043
+ usable: false,
35044
+ armed: true
35045
+ });
35046
+ return null;
35047
+ }
35048
+ if (!prev.armed) {
35049
+ this.state.set(nodeId, {
35050
+ usable: true,
35051
+ armed: true
35052
+ });
35053
+ return null;
35054
+ }
35055
+ this.state.set(nodeId, {
35056
+ usable: false,
35057
+ armed: true
35058
+ });
35059
+ return "became-unusable";
35060
+ }
35061
+ /** Can this node be given cameras? Unknown nodes answer YES. */
35062
+ isUsable(nodeId) {
35063
+ return this.state.get(nodeId)?.usable ?? true;
35064
+ }
35065
+ /** Nodes currently excluded — for the placement log and diagnostics. */
35066
+ unusableNodeIds() {
35067
+ const out = [];
35068
+ for (const [nodeId, s] of this.state) if (!s.usable) out.push(nodeId);
35069
+ return out.toSorted();
35070
+ }
35071
+ forget(nodeId) {
35072
+ this.state.delete(nodeId);
35073
+ }
35074
+ reset() {
35075
+ this.state.clear();
35076
+ }
35077
+ };
35078
+ //#endregion
34798
35079
  //#region src/orchestrator-types.ts
34799
35080
  var PHASE_MODE_VALUES = new Set([
34800
35081
  "disabled",
@@ -36305,6 +36586,69 @@ var LoadShedController = class LoadShedController {
36305
36586
  }
36306
36587
  }
36307
36588
  };
36589
+ var DEFAULT_DETECTION_BLIND_CONFIG = {
36590
+ thresholdMs: 60 * 6e4,
36591
+ frameFreshnessMs: 5 * 6e4
36592
+ };
36593
+ var DetectionBlindMonitor = class {
36594
+ config;
36595
+ state = /* @__PURE__ */ new Map();
36596
+ constructor(config = DEFAULT_DETECTION_BLIND_CONFIG) {
36597
+ this.config = config;
36598
+ }
36599
+ /**
36600
+ * One inference frame arrived for a device. `hadDetection` is false for a
36601
+ * frame that returned nothing — which is the whole point: an empty frame
36602
+ * still proves the pipeline is alive, and it is the ONLY evidence that
36603
+ * distinguishes blind from detached.
36604
+ */
36605
+ noteFrame(deviceId, hadDetection, now) {
36606
+ const prev = this.state.get(deviceId);
36607
+ if (hadDetection) {
36608
+ this.state.set(deviceId, {
36609
+ lastFrameAt: now,
36610
+ lastDetectionAt: now,
36611
+ reported: false
36612
+ });
36613
+ return;
36614
+ }
36615
+ this.state.set(deviceId, {
36616
+ lastFrameAt: now,
36617
+ lastDetectionAt: prev?.lastDetectionAt ?? now,
36618
+ reported: prev?.reported ?? false
36619
+ });
36620
+ }
36621
+ /**
36622
+ * Devices that have JUST crossed into blind. Call from an existing periodic
36623
+ * sweep; it allocates nothing on the healthy path.
36624
+ */
36625
+ sweep(now) {
36626
+ const out = [];
36627
+ for (const [deviceId, s] of this.state) {
36628
+ if (s.reported) continue;
36629
+ if (now - s.lastFrameAt > this.config.frameFreshnessMs) continue;
36630
+ const blindForMs = now - s.lastDetectionAt;
36631
+ if (blindForMs < this.config.thresholdMs) continue;
36632
+ this.state.set(deviceId, {
36633
+ ...s,
36634
+ reported: true
36635
+ });
36636
+ out.push({
36637
+ deviceId,
36638
+ blindForMs,
36639
+ thresholdMs: this.config.thresholdMs
36640
+ });
36641
+ }
36642
+ return out;
36643
+ }
36644
+ /** Drop a device (detach / unbind), so a re-attach starts a fresh clock. */
36645
+ forget(deviceId) {
36646
+ this.state.delete(deviceId);
36647
+ }
36648
+ reset() {
36649
+ this.state.clear();
36650
+ }
36651
+ };
36308
36652
  //#endregion
36309
36653
  //#region src/agent-load-service.ts
36310
36654
  var AgentLoadService = class AgentLoadService {
@@ -38557,6 +38901,7 @@ var DetectionWiringController = class {
38557
38901
  if (!this.deps.ctx()) return;
38558
38902
  const { deviceId, frame, frameHandle, capturedAt } = payload;
38559
38903
  this.deps.watchdog()?.noteSignal(deviceId, "detection");
38904
+ this.deps.blindMonitor()?.noteFrame(deviceId, frame.detections.length > 0, Date.now());
38560
38905
  if (frame.detections.length === 0 && (frame.discarded?.length ?? 0) === 0) return;
38561
38906
  this.deps.eventBus.emit({
38562
38907
  id: `detection-${deviceId}-${Date.now()}`,
@@ -40344,6 +40689,34 @@ var PlacementService = class {
40344
40689
  constructor(deps) {
40345
40690
  this.deps = deps;
40346
40691
  }
40692
+ /**
40693
+ * Drop nodes that cannot infer from a candidate set.
40694
+ *
40695
+ * Fails OPEN in one specific way that matters: if the filter would leave NO
40696
+ * candidate at all, the unfiltered set is used. Placing a camera on a node
40697
+ * that might fail its inference is bad; refusing to place it anywhere
40698
+ * because every node is currently mid-probe is worse, and that is the exact
40699
+ * shape a cold cluster start has.
40700
+ */
40701
+ filterInferenceUsable(nodes, deviceId) {
40702
+ const usable = nodes.filter((nodeId) => this.deps.isNodeInferenceUsable(nodeId));
40703
+ if (usable.length === nodes.length) return nodes;
40704
+ if (usable.length === 0) {
40705
+ this.deps.ctx().logger.warn("every candidate node reports no usable inference device — placing anyway", {
40706
+ tags: { deviceId },
40707
+ meta: { nodes: [...nodes] }
40708
+ });
40709
+ return nodes;
40710
+ }
40711
+ this.deps.ctx().logger.info("nodes dropped from placement — no usable inference device", {
40712
+ tags: { deviceId },
40713
+ meta: {
40714
+ dropped: nodes.filter((n) => !usable.includes(n)),
40715
+ kept: usable
40716
+ }
40717
+ });
40718
+ return usable;
40719
+ }
40347
40720
  async dispatchCamera(runnerConfig) {
40348
40721
  if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
40349
40722
  if (this.deps.maintenancePaused?.() === true) return {
@@ -40358,7 +40731,7 @@ var PlacementService = class {
40358
40731
  };
40359
40732
  const preferredAgent = await this.deps.readPipelinePin(runnerConfig.deviceId);
40360
40733
  const loads = await this.deps.loadService.collectAgentLoad({ onlyEnabled: true });
40361
- const eligible = this.deps.topology.detectionEligibleNodes(runnerConfig.deviceId);
40734
+ const eligible = this.filterInferenceUsable(this.deps.topology.detectionEligibleNodes(runnerConfig.deviceId), runnerConfig.deviceId);
40362
40735
  const nodeCaps = await this.deps.settingsStore.buildNodeCaps();
40363
40736
  const motionRoleCandidate = pickMotionRolePlacement({
40364
40737
  isAnalyzer: runnerConfig.motionSources.includes("analyzer"),
@@ -44231,7 +44604,10 @@ var SessionDispatchController = class {
44231
44604
  await this.deps.reconcilePlacementFromRunners();
44232
44605
  const preferredAgent = await this.deps.readPipelinePin(deviceId);
44233
44606
  const loads = await this.agentLoads.get(CLUSTER_KEY);
44234
- const eligible = this.deps.topology.detectionEligibleNodes(deviceId);
44607
+ const allEligible = this.deps.topology.detectionEligibleNodes(deviceId);
44608
+ const usableEligible = allEligible.filter((n) => this.deps.isNodeInferenceUsable(n));
44609
+ const eligible = usableEligible.length > 0 ? usableEligible : allEligible;
44610
+ 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)) } });
44235
44611
  const decision = balance({
44236
44612
  nodes: loads,
44237
44613
  preferredAgent,
@@ -45153,6 +45529,78 @@ var ZonesProvider = class {
45153
45529
  //#endregion
45154
45530
  //#region src/orchestrator-bootstrap.ts
45155
45531
  /**
45532
+ * `orchestrator-bootstrap.ts` — the controller-construction body of
45533
+ * `onInitialize` (S11 Task 4, last extraction of the S11 series).
45534
+ *
45535
+ * `buildOrchestratorControllers(deps)` constructs all ~16 controllers the
45536
+ * addon owns, in the EXACT order `onInitialize` built them in before this
45537
+ * extraction (ledger → topology → loadService → audio → settingsStore →
45538
+ * loadShed → placement → session → deviceConfig → cameraStatusService →
45539
+ * reconcile → [settings load + localNodeId resolve] → nodeLifecycle →
45540
+ * [event subs + readiness seed] → pipelineWatchdog → [timers + legacy
45541
+ * migration] → zoneRulesProvider → zonesProvider →
45542
+ * [wireOrchestratorSubscriptions + eager reconcile] → detectionWiring).
45543
+ * Every dep closure is carried over byte-for-byte — only `this.X` became
45544
+ * either a local variable (for a controller already constructed earlier in
45545
+ * THIS function) or a `deps.X` accessor (for addon-owned state/methods that
45546
+ * stay on `PipelineOrchestratorAddon`, or for a controller referenced
45547
+ * before its own construction point — a genuine forward ref, always via a
45548
+ * LAZY closure, exactly as it was in `onInitialize`).
45549
+ *
45550
+ * Three things could not simply become local variables, because an EXISTING
45551
+ * (unmoved) addon method reads the REAL class field synchronously, mid-
45552
+ * construction, not through a lazy closure:
45553
+ * - `applyRuntimeSettings` (stays on the addon) reads `this.topology!`
45554
+ * and `this.reconcile!` directly — called synchronously from the
45555
+ * settings-load block below. `deps.setTopology`/`deps.setReconcile`
45556
+ * progressively sync the REAL addon fields the instant each is built,
45557
+ * so `applyRuntimeSettings` sees them.
45558
+ * - `subscribeToAudioAnalyzerReadiness` (stays on the addon) reads
45559
+ * `this.nodeLifecycle!.readinessRegistry` AND `this.audio!` directly —
45560
+ * called synchronously right after `nodeLifecycle` is built (readiness
45561
+ * seed + the `AgentOnline` handler). `deps.setNodeLifecycle` /
45562
+ * `deps.setAudio` cover it the same way. This one bit LIVE: `audio` is
45563
+ * constructed early (fourth) but `subscribeToAudioAnalyzerReadiness`
45564
+ * wasn't called until much later (after `nodeLifecycle` exists), so the
45565
+ * old code never noticed `this.audio` was still unsynced — until
45566
+ * `ReadinessRegistry.onReadyState`'s `queueMicrotask` hydration replay
45567
+ * (an already-`ready` cap fires the handler once, asynchronously, on
45568
+ * subscribe) won the race against the `await buildOrchestratorControllers(...)`
45569
+ * continuation in `onInitialize` that assigns `this.audio` — both are
45570
+ * microtasks, and the replay's `queueMicrotask` was enqueued (during
45571
+ * this still-synchronous function body) strictly before this function's
45572
+ * `return` resolves the outer promise, so it always runs first. Fixed
45573
+ * by syncing `this.audio` the instant it's constructed, exactly like
45574
+ * `topology`/`reconcile`/`nodeLifecycle` — no lazy closure ever reads a
45575
+ * controller before this function has assigned its real field.
45576
+ * Every other controller-to-controller reference in this function — even
45577
+ * ones that look like a forward ref (e.g. `loadShed`'s `attach` closure
45578
+ * onto `placement`, built one step later) — is a LAZY closure that is
45579
+ * never invoked synchronously during construction (only later, off
45580
+ * event-bus subscriptions, timers, or RPC handlers), so a plain local
45581
+ * `let` variable captured by reference is sufficient and exactly
45582
+ * preserves the original semantics: see each field's inline comment in
45583
+ * `index.ts` for the original construction-order reasoning, unchanged
45584
+ * here.
45585
+ *
45586
+ * `globalSettings` is a purely local variable — read/written ONLY by
45587
+ * closures built inside this same function (the one exception,
45588
+ * `deviceConfig`'s `setGlobalSettings`, is ALSO built here, so the local
45589
+ * variable stays the single source of truth for every reader). No code
45590
+ * outside this closure network ever read the former `this.globalSettings`
45591
+ * class field, so it isn't part of the returned `OrchestratorControllers`
45592
+ * — the field was removed from the class entirely (S11 Task 4 cleanup).
45593
+ * `localNodeId` IS returned — `index.ts` still reads `this.localNodeId`
45594
+ * from a couple of unrelated cap methods (`dumpDiagnostics`,
45595
+ * `getDecoderAssignments`), so the class field is populated once, from the
45596
+ * returned `OrchestratorControllers`, after this function returns.
45597
+ * `failoverPolicy` is the opposite case from `globalSettings` —
45598
+ * `applyRuntimeSettings` (unmoved) writes the REAL field on every future
45599
+ * settings change, so `nodeLifecycle`'s `failoverPolicy` closure MUST keep
45600
+ * reading through `deps.failoverPolicy()` (never a local snapshot) to see
45601
+ * those later updates.
45602
+ */
45603
+ /**
45156
45604
  * Construct every orchestrator controller, wire the agent-lifecycle event
45157
45605
  * subscriptions + readiness seed + detection-wiring subscriptions, and
45158
45606
  * kick the eager reconcile — the ENTIRE former `onInitialize` body between
@@ -45311,6 +45759,7 @@ async function buildOrchestratorControllers(deps) {
45311
45759
  localNodeId: () => localNodeId,
45312
45760
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
45313
45761
  getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
45762
+ isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
45314
45763
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
45315
45764
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
45316
45765
  inferenceRotation,
@@ -45340,6 +45789,7 @@ async function buildOrchestratorControllers(deps) {
45340
45789
  listZones: async (deviceId) => await zonesProvider?.listZones({ deviceId }) ?? [],
45341
45790
  readPipelinePin: (deviceId) => deps.readPipelinePin(deviceId),
45342
45791
  getEligibleInferenceDevices: (nodeId) => deps.getEligibleInferenceDevices(nodeId),
45792
+ isNodeInferenceUsable: (nodeId) => deps.isNodeInferenceUsable(nodeId),
45343
45793
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
45344
45794
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
45345
45795
  inferenceRotation,
@@ -45498,7 +45948,34 @@ async function buildOrchestratorControllers(deps) {
45498
45948
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
45499
45949
  });
45500
45950
  pipelineWatchdog.start(deps.watchdogIntervalMs);
45501
- const pendingRetryTimer = setInterval(() => void reconcile.retryPendingDispatches(), PENDING_RETRY_INTERVAL_MS);
45951
+ const blindMonitor = new DetectionBlindMonitor();
45952
+ const pendingRetryTimer = setInterval(() => {
45953
+ reconcile.retryPendingDispatches();
45954
+ for (const blind of blindMonitor.sweep(Date.now())) {
45955
+ deps.ctx().logger.error("camera is being analysed and detecting NOTHING", {
45956
+ tags: { deviceId: blind.deviceId },
45957
+ meta: {
45958
+ blindForMs: blind.blindForMs,
45959
+ thresholdMs: blind.thresholdMs
45960
+ }
45961
+ });
45962
+ deps.ctx().eventBus.emit({
45963
+ id: randomUUID(),
45964
+ timestamp: /* @__PURE__ */ new Date(),
45965
+ source: {
45966
+ type: "device",
45967
+ id: blind.deviceId,
45968
+ deviceId: blind.deviceId
45969
+ },
45970
+ category: EventCategory.PipelineDetectionBlind,
45971
+ data: {
45972
+ deviceId: blind.deviceId,
45973
+ blindForMs: blind.blindForMs,
45974
+ thresholdMs: blind.thresholdMs
45975
+ }
45976
+ });
45977
+ }
45978
+ }, PENDING_RETRY_INTERVAL_MS);
45502
45979
  const autoRebalanceTimer = setInterval(() => void placement.runAutoRebalance(), AUTO_REBALANCE_INTERVAL_MS);
45503
45980
  deps.ctx().logger.info("Pipeline orchestrator detection-wiring subscriptions installed");
45504
45981
  migrateLegacyFlagsToBindings({
@@ -45625,6 +46102,7 @@ async function buildOrchestratorControllers(deps) {
45625
46102
  loadShed,
45626
46103
  nodeStress,
45627
46104
  watchdog: () => pipelineWatchdog,
46105
+ blindMonitor: () => blindMonitor,
45628
46106
  api: () => deps.ctx().api ?? null,
45629
46107
  eventBus: deps.ctx().eventBus,
45630
46108
  logger: deps.ctx().logger,
@@ -46202,6 +46680,48 @@ function deriveRuntimeSettings(config) {
46202
46680
  //#endregion
46203
46681
  //#region src/index.ts
46204
46682
  /**
46683
+ * addon-pipeline-orchestrator — hub-side camera-to-agent load balancer.
46684
+ *
46685
+ * Owns the control plane for the distributed detection pipeline. Tracks
46686
+ * which `addon-pipeline-runner` instance is responsible for each camera,
46687
+ * queries each runner's capacity + hardware profile, and dispatches
46688
+ * `attachCamera` / `detachCamera` cap calls to the appropriate runner via
46689
+ * the Moleculer broker. Persists manual pins via the per-device settings
46690
+ * store so user overrides survive hub restarts.
46691
+ *
46692
+ * Strictly control plane — never sees a frame, never runs inference, never
46693
+ * drains queues. The runner does that work locally on each vision node.
46694
+ *
46695
+ * ## Dispatch entrypoints
46696
+ *
46697
+ * - **`dispatchCamera(runnerConfig)`** — in-process only (not in the cap
46698
+ * def). Called by `DetectionWiringService` with a freshly-built
46699
+ * `RunnerCameraConfig`. Runs the L1/L2/L3 balancer, picks an agent, then
46700
+ * issues `broker.call('pipeline-runner.pipeline-runner.attachCamera', ...)`.
46701
+ * Caches the config so future rebalance / pinning can re-dispatch without
46702
+ * the caller rebuilding it.
46703
+ *
46704
+ * - **`assignCamera({deviceId, agentNodeId})`** — cap method. Persists a
46705
+ * manual `preferredAgent` pin to per-device settings and re-dispatches
46706
+ * immediately if the camera is already attached.
46707
+ *
46708
+ * - **`unassignCamera({deviceId})`** — cap method. Clears the pin,
46709
+ * detaches the camera from its current agent.
46710
+ *
46711
+ * - **`rebalance()`** — cap method. Walks every cached camera config and
46712
+ * re-runs the balancer; migrates cameras whose current agent is no
46713
+ * longer optimal. Pinned cameras are never migrated.
46714
+ *
46715
+ * ## Runner dispatch
46716
+ *
46717
+ * All runner calls go through `this.ctx.api.pipelineRunner.*` (the generated
46718
+ * tRPC cap router). The router extracts `nodeId` from the input and
46719
+ * routes transparently to the local runner (direct in-process call) or
46720
+ * to a remote agent (via Moleculer). Node discovery is driven by
46721
+ * `EventCategory.AgentOnline/AgentOffline` events forwarded by
46722
+ * `agent-registry.service.ts`.
46723
+ */
46724
+ /**
46205
46725
  * The FULL action catalog, under the name the HUB HARVESTS.
46206
46726
  *
46207
46727
  * The hub's forked-addon harvest imports this entry module and reads the
@@ -46460,6 +46980,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
46460
46980
  detachOn: (nodeId, deviceId) => this.detachOn(nodeId, deviceId),
46461
46981
  readPipelinePin: (deviceId) => this.readPipelinePin(deviceId),
46462
46982
  getEligibleInferenceDevices: (nodeId) => this.resolveEligibleInferenceDevices(nodeId),
46983
+ isNodeInferenceUsable: (nodeId) => this.isNodeInferenceUsable(nodeId),
46463
46984
  getInferenceDeviceCaps: (nodeId) => this.getInferenceDeviceCaps(nodeId),
46464
46985
  readPipelineDevicePin: (deviceId) => this.readPipelineDevicePin(deviceId),
46465
46986
  subscribeToAudioAnalyzerReadiness: (nodeId) => this.subscribeToAudioAnalyzerReadiness(nodeId),
@@ -47116,11 +47637,66 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
47116
47637
  const stored = (await this.settingsStore.readAgentSettingsMap())[nodeId]?.inferenceDevices ?? {};
47117
47638
  const { probed } = await this.probeNodeInferenceDevices(nodeId);
47118
47639
  const catalog = await this.settingsStore.getCatalogForNode(nodeId);
47119
- const { eligible, excluded } = resolveInferenceDeviceEligibility(probed, stored, catalog ? makeRootCapabilityGuard(catalog) : void 0);
47640
+ const eligibility = resolveInferenceDeviceEligibility(probed, stored, catalog ? makeRootCapabilityGuard(catalog) : void 0);
47641
+ const { eligible, excluded } = eligibility;
47120
47642
  this.logInferenceDeviceShrinkage(nodeId, eligible, excluded);
47643
+ this.noteNodeInferenceUsability(nodeId, resolveNodeInferenceUsability(eligibility));
47121
47644
  return eligible;
47122
47645
  }
47123
47646
  /**
47647
+ * Fold one usability observation into the placement mirror and announce the
47648
+ * transition.
47649
+ *
47650
+ * This is called from the SAME read the dispatcher already makes, plus the
47651
+ * session controller's background refresher — so a node's usability is
47652
+ * re-observed on the cadence placement itself runs at, without a timer of
47653
+ * its own. The mirror is what makes the answer readable synchronously by
47654
+ * `detectionEligibleNodes`' callers; see its own docblock for why it refuses
47655
+ * to act on a single bad read.
47656
+ *
47657
+ * The system event is emitted ONLY on the transition into unusable — the
47658
+ * kind is `node-inference-unavailable` and it is authorable in a rule.
47659
+ * Recovery is logged and deliberately has no event: a notification saying
47660
+ * the broken thing is no longer broken is a second rule nobody asked for.
47661
+ */
47662
+ noteNodeInferenceUsability(nodeId, usability) {
47663
+ const transition = this.nodeInferenceUsability.observe(nodeId, usability.usable);
47664
+ if (transition === null) return;
47665
+ if (transition === "recovered") {
47666
+ this.ctx.logger.info("node inference recovered — back in the placement candidate set", {
47667
+ tags: { nodeId },
47668
+ meta: { eligible: usability.eligibleKeys }
47669
+ });
47670
+ return;
47671
+ }
47672
+ this.ctx.logger.error("node has NO usable inference device — removed from the placement candidate set", {
47673
+ tags: { nodeId },
47674
+ meta: {
47675
+ unavailable: usability.unavailableKeys,
47676
+ eligible: usability.eligibleKeys
47677
+ }
47678
+ });
47679
+ this.ctx.eventBus.emit({
47680
+ id: randomUUID(),
47681
+ timestamp: /* @__PURE__ */ new Date(),
47682
+ source: {
47683
+ type: "addon",
47684
+ id: "pipeline-orchestrator",
47685
+ addonId: "pipeline-orchestrator"
47686
+ },
47687
+ category: EventCategory.PipelineNodeInferenceUnavailable,
47688
+ data: {
47689
+ nodeId,
47690
+ unavailableDeviceKeys: usability.unavailableKeys
47691
+ }
47692
+ });
47693
+ }
47694
+ /** Can this node be given cameras? Read synchronously by the two dispatch
47695
+ * paths before they hand `eligibleNodes` to the balancer. */
47696
+ isNodeInferenceUsable(nodeId) {
47697
+ return this.nodeInferenceUsability.isUsable(nodeId);
47698
+ }
47699
+ /**
47124
47700
  * Per-node signature of the last logged eligible/excluded split, so
47125
47701
  * {@link logInferenceDeviceShrinkage} fires on CHANGE only. This runs on
47126
47702
  * every camera dispatch; without the gate it would be one line per dispatch
@@ -47128,6 +47704,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
47128
47704
  */
47129
47705
  lastInferenceEligibilitySignature = /* @__PURE__ */ new Map();
47130
47706
  /**
47707
+ * Which nodes can still infer. Refreshed by
47708
+ * {@link resolveEligibleInferenceDevices}; read synchronously by both
47709
+ * dispatch paths so a node whose every enabled accelerator vanished stops
47710
+ * receiving cameras. little-unraid was handed cameras for a whole day in
47711
+ * exactly that state.
47712
+ */
47713
+ nodeInferenceUsability = new NodeInferenceUsabilityMirror();
47714
+ /**
47131
47715
  * Say out loud which of a node's inference devices were dropped from the
47132
47716
  * candidate set, and why.
47133
47717
  *