@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.js CHANGED
@@ -4985,6 +4985,18 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
4985
4985
  */
4986
4986
  EventCategory["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
4987
4987
  /**
4988
+ * Per-node detection-engine runtime-provisioning transition. Emitted by
4989
+ * the detection-pipeline provider on every state change of its lazy
4990
+ * engine-provisioning machine (idle → installing → verifying → ready,
4991
+ * or → failed with a `nextRetryAt`). Payload is the
4992
+ * `EngineProvisioningState` snapshot; `event.source.nodeId` carries the
4993
+ * node. The Pipeline page subscribes to drive a live "installing
4994
+ * OpenVINO… / ready" indicator per node without polling
4995
+ * `pipelineExecutor.getEngineProvisioning`. Telemetry-grade (D8): the UI
4996
+ * also reads the cap snapshot on mount / reconnect. Phase 2.
4997
+ */
4998
+ EventCategory["PipelineEngineProvisioning"] = "pipeline.engine-provisioning";
4999
+ /**
4988
5000
  * Cluster topology snapshot. Carries the same payload returned by
4989
5001
  * `nodes.topology` (every reachable node + addons + processes).
4990
5002
  * Emitted by the hub on any agent / addon lifecycle change
@@ -5134,14 +5146,15 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
5134
5146
  EventCategory["DeviceSleeping"] = "device.sleeping";
5135
5147
  EventCategory["RetentionCleanup"] = "retention.cleanup";
5136
5148
  /**
5137
- * Progress snapshot emitted by `BulkUpdateCoordinator` on every state
5138
- * transition (item status change, phase change, completion, cancel).
5139
- * Payload is `BulkUpdateState`. Admin UI subscribes via `useLiveEvent`
5140
- * to drive the sticky `BulkUpdateBanner` and per-row `AddonRowBadge`.
5141
- *
5142
- * Spec: docs/superpowers/specs/2026-05-21-addons-bulk-update-progress-design.md
5149
+ * Legacy bulk-update progress snapshot (payload `BulkUpdateState`). No longer
5150
+ * emitted F3 removed the coordinator that produced it; "Update all" now runs
5151
+ * as one lifecycle engine job (`AddonsJobProgress`/`AddonsJobLog`). Retained
5152
+ * (with `BulkUpdateState`) only to avoid regenerating the event maps; removed
5153
+ * in F4 once live bulk progress is re-implemented over the engine events.
5143
5154
  */
5144
5155
  EventCategory["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
5156
+ EventCategory["AddonsJobProgress"] = "addons.job-progress";
5157
+ EventCategory["AddonsJobLog"] = "addons.job-log";
5145
5158
  /**
5146
5159
  * A container's child visibility toggled (hidden/shown). Emitted by the
5147
5160
  * `accessories` cap when a child device is hidden or revealed.
@@ -9565,6 +9578,24 @@ var DetectorOutputSchema = object({
9565
9578
  inferenceMs: number(),
9566
9579
  modelId: string()
9567
9580
  });
9581
+ var EngineProvisioningSchema = object({
9582
+ runtimeId: _enum([
9583
+ "onnx",
9584
+ "openvino",
9585
+ "coreml"
9586
+ ]).nullable(),
9587
+ device: string().nullable(),
9588
+ state: _enum([
9589
+ "idle",
9590
+ "installing",
9591
+ "verifying",
9592
+ "ready",
9593
+ "failed"
9594
+ ]),
9595
+ progress: number().optional(),
9596
+ error: string().optional(),
9597
+ nextRetryAt: number().optional()
9598
+ });
9568
9599
  var PipelineStepInputSchema = lazy(() => object({
9569
9600
  addonId: string(),
9570
9601
  modelId: string(),
@@ -9664,6 +9695,15 @@ var pipelineExecutorCapability = {
9664
9695
  kind: "mutation",
9665
9696
  auth: "admin"
9666
9697
  }),
9698
+ /**
9699
+ * Per-node detection-engine provisioning snapshot. Returns the live
9700
+ * state of the lazy runtime-provisioning machine on `nodeId`
9701
+ * (idle / installing / verifying / ready / failed). The UI pairs this
9702
+ * one-shot query with the `pipeline.engine-provisioning` live event
9703
+ * (emitted on every transition) to drive a per-node "engine ready?"
9704
+ * indicator without polling. Phase 2.
9705
+ */
9706
+ getEngineProvisioning: method(object({ nodeId: string() }), EngineProvisioningSchema),
9667
9707
  getVideoPipelineSteps: method(_void(), record(string(), object({
9668
9708
  modelId: string(),
9669
9709
  settings: record(string(), unknown()).readonly()
@@ -13540,7 +13580,10 @@ var AgentAddonConfigSchema = object({
13540
13580
  modelId: string(),
13541
13581
  settings: record(string(), unknown()).readonly()
13542
13582
  });
13543
- var AgentPipelineSettingsSchema = object({ addonDefaults: record(string(), AgentAddonConfigSchema).readonly() });
13583
+ var AgentPipelineSettingsSchema = object({
13584
+ addonDefaults: record(string(), AgentAddonConfigSchema).readonly(),
13585
+ maxCameras: number().int().nonnegative().nullable().default(null)
13586
+ });
13544
13587
  var CameraPipelineForAgentSchema = object({
13545
13588
  steps: array(PipelineStepInputSchema).readonly(),
13546
13589
  audio: object({
@@ -13642,6 +13685,133 @@ var GlobalMetricsSchema = object({
13642
13685
  * capability providers.
13643
13686
  */
13644
13687
  var CapabilityBindingsSchema = record(string(), string());
13688
+ /** Source block — always present; derives from the stream catalog. */
13689
+ var CameraSourceStatusSchema = object({ streams: array(object({
13690
+ camStreamId: string(),
13691
+ codec: string(),
13692
+ width: number(),
13693
+ height: number(),
13694
+ fps: number(),
13695
+ kind: string()
13696
+ })).readonly() });
13697
+ /** Assignment block — always present (orchestrator-local, no remote call). */
13698
+ var CameraAssignmentStatusSchema = object({
13699
+ detectionNodeId: string().nullable(),
13700
+ decoderNodeId: string().nullable(),
13701
+ audioNodeId: string().nullable(),
13702
+ pinned: object({
13703
+ detection: boolean(),
13704
+ decoder: boolean(),
13705
+ audio: boolean()
13706
+ }),
13707
+ reasons: object({
13708
+ detection: string().optional(),
13709
+ decoder: string().optional(),
13710
+ audio: string().optional()
13711
+ })
13712
+ });
13713
+ /** Broker block — null when the broker stage is unreachable or inactive. */
13714
+ var CameraBrokerStatusSchema = object({
13715
+ profiles: array(object({
13716
+ profile: string(),
13717
+ status: string(),
13718
+ codec: string(),
13719
+ width: number(),
13720
+ height: number(),
13721
+ subscribers: number(),
13722
+ inFps: number(),
13723
+ outFps: number()
13724
+ })).readonly(),
13725
+ webrtcSessions: number(),
13726
+ rtspRestream: boolean()
13727
+ });
13728
+ /** Shared-memory ring statistics within the decoder block. */
13729
+ var CameraDecoderShmSchema = object({
13730
+ framesWritten: number(),
13731
+ getFrameHits: number(),
13732
+ getFrameMisses: number(),
13733
+ budgetMb: number()
13734
+ });
13735
+ /** Decoder block — null when the decoder stage is unreachable or inactive. */
13736
+ var CameraDecoderStatusSchema = object({
13737
+ nodeId: string(),
13738
+ formats: array(string()).readonly(),
13739
+ sessionCount: number(),
13740
+ shm: CameraDecoderShmSchema
13741
+ });
13742
+ /** Motion block — null when motion detection is not active for this device. */
13743
+ var CameraMotionStatusSchema = object({
13744
+ enabled: boolean(),
13745
+ fps: number()
13746
+ });
13747
+ /** Detection provisioning sub-block. */
13748
+ var CameraDetectionProvisioningSchema = object({
13749
+ state: _enum([
13750
+ "idle",
13751
+ "installing",
13752
+ "verifying",
13753
+ "ready",
13754
+ "failed"
13755
+ ]),
13756
+ error: string().optional()
13757
+ });
13758
+ /** Detection phase — derived from the runner's engine phase. */
13759
+ var CameraDetectionPhaseSchema = _enum([
13760
+ "idle",
13761
+ "watching",
13762
+ "active"
13763
+ ]);
13764
+ /** Detection block — null when no detection node is assigned or reachable. */
13765
+ var CameraDetectionStatusSchema = object({
13766
+ nodeId: string(),
13767
+ engine: object({
13768
+ backend: string(),
13769
+ device: string()
13770
+ }),
13771
+ phase: CameraDetectionPhaseSchema,
13772
+ configuredFps: number(),
13773
+ actualFps: number(),
13774
+ queueDepth: number(),
13775
+ avgInferenceMs: number(),
13776
+ provisioning: CameraDetectionProvisioningSchema
13777
+ });
13778
+ /** Audio block — null when no audio node is assigned or reachable. */
13779
+ var CameraAudioStatusSchema = object({
13780
+ nodeId: string(),
13781
+ enabled: boolean()
13782
+ });
13783
+ /** Recording block — null when no recording cap is active for this device. */
13784
+ var CameraRecordingStatusSchema = object({
13785
+ mode: _enum([
13786
+ "off",
13787
+ "continuous",
13788
+ "events"
13789
+ ]),
13790
+ active: boolean(),
13791
+ storageBytes: number()
13792
+ });
13793
+ /**
13794
+ * Aggregated per-camera pipeline status — server-composed, single call.
13795
+ *
13796
+ * The `assignment` and `source` blocks are always present.
13797
+ * Every other block is `null` when the stage is inactive or unreachable
13798
+ * during the bounded parallel fan-out in the orchestrator implementation.
13799
+ *
13800
+ * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
13801
+ */
13802
+ var CameraStatusSchema = object({
13803
+ deviceId: number(),
13804
+ assignment: CameraAssignmentStatusSchema,
13805
+ source: CameraSourceStatusSchema,
13806
+ broker: CameraBrokerStatusSchema.nullable(),
13807
+ decoder: CameraDecoderStatusSchema.nullable(),
13808
+ motion: CameraMotionStatusSchema.nullable(),
13809
+ detection: CameraDetectionStatusSchema.nullable(),
13810
+ audio: CameraAudioStatusSchema.nullable(),
13811
+ recording: CameraRecordingStatusSchema.nullable(),
13812
+ /** Unix timestamp (ms) when this snapshot was composed server-side. */
13813
+ fetchedAt: number()
13814
+ });
13645
13815
  /**
13646
13816
  * Pipeline Orchestrator capability — global load balancer + camera dispatcher.
13647
13817
  *
@@ -13818,6 +13988,25 @@ var pipelineOrchestratorCapability = {
13818
13988
  kind: "mutation",
13819
13989
  auth: "admin"
13820
13990
  }),
13991
+ /**
13992
+ * Set the per-node camera cap for one agent.
13993
+ *
13994
+ * `maxCameras: 0` and `maxCameras: null` are both treated as unlimited
13995
+ * by the load balancer (0 is stored as-is; the balancer treats ≤0 as
13996
+ * unlimited alongside null). Pass `null` to clear an existing cap.
13997
+ * Note: the admin UI normalises 0→null before calling this method, so
13998
+ * `maxCameras: 0` only reaches the store via direct SDK or CLI calls.
13999
+ * Changes take effect immediately on the next dispatch cycle — cameras
14000
+ * currently assigned over-cap are left in place (no forced eviction),
14001
+ * but new assignments obey the updated cap.
14002
+ */
14003
+ setAgentMaxCameras: method(object({
14004
+ agentNodeId: string(),
14005
+ maxCameras: number().int().nonnegative().nullable()
14006
+ }), object({ success: literal(true) }), {
14007
+ kind: "mutation",
14008
+ auth: "admin"
14009
+ }),
13821
14010
  /** Read one camera's settings. Null when never touched (inherits agent defaults fully). */
13822
14011
  getCameraSettings: method(object({ deviceId: number() }), CameraPipelineSettingsSchema.nullable()),
13823
14012
  /** Set or clear the 3-state toggle for one (camera, addonId). Pass `enabled: null` to clear and revert to agent default. */
@@ -13858,6 +14047,29 @@ var pipelineOrchestratorCapability = {
13858
14047
  deviceId: number(),
13859
14048
  agentNodeId: string().optional()
13860
14049
  }), CameraPipelineConfigSchema),
14050
+ /**
14051
+ * Server-composed aggregated status for a single camera.
14052
+ *
14053
+ * Fans out in parallel (bounded, per-stage graceful degradation) to
14054
+ * broker / decoder / motion / detection / audio / recording source caps
14055
+ * via `ctx.api`. A stage whose source errors or times out becomes `null`
14056
+ * in the returned payload — one slow agent never breaks the whole call.
14057
+ *
14058
+ * Intended for "on open / on camera select" snapshots. Live deltas
14059
+ * keep arriving from the existing ~1Hz events the UI already subscribes to.
14060
+ */
14061
+ getCameraStatus: method(object({ deviceId: number() }), CameraStatusSchema),
14062
+ /**
14063
+ * Server-composed aggregated status for multiple cameras in one call.
14064
+ *
14065
+ * `deviceIds` defaults to all cameras currently tracked by the
14066
+ * orchestrator's assignment map when omitted.
14067
+ *
14068
+ * Runs per-device composition in parallel (bounded). Use this to
14069
+ * populate the cluster assignments table and the pipeline flow overview
14070
+ * rail without issuing N parallel browser round-trips.
14071
+ */
14072
+ getCameraStatuses: method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()),
13861
14073
  /** List every template the operator has saved. */
13862
14074
  listTemplates: method(_void(), array(PipelineTemplateSchema).readonly()),
13863
14075
  /** Create a new named preset from a given CameraPipelineConfig. */
@@ -16869,6 +17081,69 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
16869
17081
  kind: "mutation",
16870
17082
  auth: "admin"
16871
17083
  });
17084
+ var jobKindSchema = _enum([
17085
+ "install",
17086
+ "update",
17087
+ "uninstall",
17088
+ "restart"
17089
+ ]);
17090
+ var taskPhaseSchema = _enum([
17091
+ "queued",
17092
+ "fetching",
17093
+ "staged",
17094
+ "validating",
17095
+ "applying",
17096
+ "restarting",
17097
+ "applied",
17098
+ "done",
17099
+ "failed",
17100
+ "skipped"
17101
+ ]);
17102
+ var taskTargetSchema = _enum(["framework", "addon"]);
17103
+ var taskLogEntrySchema = object({
17104
+ tsMs: number(),
17105
+ nodeId: string(),
17106
+ packageName: string(),
17107
+ phase: taskPhaseSchema,
17108
+ message: string()
17109
+ });
17110
+ var lifecycleTaskSchema = object({
17111
+ taskId: string(),
17112
+ nodeId: string(),
17113
+ packageName: string(),
17114
+ fromVersion: string().nullable(),
17115
+ toVersion: string(),
17116
+ target: taskTargetSchema,
17117
+ phase: taskPhaseSchema,
17118
+ stagedPath: string().nullable(),
17119
+ attempts: number(),
17120
+ steps: array(taskLogEntrySchema),
17121
+ error: string().nullable(),
17122
+ startedAtMs: number().nullable(),
17123
+ finishedAtMs: number().nullable()
17124
+ });
17125
+ var lifecycleJobStateSchema = _enum([
17126
+ "running",
17127
+ "completed",
17128
+ "failed",
17129
+ "partially-failed",
17130
+ "cancelled"
17131
+ ]);
17132
+ var lifecycleJobScopeSchema = _enum([
17133
+ "single",
17134
+ "bulk",
17135
+ "cluster"
17136
+ ]);
17137
+ var lifecycleJobSchema = object({
17138
+ jobId: string(),
17139
+ kind: jobKindSchema,
17140
+ createdAtMs: number(),
17141
+ createdBy: string(),
17142
+ scope: lifecycleJobScopeSchema,
17143
+ tasks: array(lifecycleTaskSchema),
17144
+ state: lifecycleJobStateSchema,
17145
+ schemaVersion: literal(1)
17146
+ });
16872
17147
  /**
16873
17148
  * addons — system-scoped singleton capability for addon package
16874
17149
  * management (install, update, configure, restart) and per-addon log
@@ -17051,7 +17326,7 @@ var BulkUpdatePhaseSchema = _enum([
17051
17326
  "restarting",
17052
17327
  "finalizing"
17053
17328
  ]);
17054
- var BulkUpdateStateSchema = object({
17329
+ object({
17055
17330
  id: string(),
17056
17331
  nodeId: string(),
17057
17332
  startedAtMs: number(),
@@ -17154,20 +17429,7 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
17154
17429
  }), UpdateFrameworkPackageResultSchema, {
17155
17430
  kind: "mutation",
17156
17431
  auth: "admin"
17157
- }), method(object({
17158
- nodeId: string(),
17159
- items: array(object({
17160
- name: string(),
17161
- version: string(),
17162
- isSystem: boolean()
17163
- })).readonly()
17164
- }), object({ id: string() }), {
17165
- kind: "mutation",
17166
- auth: "admin"
17167
- }), method(object({ id: string() }), BulkUpdateStateSchema.nullable(), { auth: "admin" }), method(object({ id: string() }), object({ cancelled: boolean() }), {
17168
- kind: "mutation",
17169
- auth: "admin"
17170
- }), method(object({ nodeId: string().optional() }), array(BulkUpdateStateSchema).readonly(), { auth: "admin" }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string() }), RestartAddonResultSchema, {
17432
+ }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string() }), RestartAddonResultSchema, {
17171
17433
  kind: "mutation",
17172
17434
  auth: "admin"
17173
17435
  }), method(object({ packageName: string() }), object({ success: literal(true) }), {
@@ -17189,6 +17451,24 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
17189
17451
  kind: "mutation",
17190
17452
  auth: "admin"
17191
17453
  }), method(CustomActionInputSchema, unknown(), { kind: "mutation" }), method(object({
17454
+ kind: _enum([
17455
+ "install",
17456
+ "update",
17457
+ "uninstall",
17458
+ "restart"
17459
+ ]),
17460
+ targets: array(object({
17461
+ name: string().min(1),
17462
+ version: string().min(1)
17463
+ })).min(1),
17464
+ nodeIds: array(string()).optional()
17465
+ }), object({ jobId: string() }), {
17466
+ kind: "mutation",
17467
+ auth: "admin"
17468
+ }), method(object({ jobId: string() }), lifecycleJobSchema.nullable(), { auth: "admin" }), method(object({ activeOnly: boolean().optional() }), array(lifecycleJobSchema), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
17469
+ kind: "mutation",
17470
+ auth: "admin"
17471
+ }), method(object({
17192
17472
  addonId: string(),
17193
17473
  level: LogLevelSchema$1.optional()
17194
17474
  }), LogStreamEntrySchema, { kind: "subscription" });
@@ -17229,7 +17509,7 @@ Object.freeze({
17229
17509
  addonId: null,
17230
17510
  access: "create"
17231
17511
  },
17232
- "addons.cancelBulkUpdate": {
17512
+ "addons.cancelJob": {
17233
17513
  capName: "addons",
17234
17514
  capScope: "system",
17235
17515
  addonId: null,
@@ -17259,7 +17539,7 @@ Object.freeze({
17259
17539
  addonId: null,
17260
17540
  access: "view"
17261
17541
  },
17262
- "addons.getBulkUpdateState": {
17542
+ "addons.getJob": {
17263
17543
  capName: "addons",
17264
17544
  capScope: "system",
17265
17545
  addonId: null,
@@ -17307,19 +17587,19 @@ Object.freeze({
17307
17587
  addonId: null,
17308
17588
  access: "view"
17309
17589
  },
17310
- "addons.listActiveBulkUpdates": {
17590
+ "addons.listCapabilityProviders": {
17311
17591
  capName: "addons",
17312
17592
  capScope: "system",
17313
17593
  addonId: null,
17314
17594
  access: "view"
17315
17595
  },
17316
- "addons.listCapabilityProviders": {
17596
+ "addons.listFrameworkPackages": {
17317
17597
  capName: "addons",
17318
17598
  capScope: "system",
17319
17599
  addonId: null,
17320
17600
  access: "view"
17321
17601
  },
17322
- "addons.listFrameworkPackages": {
17602
+ "addons.listJobs": {
17323
17603
  capName: "addons",
17324
17604
  capScope: "system",
17325
17605
  addonId: null,
@@ -17403,7 +17683,7 @@ Object.freeze({
17403
17683
  addonId: null,
17404
17684
  access: "create"
17405
17685
  },
17406
- "addons.startBulkUpdate": {
17686
+ "addons.startJob": {
17407
17687
  capName: "addons",
17408
17688
  capScope: "system",
17409
17689
  addonId: null,
@@ -19629,6 +19909,12 @@ Object.freeze({
19629
19909
  addonId: null,
19630
19910
  access: "view"
19631
19911
  },
19912
+ "pipelineExecutor.getEngineProvisioning": {
19913
+ capName: "pipeline-executor",
19914
+ capScope: "system",
19915
+ addonId: null,
19916
+ access: "view"
19917
+ },
19632
19918
  "pipelineExecutor.getGlobalPipelineConfig": {
19633
19919
  capName: "pipeline-executor",
19634
19920
  capScope: "system",
@@ -19833,6 +20119,18 @@ Object.freeze({
19833
20119
  addonId: null,
19834
20120
  access: "view"
19835
20121
  },
20122
+ "pipelineOrchestrator.getCameraStatus": {
20123
+ capName: "pipeline-orchestrator",
20124
+ capScope: "system",
20125
+ addonId: null,
20126
+ access: "view"
20127
+ },
20128
+ "pipelineOrchestrator.getCameraStatuses": {
20129
+ capName: "pipeline-orchestrator",
20130
+ capScope: "system",
20131
+ addonId: null,
20132
+ access: "view"
20133
+ },
19836
20134
  "pipelineOrchestrator.getCameraStepOverrides": {
19837
20135
  capName: "pipeline-orchestrator",
19838
20136
  capScope: "system",
@@ -19917,6 +20215,12 @@ Object.freeze({
19917
20215
  addonId: null,
19918
20216
  access: "create"
19919
20217
  },
20218
+ "pipelineOrchestrator.setAgentMaxCameras": {
20219
+ capName: "pipeline-orchestrator",
20220
+ capScope: "system",
20221
+ addonId: null,
20222
+ access: "create"
20223
+ },
19920
20224
  "pipelineOrchestrator.setCameraPipelineForAgent": {
19921
20225
  capName: "pipeline-orchestrator",
19922
20226
  capScope: "system",
@@ -21390,6 +21694,32 @@ Object.freeze({
21390
21694
  "network-access": "ingress",
21391
21695
  "smtp-provider": "email"
21392
21696
  });
21697
+ var frameworkSwapPackageSchema = object({
21698
+ name: string(),
21699
+ stagedPath: string(),
21700
+ backupPath: string(),
21701
+ toVersion: string(),
21702
+ fromVersion: string().nullable()
21703
+ });
21704
+ object({
21705
+ jobId: string(),
21706
+ taskId: string(),
21707
+ packages: array(frameworkSwapPackageSchema),
21708
+ requestedAtMs: number(),
21709
+ schemaVersion: literal(1)
21710
+ });
21711
+ object({
21712
+ jobId: string(),
21713
+ taskId: string(),
21714
+ backups: array(object({
21715
+ name: string(),
21716
+ backupPath: string(),
21717
+ livePath: string()
21718
+ })),
21719
+ appliedAtMs: number(),
21720
+ bootAttempts: number(),
21721
+ schemaVersion: literal(1)
21722
+ });
21393
21723
  /**
21394
21724
  * Promise-based timer helpers — used everywhere the codebase needs to
21395
21725
  * wait, back off, or schedule a retry. Before these helpers landed, each
@@ -21921,7 +22251,10 @@ var StoredAgentAddonConfigSchema = object({
21921
22251
  modelId: string(),
21922
22252
  settings: record(string(), unknown()).readonly()
21923
22253
  });
21924
- var StoredAgentPipelineSettingsSchema = object({ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly() });
22254
+ var StoredAgentPipelineSettingsSchema = object({
22255
+ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly(),
22256
+ maxCameras: number().int().nonnegative().nullable().default(null)
22257
+ });
21925
22258
  var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
21926
22259
  var StoredCameraStepOverridePatchSchema = object({
21927
22260
  enabled: boolean().optional(),
@@ -21966,10 +22299,26 @@ function computeCapacityScore(load) {
21966
22299
  return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
21967
22300
  }
21968
22301
  /**
22302
+ * Returns true when the node has remaining capacity.
22303
+ * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
22304
+ * its `attachedCameras` count is strictly less than the cap.
22305
+ * Pins count toward the cap via `attachedCameras`.
22306
+ */
22307
+ function isEligible(node, caps) {
22308
+ const cap = caps?.[node.nodeId];
22309
+ if (cap === null || cap === void 0 || cap <= 0) return true;
22310
+ return node.attachedCameras < cap;
22311
+ }
22312
+ /**
21969
22313
  * Run the two-level camera balancer.
21970
22314
  *
21971
- * L1 (manual affinity): if `preferredAgent` names an online node, return it.
21972
- * L2 (capacity): compute capacity scores and pick the lowest.
22315
+ * L1 (manual affinity): if `preferredAgent` names an online node AND that
22316
+ * node is under its `maxCameras` cap, return it. If the node is online but at
22317
+ * or over cap, return `{kind:'pending'}` — a pinned camera is never silently
22318
+ * over-assigned.
22319
+ *
22320
+ * L2 (capacity): filter to eligible nodes and pick the lowest capacity score.
22321
+ * If all nodes are at/over cap, return `{kind:'pending'}`.
21973
22322
  *
21974
22323
  * Returns `null` when no runners are online. The orchestrator decides how to
21975
22324
  * react — typically by logging and deferring the assignment until a runner
@@ -21978,19 +22327,32 @@ function computeCapacityScore(load) {
21978
22327
  function balance(input) {
21979
22328
  const online = input.nodes.filter((n) => n.nodeId.length > 0);
21980
22329
  if (online.length === 0) return null;
22330
+ const eligible = online.filter((n) => isEligible(n, input.nodeCaps));
21981
22331
  if (input.preferredAgent) {
21982
- const match = online.find((n) => n.nodeId === input.preferredAgent);
21983
- if (match) return {
21984
- agentNodeId: match.nodeId,
21985
- reason: "manual",
21986
- score: computeCapacityScore(match)
21987
- };
22332
+ const pinnedOnline = online.find((n) => n.nodeId === input.preferredAgent);
22333
+ if (pinnedOnline) {
22334
+ if (eligible.some((n) => n.nodeId === pinnedOnline.nodeId)) return {
22335
+ kind: "assigned",
22336
+ agentNodeId: pinnedOnline.nodeId,
22337
+ reason: "manual",
22338
+ score: computeCapacityScore(pinnedOnline)
22339
+ };
22340
+ return {
22341
+ kind: "pending",
22342
+ reason: "over-cap"
22343
+ };
22344
+ }
21988
22345
  }
21989
- const best = online.map((node) => ({
22346
+ if (eligible.length === 0) return {
22347
+ kind: "pending",
22348
+ reason: "over-cap"
22349
+ };
22350
+ const best = eligible.map((node) => ({
21990
22351
  node,
21991
22352
  score: computeCapacityScore(node)
21992
22353
  })).toSorted((a, b) => a.score - b.score)[0];
21993
22354
  return {
22355
+ kind: "assigned",
21994
22356
  agentNodeId: best.node.nodeId,
21995
22357
  reason: "capacity",
21996
22358
  score: best.score
@@ -22409,6 +22771,139 @@ var PipelineWatchdog = class {
22409
22771
  }
22410
22772
  };
22411
22773
  //#endregion
22774
+ //#region src/camera-status/compose-camera-status.ts
22775
+ function mapAssignment(input) {
22776
+ return {
22777
+ detectionNodeId: input.detectionNodeId,
22778
+ decoderNodeId: input.decoderNodeId,
22779
+ audioNodeId: input.audioNodeId,
22780
+ pinned: {
22781
+ detection: input.pinned.detection,
22782
+ decoder: input.pinned.decoder,
22783
+ audio: input.pinned.audio
22784
+ },
22785
+ reasons: {
22786
+ detection: input.reasons.detection,
22787
+ decoder: input.reasons.decoder,
22788
+ audio: input.reasons.audio
22789
+ }
22790
+ };
22791
+ }
22792
+ function mapSource(sourceResult) {
22793
+ if (sourceResult === null) return { streams: [] };
22794
+ return { streams: sourceResult.streams.map((s) => ({
22795
+ camStreamId: s.camStreamId,
22796
+ codec: s.codec,
22797
+ width: s.width,
22798
+ height: s.height,
22799
+ fps: s.fps,
22800
+ kind: s.kind
22801
+ })) };
22802
+ }
22803
+ function mapBroker(brokerResult) {
22804
+ if (brokerResult === null) return null;
22805
+ return {
22806
+ profiles: brokerResult.profiles.map((p) => ({
22807
+ profile: p.profile,
22808
+ status: p.status,
22809
+ codec: p.codec,
22810
+ width: p.width,
22811
+ height: p.height,
22812
+ subscribers: p.subscribers,
22813
+ inFps: p.inFps,
22814
+ outFps: p.outFps
22815
+ })),
22816
+ webrtcSessions: brokerResult.webrtcSessions,
22817
+ rtspRestream: brokerResult.rtspRestream
22818
+ };
22819
+ }
22820
+ function mapDecoderShm(shm) {
22821
+ return {
22822
+ framesWritten: shm.framesWritten,
22823
+ getFrameHits: shm.getFrameHits,
22824
+ getFrameMisses: shm.getFrameMisses,
22825
+ budgetMb: shm.budgetMb
22826
+ };
22827
+ }
22828
+ function mapDecoder(decoderResult) {
22829
+ if (decoderResult === null) return null;
22830
+ return {
22831
+ nodeId: decoderResult.nodeId,
22832
+ formats: [...decoderResult.formats],
22833
+ sessionCount: decoderResult.sessionCount,
22834
+ shm: mapDecoderShm(decoderResult.shm)
22835
+ };
22836
+ }
22837
+ function mapMotion(motionResult) {
22838
+ if (motionResult === null) return null;
22839
+ return {
22840
+ enabled: motionResult.enabled,
22841
+ fps: motionResult.fps
22842
+ };
22843
+ }
22844
+ function mapProvisioning(p) {
22845
+ if (p.error !== void 0) return {
22846
+ state: p.state,
22847
+ error: p.error
22848
+ };
22849
+ return { state: p.state };
22850
+ }
22851
+ function mapDetection(detectionResult) {
22852
+ if (detectionResult === null) return null;
22853
+ const phase = detectionResult.phase;
22854
+ return {
22855
+ nodeId: detectionResult.nodeId,
22856
+ engine: {
22857
+ backend: detectionResult.engine.backend,
22858
+ device: detectionResult.engine.device
22859
+ },
22860
+ phase,
22861
+ configuredFps: detectionResult.configuredFps,
22862
+ actualFps: detectionResult.actualFps,
22863
+ queueDepth: detectionResult.queueDepth,
22864
+ avgInferenceMs: detectionResult.avgInferenceMs,
22865
+ provisioning: mapProvisioning(detectionResult.provisioning)
22866
+ };
22867
+ }
22868
+ function mapAudio(audioResult) {
22869
+ if (audioResult === null) return null;
22870
+ return {
22871
+ nodeId: audioResult.nodeId,
22872
+ enabled: audioResult.enabled
22873
+ };
22874
+ }
22875
+ function mapRecording(recordingResult) {
22876
+ if (recordingResult === null) return null;
22877
+ return {
22878
+ mode: recordingResult.mode,
22879
+ active: recordingResult.active,
22880
+ storageBytes: recordingResult.storageBytes
22881
+ };
22882
+ }
22883
+ /**
22884
+ * Pure function that composes a `CameraStatus` from per-stage fetch results.
22885
+ *
22886
+ * - `assignment` is always built from orchestrator-local data (never null).
22887
+ * - `source` always present: defaults to `{ streams: [] }` when sourceResult is null.
22888
+ * - Every other block is null when its stage result is null (graceful degradation).
22889
+ * - `fetchedAt` is stamped exactly as provided — never calls `Date.now()`.
22890
+ * - No mutation of the input.
22891
+ */
22892
+ function composeCameraStatus(input) {
22893
+ return {
22894
+ deviceId: input.deviceId,
22895
+ assignment: mapAssignment(input),
22896
+ source: mapSource(input.sourceResult),
22897
+ broker: mapBroker(input.brokerResult),
22898
+ decoder: mapDecoder(input.decoderResult),
22899
+ motion: mapMotion(input.motionResult),
22900
+ detection: mapDetection(input.detectionResult),
22901
+ audio: mapAudio(input.audioResult),
22902
+ recording: mapRecording(input.recordingResult),
22903
+ fetchedAt: input.fetchedAt
22904
+ };
22905
+ }
22906
+ //#endregion
22412
22907
  //#region src/index.ts
22413
22908
  var PHASE_MODE_VALUES = new Set([
22414
22909
  "disabled",
@@ -23166,8 +23661,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23166
23661
  });
23167
23662
  return {
23168
23663
  success: true,
23169
- agentNodeId: "",
23170
- reason: "capacity"
23664
+ kind: "pending"
23171
23665
  };
23172
23666
  }
23173
23667
  }
@@ -23177,14 +23671,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23177
23671
  const preferredAgent = typeof pipelinePin === "string" && pipelinePin !== "auto" ? pipelinePin : legacyPreferred;
23178
23672
  const decision = balance({
23179
23673
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23180
- preferredAgent
23674
+ preferredAgent,
23675
+ nodeCaps: this.buildNodeCaps()
23181
23676
  });
23182
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23183
- if (!targetNodeId) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
23677
+ if (!decision) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
23678
+ if (decision.kind === "pending") {
23679
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: runnerConfig.deviceId } });
23680
+ return {
23681
+ success: true,
23682
+ kind: "pending"
23683
+ };
23684
+ }
23685
+ const targetNodeId = decision.agentNodeId;
23184
23686
  await this.attachOn(targetNodeId, runnerConfig);
23185
23687
  if (targetNodeId === this.localNodeId) this.pipelineWatchdog?.register(this.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId)));
23186
- const reason = decision?.reason ?? "capacity";
23187
- const pinned = decision?.reason === "manual";
23688
+ const reason = decision.reason;
23689
+ const pinned = decision.reason === "manual";
23188
23690
  this.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, pinned);
23189
23691
  const decoderNodeId = await this.resolveDecoderNode(runnerConfig.deviceId, targetNodeId);
23190
23692
  this.ctx.logger.info("dispatchCamera", {
@@ -23194,12 +23696,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23194
23696
  },
23195
23697
  meta: {
23196
23698
  reason,
23197
- score: decision?.score ?? "n/a",
23699
+ score: decision.score,
23198
23700
  decoderNodeId
23199
23701
  }
23200
23702
  });
23201
23703
  return {
23202
23704
  success: true,
23705
+ kind: "assigned",
23203
23706
  agentNodeId: targetNodeId,
23204
23707
  reason
23205
23708
  };
@@ -23273,10 +23776,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23273
23776
  if (cached) {
23274
23777
  const decision = balance({
23275
23778
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
23276
- preferredAgent: null
23779
+ preferredAgent: null,
23780
+ nodeCaps: this.buildNodeCaps()
23277
23781
  });
23278
- const targetNodeId = decision?.agentNodeId ?? await this.localRunnerNodeId();
23279
- if (targetNodeId) {
23782
+ if (!decision) this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23783
+ else if (decision.kind === "pending") this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: input.deviceId } });
23784
+ else {
23785
+ const targetNodeId = decision.agentNodeId;
23280
23786
  if (current && current.agentNodeId !== targetNodeId) await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
23281
23787
  const msg = errMsg(err);
23282
23788
  this.ctx.logger.debug("unassignPipeline detach-old failed", {
@@ -23285,7 +23791,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23285
23791
  });
23286
23792
  });
23287
23793
  if (!current || current.agentNodeId !== targetNodeId) await this.attachOn(targetNodeId, cached);
23288
- const reason = decision?.reason === "manual" ? "capacity" : decision?.reason ?? "capacity";
23794
+ const reason = decision.reason === "manual" ? "capacity" : decision.reason;
23289
23795
  this.recordAssignment(input.deviceId, targetNodeId, reason, false);
23290
23796
  this.ctx.logger.info("unassignPipeline: re-dispatched via auto", {
23291
23797
  tags: {
@@ -23296,7 +23802,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23296
23802
  });
23297
23803
  return { success: true };
23298
23804
  }
23299
- this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
23300
23805
  }
23301
23806
  if (current) {
23302
23807
  await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
@@ -23314,15 +23819,21 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23314
23819
  async rebalance() {
23315
23820
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
23316
23821
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
23822
+ const nodeCaps = this.buildNodeCaps();
23317
23823
  let migrated = 0;
23318
23824
  for (const [deviceId, config] of this.cameraConfigs) {
23319
23825
  const current = this.assignments.get(deviceId);
23320
23826
  if (current?.pinned) continue;
23321
23827
  const decision = balance({
23322
23828
  nodes: loads,
23323
- preferredAgent: await this.readPreferredAgent(deviceId)
23829
+ preferredAgent: await this.readPreferredAgent(deviceId),
23830
+ nodeCaps
23324
23831
  });
23325
23832
  if (!decision) continue;
23833
+ if (decision.kind === "pending") {
23834
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
23835
+ continue;
23836
+ }
23326
23837
  if (current && current.agentNodeId === decision.agentNodeId) continue;
23327
23838
  if (current) await this.detachOn(current.agentNodeId, deviceId).catch((err) => {
23328
23839
  const msg = errMsg(err);
@@ -23460,15 +23971,6 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23460
23971
  nodeId
23461
23972
  });
23462
23973
  }
23463
- async localRunnerNodeId() {
23464
- const api = this.ctx.api;
23465
- if (!api) return null;
23466
- try {
23467
- return (await api.pipelineRunner.getLocalLoad.query({ nodeId: this.localNodeId }))?.nodeId ?? this.localNodeId;
23468
- } catch {
23469
- return this.localNodeId;
23470
- }
23471
- }
23472
23974
  /**
23473
23975
  * Enumerate every runner-capable node currently known (populated via
23474
23976
  * AgentOnline/AgentOffline events). Used to populate the `enabledNodes`
@@ -23623,6 +24125,18 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23623
24125
  return null;
23624
24126
  }
23625
24127
  }
24128
+ /**
24129
+ * Build a per-node camera cap map from the persisted agent settings.
24130
+ * Returns `null` for nodes where `maxCameras` is null (unlimited).
24131
+ * Used by every `balance()` call so the balancer can honour operator-set
24132
+ * per-node maximums without polling the store on each decision.
24133
+ */
24134
+ buildNodeCaps() {
24135
+ const blob = this.agentSettingsState.get();
24136
+ const caps = {};
24137
+ for (const [nodeId, settings] of Object.entries(blob)) caps[nodeId] = settings.maxCameras ?? null;
24138
+ return caps;
24139
+ }
23626
24140
  async readAudioNodePin(deviceId) {
23627
24141
  if (!this.ctx?.settings) return null;
23628
24142
  try {
@@ -23765,13 +24279,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23765
24279
  for (const { deviceId, config } of affected) {
23766
24280
  const decision = balance({
23767
24281
  nodes: loads,
23768
- preferredAgent: null
24282
+ preferredAgent: null,
24283
+ nodeCaps: this.buildNodeCaps()
23769
24284
  });
23770
24285
  if (!decision) {
23771
24286
  this.ctx.logger.error("Failover: no online runner", { tags: { deviceId } });
23772
24287
  this.assignments.delete(deviceId);
23773
24288
  continue;
23774
24289
  }
24290
+ if (decision.kind === "pending") {
24291
+ this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
24292
+ this.assignments.delete(deviceId);
24293
+ continue;
24294
+ }
23775
24295
  try {
23776
24296
  await this.attachOn(decision.agentNodeId, config);
23777
24297
  this.recordAssignment(deviceId, decision.agentNodeId, "failover", false);
@@ -24251,7 +24771,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24251
24771
  }
24252
24772
  async setAgentAddonDefaults(input) {
24253
24773
  let existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24254
- if (!existing) existing = await this.seedAgentSettingsFromCatalog(input.agentNodeId) ?? void 0;
24774
+ if (!existing) {
24775
+ await this.seedAgentSettingsFromCatalog(input.agentNodeId);
24776
+ existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24777
+ }
24255
24778
  if (!existing) await this.writeAgentSettings(input.agentNodeId, { addonDefaults: { ...input.defaults } });
24256
24779
  else await this.writeAgentSettings(input.agentNodeId, {
24257
24780
  ...existing,
@@ -24289,6 +24812,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24289
24812
  removed: true
24290
24813
  };
24291
24814
  }
24815
+ async setAgentMaxCameras(input) {
24816
+ const existing = (await this.readAgentSettingsMap())[input.agentNodeId];
24817
+ const next = existing ? {
24818
+ ...existing,
24819
+ maxCameras: input.maxCameras
24820
+ } : {
24821
+ addonDefaults: {},
24822
+ maxCameras: input.maxCameras
24823
+ };
24824
+ await this.writeAgentSettings(input.agentNodeId, next);
24825
+ this.ctx.logger.info("agentSettings.maxCameras updated", {
24826
+ tags: { nodeId: input.agentNodeId },
24827
+ meta: { maxCameras: input.maxCameras }
24828
+ });
24829
+ return { success: true };
24830
+ }
24292
24831
  async getCameraSettings(input) {
24293
24832
  return (await this.readCameraSettingsMap())[String(input.deviceId)] ?? null;
24294
24833
  }
@@ -24418,6 +24957,204 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24418
24957
  this.ctx.logger.info("template deleted", { meta: { templateId: input.id } });
24419
24958
  return { success: true };
24420
24959
  }
24960
+ /**
24961
+ * Races a promise against a timeout. Returns `null` on timeout OR rejection.
24962
+ * Never throws — individual stage failures become `null` in the aggregate.
24963
+ *
24964
+ * @param p The stage fetch promise.
24965
+ * @param ms Timeout in milliseconds.
24966
+ */
24967
+ boundedStage(p, ms) {
24968
+ let timer;
24969
+ const timeout = new Promise((resolve) => {
24970
+ timer = setTimeout(() => resolve(null), ms);
24971
+ });
24972
+ return Promise.race([p, timeout]).catch(() => null).finally(() => {
24973
+ if (timer !== void 0) clearTimeout(timer);
24974
+ });
24975
+ }
24976
+ /**
24977
+ * Server-composed aggregated status for a single camera.
24978
+ *
24979
+ * Fans out in parallel (bounded, per-stage graceful degradation) to
24980
+ * broker / decoder / motion / detection / audio / recording source caps
24981
+ * via `ctx.api`. A stage whose source errors or times out becomes `null`
24982
+ * in the returned payload — one slow agent never breaks the whole call.
24983
+ */
24984
+ async getCameraStatus(input) {
24985
+ const { deviceId } = input;
24986
+ const api = this.ctx.api;
24987
+ const STAGE_TIMEOUT_MS = 3e3;
24988
+ const pipelineAssignment = this.assignments.get(deviceId) ?? null;
24989
+ const detectionNodeId = pipelineAssignment?.agentNodeId ?? null;
24990
+ const decoderPinRaw = (api ? await this.ctx.settings?.readDeviceStore(deviceId).catch(() => ({})) ?? {} : {})["decoderNodeId"];
24991
+ const decoderPinned = typeof decoderPinRaw === "string" && decoderPinRaw !== "auto";
24992
+ const decoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
24993
+ const audioAssignment = this.audioAssignments.get(deviceId) ?? null;
24994
+ const audioNodeId = audioAssignment?.nodeId ?? null;
24995
+ const audioPinned = audioAssignment?.pinned ?? false;
24996
+ const pinned = {
24997
+ detection: pipelineAssignment?.pinned ?? false,
24998
+ decoder: decoderPinned,
24999
+ audio: audioPinned
25000
+ };
25001
+ const reasons = {
25002
+ detection: pipelineAssignment?.reason,
25003
+ decoder: decoderPinned ? "manual" : "co-located",
25004
+ audio: audioPinned ? "manual" : void 0
25005
+ };
25006
+ const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query() : null;
25007
+ const sourceFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then((slots) => {
25008
+ return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
25009
+ camStreamId: s.sourceCamStreamId ?? s.brokerId,
25010
+ codec: s.codec ?? "",
25011
+ width: s.resolution?.width ?? 0,
25012
+ height: s.resolution?.height ?? 0,
25013
+ fps: 0,
25014
+ kind: s.profile
25015
+ })) };
25016
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25017
+ const WEBRTC_KINDS = new Set([
25018
+ "webrtc-browser",
25019
+ "webrtc-mobile",
25020
+ "webrtc-whep"
25021
+ ]);
25022
+ const brokerFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then(async (slots) => {
25023
+ const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
25024
+ if (deviceSlots.length === 0) return {
25025
+ profiles: [],
25026
+ webrtcSessions: 0,
25027
+ rtspRestream: false
25028
+ };
25029
+ const [statsAndClients, rtspEntry] = await Promise.all([Promise.all(deviceSlots.map(async (slot) => {
25030
+ return {
25031
+ slot,
25032
+ stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }).catch(() => null),
25033
+ clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }).catch(() => null)
25034
+ };
25035
+ })), api.streamBroker.getAllRtspEntries.query({}).catch(() => null)]);
25036
+ return {
25037
+ profiles: statsAndClients.map(({ slot, stats, clients }) => ({
25038
+ profile: slot.profile,
25039
+ status: slot.status,
25040
+ codec: stats?.codec ?? slot.codec ?? "",
25041
+ width: slot.resolution?.width ?? 0,
25042
+ height: slot.resolution?.height ?? 0,
25043
+ subscribers: clients?.encodedSubscribers ?? 0,
25044
+ inFps: stats?.inputFps ?? 0,
25045
+ outFps: stats?.decodeFps ?? 0
25046
+ })),
25047
+ webrtcSessions: statsAndClients.reduce((total, { clients }) => {
25048
+ if (!clients) return total;
25049
+ return total + clients.encoded.filter((c) => WEBRTC_KINDS.has(c.attribution.kind)).length;
25050
+ }, 0),
25051
+ rtspRestream: rtspEntry?.some((e) => {
25052
+ return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
25053
+ }) ?? false
25054
+ };
25055
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25056
+ const decoderFetch = decoderNodeId ? Promise.resolve({
25057
+ nodeId: decoderNodeId,
25058
+ formats: [],
25059
+ sessionCount: 0,
25060
+ shm: {
25061
+ framesWritten: 0,
25062
+ getFrameHits: 0,
25063
+ getFrameMisses: 0,
25064
+ budgetMb: 0
25065
+ }
25066
+ }) : Promise.resolve(null);
25067
+ const motionResult = (() => {
25068
+ const config = this.cameraConfigs.get(deviceId);
25069
+ if (!config) return null;
25070
+ return {
25071
+ enabled: config.motionSources.includes("analyzer") || config.motionSources.includes("onboard"),
25072
+ fps: config.motionFps
25073
+ };
25074
+ })();
25075
+ const detectionFetch = api && detectionNodeId ? this.boundedStage(Promise.all([api.pipelineExecutor.getEngineProvisioning.query({ nodeId: detectionNodeId }).catch(() => null), api.pipelineExecutor.getSelectedEngine.query({ nodeId: detectionNodeId }).catch(() => null)]).then(async ([provisioning, engine]) => {
25076
+ const metrics = await api.pipelineRunner.getCameraMetrics.query({
25077
+ deviceId,
25078
+ nodeId: detectionNodeId
25079
+ }).catch(() => null);
25080
+ const phase = (() => {
25081
+ const p = metrics?.phase;
25082
+ if (p === "active") return "active";
25083
+ if (p === "idle") return "idle";
25084
+ return "watching";
25085
+ })();
25086
+ return {
25087
+ nodeId: detectionNodeId,
25088
+ engine: {
25089
+ backend: engine?.backend ?? "",
25090
+ device: engine?.device ?? ""
25091
+ },
25092
+ phase,
25093
+ configuredFps: metrics?.configuredFps ?? 0,
25094
+ actualFps: metrics?.actualFps ?? 0,
25095
+ queueDepth: metrics?.queueDepth ?? 0,
25096
+ avgInferenceMs: metrics?.avgInferenceTimeMs ?? 0,
25097
+ provisioning: {
25098
+ state: provisioning?.state ?? "idle",
25099
+ ...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
25100
+ }
25101
+ };
25102
+ }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25103
+ const audioResult = audioNodeId ? {
25104
+ nodeId: audioNodeId,
25105
+ enabled: true
25106
+ } : null;
25107
+ function isRecordingStatus(v) {
25108
+ return v !== null && typeof v === "object" && "activeMode" in v && (v.activeMode === "off" || v.activeMode === "continuous" || v.activeMode === "events") && "enabled" in v && typeof v.enabled === "boolean" && "storageBytes" in v && typeof v.storageBytes === "number";
25109
+ }
25110
+ const recordingFetch = api ? this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
25111
+ if (!isRecordingStatus(rawStatus)) return null;
25112
+ return {
25113
+ mode: rawStatus.activeMode,
25114
+ active: rawStatus.enabled && rawStatus.activeMode !== "off",
25115
+ storageBytes: rawStatus.storageBytes
25116
+ };
25117
+ }).catch(() => null), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25118
+ const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult] = await Promise.all([
25119
+ sourceFetch,
25120
+ brokerFetch,
25121
+ decoderFetch,
25122
+ detectionFetch,
25123
+ recordingFetch
25124
+ ]);
25125
+ return composeCameraStatus({
25126
+ deviceId,
25127
+ fetchedAt: Date.now(),
25128
+ detectionNodeId,
25129
+ decoderNodeId,
25130
+ audioNodeId,
25131
+ pinned,
25132
+ reasons,
25133
+ sourceResult,
25134
+ brokerResult,
25135
+ decoderResult,
25136
+ motionResult,
25137
+ detectionResult,
25138
+ audioResult,
25139
+ recordingResult
25140
+ });
25141
+ }
25142
+ /**
25143
+ * Server-composed aggregated status for multiple cameras in one call.
25144
+ *
25145
+ * `deviceIds` defaults to all cameras currently tracked by the
25146
+ * orchestrator's assignment map when omitted.
25147
+ *
25148
+ * v1: `Promise.all` over per-device composition (no concurrency cap).
25149
+ * Note: for large fleets (hundreds of cameras) this may fan out many
25150
+ * parallel calls. A concurrency limiter (p-limit / semaphore) should be
25151
+ * added if latency measurements show it's necessary — deliberately
25152
+ * deferred per the YAGNI constraint in the spec.
25153
+ */
25154
+ async getCameraStatuses(input) {
25155
+ const ids = input.deviceIds !== void 0 && input.deviceIds.length > 0 ? input.deviceIds : [...this.assignments.keys()];
25156
+ return Promise.all(ids.map((deviceId) => this.getCameraStatus({ deviceId })));
25157
+ }
24421
25158
  /** Read the templates map from the addon store via the durable handle. */
24422
25159
  async readTemplatesMap() {
24423
25160
  const raw = await this.templatesState.get();
@@ -24448,7 +25185,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24448
25185
  return;
24449
25186
  }
24450
25187
  const all = await this.readAgentSettingsMap();
24451
- all[nodeId] = settings;
25188
+ const existing = all[nodeId];
25189
+ all[nodeId] = {
25190
+ addonDefaults: settings.addonDefaults,
25191
+ maxCameras: settings.maxCameras !== void 0 ? settings.maxCameras ?? null : existing?.maxCameras ?? null
25192
+ };
24452
25193
  await this.agentSettingsState.set(all);
24453
25194
  }
24454
25195
  /** Read the `cameraSettings` map (keyed on `deviceId` as string) via the durable handle. */
@@ -24658,12 +25399,15 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24658
25399
  }
24659
25400
  let agent = (await this.readAgentSettingsMap())[nodeId];
24660
25401
  if (!agent || Object.keys(agent.addonDefaults ?? {}).length === 0) {
24661
- const seeded = await this.seedAgentSettingsFromCatalog(nodeId);
24662
- if (!seeded) {
25402
+ if (!await this.seedAgentSettingsFromCatalog(nodeId)) {
24663
25403
  await sleep$1(2e3);
24664
25404
  continue;
24665
25405
  }
24666
- agent = seeded;
25406
+ agent = (await this.readAgentSettingsMap())[nodeId];
25407
+ }
25408
+ if (!agent) {
25409
+ await sleep$1(2e3);
25410
+ continue;
24667
25411
  }
24668
25412
  const engine = await this.readDetectionPipelineEngine(nodeId) ?? catalog.selectedEngine;
24669
25413
  return {
@@ -25570,7 +26314,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25570
26314
  };
25571
26315
  let dispatchedNodeId = null;
25572
26316
  try {
25573
- dispatchedNodeId = (await this.dispatchCamera(runnerConfig)).agentNodeId;
26317
+ const result = await this.dispatchCamera(runnerConfig);
26318
+ if (result.kind === "assigned") dispatchedNodeId = result.agentNodeId;
25574
26319
  } catch (err) {
25575
26320
  const msg = errMsg(err);
25576
26321
  log.error("dispatchCamera failed", { meta: { error: msg } });