@camstack/addon-pipeline-orchestrator 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4985,6 +4985,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4985
4985
  */
4986
4986
  EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
4987
4987
  /**
4988
+ * Per-node detection-engine runtime-provisioning transition. Emitted by
4989
+ * the detection-pipeline provider on every state change of its lazy
4990
+ * engine-provisioning machine (idle → installing → verifying → ready,
4991
+ * or → failed with a `nextRetryAt`). Payload is the
4992
+ * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
4993
+ * node. The Pipeline page subscribes to drive a live "installing
4994
+ * OpenVINO… / ready" indicator per node without polling
4995
+ * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
4996
+ * also reads the cap snapshot on mount / reconnect. Phase 2.
4997
+ */
4998
+ EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
4999
+ /**
4988
5000
  * Cluster topology snapshot. Carries the same payload returned by
4989
5001
  * `nodes.topology` (every reachable node + addons + processes).
4990
5002
  * Emitted by the hub on any agent / addon lifecycle change
@@ -9566,6 +9578,24 @@ var DetectorOutputSchema = object({
9566
9578
  inferenceMs: number(),
9567
9579
  modelId: string()
9568
9580
  });
9581
+ var EngineProvisioningSchema = object({
9582
+ runtimeId: _enum([
9583
+ "onnx",
9584
+ "openvino",
9585
+ "coreml"
9586
+ ]).nullable(),
9587
+ device: string().nullable(),
9588
+ state: _enum([
9589
+ "idle",
9590
+ "installing",
9591
+ "verifying",
9592
+ "ready",
9593
+ "failed"
9594
+ ]),
9595
+ progress: number().optional(),
9596
+ error: string().optional(),
9597
+ nextRetryAt: number().optional()
9598
+ });
9569
9599
  var PipelineStepInputSchema = lazy(() => object({
9570
9600
  addonId: string(),
9571
9601
  modelId: string(),
@@ -9665,6 +9695,15 @@ var pipelineExecutorCapability = {
9665
9695
  kind: "mutation",
9666
9696
  auth: "admin"
9667
9697
  }),
9698
+ /**
9699
+ * Per-node detection-engine provisioning snapshot. Returns the live
9700
+ * state of the lazy runtime-provisioning machine on `nodeId`
9701
+ * (idle / installing / verifying / ready / failed). The UI pairs this
9702
+ * one-shot query with the `pipeline.engine-provisioning` live event
9703
+ * (emitted on every transition) to drive a per-node "engine ready?"
9704
+ * indicator without polling. Phase 2.
9705
+ */
9706
+ getEngineProvisioning: method(object({ nodeId: string() }), EngineProvisioningSchema),
9668
9707
  getVideoPipelineSteps: method(_void(), record(string(), object({
9669
9708
  modelId: string(),
9670
9709
  settings: record(string(), unknown()).readonly()
@@ -13541,7 +13580,10 @@ var AgentAddonConfigSchema = object({
13541
13580
  modelId: string(),
13542
13581
  settings: record(string(), unknown()).readonly()
13543
13582
  });
13544
- var AgentPipelineSettingsSchema = object({ addonDefaults: record(string(), AgentAddonConfigSchema).readonly() });
13583
+ var AgentPipelineSettingsSchema = object({
13584
+ addonDefaults: record(string(), AgentAddonConfigSchema).readonly(),
13585
+ maxCameras: number().int().nonnegative().nullable().default(null)
13586
+ });
13545
13587
  var CameraPipelineForAgentSchema = object({
13546
13588
  steps: array(PipelineStepInputSchema).readonly(),
13547
13589
  audio: object({
@@ -13643,6 +13685,133 @@ var GlobalMetricsSchema = object({
13643
13685
  * capability providers.
13644
13686
  */
13645
13687
  var CapabilityBindingsSchema = record(string(), string());
13688
+ /** Source block — always present; derives from the stream catalog. */
13689
+ var CameraSourceStatusSchema = object({ streams: array(object({
13690
+ camStreamId: string(),
13691
+ codec: string(),
13692
+ width: number(),
13693
+ height: number(),
13694
+ fps: number(),
13695
+ kind: string()
13696
+ })).readonly() });
13697
+ /** Assignment block — always present (orchestrator-local, no remote call). */
13698
+ var CameraAssignmentStatusSchema = object({
13699
+ detectionNodeId: string().nullable(),
13700
+ decoderNodeId: string().nullable(),
13701
+ audioNodeId: string().nullable(),
13702
+ pinned: object({
13703
+ detection: boolean(),
13704
+ decoder: boolean(),
13705
+ audio: boolean()
13706
+ }),
13707
+ reasons: object({
13708
+ detection: string().optional(),
13709
+ decoder: string().optional(),
13710
+ audio: string().optional()
13711
+ })
13712
+ });
13713
+ /** Broker block — null when the broker stage is unreachable or inactive. */
13714
+ var CameraBrokerStatusSchema = object({
13715
+ profiles: array(object({
13716
+ profile: string(),
13717
+ status: string(),
13718
+ codec: string(),
13719
+ width: number(),
13720
+ height: number(),
13721
+ subscribers: number(),
13722
+ inFps: number(),
13723
+ outFps: number()
13724
+ })).readonly(),
13725
+ webrtcSessions: number(),
13726
+ rtspRestream: boolean()
13727
+ });
13728
+ /** Shared-memory ring statistics within the decoder block. */
13729
+ var CameraDecoderShmSchema = object({
13730
+ framesWritten: number(),
13731
+ getFrameHits: number(),
13732
+ getFrameMisses: number(),
13733
+ budgetMb: number()
13734
+ });
13735
+ /** Decoder block — null when the decoder stage is unreachable or inactive. */
13736
+ var CameraDecoderStatusSchema = object({
13737
+ nodeId: string(),
13738
+ formats: array(string()).readonly(),
13739
+ sessionCount: number(),
13740
+ shm: CameraDecoderShmSchema
13741
+ });
13742
+ /** Motion block — null when motion detection is not active for this device. */
13743
+ var CameraMotionStatusSchema = object({
13744
+ enabled: boolean(),
13745
+ fps: number()
13746
+ });
13747
+ /** Detection provisioning sub-block. */
13748
+ var CameraDetectionProvisioningSchema = object({
13749
+ state: _enum([
13750
+ "idle",
13751
+ "installing",
13752
+ "verifying",
13753
+ "ready",
13754
+ "failed"
13755
+ ]),
13756
+ error: string().optional()
13757
+ });
13758
+ /** Detection phase — derived from the runner's engine phase. */
13759
+ var CameraDetectionPhaseSchema = _enum([
13760
+ "idle",
13761
+ "watching",
13762
+ "active"
13763
+ ]);
13764
+ /** Detection block — null when no detection node is assigned or reachable. */
13765
+ var CameraDetectionStatusSchema = object({
13766
+ nodeId: string(),
13767
+ engine: object({
13768
+ backend: string(),
13769
+ device: string()
13770
+ }),
13771
+ phase: CameraDetectionPhaseSchema,
13772
+ configuredFps: number(),
13773
+ actualFps: number(),
13774
+ queueDepth: number(),
13775
+ avgInferenceMs: number(),
13776
+ provisioning: CameraDetectionProvisioningSchema
13777
+ });
13778
+ /** Audio block — null when no audio node is assigned or reachable. */
13779
+ var CameraAudioStatusSchema = object({
13780
+ nodeId: string(),
13781
+ enabled: boolean()
13782
+ });
13783
+ /** Recording block — null when no recording cap is active for this device. */
13784
+ var CameraRecordingStatusSchema = object({
13785
+ mode: _enum([
13786
+ "off",
13787
+ "continuous",
13788
+ "events"
13789
+ ]),
13790
+ active: boolean(),
13791
+ storageBytes: number()
13792
+ });
13793
+ /**
13794
+ * Aggregated per-camera pipeline status — server-composed, single call.
13795
+ *
13796
+ * The `assignment` and `source` blocks are always present.
13797
+ * Every other block is `null` when the stage is inactive or unreachable
13798
+ * during the bounded parallel fan-out in the orchestrator implementation.
13799
+ *
13800
+ * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
13801
+ */
13802
+ var CameraStatusSchema = object({
13803
+ deviceId: number(),
13804
+ assignment: CameraAssignmentStatusSchema,
13805
+ source: CameraSourceStatusSchema,
13806
+ broker: CameraBrokerStatusSchema.nullable(),
13807
+ decoder: CameraDecoderStatusSchema.nullable(),
13808
+ motion: CameraMotionStatusSchema.nullable(),
13809
+ detection: CameraDetectionStatusSchema.nullable(),
13810
+ audio: CameraAudioStatusSchema.nullable(),
13811
+ recording: CameraRecordingStatusSchema.nullable(),
13812
+ /** Unix timestamp (ms) when this snapshot was composed server-side. */
13813
+ fetchedAt: number()
13814
+ });
13646
13815
  /**
13647
13816
  * Pipeline Orchestrator capability — global load balancer + camera dispatcher.
13648
13817
  *
@@ -13819,6 +13988,25 @@ var pipelineOrchestratorCapability = {
13819
13988
  kind: "mutation",
13820
13989
  auth: "admin"
13821
13990
  }),
13991
+ /**
13992
+ * Set the per-node camera cap for one agent.
13993
+ *
13994
+ * `maxCameras: 0` and `maxCameras: null` are both treated as unlimited
13995
+ * by the load balancer (0 is stored as-is; the balancer treats ≤0 as
13996
+ * unlimited alongside null). Pass `null` to clear an existing cap.
13997
+ * Note: the admin UI normalises 0→null before calling this method, so
13998
+ * `maxCameras: 0` only reaches the store via direct SDK or CLI calls.
13999
+ * Changes take effect immediately on the next dispatch cycle — cameras
14000
+ * currently assigned over-cap are left in place (no forced eviction),
14001
+ * but new assignments obey the updated cap.
14002
+ */
14003
+ setAgentMaxCameras: method(object({
14004
+ agentNodeId: string(),
14005
+ maxCameras: number().int().nonnegative().nullable()
14006
+ }), object({ success: literal(true) }), {
14007
+ kind: "mutation",
14008
+ auth: "admin"
14009
+ }),
13822
14010
  /** Read one camera's settings. Null when never touched (inherits agent defaults fully). */
13823
14011
  getCameraSettings: method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()),
13824
14012
  /** Set or clear the 3-state toggle for one (camera, addonId). Pass `enabled: null` to clear and revert to agent default. */
@@ -13859,6 +14047,29 @@ var pipelineOrchestratorCapability = {
13859
14047
  deviceId: number(),
13860
14048
  agentNodeId: string().optional()
13861
14049
  }), CameraPipelineConfigSchema),
14050
+ /**
14051
+ * Server-composed aggregated status for a single camera.
14052
+ *
14053
+ * Fans out in parallel (bounded, per-stage graceful degradation) to
14054
+ * broker / decoder / motion / detection / audio / recording source caps
14055
+ * via `ctx.api`. A stage whose source errors or times out becomes `null`
14056
+ * in the returned payload — one slow agent never breaks the whole call.
14057
+ *
14058
+ * Intended for "on open / on camera select" snapshots. Live deltas
14059
+ * keep arriving from the existing ~1Hz events the UI already subscribes to.
14060
+ */
14061
+ getCameraStatus: method(object({ deviceId: number() }), CameraStatusSchema),
14062
+ /**
14063
+ * Server-composed aggregated status for multiple cameras in one call.
14064
+ *
14065
+ * `deviceIds` defaults to all cameras currently tracked by the
14066
+ * orchestrator's assignment map when omitted.
14067
+ *
14068
+ * Runs per-device composition in parallel (bounded). Use this to
14069
+ * populate the cluster assignments table and the pipeline flow overview
14070
+ * rail without issuing N parallel browser round-trips.
14071
+ */
14072
+ getCameraStatuses: method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()),
13862
14073
  /** List every template the operator has saved. */
13863
14074
  listTemplates: method(_void(), array(PipelineTemplateSchema).readonly()),
13864
14075
  /** Create a new named preset from a given CameraPipelineConfig. */
@@ -19698,6 +19909,12 @@ Object.freeze({
19698
19909
  addonId: null,
19699
19910
  access: "view"
19700
19911
  },
19912
+ "pipelineExecutor.getEngineProvisioning": {
19913
+ capName: "pipeline-executor",
19914
+ capScope: "system",
19915
+ addonId: null,
19916
+ access: "view"
19917
+ },
19701
19918
  "pipelineExecutor.getGlobalPipelineConfig": {
19702
19919
  capName: "pipeline-executor",
19703
19920
  capScope: "system",
@@ -19902,6 +20119,18 @@ Object.freeze({
19902
20119
  addonId: null,
19903
20120
  access: "view"
19904
20121
  },
20122
+ "pipelineOrchestrator.getCameraStatus": {
20123
+ capName: "pipeline-orchestrator",
20124
+ capScope: "system",
20125
+ addonId: null,
20126
+ access: "view"
20127
+ },
20128
+ "pipelineOrchestrator.getCameraStatuses": {
20129
+ capName: "pipeline-orchestrator",
20130
+ capScope: "system",
20131
+ addonId: null,
20132
+ access: "view"
20133
+ },
19905
20134
  "pipelineOrchestrator.getCameraStepOverrides": {
19906
20135
  capName: "pipeline-orchestrator",
19907
20136
  capScope: "system",
@@ -19986,6 +20215,12 @@ Object.freeze({
19986
20215
  addonId: null,
19987
20216
  access: "create"
19988
20217
  },
20218
+ "pipelineOrchestrator.setAgentMaxCameras": {
20219
+ capName: "pipeline-orchestrator",
20220
+ capScope: "system",
20221
+ addonId: null,
20222
+ access: "create"
20223
+ },
19989
20224
  "pipelineOrchestrator.setCameraPipelineForAgent": {
19990
20225
  capName: "pipeline-orchestrator",
19991
20226
  capScope: "system",
@@ -22016,7 +22251,10 @@ var StoredAgentAddonConfigSchema = object({
22016
22251
  modelId: string(),
22017
22252
  settings: record(string(), unknown()).readonly()
22018
22253
  });
22019
- var StoredAgentPipelineSettingsSchema = object({ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly() });
22254
+ var StoredAgentPipelineSettingsSchema = object({
22255
+ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly(),
22256
+ maxCameras: number().int().nonnegative().nullable().default(null)
22257
+ });
22020
22258
  var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
22021
22259
  var StoredCameraStepOverridePatchSchema = object({
22022
22260
  enabled: boolean().optional(),
@@ -22061,10 +22299,26 @@ function computeCapacityScore(load) {
22061
22299
  return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
22062
22300
  }
22063
22301
  /**
22302
+ * Returns true when the node has remaining capacity.
22303
+ * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
22304
+ * its `attachedCameras` count is strictly less than the cap.
22305
+ * Pins count toward the cap via `attachedCameras`.
22306
+ */
22307
+ function isEligible(node, caps) {
22308
+ const cap = caps?.[node.nodeId];
22309
+ if (cap === null || cap === void 0 || cap <= 0) return true;
22310
+ return node.attachedCameras < cap;
22311
+ }
22312
+ /**
22064
22313
  * Run the two-level camera balancer.
22065
22314
  *
22066
- * L1 (manual affinity): if `preferredAgent` names an online node, return it.
22067
- * L2 (capacity): compute capacity scores and pick the lowest.
22315
+ * L1 (manual affinity): if `preferredAgent` names an online node AND that
22316
+ * node is under its `maxCameras` cap, return it. If the node is online but at
22317
+ * or over cap, return `{kind:'pending'}` — a pinned camera is never silently
22318
+ * over-assigned.
22319
+ *
22320
+ * L2 (capacity): filter to eligible nodes and pick the lowest capacity score.
22321
+ * If all nodes are at/over cap, return `{kind:'pending'}`.
22068
22322
  *
22069
22323
  * Returns `null` when no runners are online. The orchestrator decides how to
22070
22324
  * react — typically by logging and deferring the assignment until a runner
@@ -22073,19 +22327,32 @@ function computeCapacityScore(load) {
22073
22327
  function balance(input) {
22074
22328
  const online = input.nodes.filter((n) => n.nodeId.length > 0);
22075
22329
  if (online.length === 0) return null;
22330
+ const eligible = online.filter((n) => isEligible(n, input.nodeCaps));
22076
22331
  if (input.preferredAgent) {
22077
- const match = online.find((n) => n.nodeId === input.preferredAgent);
22078
- if (match) return {
22079
- agentNodeId: match.nodeId,
22080
- reason: "manual",
22081
- score: computeCapacityScore(match)
22082
- };
22332
+ const pinnedOnline = online.find((n) => n.nodeId === input.preferredAgent);
22333
+ if (pinnedOnline) {
22334
+ if (eligible.some((n) => n.nodeId === pinnedOnline.nodeId)) return {
22335
+ kind: "assigned",
22336
+ agentNodeId: pinnedOnline.nodeId,
22337
+ reason: "manual",
22338
+ score: computeCapacityScore(pinnedOnline)
22339
+ };
22340
+ return {
22341
+ kind: "pending",
22342
+ reason: "over-cap"
22343
+ };
22344
+ }
22083
22345
  }
22084
- const best = online.map((node) => ({
22346
+ if (eligible.length === 0) return {
22347
+ kind: "pending",
22348
+ reason: "over-cap"
22349
+ };
22350
+ const best = eligible.map((node) => ({
22085
22351
  node,
22086
22352
  score: computeCapacityScore(node)
22087
22353
  })).toSorted((a, b) => a.score - b.score)[0];
22088
22354
  return {
22355
+ kind: "assigned",
22089
22356
  agentNodeId: best.node.nodeId,
22090
22357
  reason: "capacity",
22091
22358
  score: best.score
@@ -22504,6 +22771,139 @@ var PipelineWatchdog = class {
22504
22771
  }
22505
22772
  };
22506
22773
  //#endregion
22774
+ //#region src/camera-status/compose-camera-status.ts
22775
+ function mapAssignment(input) {
22776
+ return {
22777
+ detectionNodeId: input.detectionNodeId,
22778
+ decoderNodeId: input.decoderNodeId,
22779
+ audioNodeId: input.audioNodeId,
22780
+ pinned: {
22781
+ detection: input.pinned.detection,
22782
+ decoder: input.pinned.decoder,
22783
+ audio: input.pinned.audio
22784
+ },
22785
+ reasons: {
22786
+ detection: input.reasons.detection,
22787
+ decoder: input.reasons.decoder,
22788
+ audio: input.reasons.audio
22789
+ }
22790
+ };
22791
+ }
22792
+ function mapSource(sourceResult) {
22793
+ if (sourceResult === null) return { streams: [] };
22794
+ return { streams: sourceResult.streams.map((s) => ({
22795
+ camStreamId: s.camStreamId,
22796
+ codec: s.codec,
22797
+ width: s.width,
22798
+ height: s.height,
22799
+ fps: s.fps,
22800
+ kind: s.kind
22801
+ })) };
22802
+ }
22803
+ function mapBroker(brokerResult) {
22804
+ if (brokerResult === null) return null;
22805
+ return {
22806
+ profiles: brokerResult.profiles.map((p) => ({
22807
+ profile: p.profile,
22808
+ status: p.status,
22809
+ codec: p.codec,
22810
+ width: p.width,
22811
+ height: p.height,
22812
+ subscribers: p.subscribers,
22813
+ inFps: p.inFps,
22814
+ outFps: p.outFps
22815
+ })),
22816
+ webrtcSessions: brokerResult.webrtcSessions,
22817
+ rtspRestream: brokerResult.rtspRestream
22818
+ };
22819
+ }
22820
+ function mapDecoderShm(shm) {
22821
+ return {
22822
+ framesWritten: shm.framesWritten,
22823
+ getFrameHits: shm.getFrameHits,
22824
+ getFrameMisses: shm.getFrameMisses,
22825
+ budgetMb: shm.budgetMb
22826
+ };
22827
+ }
22828
+ function mapDecoder(decoderResult) {
22829
+ if (decoderResult === null) return null;
22830
+ return {
22831
+ nodeId: decoderResult.nodeId,
22832
+ formats: [...decoderResult.formats],
22833
+ sessionCount: decoderResult.sessionCount,
22834
+ shm: mapDecoderShm(decoderResult.shm)
22835
+ };
22836
+ }
22837
+ function mapMotion(motionResult) {
22838
+ if (motionResult === null) return null;
22839
+ return {
22840
+ enabled: motionResult.enabled,
22841
+ fps: motionResult.fps
22842
+ };
22843
+ }
22844
+ function mapProvisioning(p) {
22845
+ if (p.error !== void 0) return {
22846
+ state: p.state,
22847
+ error: p.error
22848
+ };
22849
+ return { state: p.state };
22850
+ }
22851
+ function mapDetection(detectionResult) {
22852
+ if (detectionResult === null) return null;
22853
+ const phase = detectionResult.phase;
22854
+ return {
22855
+ nodeId: detectionResult.nodeId,
22856
+ engine: {
22857
+ backend: detectionResult.engine.backend,
22858
+ device: detectionResult.engine.device
22859
+ },
22860
+ phase,
22861
+ configuredFps: detectionResult.configuredFps,
22862
+ actualFps: detectionResult.actualFps,
22863
+ queueDepth: detectionResult.queueDepth,
22864
+ avgInferenceMs: detectionResult.avgInferenceMs,
22865
+ provisioning: mapProvisioning(detectionResult.provisioning)
22866
+ };
22867
+ }
22868
+ function mapAudio(audioResult) {
22869
+ if (audioResult === null) return null;
22870
+ return {
22871
+ nodeId: audioResult.nodeId,
22872
+ enabled: audioResult.enabled
22873
+ };
22874
+ }
22875
+ function mapRecording(recordingResult) {
22876
+ if (recordingResult === null) return null;
22877
+ return {
22878
+ mode: recordingResult.mode,
22879
+ active: recordingResult.active,
22880
+ storageBytes: recordingResult.storageBytes
22881
+ };
22882
+ }
22883
+ /**
22884
+ * Pure function that composes a `CameraStatus` from per-stage fetch results.
22885
+ *
22886
+ * - `assignment` is always built from orchestrator-local data (never null).
22887
+ * - `source` always present: defaults to `{ streams: [] }` when sourceResult is null.
22888
+ * - Every other block is null when its stage result is null (graceful degradation).
22889
+ * - `fetchedAt` is stamped exactly as provided — never calls `Date.now()`.
22890
+ * - No mutation of the input.
22891
+ */
22892
+ function composeCameraStatus(input) {
22893
+ return {
22894
+ deviceId: input.deviceId,
22895
+ assignment: mapAssignment(input),
22896
+ source: mapSource(input.sourceResult),
22897
+ broker: mapBroker(input.brokerResult),
22898
+ decoder: mapDecoder(input.decoderResult),
22899
+ motion: mapMotion(input.motionResult),
22900
+ detection: mapDetection(input.detectionResult),
22901
+ audio: mapAudio(input.audioResult),
22902
+ recording: mapRecording(input.recordingResult),
22903
+ fetchedAt: input.fetchedAt
22904
+ };
22905
+ }
22906
+ //#endregion
22507
22907
  //#region src/index.ts
22508
22908
  var PHASE_MODE_VALUES = new Set([
22509
22909
  "disabled",
@@ -23261,8 +23661,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23261
23661
  });
23262
23662
  return {
23263
23663
  success: true,
23264
- agentNodeId: "",
23265
- reason: "capacity"
23664
+ kind: "pending"
23266
23665
  };
23267
23666
  }
23268
23667
  }
@@ -23272,14 +23671,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23272
23671
  const preferredAgent = typeof pipelinePin === "string" && pipelinePin !== "auto" ? pipelinePin : legacyPreferred;
23273
23672
  const decision = balance({
23274
23673
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23275
- preferredAgent
23674
+ preferredAgent,
23675
+ nodeCaps: this.buildNodeCaps()
23276
23676
  });
23277
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23278
- if (!targetNodeId) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
23677
+ if (!decision) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
23678
+ if (decision.kind === "pending") {
23679
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: runnerConfig.deviceId } });
23680
+ return {
23681
+ success: true,
23682
+ kind: "pending"
23683
+ };
23684
+ }
23685
+ const targetNodeId = decision.agentNodeId;
23279
23686
  await this.attachOn(targetNodeId, runnerConfig);
23280
23687
  if (targetNodeId === this.localNodeId) this.pipelineWatchdog?.register(this.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId)));
23281
- const reason = decision?.reason ?? "capacity";
23282
- const pinned = decision?.reason === "manual";
23688
+ const reason = decision.reason;
23689
+ const pinned = decision.reason === "manual";
23283
23690
  this.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, pinned);
23284
23691
  const decoderNodeId = await this.resolveDecoderNode(runnerConfig.deviceId, targetNodeId);
23285
23692
  this.ctx.logger.info("dispatchCamera", {
@@ -23289,12 +23696,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23289
23696
  },
23290
23697
  meta: {
23291
23698
  reason,
23292
- score: decision?.score ?? "n/a",
23699
+ score: decision.score,
23293
23700
  decoderNodeId
23294
23701
  }
23295
23702
  });
23296
23703
  return {
23297
23704
  success: true,
23705
+ kind: "assigned",
23298
23706
  agentNodeId: targetNodeId,
23299
23707
  reason
23300
23708
  };
@@ -23368,10 +23776,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23368
23776
  if (cached) {
23369
23777
  const decision = balance({
23370
23778
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23371
- preferredAgent: null
23779
+ preferredAgent: null,
23780
+ nodeCaps: this.buildNodeCaps()
23372
23781
  });
23373
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23374
- if (targetNodeId) {
23782
+ if (!decision) this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23783
+ else if (decision.kind === "pending") this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: input.deviceId } });
23784
+ else {
23785
+ const targetNodeId = decision.agentNodeId;
23375
23786
  if (current && current.agentNodeId !== targetNodeId) await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
23376
23787
  const msg = errMsg(err);
23377
23788
  this.ctx.logger.debug("unassignPipeline detach-old failed", {
@@ -23380,7 +23791,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23380
23791
  });
23381
23792
  });
23382
23793
  if (!current || current.agentNodeId !== targetNodeId) await this.attachOn(targetNodeId, cached);
23383
- const reason = decision?.reason === "manual" ? "capacity" : decision?.reason ?? "capacity";
23794
+ const reason = decision.reason === "manual" ? "capacity" : decision.reason;
23384
23795
  this.recordAssignment(input.deviceId, targetNodeId, reason, false);
23385
23796
  this.ctx.logger.info("unassignPipeline: re-dispatched via auto", {
23386
23797
  tags: {
@@ -23391,7 +23802,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23391
23802
  });
23392
23803
  return { success: true };
23393
23804
  }
23394
- this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23395
23805
  }
23396
23806
  if (current) {
23397
23807
  await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
@@ -23409,15 +23819,21 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23409
23819
  async rebalance() {
23410
23820
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
23411
23821
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
23822
+ const nodeCaps = this.buildNodeCaps();
23412
23823
  let migrated = 0;
23413
23824
  for (const [deviceId, config] of this.cameraConfigs) {
23414
23825
  const current = this.assignments.get(deviceId);
23415
23826
  if (current?.pinned) continue;
23416
23827
  const decision = balance({
23417
23828
  nodes: loads,
23418
- preferredAgent: await this.readPreferredAgent(deviceId)
23829
+ preferredAgent: await this.readPreferredAgent(deviceId),
23830
+ nodeCaps
23419
23831
  });
23420
23832
  if (!decision) continue;
23833
+ if (decision.kind === "pending") {
23834
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
23835
+ continue;
23836
+ }
23421
23837
  if (current && current.agentNodeId === decision.agentNodeId) continue;
23422
23838
  if (current) await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
23423
23839
  const msg = errMsg(err);
@@ -23555,15 +23971,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23555
23971
  nodeId
23556
23972
  });
23557
23973
  }
23558
- async localRunnerNodeId() {
23559
- const api = this.ctx.api;
23560
- if (!api) return null;
23561
- try {
23562
- return (await api.pipelineRunner.getLocalLoad.query({ nodeId: this.localNodeId }))?.nodeId ?? this.localNodeId;
23563
- } catch {
23564
- return this.localNodeId;
23565
- }
23566
- }
23567
23974
  /**
23568
23975
  * Enumerate every runner-capable node currently known (populated via
23569
23976
  * AgentOnline/AgentOffline events). Used to populate the `enabledNodes`
@@ -23718,6 +24125,18 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23718
24125
  return null;
23719
24126
  }
23720
24127
  }
24128
+ /**
24129
+ * Build a per-node camera cap map from the persisted agent settings.
24130
+ * Returns `null` for nodes where `maxCameras` is null (unlimited).
24131
+ * Used by every `balance()` call so the balancer can honour operator-set
24132
+ * per-node maximums without polling the store on each decision.
24133
+ */
24134
+ buildNodeCaps() {
24135
+ const blob = this.agentSettingsState.get();
24136
+ const caps = {};
24137
+ for (const [nodeId, settings] of Object.entries(blob)) caps[nodeId] = settings.maxCameras ?? null;
24138
+ return caps;
24139
+ }
23721
24140
  async readAudioNodePin(deviceId) {
23722
24141
  if (!this.ctx?.settings) return null;
23723
24142
  try {
@@ -23860,13 +24279,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23860
24279
  for (const { deviceId, config } of affected) {
23861
24280
  const decision = balance({
23862
24281
  nodes: loads,
23863
- preferredAgent: null
24282
+ preferredAgent: null,
24283
+ nodeCaps: this.buildNodeCaps()
23864
24284
  });
23865
24285
  if (!decision) {
23866
24286
  this.ctx.logger.error("Failover: no online runner", { tags: { deviceId } });
23867
24287
  this.assignments.delete(deviceId);
23868
24288
  continue;
23869
24289
  }
24290
+ if (decision.kind === "pending") {
24291
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
24292
+ this.assignments.delete(deviceId);
24293
+ continue;
24294
+ }
23870
24295
  try {
23871
24296
  await this.attachOn(decision.agentNodeId, config);
23872
24297
  this.recordAssignment(deviceId, decision.agentNodeId, "failover", false);
@@ -24346,7 +24771,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24346
24771
  }
24347
24772
  async setAgentAddonDefaults(input) {
24348
24773
  let existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24349
- if (!existing) existing = await this.seedAgentSettingsFromCatalog(input.agentNodeId) ?? void 0;
24774
+ if (!existing) {
24775
+ await this.seedAgentSettingsFromCatalog(input.agentNodeId);
24776
+ existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24777
+ }
24350
24778
  if (!existing) await this.writeAgentSettings(input.agentNodeId, { addonDefaults: { ...input.defaults } });
24351
24779
  else await this.writeAgentSettings(input.agentNodeId, {
24352
24780
  ...existing,
@@ -24384,6 +24812,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24384
24812
  removed: true
24385
24813
  };
24386
24814
  }
24815
+ async setAgentMaxCameras(input) {
24816
+ const existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24817
+ const next = existing ? {
24818
+ ...existing,
24819
+ maxCameras: input.maxCameras
24820
+ } : {
24821
+ addonDefaults: {},
24822
+ maxCameras: input.maxCameras
24823
+ };
24824
+ await this.writeAgentSettings(input.agentNodeId, next);
24825
+ this.ctx.logger.info("agentSettings.maxCameras updated", {
24826
+ tags: { nodeId: input.agentNodeId },
24827
+ meta: { maxCameras: input.maxCameras }
24828
+ });
24829
+ return { success: true };
24830
+ }
24387
24831
  async getCameraSettings(input) {
24388
24832
  return (await this.readCameraSettingsMap())[String(input.deviceId)] ?? null;
24389
24833
  }
@@ -24513,6 +24957,204 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24513
24957
  this.ctx.logger.info("template deleted", { meta: { templateId: input.id } });
24514
24958
  return { success: true };
24515
24959
  }
24960
+ /**
24961
+ * Races a promise against a timeout. Returns `null` on timeout OR rejection.
24962
+ * Never throws — individual stage failures become `null` in the aggregate.
24963
+ *
24964
+ * @param p The stage fetch promise.
24965
+ * @param ms Timeout in milliseconds.
24966
+ */
24967
+ boundedStage(p, ms) {
24968
+ let timer;
24969
+ const timeout = new Promise((resolve) => {
24970
+ timer = setTimeout(() => resolve(null), ms);
24971
+ });
24972
+ return Promise.race([p, timeout]).catch(() => null).finally(() => {
24973
+ if (timer !== void 0) clearTimeout(timer);
24974
+ });
24975
+ }
24976
+ /**
24977
+ * Server-composed aggregated status for a single camera.
24978
+ *
24979
+ * Fans out in parallel (bounded, per-stage graceful degradation) to
24980
+ * broker / decoder / motion / detection / audio / recording source caps
24981
+ * via `ctx.api`. A stage whose source errors or times out becomes `null`
24982
+ * in the returned payload — one slow agent never breaks the whole call.
24983
+ */
24984
+ async getCameraStatus(input) {
24985
+ const { deviceId } = input;
24986
+ const api = this.ctx.api;
24987
+ const STAGE_TIMEOUT_MS = 3e3;
24988
+ const pipelineAssignment = this.assignments.get(deviceId) ?? null;
24989
+ const detectionNodeId = pipelineAssignment?.agentNodeId ?? null;
24990
+ const decoderPinRaw = (api ? await this.ctx.settings?.readDeviceStore(deviceId).catch(() => ({})) ?? {} : {})["decoderNodeId"];
24991
+ const decoderPinned = typeof decoderPinRaw === "string" && decoderPinRaw !== "auto";
24992
+ const decoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
24993
+ const audioAssignment = this.audioAssignments.get(deviceId) ?? null;
24994
+ const audioNodeId = audioAssignment?.nodeId ?? null;
24995
+ const audioPinned = audioAssignment?.pinned ?? false;
24996
+ const pinned = {
24997
+ detection: pipelineAssignment?.pinned ?? false,
24998
+ decoder: decoderPinned,
24999
+ audio: audioPinned
25000
+ };
25001
+ const reasons = {
25002
+ detection: pipelineAssignment?.reason,
25003
+ decoder: decoderPinned ? "manual" : "co-located",
25004
+ audio: audioPinned ? "manual" : void 0
25005
+ };
25006
+ const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query() : null;
25007
+ const sourceFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then((slots) => {
25008
+ return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
25009
+ camStreamId: s.sourceCamStreamId ?? s.brokerId,
25010
+ codec: s.codec ?? "",
25011
+ width: s.resolution?.width ?? 0,
25012
+ height: s.resolution?.height ?? 0,
25013
+ fps: 0,
25014
+ kind: s.profile
25015
+ })) };
25016
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25017
+ const WEBRTC_KINDS = new Set([
25018
+ "webrtc-browser",
25019
+ "webrtc-mobile",
25020
+ "webrtc-whep"
25021
+ ]);
25022
+ const brokerFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then(async (slots) => {
25023
+ const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
25024
+ if (deviceSlots.length === 0) return {
25025
+ profiles: [],
25026
+ webrtcSessions: 0,
25027
+ rtspRestream: false
25028
+ };
25029
+ const [statsAndClients, rtspEntry] = await Promise.all([Promise.all(deviceSlots.map(async (slot) => {
25030
+ return {
25031
+ slot,
25032
+ stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }).catch(() => null),
25033
+ clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }).catch(() => null)
25034
+ };
25035
+ })), api.streamBroker.getAllRtspEntries.query({}).catch(() => null)]);
25036
+ return {
25037
+ profiles: statsAndClients.map(({ slot, stats, clients }) => ({
25038
+ profile: slot.profile,
25039
+ status: slot.status,
25040
+ codec: stats?.codec ?? slot.codec ?? "",
25041
+ width: slot.resolution?.width ?? 0,
25042
+ height: slot.resolution?.height ?? 0,
25043
+ subscribers: clients?.encodedSubscribers ?? 0,
25044
+ inFps: stats?.inputFps ?? 0,
25045
+ outFps: stats?.decodeFps ?? 0
25046
+ })),
25047
+ webrtcSessions: statsAndClients.reduce((total, { clients }) => {
25048
+ if (!clients) return total;
25049
+ return total + clients.encoded.filter((c) => WEBRTC_KINDS.has(c.attribution.kind)).length;
25050
+ }, 0),
25051
+ rtspRestream: rtspEntry?.some((e) => {
25052
+ return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
25053
+ }) ?? false
25054
+ };
25055
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25056
+ const decoderFetch = decoderNodeId ? Promise.resolve({
25057
+ nodeId: decoderNodeId,
25058
+ formats: [],
25059
+ sessionCount: 0,
25060
+ shm: {
25061
+ framesWritten: 0,
25062
+ getFrameHits: 0,
25063
+ getFrameMisses: 0,
25064
+ budgetMb: 0
25065
+ }
25066
+ }) : Promise.resolve(null);
25067
+ const motionResult = (() => {
25068
+ const config = this.cameraConfigs.get(deviceId);
25069
+ if (!config) return null;
25070
+ return {
25071
+ enabled: config.motionSources.includes("analyzer") || config.motionSources.includes("onboard"),
25072
+ fps: config.motionFps
25073
+ };
25074
+ })();
25075
+ const detectionFetch = api && detectionNodeId ? this.boundedStage(Promise.all([api.pipelineExecutor.getEngineProvisioning.query({ nodeId: detectionNodeId }).catch(() => null), api.pipelineExecutor.getSelectedEngine.query({ nodeId: detectionNodeId }).catch(() => null)]).then(async ([provisioning, engine]) => {
25076
+ const metrics = await api.pipelineRunner.getCameraMetrics.query({
25077
+ deviceId,
25078
+ nodeId: detectionNodeId
25079
+ }).catch(() => null);
25080
+ const phase = (() => {
25081
+ const p = metrics?.phase;
25082
+ if (p === "active") return "active";
25083
+ if (p === "idle") return "idle";
25084
+ return "watching";
25085
+ })();
25086
+ return {
25087
+ nodeId: detectionNodeId,
25088
+ engine: {
25089
+ backend: engine?.backend ?? "",
25090
+ device: engine?.device ?? ""
25091
+ },
25092
+ phase,
25093
+ configuredFps: metrics?.configuredFps ?? 0,
25094
+ actualFps: metrics?.actualFps ?? 0,
25095
+ queueDepth: metrics?.queueDepth ?? 0,
25096
+ avgInferenceMs: metrics?.avgInferenceTimeMs ?? 0,
25097
+ provisioning: {
25098
+ state: provisioning?.state ?? "idle",
25099
+ ...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
25100
+ }
25101
+ };
25102
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25103
+ const audioResult = audioNodeId ? {
25104
+ nodeId: audioNodeId,
25105
+ enabled: true
25106
+ } : null;
25107
+ function isRecordingStatus(v) {
25108
+ return v !== null && typeof v === "object" && "activeMode" in v && (v.activeMode === "off" || v.activeMode === "continuous" || v.activeMode === "events") && "enabled" in v && typeof v.enabled === "boolean" && "storageBytes" in v && typeof v.storageBytes === "number";
25109
+ }
25110
+ const recordingFetch = api ? this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
25111
+ if (!isRecordingStatus(rawStatus)) return null;
25112
+ return {
25113
+ mode: rawStatus.activeMode,
25114
+ active: rawStatus.enabled && rawStatus.activeMode !== "off",
25115
+ storageBytes: rawStatus.storageBytes
25116
+ };
25117
+ }).catch(() => null), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25118
+ const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult] = await Promise.all([
25119
+ sourceFetch,
25120
+ brokerFetch,
25121
+ decoderFetch,
25122
+ detectionFetch,
25123
+ recordingFetch
25124
+ ]);
25125
+ return composeCameraStatus({
25126
+ deviceId,
25127
+ fetchedAt: Date.now(),
25128
+ detectionNodeId,
25129
+ decoderNodeId,
25130
+ audioNodeId,
25131
+ pinned,
25132
+ reasons,
25133
+ sourceResult,
25134
+ brokerResult,
25135
+ decoderResult,
25136
+ motionResult,
25137
+ detectionResult,
25138
+ audioResult,
25139
+ recordingResult
25140
+ });
25141
+ }
25142
+ /**
25143
+ * Server-composed aggregated status for multiple cameras in one call.
25144
+ *
25145
+ * `deviceIds` defaults to all cameras currently tracked by the
25146
+ * orchestrator's assignment map when omitted.
25147
+ *
25148
+ * v1: `Promise.all` over per-device composition (no concurrency cap).
25149
+ * Note: for large fleets (hundreds of cameras) this may fan out many
25150
+ * parallel calls. A concurrency limiter (p-limit / semaphore) should be
25151
+ * added if latency measurements show it's necessary — deliberately
25152
+ * deferred per the YAGNI constraint in the spec.
25153
+ */
25154
+ async getCameraStatuses(input) {
25155
+ const ids = input.deviceIds !== void 0 && input.deviceIds.length > 0 ? input.deviceIds : [...this.assignments.keys()];
25156
+ return Promise.all(ids.map((deviceId) => this.getCameraStatus({ deviceId })));
25157
+ }
24516
25158
  /** Read the templates map from the addon store via the durable handle. */
24517
25159
  async readTemplatesMap() {
24518
25160
  const raw = await this.templatesState.get();
@@ -24543,7 +25185,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24543
25185
  return;
24544
25186
  }
24545
25187
  const all = await this.readAgentSettingsMap();
24546
- all[nodeId] = settings;
25188
+ const existing = all[nodeId];
25189
+ all[nodeId] = {
25190
+ addonDefaults: settings.addonDefaults,
25191
+ maxCameras: settings.maxCameras !== void 0 ? settings.maxCameras ?? null : existing?.maxCameras ?? null
25192
+ };
24547
25193
  await this.agentSettingsState.set(all);
24548
25194
  }
24549
25195
  /** Read the `cameraSettings` map (keyed on `deviceId` as string) via the durable handle. */
@@ -24753,12 +25399,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24753
25399
  }
24754
25400
  let agent = (await this.readAgentSettingsMap())[nodeId];
24755
25401
  if (!agent || Object.keys(agent.addonDefaults ?? {}).length === 0) {
24756
- const seeded = await this.seedAgentSettingsFromCatalog(nodeId);
24757
- if (!seeded) {
25402
+ if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
24758
25403
  await sleep$1(2e3);
24759
25404
  continue;
24760
25405
  }
24761
- agent = seeded;
25406
+ agent = (await this.readAgentSettingsMap())[nodeId];
25407
+ }
25408
+ if (!agent) {
25409
+ await sleep$1(2e3);
25410
+ continue;
24762
25411
  }
24763
25412
  const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
24764
25413
  return {
@@ -25665,7 +26314,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25665
26314
  };
25666
26315
  let dispatchedNodeId = null;
25667
26316
  try {
25668
- dispatchedNodeId = (await this.dispatchCamera(runnerConfig)).agentNodeId;
26317
+ const result = await this.dispatchCamera(runnerConfig);
26318
+ if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
25669
26319
  } catch (err) {
25670
26320
  const msg = errMsg(err);
25671
26321
  log.error("dispatchCamera failed", { meta: { error: msg } });