camstack 1.2.83 → 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.
@@ -23633,9 +23633,9 @@ var require_zod = __commonJS({
23633
23633
  }
23634
23634
  });
23635
23635
 
23636
- // ../system/dist/dist-C7qWwrK1.js
23637
- var require_dist_C7qWwrK1 = __commonJS({
23638
- "../system/dist/dist-C7qWwrK1.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) {
@@ -28488,6 +28488,68 @@ var require_dist_C7qWwrK1 = __commonJS({
28488
28488
  fileCount: zod.z.number(),
28489
28489
  present: zod.z.boolean()
28490
28490
  });
28491
+ var BackupRunStateSchema = zod.z.enum([
28492
+ "queued",
28493
+ "running",
28494
+ "succeeded",
28495
+ "failed",
28496
+ "cancelled"
28497
+ ]);
28498
+ var BackupRunPhaseSchema = zod.z.enum([
28499
+ "queued",
28500
+ "building",
28501
+ "uploading",
28502
+ "done"
28503
+ ]);
28504
+ var BackupRunSchema = zod.z.object({
28505
+ /** Stable run id — the handle `backup.cancel` takes. */
28506
+ id: zod.z.string(),
28507
+ state: BackupRunStateSchema,
28508
+ phase: BackupRunPhaseSchema,
28509
+ /**
28510
+ * Resolved destination location ids. Empty while queued (targets are
28511
+ * resolved when the run starts, against the then-current policies).
28512
+ */
28513
+ destinationIds: zod.z.array(zod.z.string()).readonly(),
28514
+ label: zod.z.string().optional(),
28515
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
28516
+ requestedAt: zod.z.number(),
28517
+ /** ms-epoch when the run left the queue and started building. */
28518
+ startedAt: zod.z.number().optional(),
28519
+ /** ms-epoch when the run reached a terminal state. */
28520
+ finishedAt: zod.z.number().optional(),
28521
+ /** Compressed bytes of the staging archive written so far. */
28522
+ stagedBytes: zod.z.number(),
28523
+ /** Final staged archive size, once the build phase completes. */
28524
+ archiveSizeBytes: zod.z.number().optional(),
28525
+ /** Bytes pushed to the destination currently uploading. */
28526
+ uploadedBytes: zod.z.number(),
28527
+ /** Destinations where the archive fully landed (uploaded + indexed). */
28528
+ completedDestinationIds: zod.z.array(zod.z.string()).readonly(),
28529
+ /** Destinations that failed during the fan-out. */
28530
+ failedDestinationIds: zod.z.array(zod.z.string()).readonly(),
28531
+ /** Failure message when `state === 'failed'`. */
28532
+ error: zod.z.string().optional(),
28533
+ /**
28534
+ * 1-based place in the execution queue — 1 = runs next. Present only
28535
+ * while `state === 'queued'`. Stamped by the orchestrator from the
28536
+ * queue's OWN pending order, never derived from timestamps, so the
28537
+ * UI cannot show an order the executor will not honour.
28538
+ */
28539
+ queuePosition: zod.z.number().int().min(1).optional()
28540
+ });
28541
+ var BackupTriggerResultSchema = zod.z.object({
28542
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
28543
+ runId: zod.z.string(),
28544
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
28545
+ queued: zod.z.boolean(),
28546
+ /** True when this trigger was coalesced onto an identical already-queued run. */
28547
+ joined: zod.z.boolean(),
28548
+ /** True when the run was cancelled before completing every destination. */
28549
+ cancelled: zod.z.boolean(),
28550
+ /** One entry per destination the archive landed at (partial on cancel). */
28551
+ entries: zod.z.array(BackupEntrySchema).readonly()
28552
+ });
28491
28553
  var BackupScheduleSchema = zod.z.object({
28492
28554
  /** Stable id. Generated by the orchestrator on first upsert if absent. */
28493
28555
  id: zod.z.string(),
@@ -28524,6 +28586,11 @@ var require_dist_C7qWwrK1 = __commonJS({
28524
28586
  * Trigger a backup. Without `destinations` the orchestrator fans
28525
28587
  * out to every destination flagged as enabled in the routing
28526
28588
  * config; with it, only the listed addons receive the archive.
28589
+ *
28590
+ * At most ONE backup run executes at a time — the source tree and
28591
+ * the staging disk are shared by every run, so a second trigger
28592
+ * while one is in flight is enqueued (or joined onto an identical
28593
+ * queued run) and the result says so. See D342.
28527
28594
  */
28528
28595
  trigger: method(zod.z.object({
28529
28596
  /** Subset of registered `backup-destination` addon ids to write to. */
@@ -28537,7 +28604,30 @@ var require_dist_C7qWwrK1 = __commonJS({
28537
28604
  * retention (manual runs).
28538
28605
  */
28539
28606
  retentionCount: zod.z.number().int().min(1).max(1e3).optional()
28540
- }).optional(), zod.z.array(BackupEntrySchema).readonly(), {
28607
+ }).optional(), BackupTriggerResultSchema, {
28608
+ kind: "mutation",
28609
+ auth: "admin"
28610
+ }),
28611
+ /**
28612
+ * Every run the orchestrator knows about, in EXECUTION order: the
28613
+ * running run first, then queued runs in the exact order they will
28614
+ * execute (each with `queuePosition`, 1 = next), then the bounded
28615
+ * finished history newest-first. Only one run executes at a time
28616
+ * (D342) — the queued section IS the line. Each run carries live
28617
+ * phase + byte counters so a runaway build is visible in seconds,
28618
+ * not via `du`.
28619
+ */
28620
+ listRuns: method(zod.z.void(), zod.z.array(BackupRunSchema).readonly(), { auth: "admin" }),
28621
+ /**
28622
+ * Stop a backup run. Mirrors `storage-migration.cancel` semantics:
28623
+ * id in, `{ cancelled }` out — `false` when the run is unknown or
28624
+ * already terminal. A QUEUED run is removed before it ever starts;
28625
+ * the RUNNING run has its tar/upload stream actually aborted, the
28626
+ * half-written staging archive is deleted, and the in-flight
28627
+ * destination upload is aborted server-side (partial discarded).
28628
+ * Destinations that already completed keep their archive.
28629
+ */
28630
+ cancel: method(zod.z.object({ runId: zod.z.string() }), zod.z.object({ cancelled: zod.z.boolean() }), {
28541
28631
  kind: "mutation",
28542
28632
  auth: "admin"
28543
28633
  }),
@@ -29277,6 +29367,38 @@ var require_dist_C7qWwrK1 = __commonJS({
29277
29367
  auth: "admin"
29278
29368
  }),
29279
29369
  /**
29370
+ * The HARDWARE behind a device number was replaced
29371
+ * (`deviceManager.migrateDevice`). Forget every piece of broker state that
29372
+ * described the old box, so the next catalog pull derives everything from
29373
+ * the camera that is actually there:
29374
+ *
29375
+ * - every `derived:*` stream definition — a derived is authored against a
29376
+ * specific profile layout, and against the wrong hardware its feeder
29377
+ * respawns forever (observed at attempt 2732 on the live hub,
29378
+ * 2026-09-01);
29379
+ * - the profile-slot assignment entry, PURGED (not unassigned — unassign
29380
+ * marks the slot manual, which would pin the stale choice instead of
29381
+ * letting `computeInitialAssignment` re-derive it);
29382
+ * - the probe snapshots (`<deviceId>/…` — probed codec/resolution of the
29383
+ * old hardware);
29384
+ * - the persisted RTSP token rows for the device's brokers (keyed
29385
+ * `<deviceId>/<camStreamId>`; the stream ids change with the hardware,
29386
+ * so the rows are dead URLs).
29387
+ *
29388
+ * An RPC, deliberately — an event is telemetry and may be dropped (D8),
29389
+ * and a dropped forget leaves a feeder respawning against a stream that
29390
+ * does not exist.
29391
+ */
29392
+ forgetDeviceHardware: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.object({
29393
+ derivedStreamsDeleted: zod.z.array(zod.z.string()).readonly(),
29394
+ assignmentsPurged: zod.z.boolean(),
29395
+ probeSnapshotsDropped: zod.z.number().int().nonnegative(),
29396
+ rtspTokenRowsDeleted: zod.z.number().int().nonnegative()
29397
+ }), {
29398
+ kind: "mutation",
29399
+ auth: "admin"
29400
+ }),
29401
+ /**
29280
29402
  * Render a short GIF or MP4 from the broker's PRE-BUFFER around an instant.
29281
29403
  *
29282
29404
  * The pre-buffer is the only source that already holds the seconds BEFORE
@@ -30992,6 +31114,35 @@ var require_dist_C7qWwrK1 = __commonJS({
30992
31114
  name: zod.z.string(),
30993
31115
  type: zod.z.string()
30994
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
+ }),
30995
31146
  supportsDiscovery: method(zod.z.object({}), zod.z.boolean()),
30996
31147
  /**
30997
31148
  * Run a network scan. `params` carries optional provider-specific scan
@@ -31312,13 +31463,21 @@ var require_dist_C7qWwrK1 = __commonJS({
31312
31463
  * `sourceStillLive` names what stayed live; empty is the only value that
31313
31464
  * means the old hardware is quiet. The migration proceeds either way —
31314
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.
31315
31473
  */
31316
31474
  migrateDevice: method(zod.z.object({
31317
31475
  sourceId: zod.z.number(),
31318
31476
  targetId: zod.z.number()
31319
31477
  }), MigrateDeviceResultSchema, {
31320
31478
  kind: "mutation",
31321
- auth: "admin"
31479
+ auth: "admin",
31480
+ timeoutMs: 12 * 6e4
31322
31481
  }),
31323
31482
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
31324
31483
  registerDevice: method(DeviceRegisterPayloadSchema, zod.z.void(), { kind: "mutation" }),
@@ -51132,6 +51291,12 @@ var require_dist_C7qWwrK1 = __commonJS({
51132
51291
  addonId: null,
51133
51292
  access: "create"
51134
51293
  },
51294
+ "backup.cancel": {
51295
+ capName: "backup",
51296
+ capScope: "system",
51297
+ addonId: null,
51298
+ access: "create"
51299
+ },
51135
51300
  "backup.delete": {
51136
51301
  capName: "backup",
51137
51302
  capScope: "system",
@@ -51174,6 +51339,12 @@ var require_dist_C7qWwrK1 = __commonJS({
51174
51339
  addonId: null,
51175
51340
  access: "view"
51176
51341
  },
51342
+ "backup.listRuns": {
51343
+ capName: "backup",
51344
+ capScope: "system",
51345
+ addonId: null,
51346
+ access: "view"
51347
+ },
51177
51348
  "backup.listSchedules": {
51178
51349
  capName: "backup",
51179
51350
  capScope: "system",
@@ -52374,6 +52545,12 @@ var require_dist_C7qWwrK1 = __commonJS({
52374
52545
  addonId: null,
52375
52546
  access: "view"
52376
52547
  },
52548
+ "deviceProvider.reloadDevice": {
52549
+ capName: "device-provider",
52550
+ capScope: "system",
52551
+ addonId: null,
52552
+ access: "create"
52553
+ },
52377
52554
  "deviceProvider.start": {
52378
52555
  capName: "device-provider",
52379
52556
  capScope: "system",
@@ -55740,6 +55917,12 @@ var require_dist_C7qWwrK1 = __commonJS({
55740
55917
  addonId: null,
55741
55918
  access: "create"
55742
55919
  },
55920
+ "streamBroker.forgetDeviceHardware": {
55921
+ capName: "stream-broker",
55922
+ capScope: "system",
55923
+ addonId: null,
55924
+ access: "delete"
55925
+ },
55743
55926
  "streamBroker.getAllRtspEntries": {
55744
55927
  capName: "stream-broker",
55745
55928
  capScope: "system",
@@ -58198,6 +58381,11 @@ var require_dist_C7qWwrK1 = __commonJS({
58198
58381
  form: "single",
58199
58382
  optional: false
58200
58383
  }],
58384
+ "streamBroker.forgetDeviceHardware": [{
58385
+ name: "deviceId",
58386
+ form: "single",
58387
+ optional: false
58388
+ }],
58201
58389
  "streamBroker.getDeviceAudioMute": [{
58202
58390
  name: "deviceId",
58203
58391
  form: "single",
@@ -59854,7 +60042,7 @@ var require_alerts_addon = __commonJS({
59854
60042
  [Symbol.toStringTag]: { value: "Module" }
59855
60043
  });
59856
60044
  require_chunk_Cek0wNdY();
59857
- var require_dist10 = require_dist_C7qWwrK1();
60045
+ var require_dist10 = require_dist_whe0b4_3();
59858
60046
  function selectExpired(alerts, cutoffMs) {
59859
60047
  return alerts.filter((a) => a.createdAt < cutoffMs).sort((a, b) => a.createdAt - b.createdAt).map((a) => a.id);
59860
60048
  }
@@ -60673,7 +60861,7 @@ var require_console_logging = __commonJS({
60673
60861
  [Symbol.toStringTag]: { value: "Module" }
60674
60862
  });
60675
60863
  require_chunk_Cek0wNdY();
60676
- var require_dist10 = require_dist_C7qWwrK1();
60864
+ var require_dist10 = require_dist_whe0b4_3();
60677
60865
  var require_formatter = require_formatter_DqAKDlvN();
60678
60866
  var LEVEL_RANK = {
60679
60867
  debug: 0,
@@ -60767,7 +60955,7 @@ var require_core_blocks_addon = __commonJS({
60767
60955
  "use strict";
60768
60956
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
60769
60957
  var require_chunk = require_chunk_Cek0wNdY();
60770
- var require_dist10 = require_dist_C7qWwrK1();
60958
+ var require_dist10 = require_dist_whe0b4_3();
60771
60959
  var node_crypto = __require("crypto");
60772
60960
  var node_fs_promises = __require("fs/promises");
60773
60961
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -61664,11 +61852,11 @@ var require_core_blocks = __commonJS({
61664
61852
  }
61665
61853
  });
61666
61854
 
61667
- // ../system/dist/retired-settings-keys-3soU9pfP.js
61668
- var require_retired_settings_keys_3soU9pfP = __commonJS({
61669
- "../system/dist/retired-settings-keys-3soU9pfP.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) {
61670
61858
  "use strict";
61671
- var require_dist10 = require_dist_C7qWwrK1();
61859
+ var require_dist10 = require_dist_whe0b4_3();
61672
61860
  function settingsStoreIsAuthoritativeHere(env) {
61673
61861
  const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
61674
61862
  return raw === "" || raw === "hub";
@@ -63882,8 +64070,8 @@ var require_device_manager_addon = __commonJS({
63882
64070
  [Symbol.toStringTag]: { value: "Module" }
63883
64071
  });
63884
64072
  require_chunk_Cek0wNdY();
63885
- var require_dist10 = require_dist_C7qWwrK1();
63886
- var require_retired_settings_keys = require_retired_settings_keys_3soU9pfP();
64073
+ var require_dist10 = require_dist_whe0b4_3();
64074
+ var require_retired_settings_keys = require_retired_settings_keys_BdvmiaK7();
63887
64075
  var node_crypto = __require("crypto");
63888
64076
  var _camstack_types_node = require_node();
63889
64077
  var JOB_HISTORY = 20;
@@ -65546,7 +65734,13 @@ var require_device_manager_addon = __commonJS({
65546
65734
  if (config.mode === "auto") {
65547
65735
  if (selfLocation !== null && selfLocation.length > 0) for (const c of candidates) {
65548
65736
  if (c.type === require_dist10.DeviceType.Container) continue;
65549
- if (c.location === selfLocation) ids.add(c.id);
65737
+ if (c.location !== selfLocation) continue;
65738
+ const parent = c.parentDeviceId !== null ? byId.get(c.parentDeviceId) : void 0;
65739
+ if (parent !== void 0 && parent.type === require_dist10.DeviceType.Container) {
65740
+ expand(parent.id);
65741
+ continue;
65742
+ }
65743
+ ids.add(c.id);
65550
65744
  }
65551
65745
  } else for (const id of config.manualIds) if (byId.has(id)) expand(id);
65552
65746
  ids.delete(selfId);
@@ -66376,10 +66570,26 @@ var require_device_manager_addon = __commonJS({
66376
66570
  detail
66377
66571
  };
66378
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
+ }
66379
66589
  async function applyAll(deps, deviceId, enabled) {
66380
66590
  const out = [];
66381
66591
  for (const switchId of require_dist10.CAMERA_SWITCH_ORDER) try {
66382
- await deps.setSwitch(deviceId, switchId, enabled);
66592
+ await setSwitchBounded(deps, deviceId, switchId, enabled);
66383
66593
  out.push({
66384
66594
  deviceId,
66385
66595
  switchId,
@@ -66409,8 +66619,17 @@ var require_device_manager_addon = __commonJS({
66409
66619
  const down = [...await applyAll(deps, sourceId, false), ...await applyAll(deps, targetId, false)];
66410
66620
  const sourceStillLive = down.filter((r) => r.deviceId === sourceId && r.outcome === "unreachable").map((r) => r.switchId);
66411
66621
  await deps.swapIds(sourceId, targetId);
66622
+ await deps.swapHardwareState(sourceId, targetId);
66623
+ for (const deviceId of [sourceId, targetId]) try {
66624
+ await deps.forgetStreamState(deviceId);
66625
+ } catch (err) {
66626
+ deps.log("migration: broker hardware state NOT forgotten \u2014 stale stream state survives", {
66627
+ deviceId,
66628
+ error: err instanceof Error ? err.message : String(err),
66629
+ remedy: "delete the device's derived streams and reassign its stream profiles, or update the stream-broker addon and re-run streamBroker.forgetDeviceHardware"
66630
+ });
66631
+ }
66412
66632
  const up = await applyAll(deps, sourceId, true);
66413
- await deps.respawnRunners([sourceId, targetId]);
66414
66633
  deps.log("camera migrated", {
66415
66634
  sourceId,
66416
66635
  targetId,
@@ -66425,6 +66644,93 @@ var require_device_manager_addon = __commonJS({
66425
66644
  swapped: true
66426
66645
  };
66427
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
+ }
66691
+ var PROFILE_MAP_CONFIG_KEY = "_profileMap";
66692
+ var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
66693
+ function splitConfig(config) {
66694
+ const hardware = {};
66695
+ const role = {};
66696
+ for (const [key, value] of Object.entries(config)) {
66697
+ if (key === PROFILE_MAP_CONFIG_KEY) continue;
66698
+ if (ROLE_SCOPED_CONFIG_KEYS.has(key)) role[key] = value;
66699
+ else hardware[key] = value;
66700
+ }
66701
+ return {
66702
+ hardware,
66703
+ role
66704
+ };
66705
+ }
66706
+ async function swapHardwareState(settings, sourceId, targetId) {
66707
+ const sourceConfig = await settings.readDeviceStore(sourceId);
66708
+ const targetConfig = await settings.readDeviceStore(targetId);
66709
+ const source = splitConfig(sourceConfig);
66710
+ const target = splitConfig(targetConfig);
66711
+ await settings.clearDeviceStore(sourceId);
66712
+ await settings.writeDeviceStore(sourceId, {
66713
+ ...target.hardware,
66714
+ ...source.role
66715
+ });
66716
+ await settings.clearDeviceStore(targetId);
66717
+ await settings.writeDeviceStore(targetId, {
66718
+ ...source.hardware,
66719
+ ...target.role
66720
+ });
66721
+ const sourceRuntimeState = await settings.readDeviceRuntimeState(sourceId);
66722
+ const targetRuntimeState = await settings.readDeviceRuntimeState(targetId);
66723
+ await settings.writeDeviceRuntimeState(sourceId, targetRuntimeState);
66724
+ await settings.writeDeviceRuntimeState(targetId, sourceRuntimeState);
66725
+ return {
66726
+ runtimeStateNowAtSource: targetRuntimeState,
66727
+ runtimeStateNowAtTarget: sourceRuntimeState,
66728
+ hardwareKeysToSource: Object.keys(target.hardware).toSorted(),
66729
+ hardwareKeysToTarget: Object.keys(source.hardware).toSorted(),
66730
+ roleKeysKeptAtSource: Object.keys(source.role).toSorted(),
66731
+ roleKeysKeptAtTarget: Object.keys(target.role).toSorted()
66732
+ };
66733
+ }
66428
66734
  async function stampIntegrationId(metaStore, ctx, deviceId, integrationId) {
66429
66735
  await metaStore.withMetaWriteLock(async () => {
66430
66736
  if (!await metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
@@ -66470,39 +66776,110 @@ var require_device_manager_addon = __commonJS({
66470
66776
  async function migrateDevice(pctx, input) {
66471
66777
  const { sourceId, targetId } = input;
66472
66778
  const ctx = pctx.host.ctx;
66473
- return await pctx.metaStore.withMetaWriteLock(async () => {
66474
- 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`);
66475
- const tempId = await pctx.metaStore.allocateNextDeviceId();
66476
- return await migrateDevice$1({
66477
- setSwitch: async (deviceId, switchId, enabled) => {
66478
- await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
66479
- deviceId,
66480
- switchId,
66481
- enabled
66482
- });
66483
- },
66484
- swapIds: async (a, b) => {
66485
- await pctx.metaStore.rows.swapIds(a, b, tempId);
66486
- },
66487
- respawnRunners: async (deviceIds) => {
66488
- const owners = /* @__PURE__ */ new Set();
66489
- for (const deviceId of deviceIds) {
66490
- const addonId = pctx.metaStore.idToAddonId.get(deviceId);
66491
- if (addonId !== void 0) owners.add(addonId);
66492
- }
66493
- for (const addonId of owners) await ctx.api.addons.restartAddon.mutate({ addonId });
66494
- },
66495
- log: (message, meta) => {
66496
- ctx.logger.info(message, {
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", {
66497
66795
  tags: { deviceId: sourceId },
66498
- meta
66796
+ meta: {
66797
+ sourceId,
66798
+ targetId
66799
+ }
66499
66800
  });
66801
+ return {
66802
+ result: replay,
66803
+ reloadTargets: null
66804
+ };
66500
66805
  }
66501
- }, {
66502
- sourceId,
66503
- targetId
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 },
66829
+ meta: {
66830
+ sourceId: a,
66831
+ targetId: b,
66832
+ hardwareKeysToSource: swapped.hardwareKeysToSource,
66833
+ hardwareKeysToTarget: swapped.hardwareKeysToTarget,
66834
+ roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
66835
+ roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
66836
+ }
66837
+ });
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
+ });
66847
+ }
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
+ };
66504
66877
  });
66505
- });
66878
+ if (outcome.reloadTargets !== null) await reloadMigratedDevices(pctx, outcome.reloadTargets);
66879
+ return outcome.result;
66880
+ } finally {
66881
+ pctx.migrationGuard.end(sourceId, targetId);
66882
+ }
66506
66883
  }
66507
66884
  async function registerDevice(pctx, input) {
66508
66885
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
@@ -67023,18 +67400,17 @@ var require_device_manager_addon = __commonJS({
67023
67400
  }
67024
67401
  });
67025
67402
  }
67026
- async function setDisabled(pctx, input) {
67027
- const { deviceId, disabled } = input;
67028
- const moved = await pctx.metaStore.withMetaWriteLock(async () => {
67029
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
67030
- if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
67031
- const { meta: m } = persisted;
67032
- await pctx.metaStore.rows.patch(deviceId, { disabled });
67033
- return {
67034
- changed: (m.disabled ?? false) !== disabled,
67035
- integrationId: m.integrationId ?? ""
67036
- };
67037
- });
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) {
67038
67414
  pctx.host.ctx.eventBus.emit({
67039
67415
  id: (0, node_crypto.randomUUID)(),
67040
67416
  timestamp: /* @__PURE__ */ new Date(),
@@ -67064,6 +67440,10 @@ var require_device_manager_addon = __commonJS({
67064
67440
  }
67065
67441
  });
67066
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
+ }
67067
67447
  async function loadRuntimeState(pctx, input) {
67068
67448
  const { deviceId } = input;
67069
67449
  if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
@@ -68444,6 +68824,42 @@ var require_device_manager_addon = __commonJS({
68444
68824
  };
68445
68825
  return resolveRemoteNativeCapFromRegistry(pctx.bindingsDeps, capName, deviceId);
68446
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
+ }
68447
68863
  var DeviceManagerAddon = class extends require_dist10.BaseAddon {
68448
68864
  constructor() {
68449
68865
  super({});
@@ -68682,6 +69098,7 @@ var require_device_manager_addon = __commonJS({
68682
69098
  settings,
68683
69099
  registry,
68684
69100
  bindingsDeps: this.bindingsDeps,
69101
+ migrationGuard: createMigrationGuard(),
68685
69102
  requireDeviceOps,
68686
69103
  stampIntegrationId: stampIntegrationId$1,
68687
69104
  get provider() {
@@ -68923,7 +69340,7 @@ var require_hub_forwarder = __commonJS({
68923
69340
  [Symbol.toStringTag]: { value: "Module" }
68924
69341
  });
68925
69342
  require_chunk_Cek0wNdY();
68926
- var require_dist10 = require_dist_C7qWwrK1();
69343
+ var require_dist10 = require_dist_whe0b4_3();
68927
69344
  var require_formatter = require_formatter_DqAKDlvN();
68928
69345
  var DEFAULT_OUTBOUND_BUFFER_SIZE = 500;
68929
69346
  var HubForwarderDestination = class {
@@ -69060,7 +69477,7 @@ var require_liveness_monitor_addon = __commonJS({
69060
69477
  "use strict";
69061
69478
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
69062
69479
  require_chunk_Cek0wNdY();
69063
- var require_dist10 = require_dist_C7qWwrK1();
69480
+ var require_dist10 = require_dist_whe0b4_3();
69064
69481
  var NO_DEVICES = "liveness:no-devices";
69065
69482
  var ALL_OFFLINE = "liveness:all-devices-offline";
69066
69483
  var NO_FOOTAGE_PREFIX = "liveness:no-footage:";
@@ -69250,7 +69667,7 @@ var require_local_auth_addon = __commonJS({
69250
69667
  [Symbol.toStringTag]: { value: "Module" }
69251
69668
  });
69252
69669
  var require_chunk = require_chunk_Cek0wNdY();
69253
- var require_dist10 = require_dist_C7qWwrK1();
69670
+ var require_dist10 = require_dist_whe0b4_3();
69254
69671
  var node_crypto = __require("crypto");
69255
69672
  node_crypto = require_chunk.__toESM(node_crypto);
69256
69673
  var crypto$1 = __require("crypto");
@@ -77063,7 +77480,7 @@ var require_loki_logging = __commonJS({
77063
77480
  [Symbol.toStringTag]: { value: "Module" }
77064
77481
  });
77065
77482
  require_chunk_Cek0wNdY();
77066
- var require_dist10 = require_dist_C7qWwrK1();
77483
+ var require_dist10 = require_dist_whe0b4_3();
77067
77484
  function sanitizeLabelName(raw) {
77068
77485
  const replaced = raw.replaceAll(/[^a-zA-Z0-9_]/g, "_");
77069
77486
  return /^[a-zA-Z_]/.test(replaced) ? replaced : `_${replaced}`;
@@ -77628,7 +78045,7 @@ var require_native_metrics_addon = __commonJS({
77628
78045
  [Symbol.toStringTag]: { value: "Module" }
77629
78046
  });
77630
78047
  var require_chunk = require_chunk_Cek0wNdY();
77631
- var require_dist10 = require_dist_C7qWwrK1();
78048
+ var require_dist10 = require_dist_whe0b4_3();
77632
78049
  var node_fs_promises = __require("fs/promises");
77633
78050
  var node_child_process = __require("child_process");
77634
78051
  var node_util = __require("util");
@@ -80212,7 +80629,7 @@ var require_filesystem_storage_addon = __commonJS({
80212
80629
  [Symbol.toStringTag]: { value: "Module" }
80213
80630
  });
80214
80631
  var require_chunk = require_chunk_Cek0wNdY();
80215
- var require_dist10 = require_dist_C7qWwrK1();
80632
+ var require_dist10 = require_dist_whe0b4_3();
80216
80633
  var node_crypto = __require("crypto");
80217
80634
  var node_fs_promises = __require("fs/promises");
80218
80635
  var node_path = __require("path");
@@ -81328,8 +81745,8 @@ var require_sqlite_settings_addon = __commonJS({
81328
81745
  [Symbol.toStringTag]: { value: "Module" }
81329
81746
  });
81330
81747
  var require_chunk = require_chunk_Cek0wNdY();
81331
- var require_dist10 = require_dist_C7qWwrK1();
81332
- var require_retired_settings_keys = require_retired_settings_keys_3soU9pfP();
81748
+ var require_dist10 = require_dist_whe0b4_3();
81749
+ var require_retired_settings_keys = require_retired_settings_keys_BdvmiaK7();
81333
81750
  var node_crypto = __require("crypto");
81334
81751
  var node_fs = __require("fs");
81335
81752
  var node_module = __require("module");
@@ -83819,7 +84236,7 @@ var require_storage_orchestrator_addon = __commonJS({
83819
84236
  [Symbol.toStringTag]: { value: "Module" }
83820
84237
  });
83821
84238
  var require_chunk = require_chunk_Cek0wNdY();
83822
- var require_dist10 = require_dist_C7qWwrK1();
84239
+ var require_dist10 = require_dist_whe0b4_3();
83823
84240
  var require_hub_cap_forward = require_hub_cap_forward_DmHNjbB0();
83824
84241
  var zod = require_zod();
83825
84242
  var node_crypto = __require("crypto");
@@ -86770,7 +87187,7 @@ var require_system_config_addon = __commonJS({
86770
87187
  [Symbol.toStringTag]: { value: "Module" }
86771
87188
  });
86772
87189
  require_chunk_Cek0wNdY();
86773
- var require_dist10 = require_dist_C7qWwrK1();
87190
+ var require_dist10 = require_dist_whe0b4_3();
86774
87191
  var SECTION_TITLES = {
86775
87192
  server: "Server",
86776
87193
  auth: "Authentication"
@@ -104831,7 +105248,7 @@ var require_winston_logging = __commonJS({
104831
105248
  [Symbol.toStringTag]: { value: "Module" }
104832
105249
  });
104833
105250
  var require_chunk = require_chunk_Cek0wNdY();
104834
- var require_dist10 = require_dist_C7qWwrK1();
105251
+ var require_dist10 = require_dist_whe0b4_3();
104835
105252
  var require_formatter = require_formatter_DqAKDlvN();
104836
105253
  var node_path = __require("path");
104837
105254
  node_path = require_chunk.__toESM(node_path);
@@ -106774,9 +107191,9 @@ var require_event_category_BaEgqJNv = __commonJS({
106774
107191
  }
106775
107192
  });
106776
107193
 
106777
- // ../types/dist/sleep-BwGJ_wL_.js
106778
- var require_sleep_BwGJ_wL = __commonJS({
106779
- "../types/dist/sleep-BwGJ_wL_.js"(exports) {
107194
+ // ../types/dist/sleep-jdpPltQH.js
107195
+ var require_sleep_jdpPltQH = __commonJS({
107196
+ "../types/dist/sleep-jdpPltQH.js"(exports) {
106780
107197
  "use strict";
106781
107198
  var require_event_category = require_event_category_BaEgqJNv();
106782
107199
  var zod = require_zod();
@@ -109700,6 +110117,7 @@ var require_sleep_BwGJ_wL = __commonJS({
109700
110117
  getDeviceLiveContribution: (input) => dispatchSystem("deviceManager", "getDeviceLiveContribution", "query", input),
109701
110118
  applyDeviceSettingsPatch: (input) => dispatchSystem("deviceManager", "applyDeviceSettingsPatch", "mutation", input)
109702
110119
  },
110120
+ deviceProvider: { reloadDevice: (input) => dispatchSystem("deviceProvider", "reloadDevice", "mutation", input) },
109703
110121
  deviceState: {
109704
110122
  getSnapshot: (input) => dispatchSystem("deviceState", "getSnapshot", "query", input),
109705
110123
  getCapSlice: (input) => dispatchSystem("deviceState", "getCapSlice", "query", input),
@@ -109784,6 +110202,7 @@ var require_sleep_BwGJ_wL = __commonJS({
109784
110202
  retractCameraStream: (input) => dispatchSystem("streamBroker", "retractCameraStream", "mutation", input),
109785
110203
  assignProfile: (input) => dispatchSystem("streamBroker", "assignProfile", "mutation", input),
109786
110204
  unassignProfile: (input) => dispatchSystem("streamBroker", "unassignProfile", "mutation", input),
110205
+ forgetDeviceHardware: (input) => dispatchSystem("streamBroker", "forgetDeviceHardware", "mutation", input),
109787
110206
  renderPreBufferClip: (input) => dispatchSystem("streamBroker", "renderPreBufferClip", "mutation", input),
109788
110207
  produceEventMedia: (input) => dispatchSystem("streamBroker", "produceEventMedia", "mutation", input),
109789
110208
  restartProfile: (input) => dispatchSystem("streamBroker", "restartProfile", "mutation", input),
@@ -110448,7 +110867,7 @@ var require_addon = __commonJS({
110448
110867
  "use strict";
110449
110868
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
110450
110869
  var require_event_category = require_event_category_BaEgqJNv();
110451
- var require_sleep = require_sleep_BwGJ_wL();
110870
+ var require_sleep = require_sleep_jdpPltQH();
110452
110871
  var require_err_msg = require_err_msg_COpsHMw2();
110453
110872
  var CAP_INPUT_DEFAULTS = Object.freeze({
110454
110873
  "addons": { "getLogs": { "limit": 100 } },
@@ -117331,12 +117750,12 @@ var require_dist2 = __commonJS({
117331
117750
  }
117332
117751
  });
117333
117752
 
117334
- // ../system/dist/manifest-system-deps-PAIfmNun.js
117335
- var require_manifest_system_deps_PAIfmNun = __commonJS({
117336
- "../system/dist/manifest-system-deps-PAIfmNun.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) {
117337
117756
  "use strict";
117338
117757
  var require_chunk = require_chunk_Cek0wNdY();
117339
- require_dist_C7qWwrK1();
117758
+ require_dist_whe0b4_3();
117340
117759
  require_hub_cap_forward_DmHNjbB0();
117341
117760
  var node_crypto = __require("crypto");
117342
117761
  node_crypto = require_chunk.__toESM(node_crypto);
@@ -129403,7 +129822,7 @@ var require_dist3 = __commonJS({
129403
129822
  "use strict";
129404
129823
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
129405
129824
  var require_chunk = require_chunk_Cek0wNdY();
129406
- var require_dist10 = require_dist_C7qWwrK1();
129825
+ var require_dist10 = require_dist_whe0b4_3();
129407
129826
  var require_builtins_alerts_alerts_addon = require_alerts_addon();
129408
129827
  require_alerts();
129409
129828
  var require_formatter = require_formatter_DqAKDlvN();
@@ -129430,7 +129849,7 @@ var require_dist3 = __commonJS({
129430
129849
  var require_builtins_winston_logging_index = require_winston_logging();
129431
129850
  var require_file_data_plane = require_file_data_plane_DO8KbxCe();
129432
129851
  var require_tls$1 = require_tls_BxQlomxd();
129433
- var require_manifest_system_deps = require_manifest_system_deps_PAIfmNun();
129852
+ var require_manifest_system_deps = require_manifest_system_deps_y5vZBO6q();
129434
129853
  var require_resource_monitor = require_resource_monitor_CdnzxBLP();
129435
129854
  var require_custom_action_registry = require_custom_action_registry_jY0NOZK8();
129436
129855
  var zod = require_zod();
@@ -132468,7 +132887,265 @@ var require_dist3 = __commonJS({
132468
132887
  };
132469
132888
  }
132470
132889
  };
132890
+ function parseVersion(value) {
132891
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(value.trim());
132892
+ if (!m) return null;
132893
+ return {
132894
+ major: Number(m[1]),
132895
+ minor: Number(m[2]),
132896
+ patch: Number(m[3]),
132897
+ prerelease: m[4] ?? null
132898
+ };
132899
+ }
132900
+ function compareTriple(a, b) {
132901
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1;
132902
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1;
132903
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1;
132904
+ return 0;
132905
+ }
132906
+ function closureSatisfiesRange(range, version2) {
132907
+ const trimmedRange = range.trim();
132908
+ const v2 = parseVersion(version2);
132909
+ if (v2 === null) return false;
132910
+ if (v2.prerelease !== null) return trimmedRange === version2.trim() || trimmedRange === `=${version2.trim()}`;
132911
+ const exactBase = parseVersion(trimmedRange.startsWith("=") ? trimmedRange.slice(1) : trimmedRange);
132912
+ if (exactBase !== null) return exactBase.prerelease === null && compareTriple(v2, exactBase) === 0;
132913
+ const operator = trimmedRange[0];
132914
+ if (operator !== "^" && operator !== "~") return false;
132915
+ const base = parseVersion(trimmedRange.slice(1));
132916
+ if (base === null || base.prerelease !== null) return false;
132917
+ if (operator === "~") return v2.major === base.major && v2.minor === base.minor && compareTriple(v2, base) >= 0;
132918
+ if (base.major > 0) return v2.major === base.major && compareTriple(v2, base) >= 0;
132919
+ if (base.minor > 0) return v2.major === 0 && v2.minor === base.minor && compareTriple(v2, base) >= 0;
132920
+ return compareTriple(v2, base) === 0;
132921
+ }
132922
+ function sameCompatLine(a, b) {
132923
+ const pa = parseVersion(a);
132924
+ const pb = parseVersion(b);
132925
+ if (pa === null || pb === null) return false;
132926
+ if (pa.major !== pb.major) return false;
132927
+ if (pa.major === 0) return pa.minor === pb.minor;
132928
+ return true;
132929
+ }
132930
+ function planClosureDedupe(input) {
132931
+ const links = [];
132932
+ const keeps = [];
132933
+ for (const candidate of input.candidates) {
132934
+ if (candidate.name.startsWith("@camstack/")) {
132935
+ keeps.push({
132936
+ name: candidate.name,
132937
+ reason: "camstack-package"
132938
+ });
132939
+ continue;
132940
+ }
132941
+ if (input.neverLink.has(candidate.name)) {
132942
+ keeps.push({
132943
+ name: candidate.name,
132944
+ reason: "never-link"
132945
+ });
132946
+ continue;
132947
+ }
132948
+ const closureVersion = input.closureVersions.get(candidate.name);
132949
+ if (closureVersion === void 0) {
132950
+ keeps.push({
132951
+ name: candidate.name,
132952
+ reason: "not-in-closure"
132953
+ });
132954
+ continue;
132955
+ }
132956
+ if (closureVersion === candidate.version) {
132957
+ links.push({
132958
+ name: candidate.name,
132959
+ closureVersion
132960
+ });
132961
+ continue;
132962
+ }
132963
+ const ranges = input.requirements.get(candidate.name) ?? [];
132964
+ if (ranges.length > 0 && ranges.every((range) => closureSatisfiesRange(range, closureVersion)) && sameCompatLine(closureVersion, candidate.version)) links.push({
132965
+ name: candidate.name,
132966
+ closureVersion
132967
+ });
132968
+ else keeps.push({
132969
+ name: candidate.name,
132970
+ reason: "version-incompatible"
132971
+ });
132972
+ }
132973
+ return {
132974
+ links,
132975
+ keeps
132976
+ };
132977
+ }
132978
+ function readTreeManifest(pkgJsonPath) {
132979
+ let raw;
132980
+ try {
132981
+ raw = require_dist10.asJsonObject(require_dist10.parseJsonUnknown(node_fs.readFileSync(pkgJsonPath, "utf-8")));
132982
+ } catch {
132983
+ return null;
132984
+ }
132985
+ if (raw === null) return null;
132986
+ const ranges = /* @__PURE__ */ new Map();
132987
+ for (const key of [
132988
+ "dependencies",
132989
+ "optionalDependencies",
132990
+ "peerDependencies"
132991
+ ]) {
132992
+ const deps = require_dist10.asJsonObject(raw[key]);
132993
+ if (deps === null) continue;
132994
+ for (const [name2, range] of Object.entries(deps)) if (typeof range === "string" && !ranges.has(name2)) ranges.set(name2, range);
132995
+ }
132996
+ const version2 = require_dist10.asString(raw["version"]);
132997
+ return {
132998
+ version: version2.length > 0 ? version2 : null,
132999
+ ranges
133000
+ };
133001
+ }
133002
+ function listTopLevelEntries(nodeModulesDir) {
133003
+ let entries;
133004
+ try {
133005
+ entries = node_fs.readdirSync(nodeModulesDir, { withFileTypes: true });
133006
+ } catch {
133007
+ return [];
133008
+ }
133009
+ const names = [];
133010
+ for (const entry of entries) {
133011
+ if (entry.name.startsWith(".")) continue;
133012
+ if (entry.name.startsWith("@")) {
133013
+ let inner;
133014
+ try {
133015
+ inner = node_fs.readdirSync(node_path.join(nodeModulesDir, entry.name), { withFileTypes: true });
133016
+ } catch {
133017
+ continue;
133018
+ }
133019
+ for (const sub of inner) if (!sub.name.startsWith(".")) names.push(`${entry.name}/${sub.name}`);
133020
+ } else names.push(entry.name);
133021
+ }
133022
+ return names;
133023
+ }
133024
+ function collectAddonTreeState(addonDir) {
133025
+ const nodeModulesDir = node_path.join(addonDir, "node_modules");
133026
+ const requirements = /* @__PURE__ */ new Map();
133027
+ const addRange = (name2, range) => {
133028
+ const existing = requirements.get(name2);
133029
+ if (existing === void 0) requirements.set(name2, [range]);
133030
+ else existing.push(range);
133031
+ };
133032
+ const rootManifest = readTreeManifest(node_path.join(addonDir, "package.json"));
133033
+ if (rootManifest !== null) for (const [name2, range] of rootManifest.ranges) addRange(name2, range);
133034
+ const candidates = [];
133035
+ const existingLinks = [];
133036
+ for (const name2 of listTopLevelEntries(nodeModulesDir)) {
133037
+ const entryDir = node_path.join(nodeModulesDir, name2);
133038
+ let isLink = false;
133039
+ try {
133040
+ isLink = node_fs.lstatSync(entryDir).isSymbolicLink();
133041
+ } catch {
133042
+ continue;
133043
+ }
133044
+ if (isLink) {
133045
+ let target = "";
133046
+ try {
133047
+ target = node_fs.readlinkSync(entryDir);
133048
+ } catch {
133049
+ }
133050
+ existingLinks.push({
133051
+ name: name2,
133052
+ target
133053
+ });
133054
+ }
133055
+ const manifest = readTreeManifest(node_path.join(entryDir, "package.json"));
133056
+ if (manifest === null) continue;
133057
+ if (!isLink && manifest.version !== null) candidates.push({
133058
+ name: name2,
133059
+ version: manifest.version
133060
+ });
133061
+ for (const [depName, range] of manifest.ranges) {
133062
+ if (node_fs.existsSync(node_path.join(entryDir, "node_modules", depName))) continue;
133063
+ addRange(depName, range);
133064
+ }
133065
+ }
133066
+ return {
133067
+ candidates,
133068
+ requirements,
133069
+ existingLinks
133070
+ };
133071
+ }
133072
+ function readClosureVersions(closureNodeModulesDir, names) {
133073
+ const versions = /* @__PURE__ */ new Map();
133074
+ for (const name2 of names) {
133075
+ const manifest = readTreeManifest(node_path.join(closureNodeModulesDir, name2, "package.json"));
133076
+ if (manifest !== null && manifest.version !== null) versions.set(name2, manifest.version);
133077
+ }
133078
+ return versions;
133079
+ }
133080
+ function resolveClosureNodeModulesDir(explicitDir) {
133081
+ const frameworkDir = explicitDir ?? process.env["CAMSTACK_FRAMEWORK_DIR"];
133082
+ if (frameworkDir === void 0 || frameworkDir.length === 0) return null;
133083
+ const nodeModulesDir = explicitDir ?? node_path.join(frameworkDir, "node_modules");
133084
+ return node_fs.existsSync(nodeModulesDir) ? nodeModulesDir : null;
133085
+ }
133086
+ function symlinkType() {
133087
+ return process.platform === "win32" ? "junction" : "dir";
133088
+ }
133089
+ function executeClosureDedupe(addonDir, closureNodeModulesDir, plan, logger) {
133090
+ if (plan.links.length === 0) return [];
133091
+ const nodeModulesDir = node_path.join(addonDir, "node_modules");
133092
+ const probePath = node_path.join(nodeModulesDir, ".closure-link-probe");
133093
+ try {
133094
+ node_fs.mkdirSync(nodeModulesDir, { recursive: true });
133095
+ node_fs.rmSync(probePath, { force: true });
133096
+ node_fs.symlinkSync(closureNodeModulesDir, probePath, symlinkType());
133097
+ node_fs.rmSync(probePath, { force: true });
133098
+ } catch (err) {
133099
+ logger.warn("closure dedupe skipped \u2014 symlinks unavailable on this volume", { meta: {
133100
+ addonDir,
133101
+ error: require_dist10.errMsg(err)
133102
+ } });
133103
+ return [];
133104
+ }
133105
+ const linked = [];
133106
+ for (const link of plan.links) {
133107
+ const target = node_path.join(closureNodeModulesDir, link.name);
133108
+ const manifest = readTreeManifest(node_path.join(target, "package.json"));
133109
+ if (manifest === null || manifest.version !== link.closureVersion) {
133110
+ logger.warn("closure dedupe \u2014 closure changed under the plan; keeping own copy", { meta: {
133111
+ name: link.name,
133112
+ planned: link.closureVersion,
133113
+ found: manifest?.version ?? null
133114
+ } });
133115
+ continue;
133116
+ }
133117
+ const entryDir = node_path.join(nodeModulesDir, link.name);
133118
+ try {
133119
+ node_fs.rmSync(entryDir, {
133120
+ recursive: true,
133121
+ force: true
133122
+ });
133123
+ node_fs.mkdirSync(node_path.dirname(entryDir), { recursive: true });
133124
+ node_fs.symlinkSync(target, entryDir, symlinkType());
133125
+ linked.push(link.name);
133126
+ } catch (err) {
133127
+ logger.warn("closure dedupe \u2014 link failed; entry removed copy may need reinstall", { meta: {
133128
+ name: link.name,
133129
+ error: require_dist10.errMsg(err)
133130
+ } });
133131
+ }
133132
+ }
133133
+ return linked;
133134
+ }
133135
+ function isClosureLinkTarget(linkTarget, closureNodeModulesDir) {
133136
+ if (linkTarget.length === 0) return false;
133137
+ const normalized = node_path.resolve(linkTarget);
133138
+ const root = node_path.resolve(closureNodeModulesDir);
133139
+ return normalized === root || normalized.startsWith(`${root}${node_path.sep}`);
133140
+ }
132471
133141
  var execFileAsync = (0, node_util.promisify)(node_child_process.execFile);
133142
+ function readNativeDependencyNames(pkg) {
133143
+ if (pkg === void 0) return /* @__PURE__ */ new Set();
133144
+ const camstack = require_dist10.asJsonObject(pkg["camstack"]);
133145
+ if (camstack === null) return /* @__PURE__ */ new Set();
133146
+ const native = require_dist10.asJsonObject(camstack["nativeDependencies"]);
133147
+ return native === null ? /* @__PURE__ */ new Set() : new Set(Object.keys(native));
133148
+ }
132472
133149
  function parseInstallSource(value) {
132473
133150
  switch (value) {
132474
133151
  case "npm":
@@ -132530,6 +133207,8 @@ var require_dist3 = __commonJS({
132530
133207
  installSource;
132531
133208
  logger;
132532
133209
  isPackageResolvable;
133210
+ /** Test seam — production resolves via `CAMSTACK_FRAMEWORK_DIR`. */
133211
+ closureNodeModulesDir;
132533
133212
  /**
132534
133213
  * Central manifest of installed addons. Tracks version + source +
132535
133214
  * timestamps + last-backup pointer per package, written atomically
@@ -132551,6 +133230,7 @@ var require_dist3 = __commonJS({
132551
133230
  this.logger = logger;
132552
133231
  this.manifest = new AddonManifest(config.addonsDir);
132553
133232
  this.isPackageResolvable = config.isPackageResolvable;
133233
+ this.closureNodeModulesDir = config.closureNodeModulesDir;
132554
133234
  if (config.installSource) this.installSource = config.installSource;
132555
133235
  else this.installSource = "npm";
132556
133236
  }
@@ -132738,6 +133418,140 @@ var require_dist3 = __commonJS({
132738
133418
  return changes;
132739
133419
  }
132740
133420
  /**
133421
+ * Make every INSTALLED addon's runtime dependencies resolvable — doing the
133422
+ * least work that achieves it. Runs at boot on hub and agent, which is what
133423
+ * turns a restore back into a working node: a system backup archives no
133424
+ * node_modules (D338), so before this pass every restored non-bootstrap
133425
+ * addon failed its first import until someone redeployed it by hand.
133426
+ *
133427
+ * Per addon, in order:
133428
+ * 1. drop closure links that no longer hold (dangling, or the closure
133429
+ * moved to a version the addon tree's recorded ranges refuse) — those
133430
+ * addons get a reinstall;
133431
+ * 2. a declared dep that already resolves in the addon tree costs nothing;
133432
+ * 3. a missing declared dep the closure provides at a COMPATIBLE version
133433
+ * becomes a link — no npm, no network;
133434
+ * 4. only an addon left with a genuinely unresolvable dep gets an npm
133435
+ * install (then dedupe + native ensure, same tail as a fresh install).
133436
+ *
133437
+ * "Only when necessary" is the contract: an addon whose deps are all
133438
+ * satisfied is not touched at all. Failures are per-addon and non-fatal —
133439
+ * a dep that cannot be re-fetched leaves that addon exactly as broken as
133440
+ * it already was, but LOUDLY, and never blocks the rest of the boot.
133441
+ */
133442
+ async ensureAddonRuntimeDeps() {
133443
+ const closureNodeModules = resolveClosureNodeModulesDir(this.closureNodeModulesDir);
133444
+ const linked = [];
133445
+ const reinstalled = [];
133446
+ const failed = [];
133447
+ const installedPackages = this.listInstalled();
133448
+ for (const pkg of installedPackages) {
133449
+ try {
133450
+ if (node_fs.lstatSync(pkg.dir).isSymbolicLink()) continue;
133451
+ } catch {
133452
+ continue;
133453
+ }
133454
+ try {
133455
+ const outcome = this.ensureOneAddonDeps(pkg.dir, pkg.name, closureNodeModules);
133456
+ for (const dep of outcome.linked) linked.push(`${pkg.name}:${dep}`);
133457
+ if (outcome.needsInstall) {
133458
+ await this.reinstallAddonDeps(pkg.dir, pkg.name);
133459
+ reinstalled.push(pkg.name);
133460
+ }
133461
+ } catch (err) {
133462
+ failed.push(pkg.name);
133463
+ this.logger.error(`${pkg.name} \u2014 runtime dependencies could NOT be ensured; the addon will fail to load until its deps install`, { meta: {
133464
+ dir: pkg.dir,
133465
+ error: require_dist10.errMsg(err)
133466
+ } });
133467
+ }
133468
+ }
133469
+ return {
133470
+ checked: installedPackages.length,
133471
+ linked,
133472
+ reinstalled,
133473
+ failed
133474
+ };
133475
+ }
133476
+ /**
133477
+ * One addon's dependency audit (steps 1–3 of `ensureAddonRuntimeDeps`).
133478
+ * Pure decision + link creation; the npm fallback is the caller's.
133479
+ */
133480
+ ensureOneAddonDeps(addonDir, packageName, closureNodeModules) {
133481
+ const pkgView = readPackageJson(node_path.join(addonDir, "package.json"));
133482
+ const declared = pkgView === null ? null : require_dist10.asJsonObject(pkgView.raw["dependencies"]);
133483
+ const declaredEntries = declared === null ? [] : Object.entries(declared);
133484
+ const state = collectAddonTreeState(addonDir);
133485
+ const nodeModulesDir = node_path.join(addonDir, "node_modules");
133486
+ let needsInstall = false;
133487
+ if (closureNodeModules !== null) for (const link of state.existingLinks) {
133488
+ if (!isClosureLinkTarget(link.target, closureNodeModules)) continue;
133489
+ const linkDir = node_path.join(nodeModulesDir, link.name);
133490
+ const targetManifest = node_fs.existsSync(node_path.join(linkDir, "package.json"));
133491
+ const ranges = state.requirements.get(link.name) ?? [];
133492
+ let holds = targetManifest;
133493
+ if (holds && ranges.length > 0) {
133494
+ const version2 = readClosureVersions(closureNodeModules, [link.name]).get(link.name);
133495
+ holds = version2 !== void 0 && ranges.every((range) => closureSatisfiesRange(range, version2));
133496
+ }
133497
+ if (!holds) {
133498
+ this.logger.warn(`${packageName} \u2014 closure link for ${link.name} no longer holds; reinstalling this addon's deps`, { meta: { target: link.target } });
133499
+ node_fs.rmSync(linkDir, { force: true });
133500
+ needsInstall = true;
133501
+ }
133502
+ }
133503
+ const linked = [];
133504
+ for (const [depName, range] of declaredEntries) {
133505
+ if (typeof range !== "string") continue;
133506
+ if (node_fs.existsSync(node_path.join(nodeModulesDir, depName, "package.json"))) continue;
133507
+ const closureVersion = closureNodeModules === null ? void 0 : readClosureVersions(closureNodeModules, [depName]).get(depName);
133508
+ if (closureNodeModules !== null && closureVersion !== void 0 && closureSatisfiesRange(range, closureVersion)) {
133509
+ if (executeClosureDedupe(addonDir, closureNodeModules, {
133510
+ links: [{
133511
+ name: depName,
133512
+ closureVersion
133513
+ }],
133514
+ keeps: []
133515
+ }, this.logger).length === 1) {
133516
+ this.logger.info(`${packageName} \u2014 ${depName} linked from the server closure`, { meta: { closureVersion } });
133517
+ linked.push(depName);
133518
+ continue;
133519
+ }
133520
+ }
133521
+ needsInstall = true;
133522
+ }
133523
+ return {
133524
+ linked,
133525
+ needsInstall
133526
+ };
133527
+ }
133528
+ /**
133529
+ * npm-install an installed addon's runtime deps IN PLACE (the restore /
133530
+ * repair path — the tree is not yet running at boot), then the same tail
133531
+ * every fresh install gets: closure dedupe + native deps + prebuild ensure.
133532
+ * Closure links are stripped first so npm reifies against plain missing
133533
+ * dirs instead of symlinks it does not own.
133534
+ */
133535
+ async reinstallAddonDeps(addonDir, packageName) {
133536
+ const closureNodeModules = resolveClosureNodeModulesDir(this.closureNodeModulesDir);
133537
+ if (closureNodeModules !== null) {
133538
+ const state = collectAddonTreeState(addonDir);
133539
+ for (const link of state.existingLinks) if (isClosureLinkTarget(link.target, closureNodeModules)) node_fs.rmSync(node_path.join(addonDir, "node_modules", link.name), { force: true });
133540
+ }
133541
+ this.logger.info(`${packageName} \u2014 reinstalling runtime dependencies`, { meta: { addonDir } });
133542
+ await require_manifest_system_deps.runNpm([
133543
+ "install",
133544
+ "--omit=dev",
133545
+ "--omit=peer",
133546
+ "--no-audit",
133547
+ "--no-fund",
133548
+ "--no-package-lock",
133549
+ ...this.registry ? ["--registry", this.registry] : []
133550
+ ], this.npmRunOptions(addonDir, 24e4));
133551
+ this.dedupeTreeAgainstClosure(addonDir, packageName);
133552
+ await this.installNativeDependencies(addonDir, packageName);
133553
+ }
133554
+ /**
132741
133555
  * Install a single addon — on-demand installer used by AddonPackageService
132742
133556
  * (admin UI "Install addon" / "Reinstall" buttons).
132743
133557
  *
@@ -132845,6 +133659,7 @@ var require_dist3 = __commonJS({
132845
133659
  logger: this.logger,
132846
133660
  bundledSourceDir: sourceDir
132847
133661
  });
133662
+ this.dedupeTreeAgainstClosure(targetDir, packageName);
132848
133663
  this.logger.info(`${packageName} \u2014 copied from ${sourceDir}`);
132849
133664
  return {
132850
133665
  name: pkgData.name,
@@ -133149,6 +133964,40 @@ var require_dist3 = __commonJS({
133149
133964
  logger: this.logger
133150
133965
  });
133151
133966
  }
133967
+ /**
133968
+ * Stop copying what the server closure already provides: replace each
133969
+ * top-level `node_modules` entry the closure serves at a COMPATIBLE version
133970
+ * with a symlink into the closure (see `deps/closure-dedupe.ts` for the
133971
+ * plan rules and the live measurements). Runs LAST in tree
133972
+ * materialisation — after the runtime-dep npm install AND the native-dep
133973
+ * steps — so no later npm invocation can fight the links.
133974
+ *
133975
+ * Non-fatal by design: dedupe is an optimisation, and its failure mode is
133976
+ * "the addon keeps its own copies", which is exactly today's behaviour. A
133977
+ * refusal on version grounds is logged loudly — that line is the answer to
133978
+ * "why does this addon still carry its own zod".
133979
+ */
133980
+ dedupeTreeAgainstClosure(pkgDir, packageName) {
133981
+ const closureNodeModules = resolveClosureNodeModulesDir(this.closureNodeModulesDir);
133982
+ if (closureNodeModules === null) return;
133983
+ try {
133984
+ const state = collectAddonTreeState(pkgDir);
133985
+ if (state.candidates.length === 0) return;
133986
+ const pkgView = readPackageJson(node_path.join(pkgDir, "package.json"));
133987
+ const plan = planClosureDedupe({
133988
+ candidates: state.candidates,
133989
+ closureVersions: readClosureVersions(closureNodeModules, state.candidates.map((c) => c.name)),
133990
+ requirements: state.requirements,
133991
+ neverLink: readNativeDependencyNames(pkgView?.raw)
133992
+ });
133993
+ const linked = executeClosureDedupe(pkgDir, closureNodeModules, plan, this.logger);
133994
+ if (linked.length > 0) this.logger.info(`${packageName} \u2014 ${linked.length} node_modules entr${linked.length === 1 ? "y" : "ies"} now resolve from the server closure`, { meta: { linked } });
133995
+ const refused = plan.keeps.filter((k2) => k2.reason === "version-incompatible");
133996
+ if (refused.length > 0) this.logger.info(`${packageName} \u2014 kept own copy where the closure version is incompatible`, { meta: { kept: refused.map((k2) => k2.name) } });
133997
+ } catch (err) {
133998
+ this.logger.warn(`${packageName} \u2014 closure dedupe failed (copies kept)`, { meta: { error: require_dist10.errMsg(err) } });
133999
+ }
134000
+ }
133152
134001
  /** Install addon from a tgz file (uploaded or downloaded) */
133153
134002
  async installFromTgz(tgzPath) {
133154
134003
  const stagingRoot = node_path.join(this.addonsDir, ".staging");
@@ -133174,6 +134023,7 @@ var require_dist3 = __commonJS({
133174
134023
  const targetDir = node_path.join(this.addonsDir, pkgView.name);
133175
134024
  await this.installRuntimeDependencies(pkgDir, pkgView.name);
133176
134025
  await this.installNativeDependencies(pkgDir, pkgView.name);
134026
+ this.dedupeTreeAgainstClosure(pkgDir, pkgView.name);
133177
134027
  await this.evictInstallDir(targetDir);
133178
134028
  ensureDir(node_path.dirname(targetDir));
133179
134029
  await node_fs.promises.rename(pkgDir, targetDir);
@@ -133306,6 +134156,7 @@ var require_dist3 = __commonJS({
133306
134156
  if (!node_fs.existsSync(addonDir)) throw new Error(`Cannot update ${packageName}: install dir ${addonDir} missing`);
133307
134157
  await this.installRuntimeDependencies(stagedPath, packageName);
133308
134158
  await this.installNativeDependencies(stagedPath, packageName);
134159
+ this.dedupeTreeAgainstClosure(stagedPath, packageName);
133309
134160
  const ts2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
133310
134161
  const backupRoot = node_path.join(this.addonsDir, ".backups", packageName);
133311
134162
  ensureDir(backupRoot);
@@ -163521,7 +164372,7 @@ globstar while`, t, d, e, u, m), this.matchOne(t.slice(d), e.slice(u), s)) retur
163521
164372
  }));
163522
164373
  var require_symlink_type2 = /* @__PURE__ */ require_chunk.__commonJSMin(((exports2, module2) => {
163523
164374
  var fs2 = require_graceful_fs2();
163524
- function symlinkType(srcpath, type2, callback) {
164375
+ function symlinkType2(srcpath, type2, callback) {
163525
164376
  callback = typeof type2 === "function" ? type2 : callback;
163526
164377
  type2 = typeof type2 === "function" ? false : type2;
163527
164378
  if (type2) return callback(null, type2);
@@ -163542,7 +164393,7 @@ globstar while`, t, d, e, u, m), this.matchOne(t.slice(d), e.slice(u), s)) retur
163542
164393
  return stats && stats.isDirectory() ? "dir" : "file";
163543
164394
  }
163544
164395
  module2.exports = {
163545
- symlinkType,
164396
+ symlinkType: symlinkType2,
163546
164397
  symlinkTypeSync
163547
164398
  };
163548
164399
  }));
@@ -163557,7 +164408,7 @@ globstar while`, t, d, e, u, m), this.matchOne(t.slice(d), e.slice(u), s)) retur
163557
164408
  var symlinkPaths = _symlinkPaths.symlinkPaths;
163558
164409
  var symlinkPathsSync = _symlinkPaths.symlinkPathsSync;
163559
164410
  var _symlinkType = require_symlink_type2();
163560
- var symlinkType = _symlinkType.symlinkType;
164411
+ var symlinkType2 = _symlinkType.symlinkType;
163561
164412
  var symlinkTypeSync = _symlinkType.symlinkTypeSync;
163562
164413
  var pathExists = require_path_exists2().pathExists;
163563
164414
  function createSymlink(srcpath, dstpath, type2, callback) {
@@ -163569,7 +164420,7 @@ globstar while`, t, d, e, u, m), this.matchOne(t.slice(d), e.slice(u), s)) retur
163569
164420
  symlinkPaths(srcpath, dstpath, (err2, relative) => {
163570
164421
  if (err2) return callback(err2);
163571
164422
  srcpath = relative.toDst;
163572
- symlinkType(relative.toCwd, type2, (err3, type3) => {
164423
+ symlinkType2(relative.toCwd, type2, (err3, type3) => {
163573
164424
  if (err3) return callback(err3);
163574
164425
  const dir = path$23.dirname(dstpath);
163575
164426
  pathExists(dir, (err4, dirExists) => {
@@ -210088,7 +210939,7 @@ var require_dist4 = __commonJS({
210088
210939
  "use strict";
210089
210940
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
210090
210941
  var require_event_category = require_event_category_BaEgqJNv();
210091
- var require_sleep = require_sleep_BwGJ_wL();
210942
+ var require_sleep = require_sleep_jdpPltQH();
210092
210943
  var require_canonical_hash = require_canonical_hash_DNV8S5ET();
210093
210944
  var require_enums2 = require_enums();
210094
210945
  var require_err_msg = require_err_msg_COpsHMw2();
@@ -214698,6 +215549,68 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
214698
215549
  fileCount: zod.z.number(),
214699
215550
  present: zod.z.boolean()
214700
215551
  });
215552
+ var BackupRunStateSchema = zod.z.enum([
215553
+ "queued",
215554
+ "running",
215555
+ "succeeded",
215556
+ "failed",
215557
+ "cancelled"
215558
+ ]);
215559
+ var BackupRunPhaseSchema = zod.z.enum([
215560
+ "queued",
215561
+ "building",
215562
+ "uploading",
215563
+ "done"
215564
+ ]);
215565
+ var BackupRunSchema = zod.z.object({
215566
+ /** Stable run id — the handle `backup.cancel` takes. */
215567
+ id: zod.z.string(),
215568
+ state: BackupRunStateSchema,
215569
+ phase: BackupRunPhaseSchema,
215570
+ /**
215571
+ * Resolved destination location ids. Empty while queued (targets are
215572
+ * resolved when the run starts, against the then-current policies).
215573
+ */
215574
+ destinationIds: zod.z.array(zod.z.string()).readonly(),
215575
+ label: zod.z.string().optional(),
215576
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
215577
+ requestedAt: zod.z.number(),
215578
+ /** ms-epoch when the run left the queue and started building. */
215579
+ startedAt: zod.z.number().optional(),
215580
+ /** ms-epoch when the run reached a terminal state. */
215581
+ finishedAt: zod.z.number().optional(),
215582
+ /** Compressed bytes of the staging archive written so far. */
215583
+ stagedBytes: zod.z.number(),
215584
+ /** Final staged archive size, once the build phase completes. */
215585
+ archiveSizeBytes: zod.z.number().optional(),
215586
+ /** Bytes pushed to the destination currently uploading. */
215587
+ uploadedBytes: zod.z.number(),
215588
+ /** Destinations where the archive fully landed (uploaded + indexed). */
215589
+ completedDestinationIds: zod.z.array(zod.z.string()).readonly(),
215590
+ /** Destinations that failed during the fan-out. */
215591
+ failedDestinationIds: zod.z.array(zod.z.string()).readonly(),
215592
+ /** Failure message when `state === 'failed'`. */
215593
+ error: zod.z.string().optional(),
215594
+ /**
215595
+ * 1-based place in the execution queue — 1 = runs next. Present only
215596
+ * while `state === 'queued'`. Stamped by the orchestrator from the
215597
+ * queue's OWN pending order, never derived from timestamps, so the
215598
+ * UI cannot show an order the executor will not honour.
215599
+ */
215600
+ queuePosition: zod.z.number().int().min(1).optional()
215601
+ });
215602
+ var BackupTriggerResultSchema = zod.z.object({
215603
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
215604
+ runId: zod.z.string(),
215605
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
215606
+ queued: zod.z.boolean(),
215607
+ /** True when this trigger was coalesced onto an identical already-queued run. */
215608
+ joined: zod.z.boolean(),
215609
+ /** True when the run was cancelled before completing every destination. */
215610
+ cancelled: zod.z.boolean(),
215611
+ /** One entry per destination the archive landed at (partial on cancel). */
215612
+ entries: zod.z.array(BackupEntrySchema).readonly()
215613
+ });
214701
215614
  var BackupScheduleSchema = zod.z.object({
214702
215615
  /** Stable id. Generated by the orchestrator on first upsert if absent. */
214703
215616
  id: zod.z.string(),
@@ -214734,6 +215647,11 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
214734
215647
  * Trigger a backup. Without `destinations` the orchestrator fans
214735
215648
  * out to every destination flagged as enabled in the routing
214736
215649
  * config; with it, only the listed addons receive the archive.
215650
+ *
215651
+ * At most ONE backup run executes at a time — the source tree and
215652
+ * the staging disk are shared by every run, so a second trigger
215653
+ * while one is in flight is enqueued (or joined onto an identical
215654
+ * queued run) and the result says so. See D342.
214737
215655
  */
214738
215656
  trigger: require_sleep.method(zod.z.object({
214739
215657
  /** Subset of registered `backup-destination` addon ids to write to. */
@@ -214747,7 +215665,30 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
214747
215665
  * retention (manual runs).
214748
215666
  */
214749
215667
  retentionCount: zod.z.number().int().min(1).max(1e3).optional()
214750
- }).optional(), zod.z.array(BackupEntrySchema).readonly(), {
215668
+ }).optional(), BackupTriggerResultSchema, {
215669
+ kind: "mutation",
215670
+ auth: "admin"
215671
+ }),
215672
+ /**
215673
+ * Every run the orchestrator knows about, in EXECUTION order: the
215674
+ * running run first, then queued runs in the exact order they will
215675
+ * execute (each with `queuePosition`, 1 = next), then the bounded
215676
+ * finished history newest-first. Only one run executes at a time
215677
+ * (D342) — the queued section IS the line. Each run carries live
215678
+ * phase + byte counters so a runaway build is visible in seconds,
215679
+ * not via `du`.
215680
+ */
215681
+ listRuns: require_sleep.method(zod.z.void(), zod.z.array(BackupRunSchema).readonly(), { auth: "admin" }),
215682
+ /**
215683
+ * Stop a backup run. Mirrors `storage-migration.cancel` semantics:
215684
+ * id in, `{ cancelled }` out — `false` when the run is unknown or
215685
+ * already terminal. A QUEUED run is removed before it ever starts;
215686
+ * the RUNNING run has its tar/upload stream actually aborted, the
215687
+ * half-written staging archive is deleted, and the in-flight
215688
+ * destination upload is aborted server-side (partial discarded).
215689
+ * Destinations that already completed keep their archive.
215690
+ */
215691
+ cancel: require_sleep.method(zod.z.object({ runId: zod.z.string() }), zod.z.object({ cancelled: zod.z.boolean() }), {
214751
215692
  kind: "mutation",
214752
215693
  auth: "admin"
214753
215694
  }),
@@ -215487,6 +216428,38 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
215487
216428
  auth: "admin"
215488
216429
  }),
215489
216430
  /**
216431
+ * The HARDWARE behind a device number was replaced
216432
+ * (`deviceManager.migrateDevice`). Forget every piece of broker state that
216433
+ * described the old box, so the next catalog pull derives everything from
216434
+ * the camera that is actually there:
216435
+ *
216436
+ * - every `derived:*` stream definition — a derived is authored against a
216437
+ * specific profile layout, and against the wrong hardware its feeder
216438
+ * respawns forever (observed at attempt 2732 on the live hub,
216439
+ * 2026-09-01);
216440
+ * - the profile-slot assignment entry, PURGED (not unassigned — unassign
216441
+ * marks the slot manual, which would pin the stale choice instead of
216442
+ * letting `computeInitialAssignment` re-derive it);
216443
+ * - the probe snapshots (`<deviceId>/…` — probed codec/resolution of the
216444
+ * old hardware);
216445
+ * - the persisted RTSP token rows for the device's brokers (keyed
216446
+ * `<deviceId>/<camStreamId>`; the stream ids change with the hardware,
216447
+ * so the rows are dead URLs).
216448
+ *
216449
+ * An RPC, deliberately — an event is telemetry and may be dropped (D8),
216450
+ * and a dropped forget leaves a feeder respawning against a stream that
216451
+ * does not exist.
216452
+ */
216453
+ forgetDeviceHardware: require_sleep.method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.object({
216454
+ derivedStreamsDeleted: zod.z.array(zod.z.string()).readonly(),
216455
+ assignmentsPurged: zod.z.boolean(),
216456
+ probeSnapshotsDropped: zod.z.number().int().nonnegative(),
216457
+ rtspTokenRowsDeleted: zod.z.number().int().nonnegative()
216458
+ }), {
216459
+ kind: "mutation",
216460
+ auth: "admin"
216461
+ }),
216462
+ /**
215490
216463
  * Render a short GIF or MP4 from the broker's PRE-BUFFER around an instant.
215491
216464
  *
215492
216465
  * The pre-buffer is the only source that already holds the seconds BEFORE
@@ -217311,6 +218284,35 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
217311
218284
  name: zod.z.string(),
217312
218285
  type: zod.z.string()
217313
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
+ }),
217314
218316
  supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
217315
218317
  /**
217316
218318
  * Run a network scan. `params` carries optional provider-specific scan
@@ -217632,13 +218634,21 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
217632
218634
  * `sourceStillLive` names what stayed live; empty is the only value that
217633
218635
  * means the old hardware is quiet. The migration proceeds either way —
217634
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.
217635
218644
  */
217636
218645
  migrateDevice: require_sleep.method(zod.z.object({
217637
218646
  sourceId: zod.z.number(),
217638
218647
  targetId: zod.z.number()
217639
218648
  }), MigrateDeviceResultSchema, {
217640
218649
  kind: "mutation",
217641
- auth: "admin"
218650
+ auth: "admin",
218651
+ timeoutMs: 12 * 6e4
217642
218652
  }),
217643
218653
  /** Register a device in the DB + in-memory registry. Called by DeviceManagerApi.register(). */
217644
218654
  registerDevice: require_sleep.method(DeviceRegisterPayloadSchema, zod.z.void(), { kind: "mutation" }),
@@ -238323,6 +239333,138 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
238323
239333
  return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
238324
239334
  }
238325
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
+ };
238326
239468
  function toDeviceSummary(device2, addonId) {
238327
239469
  const config = {};
238328
239470
  for (const entry of device2.config.entries()) config[entry.key] = entry.value;
@@ -238348,6 +239490,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
238348
239490
  }];
238349
239491
  }
238350
239492
  async onShutdown() {
239493
+ this.cancelRestoreRetries();
238351
239494
  const devices = await this.ctx.kernel.devices?.getAll() ?? [];
238352
239495
  for (const device2 of devices) try {
238353
239496
  await this.ctx.kernel.devices?.decommission(device2.id);
@@ -238367,9 +239510,16 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
238367
239510
  async stop() {
238368
239511
  }
238369
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
+ };
238370
239519
  return {
238371
239520
  connected: true,
238372
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
239521
+ deviceCount: all.length,
239522
+ error: summary
238373
239523
  };
238374
239524
  }
238375
239525
  async getDevices() {
@@ -238459,8 +239609,137 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
238459
239609
  };
238460
239610
  }
238461
239611
  async restoreDevices(savedDevices) {
238462
- await this.onRestoreDevices(savedDevices);
238463
- 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 };
238464
239743
  }
238465
239744
  /**
238466
239745
  * Restore devices from persisted state. Two-pass:
@@ -238486,55 +239765,108 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
238486
239765
  * accessory-spawn flow handles via the parent's
238487
239766
  * `getAccessoryChildren()`. Override only when the default doesn't
238488
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`.
238489
239774
  */
238490
239775
  async onRestoreDevices(savedDevices) {
238491
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
+ };
238492
239786
  const topLevel = savedDevices.filter((saved) => saved.parentDeviceId === null);
238493
239787
  const restoreOne = async (saved) => {
238494
- const Class = this.deviceClasses[saved.type];
238495
- if (!Class) {
239788
+ if (!this.deviceClasses[saved.type]) {
238496
239789
  this.ctx.logger.warn("No device class registered for restored type \u2014 skipping", {
238497
- tags: { stableId: saved.stableId },
239790
+ tags: {
239791
+ deviceId: saved.id,
239792
+ stableId: saved.stableId
239793
+ },
238498
239794
  meta: { type: saved.type }
238499
239795
  });
238500
239796
  return;
238501
239797
  }
238502
239798
  try {
238503
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
238504
- restored.add(saved.id);
239799
+ await attemptRestore(saved);
238505
239800
  } catch (err) {
238506
- this.ctx.logger.warn("Failed to restore device", {
238507
- 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
+ },
238508
239807
  meta: {
238509
239808
  type: saved.type,
238510
- error: err instanceof Error ? err.message : String(err)
239809
+ attempt: 1,
239810
+ error
238511
239811
  }
238512
239812
  });
239813
+ failures.push({
239814
+ saved,
239815
+ error
239816
+ });
238513
239817
  }
238514
239818
  };
238515
239819
  await Promise.all(topLevel.map((saved) => restoreOne(saved)));
239820
+ const failedTopLevelIds = new Set(failures.map((failure) => failure.saved.id));
238516
239821
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
238517
239822
  for (const saved of childRows) {
238518
- const Class = this.deviceClasses[saved.type];
238519
- if (!Class) continue;
239823
+ if (!this.deviceClasses[saved.type]) continue;
238520
239824
  if (saved.parentDeviceId === null) continue;
238521
- if (!restored.has(saved.parentDeviceId)) continue;
238522
- try {
238523
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
238524
- restored.add(saved.id);
238525
- } catch (err) {
238526
- 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", {
238527
239851
  tags: {
239852
+ deviceId: saved.id,
238528
239853
  stableId: saved.stableId,
238529
239854
  parentDeviceId: saved.parentDeviceId
238530
239855
  },
238531
- meta: {
238532
- type: saved.type,
238533
- error: err instanceof Error ? err.message : String(err)
238534
- }
239856
+ meta: { type: saved.type }
238535
239857
  });
239858
+ failures.push({
239859
+ saved,
239860
+ error: `parent device ${saved.parentDeviceId} not restored`
239861
+ });
239862
+ continue;
238536
239863
  }
238537
239864
  }
239865
+ if (failures.length > 0) this.scheduleRestoreRetries(failures, attemptRestore);
239866
+ return {
239867
+ restoredCount: restored.size,
239868
+ failedCount: failures.length
239869
+ };
238538
239870
  }
238539
239871
  /** Convert an IDevice to the flat DeviceSummary for the cap router. */
238540
239872
  toSummary(device2) {
@@ -241376,6 +242708,12 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
241376
242708
  addonId: null,
241377
242709
  access: "create"
241378
242710
  },
242711
+ "backup.cancel": {
242712
+ capName: "backup",
242713
+ capScope: "system",
242714
+ addonId: null,
242715
+ access: "create"
242716
+ },
241379
242717
  "backup.delete": {
241380
242718
  capName: "backup",
241381
242719
  capScope: "system",
@@ -241418,6 +242756,12 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
241418
242756
  addonId: null,
241419
242757
  access: "view"
241420
242758
  },
242759
+ "backup.listRuns": {
242760
+ capName: "backup",
242761
+ capScope: "system",
242762
+ addonId: null,
242763
+ access: "view"
242764
+ },
241421
242765
  "backup.listSchedules": {
241422
242766
  capName: "backup",
241423
242767
  capScope: "system",
@@ -242618,6 +243962,12 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
242618
243962
  addonId: null,
242619
243963
  access: "view"
242620
243964
  },
243965
+ "deviceProvider.reloadDevice": {
243966
+ capName: "device-provider",
243967
+ capScope: "system",
243968
+ addonId: null,
243969
+ access: "create"
243970
+ },
242621
243971
  "deviceProvider.start": {
242622
243972
  capName: "device-provider",
242623
243973
  capScope: "system",
@@ -245984,6 +247334,12 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
245984
247334
  addonId: null,
245985
247335
  access: "create"
245986
247336
  },
247337
+ "streamBroker.forgetDeviceHardware": {
247338
+ capName: "stream-broker",
247339
+ capScope: "system",
247340
+ addonId: null,
247341
+ access: "delete"
247342
+ },
245987
247343
  "streamBroker.getAllRtspEntries": {
245988
247344
  capName: "stream-broker",
245989
247345
  capScope: "system",
@@ -248700,6 +250056,11 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
248700
250056
  form: "single",
248701
250057
  optional: false
248702
250058
  }],
250059
+ "streamBroker.forgetDeviceHardware": [{
250060
+ name: "deviceId",
250061
+ form: "single",
250062
+ optional: false
250063
+ }],
248703
250064
  "streamBroker.getDeviceAudioMute": [{
248704
250065
  name: "deviceId",
248705
250066
  form: "single",
@@ -249071,6 +250432,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
249071
250432
  "recordingExport.listExports",
249072
250433
  "streamBroker.acquireEgressTranscode",
249073
250434
  "streamBroker.assignProfile",
250435
+ "streamBroker.forgetDeviceHardware",
249074
250436
  "streamBroker.getDeviceAudioMute",
249075
250437
  "streamBroker.getStreamWithCodec",
249076
250438
  "streamBroker.produceEventMedia",
@@ -249569,6 +250931,8 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
249569
250931
  backup: {
249570
250932
  listDestinations: (input) => dispatch("backup", "listDestinations", "query", input),
249571
250933
  trigger: (input) => dispatch("backup", "trigger", "mutation", input),
250934
+ listRuns: (input) => dispatch("backup", "listRuns", "query", input),
250935
+ cancel: (input) => dispatch("backup", "cancel", "mutation", input),
249572
250936
  list: (input) => dispatch("backup", "list", "query", input),
249573
250937
  listLocations: (input) => dispatch("backup", "listLocations", "query", input),
249574
250938
  getEntries: (input) => dispatch("backup", "getEntries", "query", input),
@@ -252848,6 +254212,10 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
252848
254212
  exports.BacklightModeSchema = BacklightModeSchema;
252849
254213
  exports.BackupDestinationInfoSchema = BackupDestinationInfoSchema;
252850
254214
  exports.BackupEntrySchema = BackupEntrySchema;
254215
+ exports.BackupRunPhaseSchema = BackupRunPhaseSchema;
254216
+ exports.BackupRunSchema = BackupRunSchema;
254217
+ exports.BackupRunStateSchema = BackupRunStateSchema;
254218
+ exports.BackupTriggerResultSchema = BackupTriggerResultSchema;
252851
254219
  exports.BaseAddon = require_sleep.BaseAddon;
252852
254220
  exports.BaseDevice = BaseDevice;
252853
254221
  exports.BaseDeviceProvider = BaseDeviceProvider;
@@ -253004,6 +254372,8 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
253004
254372
  exports.DEVICE_CAP_NAMES = DEVICE_CAP_NAMES;
253005
254373
  exports.DEVICE_CHILDREN_BATCH_MAX = DEVICE_CHILDREN_BATCH_MAX;
253006
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;
253007
254377
  exports.DEVICE_SCOPED_CAPS = require_sleep.DEVICE_SCOPED_CAPS;
253008
254378
  exports.DEVICE_SETTINGS_CONTRIBUTION_METHODS = require_sleep.DEVICE_SETTINGS_CONTRIBUTION_METHODS;
253009
254379
  exports.DEVICE_STATE_READERS = DEVICE_STATE_READERS;
@@ -253033,6 +254403,7 @@ ${recipe.triggers.map((t, i) => emitTrigger(t, i)).join("\n\n")}
253033
254403
  exports.DeviceFeature = require_sleep.DeviceFeature;
253034
254404
  exports.DeviceInfoSchema = DeviceInfoSchema;
253035
254405
  exports.DeviceNetworkStatsSchema = DeviceNetworkStatsSchema;
254406
+ exports.DeviceRestoreRetryScheduler = DeviceRestoreRetryScheduler;
253036
254407
  exports.DeviceRole = require_sleep.DeviceRole;
253037
254408
  exports.DeviceRuntimeState = DeviceRuntimeState;
253038
254409
  exports.DeviceSelectorSchema = DeviceSelectorSchema;
@@ -387819,6 +389190,16 @@ var require_main2 = __commonJS({
387819
389190
  installSource: resolvedSource
387820
389191
  });
387821
389192
  await installer.ensureRequiredPackages(system_1.AddonInstaller.AGENT_PACKAGES);
389193
+ if (typeof installer.ensureAddonRuntimeDeps === "function") {
389194
+ try {
389195
+ const depsResult = await installer.ensureAddonRuntimeDeps();
389196
+ if (depsResult.linked.length > 0 || depsResult.reinstalled.length > 0 || depsResult.failed.length > 0) {
389197
+ console.log(`[Agent] Addon runtime deps ensured \u2014 linked: [${depsResult.linked.join(", ")}], reinstalled: [${depsResult.reinstalled.join(", ")}], failed: [${depsResult.failed.join(", ")}] (${depsResult.checked} checked)`);
389198
+ }
389199
+ } catch (err) {
389200
+ console.warn(`[Agent] ensureAddonRuntimeDeps failed: ${err instanceof Error ? err.message : String(err)}`);
389201
+ }
389202
+ }
387822
389203
  console.log(`[Agent] Addon packages ready in ${config.addonsDir}`);
387823
389204
  let broker = (0, system_1.createBroker)({
387824
389205
  nodeID: config.nodeId,
@@ -427674,6 +429055,18 @@ var require_launcher = __commonJS({
427674
429055
  console.warn(`[launcher] first-boot addon plan failed: ${err instanceof Error ? err.message : String(err)}`);
427675
429056
  }
427676
429057
  await installer.ensureRequiredPackages(bootstrapRequired);
429058
+ if (typeof installer.ensureAddonRuntimeDeps === "function") {
429059
+ try {
429060
+ const depsResult = await installer.ensureAddonRuntimeDeps();
429061
+ if (depsResult.linked.length > 0 || depsResult.reinstalled.length > 0 || depsResult.failed.length > 0) {
429062
+ console.log(`[launcher] Addon runtime deps ensured \u2014 linked: [${depsResult.linked.join(", ")}], reinstalled: [${depsResult.reinstalled.join(", ")}], failed: [${depsResult.failed.join(", ")}] (${depsResult.checked} checked)`);
429063
+ }
429064
+ } catch (err) {
429065
+ console.warn(`[launcher] ensureAddonRuntimeDeps failed: ${err instanceof Error ? err.message : String(err)}`);
429066
+ }
429067
+ } else {
429068
+ console.warn("[launcher] ensureAddonRuntimeDeps unavailable \u2014 skipping addon dep repair");
429069
+ }
427677
429070
  if (typeof installer.reconcileManifest === "function") {
427678
429071
  const reconciled = installer.reconcileManifest();
427679
429072
  if (reconciled > 0) {