@camstack/addon-pipeline-orchestrator 1.2.45 → 1.2.47

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.
Files changed (31) hide show
  1. package/assets/viewer/.viewer-version +1 -1
  2. package/assets/viewer/_expo/static/js/web/{Clipboard-ea01d8cace0464fa1c39149da612be36.js → Clipboard-7ce228c3105c6643f60262c6665333bb.js} +8 -8
  3. package/assets/viewer/_expo/static/js/web/{Haptics-35bc7b5bbbc3731f03b5d5fafff2ac0c.js → Haptics-a27d50d15732e0b48774f90db59c02bf.js} +3 -3
  4. package/assets/viewer/_expo/static/js/web/{index-5b79e9d56650c4720921fd27c27ccb33.js → index-a8dfe1019b6f61fc84bf4dba973b9571.js} +27 -26
  5. package/assets/viewer/_expo/static/js/web/{node-fs-7cc97cb7668ecaeabcbcfee447afb982.js → node-fs-48d2a76a1b4358828a29686e594be263.js} +1 -1
  6. package/assets/viewer/_expo/static/js/web/{node-path-b4104903c9511874cbbf037d17e9f78d.js → node-path-f5cd235cc4e30b6508669583b1b2c718.js} +1 -1
  7. package/assets/viewer/index.html +1 -1
  8. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-9CKmTiwO.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DVSFAO6F.mjs} +1 -1
  9. package/dist/{hostInit-CpXFeXA9.mjs → hostInit-D4Kq_1dM.mjs} +1 -1
  10. package/dist/index.js +304 -33
  11. package/dist/index.mjs +304 -33
  12. package/dist/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/assets/viewer/assets/node_modules/expo-router/assets/arrow_down.017bc6ba3fc25503e5eb5e53826d48a8.png +0 -0
  15. package/assets/viewer/assets/node_modules/expo-router/assets/error.d1ea1496f9057eb392d5bbf3732a61b7.png +0 -0
  16. package/assets/viewer/assets/node_modules/expo-router/assets/file.19eeb73b9593a38f8e9f418337fc7d10.png +0 -0
  17. package/assets/viewer/assets/node_modules/expo-router/assets/forward.d8b800c443b8972542883e0b9de2bdc6.png +0 -0
  18. package/assets/viewer/assets/node_modules/expo-router/assets/pkg.ab19f4cbc543357183a20571f68380a3.png +0 -0
  19. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/back-icon-mask.0a328cd9c1afd0afe8e3b1ec5165b1b4.png +0 -0
  20. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/back-icon.35ba0eaec5a4f5ed12ca16fabeae451d.png +0 -0
  21. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55.png +0 -0
  22. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55@2x.png +0 -0
  23. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55@3x.png +0 -0
  24. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55@4x.png +0 -0
  25. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7.png +0 -0
  26. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7@2x.png +0 -0
  27. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7@3x.png +0 -0
  28. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7@4x.png +0 -0
  29. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/search-icon.286d67d3f74808a60a78d3ebf1a5fb57.png +0 -0
  30. package/assets/viewer/assets/node_modules/expo-router/assets/sitemap.412dd9275b6b48ad28f5e3d81bb1f626.png +0 -0
  31. package/assets/viewer/assets/node_modules/expo-router/assets/unmatched.20e71bdf79e3a97bf55fd9e164041578.png +0 -0
package/dist/index.mjs CHANGED
@@ -34960,6 +34960,48 @@ var DeviceFeaturesMirror = class {
34960
34960
  }
34961
34961
  };
34962
34962
  //#endregion
34963
+ //#region src/watchdog-camera.ts
34964
+ /**
34965
+ * Derive the watchdog camera descriptor from a runner config. Captures which
34966
+ * stages are expected to flow CONTINUOUSLY (always-on audio, analyzer motion,
34967
+ * always-on detection) → the camStreamId feeding each. Audio has no dedicated
34968
+ * stream field on the runner config, so it shares the motion stream id.
34969
+ *
34970
+ * `pipelineEnabled === false` (the `object-detection` switch, D62/ADR-0109)
34971
+ * arms NOTHING: `startDetection` deliberately performs no attach and opens no
34972
+ * decode session for such a camera, so no continuous stage can ever produce a
34973
+ * signal. The camera is still returned (and still registered by the caller) so
34974
+ * its watchdog row stays visible — a switched-off camera must read as off, not
34975
+ * as broken.
34976
+ *
34977
+ * `hasAudioSource` is the caller's evidence that the camera's source actually
34978
+ * carries an audio track (`BrokerStats.audio !== null`). It fails OPEN: an
34979
+ * unknown or failed read passes `true`, because the audio stage is diagnostic
34980
+ * and a false negative silently stops watching a working camera.
34981
+ */
34982
+ function buildWatchdogCamera(config, deviceName, hasAudioSource) {
34983
+ const continuousStages = /* @__PURE__ */ new Map();
34984
+ if (config.pipelineEnabled === false) return {
34985
+ deviceId: config.deviceId,
34986
+ deviceName,
34987
+ audioMode: config.audioMode,
34988
+ detectionMode: config.detectionMode,
34989
+ motionSources: config.motionSources,
34990
+ continuousStages
34991
+ };
34992
+ if (config.audioMode === "always-on" && hasAudioSource) continuousStages.set("audio", config.motionStreamId);
34993
+ if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
34994
+ if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
34995
+ return {
34996
+ deviceId: config.deviceId,
34997
+ deviceName,
34998
+ audioMode: config.audioMode,
34999
+ detectionMode: config.detectionMode,
35000
+ motionSources: config.motionSources,
35001
+ continuousStages
35002
+ };
35003
+ }
35004
+ //#endregion
34963
35005
  //#region src/detection-wiring-controller.ts
34964
35006
  var DetectionWiringController = class {
34965
35007
  deps;
@@ -35164,7 +35206,8 @@ var DetectionWiringController = class {
35164
35206
  await this.stopDetection(deviceId);
35165
35207
  }
35166
35208
  this.activeDetections.set(deviceId, config);
35167
- if (config.detectionMode === "on-motion") {
35209
+ if (!config.pipelineEnabled) log.info("object detection switched off — no attach, no decode session", { meta: { detectionMode: config.detectionMode } });
35210
+ else if (config.detectionMode === "on-motion") {
35168
35211
  if (config.motionSources.includes("analyzer")) try {
35169
35212
  await this.deps.session.ensureMotionWatch(deviceId, config);
35170
35213
  } catch (err) {
@@ -35255,11 +35298,44 @@ var DetectionWiringController = class {
35255
35298
  this.deps.forgetDeviceZones(deviceId);
35256
35299
  this.deps.forgetDeviceZoneRules(deviceId);
35257
35300
  this.deps.loadShed.forgetDevice(deviceId);
35258
- if (!this.deps.ctx().settings) return;
35301
+ const settings = this.deps.ctx().settings;
35302
+ if (!settings) return;
35259
35303
  await this.deps.settingsStore.deleteCameraSettings(deviceId);
35304
+ try {
35305
+ await settings.clearDeviceStore(deviceId);
35306
+ } catch (err) {
35307
+ this.deps.logger.warn("failed to clear per-device orchestrator settings on removal", {
35308
+ tags: { deviceId },
35309
+ meta: { error: errMsg(err) }
35310
+ });
35311
+ }
35260
35312
  this.deps.logger.info("Purged pipeline config for removed device", { tags: { deviceId } });
35261
35313
  }
35262
35314
  /**
35315
+ * True ONLY when the device registry positively answered "this device does
35316
+ * not exist" (`getDevice` → `null`, per the cap contract).
35317
+ *
35318
+ * A FAILED read answers `false` — D49: a read that fails changes nothing, and
35319
+ * the destructive direction here is refusing to run a live camera.
35320
+ * Deliberately NOT routed through {@link lookupDeviceFeatures}'s mirror: that
35321
+ * one treats missing-as-unknown on purpose (a provider restart passes through
35322
+ * exactly that state and must not re-derive a battery camera's profile), and
35323
+ * conflating the two would make a provider restart look like a deletion.
35324
+ */
35325
+ async isDeviceDeleted(deviceId) {
35326
+ const api = this.deps.api();
35327
+ if (!api) return false;
35328
+ try {
35329
+ return await api.deviceManager.getDevice.query({ deviceId }) === null;
35330
+ } catch (err) {
35331
+ this.deps.logger.debug("device existence check failed — proceeding as before", {
35332
+ tags: { deviceId },
35333
+ meta: { error: errMsg(err) }
35334
+ });
35335
+ return false;
35336
+ }
35337
+ }
35338
+ /**
35263
35339
  * Handle a profile-slot change: build a fresh detection config from
35264
35340
  * per-device settings and start detection if motion is enabled.
35265
35341
  * Fires on `camera-streams.onProfileSlotsChanged` — any publish,
@@ -35269,6 +35345,11 @@ var DetectionWiringController = class {
35269
35345
  if (!this.deps.ctx()) return;
35270
35346
  const log = this.deps.logger.withTags({ deviceId });
35271
35347
  log.info("handleDeviceRegistered", { meta: { phase: "handleDeviceRegistered" } });
35348
+ if (await this.isDeviceDeleted(deviceId)) {
35349
+ log.warn("device no longer exists — refusing to start detection");
35350
+ await this.handleDeviceUnregistered(deviceId);
35351
+ return;
35352
+ }
35272
35353
  const config = await this.buildDetectionConfig(deviceId);
35273
35354
  if (config === TRANSIENT_SLOT_READ) {
35274
35355
  log.warn("Profile-slot read failed transiently — keeping detection, scheduling retry");
@@ -35408,28 +35489,17 @@ var DetectionWiringController = class {
35408
35489
  });
35409
35490
  }
35410
35491
  /**
35411
- * Derive the watchdog camera descriptor from a runner config. Captures
35412
- * which stages are expected to flow CONTINUOUSLY (always-on audio,
35413
- * analyzer motion, always-on detection) the camStreamId feeding each.
35414
- * Audio has no dedicated stream field on the runner config, so it shares
35415
- * the motion stream id.
35492
+ * Derive the watchdog camera descriptor from a runner config — thin delegate
35493
+ * onto the pure `buildWatchdogCamera` (`watchdog-camera.ts`), where the
35494
+ * arming rules live (a switched-off camera arms nothing; the audio stage
35495
+ * needs evidence of an audio track).
35416
35496
  *
35417
35497
  * Public — `PlacementService`'s `buildWatchdogCamera` closure (wired in
35418
- * `orchestrator-bootstrap.ts`) calls straight through to this method.
35498
+ * `orchestrator-bootstrap.ts`) calls straight through to this method; the
35499
+ * caller resolves `hasAudioSource` once per dispatch.
35419
35500
  */
35420
- buildWatchdogCamera(config, deviceName) {
35421
- const continuousStages = /* @__PURE__ */ new Map();
35422
- if (config.audioMode === "always-on") continuousStages.set("audio", config.motionStreamId);
35423
- if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
35424
- if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
35425
- return {
35426
- deviceId: config.deviceId,
35427
- deviceName,
35428
- audioMode: config.audioMode,
35429
- detectionMode: config.detectionMode,
35430
- motionSources: config.motionSources,
35431
- continuousStages
35432
- };
35501
+ buildWatchdogCamera(config, deviceName, hasAudioSource) {
35502
+ return buildWatchdogCamera(config, deviceName, hasAudioSource);
35433
35503
  }
35434
35504
  /**
35435
35505
  * Watchdog recovery callback. Invoked when a camera's continuous stage
@@ -35442,7 +35512,7 @@ var DetectionWiringController = class {
35442
35512
  * Public — `PipelineWatchdog`'s `recover` closure (wired in
35443
35513
  * `orchestrator-bootstrap.ts`) calls straight through to this method.
35444
35514
  */
35445
- recoverPipelineStage(deviceId, stage, streamId) {
35515
+ recoverPipelineStage(deviceId, stage, streamId, attempt) {
35446
35516
  if (stage === "audio") {
35447
35517
  const cfg = this.deps.ledger.getConfig(deviceId);
35448
35518
  const audioCfg = cfg ? {
@@ -35456,6 +35526,14 @@ var DetectionWiringController = class {
35456
35526
  });
35457
35527
  });
35458
35528
  }
35529
+ this.deps.logger.debug("watchdog recovery: requesting source refresh", {
35530
+ tags: { deviceId },
35531
+ meta: {
35532
+ stage,
35533
+ camStreamId: streamId,
35534
+ attempt
35535
+ }
35536
+ });
35459
35537
  this.deps.eventBus.emit({
35460
35538
  id: `watchdog-refresh-${deviceId}-${Date.now()}`,
35461
35539
  timestamp: /* @__PURE__ */ new Date(),
@@ -35858,6 +35936,64 @@ function computeDispatchGap(desired, known) {
35858
35936
  return [...desired].filter((id) => !known.has(id));
35859
35937
  }
35860
35938
  /**
35939
+ * Split the desired fleet (derived from broker profile slots) against the LIVE
35940
+ * device registry.
35941
+ *
35942
+ * The broker keeps a deleted device's profile-slot assignments forever, so
35943
+ * `desiredDeviceIdsFromSlots` alone re-proposes a camera the operator already
35944
+ * deleted — and the reconcile's gap-fill then resurrects it. Callers dispatch
35945
+ * `existing` only.
35946
+ *
35947
+ * The caller MUST NOT call this with a failed/partial registry read: a read
35948
+ * that fails changes nothing (D49), it never prunes the fleet.
35949
+ */
35950
+ function splitDesiredByExistence(desired, liveDeviceIds) {
35951
+ const existing = /* @__PURE__ */ new Set();
35952
+ const vanished = /* @__PURE__ */ new Set();
35953
+ for (const deviceId of desired) if (liveDeviceIds.has(deviceId)) existing.add(deviceId);
35954
+ else vanished.add(deviceId);
35955
+ return {
35956
+ existing,
35957
+ vanished
35958
+ };
35959
+ }
35960
+ /**
35961
+ * Decide which vanished devices are CONFIRMED gone (D49's two-disagreements
35962
+ * rule). Teardown destroys work — stopping detection, releasing the camera and
35963
+ * purging its persisted config — so a single registry read is never enough: a
35964
+ * device must be missing in TWO consecutive reconcile passes before it is torn
35965
+ * down. Anything that puts the device back (a successful listing that contains
35966
+ * it, or a failed read the caller declines to act on) resets the streak, so the
35967
+ * count only ever advances on agreeing evidence.
35968
+ *
35969
+ * Teardown fires ONCE per vanish streak (`torn-down`) — the broker's slots
35970
+ * outlive the device, so it stays vanished on every later pass and would
35971
+ * otherwise be re-purged forever.
35972
+ *
35973
+ * Pure — the caller owns the (in-memory, boot-empty) state map.
35974
+ */
35975
+ function reconcileVanishedDevices(vanished, prior) {
35976
+ const tearDown = [];
35977
+ const nextState = /* @__PURE__ */ new Map();
35978
+ for (const deviceId of vanished) {
35979
+ const state = prior.get(deviceId);
35980
+ if (state === void 0) {
35981
+ nextState.set(deviceId, "first-pass");
35982
+ continue;
35983
+ }
35984
+ if (state === "first-pass") {
35985
+ tearDown.push(deviceId);
35986
+ nextState.set(deviceId, "torn-down");
35987
+ continue;
35988
+ }
35989
+ nextState.set(deviceId, "torn-down");
35990
+ }
35991
+ return {
35992
+ tearDown,
35993
+ nextState
35994
+ };
35995
+ }
35996
+ /**
35861
35997
  * Converge the orchestrator's `assignments` map to runner truth. Runner
35862
35998
  * attachments (`getLocalCameras`) are AUTHORITATIVE; this returns the actions
35863
35999
  * that make the map agree with them:
@@ -36105,7 +36241,10 @@ var PlacementService = class {
36105
36241
  try {
36106
36242
  await this.attachOn(targetNodeId, attachConfig);
36107
36243
  this.deps.ledger.setConfig(runnerConfig.deviceId, attachConfig);
36108
- if (targetNodeId === this.deps.localNodeId()) this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId)));
36244
+ if (targetNodeId === this.deps.localNodeId()) {
36245
+ const hasAudioSource = await this.resolveHasAudioSource(runnerConfig);
36246
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId), hasAudioSource));
36247
+ }
36109
36248
  this.deps.ledger.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, reason === "manual");
36110
36249
  } finally {
36111
36250
  dispatchGuard.dispose();
@@ -36531,7 +36670,20 @@ var PlacementService = class {
36531
36670
  return;
36532
36671
  }
36533
36672
  await this.attachOn(nodeId, cached);
36534
- if (nodeId === this.deps.localNodeId()) this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId)));
36673
+ if (nodeId === this.deps.localNodeId()) {
36674
+ const hasAudioSource = await this.resolveHasAudioSource(cached);
36675
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId), hasAudioSource));
36676
+ }
36677
+ }
36678
+ /**
36679
+ * Resolve the watchdog's audio-track evidence for ONE dispatch. Skipped
36680
+ * entirely (and answered `false`) when `audioMode` could not arm the stage
36681
+ * anyway — `buildWatchdogCamera` needs both — so a camera with on-motion
36682
+ * audio costs no broker round-trip.
36683
+ */
36684
+ async resolveHasAudioSource(config) {
36685
+ if (config.audioMode !== "always-on") return false;
36686
+ return this.deps.hasAudioSource(config.deviceId, config.motionStreamId);
36535
36687
  }
36536
36688
  /**
36537
36689
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
@@ -37903,7 +38055,9 @@ var PipelineWatchdog = class {
37903
38055
  for (const [stage] of cam.continuousStages) stages.set(stage, {
37904
38056
  lastSeenMs: now,
37905
38057
  attempts: 0,
37906
- lastAttemptMs: 0
38058
+ lastAttemptMs: 0,
38059
+ everSeen: false,
38060
+ registeredAtMs: now
37907
38061
  });
37908
38062
  this.state.set(cam.deviceId, stages);
37909
38063
  }
@@ -37917,20 +38071,34 @@ var PipelineWatchdog = class {
37917
38071
  if (!rt) return;
37918
38072
  rt.lastSeenMs = this.deps.now();
37919
38073
  rt.attempts = 0;
38074
+ rt.everSeen = true;
37920
38075
  }
37921
38076
  tick() {
37922
38077
  const now = this.deps.now();
37923
38078
  for (const cam of this.cameras.values()) {
37924
- const { line, stalled, recoveries } = this.evaluate(cam, now);
38079
+ const { line, stalled, recoveries, disarms } = this.evaluate(cam, now);
37925
38080
  if (stalled) this.deps.logger.warn(line, { tags: { deviceId: cam.deviceId } });
37926
38081
  else this.deps.logger.info(line, { tags: { deviceId: cam.deviceId } });
38082
+ for (const d of disarms) {
38083
+ this.deps.logger.warn("watchdog stage never produced a signal — disarming", {
38084
+ tags: { deviceId: cam.deviceId },
38085
+ meta: {
38086
+ stage: d.stage,
38087
+ streamId: d.streamId,
38088
+ sinceRegisteredMs: d.sinceRegisteredMs
38089
+ }
38090
+ });
38091
+ this.state.get(cam.deviceId)?.delete(d.stage);
38092
+ }
37927
38093
  for (const r of recoveries) {
37928
38094
  const rt = this.state.get(cam.deviceId)?.get(r.stage);
38095
+ let attempt = this.deps.thresholds.maxRecoveryAttempts;
37929
38096
  if (rt) {
37930
38097
  if (rt.attempts < this.deps.thresholds.maxRecoveryAttempts) rt.attempts += 1;
37931
38098
  rt.lastAttemptMs = now;
38099
+ attempt = rt.attempts;
37932
38100
  }
37933
- this.deps.recover(cam.deviceId, r.stage, r.streamId);
38101
+ this.deps.recover(cam.deviceId, r.stage, r.streamId, attempt);
37934
38102
  }
37935
38103
  }
37936
38104
  }
@@ -37945,6 +38113,7 @@ var PipelineWatchdog = class {
37945
38113
  evaluate(cam, now) {
37946
38114
  const parts = [];
37947
38115
  const recoveries = [];
38116
+ const disarms = [];
37948
38117
  let stalled = false;
37949
38118
  const stageOrder = [
37950
38119
  "audio",
@@ -37971,7 +38140,14 @@ var PipelineWatchdog = class {
37971
38140
  const thresholdMs = this.deps.thresholds[STAGE_THRESHOLD_KEY[stage]];
37972
38141
  const sSec = Math.round(staleness / 1e3);
37973
38142
  if (staleness <= thresholdMs) parts.push(`${stage}=${stageMode[stage]}@${streamId}(OK ${sSec}s)`);
37974
- else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts) {
38143
+ else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts && !rt.everSeen) {
38144
+ disarms.push({
38145
+ stage,
38146
+ streamId,
38147
+ sinceRegisteredMs: now - rt.registeredAtMs
38148
+ });
38149
+ parts.push(`${stage}=${stageMode[stage]}@${streamId}(NEVER SIGNALLED ${sSec}s · disarmed)`);
38150
+ } else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts) {
37975
38151
  stalled = true;
37976
38152
  if (now - rt.lastAttemptMs >= this.deps.thresholds.exhaustedRetryMs) {
37977
38153
  recoveries.push({
@@ -37992,7 +38168,8 @@ var PipelineWatchdog = class {
37992
38168
  return {
37993
38169
  line: `[watchdog] cam${cam.deviceId} ${cam.deviceName} · ${parts.join(" · ")} → ${stalled ? "STALLED" : "OK"}`,
37994
38170
  stalled,
37995
- recoveries
38171
+ recoveries,
38172
+ disarms
37996
38173
  };
37997
38174
  }
37998
38175
  };
@@ -38889,6 +39066,14 @@ var ReconcileController = class ReconcileController {
38889
39066
  /** Shared in-flight pass so a session dispatch waits for runner truth instead of racing it. */
38890
39067
  attachmentReconcilePromise = null;
38891
39068
  /**
39069
+ * Desired-but-nonexistent devices and how far their vanish streak has got
39070
+ * (`dispatch-reconcile.ts`). PURELY in-memory and seeded EMPTY on boot: it is
39071
+ * a confirmation counter, not a record — after a restart the first pass only
39072
+ * ever re-observes, never destroys. Cleared whenever the registry read fails,
39073
+ * so a failed read can never advance a device toward teardown.
39074
+ */
39075
+ vanishedDevices = /* @__PURE__ */ new Map();
39076
+ /**
38892
39077
  * Per-device backoff for retrying `handleDeviceRegistered` after a
38893
39078
  * TRANSIENT profile-slot read failure. Timers are `unref`'d and cleared on
38894
39079
  * shutdown / device removal.
@@ -38957,13 +39142,24 @@ var ReconcileController = class ReconcileController {
38957
39142
  return;
38958
39143
  }
38959
39144
  const desired = desiredDeviceIdsFromSlots(slots);
39145
+ const liveDeviceIds = await this.readLiveDeviceIds();
39146
+ const { existing, vanished } = liveDeviceIds === null ? {
39147
+ existing: desired,
39148
+ vanished: /* @__PURE__ */ new Set()
39149
+ } : splitDesiredByExistence(desired, liveDeviceIds);
39150
+ if (liveDeviceIds === null) {
39151
+ this.deps.logger.warn("dispatch reconcile: device existence filter SKIPPED — registry read failed", { meta: { desired: desired.size } });
39152
+ this.vanishedDevices = /* @__PURE__ */ new Map();
39153
+ }
38960
39154
  const known = new Set(this.deps.ledger.listConfigIds());
38961
- const gap = computeDispatchGap(desired, known);
39155
+ const gap = computeDispatchGap(existing, known);
38962
39156
  this.deps.logger.info("dispatch reconcile", { meta: {
38963
39157
  desired: desired.size,
38964
39158
  known: known.size,
38965
- gap
39159
+ gap,
39160
+ vanished: vanished.size
38966
39161
  } });
39162
+ await this.tearDownVanishedDevices(vanished);
38967
39163
  for (const deviceId of gap) try {
38968
39164
  await this.deps.handleDeviceRegistered(deviceId);
38969
39165
  } catch (err) {
@@ -38982,6 +39178,53 @@ var ReconcileController = class ReconcileController {
38982
39178
  }
38983
39179
  }
38984
39180
  /**
39181
+ * The live device registry as an id set, or `null` when the read FAILED.
39182
+ * `null` is the caller's signal to skip the existence filter entirely — it
39183
+ * must never be confused with "the registry is empty", which would prune the
39184
+ * whole fleet.
39185
+ *
39186
+ * Asks for the slim projection: this needs identity only, never
39187
+ * `config`/`sourceInfo` (a full listing of 293 devices measured 271 KB).
39188
+ */
39189
+ async readLiveDeviceIds() {
39190
+ const api = this.deps.api();
39191
+ if (!api) return null;
39192
+ try {
39193
+ const devices = await api.deviceManager.listAll.query({ projection: "slim" });
39194
+ return new Set(devices.map((device) => device.id));
39195
+ } catch (err) {
39196
+ this.deps.logger.debug("dispatch reconcile: deviceManager.listAll failed", { meta: { error: errMsg(err) } });
39197
+ return null;
39198
+ }
39199
+ }
39200
+ /**
39201
+ * Tear down the desired devices the registry no longer has — but only once
39202
+ * TWO consecutive passes agree they are gone (`reconcileVanishedDevices`,
39203
+ * D49). Teardown destroys work, so a single listing that omits a device
39204
+ * (which a partial or racy read looks exactly like) is not enough.
39205
+ *
39206
+ * Runs the SAME `handleDeviceUnregistered` the `DeviceUnregistered` event
39207
+ * runs, so there is exactly one teardown path.
39208
+ */
39209
+ async tearDownVanishedDevices(vanished) {
39210
+ const { tearDown, nextState } = reconcileVanishedDevices(vanished, this.vanishedDevices);
39211
+ this.vanishedDevices = nextState;
39212
+ for (const deviceId of tearDown) {
39213
+ this.deps.logger.warn("dispatch reconcile: device no longer exists — tearing down instead of dispatching", {
39214
+ tags: { deviceId },
39215
+ meta: { confirmedPasses: 2 }
39216
+ });
39217
+ try {
39218
+ await this.deps.handleDeviceUnregistered(deviceId);
39219
+ } catch (err) {
39220
+ this.deps.logger.warn("dispatch reconcile: teardown of vanished device failed", {
39221
+ tags: { deviceId },
39222
+ meta: { error: errMsg(err) }
39223
+ });
39224
+ }
39225
+ }
39226
+ }
39227
+ /**
38985
39228
  * Converge the in-memory `assignments` map to runner truth (Phase 1 of the
38986
39229
  * placement-derived-state design). Runner attachments (`getLocalCameras`) are
38987
39230
  * authoritative: detach duplicates/orphans, and drop stale assignments
@@ -39292,6 +39535,7 @@ var ReconcileController = class ReconcileController {
39292
39535
  this.slotReadRetryTimers.clear();
39293
39536
  this.slotReadRetryAttempts.clear();
39294
39537
  this.remoteHealthAttempts.clear();
39538
+ this.vanishedDevices = /* @__PURE__ */ new Map();
39295
39539
  }
39296
39540
  };
39297
39541
  //#endregion
@@ -40357,6 +40601,31 @@ async function buildOrchestratorControllers(deps) {
40357
40601
  getInitTimestamp: () => deps.initTimestamp
40358
40602
  });
40359
40603
  const inferenceRotation = new RoundRobinInferenceDeviceRotation();
40604
+ /**
40605
+ * Does this camera's source carry an audio track? Read from the broker that
40606
+ * owns the stream (`BrokerStats.audio === null` is the documented no-audio
40607
+ * signal), pinned to the cluster's source owner exactly like the
40608
+ * camera-status broker stage. FAILS OPEN: no api, an RPC error, or a broker
40609
+ * that has not detected the track yet (`undefined`, cold start) all answer
40610
+ * `true`, because the only consumer is the watchdog's diagnostic audio stage
40611
+ * and a false negative silently stops watching a working camera.
40612
+ */
40613
+ const resolveHasAudioSource = async (deviceId, camStreamId) => {
40614
+ const api = deps.ctx().api;
40615
+ if (!api) return true;
40616
+ try {
40617
+ return (await api.streamBroker.getBrokerStats.query({ brokerId: makeSourceBrokerId(deviceId, camStreamId) }, nodePin(topology.assignSource(deviceId)))).audio !== null;
40618
+ } catch (err) {
40619
+ deps.ctx().logger.debug("audio-source probe failed — assuming the source has audio", {
40620
+ tags: { deviceId },
40621
+ meta: {
40622
+ camStreamId,
40623
+ error: errMsg(err)
40624
+ }
40625
+ });
40626
+ return true;
40627
+ }
40628
+ };
40360
40629
  const placement = new PlacementService({
40361
40630
  ctx: () => deps.ctx(),
40362
40631
  ctxIfReady: () => deps.ctxIfReady(),
@@ -40372,7 +40641,8 @@ async function buildOrchestratorControllers(deps) {
40372
40641
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
40373
40642
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
40374
40643
  inferenceRotation,
40375
- buildWatchdogCamera: (config, deviceName) => detectionWiring.buildWatchdogCamera(config, deviceName),
40644
+ buildWatchdogCamera: (config, deviceName, hasAudioSource) => detectionWiring.buildWatchdogCamera(config, deviceName, hasAudioSource),
40645
+ hasAudioSource: (deviceId, camStreamId) => resolveHasAudioSource(deviceId, camStreamId),
40376
40646
  isIdleSessionCamera: (deviceId) => deps.isIdleSessionCamera(deviceId),
40377
40647
  activeSessionNode: (deviceId) => session.activeSessionNode(deviceId),
40378
40648
  closeActiveSession: (deviceId) => session.closeSessionForReassign(deviceId),
@@ -40447,6 +40717,7 @@ async function buildOrchestratorControllers(deps) {
40447
40717
  loadShed,
40448
40718
  logger: deps.ctx().logger,
40449
40719
  handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
40720
+ handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
40450
40721
  dispatchCamera: (config) => deps.dispatchCamera(config),
40451
40722
  detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
40452
40723
  localNodeId: () => localNodeId,
@@ -40547,7 +40818,7 @@ async function buildOrchestratorControllers(deps) {
40547
40818
  const pipelineWatchdog = new PipelineWatchdog({
40548
40819
  now: () => Date.now(),
40549
40820
  logger: deps.ctx().logger,
40550
- recover: (deviceId, stage, streamId) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId),
40821
+ recover: (deviceId, stage, streamId, attempt) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId, attempt),
40551
40822
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
40552
40823
  });
40553
40824
  pipelineWatchdog.start(deps.watchdogIntervalMs);
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-9CKmTiwO.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DVSFAO6F.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-pipeline-orchestrator",
3
- "version": "1.2.45",
3
+ "version": "1.2.47",
4
4
  "description": "Hub-side camera-to-agent load balancer — tracks runner capacity and dispatches attachCamera calls to the optimal pipeline-runner instance",
5
5
  "keywords": [
6
6
  "camstack",