@camstack/addon-pipeline-orchestrator 1.1.14 → 1.1.16

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
@@ -4631,7 +4631,7 @@ function _instanceof(cls, params = {}) {
4631
4631
  return inst;
4632
4632
  }
4633
4633
  //#endregion
4634
- //#region ../types/dist/sleep-C2M2zF7x.mjs
4634
+ //#region ../types/dist/sleep-MHm--th-.mjs
4635
4635
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4636
4636
  EventCategory["SystemBoot"] = "system.boot";
4637
4637
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -5909,6 +5909,7 @@ var CamStreamKindSchema = _enum([
5909
5909
  "pull-rtsp",
5910
5910
  "pull-rtmp",
5911
5911
  "pull-http",
5912
+ "pull-flv",
5912
5913
  "pull-rfc4571",
5913
5914
  "push-annexb",
5914
5915
  "derived"
@@ -14000,7 +14001,7 @@ var AddBrokerInputSchema = object({
14000
14001
  });
14001
14002
  var AddBrokerResultSchema = object({ id: string() });
14002
14003
  var IdInputSchema = object({ id: string() });
14003
- var TestResultSchema = discriminatedUnion("ok", [object({
14004
+ var TestResultSchema$1 = discriminatedUnion("ok", [object({
14004
14005
  ok: literal(true),
14005
14006
  latencyMs: number()
14006
14007
  }), object({
@@ -14023,7 +14024,7 @@ var StatusSchema = object({
14023
14024
  brokerCount: number(),
14024
14025
  embeddedRunning: boolean()
14025
14026
  });
14026
- method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
14027
+ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema), method(AddBrokerInputSchema, AddBrokerResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(IdInputSchema, TestResultSchema$1, { kind: "mutation" }), method(StartEmbeddedInputSchema, StartEmbeddedResultSchema, { kind: "mutation" }), method(IdInputSchema, _void(), { kind: "mutation" }), method(_void(), StatusSchema);
14027
14028
  var NetworkEndpointSchema = object({
14028
14029
  url: string(),
14029
14030
  hostname: string(),
@@ -14057,23 +14058,198 @@ var NetworkEndpointEntrySchema = NetworkEndpointSchema.extend({
14057
14058
  sourcePort: number().optional()
14058
14059
  });
14059
14060
  method(_void(), NetworkEndpointSchema, { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), NetworkEndpointSchema.nullable()), method(_void(), NetworkAccessStatusSchema), method(_void(), array(NetworkEndpointEntrySchema).readonly());
14060
- method(object({
14061
- title: string(),
14061
+ /**
14062
+ * notification-output — canonical, capability-gated notification delivery.
14063
+ *
14064
+ * Apprise-derived model (see
14065
+ * `docs/superpowers/specs/2026-07-03-notification-output-notifier-matrix.md`):
14066
+ * callers emit ONE canonical `Notification`; each provider declares a
14067
+ * per-kind capability descriptor (`TargetKind`), and the pure degrade
14068
+ * engine (`@camstack/types` `prepareNotification`) transcodes / degrades the
14069
+ * message to what the kind supports — callers never special-case a service.
14070
+ *
14071
+ * DESIGN DECISIONS (locked):
14072
+ * - Target CRUD lives on THIS cap (`upsertTarget` / `deleteTarget` /
14073
+ * `setTargetEnabled`), each provider persisting via the `settings-store`
14074
+ * cap. Rationale: the admin UI needs one uniform surface across the
14075
+ * notifiers addon AND the HA addon; the addon-`globalSettingsSchema`-array
14076
+ * alternative would fork the UI per addon and cannot host the
14077
+ * discovery→adopt flow.
14078
+ * - `listTargetKinds` / `listTargets` / `discoverTargets` return arrays →
14079
+ * the generated cap-mount auto-`concatCollection`-fans them across every
14080
+ * registered provider (notifiers addon + HA addon) so one catalog is
14081
+ * routable. `send` / `testTarget` / CRUD route to ONE provider by the
14082
+ * `addonId` the generated collection router extracts from the call input.
14083
+ * - `Attachment.bytes` is `Uint8Array`. Transport-safe: superjson (the tRPC
14084
+ * transformer) + UDS MsgPack both round-trip typed arrays — already used by
14085
+ * `storage` / `storage-provider` / `recording` caps over the same path. No
14086
+ * base64 fallback needed.
14087
+ *
14088
+ * TODO (deferred, closed-set change — separate decision): add
14089
+ * `providerKind: 'notify'` so notification providers surface on the unified
14090
+ * admin "Integrations" page.
14091
+ */
14092
+ /**
14093
+ * Zentik-derived typed-media enum — the superset across every kind. Each
14094
+ * adapter picks what it supports and the degrade engine filters the rest.
14095
+ */
14096
+ var AttachmentMediaTypeSchema = _enum([
14097
+ "image",
14098
+ "video",
14099
+ "gif",
14100
+ "audio",
14101
+ "icon"
14102
+ ]);
14103
+ /**
14104
+ * A single attachment. Exactly one of `url` (remote source, most adapters
14105
+ * prefer this) or `bytes` (inline source; required for Pushover-style
14106
+ * bytes-only kinds) MUST be present — the degrade engine expresses a
14107
+ * url→bytes fetch as a `needsFetch` directive the adapter executes.
14108
+ */
14109
+ var AttachmentSchema = object({
14110
+ mediaType: AttachmentMediaTypeSchema,
14111
+ url: string().optional(),
14112
+ bytes: _instanceof(Uint8Array).optional(),
14113
+ mime: string().optional(),
14114
+ name: string().optional()
14115
+ }).refine((a) => a.url !== void 0 || a.bytes !== void 0, { message: "Attachment requires either `url` or `bytes`" });
14116
+ var NotificationFormatSchema = _enum([
14117
+ "text",
14118
+ "markdown",
14119
+ "html"
14120
+ ]);
14121
+ /** A single tap-through action button. */
14122
+ var NotificationActionSchema = object({
14123
+ id: string(),
14124
+ label: string(),
14125
+ url: string().optional()
14126
+ });
14127
+ /**
14128
+ * The canonical notification. `body` is the only hard field (Apprise model).
14129
+ * `priority` is a 5-level ORDINAL (1=lowest … 3=normal(default) … 5=urgent),
14130
+ * NOT a fixed severity enum — each kind declares its own `caps.levels` and
14131
+ * the adapter maps this ordinal onto its native level. `level?` is an
14132
+ * optional kind-native level id (`emergency`, `silent`, …) that overrides
14133
+ * `priority` for that one target.
14134
+ */
14135
+ var NotificationSchema = object({
14062
14136
  body: string(),
14063
- imageUrl: string().optional(),
14137
+ title: string().optional(),
14138
+ format: NotificationFormatSchema.default("text"),
14139
+ priority: number().int().min(1).max(5).default(3),
14140
+ level: string().optional(),
14141
+ attachments: array(AttachmentSchema).optional(),
14142
+ clickUrl: string().optional(),
14143
+ actions: array(NotificationActionSchema).optional(),
14144
+ sound: string().optional(),
14145
+ ttl: number().optional(),
14146
+ tag: string().optional(),
14064
14147
  deviceId: number().optional(),
14065
14148
  eventId: string().optional(),
14066
- priority: _enum([
14067
- "low",
14068
- "normal",
14069
- "high",
14070
- "critical"
14071
- ]).default("normal"),
14072
14149
  metadata: record(string(), unknown()).optional()
14073
- }), _void(), { kind: "mutation" }), method(_void(), object({
14150
+ });
14151
+ /** One declared native severity/priority level for a kind. */
14152
+ var TargetKindLevelSchema = object({
14153
+ id: string(),
14154
+ label: string(),
14155
+ /** Which canonical priority (1..5) this level maps to. `null` = qualitative-only. */
14156
+ ordinal: number().int().min(1).max(5).nullable(),
14157
+ flags: object({
14158
+ critical: boolean().optional(),
14159
+ silent: boolean().optional(),
14160
+ noPush: boolean().optional()
14161
+ }).optional(),
14162
+ /** e.g. Pushover `emergency` requires `retry` / `expire`. */
14163
+ requires: array(string()).optional(),
14164
+ description: string().optional()
14165
+ });
14166
+ /** The full capability block consulted before dispatch. */
14167
+ var TargetKindCapsSchema = object({
14168
+ attachments: object({
14169
+ mediaTypes: array(AttachmentMediaTypeSchema),
14170
+ mode: _enum([
14171
+ "url",
14172
+ "bytes",
14173
+ "both"
14174
+ ]),
14175
+ max: number().int().nonnegative(),
14176
+ maxBytes: number().int().positive().optional()
14177
+ }),
14178
+ /** Max action buttons (0 = none). */
14179
+ actions: number().int().nonnegative(),
14180
+ levels: array(TargetKindLevelSchema),
14181
+ format: array(NotificationFormatSchema),
14182
+ clickUrl: boolean(),
14183
+ sound: boolean(),
14184
+ ttl: boolean(),
14185
+ bodyMaxLen: number().int().positive()
14186
+ });
14187
+ /**
14188
+ * `configSchema` is a `ConfigUISchema` tree passed through to the admin
14189
+ * FormBuilder. Stored as `z.unknown()` at the cap seam (mirrors
14190
+ * `device-provider.getChildCreationSchema` `CreationSchemaOutputSchema`) —
14191
+ * the union is large and not meant for runtime validation here; the exported
14192
+ * `TargetKind` type re-tightens `configSchema` to `ConfigUISchema`.
14193
+ */
14194
+ var ConfigSchemaPassthrough = unknown();
14195
+ var TargetKindSchema = object({
14196
+ kind: string(),
14197
+ label: string(),
14198
+ icon: string(),
14199
+ /** Stamped by each provider so the concat-fanned catalog stays routable. */
14200
+ addonId: string(),
14201
+ configSchema: ConfigSchemaPassthrough,
14202
+ supportsDiscovery: boolean(),
14203
+ caps: TargetKindCapsSchema
14204
+ });
14205
+ /**
14206
+ * A persisted target. `config` holds secrets; providers REDACT secret fields
14207
+ * (return a presence marker only) when serving `listTargets` — never
14208
+ * round-trip a stored secret to the UI.
14209
+ */
14210
+ var TargetSchema = object({
14211
+ id: string(),
14212
+ name: string(),
14213
+ kind: string(),
14214
+ addonId: string(),
14215
+ enabled: boolean(),
14216
+ config: record(string(), unknown())
14217
+ });
14218
+ /** A discovery-surfaced candidate (config is partial + non-secret). */
14219
+ var DiscoveredTargetSchema = object({
14220
+ kind: string(),
14221
+ suggestedName: string(),
14222
+ config: record(string(), unknown())
14223
+ });
14224
+ /** The degrade engine's report — what was resolved / dropped / degraded. */
14225
+ var RenderedAsSchema = object({
14226
+ level: string(),
14227
+ format: NotificationFormatSchema,
14228
+ attachmentsSent: number().int().nonnegative(),
14229
+ actionsSent: number().int().nonnegative(),
14230
+ truncated: boolean(),
14231
+ dropped: array(string())
14232
+ });
14233
+ var SendResultSchema = object({
14074
14234
  success: boolean(),
14075
- error: string().optional()
14076
- }), { kind: "mutation" });
14235
+ error: string().optional(),
14236
+ renderedAs: RenderedAsSchema.optional()
14237
+ });
14238
+ /** Same shape as SendResult — kept as a distinct name for the test panel. */
14239
+ var TestResultSchema = SendResultSchema;
14240
+ method(object({}), array(TargetKindSchema)), method(object({}), array(TargetSchema)), method(object({
14241
+ kind: string(),
14242
+ config: record(string(), unknown()).optional()
14243
+ }), array(DiscoveredTargetSchema)), method(object({
14244
+ targetId: string(),
14245
+ notification: NotificationSchema
14246
+ }), SendResultSchema, { kind: "mutation" }), method(object({
14247
+ targetId: string(),
14248
+ sample: NotificationSchema.optional()
14249
+ }), TestResultSchema, { kind: "mutation" }), method(object({ target: TargetSchema }), TargetSchema, { kind: "mutation" }), method(object({ targetId: string() }), _void(), { kind: "mutation" }), method(object({
14250
+ targetId: string(),
14251
+ enabled: boolean()
14252
+ }), _void(), { kind: "mutation" });
14077
14253
  /**
14078
14254
  * Zod schemas for persisted record types.
14079
14255
  *
@@ -14863,10 +15039,11 @@ var pipelineOrchestratorCapability = {
14863
15039
  }))),
14864
15040
  /**
14865
15041
  * Get one camera's decoder placement (computed if not yet pinned).
14866
- * Consumed by `stream-broker.createBroker` so decoder provider
14867
- * selection is deterministic fixes the 2026-04-18 race where
14868
- * `capProviders[0]` silently picked ffmpeg-on-agent-0 for a
14869
- * hub-assigned camera.
15042
+ *
15043
+ * ADVISORY today: reports the orchestrator's decoder preference only.
15044
+ * Actual decode placement is broker-owned (local-node pin + frame-plane
15045
+ * co-location guard). Reserved to become the binding source/decoder-owner
15046
+ * control in the stream-LB epic (Phase 2).
14870
15047
  *
14871
15048
  * `pipelineNodeId` is the node already chosen to run inference for
14872
15049
  * this camera. When provided, the balancer prefers co-location with
@@ -20238,13 +20415,49 @@ Object.freeze({
20238
20415
  addonId: null,
20239
20416
  access: "create"
20240
20417
  },
20418
+ "notificationOutput.deleteTarget": {
20419
+ capName: "notification-output",
20420
+ capScope: "system",
20421
+ addonId: null,
20422
+ access: "delete"
20423
+ },
20424
+ "notificationOutput.discoverTargets": {
20425
+ capName: "notification-output",
20426
+ capScope: "system",
20427
+ addonId: null,
20428
+ access: "view"
20429
+ },
20430
+ "notificationOutput.listTargetKinds": {
20431
+ capName: "notification-output",
20432
+ capScope: "system",
20433
+ addonId: null,
20434
+ access: "view"
20435
+ },
20436
+ "notificationOutput.listTargets": {
20437
+ capName: "notification-output",
20438
+ capScope: "system",
20439
+ addonId: null,
20440
+ access: "view"
20441
+ },
20241
20442
  "notificationOutput.send": {
20242
20443
  capName: "notification-output",
20243
20444
  capScope: "system",
20244
20445
  addonId: null,
20245
20446
  access: "create"
20246
20447
  },
20247
- "notificationOutput.sendTest": {
20448
+ "notificationOutput.setTargetEnabled": {
20449
+ capName: "notification-output",
20450
+ capScope: "system",
20451
+ addonId: null,
20452
+ access: "create"
20453
+ },
20454
+ "notificationOutput.testTarget": {
20455
+ capName: "notification-output",
20456
+ capScope: "system",
20457
+ addonId: null,
20458
+ access: "create"
20459
+ },
20460
+ "notificationOutput.upsertTarget": {
20248
20461
  capName: "notification-output",
20249
20462
  capScope: "system",
20250
20463
  addonId: null,
@@ -22420,470 +22633,232 @@ function buildTreeFromAddons(enabled, catalog) {
22420
22633
  return roots;
22421
22634
  }
22422
22635
  //#endregion
22423
- //#region src/zones-provider.ts
22636
+ //#region src/audio-chunk-poller.ts
22424
22637
  /**
22425
- * `zones-provider.ts` — implements `zonesCapability` for the orchestrator.
22638
+ * `AudioChunkPoller` — the consumer-side poll loop of the decoded audio-chunk
22639
+ * plane (Phase 5 / D9).
22426
22640
  *
22427
- * Per-camera CRUD over polygon detection zones. Persists to the
22428
- * orchestrator's per-device settings store under the `zones` key and
22429
- * mirrors every change into the device-state `zones` slice via
22430
- * `api.deviceState.setCapSlice`. Downstream consumers (motion-wasm,
22431
- * pipeline-executor, analytics, admin UI) read the live state with
22432
- * the canonical `dev.state.zones.onChanged` channel.
22641
+ * Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
22642
+ * path. A live callback cannot cross a process boundary; once the `pipeline`
22643
+ * group is dissolved (Task 8) the orchestrator runs in a different process
22644
+ * from the broker, so audio delivery must go over tRPC.
22433
22645
  *
22434
- * Onboard / firmware-reported zones are out of scope for now — every
22435
- * zone is operator-drawn. The provider keeps the surface symmetric:
22436
- * `addZone` rejects id collisions, `updateZone` requires an existing
22437
- * id, `removeZone` is idempotent.
22438
- */
22439
- var ZONES_STORE_KEY = "zones";
22440
- var ZONES_CAP_NAME = "zones";
22441
- var ZonesArraySchema = array(ZoneSchema);
22442
- var ZonesProvider = class {
22443
- ctx;
22444
- /** Per-device cache. Hydrated lazily on first read for a device. */
22445
- cache = /* @__PURE__ */ new Map();
22446
- /**
22447
- * Per-device durable handle over the `zones` store key. The WHOLE
22448
- * validated zone array round-trips on every read/write so no field can
22449
- * be dropped on persist. Built lazily + memoised per device.
22450
- */
22451
- stateByDevice = /* @__PURE__ */ new Map();
22452
- constructor(ctx) {
22453
- this.ctx = ctx;
22454
- }
22455
- /** Lazily build (and memoise) the durable `zones` handle for a device. */
22456
- zonesState(deviceId) {
22457
- let handle = this.stateByDevice.get(deviceId);
22458
- if (!handle) {
22459
- handle = createDurableState({
22460
- key: ZONES_STORE_KEY,
22461
- schema: ZonesArraySchema,
22462
- fallback: [],
22463
- read: () => this.ctx.settings.readDeviceStore(deviceId),
22464
- write: (patch) => this.ctx.settings.writeDeviceStore(deviceId, patch),
22465
- onParseError: (key, error) => this.ctx.logger.warn("zones store entry failed to parse — resetting", {
22466
- tags: { deviceId },
22467
- meta: {
22468
- key,
22469
- error: error instanceof Error ? error.message : String(error)
22470
- }
22471
- })
22472
- });
22473
- this.stateByDevice.set(deviceId, handle);
22474
- }
22475
- return handle;
22476
- }
22477
- async listZones({ deviceId }) {
22478
- return this.loadZones(deviceId);
22479
- }
22480
- async addZone({ deviceId, zone }) {
22481
- const existing = await this.loadZones(deviceId);
22482
- if (existing.some((entry) => entry.id === zone.id)) throw new Error(`zones.addZone: id ${zone.id} already exists`);
22483
- await this.persist(deviceId, [...existing, zone]);
22484
- }
22485
- async updateZone({ deviceId, zone }) {
22486
- const existing = await this.loadZones(deviceId);
22487
- if (!existing.some((entry) => entry.id === zone.id)) throw new Error(`zones.updateZone: id ${zone.id} not found`);
22488
- const next = existing.map((entry) => entry.id === zone.id ? zone : entry);
22489
- await this.persist(deviceId, next);
22490
- }
22491
- async removeZone({ deviceId, zoneId }) {
22492
- const existing = await this.loadZones(deviceId);
22493
- if (!existing.some((entry) => entry.id === zoneId)) return;
22494
- await this.persist(deviceId, existing.filter((entry) => entry.id !== zoneId));
22495
- }
22496
- /**
22497
- * Drop a device's cache entry. Called when the device is removed so
22498
- * the next attach starts from a fresh disk read.
22499
- */
22500
- forgetDevice(deviceId) {
22501
- this.cache.delete(deviceId);
22502
- this.stateByDevice.delete(deviceId);
22503
- }
22504
- async loadZones(deviceId) {
22505
- const cached = this.cache.get(deviceId);
22506
- if (cached) return cached;
22507
- let zones = [];
22508
- try {
22509
- zones = await this.zonesState(deviceId).get();
22510
- } catch (err) {
22511
- this.ctx.logger.warn("zones store read failed — using empty list", {
22512
- tags: { deviceId },
22513
- meta: { error: err instanceof Error ? err.message : String(err) }
22514
- });
22515
- }
22516
- this.cache.set(deviceId, zones);
22517
- return zones;
22518
- }
22519
- async persist(deviceId, zones) {
22520
- this.cache.set(deviceId, zones);
22521
- await this.zonesState(deviceId).set(zones);
22522
- await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
22523
- capName: ZONES_CAP_NAME,
22524
- slice: { zones }
22525
- });
22526
- this.ctx.onZonesChanged?.(deviceId, zones);
22527
- }
22528
- };
22529
- //#endregion
22530
- //#region src/zone-rules-provider.ts
22531
- /**
22532
- * `zone-rules-provider.ts` — implements `zoneRulesCapability` for the
22533
- * orchestrator.
22646
+ * The consumer:
22534
22647
  *
22535
- * Per-stage rule arrays (motion / detection) live next to zones in the
22536
- * orchestrator's per-device store under `zoneRules.<stage>` keys.
22537
- * Every mutation mirrors to a stage-specific device-state slice
22538
- * (`motion-zone-rules`, `detection-zone-rules`) so consumer addons
22539
- * subscribe independently and pick up the new gating without an extra
22540
- * cap round-trip.
22648
+ * 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC the broker
22649
+ * registers a per-subscription bounded FIFO queue and returns a
22650
+ * `subscriptionId`;
22651
+ * 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
22652
+ * interval, draining `DecodedAudioChunk`s in FIFO arrival order;
22653
+ * 3. feeds each chunk to its downstream audio logic;
22654
+ * 4. on teardown, `unsubscribeAudioChunks`.
22541
22655
  *
22542
- * The provider validates each rule against {@link ZoneRuleSchema}
22543
- * before persisting partial / corrupt writes are rejected outright
22544
- * since rules drive runtime filtering and a bad payload would silently
22545
- * widen the operator's intended scope.
22656
+ * Audio is not latency-critical like video, and chunks arrive only ~every
22657
+ * 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
22658
+ * a small per-poll burst keeps latency low without busy-spinning. The
22659
+ * broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
22660
+ * loses a chunk.
22661
+ *
22662
+ * Boot-race tolerance: the broker for a given camStream may not be registered
22663
+ * yet when the orchestrator wires the subscription (provider addons publish
22664
+ * their cameraStreams asynchronously after their probe completes).
22665
+ * `subscribeAudioChunks` retries with exponential backoff (capped at
22666
+ * `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
22667
+ * teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
22668
+ * shape so video and audio plumbing self-heal identically.
22546
22669
  */
22547
- /** Settings store key for stage rules. Kept under a single nested
22548
- * object so a future stage just adds another property without
22549
- * reshuffling the schema. */
22550
- var RULES_STORE_KEY = "zoneRules";
22551
- /** Cap name for the unified runtime-state slice. Matches the cap's
22552
- * declared `name` so the codegen DeviceProxy auto-wires
22553
- * `device.state.zoneRules`. The slice value is the full
22554
- * `{motion, detection}` object — both stages travel together so a
22555
- * single reactive handle covers every consumer. */
22556
- var ZONE_RULES_CAP_NAME = "zone-rules";
22557
- var RulesArraySchema = array(ZoneRuleSchema);
22558
- /**
22559
- * Whole-blob schema for the per-device `zoneRules` store key. Both stages
22560
- * travel together under one key. Deliberately lenient — each stage is
22561
- * `unknown` so a corrupt single stage can NOT drop the sibling on load
22562
- * (durable-state `get()` falls back to `{}` only on a whole-blob parse
22563
- * failure). Strict per-stage validation, with its own reset-on-corrupt
22564
- * warn, still happens in `loadRules` exactly as before the migration.
22565
- */
22566
- var ZoneRulesBlockSchema = object({
22567
- motion: unknown().optional(),
22568
- detection: unknown().optional()
22569
- }).passthrough();
22570
- var ZoneRulesProvider = class {
22571
- ctx;
22572
- /** Per-device per-stage cache. Hydrated lazily on first read. */
22573
- cache = /* @__PURE__ */ new Map();
22574
- /**
22575
- * Per-device durable handle over the `zoneRules` store key. The WHOLE
22576
- * `{motion?, detection?}` block round-trips on every read/write so a
22577
- * write on one stage can never drop the other. Built lazily + memoised.
22578
- */
22579
- stateByDevice = /* @__PURE__ */ new Map();
22580
- constructor(ctx) {
22581
- this.ctx = ctx;
22582
- }
22583
- /** Lazily build (and memoise) the durable `zoneRules` handle for a device. */
22584
- rulesState(deviceId) {
22585
- let handle = this.stateByDevice.get(deviceId);
22586
- if (!handle) {
22587
- handle = createDurableState({
22588
- key: RULES_STORE_KEY,
22589
- schema: ZoneRulesBlockSchema,
22590
- fallback: {},
22591
- read: () => this.ctx.settings.readDeviceStore(deviceId),
22592
- write: (patch) => this.ctx.settings.writeDeviceStore(deviceId, patch),
22593
- onParseError: (key, error) => this.ctx.logger.warn("zone-rules store block failed to parse — resetting", {
22594
- tags: { deviceId },
22595
- meta: {
22596
- key,
22597
- error: error instanceof Error ? error.message : String(error)
22598
- }
22599
- })
22600
- });
22601
- this.stateByDevice.set(deviceId, handle);
22602
- }
22603
- return handle;
22604
- }
22605
- async listRules({ deviceId, stage }) {
22606
- return this.loadRules(deviceId, stage);
22607
- }
22608
- async setRules({ deviceId, stage, rules }) {
22609
- const parsed = RulesArraySchema.safeParse(rules);
22610
- if (!parsed.success) throw new Error(`zone-rules.setRules: invalid rule payload — ${parsed.error.issues.map((i) => i.message).join("; ")}`);
22611
- await this.persist(deviceId, stage, parsed.data);
22612
- }
22613
- /** Drop a device's cache entries. Called when the device is removed. */
22614
- forgetDevice(deviceId) {
22615
- this.cache.delete(deviceId);
22616
- this.stateByDevice.delete(deviceId);
22617
- }
22618
- async loadRules(deviceId, stage) {
22619
- let perDevice = this.cache.get(deviceId);
22620
- if (!perDevice) {
22621
- perDevice = /* @__PURE__ */ new Map();
22622
- this.cache.set(deviceId, perDevice);
22623
- }
22624
- const cached = perDevice.get(stage);
22625
- if (cached) return cached;
22626
- let rules = [];
22627
- try {
22628
- const raw = (await this.rulesState(deviceId).get())[stage];
22629
- if (raw !== void 0) {
22630
- const parsed = RulesArraySchema.safeParse(raw);
22631
- if (parsed.success) rules = parsed.data;
22632
- else this.ctx.logger.warn("zone-rules store entry failed to parse — resetting", {
22633
- tags: { deviceId },
22634
- meta: {
22635
- stage,
22636
- issues: parsed.error.issues
22637
- }
22638
- });
22639
- }
22640
- } catch (err) {
22641
- this.ctx.logger.warn("zone-rules store read failed — using empty list", {
22642
- tags: { deviceId },
22643
- meta: {
22644
- stage,
22645
- error: err instanceof Error ? err.message : String(err)
22646
- }
22647
- });
22648
- }
22649
- perDevice.set(stage, rules);
22650
- return rules;
22651
- }
22652
- async persist(deviceId, stage, rules) {
22653
- let perDevice = this.cache.get(deviceId);
22654
- if (!perDevice) {
22655
- perDevice = /* @__PURE__ */ new Map();
22656
- this.cache.set(deviceId, perDevice);
22657
- }
22658
- perDevice.set(stage, rules);
22659
- await this.rulesState(deviceId).update((prev) => ({
22660
- ...prev,
22661
- [stage]: rules
22662
- }));
22663
- const otherStage = stage === "motion" ? "detection" : "motion";
22664
- const otherRules = perDevice.get(otherStage) ?? await this.loadRules(deviceId, otherStage);
22665
- const sliceValue = stage === "motion" ? {
22666
- motion: rules,
22667
- detection: otherRules
22668
- } : {
22669
- motion: otherRules,
22670
- detection: rules
22671
- };
22672
- try {
22673
- await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
22674
- capName: ZONE_RULES_CAP_NAME,
22675
- slice: sliceValue
22676
- });
22677
- } catch (err) {
22678
- this.ctx.logger.debug("zone-rules slice mirror failed", {
22679
- tags: { deviceId },
22680
- meta: {
22681
- stage,
22682
- error: err instanceof Error ? err.message : String(err)
22683
- }
22684
- });
22685
- }
22686
- this.ctx.onRulesChanged?.(deviceId, stage, rules);
22687
- }
22688
- };
22689
- //#endregion
22690
- //#region src/orchestrator-store-schemas.ts
22691
- /**
22692
- * `orchestrator-store-schemas.ts` — whole-blob Zod schemas for the
22693
- * orchestrator's addon-store keys that persist an ENTIRE collection
22694
- * under a single key (`nodeBindings`, `templates`, `agentSettings`,
22695
- * `cameraSettings`).
22696
- *
22697
- * These back the durable-state primitive (`this.state(KEY, Schema, {})`)
22698
- * so a hand-written serializer can never silently drop a field on save:
22699
- * the whole validated value round-trips on every read and write.
22700
- *
22701
- * Each schema mirrors the SAME shape the orchestrator already persisted
22702
- * (the `pipeline-orchestrator.cap.ts` storage contract). Every field that
22703
- * is only conditionally written is `.optional()`, so an existing blob
22704
- * that predates a newer field still loads — durable-state `get()` only
22705
- * falls back to the empty map on a whole-blob parse failure, so the
22706
- * schema is kept faithful to the on-disk shape rather than overly strict.
22707
- */
22708
- var EngineChoiceSchema = object({
22709
- runtime: _enum(["node", "python"]),
22710
- backend: string(),
22711
- format: string(),
22712
- device: string().optional()
22713
- });
22714
- var NodeBindingsSchema = record(string(), record(string(), string()));
22715
- var StoredPipelineConfigSchema = object({
22716
- engine: EngineChoiceSchema,
22717
- steps: array(PipelineStepInputSchema).readonly(),
22718
- audio: object({
22719
- engine: EngineChoiceSchema,
22720
- modelId: string(),
22721
- enabled: boolean(),
22722
- settings: record(string(), unknown()).readonly().optional()
22723
- }).nullable().optional()
22724
- });
22725
- var StoredPipelineTemplateSchema = object({
22726
- id: string(),
22727
- name: string(),
22728
- description: string().optional(),
22729
- config: StoredPipelineConfigSchema,
22730
- createdAt: string(),
22731
- updatedAt: string()
22732
- });
22733
- var TemplatesSchema = record(string(), StoredPipelineTemplateSchema);
22734
- var StoredAgentAddonConfigSchema = object({
22735
- enabled: boolean(),
22736
- modelId: string(),
22737
- settings: record(string(), unknown()).readonly()
22738
- });
22739
- var StoredAgentPipelineSettingsSchema = object({
22740
- addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly(),
22741
- maxCameras: number().int().nonnegative().nullable().default(null)
22742
- });
22743
- var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
22744
- var StoredCameraStepOverridePatchSchema = object({
22745
- enabled: boolean().optional(),
22746
- modelId: string().optional(),
22747
- settings: record(string(), unknown()).readonly().optional()
22748
- });
22749
- var StoredCameraPipelineForAgentSchema = object({
22750
- steps: array(PipelineStepInputSchema).readonly(),
22751
- audio: object({
22752
- modelId: string(),
22753
- enabled: boolean()
22754
- }).nullable()
22755
- });
22756
- var StoredCameraPipelineSettingsSchema = object({
22757
- pinnedAgentNodeId: string().optional(),
22758
- stepToggles: record(string(), boolean()).optional(),
22759
- stepOverridesByAgent: record(string(), record(string(), StoredCameraStepOverridePatchSchema)).optional(),
22760
- pipelineByAgent: record(string(), StoredCameraPipelineForAgentSchema).optional(),
22761
- /**
22762
- * Legacy "nuke inference" flag. Superseded by the detection-pipeline
22763
- * wrapper binding, but the one-shot boot migration
22764
- * (`migrateLegacyFlagsToBindings`) still reads it off the persisted
22765
- * blob to flip the binding off. Kept here so the durable round-trip
22766
- * does NOT strip it before the migration runs.
22767
- */
22768
- disableInference: boolean().optional()
22769
- });
22770
- var CameraSettingsMapSchema = record(string(), StoredCameraPipelineSettingsSchema);
22771
- //#endregion
22772
- //#region src/load-balancer.ts
22670
+ /** Poll period audio chunks arrive ~every 500ms; 200ms keeps latency low. */
22671
+ var POLL_INTERVAL_MS = 200;
22672
+ /** How many chunks to drain per poll — a small burst absorbs jitter. */
22673
+ var PULL_MAX_COUNT = 8;
22773
22674
  /**
22774
- * Compute the L2 capacity score for a runner node. Lower is better.
22775
- * The score is a weighted sum of the runner's active workload so the balancer
22776
- * prefers agents that are serving fewer cameras OR draining queues quickly.
22777
- *
22778
- * Rationale:
22779
- * - `attachedCameras * avgInferenceFps` approximates the total inference rate
22780
- * the agent is currently sustaining (not just how many cameras are assigned).
22781
- * - `queueDepthTotal` penalises agents that are falling behind the frame feed.
22675
+ * Consecutive pull failures before we attempt to re-subscribe. A single failed
22676
+ * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
22677
+ * sustained failure means the broker child restarted and dropped our
22678
+ * subscription, so we re-establish it.
22782
22679
  */
22783
- function computeCapacityScore(load) {
22784
- return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
22785
- }
22680
+ var RESUBSCRIBE_AFTER_FAILURES = 2;
22681
+ /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
22682
+ var RESUBSCRIBE_THROTTLE_TICKS = 5;
22683
+ /** First subscribe-retry delay, doubled on every subsequent failure. */
22684
+ var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
22786
22685
  /**
22787
- * Returns true when the node has remaining capacity.
22788
- * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
22789
- * its `attachedCameras` count is strictly less than the cap.
22790
- * Pins count toward the cap via `attachedCameras`.
22686
+ * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller fast
22687
+ * enough to recover within a single reconcile of the orchestrator and slow
22688
+ * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
22791
22689
  */
22792
- function isEligible(node, caps) {
22793
- const cap = caps?.[node.nodeId];
22794
- if (cap === null || cap === void 0 || cap <= 0) return true;
22795
- return node.attachedCameras < cap;
22796
- }
22690
+ var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
22797
22691
  /**
22798
- * Run the two-level camera balancer.
22799
- *
22800
- * L1 (manual affinity): if `preferredAgent` names an online node AND that
22801
- * node is under its `maxCameras` cap, return it. If the node is online but at
22802
- * or over cap, return `{kind:'pending'}` — a pinned camera is never silently
22803
- * over-assigned.
22804
- *
22805
- * L2 (capacity): filter to eligible nodes and pick the lowest capacity score.
22806
- * If all nodes are at/over cap, return `{kind:'pending'}`.
22692
+ * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
22807
22693
  *
22808
- * Returns `null` when no runners are online. The orchestrator decides how to
22809
- * react typically by logging and deferring the assignment until a runner
22810
- * comes online.
22694
+ * Always resolves to a teardown closure when the broker is not yet
22695
+ * registered the closure cancels the ongoing retry loop; when polling is
22696
+ * active it stops the loop and releases the broker subscription. Mirrors
22697
+ * `startFrameHandlePoller` so video and audio recover identically.
22811
22698
  */
22812
- function balance(input) {
22813
- const online = input.nodes.filter((n) => n.nodeId.length > 0);
22814
- if (online.length === 0) return null;
22815
- const eligible = online.filter((n) => isEligible(n, input.nodeCaps));
22816
- if (input.preferredAgent) {
22817
- const pinnedOnline = online.find((n) => n.nodeId === input.preferredAgent);
22818
- if (pinnedOnline) {
22819
- if (eligible.some((n) => n.nodeId === pinnedOnline.nodeId)) return {
22820
- kind: "assigned",
22821
- agentNodeId: pinnedOnline.nodeId,
22822
- reason: "manual",
22823
- score: computeCapacityScore(pinnedOnline)
22824
- };
22825
- return {
22826
- kind: "pending",
22827
- reason: "over-cap"
22828
- };
22829
- }
22830
- }
22831
- if (eligible.length === 0) return {
22832
- kind: "pending",
22833
- reason: "over-cap"
22699
+ function startAudioChunkPoller(options) {
22700
+ const lifecycle = {
22701
+ stopped: false,
22702
+ retryTimer: void 0,
22703
+ pollTimer: void 0,
22704
+ activeSubscriptionId: null
22834
22705
  };
22835
- const best = eligible.map((node) => ({
22836
- node,
22837
- score: computeCapacityScore(node)
22838
- })).toSorted((a, b) => a.score - b.score)[0];
22839
- return {
22840
- kind: "assigned",
22841
- agentNodeId: best.node.nodeId,
22842
- reason: "capacity",
22843
- score: best.score
22706
+ const teardown = () => {
22707
+ if (lifecycle.stopped) return;
22708
+ lifecycle.stopped = true;
22709
+ if (lifecycle.retryTimer) {
22710
+ clearTimeout(lifecycle.retryTimer);
22711
+ lifecycle.retryTimer = void 0;
22712
+ }
22713
+ if (lifecycle.pollTimer) {
22714
+ clearTimeout(lifecycle.pollTimer);
22715
+ lifecycle.pollTimer = void 0;
22716
+ }
22717
+ const subId = lifecycle.activeSubscriptionId;
22718
+ if (subId) {
22719
+ lifecycle.activeSubscriptionId = null;
22720
+ options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }).catch((err) => {
22721
+ options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
22722
+ brokerId: options.brokerId,
22723
+ subscriptionId: subId,
22724
+ error: errMsg(err)
22725
+ } });
22726
+ });
22727
+ }
22844
22728
  };
22729
+ subscribeWithRetry(options, lifecycle);
22730
+ return teardown;
22845
22731
  }
22846
22732
  /**
22847
- * Decide whether a manual per-device decoder pin may be honored.
22848
- *
22849
- * A pin to a node OUTSIDE `enabledDecoderNodes` must NOT be honored blindly —
22850
- * that is how video decode reaches a node that is not eligible to decode (e.g.
22851
- * a node whose shm frame ring the broker cannot read, or one an operator
22852
- * disabled). A pin to an ENABLED node is honored; anything else falls through
22853
- * to the auto-balance path (which filters by `enabledDecoderNodes`).
22733
+ * Run the subscribe poll handshake with exponential backoff on subscribe
22734
+ * failures. Resolves once the subscription is acquired (and the poll loop has
22735
+ * been started) or once `lifecycle.stopped` flips, whichever comes first.
22854
22736
  */
22855
- function isDecoderPinHonored(pinnedNodeId, enabledDecoderNodes) {
22856
- return enabledDecoderNodes.includes(pinnedNodeId);
22737
+ async function subscribeWithRetry(options, lifecycle) {
22738
+ const { api, brokerId, tag, logger } = options;
22739
+ let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
22740
+ let attempt = 0;
22741
+ while (!lifecycle.stopped) {
22742
+ attempt += 1;
22743
+ try {
22744
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({
22745
+ brokerId,
22746
+ tag
22747
+ });
22748
+ if (lifecycle.stopped) {
22749
+ await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
22750
+ logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
22751
+ brokerId,
22752
+ subscriptionId: result.subscriptionId,
22753
+ error: errMsg(err)
22754
+ } });
22755
+ });
22756
+ return;
22757
+ }
22758
+ if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
22759
+ brokerId,
22760
+ tag,
22761
+ attempt
22762
+ } });
22763
+ lifecycle.activeSubscriptionId = result.subscriptionId;
22764
+ startPolling(options, lifecycle);
22765
+ return;
22766
+ } catch (err) {
22767
+ if (lifecycle.stopped) return;
22768
+ if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
22769
+ brokerId,
22770
+ tag,
22771
+ error: errMsg(err),
22772
+ nextRetryInMs: backoffMs
22773
+ } });
22774
+ else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
22775
+ brokerId,
22776
+ tag,
22777
+ attempt,
22778
+ error: errMsg(err),
22779
+ nextRetryInMs: backoffMs
22780
+ } });
22781
+ await sleep(backoffMs, lifecycle);
22782
+ backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
22783
+ }
22784
+ }
22857
22785
  }
22858
22786
  /**
22859
- * Choose decoder node. Priority: manual pin → co-located with pipeline → capacity.
22787
+ * Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
22788
+ * trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
22789
+ * the broker child restart case where our `subscriptionId` is silently
22790
+ * disowned.
22860
22791
  */
22861
- function balanceDecoder(input) {
22862
- const { decoderNodes, pipelineNodeId, preferredDecoderNode } = input;
22863
- if (decoderNodes.length === 0) return null;
22864
- if (preferredDecoderNode) {
22865
- const match = decoderNodes.find((n) => n.nodeId === preferredDecoderNode);
22866
- if (match) return {
22867
- decoderNodeId: match.nodeId,
22868
- reason: "manual",
22869
- score: computeCapacityScore(match)
22870
- };
22871
- }
22872
- const colocated = decoderNodes.find((n) => n.nodeId === pipelineNodeId);
22873
- if (colocated) return {
22874
- decoderNodeId: colocated.nodeId,
22875
- reason: "co-located",
22876
- score: computeCapacityScore(colocated)
22792
+ function startPolling(options, lifecycle) {
22793
+ const { api, brokerId, tag, onChunk, logger } = options;
22794
+ let consecutiveFailures = 0;
22795
+ const resubscribe = async () => {
22796
+ try {
22797
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({
22798
+ brokerId,
22799
+ tag
22800
+ });
22801
+ lifecycle.activeSubscriptionId = result.subscriptionId;
22802
+ logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
22803
+ brokerId,
22804
+ tag,
22805
+ subscriptionId: result.subscriptionId,
22806
+ afterFailures: consecutiveFailures
22807
+ } });
22808
+ return true;
22809
+ } catch {
22810
+ return false;
22811
+ }
22877
22812
  };
22878
- const best = decoderNodes.map((node) => ({
22879
- node,
22880
- score: computeCapacityScore(node)
22881
- })).toSorted((a, b) => a.score - b.score)[0];
22882
- return {
22883
- decoderNodeId: best.node.nodeId,
22884
- reason: "capacity",
22885
- score: best.score
22813
+ const tick = async () => {
22814
+ if (lifecycle.stopped) return;
22815
+ const subId = lifecycle.activeSubscriptionId;
22816
+ if (!subId) return;
22817
+ try {
22818
+ const chunks = await api.streamBroker.pullAudioChunks.query({
22819
+ subscriptionId: subId,
22820
+ maxCount: PULL_MAX_COUNT
22821
+ });
22822
+ if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
22823
+ brokerId,
22824
+ subscriptionId: subId
22825
+ } });
22826
+ consecutiveFailures = 0;
22827
+ for (const chunk of chunks) {
22828
+ if (lifecycle.stopped) break;
22829
+ await onChunk(chunk);
22830
+ }
22831
+ } catch (err) {
22832
+ consecutiveFailures += 1;
22833
+ if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
22834
+ brokerId,
22835
+ subscriptionId: subId,
22836
+ error: errMsg(err)
22837
+ } });
22838
+ if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
22839
+ }
22840
+ if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS);
22886
22841
  };
22842
+ tick();
22843
+ }
22844
+ /**
22845
+ * Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
22846
+ * keep a local wrapper around the shared {@link sleep} helper because
22847
+ * the lifecycle tracks the active retry timer for `teardown()` to
22848
+ * clear; pure `sleep()` would leak the timer if teardown fired while
22849
+ * we were waiting.
22850
+ */
22851
+ function sleep(ms, lifecycle) {
22852
+ return new Promise((resolve) => {
22853
+ if (lifecycle.stopped) {
22854
+ resolve();
22855
+ return;
22856
+ }
22857
+ lifecycle.retryTimer = setTimeout(() => {
22858
+ lifecycle.retryTimer = void 0;
22859
+ resolve();
22860
+ }, ms);
22861
+ });
22887
22862
  }
22888
22863
  //#endregion
22889
22864
  //#region src/audio-load-balancer.ts
@@ -22902,259 +22877,412 @@ function balanceAudio(input) {
22902
22877
  };
22903
22878
  }
22904
22879
  //#endregion
22905
- //#region src/audio-chunk-poller.ts
22880
+ //#region src/camera-status/compose-camera-status.ts
22881
+ function mapAssignment(input) {
22882
+ return {
22883
+ detectionNodeId: input.detectionNodeId,
22884
+ decoderNodeId: input.decoderNodeId,
22885
+ audioNodeId: input.audioNodeId,
22886
+ pinned: {
22887
+ detection: input.pinned.detection,
22888
+ decoder: input.pinned.decoder,
22889
+ audio: input.pinned.audio
22890
+ },
22891
+ reasons: {
22892
+ detection: input.reasons.detection,
22893
+ decoder: input.reasons.decoder,
22894
+ audio: input.reasons.audio
22895
+ }
22896
+ };
22897
+ }
22898
+ function mapSource(sourceResult) {
22899
+ if (sourceResult === null) return { streams: [] };
22900
+ return { streams: sourceResult.streams.map((s) => ({
22901
+ camStreamId: s.camStreamId,
22902
+ codec: s.codec,
22903
+ width: s.width,
22904
+ height: s.height,
22905
+ fps: s.fps,
22906
+ kind: s.kind
22907
+ })) };
22908
+ }
22909
+ function mapBroker(brokerResult) {
22910
+ if (brokerResult === null) return null;
22911
+ return {
22912
+ profiles: brokerResult.profiles.map((p) => ({
22913
+ profile: p.profile,
22914
+ status: p.status,
22915
+ codec: p.codec,
22916
+ width: p.width,
22917
+ height: p.height,
22918
+ subscribers: p.subscribers,
22919
+ inFps: p.inFps,
22920
+ outFps: p.outFps
22921
+ })),
22922
+ webrtcSessions: brokerResult.webrtcSessions,
22923
+ rtspRestream: brokerResult.rtspRestream
22924
+ };
22925
+ }
22926
+ function mapDecoderShm(shm) {
22927
+ return {
22928
+ framesWritten: shm.framesWritten,
22929
+ getFrameHits: shm.getFrameHits,
22930
+ getFrameMisses: shm.getFrameMisses,
22931
+ budgetMb: shm.budgetMb
22932
+ };
22933
+ }
22934
+ function mapDecoder(decoderResult) {
22935
+ if (decoderResult === null) return null;
22936
+ return {
22937
+ nodeId: decoderResult.nodeId,
22938
+ formats: [...decoderResult.formats],
22939
+ sessionCount: decoderResult.sessionCount,
22940
+ shm: mapDecoderShm(decoderResult.shm)
22941
+ };
22942
+ }
22943
+ function mapMotion(motionResult) {
22944
+ if (motionResult === null) return null;
22945
+ return {
22946
+ enabled: motionResult.enabled,
22947
+ fps: motionResult.fps
22948
+ };
22949
+ }
22950
+ function mapProvisioning(p) {
22951
+ if (p.error !== void 0) return {
22952
+ state: p.state,
22953
+ error: p.error
22954
+ };
22955
+ return { state: p.state };
22956
+ }
22957
+ function mapDetection(detectionResult) {
22958
+ if (detectionResult === null) return null;
22959
+ const phase = detectionResult.phase;
22960
+ return {
22961
+ nodeId: detectionResult.nodeId,
22962
+ engine: {
22963
+ backend: detectionResult.engine.backend,
22964
+ device: detectionResult.engine.device
22965
+ },
22966
+ phase,
22967
+ configuredFps: detectionResult.configuredFps,
22968
+ actualFps: detectionResult.actualFps,
22969
+ queueDepth: detectionResult.queueDepth,
22970
+ avgInferenceMs: detectionResult.avgInferenceMs,
22971
+ provisioning: mapProvisioning(detectionResult.provisioning)
22972
+ };
22973
+ }
22974
+ function mapAudio(audioResult) {
22975
+ if (audioResult === null) return null;
22976
+ return {
22977
+ nodeId: audioResult.nodeId,
22978
+ enabled: audioResult.enabled
22979
+ };
22980
+ }
22981
+ function mapRecording(recordingResult) {
22982
+ if (recordingResult === null) return null;
22983
+ return {
22984
+ mode: recordingResult.mode,
22985
+ active: recordingResult.active,
22986
+ storageBytes: recordingResult.storageBytes
22987
+ };
22988
+ }
22906
22989
  /**
22907
- * `AudioChunkPoller` the consumer-side poll loop of the decoded audio-chunk
22908
- * plane (Phase 5 / D9).
22990
+ * Pure function that composes a `CameraStatus` from per-stage fetch results.
22909
22991
  *
22910
- * Replaces the live-object `IStreamBroker.onDecodedAudioChunk(cb)` callback
22911
- * path. A live callback cannot cross a process boundary; once the `pipeline`
22912
- * group is dissolved (Task 8) the orchestrator runs in a different process
22913
- * from the broker, so audio delivery must go over tRPC.
22992
+ * - `assignment` is always built from orchestrator-local data (never null).
22993
+ * - `source` always present: defaults to `{ streams: [] }` when sourceResult is null.
22994
+ * - Every other block is null when its stage result is null (graceful degradation).
22995
+ * - `fetchedAt` is stamped exactly as provided never calls `Date.now()`.
22996
+ * - No mutation of the input.
22997
+ */
22998
+ function composeCameraStatus(input) {
22999
+ return {
23000
+ deviceId: input.deviceId,
23001
+ assignment: mapAssignment(input),
23002
+ source: mapSource(input.sourceResult),
23003
+ broker: mapBroker(input.brokerResult),
23004
+ decoder: mapDecoder(input.decoderResult),
23005
+ motion: mapMotion(input.motionResult),
23006
+ detection: mapDetection(input.detectionResult),
23007
+ audio: mapAudio(input.audioResult),
23008
+ recording: mapRecording(input.recordingResult),
23009
+ fetchedAt: input.fetchedAt
23010
+ };
23011
+ }
23012
+ //#endregion
23013
+ //#region src/dispatch-reconcile.ts
23014
+ /**
23015
+ * Derives the set of deviceIds that currently have at least one broker
23016
+ * profile slot that is assigned (status !== 'unassigned') AND has a
23017
+ * non-null sourceCamStreamId.
22914
23018
  *
22915
- * The consumer:
23019
+ * This is the "desired" fleet — cameras the orchestrator should have
23020
+ * dispatched. Used by `reconcileDispatch` to compute the gap against
23021
+ * `cameraConfigs`.
23022
+ */
23023
+ function desiredDeviceIdsFromSlots(slots) {
23024
+ const result = /* @__PURE__ */ new Set();
23025
+ for (const slot of slots) if (slot.status !== "unassigned" && slot.sourceCamStreamId !== null) result.add(slot.deviceId);
23026
+ return result;
23027
+ }
23028
+ /**
23029
+ * Returns the deviceIds that are present in `desired` but absent from
23030
+ * `known`. These are the cameras the orchestrator has not yet dispatched
23031
+ * and needs to process via `handleDeviceRegistered`.
22916
23032
  *
22917
- * 1. `subscribeAudioChunks({ brokerId, tag })` over tRPC the broker
22918
- * registers a per-subscription bounded FIFO queue and returns a
22919
- * `subscriptionId`;
22920
- * 2. polls `pullAudioChunks({ subscriptionId, maxCount })` on a modest
22921
- * interval, draining `DecodedAudioChunk`s in FIFO arrival order;
22922
- * 3. feeds each chunk to its downstream audio logic;
22923
- * 4. on teardown, `unsubscribeAudioChunks`.
23033
+ * Additive only cameras present in `known` are never included, even
23034
+ * if they fall out of `desired`, to avoid fighting the live event path.
23035
+ */
23036
+ function computeDispatchGap(desired, known) {
23037
+ return [...desired].filter((id) => !known.has(id));
23038
+ }
23039
+ //#endregion
23040
+ //#region src/frame-source-eligibility.ts
23041
+ /**
23042
+ * The set of nodes that can obtain a given camera's decoded frames.
22924
23043
  *
22925
- * Audio is not latency-critical like video, and chunks arrive only ~every
22926
- * 500ms (the broker's `AudioCodecSession` window). A modest poll period plus
22927
- * a small per-poll burst keeps latency low without busy-spinning. The
22928
- * broker's queue is FIFO/no-drop-sized so a poll-period of jitter never
22929
- * loses a chunk.
23044
+ * - No `sourceOwnerNodeId` `enabledDecoderNodes` unchanged (today's
23045
+ * semantics; `remoteSourcingNodes` cannot restrict anything when no owner
23046
+ * restricts first it only ever WIDENS an owner-restricted set).
23047
+ * - With an owner `enabledDecoderNodes ({owner} remoteSourcingNodes)`.
22930
23048
  *
22931
- * Boot-race tolerance: the broker for a given camStream may not be registered
22932
- * yet when the orchestrator wires the subscription (provider addons publish
22933
- * their cameraStreams asynchronously after their probe completes).
22934
- * `subscribeAudioChunks` retries with exponential backoff (capped at
22935
- * `MAX_SUBSCRIBE_RETRY_BACKOFF_MS`) until it succeeds or the returned
22936
- * teardown closure is invoked. Mirrors the `FrameHandlePoller` recovery
22937
- * shape so video and audio plumbing self-heal identically.
23049
+ * Pure: never mutates its inputs; result preserves `enabledDecoderNodes`
23050
+ * order.
22938
23051
  */
22939
- /** Poll period — audio chunks arrive ~every 500ms; 200ms keeps latency low. */
22940
- var POLL_INTERVAL_MS = 200;
22941
- /** How many chunks to drain per poll — a small burst absorbs jitter. */
22942
- var PULL_MAX_COUNT = 8;
23052
+ function computeFrameSourceNodes(input) {
23053
+ const { enabledDecoderNodes, sourceOwnerNodeId, remoteSourcingNodes } = input;
23054
+ if (sourceOwnerNodeId === void 0) return enabledDecoderNodes;
23055
+ const remote = remoteSourcingNodes ?? [];
23056
+ return enabledDecoderNodes.filter((nodeId) => nodeId === sourceOwnerNodeId || remote.includes(nodeId));
23057
+ }
23058
+ //#endregion
23059
+ //#region src/load-balancer.ts
22943
23060
  /**
22944
- * Consecutive pull failures before we attempt to re-subscribe. A single failed
22945
- * poll is treated as a blip (re-subscribing would leak a broker-side FIFO); a
22946
- * sustained failure means the broker child restarted and dropped our
22947
- * subscription, so we re-establish it.
23061
+ * Compute the L2 capacity score for a runner node. Lower is better.
23062
+ * The score is a weighted sum of the runner's active workload so the balancer
23063
+ * prefers agents that are serving fewer cameras OR draining queues quickly.
23064
+ *
23065
+ * Rationale:
23066
+ * - `attachedCameras * avgInferenceFps` approximates the total inference rate
23067
+ * the agent is currently sustaining (not just how many cameras are assigned).
23068
+ * - `queueDepthTotal` penalises agents that are falling behind the frame feed.
22948
23069
  */
22949
- var RESUBSCRIBE_AFTER_FAILURES = 2;
22950
- /** Re-subscribe at most once every N ticks while failing (~1s at 200ms). */
22951
- var RESUBSCRIBE_THROTTLE_TICKS = 5;
22952
- /** First subscribe-retry delay, doubled on every subsequent failure. */
22953
- var INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS = 250;
23070
+ function computeCapacityScore(load) {
23071
+ return load.attachedCameras * Math.max(load.avgInferenceFps, 0) + Math.max(load.queueDepthTotal, 0);
23072
+ }
22954
23073
  /**
22955
- * Subscribe-retry backoff ceiling. 5 s matches the frame-handle poller fast
22956
- * enough to recover within a single reconcile of the orchestrator and slow
22957
- * enough that a misconfigured camStream costs ~12 op/min, not 5/s.
23074
+ * Returns true when the node has remaining capacity.
23075
+ * A node is eligible iff `cap` is unlimited (null/absent/<=0) OR
23076
+ * its `attachedCameras` count is strictly less than the cap.
23077
+ * Pins count toward the cap via `attachedCameras`.
22958
23078
  */
22959
- var MAX_SUBSCRIBE_RETRY_BACKOFF_MS = 5e3;
23079
+ function isEligible(node, caps) {
23080
+ const cap = caps?.[node.nodeId];
23081
+ if (cap === null || cap === void 0 || cap <= 0) return true;
23082
+ return node.attachedCameras < cap;
23083
+ }
22960
23084
  /**
22961
- * Subscribe to a broker's decoded audio-chunk stream and start the poll loop.
23085
+ * Run the two-level camera balancer.
22962
23086
  *
22963
- * Always resolves to a teardown closure when the broker is not yet
22964
- * registered the closure cancels the ongoing retry loop; when polling is
22965
- * active it stops the loop and releases the broker subscription. Mirrors
22966
- * `startFrameHandlePoller` so video and audio recover identically.
23087
+ * Frame-source constraint: when `eligibleNodes` is set, only nodes in that set
23088
+ * can obtain this camera's decoded frames. Such a node is a prerequisite for
23089
+ * BOTH L1 and L2 a node outside `eligibleNodes` is never assignable, and a
23090
+ * pin to an online-but-ineligible node returns `{kind:'pending',
23091
+ * reason:'no-frame-source'}` (mirrors the over-cap-pin behaviour: a pinned
23092
+ * camera is never silently placed on a different node).
23093
+ *
23094
+ * L1 (manual affinity): if `preferredAgent` names an online + frame-sourceable
23095
+ * node AND that node is under its `maxCameras` cap, return it. If the node is
23096
+ * online but at/over cap, return `{kind:'pending', reason:'over-cap'}`; if it
23097
+ * is online but not frame-sourceable, `{kind:'pending', reason:'no-frame-source'}`
23098
+ * — a pinned camera is never silently over-assigned or re-homed.
23099
+ *
23100
+ * L2 (capacity): among the frame-sourceable nodes, filter to those under cap
23101
+ * and pick the lowest capacity score. If no frame-sourceable node exists (but
23102
+ * online nodes do), return `no-frame-source`; if they exist but are all at/over
23103
+ * cap, return `over-cap`.
23104
+ *
23105
+ * Returns `null` when no runners are online. The orchestrator decides how to
23106
+ * react — typically by logging and deferring the assignment until a runner
23107
+ * comes online.
22967
23108
  */
22968
- function startAudioChunkPoller(options) {
22969
- const lifecycle = {
22970
- stopped: false,
22971
- retryTimer: void 0,
22972
- pollTimer: void 0,
22973
- activeSubscriptionId: null
22974
- };
22975
- const teardown = () => {
22976
- if (lifecycle.stopped) return;
22977
- lifecycle.stopped = true;
22978
- if (lifecycle.retryTimer) {
22979
- clearTimeout(lifecycle.retryTimer);
22980
- lifecycle.retryTimer = void 0;
22981
- }
22982
- if (lifecycle.pollTimer) {
22983
- clearTimeout(lifecycle.pollTimer);
22984
- lifecycle.pollTimer = void 0;
22985
- }
22986
- const subId = lifecycle.activeSubscriptionId;
22987
- if (subId) {
22988
- lifecycle.activeSubscriptionId = null;
22989
- options.api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: subId }).catch((err) => {
22990
- options.logger.warn("audio-chunk poller: unsubscribeAudioChunks failed", { meta: {
22991
- brokerId: options.brokerId,
22992
- subscriptionId: subId,
22993
- error: errMsg(err)
22994
- } });
22995
- });
23109
+ function balance(input) {
23110
+ const online = input.nodes.filter((n) => n.nodeId.length > 0);
23111
+ if (online.length === 0) return null;
23112
+ const sourceable = input.eligibleNodes ? online.filter((n) => input.eligibleNodes.includes(n.nodeId)) : online;
23113
+ const eligible = sourceable.filter((n) => isEligible(n, input.nodeCaps));
23114
+ if (input.preferredAgent) {
23115
+ const pinnedOnline = online.find((n) => n.nodeId === input.preferredAgent);
23116
+ if (pinnedOnline) {
23117
+ if (!sourceable.some((n) => n.nodeId === pinnedOnline.nodeId)) return {
23118
+ kind: "pending",
23119
+ reason: "no-frame-source"
23120
+ };
23121
+ if (eligible.some((n) => n.nodeId === pinnedOnline.nodeId)) return {
23122
+ kind: "assigned",
23123
+ agentNodeId: pinnedOnline.nodeId,
23124
+ reason: "manual",
23125
+ score: computeCapacityScore(pinnedOnline)
23126
+ };
23127
+ return {
23128
+ kind: "pending",
23129
+ reason: "over-cap"
23130
+ };
22996
23131
  }
23132
+ }
23133
+ if (eligible.length === 0) return {
23134
+ kind: "pending",
23135
+ reason: sourceable.length === 0 ? "no-frame-source" : "over-cap"
23136
+ };
23137
+ const best = eligible.map((node) => ({
23138
+ node,
23139
+ score: computeCapacityScore(node)
23140
+ })).toSorted((a, b) => a.score - b.score)[0];
23141
+ return {
23142
+ kind: "assigned",
23143
+ agentNodeId: best.node.nodeId,
23144
+ reason: "capacity",
23145
+ score: best.score
22997
23146
  };
22998
- subscribeWithRetry(options, lifecycle);
22999
- return teardown;
23000
23147
  }
23001
23148
  /**
23002
- * Run the subscribe poll handshake with exponential backoff on subscribe
23003
- * failures. Resolves once the subscription is acquired (and the poll loop has
23004
- * been started) or once `lifecycle.stopped` flips, whichever comes first.
23149
+ * Decide whether a manual per-device decoder pin may be honored.
23150
+ *
23151
+ * A pin to a node OUTSIDE `enabledDecoderNodes` must NOT be honored blindly —
23152
+ * that is how video decode reaches a node that is not eligible to decode (e.g.
23153
+ * a node whose shm frame ring the broker cannot read, or one an operator
23154
+ * disabled). A pin to an ENABLED node is honored; anything else falls through
23155
+ * to the auto-balance path (which filters by `enabledDecoderNodes`).
23005
23156
  */
23006
- async function subscribeWithRetry(options, lifecycle) {
23007
- const { api, brokerId, tag, logger } = options;
23008
- let backoffMs = INITIAL_SUBSCRIBE_RETRY_BACKOFF_MS;
23009
- let attempt = 0;
23010
- while (!lifecycle.stopped) {
23011
- attempt += 1;
23012
- try {
23013
- const result = await api.streamBroker.subscribeAudioChunks.mutate({
23014
- brokerId,
23015
- tag
23016
- });
23017
- if (lifecycle.stopped) {
23018
- await api.streamBroker.unsubscribeAudioChunks.mutate({ subscriptionId: result.subscriptionId }).catch((err) => {
23019
- logger.warn("audio-chunk poller: late unsubscribe failed", { meta: {
23020
- brokerId,
23021
- subscriptionId: result.subscriptionId,
23022
- error: errMsg(err)
23023
- } });
23024
- });
23025
- return;
23026
- }
23027
- if (attempt > 1) logger.info("audio-chunk poller: subscribeAudioChunks recovered", { meta: {
23028
- brokerId,
23029
- tag,
23030
- attempt
23031
- } });
23032
- lifecycle.activeSubscriptionId = result.subscriptionId;
23033
- startPolling(options, lifecycle);
23034
- return;
23035
- } catch (err) {
23036
- if (lifecycle.stopped) return;
23037
- if (attempt === 1) logger.warn("audio-chunk poller: subscribeAudioChunks failed, retrying", { meta: {
23038
- brokerId,
23039
- tag,
23040
- error: errMsg(err),
23041
- nextRetryInMs: backoffMs
23042
- } });
23043
- else logger.debug("audio-chunk poller: subscribeAudioChunks still failing", { meta: {
23044
- brokerId,
23045
- tag,
23046
- attempt,
23047
- error: errMsg(err),
23048
- nextRetryInMs: backoffMs
23049
- } });
23050
- await sleep(backoffMs, lifecycle);
23051
- backoffMs = Math.min(MAX_SUBSCRIBE_RETRY_BACKOFF_MS, backoffMs * 2);
23052
- }
23053
- }
23157
+ function isDecoderPinHonored(pinnedNodeId, enabledDecoderNodes) {
23158
+ return enabledDecoderNodes.includes(pinnedNodeId);
23054
23159
  }
23055
23160
  /**
23056
- * Drive the steady-state poll loop. Mid-stream `pullAudioChunks` failures
23057
- * trigger a fresh `subscribeAudioChunks` via the recovery branch — covers
23058
- * the broker child restart case where our `subscriptionId` is silently
23059
- * disowned.
23161
+ * Choose decoder node. Priority: manual pin → co-located with pipeline → capacity.
23060
23162
  */
23061
- function startPolling(options, lifecycle) {
23062
- const { api, brokerId, tag, onChunk, logger } = options;
23063
- let consecutiveFailures = 0;
23064
- const resubscribe = async () => {
23065
- try {
23066
- const result = await api.streamBroker.subscribeAudioChunks.mutate({
23067
- brokerId,
23068
- tag
23069
- });
23070
- lifecycle.activeSubscriptionId = result.subscriptionId;
23071
- logger.info("audio-chunk poller: re-subscribed after broker outage", { meta: {
23072
- brokerId,
23073
- tag,
23074
- subscriptionId: result.subscriptionId,
23075
- afterFailures: consecutiveFailures
23076
- } });
23077
- return true;
23078
- } catch {
23079
- return false;
23080
- }
23163
+ function balanceDecoder(input) {
23164
+ const { decoderNodes, pipelineNodeId, preferredDecoderNode } = input;
23165
+ if (decoderNodes.length === 0) return null;
23166
+ if (preferredDecoderNode) {
23167
+ const match = decoderNodes.find((n) => n.nodeId === preferredDecoderNode);
23168
+ if (match) return {
23169
+ decoderNodeId: match.nodeId,
23170
+ reason: "manual",
23171
+ score: computeCapacityScore(match)
23172
+ };
23173
+ }
23174
+ const colocated = decoderNodes.find((n) => n.nodeId === pipelineNodeId);
23175
+ if (colocated) return {
23176
+ decoderNodeId: colocated.nodeId,
23177
+ reason: "co-located",
23178
+ score: computeCapacityScore(colocated)
23081
23179
  };
23082
- const tick = async () => {
23083
- if (lifecycle.stopped) return;
23084
- const subId = lifecycle.activeSubscriptionId;
23085
- if (!subId) return;
23086
- try {
23087
- const chunks = await api.streamBroker.pullAudioChunks.query({
23088
- subscriptionId: subId,
23089
- maxCount: PULL_MAX_COUNT
23090
- });
23091
- if (consecutiveFailures > 0) logger.info("audio-chunk poller: stream recovered", { meta: {
23092
- brokerId,
23093
- subscriptionId: subId
23094
- } });
23095
- consecutiveFailures = 0;
23096
- for (const chunk of chunks) {
23097
- if (lifecycle.stopped) break;
23098
- await onChunk(chunk);
23099
- }
23100
- } catch (err) {
23101
- consecutiveFailures += 1;
23102
- if (consecutiveFailures === 1) logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", { meta: {
23103
- brokerId,
23104
- subscriptionId: subId,
23105
- error: errMsg(err)
23106
- } });
23107
- if (!lifecycle.stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) await resubscribe();
23108
- }
23109
- if (!lifecycle.stopped) lifecycle.pollTimer = setTimeout(() => void tick(), POLL_INTERVAL_MS);
23180
+ const best = decoderNodes.map((node) => ({
23181
+ node,
23182
+ score: computeCapacityScore(node)
23183
+ })).toSorted((a, b) => a.score - b.score)[0];
23184
+ return {
23185
+ decoderNodeId: best.node.nodeId,
23186
+ reason: "capacity",
23187
+ score: best.score
23110
23188
  };
23111
- tick();
23112
- }
23113
- /**
23114
- * Cancellable sleep — wakes early when `lifecycle.stopped` flips. We
23115
- * keep a local wrapper around the shared {@link sleep} helper because
23116
- * the lifecycle tracks the active retry timer for `teardown()` to
23117
- * clear; pure `sleep()` would leak the timer if teardown fired while
23118
- * we were waiting.
23119
- */
23120
- function sleep(ms, lifecycle) {
23121
- return new Promise((resolve) => {
23122
- if (lifecycle.stopped) {
23123
- resolve();
23124
- return;
23125
- }
23126
- lifecycle.retryTimer = setTimeout(() => {
23127
- lifecycle.retryTimer = void 0;
23128
- resolve();
23129
- }, ms);
23130
- });
23131
23189
  }
23132
23190
  //#endregion
23133
- //#region src/dispatch-reconcile.ts
23191
+ //#region src/orchestrator-store-schemas.ts
23134
23192
  /**
23135
- * Derives the set of deviceIds that currently have at least one broker
23136
- * profile slot that is assigned (status !== 'unassigned') AND has a
23137
- * non-null sourceCamStreamId.
23193
+ * `orchestrator-store-schemas.ts` whole-blob Zod schemas for the
23194
+ * orchestrator's addon-store keys that persist an ENTIRE collection
23195
+ * under a single key (`nodeBindings`, `templates`, `agentSettings`,
23196
+ * `cameraSettings`).
23138
23197
  *
23139
- * This is the "desired" fleet cameras the orchestrator should have
23140
- * dispatched. Used by `reconcileDispatch` to compute the gap against
23141
- * `cameraConfigs`.
23198
+ * These back the durable-state primitive (`this.state(KEY, Schema, {})`)
23199
+ * so a hand-written serializer can never silently drop a field on save:
23200
+ * the whole validated value round-trips on every read and write.
23201
+ *
23202
+ * Each schema mirrors the SAME shape the orchestrator already persisted
23203
+ * (the `pipeline-orchestrator.cap.ts` storage contract). Every field that
23204
+ * is only conditionally written is `.optional()`, so an existing blob
23205
+ * that predates a newer field still loads — durable-state `get()` only
23206
+ * falls back to the empty map on a whole-blob parse failure, so the
23207
+ * schema is kept faithful to the on-disk shape rather than overly strict.
23142
23208
  */
23143
- function desiredDeviceIdsFromSlots(slots) {
23144
- const result = /* @__PURE__ */ new Set();
23145
- for (const slot of slots) if (slot.status !== "unassigned" && slot.sourceCamStreamId !== null) result.add(slot.deviceId);
23146
- return result;
23147
- }
23209
+ var EngineChoiceSchema = object({
23210
+ runtime: _enum(["node", "python"]),
23211
+ backend: string(),
23212
+ format: string(),
23213
+ device: string().optional()
23214
+ });
23215
+ var NodeBindingsSchema = record(string(), record(string(), string()));
23216
+ var StoredPipelineConfigSchema = object({
23217
+ engine: EngineChoiceSchema,
23218
+ steps: array(PipelineStepInputSchema).readonly(),
23219
+ audio: object({
23220
+ engine: EngineChoiceSchema,
23221
+ modelId: string(),
23222
+ enabled: boolean(),
23223
+ settings: record(string(), unknown()).readonly().optional()
23224
+ }).nullable().optional()
23225
+ });
23226
+ var StoredPipelineTemplateSchema = object({
23227
+ id: string(),
23228
+ name: string(),
23229
+ description: string().optional(),
23230
+ config: StoredPipelineConfigSchema,
23231
+ createdAt: string(),
23232
+ updatedAt: string()
23233
+ });
23234
+ var TemplatesSchema = record(string(), StoredPipelineTemplateSchema);
23235
+ var StoredAgentAddonConfigSchema = object({
23236
+ enabled: boolean(),
23237
+ modelId: string(),
23238
+ settings: record(string(), unknown()).readonly()
23239
+ });
23240
+ var StoredAgentPipelineSettingsSchema = object({
23241
+ addonDefaults: record(string(), StoredAgentAddonConfigSchema).readonly(),
23242
+ maxCameras: number().int().nonnegative().nullable().default(null)
23243
+ });
23244
+ var AgentSettingsMapSchema = record(string(), StoredAgentPipelineSettingsSchema);
23245
+ var StoredCameraStepOverridePatchSchema = object({
23246
+ enabled: boolean().optional(),
23247
+ modelId: string().optional(),
23248
+ settings: record(string(), unknown()).readonly().optional()
23249
+ });
23250
+ var StoredCameraPipelineForAgentSchema = object({
23251
+ steps: array(PipelineStepInputSchema).readonly(),
23252
+ audio: object({
23253
+ modelId: string(),
23254
+ enabled: boolean()
23255
+ }).nullable()
23256
+ });
23257
+ var StoredCameraPipelineSettingsSchema = object({
23258
+ pinnedAgentNodeId: string().optional(),
23259
+ stepToggles: record(string(), boolean()).optional(),
23260
+ stepOverridesByAgent: record(string(), record(string(), StoredCameraStepOverridePatchSchema)).optional(),
23261
+ pipelineByAgent: record(string(), StoredCameraPipelineForAgentSchema).optional(),
23262
+ /**
23263
+ * Legacy "nuke inference" flag. Superseded by the detection-pipeline
23264
+ * wrapper binding, but the one-shot boot migration
23265
+ * (`migrateLegacyFlagsToBindings`) still reads it off the persisted
23266
+ * blob to flip the binding off. Kept here so the durable round-trip
23267
+ * does NOT strip it before the migration runs.
23268
+ */
23269
+ disableInference: boolean().optional()
23270
+ });
23271
+ var CameraSettingsMapSchema = record(string(), StoredCameraPipelineSettingsSchema);
23272
+ //#endregion
23273
+ //#region src/pending-retry.ts
23148
23274
  /**
23149
- * Returns the deviceIds that are present in `desired` but absent from
23150
- * `known`. These are the cameras the orchestrator has not yet dispatched
23151
- * and needs to process via `handleDeviceRegistered`.
23275
+ * Returns the deviceIds that are KNOWN (present in `known`) but currently
23276
+ * UNASSIGNED (absent from `assigned`). These are the "stranded" cameras the
23277
+ * orchestrator has a runner config for them but no live pipeline assignment
23278
+ * (pending / over-cap / no-frame-source / load-shed).
23152
23279
  *
23153
- * Additive only cameras present in `known` are never included, even
23154
- * if they fall out of `desired`, to avoid fighting the live event path.
23280
+ * Mirror of `computeDispatchGap` in `dispatch-reconcile.ts`: a plain set
23281
+ * difference (`known \ assigned`). Pure no side effects, deterministic
23282
+ * ordering (iteration order of `known`).
23155
23283
  */
23156
- function computeDispatchGap(desired, known) {
23157
- return [...desired].filter((id) => !known.has(id));
23284
+ function computeStrandedDevices(known, assigned) {
23285
+ return [...known].filter((id) => !assigned.has(id));
23158
23286
  }
23159
23287
  //#endregion
23160
23288
  //#region src/pipeline-watchdog.ts
@@ -23187,219 +23315,407 @@ var PipelineWatchdog = class {
23187
23315
  });
23188
23316
  this.state.set(cam.deviceId, stages);
23189
23317
  }
23190
- unregister(deviceId) {
23191
- this.cameras.delete(deviceId);
23192
- this.state.delete(deviceId);
23318
+ unregister(deviceId) {
23319
+ this.cameras.delete(deviceId);
23320
+ this.state.delete(deviceId);
23321
+ }
23322
+ /** Called when a stage's live signal arrives (audio chunk / motion frame / inference). */
23323
+ noteSignal(deviceId, stage) {
23324
+ const rt = this.state.get(deviceId)?.get(stage);
23325
+ if (!rt) return;
23326
+ rt.lastSeenMs = this.deps.now();
23327
+ rt.attempts = 0;
23328
+ }
23329
+ tick() {
23330
+ const now = this.deps.now();
23331
+ for (const cam of this.cameras.values()) {
23332
+ const { line, stalled, recoveries } = this.evaluate(cam, now);
23333
+ if (stalled) this.deps.logger.warn(line);
23334
+ else this.deps.logger.info(line);
23335
+ for (const r of recoveries) {
23336
+ const rt = this.state.get(cam.deviceId)?.get(r.stage);
23337
+ if (rt) rt.attempts += 1;
23338
+ this.deps.recover(cam.deviceId, r.stage, r.streamId);
23339
+ }
23340
+ }
23341
+ }
23342
+ start(intervalMs) {
23343
+ if (this.timer) return;
23344
+ this.timer = setInterval(() => this.tick(), intervalMs);
23345
+ }
23346
+ stop() {
23347
+ if (this.timer) clearInterval(this.timer);
23348
+ this.timer = null;
23349
+ }
23350
+ evaluate(cam, now) {
23351
+ const parts = [];
23352
+ const recoveries = [];
23353
+ let stalled = false;
23354
+ const stageOrder = [
23355
+ "audio",
23356
+ "motion",
23357
+ "detection"
23358
+ ];
23359
+ const stageMode = {
23360
+ audio: cam.audioMode,
23361
+ motion: cam.motionSources.includes("analyzer") ? "analyzer" : cam.motionSources.join("+"),
23362
+ detection: cam.detectionMode
23363
+ };
23364
+ for (const stage of stageOrder) {
23365
+ const streamId = cam.continuousStages.get(stage);
23366
+ if (streamId === void 0) {
23367
+ parts.push(`${stage}=${stageMode[stage]}(idle)`);
23368
+ continue;
23369
+ }
23370
+ const rt = this.state.get(cam.deviceId)?.get(stage);
23371
+ if (!rt) {
23372
+ parts.push(`${stage}=${stageMode[stage]}(unknown)`);
23373
+ continue;
23374
+ }
23375
+ const staleness = now - rt.lastSeenMs;
23376
+ const thresholdMs = this.deps.thresholds[STAGE_THRESHOLD_KEY[stage]];
23377
+ const sSec = Math.round(staleness / 1e3);
23378
+ if (staleness <= thresholdMs) parts.push(`${stage}=${stageMode[stage]}@${streamId}(OK ${sSec}s)`);
23379
+ else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts) {
23380
+ stalled = true;
23381
+ parts.push(`${stage}=${stageMode[stage]}@${streamId}(STALLED ${sSec}s · exhausted ${rt.attempts})`);
23382
+ } else {
23383
+ stalled = true;
23384
+ recoveries.push({
23385
+ stage,
23386
+ streamId
23387
+ });
23388
+ parts.push(`${stage}=${stageMode[stage]}@${streamId}(STALLED ${sSec}s · recovering ${rt.attempts + 1}/${this.deps.thresholds.maxRecoveryAttempts})`);
23389
+ }
23390
+ }
23391
+ return {
23392
+ line: `[watchdog] cam${cam.deviceId} ${cam.deviceName} · ${parts.join(" · ")} → ${stalled ? "STALLED" : "OK"}`,
23393
+ stalled,
23394
+ recoveries
23395
+ };
23396
+ }
23397
+ };
23398
+ //#endregion
23399
+ //#region src/remote-health.ts
23400
+ /**
23401
+ * Default thresholds: 3-min grace (cold start), 2-min stale window (metrics
23402
+ * stopped entirely), 0.5 fps floor. A degraded foreign-handle camera runs
23403
+ * ~1 fps (above the bar); with T2's frame-source eligibility a remote
23404
+ * assignment is only legal on a frame-source node, so sustained sub-0.5 fps
23405
+ * means genuinely broken.
23406
+ */
23407
+ var DEFAULT_REMOTE_HEALTH_OPTS = {
23408
+ graceMs: 3 * 6e4,
23409
+ staleMs: 2 * 6e4,
23410
+ minFps: .5
23411
+ };
23412
+ /**
23413
+ * Evaluate every remote pipeline assignment and return the cameras that must be
23414
+ * re-placed. Rules (each assignment evaluated independently):
23415
+ * 1. LOCAL assignments (`agentNodeId === localNodeId`) are skipped — the hub
23416
+ * watchdog owns them.
23417
+ * 2. Cameras not in `activeDeviceIds` are skipped — detection isn't expected.
23418
+ * 3. Assignments younger than `graceMs` are skipped — cold-start grace.
23419
+ * 4. No fps entry, or `now - lastSeen > staleMs` → `stale-metrics`.
23420
+ * 5. Otherwise `fps < minFps` → `zero-fps`.
23421
+ * 6. Otherwise healthy → no action.
23422
+ *
23423
+ * Deterministic ordering: iteration order of `assignments`.
23424
+ */
23425
+ function evaluateRemoteHealth(input) {
23426
+ const { assignments, fpsMap, activeDeviceIds, localNodeId, now, opts } = input;
23427
+ const actions = [];
23428
+ for (const [deviceId, assignment] of assignments) {
23429
+ if (assignment.agentNodeId === localNodeId) continue;
23430
+ if (!activeDeviceIds.has(deviceId)) continue;
23431
+ if (now - assignment.assignedAt < opts.graceMs) continue;
23432
+ const entry = fpsMap.get(deviceId);
23433
+ if (!entry || now - entry.lastSeen > opts.staleMs) {
23434
+ actions.push({
23435
+ deviceId,
23436
+ kind: "replace",
23437
+ why: "stale-metrics"
23438
+ });
23439
+ continue;
23440
+ }
23441
+ if (entry.fps < opts.minFps) {
23442
+ actions.push({
23443
+ deviceId,
23444
+ kind: "replace",
23445
+ why: "zero-fps"
23446
+ });
23447
+ continue;
23448
+ }
23449
+ }
23450
+ return actions;
23451
+ }
23452
+ //#endregion
23453
+ //#region src/zone-rules-provider.ts
23454
+ /**
23455
+ * `zone-rules-provider.ts` — implements `zoneRulesCapability` for the
23456
+ * orchestrator.
23457
+ *
23458
+ * Per-stage rule arrays (motion / detection) live next to zones in the
23459
+ * orchestrator's per-device store under `zoneRules.<stage>` keys.
23460
+ * Every mutation mirrors to a stage-specific device-state slice
23461
+ * (`motion-zone-rules`, `detection-zone-rules`) so consumer addons
23462
+ * subscribe independently and pick up the new gating without an extra
23463
+ * cap round-trip.
23464
+ *
23465
+ * The provider validates each rule against {@link ZoneRuleSchema}
23466
+ * before persisting — partial / corrupt writes are rejected outright
23467
+ * since rules drive runtime filtering and a bad payload would silently
23468
+ * widen the operator's intended scope.
23469
+ */
23470
+ /** Settings store key for stage rules. Kept under a single nested
23471
+ * object so a future stage just adds another property without
23472
+ * reshuffling the schema. */
23473
+ var RULES_STORE_KEY = "zoneRules";
23474
+ /** Cap name for the unified runtime-state slice. Matches the cap's
23475
+ * declared `name` so the codegen DeviceProxy auto-wires
23476
+ * `device.state.zoneRules`. The slice value is the full
23477
+ * `{motion, detection}` object — both stages travel together so a
23478
+ * single reactive handle covers every consumer. */
23479
+ var ZONE_RULES_CAP_NAME = "zone-rules";
23480
+ var RulesArraySchema = array(ZoneRuleSchema);
23481
+ /**
23482
+ * Whole-blob schema for the per-device `zoneRules` store key. Both stages
23483
+ * travel together under one key. Deliberately lenient — each stage is
23484
+ * `unknown` so a corrupt single stage can NOT drop the sibling on load
23485
+ * (durable-state `get()` falls back to `{}` only on a whole-blob parse
23486
+ * failure). Strict per-stage validation, with its own reset-on-corrupt
23487
+ * warn, still happens in `loadRules` exactly as before the migration.
23488
+ */
23489
+ var ZoneRulesBlockSchema = object({
23490
+ motion: unknown().optional(),
23491
+ detection: unknown().optional()
23492
+ }).passthrough();
23493
+ var ZoneRulesProvider = class {
23494
+ ctx;
23495
+ /** Per-device per-stage cache. Hydrated lazily on first read. */
23496
+ cache = /* @__PURE__ */ new Map();
23497
+ /**
23498
+ * Per-device durable handle over the `zoneRules` store key. The WHOLE
23499
+ * `{motion?, detection?}` block round-trips on every read/write so a
23500
+ * write on one stage can never drop the other. Built lazily + memoised.
23501
+ */
23502
+ stateByDevice = /* @__PURE__ */ new Map();
23503
+ constructor(ctx) {
23504
+ this.ctx = ctx;
23505
+ }
23506
+ /** Lazily build (and memoise) the durable `zoneRules` handle for a device. */
23507
+ rulesState(deviceId) {
23508
+ let handle = this.stateByDevice.get(deviceId);
23509
+ if (!handle) {
23510
+ handle = createDurableState({
23511
+ key: RULES_STORE_KEY,
23512
+ schema: ZoneRulesBlockSchema,
23513
+ fallback: {},
23514
+ read: () => this.ctx.settings.readDeviceStore(deviceId),
23515
+ write: (patch) => this.ctx.settings.writeDeviceStore(deviceId, patch),
23516
+ onParseError: (key, error) => this.ctx.logger.warn("zone-rules store block failed to parse — resetting", {
23517
+ tags: { deviceId },
23518
+ meta: {
23519
+ key,
23520
+ error: error instanceof Error ? error.message : String(error)
23521
+ }
23522
+ })
23523
+ });
23524
+ this.stateByDevice.set(deviceId, handle);
23525
+ }
23526
+ return handle;
23527
+ }
23528
+ async listRules({ deviceId, stage }) {
23529
+ return this.loadRules(deviceId, stage);
23530
+ }
23531
+ async setRules({ deviceId, stage, rules }) {
23532
+ const parsed = RulesArraySchema.safeParse(rules);
23533
+ if (!parsed.success) throw new Error(`zone-rules.setRules: invalid rule payload — ${parsed.error.issues.map((i) => i.message).join("; ")}`);
23534
+ await this.persist(deviceId, stage, parsed.data);
23193
23535
  }
23194
- /** Called when a stage's live signal arrives (audio chunk / motion frame / inference). */
23195
- noteSignal(deviceId, stage) {
23196
- const rt = this.state.get(deviceId)?.get(stage);
23197
- if (!rt) return;
23198
- rt.lastSeenMs = this.deps.now();
23199
- rt.attempts = 0;
23536
+ /** Drop a device's cache entries. Called when the device is removed. */
23537
+ forgetDevice(deviceId) {
23538
+ this.cache.delete(deviceId);
23539
+ this.stateByDevice.delete(deviceId);
23200
23540
  }
23201
- tick() {
23202
- const now = this.deps.now();
23203
- for (const cam of this.cameras.values()) {
23204
- const { line, stalled, recoveries } = this.evaluate(cam, now);
23205
- if (stalled) this.deps.logger.warn(line);
23206
- else this.deps.logger.info(line);
23207
- for (const r of recoveries) {
23208
- const rt = this.state.get(cam.deviceId)?.get(r.stage);
23209
- if (rt) rt.attempts += 1;
23210
- this.deps.recover(cam.deviceId, r.stage, r.streamId);
23541
+ async loadRules(deviceId, stage) {
23542
+ let perDevice = this.cache.get(deviceId);
23543
+ if (!perDevice) {
23544
+ perDevice = /* @__PURE__ */ new Map();
23545
+ this.cache.set(deviceId, perDevice);
23546
+ }
23547
+ const cached = perDevice.get(stage);
23548
+ if (cached) return cached;
23549
+ let rules = [];
23550
+ try {
23551
+ const raw = (await this.rulesState(deviceId).get())[stage];
23552
+ if (raw !== void 0) {
23553
+ const parsed = RulesArraySchema.safeParse(raw);
23554
+ if (parsed.success) rules = parsed.data;
23555
+ else this.ctx.logger.warn("zone-rules store entry failed to parse — resetting", {
23556
+ tags: { deviceId },
23557
+ meta: {
23558
+ stage,
23559
+ issues: parsed.error.issues
23560
+ }
23561
+ });
23211
23562
  }
23563
+ } catch (err) {
23564
+ this.ctx.logger.warn("zone-rules store read failed — using empty list", {
23565
+ tags: { deviceId },
23566
+ meta: {
23567
+ stage,
23568
+ error: err instanceof Error ? err.message : String(err)
23569
+ }
23570
+ });
23212
23571
  }
23572
+ perDevice.set(stage, rules);
23573
+ return rules;
23213
23574
  }
23214
- start(intervalMs) {
23215
- if (this.timer) return;
23216
- this.timer = setInterval(() => this.tick(), intervalMs);
23217
- }
23218
- stop() {
23219
- if (this.timer) clearInterval(this.timer);
23220
- this.timer = null;
23221
- }
23222
- evaluate(cam, now) {
23223
- const parts = [];
23224
- const recoveries = [];
23225
- let stalled = false;
23226
- const stageOrder = [
23227
- "audio",
23228
- "motion",
23229
- "detection"
23230
- ];
23231
- const stageMode = {
23232
- audio: cam.audioMode,
23233
- motion: cam.motionSources.includes("analyzer") ? "analyzer" : cam.motionSources.join("+"),
23234
- detection: cam.detectionMode
23575
+ async persist(deviceId, stage, rules) {
23576
+ let perDevice = this.cache.get(deviceId);
23577
+ if (!perDevice) {
23578
+ perDevice = /* @__PURE__ */ new Map();
23579
+ this.cache.set(deviceId, perDevice);
23580
+ }
23581
+ perDevice.set(stage, rules);
23582
+ await this.rulesState(deviceId).update((prev) => ({
23583
+ ...prev,
23584
+ [stage]: rules
23585
+ }));
23586
+ const otherStage = stage === "motion" ? "detection" : "motion";
23587
+ const otherRules = perDevice.get(otherStage) ?? await this.loadRules(deviceId, otherStage);
23588
+ const sliceValue = stage === "motion" ? {
23589
+ motion: rules,
23590
+ detection: otherRules
23591
+ } : {
23592
+ motion: otherRules,
23593
+ detection: rules
23235
23594
  };
23236
- for (const stage of stageOrder) {
23237
- const streamId = cam.continuousStages.get(stage);
23238
- if (streamId === void 0) {
23239
- parts.push(`${stage}=${stageMode[stage]}(idle)`);
23240
- continue;
23241
- }
23242
- const rt = this.state.get(cam.deviceId)?.get(stage);
23243
- if (!rt) {
23244
- parts.push(`${stage}=${stageMode[stage]}(unknown)`);
23245
- continue;
23246
- }
23247
- const staleness = now - rt.lastSeenMs;
23248
- const thresholdMs = this.deps.thresholds[STAGE_THRESHOLD_KEY[stage]];
23249
- const sSec = Math.round(staleness / 1e3);
23250
- if (staleness <= thresholdMs) parts.push(`${stage}=${stageMode[stage]}@${streamId}(OK ${sSec}s)`);
23251
- else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts) {
23252
- stalled = true;
23253
- parts.push(`${stage}=${stageMode[stage]}@${streamId}(STALLED ${sSec}s · exhausted ${rt.attempts})`);
23254
- } else {
23255
- stalled = true;
23256
- recoveries.push({
23595
+ try {
23596
+ await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
23597
+ capName: ZONE_RULES_CAP_NAME,
23598
+ slice: sliceValue
23599
+ });
23600
+ } catch (err) {
23601
+ this.ctx.logger.debug("zone-rules slice mirror failed", {
23602
+ tags: { deviceId },
23603
+ meta: {
23257
23604
  stage,
23258
- streamId
23259
- });
23260
- parts.push(`${stage}=${stageMode[stage]}@${streamId}(STALLED ${sSec}s · recovering ${rt.attempts + 1}/${this.deps.thresholds.maxRecoveryAttempts})`);
23261
- }
23605
+ error: err instanceof Error ? err.message : String(err)
23606
+ }
23607
+ });
23262
23608
  }
23263
- return {
23264
- line: `[watchdog] cam${cam.deviceId} ${cam.deviceName} · ${parts.join(" · ")} → ${stalled ? "STALLED" : "OK"}`,
23265
- stalled,
23266
- recoveries
23267
- };
23609
+ this.ctx.onRulesChanged?.(deviceId, stage, rules);
23268
23610
  }
23269
23611
  };
23270
23612
  //#endregion
23271
- //#region src/camera-status/compose-camera-status.ts
23272
- function mapAssignment(input) {
23273
- return {
23274
- detectionNodeId: input.detectionNodeId,
23275
- decoderNodeId: input.decoderNodeId,
23276
- audioNodeId: input.audioNodeId,
23277
- pinned: {
23278
- detection: input.pinned.detection,
23279
- decoder: input.pinned.decoder,
23280
- audio: input.pinned.audio
23281
- },
23282
- reasons: {
23283
- detection: input.reasons.detection,
23284
- decoder: input.reasons.decoder,
23285
- audio: input.reasons.audio
23286
- }
23287
- };
23288
- }
23289
- function mapSource(sourceResult) {
23290
- if (sourceResult === null) return { streams: [] };
23291
- return { streams: sourceResult.streams.map((s) => ({
23292
- camStreamId: s.camStreamId,
23293
- codec: s.codec,
23294
- width: s.width,
23295
- height: s.height,
23296
- fps: s.fps,
23297
- kind: s.kind
23298
- })) };
23299
- }
23300
- function mapBroker(brokerResult) {
23301
- if (brokerResult === null) return null;
23302
- return {
23303
- profiles: brokerResult.profiles.map((p) => ({
23304
- profile: p.profile,
23305
- status: p.status,
23306
- codec: p.codec,
23307
- width: p.width,
23308
- height: p.height,
23309
- subscribers: p.subscribers,
23310
- inFps: p.inFps,
23311
- outFps: p.outFps
23312
- })),
23313
- webrtcSessions: brokerResult.webrtcSessions,
23314
- rtspRestream: brokerResult.rtspRestream
23315
- };
23316
- }
23317
- function mapDecoderShm(shm) {
23318
- return {
23319
- framesWritten: shm.framesWritten,
23320
- getFrameHits: shm.getFrameHits,
23321
- getFrameMisses: shm.getFrameMisses,
23322
- budgetMb: shm.budgetMb
23323
- };
23324
- }
23325
- function mapDecoder(decoderResult) {
23326
- if (decoderResult === null) return null;
23327
- return {
23328
- nodeId: decoderResult.nodeId,
23329
- formats: [...decoderResult.formats],
23330
- sessionCount: decoderResult.sessionCount,
23331
- shm: mapDecoderShm(decoderResult.shm)
23332
- };
23333
- }
23334
- function mapMotion(motionResult) {
23335
- if (motionResult === null) return null;
23336
- return {
23337
- enabled: motionResult.enabled,
23338
- fps: motionResult.fps
23339
- };
23340
- }
23341
- function mapProvisioning(p) {
23342
- if (p.error !== void 0) return {
23343
- state: p.state,
23344
- error: p.error
23345
- };
23346
- return { state: p.state };
23347
- }
23348
- function mapDetection(detectionResult) {
23349
- if (detectionResult === null) return null;
23350
- const phase = detectionResult.phase;
23351
- return {
23352
- nodeId: detectionResult.nodeId,
23353
- engine: {
23354
- backend: detectionResult.engine.backend,
23355
- device: detectionResult.engine.device
23356
- },
23357
- phase,
23358
- configuredFps: detectionResult.configuredFps,
23359
- actualFps: detectionResult.actualFps,
23360
- queueDepth: detectionResult.queueDepth,
23361
- avgInferenceMs: detectionResult.avgInferenceMs,
23362
- provisioning: mapProvisioning(detectionResult.provisioning)
23363
- };
23364
- }
23365
- function mapAudio(audioResult) {
23366
- if (audioResult === null) return null;
23367
- return {
23368
- nodeId: audioResult.nodeId,
23369
- enabled: audioResult.enabled
23370
- };
23371
- }
23372
- function mapRecording(recordingResult) {
23373
- if (recordingResult === null) return null;
23374
- return {
23375
- mode: recordingResult.mode,
23376
- active: recordingResult.active,
23377
- storageBytes: recordingResult.storageBytes
23378
- };
23379
- }
23613
+ //#region src/zones-provider.ts
23380
23614
  /**
23381
- * Pure function that composes a `CameraStatus` from per-stage fetch results.
23615
+ * `zones-provider.ts` implements `zonesCapability` for the orchestrator.
23616
+ *
23617
+ * Per-camera CRUD over polygon detection zones. Persists to the
23618
+ * orchestrator's per-device settings store under the `zones` key and
23619
+ * mirrors every change into the device-state `zones` slice via
23620
+ * `api.deviceState.setCapSlice`. Downstream consumers (motion-wasm,
23621
+ * pipeline-executor, analytics, admin UI) read the live state with
23622
+ * the canonical `dev.state.zones.onChanged` channel.
23382
23623
  *
23383
- * - `assignment` is always built from orchestrator-local data (never null).
23384
- * - `source` always present: defaults to `{ streams: [] }` when sourceResult is null.
23385
- * - Every other block is null when its stage result is null (graceful degradation).
23386
- * - `fetchedAt` is stamped exactly as provided — never calls `Date.now()`.
23387
- * - No mutation of the input.
23624
+ * Onboard / firmware-reported zones are out of scope for now every
23625
+ * zone is operator-drawn. The provider keeps the surface symmetric:
23626
+ * `addZone` rejects id collisions, `updateZone` requires an existing
23627
+ * id, `removeZone` is idempotent.
23388
23628
  */
23389
- function composeCameraStatus(input) {
23390
- return {
23391
- deviceId: input.deviceId,
23392
- assignment: mapAssignment(input),
23393
- source: mapSource(input.sourceResult),
23394
- broker: mapBroker(input.brokerResult),
23395
- decoder: mapDecoder(input.decoderResult),
23396
- motion: mapMotion(input.motionResult),
23397
- detection: mapDetection(input.detectionResult),
23398
- audio: mapAudio(input.audioResult),
23399
- recording: mapRecording(input.recordingResult),
23400
- fetchedAt: input.fetchedAt
23401
- };
23402
- }
23629
+ var ZONES_STORE_KEY = "zones";
23630
+ var ZONES_CAP_NAME = "zones";
23631
+ var ZonesArraySchema = array(ZoneSchema);
23632
+ var ZonesProvider = class {
23633
+ ctx;
23634
+ /** Per-device cache. Hydrated lazily on first read for a device. */
23635
+ cache = /* @__PURE__ */ new Map();
23636
+ /**
23637
+ * Per-device durable handle over the `zones` store key. The WHOLE
23638
+ * validated zone array round-trips on every read/write so no field can
23639
+ * be dropped on persist. Built lazily + memoised per device.
23640
+ */
23641
+ stateByDevice = /* @__PURE__ */ new Map();
23642
+ constructor(ctx) {
23643
+ this.ctx = ctx;
23644
+ }
23645
+ /** Lazily build (and memoise) the durable `zones` handle for a device. */
23646
+ zonesState(deviceId) {
23647
+ let handle = this.stateByDevice.get(deviceId);
23648
+ if (!handle) {
23649
+ handle = createDurableState({
23650
+ key: ZONES_STORE_KEY,
23651
+ schema: ZonesArraySchema,
23652
+ fallback: [],
23653
+ read: () => this.ctx.settings.readDeviceStore(deviceId),
23654
+ write: (patch) => this.ctx.settings.writeDeviceStore(deviceId, patch),
23655
+ onParseError: (key, error) => this.ctx.logger.warn("zones store entry failed to parse — resetting", {
23656
+ tags: { deviceId },
23657
+ meta: {
23658
+ key,
23659
+ error: error instanceof Error ? error.message : String(error)
23660
+ }
23661
+ })
23662
+ });
23663
+ this.stateByDevice.set(deviceId, handle);
23664
+ }
23665
+ return handle;
23666
+ }
23667
+ async listZones({ deviceId }) {
23668
+ return this.loadZones(deviceId);
23669
+ }
23670
+ async addZone({ deviceId, zone }) {
23671
+ const existing = await this.loadZones(deviceId);
23672
+ if (existing.some((entry) => entry.id === zone.id)) throw new Error(`zones.addZone: id ${zone.id} already exists`);
23673
+ await this.persist(deviceId, [...existing, zone]);
23674
+ }
23675
+ async updateZone({ deviceId, zone }) {
23676
+ const existing = await this.loadZones(deviceId);
23677
+ if (!existing.some((entry) => entry.id === zone.id)) throw new Error(`zones.updateZone: id ${zone.id} not found`);
23678
+ const next = existing.map((entry) => entry.id === zone.id ? zone : entry);
23679
+ await this.persist(deviceId, next);
23680
+ }
23681
+ async removeZone({ deviceId, zoneId }) {
23682
+ const existing = await this.loadZones(deviceId);
23683
+ if (!existing.some((entry) => entry.id === zoneId)) return;
23684
+ await this.persist(deviceId, existing.filter((entry) => entry.id !== zoneId));
23685
+ }
23686
+ /**
23687
+ * Drop a device's cache entry. Called when the device is removed so
23688
+ * the next attach starts from a fresh disk read.
23689
+ */
23690
+ forgetDevice(deviceId) {
23691
+ this.cache.delete(deviceId);
23692
+ this.stateByDevice.delete(deviceId);
23693
+ }
23694
+ async loadZones(deviceId) {
23695
+ const cached = this.cache.get(deviceId);
23696
+ if (cached) return cached;
23697
+ let zones = [];
23698
+ try {
23699
+ zones = await this.zonesState(deviceId).get();
23700
+ } catch (err) {
23701
+ this.ctx.logger.warn("zones store read failed — using empty list", {
23702
+ tags: { deviceId },
23703
+ meta: { error: err instanceof Error ? err.message : String(err) }
23704
+ });
23705
+ }
23706
+ this.cache.set(deviceId, zones);
23707
+ return zones;
23708
+ }
23709
+ async persist(deviceId, zones) {
23710
+ this.cache.set(deviceId, zones);
23711
+ await this.zonesState(deviceId).set(zones);
23712
+ await (await this.ctx.fetchDevice(deviceId)).deviceState.setCapSlice({
23713
+ capName: ZONES_CAP_NAME,
23714
+ slice: { zones }
23715
+ });
23716
+ this.ctx.onZonesChanged?.(deviceId, zones);
23717
+ }
23718
+ };
23403
23719
  //#endregion
23404
23720
  //#region src/index.ts
23405
23721
  var PHASE_MODE_VALUES = new Set([
@@ -23418,6 +23734,24 @@ var DEFAULT_BROKER_CALL_TIMEOUT_MS = 5e3;
23418
23734
  /** Debounce window for `scheduleReconcile` — coalesces bursts of topology/slot-change signals. */
23419
23735
  var RECONCILE_DEBOUNCE_MS = 200;
23420
23736
  /**
23737
+ * Periodic sweep interval for `retryPendingDispatches` — re-dispatches cameras
23738
+ * that are KNOWN but UNASSIGNED (pending/over-cap/no-frame-source/load-shed).
23739
+ * `reconcileDispatch` is additive-only and never revisits these, so a slow
23740
+ * safety-net timer + event-driven debounce triggers recover them.
23741
+ */
23742
+ var PENDING_RETRY_INTERVAL_MS = 6e4;
23743
+ /** Debounce window for `schedulePendingRetry` — coalesces capacity/eligibility/readiness signals. */
23744
+ var PENDING_RETRY_DEBOUNCE_MS = 2e3;
23745
+ /**
23746
+ * Remote-assignment health loop (T7) bounded backoff. A remote camera flagged
23747
+ * as unhealthy (0-fps / metrics-stale) is re-placed at most
23748
+ * `REMOTE_HEALTH_MAX_ATTEMPTS` times inside a rolling `REMOTE_HEALTH_WINDOW_MS`
23749
+ * window; once exhausted the camera is left visibly pending (`'unhealthy'`) for
23750
+ * the operator instead of churning forever.
23751
+ */
23752
+ var REMOTE_HEALTH_MAX_ATTEMPTS = 3;
23753
+ var REMOTE_HEALTH_WINDOW_MS = 60 * 6e4;
23754
+ /**
23421
23755
  * Device-details keys routed through the orchestrator's pipeline
23422
23756
  * settings writer instead of the device orchestration store. The
23423
23757
  * `cameraPipeline` key carries the full `CameraPipelineConfig`
@@ -23506,6 +23840,28 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23506
23840
  zoneRulesProvider = null;
23507
23841
  /** In-memory assignment map — mirrored into events + per-device settings for the pin. */
23508
23842
  assignments = /* @__PURE__ */ new Map();
23843
+ /**
23844
+ * Why a camera is currently unassigned (pending), keyed by deviceId. Set on
23845
+ * every pending placement path (over-cap, no-frame-source, load-shed) and
23846
+ * cleared when the camera is (re)assigned or released. Consumed by T3's
23847
+ * pending-retry sweep + `getCameraStatus` surface — nothing reads it yet in
23848
+ * this commit; the map is populated so T3 can wire the read side without
23849
+ * re-touching every placement path.
23850
+ *
23851
+ * `protected` (not `private`) to match the existing test-seam convention in
23852
+ * this class (`audioSubscriptions`, `audioSubLocks`) — the frame-source
23853
+ * eligibility spec subclasses the addon to assert the recorded reason.
23854
+ */
23855
+ pendingReasons = /* @__PURE__ */ new Map();
23856
+ /**
23857
+ * Remote-assignment health loop (T7) per-device backoff ledger. Each entry
23858
+ * tracks how many times a remote camera has been re-placed inside the current
23859
+ * rolling window (`REMOTE_HEALTH_WINDOW_MS`). Bounds churn: once the count
23860
+ * hits `REMOTE_HEALTH_MAX_ATTEMPTS`, the camera is left pending (`'unhealthy'`)
23861
+ * for the operator instead of being re-placed again. `protected` so the
23862
+ * remote-health spec can assert the ledger without casts.
23863
+ */
23864
+ remoteHealthAttempts = /* @__PURE__ */ new Map();
23509
23865
  /** Per-device audio node assignment — nodeId of the audio-analyzer handling this device's chunks. */
23510
23866
  audioNodeByDevice = /* @__PURE__ */ new Map();
23511
23867
  /** Assignments with metadata — replaces plain audioNodeByDevice values as the source of truth. */
@@ -23625,6 +23981,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23625
23981
  reconcileInFlight = false;
23626
23982
  /** Set to true when a reconcile is requested while one is already in-flight; triggers a follow-up pass. */
23627
23983
  reconcileRerunRequested = false;
23984
+ /** Periodic `retryPendingDispatches` safety-net timer (T3). */
23985
+ pendingRetryTimer = null;
23986
+ /** Pending `schedulePendingRetry` debounce timer (T3). */
23987
+ pendingRetryDebounceTimer = null;
23988
+ /** True while `retryPendingDispatches` is running. */
23989
+ pendingRetryInFlight = false;
23990
+ /** Set when a pending-retry is requested while one is already in-flight; triggers a follow-up pass. */
23991
+ pendingRetryRerunRequested = false;
23628
23992
  initTimestamp = 0;
23629
23993
  constructor() {
23630
23994
  super({});
@@ -23841,6 +24205,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23841
24205
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
23842
24206
  });
23843
24207
  this.pipelineWatchdog.start(PipelineOrchestratorAddon.WATCHDOG_INTERVAL_MS);
24208
+ this.pendingRetryTimer = setInterval(() => void this.retryPendingDispatches(), PENDING_RETRY_INTERVAL_MS);
23844
24209
  this.ctx.logger.info("Pipeline orchestrator detection-wiring subscriptions installed");
23845
24210
  this.migrateLegacyFlagsToBindings().catch((err) => {
23846
24211
  this.ctx.logger.warn("bindings migration failed — will retry on next boot", { meta: { error: errMsg(err) } });
@@ -24070,6 +24435,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24070
24435
  clearTimeout(this.reconcileTimer);
24071
24436
  this.reconcileTimer = null;
24072
24437
  }
24438
+ if (this.pendingRetryTimer !== null) {
24439
+ clearInterval(this.pendingRetryTimer);
24440
+ this.pendingRetryTimer = null;
24441
+ }
24442
+ if (this.pendingRetryDebounceTimer !== null) {
24443
+ clearTimeout(this.pendingRetryDebounceTimer);
24444
+ this.pendingRetryDebounceTimer = null;
24445
+ }
24073
24446
  this.unsubDeviceRegistered?.();
24074
24447
  this.unsubDeviceRegistered = null;
24075
24448
  this.unsubDeviceUnregistered?.();
@@ -24095,6 +24468,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24095
24468
  for (const t of this.lazyAudioTeardownTimers.values()) clearTimeout(t);
24096
24469
  this.lazyAudioTeardownTimers.clear();
24097
24470
  this.cameraFpsMap.clear();
24471
+ this.remoteHealthAttempts.clear();
24098
24472
  this.loadShedState.clear();
24099
24473
  if (this.loadShedResumeTimer) {
24100
24474
  clearInterval(this.loadShedResumeTimer);
@@ -24156,6 +24530,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24156
24530
  windowMs
24157
24531
  }
24158
24532
  });
24533
+ this.pendingReasons.set(runnerConfig.deviceId, "load-shed");
24159
24534
  return {
24160
24535
  success: true,
24161
24536
  kind: "pending"
@@ -24169,11 +24544,16 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24169
24544
  const decision = balance({
24170
24545
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
24171
24546
  preferredAgent,
24172
- nodeCaps: this.buildNodeCaps()
24547
+ nodeCaps: await this.buildNodeCaps(),
24548
+ eligibleNodes: this.detectionEligibleNodes(runnerConfig.deviceId)
24173
24549
  });
24174
24550
  if (!decision) throw new Error(`dispatchCamera: no runner available for ${runnerConfig.deviceId}`);
24175
24551
  if (decision.kind === "pending") {
24176
- this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: runnerConfig.deviceId } });
24552
+ this.pendingReasons.set(runnerConfig.deviceId, decision.reason);
24553
+ this.ctx.logger.warn("camera left pending — no eligible node", {
24554
+ tags: { deviceId: runnerConfig.deviceId },
24555
+ meta: { reason: decision.reason }
24556
+ });
24177
24557
  return {
24178
24558
  success: true,
24179
24559
  kind: "pending"
@@ -24224,11 +24604,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24224
24604
  }
24225
24605
  this.assignments.delete(input.deviceId);
24226
24606
  this.cameraConfigs.delete(input.deviceId);
24607
+ this.pendingReasons.delete(input.deviceId);
24227
24608
  this.pipelineWatchdog?.unregister(input.deviceId);
24228
24609
  return { success: true };
24229
24610
  }
24230
24611
  async assignPipeline(input) {
24231
24612
  if (!this.ctx) throw new Error("PipelineOrchestrator: assignPipeline called before initialize");
24613
+ const eligible = this.detectionEligibleNodes(input.deviceId);
24614
+ if (!eligible.includes(input.agentNodeId)) throw new Error(`Cannot pin camera ${input.deviceId} detection to '${input.agentNodeId}': the node cannot obtain this camera's decoded frames (frame-source nodes: ${eligible.join(", ") || "none"}). Add it to Enabled Decoder Nodes first.`);
24232
24615
  await this.ctx.settings?.writeDeviceStore(input.deviceId, { [PREFERRED_AGENT_SETTING]: input.agentNodeId }).catch((err) => {
24233
24616
  const msg = errMsg(err);
24234
24617
  this.ctx.logger.warn("assignPipeline: failed to persist pin", {
@@ -24274,11 +24657,17 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24274
24657
  const decision = balance({
24275
24658
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
24276
24659
  preferredAgent: null,
24277
- nodeCaps: this.buildNodeCaps()
24660
+ nodeCaps: await this.buildNodeCaps(),
24661
+ eligibleNodes: this.detectionEligibleNodes(input.deviceId)
24278
24662
  });
24279
24663
  if (!decision) this.ctx.logger.warn("unassignPipeline: no runner available, leaving unassigned", { tags: { deviceId: input.deviceId } });
24280
- else if (decision.kind === "pending") this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId: input.deviceId } });
24281
- else {
24664
+ else if (decision.kind === "pending") {
24665
+ this.pendingReasons.set(input.deviceId, decision.reason);
24666
+ this.ctx.logger.warn("camera left pending — no eligible node", {
24667
+ tags: { deviceId: input.deviceId },
24668
+ meta: { reason: decision.reason }
24669
+ });
24670
+ } else {
24282
24671
  const targetNodeId = decision.agentNodeId;
24283
24672
  if (current && current.agentNodeId !== targetNodeId) await this.detachOn(current.agentNodeId, input.deviceId).catch((err) => {
24284
24673
  const msg = errMsg(err);
@@ -24316,7 +24705,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24316
24705
  async rebalance() {
24317
24706
  if (!this.ctx) throw new Error("PipelineOrchestrator: rebalance called before initialize");
24318
24707
  const loads = await this.collectAgentLoad({ onlyEnabled: true });
24319
- const nodeCaps = this.buildNodeCaps();
24708
+ const nodeCaps = await this.buildNodeCaps();
24320
24709
  let migrated = 0;
24321
24710
  for (const [deviceId, config] of this.cameraConfigs) {
24322
24711
  const current = this.assignments.get(deviceId);
@@ -24324,11 +24713,16 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24324
24713
  const decision = balance({
24325
24714
  nodes: loads,
24326
24715
  preferredAgent: await this.readPreferredAgent(deviceId),
24327
- nodeCaps
24716
+ nodeCaps,
24717
+ eligibleNodes: this.detectionEligibleNodes(deviceId)
24328
24718
  });
24329
24719
  if (!decision) continue;
24330
24720
  if (decision.kind === "pending") {
24331
- this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
24721
+ this.pendingReasons.set(deviceId, decision.reason);
24722
+ this.ctx.logger.warn("camera left pending — no eligible node", {
24723
+ tags: { deviceId },
24724
+ meta: { reason: decision.reason }
24725
+ });
24332
24726
  continue;
24333
24727
  }
24334
24728
  if (current && current.agentNodeId === decision.agentNodeId) continue;
@@ -24535,6 +24929,57 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24535
24929
  return this.enabledNodes.includes(nodeId);
24536
24930
  }
24537
24931
  /**
24932
+ * The node that owns `deviceId`'s source pull (dials the real RTSP, hosts
24933
+ * the broker). **P2a (today): intentionally `undefined`** — per-camera
24934
+ * source ownership is not modeled yet, and an absent owner makes
24935
+ * `computeFrameSourceNodes` collapse to the global `enabledDecoderNodes`
24936
+ * (bit-identical to pre-P2a behavior, the safe-rollout invariant).
24937
+ *
24938
+ * Deliberately NOT `'hub'` yet: with an owner set and no
24939
+ * `remoteSourcingNodes`, the predicate would restrict eligibility to the
24940
+ * hub even when agents are decoder-enabled — a behavior change reserved for
24941
+ * P2d, which backs this seam with the `assignSource`/`getSourceAssignment`
24942
+ * cap surface (restream-owner design §2.3).
24943
+ */
24944
+ sourceOwner(_deviceId) {}
24945
+ /**
24946
+ * The set of nodes that can OBTAIN a camera's decoded frames — PER-CAMERA
24947
+ * since P2a (restream-owner design §2.3), delegating to the pure
24948
+ * `computeFrameSourceNodes` predicate: a node qualifies iff it can decode
24949
+ * locally (`enabledDecoderNodes`) AND (it owns the camera's source pull OR
24950
+ * it may dial the owner's restream — `remoteSourcingNodes`, a P2c/P2d
24951
+ * rollout knob not wired yet).
24952
+ *
24953
+ * **Today's effective value:** `sourceOwner()` returns `undefined` for
24954
+ * every camera, so this is exactly `enabledDecoderNodes` — identical to the
24955
+ * pre-P2a global predicate. Every predicate change edits the pure module
24956
+ * (and this ONE method); `deviceId` is threaded from all placement sites so
24957
+ * later phases turn per-camera without re-touching call sites.
24958
+ */
24959
+ frameSourceNodes(deviceId) {
24960
+ return computeFrameSourceNodes({
24961
+ enabledDecoderNodes: this.enabledDecoderNodes,
24962
+ sourceOwnerNodeId: this.sourceOwner(deviceId)
24963
+ });
24964
+ }
24965
+ /**
24966
+ * Nodes eligible to run a camera's detection pipeline: the whitelist of
24967
+ * detection-enabled nodes (`enabledNodes`) intersected with the nodes that
24968
+ * can obtain the camera's frames (`frameSourceNodes(deviceId)`). A node
24969
+ * that is detection-enabled but cannot source frames (or vice-versa) is
24970
+ * NEVER a valid placement — the balancer receives this as `eligibleNodes`
24971
+ * and treats anything outside it as unassignable (`no-frame-source`).
24972
+ *
24973
+ * Single choke point for T2/T10: passed to every `balance()` call site and
24974
+ * enforced by `assignPipeline` before persisting a pin. `deviceId` is
24975
+ * optional only for node-scoped checks (reconnect-restore guard); camera
24976
+ * placement sites always pass it.
24977
+ */
24978
+ detectionEligibleNodes(deviceId) {
24979
+ const frameSource = this.frameSourceNodes(deviceId);
24980
+ return this.enabledNodes.filter((nodeId) => frameSource.includes(nodeId));
24981
+ }
24982
+ /**
24538
24983
  * Query every online runner in the cluster for its current load, plus the
24539
24984
  * local runner if co-located. Refreshes the `cachedAgentLoad` snapshot so
24540
24985
  * `getAgentLoad()` / `getGlobalMetrics()` can return fresh data.
@@ -24635,8 +25080,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24635
25080
  * Used by every `balance()` call so the balancer can honour operator-set
24636
25081
  * per-node maximums without polling the store on each decision.
24637
25082
  */
24638
- buildNodeCaps() {
24639
- const blob = this.agentSettingsState.get();
25083
+ async buildNodeCaps() {
25084
+ const blob = await this.agentSettingsState.get();
24640
25085
  const caps = {};
24641
25086
  for (const [nodeId, settings] of Object.entries(blob)) caps[nodeId] = settings.maxCameras ?? null;
24642
25087
  return caps;
@@ -24700,6 +25145,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24700
25145
  assignedAt: Date.now()
24701
25146
  };
24702
25147
  this.assignments.set(deviceId, assignment);
25148
+ this.pendingReasons.delete(deviceId);
24703
25149
  if (!this.ctx?.eventBus) return;
24704
25150
  const payload = {
24705
25151
  deviceId,
@@ -24784,7 +25230,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24784
25230
  const decision = balance({
24785
25231
  nodes: loads,
24786
25232
  preferredAgent: null,
24787
- nodeCaps: this.buildNodeCaps()
25233
+ nodeCaps: await this.buildNodeCaps(),
25234
+ eligibleNodes: this.detectionEligibleNodes(deviceId)
24788
25235
  });
24789
25236
  if (!decision) {
24790
25237
  this.ctx.logger.error("Failover: no online runner", { tags: { deviceId } });
@@ -24792,7 +25239,11 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24792
25239
  continue;
24793
25240
  }
24794
25241
  if (decision.kind === "pending") {
24795
- this.ctx.logger.warn("camera left pending — all nodes at maxCameras", { tags: { deviceId } });
25242
+ this.pendingReasons.set(deviceId, decision.reason);
25243
+ this.ctx.logger.warn("Failover: camera left pending — no eligible node", {
25244
+ tags: { deviceId },
25245
+ meta: { reason: decision.reason }
25246
+ });
24796
25247
  this.assignments.delete(deviceId);
24797
25248
  continue;
24798
25249
  }
@@ -24831,6 +25282,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24831
25282
  tags: { nodeId },
24832
25283
  meta: { policy: this.failoverPolicy.onReconnect }
24833
25284
  });
25285
+ this.schedulePendingRetry();
24834
25286
  if (this.failoverPolicy.onReconnect === "rebalance") {
24835
25287
  await this.rebalance().catch((err) => {
24836
25288
  const msg = errMsg(err);
@@ -24838,6 +25290,17 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24838
25290
  });
24839
25291
  return;
24840
25292
  }
25293
+ if (!this.detectionEligibleNodes().includes(nodeId)) {
25294
+ this.ctx.logger.warn("restore skipped — node not frame-source eligible", {
25295
+ tags: { nodeId },
25296
+ meta: { eligibleNodes: this.detectionEligibleNodes().join(",") }
25297
+ });
25298
+ for (const [deviceId, assignment] of this.assignments) {
25299
+ if (assignment.agentNodeId !== nodeId) continue;
25300
+ this.pendingReasons.set(deviceId, "no-frame-source");
25301
+ }
25302
+ return;
25303
+ }
24841
25304
  let restored = 0;
24842
25305
  for (const [deviceId, config] of this.cameraConfigs) {
24843
25306
  const current = this.assignments.get(deviceId);
@@ -24925,34 +25388,43 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24925
25388
  async reattachAudioForNode(nodeId) {
24926
25389
  for (const [deviceId, audioNode] of this.audioNodeByDevice) {
24927
25390
  if (audioNode !== nodeId) continue;
24928
- const config = this.cameraConfigs.get(deviceId);
24929
- if (!config) continue;
24930
- const audioCfg = {
24931
- ...config,
24932
- enabled: config.pipelineEnabled
24933
- };
24934
- try {
24935
- await this.withAudioSubLock(deviceId, async () => {
24936
- const prior = this.audioSubscriptions.get(deviceId);
24937
- if (prior) {
24938
- try {
24939
- prior();
24940
- } catch {}
24941
- this.audioSubscriptions.delete(deviceId);
24942
- }
24943
- const unsub = await this.subscribeAudioStream(deviceId, audioCfg);
24944
- if (unsub) if (this.activeDetections.has(deviceId)) this.storeAudioSub(deviceId, unsub);
24945
- else unsub();
24946
- });
24947
- } catch (err) {
24948
- this.ctx.logger.error("audio re-attach on analyzer readiness failed", {
24949
- tags: {
24950
- deviceId,
24951
- nodeId
24952
- },
24953
- meta: { error: errMsg(err) }
24954
- });
24955
- }
25391
+ await this.reattachAudioForDevice(deviceId);
25392
+ }
25393
+ }
25394
+ /**
25395
+ * Re-establish the audio subscription for ONE camera: tear down any prior
25396
+ * sub and re-subscribe under the current pin/balance, keeping the new handle
25397
+ * only while detection is active. The whole get→teardown→subscribe→store
25398
+ * sequence runs inside `withAudioSubLock` so a prior handle is never orphaned
25399
+ * (the same invariant `reattachAudioForNode` relied on before it was
25400
+ * extracted here). No-op when the device has no runner config or audio is
25401
+ * disabled/lazy (`subscribeAudioStream` self-gates).
25402
+ */
25403
+ async reattachAudioForDevice(deviceId) {
25404
+ const config = this.cameraConfigs.get(deviceId);
25405
+ if (!config) return;
25406
+ const audioCfg = {
25407
+ ...config,
25408
+ enabled: config.pipelineEnabled
25409
+ };
25410
+ try {
25411
+ await this.withAudioSubLock(deviceId, async () => {
25412
+ const prior = this.audioSubscriptions.get(deviceId);
25413
+ if (prior) {
25414
+ try {
25415
+ prior();
25416
+ } catch {}
25417
+ this.audioSubscriptions.delete(deviceId);
25418
+ }
25419
+ const unsub = await this.subscribeAudioStream(deviceId, audioCfg);
25420
+ if (unsub) if (this.activeDetections.has(deviceId)) this.storeAudioSub(deviceId, unsub);
25421
+ else unsub();
25422
+ });
25423
+ } catch (err) {
25424
+ this.ctx.logger.error("audio re-attach failed", {
25425
+ tags: { deviceId },
25426
+ meta: { error: errMsg(err) }
25427
+ });
24956
25428
  }
24957
25429
  }
24958
25430
  /**
@@ -25027,6 +25499,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25027
25499
  });
25028
25500
  }, 3e3);
25029
25501
  this.scheduleReconcile();
25502
+ this.schedulePendingRetry();
25030
25503
  } catch (err) {
25031
25504
  this.ctx.logger.debug("readiness seed+redispatch failed", {
25032
25505
  tags: { nodeId },
@@ -25141,6 +25614,175 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25141
25614
  }
25142
25615
  }
25143
25616
  }
25617
+ /**
25618
+ * Coalesce bursts of capacity/eligibility/readiness signals into a single
25619
+ * `retryPendingDispatches` pass. Mirrors `scheduleReconcile`, but on a
25620
+ * dedicated (longer) debounce so a raise-cap / node-connect flurry doesn't
25621
+ * hammer `dispatchCamera`.
25622
+ */
25623
+ schedulePendingRetry() {
25624
+ if (this.pendingRetryDebounceTimer !== null) clearTimeout(this.pendingRetryDebounceTimer);
25625
+ this.pendingRetryDebounceTimer = setTimeout(() => {
25626
+ this.pendingRetryDebounceTimer = null;
25627
+ this.retryPendingDispatches();
25628
+ }, PENDING_RETRY_DEBOUNCE_MS);
25629
+ }
25630
+ /**
25631
+ * Re-dispatch every KNOWN-but-UNASSIGNED camera. `reconcileDispatch` is
25632
+ * additive-only over `cameraConfigs` and never revisits a camera that is
25633
+ * tracked but has no live assignment (left pending by over-cap /
25634
+ * no-frame-source / load-shed), so those cameras would otherwise stay
25635
+ * stranded until an unrelated live event happens to touch them. This sweep
25636
+ * closes that gap.
25637
+ *
25638
+ * `dispatchCamera` re-reads pins, re-balances with the frame-source
25639
+ * predicate, and re-returns pending harmlessly when nothing changed —
25640
+ * so a no-op sweep is cheap and side-effect-free. Serialized with an
25641
+ * in-flight flag + trailing-rerun bit (mirrors `reconcileDispatch`).
25642
+ */
25643
+ async retryPendingDispatches() {
25644
+ if (this.pendingRetryInFlight) {
25645
+ this.pendingRetryRerunRequested = true;
25646
+ return;
25647
+ }
25648
+ if (this.reconcileInFlight) return;
25649
+ if (!this.api) return;
25650
+ this.pendingRetryInFlight = true;
25651
+ try {
25652
+ const stranded = computeStrandedDevices(new Set(this.cameraConfigs.keys()), new Set(this.assignments.keys()));
25653
+ if (stranded.length > 0) {
25654
+ const loadShedActive = this.isAnyNodeLoadShed();
25655
+ let retried = 0;
25656
+ let stillPending = 0;
25657
+ for (const deviceId of stranded) {
25658
+ if (this.pendingReasons.get(deviceId) === "load-shed" && loadShedActive) {
25659
+ stillPending++;
25660
+ continue;
25661
+ }
25662
+ const cfg = this.cameraConfigs.get(deviceId);
25663
+ if (!cfg) continue;
25664
+ try {
25665
+ const result = await this.dispatchCamera(cfg);
25666
+ retried++;
25667
+ if (result.kind === "pending") stillPending++;
25668
+ } catch (err) {
25669
+ this.ctx.logger.warn("pending retry: dispatchCamera failed", {
25670
+ tags: { deviceId },
25671
+ meta: { error: errMsg(err) }
25672
+ });
25673
+ stillPending++;
25674
+ }
25675
+ }
25676
+ this.ctx.logger.info("pending retry sweep", { meta: {
25677
+ stranded: stranded.length,
25678
+ retried,
25679
+ stillPending
25680
+ } });
25681
+ }
25682
+ await this.evaluateRemoteAssignmentHealth();
25683
+ } finally {
25684
+ this.pendingRetryInFlight = false;
25685
+ if (this.pendingRetryRerunRequested) {
25686
+ this.pendingRetryRerunRequested = false;
25687
+ this.schedulePendingRetry();
25688
+ }
25689
+ }
25690
+ }
25691
+ /** True when any node is currently load-shed paused (used to skip churny retries). */
25692
+ isAnyNodeLoadShed() {
25693
+ for (const state of this.loadShedState.values()) if (state.pausedAt !== null) return true;
25694
+ return false;
25695
+ }
25696
+ /**
25697
+ * Evaluate the health of every REMOTE pipeline assignment and act on the
25698
+ * cameras the pure `evaluateRemoteHealth` flags. The hub watchdog covers
25699
+ * local cameras; this is its remote-node equivalent (gap i). Called from the
25700
+ * T3 sweep tick after retrying pending dispatches.
25701
+ *
25702
+ * `protected` so the remote-health orchestrator spec can drive one evaluation
25703
+ * pass deterministically (mirrors the `pendingReasons`/`audioSubLocks` test-seam
25704
+ * convention in this class) without waiting on the periodic timer.
25705
+ */
25706
+ async evaluateRemoteAssignmentHealth() {
25707
+ if (!this.api) return;
25708
+ const actions = evaluateRemoteHealth({
25709
+ assignments: this.assignments,
25710
+ fpsMap: this.cameraFpsMap,
25711
+ activeDeviceIds: new Set(this.activeDetections.keys()),
25712
+ localNodeId: this.localNodeId,
25713
+ now: Date.now(),
25714
+ opts: DEFAULT_REMOTE_HEALTH_OPTS
25715
+ });
25716
+ for (const action of actions) await this.handleRemoteHealthReplace(action);
25717
+ }
25718
+ /**
25719
+ * Re-place a single unhealthy remote camera with bounded per-device backoff.
25720
+ * Under budget: detach from the unhealthy node + re-dispatch (inherits the
25721
+ * T2 frame-source predicate). Over budget: log an error, drop the dead
25722
+ * assignment, and mark the camera `'unhealthy'`-pending for the operator so
25723
+ * `getCameraStatus` surfaces it (R2 "bounded-retry ... state visible").
25724
+ */
25725
+ async handleRemoteHealthReplace(action) {
25726
+ const { deviceId, why } = action;
25727
+ const assignment = this.assignments.get(deviceId);
25728
+ if (!assignment) return;
25729
+ const cfg = this.cameraConfigs.get(deviceId);
25730
+ if (!cfg) return;
25731
+ const now = Date.now();
25732
+ const prior = this.remoteHealthAttempts.get(deviceId);
25733
+ const windowFresh = prior !== void 0 && now - prior.windowStart < REMOTE_HEALTH_WINDOW_MS;
25734
+ const count = windowFresh ? prior.count : 0;
25735
+ if (count >= REMOTE_HEALTH_MAX_ATTEMPTS) {
25736
+ this.ctx.logger.error("remote assignment unhealthy — retries exhausted, leaving pending", {
25737
+ tags: {
25738
+ deviceId,
25739
+ nodeId: assignment.agentNodeId
25740
+ },
25741
+ meta: {
25742
+ why,
25743
+ attempts: count,
25744
+ windowMs: REMOTE_HEALTH_WINDOW_MS
25745
+ }
25746
+ });
25747
+ this.assignments.delete(deviceId);
25748
+ this.pendingReasons.set(deviceId, "unhealthy");
25749
+ await this.detachOn(assignment.agentNodeId, deviceId).catch((err) => {
25750
+ this.ctx.logger.debug("remote-health exhausted-detach failed", {
25751
+ tags: { deviceId },
25752
+ meta: { error: errMsg(err) }
25753
+ });
25754
+ });
25755
+ return;
25756
+ }
25757
+ this.remoteHealthAttempts.set(deviceId, {
25758
+ count: count + 1,
25759
+ windowStart: windowFresh ? prior.windowStart : now
25760
+ });
25761
+ this.ctx.logger.warn("remote assignment unhealthy — re-placing camera", {
25762
+ tags: {
25763
+ deviceId,
25764
+ nodeId: assignment.agentNodeId
25765
+ },
25766
+ meta: {
25767
+ why,
25768
+ attempt: count + 1,
25769
+ maxAttempts: REMOTE_HEALTH_MAX_ATTEMPTS
25770
+ }
25771
+ });
25772
+ await this.detachOn(assignment.agentNodeId, deviceId).catch((err) => {
25773
+ this.ctx.logger.warn("remote-health detach failed", {
25774
+ tags: { deviceId },
25775
+ meta: { error: errMsg(err) }
25776
+ });
25777
+ });
25778
+ this.assignments.delete(deviceId);
25779
+ await this.dispatchCamera(cfg).catch((err) => {
25780
+ this.ctx.logger.warn("remote-health re-dispatch failed", {
25781
+ tags: { deviceId },
25782
+ meta: { error: errMsg(err) }
25783
+ });
25784
+ });
25785
+ }
25144
25786
  async getCapabilityBindings(input) {
25145
25787
  if (!this.ctx?.settings) return {};
25146
25788
  const perNodeRaw = (await this.nodeBindingsState.get().catch(() => ({})))[input.nodeId];
@@ -25234,12 +25876,17 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25234
25876
  }
25235
25877
  async assignAudio(input) {
25236
25878
  await this.ctx.settings?.writeDeviceStore(input.deviceId, { [AUDIO_NODE_SETTING]: input.nodeId });
25879
+ if (!this.enabledAudioNodes.includes(input.nodeId)) this.ctx.logger.warn("audio pin stored but node is not in enabledAudioNodes — pin will not take effect until enabled", { tags: {
25880
+ deviceId: input.deviceId,
25881
+ nodeId: input.nodeId
25882
+ } });
25237
25883
  this.audioAssignments.delete(input.deviceId);
25238
25884
  this.audioNodeByDevice.delete(input.deviceId);
25239
25885
  this.ctx.logger.info("Audio node pinned", { tags: {
25240
25886
  deviceId: input.deviceId,
25241
25887
  nodeId: input.nodeId
25242
25888
  } });
25889
+ await this.reattachAudioForDevice(input.deviceId);
25243
25890
  return { success: true };
25244
25891
  }
25245
25892
  async unassignAudio(input) {
@@ -25247,6 +25894,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25247
25894
  this.audioAssignments.delete(input.deviceId);
25248
25895
  this.audioNodeByDevice.delete(input.deviceId);
25249
25896
  this.ctx.logger.info("Audio node unpinned", { tags: { deviceId: input.deviceId } });
25897
+ await this.reattachAudioForDevice(input.deviceId);
25250
25898
  return { success: true };
25251
25899
  }
25252
25900
  async getAudioAssignment(input) {
@@ -25330,6 +25978,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25330
25978
  tags: { nodeId: input.agentNodeId },
25331
25979
  meta: { maxCameras: input.maxCameras }
25332
25980
  });
25981
+ this.schedulePendingRetry();
25333
25982
  return { success: true };
25334
25983
  }
25335
25984
  async getCameraSettings(input) {
@@ -25493,7 +26142,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25493
26142
  const detectionNodeId = pipelineAssignment?.agentNodeId ?? null;
25494
26143
  const decoderPinRaw = (api ? await this.ctx.settings?.readDeviceStore(deviceId).catch(() => ({})) ?? {} : {})["decoderNodeId"];
25495
26144
  const decoderPinned = typeof decoderPinRaw === "string" && decoderPinRaw !== "auto";
25496
- const decoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
26145
+ const advisoryDecoderNodeId = detectionNodeId ? await this.resolveDecoderNode(deviceId, detectionNodeId).catch(() => null) : null;
25497
26146
  const audioAssignment = this.audioAssignments.get(deviceId) ?? null;
25498
26147
  const audioNodeId = audioAssignment?.nodeId ?? null;
25499
26148
  const audioPinned = audioAssignment?.pinned ?? false;
@@ -25502,11 +26151,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25502
26151
  decoder: decoderPinned,
25503
26152
  audio: audioPinned
25504
26153
  };
25505
- const reasons = {
25506
- detection: pipelineAssignment?.reason,
25507
- decoder: decoderPinned ? "manual" : "co-located",
25508
- audio: audioPinned ? "manual" : void 0
25509
- };
26154
+ const detectionReason = pipelineAssignment !== null ? pipelineAssignment.reason : this.cameraConfigs.has(deviceId) ? `pending:${this.pendingReasons.get(deviceId) ?? "pending"}` : void 0;
26155
+ const liveDecoder = { nodeId: null };
25510
26156
  const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query() : null;
25511
26157
  const sourceFetch = api && allSlotsFetch ? this.boundedStage(allSlotsFetch.then((slots) => {
25512
26158
  return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
@@ -25537,17 +26183,19 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25537
26183
  clients: await api.streamBroker.listClients.query({ brokerId: slot.brokerId }).catch(() => null)
25538
26184
  };
25539
26185
  })), api.streamBroker.getAllRtspEntries.query({}).catch(() => null)]);
26186
+ const profileDetails = statsAndClients.map(({ slot, stats, clients }) => ({
26187
+ profile: slot.profile,
26188
+ status: slot.status,
26189
+ codec: stats?.codec ?? slot.codec ?? "",
26190
+ width: slot.resolution?.width ?? 0,
26191
+ height: slot.resolution?.height ?? 0,
26192
+ subscribers: clients?.encodedSubscribers ?? 0,
26193
+ inFps: stats?.inputFps ?? 0,
26194
+ outFps: stats?.decodeFps ?? 0
26195
+ }));
26196
+ for (const { stats } of statsAndClients) if (liveDecoder.nodeId === null && typeof stats?.decoderNodeId === "string") liveDecoder.nodeId = stats.decoderNodeId;
25540
26197
  return {
25541
- profiles: statsAndClients.map(({ slot, stats, clients }) => ({
25542
- profile: slot.profile,
25543
- status: slot.status,
25544
- codec: stats?.codec ?? slot.codec ?? "",
25545
- width: slot.resolution?.width ?? 0,
25546
- height: slot.resolution?.height ?? 0,
25547
- subscribers: clients?.encodedSubscribers ?? 0,
25548
- inFps: stats?.inputFps ?? 0,
25549
- outFps: stats?.decodeFps ?? 0
25550
- })),
26198
+ profiles: profileDetails,
25551
26199
  webrtcSessions: statsAndClients.reduce((total, { clients }) => {
25552
26200
  if (!clients) return total;
25553
26201
  return total + clients.encoded.filter((c) => WEBRTC_KINDS.has(c.attribution.kind)).length;
@@ -25557,8 +26205,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25557
26205
  }) ?? false
25558
26206
  };
25559
26207
  }), STAGE_TIMEOUT_MS) : Promise.resolve(null);
25560
- const decoderFetch = decoderNodeId ? Promise.resolve({
25561
- nodeId: decoderNodeId,
26208
+ const decoderFetch = advisoryDecoderNodeId ? Promise.resolve({
26209
+ nodeId: advisoryDecoderNodeId,
25562
26210
  formats: [],
25563
26211
  sessionCount: 0,
25564
26212
  shm: {
@@ -25626,6 +26274,13 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25626
26274
  detectionFetch,
25627
26275
  recordingFetch
25628
26276
  ]);
26277
+ const liveDecoderNodeId = brokerResult !== null ? liveDecoder.nodeId : null;
26278
+ const decoderNodeId = liveDecoderNodeId ?? advisoryDecoderNodeId;
26279
+ const reasons = {
26280
+ detection: detectionReason,
26281
+ decoder: decoderPinned ? "manual" : liveDecoderNodeId !== null ? "session" : decoderNodeId !== null ? "advisory" : void 0,
26282
+ audio: audioPinned ? "manual" : void 0
26283
+ };
25629
26284
  return composeCameraStatus({
25630
26285
  deviceId,
25631
26286
  fetchedAt: Date.now(),
@@ -25981,7 +26636,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25981
26636
  id: "cluster",
25982
26637
  title: "Cluster",
25983
26638
  tab: "pipeline",
25984
- description: "Which cluster nodes are eligible to run the detection pipeline. Leave at least one node enabled an empty list falls back to all online nodes so the pipeline never becomes completely unassignable.",
26639
+ description: "Which cluster nodes are eligible to run the detection pipeline. Strict whitelist: an empty list disables dispatch everywhere. Fresh installs default to ['hub']. Video detection additionally requires the node to be frame-source capable (Enabled Decoder Nodes) until cross-node frame transport ships.",
25985
26640
  fields: [
25986
26641
  {
25987
26642
  key: "enabledNodes",
@@ -26513,6 +27168,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
26513
27168
  this.enabledDecoderNodes = rawEnabledDecoder === void 0 ? ["hub"] : Array.isArray(rawEnabledDecoder) ? rawEnabledDecoder.filter((v) => typeof v === "string" && !v.includes("/")) : ["hub"];
26514
27169
  const rawEnabledAudio = config["enabledAudioNodes"];
26515
27170
  this.enabledAudioNodes = rawEnabledAudio === void 0 ? ["hub"] : Array.isArray(rawEnabledAudio) ? rawEnabledAudio.filter((v) => typeof v === "string" && !v.includes("/")) : ["hub"];
27171
+ this.schedulePendingRetry();
26516
27172
  }
26517
27173
  get api() {
26518
27174
  return this.ctx.api ?? null;