camstack 1.2.84 → 1.2.85

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.
@@ -14522,7 +14522,7 @@ function date4(params) {
14522
14522
  // ../../node_modules/zod/v4/classic/external.js
14523
14523
  config(en_default());
14524
14524
 
14525
- // ../types/dist/sleep-Cwo3fRlv.mjs
14525
+ // ../types/dist/sleep-B195W080.mjs
14526
14526
  var WELL_KNOWN_TABS = [
14527
14527
  {
14528
14528
  id: "overview",
@@ -20401,6 +20401,35 @@ var deviceProviderCapability = {
20401
20401
  name: external_exports.string(),
20402
20402
  type: external_exports.string()
20403
20403
  }))),
20404
+ /**
20405
+ * Tear down and reconstruct ONE device in place from its persisted rows —
20406
+ * touching no other device this provider owns.
20407
+ *
20408
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
20409
+ * migrated numbers: after `swapIds` the runner's live instance still
20410
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
20411
+ * registrations and its log tags), and a live object cannot be renumbered.
20412
+ * Before this method the only flush was restarting the whole owning addon
20413
+ * — which took every camera the provider owns down with it (28 devices
20414
+ * for one migrated camera, measured 2026-09-04, and the morning of the
20415
+ * same day ~27 devices' native caps did not come back on their own).
20416
+ *
20417
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
20418
+ * that changes. The reply carries the id the device answers on NOW.
20419
+ * Implemented once in `BaseDeviceProvider` — decommission the live
20420
+ * instance (if any), then re-create from the persisted row: the same
20421
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
20422
+ * An RPC, never an event: a dropped event would leave the runner writing
20423
+ * against the wrong camera (D8).
20424
+ *
20425
+ * Construction can dial hardware, and the migrated source is
20426
+ * characteristically dead — the timeout covers a full activate window
20427
+ * rather than the 60 s default.
20428
+ */
20429
+ reloadDevice: method(external_exports.object({ stableId: external_exports.string() }), external_exports.object({ deviceId: external_exports.number() }), {
20430
+ kind: "mutation",
20431
+ timeoutMs: 3 * 6e4
20432
+ }),
20404
20433
  supportsDiscovery: method(external_exports.object({}), external_exports.boolean()),
20405
20434
  /**
20406
20435
  * Run a network scan. `params` carries optional provider-specific scan
@@ -20721,13 +20750,21 @@ var deviceManagerCapability = {
20721
20750
  * `sourceStillLive` names what stayed live; empty is the only value that
20722
20751
  * means the old hardware is quiet. The migration proceeds either way —
20723
20752
  * refusing would refuse the case this exists for.
20753
+ *
20754
+ * **Safe to retry.** A duplicate call while a migration involving either
20755
+ * device is in flight is REFUSED, never queued; a repeat of a swap that
20756
+ * already completed (matched against the `addonId`/`stableId` fingerprint
20757
+ * the swap left on the rows, within 15 min) is a no-op returning the
20758
+ * original report. A blind re-run can no longer silently undo the
20759
+ * migration it retried.
20724
20760
  */
20725
20761
  migrateDevice: method(external_exports.object({
20726
20762
  sourceId: external_exports.number(),
20727
20763
  targetId: external_exports.number()
20728
20764
  }), MigrateDeviceResultSchema, {
20729
20765
  kind: "mutation",
20730
- auth: "admin"
20766
+ auth: "admin",
20767
+ timeoutMs: 12 * 6e4
20731
20768
  }),
20732
20769
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
20733
20770
  registerDevice: method(DeviceRegisterPayloadSchema, external_exports.void(), { kind: "mutation" }),
@@ -41144,6 +41181,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
41144
41181
  addonId: null,
41145
41182
  access: "view"
41146
41183
  },
41184
+ "deviceProvider.reloadDevice": {
41185
+ capName: "device-provider",
41186
+ capScope: "system",
41187
+ addonId: null,
41188
+ access: "create"
41189
+ },
41147
41190
  "deviceProvider.start": {
41148
41191
  capName: "device-provider",
41149
41192
  capScope: "system",
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runDiscover
4
- } from "./chunk-O7PXXZZ7.js";
4
+ } from "./chunk-Y3OL5WYZ.js";
5
5
  import "./chunk-LMMQX4CK.js";
6
6
 
7
7
  // src/cli.ts
@@ -38,7 +38,7 @@ async function runServe(args) {
38
38
  ...typeof values.data === "string" ? { data: values.data } : {}
39
39
  };
40
40
  Object.assign(process.env, buildServeEnv(opts));
41
- await import("./launcher-EQ3SI35Z.js");
41
+ await import("./launcher-6T2UFXIG.js");
42
42
  }
43
43
 
44
44
  // src/commands/agent.ts
@@ -83,7 +83,7 @@ async function runAgent(args) {
83
83
  ...typeof values.port === "string" ? { port: values.port } : {}
84
84
  };
85
85
  Object.assign(process.env, buildAgentEnv(opts));
86
- await import("./launcher-EQ3SI35Z.js");
86
+ await import("./launcher-6T2UFXIG.js");
87
87
  }
88
88
 
89
89
  // src/commands/setup.ts
@@ -1130,7 +1130,7 @@ function isUnknown(_value) {
1130
1130
  return true;
1131
1131
  }
1132
1132
  async function resolveServerInteractive(presetNamespace) {
1133
- const { discoverNodes, resolveHubFromDiscovered, filterHubNodes, DEFAULT_HUB_HTTPS_PORT } = await import("./discover-E5HX2QAP.js");
1133
+ const { discoverNodes, resolveHubFromDiscovered, filterHubNodes, DEFAULT_HUB_HTTPS_PORT } = await import("./discover-PPTEAP5N.js");
1134
1134
  if (presetNamespace) {
1135
1135
  const spinner4 = clack.spinner();
1136
1136
  spinner4.start(`Discovering hub on LAN (namespace "${presetNamespace}")`);
@@ -5,7 +5,7 @@ import {
5
5
  filterHubNodes,
6
6
  resolveHubFromDiscovered,
7
7
  runDiscover
8
- } from "./chunk-O7PXXZZ7.js";
8
+ } from "./chunk-Y3OL5WYZ.js";
9
9
  import "./chunk-LMMQX4CK.js";
10
10
  export {
11
11
  DEFAULT_HUB_HTTPS_PORT,
@@ -23633,9 +23633,9 @@ var require_zod = __commonJS({
23633
23633
  }
23634
23634
  });
23635
23635
 
23636
- // ../system/dist/dist-TEwQZL3g.js
23637
- var require_dist_TEwQZL3g = __commonJS({
23638
- "../system/dist/dist-TEwQZL3g.js"(exports) {
23636
+ // ../system/dist/dist-whe0b4_3.js
23637
+ var require_dist_whe0b4_3 = __commonJS({
23638
+ "../system/dist/dist-whe0b4_3.js"(exports) {
23639
23639
  "use strict";
23640
23640
  var zod = require_zod();
23641
23641
  var EventCategory = /* @__PURE__ */ (function(EventCategory2) {
@@ -31114,6 +31114,35 @@ var require_dist_TEwQZL3g = __commonJS({
31114
31114
  name: zod.z.string(),
31115
31115
  type: zod.z.string()
31116
31116
  }))),
31117
+ /**
31118
+ * Tear down and reconstruct ONE device in place from its persisted rows —
31119
+ * touching no other device this provider owns.
31120
+ *
31121
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
31122
+ * migrated numbers: after `swapIds` the runner's live instance still
31123
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
31124
+ * registrations and its log tags), and a live object cannot be renumbered.
31125
+ * Before this method the only flush was restarting the whole owning addon
31126
+ * — which took every camera the provider owns down with it (28 devices
31127
+ * for one migrated camera, measured 2026-09-04, and the morning of the
31128
+ * same day ~27 devices' native caps did not come back on their own).
31129
+ *
31130
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
31131
+ * that changes. The reply carries the id the device answers on NOW.
31132
+ * Implemented once in `BaseDeviceProvider` — decommission the live
31133
+ * instance (if any), then re-create from the persisted row: the same
31134
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
31135
+ * An RPC, never an event: a dropped event would leave the runner writing
31136
+ * against the wrong camera (D8).
31137
+ *
31138
+ * Construction can dial hardware, and the migrated source is
31139
+ * characteristically dead — the timeout covers a full activate window
31140
+ * rather than the 60 s default.
31141
+ */
31142
+ reloadDevice: method(zod.z.object({ stableId: zod.z.string() }), zod.z.object({ deviceId: zod.z.number() }), {
31143
+ kind: "mutation",
31144
+ timeoutMs: 3 * 6e4
31145
+ }),
31117
31146
  supportsDiscovery: method(zod.z.object({}), zod.z.boolean()),
31118
31147
  /**
31119
31148
  * Run a network scan. `params` carries optional provider-specific scan
@@ -31434,13 +31463,21 @@ var require_dist_TEwQZL3g = __commonJS({
31434
31463
  * `sourceStillLive` names what stayed live; empty is the only value that
31435
31464
  * means the old hardware is quiet. The migration proceeds either way —
31436
31465
  * refusing would refuse the case this exists for.
31466
+ *
31467
+ * **Safe to retry.** A duplicate call while a migration involving either
31468
+ * device is in flight is REFUSED, never queued; a repeat of a swap that
31469
+ * already completed (matched against the `addonId`/`stableId` fingerprint
31470
+ * the swap left on the rows, within 15 min) is a no-op returning the
31471
+ * original report. A blind re-run can no longer silently undo the
31472
+ * migration it retried.
31437
31473
  */
31438
31474
  migrateDevice: method(zod.z.object({
31439
31475
  sourceId: zod.z.number(),
31440
31476
  targetId: zod.z.number()
31441
31477
  }), MigrateDeviceResultSchema, {
31442
31478
  kind: "mutation",
31443
- auth: "admin"
31479
+ auth: "admin",
31480
+ timeoutMs: 12 * 6e4
31444
31481
  }),
31445
31482
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
31446
31483
  registerDevice: method(DeviceRegisterPayloadSchema, zod.z.void(), { kind: "mutation" }),
@@ -52508,6 +52545,12 @@ var require_dist_TEwQZL3g = __commonJS({
52508
52545
  addonId: null,
52509
52546
  access: "view"
52510
52547
  },
52548
+ "deviceProvider.reloadDevice": {
52549
+ capName: "device-provider",
52550
+ capScope: "system",
52551
+ addonId: null,
52552
+ access: "create"
52553
+ },
52511
52554
  "deviceProvider.start": {
52512
52555
  capName: "device-provider",
52513
52556
  capScope: "system",
@@ -59999,7 +60042,7 @@ var require_alerts_addon = __commonJS({
59999
60042
  [Symbol.toStringTag]: { value: "Module" }
60000
60043
  });
60001
60044
  require_chunk_Cek0wNdY();
60002
- var require_dist10 = require_dist_TEwQZL3g();
60045
+ var require_dist10 = require_dist_whe0b4_3();
60003
60046
  function selectExpired(alerts, cutoffMs) {
60004
60047
  return alerts.filter((a) => a.createdAt < cutoffMs).sort((a, b) => a.createdAt - b.createdAt).map((a) => a.id);
60005
60048
  }
@@ -60818,7 +60861,7 @@ var require_console_logging = __commonJS({
60818
60861
  [Symbol.toStringTag]: { value: "Module" }
60819
60862
  });
60820
60863
  require_chunk_Cek0wNdY();
60821
- var require_dist10 = require_dist_TEwQZL3g();
60864
+ var require_dist10 = require_dist_whe0b4_3();
60822
60865
  var require_formatter = require_formatter_DqAKDlvN();
60823
60866
  var LEVEL_RANK = {
60824
60867
  debug: 0,
@@ -60912,7 +60955,7 @@ var require_core_blocks_addon = __commonJS({
60912
60955
  "use strict";
60913
60956
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
60914
60957
  var require_chunk = require_chunk_Cek0wNdY();
60915
- var require_dist10 = require_dist_TEwQZL3g();
60958
+ var require_dist10 = require_dist_whe0b4_3();
60916
60959
  var node_crypto = __require("crypto");
60917
60960
  var node_fs_promises = __require("fs/promises");
60918
60961
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -61809,11 +61852,11 @@ var require_core_blocks = __commonJS({
61809
61852
  }
61810
61853
  });
61811
61854
 
61812
- // ../system/dist/retired-settings-keys-DauPtbFR.js
61813
- var require_retired_settings_keys_DauPtbFR = __commonJS({
61814
- "../system/dist/retired-settings-keys-DauPtbFR.js"(exports) {
61855
+ // ../system/dist/retired-settings-keys-BdvmiaK7.js
61856
+ var require_retired_settings_keys_BdvmiaK7 = __commonJS({
61857
+ "../system/dist/retired-settings-keys-BdvmiaK7.js"(exports) {
61815
61858
  "use strict";
61816
- var require_dist10 = require_dist_TEwQZL3g();
61859
+ var require_dist10 = require_dist_whe0b4_3();
61817
61860
  function settingsStoreIsAuthoritativeHere(env) {
61818
61861
  const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
61819
61862
  return raw === "" || raw === "hub";
@@ -64027,8 +64070,8 @@ var require_device_manager_addon = __commonJS({
64027
64070
  [Symbol.toStringTag]: { value: "Module" }
64028
64071
  });
64029
64072
  require_chunk_Cek0wNdY();
64030
- var require_dist10 = require_dist_TEwQZL3g();
64031
- var require_retired_settings_keys = require_retired_settings_keys_DauPtbFR();
64073
+ var require_dist10 = require_dist_whe0b4_3();
64074
+ var require_retired_settings_keys = require_retired_settings_keys_BdvmiaK7();
64032
64075
  var node_crypto = __require("crypto");
64033
64076
  var _camstack_types_node = require_node();
64034
64077
  var JOB_HISTORY = 20;
@@ -66527,10 +66570,26 @@ var require_device_manager_addon = __commonJS({
66527
66570
  detail
66528
66571
  };
66529
66572
  }
66573
+ async function setSwitchBounded(deps, deviceId, switchId, enabled) {
66574
+ const budget = deps.switchTimeoutMs ?? 15e3;
66575
+ const write = deps.setSwitch(deviceId, switchId, enabled);
66576
+ write.catch(() => {
66577
+ });
66578
+ let timer;
66579
+ try {
66580
+ await Promise.race([write, new Promise((_resolve, reject) => {
66581
+ timer = setTimeout(() => {
66582
+ reject(/* @__PURE__ */ new Error(`switch write did not answer within ${budget}ms \u2014 reported unreachable; if it lands late, a duplicate write of the same value is a no-op`));
66583
+ }, budget);
66584
+ })]);
66585
+ } finally {
66586
+ if (timer !== void 0) clearTimeout(timer);
66587
+ }
66588
+ }
66530
66589
  async function applyAll(deps, deviceId, enabled) {
66531
66590
  const out = [];
66532
66591
  for (const switchId of require_dist10.CAMERA_SWITCH_ORDER) try {
66533
- await deps.setSwitch(deviceId, switchId, enabled);
66592
+ await setSwitchBounded(deps, deviceId, switchId, enabled);
66534
66593
  out.push({
66535
66594
  deviceId,
66536
66595
  switchId,
@@ -66571,7 +66630,6 @@ var require_device_manager_addon = __commonJS({
66571
66630
  });
66572
66631
  }
66573
66632
  const up = await applyAll(deps, sourceId, true);
66574
- await deps.respawnRunners([sourceId, targetId]);
66575
66633
  deps.log("camera migrated", {
66576
66634
  sourceId,
66577
66635
  targetId,
@@ -66586,6 +66644,50 @@ var require_device_manager_addon = __commonJS({
66586
66644
  swapped: true
66587
66645
  };
66588
66646
  }
66647
+ async function reloadMigratedDevices(pctx, targets) {
66648
+ const ctx = pctx.host.ctx;
66649
+ for (const target of targets) {
66650
+ pctx.metaStore.idToAddonId.set(target.deviceId, target.addonId);
66651
+ pctx.host.remoteNativeCaps.delete(target.deviceId);
66652
+ pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(target.deviceId);
66653
+ }
66654
+ const fallbackAddons = /* @__PURE__ */ new Map();
66655
+ await Promise.all(targets.map(async (target) => {
66656
+ try {
66657
+ const reloaded = await (await pctx.host.requireDeviceProvider(target.addonId)).reloadDevice({ stableId: target.stableId });
66658
+ ctx.logger.info("migration: device reloaded in place \u2014 no other device disturbed", {
66659
+ tags: { deviceId: target.deviceId },
66660
+ meta: {
66661
+ addonId: target.addonId,
66662
+ stableId: target.stableId,
66663
+ reloadedAs: reloaded.deviceId
66664
+ }
66665
+ });
66666
+ } catch (err) {
66667
+ ctx.logger.error("migration: in-place reload FAILED \u2014 falling back to a provider restart", {
66668
+ tags: { deviceId: target.deviceId },
66669
+ meta: {
66670
+ addonId: target.addonId,
66671
+ stableId: target.stableId,
66672
+ error: err instanceof Error ? err.message : String(err)
66673
+ }
66674
+ });
66675
+ fallbackAddons.set(target.addonId, target.deviceId);
66676
+ }
66677
+ }));
66678
+ if (fallbackAddons.size === 0) return;
66679
+ await Promise.all([...fallbackAddons.entries()].map(async ([addonId, migratedDeviceId]) => {
66680
+ const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
66681
+ ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
66682
+ tags: { deviceId: migratedDeviceId },
66683
+ meta: {
66684
+ addonId,
66685
+ devicesTakenDown: owned
66686
+ }
66687
+ });
66688
+ await ctx.api.addons.restartAddon.mutate({ addonId });
66689
+ }));
66690
+ }
66589
66691
  var PROFILE_MAP_CONFIG_KEY = "_profileMap";
66590
66692
  var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
66591
66693
  function splitConfig(config) {
@@ -66674,68 +66776,110 @@ var require_device_manager_addon = __commonJS({
66674
66776
  async function migrateDevice(pctx, input) {
66675
66777
  const { sourceId, targetId } = input;
66676
66778
  const ctx = pctx.host.ctx;
66677
- return await pctx.metaStore.withMetaWriteLock(async () => {
66678
- for (const id of [sourceId, targetId]) if (await pctx.metaStore.rows.get(id) === null) throw new Error(`[device-manager] migrateDevice: device ${id} does not exist`);
66679
- const tempId = await pctx.metaStore.allocateNextDeviceId();
66680
- return await migrateDevice$1({
66681
- setSwitch: async (deviceId, switchId, enabled) => {
66682
- await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
66683
- deviceId,
66684
- switchId,
66685
- enabled
66686
- });
66687
- },
66688
- swapIds: async (a, b) => {
66689
- await pctx.metaStore.rows.swapIds(a, b, tempId);
66690
- },
66691
- swapHardwareState: async (a, b) => {
66692
- const swapped = await swapHardwareState(pctx.settings, a, b);
66693
- pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
66694
- pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
66695
- ctx.logger.info("migration: hardware-scoped device state swapped", {
66696
- tags: { deviceId: a },
66779
+ pctx.migrationGuard.begin(sourceId, targetId);
66780
+ try {
66781
+ const outcome = await pctx.metaStore.withMetaWriteLock(async () => {
66782
+ const sourceRow = await pctx.metaStore.rows.get(sourceId);
66783
+ if (sourceRow === null) throw new Error(`[device-manager] migrateDevice: device ${sourceId} does not exist`);
66784
+ const targetRow = await pctx.metaStore.rows.get(targetId);
66785
+ if (targetRow === null) throw new Error(`[device-manager] migrateDevice: device ${targetId} does not exist`);
66786
+ const replay = pctx.migrationGuard.matchReplay(sourceId, targetId, {
66787
+ addonId: sourceRow.meta.addonId,
66788
+ stableId: sourceRow.meta.stableId
66789
+ }, {
66790
+ addonId: targetRow.meta.addonId,
66791
+ stableId: targetRow.meta.stableId
66792
+ });
66793
+ if (replay !== null) {
66794
+ ctx.logger.info("migrateDevice: this swap already completed \u2014 replaying the original report, NOTHING re-run", {
66795
+ tags: { deviceId: sourceId },
66697
66796
  meta: {
66698
- sourceId: a,
66699
- targetId: b,
66700
- hardwareKeysToSource: swapped.hardwareKeysToSource,
66701
- hardwareKeysToTarget: swapped.hardwareKeysToTarget,
66702
- roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
66703
- roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
66797
+ sourceId,
66798
+ targetId
66704
66799
  }
66705
66800
  });
66706
- },
66707
- forgetStreamState: async (deviceId) => {
66708
- await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
66709
- },
66710
- respawnRunners: async (deviceIds) => {
66711
- const owners = /* @__PURE__ */ new Set();
66712
- for (const deviceId of deviceIds) {
66713
- const addonId = pctx.metaStore.idToAddonId.get(deviceId);
66714
- if (addonId !== void 0) owners.add(addonId);
66715
- }
66716
- for (const addonId of owners) {
66717
- const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
66718
- ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
66719
- tags: { deviceId: sourceId },
66801
+ return {
66802
+ result: replay,
66803
+ reloadTargets: null
66804
+ };
66805
+ }
66806
+ const tempId = await pctx.metaStore.allocateNextDeviceId();
66807
+ const result = await migrateDevice$1({
66808
+ setSwitch: async (deviceId, switchId, enabled) => {
66809
+ if (switchId === "stream-broker") {
66810
+ const moved = await writeDisabledFlagUnderLock(pctx, deviceId, !enabled);
66811
+ emitDisabledEvents(pctx, deviceId, !enabled, moved);
66812
+ return;
66813
+ }
66814
+ await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
66815
+ deviceId,
66816
+ switchId,
66817
+ enabled
66818
+ });
66819
+ },
66820
+ swapIds: async (a, b) => {
66821
+ await pctx.metaStore.rows.swapIds(a, b, tempId);
66822
+ },
66823
+ swapHardwareState: async (a, b) => {
66824
+ const swapped = await swapHardwareState(pctx.settings, a, b);
66825
+ pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
66826
+ pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
66827
+ ctx.logger.info("migration: hardware-scoped device state swapped", {
66828
+ tags: { deviceId: a },
66720
66829
  meta: {
66721
- addonId,
66722
- devicesTakenDown: owned
66830
+ sourceId: a,
66831
+ targetId: b,
66832
+ hardwareKeysToSource: swapped.hardwareKeysToSource,
66833
+ hardwareKeysToTarget: swapped.hardwareKeysToTarget,
66834
+ roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
66835
+ roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
66723
66836
  }
66724
66837
  });
66725
- await ctx.api.addons.restartAddon.mutate({ addonId });
66838
+ },
66839
+ forgetStreamState: async (deviceId) => {
66840
+ await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
66841
+ },
66842
+ log: (message, meta) => {
66843
+ ctx.logger.info(message, {
66844
+ tags: { deviceId: sourceId },
66845
+ meta
66846
+ });
66726
66847
  }
66727
- },
66728
- log: (message, meta) => {
66729
- ctx.logger.info(message, {
66730
- tags: { deviceId: sourceId },
66731
- meta
66732
- });
66733
- }
66734
- }, {
66735
- sourceId,
66736
- targetId
66848
+ }, {
66849
+ sourceId,
66850
+ targetId
66851
+ });
66852
+ pctx.migrationGuard.recordCompleted({
66853
+ sourceId,
66854
+ targetId,
66855
+ identityAtSource: {
66856
+ addonId: targetRow.meta.addonId,
66857
+ stableId: targetRow.meta.stableId
66858
+ },
66859
+ identityAtTarget: {
66860
+ addonId: sourceRow.meta.addonId,
66861
+ stableId: sourceRow.meta.stableId
66862
+ },
66863
+ result
66864
+ });
66865
+ return {
66866
+ result,
66867
+ reloadTargets: [{
66868
+ deviceId: sourceId,
66869
+ addonId: targetRow.meta.addonId,
66870
+ stableId: targetRow.meta.stableId
66871
+ }, {
66872
+ deviceId: targetId,
66873
+ addonId: sourceRow.meta.addonId,
66874
+ stableId: sourceRow.meta.stableId
66875
+ }]
66876
+ };
66737
66877
  });
66738
- });
66878
+ if (outcome.reloadTargets !== null) await reloadMigratedDevices(pctx, outcome.reloadTargets);
66879
+ return outcome.result;
66880
+ } finally {
66881
+ pctx.migrationGuard.end(sourceId, targetId);
66882
+ }
66739
66883
  }
66740
66884
  async function registerDevice(pctx, input) {
66741
66885
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
@@ -67256,18 +67400,17 @@ var require_device_manager_addon = __commonJS({
67256
67400
  }
67257
67401
  });
67258
67402
  }
67259
- async function setDisabled(pctx, input) {
67260
- const { deviceId, disabled } = input;
67261
- const moved = await pctx.metaStore.withMetaWriteLock(async () => {
67262
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
67263
- if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
67264
- const { meta: m } = persisted;
67265
- await pctx.metaStore.rows.patch(deviceId, { disabled });
67266
- return {
67267
- changed: (m.disabled ?? false) !== disabled,
67268
- integrationId: m.integrationId ?? ""
67269
- };
67270
- });
67403
+ async function writeDisabledFlagUnderLock(pctx, deviceId, disabled) {
67404
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
67405
+ if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
67406
+ const { meta: m } = persisted;
67407
+ await pctx.metaStore.rows.patch(deviceId, { disabled });
67408
+ return {
67409
+ changed: (m.disabled ?? false) !== disabled,
67410
+ integrationId: m.integrationId ?? ""
67411
+ };
67412
+ }
67413
+ function emitDisabledEvents(pctx, deviceId, disabled, moved) {
67271
67414
  pctx.host.ctx.eventBus.emit({
67272
67415
  id: (0, node_crypto.randomUUID)(),
67273
67416
  timestamp: /* @__PURE__ */ new Date(),
@@ -67297,6 +67440,10 @@ var require_device_manager_addon = __commonJS({
67297
67440
  }
67298
67441
  });
67299
67442
  }
67443
+ async function setDisabled(pctx, input) {
67444
+ const { deviceId, disabled } = input;
67445
+ emitDisabledEvents(pctx, deviceId, disabled, await pctx.metaStore.withMetaWriteLock(() => writeDisabledFlagUnderLock(pctx, deviceId, disabled)));
67446
+ }
67300
67447
  async function loadRuntimeState(pctx, input) {
67301
67448
  const { deviceId } = input;
67302
67449
  if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
@@ -68677,6 +68824,42 @@ var require_device_manager_addon = __commonJS({
68677
68824
  };
68678
68825
  return resolveRemoteNativeCapFromRegistry(pctx.bindingsDeps, capName, deviceId);
68679
68826
  }
68827
+ function sameIdentity(a, b) {
68828
+ return a.addonId === b.addonId && a.stableId === b.stableId;
68829
+ }
68830
+ function createMigrationGuard(now = Date.now) {
68831
+ const inFlight = /* @__PURE__ */ new Set();
68832
+ const completed = /* @__PURE__ */ new Map();
68833
+ const pairKey = (sourceId, targetId) => `${sourceId}->${targetId}`;
68834
+ return {
68835
+ begin(sourceId, targetId) {
68836
+ for (const id of [sourceId, targetId]) if (inFlight.has(id)) throw new Error(`[device-manager] migrateDevice: a migration involving device ${id} is already in flight \u2014 this call was refused, not queued. The running migration's report stands; re-read the device rows before retrying.`);
68837
+ inFlight.add(sourceId);
68838
+ inFlight.add(targetId);
68839
+ },
68840
+ end(sourceId, targetId) {
68841
+ inFlight.delete(sourceId);
68842
+ inFlight.delete(targetId);
68843
+ },
68844
+ recordCompleted(record) {
68845
+ completed.set(pairKey(record.sourceId, record.targetId), {
68846
+ ...record,
68847
+ completedAt: now()
68848
+ });
68849
+ },
68850
+ matchReplay(sourceId, targetId, atSource, atTarget) {
68851
+ const key = pairKey(sourceId, targetId);
68852
+ const record = completed.get(key);
68853
+ if (record === void 0) return null;
68854
+ if (now() - record.completedAt > 9e5) {
68855
+ completed.delete(key);
68856
+ return null;
68857
+ }
68858
+ if (!sameIdentity(record.identityAtSource, atSource) || !sameIdentity(record.identityAtTarget, atTarget)) return null;
68859
+ return record.result;
68860
+ }
68861
+ };
68862
+ }
68680
68863
  var DeviceManagerAddon = class extends require_dist10.BaseAddon {
68681
68864
  constructor() {
68682
68865
  super({});
@@ -68915,6 +69098,7 @@ var require_device_manager_addon = __commonJS({
68915
69098
  settings,
68916
69099
  registry,
68917
69100
  bindingsDeps: this.bindingsDeps,
69101
+ migrationGuard: createMigrationGuard(),
68918
69102
  requireDeviceOps,
68919
69103
  stampIntegrationId: stampIntegrationId$1,
68920
69104
  get provider() {
@@ -69156,7 +69340,7 @@ var require_hub_forwarder = __commonJS({
69156
69340
  [Symbol.toStringTag]: { value: "Module" }
69157
69341
  });
69158
69342
  require_chunk_Cek0wNdY();
69159
- var require_dist10 = require_dist_TEwQZL3g();
69343
+ var require_dist10 = require_dist_whe0b4_3();
69160
69344
  var require_formatter = require_formatter_DqAKDlvN();
69161
69345
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
69162
69346
  var HubForwarderDestination = class {
@@ -69293,7 +69477,7 @@ var require_liveness_monitor_addon = __commonJS({
69293
69477
  "use strict";
69294
69478
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
69295
69479
  require_chunk_Cek0wNdY();
69296
- var require_dist10 = require_dist_TEwQZL3g();
69480
+ var require_dist10 = require_dist_whe0b4_3();
69297
69481
  var NO_DEVICES = "liveness:no-devices";
69298
69482
  var ALL_OFFLINE = "liveness:all-devices-offline";
69299
69483
  var NO_FOOTAGE_PREFIX = "liveness:no-footage:";
@@ -69483,7 +69667,7 @@ var require_local_auth_addon = __commonJS({
69483
69667
  [Symbol.toStringTag]: { value: "Module" }
69484
69668
  });
69485
69669
  var require_chunk = require_chunk_Cek0wNdY();
69486
- var require_dist10 = require_dist_TEwQZL3g();
69670
+ var require_dist10 = require_dist_whe0b4_3();
69487
69671
  var node_crypto = __require("crypto");
69488
69672
  node_crypto = require_chunk.__toESM(node_crypto);
69489
69673
  var crypto$1 = __require("crypto");
@@ -77296,7 +77480,7 @@ var require_loki_logging = __commonJS({
77296
77480
  [Symbol.toStringTag]: { value: "Module" }
77297
77481
  });
77298
77482
  require_chunk_Cek0wNdY();
77299
- var require_dist10 = require_dist_TEwQZL3g();
77483
+ var require_dist10 = require_dist_whe0b4_3();
77300
77484
  function sanitizeLabelName(raw) {
77301
77485
  const replaced = raw.replaceAll(/[^a-zA-Z0-9_]/g, "_");
77302
77486
  return /^[a-zA-Z_]/.test(replaced) ? replaced : `_${replaced}`;
@@ -77861,7 +78045,7 @@ var require_native_metrics_addon = __commonJS({
77861
78045
  [Symbol.toStringTag]: { value: "Module" }
77862
78046
  });
77863
78047
  var require_chunk = require_chunk_Cek0wNdY();
77864
- var require_dist10 = require_dist_TEwQZL3g();
78048
+ var require_dist10 = require_dist_whe0b4_3();
77865
78049
  var node_fs_promises = __require("fs/promises");
77866
78050
  var node_child_process = __require("child_process");
77867
78051
  var node_util = __require("util");
@@ -80445,7 +80629,7 @@ var require_filesystem_storage_addon = __commonJS({
80445
80629
  [Symbol.toStringTag]: { value: "Module" }
80446
80630
  });
80447
80631
  var require_chunk = require_chunk_Cek0wNdY();
80448
- var require_dist10 = require_dist_TEwQZL3g();
80632
+ var require_dist10 = require_dist_whe0b4_3();
80449
80633
  var node_crypto = __require("crypto");
80450
80634
  var node_fs_promises = __require("fs/promises");
80451
80635
  var node_path = __require("path");
@@ -81561,8 +81745,8 @@ var require_sqlite_settings_addon = __commonJS({
81561
81745
  [Symbol.toStringTag]: { value: "Module" }
81562
81746
  });
81563
81747
  var require_chunk = require_chunk_Cek0wNdY();
81564
- var require_dist10 = require_dist_TEwQZL3g();
81565
- var require_retired_settings_keys = require_retired_settings_keys_DauPtbFR();
81748
+ var require_dist10 = require_dist_whe0b4_3();
81749
+ var require_retired_settings_keys = require_retired_settings_keys_BdvmiaK7();
81566
81750
  var node_crypto = __require("crypto");
81567
81751
  var node_fs = __require("fs");
81568
81752
  var node_module = __require("module");
@@ -84052,7 +84236,7 @@ var require_storage_orchestrator_addon = __commonJS({
84052
84236
  [Symbol.toStringTag]: { value: "Module" }
84053
84237
  });
84054
84238
  var require_chunk = require_chunk_Cek0wNdY();
84055
- var require_dist10 = require_dist_TEwQZL3g();
84239
+ var require_dist10 = require_dist_whe0b4_3();
84056
84240
  var require_hub_cap_forward = require_hub_cap_forward_DmHNjbB0();
84057
84241
  var zod = require_zod();
84058
84242
  var node_crypto = __require("crypto");
@@ -87003,7 +87187,7 @@ var require_system_config_addon = __commonJS({
87003
87187
  [Symbol.toStringTag]: { value: "Module" }
87004
87188
  });
87005
87189
  require_chunk_Cek0wNdY();
87006
- var require_dist10 = require_dist_TEwQZL3g();
87190
+ var require_dist10 = require_dist_whe0b4_3();
87007
87191
  var SECTION_TITLES = {
87008
87192
  server: "Server",
87009
87193
  auth: "Authentication"
@@ -105064,7 +105248,7 @@ var require_winston_logging = __commonJS({
105064
105248
  [Symbol.toStringTag]: { value: "Module" }
105065
105249
  });
105066
105250
  var require_chunk = require_chunk_Cek0wNdY();
105067
- var require_dist10 = require_dist_TEwQZL3g();
105251
+ var require_dist10 = require_dist_whe0b4_3();
105068
105252
  var require_formatter = require_formatter_DqAKDlvN();
105069
105253
  var node_path = __require("path");
105070
105254
  node_path = require_chunk.__toESM(node_path);
@@ -107007,9 +107191,9 @@ var require_event_category_BaEgqJNv = __commonJS({
107007
107191
  }
107008
107192
  });
107009
107193
 
107010
- // ../types/dist/sleep-BDmIj1HV.js
107011
- var require_sleep_BDmIj1HV = __commonJS({
107012
- "../types/dist/sleep-BDmIj1HV.js"(exports) {
107194
+ // ../types/dist/sleep-jdpPltQH.js
107195
+ var require_sleep_jdpPltQH = __commonJS({
107196
+ "../types/dist/sleep-jdpPltQH.js"(exports) {
107013
107197
  "use strict";
107014
107198
  var require_event_category = require_event_category_BaEgqJNv();
107015
107199
  var zod = require_zod();
@@ -109933,6 +110117,7 @@ var require_sleep_BDmIj1HV = __commonJS({
109933
110117
  getDeviceLiveContribution: (input) => dispatchSystem("deviceManager", "getDeviceLiveContribution", "query", input),
109934
110118
  applyDeviceSettingsPatch: (input) => dispatchSystem("deviceManager", "applyDeviceSettingsPatch", "mutation", input)
109935
110119
  },
110120
+ deviceProvider: { reloadDevice: (input) => dispatchSystem("deviceProvider", "reloadDevice", "mutation", input) },
109936
110121
  deviceState: {
109937
110122
  getSnapshot: (input) => dispatchSystem("deviceState", "getSnapshot", "query", input),
109938
110123
  getCapSlice: (input) => dispatchSystem("deviceState", "getCapSlice", "query", input),
@@ -110682,7 +110867,7 @@ var require_addon = __commonJS({
110682
110867
  "use strict";
110683
110868
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
110684
110869
  var require_event_category = require_event_category_BaEgqJNv();
110685
- var require_sleep = require_sleep_BDmIj1HV();
110870
+ var require_sleep = require_sleep_jdpPltQH();
110686
110871
  var require_err_msg = require_err_msg_COpsHMw2();
110687
110872
  var CAP_INPUT_DEFAULTS = Object.freeze({
110688
110873
  "addons": { "getLogs": { "limit": 100 } },
@@ -117565,12 +117750,12 @@ var require_dist2 = __commonJS({
117565
117750
  }
117566
117751
  });
117567
117752
 
117568
- // ../system/dist/manifest-system-deps-BVIr3MYJ.js
117569
- var require_manifest_system_deps_BVIr3MYJ = __commonJS({
117570
- "../system/dist/manifest-system-deps-BVIr3MYJ.js"(exports) {
117753
+ // ../system/dist/manifest-system-deps-y5vZBO6q.js
117754
+ var require_manifest_system_deps_y5vZBO6q = __commonJS({
117755
+ "../system/dist/manifest-system-deps-y5vZBO6q.js"(exports) {
117571
117756
  "use strict";
117572
117757
  var require_chunk = require_chunk_Cek0wNdY();
117573
- require_dist_TEwQZL3g();
117758
+ require_dist_whe0b4_3();
117574
117759
  require_hub_cap_forward_DmHNjbB0();
117575
117760
  var node_crypto = __require("crypto");
117576
117761
  node_crypto = require_chunk.__toESM(node_crypto);
@@ -129637,7 +129822,7 @@ var require_dist3 = __commonJS({
129637
129822
  "use strict";
129638
129823
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
129639
129824
  var require_chunk = require_chunk_Cek0wNdY();
129640
- var require_dist10 = require_dist_TEwQZL3g();
129825
+ var require_dist10 = require_dist_whe0b4_3();
129641
129826
  var require_builtins_alerts_alerts_addon = require_alerts_addon();
129642
129827
  require_alerts();
129643
129828
  var require_formatter = require_formatter_DqAKDlvN();
@@ -129664,7 +129849,7 @@ var require_dist3 = __commonJS({
129664
129849
  var require_builtins_winston_logging_index = require_winston_logging();
129665
129850
  var require_file_data_plane = require_file_data_plane_DO8KbxCe();
129666
129851
  var require_tls$1 = require_tls_BxQlomxd();
129667
- var require_manifest_system_deps = require_manifest_system_deps_BVIr3MYJ();
129852
+ var require_manifest_system_deps = require_manifest_system_deps_y5vZBO6q();
129668
129853
  var require_resource_monitor = require_resource_monitor_CdnzxBLP();
129669
129854
  var require_custom_action_registry = require_custom_action_registry_jY0NOZK8();
129670
129855
  var zod = require_zod();
@@ -210754,7 +210939,7 @@ var require_dist4 = __commonJS({
210754
210939
  "use strict";
210755
210940
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
210756
210941
  var require_event_category = require_event_category_BaEgqJNv();
210757
- var require_sleep = require_sleep_BDmIj1HV();
210942
+ var require_sleep = require_sleep_jdpPltQH();
210758
210943
  var require_canonical_hash = require_canonical_hash_DNV8S5ET();
210759
210944
  var require_enums2 = require_enums();
210760
210945
  var require_err_msg = require_err_msg_COpsHMw2();
@@ -218099,6 +218284,35 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
218099
218284
  name: zod.z.string(),
218100
218285
  type: zod.z.string()
218101
218286
  }))),
218287
+ /**
218288
+ * Tear down and reconstruct ONE device in place from its persisted rows —
218289
+ * touching no other device this provider owns.
218290
+ *
218291
+ * The primitive `deviceManager.migrateDevice` uses to flush the two
218292
+ * migrated numbers: after `swapIds` the runner's live instance still
218293
+ * carries the PRE-swap numeric id (baked into the object, its native-cap
218294
+ * registrations and its log tags), and a live object cannot be renumbered.
218295
+ * Before this method the only flush was restarting the whole owning addon
218296
+ * — which took every camera the provider owns down with it (28 devices
218297
+ * for one migrated camera, measured 2026-09-04, and the morning of the
218298
+ * same day ~27 devices' native caps did not come back on their own).
218299
+ *
218300
+ * Keyed by `stableId`, deliberately: the numeric id is exactly the thing
218301
+ * that changes. The reply carries the id the device answers on NOW.
218302
+ * Implemented once in `BaseDeviceProvider` — decommission the live
218303
+ * instance (if any), then re-create from the persisted row: the same
218304
+ * teardown/rehydrate pair every graceful shutdown + boot already uses.
218305
+ * An RPC, never an event: a dropped event would leave the runner writing
218306
+ * against the wrong camera (D8).
218307
+ *
218308
+ * Construction can dial hardware, and the migrated source is
218309
+ * characteristically dead — the timeout covers a full activate window
218310
+ * rather than the 60 s default.
218311
+ */
218312
+ reloadDevice: require_sleep.method(zod.z.object({ stableId: zod.z.string() }), zod.z.object({ deviceId: zod.z.number() }), {
218313
+ kind: "mutation",
218314
+ timeoutMs: 3 * 6e4
218315
+ }),
218102
218316
  supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
218103
218317
  /**
218104
218318
  * Run a network scan. `params` carries optional provider-specific scan
@@ -218420,13 +218634,21 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
218420
218634
  * `sourceStillLive` names what stayed live; empty is the only value that
218421
218635
  * means the old hardware is quiet. The migration proceeds either way —
218422
218636
  * refusing would refuse the case this exists for.
218637
+ *
218638
+ * **Safe to retry.** A duplicate call while a migration involving either
218639
+ * device is in flight is REFUSED, never queued; a repeat of a swap that
218640
+ * already completed (matched against the `addonId`/`stableId` fingerprint
218641
+ * the swap left on the rows, within 15 min) is a no-op returning the
218642
+ * original report. A blind re-run can no longer silently undo the
218643
+ * migration it retried.
218423
218644
  */
218424
218645
  migrateDevice: require_sleep.method(zod.z.object({
218425
218646
  sourceId: zod.z.number(),
218426
218647
  targetId: zod.z.number()
218427
218648
  }), MigrateDeviceResultSchema, {
218428
218649
  kind: "mutation",
218429
- auth: "admin"
218650
+ auth: "admin",
218651
+ timeoutMs: 12 * 6e4
218430
218652
  }),
218431
218653
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
218432
218654
  registerDevice: require_sleep.method(DeviceRegisterPayloadSchema, zod.z.void(), { kind: "mutation" }),
@@ -239111,6 +239333,138 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239111
239333
  return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
239112
239334
  }
239113
239335
  };
239336
+ var DEVICE_RESTORE_RETRY_DELAYS_MS = [
239337
+ 1e4,
239338
+ 3e4,
239339
+ 9e4
239340
+ ];
239341
+ var DEVICE_RESTORE_RETRY_CONCURRENCY = 4;
239342
+ function sleep$1(ms, signal) {
239343
+ return new Promise((resolve) => {
239344
+ if (signal.aborted) {
239345
+ resolve();
239346
+ return;
239347
+ }
239348
+ const onAbort = () => {
239349
+ clearTimeout(timer);
239350
+ resolve();
239351
+ };
239352
+ const timer = setTimeout(() => {
239353
+ signal.removeEventListener("abort", onAbort);
239354
+ resolve();
239355
+ }, ms);
239356
+ timer.unref?.();
239357
+ signal.addEventListener("abort", onAbort, { once: true });
239358
+ });
239359
+ }
239360
+ async function runWithConcurrency(items, width, fn) {
239361
+ const queue = [...items];
239362
+ const laneCount = Math.max(1, Math.min(width, queue.length));
239363
+ const lane = async () => {
239364
+ for (; ; ) {
239365
+ const item = queue.shift();
239366
+ if (item === void 0) return;
239367
+ await fn(item);
239368
+ }
239369
+ };
239370
+ await Promise.all(Array.from({ length: laneCount }, lane));
239371
+ }
239372
+ var DeviceRestoreRetryScheduler = class {
239373
+ #logger;
239374
+ #attempt;
239375
+ #onPermanentFailure;
239376
+ #delaysMs;
239377
+ #concurrency;
239378
+ #now;
239379
+ #abort = new AbortController();
239380
+ constructor(options) {
239381
+ this.#logger = options.logger;
239382
+ this.#attempt = options.attempt;
239383
+ this.#onPermanentFailure = options.onPermanentFailure;
239384
+ this.#delaysMs = options.delaysMs ?? DEVICE_RESTORE_RETRY_DELAYS_MS;
239385
+ this.#concurrency = options.concurrency ?? 4;
239386
+ this.#now = options.now ?? Date.now;
239387
+ }
239388
+ /** Stop retrying (shutdown). Pending entries are NOT marked
239389
+ * permanently failed — the next boot restores them from disk. */
239390
+ cancel() {
239391
+ this.#abort.abort();
239392
+ }
239393
+ /**
239394
+ * Run the bounded retry rounds. Resolves when every entry has either
239395
+ * restored, been marked permanently failed, or the scheduler was
239396
+ * cancelled. Never rejects.
239397
+ */
239398
+ async run(initialFailures) {
239399
+ let pending = initialFailures.map((failure) => ({
239400
+ saved: failure.saved,
239401
+ lastError: failure.error,
239402
+ attempts: 1
239403
+ }));
239404
+ for (let round = 0; round < this.#delaysMs.length; round += 1) {
239405
+ if (pending.length === 0 || this.#abort.signal.aborted) break;
239406
+ await sleep$1(this.#delaysMs[round] ?? 0, this.#abort.signal);
239407
+ if (this.#abort.signal.aborted) break;
239408
+ pending = await this.#runRound(pending, round);
239409
+ }
239410
+ if (this.#abort.signal.aborted) return [];
239411
+ const terminal = pending.map((entry) => ({
239412
+ deviceId: entry.saved.id,
239413
+ stableId: entry.saved.stableId,
239414
+ type: String(entry.saved.type),
239415
+ attempts: entry.attempts,
239416
+ lastError: entry.lastError,
239417
+ failedAt: this.#now()
239418
+ }));
239419
+ for (const failure of terminal) this.#onPermanentFailure(failure);
239420
+ return terminal;
239421
+ }
239422
+ /** One retry round: parents first (phase 0), then hub-adopted
239423
+ * children (phase 1) — a child's attempt depends on its parent
239424
+ * having landed, exactly like the initial two-pass restore. */
239425
+ async #runRound(pending, round) {
239426
+ const next = [];
239427
+ const parents = pending.filter((entry) => entry.saved.parentDeviceId === null);
239428
+ const children = pending.filter((entry) => entry.saved.parentDeviceId !== null);
239429
+ for (const phase of [parents, children]) await runWithConcurrency(phase, this.#concurrency, async (entry) => {
239430
+ if (this.#abort.signal.aborted) {
239431
+ next.push(entry);
239432
+ return;
239433
+ }
239434
+ const attemptNo = entry.attempts + 1;
239435
+ try {
239436
+ await this.#attempt(entry.saved);
239437
+ this.#logger.info("Device restored on retry", {
239438
+ tags: {
239439
+ deviceId: entry.saved.id,
239440
+ stableId: entry.saved.stableId
239441
+ },
239442
+ meta: { attempt: attemptNo }
239443
+ });
239444
+ } catch (err) {
239445
+ const lastError = err instanceof Error ? err.message : String(err);
239446
+ const remainingRetries = this.#delaysMs.length - (round + 1);
239447
+ this.#logger.warn("Device restore retry failed", {
239448
+ tags: {
239449
+ deviceId: entry.saved.id,
239450
+ stableId: entry.saved.stableId
239451
+ },
239452
+ meta: {
239453
+ attempt: attemptNo,
239454
+ remainingRetries,
239455
+ error: lastError
239456
+ }
239457
+ });
239458
+ next.push({
239459
+ saved: entry.saved,
239460
+ lastError,
239461
+ attempts: attemptNo
239462
+ });
239463
+ }
239464
+ });
239465
+ return next;
239466
+ }
239467
+ };
239114
239468
  function toDeviceSummary(device2, addonId) {
239115
239469
  const config = {};
239116
239470
  for (const entry of device2.config.entries()) config[entry.key] = entry.value;
@@ -239136,6 +239490,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239136
239490
  }];
239137
239491
  }
239138
239492
  async onShutdown() {
239493
+ this.cancelRestoreRetries();
239139
239494
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
239140
239495
  for (const device2 of devices) try {
239141
239496
  await this.ctx.kernel.devices?.decommission(device2.id);
@@ -239155,9 +239510,16 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239155
239510
  async stop() {
239156
239511
  }
239157
239512
  async getStatus() {
239513
+ const all = await this.ctx.kernel.devices?.getAll() ?? [];
239514
+ const summary = this.restoreFailureSummary();
239515
+ if (summary === null) return {
239516
+ connected: true,
239517
+ deviceCount: all.length
239518
+ };
239158
239519
  return {
239159
239520
  connected: true,
239160
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
239521
+ deviceCount: all.length,
239522
+ error: summary
239161
239523
  };
239162
239524
  }
239163
239525
  async getDevices() {
@@ -239247,8 +239609,137 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239247
239609
  };
239248
239610
  }
239249
239611
  async restoreDevices(savedDevices) {
239250
- await this.onRestoreDevices(savedDevices);
239251
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
239612
+ const report = await this.onRestoreDevices(savedDevices);
239613
+ if (savedDevices.length === 0) return;
239614
+ if (report && report.failedCount > 0) {
239615
+ this.ctx.logger.warn(`Restored ${report.restoredCount}/${savedDevices.length} ${this.providerName} device(s) \u2014 ${report.failedCount} failed, bounded retry scheduled`);
239616
+ return;
239617
+ }
239618
+ const restoredCount = report ? report.restoredCount : savedDevices.length;
239619
+ this.ctx.logger.info(`Restored ${restoredCount} ${this.providerName} device(s)`);
239620
+ }
239621
+ /** Retry schedule. Overridable (tests use millisecond delays). */
239622
+ restoreRetryDelaysMs = DEVICE_RESTORE_RETRY_DELAYS_MS;
239623
+ /** Retry lane width. See `device-restore-retry.ts` for why retries
239624
+ * never re-stampede full-width while the initial pass does (D167). */
239625
+ restoreRetryConcurrency = 4;
239626
+ _restoreRetryScheduler = null;
239627
+ _restoreRetryCompletion = null;
239628
+ _permanentRestoreFailures = /* @__PURE__ */ new Map();
239629
+ /** Settles when the background retry rounds finish (or `null` when
239630
+ * nothing failed). Exposed for tests and subclass diagnostics —
239631
+ * boot NEVER awaits this: the runner's post-init handshake goes out
239632
+ * with the devices that restored, and a late success is announced
239633
+ * through the `native-cap-change` → `updateCaps` path. */
239634
+ get restoreRetryCompletion() {
239635
+ return this._restoreRetryCompletion;
239636
+ }
239637
+ /** Devices that exhausted the retry bound this process lifetime. */
239638
+ get permanentRestoreFailures() {
239639
+ return [...this._permanentRestoreFailures.values()];
239640
+ }
239641
+ /** One-line operator-facing summary for `getStatus().error`, or
239642
+ * `null` when every device restored. */
239643
+ restoreFailureSummary() {
239644
+ if (this._permanentRestoreFailures.size === 0) return null;
239645
+ const ids = [...this._permanentRestoreFailures.keys()].join(", ");
239646
+ return `${this._permanentRestoreFailures.size} device(s) permanently failed restore (deviceIds: ${ids}) \u2014 restart the ${this.providerName} provider to retry`;
239647
+ }
239648
+ cancelRestoreRetries() {
239649
+ this._restoreRetryScheduler?.cancel();
239650
+ this._restoreRetryScheduler = null;
239651
+ }
239652
+ recordPermanentRestoreFailure(failure) {
239653
+ this._permanentRestoreFailures.set(failure.deviceId, failure);
239654
+ this.ctx.logger.error("Device restore permanently failed \u2014 its capabilities will not register until the provider restarts", {
239655
+ tags: {
239656
+ deviceId: failure.deviceId,
239657
+ stableId: failure.stableId
239658
+ },
239659
+ meta: {
239660
+ type: failure.type,
239661
+ attempts: failure.attempts,
239662
+ error: failure.lastError
239663
+ }
239664
+ });
239665
+ }
239666
+ scheduleRestoreRetries(failures, attempt) {
239667
+ const scheduler2 = new DeviceRestoreRetryScheduler({
239668
+ logger: this.ctx.logger,
239669
+ delaysMs: this.restoreRetryDelaysMs,
239670
+ concurrency: this.restoreRetryConcurrency,
239671
+ attempt,
239672
+ onPermanentFailure: (failure) => this.recordPermanentRestoreFailure(failure)
239673
+ });
239674
+ this._restoreRetryScheduler = scheduler2;
239675
+ this._restoreRetryCompletion = scheduler2.run(failures).then(() => void 0).catch((err) => {
239676
+ this.ctx.logger.error("Restore retry scheduler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
239677
+ });
239678
+ }
239679
+ /**
239680
+ * Tear down and reconstruct ONE device from its persisted rows — the
239681
+ * `deviceProvider.reloadDevice` cap method. Persistence is never touched,
239682
+ * and no other device this provider owns is disturbed.
239683
+ *
239684
+ * Keyed by `stableId` because the caller's whole reason to be here is that
239685
+ * the NUMERIC id changed (`deviceManager.migrateDevice` swapped it): the
239686
+ * fresh instance resolves its id through `allocateDeviceId`, which returns
239687
+ * whatever number the row carries NOW. The teardown is `decommission` —
239688
+ * exactly what a graceful shutdown runs per device (fires `removeDevice()`,
239689
+ * unregisters native caps, drops the registry entry) — and the rebuild is
239690
+ * the boot restore's own `create()` path, including its pass 2: first-class
239691
+ * children (hub-adopted cameras under an NVR) are decommissioned with the
239692
+ * parent by the cascade and must be re-created explicitly, because only
239693
+ * accessory children come back through `getAccessoryChildren()`.
239694
+ *
239695
+ * Reloading an accessory child directly is refused (no device class) —
239696
+ * reload its parent instead.
239697
+ */
239698
+ async reloadDevice(input) {
239699
+ const { stableId } = input;
239700
+ const devices = this.ctx.kernel.devices;
239701
+ if (!devices) throw new Error(`${this.providerName}: kernel.devices unavailable \u2014 cannot reload`);
239702
+ const live = (await devices.getAll()).find((d) => d.stableId === stableId);
239703
+ if (live) await devices.decommission(live.id);
239704
+ const { id } = await this.ctx.api.deviceManager.allocateDeviceId.mutate({
239705
+ addonId: this.addonId,
239706
+ stableId
239707
+ });
239708
+ const meta = await this.ctx.api.deviceManager.loadMeta.query({ deviceId: id });
239709
+ if (meta === null) throw new Error(`${this.providerName}: no persisted meta for "${stableId}" (id ${id}) \u2014 cannot reload`);
239710
+ const deviceType = Object.values(require_sleep.DeviceType).find((t) => t === meta.type);
239711
+ const Class = deviceType !== void 0 ? this.deviceClasses[deviceType] : void 0;
239712
+ if (!Class) throw new Error(`${this.providerName}: no device class for type "${meta.type}" \u2014 "${stableId}" is an accessory child; reload its parent instead`);
239713
+ await devices.create(stableId, Class, {}, meta.parentDeviceId ?? null);
239714
+ const rows = await this.ctx.api.deviceManager.listPersistedByAddon.query({ addonId: this.addonId });
239715
+ for (const row of rows) {
239716
+ if (row.parentDeviceId !== id) continue;
239717
+ const childType = Object.values(require_sleep.DeviceType).find((t) => t === row.type);
239718
+ const ChildClass = childType !== void 0 ? this.deviceClasses[childType] : void 0;
239719
+ if (!ChildClass) continue;
239720
+ try {
239721
+ await devices.create(row.stableId, ChildClass, {}, id);
239722
+ } catch (err) {
239723
+ this.ctx.logger.warn("reloadDevice: failed to re-create first-class child", {
239724
+ tags: {
239725
+ deviceId: row.id,
239726
+ stableId: row.stableId
239727
+ },
239728
+ meta: {
239729
+ parentDeviceId: id,
239730
+ error: err instanceof Error ? err.message : String(err)
239731
+ }
239732
+ });
239733
+ }
239734
+ }
239735
+ this.ctx.logger.info("device reloaded in place from persisted rows", {
239736
+ tags: { deviceId: id },
239737
+ meta: {
239738
+ stableId,
239739
+ type: meta.type
239740
+ }
239741
+ });
239742
+ return { deviceId: id };
239252
239743
  }
239253
239744
  /**
239254
239745
  * Restore devices from persisted state. Two-pass:
@@ -239274,55 +239765,108 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
239274
239765
  * accessory-spawn flow handles via the parent's
239275
239766
  * `getAccessoryChildren()`. Override only when the default doesn't
239276
239767
  * fit.
239768
+ *
239769
+ * A row that fails either pass is NOT terminal (D347): it is handed
239770
+ * to a bounded background retry (`DeviceRestoreRetryScheduler`).
239771
+ * Only after the bound is exhausted is the device marked permanently
239772
+ * failed — logged at ERROR with `tags.deviceId` and surfaced via
239773
+ * `getStatus().error`.
239277
239774
  */
239278
239775
  async onRestoreDevices(savedDevices) {
239279
239776
  const restored = /* @__PURE__ */ new Set();
239777
+ const failures = [];
239778
+ const attemptRestore = async (saved) => {
239779
+ if (restored.has(saved.id)) return;
239780
+ const Class = this.deviceClasses[saved.type];
239781
+ if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
239782
+ if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
239783
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
239784
+ restored.add(saved.id);
239785
+ };
239280
239786
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
239281
239787
  const restoreOne = async (saved) => {
239282
- const Class = this.deviceClasses[saved.type];
239283
- if (!Class) {
239788
+ if (!this.deviceClasses[saved.type]) {
239284
239789
  this.ctx.logger.warn("No device class registered for restored type \u2014 skipping", {
239285
- tags: { stableId: saved.stableId },
239790
+ tags: {
239791
+ deviceId: saved.id,
239792
+ stableId: saved.stableId
239793
+ },
239286
239794
  meta: { type: saved.type }
239287
239795
  });
239288
239796
  return;
239289
239797
  }
239290
239798
  try {
239291
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
239292
- restored.add(saved.id);
239799
+ await attemptRestore(saved);
239293
239800
  } catch (err) {
239294
- this.ctx.logger.warn("Failed to restore device", {
239295
- tags: { stableId: saved.stableId },
239801
+ const error = err instanceof Error ? err.message : String(err);
239802
+ this.ctx.logger.warn("Failed to restore device \u2014 bounded retry scheduled", {
239803
+ tags: {
239804
+ deviceId: saved.id,
239805
+ stableId: saved.stableId
239806
+ },
239296
239807
  meta: {
239297
239808
  type: saved.type,
239298
- error: err instanceof Error ? err.message : String(err)
239809
+ attempt: 1,
239810
+ error
239299
239811
  }
239300
239812
  });
239813
+ failures.push({
239814
+ saved,
239815
+ error
239816
+ });
239301
239817
  }
239302
239818
  };
239303
239819
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
239820
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
239304
239821
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
239305
239822
  for (const saved of childRows) {
239306
- const Class = this.deviceClasses[saved.type];
239307
- if (!Class) continue;
239823
+ if (!this.deviceClasses[saved.type]) continue;
239308
239824
  if (saved.parentDeviceId === null) continue;
239309
- if (!restored.has(saved.parentDeviceId)) continue;
239310
- try {
239311
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
239312
- restored.add(saved.id);
239313
- } catch (err) {
239314
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
239825
+ if (restored.has(saved.parentDeviceId)) {
239826
+ try {
239827
+ await attemptRestore(saved);
239828
+ } catch (err) {
239829
+ const error = err instanceof Error ? err.message : String(err);
239830
+ this.ctx.logger.warn("Failed to restore hub-adopted child \u2014 bounded retry scheduled", {
239831
+ tags: {
239832
+ deviceId: saved.id,
239833
+ stableId: saved.stableId,
239834
+ parentDeviceId: saved.parentDeviceId
239835
+ },
239836
+ meta: {
239837
+ type: saved.type,
239838
+ attempt: 1,
239839
+ error
239840
+ }
239841
+ });
239842
+ failures.push({
239843
+ saved,
239844
+ error
239845
+ });
239846
+ }
239847
+ continue;
239848
+ }
239849
+ if (failedTopLevelIds.has(saved.parentDeviceId)) {
239850
+ this.ctx.logger.warn("Hub-adopted child deferred \u2014 parent failed initial restore", {
239315
239851
  tags: {
239852
+ deviceId: saved.id,
239316
239853
  stableId: saved.stableId,
239317
239854
  parentDeviceId: saved.parentDeviceId
239318
239855
  },
239319
- meta: {
239320
- type: saved.type,
239321
- error: err instanceof Error ? err.message : String(err)
239322
- }
239856
+ meta: { type: saved.type }
239857
+ });
239858
+ failures.push({
239859
+ saved,
239860
+ error: `parent device ${saved.parentDeviceId} not restored`
239323
239861
  });
239862
+ continue;
239324
239863
  }
239325
239864
  }
239865
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
239866
+ return {
239867
+ restoredCount: restored.size,
239868
+ failedCount: failures.length
239869
+ };
239326
239870
  }
239327
239871
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
239328
239872
  toSummary(device2) {
@@ -243418,6 +243962,12 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
243418
243962
  addonId: null,
243419
243963
  access: "view"
243420
243964
  },
243965
+ "deviceProvider.reloadDevice": {
243966
+ capName: "device-provider",
243967
+ capScope: "system",
243968
+ addonId: null,
243969
+ access: "create"
243970
+ },
243421
243971
  "deviceProvider.start": {
243422
243972
  capName: "device-provider",
243423
243973
  capScope: "system",
@@ -253822,6 +254372,8 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
253822
254372
  exports.DEVICE_CAP_NAMES = DEVICE_CAP_NAMES;
253823
254373
  exports.DEVICE_CHILDREN_BATCH_MAX = DEVICE_CHILDREN_BATCH_MAX;
253824
254374
  exports.DEVICE_PROFILES = DEVICE_PROFILES;
254375
+ exports.DEVICE_RESTORE_RETRY_CONCURRENCY = DEVICE_RESTORE_RETRY_CONCURRENCY;
254376
+ exports.DEVICE_RESTORE_RETRY_DELAYS_MS = DEVICE_RESTORE_RETRY_DELAYS_MS;
253825
254377
  exports.DEVICE_SCOPED_CAPS = require_sleep.DEVICE_SCOPED_CAPS;
253826
254378
  exports.DEVICE_SETTINGS_CONTRIBUTION_METHODS = require_sleep.DEVICE_SETTINGS_CONTRIBUTION_METHODS;
253827
254379
  exports.DEVICE_STATE_READERS = DEVICE_STATE_READERS;
@@ -253851,6 +254403,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
253851
254403
  exports.DeviceFeature = require_sleep.DeviceFeature;
253852
254404
  exports.DeviceInfoSchema = DeviceInfoSchema;
253853
254405
  exports.DeviceNetworkStatsSchema = DeviceNetworkStatsSchema;
254406
+ exports.DeviceRestoreRetryScheduler = DeviceRestoreRetryScheduler;
253854
254407
  exports.DeviceRole = require_sleep.DeviceRole;
253855
254408
  exports.DeviceRuntimeState = DeviceRuntimeState;
253856
254409
  exports.DeviceSelectorSchema = DeviceSelectorSchema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "camstack",
3
- "version": "1.2.84",
3
+ "version": "1.2.85",
4
4
  "description": "CLI tool for managing and running CamStack server",
5
5
  "keywords": [
6
6
  "camstack",