@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
@@ -1 +1 @@
1
- __d(function(g,r,i,a,m,e,d){function n(){throw new Error("node:fs is unavailable in the Viewer runtime")}m.exports={chmodSync:n,createWriteStream:n,existsSync:n,mkdirSync:n,promises:new Proxy({},{get:()=>n}),readFileSync:n,readdirSync:n,renameSync:n,rmSync:n,unlinkSync:n,writeFileSync:n}},2163,[]);
1
+ __d(function(g,r,i,a,m,e,d){function n(){throw new Error("node:fs is unavailable in the Viewer runtime")}m.exports={chmodSync:n,createWriteStream:n,existsSync:n,mkdirSync:n,promises:new Proxy({},{get:()=>n}),readFileSync:n,readdirSync:n,renameSync:n,rmSync:n,unlinkSync:n,writeFileSync:n}},2164,[]);
@@ -1 +1 @@
1
- __d(function(g,r,i,a,m,e,d){function n(){throw new Error("node:path is unavailable in the Viewer runtime")}m.exports={basename:n,join:n}},2162,[]);
1
+ __d(function(g,r,i,a,m,e,d){function n(){throw new Error("node:path is unavailable in the Viewer runtime")}m.exports={basename:n,join:n}},2163,[]);
@@ -36,6 +36,6 @@
36
36
  </noscript>
37
37
  <!-- The root element for your Expo app. -->
38
38
  <div id="root"></div>
39
- <script src="/viewer/camstack/_expo/static/js/web/index-5b79e9d56650c4720921fd27c27ccb33.js" defer></script>
39
+ <script src="/viewer/camstack/_expo/static/js/web/index-a8dfe1019b6f61fc84bf4dba973b9571.js" defer></script>
40
40
  </body>
41
41
  </html>
@@ -18,7 +18,7 @@ var e = {
18
18
  },
19
19
  "@camstack/types": {
20
20
  name: "@camstack/types",
21
- version: "1.2.59",
21
+ version: "1.2.60",
22
22
  scope: ["default"],
23
23
  loaded: !1,
24
24
  from: "addon_pipeline_orchestrator_widgets",
@@ -36,7 +36,7 @@ async function r() {
36
36
  }
37
37
  },
38
38
  "@camstack/types": {
39
- version: "1.2.59",
39
+ version: "1.2.60",
40
40
  scope: "default",
41
41
  shareConfig: {
42
42
  singleton: !0,
package/dist/index.js CHANGED
@@ -34988,6 +34988,48 @@ var DeviceFeaturesMirror = class {
34988
34988
  }
34989
34989
  };
34990
34990
  //#endregion
34991
+ //#region src/watchdog-camera.ts
34992
+ /**
34993
+ * Derive the watchdog camera descriptor from a runner config. Captures which
34994
+ * stages are expected to flow CONTINUOUSLY (always-on audio, analyzer motion,
34995
+ * always-on detection) → the camStreamId feeding each. Audio has no dedicated
34996
+ * stream field on the runner config, so it shares the motion stream id.
34997
+ *
34998
+ * `pipelineEnabled === false` (the `object-detection` switch, D62/ADR-0109)
34999
+ * arms NOTHING: `startDetection` deliberately performs no attach and opens no
35000
+ * decode session for such a camera, so no continuous stage can ever produce a
35001
+ * signal. The camera is still returned (and still registered by the caller) so
35002
+ * its watchdog row stays visible — a switched-off camera must read as off, not
35003
+ * as broken.
35004
+ *
35005
+ * `hasAudioSource` is the caller's evidence that the camera's source actually
35006
+ * carries an audio track (`BrokerStats.audio !== null`). It fails OPEN: an
35007
+ * unknown or failed read passes `true`, because the audio stage is diagnostic
35008
+ * and a false negative silently stops watching a working camera.
35009
+ */
35010
+ function buildWatchdogCamera(config, deviceName, hasAudioSource) {
35011
+ const continuousStages = /* @__PURE__ */ new Map();
35012
+ if (config.pipelineEnabled === false) return {
35013
+ deviceId: config.deviceId,
35014
+ deviceName,
35015
+ audioMode: config.audioMode,
35016
+ detectionMode: config.detectionMode,
35017
+ motionSources: config.motionSources,
35018
+ continuousStages
35019
+ };
35020
+ if (config.audioMode === "always-on" && hasAudioSource) continuousStages.set("audio", config.motionStreamId);
35021
+ if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
35022
+ if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
35023
+ return {
35024
+ deviceId: config.deviceId,
35025
+ deviceName,
35026
+ audioMode: config.audioMode,
35027
+ detectionMode: config.detectionMode,
35028
+ motionSources: config.motionSources,
35029
+ continuousStages
35030
+ };
35031
+ }
35032
+ //#endregion
34991
35033
  //#region src/detection-wiring-controller.ts
34992
35034
  var DetectionWiringController = class {
34993
35035
  deps;
@@ -35192,7 +35234,8 @@ var DetectionWiringController = class {
35192
35234
  await this.stopDetection(deviceId);
35193
35235
  }
35194
35236
  this.activeDetections.set(deviceId, config);
35195
- if (config.detectionMode === "on-motion") {
35237
+ if (!config.pipelineEnabled) log.info("object detection switched off — no attach, no decode session", { meta: { detectionMode: config.detectionMode } });
35238
+ else if (config.detectionMode === "on-motion") {
35196
35239
  if (config.motionSources.includes("analyzer")) try {
35197
35240
  await this.deps.session.ensureMotionWatch(deviceId, config);
35198
35241
  } catch (err) {
@@ -35283,11 +35326,44 @@ var DetectionWiringController = class {
35283
35326
  this.deps.forgetDeviceZones(deviceId);
35284
35327
  this.deps.forgetDeviceZoneRules(deviceId);
35285
35328
  this.deps.loadShed.forgetDevice(deviceId);
35286
- if (!this.deps.ctx().settings) return;
35329
+ const settings = this.deps.ctx().settings;
35330
+ if (!settings) return;
35287
35331
  await this.deps.settingsStore.deleteCameraSettings(deviceId);
35332
+ try {
35333
+ await settings.clearDeviceStore(deviceId);
35334
+ } catch (err) {
35335
+ this.deps.logger.warn("failed to clear per-device orchestrator settings on removal", {
35336
+ tags: { deviceId },
35337
+ meta: { error: errMsg(err) }
35338
+ });
35339
+ }
35288
35340
  this.deps.logger.info("Purged pipeline config for removed device", { tags: { deviceId } });
35289
35341
  }
35290
35342
  /**
35343
+ * True ONLY when the device registry positively answered "this device does
35344
+ * not exist" (`getDevice` → `null`, per the cap contract).
35345
+ *
35346
+ * A FAILED read answers `false` — D49: a read that fails changes nothing, and
35347
+ * the destructive direction here is refusing to run a live camera.
35348
+ * Deliberately NOT routed through {@link lookupDeviceFeatures}'s mirror: that
35349
+ * one treats missing-as-unknown on purpose (a provider restart passes through
35350
+ * exactly that state and must not re-derive a battery camera's profile), and
35351
+ * conflating the two would make a provider restart look like a deletion.
35352
+ */
35353
+ async isDeviceDeleted(deviceId) {
35354
+ const api = this.deps.api();
35355
+ if (!api) return false;
35356
+ try {
35357
+ return await api.deviceManager.getDevice.query({ deviceId }) === null;
35358
+ } catch (err) {
35359
+ this.deps.logger.debug("device existence check failed — proceeding as before", {
35360
+ tags: { deviceId },
35361
+ meta: { error: errMsg(err) }
35362
+ });
35363
+ return false;
35364
+ }
35365
+ }
35366
+ /**
35291
35367
  * Handle a profile-slot change: build a fresh detection config from
35292
35368
  * per-device settings and start detection if motion is enabled.
35293
35369
  * Fires on `camera-streams.onProfileSlotsChanged` — any publish,
@@ -35297,6 +35373,11 @@ var DetectionWiringController = class {
35297
35373
  if (!this.deps.ctx()) return;
35298
35374
  const log = this.deps.logger.withTags({ deviceId });
35299
35375
  log.info("handleDeviceRegistered", { meta: { phase: "handleDeviceRegistered" } });
35376
+ if (await this.isDeviceDeleted(deviceId)) {
35377
+ log.warn("device no longer exists — refusing to start detection");
35378
+ await this.handleDeviceUnregistered(deviceId);
35379
+ return;
35380
+ }
35300
35381
  const config = await this.buildDetectionConfig(deviceId);
35301
35382
  if (config === TRANSIENT_SLOT_READ) {
35302
35383
  log.warn("Profile-slot read failed transiently — keeping detection, scheduling retry");
@@ -35436,28 +35517,17 @@ var DetectionWiringController = class {
35436
35517
  });
35437
35518
  }
35438
35519
  /**
35439
- * Derive the watchdog camera descriptor from a runner config. Captures
35440
- * which stages are expected to flow CONTINUOUSLY (always-on audio,
35441
- * analyzer motion, always-on detection) the camStreamId feeding each.
35442
- * Audio has no dedicated stream field on the runner config, so it shares
35443
- * the motion stream id.
35520
+ * Derive the watchdog camera descriptor from a runner config — thin delegate
35521
+ * onto the pure `buildWatchdogCamera` (`watchdog-camera.ts`), where the
35522
+ * arming rules live (a switched-off camera arms nothing; the audio stage
35523
+ * needs evidence of an audio track).
35444
35524
  *
35445
35525
  * Public — `PlacementService`'s `buildWatchdogCamera` closure (wired in
35446
- * `orchestrator-bootstrap.ts`) calls straight through to this method.
35526
+ * `orchestrator-bootstrap.ts`) calls straight through to this method; the
35527
+ * caller resolves `hasAudioSource` once per dispatch.
35447
35528
  */
35448
- buildWatchdogCamera(config, deviceName) {
35449
- const continuousStages = /* @__PURE__ */ new Map();
35450
- if (config.audioMode === "always-on") continuousStages.set("audio", config.motionStreamId);
35451
- if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
35452
- if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
35453
- return {
35454
- deviceId: config.deviceId,
35455
- deviceName,
35456
- audioMode: config.audioMode,
35457
- detectionMode: config.detectionMode,
35458
- motionSources: config.motionSources,
35459
- continuousStages
35460
- };
35529
+ buildWatchdogCamera(config, deviceName, hasAudioSource) {
35530
+ return buildWatchdogCamera(config, deviceName, hasAudioSource);
35461
35531
  }
35462
35532
  /**
35463
35533
  * Watchdog recovery callback. Invoked when a camera's continuous stage
@@ -35470,7 +35540,7 @@ var DetectionWiringController = class {
35470
35540
  * Public — `PipelineWatchdog`'s `recover` closure (wired in
35471
35541
  * `orchestrator-bootstrap.ts`) calls straight through to this method.
35472
35542
  */
35473
- recoverPipelineStage(deviceId, stage, streamId) {
35543
+ recoverPipelineStage(deviceId, stage, streamId, attempt) {
35474
35544
  if (stage === "audio") {
35475
35545
  const cfg = this.deps.ledger.getConfig(deviceId);
35476
35546
  const audioCfg = cfg ? {
@@ -35484,6 +35554,14 @@ var DetectionWiringController = class {
35484
35554
  });
35485
35555
  });
35486
35556
  }
35557
+ this.deps.logger.debug("watchdog recovery: requesting source refresh", {
35558
+ tags: { deviceId },
35559
+ meta: {
35560
+ stage,
35561
+ camStreamId: streamId,
35562
+ attempt
35563
+ }
35564
+ });
35487
35565
  this.deps.eventBus.emit({
35488
35566
  id: `watchdog-refresh-${deviceId}-${Date.now()}`,
35489
35567
  timestamp: /* @__PURE__ */ new Date(),
@@ -35886,6 +35964,64 @@ function computeDispatchGap(desired, known) {
35886
35964
  return [...desired].filter((id) => !known.has(id));
35887
35965
  }
35888
35966
  /**
35967
+ * Split the desired fleet (derived from broker profile slots) against the LIVE
35968
+ * device registry.
35969
+ *
35970
+ * The broker keeps a deleted device's profile-slot assignments forever, so
35971
+ * `desiredDeviceIdsFromSlots` alone re-proposes a camera the operator already
35972
+ * deleted — and the reconcile's gap-fill then resurrects it. Callers dispatch
35973
+ * `existing` only.
35974
+ *
35975
+ * The caller MUST NOT call this with a failed/partial registry read: a read
35976
+ * that fails changes nothing (D49), it never prunes the fleet.
35977
+ */
35978
+ function splitDesiredByExistence(desired, liveDeviceIds) {
35979
+ const existing = /* @__PURE__ */ new Set();
35980
+ const vanished = /* @__PURE__ */ new Set();
35981
+ for (const deviceId of desired) if (liveDeviceIds.has(deviceId)) existing.add(deviceId);
35982
+ else vanished.add(deviceId);
35983
+ return {
35984
+ existing,
35985
+ vanished
35986
+ };
35987
+ }
35988
+ /**
35989
+ * Decide which vanished devices are CONFIRMED gone (D49's two-disagreements
35990
+ * rule). Teardown destroys work — stopping detection, releasing the camera and
35991
+ * purging its persisted config — so a single registry read is never enough: a
35992
+ * device must be missing in TWO consecutive reconcile passes before it is torn
35993
+ * down. Anything that puts the device back (a successful listing that contains
35994
+ * it, or a failed read the caller declines to act on) resets the streak, so the
35995
+ * count only ever advances on agreeing evidence.
35996
+ *
35997
+ * Teardown fires ONCE per vanish streak (`torn-down`) — the broker's slots
35998
+ * outlive the device, so it stays vanished on every later pass and would
35999
+ * otherwise be re-purged forever.
36000
+ *
36001
+ * Pure — the caller owns the (in-memory, boot-empty) state map.
36002
+ */
36003
+ function reconcileVanishedDevices(vanished, prior) {
36004
+ const tearDown = [];
36005
+ const nextState = /* @__PURE__ */ new Map();
36006
+ for (const deviceId of vanished) {
36007
+ const state = prior.get(deviceId);
36008
+ if (state === void 0) {
36009
+ nextState.set(deviceId, "first-pass");
36010
+ continue;
36011
+ }
36012
+ if (state === "first-pass") {
36013
+ tearDown.push(deviceId);
36014
+ nextState.set(deviceId, "torn-down");
36015
+ continue;
36016
+ }
36017
+ nextState.set(deviceId, "torn-down");
36018
+ }
36019
+ return {
36020
+ tearDown,
36021
+ nextState
36022
+ };
36023
+ }
36024
+ /**
35889
36025
  * Converge the orchestrator's `assignments` map to runner truth. Runner
35890
36026
  * attachments (`getLocalCameras`) are AUTHORITATIVE; this returns the actions
35891
36027
  * that make the map agree with them:
@@ -36133,7 +36269,10 @@ var PlacementService = class {
36133
36269
  try {
36134
36270
  await this.attachOn(targetNodeId, attachConfig);
36135
36271
  this.deps.ledger.setConfig(runnerConfig.deviceId, attachConfig);
36136
- if (targetNodeId === this.deps.localNodeId()) this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId)));
36272
+ if (targetNodeId === this.deps.localNodeId()) {
36273
+ const hasAudioSource = await this.resolveHasAudioSource(runnerConfig);
36274
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(runnerConfig, String(runnerConfig.deviceId), hasAudioSource));
36275
+ }
36137
36276
  this.deps.ledger.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, reason === "manual");
36138
36277
  } finally {
36139
36278
  dispatchGuard.dispose();
@@ -36559,7 +36698,20 @@ var PlacementService = class {
36559
36698
  return;
36560
36699
  }
36561
36700
  await this.attachOn(nodeId, cached);
36562
- if (nodeId === this.deps.localNodeId()) this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId)));
36701
+ if (nodeId === this.deps.localNodeId()) {
36702
+ const hasAudioSource = await this.resolveHasAudioSource(cached);
36703
+ this.deps.watchdog()?.register(this.deps.buildWatchdogCamera(cached, String(cached.deviceId), hasAudioSource));
36704
+ }
36705
+ }
36706
+ /**
36707
+ * Resolve the watchdog's audio-track evidence for ONE dispatch. Skipped
36708
+ * entirely (and answered `false`) when `audioMode` could not arm the stage
36709
+ * anyway — `buildWatchdogCamera` needs both — so a camera with on-motion
36710
+ * audio costs no broker round-trip.
36711
+ */
36712
+ async resolveHasAudioSource(config) {
36713
+ if (config.audioMode !== "always-on") return false;
36714
+ return this.deps.hasAudioSource(config.deviceId, config.motionStreamId);
36563
36715
  }
36564
36716
  /**
36565
36717
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
@@ -37931,7 +38083,9 @@ var PipelineWatchdog = class {
37931
38083
  for (const [stage] of cam.continuousStages) stages.set(stage, {
37932
38084
  lastSeenMs: now,
37933
38085
  attempts: 0,
37934
- lastAttemptMs: 0
38086
+ lastAttemptMs: 0,
38087
+ everSeen: false,
38088
+ registeredAtMs: now
37935
38089
  });
37936
38090
  this.state.set(cam.deviceId, stages);
37937
38091
  }
@@ -37945,20 +38099,34 @@ var PipelineWatchdog = class {
37945
38099
  if (!rt) return;
37946
38100
  rt.lastSeenMs = this.deps.now();
37947
38101
  rt.attempts = 0;
38102
+ rt.everSeen = true;
37948
38103
  }
37949
38104
  tick() {
37950
38105
  const now = this.deps.now();
37951
38106
  for (const cam of this.cameras.values()) {
37952
- const { line, stalled, recoveries } = this.evaluate(cam, now);
38107
+ const { line, stalled, recoveries, disarms } = this.evaluate(cam, now);
37953
38108
  if (stalled) this.deps.logger.warn(line, { tags: { deviceId: cam.deviceId } });
37954
38109
  else this.deps.logger.info(line, { tags: { deviceId: cam.deviceId } });
38110
+ for (const d of disarms) {
38111
+ this.deps.logger.warn("watchdog stage never produced a signal — disarming", {
38112
+ tags: { deviceId: cam.deviceId },
38113
+ meta: {
38114
+ stage: d.stage,
38115
+ streamId: d.streamId,
38116
+ sinceRegisteredMs: d.sinceRegisteredMs
38117
+ }
38118
+ });
38119
+ this.state.get(cam.deviceId)?.delete(d.stage);
38120
+ }
37955
38121
  for (const r of recoveries) {
37956
38122
  const rt = this.state.get(cam.deviceId)?.get(r.stage);
38123
+ let attempt = this.deps.thresholds.maxRecoveryAttempts;
37957
38124
  if (rt) {
37958
38125
  if (rt.attempts < this.deps.thresholds.maxRecoveryAttempts) rt.attempts += 1;
37959
38126
  rt.lastAttemptMs = now;
38127
+ attempt = rt.attempts;
37960
38128
  }
37961
- this.deps.recover(cam.deviceId, r.stage, r.streamId);
38129
+ this.deps.recover(cam.deviceId, r.stage, r.streamId, attempt);
37962
38130
  }
37963
38131
  }
37964
38132
  }
@@ -37973,6 +38141,7 @@ var PipelineWatchdog = class {
37973
38141
  evaluate(cam, now) {
37974
38142
  const parts = [];
37975
38143
  const recoveries = [];
38144
+ const disarms = [];
37976
38145
  let stalled = false;
37977
38146
  const stageOrder = [
37978
38147
  "audio",
@@ -37999,7 +38168,14 @@ var PipelineWatchdog = class {
37999
38168
  const thresholdMs = this.deps.thresholds[STAGE_THRESHOLD_KEY[stage]];
38000
38169
  const sSec = Math.round(staleness / 1e3);
38001
38170
  if (staleness <= thresholdMs) parts.push(`${stage}=${stageMode[stage]}@${streamId}(OK ${sSec}s)`);
38002
- else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts) {
38171
+ else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts && !rt.everSeen) {
38172
+ disarms.push({
38173
+ stage,
38174
+ streamId,
38175
+ sinceRegisteredMs: now - rt.registeredAtMs
38176
+ });
38177
+ parts.push(`${stage}=${stageMode[stage]}@${streamId}(NEVER SIGNALLED ${sSec}s · disarmed)`);
38178
+ } else if (rt.attempts >= this.deps.thresholds.maxRecoveryAttempts) {
38003
38179
  stalled = true;
38004
38180
  if (now - rt.lastAttemptMs >= this.deps.thresholds.exhaustedRetryMs) {
38005
38181
  recoveries.push({
@@ -38020,7 +38196,8 @@ var PipelineWatchdog = class {
38020
38196
  return {
38021
38197
  line: `[watchdog] cam${cam.deviceId} ${cam.deviceName} · ${parts.join(" · ")} → ${stalled ? "STALLED" : "OK"}`,
38022
38198
  stalled,
38023
- recoveries
38199
+ recoveries,
38200
+ disarms
38024
38201
  };
38025
38202
  }
38026
38203
  };
@@ -38917,6 +39094,14 @@ var ReconcileController = class ReconcileController {
38917
39094
  /** Shared in-flight pass so a session dispatch waits for runner truth instead of racing it. */
38918
39095
  attachmentReconcilePromise = null;
38919
39096
  /**
39097
+ * Desired-but-nonexistent devices and how far their vanish streak has got
39098
+ * (`dispatch-reconcile.ts`). PURELY in-memory and seeded EMPTY on boot: it is
39099
+ * a confirmation counter, not a record — after a restart the first pass only
39100
+ * ever re-observes, never destroys. Cleared whenever the registry read fails,
39101
+ * so a failed read can never advance a device toward teardown.
39102
+ */
39103
+ vanishedDevices = /* @__PURE__ */ new Map();
39104
+ /**
38920
39105
  * Per-device backoff for retrying `handleDeviceRegistered` after a
38921
39106
  * TRANSIENT profile-slot read failure. Timers are `unref`'d and cleared on
38922
39107
  * shutdown / device removal.
@@ -38985,13 +39170,24 @@ var ReconcileController = class ReconcileController {
38985
39170
  return;
38986
39171
  }
38987
39172
  const desired = desiredDeviceIdsFromSlots(slots);
39173
+ const liveDeviceIds = await this.readLiveDeviceIds();
39174
+ const { existing, vanished } = liveDeviceIds === null ? {
39175
+ existing: desired,
39176
+ vanished: /* @__PURE__ */ new Set()
39177
+ } : splitDesiredByExistence(desired, liveDeviceIds);
39178
+ if (liveDeviceIds === null) {
39179
+ this.deps.logger.warn("dispatch reconcile: device existence filter SKIPPED — registry read failed", { meta: { desired: desired.size } });
39180
+ this.vanishedDevices = /* @__PURE__ */ new Map();
39181
+ }
38988
39182
  const known = new Set(this.deps.ledger.listConfigIds());
38989
- const gap = computeDispatchGap(desired, known);
39183
+ const gap = computeDispatchGap(existing, known);
38990
39184
  this.deps.logger.info("dispatch reconcile", { meta: {
38991
39185
  desired: desired.size,
38992
39186
  known: known.size,
38993
- gap
39187
+ gap,
39188
+ vanished: vanished.size
38994
39189
  } });
39190
+ await this.tearDownVanishedDevices(vanished);
38995
39191
  for (const deviceId of gap) try {
38996
39192
  await this.deps.handleDeviceRegistered(deviceId);
38997
39193
  } catch (err) {
@@ -39010,6 +39206,53 @@ var ReconcileController = class ReconcileController {
39010
39206
  }
39011
39207
  }
39012
39208
  /**
39209
+ * The live device registry as an id set, or `null` when the read FAILED.
39210
+ * `null` is the caller's signal to skip the existence filter entirely — it
39211
+ * must never be confused with "the registry is empty", which would prune the
39212
+ * whole fleet.
39213
+ *
39214
+ * Asks for the slim projection: this needs identity only, never
39215
+ * `config`/`sourceInfo` (a full listing of 293 devices measured 271 KB).
39216
+ */
39217
+ async readLiveDeviceIds() {
39218
+ const api = this.deps.api();
39219
+ if (!api) return null;
39220
+ try {
39221
+ const devices = await api.deviceManager.listAll.query({ projection: "slim" });
39222
+ return new Set(devices.map((device) => device.id));
39223
+ } catch (err) {
39224
+ this.deps.logger.debug("dispatch reconcile: deviceManager.listAll failed", { meta: { error: errMsg(err) } });
39225
+ return null;
39226
+ }
39227
+ }
39228
+ /**
39229
+ * Tear down the desired devices the registry no longer has — but only once
39230
+ * TWO consecutive passes agree they are gone (`reconcileVanishedDevices`,
39231
+ * D49). Teardown destroys work, so a single listing that omits a device
39232
+ * (which a partial or racy read looks exactly like) is not enough.
39233
+ *
39234
+ * Runs the SAME `handleDeviceUnregistered` the `DeviceUnregistered` event
39235
+ * runs, so there is exactly one teardown path.
39236
+ */
39237
+ async tearDownVanishedDevices(vanished) {
39238
+ const { tearDown, nextState } = reconcileVanishedDevices(vanished, this.vanishedDevices);
39239
+ this.vanishedDevices = nextState;
39240
+ for (const deviceId of tearDown) {
39241
+ this.deps.logger.warn("dispatch reconcile: device no longer exists — tearing down instead of dispatching", {
39242
+ tags: { deviceId },
39243
+ meta: { confirmedPasses: 2 }
39244
+ });
39245
+ try {
39246
+ await this.deps.handleDeviceUnregistered(deviceId);
39247
+ } catch (err) {
39248
+ this.deps.logger.warn("dispatch reconcile: teardown of vanished device failed", {
39249
+ tags: { deviceId },
39250
+ meta: { error: errMsg(err) }
39251
+ });
39252
+ }
39253
+ }
39254
+ }
39255
+ /**
39013
39256
  * Converge the in-memory `assignments` map to runner truth (Phase 1 of the
39014
39257
  * placement-derived-state design). Runner attachments (`getLocalCameras`) are
39015
39258
  * authoritative: detach duplicates/orphans, and drop stale assignments
@@ -39320,6 +39563,7 @@ var ReconcileController = class ReconcileController {
39320
39563
  this.slotReadRetryTimers.clear();
39321
39564
  this.slotReadRetryAttempts.clear();
39322
39565
  this.remoteHealthAttempts.clear();
39566
+ this.vanishedDevices = /* @__PURE__ */ new Map();
39323
39567
  }
39324
39568
  };
39325
39569
  //#endregion
@@ -40385,6 +40629,31 @@ async function buildOrchestratorControllers(deps) {
40385
40629
  getInitTimestamp: () => deps.initTimestamp
40386
40630
  });
40387
40631
  const inferenceRotation = new RoundRobinInferenceDeviceRotation();
40632
+ /**
40633
+ * Does this camera's source carry an audio track? Read from the broker that
40634
+ * owns the stream (`BrokerStats.audio === null` is the documented no-audio
40635
+ * signal), pinned to the cluster's source owner exactly like the
40636
+ * camera-status broker stage. FAILS OPEN: no api, an RPC error, or a broker
40637
+ * that has not detected the track yet (`undefined`, cold start) all answer
40638
+ * `true`, because the only consumer is the watchdog's diagnostic audio stage
40639
+ * and a false negative silently stops watching a working camera.
40640
+ */
40641
+ const resolveHasAudioSource = async (deviceId, camStreamId) => {
40642
+ const api = deps.ctx().api;
40643
+ if (!api) return true;
40644
+ try {
40645
+ return (await api.streamBroker.getBrokerStats.query({ brokerId: makeSourceBrokerId(deviceId, camStreamId) }, nodePin(topology.assignSource(deviceId)))).audio !== null;
40646
+ } catch (err) {
40647
+ deps.ctx().logger.debug("audio-source probe failed — assuming the source has audio", {
40648
+ tags: { deviceId },
40649
+ meta: {
40650
+ camStreamId,
40651
+ error: errMsg(err)
40652
+ }
40653
+ });
40654
+ return true;
40655
+ }
40656
+ };
40388
40657
  const placement = new PlacementService({
40389
40658
  ctx: () => deps.ctx(),
40390
40659
  ctxIfReady: () => deps.ctxIfReady(),
@@ -40400,7 +40669,8 @@ async function buildOrchestratorControllers(deps) {
40400
40669
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
40401
40670
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
40402
40671
  inferenceRotation,
40403
- buildWatchdogCamera: (config, deviceName) => detectionWiring.buildWatchdogCamera(config, deviceName),
40672
+ buildWatchdogCamera: (config, deviceName, hasAudioSource) => detectionWiring.buildWatchdogCamera(config, deviceName, hasAudioSource),
40673
+ hasAudioSource: (deviceId, camStreamId) => resolveHasAudioSource(deviceId, camStreamId),
40404
40674
  isIdleSessionCamera: (deviceId) => deps.isIdleSessionCamera(deviceId),
40405
40675
  activeSessionNode: (deviceId) => session.activeSessionNode(deviceId),
40406
40676
  closeActiveSession: (deviceId) => session.closeSessionForReassign(deviceId),
@@ -40475,6 +40745,7 @@ async function buildOrchestratorControllers(deps) {
40475
40745
  loadShed,
40476
40746
  logger: deps.ctx().logger,
40477
40747
  handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
40748
+ handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
40478
40749
  dispatchCamera: (config) => deps.dispatchCamera(config),
40479
40750
  detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
40480
40751
  localNodeId: () => localNodeId,
@@ -40575,7 +40846,7 @@ async function buildOrchestratorControllers(deps) {
40575
40846
  const pipelineWatchdog = new PipelineWatchdog({
40576
40847
  now: () => Date.now(),
40577
40848
  logger: deps.ctx().logger,
40578
- recover: (deviceId, stage, streamId) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId),
40849
+ recover: (deviceId, stage, streamId, attempt) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId, attempt),
40579
40850
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
40580
40851
  });
40581
40852
  pipelineWatchdog.start(deps.watchdogIntervalMs);