@camstack/addon-pipeline-orchestrator 1.0.3 → 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
@@ -5130,14 +5142,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
5130
5142
  EventCategory["DeviceSleeping"] = "device.sleeping";
5131
5143
  EventCategory["RetentionCleanup"] = "retention.cleanup";
5132
5144
  /**
5133
- * Progress snapshot emitted by `BulkUpdateCoordinator` on every state
5134
- * transition (item status change, phase change, completion, cancel).
5135
- * Payload is `BulkUpdateState`. Admin UI subscribes via `useLiveEvent`
5136
- * to drive the sticky `BulkUpdateBanner` and per-row `AddonRowBadge`.
5137
- *
5138
- * Spec: docs/superpowers/specs/2026-05-21-addons-bulk-update-progress-design.md
5145
+ * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
5146
+ * emitted F3 removed the coordinator that produced it; "Update all" now runs
5147
+ * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
5148
+ * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
5149
+ * in F4 once live bulk progress is re-implemented over the engine events.
5139
5150
  */
5140
5151
  EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
5152
+ EventCategory["AddonsJobProgress"] = "addons.job-progress";
5153
+ EventCategory["AddonsJobLog"] = "addons.job-log";
5141
5154
  /**
5142
5155
  * A container's child visibility toggled (hidden/shown). Emitted by the
5143
5156
  * `accessories` cap when a child device is hidden or revealed.
@@ -9561,6 +9574,24 @@ var DetectorOutputSchema = object({
9561
9574
  inferenceMs: number(),
9562
9575
  modelId: string()
9563
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
+ });
9564
9595
  var PipelineStepInputSchema = lazy(() => object({
9565
9596
  addonId: string(),
9566
9597
  modelId: string(),
@@ -9660,6 +9691,15 @@ var pipelineExecutorCapability = {
9660
9691
  kind: "mutation",
9661
9692
  auth: "admin"
9662
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),
9663
9703
  getVideoPipelineSteps: method(_void(), record(string(), object({
9664
9704
  modelId: string(),
9665
9705
  settings: record(string(), unknown()).readonly()
@@ -13536,7 +13576,10 @@ var AgentAddonConfigSchema = object({
13536
13576
  modelId: string(),
13537
13577
  settings: record(string(), unknown()).readonly()
13538
13578
  });
13539
- 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
+ });
13540
13583
  var CameraPipelineForAgentSchema = object({
13541
13584
  steps: array(PipelineStepInputSchema).readonly(),
13542
13585
  audio: object({
@@ -13638,6 +13681,133 @@ var GlobalMetricsSchema = object({
13638
13681
  * capability providers.
13639
13682
  */
13640
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
+ });
13641
13811
  /**
13642
13812
  * Pipeline Orchestrator capability — global load balancer + camera dispatcher.
13643
13813
  *
@@ -13814,6 +13984,25 @@ var pipelineOrchestratorCapability = {
13814
13984
  kind: "mutation",
13815
13985
  auth: "admin"
13816
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
+ }),
13817
14006
  /** Read one camera's settings. Null when never touched (inherits agent defaults fully). */
13818
14007
  getCameraSettings: method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()),
13819
14008
  /** Set or clear the 3-state toggle for one (camera, addonId). Pass `enabled: null` to clear and revert to agent default. */
@@ -13854,6 +14043,29 @@ var pipelineOrchestratorCapability = {
13854
14043
  deviceId: number(),
13855
14044
  agentNodeId: string().optional()
13856
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()),
13857
14069
  /** List every template the operator has saved. */
13858
14070
  listTemplates: method(_void(), array(PipelineTemplateSchema).readonly()),
13859
14071
  /** Create a new named preset from a given CameraPipelineConfig. */
@@ -16865,6 +17077,69 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
16865
17077
  kind: "mutation",
16866
17078
  auth: "admin"
16867
17079
  });
17080
+ var jobKindSchema = _enum([
17081
+ "install",
17082
+ "update",
17083
+ "uninstall",
17084
+ "restart"
17085
+ ]);
17086
+ var taskPhaseSchema = _enum([
17087
+ "queued",
17088
+ "fetching",
17089
+ "staged",
17090
+ "validating",
17091
+ "applying",
17092
+ "restarting",
17093
+ "applied",
17094
+ "done",
17095
+ "failed",
17096
+ "skipped"
17097
+ ]);
17098
+ var taskTargetSchema = _enum(["framework", "addon"]);
17099
+ var taskLogEntrySchema = object({
17100
+ tsMs: number(),
17101
+ nodeId: string(),
17102
+ packageName: string(),
17103
+ phase: taskPhaseSchema,
17104
+ message: string()
17105
+ });
17106
+ var lifecycleTaskSchema = object({
17107
+ taskId: string(),
17108
+ nodeId: string(),
17109
+ packageName: string(),
17110
+ fromVersion: string().nullable(),
17111
+ toVersion: string(),
17112
+ target: taskTargetSchema,
17113
+ phase: taskPhaseSchema,
17114
+ stagedPath: string().nullable(),
17115
+ attempts: number(),
17116
+ steps: array(taskLogEntrySchema),
17117
+ error: string().nullable(),
17118
+ startedAtMs: number().nullable(),
17119
+ finishedAtMs: number().nullable()
17120
+ });
17121
+ var lifecycleJobStateSchema = _enum([
17122
+ "running",
17123
+ "completed",
17124
+ "failed",
17125
+ "partially-failed",
17126
+ "cancelled"
17127
+ ]);
17128
+ var lifecycleJobScopeSchema = _enum([
17129
+ "single",
17130
+ "bulk",
17131
+ "cluster"
17132
+ ]);
17133
+ var lifecycleJobSchema = object({
17134
+ jobId: string(),
17135
+ kind: jobKindSchema,
17136
+ createdAtMs: number(),
17137
+ createdBy: string(),
17138
+ scope: lifecycleJobScopeSchema,
17139
+ tasks: array(lifecycleTaskSchema),
17140
+ state: lifecycleJobStateSchema,
17141
+ schemaVersion: literal(1)
17142
+ });
16868
17143
  /**
16869
17144
  * addons — system-scoped singleton capability for addon package
16870
17145
  * management (install, update, configure, restart) and per-addon log
@@ -17047,7 +17322,7 @@ var BulkUpdatePhaseSchema = _enum([
17047
17322
  "restarting",
17048
17323
  "finalizing"
17049
17324
  ]);
17050
- var BulkUpdateStateSchema = object({
17325
+ object({
17051
17326
  id: string(),
17052
17327
  nodeId: string(),
17053
17328
  startedAtMs: number(),
@@ -17150,20 +17425,7 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
17150
17425
  }), UpdateFrameworkPackageResultSchema, {
17151
17426
  kind: "mutation",
17152
17427
  auth: "admin"
17153
- }), method(object({
17154
- nodeId: string(),
17155
- items: array(object({
17156
- name: string(),
17157
- version: string(),
17158
- isSystem: boolean()
17159
- })).readonly()
17160
- }), object({ id: string() }), {
17161
- kind: "mutation",
17162
- auth: "admin"
17163
- }), method(object({ id: string() }), BulkUpdateStateSchema.nullable(), { auth: "admin" }), method(object({ id: string() }), object({ cancelled: boolean() }), {
17164
- kind: "mutation",
17165
- auth: "admin"
17166
- }), method(object({ nodeId: string().optional() }), array(BulkUpdateStateSchema).readonly(), { auth: "admin" }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string() }), RestartAddonResultSchema, {
17428
+ }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string() }), RestartAddonResultSchema, {
17167
17429
  kind: "mutation",
17168
17430
  auth: "admin"
17169
17431
  }), method(object({ packageName: string() }), object({ success: literal(true) }), {
@@ -17185,6 +17447,24 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
17185
17447
  kind: "mutation",
17186
17448
  auth: "admin"
17187
17449
  }), method(CustomActionInputSchema, unknown(), { kind: "mutation" }), method(object({
17450
+ kind: _enum([
17451
+ "install",
17452
+ "update",
17453
+ "uninstall",
17454
+ "restart"
17455
+ ]),
17456
+ targets: array(object({
17457
+ name: string().min(1),
17458
+ version: string().min(1)
17459
+ })).min(1),
17460
+ nodeIds: array(string()).optional()
17461
+ }), object({ jobId: string() }), {
17462
+ kind: "mutation",
17463
+ auth: "admin"
17464
+ }), method(object({ jobId: string() }), lifecycleJobSchema.nullable(), { auth: "admin" }), method(object({ activeOnly: boolean().optional() }), array(lifecycleJobSchema), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
17465
+ kind: "mutation",
17466
+ auth: "admin"
17467
+ }), method(object({
17188
17468
  addonId: string(),
17189
17469
  level: LogLevelSchema$1.optional()
17190
17470
  }), LogStreamEntrySchema, { kind: "subscription" });
@@ -17225,7 +17505,7 @@ Object.freeze({
17225
17505
  addonId: null,
17226
17506
  access: "create"
17227
17507
  },
17228
- "addons.cancelBulkUpdate": {
17508
+ "addons.cancelJob": {
17229
17509
  capName: "addons",
17230
17510
  capScope: "system",
17231
17511
  addonId: null,
@@ -17255,7 +17535,7 @@ Object.freeze({
17255
17535
  addonId: null,
17256
17536
  access: "view"
17257
17537
  },
17258
- "addons.getBulkUpdateState": {
17538
+ "addons.getJob": {
17259
17539
  capName: "addons",
17260
17540
  capScope: "system",
17261
17541
  addonId: null,
@@ -17303,19 +17583,19 @@ Object.freeze({
17303
17583
  addonId: null,
17304
17584
  access: "view"
17305
17585
  },
17306
- "addons.listActiveBulkUpdates": {
17586
+ "addons.listCapabilityProviders": {
17307
17587
  capName: "addons",
17308
17588
  capScope: "system",
17309
17589
  addonId: null,
17310
17590
  access: "view"
17311
17591
  },
17312
- "addons.listCapabilityProviders": {
17592
+ "addons.listFrameworkPackages": {
17313
17593
  capName: "addons",
17314
17594
  capScope: "system",
17315
17595
  addonId: null,
17316
17596
  access: "view"
17317
17597
  },
17318
- "addons.listFrameworkPackages": {
17598
+ "addons.listJobs": {
17319
17599
  capName: "addons",
17320
17600
  capScope: "system",
17321
17601
  addonId: null,
@@ -17399,7 +17679,7 @@ Object.freeze({
17399
17679
  addonId: null,
17400
17680
  access: "create"
17401
17681
  },
17402
- "addons.startBulkUpdate": {
17682
+ "addons.startJob": {
17403
17683
  capName: "addons",
17404
17684
  capScope: "system",
17405
17685
  addonId: null,
@@ -19625,6 +19905,12 @@ Object.freeze({
19625
19905
  addonId: null,
19626
19906
  access: "view"
19627
19907
  },
19908
+ "pipelineExecutor.getEngineProvisioning": {
19909
+ capName: "pipeline-executor",
19910
+ capScope: "system",
19911
+ addonId: null,
19912
+ access: "view"
19913
+ },
19628
19914
  "pipelineExecutor.getGlobalPipelineConfig": {
19629
19915
  capName: "pipeline-executor",
19630
19916
  capScope: "system",
@@ -19829,6 +20115,18 @@ Object.freeze({
19829
20115
  addonId: null,
19830
20116
  access: "view"
19831
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
+ },
19832
20130
  "pipelineOrchestrator.getCameraStepOverrides": {
19833
20131
  capName: "pipeline-orchestrator",
19834
20132
  capScope: "system",
@@ -19913,6 +20211,12 @@ Object.freeze({
19913
20211
  addonId: null,
19914
20212
  access: "create"
19915
20213
  },
20214
+ "pipelineOrchestrator.setAgentMaxCameras": {
20215
+ capName: "pipeline-orchestrator",
20216
+ capScope: "system",
20217
+ addonId: null,
20218
+ access: "create"
20219
+ },
19916
20220
  "pipelineOrchestrator.setCameraPipelineForAgent": {
19917
20221
  capName: "pipeline-orchestrator",
19918
20222
  capScope: "system",
@@ -21386,6 +21690,32 @@ Object.freeze({
21386
21690
  "network-access": "ingress",
21387
21691
  "smtp-provider": "email"
21388
21692
  });
21693
+ var frameworkSwapPackageSchema = object({
21694
+ name: string(),
21695
+ stagedPath: string(),
21696
+ backupPath: string(),
21697
+ toVersion: string(),
21698
+ fromVersion: string().nullable()
21699
+ });
21700
+ object({
21701
+ jobId: string(),
21702
+ taskId: string(),
21703
+ packages: array(frameworkSwapPackageSchema),
21704
+ requestedAtMs: number(),
21705
+ schemaVersion: literal(1)
21706
+ });
21707
+ object({
21708
+ jobId: string(),
21709
+ taskId: string(),
21710
+ backups: array(object({
21711
+ name: string(),
21712
+ backupPath: string(),
21713
+ livePath: string()
21714
+ })),
21715
+ appliedAtMs: number(),
21716
+ bootAttempts: number(),
21717
+ schemaVersion: literal(1)
21718
+ });
21389
21719
  /**
21390
21720
  * Promise-based timer helpers — used everywhere the codebase needs to
21391
21721
  * wait, back off, or schedule a retry. Before these helpers landed, each
@@ -21917,7 +22247,10 @@ var StoredAgentAddonConfigSchema = object({
21917
22247
  modelId: string(),
21918
22248
  settings: record(string(), unknown()).readonly()
21919
22249
  });
21920
- 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
+ });
21921
22254
  var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
21922
22255
  var StoredCameraStepOverridePatchSchema = object({
21923
22256
  enabled: boolean().optional(),
@@ -21962,10 +22295,26 @@ function computeCapacityScore(load) {
21962
22295
  return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
21963
22296
  }
21964
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
+ /**
21965
22309
  * Run the two-level camera balancer.
21966
22310
  *
21967
- * L1 (manual affinity): if `preferredAgent` names an online node, return it.
21968
- * 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'}`.
21969
22318
  *
21970
22319
  * Returns `null` when no runners are online. The orchestrator decides how to
21971
22320
  * react — typically by logging and deferring the assignment until a runner
@@ -21974,19 +22323,32 @@ function computeCapacityScore(load) {
21974
22323
  function balance(input) {
21975
22324
  const online = input.nodes.filter((n) => n.nodeId.length > 0);
21976
22325
  if (online.length === 0) return null;
22326
+ const eligible = online.filter((n) => isEligible(n, input.nodeCaps));
21977
22327
  if (input.preferredAgent) {
21978
- const match = online.find((n) => n.nodeId === input.preferredAgent);
21979
- if (match) return {
21980
- agentNodeId: match.nodeId,
21981
- reason: "manual",
21982
- score: computeCapacityScore(match)
21983
- };
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
+ }
21984
22341
  }
21985
- 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) => ({
21986
22347
  node,
21987
22348
  score: computeCapacityScore(node)
21988
22349
  })).toSorted((a, b) => a.score - b.score)[0];
21989
22350
  return {
22351
+ kind: "assigned",
21990
22352
  agentNodeId: best.node.nodeId,
21991
22353
  reason: "capacity",
21992
22354
  score: best.score
@@ -22405,6 +22767,139 @@ var PipelineWatchdog = class {
22405
22767
  }
22406
22768
  };
22407
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
22408
22903
  //#region src/index.ts
22409
22904
  var PHASE_MODE_VALUES = new Set([
22410
22905
  "disabled",
@@ -23162,8 +23657,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23162
23657
  });
23163
23658
  return {
23164
23659
  success: true,
23165
- agentNodeId: "",
23166
- reason: "capacity"
23660
+ kind: "pending"
23167
23661
  };
23168
23662
  }
23169
23663
  }
@@ -23173,14 +23667,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23173
23667
  const preferredAgent = typeof pipelinePin === "string" && pipelinePin !== "auto" ? pipelinePin : legacyPreferred;
23174
23668
  const decision = balance({
23175
23669
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23176
- preferredAgent
23670
+ preferredAgent,
23671
+ nodeCaps: this.buildNodeCaps()
23177
23672
  });
23178
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23179
- 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;
23180
23682
  await this.attachOn(targetNodeId, runnerConfig);
23181
23683
  if (targetNodeId === this.localNodeId) this.pipelineWatchdog?.register(this.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId)));
23182
- const reason = decision?.reason ?? "capacity";
23183
- const pinned = decision?.reason === "manual";
23684
+ const reason = decision.reason;
23685
+ const pinned = decision.reason === "manual";
23184
23686
  this.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, pinned);
23185
23687
  const decoderNodeId = await this.resolveDecoderNode(runnerConfig.deviceId, targetNodeId);
23186
23688
  this.ctx.logger.info("dispatchCamera", {
@@ -23190,12 +23692,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23190
23692
  },
23191
23693
  meta: {
23192
23694
  reason,
23193
- score: decision?.score ?? "n/a",
23695
+ score: decision.score,
23194
23696
  decoderNodeId
23195
23697
  }
23196
23698
  });
23197
23699
  return {
23198
23700
  success: true,
23701
+ kind: "assigned",
23199
23702
  agentNodeId: targetNodeId,
23200
23703
  reason
23201
23704
  };
@@ -23269,10 +23772,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23269
23772
  if (cached) {
23270
23773
  const decision = balance({
23271
23774
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23272
- preferredAgent: null
23775
+ preferredAgent: null,
23776
+ nodeCaps: this.buildNodeCaps()
23273
23777
  });
23274
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23275
- 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;
23276
23782
  if (current && current.agentNodeId !== targetNodeId) await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
23277
23783
  const msg = errMsg(err);
23278
23784
  this.ctx.logger.debug("unassignPipeline detach-old failed", {
@@ -23281,7 +23787,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23281
23787
  });
23282
23788
  });
23283
23789
  if (!current || current.agentNodeId !== targetNodeId) await this.attachOn(targetNodeId, cached);
23284
- const reason = decision?.reason === "manual" ? "capacity" : decision?.reason ?? "capacity";
23790
+ const reason = decision.reason === "manual" ? "capacity" : decision.reason;
23285
23791
  this.recordAssignment(input.deviceId, targetNodeId, reason, false);
23286
23792
  this.ctx.logger.info("unassignPipeline: re-dispatched via auto", {
23287
23793
  tags: {
@@ -23292,7 +23798,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23292
23798
  });
23293
23799
  return { success: true };
23294
23800
  }
23295
- this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23296
23801
  }
23297
23802
  if (current) {
23298
23803
  await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
@@ -23310,15 +23815,21 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23310
23815
  async rebalance() {
23311
23816
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
23312
23817
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
23818
+ const nodeCaps = this.buildNodeCaps();
23313
23819
  let migrated = 0;
23314
23820
  for (const [deviceId, config] of this.cameraConfigs) {
23315
23821
  const current = this.assignments.get(deviceId);
23316
23822
  if (current?.pinned) continue;
23317
23823
  const decision = balance({
23318
23824
  nodes: loads,
23319
- preferredAgent: await this.readPreferredAgent(deviceId)
23825
+ preferredAgent: await this.readPreferredAgent(deviceId),
23826
+ nodeCaps
23320
23827
  });
23321
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
+ }
23322
23833
  if (current && current.agentNodeId === decision.agentNodeId) continue;
23323
23834
  if (current) await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
23324
23835
  const msg = errMsg(err);
@@ -23456,15 +23967,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23456
23967
  nodeId
23457
23968
  });
23458
23969
  }
23459
- async localRunnerNodeId() {
23460
- const api = this.ctx.api;
23461
- if (!api) return null;
23462
- try {
23463
- return (await api.pipelineRunner.getLocalLoad.query({ nodeId: this.localNodeId }))?.nodeId ?? this.localNodeId;
23464
- } catch {
23465
- return this.localNodeId;
23466
- }
23467
- }
23468
23970
  /**
23469
23971
  * Enumerate every runner-capable node currently known (populated via
23470
23972
  * AgentOnline/AgentOffline events). Used to populate the `enabledNodes`
@@ -23619,6 +24121,18 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23619
24121
  return null;
23620
24122
  }
23621
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
+ }
23622
24136
  async readAudioNodePin(deviceId) {
23623
24137
  if (!this.ctx?.settings) return null;
23624
24138
  try {
@@ -23761,13 +24275,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23761
24275
  for (const { deviceId, config } of affected) {
23762
24276
  const decision = balance({
23763
24277
  nodes: loads,
23764
- preferredAgent: null
24278
+ preferredAgent: null,
24279
+ nodeCaps: this.buildNodeCaps()
23765
24280
  });
23766
24281
  if (!decision) {
23767
24282
  this.ctx.logger.error("Failover: no online runner", { tags: { deviceId } });
23768
24283
  this.assignments.delete(deviceId);
23769
24284
  continue;
23770
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
+ }
23771
24291
  try {
23772
24292
  await this.attachOn(decision.agentNodeId, config);
23773
24293
  this.recordAssignment(deviceId, decision.agentNodeId, "failover", false);
@@ -24247,7 +24767,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24247
24767
  }
24248
24768
  async setAgentAddonDefaults(input) {
24249
24769
  let existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24250
- 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
+ }
24251
24774
  if (!existing) await this.writeAgentSettings(input.agentNodeId, { addonDefaults: { ...input.defaults } });
24252
24775
  else await this.writeAgentSettings(input.agentNodeId, {
24253
24776
  ...existing,
@@ -24285,6 +24808,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24285
24808
  removed: true
24286
24809
  };
24287
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
+ }
24288
24827
  async getCameraSettings(input) {
24289
24828
  return (await this.readCameraSettingsMap())[String(input.deviceId)] ?? null;
24290
24829
  }
@@ -24414,6 +24953,204 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24414
24953
  this.ctx.logger.info("template deleted", { meta: { templateId: input.id } });
24415
24954
  return { success: true };
24416
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
+ }
24417
25154
  /** Read the templates map from the addon store via the durable handle. */
24418
25155
  async readTemplatesMap() {
24419
25156
  const raw = await this.templatesState.get();
@@ -24444,7 +25181,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24444
25181
  return;
24445
25182
  }
24446
25183
  const all = await this.readAgentSettingsMap();
24447
- 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
+ };
24448
25189
  await this.agentSettingsState.set(all);
24449
25190
  }
24450
25191
  /** Read the `cameraSettings` map (keyed on `deviceId` as string) via the durable handle. */
@@ -24654,12 +25395,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24654
25395
  }
24655
25396
  let agent = (await this.readAgentSettingsMap())[nodeId];
24656
25397
  if (!agent || Object.keys(agent.addonDefaults ?? {}).length === 0) {
24657
- const seeded = await this.seedAgentSettingsFromCatalog(nodeId);
24658
- if (!seeded) {
25398
+ if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
24659
25399
  await sleep$1(2e3);
24660
25400
  continue;
24661
25401
  }
24662
- agent = seeded;
25402
+ agent = (await this.readAgentSettingsMap())[nodeId];
25403
+ }
25404
+ if (!agent) {
25405
+ await sleep$1(2e3);
25406
+ continue;
24663
25407
  }
24664
25408
  const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
24665
25409
  return {
@@ -25566,7 +26310,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25566
26310
  };
25567
26311
  let dispatchedNodeId = null;
25568
26312
  try {
25569
- dispatchedNodeId = (await this.dispatchCamera(runnerConfig)).agentNodeId;
26313
+ const result = await this.dispatchCamera(runnerConfig);
26314
+ if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
25570
26315
  } catch (err) {
25571
26316
  const msg = errMsg(err);
25572
26317
  log.error("dispatchCamera failed", { meta: { error: msg } });