@camstack/addon-pipeline-orchestrator 1.2.44 → 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 (34) 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/_stub.js +2 -2
  9. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-KN60YkLI.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DVSFAO6F.mjs} +3 -3
  10. package/dist/{_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-REijOHWS.mjs → _virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DeEbejFq.mjs} +1 -1
  11. package/dist/{_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C-VsJkiZ.mjs → _virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-K6wrAHkp.mjs} +1 -1
  12. package/dist/{hostInit-CKQmEzIK.mjs → hostInit-D4Kq_1dM.mjs} +3 -3
  13. package/dist/index.js +667 -64
  14. package/dist/index.mjs +667 -64
  15. package/dist/remoteEntry.js +1 -1
  16. package/package.json +1 -1
  17. package/assets/viewer/assets/node_modules/expo-router/assets/arrow_down.017bc6ba3fc25503e5eb5e53826d48a8.png +0 -0
  18. package/assets/viewer/assets/node_modules/expo-router/assets/error.d1ea1496f9057eb392d5bbf3732a61b7.png +0 -0
  19. package/assets/viewer/assets/node_modules/expo-router/assets/file.19eeb73b9593a38f8e9f418337fc7d10.png +0 -0
  20. package/assets/viewer/assets/node_modules/expo-router/assets/forward.d8b800c443b8972542883e0b9de2bdc6.png +0 -0
  21. package/assets/viewer/assets/node_modules/expo-router/assets/pkg.ab19f4cbc543357183a20571f68380a3.png +0 -0
  22. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/back-icon-mask.0a328cd9c1afd0afe8e3b1ec5165b1b4.png +0 -0
  23. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/back-icon.35ba0eaec5a4f5ed12ca16fabeae451d.png +0 -0
  24. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55.png +0 -0
  25. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55@2x.png +0 -0
  26. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55@3x.png +0 -0
  27. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/clear-icon.c94f6478e7ae0cdd9f15de1fcb9e5e55@4x.png +0 -0
  28. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7.png +0 -0
  29. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7@2x.png +0 -0
  30. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7@3x.png +0 -0
  31. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/close-icon.808e1b1b9b53114ec2838071a7e6daa7@4x.png +0 -0
  32. package/assets/viewer/assets/node_modules/expo-router/assets/react-navigation/elements/search-icon.286d67d3f74808a60a78d3ebf1a5fb57.png +0 -0
  33. package/assets/viewer/assets/node_modules/expo-router/assets/sitemap.412dd9275b6b48ad28f5e3d81bb1f626.png +0 -0
  34. package/assets/viewer/assets/node_modules/expo-router/assets/unmatched.20e71bdf79e3a97bf55fd9e164041578.png +0 -0
package/dist/index.mjs CHANGED
@@ -8365,12 +8365,11 @@ var RecordingConfigSchema = object({
8365
8365
  /**
8366
8366
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
8367
8367
  *
8368
- * One shape shared by the recorder's `relocateFootage` (segments) and
8369
- * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
8370
- * page renders both movers with one component. Jobs are in-RAM (a restart
8371
- * forgets them — re-running is safe by construction: copy-if-absent, delete
8372
- * after verify) and each completed/failed run also lands one durable ops-log
8373
- * row on the owning addon's surface.
8368
+ * One shape shared by the recorder and pipeline-analytics internal movers.
8369
+ * The public admin surface is `storage-migration`; child jobs remain in RAM
8370
+ * because copy-if-absent, verify, delete and index/row repoint are resumable.
8371
+ * Each completed/failed run also lands one durable ops-log row on its owning
8372
+ * addon surface.
8374
8373
  */
8375
8374
  var RelocateJobStateSchema = _enum([
8376
8375
  "running",
@@ -8397,19 +8396,100 @@ var RelocateJobSchema = object({
8397
8396
  finishedAt: number().nullable(),
8398
8397
  error: string().nullable()
8399
8398
  });
8399
+ /** Profile-derived footage selection used only by the migration coordinator:
8400
+ * `recordings` owns high+mid; `recordingsLow` owns low. */
8401
+ var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
8400
8402
  var RelocateFootageInputSchema = object({
8401
- deviceId: number().optional(),
8402
8403
  fromLocationId: string(),
8403
8404
  toLocationId: string(),
8404
8405
  entities: array(_enum(["segments"])).optional(),
8406
+ /** Limits relocation to the logical profile class. Omit only for the
8407
+ * pre-orchestration compatibility path. */
8408
+ footageClass: RelocateFootageClassSchema.optional(),
8405
8409
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
8406
8410
  * never allowed to starve live writers. */
8407
8411
  throttleMbps: number().min(1).max(1e3).optional()
8408
8412
  });
8409
- var RelocateMediaInputSchema = object({
8410
- deviceId: number().optional(),
8413
+ /** Internal, lease-scoped participant operation. It is intentionally separate
8414
+ * from persistent recording settings: a migration never changes
8415
+ * `RecordingConfig.enabled` or camera wrapper bindings. */
8416
+ var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
8417
+ var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
8418
+ var StorageMigrationMediaMoveInputSchema = object({
8411
8419
  toLocationId: string(),
8412
8420
  throttleMbps: number().min(1).max(1e3).optional()
8421
+ }).extend({ leaseId: string().min(1) });
8422
+ /** The independently selectable logical storage classes. `recordings`
8423
+ * encompasses the high and mid segment profiles; `recordingsLow` is low
8424
+ * segments; `eventMedia` is post-analysis blobs. */
8425
+ var StorageMigrationClassSchema = _enum([
8426
+ "recordings",
8427
+ "recordingsLow",
8428
+ "eventMedia"
8429
+ ]);
8430
+ /** A destination is always an existing, fully-qualified location id. The
8431
+ * migration API intentionally never changes a source location's `basePath`:
8432
+ * callers create a new `<type>:<slug>` location, then select it here. */
8433
+ var StorageMigrationDestinationsSchema = object({
8434
+ recordings: string().min(1).optional(),
8435
+ recordingsLow: string().min(1).optional(),
8436
+ eventMedia: string().min(1).optional()
8437
+ }).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
8438
+ /** Shared input for planning and starting an orchestrated storage migration. */
8439
+ var StorageMigrationInputSchema = object({
8440
+ destinations: StorageMigrationDestinationsSchema,
8441
+ throttleMbps: number().min(1).max(1e3).optional()
8442
+ });
8443
+ /** The durable coordinator state machine. The only phase that changes default
8444
+ * locations is `repointing`, after every selected mover has completed and been
8445
+ * verified. */
8446
+ var StorageMigrationPhaseSchema = _enum([
8447
+ "planning",
8448
+ "pausing",
8449
+ "moving",
8450
+ "verifying",
8451
+ "repointing",
8452
+ "refreshing",
8453
+ "resuming",
8454
+ "done",
8455
+ "failed",
8456
+ "cancelled"
8457
+ ]);
8458
+ var StorageMigrationParticipantSchema = _enum([
8459
+ "pipeline",
8460
+ "recorder",
8461
+ "analytics"
8462
+ ]);
8463
+ var StorageMigrationMoveSchema = object({
8464
+ storageClass: StorageMigrationClassSchema,
8465
+ fromLocationId: string(),
8466
+ toLocationId: string(),
8467
+ moverJobId: string().nullable(),
8468
+ state: RelocateJobStateSchema.nullable(),
8469
+ error: string().nullable()
8470
+ });
8471
+ var StorageMigrationJobSchema = object({
8472
+ jobId: string(),
8473
+ phase: StorageMigrationPhaseSchema,
8474
+ destinations: StorageMigrationDestinationsSchema,
8475
+ throttleMbps: number(),
8476
+ moves: array(StorageMigrationMoveSchema),
8477
+ pauseLeaseId: string().nullable(),
8478
+ pausedParticipants: array(StorageMigrationParticipantSchema),
8479
+ repointed: boolean(),
8480
+ cancelRequested: boolean(),
8481
+ startedAt: number(),
8482
+ updatedAt: number(),
8483
+ finishedAt: number().nullable(),
8484
+ error: string().nullable()
8485
+ });
8486
+ var StorageMigrationPlanSchema = object({
8487
+ destinations: StorageMigrationDestinationsSchema,
8488
+ moves: array(object({
8489
+ storageClass: StorageMigrationClassSchema,
8490
+ fromLocationId: string(),
8491
+ toLocationId: string()
8492
+ }))
8413
8493
  });
8414
8494
  /**
8415
8495
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -16851,13 +16931,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
16851
16931
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
16852
16932
  kind: "mutation",
16853
16933
  auth: "admin"
16854
- }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
16934
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
16855
16935
  kind: "mutation",
16856
16936
  auth: "admin"
16857
- }), method(object({}), array(RelocateJobSchema).readonly(), {
16858
- kind: "query",
16937
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
16938
+ kind: "mutation",
16939
+ auth: "admin"
16940
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
16941
+ kind: "mutation",
16942
+ auth: "admin"
16943
+ }), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
16944
+ kind: "mutation",
16859
16945
  auth: "admin"
16860
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16946
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16861
16947
  kind: "mutation",
16862
16948
  auth: "admin"
16863
16949
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
@@ -18697,6 +18783,16 @@ var pipelineOrchestratorCapability = {
18697
18783
  nodeIdMode: "data",
18698
18784
  exposesDeviceSettings: true,
18699
18785
  methods: {
18786
+ /** Internal storage-migration participant lease. While held, dispatch
18787
+ * requests are accepted as pending but no new runner attachment starts. */
18788
+ pauseForStorageMigration: method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
18789
+ kind: "mutation",
18790
+ auth: "admin"
18791
+ }),
18792
+ resumeForStorageMigration: method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
18793
+ kind: "mutation",
18794
+ auth: "admin"
18795
+ }),
18700
18796
  /**
18701
18797
  * Pin a camera's pipeline to a specific agent (L1 affinity).
18702
18798
  * The orchestrator re-evaluates the assignment immediately and persists
@@ -19644,6 +19740,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
19644
19740
  locationId: string(),
19645
19741
  targetBytes: number().int().positive()
19646
19742
  }), EvictResultSchema, { kind: "mutation" });
19743
+ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
19744
+ kind: "mutation",
19745
+ auth: "admin"
19746
+ }), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
19747
+ kind: "mutation",
19748
+ auth: "admin"
19749
+ });
19647
19750
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
19648
19751
  providerId: string().min(1),
19649
19752
  displayName: string().min(1),
@@ -19747,6 +19850,28 @@ var TerminalProfileInfoSchema = object({
19747
19850
  label: string(),
19748
19851
  description: string().optional()
19749
19852
  });
19853
+ /**
19854
+ * A durable operator-created Terminal instance. Profiles are templates; only
19855
+ * an instance declares a camera.
19856
+ */
19857
+ var TerminalInstanceInfoSchema = object({
19858
+ instanceId: string(),
19859
+ cameraStableId: string(),
19860
+ nodeId: string(),
19861
+ profileId: string(),
19862
+ profileLabel: string(),
19863
+ name: string(),
19864
+ enabled: boolean()
19865
+ });
19866
+ var TerminalLegacyCameraSchema = object({
19867
+ stableId: string(),
19868
+ nodeId: string(),
19869
+ profileId: string(),
19870
+ profileLabel: string(),
19871
+ name: string(),
19872
+ /** Only legacy monitor cameras can retain their historic stable identity. */
19873
+ adoptable: boolean()
19874
+ });
19750
19875
  var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
19751
19876
  seq: number().int().positive(),
19752
19877
  kind: literal("data"),
@@ -19763,7 +19888,29 @@ var TerminalOutputBatchSchema = object({
19763
19888
  snapshot: string().optional(),
19764
19889
  events: array(TerminalOutputEventSchema).readonly()
19765
19890
  });
19766
- method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
19891
+ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
19892
+ targetNodeId: string().min(1),
19893
+ profileId: string().min(1),
19894
+ name: string().trim().min(1).max(160).optional()
19895
+ }), TerminalInstanceInfoSchema, {
19896
+ kind: "mutation",
19897
+ auth: "admin"
19898
+ }), method(object({ instanceId: string().min(1) }), _void(), {
19899
+ kind: "mutation",
19900
+ auth: "admin"
19901
+ }), method(object({
19902
+ instanceId: string().min(1),
19903
+ enabled: boolean()
19904
+ }), TerminalInstanceInfoSchema, {
19905
+ kind: "mutation",
19906
+ auth: "admin"
19907
+ }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
19908
+ stableId: string().min(1),
19909
+ name: string().trim().min(1).max(160).optional()
19910
+ }), TerminalInstanceInfoSchema, {
19911
+ kind: "mutation",
19912
+ auth: "admin"
19913
+ }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
19767
19914
  profileId: string(),
19768
19915
  cols: number().int().positive(),
19769
19916
  rows: number().int().positive()
@@ -19780,7 +19927,13 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
19780
19927
  }), method(object({
19781
19928
  sessionId: string(),
19782
19929
  afterSeq: number().int().nonnegative(),
19783
- waitMs: number().int().min(0).max(2e3).default(0)
19930
+ waitMs: number().int().min(0).max(2e3).default(0),
19931
+ /**
19932
+ * Wait when a just-opened session has no output yet. Kept opt-in so a
19933
+ * browser's initial repaint remains immediate; the camera snapshot
19934
+ * relay uses it to avoid encoding a blank startup frame.
19935
+ */
19936
+ waitForOutput: boolean().optional()
19784
19937
  }), TerminalOutputBatchSchema, {
19785
19938
  kind: "mutation",
19786
19939
  auth: "admin",
@@ -21721,6 +21874,7 @@ var FaceInfoSchema = object({
21721
21874
  var FaceFilterEnum = _enum([
21722
21875
  "unassigned",
21723
21876
  "recognized",
21877
+ "identified",
21724
21878
  "all"
21725
21879
  ]);
21726
21880
  var MediaFileLiteSchema$1 = object({
@@ -21749,6 +21903,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21749
21903
  kind: "mutation",
21750
21904
  auth: "admin"
21751
21905
  }), method(object({
21906
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21907
+ deviceId: number().int().optional(),
21752
21908
  limit: number().int().positive().optional(),
21753
21909
  filter: FaceFilterEnum.optional(),
21754
21910
  /**
@@ -23514,6 +23670,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23514
23670
  capName: string().min(1).max(64),
23515
23671
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23516
23672
  valuePath: string().min(1).max(64)
23673
+ }),
23674
+ object({
23675
+ kind: literal("latest-recognition"),
23676
+ recognition: _enum(["person", "plate"])
23517
23677
  })
23518
23678
  ]);
23519
23679
  var OsdSlotBindingSchema = object({
@@ -23619,6 +23779,15 @@ method(object({ deviceId: number().int() }), object({
23619
23779
  }), object({ success: literal(true) }), {
23620
23780
  kind: "mutation",
23621
23781
  auth: "admin"
23782
+ }), method(object({
23783
+ sourceDeviceId: number().int(),
23784
+ targetDeviceId: number().int()
23785
+ }), object({
23786
+ copied: number().int().nonnegative(),
23787
+ skipped: number().int().nonnegative()
23788
+ }), {
23789
+ kind: "mutation",
23790
+ auth: "admin"
23622
23791
  }), method(object({
23623
23792
  deviceId: number().int(),
23624
23793
  slotId: string().min(1),
@@ -24504,13 +24673,19 @@ method(object({
24504
24673
  }), {
24505
24674
  kind: "mutation",
24506
24675
  auth: "admin"
24507
- }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
24676
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
24508
24677
  kind: "mutation",
24509
24678
  auth: "admin"
24510
- }), method(object({}), array(RelocateJobSchema).readonly(), {
24511
- kind: "query",
24679
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
24680
+ kind: "mutation",
24681
+ auth: "admin"
24682
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
24683
+ kind: "mutation",
24684
+ auth: "admin"
24685
+ }), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
24686
+ kind: "mutation",
24512
24687
  auth: "admin"
24513
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24688
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24514
24689
  kind: "mutation",
24515
24690
  auth: "admin"
24516
24691
  });
@@ -28709,6 +28884,12 @@ Object.freeze({
28709
28884
  addonId: null,
28710
28885
  access: "delete"
28711
28886
  },
28887
+ "osdManager.copyDeviceConfiguration": {
28888
+ capName: "osd-manager",
28889
+ capScope: "system",
28890
+ addonId: null,
28891
+ access: "create"
28892
+ },
28712
28893
  "osdManager.getConditionSupport": {
28713
28894
  capName: "osd-manager",
28714
28895
  capScope: "system",
@@ -28805,7 +28986,7 @@ Object.freeze({
28805
28986
  addonId: null,
28806
28987
  access: "create"
28807
28988
  },
28808
- "pipelineAnalytics.cancelMediaRelocate": {
28989
+ "pipelineAnalytics.cancelStorageMigrationMove": {
28809
28990
  capName: "pipeline-analytics",
28810
28991
  capScope: "device",
28811
28992
  addonId: null,
@@ -28877,12 +29058,6 @@ Object.freeze({
28877
29058
  addonId: null,
28878
29059
  access: "view"
28879
29060
  },
28880
- "pipelineAnalytics.getMediaRelocateStatus": {
28881
- capName: "pipeline-analytics",
28882
- capScope: "device",
28883
- addonId: null,
28884
- access: "view"
28885
- },
28886
29061
  "pipelineAnalytics.getMotionEvents": {
28887
29062
  capName: "pipeline-analytics",
28888
29063
  capScope: "device",
@@ -28919,6 +29094,12 @@ Object.freeze({
28919
29094
  addonId: null,
28920
29095
  access: "view"
28921
29096
  },
29097
+ "pipelineAnalytics.getStorageMigrationMoveStatus": {
29098
+ capName: "pipeline-analytics",
29099
+ capScope: "device",
29100
+ addonId: null,
29101
+ access: "view"
29102
+ },
28922
29103
  "pipelineAnalytics.getTrack": {
28923
29104
  capName: "pipeline-analytics",
28924
29105
  capScope: "device",
@@ -28997,6 +29178,12 @@ Object.freeze({
28997
29178
  addonId: null,
28998
29179
  access: "view"
28999
29180
  },
29181
+ "pipelineAnalytics.pauseForStorageMigration": {
29182
+ capName: "pipeline-analytics",
29183
+ capScope: "device",
29184
+ addonId: null,
29185
+ access: "create"
29186
+ },
29000
29187
  "pipelineAnalytics.proposeRetrainAnnotations": {
29001
29188
  capName: "pipeline-analytics",
29002
29189
  capScope: "device",
@@ -29027,7 +29214,7 @@ Object.freeze({
29027
29214
  addonId: null,
29028
29215
  access: "create"
29029
29216
  },
29030
- "pipelineAnalytics.relocateMedia": {
29217
+ "pipelineAnalytics.refreshStorageLocationsForMigration": {
29031
29218
  capName: "pipeline-analytics",
29032
29219
  capScope: "device",
29033
29220
  addonId: null,
@@ -29039,6 +29226,12 @@ Object.freeze({
29039
29226
  addonId: null,
29040
29227
  access: "create"
29041
29228
  },
29229
+ "pipelineAnalytics.resumeForStorageMigration": {
29230
+ capName: "pipeline-analytics",
29231
+ capScope: "device",
29232
+ addonId: null,
29233
+ access: "create"
29234
+ },
29042
29235
  "pipelineAnalytics.saveRetrainAnnotations": {
29043
29236
  capName: "pipeline-analytics",
29044
29237
  capScope: "device",
@@ -29063,6 +29256,12 @@ Object.freeze({
29063
29256
  addonId: null,
29064
29257
  access: "create"
29065
29258
  },
29259
+ "pipelineAnalytics.startStorageMigrationMove": {
29260
+ capName: "pipeline-analytics",
29261
+ capScope: "device",
29262
+ addonId: null,
29263
+ access: "create"
29264
+ },
29066
29265
  "pipelineAnalytics.wipeAllAnalytics": {
29067
29266
  capName: "pipeline-analytics",
29068
29267
  capScope: "device",
@@ -29429,6 +29628,12 @@ Object.freeze({
29429
29628
  addonId: null,
29430
29629
  access: "view"
29431
29630
  },
29631
+ "pipelineOrchestrator.pauseForStorageMigration": {
29632
+ capName: "pipeline-orchestrator",
29633
+ capScope: "system",
29634
+ addonId: null,
29635
+ access: "create"
29636
+ },
29432
29637
  "pipelineOrchestrator.rebalance": {
29433
29638
  capName: "pipeline-orchestrator",
29434
29639
  capScope: "system",
@@ -29453,6 +29658,12 @@ Object.freeze({
29453
29658
  addonId: null,
29454
29659
  access: "view"
29455
29660
  },
29661
+ "pipelineOrchestrator.resumeForStorageMigration": {
29662
+ capName: "pipeline-orchestrator",
29663
+ capScope: "system",
29664
+ addonId: null,
29665
+ access: "create"
29666
+ },
29456
29667
  "pipelineOrchestrator.saveTemplate": {
29457
29668
  capName: "pipeline-orchestrator",
29458
29669
  capScope: "system",
@@ -29849,7 +30060,7 @@ Object.freeze({
29849
30060
  addonId: null,
29850
30061
  access: "create"
29851
30062
  },
29852
- "recording.cancelRelocate": {
30063
+ "recording.cancelStorageMigrationMove": {
29853
30064
  capName: "recording",
29854
30065
  capScope: "system",
29855
30066
  addonId: null,
@@ -29885,7 +30096,7 @@ Object.freeze({
29885
30096
  addonId: null,
29886
30097
  access: "view"
29887
30098
  },
29888
- "recording.getRelocateStatus": {
30099
+ "recording.getStorageMigrationMoveStatus": {
29889
30100
  capName: "recording",
29890
30101
  capScope: "system",
29891
30102
  addonId: null,
@@ -29909,6 +30120,12 @@ Object.freeze({
29909
30120
  addonId: null,
29910
30121
  access: "view"
29911
30122
  },
30123
+ "recording.pauseForStorageMigration": {
30124
+ capName: "recording",
30125
+ capScope: "system",
30126
+ addonId: null,
30127
+ access: "create"
30128
+ },
29912
30129
  "recording.pruneFootage": {
29913
30130
  capName: "recording",
29914
30131
  capScope: "system",
@@ -29927,7 +30144,7 @@ Object.freeze({
29927
30144
  addonId: null,
29928
30145
  access: "view"
29929
30146
  },
29930
- "recording.relocateFootage": {
30147
+ "recording.refreshStorageLocationsForMigration": {
29931
30148
  capName: "recording",
29932
30149
  capScope: "system",
29933
30150
  addonId: null,
@@ -29951,12 +30168,24 @@ Object.freeze({
29951
30168
  addonId: null,
29952
30169
  access: "create"
29953
30170
  },
30171
+ "recording.resumeForStorageMigration": {
30172
+ capName: "recording",
30173
+ capScope: "system",
30174
+ addonId: null,
30175
+ access: "create"
30176
+ },
29954
30177
  "recording.setDeviceConfig": {
29955
30178
  capName: "recording",
29956
30179
  capScope: "system",
29957
30180
  addonId: null,
29958
30181
  access: "create"
29959
30182
  },
30183
+ "recording.startStorageMigrationMove": {
30184
+ capName: "recording",
30185
+ capScope: "system",
30186
+ addonId: null,
30187
+ access: "create"
30188
+ },
29960
30189
  "recordingExport.cancelExport": {
29961
30190
  capName: "recordingExport",
29962
30191
  capScope: "system",
@@ -30347,6 +30576,30 @@ Object.freeze({
30347
30576
  addonId: null,
30348
30577
  access: "view"
30349
30578
  },
30579
+ "storageMigration.cancel": {
30580
+ capName: "storage-migration",
30581
+ capScope: "system",
30582
+ addonId: null,
30583
+ access: "create"
30584
+ },
30585
+ "storageMigration.plan": {
30586
+ capName: "storage-migration",
30587
+ capScope: "system",
30588
+ addonId: null,
30589
+ access: "view"
30590
+ },
30591
+ "storageMigration.start": {
30592
+ capName: "storage-migration",
30593
+ capScope: "system",
30594
+ addonId: null,
30595
+ access: "create"
30596
+ },
30597
+ "storageMigration.status": {
30598
+ capName: "storage-migration",
30599
+ capScope: "system",
30600
+ addonId: null,
30601
+ access: "view"
30602
+ },
30350
30603
  "storageProvider.abortUpload": {
30351
30604
  capName: "storage-provider",
30352
30605
  capScope: "system",
@@ -30725,12 +30978,42 @@ Object.freeze({
30725
30978
  addonId: null,
30726
30979
  access: "create"
30727
30980
  },
30981
+ "terminalSession.adoptLegacyMonitor": {
30982
+ capName: "terminal-session",
30983
+ capScope: "system",
30984
+ addonId: null,
30985
+ access: "create"
30986
+ },
30728
30987
  "terminalSession.close": {
30729
30988
  capName: "terminal-session",
30730
30989
  capScope: "system",
30731
30990
  addonId: null,
30732
30991
  access: "create"
30733
30992
  },
30993
+ "terminalSession.createInstance": {
30994
+ capName: "terminal-session",
30995
+ capScope: "system",
30996
+ addonId: null,
30997
+ access: "create"
30998
+ },
30999
+ "terminalSession.deleteInstance": {
31000
+ capName: "terminal-session",
31001
+ capScope: "system",
31002
+ addonId: null,
31003
+ access: "delete"
31004
+ },
31005
+ "terminalSession.listInstances": {
31006
+ capName: "terminal-session",
31007
+ capScope: "system",
31008
+ addonId: null,
31009
+ access: "view"
31010
+ },
31011
+ "terminalSession.listLegacyCameras": {
31012
+ capName: "terminal-session",
31013
+ capScope: "system",
31014
+ addonId: null,
31015
+ access: "view"
31016
+ },
30734
31017
  "terminalSession.listProfiles": {
30735
31018
  capName: "terminal-session",
30736
31019
  capScope: "system",
@@ -30761,6 +31044,12 @@ Object.freeze({
30761
31044
  addonId: null,
30762
31045
  access: "create"
30763
31046
  },
31047
+ "terminalSession.setInstanceEnabled": {
31048
+ capName: "terminal-session",
31049
+ capScope: "system",
31050
+ addonId: null,
31051
+ access: "create"
31052
+ },
30764
31053
  "terminalSession.writeInput": {
30765
31054
  capName: "terminal-session",
30766
31055
  capScope: "system",
@@ -34671,6 +34960,48 @@ var DeviceFeaturesMirror = class {
34671
34960
  }
34672
34961
  };
34673
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
34674
35005
  //#region src/detection-wiring-controller.ts
34675
35006
  var DetectionWiringController = class {
34676
35007
  deps;
@@ -34875,7 +35206,8 @@ var DetectionWiringController = class {
34875
35206
  await this.stopDetection(deviceId);
34876
35207
  }
34877
35208
  this.activeDetections.set(deviceId, config);
34878
- 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") {
34879
35211
  if (config.motionSources.includes("analyzer")) try {
34880
35212
  await this.deps.session.ensureMotionWatch(deviceId, config);
34881
35213
  } catch (err) {
@@ -34966,11 +35298,44 @@ var DetectionWiringController = class {
34966
35298
  this.deps.forgetDeviceZones(deviceId);
34967
35299
  this.deps.forgetDeviceZoneRules(deviceId);
34968
35300
  this.deps.loadShed.forgetDevice(deviceId);
34969
- if (!this.deps.ctx().settings) return;
35301
+ const settings = this.deps.ctx().settings;
35302
+ if (!settings) return;
34970
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
+ }
34971
35312
  this.deps.logger.info("Purged pipeline config for removed device", { tags: { deviceId } });
34972
35313
  }
34973
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
+ /**
34974
35339
  * Handle a profile-slot change: build a fresh detection config from
34975
35340
  * per-device settings and start detection if motion is enabled.
34976
35341
  * Fires on `camera-streams.onProfileSlotsChanged` — any publish,
@@ -34980,6 +35345,11 @@ var DetectionWiringController = class {
34980
35345
  if (!this.deps.ctx()) return;
34981
35346
  const log = this.deps.logger.withTags({ deviceId });
34982
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
+ }
34983
35353
  const config = await this.buildDetectionConfig(deviceId);
34984
35354
  if (config === TRANSIENT_SLOT_READ) {
34985
35355
  log.warn("Profile-slot read failed transiently — keeping detection, scheduling retry");
@@ -35119,28 +35489,17 @@ var DetectionWiringController = class {
35119
35489
  });
35120
35490
  }
35121
35491
  /**
35122
- * Derive the watchdog camera descriptor from a runner config. Captures
35123
- * which stages are expected to flow CONTINUOUSLY (always-on audio,
35124
- * analyzer motion, always-on detection) → the camStreamId feeding each.
35125
- * Audio has no dedicated stream field on the runner config, so it shares
35126
- * 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).
35127
35496
  *
35128
35497
  * Public — `PlacementService`'s `buildWatchdogCamera` closure (wired in
35129
- * `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.
35130
35500
  */
35131
- buildWatchdogCamera(config, deviceName) {
35132
- const continuousStages = /* @__PURE__ */ new Map();
35133
- if (config.audioMode === "always-on") continuousStages.set("audio", config.motionStreamId);
35134
- if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
35135
- if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
35136
- return {
35137
- deviceId: config.deviceId,
35138
- deviceName,
35139
- audioMode: config.audioMode,
35140
- detectionMode: config.detectionMode,
35141
- motionSources: config.motionSources,
35142
- continuousStages
35143
- };
35501
+ buildWatchdogCamera(config, deviceName, hasAudioSource) {
35502
+ return buildWatchdogCamera(config, deviceName, hasAudioSource);
35144
35503
  }
35145
35504
  /**
35146
35505
  * Watchdog recovery callback. Invoked when a camera's continuous stage
@@ -35153,7 +35512,7 @@ var DetectionWiringController = class {
35153
35512
  * Public — `PipelineWatchdog`'s `recover` closure (wired in
35154
35513
  * `orchestrator-bootstrap.ts`) calls straight through to this method.
35155
35514
  */
35156
- recoverPipelineStage(deviceId, stage, streamId) {
35515
+ recoverPipelineStage(deviceId, stage, streamId, attempt) {
35157
35516
  if (stage === "audio") {
35158
35517
  const cfg = this.deps.ledger.getConfig(deviceId);
35159
35518
  const audioCfg = cfg ? {
@@ -35167,6 +35526,14 @@ var DetectionWiringController = class {
35167
35526
  });
35168
35527
  });
35169
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
+ });
35170
35537
  this.deps.eventBus.emit({
35171
35538
  id: `watchdog-refresh-${deviceId}-${Date.now()}`,
35172
35539
  timestamp: /* @__PURE__ */ new Date(),
@@ -35569,6 +35936,64 @@ function computeDispatchGap(desired, known) {
35569
35936
  return [...desired].filter((id) => !known.has(id));
35570
35937
  }
35571
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
+ /**
35572
35997
  * Converge the orchestrator's `assignments` map to runner truth. Runner
35573
35998
  * attachments (`getLocalCameras`) are AUTHORITATIVE; this returns the actions
35574
35999
  * that make the map agree with them:
@@ -35715,11 +36140,17 @@ var PlacementService = class {
35715
36140
  deps;
35716
36141
  /** True while an auto-rebalance pass is running (skip overlap). */
35717
36142
  autoRebalanceInFlight = false;
36143
+ inFlightAttaches = 0;
36144
+ idleWaiters = [];
35718
36145
  constructor(deps) {
35719
36146
  this.deps = deps;
35720
36147
  }
35721
36148
  async dispatchCamera(runnerConfig) {
35722
36149
  if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
36150
+ if (this.deps.maintenancePaused?.() === true) return {
36151
+ success: true,
36152
+ kind: "pending"
36153
+ };
35723
36154
  const currentDeviceKey = this.deps.ledger.getConfig(runnerConfig.deviceId)?.deviceKey ?? null;
35724
36155
  this.deps.ledger.setConfig(runnerConfig.deviceId, runnerConfig);
35725
36156
  if (this.deps.loadShed.dispatchDeferralCheck(runnerConfig.deviceId)) return {
@@ -35810,7 +36241,10 @@ var PlacementService = class {
35810
36241
  try {
35811
36242
  await this.attachOn(targetNodeId, attachConfig);
35812
36243
  this.deps.ledger.setConfig(runnerConfig.deviceId, attachConfig);
35813
- 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
+ }
35814
36248
  this.deps.ledger.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, reason === "manual");
35815
36249
  } finally {
35816
36250
  dispatchGuard.dispose();
@@ -36110,6 +36544,7 @@ var PlacementService = class {
36110
36544
  * an absent field would produce.
36111
36545
  */
36112
36546
  async attachOn(nodeId, config) {
36547
+ if (this.deps.maintenancePaused?.() === true) throw new Error("pipeline dispatch is paused for storage migration");
36113
36548
  const ctx = this.deps.ctx();
36114
36549
  const api = ctx.api;
36115
36550
  if (!api) throw new Error(`attachOn(${nodeId}, ${config.deviceId}): this.ctx.api not available`);
@@ -36144,6 +36579,7 @@ var PlacementService = class {
36144
36579
  meta: logMeta
36145
36580
  });
36146
36581
  }).catch(() => {});
36582
+ this.inFlightAttaches++;
36147
36583
  try {
36148
36584
  await api.pipelineRunner.attachCamera.mutate({
36149
36585
  ...config,
@@ -36173,8 +36609,17 @@ var PlacementService = class {
36173
36609
  });
36174
36610
  }
36175
36611
  throw err;
36612
+ } finally {
36613
+ this.inFlightAttaches--;
36614
+ if (this.inFlightAttaches === 0) for (const resolve of this.idleWaiters.splice(0)) resolve();
36176
36615
  }
36177
36616
  }
36617
+ /** Wait until every attach that began before the maintenance gate closed has
36618
+ * settled. New attaches are rejected at the top of `attachOn`. */
36619
+ waitForMigrationIdle() {
36620
+ if (this.inFlightAttaches === 0) return Promise.resolve();
36621
+ return new Promise((resolve) => this.idleWaiters.push(resolve));
36622
+ }
36178
36623
  async detachOn(nodeId, deviceId) {
36179
36624
  const api = this.deps.ctx().api;
36180
36625
  if (!api) return;
@@ -36225,7 +36670,20 @@ var PlacementService = class {
36225
36670
  return;
36226
36671
  }
36227
36672
  await this.attachOn(nodeId, cached);
36228
- 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);
36229
36687
  }
36230
36688
  /**
36231
36689
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
@@ -37597,7 +38055,9 @@ var PipelineWatchdog = class {
37597
38055
  for (const [stage] of cam.continuousStages) stages.set(stage, {
37598
38056
  lastSeenMs: now,
37599
38057
  attempts: 0,
37600
- lastAttemptMs: 0
38058
+ lastAttemptMs: 0,
38059
+ everSeen: false,
38060
+ registeredAtMs: now
37601
38061
  });
37602
38062
  this.state.set(cam.deviceId, stages);
37603
38063
  }
@@ -37611,20 +38071,34 @@ var PipelineWatchdog = class {
37611
38071
  if (!rt) return;
37612
38072
  rt.lastSeenMs = this.deps.now();
37613
38073
  rt.attempts = 0;
38074
+ rt.everSeen = true;
37614
38075
  }
37615
38076
  tick() {
37616
38077
  const now = this.deps.now();
37617
38078
  for (const cam of this.cameras.values()) {
37618
- const { line, stalled, recoveries } = this.evaluate(cam, now);
38079
+ const { line, stalled, recoveries, disarms } = this.evaluate(cam, now);
37619
38080
  if (stalled) this.deps.logger.warn(line, { tags: { deviceId: cam.deviceId } });
37620
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
+ }
37621
38093
  for (const r of recoveries) {
37622
38094
  const rt = this.state.get(cam.deviceId)?.get(r.stage);
38095
+ let attempt = this.deps.thresholds.maxRecoveryAttempts;
37623
38096
  if (rt) {
37624
38097
  if (rt.attempts < this.deps.thresholds.maxRecoveryAttempts) rt.attempts += 1;
37625
38098
  rt.lastAttemptMs = now;
38099
+ attempt = rt.attempts;
37626
38100
  }
37627
- this.deps.recover(cam.deviceId, r.stage, r.streamId);
38101
+ this.deps.recover(cam.deviceId, r.stage, r.streamId, attempt);
37628
38102
  }
37629
38103
  }
37630
38104
  }
@@ -37639,6 +38113,7 @@ var PipelineWatchdog = class {
37639
38113
  evaluate(cam, now) {
37640
38114
  const parts = [];
37641
38115
  const recoveries = [];
38116
+ const disarms = [];
37642
38117
  let stalled = false;
37643
38118
  const stageOrder = [
37644
38119
  "audio",
@@ -37665,7 +38140,14 @@ var PipelineWatchdog = class {
37665
38140
  const thresholdMs = this.deps.thresholds[STAGE_THRESHOLD_KEY[stage]];
37666
38141
  const sSec = Math.round(staleness / 1e3);
37667
38142
  if (staleness <= thresholdMs) parts.push(`${stage}=${stageMode[stage]}@${streamId}(OK ${sSec}s)`);
37668
- 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) {
37669
38151
  stalled = true;
37670
38152
  if (now - rt.lastAttemptMs >= this.deps.thresholds.exhaustedRetryMs) {
37671
38153
  recoveries.push({
@@ -37686,7 +38168,8 @@ var PipelineWatchdog = class {
37686
38168
  return {
37687
38169
  line: `[watchdog] cam${cam.deviceId} ${cam.deviceName} · ${parts.join(" · ")} → ${stalled ? "STALLED" : "OK"}`,
37688
38170
  stalled,
37689
- recoveries
38171
+ recoveries,
38172
+ disarms
37690
38173
  };
37691
38174
  }
37692
38175
  };
@@ -38583,6 +39066,14 @@ var ReconcileController = class ReconcileController {
38583
39066
  /** Shared in-flight pass so a session dispatch waits for runner truth instead of racing it. */
38584
39067
  attachmentReconcilePromise = null;
38585
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
+ /**
38586
39077
  * Per-device backoff for retrying `handleDeviceRegistered` after a
38587
39078
  * TRANSIENT profile-slot read failure. Timers are `unref`'d and cleared on
38588
39079
  * shutdown / device removal.
@@ -38651,13 +39142,24 @@ var ReconcileController = class ReconcileController {
38651
39142
  return;
38652
39143
  }
38653
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
+ }
38654
39154
  const known = new Set(this.deps.ledger.listConfigIds());
38655
- const gap = computeDispatchGap(desired, known);
39155
+ const gap = computeDispatchGap(existing, known);
38656
39156
  this.deps.logger.info("dispatch reconcile", { meta: {
38657
39157
  desired: desired.size,
38658
39158
  known: known.size,
38659
- gap
39159
+ gap,
39160
+ vanished: vanished.size
38660
39161
  } });
39162
+ await this.tearDownVanishedDevices(vanished);
38661
39163
  for (const deviceId of gap) try {
38662
39164
  await this.deps.handleDeviceRegistered(deviceId);
38663
39165
  } catch (err) {
@@ -38676,6 +39178,53 @@ var ReconcileController = class ReconcileController {
38676
39178
  }
38677
39179
  }
38678
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
+ /**
38679
39228
  * Converge the in-memory `assignments` map to runner truth (Phase 1 of the
38680
39229
  * placement-derived-state design). Runner attachments (`getLocalCameras`) are
38681
39230
  * authoritative: detach duplicates/orphans, and drop stale assignments
@@ -38986,6 +39535,7 @@ var ReconcileController = class ReconcileController {
38986
39535
  this.slotReadRetryTimers.clear();
38987
39536
  this.slotReadRetryAttempts.clear();
38988
39537
  this.remoteHealthAttempts.clear();
39538
+ this.vanishedDevices = /* @__PURE__ */ new Map();
38989
39539
  }
38990
39540
  };
38991
39541
  //#endregion
@@ -40051,6 +40601,31 @@ async function buildOrchestratorControllers(deps) {
40051
40601
  getInitTimestamp: () => deps.initTimestamp
40052
40602
  });
40053
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
+ };
40054
40629
  const placement = new PlacementService({
40055
40630
  ctx: () => deps.ctx(),
40056
40631
  ctxIfReady: () => deps.ctxIfReady(),
@@ -40066,12 +40641,14 @@ async function buildOrchestratorControllers(deps) {
40066
40641
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
40067
40642
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
40068
40643
  inferenceRotation,
40069
- buildWatchdogCamera: (config, deviceName) => detectionWiring.buildWatchdogCamera(config, deviceName),
40644
+ buildWatchdogCamera: (config, deviceName, hasAudioSource) => detectionWiring.buildWatchdogCamera(config, deviceName, hasAudioSource),
40645
+ hasAudioSource: (deviceId, camStreamId) => resolveHasAudioSource(deviceId, camStreamId),
40070
40646
  isIdleSessionCamera: (deviceId) => deps.isIdleSessionCamera(deviceId),
40071
40647
  activeSessionNode: (deviceId) => session.activeSessionNode(deviceId),
40072
40648
  closeActiveSession: (deviceId) => session.closeSessionForReassign(deviceId),
40073
40649
  reconcilePlacementFromRunners: () => reconcile.reconcilePlacementFromRunners(),
40074
- emitResolvedCameraUpdated: (deviceId) => deps.emitResolvedCameraUpdated(deviceId)
40650
+ emitResolvedCameraUpdated: (deviceId) => deps.emitResolvedCameraUpdated(deviceId),
40651
+ maintenancePaused: () => deps.maintenancePaused()
40075
40652
  });
40076
40653
  const session = new SessionDispatchController({
40077
40654
  ledger,
@@ -40140,6 +40717,7 @@ async function buildOrchestratorControllers(deps) {
40140
40717
  loadShed,
40141
40718
  logger: deps.ctx().logger,
40142
40719
  handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
40720
+ handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
40143
40721
  dispatchCamera: (config) => deps.dispatchCamera(config),
40144
40722
  detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
40145
40723
  localNodeId: () => localNodeId,
@@ -40240,7 +40818,7 @@ async function buildOrchestratorControllers(deps) {
40240
40818
  const pipelineWatchdog = new PipelineWatchdog({
40241
40819
  now: () => Date.now(),
40242
40820
  logger: deps.ctx().logger,
40243
- recover: (deviceId, stage, streamId) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId),
40821
+ recover: (deviceId, stage, streamId, attempt) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId, attempt),
40244
40822
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
40245
40823
  });
40246
40824
  pipelineWatchdog.start(deps.watchdogIntervalMs);
@@ -41096,6 +41674,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
41096
41674
  /** Periodic auto-rebalance sweep timer (drift correction under hysteresis). */
41097
41675
  autoRebalanceTimer = null;
41098
41676
  initTimestamp = 0;
41677
+ /** Storage migration maintenance lease. It only gates dispatch; it does not
41678
+ * change any camera wrapper or persistent pipeline configuration. */
41679
+ storageMigrationLeaseId = null;
41099
41680
  constructor() {
41100
41681
  super({});
41101
41682
  }
@@ -41143,6 +41724,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
41143
41724
  rebalance: (opts) => this.rebalance(opts),
41144
41725
  setCameraPipelineForAgent: (input) => this.setCameraPipelineForAgent(input),
41145
41726
  dispatchCamera: (config) => this.dispatchCamera(config),
41727
+ maintenancePaused: () => this.storageMigrationLeaseId !== null,
41146
41728
  setTopology: (topology) => {
41147
41729
  this.topology = topology;
41148
41730
  },
@@ -41289,8 +41871,29 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
41289
41871
  this.settingsStore = null;
41290
41872
  }
41291
41873
  async dispatchCamera(runnerConfig) {
41874
+ if (this.storageMigrationLeaseId !== null) return {
41875
+ success: true,
41876
+ kind: "pending"
41877
+ };
41292
41878
  return this.placement.dispatchCamera(runnerConfig);
41293
41879
  }
41880
+ async pauseForStorageMigration(input) {
41881
+ if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== input.leaseId) throw new Error("pipeline dispatch is already paused by another storage migration");
41882
+ this.storageMigrationLeaseId = input.leaseId;
41883
+ try {
41884
+ await this.placement.waitForMigrationIdle();
41885
+ return { paused: true };
41886
+ } catch (err) {
41887
+ this.storageMigrationLeaseId = null;
41888
+ throw err;
41889
+ }
41890
+ }
41891
+ async resumeForStorageMigration(input) {
41892
+ if (this.storageMigrationLeaseId === null) return { resumed: true };
41893
+ if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("pipeline storage migration lease does not match");
41894
+ this.storageMigrationLeaseId = null;
41895
+ return { resumed: true };
41896
+ }
41294
41897
  async releaseCamera(input) {
41295
41898
  return this.placement.releaseCamera(input);
41296
41899
  }