@camstack/addon-pipeline-orchestrator 1.2.127 → 1.2.129

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
@@ -8894,6 +8894,20 @@ var RelocateJobSchema = object({
8894
8894
  * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8895
8895
  */
8896
8896
  rowsReconciled: number().int().nonnegative().optional(),
8897
+ /**
8898
+ * Rows this run FORGOT because the file they name is not on disk.
8899
+ *
8900
+ * The mover derived the path from the row's own fields and `stat`ed it; an
8901
+ * ENOENT there is a per-path confirmation that the segment is gone (D296),
8902
+ * and the durable row is dropped through the same channel eviction uses. It
8903
+ * is reported for the same reason `rowsReconciled` is: this is a durable
8904
+ * mutation nobody asked for, and a migration that quietly erases hour rows is
8905
+ * the same failure as one that quietly skips them (D295).
8906
+ *
8907
+ * The production drain of 2026-08-30 would have reported 11 074 here — the
8908
+ * ledger claimed 5.65 GB of footage that no longer existed.
8909
+ */
8910
+ rowsForgotten: number().int().nonnegative().optional(),
8897
8911
  startedAt: number(),
8898
8912
  finishedAt: number().nullable(),
8899
8913
  error: string().nullable()
@@ -9257,6 +9271,91 @@ var RelocateResidueSchema = object({
9257
9271
  segments: number().int().nonnegative(),
9258
9272
  bytes: number().int().nonnegative()
9259
9273
  }).nullable();
9274
+ /**
9275
+ * Ask one location whether its durable hour rows describe the disk — the walk
9276
+ * (D319).
9277
+ *
9278
+ * `apply` DEFAULTS TO FALSE and that default is the product: the operator's
9279
+ * missing tool is the question, and the dry run is how they sanity-check the
9280
+ * destructive run before authorising it.
9281
+ */
9282
+ var LedgerWalkInputSchema = object({
9283
+ locationId: string().min(1),
9284
+ /** Forget the confirmed-absent rows, rather than only counting them. */
9285
+ apply: boolean().optional(),
9286
+ /** Narrow to one camera. */
9287
+ deviceId: number().int().positive().optional(),
9288
+ /** Narrow to these recording profiles; empty/absent = every profile. */
9289
+ profiles: array(string().min(1)).optional()
9290
+ });
9291
+ /** Why a whole walk did nothing. Every one leaves the ledger untouched. */
9292
+ var LedgerWalkRefusalSchema = _enum([
9293
+ "location-unknown",
9294
+ "source-writable",
9295
+ "no-ledger",
9296
+ "archive-unreadable",
9297
+ "anchor-absent",
9298
+ "anchor-unreadable",
9299
+ "anchor-moved"
9300
+ ]);
9301
+ _enum([
9302
+ "live-tail",
9303
+ "listing-error",
9304
+ "path-mismatch",
9305
+ "durable-refused"
9306
+ ]);
9307
+ /** Every skip reason, always present, always a number — so a reason that never
9308
+ * fired reports as zero rather than absent and the report shape is constant
9309
+ * between passes. Spelled out rather than `z.record` for exactly that. */
9310
+ var LedgerWalkSkipCountsSchema = object({
9311
+ "live-tail": number().int().nonnegative(),
9312
+ "listing-error": number().int().nonnegative(),
9313
+ "path-mismatch": number().int().nonnegative(),
9314
+ "durable-refused": number().int().nonnegative()
9315
+ });
9316
+ /** One camera's share of a walk, so a report names cameras and not rows. */
9317
+ var LedgerWalkDeviceReportSchema = object({
9318
+ deviceId: number().int(),
9319
+ hoursWalked: number().int().nonnegative(),
9320
+ hoursMissing: number().int().nonnegative(),
9321
+ ghostSegments: number().int().nonnegative(),
9322
+ ghostBytes: number().int().nonnegative(),
9323
+ forgottenSegments: number().int().nonnegative(),
9324
+ orphanFiles: number().int().nonnegative()
9325
+ });
9326
+ /**
9327
+ * What one walk claimed, listed, found and (only when armed) forgot.
9328
+ *
9329
+ * `archiveSegments` is the **M** and `segmentsClaimed` the **N** (D295), so a
9330
+ * walk that saw a fraction of the location is visible in its own report rather
9331
+ * than in the absence of one.
9332
+ */
9333
+ var LedgerWalkReportSchema = object({
9334
+ locationId: string(),
9335
+ applied: boolean(),
9336
+ refused: LedgerWalkRefusalSchema.nullable(),
9337
+ archiveSegments: number().int().nonnegative().nullable(),
9338
+ archiveBytes: number().int().nonnegative().nullable(),
9339
+ hoursClaimed: number().int().nonnegative(),
9340
+ hoursWalked: number().int().nonnegative(),
9341
+ hoursMissing: number().int().nonnegative(),
9342
+ /** `readdir` calls issued — the cost, stated in the unit that is paid. */
9343
+ listings: number().int().nonnegative(),
9344
+ segmentsClaimed: number().int().nonnegative(),
9345
+ ghostSegments: number().int().nonnegative(),
9346
+ ghostBytes: number().int().nonnegative(),
9347
+ ghostHoursWhole: number().int().nonnegative(),
9348
+ forgottenSegments: number().int().nonnegative(),
9349
+ forgottenBytes: number().int().nonnegative(),
9350
+ /** Files under a claimed hour that no durable row names. Never deleted. */
9351
+ orphanFiles: number().int().nonnegative(),
9352
+ orphanSample: array(string()).readonly(),
9353
+ hoursSkipped: number().int().nonnegative(),
9354
+ skippedByReason: LedgerWalkSkipCountsSchema,
9355
+ /** The walk stopped at its per-pass hour bound with claims unwalked. */
9356
+ bounded: boolean(),
9357
+ byDevice: array(LedgerWalkDeviceReportSchema).readonly()
9358
+ });
9260
9359
  /** How many rows a media pass would still act on against a given target — the
9261
9360
  * media lane's denominator AND its residue, from ONE derivation so the two can
9262
9361
  * never disagree. `null` = the count could not be taken. */
@@ -19833,13 +19932,15 @@ var ListGroupsPageSchema = object({
19833
19932
  groups: array(AnalyticsGroupRecordSchema).readonly(),
19834
19933
  nextCursor: string().nullable()
19835
19934
  });
19935
+ var KEY_EVENTS_DEFAULT_LIMIT = 50;
19936
+ var KEY_EVENTS_MAX_LIMIT = 200;
19836
19937
  var KeyEventQueryInput = object({
19837
19938
  deviceId: number(),
19838
19939
  /** Window lower bound (track firstSeen ≥ since). */
19839
19940
  since: number(),
19840
19941
  /** Window upper bound (track firstSeen ≤ until). */
19841
19942
  until: number(),
19842
- limit: number().int().min(1).max(200).default(50),
19943
+ limit: number().int().min(1).max(KEY_EVENTS_MAX_LIMIT).default(KEY_EVENTS_DEFAULT_LIMIT),
19843
19944
  /** Drop tracks scoring below this importance. */
19844
19945
  minImportance: number().min(0).max(1).optional(),
19845
19946
  /** Restrict to a single class (e.g. 'person'). */
@@ -19861,6 +19962,32 @@ var KeyEventSchema = object({
19861
19962
  ...TrackFlagFields,
19862
19963
  ...TrackRetrainFields
19863
19964
  });
19965
+ /** `getKeyEvents`' window and filters, asked of a SET of cameras at once. */
19966
+ var KeyEventBatchQueryInput = object({
19967
+ deviceIds: array(number()).min(1).max(200),
19968
+ since: number(),
19969
+ until: number(),
19970
+ /** Applied PER CAMERA, exactly as `getKeyEvents.limit` is — never a total
19971
+ * across the set, which would let a busy camera starve a quiet one of its
19972
+ * rows and change what the merged feed contains. */
19973
+ limit: number().int().min(1).max(KEY_EVENTS_MAX_LIMIT).default(KEY_EVENTS_DEFAULT_LIMIT),
19974
+ minImportance: number().min(0).max(1).optional(),
19975
+ classFilter: string().optional()
19976
+ });
19977
+ /**
19978
+ * One camera's key events in a batch answer.
19979
+ *
19980
+ * The row exists for every requested id. `getKeyEvents` degrades to `[]` on
19981
+ * error rather than throwing, so a camera whose store read failed and one with
19982
+ * no events in the window were ALREADY indistinguishable per camera — the
19983
+ * batch does not make that worse, and the row keeps the deviceId the single
19984
+ * method's output never carried (the caller used to stamp it from the fan-out
19985
+ * key, which only worked because there was one query per camera).
19986
+ */
19987
+ var KeyEventsForDeviceSchema = object({
19988
+ deviceId: number(),
19989
+ events: array(KeyEventSchema).readonly()
19990
+ });
19864
19991
  object({
19865
19992
  trackId: string(),
19866
19993
  className: string(),
@@ -19932,6 +20059,50 @@ var EventStoreFootprintSchema = object({
19932
20059
  totalBytes: number().int(),
19933
20060
  devices: array(EventStoreDeviceFootprintSchema).readonly()
19934
20061
  });
20062
+ /** Event-media footprint for one {@link MediaFileKind}. */
20063
+ var EventMediaKindFootprintSchema = object({
20064
+ kind: MediaFileKindEnum,
20065
+ /** Media rows of this kind. */
20066
+ rows: number().int(),
20067
+ /** Bytes on disk held by those rows. */
20068
+ bytes: number().int()
20069
+ });
20070
+ /**
20071
+ * The media footprint broken down by KIND — the axis a deletion decision
20072
+ * actually turns on.
20073
+ *
20074
+ * A byte total says how much there is; it cannot say what is safe to remove.
20075
+ * The deletable set (the periodic `snapshot` filmstrip, the surplus per-edge
20076
+ * motion stills) and the keep set (`firstFrame`, rolling `lastFrame`,
20077
+ * `thumbnail`/`thumbnailSmall`, `keyFrame`/`keyFrameSmall`, the face/plate
20078
+ * buffers, gallery media, the CLIP `crop`) are distinguished by `kind` and by
20079
+ * nothing else, so sizing a deletion means summing per kind.
20080
+ *
20081
+ * ## Why `unaccounted*` exists
20082
+ *
20083
+ * `kinds` is enumerated from {@link MediaFileKindEnum} — the closed set the
20084
+ * writers use — and summed one kind at a time. `totalRows` / `totalBytes` come
20085
+ * from a SEPARATE unfiltered aggregate over the same rows, never from adding
20086
+ * `kinds` up. A row whose stored `kind` is not in the enum (written by a
20087
+ * retired code path, or by a version that knew a kind this one does not) would
20088
+ * otherwise vanish from the total silently, and an operator would delete
20089
+ * against a denominator smaller than the disk.
20090
+ *
20091
+ * `unaccountedRows` / `unaccountedBytes` are the difference. They are normally
20092
+ * zero; a non-zero value is a real finding and must be shown, not rounded away.
20093
+ */
20094
+ var EventMediaKindBreakdownSchema = object({
20095
+ /** Every media row in scope, from one unfiltered aggregate. */
20096
+ totalRows: number().int(),
20097
+ /** Every media byte in scope, from that same aggregate. */
20098
+ totalBytes: number().int(),
20099
+ /** Per-kind footprint, ordered by bytes descending. */
20100
+ kinds: array(EventMediaKindFootprintSchema).readonly(),
20101
+ /** `totalRows` minus the summed `kinds` rows — see the schema note. */
20102
+ unaccountedRows: number().int(),
20103
+ /** `totalBytes` minus the summed `kinds` bytes — see the schema note. */
20104
+ unaccountedBytes: number().int()
20105
+ });
19935
20106
  /** Per-kind counts returned by the event-prune / device-delete mutations. */
19936
20107
  var EventPruneCountsSchema = object({
19937
20108
  motion: number().int(),
@@ -20086,7 +20257,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20086
20257
  until: number().optional(),
20087
20258
  kinds: array(string()).optional(),
20088
20259
  limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT)
20089
- }), array(SensorEventSchema).readonly()), method(KeyEventQueryInput, array(KeyEventSchema).readonly()), method(object({
20260
+ }), array(SensorEventSchema).readonly()), method(KeyEventQueryInput, array(KeyEventSchema).readonly()), method(KeyEventBatchQueryInput, array(KeyEventsForDeviceSchema).readonly()), method(object({
20090
20261
  deviceId: number(),
20091
20262
  since: number(),
20092
20263
  until: number(),
@@ -20135,6 +20306,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
20135
20306
  }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
20136
20307
  kind: "query",
20137
20308
  auth: "admin"
20309
+ }), method(object({ deviceId: number().int().optional() }), EventMediaKindBreakdownSchema, {
20310
+ kind: "query",
20311
+ auth: "admin"
20138
20312
  }), method(object({
20139
20313
  olderThanMs: number(),
20140
20314
  reason: OpsLogReasonSchema.optional()
@@ -22567,6 +22741,20 @@ method(object({
22567
22741
  error: string().optional()
22568
22742
  }), { auth: "admin" }), method(_void(), array(ProviderListEntrySchema).readonly()), method(object({
22569
22743
  providerId: string(),
22744
+ /**
22745
+ * The location this config is an UNSAVED edit of, when there is one.
22746
+ *
22747
+ * `listLocations` replaces every declared secret with the redaction
22748
+ * sentinel, so the edit modal's form state holds the sentinel for any
22749
+ * credential the operator did not retype — and posting that here
22750
+ * without a way to resolve it makes the provider try to authenticate
22751
+ * as `__camstack_redacted__` and report the operator's own working
22752
+ * password as wrong. Given this id, the orchestrator restores each
22753
+ * sentinel from the stored config (same rule as `upsertLocation`)
22754
+ * before dispatching. Omitted by the "Add location" wizard, where
22755
+ * every value was typed just now and nothing is stored yet.
22756
+ */
22757
+ locationId: string().optional(),
22570
22758
  config: record(string(), unknown())
22571
22759
  }), object({
22572
22760
  ok: boolean(),
@@ -28202,6 +28390,9 @@ method(object({
28202
28390
  }), method(RelocateResidueInputSchema, RelocateResidueSchema, {
28203
28391
  kind: "query",
28204
28392
  auth: "admin"
28393
+ }), method(LedgerWalkInputSchema, LedgerWalkReportSchema, {
28394
+ kind: "mutation",
28395
+ auth: "admin"
28205
28396
  }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
28206
28397
  kind: "mutation",
28207
28398
  auth: "admin"
@@ -28593,7 +28784,26 @@ var SceneMonitorStatusSchema = object({
28593
28784
  monitors: array(SceneMonitorSchema),
28594
28785
  lastFetchedAt: number()
28595
28786
  });
28596
- DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSchema), method(object({
28787
+ /**
28788
+ * One camera's row in a `listScenesBatch` answer.
28789
+ *
28790
+ * `status` is NULLABLE and the nullability is the whole point. `scene-monitor`
28791
+ * is a `defaultActive` wrapper, so every camera is asked; a camera whose
28792
+ * provider is absent (pipeline-analytics not deployed, the post-processing node
28793
+ * down) cannot answer, and that is not the same fact as a camera with no scenes
28794
+ * configured. Fanned out per camera the difference was visible — one query
28795
+ * errored while the others resolved — and a batch that returned only the rows
28796
+ * it managed would have destroyed it, silently, by making an unreachable camera
28797
+ * indistinguishable from one that answered `monitors: []`.
28798
+ *
28799
+ * So: EVERY requested deviceId gets a row. `status: null` means "this camera
28800
+ * could not be read"; `status.monitors: []` means "read, and it has none".
28801
+ */
28802
+ var SceneMonitorStatusForDeviceSchema = object({
28803
+ deviceId: number(),
28804
+ status: SceneMonitorStatusSchema.nullable()
28805
+ });
28806
+ DeviceType.Camera, method(object({ deviceId: number() }), SceneMonitorStatusSchema), method(object({ deviceIds: array(number()).min(1).max(200) }), array(SceneMonitorStatusForDeviceSchema).readonly()), method(object({
28597
28807
  deviceId: number(),
28598
28808
  label: string(),
28599
28809
  roi: MaskRectShapeSchema,
@@ -29917,6 +30127,27 @@ var CameraOccupancySnapshotSchema = object({
29917
30127
  stationaryObjects: array(StationaryObjectSchema).readonly().optional()
29918
30128
  });
29919
30129
  /**
30130
+ * One camera's row in a `getCurrentSnapshotBatch` answer.
30131
+ *
30132
+ * THREE outcomes, and the single-camera method could only express two of them
30133
+ * because `snapshot: null` was already spoken for:
30134
+ *
30135
+ * - `read: 'read'`, `snapshot` present — the live occupancy reading.
30136
+ * - `read: 'read'`, `snapshot: null` — read, and this camera has no reading
30137
+ * yet: no frame since boot, and no parked-object registry to hydrate from.
30138
+ * - `read: 'unreadable'` — the owner could not answer for this
30139
+ * camera. `snapshot` is null, and it does NOT mean "nothing parked here".
30140
+ *
30141
+ * Collapsing the last two is the failure this field exists to prevent: a
30142
+ * hydration that threw would otherwise render as an empty Stationary section,
30143
+ * which is a definite claim about a camera nobody could read.
30144
+ */
30145
+ var CameraOccupancySnapshotForDeviceSchema = object({
30146
+ deviceId: number(),
30147
+ read: _enum(["read", "unreadable"]),
30148
+ snapshot: CameraOccupancySnapshotSchema.nullable()
30149
+ });
30150
+ /**
29920
30151
  * Time-series resolution. The history methods return one bucket per
29921
30152
  * step over the requested range. Smaller resolutions cost more
29922
30153
  * memory + bandwidth; bound to discrete steps so caller cannot ask
@@ -29940,7 +30171,7 @@ var HistoryPointSchema = object({
29940
30171
  /** Object count averaged over the bucket (rounded to nearest integer). */
29941
30172
  count: number().int().nonnegative()
29942
30173
  });
29943
- DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
30174
+ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({ deviceIds: array(number()).min(1).max(200) }), array(CameraOccupancySnapshotForDeviceSchema).readonly()), method(object({
29944
30175
  deviceId: number(),
29945
30176
  zoneId: string(),
29946
30177
  className: string().optional()
@@ -33305,6 +33536,12 @@ Object.freeze({
33305
33536
  addonId: null,
33306
33537
  access: "view"
33307
33538
  },
33539
+ "pipelineAnalytics.getEventMediaFootprintByKind": {
33540
+ capName: "pipeline-analytics",
33541
+ capScope: "device",
33542
+ addonId: null,
33543
+ access: "view"
33544
+ },
33308
33545
  "pipelineAnalytics.getEventStoreFootprint": {
33309
33546
  capName: "pipeline-analytics",
33310
33547
  capScope: "device",
@@ -33323,6 +33560,12 @@ Object.freeze({
33323
33560
  addonId: null,
33324
33561
  access: "view"
33325
33562
  },
33563
+ "pipelineAnalytics.getKeyEventsBatch": {
33564
+ capName: "pipeline-analytics",
33565
+ capScope: "device",
33566
+ addonId: null,
33567
+ access: "view"
33568
+ },
33326
33569
  "pipelineAnalytics.getMotionEvents": {
33327
33570
  capName: "pipeline-analytics",
33328
33571
  capScope: "device",
@@ -34499,6 +34742,12 @@ Object.freeze({
34499
34742
  addonId: null,
34500
34743
  access: "view"
34501
34744
  },
34745
+ "recording.reconcileLedgerAgainstDisk": {
34746
+ capName: "recording",
34747
+ capScope: "system",
34748
+ addonId: null,
34749
+ access: "create"
34750
+ },
34502
34751
  "recording.refreshStorageLocationsForMigration": {
34503
34752
  capName: "recording",
34504
34753
  capScope: "system",
@@ -34625,6 +34874,12 @@ Object.freeze({
34625
34874
  addonId: null,
34626
34875
  access: "view"
34627
34876
  },
34877
+ "sceneMonitor.listScenesBatch": {
34878
+ capName: "scene-monitor",
34879
+ capScope: "device",
34880
+ addonId: null,
34881
+ access: "view"
34882
+ },
34628
34883
  "sceneMonitor.recheckNow": {
34629
34884
  capName: "scene-monitor",
34630
34885
  capScope: "device",
@@ -35981,6 +36236,12 @@ Object.freeze({
35981
36236
  addonId: null,
35982
36237
  access: "view"
35983
36238
  },
36239
+ "zoneAnalytics.getCurrentSnapshotBatch": {
36240
+ capName: "zone-analytics",
36241
+ capScope: "device",
36242
+ addonId: null,
36243
+ access: "view"
36244
+ },
35984
36245
  "zoneAnalytics.getUnzonedHistory": {
35985
36246
  capName: "zone-analytics",
35986
36247
  capScope: "device",
@@ -36970,6 +37231,11 @@ Object.freeze({
36970
37231
  form: "single",
36971
37232
  optional: false
36972
37233
  }],
37234
+ "pipelineAnalytics.getEventMediaFootprintByKind": [{
37235
+ name: "deviceId",
37236
+ form: "single",
37237
+ optional: true
37238
+ }],
36973
37239
  "pipelineAnalytics.getGroup": [{
36974
37240
  name: "deviceId",
36975
37241
  form: "single",
@@ -36980,6 +37246,11 @@ Object.freeze({
36980
37246
  form: "single",
36981
37247
  optional: false
36982
37248
  }],
37249
+ "pipelineAnalytics.getKeyEventsBatch": [{
37250
+ name: "deviceIds",
37251
+ form: "array",
37252
+ optional: false
37253
+ }],
36983
37254
  "pipelineAnalytics.getMotionEvents": [{
36984
37255
  name: "deviceId",
36985
37256
  form: "single",
@@ -37420,6 +37691,11 @@ Object.freeze({
37420
37691
  form: "single",
37421
37692
  optional: false
37422
37693
  }],
37694
+ "recording.reconcileLedgerAgainstDisk": [{
37695
+ name: "deviceId",
37696
+ form: "single",
37697
+ optional: true
37698
+ }],
37423
37699
  "recording.relocateFootage": [{
37424
37700
  name: "deviceId",
37425
37701
  form: "single",
@@ -37485,6 +37761,11 @@ Object.freeze({
37485
37761
  form: "single",
37486
37762
  optional: false
37487
37763
  }],
37764
+ "sceneMonitor.listScenesBatch": [{
37765
+ name: "deviceIds",
37766
+ form: "array",
37767
+ optional: false
37768
+ }],
37488
37769
  "sceneMonitor.recheckNow": [{
37489
37770
  name: "deviceId",
37490
37771
  form: "single",
@@ -37746,6 +38027,11 @@ Object.freeze({
37746
38027
  form: "single",
37747
38028
  optional: false
37748
38029
  }],
38030
+ "zoneAnalytics.getCurrentSnapshotBatch": [{
38031
+ name: "deviceIds",
38032
+ form: "array",
38033
+ optional: false
38034
+ }],
37749
38035
  "zoneAnalytics.getUnzonedHistory": [{
37750
38036
  name: "deviceId",
37751
38037
  form: "single",
@@ -40855,6 +41141,124 @@ function composeCameraStatus(input) {
40855
41141
  };
40856
41142
  }
40857
41143
  //#endregion
41144
+ //#region src/camera-status/fleet-read-pass.ts
41145
+ /**
41146
+ * Keep a rejection from becoming an UNHANDLED one.
41147
+ *
41148
+ * A shared promise is created by the first camera to ask and consumed by the
41149
+ * others later — `listBindableCapsForDeviceType` is reached in stage 2 of
41150
+ * `readSwitchAuthorities`, after an await, so two cameras genuinely attach
41151
+ * their handlers in different ticks. Without this the first rejection would be
41152
+ * unhandled for the window in between.
41153
+ *
41154
+ * The handler is a no-op ON PURPOSE: it never becomes the answer. Every real
41155
+ * consumer attaches its own `.catch` and reports the failure per camera.
41156
+ */
41157
+ function keepAlive(p) {
41158
+ p.catch(() => void 0);
41159
+ return p;
41160
+ }
41161
+ var FleetReadPass = class {
41162
+ deps;
41163
+ roster = null;
41164
+ bindings = null;
41165
+ mutes = null;
41166
+ bindableByType = /* @__PURE__ */ new Map();
41167
+ slotsByNode = /* @__PURE__ */ new Map();
41168
+ rtspByNode = /* @__PURE__ */ new Map();
41169
+ provisioningByNode = /* @__PURE__ */ new Map();
41170
+ selectedEngineByNode = /* @__PURE__ */ new Map();
41171
+ constructor(deps) {
41172
+ this.deps = deps;
41173
+ }
41174
+ /** The cameras this pass covers — the set both batch reads are issued for. */
41175
+ get deviceIds() {
41176
+ return this.deps.deviceIds;
41177
+ }
41178
+ /**
41179
+ * The device rows for the pass's set.
41180
+ *
41181
+ * **The shape follows the SET, not the caller.** A pass of one asks
41182
+ * `getDevice` — the exact question, and the one the write path
41183
+ * (`CameraSwitchService.setCameraSwitch`) has always asked. A pass of many
41184
+ * asks `listAll({deviceIds})`, which exists precisely because "these N" was
41185
+ * otherwise either N round trips or the whole fleet. Answering a set of one
41186
+ * with the fleet-shaped method would be no cheaper and would move the write
41187
+ * path onto a read it does not need.
41188
+ *
41189
+ * `projection: 'slim'` because the only fields read off it are `type` and
41190
+ * `disabled`: the full projection reads each device's settings row, which is
41191
+ * the per-device round trip this whole change exists to remove.
41192
+ */
41193
+ deviceRoster(api) {
41194
+ const single = this.deps.deviceIds.length === 1 ? this.deps.deviceIds[0] : void 0;
41195
+ const source = single === void 0 ? "deviceManager.listAll" : "deviceManager.getDevice";
41196
+ if (this.roster === null) this.roster = keepAlive(single === void 0 ? api.deviceManager.listAll.query({
41197
+ deviceIds: [...this.deps.deviceIds],
41198
+ projection: "slim"
41199
+ }) : api.deviceManager.getDevice.query({ deviceId: single }).then((row) => row === null ? [] : [row]));
41200
+ return {
41201
+ source,
41202
+ result: this.roster
41203
+ };
41204
+ }
41205
+ /** The binding rows for the pass's set — `getBindings` for one, the batch for many. */
41206
+ bindingRows(api) {
41207
+ const single = this.deps.deviceIds.length === 1 ? this.deps.deviceIds[0] : void 0;
41208
+ const source = single === void 0 ? "deviceManager.getBindingsBatch" : "deviceManager.getBindings";
41209
+ if (this.bindings === null) this.bindings = keepAlive(single === void 0 ? api.deviceManager.getBindingsBatch.query({ deviceIds: [...this.deps.deviceIds] }) : api.deviceManager.getBindings.query({ deviceId: single }).then((row) => [row]));
41210
+ return {
41211
+ source,
41212
+ result: this.bindings
41213
+ };
41214
+ }
41215
+ /** The fleet's muted-camera list. One answer for every camera in the pass. */
41216
+ mutedDevices(api) {
41217
+ if (this.mutes === null) this.mutes = keepAlive(api.notificationRules.listDeviceMutes.query({}));
41218
+ return this.mutes;
41219
+ }
41220
+ /** The bindable caps for one device TYPE. Every camera shares one answer. */
41221
+ bindableCapsFor(api, deviceType) {
41222
+ const existing = this.bindableByType.get(deviceType);
41223
+ if (existing !== void 0) return existing;
41224
+ const created = keepAlive(api.deviceManager.listBindableCapsForDeviceType.query({ deviceType }));
41225
+ this.bindableByType.set(deviceType, created);
41226
+ return created;
41227
+ }
41228
+ /** One source node's whole profile-slot table. */
41229
+ profileSlots(api, sourceNodeId) {
41230
+ const existing = this.slotsByNode.get(sourceNodeId);
41231
+ if (existing !== void 0) return existing;
41232
+ const created = keepAlive(api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)));
41233
+ this.slotsByNode.set(sourceNodeId, created);
41234
+ return created;
41235
+ }
41236
+ /** One source node's whole RTSP-restream table. */
41237
+ rtspEntries(api, sourceNodeId) {
41238
+ const existing = this.rtspByNode.get(sourceNodeId);
41239
+ if (existing !== void 0) return existing;
41240
+ const created = keepAlive(api.streamBroker.getAllRtspEntries.query({}, nodePin(sourceNodeId)));
41241
+ this.rtspByNode.set(sourceNodeId, created);
41242
+ return created;
41243
+ }
41244
+ /** One detection node's runtime-provisioning snapshot. */
41245
+ engineProvisioning(api, detectionNodeId) {
41246
+ const existing = this.provisioningByNode.get(detectionNodeId);
41247
+ if (existing !== void 0) return existing;
41248
+ const created = keepAlive(api.pipelineExecutor.getEngineProvisioning.query({ nodeId: detectionNodeId }));
41249
+ this.provisioningByNode.set(detectionNodeId, created);
41250
+ return created;
41251
+ }
41252
+ /** The executor's bootstrap engine, as the detection stage reads it. */
41253
+ selectedEngine(api, detectionNodeId) {
41254
+ const existing = this.selectedEngineByNode.get(detectionNodeId);
41255
+ if (existing !== void 0) return existing;
41256
+ const created = keepAlive(api.pipelineExecutor.getSelectedEngine.query({ nodeId: detectionNodeId }));
41257
+ this.selectedEngineByNode.set(detectionNodeId, created);
41258
+ return created;
41259
+ }
41260
+ };
41261
+ //#endregion
40858
41262
  //#region src/camera-status-service.ts
40859
41263
  /** WebRTC consumer kinds counted toward `BrokerResult.webrtcSessions`. */
40860
41264
  var WEBRTC_KINDS = new Set([
@@ -41043,7 +41447,7 @@ var CameraStatusService = class {
41043
41447
  * broker's actual decode-session node into `liveDecoder` (T6) — the first
41044
41448
  * slot that reports one wins.
41045
41449
  */
41046
- buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, sink) {
41450
+ buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, liveDecoder, sink) {
41047
41451
  if (!api || !allSlotsFetch) return Promise.resolve(null);
41048
41452
  return this.boundedStage(allSlotsFetch.then(async (slots) => {
41049
41453
  const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
@@ -41053,12 +41457,13 @@ var CameraStatusService = class {
41053
41457
  rtspRestream: false
41054
41458
  };
41055
41459
  const [statsAndClients, rtspEntry] = await Promise.all([Promise.all(deviceSlots.map(async (slot) => {
41460
+ const [stats, clients] = await Promise.all([api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null), api.streamBroker.listClients.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null)]);
41056
41461
  return {
41057
41462
  slot,
41058
- stats: await api.streamBroker.getBrokerStats.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null),
41059
- clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }, nodePin(sourceNodeId)).catch(() => null)
41463
+ stats,
41464
+ clients
41060
41465
  };
41061
- })), api.streamBroker.getAllRtspEntries.query({}, nodePin(sourceNodeId)).catch(() => null)]);
41466
+ })), pass.rtspEntries(api, sourceNodeId).catch(() => null)]);
41062
41467
  const profileDetails = statsAndClients.map(({ slot, stats, clients }) => ({
41063
41468
  profile: slot.profile,
41064
41469
  status: slot.status,
@@ -41119,13 +41524,16 @@ var CameraStatusService = class {
41119
41524
  };
41120
41525
  }
41121
41526
  /** Detection stage (pipeline-executor + runner metrics). */
41122
- buildDetectionStage(api, detectionNodeId, deviceId, sink) {
41527
+ buildDetectionStage(api, detectionNodeId, deviceId, pass, sink) {
41123
41528
  if (!api || !detectionNodeId) return Promise.resolve(null);
41124
- return this.boundedStage(Promise.all([api.pipelineExecutor.getEngineProvisioning.query({ nodeId: detectionNodeId }).catch(() => null), api.pipelineExecutor.getSelectedEngine.query({ nodeId: detectionNodeId }).catch(() => null)]).then(async ([provisioning, engine]) => {
41125
- const metrics = await api.pipelineRunner.getCameraMetrics.query({
41529
+ return this.boundedStage(Promise.all([
41530
+ pass.engineProvisioning(api, detectionNodeId).catch(() => null),
41531
+ pass.selectedEngine(api, detectionNodeId).catch(() => null),
41532
+ api.pipelineRunner.getCameraMetrics.query({
41126
41533
  deviceId,
41127
41534
  nodeId: detectionNodeId
41128
- }).catch(() => null);
41535
+ }).catch(() => null)
41536
+ ]).then(async ([provisioning, engine, metrics]) => {
41129
41537
  const phase = (() => {
41130
41538
  const p = metrics?.phase;
41131
41539
  if (p === "active") return "active";
@@ -41237,9 +41645,9 @@ var CameraStatusService = class {
41237
41645
  * badge on a working camera); what matters is that the emptiness travels with
41238
41646
  * the reason it is empty.
41239
41647
  */
41240
- buildSwitchStage(deviceId, sink) {
41648
+ buildSwitchStage(deviceId, pass, sink) {
41241
41649
  const startedAt = Date.now();
41242
- return this.boundedStage(this.deps.switchAuthoritiesFor(deviceId), STAGE_TIMEOUT_MS, "switches", deviceId, sink).then((reads) => {
41650
+ return this.boundedStage(this.deps.switchAuthoritiesFor(deviceId, pass), STAGE_TIMEOUT_MS, "switches", deviceId, sink).then((reads) => {
41243
41651
  if (reads === null) return [];
41244
41652
  const { switchedOff, unreadable } = composeSwitchedOff(reads);
41245
41653
  if (unreadable.length > 0) this.recordDegraded(sink, deviceId, "switches", "partial", Date.now() - startedAt, { unreadableAuthorities: unreadable });
@@ -41257,18 +41665,28 @@ var CameraStatusService = class {
41257
41665
  * `null` of a camera that legitimately has no such stage.
41258
41666
  */
41259
41667
  async getCameraStatus(deviceId) {
41668
+ return this.composeOne(deviceId, new FleetReadPass({ deviceIds: [deviceId] }));
41669
+ }
41670
+ /**
41671
+ * One camera's status, composed WITHIN a pass.
41672
+ *
41673
+ * Everything the camera alone can answer is fetched here; everything the pass
41674
+ * already knows (a node's slot table, the fleet's mute list, a device type's
41675
+ * bindable caps) is asked of `pass`, which issues it once for the whole call.
41676
+ */
41677
+ async composeOne(deviceId, pass) {
41260
41678
  const api = this.deps.api();
41261
41679
  const degradations = { entries: [] };
41262
41680
  const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
41263
41681
  const liveDecoder = { nodeId: null };
41264
- const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
41682
+ const allSlotsFetch = api ? pass.profileSlots(api, sourceNodeId) : null;
41265
41683
  const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
41266
- const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, degradations);
41684
+ const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, pass, liveDecoder, degradations);
41267
41685
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
41268
41686
  const motionResult = this.buildMotionStage(deviceId);
41269
- const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, degradations);
41687
+ const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, pass, degradations);
41270
41688
  const recordingFetch = this.buildRecordingStage(api, deviceId, degradations);
41271
- const switchesFetch = this.buildSwitchStage(deviceId, degradations);
41689
+ const switchesFetch = this.buildSwitchStage(deviceId, pass, degradations);
41272
41690
  const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
41273
41691
  sourceFetch,
41274
41692
  brokerFetch,
@@ -41311,15 +41729,30 @@ var CameraStatusService = class {
41311
41729
  * `deviceIds` defaults to all cameras currently tracked by the
41312
41730
  * orchestrator's assignment map when omitted.
41313
41731
  *
41314
- * v1: `Promise.all` over per-device composition (no concurrency cap).
41315
- * Note: for large fleets (hundreds of cameras) this may fan out many
41316
- * parallel calls. A concurrency limiter (p-limit / semaphore) should be
41317
- * added if latency measurements show it's necessary deliberately
41318
- * deferred per the YAGNI constraint in the spec.
41732
+ * The whole call runs inside ONE {@link FleetReadPass}, and that is what
41733
+ * makes a five-second poll affordable. Composing a camera reaches seven
41734
+ * stages, but six of the questions those stages ask are about a NODE, a
41735
+ * device TYPE or the whole FLEETthe slot table, the restream table, the
41736
+ * mute list, the engine pair, the bindable caps — and asking them per camera
41737
+ * meant N identical round trips for N identical answers. Two more (the device
41738
+ * row, the binding table) are per-camera questions the device manager already
41739
+ * answers for a set. The pass asks each exactly once and shares the promise;
41740
+ * it holds nothing between calls, so the next poll asks again from scratch.
41741
+ *
41742
+ * What is left is genuinely per camera and stays per camera: the broker slot
41743
+ * stats, the runner metrics, the recorder, the broker mute and the camera's
41744
+ * own privacy provider. Collapsing any of those would be a narrower answer,
41745
+ * not a cheaper one.
41746
+ *
41747
+ * `Promise.all` over per-device composition (no concurrency cap). The fan-out
41748
+ * is now ~8 round trips per camera rather than ~16, and the six fleet reads
41749
+ * no longer multiply — which is the part that made the count grow with the
41750
+ * fleet twice over.
41319
41751
  */
41320
41752
  async getCameraStatuses(deviceIds) {
41321
41753
  const ids = deviceIds !== void 0 && deviceIds.length > 0 ? deviceIds : this.deps.listAssignedDeviceIds();
41322
- return Promise.all(ids.map((deviceId) => this.getCameraStatus(deviceId)));
41754
+ const pass = new FleetReadPass({ deviceIds: ids });
41755
+ return Promise.all(ids.map((deviceId) => this.composeOne(deviceId, pass)));
41323
41756
  }
41324
41757
  };
41325
41758
  //#endregion
@@ -41568,10 +42001,15 @@ async function probeCamera(api, deviceId, deps) {
41568
42001
  * @param deviceId The camera.
41569
42002
  * @param deps Logger + source-owner resolver.
41570
42003
  */
41571
- async function readSwitchAuthorities(api, deviceId, deps) {
42004
+ async function readSwitchAuthorities(api, deviceId, deps, sharedPass) {
41572
42005
  if (!api) return unknownReads(deviceId);
41573
- const devicePromise = bounded(deps, deviceId, "deviceManager.getDevice", api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
41574
- warnUnreachable(deps, deviceId, "getDevice", err);
42006
+ const pass = sharedPass ?? new FleetReadPass({ deviceIds: [deviceId] });
42007
+ const roster = pass.deviceRoster(api);
42008
+ const devicePromise = bounded(deps, deviceId, roster.source, roster.result.then((rows) => {
42009
+ const row = rows.find((d) => d.id === deviceId);
42010
+ return isDeviceShape(row) ? row : null;
42011
+ }).catch((err) => {
42012
+ warnUnreachable(deps, deviceId, roster.source, err);
41575
42013
  return null;
41576
42014
  }), null);
41577
42015
  const unknownBindings = {
@@ -41579,11 +42017,14 @@ async function readSwitchAuthorities(api, deviceId, deps) {
41579
42017
  providerAddonIdByCap: /* @__PURE__ */ new Map(),
41580
42018
  allCapNames: null
41581
42019
  };
41582
- const bindingsPromise = bounded(deps, deviceId, "deviceManager.getBindings", api.deviceManager.getBindings.query({ deviceId }).then((b) => {
42020
+ const bindingRead = pass.bindingRows(api);
42021
+ const bindingsPromise = bounded(deps, deviceId, bindingRead.source, bindingRead.result.then((rows) => {
42022
+ const row = rows.find((r) => r.deviceId === deviceId);
42023
+ if (row === void 0) return unknownBindings;
41583
42024
  const active = [];
41584
42025
  const all = [];
41585
42026
  const providers = /* @__PURE__ */ new Map();
41586
- for (const e of b.entries) {
42027
+ for (const e of row.entries) {
41587
42028
  all.push(e.capName);
41588
42029
  if (e.kind !== "wrapped") continue;
41589
42030
  active.push(e.capName);
@@ -41595,14 +42036,14 @@ async function readSwitchAuthorities(api, deviceId, deps) {
41595
42036
  allCapNames: all
41596
42037
  };
41597
42038
  }).catch((err) => {
41598
- warnUnreachable(deps, deviceId, "getBindings", err);
42039
+ warnUnreachable(deps, deviceId, bindingRead.source, err);
41599
42040
  return unknownBindings;
41600
42041
  }), unknownBindings);
41601
42042
  const recordingPromise = bounded(deps, deviceId, "recording.getDeviceConfig", api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
41602
42043
  warnUnreachable(deps, deviceId, "recording.getDeviceConfig", err);
41603
42044
  return null;
41604
42045
  }), null);
41605
- const mutesPromise = bounded(deps, deviceId, "notificationRules.listDeviceMutes", api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
42046
+ const mutesPromise = bounded(deps, deviceId, "notificationRules.listDeviceMutes", pass.mutedDevices(api).then((r) => r.mutedDeviceIds).catch((err) => {
41606
42047
  warnUnreachable(deps, deviceId, "notificationRules.listDeviceMutes", err);
41607
42048
  return null;
41608
42049
  }), null);
@@ -41617,7 +42058,7 @@ async function readSwitchAuthorities(api, deviceId, deps) {
41617
42058
  mutesPromise,
41618
42059
  brokerAudioPromise
41619
42060
  ]);
41620
- const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : bounded(deps, deviceId, "deviceManager.listBindableCapsForDeviceType", api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
42061
+ const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : bounded(deps, deviceId, "deviceManager.listBindableCapsForDeviceType", pass.bindableCapsFor(api, device.type).catch((err) => {
41621
42062
  warnUnreachable(deps, deviceId, "listBindableCapsForDeviceType", err);
41622
42063
  return null;
41623
42064
  }), null), readPrivacyPlanes(api, deviceId, deps, bindings.allCapNames)]);
@@ -50324,11 +50765,11 @@ async function buildOrchestratorControllers(deps) {
50324
50765
  assignSource: (deviceId) => topology.assignSource(deviceId),
50325
50766
  listAssignedDeviceIds: () => [...new Set([...ledger.listAssignedDeviceIds(), ...detectionWiring.activeDeviceIds()])],
50326
50767
  isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
50327
- switchAuthoritiesFor: async (deviceId) => (await readSwitchAuthorities(deps.ctx().api ?? null, deviceId, {
50768
+ switchAuthoritiesFor: async (deviceId, pass) => (await readSwitchAuthorities(deps.ctx().api ?? null, deviceId, {
50328
50769
  logger: deps.ctx().logger,
50329
50770
  assignSource: (id) => topology.assignSource(id),
50330
50771
  warnSampler: switchWarnSampler
50331
- })).derivation
50772
+ }, pass)).derivation
50332
50773
  });
50333
50774
  const reconcile = new ReconcileController({
50334
50775
  api: () => deps.ctx().api ?? null,