@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.js CHANGED
@@ -8393,12 +8393,11 @@ var RecordingConfigSchema = object({
8393
8393
  /**
8394
8394
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
8395
8395
  *
8396
- * One shape shared by the recorder's `relocateFootage` (segments) and
8397
- * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
8398
- * page renders both movers with one component. Jobs are in-RAM (a restart
8399
- * forgets them re-running is safe by construction: copy-if-absent, delete
8400
- * after verify) and each completed/failed run also lands one durable ops-log
8401
- * row on the owning addon's surface.
8396
+ * One shape shared by the recorder and pipeline-analytics internal movers.
8397
+ * The public admin surface is `storage-migration`; child jobs remain in RAM
8398
+ * because copy-if-absent, verify, delete and index/row repoint are resumable.
8399
+ * Each completed/failed run also lands one durable ops-log row on its owning
8400
+ * addon surface.
8402
8401
  */
8403
8402
  var RelocateJobStateSchema = _enum([
8404
8403
  "running",
@@ -8425,19 +8424,100 @@ var RelocateJobSchema = object({
8425
8424
  finishedAt: number().nullable(),
8426
8425
  error: string().nullable()
8427
8426
  });
8427
+ /** Profile-derived footage selection used only by the migration coordinator:
8428
+ * `recordings` owns high+mid; `recordingsLow` owns low. */
8429
+ var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
8428
8430
  var RelocateFootageInputSchema = object({
8429
- deviceId: number().optional(),
8430
8431
  fromLocationId: string(),
8431
8432
  toLocationId: string(),
8432
8433
  entities: array(_enum(["segments"])).optional(),
8434
+ /** Limits relocation to the logical profile class. Omit only for the
8435
+ * pre-orchestration compatibility path. */
8436
+ footageClass: RelocateFootageClassSchema.optional(),
8433
8437
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
8434
8438
  * never allowed to starve live writers. */
8435
8439
  throttleMbps: number().min(1).max(1e3).optional()
8436
8440
  });
8437
- var RelocateMediaInputSchema = object({
8438
- deviceId: number().optional(),
8441
+ /** Internal, lease-scoped participant operation. It is intentionally separate
8442
+ * from persistent recording settings: a migration never changes
8443
+ * `RecordingConfig.enabled` or camera wrapper bindings. */
8444
+ var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
8445
+ var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
8446
+ var StorageMigrationMediaMoveInputSchema = object({
8439
8447
  toLocationId: string(),
8440
8448
  throttleMbps: number().min(1).max(1e3).optional()
8449
+ }).extend({ leaseId: string().min(1) });
8450
+ /** The independently selectable logical storage classes. `recordings`
8451
+ * encompasses the high and mid segment profiles; `recordingsLow` is low
8452
+ * segments; `eventMedia` is post-analysis blobs. */
8453
+ var StorageMigrationClassSchema = _enum([
8454
+ "recordings",
8455
+ "recordingsLow",
8456
+ "eventMedia"
8457
+ ]);
8458
+ /** A destination is always an existing, fully-qualified location id. The
8459
+ * migration API intentionally never changes a source location's `basePath`:
8460
+ * callers create a new `<type>:<slug>` location, then select it here. */
8461
+ var StorageMigrationDestinationsSchema = object({
8462
+ recordings: string().min(1).optional(),
8463
+ recordingsLow: string().min(1).optional(),
8464
+ eventMedia: string().min(1).optional()
8465
+ }).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
8466
+ /** Shared input for planning and starting an orchestrated storage migration. */
8467
+ var StorageMigrationInputSchema = object({
8468
+ destinations: StorageMigrationDestinationsSchema,
8469
+ throttleMbps: number().min(1).max(1e3).optional()
8470
+ });
8471
+ /** The durable coordinator state machine. The only phase that changes default
8472
+ * locations is `repointing`, after every selected mover has completed and been
8473
+ * verified. */
8474
+ var StorageMigrationPhaseSchema = _enum([
8475
+ "planning",
8476
+ "pausing",
8477
+ "moving",
8478
+ "verifying",
8479
+ "repointing",
8480
+ "refreshing",
8481
+ "resuming",
8482
+ "done",
8483
+ "failed",
8484
+ "cancelled"
8485
+ ]);
8486
+ var StorageMigrationParticipantSchema = _enum([
8487
+ "pipeline",
8488
+ "recorder",
8489
+ "analytics"
8490
+ ]);
8491
+ var StorageMigrationMoveSchema = object({
8492
+ storageClass: StorageMigrationClassSchema,
8493
+ fromLocationId: string(),
8494
+ toLocationId: string(),
8495
+ moverJobId: string().nullable(),
8496
+ state: RelocateJobStateSchema.nullable(),
8497
+ error: string().nullable()
8498
+ });
8499
+ var StorageMigrationJobSchema = object({
8500
+ jobId: string(),
8501
+ phase: StorageMigrationPhaseSchema,
8502
+ destinations: StorageMigrationDestinationsSchema,
8503
+ throttleMbps: number(),
8504
+ moves: array(StorageMigrationMoveSchema),
8505
+ pauseLeaseId: string().nullable(),
8506
+ pausedParticipants: array(StorageMigrationParticipantSchema),
8507
+ repointed: boolean(),
8508
+ cancelRequested: boolean(),
8509
+ startedAt: number(),
8510
+ updatedAt: number(),
8511
+ finishedAt: number().nullable(),
8512
+ error: string().nullable()
8513
+ });
8514
+ var StorageMigrationPlanSchema = object({
8515
+ destinations: StorageMigrationDestinationsSchema,
8516
+ moves: array(object({
8517
+ storageClass: StorageMigrationClassSchema,
8518
+ fromLocationId: string(),
8519
+ toLocationId: string()
8520
+ }))
8441
8521
  });
8442
8522
  /**
8443
8523
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -16879,13 +16959,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
16879
16959
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
16880
16960
  kind: "mutation",
16881
16961
  auth: "admin"
16882
- }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
16962
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
16883
16963
  kind: "mutation",
16884
16964
  auth: "admin"
16885
- }), method(object({}), array(RelocateJobSchema).readonly(), {
16886
- kind: "query",
16965
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
16966
+ kind: "mutation",
16967
+ auth: "admin"
16968
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
16969
+ kind: "mutation",
16970
+ auth: "admin"
16971
+ }), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
16972
+ kind: "mutation",
16887
16973
  auth: "admin"
16888
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16974
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16889
16975
  kind: "mutation",
16890
16976
  auth: "admin"
16891
16977
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
@@ -18725,6 +18811,16 @@ var pipelineOrchestratorCapability = {
18725
18811
  nodeIdMode: "data",
18726
18812
  exposesDeviceSettings: true,
18727
18813
  methods: {
18814
+ /** Internal storage-migration participant lease. While held, dispatch
18815
+ * requests are accepted as pending but no new runner attachment starts. */
18816
+ pauseForStorageMigration: method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
18817
+ kind: "mutation",
18818
+ auth: "admin"
18819
+ }),
18820
+ resumeForStorageMigration: method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
18821
+ kind: "mutation",
18822
+ auth: "admin"
18823
+ }),
18728
18824
  /**
18729
18825
  * Pin a camera's pipeline to a specific agent (L1 affinity).
18730
18826
  * The orchestrator re-evaluates the assignment immediately and persists
@@ -19672,6 +19768,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
19672
19768
  locationId: string(),
19673
19769
  targetBytes: number().int().positive()
19674
19770
  }), EvictResultSchema, { kind: "mutation" });
19771
+ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
19772
+ kind: "mutation",
19773
+ auth: "admin"
19774
+ }), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
19775
+ kind: "mutation",
19776
+ auth: "admin"
19777
+ });
19675
19778
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
19676
19779
  providerId: string().min(1),
19677
19780
  displayName: string().min(1),
@@ -19775,6 +19878,28 @@ var TerminalProfileInfoSchema = object({
19775
19878
  label: string(),
19776
19879
  description: string().optional()
19777
19880
  });
19881
+ /**
19882
+ * A durable operator-created Terminal instance. Profiles are templates; only
19883
+ * an instance declares a camera.
19884
+ */
19885
+ var TerminalInstanceInfoSchema = object({
19886
+ instanceId: string(),
19887
+ cameraStableId: string(),
19888
+ nodeId: string(),
19889
+ profileId: string(),
19890
+ profileLabel: string(),
19891
+ name: string(),
19892
+ enabled: boolean()
19893
+ });
19894
+ var TerminalLegacyCameraSchema = object({
19895
+ stableId: string(),
19896
+ nodeId: string(),
19897
+ profileId: string(),
19898
+ profileLabel: string(),
19899
+ name: string(),
19900
+ /** Only legacy monitor cameras can retain their historic stable identity. */
19901
+ adoptable: boolean()
19902
+ });
19778
19903
  var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
19779
19904
  seq: number().int().positive(),
19780
19905
  kind: literal("data"),
@@ -19791,7 +19916,29 @@ var TerminalOutputBatchSchema = object({
19791
19916
  snapshot: string().optional(),
19792
19917
  events: array(TerminalOutputEventSchema).readonly()
19793
19918
  });
19794
- method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
19919
+ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }), method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }), method(object({
19920
+ targetNodeId: string().min(1),
19921
+ profileId: string().min(1),
19922
+ name: string().trim().min(1).max(160).optional()
19923
+ }), TerminalInstanceInfoSchema, {
19924
+ kind: "mutation",
19925
+ auth: "admin"
19926
+ }), method(object({ instanceId: string().min(1) }), _void(), {
19927
+ kind: "mutation",
19928
+ auth: "admin"
19929
+ }), method(object({
19930
+ instanceId: string().min(1),
19931
+ enabled: boolean()
19932
+ }), TerminalInstanceInfoSchema, {
19933
+ kind: "mutation",
19934
+ auth: "admin"
19935
+ }), method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }), method(object({
19936
+ stableId: string().min(1),
19937
+ name: string().trim().min(1).max(160).optional()
19938
+ }), TerminalInstanceInfoSchema, {
19939
+ kind: "mutation",
19940
+ auth: "admin"
19941
+ }), method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }), method(object({
19795
19942
  profileId: string(),
19796
19943
  cols: number().int().positive(),
19797
19944
  rows: number().int().positive()
@@ -19808,7 +19955,13 @@ method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
19808
19955
  }), method(object({
19809
19956
  sessionId: string(),
19810
19957
  afterSeq: number().int().nonnegative(),
19811
- waitMs: number().int().min(0).max(2e3).default(0)
19958
+ waitMs: number().int().min(0).max(2e3).default(0),
19959
+ /**
19960
+ * Wait when a just-opened session has no output yet. Kept opt-in so a
19961
+ * browser's initial repaint remains immediate; the camera snapshot
19962
+ * relay uses it to avoid encoding a blank startup frame.
19963
+ */
19964
+ waitForOutput: boolean().optional()
19812
19965
  }), TerminalOutputBatchSchema, {
19813
19966
  kind: "mutation",
19814
19967
  auth: "admin",
@@ -21749,6 +21902,7 @@ var FaceInfoSchema = object({
21749
21902
  var FaceFilterEnum = _enum([
21750
21903
  "unassigned",
21751
21904
  "recognized",
21905
+ "identified",
21752
21906
  "all"
21753
21907
  ]);
21754
21908
  var MediaFileLiteSchema$1 = object({
@@ -21777,6 +21931,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21777
21931
  kind: "mutation",
21778
21932
  auth: "admin"
21779
21933
  }), method(object({
21934
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21935
+ deviceId: number().int().optional(),
21780
21936
  limit: number().int().positive().optional(),
21781
21937
  filter: FaceFilterEnum.optional(),
21782
21938
  /**
@@ -23542,6 +23698,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23542
23698
  capName: string().min(1).max(64),
23543
23699
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23544
23700
  valuePath: string().min(1).max(64)
23701
+ }),
23702
+ object({
23703
+ kind: literal("latest-recognition"),
23704
+ recognition: _enum(["person", "plate"])
23545
23705
  })
23546
23706
  ]);
23547
23707
  var OsdSlotBindingSchema = object({
@@ -23647,6 +23807,15 @@ method(object({ deviceId: number().int() }), object({
23647
23807
  }), object({ success: literal(true) }), {
23648
23808
  kind: "mutation",
23649
23809
  auth: "admin"
23810
+ }), method(object({
23811
+ sourceDeviceId: number().int(),
23812
+ targetDeviceId: number().int()
23813
+ }), object({
23814
+ copied: number().int().nonnegative(),
23815
+ skipped: number().int().nonnegative()
23816
+ }), {
23817
+ kind: "mutation",
23818
+ auth: "admin"
23650
23819
  }), method(object({
23651
23820
  deviceId: number().int(),
23652
23821
  slotId: string().min(1),
@@ -24532,13 +24701,19 @@ method(object({
24532
24701
  }), {
24533
24702
  kind: "mutation",
24534
24703
  auth: "admin"
24535
- }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
24704
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
24536
24705
  kind: "mutation",
24537
24706
  auth: "admin"
24538
- }), method(object({}), array(RelocateJobSchema).readonly(), {
24539
- kind: "query",
24707
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
24708
+ kind: "mutation",
24709
+ auth: "admin"
24710
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
24711
+ kind: "mutation",
24712
+ auth: "admin"
24713
+ }), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
24714
+ kind: "mutation",
24540
24715
  auth: "admin"
24541
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24716
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24542
24717
  kind: "mutation",
24543
24718
  auth: "admin"
24544
24719
  });
@@ -28737,6 +28912,12 @@ Object.freeze({
28737
28912
  addonId: null,
28738
28913
  access: "delete"
28739
28914
  },
28915
+ "osdManager.copyDeviceConfiguration": {
28916
+ capName: "osd-manager",
28917
+ capScope: "system",
28918
+ addonId: null,
28919
+ access: "create"
28920
+ },
28740
28921
  "osdManager.getConditionSupport": {
28741
28922
  capName: "osd-manager",
28742
28923
  capScope: "system",
@@ -28833,7 +29014,7 @@ Object.freeze({
28833
29014
  addonId: null,
28834
29015
  access: "create"
28835
29016
  },
28836
- "pipelineAnalytics.cancelMediaRelocate": {
29017
+ "pipelineAnalytics.cancelStorageMigrationMove": {
28837
29018
  capName: "pipeline-analytics",
28838
29019
  capScope: "device",
28839
29020
  addonId: null,
@@ -28905,12 +29086,6 @@ Object.freeze({
28905
29086
  addonId: null,
28906
29087
  access: "view"
28907
29088
  },
28908
- "pipelineAnalytics.getMediaRelocateStatus": {
28909
- capName: "pipeline-analytics",
28910
- capScope: "device",
28911
- addonId: null,
28912
- access: "view"
28913
- },
28914
29089
  "pipelineAnalytics.getMotionEvents": {
28915
29090
  capName: "pipeline-analytics",
28916
29091
  capScope: "device",
@@ -28947,6 +29122,12 @@ Object.freeze({
28947
29122
  addonId: null,
28948
29123
  access: "view"
28949
29124
  },
29125
+ "pipelineAnalytics.getStorageMigrationMoveStatus": {
29126
+ capName: "pipeline-analytics",
29127
+ capScope: "device",
29128
+ addonId: null,
29129
+ access: "view"
29130
+ },
28950
29131
  "pipelineAnalytics.getTrack": {
28951
29132
  capName: "pipeline-analytics",
28952
29133
  capScope: "device",
@@ -29025,6 +29206,12 @@ Object.freeze({
29025
29206
  addonId: null,
29026
29207
  access: "view"
29027
29208
  },
29209
+ "pipelineAnalytics.pauseForStorageMigration": {
29210
+ capName: "pipeline-analytics",
29211
+ capScope: "device",
29212
+ addonId: null,
29213
+ access: "create"
29214
+ },
29028
29215
  "pipelineAnalytics.proposeRetrainAnnotations": {
29029
29216
  capName: "pipeline-analytics",
29030
29217
  capScope: "device",
@@ -29055,7 +29242,7 @@ Object.freeze({
29055
29242
  addonId: null,
29056
29243
  access: "create"
29057
29244
  },
29058
- "pipelineAnalytics.relocateMedia": {
29245
+ "pipelineAnalytics.refreshStorageLocationsForMigration": {
29059
29246
  capName: "pipeline-analytics",
29060
29247
  capScope: "device",
29061
29248
  addonId: null,
@@ -29067,6 +29254,12 @@ Object.freeze({
29067
29254
  addonId: null,
29068
29255
  access: "create"
29069
29256
  },
29257
+ "pipelineAnalytics.resumeForStorageMigration": {
29258
+ capName: "pipeline-analytics",
29259
+ capScope: "device",
29260
+ addonId: null,
29261
+ access: "create"
29262
+ },
29070
29263
  "pipelineAnalytics.saveRetrainAnnotations": {
29071
29264
  capName: "pipeline-analytics",
29072
29265
  capScope: "device",
@@ -29091,6 +29284,12 @@ Object.freeze({
29091
29284
  addonId: null,
29092
29285
  access: "create"
29093
29286
  },
29287
+ "pipelineAnalytics.startStorageMigrationMove": {
29288
+ capName: "pipeline-analytics",
29289
+ capScope: "device",
29290
+ addonId: null,
29291
+ access: "create"
29292
+ },
29094
29293
  "pipelineAnalytics.wipeAllAnalytics": {
29095
29294
  capName: "pipeline-analytics",
29096
29295
  capScope: "device",
@@ -29457,6 +29656,12 @@ Object.freeze({
29457
29656
  addonId: null,
29458
29657
  access: "view"
29459
29658
  },
29659
+ "pipelineOrchestrator.pauseForStorageMigration": {
29660
+ capName: "pipeline-orchestrator",
29661
+ capScope: "system",
29662
+ addonId: null,
29663
+ access: "create"
29664
+ },
29460
29665
  "pipelineOrchestrator.rebalance": {
29461
29666
  capName: "pipeline-orchestrator",
29462
29667
  capScope: "system",
@@ -29481,6 +29686,12 @@ Object.freeze({
29481
29686
  addonId: null,
29482
29687
  access: "view"
29483
29688
  },
29689
+ "pipelineOrchestrator.resumeForStorageMigration": {
29690
+ capName: "pipeline-orchestrator",
29691
+ capScope: "system",
29692
+ addonId: null,
29693
+ access: "create"
29694
+ },
29484
29695
  "pipelineOrchestrator.saveTemplate": {
29485
29696
  capName: "pipeline-orchestrator",
29486
29697
  capScope: "system",
@@ -29877,7 +30088,7 @@ Object.freeze({
29877
30088
  addonId: null,
29878
30089
  access: "create"
29879
30090
  },
29880
- "recording.cancelRelocate": {
30091
+ "recording.cancelStorageMigrationMove": {
29881
30092
  capName: "recording",
29882
30093
  capScope: "system",
29883
30094
  addonId: null,
@@ -29913,7 +30124,7 @@ Object.freeze({
29913
30124
  addonId: null,
29914
30125
  access: "view"
29915
30126
  },
29916
- "recording.getRelocateStatus": {
30127
+ "recording.getStorageMigrationMoveStatus": {
29917
30128
  capName: "recording",
29918
30129
  capScope: "system",
29919
30130
  addonId: null,
@@ -29937,6 +30148,12 @@ Object.freeze({
29937
30148
  addonId: null,
29938
30149
  access: "view"
29939
30150
  },
30151
+ "recording.pauseForStorageMigration": {
30152
+ capName: "recording",
30153
+ capScope: "system",
30154
+ addonId: null,
30155
+ access: "create"
30156
+ },
29940
30157
  "recording.pruneFootage": {
29941
30158
  capName: "recording",
29942
30159
  capScope: "system",
@@ -29955,7 +30172,7 @@ Object.freeze({
29955
30172
  addonId: null,
29956
30173
  access: "view"
29957
30174
  },
29958
- "recording.relocateFootage": {
30175
+ "recording.refreshStorageLocationsForMigration": {
29959
30176
  capName: "recording",
29960
30177
  capScope: "system",
29961
30178
  addonId: null,
@@ -29979,12 +30196,24 @@ Object.freeze({
29979
30196
  addonId: null,
29980
30197
  access: "create"
29981
30198
  },
30199
+ "recording.resumeForStorageMigration": {
30200
+ capName: "recording",
30201
+ capScope: "system",
30202
+ addonId: null,
30203
+ access: "create"
30204
+ },
29982
30205
  "recording.setDeviceConfig": {
29983
30206
  capName: "recording",
29984
30207
  capScope: "system",
29985
30208
  addonId: null,
29986
30209
  access: "create"
29987
30210
  },
30211
+ "recording.startStorageMigrationMove": {
30212
+ capName: "recording",
30213
+ capScope: "system",
30214
+ addonId: null,
30215
+ access: "create"
30216
+ },
29988
30217
  "recordingExport.cancelExport": {
29989
30218
  capName: "recordingExport",
29990
30219
  capScope: "system",
@@ -30375,6 +30604,30 @@ Object.freeze({
30375
30604
  addonId: null,
30376
30605
  access: "view"
30377
30606
  },
30607
+ "storageMigration.cancel": {
30608
+ capName: "storage-migration",
30609
+ capScope: "system",
30610
+ addonId: null,
30611
+ access: "create"
30612
+ },
30613
+ "storageMigration.plan": {
30614
+ capName: "storage-migration",
30615
+ capScope: "system",
30616
+ addonId: null,
30617
+ access: "view"
30618
+ },
30619
+ "storageMigration.start": {
30620
+ capName: "storage-migration",
30621
+ capScope: "system",
30622
+ addonId: null,
30623
+ access: "create"
30624
+ },
30625
+ "storageMigration.status": {
30626
+ capName: "storage-migration",
30627
+ capScope: "system",
30628
+ addonId: null,
30629
+ access: "view"
30630
+ },
30378
30631
  "storageProvider.abortUpload": {
30379
30632
  capName: "storage-provider",
30380
30633
  capScope: "system",
@@ -30753,12 +31006,42 @@ Object.freeze({
30753
31006
  addonId: null,
30754
31007
  access: "create"
30755
31008
  },
31009
+ "terminalSession.adoptLegacyMonitor": {
31010
+ capName: "terminal-session",
31011
+ capScope: "system",
31012
+ addonId: null,
31013
+ access: "create"
31014
+ },
30756
31015
  "terminalSession.close": {
30757
31016
  capName: "terminal-session",
30758
31017
  capScope: "system",
30759
31018
  addonId: null,
30760
31019
  access: "create"
30761
31020
  },
31021
+ "terminalSession.createInstance": {
31022
+ capName: "terminal-session",
31023
+ capScope: "system",
31024
+ addonId: null,
31025
+ access: "create"
31026
+ },
31027
+ "terminalSession.deleteInstance": {
31028
+ capName: "terminal-session",
31029
+ capScope: "system",
31030
+ addonId: null,
31031
+ access: "delete"
31032
+ },
31033
+ "terminalSession.listInstances": {
31034
+ capName: "terminal-session",
31035
+ capScope: "system",
31036
+ addonId: null,
31037
+ access: "view"
31038
+ },
31039
+ "terminalSession.listLegacyCameras": {
31040
+ capName: "terminal-session",
31041
+ capScope: "system",
31042
+ addonId: null,
31043
+ access: "view"
31044
+ },
30762
31045
  "terminalSession.listProfiles": {
30763
31046
  capName: "terminal-session",
30764
31047
  capScope: "system",
@@ -30789,6 +31072,12 @@ Object.freeze({
30789
31072
  addonId: null,
30790
31073
  access: "create"
30791
31074
  },
31075
+ "terminalSession.setInstanceEnabled": {
31076
+ capName: "terminal-session",
31077
+ capScope: "system",
31078
+ addonId: null,
31079
+ access: "create"
31080
+ },
30792
31081
  "terminalSession.writeInput": {
30793
31082
  capName: "terminal-session",
30794
31083
  capScope: "system",
@@ -34699,6 +34988,48 @@ var DeviceFeaturesMirror = class {
34699
34988
  }
34700
34989
  };
34701
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
34702
35033
  //#region src/detection-wiring-controller.ts
34703
35034
  var DetectionWiringController = class {
34704
35035
  deps;
@@ -34903,7 +35234,8 @@ var DetectionWiringController = class {
34903
35234
  await this.stopDetection(deviceId);
34904
35235
  }
34905
35236
  this.activeDetections.set(deviceId, config);
34906
- 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") {
34907
35239
  if (config.motionSources.includes("analyzer")) try {
34908
35240
  await this.deps.session.ensureMotionWatch(deviceId, config);
34909
35241
  } catch (err) {
@@ -34994,11 +35326,44 @@ var DetectionWiringController = class {
34994
35326
  this.deps.forgetDeviceZones(deviceId);
34995
35327
  this.deps.forgetDeviceZoneRules(deviceId);
34996
35328
  this.deps.loadShed.forgetDevice(deviceId);
34997
- if (!this.deps.ctx().settings) return;
35329
+ const settings = this.deps.ctx().settings;
35330
+ if (!settings) return;
34998
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
+ }
34999
35340
  this.deps.logger.info("Purged pipeline config for removed device", { tags: { deviceId } });
35000
35341
  }
35001
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
+ /**
35002
35367
  * Handle a profile-slot change: build a fresh detection config from
35003
35368
  * per-device settings and start detection if motion is enabled.
35004
35369
  * Fires on `camera-streams.onProfileSlotsChanged` — any publish,
@@ -35008,6 +35373,11 @@ var DetectionWiringController = class {
35008
35373
  if (!this.deps.ctx()) return;
35009
35374
  const log = this.deps.logger.withTags({ deviceId });
35010
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
+ }
35011
35381
  const config = await this.buildDetectionConfig(deviceId);
35012
35382
  if (config === TRANSIENT_SLOT_READ) {
35013
35383
  log.warn("Profile-slot read failed transiently — keeping detection, scheduling retry");
@@ -35147,28 +35517,17 @@ var DetectionWiringController = class {
35147
35517
  });
35148
35518
  }
35149
35519
  /**
35150
- * Derive the watchdog camera descriptor from a runner config. Captures
35151
- * which stages are expected to flow CONTINUOUSLY (always-on audio,
35152
- * analyzer motion, always-on detection) the camStreamId feeding each.
35153
- * Audio has no dedicated stream field on the runner config, so it shares
35154
- * 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).
35155
35524
  *
35156
35525
  * Public — `PlacementService`'s `buildWatchdogCamera` closure (wired in
35157
- * `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.
35158
35528
  */
35159
- buildWatchdogCamera(config, deviceName) {
35160
- const continuousStages = /* @__PURE__ */ new Map();
35161
- if (config.audioMode === "always-on") continuousStages.set("audio", config.motionStreamId);
35162
- if (config.motionSources.includes("analyzer")) continuousStages.set("motion", config.motionStreamId);
35163
- if (config.detectionMode === "always-on") continuousStages.set("detection", config.detectionStreamId);
35164
- return {
35165
- deviceId: config.deviceId,
35166
- deviceName,
35167
- audioMode: config.audioMode,
35168
- detectionMode: config.detectionMode,
35169
- motionSources: config.motionSources,
35170
- continuousStages
35171
- };
35529
+ buildWatchdogCamera(config, deviceName, hasAudioSource) {
35530
+ return buildWatchdogCamera(config, deviceName, hasAudioSource);
35172
35531
  }
35173
35532
  /**
35174
35533
  * Watchdog recovery callback. Invoked when a camera's continuous stage
@@ -35181,7 +35540,7 @@ var DetectionWiringController = class {
35181
35540
  * Public — `PipelineWatchdog`'s `recover` closure (wired in
35182
35541
  * `orchestrator-bootstrap.ts`) calls straight through to this method.
35183
35542
  */
35184
- recoverPipelineStage(deviceId, stage, streamId) {
35543
+ recoverPipelineStage(deviceId, stage, streamId, attempt) {
35185
35544
  if (stage === "audio") {
35186
35545
  const cfg = this.deps.ledger.getConfig(deviceId);
35187
35546
  const audioCfg = cfg ? {
@@ -35195,6 +35554,14 @@ var DetectionWiringController = class {
35195
35554
  });
35196
35555
  });
35197
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
+ });
35198
35565
  this.deps.eventBus.emit({
35199
35566
  id: `watchdog-refresh-${deviceId}-${Date.now()}`,
35200
35567
  timestamp: /* @__PURE__ */ new Date(),
@@ -35597,6 +35964,64 @@ function computeDispatchGap(desired, known) {
35597
35964
  return [...desired].filter((id) => !known.has(id));
35598
35965
  }
35599
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
+ /**
35600
36025
  * Converge the orchestrator's `assignments` map to runner truth. Runner
35601
36026
  * attachments (`getLocalCameras`) are AUTHORITATIVE; this returns the actions
35602
36027
  * that make the map agree with them:
@@ -35743,11 +36168,17 @@ var PlacementService = class {
35743
36168
  deps;
35744
36169
  /** True while an auto-rebalance pass is running (skip overlap). */
35745
36170
  autoRebalanceInFlight = false;
36171
+ inFlightAttaches = 0;
36172
+ idleWaiters = [];
35746
36173
  constructor(deps) {
35747
36174
  this.deps = deps;
35748
36175
  }
35749
36176
  async dispatchCamera(runnerConfig) {
35750
36177
  if (!this.deps.ctx()) throw new Error("PipelineOrchestrator: dispatchCamera called before initialize");
36178
+ if (this.deps.maintenancePaused?.() === true) return {
36179
+ success: true,
36180
+ kind: "pending"
36181
+ };
35751
36182
  const currentDeviceKey = this.deps.ledger.getConfig(runnerConfig.deviceId)?.deviceKey ?? null;
35752
36183
  this.deps.ledger.setConfig(runnerConfig.deviceId, runnerConfig);
35753
36184
  if (this.deps.loadShed.dispatchDeferralCheck(runnerConfig.deviceId)) return {
@@ -35838,7 +36269,10 @@ var PlacementService = class {
35838
36269
  try {
35839
36270
  await this.attachOn(targetNodeId, attachConfig);
35840
36271
  this.deps.ledger.setConfig(runnerConfig.deviceId, attachConfig);
35841
- 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
+ }
35842
36276
  this.deps.ledger.recordAssignment(runnerConfig.deviceId, targetNodeId, reason, reason === "manual");
35843
36277
  } finally {
35844
36278
  dispatchGuard.dispose();
@@ -36138,6 +36572,7 @@ var PlacementService = class {
36138
36572
  * an absent field would produce.
36139
36573
  */
36140
36574
  async attachOn(nodeId, config) {
36575
+ if (this.deps.maintenancePaused?.() === true) throw new Error("pipeline dispatch is paused for storage migration");
36141
36576
  const ctx = this.deps.ctx();
36142
36577
  const api = ctx.api;
36143
36578
  if (!api) throw new Error(`attachOn(${nodeId}, ${config.deviceId}): this.ctx.api not available`);
@@ -36172,6 +36607,7 @@ var PlacementService = class {
36172
36607
  meta: logMeta
36173
36608
  });
36174
36609
  }).catch(() => {});
36610
+ this.inFlightAttaches++;
36175
36611
  try {
36176
36612
  await api.pipelineRunner.attachCamera.mutate({
36177
36613
  ...config,
@@ -36201,8 +36637,17 @@ var PlacementService = class {
36201
36637
  });
36202
36638
  }
36203
36639
  throw err;
36640
+ } finally {
36641
+ this.inFlightAttaches--;
36642
+ if (this.inFlightAttaches === 0) for (const resolve of this.idleWaiters.splice(0)) resolve();
36204
36643
  }
36205
36644
  }
36645
+ /** Wait until every attach that began before the maintenance gate closed has
36646
+ * settled. New attaches are rejected at the top of `attachOn`. */
36647
+ waitForMigrationIdle() {
36648
+ if (this.inFlightAttaches === 0) return Promise.resolve();
36649
+ return new Promise((resolve) => this.idleWaiters.push(resolve));
36650
+ }
36206
36651
  async detachOn(nodeId, deviceId) {
36207
36652
  const api = this.deps.ctx().api;
36208
36653
  if (!api) return;
@@ -36253,7 +36698,20 @@ var PlacementService = class {
36253
36698
  return;
36254
36699
  }
36255
36700
  await this.attachOn(nodeId, cached);
36256
- 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);
36257
36715
  }
36258
36716
  /**
36259
36717
  * Normalizes the `decision.kind === 'pending'` → `setPendingReason` +
@@ -37625,7 +38083,9 @@ var PipelineWatchdog = class {
37625
38083
  for (const [stage] of cam.continuousStages) stages.set(stage, {
37626
38084
  lastSeenMs: now,
37627
38085
  attempts: 0,
37628
- lastAttemptMs: 0
38086
+ lastAttemptMs: 0,
38087
+ everSeen: false,
38088
+ registeredAtMs: now
37629
38089
  });
37630
38090
  this.state.set(cam.deviceId, stages);
37631
38091
  }
@@ -37639,20 +38099,34 @@ var PipelineWatchdog = class {
37639
38099
  if (!rt) return;
37640
38100
  rt.lastSeenMs = this.deps.now();
37641
38101
  rt.attempts = 0;
38102
+ rt.everSeen = true;
37642
38103
  }
37643
38104
  tick() {
37644
38105
  const now = this.deps.now();
37645
38106
  for (const cam of this.cameras.values()) {
37646
- const { line, stalled, recoveries } = this.evaluate(cam, now);
38107
+ const { line, stalled, recoveries, disarms } = this.evaluate(cam, now);
37647
38108
  if (stalled) this.deps.logger.warn(line, { tags: { deviceId: cam.deviceId } });
37648
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
+ }
37649
38121
  for (const r of recoveries) {
37650
38122
  const rt = this.state.get(cam.deviceId)?.get(r.stage);
38123
+ let attempt = this.deps.thresholds.maxRecoveryAttempts;
37651
38124
  if (rt) {
37652
38125
  if (rt.attempts < this.deps.thresholds.maxRecoveryAttempts) rt.attempts += 1;
37653
38126
  rt.lastAttemptMs = now;
38127
+ attempt = rt.attempts;
37654
38128
  }
37655
- this.deps.recover(cam.deviceId, r.stage, r.streamId);
38129
+ this.deps.recover(cam.deviceId, r.stage, r.streamId, attempt);
37656
38130
  }
37657
38131
  }
37658
38132
  }
@@ -37667,6 +38141,7 @@ var PipelineWatchdog = class {
37667
38141
  evaluate(cam, now) {
37668
38142
  const parts = [];
37669
38143
  const recoveries = [];
38144
+ const disarms = [];
37670
38145
  let stalled = false;
37671
38146
  const stageOrder = [
37672
38147
  "audio",
@@ -37693,7 +38168,14 @@ var PipelineWatchdog = class {
37693
38168
  const thresholdMs = this.deps.thresholds[STAGE_THRESHOLD_KEY[stage]];
37694
38169
  const sSec = Math.round(staleness / 1e3);
37695
38170
  if (staleness <= thresholdMs) parts.push(`${stage}=${stageMode[stage]}@${streamId}(OK ${sSec}s)`);
37696
- 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) {
37697
38179
  stalled = true;
37698
38180
  if (now - rt.lastAttemptMs >= this.deps.thresholds.exhaustedRetryMs) {
37699
38181
  recoveries.push({
@@ -37714,7 +38196,8 @@ var PipelineWatchdog = class {
37714
38196
  return {
37715
38197
  line: `[watchdog] cam${cam.deviceId} ${cam.deviceName} · ${parts.join(" · ")} → ${stalled ? "STALLED" : "OK"}`,
37716
38198
  stalled,
37717
- recoveries
38199
+ recoveries,
38200
+ disarms
37718
38201
  };
37719
38202
  }
37720
38203
  };
@@ -38611,6 +39094,14 @@ var ReconcileController = class ReconcileController {
38611
39094
  /** Shared in-flight pass so a session dispatch waits for runner truth instead of racing it. */
38612
39095
  attachmentReconcilePromise = null;
38613
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
+ /**
38614
39105
  * Per-device backoff for retrying `handleDeviceRegistered` after a
38615
39106
  * TRANSIENT profile-slot read failure. Timers are `unref`'d and cleared on
38616
39107
  * shutdown / device removal.
@@ -38679,13 +39170,24 @@ var ReconcileController = class ReconcileController {
38679
39170
  return;
38680
39171
  }
38681
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
+ }
38682
39182
  const known = new Set(this.deps.ledger.listConfigIds());
38683
- const gap = computeDispatchGap(desired, known);
39183
+ const gap = computeDispatchGap(existing, known);
38684
39184
  this.deps.logger.info("dispatch reconcile", { meta: {
38685
39185
  desired: desired.size,
38686
39186
  known: known.size,
38687
- gap
39187
+ gap,
39188
+ vanished: vanished.size
38688
39189
  } });
39190
+ await this.tearDownVanishedDevices(vanished);
38689
39191
  for (const deviceId of gap) try {
38690
39192
  await this.deps.handleDeviceRegistered(deviceId);
38691
39193
  } catch (err) {
@@ -38704,6 +39206,53 @@ var ReconcileController = class ReconcileController {
38704
39206
  }
38705
39207
  }
38706
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
+ /**
38707
39256
  * Converge the in-memory `assignments` map to runner truth (Phase 1 of the
38708
39257
  * placement-derived-state design). Runner attachments (`getLocalCameras`) are
38709
39258
  * authoritative: detach duplicates/orphans, and drop stale assignments
@@ -39014,6 +39563,7 @@ var ReconcileController = class ReconcileController {
39014
39563
  this.slotReadRetryTimers.clear();
39015
39564
  this.slotReadRetryAttempts.clear();
39016
39565
  this.remoteHealthAttempts.clear();
39566
+ this.vanishedDevices = /* @__PURE__ */ new Map();
39017
39567
  }
39018
39568
  };
39019
39569
  //#endregion
@@ -40079,6 +40629,31 @@ async function buildOrchestratorControllers(deps) {
40079
40629
  getInitTimestamp: () => deps.initTimestamp
40080
40630
  });
40081
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
+ };
40082
40657
  const placement = new PlacementService({
40083
40658
  ctx: () => deps.ctx(),
40084
40659
  ctxIfReady: () => deps.ctxIfReady(),
@@ -40094,12 +40669,14 @@ async function buildOrchestratorControllers(deps) {
40094
40669
  getInferenceDeviceCaps: (nodeId) => deps.getInferenceDeviceCaps(nodeId),
40095
40670
  readPipelineDevicePin: (deviceId) => deps.readPipelineDevicePin(deviceId),
40096
40671
  inferenceRotation,
40097
- buildWatchdogCamera: (config, deviceName) => detectionWiring.buildWatchdogCamera(config, deviceName),
40672
+ buildWatchdogCamera: (config, deviceName, hasAudioSource) => detectionWiring.buildWatchdogCamera(config, deviceName, hasAudioSource),
40673
+ hasAudioSource: (deviceId, camStreamId) => resolveHasAudioSource(deviceId, camStreamId),
40098
40674
  isIdleSessionCamera: (deviceId) => deps.isIdleSessionCamera(deviceId),
40099
40675
  activeSessionNode: (deviceId) => session.activeSessionNode(deviceId),
40100
40676
  closeActiveSession: (deviceId) => session.closeSessionForReassign(deviceId),
40101
40677
  reconcilePlacementFromRunners: () => reconcile.reconcilePlacementFromRunners(),
40102
- emitResolvedCameraUpdated: (deviceId) => deps.emitResolvedCameraUpdated(deviceId)
40678
+ emitResolvedCameraUpdated: (deviceId) => deps.emitResolvedCameraUpdated(deviceId),
40679
+ maintenancePaused: () => deps.maintenancePaused()
40103
40680
  });
40104
40681
  const session = new SessionDispatchController({
40105
40682
  ledger,
@@ -40168,6 +40745,7 @@ async function buildOrchestratorControllers(deps) {
40168
40745
  loadShed,
40169
40746
  logger: deps.ctx().logger,
40170
40747
  handleDeviceRegistered: (deviceId) => deps.handleDeviceRegistered(deviceId),
40748
+ handleDeviceUnregistered: (deviceId) => deps.handleDeviceUnregistered(deviceId),
40171
40749
  dispatchCamera: (config) => deps.dispatchCamera(config),
40172
40750
  detachOn: (nodeId, deviceId) => deps.detachOn(nodeId, deviceId),
40173
40751
  localNodeId: () => localNodeId,
@@ -40268,7 +40846,7 @@ async function buildOrchestratorControllers(deps) {
40268
40846
  const pipelineWatchdog = new PipelineWatchdog({
40269
40847
  now: () => Date.now(),
40270
40848
  logger: deps.ctx().logger,
40271
- recover: (deviceId, stage, streamId) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId),
40849
+ recover: (deviceId, stage, streamId, attempt) => detectionWiring.recoverPipelineStage(deviceId, stage, streamId, attempt),
40272
40850
  thresholds: DEFAULT_WATCHDOG_THRESHOLDS
40273
40851
  });
40274
40852
  pipelineWatchdog.start(deps.watchdogIntervalMs);
@@ -41124,6 +41702,9 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
41124
41702
  /** Periodic auto-rebalance sweep timer (drift correction under hysteresis). */
41125
41703
  autoRebalanceTimer = null;
41126
41704
  initTimestamp = 0;
41705
+ /** Storage migration maintenance lease. It only gates dispatch; it does not
41706
+ * change any camera wrapper or persistent pipeline configuration. */
41707
+ storageMigrationLeaseId = null;
41127
41708
  constructor() {
41128
41709
  super({});
41129
41710
  }
@@ -41171,6 +41752,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
41171
41752
  rebalance: (opts) => this.rebalance(opts),
41172
41753
  setCameraPipelineForAgent: (input) => this.setCameraPipelineForAgent(input),
41173
41754
  dispatchCamera: (config) => this.dispatchCamera(config),
41755
+ maintenancePaused: () => this.storageMigrationLeaseId !== null,
41174
41756
  setTopology: (topology) => {
41175
41757
  this.topology = topology;
41176
41758
  },
@@ -41317,8 +41899,29 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
41317
41899
  this.settingsStore = null;
41318
41900
  }
41319
41901
  async dispatchCamera(runnerConfig) {
41902
+ if (this.storageMigrationLeaseId !== null) return {
41903
+ success: true,
41904
+ kind: "pending"
41905
+ };
41320
41906
  return this.placement.dispatchCamera(runnerConfig);
41321
41907
  }
41908
+ async pauseForStorageMigration(input) {
41909
+ if (this.storageMigrationLeaseId !== null && this.storageMigrationLeaseId !== input.leaseId) throw new Error("pipeline dispatch is already paused by another storage migration");
41910
+ this.storageMigrationLeaseId = input.leaseId;
41911
+ try {
41912
+ await this.placement.waitForMigrationIdle();
41913
+ return { paused: true };
41914
+ } catch (err) {
41915
+ this.storageMigrationLeaseId = null;
41916
+ throw err;
41917
+ }
41918
+ }
41919
+ async resumeForStorageMigration(input) {
41920
+ if (this.storageMigrationLeaseId === null) return { resumed: true };
41921
+ if (this.storageMigrationLeaseId !== input.leaseId) throw new Error("pipeline storage migration lease does not match");
41922
+ this.storageMigrationLeaseId = null;
41923
+ return { resumed: true };
41924
+ }
41322
41925
  async releaseCamera(input) {
41323
41926
  return this.placement.releaseCamera(input);
41324
41927
  }