@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.mjs CHANGED
@@ -4981,6 +4981,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4981
4981
  */
4982
4982
  EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
4983
4983
  /**
4984
+ * Per-node detection-engine runtime-provisioning transition. Emitted by
4985
+ * the detection-pipeline provider on every state change of its lazy
4986
+ * engine-provisioning machine (idle → installing → verifying → ready,
4987
+ * or → failed with a `nextRetryAt`). Payload is the
4988
+ * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
4989
+ * node. The Pipeline page subscribes to drive a live "installing
4990
+ * OpenVINO… / ready" indicator per node without polling
4991
+ * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
4992
+ * also reads the cap snapshot on mount / reconnect. Phase 2.
4993
+ */
4994
+ EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
4995
+ /**
4984
4996
  * Cluster topology snapshot. Carries the same payload returned by
4985
4997
  * `nodes.topology` (every reachable node + addons + processes).
4986
4998
  * Emitted by the hub on any agent / addon lifecycle change
@@ -9562,6 +9574,24 @@ var DetectorOutputSchema = object({
9562
9574
  inferenceMs: number(),
9563
9575
  modelId: string()
9564
9576
  });
9577
+ var EngineProvisioningSchema = object({
9578
+ runtimeId: _enum([
9579
+ "onnx",
9580
+ "openvino",
9581
+ "coreml"
9582
+ ]).nullable(),
9583
+ device: string().nullable(),
9584
+ state: _enum([
9585
+ "idle",
9586
+ "installing",
9587
+ "verifying",
9588
+ "ready",
9589
+ "failed"
9590
+ ]),
9591
+ progress: number().optional(),
9592
+ error: string().optional(),
9593
+ nextRetryAt: number().optional()
9594
+ });
9565
9595
  var PipelineStepInputSchema = lazy(() => object({
9566
9596
  addonId: string(),
9567
9597
  modelId: string(),
@@ -9661,6 +9691,15 @@ var pipelineExecutorCapability = {
9661
9691
  kind: "mutation",
9662
9692
  auth: "admin"
9663
9693
  }),
9694
+ /**
9695
+ * Per-node detection-engine provisioning snapshot. Returns the live
9696
+ * state of the lazy runtime-provisioning machine on `nodeId`
9697
+ * (idle / installing / verifying / ready / failed). The UI pairs this
9698
+ * one-shot query with the `pipeline.engine-provisioning` live event
9699
+ * (emitted on every transition) to drive a per-node "engine ready?"
9700
+ * indicator without polling. Phase 2.
9701
+ */
9702
+ getEngineProvisioning: method(object({ nodeId: string() }), EngineProvisioningSchema),
9664
9703
  getVideoPipelineSteps: method(_void(), record(string(), object({
9665
9704
  modelId: string(),
9666
9705
  settings: record(string(), unknown()).readonly()
@@ -13537,7 +13576,10 @@ var AgentAddonConfigSchema = object({
13537
13576
  modelId: string(),
13538
13577
  settings: record(string(), unknown()).readonly()
13539
13578
  });
13540
- var AgentPipelineSettingsSchema = object({ addonDefaults: record(string(), AgentAddonConfigSchema).readonly() });
13579
+ var AgentPipelineSettingsSchema = object({
13580
+ addonDefaults: record(string(), AgentAddonConfigSchema).readonly(),
13581
+ maxCameras: number().int().nonnegative().nullable().default(null)
13582
+ });
13541
13583
  var CameraPipelineForAgentSchema = object({
13542
13584
  steps: array(PipelineStepInputSchema).readonly(),
13543
13585
  audio: object({
@@ -13639,6 +13681,133 @@ var GlobalMetricsSchema = object({
13639
13681
  * capability providers.
13640
13682
  */
13641
13683
  var CapabilityBindingsSchema = record(string(), string());
13684
+ /** Source block — always present; derives from the stream catalog. */
13685
+ var CameraSourceStatusSchema = object({ streams: array(object({
13686
+ camStreamId: string(),
13687
+ codec: string(),
13688
+ width: number(),
13689
+ height: number(),
13690
+ fps: number(),
13691
+ kind: string()
13692
+ })).readonly() });
13693
+ /** Assignment block — always present (orchestrator-local, no remote call). */
13694
+ var CameraAssignmentStatusSchema = object({
13695
+ detectionNodeId: string().nullable(),
13696
+ decoderNodeId: string().nullable(),
13697
+ audioNodeId: string().nullable(),
13698
+ pinned: object({
13699
+ detection: boolean(),
13700
+ decoder: boolean(),
13701
+ audio: boolean()
13702
+ }),
13703
+ reasons: object({
13704
+ detection: string().optional(),
13705
+ decoder: string().optional(),
13706
+ audio: string().optional()
13707
+ })
13708
+ });
13709
+ /** Broker block — null when the broker stage is unreachable or inactive. */
13710
+ var CameraBrokerStatusSchema = object({
13711
+ profiles: array(object({
13712
+ profile: string(),
13713
+ status: string(),
13714
+ codec: string(),
13715
+ width: number(),
13716
+ height: number(),
13717
+ subscribers: number(),
13718
+ inFps: number(),
13719
+ outFps: number()
13720
+ })).readonly(),
13721
+ webrtcSessions: number(),
13722
+ rtspRestream: boolean()
13723
+ });
13724
+ /** Shared-memory ring statistics within the decoder block. */
13725
+ var CameraDecoderShmSchema = object({
13726
+ framesWritten: number(),
13727
+ getFrameHits: number(),
13728
+ getFrameMisses: number(),
13729
+ budgetMb: number()
13730
+ });
13731
+ /** Decoder block — null when the decoder stage is unreachable or inactive. */
13732
+ var CameraDecoderStatusSchema = object({
13733
+ nodeId: string(),
13734
+ formats: array(string()).readonly(),
13735
+ sessionCount: number(),
13736
+ shm: CameraDecoderShmSchema
13737
+ });
13738
+ /** Motion block — null when motion detection is not active for this device. */
13739
+ var CameraMotionStatusSchema = object({
13740
+ enabled: boolean(),
13741
+ fps: number()
13742
+ });
13743
+ /** Detection provisioning sub-block. */
13744
+ var CameraDetectionProvisioningSchema = object({
13745
+ state: _enum([
13746
+ "idle",
13747
+ "installing",
13748
+ "verifying",
13749
+ "ready",
13750
+ "failed"
13751
+ ]),
13752
+ error: string().optional()
13753
+ });
13754
+ /** Detection phase — derived from the runner's engine phase. */
13755
+ var CameraDetectionPhaseSchema = _enum([
13756
+ "idle",
13757
+ "watching",
13758
+ "active"
13759
+ ]);
13760
+ /** Detection block — null when no detection node is assigned or reachable. */
13761
+ var CameraDetectionStatusSchema = object({
13762
+ nodeId: string(),
13763
+ engine: object({
13764
+ backend: string(),
13765
+ device: string()
13766
+ }),
13767
+ phase: CameraDetectionPhaseSchema,
13768
+ configuredFps: number(),
13769
+ actualFps: number(),
13770
+ queueDepth: number(),
13771
+ avgInferenceMs: number(),
13772
+ provisioning: CameraDetectionProvisioningSchema
13773
+ });
13774
+ /** Audio block — null when no audio node is assigned or reachable. */
13775
+ var CameraAudioStatusSchema = object({
13776
+ nodeId: string(),
13777
+ enabled: boolean()
13778
+ });
13779
+ /** Recording block — null when no recording cap is active for this device. */
13780
+ var CameraRecordingStatusSchema = object({
13781
+ mode: _enum([
13782
+ "off",
13783
+ "continuous",
13784
+ "events"
13785
+ ]),
13786
+ active: boolean(),
13787
+ storageBytes: number()
13788
+ });
13789
+ /**
13790
+ * Aggregated per-camera pipeline status — server-composed, single call.
13791
+ *
13792
+ * The `assignment` and `source` blocks are always present.
13793
+ * Every other block is `null` when the stage is inactive or unreachable
13794
+ * during the bounded parallel fan-out in the orchestrator implementation.
13795
+ *
13796
+ * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
13797
+ */
13798
+ var CameraStatusSchema = object({
13799
+ deviceId: number(),
13800
+ assignment: CameraAssignmentStatusSchema,
13801
+ source: CameraSourceStatusSchema,
13802
+ broker: CameraBrokerStatusSchema.nullable(),
13803
+ decoder: CameraDecoderStatusSchema.nullable(),
13804
+ motion: CameraMotionStatusSchema.nullable(),
13805
+ detection: CameraDetectionStatusSchema.nullable(),
13806
+ audio: CameraAudioStatusSchema.nullable(),
13807
+ recording: CameraRecordingStatusSchema.nullable(),
13808
+ /** Unix timestamp (ms) when this snapshot was composed server-side. */
13809
+ fetchedAt: number()
13810
+ });
13642
13811
  /**
13643
13812
  * Pipeline Orchestrator capability — global load balancer + camera dispatcher.
13644
13813
  *
@@ -13815,6 +13984,25 @@ var pipelineOrchestratorCapability = {
13815
13984
  kind: "mutation",
13816
13985
  auth: "admin"
13817
13986
  }),
13987
+ /**
13988
+ * Set the per-node camera cap for one agent.
13989
+ *
13990
+ * `maxCameras: 0` and `maxCameras: null` are both treated as unlimited
13991
+ * by the load balancer (0 is stored as-is; the balancer treats ≤0 as
13992
+ * unlimited alongside null). Pass `null` to clear an existing cap.
13993
+ * Note: the admin UI normalises 0→null before calling this method, so
13994
+ * `maxCameras: 0` only reaches the store via direct SDK or CLI calls.
13995
+ * Changes take effect immediately on the next dispatch cycle — cameras
13996
+ * currently assigned over-cap are left in place (no forced eviction),
13997
+ * but new assignments obey the updated cap.
13998
+ */
13999
+ setAgentMaxCameras: method(object({
14000
+ agentNodeId: string(),
14001
+ maxCameras: number().int().nonnegative().nullable()
14002
+ }), object({ success: literal(true) }), {
14003
+ kind: "mutation",
14004
+ auth: "admin"
14005
+ }),
13818
14006
  /** Read one camera's settings. Null when never touched (inherits agent defaults fully). */
13819
14007
  getCameraSettings: method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()),
13820
14008
  /** Set or clear the 3-state toggle for one (camera, addonId). Pass `enabled: null` to clear and revert to agent default. */
@@ -13855,6 +14043,29 @@ var pipelineOrchestratorCapability = {
13855
14043
  deviceId: number(),
13856
14044
  agentNodeId: string().optional()
13857
14045
  }), CameraPipelineConfigSchema),
14046
+ /**
14047
+ * Server-composed aggregated status for a single camera.
14048
+ *
14049
+ * Fans out in parallel (bounded, per-stage graceful degradation) to
14050
+ * broker / decoder / motion / detection / audio / recording source caps
14051
+ * via `ctx.api`. A stage whose source errors or times out becomes `null`
14052
+ * in the returned payload — one slow agent never breaks the whole call.
14053
+ *
14054
+ * Intended for "on open / on camera select" snapshots. Live deltas
14055
+ * keep arriving from the existing ~1Hz events the UI already subscribes to.
14056
+ */
14057
+ getCameraStatus: method(object({ deviceId: number() }), CameraStatusSchema),
14058
+ /**
14059
+ * Server-composed aggregated status for multiple cameras in one call.
14060
+ *
14061
+ * `deviceIds` defaults to all cameras currently tracked by the
14062
+ * orchestrator's assignment map when omitted.
14063
+ *
14064
+ * Runs per-device composition in parallel (bounded). Use this to
14065
+ * populate the cluster assignments table and the pipeline flow overview
14066
+ * rail without issuing N parallel browser round-trips.
14067
+ */
14068
+ getCameraStatuses: method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()),
13858
14069
  /** List every template the operator has saved. */
13859
14070
  listTemplates: method(_void(), array(PipelineTemplateSchema).readonly()),
13860
14071
  /** Create a new named preset from a given CameraPipelineConfig. */
@@ -19694,6 +19905,12 @@ Object.freeze({
19694
19905
  addonId: null,
19695
19906
  access: "view"
19696
19907
  },
19908
+ "pipelineExecutor.getEngineProvisioning": {
19909
+ capName: "pipeline-executor",
19910
+ capScope: "system",
19911
+ addonId: null,
19912
+ access: "view"
19913
+ },
19697
19914
  "pipelineExecutor.getGlobalPipelineConfig": {
19698
19915
  capName: "pipeline-executor",
19699
19916
  capScope: "system",
@@ -19898,6 +20115,18 @@ Object.freeze({
19898
20115
  addonId: null,
19899
20116
  access: "view"
19900
20117
  },
20118
+ "pipelineOrchestrator.getCameraStatus": {
20119
+ capName: "pipeline-orchestrator",
20120
+ capScope: "system",
20121
+ addonId: null,
20122
+ access: "view"
20123
+ },
20124
+ "pipelineOrchestrator.getCameraStatuses": {
20125
+ capName: "pipeline-orchestrator",
20126
+ capScope: "system",
20127
+ addonId: null,
20128
+ access: "view"
20129
+ },
19901
20130
  "pipelineOrchestrator.getCameraStepOverrides": {
19902
20131
  capName: "pipeline-orchestrator",
19903
20132
  capScope: "system",
@@ -19982,6 +20211,12 @@ Object.freeze({
19982
20211
  addonId: null,
19983
20212
  access: "create"
19984
20213
  },
20214
+ "pipelineOrchestrator.setAgentMaxCameras": {
20215
+ capName: "pipeline-orchestrator",
20216
+ capScope: "system",
20217
+ addonId: null,
20218
+ access: "create"
20219
+ },
19985
20220
  "pipelineOrchestrator.setCameraPipelineForAgent": {
19986
20221
  capName: "pipeline-orchestrator",
19987
20222
  capScope: "system",
@@ -22012,7 +22247,10 @@ var StoredAgentAddonConfigSchema = object({
22012
22247
  modelId: string(),
22013
22248
  settings: record(string(), unknown()).readonly()
22014
22249
  });
22015
- var StoredAgentPipelineSettingsSchema = object({ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly() });
22250
+ var StoredAgentPipelineSettingsSchema = object({
22251
+ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly(),
22252
+ maxCameras: number().int().nonnegative().nullable().default(null)
22253
+ });
22016
22254
  var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
22017
22255
  var StoredCameraStepOverridePatchSchema = object({
22018
22256
  enabled: boolean().optional(),
@@ -22057,10 +22295,26 @@ function computeCapacityScore(load) {
22057
22295
  return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
22058
22296
  }
22059
22297
  /**
22298
+ * Returns true when the node has remaining capacity.
22299
+ * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
22300
+ * its `attachedCameras` count is strictly less than the cap.
22301
+ * Pins count toward the cap via `attachedCameras`.
22302
+ */
22303
+ function isEligible(node, caps) {
22304
+ const cap = caps?.[node.nodeId];
22305
+ if (cap === null || cap === void 0 || cap <= 0) return true;
22306
+ return node.attachedCameras < cap;
22307
+ }
22308
+ /**
22060
22309
  * Run the two-level camera balancer.
22061
22310
  *
22062
- * L1 (manual affinity): if `preferredAgent` names an online node, return it.
22063
- * L2 (capacity): compute capacity scores and pick the lowest.
22311
+ * L1 (manual affinity): if `preferredAgent` names an online node AND that
22312
+ * node is under its `maxCameras` cap, return it. If the node is online but at
22313
+ * or over cap, return `{kind:'pending'}` — a pinned camera is never silently
22314
+ * over-assigned.
22315
+ *
22316
+ * L2 (capacity): filter to eligible nodes and pick the lowest capacity score.
22317
+ * If all nodes are at/over cap, return `{kind:'pending'}`.
22064
22318
  *
22065
22319
  * Returns `null` when no runners are online. The orchestrator decides how to
22066
22320
  * react — typically by logging and deferring the assignment until a runner
@@ -22069,19 +22323,32 @@ function computeCapacityScore(load) {
22069
22323
  function balance(input) {
22070
22324
  const online = input.nodes.filter((n) => n.nodeId.length > 0);
22071
22325
  if (online.length === 0) return null;
22326
+ const eligible = online.filter((n) => isEligible(n, input.nodeCaps));
22072
22327
  if (input.preferredAgent) {
22073
- const match = online.find((n) => n.nodeId === input.preferredAgent);
22074
- if (match) return {
22075
- agentNodeId: match.nodeId,
22076
- reason: "manual",
22077
- score: computeCapacityScore(match)
22078
- };
22328
+ const pinnedOnline = online.find((n) => n.nodeId === input.preferredAgent);
22329
+ if (pinnedOnline) {
22330
+ if (eligible.some((n) => n.nodeId === pinnedOnline.nodeId)) return {
22331
+ kind: "assigned",
22332
+ agentNodeId: pinnedOnline.nodeId,
22333
+ reason: "manual",
22334
+ score: computeCapacityScore(pinnedOnline)
22335
+ };
22336
+ return {
22337
+ kind: "pending",
22338
+ reason: "over-cap"
22339
+ };
22340
+ }
22079
22341
  }
22080
- const best = online.map((node) => ({
22342
+ if (eligible.length === 0) return {
22343
+ kind: "pending",
22344
+ reason: "over-cap"
22345
+ };
22346
+ const best = eligible.map((node) => ({
22081
22347
  node,
22082
22348
  score: computeCapacityScore(node)
22083
22349
  })).toSorted((a, b) => a.score - b.score)[0];
22084
22350
  return {
22351
+ kind: "assigned",
22085
22352
  agentNodeId: best.node.nodeId,
22086
22353
  reason: "capacity",
22087
22354
  score: best.score
@@ -22500,6 +22767,139 @@ var PipelineWatchdog = class {
22500
22767
  }
22501
22768
  };
22502
22769
  //#endregion
22770
+ //#region src/camera-status/compose-camera-status.ts
22771
+ function mapAssignment(input) {
22772
+ return {
22773
+ detectionNodeId: input.detectionNodeId,
22774
+ decoderNodeId: input.decoderNodeId,
22775
+ audioNodeId: input.audioNodeId,
22776
+ pinned: {
22777
+ detection: input.pinned.detection,
22778
+ decoder: input.pinned.decoder,
22779
+ audio: input.pinned.audio
22780
+ },
22781
+ reasons: {
22782
+ detection: input.reasons.detection,
22783
+ decoder: input.reasons.decoder,
22784
+ audio: input.reasons.audio
22785
+ }
22786
+ };
22787
+ }
22788
+ function mapSource(sourceResult) {
22789
+ if (sourceResult === null) return { streams: [] };
22790
+ return { streams: sourceResult.streams.map((s) => ({
22791
+ camStreamId: s.camStreamId,
22792
+ codec: s.codec,
22793
+ width: s.width,
22794
+ height: s.height,
22795
+ fps: s.fps,
22796
+ kind: s.kind
22797
+ })) };
22798
+ }
22799
+ function mapBroker(brokerResult) {
22800
+ if (brokerResult === null) return null;
22801
+ return {
22802
+ profiles: brokerResult.profiles.map((p) => ({
22803
+ profile: p.profile,
22804
+ status: p.status,
22805
+ codec: p.codec,
22806
+ width: p.width,
22807
+ height: p.height,
22808
+ subscribers: p.subscribers,
22809
+ inFps: p.inFps,
22810
+ outFps: p.outFps
22811
+ })),
22812
+ webrtcSessions: brokerResult.webrtcSessions,
22813
+ rtspRestream: brokerResult.rtspRestream
22814
+ };
22815
+ }
22816
+ function mapDecoderShm(shm) {
22817
+ return {
22818
+ framesWritten: shm.framesWritten,
22819
+ getFrameHits: shm.getFrameHits,
22820
+ getFrameMisses: shm.getFrameMisses,
22821
+ budgetMb: shm.budgetMb
22822
+ };
22823
+ }
22824
+ function mapDecoder(decoderResult) {
22825
+ if (decoderResult === null) return null;
22826
+ return {
22827
+ nodeId: decoderResult.nodeId,
22828
+ formats: [...decoderResult.formats],
22829
+ sessionCount: decoderResult.sessionCount,
22830
+ shm: mapDecoderShm(decoderResult.shm)
22831
+ };
22832
+ }
22833
+ function mapMotion(motionResult) {
22834
+ if (motionResult === null) return null;
22835
+ return {
22836
+ enabled: motionResult.enabled,
22837
+ fps: motionResult.fps
22838
+ };
22839
+ }
22840
+ function mapProvisioning(p) {
22841
+ if (p.error !== void 0) return {
22842
+ state: p.state,
22843
+ error: p.error
22844
+ };
22845
+ return { state: p.state };
22846
+ }
22847
+ function mapDetection(detectionResult) {
22848
+ if (detectionResult === null) return null;
22849
+ const phase = detectionResult.phase;
22850
+ return {
22851
+ nodeId: detectionResult.nodeId,
22852
+ engine: {
22853
+ backend: detectionResult.engine.backend,
22854
+ device: detectionResult.engine.device
22855
+ },
22856
+ phase,
22857
+ configuredFps: detectionResult.configuredFps,
22858
+ actualFps: detectionResult.actualFps,
22859
+ queueDepth: detectionResult.queueDepth,
22860
+ avgInferenceMs: detectionResult.avgInferenceMs,
22861
+ provisioning: mapProvisioning(detectionResult.provisioning)
22862
+ };
22863
+ }
22864
+ function mapAudio(audioResult) {
22865
+ if (audioResult === null) return null;
22866
+ return {
22867
+ nodeId: audioResult.nodeId,
22868
+ enabled: audioResult.enabled
22869
+ };
22870
+ }
22871
+ function mapRecording(recordingResult) {
22872
+ if (recordingResult === null) return null;
22873
+ return {
22874
+ mode: recordingResult.mode,
22875
+ active: recordingResult.active,
22876
+ storageBytes: recordingResult.storageBytes
22877
+ };
22878
+ }
22879
+ /**
22880
+ * Pure function that composes a `CameraStatus` from per-stage fetch results.
22881
+ *
22882
+ * - `assignment` is always built from orchestrator-local data (never null).
22883
+ * - `source` always present: defaults to `{ streams: [] }` when sourceResult is null.
22884
+ * - Every other block is null when its stage result is null (graceful degradation).
22885
+ * - `fetchedAt` is stamped exactly as provided — never calls `Date.now()`.
22886
+ * - No mutation of the input.
22887
+ */
22888
+ function composeCameraStatus(input) {
22889
+ return {
22890
+ deviceId: input.deviceId,
22891
+ assignment: mapAssignment(input),
22892
+ source: mapSource(input.sourceResult),
22893
+ broker: mapBroker(input.brokerResult),
22894
+ decoder: mapDecoder(input.decoderResult),
22895
+ motion: mapMotion(input.motionResult),
22896
+ detection: mapDetection(input.detectionResult),
22897
+ audio: mapAudio(input.audioResult),
22898
+ recording: mapRecording(input.recordingResult),
22899
+ fetchedAt: input.fetchedAt
22900
+ };
22901
+ }
22902
+ //#endregion
22503
22903
  //#region src/index.ts
22504
22904
  var PHASE_MODE_VALUES = new Set([
22505
22905
  "disabled",
@@ -23257,8 +23657,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23257
23657
  });
23258
23658
  return {
23259
23659
  success: true,
23260
- agentNodeId: "",
23261
- reason: "capacity"
23660
+ kind: "pending"
23262
23661
  };
23263
23662
  }
23264
23663
  }
@@ -23268,14 +23667,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23268
23667
  const preferredAgent = typeof pipelinePin === "string" && pipelinePin !== "auto" ? pipelinePin : legacyPreferred;
23269
23668
  const decision = balance({
23270
23669
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23271
- preferredAgent
23670
+ preferredAgent,
23671
+ nodeCaps: this.buildNodeCaps()
23272
23672
  });
23273
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23274
- if (!targetNodeId) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
23673
+ if (!decision) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
23674
+ if (decision.kind === "pending") {
23675
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: runnerConfig.deviceId } });
23676
+ return {
23677
+ success: true,
23678
+ kind: "pending"
23679
+ };
23680
+ }
23681
+ const targetNodeId = decision.agentNodeId;
23275
23682
  await this.attachOn(targetNodeId, runnerConfig);
23276
23683
  if (targetNodeId === this.localNodeId) this.pipelineWatchdog?.register(this.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId)));
23277
- const reason = decision?.reason ?? "capacity";
23278
- const pinned = decision?.reason === "manual";
23684
+ const reason = decision.reason;
23685
+ const pinned = decision.reason === "manual";
23279
23686
  this.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, pinned);
23280
23687
  const decoderNodeId = await this.resolveDecoderNode(runnerConfig.deviceId, targetNodeId);
23281
23688
  this.ctx.logger.info("dispatchCamera", {
@@ -23285,12 +23692,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23285
23692
  },
23286
23693
  meta: {
23287
23694
  reason,
23288
- score: decision?.score ?? "n/a",
23695
+ score: decision.score,
23289
23696
  decoderNodeId
23290
23697
  }
23291
23698
  });
23292
23699
  return {
23293
23700
  success: true,
23701
+ kind: "assigned",
23294
23702
  agentNodeId: targetNodeId,
23295
23703
  reason
23296
23704
  };
@@ -23364,10 +23772,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23364
23772
  if (cached) {
23365
23773
  const decision = balance({
23366
23774
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23367
- preferredAgent: null
23775
+ preferredAgent: null,
23776
+ nodeCaps: this.buildNodeCaps()
23368
23777
  });
23369
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23370
- if (targetNodeId) {
23778
+ if (!decision) this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23779
+ else if (decision.kind === "pending") this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: input.deviceId } });
23780
+ else {
23781
+ const targetNodeId = decision.agentNodeId;
23371
23782
  if (current && current.agentNodeId !== targetNodeId) await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
23372
23783
  const msg = errMsg(err);
23373
23784
  this.ctx.logger.debug("unassignPipeline detach-old failed", {
@@ -23376,7 +23787,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23376
23787
  });
23377
23788
  });
23378
23789
  if (!current || current.agentNodeId !== targetNodeId) await this.attachOn(targetNodeId, cached);
23379
- const reason = decision?.reason === "manual" ? "capacity" : decision?.reason ?? "capacity";
23790
+ const reason = decision.reason === "manual" ? "capacity" : decision.reason;
23380
23791
  this.recordAssignment(input.deviceId, targetNodeId, reason, false);
23381
23792
  this.ctx.logger.info("unassignPipeline: re-dispatched via auto", {
23382
23793
  tags: {
@@ -23387,7 +23798,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23387
23798
  });
23388
23799
  return { success: true };
23389
23800
  }
23390
- this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23391
23801
  }
23392
23802
  if (current) {
23393
23803
  await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
@@ -23405,15 +23815,21 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23405
23815
  async rebalance() {
23406
23816
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
23407
23817
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
23818
+ const nodeCaps = this.buildNodeCaps();
23408
23819
  let migrated = 0;
23409
23820
  for (const [deviceId, config] of this.cameraConfigs) {
23410
23821
  const current = this.assignments.get(deviceId);
23411
23822
  if (current?.pinned) continue;
23412
23823
  const decision = balance({
23413
23824
  nodes: loads,
23414
- preferredAgent: await this.readPreferredAgent(deviceId)
23825
+ preferredAgent: await this.readPreferredAgent(deviceId),
23826
+ nodeCaps
23415
23827
  });
23416
23828
  if (!decision) continue;
23829
+ if (decision.kind === "pending") {
23830
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
23831
+ continue;
23832
+ }
23417
23833
  if (current && current.agentNodeId === decision.agentNodeId) continue;
23418
23834
  if (current) await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
23419
23835
  const msg = errMsg(err);
@@ -23551,15 +23967,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23551
23967
  nodeId
23552
23968
  });
23553
23969
  }
23554
- async localRunnerNodeId() {
23555
- const api = this.ctx.api;
23556
- if (!api) return null;
23557
- try {
23558
- return (await api.pipelineRunner.getLocalLoad.query({ nodeId: this.localNodeId }))?.nodeId ?? this.localNodeId;
23559
- } catch {
23560
- return this.localNodeId;
23561
- }
23562
- }
23563
23970
  /**
23564
23971
  * Enumerate every runner-capable node currently known (populated via
23565
23972
  * AgentOnline/AgentOffline events). Used to populate the `enabledNodes`
@@ -23714,6 +24121,18 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23714
24121
  return null;
23715
24122
  }
23716
24123
  }
24124
+ /**
24125
+ * Build a per-node camera cap map from the persisted agent settings.
24126
+ * Returns `null` for nodes where `maxCameras` is null (unlimited).
24127
+ * Used by every `balance()` call so the balancer can honour operator-set
24128
+ * per-node maximums without polling the store on each decision.
24129
+ */
24130
+ buildNodeCaps() {
24131
+ const blob = this.agentSettingsState.get();
24132
+ const caps = {};
24133
+ for (const [nodeId, settings] of Object.entries(blob)) caps[nodeId] = settings.maxCameras ?? null;
24134
+ return caps;
24135
+ }
23717
24136
  async readAudioNodePin(deviceId) {
23718
24137
  if (!this.ctx?.settings) return null;
23719
24138
  try {
@@ -23856,13 +24275,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23856
24275
  for (const { deviceId, config } of affected) {
23857
24276
  const decision = balance({
23858
24277
  nodes: loads,
23859
- preferredAgent: null
24278
+ preferredAgent: null,
24279
+ nodeCaps: this.buildNodeCaps()
23860
24280
  });
23861
24281
  if (!decision) {
23862
24282
  this.ctx.logger.error("Failover: no online runner", { tags: { deviceId } });
23863
24283
  this.assignments.delete(deviceId);
23864
24284
  continue;
23865
24285
  }
24286
+ if (decision.kind === "pending") {
24287
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
24288
+ this.assignments.delete(deviceId);
24289
+ continue;
24290
+ }
23866
24291
  try {
23867
24292
  await this.attachOn(decision.agentNodeId, config);
23868
24293
  this.recordAssignment(deviceId, decision.agentNodeId, "failover", false);
@@ -24342,7 +24767,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24342
24767
  }
24343
24768
  async setAgentAddonDefaults(input) {
24344
24769
  let existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24345
- if (!existing) existing = await this.seedAgentSettingsFromCatalog(input.agentNodeId) ?? void 0;
24770
+ if (!existing) {
24771
+ await this.seedAgentSettingsFromCatalog(input.agentNodeId);
24772
+ existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24773
+ }
24346
24774
  if (!existing) await this.writeAgentSettings(input.agentNodeId, { addonDefaults: { ...input.defaults } });
24347
24775
  else await this.writeAgentSettings(input.agentNodeId, {
24348
24776
  ...existing,
@@ -24380,6 +24808,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24380
24808
  removed: true
24381
24809
  };
24382
24810
  }
24811
+ async setAgentMaxCameras(input) {
24812
+ const existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24813
+ const next = existing ? {
24814
+ ...existing,
24815
+ maxCameras: input.maxCameras
24816
+ } : {
24817
+ addonDefaults: {},
24818
+ maxCameras: input.maxCameras
24819
+ };
24820
+ await this.writeAgentSettings(input.agentNodeId, next);
24821
+ this.ctx.logger.info("agentSettings.maxCameras updated", {
24822
+ tags: { nodeId: input.agentNodeId },
24823
+ meta: { maxCameras: input.maxCameras }
24824
+ });
24825
+ return { success: true };
24826
+ }
24383
24827
  async getCameraSettings(input) {
24384
24828
  return (await this.readCameraSettingsMap())[String(input.deviceId)] ?? null;
24385
24829
  }
@@ -24509,6 +24953,204 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24509
24953
  this.ctx.logger.info("template deleted", { meta: { templateId: input.id } });
24510
24954
  return { success: true };
24511
24955
  }
24956
+ /**
24957
+ * Races a promise against a timeout. Returns `null` on timeout OR rejection.
24958
+ * Never throws — individual stage failures become `null` in the aggregate.
24959
+ *
24960
+ * @param p The stage fetch promise.
24961
+ * @param ms Timeout in milliseconds.
24962
+ */
24963
+ boundedStage(p, ms) {
24964
+ let timer;
24965
+ const timeout = new Promise((resolve) => {
24966
+ timer = setTimeout(() => resolve(null), ms);
24967
+ });
24968
+ return Promise.race([p, timeout]).catch(() => null).finally(() => {
24969
+ if (timer !== void 0) clearTimeout(timer);
24970
+ });
24971
+ }
24972
+ /**
24973
+ * Server-composed aggregated status for a single camera.
24974
+ *
24975
+ * Fans out in parallel (bounded, per-stage graceful degradation) to
24976
+ * broker / decoder / motion / detection / audio / recording source caps
24977
+ * via `ctx.api`. A stage whose source errors or times out becomes `null`
24978
+ * in the returned payload — one slow agent never breaks the whole call.
24979
+ */
24980
+ async getCameraStatus(input) {
24981
+ const { deviceId } = input;
24982
+ const api = this.ctx.api;
24983
+ const STAGE_TIMEOUT_MS = 3e3;
24984
+ const pipelineAssignment = this.assignments.get(deviceId) ?? null;
24985
+ const detectionNodeId = pipelineAssignment?.agentNodeId ?? null;
24986
+ const decoderPinRaw = (api ? await this.ctx.settings?.readDeviceStore(deviceId).catch(() => ({})) ?? {} : {})["decoderNodeId"];
24987
+ const decoderPinned = typeof decoderPinRaw === "string" && decoderPinRaw !== "auto";
24988
+ const decoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
24989
+ const audioAssignment = this.audioAssignments.get(deviceId) ?? null;
24990
+ const audioNodeId = audioAssignment?.nodeId ?? null;
24991
+ const audioPinned = audioAssignment?.pinned ?? false;
24992
+ const pinned = {
24993
+ detection: pipelineAssignment?.pinned ?? false,
24994
+ decoder: decoderPinned,
24995
+ audio: audioPinned
24996
+ };
24997
+ const reasons = {
24998
+ detection: pipelineAssignment?.reason,
24999
+ decoder: decoderPinned ? "manual" : "co-located",
25000
+ audio: audioPinned ? "manual" : void 0
25001
+ };
25002
+ const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query() : null;
25003
+ const sourceFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then((slots) => {
25004
+ return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
25005
+ camStreamId: s.sourceCamStreamId ?? s.brokerId,
25006
+ codec: s.codec ?? "",
25007
+ width: s.resolution?.width ?? 0,
25008
+ height: s.resolution?.height ?? 0,
25009
+ fps: 0,
25010
+ kind: s.profile
25011
+ })) };
25012
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25013
+ const WEBRTC_KINDS = new Set([
25014
+ "webrtc-browser",
25015
+ "webrtc-mobile",
25016
+ "webrtc-whep"
25017
+ ]);
25018
+ const brokerFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then(async (slots) => {
25019
+ const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
25020
+ if (deviceSlots.length === 0) return {
25021
+ profiles: [],
25022
+ webrtcSessions: 0,
25023
+ rtspRestream: false
25024
+ };
25025
+ const [statsAndClients, rtspEntry] = await Promise.all([Promise.all(deviceSlots.map(async (slot) => {
25026
+ return {
25027
+ slot,
25028
+ stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }).catch(() => null),
25029
+ clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }).catch(() => null)
25030
+ };
25031
+ })), api.streamBroker.getAllRtspEntries.query({}).catch(() => null)]);
25032
+ return {
25033
+ profiles: statsAndClients.map(({ slot, stats, clients }) => ({
25034
+ profile: slot.profile,
25035
+ status: slot.status,
25036
+ codec: stats?.codec ?? slot.codec ?? "",
25037
+ width: slot.resolution?.width ?? 0,
25038
+ height: slot.resolution?.height ?? 0,
25039
+ subscribers: clients?.encodedSubscribers ?? 0,
25040
+ inFps: stats?.inputFps ?? 0,
25041
+ outFps: stats?.decodeFps ?? 0
25042
+ })),
25043
+ webrtcSessions: statsAndClients.reduce((total, { clients }) => {
25044
+ if (!clients) return total;
25045
+ return total + clients.encoded.filter((c) => WEBRTC_KINDS.has(c.attribution.kind)).length;
25046
+ }, 0),
25047
+ rtspRestream: rtspEntry?.some((e) => {
25048
+ return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
25049
+ }) ?? false
25050
+ };
25051
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25052
+ const decoderFetch = decoderNodeId ? Promise.resolve({
25053
+ nodeId: decoderNodeId,
25054
+ formats: [],
25055
+ sessionCount: 0,
25056
+ shm: {
25057
+ framesWritten: 0,
25058
+ getFrameHits: 0,
25059
+ getFrameMisses: 0,
25060
+ budgetMb: 0
25061
+ }
25062
+ }) : Promise.resolve(null);
25063
+ const motionResult = (() => {
25064
+ const config = this.cameraConfigs.get(deviceId);
25065
+ if (!config) return null;
25066
+ return {
25067
+ enabled: config.motionSources.includes("analyzer") || config.motionSources.includes("onboard"),
25068
+ fps: config.motionFps
25069
+ };
25070
+ })();
25071
+ 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]) => {
25072
+ const metrics = await api.pipelineRunner.getCameraMetrics.query({
25073
+ deviceId,
25074
+ nodeId: detectionNodeId
25075
+ }).catch(() => null);
25076
+ const phase = (() => {
25077
+ const p = metrics?.phase;
25078
+ if (p === "active") return "active";
25079
+ if (p === "idle") return "idle";
25080
+ return "watching";
25081
+ })();
25082
+ return {
25083
+ nodeId: detectionNodeId,
25084
+ engine: {
25085
+ backend: engine?.backend ?? "",
25086
+ device: engine?.device ?? ""
25087
+ },
25088
+ phase,
25089
+ configuredFps: metrics?.configuredFps ?? 0,
25090
+ actualFps: metrics?.actualFps ?? 0,
25091
+ queueDepth: metrics?.queueDepth ?? 0,
25092
+ avgInferenceMs: metrics?.avgInferenceTimeMs ?? 0,
25093
+ provisioning: {
25094
+ state: provisioning?.state ?? "idle",
25095
+ ...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
25096
+ }
25097
+ };
25098
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25099
+ const audioResult = audioNodeId ? {
25100
+ nodeId: audioNodeId,
25101
+ enabled: true
25102
+ } : null;
25103
+ function isRecordingStatus(v) {
25104
+ 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";
25105
+ }
25106
+ const recordingFetch = api ? this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
25107
+ if (!isRecordingStatus(rawStatus)) return null;
25108
+ return {
25109
+ mode: rawStatus.activeMode,
25110
+ active: rawStatus.enabled && rawStatus.activeMode !== "off",
25111
+ storageBytes: rawStatus.storageBytes
25112
+ };
25113
+ }).catch(() => null), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25114
+ const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult] = await Promise.all([
25115
+ sourceFetch,
25116
+ brokerFetch,
25117
+ decoderFetch,
25118
+ detectionFetch,
25119
+ recordingFetch
25120
+ ]);
25121
+ return composeCameraStatus({
25122
+ deviceId,
25123
+ fetchedAt: Date.now(),
25124
+ detectionNodeId,
25125
+ decoderNodeId,
25126
+ audioNodeId,
25127
+ pinned,
25128
+ reasons,
25129
+ sourceResult,
25130
+ brokerResult,
25131
+ decoderResult,
25132
+ motionResult,
25133
+ detectionResult,
25134
+ audioResult,
25135
+ recordingResult
25136
+ });
25137
+ }
25138
+ /**
25139
+ * Server-composed aggregated status for multiple cameras in one call.
25140
+ *
25141
+ * `deviceIds` defaults to all cameras currently tracked by the
25142
+ * orchestrator's assignment map when omitted.
25143
+ *
25144
+ * v1: `Promise.all` over per-device composition (no concurrency cap).
25145
+ * Note: for large fleets (hundreds of cameras) this may fan out many
25146
+ * parallel calls. A concurrency limiter (p-limit / semaphore) should be
25147
+ * added if latency measurements show it's necessary — deliberately
25148
+ * deferred per the YAGNI constraint in the spec.
25149
+ */
25150
+ async getCameraStatuses(input) {
25151
+ const ids = input.deviceIds !== void 0 && input.deviceIds.length > 0 ? input.deviceIds : [...this.assignments.keys()];
25152
+ return Promise.all(ids.map((deviceId) => this.getCameraStatus({ deviceId })));
25153
+ }
24512
25154
  /** Read the templates map from the addon store via the durable handle. */
24513
25155
  async readTemplatesMap() {
24514
25156
  const raw = await this.templatesState.get();
@@ -24539,7 +25181,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24539
25181
  return;
24540
25182
  }
24541
25183
  const all = await this.readAgentSettingsMap();
24542
- all[nodeId] = settings;
25184
+ const existing = all[nodeId];
25185
+ all[nodeId] = {
25186
+ addonDefaults: settings.addonDefaults,
25187
+ maxCameras: settings.maxCameras !== void 0 ? settings.maxCameras ?? null : existing?.maxCameras ?? null
25188
+ };
24543
25189
  await this.agentSettingsState.set(all);
24544
25190
  }
24545
25191
  /** Read the `cameraSettings` map (keyed on `deviceId` as string) via the durable handle. */
@@ -24749,12 +25395,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24749
25395
  }
24750
25396
  let agent = (await this.readAgentSettingsMap())[nodeId];
24751
25397
  if (!agent || Object.keys(agent.addonDefaults ?? {}).length === 0) {
24752
- const seeded = await this.seedAgentSettingsFromCatalog(nodeId);
24753
- if (!seeded) {
25398
+ if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
24754
25399
  await sleep$1(2e3);
24755
25400
  continue;
24756
25401
  }
24757
- agent = seeded;
25402
+ agent = (await this.readAgentSettingsMap())[nodeId];
25403
+ }
25404
+ if (!agent) {
25405
+ await sleep$1(2e3);
25406
+ continue;
24758
25407
  }
24759
25408
  const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
24760
25409
  return {
@@ -25661,7 +26310,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25661
26310
  };
25662
26311
  let dispatchedNodeId = null;
25663
26312
  try {
25664
- dispatchedNodeId = (await this.dispatchCamera(runnerConfig)).agentNodeId;
26313
+ const result = await this.dispatchCamera(runnerConfig);
26314
+ if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
25665
26315
  } catch (err) {
25666
26316
  const msg = errMsg(err);
25667
26317
  log.error("dispatchCamera failed", { meta: { error: msg } });