@camstack/addon-pipeline-orchestrator 1.1.31 → 1.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4631,7 +4631,7 @@ function _instanceof(cls, params = {}) {
4631
4631
  return inst;
4632
4632
  }
4633
4633
  //#endregion
4634
- //#region ../types/dist/sleep-CZDdRBua.mjs
4634
+ //#region ../types/dist/sleep-Cc14_yxc.mjs
4635
4635
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4636
4636
  EventCategory["SystemBoot"] = "system.boot";
4637
4637
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -4817,6 +4817,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4817
4817
  */
4818
4818
  EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
4819
4819
  /**
4820
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
4821
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
4822
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
4823
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
4824
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
4825
+ * cross-process `getIngestOwner` query (push the authority's decision instead
4826
+ * of polling it on the hot path). Idempotent state — re-emitted on every
4827
+ * topology change, so a dropped event self-heals on the next one (plus the
4828
+ * broker's long backstop reconcile query).
4829
+ */
4830
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
4831
+ /**
4820
4832
  * Periodic snapshot of per-node pipeline-runner load
4821
4833
  * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
4822
4834
  * subscribe instead of polling `pipelineRunner.getLocalLoad`.
@@ -7308,6 +7320,28 @@ var ModelFormatsSchema = object({
7308
7320
  tflite: ModelFormatEntrySchema.optional(),
7309
7321
  pt: ModelFormatEntrySchema.optional()
7310
7322
  });
7323
+ /**
7324
+ * Variant-selector grouping axes. Shared by the full `ModelCatalogEntry` and by
7325
+ * the reduced `PipelineModelOption` returned in `pipeline.getSchema()` so the
7326
+ * grouped Family→Tier→Variant picker renders identically in the config UI and
7327
+ * in the pipeline/device steppers. The flat `id` stays the source of truth for
7328
+ * resolution/download/persistence; this is a presentation overlay resolved back
7329
+ * to an `id`.
7330
+ */
7331
+ var ModelVariantGroupSchema = object({
7332
+ /** Top-level family, e.g. `yolo26` (later `d-fine`, `rf-detr`). */
7333
+ family: string(),
7334
+ /** Size within the family, e.g. `n` | `s` | `m` | `l`. */
7335
+ tier: string(),
7336
+ /** Quantization axis. Omit ⇒ the fp32 base build. */
7337
+ precision: _enum(["fp32", "int8"]).optional(),
7338
+ /**
7339
+ * Speed-optimization axis. Omit ⇒ the standard build. `fast` marks a
7340
+ * latency-optimized export (e.g. ReLU-activation / reduced-input variant)
7341
+ * — the slot the future performance variants plug into.
7342
+ */
7343
+ optimization: _enum(["standard", "fast"]).optional()
7344
+ });
7311
7345
  var ModelCatalogEntrySchema = object({
7312
7346
  id: string(),
7313
7347
  name: string(),
@@ -7337,7 +7371,43 @@ var ModelCatalogEntrySchema = object({
7337
7371
  * Auxiliary files required at runtime (labels JSON, charset dict, etc.).
7338
7372
  * Downloaded into the same modelsDir alongside the model file.
7339
7373
  */
7340
- extraFiles: array(ModelExtraFileSchema).readonly().optional()
7374
+ extraFiles: array(ModelExtraFileSchema).readonly().optional(),
7375
+ /**
7376
+ * LEGACY entry — retained in the catalog so a persisted operator selection
7377
+ * still RESOLVES (and can be re-activated), but hidden from the selectable
7378
+ * model list and excluded from the auto format-default pick. Set on the
7379
+ * superseded / consolidated models (older lineages, redundant fp16 IRs) so
7380
+ * the active lineup stays the coherent curated ladder without deleting a
7381
+ * model anyone may still be pinned to. `resolveModelForFormat` keeps honoring
7382
+ * an explicit legacy id that has a build for the node's format.
7383
+ */
7384
+ legacy: boolean().optional(),
7385
+ /**
7386
+ * Measured quality/latency metadata — populated from the benchmark addon on
7387
+ * the real node classes. Absent = not yet measured (most entries today; the
7388
+ * catalog historically carried only `sizeMB`, a poor cross-architecture
7389
+ * speed proxy). `p95LatencyMs` is keyed by node class (e.g. `n100`, `mac`).
7390
+ */
7391
+ metrics: object({
7392
+ map50: number().optional(),
7393
+ p95LatencyMs: record(string(), number()).optional()
7394
+ }).optional(),
7395
+ /**
7396
+ * SPDX-ish license id of the model weights (e.g. `AGPL-3.0` for Ultralytics
7397
+ * YOLO26, `GPL-3.0` for YOLOv9, `Apache-2.0` for D-FINE/RF-DETR). Matters for
7398
+ * the retraining addon and any future commercial distribution.
7399
+ */
7400
+ license: string().optional(),
7401
+ /**
7402
+ * Variant-selector grouping. The UI groups models by `family` + `tier` and
7403
+ * offers `precision` / `optimization` as variant axes WITHIN a tier — so all
7404
+ * of a family's sizes and quantizations collapse into one grouped picker
7405
+ * instead of a flat list of `yolo26s`, `yolo26s-int8`, … Absent ⇒ ungrouped
7406
+ * (legacy / custom models) — never shown in the grouped selector. The flat
7407
+ * `id` stays the source of truth for resolution/download/persistence; grouping
7408
+ * is a presentation overlay resolved back to an `id`.
7409
+ */
7410
+ group: ModelVariantGroupSchema.optional()
7341
7411
  });
7342
7412
  var ConvertTargetSchema = discriminatedUnion("format", [object({
7343
7413
  format: literal("openvino"),
@@ -7398,8 +7468,8 @@ var RecordingModeSchema = _enum([
7398
7468
  "onAudioThreshold"
7399
7469
  ]);
7400
7470
  /**
7401
- * First-class, authoritative per-camera storage mode — the netta choice the UI
7402
- * reads directly (never inferred from `rules`):
7471
+ * First-class, authoritative per-camera storage mode — the explicit choice the
7472
+ * UI reads directly (never inferred from `rules`):
7403
7473
  * - `off` — not recording.
7404
7474
  * - `events` — record only around triggers (motion / audio threshold),
7405
7475
  * with pre/post-buffer.
@@ -9108,26 +9178,13 @@ DeviceType.Light, method(object({
9108
9178
  percentage: number().min(0).max(100),
9109
9179
  lastChangedAt: number()
9110
9180
  });
9181
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
9111
9182
  var StreamFormatSchema = _enum([
9112
9183
  "webrtc",
9113
9184
  "hls",
9114
9185
  "mjpeg",
9115
9186
  "rtsp"
9116
9187
  ]);
9117
- var StreamInfoSchema = object({
9118
- streamId: string(),
9119
- format: StreamFormatSchema,
9120
- url: string().nullable(),
9121
- active: boolean()
9122
- });
9123
- method(object({
9124
- streamId: string(),
9125
- sourceUrl: string(),
9126
- codec: string().optional()
9127
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
9128
- streamId: string(),
9129
- format: StreamFormatSchema
9130
- }), string().nullable()), method(_void(), array(StreamInfoSchema));
9131
9188
  var RtspRestreamEntrySchema = object({
9132
9189
  brokerId: string(),
9133
9190
  url: string(),
@@ -9792,7 +9849,7 @@ var ConsumablesStatusSchema = object({
9792
9849
  })),
9793
9850
  lastChangedAt: number()
9794
9851
  });
9795
- DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, method(object({
9852
+ Object.values(DeviceType), method(object({
9796
9853
  deviceId: number().int().nonnegative(),
9797
9854
  key: string().min(1)
9798
9855
  }), _void(), {
@@ -10842,7 +10899,6 @@ var PipelineDefaultStepSchema = lazy(() => object({
10842
10899
  enabled: boolean(),
10843
10900
  modelId: string(),
10844
10901
  children: array(PipelineDefaultStepSchema).readonly(),
10845
- engine: PipelineEngineChoiceSchema.optional(),
10846
10902
  group: string().optional(),
10847
10903
  settings: record(string(), unknown()).optional()
10848
10904
  }));
@@ -10867,7 +10923,9 @@ var PipelineModelOptionSchema = object({
10867
10923
  formats: record(string(), object({
10868
10924
  downloaded: boolean(),
10869
10925
  sizeMB: number()
10870
- }))
10926
+ })),
10927
+ group: ModelVariantGroupSchema.optional(),
10928
+ legacy: boolean().optional()
10871
10929
  });
10872
10930
  var ConfigFieldBridge = custom();
10873
10931
  var PipelineAddonSchemaSchema = object({
@@ -10918,15 +10976,42 @@ var EngineProvisioningSchema = object({
10918
10976
  ]),
10919
10977
  progress: number().optional(),
10920
10978
  error: string().optional(),
10921
- nextRetryAt: number().optional()
10979
+ nextRetryAt: number().optional(),
10980
+ /**
10981
+ * Gate A (config-correctness gate at engine change): human-readable
10982
+ * config issues surfaced EAGERLY when the node's engine changes — model
10983
+ * substitutions ("chose X, running Y") and zero-build steps ("no model
10984
+ * has a <format> build"). Additive/optional: informational only, never
10985
+ * enforced here — `assertEngineReady` (readiness) still gates inference.
10986
+ * Absent/empty when the node-default tree resolves cleanly.
10987
+ */
10988
+ configIssues: array(string()).optional()
10922
10989
  });
10923
10990
  var PipelineStepInputSchema = lazy(() => object({
10924
10991
  addonId: string(),
10925
- modelId: string(),
10992
+ modelId: string().optional(),
10926
10993
  enabled: boolean().default(true),
10927
10994
  children: array(PipelineStepInputSchema).optional(),
10928
10995
  settings: record(string(), unknown()).optional()
10929
10996
  }));
10997
+ var ModelSubstitutionSchema = object({
10998
+ addonId: string(),
10999
+ chosen: string(),
11000
+ running: string(),
11001
+ format: string()
11002
+ });
11003
+ var PipelineValidationIssueSchema = object({
11004
+ addonId: string(),
11005
+ kind: _enum(["unknown-addon", "no-format-build"]),
11006
+ detail: string()
11007
+ });
11008
+ var PipelineValidationResultSchema = object({
11009
+ ok: boolean(),
11010
+ issues: array(PipelineValidationIssueSchema).readonly(),
11011
+ substitutions: array(ModelSubstitutionSchema).readonly(),
11012
+ /** The node's `currentEngine.format` this validation ran against. */
11013
+ format: string()
11014
+ });
10930
11015
  var ReferenceImageEntrySchema = object({
10931
11016
  filename: string(),
10932
11017
  stepIds: array(string()).readonly().optional()
@@ -11043,6 +11128,19 @@ var pipelineExecutorCapability = {
11043
11128
  getGlobalSteps: method(_void(), array(PipelineDefaultStepSchema).readonly().nullable()),
11044
11129
  getGlobalPipelineConfig: method(_void(), PipelineConfigBridge),
11045
11130
  getOrchestratorConfigSchema: method(_void(), ConfigUISchemaBridge),
11131
+ /**
11132
+ * Gate B (pre-init config-correctness gate). PURE COMPUTE against this
11133
+ * node's `currentEngine.format` — resolves `steps` the same way the
11134
+ * runtime dispatch path would, and reports what WOULD happen without
11135
+ * touching any node-global state. Called by the orchestrator at attach
11136
+ * time (`attachOn`), node-pinned to the TARGET node, so config problems
11137
+ * surface BEFORE `pipelineRunner.attachCamera` rather than at the first
11138
+ * per-frame resolve. `ok` is false iff `issues` is non-empty (both
11139
+ * `unknown-addon` and `no-format-build` are HARD issues); `substitutions`
11140
+ * is informational (a degraded-but-loadable model swap) and never
11141
+ * affects `ok`. Never throws.
11142
+ */
11143
+ validatePipeline: method(object({ steps: array(PipelineStepInputSchema) }), PipelineValidationResultSchema),
11046
11144
  listTemplates: method(_void(), array(PipelineTemplateSchema$1).readonly()),
11047
11145
  saveTemplate: method(object({
11048
11146
  name: string(),
@@ -11425,6 +11523,13 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
11425
11523
  kind: literal("remote-restream"),
11426
11524
  /** The camera's source-owner node (slice 1: always the hub). */
11427
11525
  ownerNodeId: string(),
11526
+ /**
11527
+ * The owner's LAN-reachable host, resolved by the orchestrator from the
11528
+ * per-node `reachableHost` override (Cluster UI). When present the runner
11529
+ * dials THIS host for the owner's restream, in preference to the
11530
+ * `CAMSTACK_HUB_URL`-derived default. Absent → auto-detect fallback.
11531
+ */
11532
+ ownerReachableHost: string().optional(),
11428
11533
  /** Operator override for the owner host the runner dials. */
11429
11534
  hubHostnameOverride: string().optional()
11430
11535
  })]).describe("Per-camera frame-source mode for the runner (P2c)");
@@ -11433,13 +11538,11 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
11433
11538
  * specific runner instance via `attachCamera`. Carries everything the
11434
11539
  * runner needs to subscribe to the local broker and execute inference.
11435
11540
  *
11436
- * Stateless-pipeline model: the full pipeline content (`engine`, `steps`,
11437
- * optional `audio`) travels with the attach payload. The runner keeps it
11438
- * in RAM for the lifetime of the attach — on rebalance, edit, or
11439
- * restart the orchestrator re-sends the latest snapshot.
11440
- *
11441
- * `engine`/`steps`/`audio` are optional during the additive migration
11442
- * window; once orchestrator + UI are migrated they become required.
11541
+ * Stateless-pipeline model: the pipeline content (`steps`, optional
11542
+ * `audio`) travels with the attach payload. The runner keeps it in RAM
11543
+ * for the lifetime of the attach — on rebalance, edit, or restart the
11544
+ * orchestrator re-sends the latest snapshot. Engine is NOT carried: it is
11545
+ * node-local, resolved by the executing runner at dispatch time.
11443
11546
  */
11444
11547
  var RunnerCameraConfigSchema = object({
11445
11548
  deviceId: number(),
@@ -11490,14 +11593,11 @@ var RunnerCameraConfigSchema = object({
11490
11593
  */
11491
11594
  motionSources: MotionSourcesSchema.default(["analyzer"]),
11492
11595
  pipelineEnabled: boolean().default(true),
11493
- /** Engine choice for video steps (runtime+backend+format). */
11494
- engine: PipelineEngineChoiceSchema.optional(),
11495
11596
  /** Ordered tree of video steps. Absent → runner skips video detection. */
11496
11597
  steps: array(PipelineStepInputSchema).readonly().optional(),
11497
11598
  /** Audio classification branch. `enabled:false` disables, null skips. */
11498
11599
  audio: object({
11499
- engine: PipelineEngineChoiceSchema,
11500
- modelId: string(),
11600
+ modelId: string().optional(),
11501
11601
  enabled: boolean()
11502
11602
  }).nullable().optional(),
11503
11603
  /**
@@ -15953,11 +16053,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15953
16053
  timestamp: number()
15954
16054
  });
15955
16055
  var CameraPipelineConfigSchema = object({
15956
- engine: PipelineEngineChoiceSchema,
16056
+ engine: PipelineEngineChoiceSchema.optional(),
15957
16057
  steps: array(PipelineStepInputSchema).readonly(),
15958
16058
  audio: object({
15959
- engine: PipelineEngineChoiceSchema,
15960
- modelId: string(),
16059
+ engine: PipelineEngineChoiceSchema.optional(),
16060
+ modelId: string().optional(),
15961
16061
  enabled: boolean(),
15962
16062
  settings: record(string(), unknown()).readonly().optional()
15963
16063
  }).nullable().optional()
@@ -15972,7 +16072,7 @@ var PipelineTemplateSchema = object({
15972
16072
  });
15973
16073
  var AgentAddonConfigSchema = object({
15974
16074
  enabled: boolean(),
15975
- modelId: string(),
16075
+ modelId: string().optional(),
15976
16076
  settings: record(string(), unknown()).readonly()
15977
16077
  });
15978
16078
  var AgentPipelineSettingsSchema = object({
@@ -15987,7 +16087,15 @@ var AgentPipelineSettingsSchema = object({
15987
16087
  /** Node is eligible to run audio-analyzer sessions. */
15988
16088
  audio: boolean().optional(),
15989
16089
  /** Node is eligible to be the ingest / source-owner (serve the restream). */
15990
- ingest: boolean().optional()
16090
+ ingest: boolean().optional(),
16091
+ /**
16092
+ * Operator override for the LAN host a cross-node decoder dials to reach
16093
+ * THIS node's restream (Cluster UI). Absent → auto-detect: a remote runner
16094
+ * falls back to its `CAMSTACK_HUB_URL`-derived host (the Moleculer address
16095
+ * it already uses to reach the hub). Set this only when the auto-detected
16096
+ * address is wrong (multi-homed host, NAT, custom interface).
16097
+ */
16098
+ reachableHost: string().optional()
15991
16099
  });
15992
16100
  var CameraPipelineForAgentSchema = object({
15993
16101
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16093,6 +16201,15 @@ var GlobalMetricsSchema = object({
16093
16201
  * capability providers.
16094
16202
  */
16095
16203
  var CapabilityBindingsSchema = record(string(), string());
16204
+ /**
16205
+ * The cluster's single camera-source owner (`clusterRoles.ingestNode`) plus
16206
+ * its LAN-reachable host, if one is registered. See `getIngestOwner`.
16207
+ */
16208
+ var IngestOwnerSchema = object({
16209
+ ownerNodeId: string(),
16210
+ reachableHost: string().optional(),
16211
+ configIssue: string().optional()
16212
+ });
16096
16213
  /** Source block — always present; derives from the stream catalog. */
16097
16214
  var CameraSourceStatusSchema = object({ streams: array(object({
16098
16215
  camStreamId: string(),
@@ -16107,6 +16224,14 @@ var CameraAssignmentStatusSchema = object({
16107
16224
  detectionNodeId: string().nullable(),
16108
16225
  decoderNodeId: string().nullable(),
16109
16226
  audioNodeId: string().nullable(),
16227
+ /**
16228
+ * The node that OWNS this camera's physical source pull (dials the RTSP and
16229
+ * hosts the broker/restream) — the cluster ingest owner today
16230
+ * (`clusterRoles.ingestNode`), per-camera once source assignment lands. Lets
16231
+ * the UI show WHERE a camera is sourced without SSH/logs, and is the node the
16232
+ * broker block below was read from (pinned). Nullable only pre-wiring.
16233
+ */
16234
+ sourceNodeId: string().nullable(),
16110
16235
  pinned: object({
16111
16236
  detection: boolean(),
16112
16237
  decoder: boolean(),
@@ -16307,6 +16432,14 @@ var pipelineOrchestratorCapability = {
16307
16432
  kind: "mutation",
16308
16433
  auth: "admin"
16309
16434
  }),
16435
+ /**
16436
+ * The cluster's single camera-source owner — resolved UNCONDITIONALLY
16437
+ * from `clusterRoles.ingestNode` (NOT the detection-decode
16438
+ * `remoteSourcingNodes` knob). Consumers (recorder/snapshot/audio) pin
16439
+ * their broker calls to `ownerNodeId` and dial `reachableHost` when
16440
+ * present. Defaults to `{ ownerNodeId: 'hub' }`.
16441
+ */
16442
+ getIngestOwner: method(_void(), IngestOwnerSchema),
16310
16443
  /** Pin a device's decoder to a specific node. */
16311
16444
  assignDecoder: method(object({
16312
16445
  deviceId: number(),
@@ -16453,6 +16586,22 @@ var pipelineOrchestratorCapability = {
16453
16586
  kind: "mutation",
16454
16587
  auth: "admin"
16455
16588
  }),
16589
+ /**
16590
+ * Set (or clear) the operator override for the LAN host a cross-node
16591
+ * decoder dials to reach this node's restream. Pass a non-empty string to
16592
+ * override the auto-detected address (the runner's `CAMSTACK_HUB_URL`
16593
+ * default); pass `null` or an empty string to clear it and revert to
16594
+ * auto-detect. Applied on the next dispatch cycle — takes effect for new
16595
+ * cross-node attaches; existing attaches keep their current host until
16596
+ * re-dispatched.
16597
+ */
16598
+ setAgentReachableHost: method(object({
16599
+ agentNodeId: string(),
16600
+ reachableHost: string().nullable()
16601
+ }), object({ success: literal(true) }), {
16602
+ kind: "mutation",
16603
+ auth: "admin"
16604
+ }),
16456
16605
  /** Read one camera's settings. Null when never touched (inherits agent defaults fully). */
16457
16606
  getCameraSettings: method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()),
16458
16607
  /** Set or clear the 3-state toggle for one (camera, addonId). Pass `enabled: null` to clear and revert to agent default. */
@@ -16544,22 +16693,6 @@ var pipelineOrchestratorCapability = {
16544
16693
  })
16545
16694
  }
16546
16695
  };
16547
- var RegisteredStreamSchema = object({
16548
- streamId: string(),
16549
- label: string().optional(),
16550
- codec: string(),
16551
- type: _enum(["video", "audio"]),
16552
- sourceUrl: string()
16553
- });
16554
- var ExposedResourceSchema = object({
16555
- streamId: string(),
16556
- format: string(),
16557
- value: string()
16558
- });
16559
- method(object({
16560
- deviceId: number(),
16561
- streams: array(RegisteredStreamSchema).readonly()
16562
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(ExposedResourceSchema).readonly());
16563
16696
  /**
16564
16697
  * Query filter for settings-store collections.
16565
16698
  */
@@ -16712,9 +16845,9 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
16712
16845
  /**
16713
16846
  * A single device snapshot returned as base64 JPEG/PNG.
16714
16847
  *
16715
- * Shared with the `snapshot-provider` collection cap the orchestrator
16716
- * receives the same shape from each native provider and from the
16717
- * broker-based fallback.
16848
+ * The `SnapshotAddon` wrapper returns this shape whether the frame came from
16849
+ * the device-native provider (onboard capture) or from the stream-broker
16850
+ * prebuffer fallback.
16718
16851
  */
16719
16852
  var SnapshotImageSchema = object({
16720
16853
  base64: string(),
@@ -16746,10 +16879,6 @@ DeviceType.Camera, method(object({
16746
16879
  kind: "mutation",
16747
16880
  auth: "admin"
16748
16881
  });
16749
- method(object({ deviceId: number() }), boolean()), method(object({
16750
- deviceId: number(),
16751
- streamId: string().optional()
16752
- }), SnapshotImageSchema.nullable());
16753
16882
  /**
16754
16883
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
16755
16884
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -17110,9 +17239,10 @@ method(object({
17110
17239
  auth: "admin"
17111
17240
  });
17112
17241
  /**
17113
- * Optional client-side hints sent at session creation to help the
17114
- * provider pick the best native source. All fields are optional —
17115
- * a viewer that knows nothing still gets a sane default.
17242
+ * Optional client-side hints sent at session creation to help the provider
17243
+ * pick the best native source. All fields optional — a viewer that knows
17244
+ * nothing still gets a sane default. (Relocated from the retired `webrtc`
17245
+ * collection cap; this `webrtc-session` cap is the live signaling surface.)
17116
17246
  */
17117
17247
  var webrtcClientHintsSchema = object({
17118
17248
  viewportWidth: number().int().positive().optional(),
@@ -17123,22 +17253,6 @@ var webrtcClientHintsSchema = object({
17123
17253
  /** Hard tier override; takes precedence over scoring when registered. */
17124
17254
  prefersTier: string().optional()
17125
17255
  }).partial();
17126
- method(object({
17127
- streamId: string(),
17128
- sdpOffer: string()
17129
- }), string(), { kind: "mutation" }), method(object({ streamId: string() }), boolean()), method(object({
17130
- streamId: string(),
17131
- codec: string()
17132
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
17133
- streamId: string(),
17134
- hints: webrtcClientHintsSchema.optional()
17135
- }), object({
17136
- sessionId: string(),
17137
- sdpOffer: string()
17138
- }), { kind: "mutation" }), method(object({
17139
- sessionId: string(),
17140
- sdpAnswer: string()
17141
- }), _void(), { kind: "mutation" }), method(object({ sessionId: string() }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), boolean());
17142
17256
  /**
17143
17257
  * Discriminated target for a WebRTC session. The client sends this
17144
17258
  * structured object instead of building / parsing brokerId strings;
@@ -22229,6 +22343,12 @@ Object.freeze({
22229
22343
  addonId: null,
22230
22344
  access: "create"
22231
22345
  },
22346
+ "pipelineExecutor.validatePipeline": {
22347
+ capName: "pipeline-executor",
22348
+ capScope: "system",
22349
+ addonId: null,
22350
+ access: "view"
22351
+ },
22232
22352
  "pipelineOrchestrator.assignAudio": {
22233
22353
  capName: "pipeline-orchestrator",
22234
22354
  capScope: "system",
@@ -22337,6 +22457,12 @@ Object.freeze({
22337
22457
  addonId: null,
22338
22458
  access: "view"
22339
22459
  },
22460
+ "pipelineOrchestrator.getIngestOwner": {
22461
+ capName: "pipeline-orchestrator",
22462
+ capScope: "system",
22463
+ addonId: null,
22464
+ access: "view"
22465
+ },
22340
22466
  "pipelineOrchestrator.getPipelineAssignment": {
22341
22467
  capName: "pipeline-orchestrator",
22342
22468
  capScope: "system",
@@ -22409,6 +22535,12 @@ Object.freeze({
22409
22535
  addonId: null,
22410
22536
  access: "create"
22411
22537
  },
22538
+ "pipelineOrchestrator.setAgentReachableHost": {
22539
+ capName: "pipeline-orchestrator",
22540
+ capScope: "system",
22541
+ addonId: null,
22542
+ access: "create"
22543
+ },
22412
22544
  "pipelineOrchestrator.setCameraPipelineForAgent": {
22413
22545
  capName: "pipeline-orchestrator",
22414
22546
  capScope: "system",
@@ -22739,24 +22871,6 @@ Object.freeze({
22739
22871
  addonId: null,
22740
22872
  access: "create"
22741
22873
  },
22742
- "restreamer.getExposedResources": {
22743
- capName: "restreamer",
22744
- capScope: "system",
22745
- addonId: null,
22746
- access: "view"
22747
- },
22748
- "restreamer.registerDevice": {
22749
- capName: "restreamer",
22750
- capScope: "system",
22751
- addonId: null,
22752
- access: "create"
22753
- },
22754
- "restreamer.unregisterDevice": {
22755
- capName: "restreamer",
22756
- capScope: "system",
22757
- addonId: null,
22758
- access: "delete"
22759
- },
22760
22874
  "scriptRunner.run": {
22761
22875
  capName: "script-runner",
22762
22876
  capScope: "device",
@@ -22859,18 +22973,6 @@ Object.freeze({
22859
22973
  addonId: null,
22860
22974
  access: "create"
22861
22975
  },
22862
- "snapshotProvider.getSnapshot": {
22863
- capName: "snapshot-provider",
22864
- capScope: "system",
22865
- addonId: null,
22866
- access: "view"
22867
- },
22868
- "snapshotProvider.supportsDevice": {
22869
- capName: "snapshot-provider",
22870
- capScope: "system",
22871
- addonId: null,
22872
- access: "view"
22873
- },
22874
22976
  "ssoBridge.signBridgeToken": {
22875
22977
  capName: "sso-bridge",
22876
22978
  capScope: "system",
@@ -23297,30 +23399,6 @@ Object.freeze({
23297
23399
  addonId: null,
23298
23400
  access: "view"
23299
23401
  },
23300
- "streamingEngine.getStreamUrl": {
23301
- capName: "streaming-engine",
23302
- capScope: "system",
23303
- addonId: null,
23304
- access: "view"
23305
- },
23306
- "streamingEngine.listStreams": {
23307
- capName: "streaming-engine",
23308
- capScope: "system",
23309
- addonId: null,
23310
- access: "view"
23311
- },
23312
- "streamingEngine.registerStream": {
23313
- capName: "streaming-engine",
23314
- capScope: "system",
23315
- addonId: null,
23316
- access: "create"
23317
- },
23318
- "streamingEngine.unregisterStream": {
23319
- capName: "streaming-engine",
23320
- capScope: "system",
23321
- addonId: null,
23322
- access: "delete"
23323
- },
23324
23402
  "streamParams.getConfigSchema": {
23325
23403
  capName: "stream-params",
23326
23404
  capScope: "device",
@@ -23687,54 +23765,6 @@ Object.freeze({
23687
23765
  addonId: null,
23688
23766
  access: "create"
23689
23767
  },
23690
- "webrtc.closeSession": {
23691
- capName: "webrtc",
23692
- capScope: "system",
23693
- addonId: null,
23694
- access: "create"
23695
- },
23696
- "webrtc.createSession": {
23697
- capName: "webrtc",
23698
- capScope: "system",
23699
- addonId: null,
23700
- access: "create"
23701
- },
23702
- "webrtc.handleAnswer": {
23703
- capName: "webrtc",
23704
- capScope: "system",
23705
- addonId: null,
23706
- access: "create"
23707
- },
23708
- "webrtc.handleOffer": {
23709
- capName: "webrtc",
23710
- capScope: "system",
23711
- addonId: null,
23712
- access: "create"
23713
- },
23714
- "webrtc.hasAdaptiveBitrate": {
23715
- capName: "webrtc",
23716
- capScope: "system",
23717
- addonId: null,
23718
- access: "view"
23719
- },
23720
- "webrtc.registerStream": {
23721
- capName: "webrtc",
23722
- capScope: "system",
23723
- addonId: null,
23724
- access: "create"
23725
- },
23726
- "webrtc.supportsStream": {
23727
- capName: "webrtc",
23728
- capScope: "system",
23729
- addonId: null,
23730
- access: "view"
23731
- },
23732
- "webrtc.unregisterStream": {
23733
- capName: "webrtc",
23734
- capScope: "system",
23735
- addonId: null,
23736
- access: "delete"
23737
- },
23738
23768
  "webrtcSession.addIceCandidate": {
23739
23769
  capName: "webrtc-session",
23740
23770
  capScope: "device",
@@ -23897,18 +23927,21 @@ function applyStepPatch(base, patch) {
23897
23927
  /**
23898
23928
  * Seed an agent's addonDefaults map from the catalog. For each addonId
23899
23929
  * advertised by any slot, populate a sensible entry:
23900
- * - `modelId` = addon's `defaultModelIdByFormat[engine.format]` if
23901
- * present, else `defaultModelId` (from StepDefinition).
23902
23930
  * - `enabled` = addon's `enabledByDefault` (absent = true).
23903
23931
  * - `settings` = catalog's default settings (empty for now — the
23904
23932
  * runtime applies step-level defaults when settings[key] is absent).
23905
23933
  *
23934
+ * `modelId` is intentionally ABSENT on a fresh entry — model selection is
23935
+ * data only now. When the operator hasn't explicitly chosen a model, the
23936
+ * step carries no `modelId` and the executing NODE resolves its own
23937
+ * format default at runtime (the resolver has no notion of engines).
23938
+ *
23906
23939
  * Entries already present in `current` are LEFT ALONE — operators who
23907
23940
  * customised a model keep their choice after catalog growth adds new
23908
23941
  * addons. Entries for addonIds that no longer exist in the catalog are
23909
23942
  * dropped (the addon was removed; its config is dead weight).
23910
23943
  */
23911
- function seedAgentAddonDefaults(current, engine, catalog) {
23944
+ function seedAgentAddonDefaults(current, catalog) {
23912
23945
  const next = {};
23913
23946
  const liveAddonIds = /* @__PURE__ */ new Set();
23914
23947
  for (const slot of catalog.slots) for (const addon of slot.addons) {
@@ -23919,7 +23952,6 @@ function seedAgentAddonDefaults(current, engine, catalog) {
23919
23952
  }
23920
23953
  next[addon.id] = {
23921
23954
  enabled: addon.enabledByDefault ?? true,
23922
- modelId: pickDefaultModelId(addon, engine.format),
23923
23955
  settings: {}
23924
23956
  };
23925
23957
  }
@@ -23927,27 +23959,11 @@ function seedAgentAddonDefaults(current, engine, catalog) {
23927
23959
  return next;
23928
23960
  }
23929
23961
  /**
23930
- * Pick the best model ID for `(addon, agent.format)`:
23931
- * 1. `defaultModelIdByFormat[agent.format]` — explicit per-format choice
23932
- * 2. `defaultModelId` from the StepDefinition — format-agnostic default
23933
- * 3. First model in `addon.models[]` that has `formats[agent.format]` — desperate fallback
23934
- *
23935
- * Returns the first candidate whose model exists in the catalog entry's
23936
- * `formats` map for the target engine format. If no candidate matches,
23937
- * falls through to `addon.defaultModelId` (even if incompatible — let
23938
- * the runtime error explicitly rather than silently substitute).
23939
- */
23940
- function pickDefaultModelId(addon, format) {
23941
- const byFormat = addon.defaultModelIdByFormat?.[format];
23942
- if (byFormat && addon.models.some((m) => m.id === byFormat)) return byFormat;
23943
- if (addon.models.some((m) => m.id === addon.defaultModelId && m.formats?.[format])) return addon.defaultModelId;
23944
- const compat = addon.models.find((m) => m.formats?.[format]);
23945
- if (compat) return compat.id;
23946
- return addon.defaultModelId;
23947
- }
23948
- /**
23949
23962
  * Main resolver — produces the `CameraPipelineConfig` the runner should
23950
23963
  * receive for `(deviceId, agent)`. Pure: zero I/O, zero side effects.
23964
+ * DATA ONLY: this resolver has no notion of engines. A step carries a
23965
+ * `modelId` only when the operator explicitly chose one; when absent,
23966
+ * the executing node resolves its own format default at runtime.
23951
23967
  *
23952
23968
  * Step chain:
23953
23969
  * 1. `cameraSettings.pipelineByAgent[agentNodeId]` → wholesale override
@@ -23955,16 +23971,14 @@ function pickDefaultModelId(addon, format) {
23955
23971
  *
23956
23972
  * "Inference disabled for this camera" is NOT handled here — the caller
23957
23973
  * (resolvePipelineForDevice) consults the detection-pipeline binding and
23958
- * short-circuits to `emptyPipeline(engine)` before calling this resolver.
23974
+ * short-circuits to an empty pipeline before calling this resolver.
23959
23975
  */
23960
- function resolvePipeline(agentSettings, cameraSettings, agentNodeId, engine, catalog) {
23976
+ function resolvePipeline(agentSettings, cameraSettings, agentNodeId, catalog) {
23961
23977
  const cam = cameraSettings ?? {};
23962
23978
  const wholesale = cam.pipelineByAgent?.[agentNodeId];
23963
23979
  if (wholesale) return {
23964
- engine,
23965
23980
  steps: wholesale.steps,
23966
23981
  audio: wholesale.audio ? {
23967
- engine,
23968
23982
  modelId: wholesale.audio.modelId,
23969
23983
  enabled: wholesale.audio.enabled
23970
23984
  } : null
@@ -23986,11 +24000,9 @@ function resolvePipeline(agentSettings, cameraSettings, agentNodeId, engine, cat
23986
24000
  for (const [addonId, override] of Object.entries(toggles)) {
23987
24001
  if (override !== true) continue;
23988
24002
  if (addonDefaults[addonId]) continue;
23989
- const catalogAddon = findInCatalog(catalog, addonId);
23990
- if (!catalogAddon) continue;
24003
+ if (!findInCatalog(catalog, addonId)) continue;
23991
24004
  const patched = applyStepPatch({
23992
24005
  enabled: true,
23993
- modelId: pickDefaultModelId(catalogAddon, engine.format),
23994
24006
  settings: {}
23995
24007
  }, overridesForAgent[addonId]);
23996
24008
  if (!patched.enabled) continue;
@@ -24008,10 +24020,8 @@ function resolvePipeline(agentSettings, cameraSettings, agentNodeId, engine, cat
24008
24020
  else videoAddons.push(entry);
24009
24021
  }
24010
24022
  return {
24011
- engine,
24012
24023
  steps: buildTreeFromAddons(videoAddons, catalog),
24013
24024
  audio: audioAddons.length > 0 ? {
24014
- engine,
24015
24025
  modelId: audioAddons[0].cfg.modelId,
24016
24026
  enabled: true,
24017
24027
  ...Object.keys(audioAddons[0].cfg.settings).length > 0 ? { settings: audioAddons[0].cfg.settings } : {}
@@ -24049,9 +24059,9 @@ function buildTreeFromAddons(enabled, catalog) {
24049
24059
  metaById.set(addonId, meta);
24050
24060
  nodes.set(addonId, {
24051
24061
  addonId,
24052
- modelId: cfg.modelId,
24053
24062
  enabled: true,
24054
24063
  children: [],
24064
+ ...cfg.modelId ? { modelId: cfg.modelId } : {},
24055
24065
  ...Object.keys(cfg.settings).length > 0 ? { settings: cfg.settings } : {}
24056
24066
  });
24057
24067
  }
@@ -24159,7 +24169,7 @@ function startAudioChunkPoller(options) {
24159
24169
  const subId = lifecycle.activeSubscriptionId;
24160
24170
  if (subId) {
24161
24171
  lifecycle.activeSubscriptionId = null;
24162
- options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }).catch((err) => {
24172
+ options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
24163
24173
  options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
24164
24174
  brokerId: options.brokerId,
24165
24175
  subscriptionId: subId,
@@ -24177,7 +24187,8 @@ function startAudioChunkPoller(options) {
24177
24187
  * been started) or once `lifecycle.stopped` flips, whichever comes first.
24178
24188
  */
24179
24189
  async function subscribeWithRetry(options, lifecycle) {
24180
- const { api, brokerId, tag, logger } = options;
24190
+ const { api, brokerId, tag, ownerNodeId, logger } = options;
24191
+ const pin = nodePin(ownerNodeId);
24181
24192
  let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
24182
24193
  let attempt = 0;
24183
24194
  while (!lifecycle.stopped) {
@@ -24186,9 +24197,9 @@ async function subscribeWithRetry(options, lifecycle) {
24186
24197
  const result = await api.streamBroker.subscribeAudioChunks.mutate({
24187
24198
  brokerId,
24188
24199
  tag
24189
- });
24200
+ }, pin);
24190
24201
  if (lifecycle.stopped) {
24191
- await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
24202
+ await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
24192
24203
  logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
24193
24204
  brokerId,
24194
24205
  subscriptionId: result.subscriptionId,
@@ -24232,14 +24243,15 @@ async function subscribeWithRetry(options, lifecycle) {
24232
24243
  * disowned.
24233
24244
  */
24234
24245
  function startPolling(options, lifecycle) {
24235
- const { api, brokerId, tag, onChunk, logger } = options;
24246
+ const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
24247
+ const pin = nodePin(ownerNodeId);
24236
24248
  let consecutiveFailures = 0;
24237
24249
  const resubscribe = async () => {
24238
24250
  try {
24239
24251
  const result = await api.streamBroker.subscribeAudioChunks.mutate({
24240
24252
  brokerId,
24241
24253
  tag
24242
- });
24254
+ }, pin);
24243
24255
  lifecycle.activeSubscriptionId = result.subscriptionId;
24244
24256
  logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
24245
24257
  brokerId,
@@ -24260,7 +24272,7 @@ function startPolling(options, lifecycle) {
24260
24272
  const chunks = await api.streamBroker.pullAudioChunks.query({
24261
24273
  subscriptionId: subId,
24262
24274
  maxCount: PULL_MAX_COUNT
24263
- });
24275
+ }, pin);
24264
24276
  if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
24265
24277
  brokerId,
24266
24278
  subscriptionId: subId
@@ -24325,6 +24337,7 @@ function mapAssignment(input) {
24325
24337
  detectionNodeId: input.detectionNodeId,
24326
24338
  decoderNodeId: input.decoderNodeId,
24327
24339
  audioNodeId: input.audioNodeId,
24340
+ sourceNodeId: input.sourceNodeId,
24328
24341
  pinned: {
24329
24342
  detection: input.pinned.detection,
24330
24343
  decoder: input.pinned.decoder,
@@ -24498,14 +24511,58 @@ function computeFrameSourceNodes(input) {
24498
24511
  return enabledDecoderNodes.filter((nodeId) => nodeId === sourceOwnerNodeId || remote.includes(nodeId));
24499
24512
  }
24500
24513
  //#endregion
24514
+ //#region src/ingest-owner.ts
24515
+ /**
24516
+ * The cluster's single camera-source owner + its LAN-reachable host, resolved
24517
+ * UNCONDITIONALLY from `clusterRoles.ingestNode` (NOT `resolveSourceOwner`,
24518
+ * which is gated on the detection-decode `remoteSourcingNodes` knob). Consumers
24519
+ * (recorder/snapshot/audio) pin their broker calls to `ownerNodeId` and dial
24520
+ * `reachableHost`. Defaults to `{ ownerNodeId: 'hub' }` — byte-identical to
24521
+ * today's hub-only behavior.
24522
+ *
24523
+ * `configIssue` is a NON-FATAL guard: when the configured ingest owner is an
24524
+ * agent that's unreachable or not ingest-capable, it surfaces a human-readable
24525
+ * warning string. The hub is always assumed reachable and ingest-capable, so
24526
+ * `ownerNodeId === 'hub'` never produces a `configIssue`.
24527
+ */
24528
+ function resolveIngestOwner(clusterRoles, reachableHostByNode, ingestCapableNodes) {
24529
+ const ownerNodeId = clusterRoles.ingestNode;
24530
+ const reachableHost = reachableHostByNode.get(ownerNodeId);
24531
+ const configIssue = computeIngestConfigIssue(ownerNodeId, reachableHost, ingestCapableNodes);
24532
+ return {
24533
+ ownerNodeId,
24534
+ ...reachableHost !== void 0 ? { reachableHost } : {},
24535
+ ...configIssue !== void 0 ? { configIssue } : {}
24536
+ };
24537
+ }
24538
+ function computeIngestConfigIssue(ownerNodeId, reachableHost, ingestCapableNodes) {
24539
+ if (ownerNodeId === "hub") return void 0;
24540
+ if (reachableHost === void 0) return `ingest node '${ownerNodeId}' has no reachable host — set its reachableHost or cameras cannot be sourced from it`;
24541
+ if (!ingestCapableNodes.includes(ownerNodeId)) return `ingest node '${ownerNodeId}' is not ingest-capable — enable its ingest capability`;
24542
+ }
24543
+ //#endregion
24501
24544
  //#region src/source-owner.ts
24502
24545
  /**
24546
+ * The set of nodes allowed to run the REMOTE-SOURCE leg — every decode-enabled
24547
+ * node EXCEPT the camera-source OWNER (the node that dials the camera). Derived
24548
+ * from `enabledDecoderNodes`. When the owner is the hub this equals the old
24549
+ * hub-exclusion (byte-identical to Phase 1); when the owner is an agent the hub
24550
+ * itself becomes a valid remote-source target.
24551
+ *
24552
+ * Rollout-safe: when the owner is the only decode node the result is `[]`, which
24553
+ * keeps `resolveSourceOwner` unmodeled and every frame source `local-broker`.
24554
+ * Pure: preserves input order, never mutates.
24555
+ */
24556
+ function deriveRemoteSourcingNodes(enabledDecoderNodes, ownerNodeId) {
24557
+ return enabledDecoderNodes.filter((nodeId) => nodeId !== ownerNodeId);
24558
+ }
24559
+ /**
24503
24560
  * The node that owns a camera's source pull (dials the real RTSP, hosts its
24504
24561
  * broker), or `undefined` while ownership is unmodeled (rollout OFF).
24505
24562
  */
24506
24563
  function resolveSourceOwner(input) {
24507
24564
  if (input.remoteSourcingNodes.length === 0) return void 0;
24508
- return input.assignedOwner ?? input.hubNodeId;
24565
+ return input.assignedOwner ?? input.ownerNodeId;
24509
24566
  }
24510
24567
  /**
24511
24568
  * The `frameSource` the attach payload carries for a target node:
@@ -24520,13 +24577,14 @@ function resolveSourceOwner(input) {
24520
24577
  * degrade to the safe local path, never to an unauthorized cross-node pull.
24521
24578
  */
24522
24579
  function selectRunnerFrameSource(input) {
24523
- const { targetNodeId, sourceOwnerNodeId, remoteSourcingNodes, hubHostnameOverride } = input;
24580
+ const { targetNodeId, sourceOwnerNodeId, remoteSourcingNodes, ownerReachableHost, hubHostnameOverride } = input;
24524
24581
  if (sourceOwnerNodeId === void 0) return { kind: "local-broker" };
24525
24582
  if (targetNodeId === sourceOwnerNodeId) return { kind: "local-broker" };
24526
24583
  if (!remoteSourcingNodes.includes(targetNodeId)) return { kind: "local-broker" };
24527
24584
  return {
24528
24585
  kind: "remote-restream",
24529
24586
  ownerNodeId: sourceOwnerNodeId,
24587
+ ...ownerReachableHost !== void 0 ? { ownerReachableHost } : {},
24530
24588
  ...hubHostnameOverride !== void 0 ? { hubHostnameOverride } : {}
24531
24589
  };
24532
24590
  }
@@ -24534,16 +24592,29 @@ function selectRunnerFrameSource(input) {
24534
24592
  //#region src/load-balancer.ts
24535
24593
  /**
24536
24594
  * Compute the L2 capacity score for a runner node. Lower is better.
24537
- * The score is a weighted sum of the runner's active workload so the balancer
24538
- * prefers agents that are serving fewer cameras OR draining queues quickly.
24539
24595
  *
24540
- * Rationale:
24541
- * - `attachedCameras * avgInferenceFps` approximates the total inference rate
24542
- * the agent is currently sustaining (not just how many cameras are assigned).
24543
- * - `queueDepthTotal` penalises agents that are falling behind the frame feed.
24596
+ * The score is **committed standing load + real-time backpressure**:
24597
+ *
24598
+ * - `attachedCameras` the primary signal. Every attached camera is a
24599
+ * standing commitment: an `on-motion` camera costs zero while idle but
24600
+ * WILL fork a decode+inference session on its node the moment motion fires.
24601
+ * Counting *attachments* (not current inference activity) is what makes the
24602
+ * balancer honest about steady-state load and gives the per-node `weights`
24603
+ * sliders camera-count-share semantics.
24604
+ * - `queueDepthTotal` — real-time pressure: a node whose frame queues are
24605
+ * backing up is falling behind NOW and must be deprioritised beyond its
24606
+ * camera count.
24607
+ *
24608
+ * The previous formula multiplied `attachedCameras * avgInferenceFps`, which
24609
+ * scored a node carrying many IDLE on-motion cameras (fps 0) identically to an
24610
+ * empty node — the balancer then piled unpinned cameras onto an already-loaded
24611
+ * node until `maxCameras` bit. `avgInferenceFps` (in practice a mislabeled SUM
24612
+ * of per-camera fps) is deliberately dropped: transient inference activity is
24613
+ * not steady-state load, and a genuinely overloaded node surfaces through
24614
+ * `queueDepthTotal` instead.
24544
24615
  */
24545
24616
  function computeCapacityScore(load) {
24546
- return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
24617
+ return Math.max(load.attachedCameras, 0) + Math.max(load.queueDepthTotal, 0);
24547
24618
  }
24548
24619
  /**
24549
24620
  * Node detection weight, defaulting to 1 when absent/invalid. A higher weight
@@ -24563,6 +24634,18 @@ function weightedScore(load, weights) {
24563
24634
  return computeCapacityScore(load) / nodeWeight(load.nodeId, weights);
24564
24635
  }
24565
24636
  /**
24637
+ * Weighted-score improvement of moving one camera off `current` onto `target`
24638
+ * (both weight-adjusted). Positive = `target` is less loaded than `current`.
24639
+ *
24640
+ * Used by the auto-rebalance hysteresis: a migration is applied only when this
24641
+ * clears a margin (> 1), so equalizing a mere 1-camera gap — which would just
24642
+ * reverse the imbalance — is skipped and a periodic pass never churns an
24643
+ * already-balanced cluster.
24644
+ */
24645
+ function capacityImprovement(current, target, weights) {
24646
+ return weightedScore(current, weights) - weightedScore(target, weights);
24647
+ }
24648
+ /**
24566
24649
  * Returns true when the node has remaining capacity.
24567
24650
  * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
24568
24651
  * its `attachedCameras` count is strictly less than the cap.
@@ -24707,11 +24790,11 @@ var EngineChoiceSchema = object({
24707
24790
  });
24708
24791
  var NodeBindingsSchema = record(string(), record(string(), string()));
24709
24792
  var StoredPipelineConfigSchema = object({
24710
- engine: EngineChoiceSchema,
24793
+ engine: EngineChoiceSchema.optional(),
24711
24794
  steps: array(PipelineStepInputSchema).readonly(),
24712
24795
  audio: object({
24713
- engine: EngineChoiceSchema,
24714
- modelId: string(),
24796
+ engine: EngineChoiceSchema.optional(),
24797
+ modelId: string().optional(),
24715
24798
  enabled: boolean(),
24716
24799
  settings: record(string(), unknown()).readonly().optional()
24717
24800
  }).nullable().optional()
@@ -24727,7 +24810,7 @@ var StoredPipelineTemplateSchema = object({
24727
24810
  var TemplatesSchema = record(string(), StoredPipelineTemplateSchema);
24728
24811
  var StoredAgentAddonConfigSchema = object({
24729
24812
  enabled: boolean(),
24730
- modelId: string(),
24813
+ modelId: string().optional(),
24731
24814
  settings: record(string(), unknown()).readonly()
24732
24815
  });
24733
24816
  var StoredAgentPipelineSettingsSchema = object({
@@ -24737,7 +24820,8 @@ var StoredAgentPipelineSettingsSchema = object({
24737
24820
  detect: boolean().optional(),
24738
24821
  decode: boolean().optional(),
24739
24822
  audio: boolean().optional(),
24740
- ingest: boolean().optional()
24823
+ ingest: boolean().optional(),
24824
+ reachableHost: string().optional()
24741
24825
  });
24742
24826
  var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
24743
24827
  var StoredCameraStepOverridePatchSchema = object({
@@ -24748,7 +24832,7 @@ var StoredCameraStepOverridePatchSchema = object({
24748
24832
  var StoredCameraPipelineForAgentSchema = object({
24749
24833
  steps: array(PipelineStepInputSchema).readonly(),
24750
24834
  audio: object({
24751
- modelId: string(),
24835
+ modelId: string().optional(),
24752
24836
  enabled: boolean()
24753
24837
  }).nullable()
24754
24838
  });
@@ -25270,6 +25354,21 @@ var PENDING_RETRY_INTERVAL_MS = 6e4;
25270
25354
  /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
25271
25355
  var PENDING_RETRY_DEBOUNCE_MS = 2e3;
25272
25356
  /**
25357
+ * Periodic auto-rebalance sweep. New attaches are already load-balanced at
25358
+ * dispatch time; this corrects DRIFT that accumulates over time (uneven
25359
+ * detach, a node returning online, a weight change) so the steady-state
25360
+ * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
25361
+ * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
25362
+ */
25363
+ var AUTO_REBALANCE_INTERVAL_MS = 6e4;
25364
+ /**
25365
+ * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
25366
+ * migrate a camera only when its target node is at least this much less loaded
25367
+ * than its current node. > 1 so equalizing a single-camera gap (which would
25368
+ * only reverse the imbalance) is skipped — prevents periodic churn.
25369
+ */
25370
+ var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
25371
+ /**
25273
25372
  * Remote-assignment health loop (T7) bounded backoff. A remote camera flagged
25274
25373
  * as unhealthy (0-fps / metrics-stale) is re-placed at most
25275
25374
  * `REMOTE_HEALTH_MAX_ATTEMPTS` times inside a rolling `REMOTE_HEALTH_WINDOW_MS`
@@ -25307,6 +25406,7 @@ var OrchestratorDiagnosticsSchema = object({
25307
25406
  enabledNodes: array(string()),
25308
25407
  enabledDecoderNodes: array(string()),
25309
25408
  enabledAudioNodes: array(string()),
25409
+ enabledIngestNodes: array(string()),
25310
25410
  clusterRoles: object({
25311
25411
  ingestNode: string(),
25312
25412
  audioNode: string()
@@ -25452,21 +25552,38 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25452
25552
  enabledDecoderNodes = ["hub"];
25453
25553
  /**
25454
25554
  * Nodes allowed to run the REMOTE-SOURCE leg — dial a camera's source-owner
25455
- * restream over the LAN and decode it locally. Held for the source-owner
25456
- * plumbing (`source-owner.ts`) but currently ALWAYS EMPTY: the cross-node
25457
- * frame transport is a separate activation phase (placement-model §9 P1).
25458
- * With this empty, `resolveSourceOwner` returns `undefined` and every
25459
- * `selectRunnerFrameSource` emits `local-broker` — bit-identical to
25460
- * pre-Phase-2 behavior.
25555
+ * restream over the LAN and decode it locally. DERIVED (Phase-1 activation,
25556
+ * `deriveRemoteSourcingNodes`) from `enabledDecoderNodes` minus the hub, in
25557
+ * `refreshNodeCapabilities`: every decode-enabled AGENT is a remote-sourcing
25558
+ * node. When only the hub decodes this stays `[]`, so `resolveSourceOwner`
25559
+ * returns `undefined` and every `selectRunnerFrameSource` emits
25560
+ * `local-broker` — bit-identical to pre-activation (rollout-safe).
25461
25561
  */
25462
25562
  remoteSourcingNodes = [];
25463
25563
  /**
25564
+ * Per-node `reachableHost` override (the LAN host a cross-node decoder dials
25565
+ * to reach that node's restream), from `agentSettings[node].reachableHost`.
25566
+ * DERIVED alongside the enabled-node sets in {@link refreshNodeCapabilities}
25567
+ * so `attachOn` resolves the owner host synchronously (no per-attach durable
25568
+ * read). Absent entry → the runner auto-detects via `CAMSTACK_HUB_URL`.
25569
+ */
25570
+ reachableHostByNode = /* @__PURE__ */ new Map();
25571
+ /**
25464
25572
  * Hub-wide allow-list of node ids eligible to run audio-analyzer sessions.
25465
25573
  * DERIVED from the per-node capability store (`agentSettings[node].audio`).
25466
25574
  * Defaults to `['hub']`.
25467
25575
  */
25468
25576
  enabledAudioNodes = ["hub"];
25469
25577
  /**
25578
+ * Hub-wide allow-list of node ids eligible to run the INGEST leg (dial the
25579
+ * camera's real source, host its broker). DERIVED from the per-node
25580
+ * capability store (`agentSettings[node].ingest`), mirroring
25581
+ * `enabledAudioNodes`. Defaults to `['hub']`. Not yet consumed by any
25582
+ * placement logic — this field only makes the persisted `ingest` flag
25583
+ * readable for a later config guard.
25584
+ */
25585
+ enabledIngestNodes = ["hub"];
25586
+ /**
25470
25587
  * Cluster-wide singleton role assignments (placement-model §6). Each role
25471
25588
  * is the ONE node that serves it for every camera. Driven by the
25472
25589
  * `ingestNode` / `audioNode` node-selects in the addon global schema;
@@ -25574,6 +25691,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25574
25691
  pendingRetryInFlight = false;
25575
25692
  /** Set when a pending-retry is requested while one is already in-flight; triggers a follow-up pass. */
25576
25693
  pendingRetryRerunRequested = false;
25694
+ /** Periodic auto-rebalance sweep timer (drift correction under hysteresis). */
25695
+ autoRebalanceTimer = null;
25696
+ /** True while an auto-rebalance pass is running (skip overlap). */
25697
+ autoRebalanceInFlight = false;
25577
25698
  initTimestamp = 0;
25578
25699
  constructor() {
25579
25700
  super({});
@@ -25795,6 +25916,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25795
25916
  });
25796
25917
  this.pipelineWatchdog.start(PipelineOrchestratorAddon.WATCHDOG_INTERVAL_MS);
25797
25918
  this.pendingRetryTimer = setInterval(() => void this.retryPendingDispatches(), PENDING_RETRY_INTERVAL_MS);
25919
+ this.autoRebalanceTimer = setInterval(() => void this.runAutoRebalance(), AUTO_REBALANCE_INTERVAL_MS);
25798
25920
  this.ctx.logger.info("Pipeline orchestrator detection-wiring subscriptions installed");
25799
25921
  this.migrateLegacyFlagsToBindings().catch((err) => {
25800
25922
  this.ctxIfReady?.logger.warn("bindings migration failed — will retry on next boot", { meta: { error: errMsg(err) } });
@@ -26005,6 +26127,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26005
26127
  enabledNodes: [...this.enabledNodes],
26006
26128
  enabledDecoderNodes: [...this.enabledDecoderNodes],
26007
26129
  enabledAudioNodes: [...this.enabledAudioNodes],
26130
+ enabledIngestNodes: [...this.enabledIngestNodes],
26008
26131
  clusterRoles: { ...this.clusterRoles },
26009
26132
  assignedDeviceCount: this.assignments.size,
26010
26133
  cameraConfigCount: this.cameraConfigs.size,
@@ -26030,6 +26153,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26030
26153
  clearTimeout(this.pendingRetryDebounceTimer);
26031
26154
  this.pendingRetryDebounceTimer = null;
26032
26155
  }
26156
+ if (this.autoRebalanceTimer !== null) {
26157
+ clearInterval(this.autoRebalanceTimer);
26158
+ this.autoRebalanceTimer = null;
26159
+ }
26033
26160
  for (const t of this.slotReadRetryTimers.values()) clearTimeout(t);
26034
26161
  this.slotReadRetryTimers.clear();
26035
26162
  this.slotReadRetryAttempts.clear();
@@ -26295,8 +26422,30 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26295
26422
  this.assignments.delete(input.deviceId);
26296
26423
  return { success: true };
26297
26424
  }
26298
- async rebalance() {
26425
+ /**
26426
+ * Periodic auto-rebalance tick. Corrects placement drift toward the per-node
26427
+ * weights without operator action. Guards: never overlaps itself; skips when
26428
+ * fewer than two nodes can run detection (a hub-only cluster has nothing to
26429
+ * balance); migrates under {@link AUTO_REBALANCE_MIN_IMPROVEMENT} hysteresis
26430
+ * so a balanced cluster is a no-op. Errors are swallowed (best-effort net).
26431
+ */
26432
+ async runAutoRebalance() {
26433
+ if (!this.ctxIfReady) return;
26434
+ if (this.autoRebalanceInFlight) return;
26435
+ if (this.enabledNodes.length < 2) return;
26436
+ this.autoRebalanceInFlight = true;
26437
+ try {
26438
+ const { migrated } = await this.rebalance({ minImprovement: AUTO_REBALANCE_MIN_IMPROVEMENT });
26439
+ if (migrated > 0) this.ctx.logger.info("auto-rebalance: corrected placement drift", { meta: { migrated } });
26440
+ } catch (err) {
26441
+ this.ctx.logger.debug("auto-rebalance pass failed", { meta: { error: errMsg(err) } });
26442
+ } finally {
26443
+ this.autoRebalanceInFlight = false;
26444
+ }
26445
+ }
26446
+ async rebalance(opts) {
26299
26447
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
26448
+ const minImprovement = Math.max(opts?.minImprovement ?? 0, 0);
26300
26449
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
26301
26450
  const nodeCaps = await this.buildNodeCaps();
26302
26451
  const nodeWeights = await this.buildNodeWeights();
@@ -26334,6 +26483,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26334
26483
  continue;
26335
26484
  }
26336
26485
  if (current && current.agentNodeId === decision.agentNodeId) continue;
26486
+ if (current && minImprovement > 0) {
26487
+ const tallied = talliedLoads();
26488
+ const currentLoad = tallied.find((l) => l.nodeId === current.agentNodeId);
26489
+ const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
26490
+ if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
26491
+ }
26337
26492
  if (current) {
26338
26493
  await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
26339
26494
  const msg = errMsg(err);
@@ -26344,7 +26499,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26344
26499
  });
26345
26500
  bumpAttached(current.agentNodeId, -1);
26346
26501
  }
26347
- await this.attachOn(decision.agentNodeId, config);
26502
+ const reResolved = await this.resolvePipelineForDevice(deviceId, decision.agentNodeId);
26503
+ const targetConfig = reResolved.steps.length > 0 ? {
26504
+ ...config,
26505
+ steps: reResolved.steps,
26506
+ audio: reResolved.audio ?? null
26507
+ } : config;
26508
+ this.cameraConfigs.set(deviceId, targetConfig);
26509
+ await this.attachOn(decision.agentNodeId, targetConfig);
26348
26510
  bumpAttached(decision.agentNodeId, 1);
26349
26511
  this.recordAssignment(deviceId, decision.agentNodeId, "rebalance", false);
26350
26512
  migrated++;
@@ -26443,11 +26605,37 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26443
26605
  async attachOn(nodeId, config) {
26444
26606
  const api = this.ctx.api;
26445
26607
  if (!api) throw new Error(`attachOn(${nodeId}, ${config.deviceId}): this.ctx.api not available`);
26608
+ const sourceOwnerNodeId = this.sourceOwner(config.deviceId);
26609
+ const ownerReachableHost = sourceOwnerNodeId !== void 0 ? this.reachableHostByNode.get(sourceOwnerNodeId) : void 0;
26446
26610
  const frameSource = selectRunnerFrameSource({
26447
26611
  targetNodeId: nodeId,
26448
- sourceOwnerNodeId: this.sourceOwner(config.deviceId),
26449
- remoteSourcingNodes: this.remoteSourcingNodes
26612
+ sourceOwnerNodeId,
26613
+ remoteSourcingNodes: this.remoteSourcingNodes,
26614
+ ...ownerReachableHost !== void 0 ? { ownerReachableHost } : {}
26450
26615
  });
26616
+ api.pipelineExecutor.validatePipeline.query({ steps: [...config.steps ?? []] }, nodePin(nodeId)).then((validation) => {
26617
+ if (!validation || validation.ok && validation.substitutions.length === 0) return;
26618
+ const logMeta = {
26619
+ nodeId,
26620
+ deviceId: config.deviceId,
26621
+ issues: validation.issues,
26622
+ substitutions: validation.substitutions
26623
+ };
26624
+ if (!validation.ok) this.ctx.logger.warn("attachOn: pre-init pipeline validation found issues", {
26625
+ tags: {
26626
+ nodeId,
26627
+ deviceId: config.deviceId
26628
+ },
26629
+ meta: logMeta
26630
+ });
26631
+ else this.ctx.logger.info("attachOn: pre-init pipeline validation found model substitutions", {
26632
+ tags: {
26633
+ nodeId,
26634
+ deviceId: config.deviceId
26635
+ },
26636
+ meta: logMeta
26637
+ });
26638
+ }).catch(() => {});
26451
26639
  try {
26452
26640
  await api.pipelineRunner.attachCamera.mutate({
26453
26641
  ...config,
@@ -26462,7 +26650,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26462
26650
  audioMode: config.audioMode,
26463
26651
  motionStreamId: config.motionStreamId,
26464
26652
  detectionStreamId: config.detectionStreamId,
26465
- hasEngine: !!config.engine,
26466
26653
  stepsCount: config.steps?.length ?? null,
26467
26654
  hasAudio: config.audio?.enabled ?? null
26468
26655
  };
@@ -26567,18 +26754,31 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26567
26754
  return this.clusterRoles.ingestNode;
26568
26755
  }
26569
26756
  /**
26757
+ * Cap surface: the cluster's single camera-source owner + its
26758
+ * LAN-reachable host, resolved UNCONDITIONALLY from `clusterRoles.ingestNode`
26759
+ * (NOT `resolveSourceOwner`/`sourceOwner`, which are gated on the
26760
+ * detection-decode `remoteSourcingNodes` rollout knob). Consumers
26761
+ * (recorder/snapshot/audio) call this to pin their broker calls to the
26762
+ * owner and dial `reachableHost`. Rollout-safe: defaults to
26763
+ * `{ ownerNodeId: 'hub' }` — byte-identical to today's hub-only behavior.
26764
+ */
26765
+ async getIngestOwner() {
26766
+ return resolveIngestOwner(this.clusterRoles, this.reachableHostByNode, this.enabledIngestNodes);
26767
+ }
26768
+ /**
26570
26769
  * The node that owns `deviceId`'s source pull, or `undefined` while
26571
26770
  * ownership is UNMODELED — which is exactly the rollout-OFF state: with the
26572
26771
  * `remoteSourcingNodes` knob empty (the default), `resolveSourceOwner`
26573
26772
  * returns `undefined`, `computeFrameSourceNodes` collapses to the global
26574
26773
  * `enabledDecoderNodes`, and `selectRunnerFrameSource` emits `local-broker`
26575
26774
  * for every target — bit-identical to pre-Phase-2 behavior. Populating the
26576
- * knob models ownership (`assignSource`, slice 1: the hub) and thereby
26577
- * turns eligibility per-camera and the remote-source leg dispatchable.
26775
+ * knob (a decode node other than the ingest owner exists) models ownership as
26776
+ * `clusterRoles.ingestNode` and thereby turns eligibility per-camera and the
26777
+ * remote-source leg dispatchable.
26578
26778
  */
26579
26779
  sourceOwner(deviceId) {
26580
26780
  return resolveSourceOwner({
26581
- hubNodeId: this.localNodeId,
26781
+ ownerNodeId: this.clusterRoles.ingestNode,
26582
26782
  remoteSourcingNodes: this.remoteSourcingNodes,
26583
26783
  ...deviceId !== void 0 ? { assignedOwner: this.assignSource(deviceId) } : {}
26584
26784
  });
@@ -26691,7 +26891,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26691
26891
  * lookup. Works for any backend (ffmpeg / node-av / future) and no longer
26692
26892
  * returns empty on a node-av-only node (the old `decoder-ffmpeg`-keyed read
26693
26893
  * did). Returns `null` when the value is empty/unset, `undefined` on a read
26694
- * failure (keep the last known). Mirrors {@link readDetectionPipelineEngine}.
26894
+ * failure (keep the last known). Node-local the same pattern the
26895
+ * orchestrator's (now-deleted) per-node engine reader used before engine
26896
+ * resolution moved entirely off the orchestrator.
26695
26897
  */
26696
26898
  async readNodeDecodeHwaccel(nodeId) {
26697
26899
  try {
@@ -27167,7 +27369,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27167
27369
  if (resolved.steps.length === 0) continue;
27168
27370
  const next = {
27169
27371
  ...cached,
27170
- engine: resolved.engine,
27171
27372
  steps: resolved.steps,
27172
27373
  audio: resolved.audio ?? null
27173
27374
  };
@@ -27237,7 +27438,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27237
27438
  if (resolved.steps.length === 0) continue;
27238
27439
  const next = {
27239
27440
  ...cached,
27240
- engine: resolved.engine,
27241
27441
  steps: resolved.steps,
27242
27442
  audio: resolved.audio ?? null
27243
27443
  };
@@ -27797,6 +27997,33 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27797
27997
  this.schedulePendingRetry();
27798
27998
  return { success: true };
27799
27999
  }
28000
+ /**
28001
+ * Set (or clear) the per-node `reachableHost` override — the LAN host a
28002
+ * cross-node decoder dials to reach this node's restream. A non-empty string
28003
+ * overrides the auto-detected host the runner would otherwise derive from
28004
+ * `CAMSTACK_HUB_URL`; `null` or an empty string clears it (revert to
28005
+ * auto-detect). Persisted onto the node's `agentSettings` entry, then a
28006
+ * debounced re-dispatch so new cross-node attaches pick up the host.
28007
+ */
28008
+ async setAgentReachableHost(input) {
28009
+ const existing = (await this.readAgentSettingsMap())[input.agentNodeId];
28010
+ const trimmed = input.reachableHost?.trim();
28011
+ const host = trimmed !== void 0 && trimmed.length > 0 ? trimmed : void 0;
28012
+ const next = {
28013
+ ...existing ?? {
28014
+ addonDefaults: {},
28015
+ maxCameras: null
28016
+ },
28017
+ reachableHost: host
28018
+ };
28019
+ await this.writeAgentSettings(input.agentNodeId, next);
28020
+ this.ctx.logger.info("agentSettings.reachableHost updated", {
28021
+ tags: { nodeId: input.agentNodeId },
28022
+ meta: { reachableHost: host ?? null }
28023
+ });
28024
+ this.schedulePendingRetry();
28025
+ return { success: true };
28026
+ }
27800
28027
  async getCameraSettings(input) {
27801
28028
  return (await this.readCameraSettingsMap())[String(input.deviceId)] ?? null;
27802
28029
  }
@@ -27880,6 +28107,25 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27880
28107
  });
27881
28108
  for (const deviceId of affected) await this.emitResolvedCameraUpdated(deviceId);
27882
28109
  }
28110
+ /**
28111
+ * Re-dispatch EVERY assigned camera through the REAL attach path
28112
+ * (`redispatchSingleCamera` → `attachOn`), so each camera's `frameSource`
28113
+ * (`local-broker` vs `remote-restream` naming the current ingest owner) is
28114
+ * RECOMPUTED and re-applied to its runner. Used when the cluster ingest owner
28115
+ * changes: `frameSource` is computed only in `attachOn` (the single choke
28116
+ * point), so a plain `emitCameraUpdated` config event would NOT move the
28117
+ * source — the runner would keep sourcing from the OLD owner. No-op when
28118
+ * nothing is assigned.
28119
+ */
28120
+ async redispatchAllActiveCameras(reason) {
28121
+ const deviceIds = [...this.assignments.keys()];
28122
+ if (deviceIds.length === 0) return;
28123
+ this.ctx.logger.info("re-dispatching all cameras", { meta: {
28124
+ reason,
28125
+ count: deviceIds.length
28126
+ } });
28127
+ for (const deviceId of deviceIds) await this.redispatchSingleCamera(deviceId);
28128
+ }
27883
28129
  async listTemplates() {
27884
28130
  const templates = await this.readTemplatesMap();
27885
28131
  return Object.values(templates).slice().toSorted((a, b) => a.name.localeCompare(b.name));
@@ -27956,6 +28202,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27956
28202
  const STAGE_TIMEOUT_MS = 3e3;
27957
28203
  const pipelineAssignment = this.assignments.get(deviceId) ?? null;
27958
28204
  const detectionNodeId = pipelineAssignment?.agentNodeId ?? null;
28205
+ const sourceNodeId = this.assignSource(deviceId);
27959
28206
  const decoderPinRaw = (api ? await this.ctx.settings?.readDeviceStore(deviceId).catch(() => ({})) ?? {} : {})["decoderNodeId"];
27960
28207
  const decoderPinned = typeof decoderPinRaw === "string" && decoderPinRaw !== "auto";
27961
28208
  const advisoryDecoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
@@ -27969,7 +28216,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27969
28216
  };
27970
28217
  const detectionReason = pipelineAssignment !== null ? pipelineAssignment.reason : this.cameraConfigs.has(deviceId) ? `pending:${this.pendingReasons.get(deviceId) ?? "pending"}` : void 0;
27971
28218
  const liveDecoder = { nodeId: null };
27972
- const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query() : null;
28219
+ const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
27973
28220
  const sourceFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then((slots) => {
27974
28221
  return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
27975
28222
  camStreamId: s.sourceCamStreamId ?? s.brokerId,
@@ -27995,10 +28242,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27995
28242
  const [statsAndClients, rtspEntry] = await Promise.all([Promise.all(deviceSlots.map(async (slot) => {
27996
28243
  return {
27997
28244
  slot,
27998
- stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }).catch(() => null),
27999
- clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }).catch(() => null)
28245
+ stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null),
28246
+ clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null)
28000
28247
  };
28001
- })), api.streamBroker.getAllRtspEntries.query({}).catch(() => null)]);
28248
+ })), api.streamBroker.getAllRtspEntries.query({}, nodePin(sourceNodeId)).catch(() => null)]);
28002
28249
  const profileDetails = statsAndClients.map(({ slot, stats, clients }) => ({
28003
28250
  profile: slot.profile,
28004
28251
  status: slot.status,
@@ -28103,6 +28350,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28103
28350
  detectionNodeId,
28104
28351
  decoderNodeId,
28105
28352
  audioNodeId,
28353
+ sourceNodeId,
28106
28354
  pinned,
28107
28355
  reasons,
28108
28356
  sourceResult,
@@ -28180,14 +28428,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28180
28428
  }
28181
28429
  /**
28182
28430
  * Fetch `pipelineExecutor.getSchema({nodeId})` through the standard cap
28183
- * router, cached per `(nodeId, engineKey)` with a short TTL. Returns
28184
- * `null` when the executor can't be reached (no runner yet attached)
28185
- * — callers fall back to an empty pipeline in that case.
28431
+ * router, cached per node with a short TTL. Returns `null` when the
28432
+ * executor can't be reached (no runner yet attached) — callers fall
28433
+ * back to an empty pipeline in that case.
28434
+ *
28435
+ * The cache key keeps the `${nodeId}::*` suffix (rather than a bare
28436
+ * `nodeId`) so `handleDetectionPipelineReadiness`'s prefix-match
28437
+ * invalidation (`key.startsWith(\`${nodeId}::\`)`) keeps working
28438
+ * unchanged; there is no longer a per-engine variant to distinguish.
28186
28439
  */
28187
- async getCatalogForAgent(nodeId, engine) {
28440
+ async getCatalogForAgent(nodeId) {
28188
28441
  const api = this.ctx.api;
28189
28442
  if (!api) return null;
28190
- const engineKey = engine ? `${nodeId}::${engine.runtime}/${engine.backend}/${engine.format}` : `${nodeId}::*`;
28443
+ const engineKey = `${nodeId}::*`;
28191
28444
  const now = Date.now();
28192
28445
  const hit = this.catalogCache.get(engineKey);
28193
28446
  if (hit && now - hit.at < PipelineOrchestratorAddon.CATALOG_CACHE_TTL_MS) return hit.schema;
@@ -28213,11 +28466,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28213
28466
  *
28214
28467
  * Behaviour:
28215
28468
  * - Fetch catalog for the node.
28216
- * - Call `seedAgentAddonDefaults(current.addonDefaults, engine, catalog)` — preserves
28469
+ * - Call `seedAgentAddonDefaults(current.addonDefaults, catalog)` — preserves
28217
28470
  * operator customisations, adds missing addons, drops orphans.
28218
28471
  * - Persist if anything changed.
28219
28472
  *
28220
- * Idempotent: a second call with the same catalog is a no-op.
28473
+ * Idempotent: a second call with the same catalog is a no-op. Data
28474
+ * only: no engine is resolved or stored here — the executing node
28475
+ * picks its own format default at runtime when a step's `modelId` is
28476
+ * absent.
28221
28477
  */
28222
28478
  async seedAgentSettingsFromCatalog(nodeId) {
28223
28479
  await this.ctx.acquireCapability("pipeline-executor", {
@@ -28227,71 +28483,28 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28227
28483
  const catalog = await this.getCatalogForAgent(nodeId);
28228
28484
  if (!catalog) return null;
28229
28485
  const existing = (await this.readAgentSettingsMap())[nodeId];
28230
- const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
28231
- const nextAddonDefaults = seedAgentAddonDefaults(existing?.addonDefaults ?? {}, engine, catalog);
28486
+ const nextAddonDefaults = seedAgentAddonDefaults(existing?.addonDefaults ?? {}, catalog);
28232
28487
  const next = { addonDefaults: nextAddonDefaults };
28233
28488
  if (!existing || JSON.stringify(existing.addonDefaults) !== JSON.stringify(next.addonDefaults)) {
28234
28489
  await this.writeAgentSettings(nodeId, next);
28235
28490
  this.ctx.logger.info("agentSettings seeded/refreshed", {
28236
28491
  tags: { nodeId },
28237
- meta: {
28238
- runtime: engine.runtime,
28239
- backend: engine.backend,
28240
- format: engine.format,
28241
- addons: Object.keys(nextAddonDefaults).length
28242
- }
28492
+ meta: { addons: Object.keys(nextAddonDefaults).length }
28243
28493
  });
28244
28494
  }
28245
28495
  return next;
28246
28496
  }
28247
28497
  /**
28248
- * Fetch the engine triple (`runtime/backend/device`) from the
28249
- * `detection-pipeline` addon's global settings on the target node.
28250
- * This is the authoritative engine source since phase 2f. Returns
28251
- * `null` when the addon hasn't responded yet or doesn't expose the
28252
- * fields — callers fall back to `catalog.selectedEngine`.
28253
- */
28254
- async readDetectionPipelineEngine(agentNodeId) {
28255
- const api = this.ctx.api;
28256
- if (!api?.addonSettings) return null;
28257
- try {
28258
- const schema = await api.addonSettings.getGlobalSettings.query({
28259
- addonId: "detection-pipeline",
28260
- nodeId: agentNodeId
28261
- });
28262
- if (!schema) return null;
28263
- const leaf = (key) => {
28264
- for (const s of schema.sections) for (const f of s.fields) if (f.key === key) return f.value;
28265
- };
28266
- const runtime = leaf("engineRuntime");
28267
- const backend = leaf("engineBackend");
28268
- const device = leaf("engineDevice");
28269
- if (runtime !== "python" && runtime !== "node" || typeof backend !== "string" || backend.length === 0) return null;
28270
- const base = {
28271
- runtime,
28272
- backend,
28273
- format: backend === "coreml" ? "coreml" : backend === "openvino" ? "openvino" : "onnx"
28274
- };
28275
- if (typeof device === "string" && device.length > 0) return {
28276
- ...base,
28277
- device
28278
- };
28279
- return base;
28280
- } catch (err) {
28281
- this.ctx.logger.debug("readDetectionPipelineEngine failed — falling back", {
28282
- tags: { nodeId: agentNodeId },
28283
- meta: { error: err instanceof Error ? err.message : String(err) }
28284
- });
28285
- return null;
28286
- }
28287
- }
28288
- /**
28289
28498
  * Resolve the pipeline for a device. NEVER returns an empty/fallback
28290
28499
  * config: if the catalog or agent settings aren't available yet, this
28291
28500
  * blocks (with backoff) until the detection-pipeline cap is registered
28292
28501
  * and a non-null catalog comes back. Cameras must never be dispatched
28293
- * with `engine=node/cpu, steps=[]` because the runner then attaches
28294
- * the camera with no steps and silently never recovers.
28502
+ * with `steps=[]` because the runner then attaches the camera with no
28503
+ * steps and silently never recovers.
28504
+ *
28505
+ * Data only: the returned config never carries an engine — model
28506
+ * selection is data only, and the executing node resolves its own
28507
+ * format default at runtime when a step's `modelId` is absent.
28295
28508
  *
28296
28509
  * Two legitimate exceptions to "never empty":
28297
28510
  * 1. The device's `detection-pipeline` binding is INACTIVE — the
@@ -28305,39 +28518,29 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28305
28518
  async resolvePipelineForDevice(deviceId, agentNodeIdOverride) {
28306
28519
  const agentNodeId = agentNodeIdOverride ?? this.assignments.get(deviceId)?.agentNodeId ?? this.localNodeId;
28307
28520
  if (!await this.isDetectionPipelineActive(deviceId)) {
28308
- const engine = await this.waitForEngine(agentNodeId);
28309
28521
  this.ctx.logger.info("resolvePipelineForDevice → empty (binding inactive)", {
28310
28522
  tags: {
28311
28523
  deviceId,
28312
28524
  nodeId: agentNodeId
28313
28525
  },
28314
- meta: {
28315
- reason: "detection-pipeline binding inactive",
28316
- engine: `${engine.runtime}/${engine.backend}`
28317
- }
28526
+ meta: { reason: "detection-pipeline binding inactive" }
28318
28527
  });
28319
28528
  return {
28320
- engine,
28321
28529
  steps: [],
28322
28530
  audio: null
28323
28531
  };
28324
28532
  }
28325
28533
  const cam = (await this.readCameraSettingsMap())[String(deviceId)] ?? null;
28326
28534
  const wholesale = cam?.pipelineByAgent?.[agentNodeId];
28327
- if (wholesale) {
28328
- const engine = await this.waitForEngine(agentNodeId);
28329
- return {
28330
- engine,
28331
- steps: wholesale.steps,
28332
- audio: wholesale.audio ? {
28333
- engine,
28334
- modelId: wholesale.audio.modelId,
28335
- enabled: wholesale.audio.enabled
28336
- } : null
28337
- };
28338
- }
28339
- const { agent, catalog, engine } = await this.waitForAgentAndCatalog(agentNodeId);
28340
- const resolved = resolvePipeline(agent, cam, agentNodeId, engine, catalog);
28535
+ if (wholesale) return {
28536
+ steps: wholesale.steps,
28537
+ audio: wholesale.audio ? {
28538
+ modelId: wholesale.audio.modelId,
28539
+ enabled: wholesale.audio.enabled
28540
+ } : null
28541
+ };
28542
+ const { agent, catalog } = await this.waitForAgentAndCatalog(agentNodeId);
28543
+ const resolved = resolvePipeline(agent, cam, agentNodeId, catalog);
28341
28544
  if (resolved.steps.length === 0) this.ctx.logger.info("resolvePipelineForDevice → empty (all addons disabled)", {
28342
28545
  tags: {
28343
28546
  deviceId,
@@ -28385,51 +28588,27 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28385
28588
  await sleep$1(2e3);
28386
28589
  continue;
28387
28590
  }
28388
- const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
28389
28591
  return {
28390
28592
  agent,
28391
- catalog,
28392
- engine
28593
+ catalog
28393
28594
  };
28394
28595
  }
28395
28596
  }
28396
28597
  /**
28397
- * Block until a real engine choice is available. `acquireCapability`
28398
- * defaults to infinite wait; the post-acquire loop only handles the
28399
- * propagation gap where the cap is `ready` but the derived calls
28400
- * still return null for a tick.
28401
- */
28402
- async waitForEngine(nodeId) {
28403
- await this.ctx.acquireCapability("pipeline-executor", {
28404
- type: "node",
28405
- nodeId
28406
- });
28407
- while (true) {
28408
- const fromAddon = await this.readDetectionPipelineEngine(nodeId);
28409
- if (fromAddon) return fromAddon;
28410
- const catalog = await this.getCatalogForAgent(nodeId);
28411
- if (catalog?.selectedEngine) return catalog.selectedEngine;
28412
- await sleep$1(2e3);
28413
- }
28414
- }
28415
- /**
28416
28598
  * Structural runtime check for `CameraPipelineConfig`. Persisted JSON
28417
28599
  * may be stale or partial across upgrades, so we gate reads on shape
28418
28600
  * rather than trusting the raw object. False = silently ignore that
28419
28601
  * entry and fall back to defaults — the orchestrator's job is to
28420
28602
  * never crash on corrupt store state.
28603
+ *
28604
+ * Data only: `engine` is no longer required — a persisted config may
28605
+ * still carry a vestigial `engine` from before the data-only refactor;
28606
+ * it is simply ignored.
28421
28607
  */
28422
28608
  isCameraPipelineConfig(value) {
28423
28609
  if (!value || typeof value !== "object") return false;
28424
28610
  const v = value;
28425
- const engine = v["engine"];
28426
- if (!engine || typeof engine !== "object") return false;
28427
- const e = engine;
28428
- if (typeof e["runtime"] !== "string") return false;
28429
- if (typeof e["backend"] !== "string") return false;
28430
- if (typeof e["format"] !== "string") return false;
28431
- if (!Array.isArray(v["steps"])) return false;
28432
- return true;
28611
+ return Array.isArray(v["steps"]);
28433
28612
  }
28434
28613
  isPipelineTemplate(value) {
28435
28614
  if (!value || typeof value !== "object") return false;
@@ -28781,10 +28960,21 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28781
28960
  ] });
28782
28961
  }
28783
28962
  async updateGlobalSettings(patch) {
28963
+ const previousIngestNode = this.clusterRoles.ingestNode;
28784
28964
  await super.updateGlobalSettings(patch);
28785
28965
  const full = await this.resolveGlobalStore();
28786
28966
  this.globalSettings = { ...full };
28787
28967
  this.applyRuntimeSettings(full);
28968
+ if (this.clusterRoles.ingestNode !== previousIngestNode) {
28969
+ const { configIssue } = resolveIngestOwner(this.clusterRoles, this.reachableHostByNode, this.enabledIngestNodes);
28970
+ if (configIssue !== void 0) this.ctx.logger.warn("ingest node config issue", { meta: {
28971
+ ingestNode: this.clusterRoles.ingestNode,
28972
+ issue: configIssue
28973
+ } });
28974
+ this.refreshNodeCapabilities().then(() => this.redispatchAllActiveCameras("ingest-owner-changed")).catch((err) => {
28975
+ this.ctx.logger.warn("ingest-owner-changed re-dispatch failed", { meta: { error: errMsg(err) } });
28976
+ });
28977
+ }
28788
28978
  const pausedIds = [...this.loadShedState.entries()].filter(([, s]) => s.pausedAt !== null).map(([id]) => id);
28789
28979
  if (pausedIds.length > 0) {
28790
28980
  this.loadShedState.clear();
@@ -28953,7 +29143,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28953
29143
  async applyPipelinePatch(deviceId, pipelinePatch) {
28954
29144
  const incoming = pipelinePatch.cameraPipeline;
28955
29145
  if (incoming === void 0) return;
28956
- if (!this.isCameraPipelineConfig(incoming)) throw new Error(`applyPipelinePatch: cameraPipeline value for device ${deviceId} is not a valid CameraPipelineConfig (expected { engine:{runtime,backend,format}, steps:[], audio?:null|object })`);
29146
+ if (!this.isCameraPipelineConfig(incoming)) throw new Error(`applyPipelinePatch: cameraPipeline value for device ${deviceId} is not a valid CameraPipelineConfig (expected { steps:[], audio?:null|object })`);
28957
29147
  const agentNodeId = this.assignments.get(deviceId)?.agentNodeId ?? this.localNodeId;
28958
29148
  await this.setCameraPipelineForAgent({
28959
29149
  deviceId,
@@ -29009,12 +29199,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29009
29199
  }
29010
29200
  /**
29011
29201
  * Recompute the derived enabled-node sets (`enabledNodes` /
29012
- * `enabledDecoderNodes` / `enabledAudioNodes`) from the per-node capability
29013
- * store. A node is eligible for a concern iff its stored flag is `true`, or
29014
- * the flag is unset AND the node defaults capable ({@link nodeCapabilityDefault}).
29015
- * Forked child nodeIds (`hub/classifier`) are never dispatchable and are
29016
- * excluded. `hub` is always considered even when absent from the store, so a
29017
- * fresh install keeps the hub-only cluster working.
29202
+ * `enabledDecoderNodes` / `enabledAudioNodes` / `enabledIngestNodes`) from
29203
+ * the per-node capability store. A node is eligible for a concern iff its
29204
+ * stored flag is `true`, or the flag is unset AND the node defaults capable
29205
+ * ({@link nodeCapabilityDefault}). Forked child nodeIds (`hub/classifier`)
29206
+ * are never dispatchable and are excluded. `hub` is always considered even
29207
+ * when absent from the store, so a fresh install keeps the hub-only cluster
29208
+ * working.
29018
29209
  */
29019
29210
  async refreshNodeCapabilities() {
29020
29211
  let blob = {};
@@ -29028,16 +29219,30 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29028
29219
  const detect = [];
29029
29220
  const decode = [];
29030
29221
  const audio = [];
29222
+ const ingest = [];
29223
+ const reachableHosts = /* @__PURE__ */ new Map();
29031
29224
  const capable = (flag, nodeId) => typeof flag === "boolean" ? flag : PipelineOrchestratorAddon.nodeCapabilityDefault(nodeId);
29032
29225
  for (const nodeId of nodeIds) {
29033
29226
  const settings = blob[nodeId];
29034
29227
  if (capable(settings?.detect, nodeId)) detect.push(nodeId);
29035
29228
  if (capable(settings?.decode, nodeId)) decode.push(nodeId);
29036
29229
  if (capable(settings?.audio, nodeId)) audio.push(nodeId);
29230
+ if (capable(settings?.ingest, nodeId)) ingest.push(nodeId);
29231
+ const host = settings?.reachableHost?.trim();
29232
+ if (host !== void 0 && host.length > 0) reachableHosts.set(nodeId, host);
29037
29233
  }
29234
+ this.reachableHostByNode = reachableHosts;
29038
29235
  this.enabledNodes = detect.toSorted();
29039
29236
  this.enabledDecoderNodes = decode.toSorted();
29040
29237
  this.enabledAudioNodes = audio.toSorted();
29238
+ this.enabledIngestNodes = ingest.toSorted();
29239
+ this.remoteSourcingNodes = deriveRemoteSourcingNodes(this.enabledDecoderNodes, this.clusterRoles.ingestNode);
29240
+ const { ownerNodeId } = resolveIngestOwner(this.clusterRoles, this.reachableHostByNode, this.enabledIngestNodes);
29241
+ this.ctx.eventBus.emit(createEvent(EventCategory.PipelineIngestOwnerChanged, {
29242
+ type: "addon",
29243
+ id: this.ctx.id,
29244
+ nodeId: "hub"
29245
+ }, { ownerNodeId }));
29041
29246
  }
29042
29247
  get api() {
29043
29248
  return this.ctx.api ?? null;
@@ -29336,6 +29541,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29336
29541
  await this.stopDetection(deviceId);
29337
29542
  }
29338
29543
  this.activeDetections.set(deviceId, config);
29544
+ const hydrateNodeId = this.assignments.get(deviceId)?.agentNodeId ?? this.localNodeId;
29339
29545
  const pipelineConfig = await this.resolvePipelineForDevice(deviceId);
29340
29546
  const zones = await this.zonesProvider?.listZones({ deviceId }) ?? [];
29341
29547
  const runnerConfig = {
@@ -29347,7 +29553,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29347
29553
  detectionStreamId: config.detectionStreamId,
29348
29554
  motionSources: config.motionSources,
29349
29555
  pipelineEnabled: config.pipelineEnabled,
29350
- engine: pipelineConfig.engine,
29351
29556
  steps: pipelineConfig.steps,
29352
29557
  audio: pipelineConfig.audio ?? null,
29353
29558
  zones,
@@ -29366,15 +29571,20 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29366
29571
  const msg = errMsg(err);
29367
29572
  log.error("dispatchCamera failed", { meta: { error: msg } });
29368
29573
  }
29369
- if (dispatchedNodeId && (runnerConfig.steps?.length ?? 0) === 0) try {
29574
+ if (dispatchedNodeId && (dispatchedNodeId !== hydrateNodeId || (runnerConfig.steps?.length ?? 0) === 0)) try {
29370
29575
  const corrected = await this.resolvePipelineForDevice(deviceId, dispatchedNodeId);
29371
- if (corrected.steps.length > 0) {
29372
- const correctedConfig = {
29373
- ...runnerConfig,
29374
- engine: corrected.engine,
29375
- steps: corrected.steps,
29376
- audio: corrected.audio ?? null
29377
- };
29576
+ const correctedConfig = {
29577
+ ...runnerConfig,
29578
+ steps: corrected.steps,
29579
+ audio: corrected.audio ?? null
29580
+ };
29581
+ if (JSON.stringify({
29582
+ steps: runnerConfig.steps,
29583
+ audio: runnerConfig.audio ?? null
29584
+ }) !== JSON.stringify({
29585
+ steps: correctedConfig.steps,
29586
+ audio: correctedConfig.audio
29587
+ })) {
29378
29588
  await this.attachOn(dispatchedNodeId, correctedConfig);
29379
29589
  this.cameraConfigs.set(deviceId, correctedConfig);
29380
29590
  log.info("startDetection: pipeline re-resolved after agent assignment", {
@@ -29575,7 +29785,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29575
29785
  }
29576
29786
  const nextConfig = {
29577
29787
  ...cached,
29578
- engine: config.engine,
29579
29788
  steps: config.steps,
29580
29789
  audio: config.audio ?? null
29581
29790
  };
@@ -30067,6 +30276,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
30067
30276
  api,
30068
30277
  brokerId: audioBrokerId,
30069
30278
  tag: "audio-analyzer",
30279
+ ownerNodeId: this.clusterRoles.ingestNode,
30070
30280
  logger: this.ctx.logger,
30071
30281
  onChunk: async (chunk) => {
30072
30282
  this.pipelineWatchdog?.noteSignal(deviceId, "audio");