@camstack/addon-pipeline-orchestrator 1.1.30 → 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.mjs CHANGED
@@ -4627,7 +4627,7 @@ function _instanceof(cls, params = {}) {
4627
4627
  return inst;
4628
4628
  }
4629
4629
  //#endregion
4630
- //#region ../types/dist/sleep-CZDdRBua.mjs
4630
+ //#region ../types/dist/sleep-Cc14_yxc.mjs
4631
4631
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4632
4632
  EventCategory["SystemBoot"] = "system.boot";
4633
4633
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -4813,6 +4813,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4813
4813
  */
4814
4814
  EventCategory["PipelineCameraUpdated"] = "pipeline.camera-updated";
4815
4815
  /**
4816
+ * The cluster camera-source OWNER changed (`clusterRoles.ingestNode`).
4817
+ * Emitted by addon-pipeline-orchestrator whenever it (re)derives node
4818
+ * capabilities — at boot, on agent online/offline, and on an ingest-node
4819
+ * flip. Carries the resolved `ownerNodeId`. The stream-broker consumes it to
4820
+ * keep its ingest-owner-gate decision current WITHOUT a per-`ensureBroker`
4821
+ * cross-process `getIngestOwner` query (push the authority's decision instead
4822
+ * of polling it on the hot path). Idempotent state — re-emitted on every
4823
+ * topology change, so a dropped event self-heals on the next one (plus the
4824
+ * broker's long backstop reconcile query).
4825
+ */
4826
+ EventCategory["PipelineIngestOwnerChanged"] = "pipeline.ingest-owner-changed";
4827
+ /**
4816
4828
  * Periodic snapshot of per-node pipeline-runner load
4817
4829
  * (`RunnerLocalLoad`). Emitted ~1Hz by every runner so UI dashboards
4818
4830
  * subscribe instead of polling `pipelineRunner.getLocalLoad`.
@@ -7304,6 +7316,28 @@ var ModelFormatsSchema = object({
7304
7316
  tflite: ModelFormatEntrySchema.optional(),
7305
7317
  pt: ModelFormatEntrySchema.optional()
7306
7318
  });
7319
+ /**
7320
+ * Variant-selector grouping axes. Shared by the full `ModelCatalogEntry` and by
7321
+ * the reduced `PipelineModelOption` returned in `pipeline.getSchema()` so the
7322
+ * grouped Family→Tier→Variant picker renders identically in the config UI and
7323
+ * in the pipeline/device steppers. The flat `id` stays the source of truth for
7324
+ * resolution/download/persistence; this is a presentation overlay resolved back
7325
+ * to an `id`.
7326
+ */
7327
+ var ModelVariantGroupSchema = object({
7328
+ /** Top-level family, e.g. `yolo26` (later `d-fine`, `rf-detr`). */
7329
+ family: string(),
7330
+ /** Size within the family, e.g. `n` | `s` | `m` | `l`. */
7331
+ tier: string(),
7332
+ /** Quantization axis. Omit ⇒ the fp32 base build. */
7333
+ precision: _enum(["fp32", "int8"]).optional(),
7334
+ /**
7335
+ * Speed-optimization axis. Omit ⇒ the standard build. `fast` marks a
7336
+ * latency-optimized export (e.g. ReLU-activation / reduced-input variant)
7337
+ * — the slot the future performance variants plug into.
7338
+ */
7339
+ optimization: _enum(["standard", "fast"]).optional()
7340
+ });
7307
7341
  var ModelCatalogEntrySchema = object({
7308
7342
  id: string(),
7309
7343
  name: string(),
@@ -7333,7 +7367,43 @@ var ModelCatalogEntrySchema = object({
7333
7367
  * Auxiliary files required at runtime (labels JSON, charset dict, etc.).
7334
7368
  * Downloaded into the same modelsDir alongside the model file.
7335
7369
  */
7336
- extraFiles: array(ModelExtraFileSchema).readonly().optional()
7370
+ extraFiles: array(ModelExtraFileSchema).readonly().optional(),
7371
+ /**
7372
+ * LEGACY entry — retained in the catalog so a persisted operator selection
7373
+ * still RESOLVES (and can be re-activated), but hidden from the selectable
7374
+ * model list and excluded from the auto format-default pick. Set on the
7375
+ * superseded / consolidated models (older lineages, redundant fp16 IRs) so
7376
+ * the active lineup stays the coherent curated ladder without deleting a
7377
+ * model anyone may still be pinned to. `resolveModelForFormat` keeps honoring
7378
+ * an explicit legacy id that has a build for the node's format.
7379
+ */
7380
+ legacy: boolean().optional(),
7381
+ /**
7382
+ * Measured quality/latency metadata — populated from the benchmark addon on
7383
+ * the real node classes. Absent = not yet measured (most entries today; the
7384
+ * catalog historically carried only `sizeMB`, a poor cross-architecture
7385
+ * speed proxy). `p95LatencyMs` is keyed by node class (e.g. `n100`, `mac`).
7386
+ */
7387
+ metrics: object({
7388
+ map50: number().optional(),
7389
+ p95LatencyMs: record(string(), number()).optional()
7390
+ }).optional(),
7391
+ /**
7392
+ * SPDX-ish license id of the model weights (e.g. `AGPL-3.0` for Ultralytics
7393
+ * YOLO26, `GPL-3.0` for YOLOv9, `Apache-2.0` for D-FINE/RF-DETR). Matters for
7394
+ * the retraining addon and any future commercial distribution.
7395
+ */
7396
+ license: string().optional(),
7397
+ /**
7398
+ * Variant-selector grouping. The UI groups models by `family` + `tier` and
7399
+ * offers `precision` / `optimization` as variant axes WITHIN a tier — so all
7400
+ * of a family's sizes and quantizations collapse into one grouped picker
7401
+ * instead of a flat list of `yolo26s`, `yolo26s-int8`, … Absent ⇒ ungrouped
7402
+ * (legacy / custom models) — never shown in the grouped selector. The flat
7403
+ * `id` stays the source of truth for resolution/download/persistence; grouping
7404
+ * is a presentation overlay resolved back to an `id`.
7405
+ */
7406
+ group: ModelVariantGroupSchema.optional()
7337
7407
  });
7338
7408
  var ConvertTargetSchema = discriminatedUnion("format", [object({
7339
7409
  format: literal("openvino"),
@@ -7394,8 +7464,8 @@ var RecordingModeSchema = _enum([
7394
7464
  "onAudioThreshold"
7395
7465
  ]);
7396
7466
  /**
7397
- * First-class, authoritative per-camera storage mode — the netta choice the UI
7398
- * reads directly (never inferred from `rules`):
7467
+ * First-class, authoritative per-camera storage mode — the explicit choice the
7468
+ * UI reads directly (never inferred from `rules`):
7399
7469
  * - `off` — not recording.
7400
7470
  * - `events` — record only around triggers (motion / audio threshold),
7401
7471
  * with pre/post-buffer.
@@ -9104,26 +9174,13 @@ DeviceType.Light, method(object({
9104
9174
  percentage: number().min(0).max(100),
9105
9175
  lastChangedAt: number()
9106
9176
  });
9177
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
9107
9178
  var StreamFormatSchema = _enum([
9108
9179
  "webrtc",
9109
9180
  "hls",
9110
9181
  "mjpeg",
9111
9182
  "rtsp"
9112
9183
  ]);
9113
- var StreamInfoSchema = object({
9114
- streamId: string(),
9115
- format: StreamFormatSchema,
9116
- url: string().nullable(),
9117
- active: boolean()
9118
- });
9119
- method(object({
9120
- streamId: string(),
9121
- sourceUrl: string(),
9122
- codec: string().optional()
9123
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
9124
- streamId: string(),
9125
- format: StreamFormatSchema
9126
- }), string().nullable()), method(_void(), array(StreamInfoSchema));
9127
9184
  var RtspRestreamEntrySchema = object({
9128
9185
  brokerId: string(),
9129
9186
  url: string(),
@@ -9788,7 +9845,7 @@ var ConsumablesStatusSchema = object({
9788
9845
  })),
9789
9846
  lastChangedAt: number()
9790
9847
  });
9791
- 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({
9848
+ Object.values(DeviceType), method(object({
9792
9849
  deviceId: number().int().nonnegative(),
9793
9850
  key: string().min(1)
9794
9851
  }), _void(), {
@@ -10838,7 +10895,6 @@ var PipelineDefaultStepSchema = lazy(() => object({
10838
10895
  enabled: boolean(),
10839
10896
  modelId: string(),
10840
10897
  children: array(PipelineDefaultStepSchema).readonly(),
10841
- engine: PipelineEngineChoiceSchema.optional(),
10842
10898
  group: string().optional(),
10843
10899
  settings: record(string(), unknown()).optional()
10844
10900
  }));
@@ -10863,7 +10919,9 @@ var PipelineModelOptionSchema = object({
10863
10919
  formats: record(string(), object({
10864
10920
  downloaded: boolean(),
10865
10921
  sizeMB: number()
10866
- }))
10922
+ })),
10923
+ group: ModelVariantGroupSchema.optional(),
10924
+ legacy: boolean().optional()
10867
10925
  });
10868
10926
  var ConfigFieldBridge = custom();
10869
10927
  var PipelineAddonSchemaSchema = object({
@@ -10914,15 +10972,42 @@ var EngineProvisioningSchema = object({
10914
10972
  ]),
10915
10973
  progress: number().optional(),
10916
10974
  error: string().optional(),
10917
- nextRetryAt: number().optional()
10975
+ nextRetryAt: number().optional(),
10976
+ /**
10977
+ * Gate A (config-correctness gate at engine change): human-readable
10978
+ * config issues surfaced EAGERLY when the node's engine changes — model
10979
+ * substitutions ("chose X, running Y") and zero-build steps ("no model
10980
+ * has a <format> build"). Additive/optional: informational only, never
10981
+ * enforced here — `assertEngineReady` (readiness) still gates inference.
10982
+ * Absent/empty when the node-default tree resolves cleanly.
10983
+ */
10984
+ configIssues: array(string()).optional()
10918
10985
  });
10919
10986
  var PipelineStepInputSchema = lazy(() => object({
10920
10987
  addonId: string(),
10921
- modelId: string(),
10988
+ modelId: string().optional(),
10922
10989
  enabled: boolean().default(true),
10923
10990
  children: array(PipelineStepInputSchema).optional(),
10924
10991
  settings: record(string(), unknown()).optional()
10925
10992
  }));
10993
+ var ModelSubstitutionSchema = object({
10994
+ addonId: string(),
10995
+ chosen: string(),
10996
+ running: string(),
10997
+ format: string()
10998
+ });
10999
+ var PipelineValidationIssueSchema = object({
11000
+ addonId: string(),
11001
+ kind: _enum(["unknown-addon", "no-format-build"]),
11002
+ detail: string()
11003
+ });
11004
+ var PipelineValidationResultSchema = object({
11005
+ ok: boolean(),
11006
+ issues: array(PipelineValidationIssueSchema).readonly(),
11007
+ substitutions: array(ModelSubstitutionSchema).readonly(),
11008
+ /** The node's `currentEngine.format` this validation ran against. */
11009
+ format: string()
11010
+ });
10926
11011
  var ReferenceImageEntrySchema = object({
10927
11012
  filename: string(),
10928
11013
  stepIds: array(string()).readonly().optional()
@@ -11039,6 +11124,19 @@ var pipelineExecutorCapability = {
11039
11124
  getGlobalSteps: method(_void(), array(PipelineDefaultStepSchema).readonly().nullable()),
11040
11125
  getGlobalPipelineConfig: method(_void(), PipelineConfigBridge),
11041
11126
  getOrchestratorConfigSchema: method(_void(), ConfigUISchemaBridge),
11127
+ /**
11128
+ * Gate B (pre-init config-correctness gate). PURE COMPUTE against this
11129
+ * node's `currentEngine.format` — resolves `steps` the same way the
11130
+ * runtime dispatch path would, and reports what WOULD happen without
11131
+ * touching any node-global state. Called by the orchestrator at attach
11132
+ * time (`attachOn`), node-pinned to the TARGET node, so config problems
11133
+ * surface BEFORE `pipelineRunner.attachCamera` rather than at the first
11134
+ * per-frame resolve. `ok` is false iff `issues` is non-empty (both
11135
+ * `unknown-addon` and `no-format-build` are HARD issues); `substitutions`
11136
+ * is informational (a degraded-but-loadable model swap) and never
11137
+ * affects `ok`. Never throws.
11138
+ */
11139
+ validatePipeline: method(object({ steps: array(PipelineStepInputSchema) }), PipelineValidationResultSchema),
11042
11140
  listTemplates: method(_void(), array(PipelineTemplateSchema$1).readonly()),
11043
11141
  saveTemplate: method(object({
11044
11142
  name: string(),
@@ -11421,6 +11519,13 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
11421
11519
  kind: literal("remote-restream"),
11422
11520
  /** The camera's source-owner node (slice 1: always the hub). */
11423
11521
  ownerNodeId: string(),
11522
+ /**
11523
+ * The owner's LAN-reachable host, resolved by the orchestrator from the
11524
+ * per-node `reachableHost` override (Cluster UI). When present the runner
11525
+ * dials THIS host for the owner's restream, in preference to the
11526
+ * `CAMSTACK_HUB_URL`-derived default. Absent → auto-detect fallback.
11527
+ */
11528
+ ownerReachableHost: string().optional(),
11424
11529
  /** Operator override for the owner host the runner dials. */
11425
11530
  hubHostnameOverride: string().optional()
11426
11531
  })]).describe("Per-camera frame-source mode for the runner (P2c)");
@@ -11429,13 +11534,11 @@ var RunnerFrameSourceSchema = discriminatedUnion("kind", [object({ kind: literal
11429
11534
  * specific runner instance via `attachCamera`. Carries everything the
11430
11535
  * runner needs to subscribe to the local broker and execute inference.
11431
11536
  *
11432
- * Stateless-pipeline model: the full pipeline content (`engine`, `steps`,
11433
- * optional `audio`) travels with the attach payload. The runner keeps it
11434
- * in RAM for the lifetime of the attach — on rebalance, edit, or
11435
- * restart the orchestrator re-sends the latest snapshot.
11436
- *
11437
- * `engine`/`steps`/`audio` are optional during the additive migration
11438
- * window; once orchestrator + UI are migrated they become required.
11537
+ * Stateless-pipeline model: the pipeline content (`steps`, optional
11538
+ * `audio`) travels with the attach payload. The runner keeps it in RAM
11539
+ * for the lifetime of the attach — on rebalance, edit, or restart the
11540
+ * orchestrator re-sends the latest snapshot. Engine is NOT carried: it is
11541
+ * node-local, resolved by the executing runner at dispatch time.
11439
11542
  */
11440
11543
  var RunnerCameraConfigSchema = object({
11441
11544
  deviceId: number(),
@@ -11486,14 +11589,11 @@ var RunnerCameraConfigSchema = object({
11486
11589
  */
11487
11590
  motionSources: MotionSourcesSchema.default(["analyzer"]),
11488
11591
  pipelineEnabled: boolean().default(true),
11489
- /** Engine choice for video steps (runtime+backend+format). */
11490
- engine: PipelineEngineChoiceSchema.optional(),
11491
11592
  /** Ordered tree of video steps. Absent → runner skips video detection. */
11492
11593
  steps: array(PipelineStepInputSchema).readonly().optional(),
11493
11594
  /** Audio classification branch. `enabled:false` disables, null skips. */
11494
11595
  audio: object({
11495
- engine: PipelineEngineChoiceSchema,
11496
- modelId: string(),
11596
+ modelId: string().optional(),
11497
11597
  enabled: boolean()
11498
11598
  }).nullable().optional(),
11499
11599
  /**
@@ -15949,11 +16049,11 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15949
16049
  timestamp: number()
15950
16050
  });
15951
16051
  var CameraPipelineConfigSchema = object({
15952
- engine: PipelineEngineChoiceSchema,
16052
+ engine: PipelineEngineChoiceSchema.optional(),
15953
16053
  steps: array(PipelineStepInputSchema).readonly(),
15954
16054
  audio: object({
15955
- engine: PipelineEngineChoiceSchema,
15956
- modelId: string(),
16055
+ engine: PipelineEngineChoiceSchema.optional(),
16056
+ modelId: string().optional(),
15957
16057
  enabled: boolean(),
15958
16058
  settings: record(string(), unknown()).readonly().optional()
15959
16059
  }).nullable().optional()
@@ -15968,7 +16068,7 @@ var PipelineTemplateSchema = object({
15968
16068
  });
15969
16069
  var AgentAddonConfigSchema = object({
15970
16070
  enabled: boolean(),
15971
- modelId: string(),
16071
+ modelId: string().optional(),
15972
16072
  settings: record(string(), unknown()).readonly()
15973
16073
  });
15974
16074
  var AgentPipelineSettingsSchema = object({
@@ -15983,7 +16083,15 @@ var AgentPipelineSettingsSchema = object({
15983
16083
  /** Node is eligible to run audio-analyzer sessions. */
15984
16084
  audio: boolean().optional(),
15985
16085
  /** Node is eligible to be the ingest / source-owner (serve the restream). */
15986
- ingest: boolean().optional()
16086
+ ingest: boolean().optional(),
16087
+ /**
16088
+ * Operator override for the LAN host a cross-node decoder dials to reach
16089
+ * THIS node's restream (Cluster UI). Absent → auto-detect: a remote runner
16090
+ * falls back to its `CAMSTACK_HUB_URL`-derived host (the Moleculer address
16091
+ * it already uses to reach the hub). Set this only when the auto-detected
16092
+ * address is wrong (multi-homed host, NAT, custom interface).
16093
+ */
16094
+ reachableHost: string().optional()
15987
16095
  });
15988
16096
  var CameraPipelineForAgentSchema = object({
15989
16097
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16089,6 +16197,15 @@ var GlobalMetricsSchema = object({
16089
16197
  * capability providers.
16090
16198
  */
16091
16199
  var CapabilityBindingsSchema = record(string(), string());
16200
+ /**
16201
+ * The cluster's single camera-source owner (`clusterRoles.ingestNode`) plus
16202
+ * its LAN-reachable host, if one is registered. See `getIngestOwner`.
16203
+ */
16204
+ var IngestOwnerSchema = object({
16205
+ ownerNodeId: string(),
16206
+ reachableHost: string().optional(),
16207
+ configIssue: string().optional()
16208
+ });
16092
16209
  /** Source block — always present; derives from the stream catalog. */
16093
16210
  var CameraSourceStatusSchema = object({ streams: array(object({
16094
16211
  camStreamId: string(),
@@ -16103,6 +16220,14 @@ var CameraAssignmentStatusSchema = object({
16103
16220
  detectionNodeId: string().nullable(),
16104
16221
  decoderNodeId: string().nullable(),
16105
16222
  audioNodeId: string().nullable(),
16223
+ /**
16224
+ * The node that OWNS this camera's physical source pull (dials the RTSP and
16225
+ * hosts the broker/restream) — the cluster ingest owner today
16226
+ * (`clusterRoles.ingestNode`), per-camera once source assignment lands. Lets
16227
+ * the UI show WHERE a camera is sourced without SSH/logs, and is the node the
16228
+ * broker block below was read from (pinned). Nullable only pre-wiring.
16229
+ */
16230
+ sourceNodeId: string().nullable(),
16106
16231
  pinned: object({
16107
16232
  detection: boolean(),
16108
16233
  decoder: boolean(),
@@ -16303,6 +16428,14 @@ var pipelineOrchestratorCapability = {
16303
16428
  kind: "mutation",
16304
16429
  auth: "admin"
16305
16430
  }),
16431
+ /**
16432
+ * The cluster's single camera-source owner — resolved UNCONDITIONALLY
16433
+ * from `clusterRoles.ingestNode` (NOT the detection-decode
16434
+ * `remoteSourcingNodes` knob). Consumers (recorder/snapshot/audio) pin
16435
+ * their broker calls to `ownerNodeId` and dial `reachableHost` when
16436
+ * present. Defaults to `{ ownerNodeId: 'hub' }`.
16437
+ */
16438
+ getIngestOwner: method(_void(), IngestOwnerSchema),
16306
16439
  /** Pin a device's decoder to a specific node. */
16307
16440
  assignDecoder: method(object({
16308
16441
  deviceId: number(),
@@ -16449,6 +16582,22 @@ var pipelineOrchestratorCapability = {
16449
16582
  kind: "mutation",
16450
16583
  auth: "admin"
16451
16584
  }),
16585
+ /**
16586
+ * Set (or clear) the operator override for the LAN host a cross-node
16587
+ * decoder dials to reach this node's restream. Pass a non-empty string to
16588
+ * override the auto-detected address (the runner's `CAMSTACK_HUB_URL`
16589
+ * default); pass `null` or an empty string to clear it and revert to
16590
+ * auto-detect. Applied on the next dispatch cycle — takes effect for new
16591
+ * cross-node attaches; existing attaches keep their current host until
16592
+ * re-dispatched.
16593
+ */
16594
+ setAgentReachableHost: method(object({
16595
+ agentNodeId: string(),
16596
+ reachableHost: string().nullable()
16597
+ }), object({ success: literal(true) }), {
16598
+ kind: "mutation",
16599
+ auth: "admin"
16600
+ }),
16452
16601
  /** Read one camera's settings. Null when never touched (inherits agent defaults fully). */
16453
16602
  getCameraSettings: method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()),
16454
16603
  /** Set or clear the 3-state toggle for one (camera, addonId). Pass `enabled: null` to clear and revert to agent default. */
@@ -16540,22 +16689,6 @@ var pipelineOrchestratorCapability = {
16540
16689
  })
16541
16690
  }
16542
16691
  };
16543
- var RegisteredStreamSchema = object({
16544
- streamId: string(),
16545
- label: string().optional(),
16546
- codec: string(),
16547
- type: _enum(["video", "audio"]),
16548
- sourceUrl: string()
16549
- });
16550
- var ExposedResourceSchema = object({
16551
- streamId: string(),
16552
- format: string(),
16553
- value: string()
16554
- });
16555
- method(object({
16556
- deviceId: number(),
16557
- streams: array(RegisteredStreamSchema).readonly()
16558
- }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), array(ExposedResourceSchema).readonly());
16559
16692
  /**
16560
16693
  * Query filter for settings-store collections.
16561
16694
  */
@@ -16708,9 +16841,9 @@ method(SendEmailInputSchema, SendEmailResultSchema, {
16708
16841
  /**
16709
16842
  * A single device snapshot returned as base64 JPEG/PNG.
16710
16843
  *
16711
- * Shared with the `snapshot-provider` collection cap the orchestrator
16712
- * receives the same shape from each native provider and from the
16713
- * broker-based fallback.
16844
+ * The `SnapshotAddon` wrapper returns this shape whether the frame came from
16845
+ * the device-native provider (onboard capture) or from the stream-broker
16846
+ * prebuffer fallback.
16714
16847
  */
16715
16848
  var SnapshotImageSchema = object({
16716
16849
  base64: string(),
@@ -16742,10 +16875,6 @@ DeviceType.Camera, method(object({
16742
16875
  kind: "mutation",
16743
16876
  auth: "admin"
16744
16877
  });
16745
- method(object({ deviceId: number() }), boolean()), method(object({
16746
- deviceId: number(),
16747
- streamId: string().optional()
16748
- }), SnapshotImageSchema.nullable());
16749
16878
  /**
16750
16879
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
16751
16880
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -17106,9 +17235,10 @@ method(object({
17106
17235
  auth: "admin"
17107
17236
  });
17108
17237
  /**
17109
- * Optional client-side hints sent at session creation to help the
17110
- * provider pick the best native source. All fields are optional —
17111
- * a viewer that knows nothing still gets a sane default.
17238
+ * Optional client-side hints sent at session creation to help the provider
17239
+ * pick the best native source. All fields optional — a viewer that knows
17240
+ * nothing still gets a sane default. (Relocated from the retired `webrtc`
17241
+ * collection cap; this `webrtc-session` cap is the live signaling surface.)
17112
17242
  */
17113
17243
  var webrtcClientHintsSchema = object({
17114
17244
  viewportWidth: number().int().positive().optional(),
@@ -17119,22 +17249,6 @@ var webrtcClientHintsSchema = object({
17119
17249
  /** Hard tier override; takes precedence over scoring when registered. */
17120
17250
  prefersTier: string().optional()
17121
17251
  }).partial();
17122
- method(object({
17123
- streamId: string(),
17124
- sdpOffer: string()
17125
- }), string(), { kind: "mutation" }), method(object({ streamId: string() }), boolean()), method(object({
17126
- streamId: string(),
17127
- codec: string()
17128
- }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), _void(), { kind: "mutation" }), method(object({
17129
- streamId: string(),
17130
- hints: webrtcClientHintsSchema.optional()
17131
- }), object({
17132
- sessionId: string(),
17133
- sdpOffer: string()
17134
- }), { kind: "mutation" }), method(object({
17135
- sessionId: string(),
17136
- sdpAnswer: string()
17137
- }), _void(), { kind: "mutation" }), method(object({ sessionId: string() }), _void(), { kind: "mutation" }), method(object({ streamId: string() }), boolean());
17138
17252
  /**
17139
17253
  * Discriminated target for a WebRTC session. The client sends this
17140
17254
  * structured object instead of building / parsing brokerId strings;
@@ -22225,6 +22339,12 @@ Object.freeze({
22225
22339
  addonId: null,
22226
22340
  access: "create"
22227
22341
  },
22342
+ "pipelineExecutor.validatePipeline": {
22343
+ capName: "pipeline-executor",
22344
+ capScope: "system",
22345
+ addonId: null,
22346
+ access: "view"
22347
+ },
22228
22348
  "pipelineOrchestrator.assignAudio": {
22229
22349
  capName: "pipeline-orchestrator",
22230
22350
  capScope: "system",
@@ -22333,6 +22453,12 @@ Object.freeze({
22333
22453
  addonId: null,
22334
22454
  access: "view"
22335
22455
  },
22456
+ "pipelineOrchestrator.getIngestOwner": {
22457
+ capName: "pipeline-orchestrator",
22458
+ capScope: "system",
22459
+ addonId: null,
22460
+ access: "view"
22461
+ },
22336
22462
  "pipelineOrchestrator.getPipelineAssignment": {
22337
22463
  capName: "pipeline-orchestrator",
22338
22464
  capScope: "system",
@@ -22405,6 +22531,12 @@ Object.freeze({
22405
22531
  addonId: null,
22406
22532
  access: "create"
22407
22533
  },
22534
+ "pipelineOrchestrator.setAgentReachableHost": {
22535
+ capName: "pipeline-orchestrator",
22536
+ capScope: "system",
22537
+ addonId: null,
22538
+ access: "create"
22539
+ },
22408
22540
  "pipelineOrchestrator.setCameraPipelineForAgent": {
22409
22541
  capName: "pipeline-orchestrator",
22410
22542
  capScope: "system",
@@ -22735,24 +22867,6 @@ Object.freeze({
22735
22867
  addonId: null,
22736
22868
  access: "create"
22737
22869
  },
22738
- "restreamer.getExposedResources": {
22739
- capName: "restreamer",
22740
- capScope: "system",
22741
- addonId: null,
22742
- access: "view"
22743
- },
22744
- "restreamer.registerDevice": {
22745
- capName: "restreamer",
22746
- capScope: "system",
22747
- addonId: null,
22748
- access: "create"
22749
- },
22750
- "restreamer.unregisterDevice": {
22751
- capName: "restreamer",
22752
- capScope: "system",
22753
- addonId: null,
22754
- access: "delete"
22755
- },
22756
22870
  "scriptRunner.run": {
22757
22871
  capName: "script-runner",
22758
22872
  capScope: "device",
@@ -22855,18 +22969,6 @@ Object.freeze({
22855
22969
  addonId: null,
22856
22970
  access: "create"
22857
22971
  },
22858
- "snapshotProvider.getSnapshot": {
22859
- capName: "snapshot-provider",
22860
- capScope: "system",
22861
- addonId: null,
22862
- access: "view"
22863
- },
22864
- "snapshotProvider.supportsDevice": {
22865
- capName: "snapshot-provider",
22866
- capScope: "system",
22867
- addonId: null,
22868
- access: "view"
22869
- },
22870
22972
  "ssoBridge.signBridgeToken": {
22871
22973
  capName: "sso-bridge",
22872
22974
  capScope: "system",
@@ -23293,30 +23395,6 @@ Object.freeze({
23293
23395
  addonId: null,
23294
23396
  access: "view"
23295
23397
  },
23296
- "streamingEngine.getStreamUrl": {
23297
- capName: "streaming-engine",
23298
- capScope: "system",
23299
- addonId: null,
23300
- access: "view"
23301
- },
23302
- "streamingEngine.listStreams": {
23303
- capName: "streaming-engine",
23304
- capScope: "system",
23305
- addonId: null,
23306
- access: "view"
23307
- },
23308
- "streamingEngine.registerStream": {
23309
- capName: "streaming-engine",
23310
- capScope: "system",
23311
- addonId: null,
23312
- access: "create"
23313
- },
23314
- "streamingEngine.unregisterStream": {
23315
- capName: "streaming-engine",
23316
- capScope: "system",
23317
- addonId: null,
23318
- access: "delete"
23319
- },
23320
23398
  "streamParams.getConfigSchema": {
23321
23399
  capName: "stream-params",
23322
23400
  capScope: "device",
@@ -23683,54 +23761,6 @@ Object.freeze({
23683
23761
  addonId: null,
23684
23762
  access: "create"
23685
23763
  },
23686
- "webrtc.closeSession": {
23687
- capName: "webrtc",
23688
- capScope: "system",
23689
- addonId: null,
23690
- access: "create"
23691
- },
23692
- "webrtc.createSession": {
23693
- capName: "webrtc",
23694
- capScope: "system",
23695
- addonId: null,
23696
- access: "create"
23697
- },
23698
- "webrtc.handleAnswer": {
23699
- capName: "webrtc",
23700
- capScope: "system",
23701
- addonId: null,
23702
- access: "create"
23703
- },
23704
- "webrtc.handleOffer": {
23705
- capName: "webrtc",
23706
- capScope: "system",
23707
- addonId: null,
23708
- access: "create"
23709
- },
23710
- "webrtc.hasAdaptiveBitrate": {
23711
- capName: "webrtc",
23712
- capScope: "system",
23713
- addonId: null,
23714
- access: "view"
23715
- },
23716
- "webrtc.registerStream": {
23717
- capName: "webrtc",
23718
- capScope: "system",
23719
- addonId: null,
23720
- access: "create"
23721
- },
23722
- "webrtc.supportsStream": {
23723
- capName: "webrtc",
23724
- capScope: "system",
23725
- addonId: null,
23726
- access: "view"
23727
- },
23728
- "webrtc.unregisterStream": {
23729
- capName: "webrtc",
23730
- capScope: "system",
23731
- addonId: null,
23732
- access: "delete"
23733
- },
23734
23764
  "webrtcSession.addIceCandidate": {
23735
23765
  capName: "webrtc-session",
23736
23766
  capScope: "device",
@@ -23893,18 +23923,21 @@ function applyStepPatch(base, patch) {
23893
23923
  /**
23894
23924
  * Seed an agent's addonDefaults map from the catalog. For each addonId
23895
23925
  * advertised by any slot, populate a sensible entry:
23896
- * - `modelId` = addon's `defaultModelIdByFormat[engine.format]` if
23897
- * present, else `defaultModelId` (from StepDefinition).
23898
23926
  * - `enabled` = addon's `enabledByDefault` (absent = true).
23899
23927
  * - `settings` = catalog's default settings (empty for now — the
23900
23928
  * runtime applies step-level defaults when settings[key] is absent).
23901
23929
  *
23930
+ * `modelId` is intentionally ABSENT on a fresh entry — model selection is
23931
+ * data only now. When the operator hasn't explicitly chosen a model, the
23932
+ * step carries no `modelId` and the executing NODE resolves its own
23933
+ * format default at runtime (the resolver has no notion of engines).
23934
+ *
23902
23935
  * Entries already present in `current` are LEFT ALONE — operators who
23903
23936
  * customised a model keep their choice after catalog growth adds new
23904
23937
  * addons. Entries for addonIds that no longer exist in the catalog are
23905
23938
  * dropped (the addon was removed; its config is dead weight).
23906
23939
  */
23907
- function seedAgentAddonDefaults(current, engine, catalog) {
23940
+ function seedAgentAddonDefaults(current, catalog) {
23908
23941
  const next = {};
23909
23942
  const liveAddonIds = /* @__PURE__ */ new Set();
23910
23943
  for (const slot of catalog.slots) for (const addon of slot.addons) {
@@ -23915,7 +23948,6 @@ function seedAgentAddonDefaults(current, engine, catalog) {
23915
23948
  }
23916
23949
  next[addon.id] = {
23917
23950
  enabled: addon.enabledByDefault ?? true,
23918
- modelId: pickDefaultModelId(addon, engine.format),
23919
23951
  settings: {}
23920
23952
  };
23921
23953
  }
@@ -23923,27 +23955,11 @@ function seedAgentAddonDefaults(current, engine, catalog) {
23923
23955
  return next;
23924
23956
  }
23925
23957
  /**
23926
- * Pick the best model ID for `(addon, agent.format)`:
23927
- * 1. `defaultModelIdByFormat[agent.format]` — explicit per-format choice
23928
- * 2. `defaultModelId` from the StepDefinition — format-agnostic default
23929
- * 3. First model in `addon.models[]` that has `formats[agent.format]` — desperate fallback
23930
- *
23931
- * Returns the first candidate whose model exists in the catalog entry's
23932
- * `formats` map for the target engine format. If no candidate matches,
23933
- * falls through to `addon.defaultModelId` (even if incompatible — let
23934
- * the runtime error explicitly rather than silently substitute).
23935
- */
23936
- function pickDefaultModelId(addon, format) {
23937
- const byFormat = addon.defaultModelIdByFormat?.[format];
23938
- if (byFormat && addon.models.some((m) => m.id === byFormat)) return byFormat;
23939
- if (addon.models.some((m) => m.id === addon.defaultModelId && m.formats?.[format])) return addon.defaultModelId;
23940
- const compat = addon.models.find((m) => m.formats?.[format]);
23941
- if (compat) return compat.id;
23942
- return addon.defaultModelId;
23943
- }
23944
- /**
23945
23958
  * Main resolver — produces the `CameraPipelineConfig` the runner should
23946
23959
  * receive for `(deviceId, agent)`. Pure: zero I/O, zero side effects.
23960
+ * DATA ONLY: this resolver has no notion of engines. A step carries a
23961
+ * `modelId` only when the operator explicitly chose one; when absent,
23962
+ * the executing node resolves its own format default at runtime.
23947
23963
  *
23948
23964
  * Step chain:
23949
23965
  * 1. `cameraSettings.pipelineByAgent[agentNodeId]` → wholesale override
@@ -23951,16 +23967,14 @@ function pickDefaultModelId(addon, format) {
23951
23967
  *
23952
23968
  * "Inference disabled for this camera" is NOT handled here — the caller
23953
23969
  * (resolvePipelineForDevice) consults the detection-pipeline binding and
23954
- * short-circuits to `emptyPipeline(engine)` before calling this resolver.
23970
+ * short-circuits to an empty pipeline before calling this resolver.
23955
23971
  */
23956
- function resolvePipeline(agentSettings, cameraSettings, agentNodeId, engine, catalog) {
23972
+ function resolvePipeline(agentSettings, cameraSettings, agentNodeId, catalog) {
23957
23973
  const cam = cameraSettings ?? {};
23958
23974
  const wholesale = cam.pipelineByAgent?.[agentNodeId];
23959
23975
  if (wholesale) return {
23960
- engine,
23961
23976
  steps: wholesale.steps,
23962
23977
  audio: wholesale.audio ? {
23963
- engine,
23964
23978
  modelId: wholesale.audio.modelId,
23965
23979
  enabled: wholesale.audio.enabled
23966
23980
  } : null
@@ -23982,11 +23996,9 @@ function resolvePipeline(agentSettings, cameraSettings, agentNodeId, engine, cat
23982
23996
  for (const [addonId, override] of Object.entries(toggles)) {
23983
23997
  if (override !== true) continue;
23984
23998
  if (addonDefaults[addonId]) continue;
23985
- const catalogAddon = findInCatalog(catalog, addonId);
23986
- if (!catalogAddon) continue;
23999
+ if (!findInCatalog(catalog, addonId)) continue;
23987
24000
  const patched = applyStepPatch({
23988
24001
  enabled: true,
23989
- modelId: pickDefaultModelId(catalogAddon, engine.format),
23990
24002
  settings: {}
23991
24003
  }, overridesForAgent[addonId]);
23992
24004
  if (!patched.enabled) continue;
@@ -24004,10 +24016,8 @@ function resolvePipeline(agentSettings, cameraSettings, agentNodeId, engine, cat
24004
24016
  else videoAddons.push(entry);
24005
24017
  }
24006
24018
  return {
24007
- engine,
24008
24019
  steps: buildTreeFromAddons(videoAddons, catalog),
24009
24020
  audio: audioAddons.length > 0 ? {
24010
- engine,
24011
24021
  modelId: audioAddons[0].cfg.modelId,
24012
24022
  enabled: true,
24013
24023
  ...Object.keys(audioAddons[0].cfg.settings).length > 0 ? { settings: audioAddons[0].cfg.settings } : {}
@@ -24045,9 +24055,9 @@ function buildTreeFromAddons(enabled, catalog) {
24045
24055
  metaById.set(addonId, meta);
24046
24056
  nodes.set(addonId, {
24047
24057
  addonId,
24048
- modelId: cfg.modelId,
24049
24058
  enabled: true,
24050
24059
  children: [],
24060
+ ...cfg.modelId ? { modelId: cfg.modelId } : {},
24051
24061
  ...Object.keys(cfg.settings).length > 0 ? { settings: cfg.settings } : {}
24052
24062
  });
24053
24063
  }
@@ -24155,7 +24165,7 @@ function startAudioChunkPoller(options) {
24155
24165
  const subId = lifecycle.activeSubscriptionId;
24156
24166
  if (subId) {
24157
24167
  lifecycle.activeSubscriptionId = null;
24158
- options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }).catch((err) => {
24168
+ options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }, nodePin(options.ownerNodeId)).catch((err) => {
24159
24169
  options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
24160
24170
  brokerId: options.brokerId,
24161
24171
  subscriptionId: subId,
@@ -24173,7 +24183,8 @@ function startAudioChunkPoller(options) {
24173
24183
  * been started) or once `lifecycle.stopped` flips, whichever comes first.
24174
24184
  */
24175
24185
  async function subscribeWithRetry(options, lifecycle) {
24176
- const { api, brokerId, tag, logger } = options;
24186
+ const { api, brokerId, tag, ownerNodeId, logger } = options;
24187
+ const pin = nodePin(ownerNodeId);
24177
24188
  let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
24178
24189
  let attempt = 0;
24179
24190
  while (!lifecycle.stopped) {
@@ -24182,9 +24193,9 @@ async function subscribeWithRetry(options, lifecycle) {
24182
24193
  const result = await api.streamBroker.subscribeAudioChunks.mutate({
24183
24194
  brokerId,
24184
24195
  tag
24185
- });
24196
+ }, pin);
24186
24197
  if (lifecycle.stopped) {
24187
- await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
24198
+ await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }, pin).catch((err) => {
24188
24199
  logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
24189
24200
  brokerId,
24190
24201
  subscriptionId: result.subscriptionId,
@@ -24228,14 +24239,15 @@ async function subscribeWithRetry(options, lifecycle) {
24228
24239
  * disowned.
24229
24240
  */
24230
24241
  function startPolling(options, lifecycle) {
24231
- const { api, brokerId, tag, onChunk, logger } = options;
24242
+ const { api, brokerId, tag, ownerNodeId, onChunk, logger } = options;
24243
+ const pin = nodePin(ownerNodeId);
24232
24244
  let consecutiveFailures = 0;
24233
24245
  const resubscribe = async () => {
24234
24246
  try {
24235
24247
  const result = await api.streamBroker.subscribeAudioChunks.mutate({
24236
24248
  brokerId,
24237
24249
  tag
24238
- });
24250
+ }, pin);
24239
24251
  lifecycle.activeSubscriptionId = result.subscriptionId;
24240
24252
  logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
24241
24253
  brokerId,
@@ -24256,7 +24268,7 @@ function startPolling(options, lifecycle) {
24256
24268
  const chunks = await api.streamBroker.pullAudioChunks.query({
24257
24269
  subscriptionId: subId,
24258
24270
  maxCount: PULL_MAX_COUNT
24259
- });
24271
+ }, pin);
24260
24272
  if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
24261
24273
  brokerId,
24262
24274
  subscriptionId: subId
@@ -24321,6 +24333,7 @@ function mapAssignment(input) {
24321
24333
  detectionNodeId: input.detectionNodeId,
24322
24334
  decoderNodeId: input.decoderNodeId,
24323
24335
  audioNodeId: input.audioNodeId,
24336
+ sourceNodeId: input.sourceNodeId,
24324
24337
  pinned: {
24325
24338
  detection: input.pinned.detection,
24326
24339
  decoder: input.pinned.decoder,
@@ -24494,14 +24507,58 @@ function computeFrameSourceNodes(input) {
24494
24507
  return enabledDecoderNodes.filter((nodeId) => nodeId === sourceOwnerNodeId || remote.includes(nodeId));
24495
24508
  }
24496
24509
  //#endregion
24510
+ //#region src/ingest-owner.ts
24511
+ /**
24512
+ * The cluster's single camera-source owner + its LAN-reachable host, resolved
24513
+ * UNCONDITIONALLY from `clusterRoles.ingestNode` (NOT `resolveSourceOwner`,
24514
+ * which is gated on the detection-decode `remoteSourcingNodes` knob). Consumers
24515
+ * (recorder/snapshot/audio) pin their broker calls to `ownerNodeId` and dial
24516
+ * `reachableHost`. Defaults to `{ ownerNodeId: 'hub' }` — byte-identical to
24517
+ * today's hub-only behavior.
24518
+ *
24519
+ * `configIssue` is a NON-FATAL guard: when the configured ingest owner is an
24520
+ * agent that's unreachable or not ingest-capable, it surfaces a human-readable
24521
+ * warning string. The hub is always assumed reachable and ingest-capable, so
24522
+ * `ownerNodeId === 'hub'` never produces a `configIssue`.
24523
+ */
24524
+ function resolveIngestOwner(clusterRoles, reachableHostByNode, ingestCapableNodes) {
24525
+ const ownerNodeId = clusterRoles.ingestNode;
24526
+ const reachableHost = reachableHostByNode.get(ownerNodeId);
24527
+ const configIssue = computeIngestConfigIssue(ownerNodeId, reachableHost, ingestCapableNodes);
24528
+ return {
24529
+ ownerNodeId,
24530
+ ...reachableHost !== void 0 ? { reachableHost } : {},
24531
+ ...configIssue !== void 0 ? { configIssue } : {}
24532
+ };
24533
+ }
24534
+ function computeIngestConfigIssue(ownerNodeId, reachableHost, ingestCapableNodes) {
24535
+ if (ownerNodeId === "hub") return void 0;
24536
+ if (reachableHost === void 0) return `ingest node '${ownerNodeId}' has no reachable host — set its reachableHost or cameras cannot be sourced from it`;
24537
+ if (!ingestCapableNodes.includes(ownerNodeId)) return `ingest node '${ownerNodeId}' is not ingest-capable — enable its ingest capability`;
24538
+ }
24539
+ //#endregion
24497
24540
  //#region src/source-owner.ts
24498
24541
  /**
24542
+ * The set of nodes allowed to run the REMOTE-SOURCE leg — every decode-enabled
24543
+ * node EXCEPT the camera-source OWNER (the node that dials the camera). Derived
24544
+ * from `enabledDecoderNodes`. When the owner is the hub this equals the old
24545
+ * hub-exclusion (byte-identical to Phase 1); when the owner is an agent the hub
24546
+ * itself becomes a valid remote-source target.
24547
+ *
24548
+ * Rollout-safe: when the owner is the only decode node the result is `[]`, which
24549
+ * keeps `resolveSourceOwner` unmodeled and every frame source `local-broker`.
24550
+ * Pure: preserves input order, never mutates.
24551
+ */
24552
+ function deriveRemoteSourcingNodes(enabledDecoderNodes, ownerNodeId) {
24553
+ return enabledDecoderNodes.filter((nodeId) => nodeId !== ownerNodeId);
24554
+ }
24555
+ /**
24499
24556
  * The node that owns a camera's source pull (dials the real RTSP, hosts its
24500
24557
  * broker), or `undefined` while ownership is unmodeled (rollout OFF).
24501
24558
  */
24502
24559
  function resolveSourceOwner(input) {
24503
24560
  if (input.remoteSourcingNodes.length === 0) return void 0;
24504
- return input.assignedOwner ?? input.hubNodeId;
24561
+ return input.assignedOwner ?? input.ownerNodeId;
24505
24562
  }
24506
24563
  /**
24507
24564
  * The `frameSource` the attach payload carries for a target node:
@@ -24516,13 +24573,14 @@ function resolveSourceOwner(input) {
24516
24573
  * degrade to the safe local path, never to an unauthorized cross-node pull.
24517
24574
  */
24518
24575
  function selectRunnerFrameSource(input) {
24519
- const { targetNodeId, sourceOwnerNodeId, remoteSourcingNodes, hubHostnameOverride } = input;
24576
+ const { targetNodeId, sourceOwnerNodeId, remoteSourcingNodes, ownerReachableHost, hubHostnameOverride } = input;
24520
24577
  if (sourceOwnerNodeId === void 0) return { kind: "local-broker" };
24521
24578
  if (targetNodeId === sourceOwnerNodeId) return { kind: "local-broker" };
24522
24579
  if (!remoteSourcingNodes.includes(targetNodeId)) return { kind: "local-broker" };
24523
24580
  return {
24524
24581
  kind: "remote-restream",
24525
24582
  ownerNodeId: sourceOwnerNodeId,
24583
+ ...ownerReachableHost !== void 0 ? { ownerReachableHost } : {},
24526
24584
  ...hubHostnameOverride !== void 0 ? { hubHostnameOverride } : {}
24527
24585
  };
24528
24586
  }
@@ -24530,16 +24588,29 @@ function selectRunnerFrameSource(input) {
24530
24588
  //#region src/load-balancer.ts
24531
24589
  /**
24532
24590
  * Compute the L2 capacity score for a runner node. Lower is better.
24533
- * The score is a weighted sum of the runner's active workload so the balancer
24534
- * prefers agents that are serving fewer cameras OR draining queues quickly.
24535
24591
  *
24536
- * Rationale:
24537
- * - `attachedCameras * avgInferenceFps` approximates the total inference rate
24538
- * the agent is currently sustaining (not just how many cameras are assigned).
24539
- * - `queueDepthTotal` penalises agents that are falling behind the frame feed.
24592
+ * The score is **committed standing load + real-time backpressure**:
24593
+ *
24594
+ * - `attachedCameras` the primary signal. Every attached camera is a
24595
+ * standing commitment: an `on-motion` camera costs zero while idle but
24596
+ * WILL fork a decode+inference session on its node the moment motion fires.
24597
+ * Counting *attachments* (not current inference activity) is what makes the
24598
+ * balancer honest about steady-state load and gives the per-node `weights`
24599
+ * sliders camera-count-share semantics.
24600
+ * - `queueDepthTotal` — real-time pressure: a node whose frame queues are
24601
+ * backing up is falling behind NOW and must be deprioritised beyond its
24602
+ * camera count.
24603
+ *
24604
+ * The previous formula multiplied `attachedCameras * avgInferenceFps`, which
24605
+ * scored a node carrying many IDLE on-motion cameras (fps 0) identically to an
24606
+ * empty node — the balancer then piled unpinned cameras onto an already-loaded
24607
+ * node until `maxCameras` bit. `avgInferenceFps` (in practice a mislabeled SUM
24608
+ * of per-camera fps) is deliberately dropped: transient inference activity is
24609
+ * not steady-state load, and a genuinely overloaded node surfaces through
24610
+ * `queueDepthTotal` instead.
24540
24611
  */
24541
24612
  function computeCapacityScore(load) {
24542
- return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
24613
+ return Math.max(load.attachedCameras, 0) + Math.max(load.queueDepthTotal, 0);
24543
24614
  }
24544
24615
  /**
24545
24616
  * Node detection weight, defaulting to 1 when absent/invalid. A higher weight
@@ -24559,6 +24630,18 @@ function weightedScore(load, weights) {
24559
24630
  return computeCapacityScore(load) / nodeWeight(load.nodeId, weights);
24560
24631
  }
24561
24632
  /**
24633
+ * Weighted-score improvement of moving one camera off `current` onto `target`
24634
+ * (both weight-adjusted). Positive = `target` is less loaded than `current`.
24635
+ *
24636
+ * Used by the auto-rebalance hysteresis: a migration is applied only when this
24637
+ * clears a margin (> 1), so equalizing a mere 1-camera gap — which would just
24638
+ * reverse the imbalance — is skipped and a periodic pass never churns an
24639
+ * already-balanced cluster.
24640
+ */
24641
+ function capacityImprovement(current, target, weights) {
24642
+ return weightedScore(current, weights) - weightedScore(target, weights);
24643
+ }
24644
+ /**
24562
24645
  * Returns true when the node has remaining capacity.
24563
24646
  * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
24564
24647
  * its `attachedCameras` count is strictly less than the cap.
@@ -24703,11 +24786,11 @@ var EngineChoiceSchema = object({
24703
24786
  });
24704
24787
  var NodeBindingsSchema = record(string(), record(string(), string()));
24705
24788
  var StoredPipelineConfigSchema = object({
24706
- engine: EngineChoiceSchema,
24789
+ engine: EngineChoiceSchema.optional(),
24707
24790
  steps: array(PipelineStepInputSchema).readonly(),
24708
24791
  audio: object({
24709
- engine: EngineChoiceSchema,
24710
- modelId: string(),
24792
+ engine: EngineChoiceSchema.optional(),
24793
+ modelId: string().optional(),
24711
24794
  enabled: boolean(),
24712
24795
  settings: record(string(), unknown()).readonly().optional()
24713
24796
  }).nullable().optional()
@@ -24723,7 +24806,7 @@ var StoredPipelineTemplateSchema = object({
24723
24806
  var TemplatesSchema = record(string(), StoredPipelineTemplateSchema);
24724
24807
  var StoredAgentAddonConfigSchema = object({
24725
24808
  enabled: boolean(),
24726
- modelId: string(),
24809
+ modelId: string().optional(),
24727
24810
  settings: record(string(), unknown()).readonly()
24728
24811
  });
24729
24812
  var StoredAgentPipelineSettingsSchema = object({
@@ -24733,7 +24816,8 @@ var StoredAgentPipelineSettingsSchema = object({
24733
24816
  detect: boolean().optional(),
24734
24817
  decode: boolean().optional(),
24735
24818
  audio: boolean().optional(),
24736
- ingest: boolean().optional()
24819
+ ingest: boolean().optional(),
24820
+ reachableHost: string().optional()
24737
24821
  });
24738
24822
  var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
24739
24823
  var StoredCameraStepOverridePatchSchema = object({
@@ -24744,7 +24828,7 @@ var StoredCameraStepOverridePatchSchema = object({
24744
24828
  var StoredCameraPipelineForAgentSchema = object({
24745
24829
  steps: array(PipelineStepInputSchema).readonly(),
24746
24830
  audio: object({
24747
- modelId: string(),
24831
+ modelId: string().optional(),
24748
24832
  enabled: boolean()
24749
24833
  }).nullable()
24750
24834
  });
@@ -24826,8 +24910,8 @@ var PipelineWatchdog = class {
24826
24910
  const now = this.deps.now();
24827
24911
  for (const cam of this.cameras.values()) {
24828
24912
  const { line, stalled, recoveries } = this.evaluate(cam, now);
24829
- if (stalled) this.deps.logger.warn(line);
24830
- else this.deps.logger.info(line);
24913
+ if (stalled) this.deps.logger.warn(line, { tags: { deviceId: cam.deviceId } });
24914
+ else this.deps.logger.info(line, { tags: { deviceId: cam.deviceId } });
24831
24915
  for (const r of recoveries) {
24832
24916
  const rt = this.state.get(cam.deviceId)?.get(r.stage);
24833
24917
  if (rt) {
@@ -25266,6 +25350,21 @@ var PENDING_RETRY_INTERVAL_MS = 6e4;
25266
25350
  /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
25267
25351
  var PENDING_RETRY_DEBOUNCE_MS = 2e3;
25268
25352
  /**
25353
+ * Periodic auto-rebalance sweep. New attaches are already load-balanced at
25354
+ * dispatch time; this corrects DRIFT that accumulates over time (uneven
25355
+ * detach, a node returning online, a weight change) so the steady-state
25356
+ * distribution tracks the per-node weights. Only runs with ≥2 enabled detect
25357
+ * nodes, and migrates under hysteresis so a balanced cluster is a no-op.
25358
+ */
25359
+ var AUTO_REBALANCE_INTERVAL_MS = 6e4;
25360
+ /**
25361
+ * Hysteresis margin (weight-adjusted cameras) for the periodic auto-rebalance:
25362
+ * migrate a camera only when its target node is at least this much less loaded
25363
+ * than its current node. > 1 so equalizing a single-camera gap (which would
25364
+ * only reverse the imbalance) is skipped — prevents periodic churn.
25365
+ */
25366
+ var AUTO_REBALANCE_MIN_IMPROVEMENT = 1.5;
25367
+ /**
25269
25368
  * Remote-assignment health loop (T7) bounded backoff. A remote camera flagged
25270
25369
  * as unhealthy (0-fps / metrics-stale) is re-placed at most
25271
25370
  * `REMOTE_HEALTH_MAX_ATTEMPTS` times inside a rolling `REMOTE_HEALTH_WINDOW_MS`
@@ -25303,6 +25402,7 @@ var OrchestratorDiagnosticsSchema = object({
25303
25402
  enabledNodes: array(string()),
25304
25403
  enabledDecoderNodes: array(string()),
25305
25404
  enabledAudioNodes: array(string()),
25405
+ enabledIngestNodes: array(string()),
25306
25406
  clusterRoles: object({
25307
25407
  ingestNode: string(),
25308
25408
  audioNode: string()
@@ -25448,21 +25548,38 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25448
25548
  enabledDecoderNodes = ["hub"];
25449
25549
  /**
25450
25550
  * Nodes allowed to run the REMOTE-SOURCE leg — dial a camera's source-owner
25451
- * restream over the LAN and decode it locally. Held for the source-owner
25452
- * plumbing (`source-owner.ts`) but currently ALWAYS EMPTY: the cross-node
25453
- * frame transport is a separate activation phase (placement-model §9 P1).
25454
- * With this empty, `resolveSourceOwner` returns `undefined` and every
25455
- * `selectRunnerFrameSource` emits `local-broker` — bit-identical to
25456
- * pre-Phase-2 behavior.
25551
+ * restream over the LAN and decode it locally. DERIVED (Phase-1 activation,
25552
+ * `deriveRemoteSourcingNodes`) from `enabledDecoderNodes` minus the hub, in
25553
+ * `refreshNodeCapabilities`: every decode-enabled AGENT is a remote-sourcing
25554
+ * node. When only the hub decodes this stays `[]`, so `resolveSourceOwner`
25555
+ * returns `undefined` and every `selectRunnerFrameSource` emits
25556
+ * `local-broker` — bit-identical to pre-activation (rollout-safe).
25457
25557
  */
25458
25558
  remoteSourcingNodes = [];
25459
25559
  /**
25560
+ * Per-node `reachableHost` override (the LAN host a cross-node decoder dials
25561
+ * to reach that node's restream), from `agentSettings[node].reachableHost`.
25562
+ * DERIVED alongside the enabled-node sets in {@link refreshNodeCapabilities}
25563
+ * so `attachOn` resolves the owner host synchronously (no per-attach durable
25564
+ * read). Absent entry → the runner auto-detects via `CAMSTACK_HUB_URL`.
25565
+ */
25566
+ reachableHostByNode = /* @__PURE__ */ new Map();
25567
+ /**
25460
25568
  * Hub-wide allow-list of node ids eligible to run audio-analyzer sessions.
25461
25569
  * DERIVED from the per-node capability store (`agentSettings[node].audio`).
25462
25570
  * Defaults to `['hub']`.
25463
25571
  */
25464
25572
  enabledAudioNodes = ["hub"];
25465
25573
  /**
25574
+ * Hub-wide allow-list of node ids eligible to run the INGEST leg (dial the
25575
+ * camera's real source, host its broker). DERIVED from the per-node
25576
+ * capability store (`agentSettings[node].ingest`), mirroring
25577
+ * `enabledAudioNodes`. Defaults to `['hub']`. Not yet consumed by any
25578
+ * placement logic — this field only makes the persisted `ingest` flag
25579
+ * readable for a later config guard.
25580
+ */
25581
+ enabledIngestNodes = ["hub"];
25582
+ /**
25466
25583
  * Cluster-wide singleton role assignments (placement-model §6). Each role
25467
25584
  * is the ONE node that serves it for every camera. Driven by the
25468
25585
  * `ingestNode` / `audioNode` node-selects in the addon global schema;
@@ -25570,6 +25687,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25570
25687
  pendingRetryInFlight = false;
25571
25688
  /** Set when a pending-retry is requested while one is already in-flight; triggers a follow-up pass. */
25572
25689
  pendingRetryRerunRequested = false;
25690
+ /** Periodic auto-rebalance sweep timer (drift correction under hysteresis). */
25691
+ autoRebalanceTimer = null;
25692
+ /** True while an auto-rebalance pass is running (skip overlap). */
25693
+ autoRebalanceInFlight = false;
25573
25694
  initTimestamp = 0;
25574
25695
  constructor() {
25575
25696
  super({});
@@ -25672,7 +25793,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25672
25793
  if (!isEvent(event, EventCategory.PipelineInferenceResult)) return;
25673
25794
  this.handleInferenceResult(event.data).catch((err) => {
25674
25795
  const msg = errMsg(err);
25675
- this.ctx.logger.error("PipelineInferenceResult post-processing failed", { meta: { error: msg } });
25796
+ this.ctx.logger.error("PipelineInferenceResult post-processing failed", {
25797
+ tags: { deviceId: event.data.deviceId },
25798
+ meta: { error: msg }
25799
+ });
25676
25800
  });
25677
25801
  });
25678
25802
  const PROFILE_SLOTS_DEBOUNCE_MS = 200;
@@ -25788,6 +25912,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25788
25912
  });
25789
25913
  this.pipelineWatchdog.start(PipelineOrchestratorAddon.WATCHDOG_INTERVAL_MS);
25790
25914
  this.pendingRetryTimer = setInterval(() => void this.retryPendingDispatches(), PENDING_RETRY_INTERVAL_MS);
25915
+ this.autoRebalanceTimer = setInterval(() => void this.runAutoRebalance(), AUTO_REBALANCE_INTERVAL_MS);
25791
25916
  this.ctx.logger.info("Pipeline orchestrator detection-wiring subscriptions installed");
25792
25917
  this.migrateLegacyFlagsToBindings().catch((err) => {
25793
25918
  this.ctxIfReady?.logger.warn("bindings migration failed — will retry on next boot", { meta: { error: errMsg(err) } });
@@ -25998,6 +26123,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25998
26123
  enabledNodes: [...this.enabledNodes],
25999
26124
  enabledDecoderNodes: [...this.enabledDecoderNodes],
26000
26125
  enabledAudioNodes: [...this.enabledAudioNodes],
26126
+ enabledIngestNodes: [...this.enabledIngestNodes],
26001
26127
  clusterRoles: { ...this.clusterRoles },
26002
26128
  assignedDeviceCount: this.assignments.size,
26003
26129
  cameraConfigCount: this.cameraConfigs.size,
@@ -26023,6 +26149,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26023
26149
  clearTimeout(this.pendingRetryDebounceTimer);
26024
26150
  this.pendingRetryDebounceTimer = null;
26025
26151
  }
26152
+ if (this.autoRebalanceTimer !== null) {
26153
+ clearInterval(this.autoRebalanceTimer);
26154
+ this.autoRebalanceTimer = null;
26155
+ }
26026
26156
  for (const t of this.slotReadRetryTimers.values()) clearTimeout(t);
26027
26157
  this.slotReadRetryTimers.clear();
26028
26158
  this.slotReadRetryAttempts.clear();
@@ -26288,8 +26418,30 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26288
26418
  this.assignments.delete(input.deviceId);
26289
26419
  return { success: true };
26290
26420
  }
26291
- async rebalance() {
26421
+ /**
26422
+ * Periodic auto-rebalance tick. Corrects placement drift toward the per-node
26423
+ * weights without operator action. Guards: never overlaps itself; skips when
26424
+ * fewer than two nodes can run detection (a hub-only cluster has nothing to
26425
+ * balance); migrates under {@link AUTO_REBALANCE_MIN_IMPROVEMENT} hysteresis
26426
+ * so a balanced cluster is a no-op. Errors are swallowed (best-effort net).
26427
+ */
26428
+ async runAutoRebalance() {
26429
+ if (!this.ctxIfReady) return;
26430
+ if (this.autoRebalanceInFlight) return;
26431
+ if (this.enabledNodes.length < 2) return;
26432
+ this.autoRebalanceInFlight = true;
26433
+ try {
26434
+ const { migrated } = await this.rebalance({ minImprovement: AUTO_REBALANCE_MIN_IMPROVEMENT });
26435
+ if (migrated > 0) this.ctx.logger.info("auto-rebalance: corrected placement drift", { meta: { migrated } });
26436
+ } catch (err) {
26437
+ this.ctx.logger.debug("auto-rebalance pass failed", { meta: { error: errMsg(err) } });
26438
+ } finally {
26439
+ this.autoRebalanceInFlight = false;
26440
+ }
26441
+ }
26442
+ async rebalance(opts) {
26292
26443
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
26444
+ const minImprovement = Math.max(opts?.minImprovement ?? 0, 0);
26293
26445
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
26294
26446
  const nodeCaps = await this.buildNodeCaps();
26295
26447
  const nodeWeights = await this.buildNodeWeights();
@@ -26327,6 +26479,12 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26327
26479
  continue;
26328
26480
  }
26329
26481
  if (current && current.agentNodeId === decision.agentNodeId) continue;
26482
+ if (current && minImprovement > 0) {
26483
+ const tallied = talliedLoads();
26484
+ const currentLoad = tallied.find((l) => l.nodeId === current.agentNodeId);
26485
+ const targetLoad = tallied.find((l) => l.nodeId === decision.agentNodeId);
26486
+ if (currentLoad && targetLoad && capacityImprovement(currentLoad, targetLoad, nodeWeights) < minImprovement) continue;
26487
+ }
26330
26488
  if (current) {
26331
26489
  await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
26332
26490
  const msg = errMsg(err);
@@ -26337,7 +26495,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26337
26495
  });
26338
26496
  bumpAttached(current.agentNodeId, -1);
26339
26497
  }
26340
- await this.attachOn(decision.agentNodeId, config);
26498
+ const reResolved = await this.resolvePipelineForDevice(deviceId, decision.agentNodeId);
26499
+ const targetConfig = reResolved.steps.length > 0 ? {
26500
+ ...config,
26501
+ steps: reResolved.steps,
26502
+ audio: reResolved.audio ?? null
26503
+ } : config;
26504
+ this.cameraConfigs.set(deviceId, targetConfig);
26505
+ await this.attachOn(decision.agentNodeId, targetConfig);
26341
26506
  bumpAttached(decision.agentNodeId, 1);
26342
26507
  this.recordAssignment(deviceId, decision.agentNodeId, "rebalance", false);
26343
26508
  migrated++;
@@ -26436,11 +26601,37 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26436
26601
  async attachOn(nodeId, config) {
26437
26602
  const api = this.ctx.api;
26438
26603
  if (!api) throw new Error(`attachOn(${nodeId}, ${config.deviceId}): this.ctx.api not available`);
26604
+ const sourceOwnerNodeId = this.sourceOwner(config.deviceId);
26605
+ const ownerReachableHost = sourceOwnerNodeId !== void 0 ? this.reachableHostByNode.get(sourceOwnerNodeId) : void 0;
26439
26606
  const frameSource = selectRunnerFrameSource({
26440
26607
  targetNodeId: nodeId,
26441
- sourceOwnerNodeId: this.sourceOwner(config.deviceId),
26442
- remoteSourcingNodes: this.remoteSourcingNodes
26608
+ sourceOwnerNodeId,
26609
+ remoteSourcingNodes: this.remoteSourcingNodes,
26610
+ ...ownerReachableHost !== void 0 ? { ownerReachableHost } : {}
26443
26611
  });
26612
+ api.pipelineExecutor.validatePipeline.query({ steps: [...config.steps ?? []] }, nodePin(nodeId)).then((validation) => {
26613
+ if (!validation || validation.ok && validation.substitutions.length === 0) return;
26614
+ const logMeta = {
26615
+ nodeId,
26616
+ deviceId: config.deviceId,
26617
+ issues: validation.issues,
26618
+ substitutions: validation.substitutions
26619
+ };
26620
+ if (!validation.ok) this.ctx.logger.warn("attachOn: pre-init pipeline validation found issues", {
26621
+ tags: {
26622
+ nodeId,
26623
+ deviceId: config.deviceId
26624
+ },
26625
+ meta: logMeta
26626
+ });
26627
+ else this.ctx.logger.info("attachOn: pre-init pipeline validation found model substitutions", {
26628
+ tags: {
26629
+ nodeId,
26630
+ deviceId: config.deviceId
26631
+ },
26632
+ meta: logMeta
26633
+ });
26634
+ }).catch(() => {});
26444
26635
  try {
26445
26636
  await api.pipelineRunner.attachCamera.mutate({
26446
26637
  ...config,
@@ -26455,7 +26646,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26455
26646
  audioMode: config.audioMode,
26456
26647
  motionStreamId: config.motionStreamId,
26457
26648
  detectionStreamId: config.detectionStreamId,
26458
- hasEngine: !!config.engine,
26459
26649
  stepsCount: config.steps?.length ?? null,
26460
26650
  hasAudio: config.audio?.enabled ?? null
26461
26651
  };
@@ -26560,18 +26750,31 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26560
26750
  return this.clusterRoles.ingestNode;
26561
26751
  }
26562
26752
  /**
26753
+ * Cap surface: the cluster's single camera-source owner + its
26754
+ * LAN-reachable host, resolved UNCONDITIONALLY from `clusterRoles.ingestNode`
26755
+ * (NOT `resolveSourceOwner`/`sourceOwner`, which are gated on the
26756
+ * detection-decode `remoteSourcingNodes` rollout knob). Consumers
26757
+ * (recorder/snapshot/audio) call this to pin their broker calls to the
26758
+ * owner and dial `reachableHost`. Rollout-safe: defaults to
26759
+ * `{ ownerNodeId: 'hub' }` — byte-identical to today's hub-only behavior.
26760
+ */
26761
+ async getIngestOwner() {
26762
+ return resolveIngestOwner(this.clusterRoles, this.reachableHostByNode, this.enabledIngestNodes);
26763
+ }
26764
+ /**
26563
26765
  * The node that owns `deviceId`'s source pull, or `undefined` while
26564
26766
  * ownership is UNMODELED — which is exactly the rollout-OFF state: with the
26565
26767
  * `remoteSourcingNodes` knob empty (the default), `resolveSourceOwner`
26566
26768
  * returns `undefined`, `computeFrameSourceNodes` collapses to the global
26567
26769
  * `enabledDecoderNodes`, and `selectRunnerFrameSource` emits `local-broker`
26568
26770
  * for every target — bit-identical to pre-Phase-2 behavior. Populating the
26569
- * knob models ownership (`assignSource`, slice 1: the hub) and thereby
26570
- * turns eligibility per-camera and the remote-source leg dispatchable.
26771
+ * knob (a decode node other than the ingest owner exists) models ownership as
26772
+ * `clusterRoles.ingestNode` and thereby turns eligibility per-camera and the
26773
+ * remote-source leg dispatchable.
26571
26774
  */
26572
26775
  sourceOwner(deviceId) {
26573
26776
  return resolveSourceOwner({
26574
- hubNodeId: this.localNodeId,
26777
+ ownerNodeId: this.clusterRoles.ingestNode,
26575
26778
  remoteSourcingNodes: this.remoteSourcingNodes,
26576
26779
  ...deviceId !== void 0 ? { assignedOwner: this.assignSource(deviceId) } : {}
26577
26780
  });
@@ -26684,7 +26887,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26684
26887
  * lookup. Works for any backend (ffmpeg / node-av / future) and no longer
26685
26888
  * returns empty on a node-av-only node (the old `decoder-ffmpeg`-keyed read
26686
26889
  * did). Returns `null` when the value is empty/unset, `undefined` on a read
26687
- * failure (keep the last known). Mirrors {@link readDetectionPipelineEngine}.
26890
+ * failure (keep the last known). Node-local the same pattern the
26891
+ * orchestrator's (now-deleted) per-node engine reader used before engine
26892
+ * resolution moved entirely off the orchestrator.
26688
26893
  */
26689
26894
  async readNodeDecodeHwaccel(nodeId) {
26690
26895
  try {
@@ -27160,7 +27365,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27160
27365
  if (resolved.steps.length === 0) continue;
27161
27366
  const next = {
27162
27367
  ...cached,
27163
- engine: resolved.engine,
27164
27368
  steps: resolved.steps,
27165
27369
  audio: resolved.audio ?? null
27166
27370
  };
@@ -27230,7 +27434,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27230
27434
  if (resolved.steps.length === 0) continue;
27231
27435
  const next = {
27232
27436
  ...cached,
27233
- engine: resolved.engine,
27234
27437
  steps: resolved.steps,
27235
27438
  audio: resolved.audio ?? null
27236
27439
  };
@@ -27790,6 +27993,33 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27790
27993
  this.schedulePendingRetry();
27791
27994
  return { success: true };
27792
27995
  }
27996
+ /**
27997
+ * Set (or clear) the per-node `reachableHost` override — the LAN host a
27998
+ * cross-node decoder dials to reach this node's restream. A non-empty string
27999
+ * overrides the auto-detected host the runner would otherwise derive from
28000
+ * `CAMSTACK_HUB_URL`; `null` or an empty string clears it (revert to
28001
+ * auto-detect). Persisted onto the node's `agentSettings` entry, then a
28002
+ * debounced re-dispatch so new cross-node attaches pick up the host.
28003
+ */
28004
+ async setAgentReachableHost(input) {
28005
+ const existing = (await this.readAgentSettingsMap())[input.agentNodeId];
28006
+ const trimmed = input.reachableHost?.trim();
28007
+ const host = trimmed !== void 0 && trimmed.length > 0 ? trimmed : void 0;
28008
+ const next = {
28009
+ ...existing ?? {
28010
+ addonDefaults: {},
28011
+ maxCameras: null
28012
+ },
28013
+ reachableHost: host
28014
+ };
28015
+ await this.writeAgentSettings(input.agentNodeId, next);
28016
+ this.ctx.logger.info("agentSettings.reachableHost updated", {
28017
+ tags: { nodeId: input.agentNodeId },
28018
+ meta: { reachableHost: host ?? null }
28019
+ });
28020
+ this.schedulePendingRetry();
28021
+ return { success: true };
28022
+ }
27793
28023
  async getCameraSettings(input) {
27794
28024
  return (await this.readCameraSettingsMap())[String(input.deviceId)] ?? null;
27795
28025
  }
@@ -27873,6 +28103,25 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27873
28103
  });
27874
28104
  for (const deviceId of affected) await this.emitResolvedCameraUpdated(deviceId);
27875
28105
  }
28106
+ /**
28107
+ * Re-dispatch EVERY assigned camera through the REAL attach path
28108
+ * (`redispatchSingleCamera` → `attachOn`), so each camera's `frameSource`
28109
+ * (`local-broker` vs `remote-restream` naming the current ingest owner) is
28110
+ * RECOMPUTED and re-applied to its runner. Used when the cluster ingest owner
28111
+ * changes: `frameSource` is computed only in `attachOn` (the single choke
28112
+ * point), so a plain `emitCameraUpdated` config event would NOT move the
28113
+ * source — the runner would keep sourcing from the OLD owner. No-op when
28114
+ * nothing is assigned.
28115
+ */
28116
+ async redispatchAllActiveCameras(reason) {
28117
+ const deviceIds = [...this.assignments.keys()];
28118
+ if (deviceIds.length === 0) return;
28119
+ this.ctx.logger.info("re-dispatching all cameras", { meta: {
28120
+ reason,
28121
+ count: deviceIds.length
28122
+ } });
28123
+ for (const deviceId of deviceIds) await this.redispatchSingleCamera(deviceId);
28124
+ }
27876
28125
  async listTemplates() {
27877
28126
  const templates = await this.readTemplatesMap();
27878
28127
  return Object.values(templates).slice().toSorted((a, b) => a.name.localeCompare(b.name));
@@ -27949,6 +28198,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27949
28198
  const STAGE_TIMEOUT_MS = 3e3;
27950
28199
  const pipelineAssignment = this.assignments.get(deviceId) ?? null;
27951
28200
  const detectionNodeId = pipelineAssignment?.agentNodeId ?? null;
28201
+ const sourceNodeId = this.assignSource(deviceId);
27952
28202
  const decoderPinRaw = (api ? await this.ctx.settings?.readDeviceStore(deviceId).catch(() => ({})) ?? {} : {})["decoderNodeId"];
27953
28203
  const decoderPinned = typeof decoderPinRaw === "string" && decoderPinRaw !== "auto";
27954
28204
  const advisoryDecoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
@@ -27962,7 +28212,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27962
28212
  };
27963
28213
  const detectionReason = pipelineAssignment !== null ? pipelineAssignment.reason : this.cameraConfigs.has(deviceId) ? `pending:${this.pendingReasons.get(deviceId) ?? "pending"}` : void 0;
27964
28214
  const liveDecoder = { nodeId: null };
27965
- const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query() : null;
28215
+ const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
27966
28216
  const sourceFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then((slots) => {
27967
28217
  return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
27968
28218
  camStreamId: s.sourceCamStreamId ?? s.brokerId,
@@ -27988,10 +28238,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27988
28238
  const [statsAndClients, rtspEntry] = await Promise.all([Promise.all(deviceSlots.map(async (slot) => {
27989
28239
  return {
27990
28240
  slot,
27991
- stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }).catch(() => null),
27992
- clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }).catch(() => null)
28241
+ stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null),
28242
+ clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null)
27993
28243
  };
27994
- })), api.streamBroker.getAllRtspEntries.query({}).catch(() => null)]);
28244
+ })), api.streamBroker.getAllRtspEntries.query({}, nodePin(sourceNodeId)).catch(() => null)]);
27995
28245
  const profileDetails = statsAndClients.map(({ slot, stats, clients }) => ({
27996
28246
  profile: slot.profile,
27997
28247
  status: slot.status,
@@ -28096,6 +28346,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28096
28346
  detectionNodeId,
28097
28347
  decoderNodeId,
28098
28348
  audioNodeId,
28349
+ sourceNodeId,
28099
28350
  pinned,
28100
28351
  reasons,
28101
28352
  sourceResult,
@@ -28173,14 +28424,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28173
28424
  }
28174
28425
  /**
28175
28426
  * Fetch `pipelineExecutor.getSchema({nodeId})` through the standard cap
28176
- * router, cached per `(nodeId, engineKey)` with a short TTL. Returns
28177
- * `null` when the executor can't be reached (no runner yet attached)
28178
- * — callers fall back to an empty pipeline in that case.
28427
+ * router, cached per node with a short TTL. Returns `null` when the
28428
+ * executor can't be reached (no runner yet attached) — callers fall
28429
+ * back to an empty pipeline in that case.
28430
+ *
28431
+ * The cache key keeps the `${nodeId}::*` suffix (rather than a bare
28432
+ * `nodeId`) so `handleDetectionPipelineReadiness`'s prefix-match
28433
+ * invalidation (`key.startsWith(\`${nodeId}::\`)`) keeps working
28434
+ * unchanged; there is no longer a per-engine variant to distinguish.
28179
28435
  */
28180
- async getCatalogForAgent(nodeId, engine) {
28436
+ async getCatalogForAgent(nodeId) {
28181
28437
  const api = this.ctx.api;
28182
28438
  if (!api) return null;
28183
- const engineKey = engine ? `${nodeId}::${engine.runtime}/${engine.backend}/${engine.format}` : `${nodeId}::*`;
28439
+ const engineKey = `${nodeId}::*`;
28184
28440
  const now = Date.now();
28185
28441
  const hit = this.catalogCache.get(engineKey);
28186
28442
  if (hit && now - hit.at < PipelineOrchestratorAddon.CATALOG_CACHE_TTL_MS) return hit.schema;
@@ -28206,11 +28462,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28206
28462
  *
28207
28463
  * Behaviour:
28208
28464
  * - Fetch catalog for the node.
28209
- * - Call `seedAgentAddonDefaults(current.addonDefaults, engine, catalog)` — preserves
28465
+ * - Call `seedAgentAddonDefaults(current.addonDefaults, catalog)` — preserves
28210
28466
  * operator customisations, adds missing addons, drops orphans.
28211
28467
  * - Persist if anything changed.
28212
28468
  *
28213
- * Idempotent: a second call with the same catalog is a no-op.
28469
+ * Idempotent: a second call with the same catalog is a no-op. Data
28470
+ * only: no engine is resolved or stored here — the executing node
28471
+ * picks its own format default at runtime when a step's `modelId` is
28472
+ * absent.
28214
28473
  */
28215
28474
  async seedAgentSettingsFromCatalog(nodeId) {
28216
28475
  await this.ctx.acquireCapability("pipeline-executor", {
@@ -28220,71 +28479,28 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28220
28479
  const catalog = await this.getCatalogForAgent(nodeId);
28221
28480
  if (!catalog) return null;
28222
28481
  const existing = (await this.readAgentSettingsMap())[nodeId];
28223
- const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
28224
- const nextAddonDefaults = seedAgentAddonDefaults(existing?.addonDefaults ?? {}, engine, catalog);
28482
+ const nextAddonDefaults = seedAgentAddonDefaults(existing?.addonDefaults ?? {}, catalog);
28225
28483
  const next = { addonDefaults: nextAddonDefaults };
28226
28484
  if (!existing || JSON.stringify(existing.addonDefaults) !== JSON.stringify(next.addonDefaults)) {
28227
28485
  await this.writeAgentSettings(nodeId, next);
28228
28486
  this.ctx.logger.info("agentSettings seeded/refreshed", {
28229
28487
  tags: { nodeId },
28230
- meta: {
28231
- runtime: engine.runtime,
28232
- backend: engine.backend,
28233
- format: engine.format,
28234
- addons: Object.keys(nextAddonDefaults).length
28235
- }
28488
+ meta: { addons: Object.keys(nextAddonDefaults).length }
28236
28489
  });
28237
28490
  }
28238
28491
  return next;
28239
28492
  }
28240
28493
  /**
28241
- * Fetch the engine triple (`runtime/backend/device`) from the
28242
- * `detection-pipeline` addon's global settings on the target node.
28243
- * This is the authoritative engine source since phase 2f. Returns
28244
- * `null` when the addon hasn't responded yet or doesn't expose the
28245
- * fields — callers fall back to `catalog.selectedEngine`.
28246
- */
28247
- async readDetectionPipelineEngine(agentNodeId) {
28248
- const api = this.ctx.api;
28249
- if (!api?.addonSettings) return null;
28250
- try {
28251
- const schema = await api.addonSettings.getGlobalSettings.query({
28252
- addonId: "detection-pipeline",
28253
- nodeId: agentNodeId
28254
- });
28255
- if (!schema) return null;
28256
- const leaf = (key) => {
28257
- for (const s of schema.sections) for (const f of s.fields) if (f.key === key) return f.value;
28258
- };
28259
- const runtime = leaf("engineRuntime");
28260
- const backend = leaf("engineBackend");
28261
- const device = leaf("engineDevice");
28262
- if (runtime !== "python" && runtime !== "node" || typeof backend !== "string" || backend.length === 0) return null;
28263
- const base = {
28264
- runtime,
28265
- backend,
28266
- format: backend === "coreml" ? "coreml" : backend === "openvino" ? "openvino" : "onnx"
28267
- };
28268
- if (typeof device === "string" && device.length > 0) return {
28269
- ...base,
28270
- device
28271
- };
28272
- return base;
28273
- } catch (err) {
28274
- this.ctx.logger.debug("readDetectionPipelineEngine failed — falling back", {
28275
- tags: { nodeId: agentNodeId },
28276
- meta: { error: err instanceof Error ? err.message : String(err) }
28277
- });
28278
- return null;
28279
- }
28280
- }
28281
- /**
28282
28494
  * Resolve the pipeline for a device. NEVER returns an empty/fallback
28283
28495
  * config: if the catalog or agent settings aren't available yet, this
28284
28496
  * blocks (with backoff) until the detection-pipeline cap is registered
28285
28497
  * and a non-null catalog comes back. Cameras must never be dispatched
28286
- * with `engine=node/cpu, steps=[]` because the runner then attaches
28287
- * the camera with no steps and silently never recovers.
28498
+ * with `steps=[]` because the runner then attaches the camera with no
28499
+ * steps and silently never recovers.
28500
+ *
28501
+ * Data only: the returned config never carries an engine — model
28502
+ * selection is data only, and the executing node resolves its own
28503
+ * format default at runtime when a step's `modelId` is absent.
28288
28504
  *
28289
28505
  * Two legitimate exceptions to "never empty":
28290
28506
  * 1. The device's `detection-pipeline` binding is INACTIVE — the
@@ -28298,39 +28514,29 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28298
28514
  async resolvePipelineForDevice(deviceId, agentNodeIdOverride) {
28299
28515
  const agentNodeId = agentNodeIdOverride ?? this.assignments.get(deviceId)?.agentNodeId ?? this.localNodeId;
28300
28516
  if (!await this.isDetectionPipelineActive(deviceId)) {
28301
- const engine = await this.waitForEngine(agentNodeId);
28302
28517
  this.ctx.logger.info("resolvePipelineForDevice → empty (binding inactive)", {
28303
28518
  tags: {
28304
28519
  deviceId,
28305
28520
  nodeId: agentNodeId
28306
28521
  },
28307
- meta: {
28308
- reason: "detection-pipeline binding inactive",
28309
- engine: `${engine.runtime}/${engine.backend}`
28310
- }
28522
+ meta: { reason: "detection-pipeline binding inactive" }
28311
28523
  });
28312
28524
  return {
28313
- engine,
28314
28525
  steps: [],
28315
28526
  audio: null
28316
28527
  };
28317
28528
  }
28318
28529
  const cam = (await this.readCameraSettingsMap())[String(deviceId)] ?? null;
28319
28530
  const wholesale = cam?.pipelineByAgent?.[agentNodeId];
28320
- if (wholesale) {
28321
- const engine = await this.waitForEngine(agentNodeId);
28322
- return {
28323
- engine,
28324
- steps: wholesale.steps,
28325
- audio: wholesale.audio ? {
28326
- engine,
28327
- modelId: wholesale.audio.modelId,
28328
- enabled: wholesale.audio.enabled
28329
- } : null
28330
- };
28331
- }
28332
- const { agent, catalog, engine } = await this.waitForAgentAndCatalog(agentNodeId);
28333
- const resolved = resolvePipeline(agent, cam, agentNodeId, engine, catalog);
28531
+ if (wholesale) return {
28532
+ steps: wholesale.steps,
28533
+ audio: wholesale.audio ? {
28534
+ modelId: wholesale.audio.modelId,
28535
+ enabled: wholesale.audio.enabled
28536
+ } : null
28537
+ };
28538
+ const { agent, catalog } = await this.waitForAgentAndCatalog(agentNodeId);
28539
+ const resolved = resolvePipeline(agent, cam, agentNodeId, catalog);
28334
28540
  if (resolved.steps.length === 0) this.ctx.logger.info("resolvePipelineForDevice → empty (all addons disabled)", {
28335
28541
  tags: {
28336
28542
  deviceId,
@@ -28378,51 +28584,27 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28378
28584
  await sleep$1(2e3);
28379
28585
  continue;
28380
28586
  }
28381
- const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
28382
28587
  return {
28383
28588
  agent,
28384
- catalog,
28385
- engine
28589
+ catalog
28386
28590
  };
28387
28591
  }
28388
28592
  }
28389
28593
  /**
28390
- * Block until a real engine choice is available. `acquireCapability`
28391
- * defaults to infinite wait; the post-acquire loop only handles the
28392
- * propagation gap where the cap is `ready` but the derived calls
28393
- * still return null for a tick.
28394
- */
28395
- async waitForEngine(nodeId) {
28396
- await this.ctx.acquireCapability("pipeline-executor", {
28397
- type: "node",
28398
- nodeId
28399
- });
28400
- while (true) {
28401
- const fromAddon = await this.readDetectionPipelineEngine(nodeId);
28402
- if (fromAddon) return fromAddon;
28403
- const catalog = await this.getCatalogForAgent(nodeId);
28404
- if (catalog?.selectedEngine) return catalog.selectedEngine;
28405
- await sleep$1(2e3);
28406
- }
28407
- }
28408
- /**
28409
28594
  * Structural runtime check for `CameraPipelineConfig`. Persisted JSON
28410
28595
  * may be stale or partial across upgrades, so we gate reads on shape
28411
28596
  * rather than trusting the raw object. False = silently ignore that
28412
28597
  * entry and fall back to defaults — the orchestrator's job is to
28413
28598
  * never crash on corrupt store state.
28599
+ *
28600
+ * Data only: `engine` is no longer required — a persisted config may
28601
+ * still carry a vestigial `engine` from before the data-only refactor;
28602
+ * it is simply ignored.
28414
28603
  */
28415
28604
  isCameraPipelineConfig(value) {
28416
28605
  if (!value || typeof value !== "object") return false;
28417
28606
  const v = value;
28418
- const engine = v["engine"];
28419
- if (!engine || typeof engine !== "object") return false;
28420
- const e = engine;
28421
- if (typeof e["runtime"] !== "string") return false;
28422
- if (typeof e["backend"] !== "string") return false;
28423
- if (typeof e["format"] !== "string") return false;
28424
- if (!Array.isArray(v["steps"])) return false;
28425
- return true;
28607
+ return Array.isArray(v["steps"]);
28426
28608
  }
28427
28609
  isPipelineTemplate(value) {
28428
28610
  if (!value || typeof value !== "object") return false;
@@ -28774,10 +28956,21 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28774
28956
  ] });
28775
28957
  }
28776
28958
  async updateGlobalSettings(patch) {
28959
+ const previousIngestNode = this.clusterRoles.ingestNode;
28777
28960
  await super.updateGlobalSettings(patch);
28778
28961
  const full = await this.resolveGlobalStore();
28779
28962
  this.globalSettings = { ...full };
28780
28963
  this.applyRuntimeSettings(full);
28964
+ if (this.clusterRoles.ingestNode !== previousIngestNode) {
28965
+ const { configIssue } = resolveIngestOwner(this.clusterRoles, this.reachableHostByNode, this.enabledIngestNodes);
28966
+ if (configIssue !== void 0) this.ctx.logger.warn("ingest node config issue", { meta: {
28967
+ ingestNode: this.clusterRoles.ingestNode,
28968
+ issue: configIssue
28969
+ } });
28970
+ this.refreshNodeCapabilities().then(() => this.redispatchAllActiveCameras("ingest-owner-changed")).catch((err) => {
28971
+ this.ctx.logger.warn("ingest-owner-changed re-dispatch failed", { meta: { error: errMsg(err) } });
28972
+ });
28973
+ }
28781
28974
  const pausedIds = [...this.loadShedState.entries()].filter(([, s]) => s.pausedAt !== null).map(([id]) => id);
28782
28975
  if (pausedIds.length > 0) {
28783
28976
  this.loadShedState.clear();
@@ -28946,7 +29139,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
28946
29139
  async applyPipelinePatch(deviceId, pipelinePatch) {
28947
29140
  const incoming = pipelinePatch.cameraPipeline;
28948
29141
  if (incoming === void 0) return;
28949
- 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 })`);
29142
+ if (!this.isCameraPipelineConfig(incoming)) throw new Error(`applyPipelinePatch: cameraPipeline value for device ${deviceId} is not a valid CameraPipelineConfig (expected { steps:[], audio?:null|object })`);
28950
29143
  const agentNodeId = this.assignments.get(deviceId)?.agentNodeId ?? this.localNodeId;
28951
29144
  await this.setCameraPipelineForAgent({
28952
29145
  deviceId,
@@ -29002,12 +29195,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29002
29195
  }
29003
29196
  /**
29004
29197
  * Recompute the derived enabled-node sets (`enabledNodes` /
29005
- * `enabledDecoderNodes` / `enabledAudioNodes`) from the per-node capability
29006
- * store. A node is eligible for a concern iff its stored flag is `true`, or
29007
- * the flag is unset AND the node defaults capable ({@link nodeCapabilityDefault}).
29008
- * Forked child nodeIds (`hub/classifier`) are never dispatchable and are
29009
- * excluded. `hub` is always considered even when absent from the store, so a
29010
- * fresh install keeps the hub-only cluster working.
29198
+ * `enabledDecoderNodes` / `enabledAudioNodes` / `enabledIngestNodes`) from
29199
+ * the per-node capability store. A node is eligible for a concern iff its
29200
+ * stored flag is `true`, or the flag is unset AND the node defaults capable
29201
+ * ({@link nodeCapabilityDefault}). Forked child nodeIds (`hub/classifier`)
29202
+ * are never dispatchable and are excluded. `hub` is always considered even
29203
+ * when absent from the store, so a fresh install keeps the hub-only cluster
29204
+ * working.
29011
29205
  */
29012
29206
  async refreshNodeCapabilities() {
29013
29207
  let blob = {};
@@ -29021,16 +29215,30 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29021
29215
  const detect = [];
29022
29216
  const decode = [];
29023
29217
  const audio = [];
29218
+ const ingest = [];
29219
+ const reachableHosts = /* @__PURE__ */ new Map();
29024
29220
  const capable = (flag, nodeId) => typeof flag === "boolean" ? flag : PipelineOrchestratorAddon.nodeCapabilityDefault(nodeId);
29025
29221
  for (const nodeId of nodeIds) {
29026
29222
  const settings = blob[nodeId];
29027
29223
  if (capable(settings?.detect, nodeId)) detect.push(nodeId);
29028
29224
  if (capable(settings?.decode, nodeId)) decode.push(nodeId);
29029
29225
  if (capable(settings?.audio, nodeId)) audio.push(nodeId);
29226
+ if (capable(settings?.ingest, nodeId)) ingest.push(nodeId);
29227
+ const host = settings?.reachableHost?.trim();
29228
+ if (host !== void 0 && host.length > 0) reachableHosts.set(nodeId, host);
29030
29229
  }
29230
+ this.reachableHostByNode = reachableHosts;
29031
29231
  this.enabledNodes = detect.toSorted();
29032
29232
  this.enabledDecoderNodes = decode.toSorted();
29033
29233
  this.enabledAudioNodes = audio.toSorted();
29234
+ this.enabledIngestNodes = ingest.toSorted();
29235
+ this.remoteSourcingNodes = deriveRemoteSourcingNodes(this.enabledDecoderNodes, this.clusterRoles.ingestNode);
29236
+ const { ownerNodeId } = resolveIngestOwner(this.clusterRoles, this.reachableHostByNode, this.enabledIngestNodes);
29237
+ this.ctx.eventBus.emit(createEvent(EventCategory.PipelineIngestOwnerChanged, {
29238
+ type: "addon",
29239
+ id: this.ctx.id,
29240
+ nodeId: "hub"
29241
+ }, { ownerNodeId }));
29034
29242
  }
29035
29243
  get api() {
29036
29244
  return this.ctx.api ?? null;
@@ -29329,6 +29537,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29329
29537
  await this.stopDetection(deviceId);
29330
29538
  }
29331
29539
  this.activeDetections.set(deviceId, config);
29540
+ const hydrateNodeId = this.assignments.get(deviceId)?.agentNodeId ?? this.localNodeId;
29332
29541
  const pipelineConfig = await this.resolvePipelineForDevice(deviceId);
29333
29542
  const zones = await this.zonesProvider?.listZones({ deviceId }) ?? [];
29334
29543
  const runnerConfig = {
@@ -29340,7 +29549,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29340
29549
  detectionStreamId: config.detectionStreamId,
29341
29550
  motionSources: config.motionSources,
29342
29551
  pipelineEnabled: config.pipelineEnabled,
29343
- engine: pipelineConfig.engine,
29344
29552
  steps: pipelineConfig.steps,
29345
29553
  audio: pipelineConfig.audio ?? null,
29346
29554
  zones,
@@ -29359,15 +29567,20 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29359
29567
  const msg = errMsg(err);
29360
29568
  log.error("dispatchCamera failed", { meta: { error: msg } });
29361
29569
  }
29362
- if (dispatchedNodeId && (runnerConfig.steps?.length ?? 0) === 0) try {
29570
+ if (dispatchedNodeId && (dispatchedNodeId !== hydrateNodeId || (runnerConfig.steps?.length ?? 0) === 0)) try {
29363
29571
  const corrected = await this.resolvePipelineForDevice(deviceId, dispatchedNodeId);
29364
- if (corrected.steps.length > 0) {
29365
- const correctedConfig = {
29366
- ...runnerConfig,
29367
- engine: corrected.engine,
29368
- steps: corrected.steps,
29369
- audio: corrected.audio ?? null
29370
- };
29572
+ const correctedConfig = {
29573
+ ...runnerConfig,
29574
+ steps: corrected.steps,
29575
+ audio: corrected.audio ?? null
29576
+ };
29577
+ if (JSON.stringify({
29578
+ steps: runnerConfig.steps,
29579
+ audio: runnerConfig.audio ?? null
29580
+ }) !== JSON.stringify({
29581
+ steps: correctedConfig.steps,
29582
+ audio: correctedConfig.audio
29583
+ })) {
29371
29584
  await this.attachOn(dispatchedNodeId, correctedConfig);
29372
29585
  this.cameraConfigs.set(deviceId, correctedConfig);
29373
29586
  log.info("startDetection: pipeline re-resolved after agent assignment", {
@@ -29568,7 +29781,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
29568
29781
  }
29569
29782
  const nextConfig = {
29570
29783
  ...cached,
29571
- engine: config.engine,
29572
29784
  steps: config.steps,
29573
29785
  audio: config.audio ?? null
29574
29786
  };
@@ -30060,6 +30272,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
30060
30272
  api,
30061
30273
  brokerId: audioBrokerId,
30062
30274
  tag: "audio-analyzer",
30275
+ ownerNodeId: this.clusterRoles.ingestNode,
30063
30276
  logger: this.ctx.logger,
30064
30277
  onChunk: async (chunk) => {
30065
30278
  this.pipelineWatchdog?.noteSignal(deviceId, "audio");