@camstack/system 1.2.179 → 1.2.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +56 -10
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +657 -62
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +658 -63
  12. package/dist/builtins/backup-orchestrator/run-queue.d.ts +103 -0
  13. package/dist/builtins/backup-orchestrator/staging-paths.d.ts +36 -0
  14. package/dist/builtins/console-logging/index.js +1 -1
  15. package/dist/builtins/console-logging/index.mjs +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  17. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  18. package/dist/builtins/device-manager/device-manager.addon.js +349 -65
  19. package/dist/builtins/device-manager/device-manager.addon.mjs +349 -65
  20. package/dist/builtins/device-manager/device-meta-actions.d.ts +8 -19
  21. package/dist/builtins/device-manager/device-provider-context.d.ts +4 -0
  22. package/dist/builtins/device-manager/migrate-device.d.ts +47 -3
  23. package/dist/builtins/device-manager/migrate-guard.d.ts +39 -0
  24. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +24 -0
  25. package/dist/builtins/device-manager/migrate-reload.d.ts +8 -0
  26. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  27. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  28. package/dist/builtins/hub-forwarder/index.js +1 -1
  29. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  30. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  31. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  32. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  33. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  34. package/dist/builtins/local-network/local-network.addon.js +1 -1
  35. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  36. package/dist/builtins/loki-logging/index.js +1 -1
  37. package/dist/builtins/loki-logging/index.mjs +1 -1
  38. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  39. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  40. package/dist/builtins/platform-probe/index.js +1 -1
  41. package/dist/builtins/platform-probe/index.mjs +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  43. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/snapshot/index.js +1 -1
  45. package/dist/builtins/snapshot/index.mjs +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  47. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  50. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  51. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  52. package/dist/builtins/system-backup/system-backup.service.d.ts +55 -2
  53. package/dist/builtins/system-config/system-config.addon.js +1 -1
  54. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  55. package/dist/builtins/winston-logging/index.js +1 -1
  56. package/dist/builtins/winston-logging/index.mjs +1 -1
  57. package/dist/{dist-L_39vRmH.mjs → dist-DbnNk4Lw.mjs} +211 -2
  58. package/dist/{dist-C7qWwrK1.js → dist-whe0b4_3.js} +211 -2
  59. package/dist/index.js +530 -2
  60. package/dist/index.mjs +533 -5
  61. package/dist/kernel/addon-installer.d.ts +69 -0
  62. package/dist/kernel/deps/closure-dedupe.d.ts +105 -0
  63. package/dist/{manifest-system-deps-CMOGc5nC.mjs → manifest-system-deps-DOcMuUWK.mjs} +1 -1
  64. package/dist/{manifest-system-deps-PAIfmNun.js → manifest-system-deps-y5vZBO6q.js} +1 -1
  65. package/dist/{retired-settings-keys-3soU9pfP.js → retired-settings-keys-BdvmiaK7.js} +1 -1
  66. package/dist/{retired-settings-keys-CnbKAxmX.mjs → retired-settings-keys-CyQG8b-A.mjs} +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,103 @@
1
+ export type BackupRunState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
2
+ export type BackupRunPhase = 'queued' | 'building' | 'uploading' | 'done';
3
+ /** What a trigger asks for — the queue keys joining on this. */
4
+ export interface BackupRunRequest {
5
+ readonly destinations?: readonly string[];
6
+ readonly locations?: readonly string[];
7
+ readonly label?: string;
8
+ readonly retentionCount?: number;
9
+ }
10
+ /** Immutable snapshot of one run — the wire shape of `backup.listRuns`. */
11
+ export interface BackupRunSnapshot {
12
+ readonly id: string;
13
+ readonly state: BackupRunState;
14
+ readonly phase: BackupRunPhase;
15
+ readonly destinationIds: readonly string[];
16
+ readonly label?: string;
17
+ readonly requestedAt: number;
18
+ readonly startedAt?: number;
19
+ readonly finishedAt?: number;
20
+ readonly stagedBytes: number;
21
+ readonly archiveSizeBytes?: number;
22
+ readonly uploadedBytes: number;
23
+ readonly completedDestinationIds: readonly string[];
24
+ readonly failedDestinationIds: readonly string[];
25
+ readonly error?: string;
26
+ /**
27
+ * 1-based place in the execution queue — 1 = runs next. Present only
28
+ * while `state === 'queued'`, and derived from the queue's OWN
29
+ * pending order at read time (never from timestamps), so a consumer
30
+ * can only ever show the order the executor will actually honour.
31
+ */
32
+ readonly queuePosition?: number;
33
+ }
34
+ /**
35
+ * Progress sink handed to the executor. Every update replaces the
36
+ * stored snapshot (no shared mutable state leaks out of the queue).
37
+ */
38
+ export interface BackupRunProgress {
39
+ setPhase(phase: BackupRunPhase): void;
40
+ /** Resolved destination targets — known only once the run starts. */
41
+ setDestinations(ids: readonly string[]): void;
42
+ setStagedBytes(bytes: number): void;
43
+ setArchiveSize(bytes: number): void;
44
+ setUploadedBytes(bytes: number): void;
45
+ destinationCompleted(id: string): void;
46
+ destinationFailed(id: string): void;
47
+ }
48
+ export interface SubmitResult<T> {
49
+ readonly runId: string;
50
+ /** True when the run had to wait behind an in-flight run. */
51
+ readonly queued: boolean;
52
+ /** True when this submit was coalesced onto an identical queued run. */
53
+ readonly joined: boolean;
54
+ /** Settles when the run terminates. Rejects `BackupCancelledError` on cancel-before-any-result. */
55
+ readonly result: Promise<T>;
56
+ }
57
+ export type BackupRunExecutor<T> = (request: BackupRunRequest, signal: AbortSignal, progress: BackupRunProgress) => Promise<T>;
58
+ export interface BackupRunQueueOptions {
59
+ readonly maxFinished?: number;
60
+ readonly now?: () => number;
61
+ }
62
+ /**
63
+ * FIFO queue with concurrency 1. `submit` never starts a second
64
+ * executor while one is in flight; an identical request already
65
+ * WAITING (same destinations/locations/label/retention) is joined
66
+ * instead of queued twice — two schedule ticks due on the same minute
67
+ * produce one run, not two.
68
+ */
69
+ export declare class BackupRunQueue<T> {
70
+ private readonly executor;
71
+ private readonly runs;
72
+ private readonly pending;
73
+ private readonly finished;
74
+ private activeId;
75
+ private readonly maxFinished;
76
+ private readonly now;
77
+ constructor(executor: BackupRunExecutor<T>, opts?: BackupRunQueueOptions);
78
+ submit(request: BackupRunRequest): SubmitResult<T>;
79
+ /**
80
+ * Cancel a run. Queued: removed before it ever starts (its result
81
+ * rejects `BackupCancelledError`). Running: the abort signal fires —
82
+ * the executor's tar/upload streams stop and its cleanup runs.
83
+ * Unknown or already terminal: `false`.
84
+ */
85
+ cancel(runId: string): boolean;
86
+ /**
87
+ * EXECUTION order, not timestamp order: the running run first, then
88
+ * every queued run in the exact order the pump will start them (each
89
+ * stamped with `queuePosition`, 1 = next), then the bounded finished
90
+ * history newest-first. The queued section is read straight off
91
+ * `this.pending` — a consumer sorting by `requestedAt` descending
92
+ * would put the run that starts NEXT at the BOTTOM, which is
93
+ * backwards exactly when the queue matters (more than one waiter).
94
+ */
95
+ listRuns(): readonly BackupRunSnapshot[];
96
+ getRun(runId: string): BackupRunSnapshot | undefined;
97
+ private createRecord;
98
+ private pump;
99
+ private execute;
100
+ private progressFor;
101
+ private patch;
102
+ private finish;
103
+ }
@@ -0,0 +1,36 @@
1
+ import { IScopedLogger } from '@camstack/types';
2
+ /** Basename of the staging dir under the node data root. */
3
+ export declare const STAGING_DIR_NAME = "backup-staging";
4
+ /** Canonical staging dir for a node data root. */
5
+ export declare function resolveStagingDir(nodeDataDir: string): string;
6
+ export interface StagingSweepResult {
7
+ /** Top-level staging entries removed. */
8
+ readonly removedCount: number;
9
+ /** File bytes reclaimed (recursive for removed subdirectories). */
10
+ readonly freedBytes: number;
11
+ /** Entries spared because `keepPaths` named them. */
12
+ readonly keptCount: number;
13
+ }
14
+ export interface DirectoryFootprint {
15
+ readonly present: boolean;
16
+ readonly totalBytes: number;
17
+ readonly fileCount: number;
18
+ }
19
+ /**
20
+ * Delete every top-level entry of `stagingDir` except those whose
21
+ * resolved path appears in `keepPaths`. Per-entry failures are logged
22
+ * and skipped — a locked file must not abort the sweep. A missing
23
+ * staging dir is a clean no-op.
24
+ */
25
+ export declare function sweepStagingDir(input: {
26
+ stagingDir: string;
27
+ keepPaths: readonly string[];
28
+ logger: IScopedLogger;
29
+ }): Promise<StagingSweepResult>;
30
+ /**
31
+ * Recursive size + file count of a path (file or directory). Absent
32
+ * path → `present: false` with zeros. Used by the sweep to report
33
+ * reclaimed bytes and by the orchestrator to surface the legacy
34
+ * staging dir's footprint without touching it.
35
+ */
36
+ export declare function measureDirectoryFootprint(dir: string): Promise<DirectoryFootprint>;
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-C7qWwrK1.js");
6
+ const require_dist = require("../../dist-whe0b4_3.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  //#region src/builtins/console-logging/console-destination.ts
9
9
  var LEVEL_RANK = {
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, ct as logDestinationCapability } from "../../dist-L_39vRmH.mjs";
1
+ import { Ft as BaseAddon, ct as logDestinationCapability } from "../../dist-DbnNk4Lw.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  //#region src/builtins/console-logging/console-destination.ts
4
4
  var LEVEL_RANK = {
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-C7qWwrK1.js");
3
+ const require_dist = require("../../dist-whe0b4_3.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, I as coreBlockAddonId, L as coreBlockIdFromAddonId, Pt as errMsg, R as coreBlocksCapability, c as CORE_BLOCK_ADDON_PREFIX, d as DeclaredDevices, l as CoreBlockSchema, s as CORE_BLOCKS_ADDON_ID } from "../../dist-L_39vRmH.mjs";
1
+ import { Ft as BaseAddon, I as coreBlockAddonId, L as coreBlockIdFromAddonId, Pt as errMsg, R as coreBlocksCapability, c as CORE_BLOCK_ADDON_PREFIX, d as DeclaredDevices, l as CoreBlockSchema, s as CORE_BLOCKS_ADDON_ID } from "../../dist-DbnNk4Lw.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -3,8 +3,8 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-C7qWwrK1.js");
7
- const require_retired_settings_keys = require("../../retired-settings-keys-3soU9pfP.js");
6
+ const require_dist = require("../../dist-whe0b4_3.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-BdvmiaK7.js");
8
8
  let node_crypto = require("node:crypto");
9
9
  let _camstack_types_node = require("@camstack/types/node");
10
10
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -2114,7 +2114,13 @@ function resolveLinkedDeviceIds(params) {
2114
2114
  if (config.mode === "auto") {
2115
2115
  if (selfLocation !== null && selfLocation.length > 0) for (const c of candidates) {
2116
2116
  if (c.type === require_dist.DeviceType.Container) continue;
2117
- if (c.location === selfLocation) ids.add(c.id);
2117
+ if (c.location !== selfLocation) continue;
2118
+ const parent = c.parentDeviceId !== null ? byId.get(c.parentDeviceId) : void 0;
2119
+ if (parent !== void 0 && parent.type === require_dist.DeviceType.Container) {
2120
+ expand(parent.id);
2121
+ continue;
2122
+ }
2123
+ ids.add(c.id);
2118
2124
  }
2119
2125
  } else for (const id of config.manualIds) if (byId.has(id)) expand(id);
2120
2126
  ids.delete(selfId);
@@ -3274,8 +3280,6 @@ var DeviceEventPropagator = class {
3274
3280
  return chain;
3275
3281
  }
3276
3282
  };
3277
- //#endregion
3278
- //#region src/builtins/device-manager/migrate-device.ts
3279
3283
  /** A refusal that names a switch the camera does not offer, rather than one it
3280
3284
  * could not be asked about. The cap documents rejecting the former; anything
3281
3285
  * else is treated as the camera being out of reach, which is the safe reading:
@@ -3287,10 +3291,31 @@ function classify(err) {
3287
3291
  detail
3288
3292
  };
3289
3293
  }
3294
+ /**
3295
+ * One switch write under the bound. The race, not the write, is what fails on
3296
+ * timeout — the underlying promise is marked handled so a late rejection from
3297
+ * a camera that eventually answers "no" cannot surface as an unhandled
3298
+ * rejection after the migration already reported `unreachable`.
3299
+ */
3300
+ async function setSwitchBounded(deps, deviceId, switchId, enabled) {
3301
+ const budget = deps.switchTimeoutMs ?? 15e3;
3302
+ const write = deps.setSwitch(deviceId, switchId, enabled);
3303
+ write.catch(() => {});
3304
+ let timer;
3305
+ try {
3306
+ await Promise.race([write, new Promise((_resolve, reject) => {
3307
+ timer = setTimeout(() => {
3308
+ reject(/* @__PURE__ */ new Error(`switch write did not answer within ${budget}ms — reported unreachable; if it lands late, a duplicate write of the same value is a no-op`));
3309
+ }, budget);
3310
+ })]);
3311
+ } finally {
3312
+ if (timer !== void 0) clearTimeout(timer);
3313
+ }
3314
+ }
3290
3315
  async function applyAll(deps, deviceId, enabled) {
3291
3316
  const out = [];
3292
3317
  for (const switchId of require_dist.CAMERA_SWITCH_ORDER) try {
3293
- await deps.setSwitch(deviceId, switchId, enabled);
3318
+ await setSwitchBounded(deps, deviceId, switchId, enabled);
3294
3319
  out.push({
3295
3320
  deviceId,
3296
3321
  switchId,
@@ -3327,8 +3352,17 @@ async function migrateDevice$1(deps, input) {
3327
3352
  const down = [...await applyAll(deps, sourceId, false), ...await applyAll(deps, targetId, false)];
3328
3353
  const sourceStillLive = down.filter((r) => r.deviceId === sourceId && r.outcome === "unreachable").map((r) => r.switchId);
3329
3354
  await deps.swapIds(sourceId, targetId);
3355
+ await deps.swapHardwareState(sourceId, targetId);
3356
+ for (const deviceId of [sourceId, targetId]) try {
3357
+ await deps.forgetStreamState(deviceId);
3358
+ } catch (err) {
3359
+ deps.log("migration: broker hardware state NOT forgotten — stale stream state survives", {
3360
+ deviceId,
3361
+ error: err instanceof Error ? err.message : String(err),
3362
+ remedy: "delete the device's derived streams and reassign its stream profiles, or update the stream-broker addon and re-run streamBroker.forgetDeviceHardware"
3363
+ });
3364
+ }
3330
3365
  const up = await applyAll(deps, sourceId, true);
3331
- await deps.respawnRunners([sourceId, targetId]);
3332
3366
  deps.log("camera migrated", {
3333
3367
  sourceId,
3334
3368
  targetId,
@@ -3344,6 +3378,148 @@ async function migrateDevice$1(deps, input) {
3344
3378
  };
3345
3379
  }
3346
3380
  //#endregion
3381
+ //#region src/builtins/device-manager/migrate-reload.ts
3382
+ async function reloadMigratedDevices(pctx, targets) {
3383
+ const ctx = pctx.host.ctx;
3384
+ for (const target of targets) {
3385
+ pctx.metaStore.idToAddonId.set(target.deviceId, target.addonId);
3386
+ pctx.host.remoteNativeCaps.delete(target.deviceId);
3387
+ pctx.host.capabilityRegistry?.unregisterAllNativeForDevice(target.deviceId);
3388
+ }
3389
+ const fallbackAddons = /* @__PURE__ */ new Map();
3390
+ await Promise.all(targets.map(async (target) => {
3391
+ try {
3392
+ const reloaded = await (await pctx.host.requireDeviceProvider(target.addonId)).reloadDevice({ stableId: target.stableId });
3393
+ ctx.logger.info("migration: device reloaded in place — no other device disturbed", {
3394
+ tags: { deviceId: target.deviceId },
3395
+ meta: {
3396
+ addonId: target.addonId,
3397
+ stableId: target.stableId,
3398
+ reloadedAs: reloaded.deviceId
3399
+ }
3400
+ });
3401
+ } catch (err) {
3402
+ ctx.logger.error("migration: in-place reload FAILED — falling back to a provider restart", {
3403
+ tags: { deviceId: target.deviceId },
3404
+ meta: {
3405
+ addonId: target.addonId,
3406
+ stableId: target.stableId,
3407
+ error: err instanceof Error ? err.message : String(err)
3408
+ }
3409
+ });
3410
+ fallbackAddons.set(target.addonId, target.deviceId);
3411
+ }
3412
+ }));
3413
+ if (fallbackAddons.size === 0) return;
3414
+ await Promise.all([...fallbackAddons.entries()].map(async ([addonId, migratedDeviceId]) => {
3415
+ const owned = [...pctx.metaStore.idToAddonId.entries()].filter(([, a]) => a === addonId).length;
3416
+ ctx.logger.info("migration: restarting a whole provider to flush the id maps", {
3417
+ tags: { deviceId: migratedDeviceId },
3418
+ meta: {
3419
+ addonId,
3420
+ devicesTakenDown: owned
3421
+ }
3422
+ });
3423
+ await ctx.api.addons.restartAddon.mutate({ addonId });
3424
+ }));
3425
+ }
3426
+ //#endregion
3427
+ //#region src/builtins/device-manager/migrate-hardware-state.ts
3428
+ /**
3429
+ * The per-device state half of a migration (`deviceManager.migrateDevice`).
3430
+ *
3431
+ * ## The rule
3432
+ *
3433
+ * **Role-scoped state follows the NUMBER. Hardware-scoped state follows the
3434
+ * DEVICE.** `swapIds` moves the device ROWS — `addonId` and `stableId` follow
3435
+ * the hardware — but the per-device CONFIG blob and the runtime-state blob are
3436
+ * keyed by the number in a separate store, and until 2026-09-01 they simply
3437
+ * stayed put. Measured on the live hub after replacing a Hikvision (id 618)
3438
+ * with a Reolink: device 618 — Reolink hardware — was dialled at
3439
+ * `192.168.1.245:80` with `deviceCache.model="DS-2DE2A404IW-DE3/W"`, the
3440
+ * Hikvision's address, credentials and cached model, and never came online.
3441
+ * Device 4362 wore the Reolink's config on Hikvision hardware. The two devices
3442
+ * were wearing each other's identity.
3443
+ *
3444
+ * The proof the config belongs to the hardware, not the number: writing the
3445
+ * Hikvision blob onto the Reolink was REJECTED by the Reolink driver's own
3446
+ * zod schema (`motionSensitivity: 60` vs max 50). A config that fails the
3447
+ * other provider's schema is a config for different hardware.
3448
+ *
3449
+ * ## What swaps, what stays
3450
+ *
3451
+ * - The DRIVER config keys (host, port, credentials, `deviceCache`, every
3452
+ * schema field) SWAP — they describe the box.
3453
+ * - The device-manager-owned section keys ({@link DEVICE_STORE_SECTIONS}:
3454
+ * linked-devices mode/ids) STAY with the number — a sensor is linked to
3455
+ * "the driveway camera", not to a serial number.
3456
+ * - `_profileMap` is DROPPED from both. It is the legacy spelling of the
3457
+ * stream-profile assignment (the live authority is the stream broker's own
3458
+ * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
3459
+ * names stream ids of the wrong hardware after a swap.
3460
+ * - The runtime-state blobs (`__device-state:<id>` — battery snapshot, sleep
3461
+ * flag, feature probe) SWAP wholesale: every slice mirrors the box.
3462
+ *
3463
+ * ## Crash points are loud, same as `swapIds`
3464
+ *
3465
+ * There is no transaction: each blob is cleared and rewritten. A crash between
3466
+ * clear and write leaves a device with NO config — a provider that fails to
3467
+ * construct and says so — never two devices wearing each other's, which is the
3468
+ * state that reads as plausible and costs a day.
3469
+ */
3470
+ /** Legacy stream-profile assignment key (cf. `device-queries.ts`). */
3471
+ var PROFILE_MAP_CONFIG_KEY = "_profileMap";
3472
+ /** Config keys that belong to the camera's ROLE and stay with the number. */
3473
+ var ROLE_SCOPED_CONFIG_KEYS = new Set(DEVICE_STORE_SECTIONS.flatMap((section) => [...section.keys]));
3474
+ function splitConfig(config) {
3475
+ const hardware = {};
3476
+ const role = {};
3477
+ for (const [key, value] of Object.entries(config)) {
3478
+ if (key === PROFILE_MAP_CONFIG_KEY) continue;
3479
+ if (ROLE_SCOPED_CONFIG_KEYS.has(key)) role[key] = value;
3480
+ else hardware[key] = value;
3481
+ }
3482
+ return {
3483
+ hardware,
3484
+ role
3485
+ };
3486
+ }
3487
+ /**
3488
+ * Swap the hardware-scoped halves of two devices' persisted state. Runs right
3489
+ * after `swapIds`, under the same meta write lock. THROWS on failure: a
3490
+ * migration whose rows swapped but whose configs did not is exactly the
3491
+ * incident this module exists to prevent, and it must never be reported as a
3492
+ * success.
3493
+ */
3494
+ async function swapHardwareState(settings, sourceId, targetId) {
3495
+ const sourceConfig = await settings.readDeviceStore(sourceId);
3496
+ const targetConfig = await settings.readDeviceStore(targetId);
3497
+ const source = splitConfig(sourceConfig);
3498
+ const target = splitConfig(targetConfig);
3499
+ await settings.clearDeviceStore(sourceId);
3500
+ await settings.writeDeviceStore(sourceId, {
3501
+ ...target.hardware,
3502
+ ...source.role
3503
+ });
3504
+ await settings.clearDeviceStore(targetId);
3505
+ await settings.writeDeviceStore(targetId, {
3506
+ ...source.hardware,
3507
+ ...target.role
3508
+ });
3509
+ const sourceRuntimeState = await settings.readDeviceRuntimeState(sourceId);
3510
+ const targetRuntimeState = await settings.readDeviceRuntimeState(targetId);
3511
+ await settings.writeDeviceRuntimeState(sourceId, targetRuntimeState);
3512
+ await settings.writeDeviceRuntimeState(targetId, sourceRuntimeState);
3513
+ return {
3514
+ runtimeStateNowAtSource: targetRuntimeState,
3515
+ runtimeStateNowAtTarget: sourceRuntimeState,
3516
+ hardwareKeysToSource: Object.keys(target.hardware).toSorted(),
3517
+ hardwareKeysToTarget: Object.keys(source.hardware).toSorted(),
3518
+ roleKeysKeptAtSource: Object.keys(source.role).toSorted(),
3519
+ roleKeysKeptAtTarget: Object.keys(target.role).toSorted()
3520
+ };
3521
+ }
3522
+ //#endregion
3347
3523
  //#region src/builtins/device-manager/device-meta-actions.ts
3348
3524
  /**
3349
3525
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -3423,48 +3599,126 @@ async function allocateDeviceId(pctx, input) {
3423
3599
  * The composition lives in `migrate-device.ts`, deliberately free of this
3424
3600
  * file's context so the decisions it carries — above all "never report a switch
3425
3601
  * as off when it could not be written" — are testable without a hub. This is
3426
- * the wiring: the four real dependencies, and the write lock.
3602
+ * the wiring: the real dependencies, and the write lock.
3427
3603
  *
3428
3604
  * The temp id comes from `allocateNextDeviceId`, the monotonic counter, so the
3429
3605
  * value a crash could park a row on was never used and will never be reused.
3430
3606
  * `swapIds` therefore runs INSIDE the same lock the id was allocated under.
3607
+ *
3608
+ * Retry-safe since 2026-09-04 (`migrate-guard.ts`): a concurrent call touching
3609
+ * either device is REFUSED before it can queue on the lock, and a repeat of a
3610
+ * completed swap — matched against the `addonId`/`stableId` fingerprint the
3611
+ * swap left on the rows — is a no-op returning the original report. A client
3612
+ * retry ran a second full cycle on 2026-09-01 and swapped two cameras back;
3613
+ * neither path can re-run a swap any more.
3431
3614
  */
3432
3615
  async function migrateDevice(pctx, input) {
3433
3616
  const { sourceId, targetId } = input;
3434
3617
  const ctx = pctx.host.ctx;
3435
- return await pctx.metaStore.withMetaWriteLock(async () => {
3436
- 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`);
3437
- const tempId = await pctx.metaStore.allocateNextDeviceId();
3438
- return await migrateDevice$1({
3439
- setSwitch: async (deviceId, switchId, enabled) => {
3440
- await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
3441
- deviceId,
3442
- switchId,
3443
- enabled
3444
- });
3445
- },
3446
- swapIds: async (a, b) => {
3447
- await pctx.metaStore.rows.swapIds(a, b, tempId);
3448
- },
3449
- respawnRunners: async (deviceIds) => {
3450
- const owners = /* @__PURE__ */ new Set();
3451
- for (const deviceId of deviceIds) {
3452
- const addonId = pctx.metaStore.idToAddonId.get(deviceId);
3453
- if (addonId !== void 0) owners.add(addonId);
3454
- }
3455
- for (const addonId of owners) await ctx.api.addons.restartAddon.mutate({ addonId });
3456
- },
3457
- log: (message, meta) => {
3458
- ctx.logger.info(message, {
3618
+ pctx.migrationGuard.begin(sourceId, targetId);
3619
+ try {
3620
+ const outcome = await pctx.metaStore.withMetaWriteLock(async () => {
3621
+ const sourceRow = await pctx.metaStore.rows.get(sourceId);
3622
+ if (sourceRow === null) throw new Error(`[device-manager] migrateDevice: device ${sourceId} does not exist`);
3623
+ const targetRow = await pctx.metaStore.rows.get(targetId);
3624
+ if (targetRow === null) throw new Error(`[device-manager] migrateDevice: device ${targetId} does not exist`);
3625
+ const replay = pctx.migrationGuard.matchReplay(sourceId, targetId, {
3626
+ addonId: sourceRow.meta.addonId,
3627
+ stableId: sourceRow.meta.stableId
3628
+ }, {
3629
+ addonId: targetRow.meta.addonId,
3630
+ stableId: targetRow.meta.stableId
3631
+ });
3632
+ if (replay !== null) {
3633
+ ctx.logger.info("migrateDevice: this swap already completed — replaying the original report, NOTHING re-run", {
3459
3634
  tags: { deviceId: sourceId },
3460
- meta
3635
+ meta: {
3636
+ sourceId,
3637
+ targetId
3638
+ }
3461
3639
  });
3640
+ return {
3641
+ result: replay,
3642
+ reloadTargets: null
3643
+ };
3462
3644
  }
3463
- }, {
3464
- sourceId,
3465
- targetId
3645
+ const tempId = await pctx.metaStore.allocateNextDeviceId();
3646
+ const result = await migrateDevice$1({
3647
+ setSwitch: async (deviceId, switchId, enabled) => {
3648
+ if (switchId === "stream-broker") {
3649
+ const moved = await writeDisabledFlagUnderLock(pctx, deviceId, !enabled);
3650
+ emitDisabledEvents(pctx, deviceId, !enabled, moved);
3651
+ return;
3652
+ }
3653
+ await ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
3654
+ deviceId,
3655
+ switchId,
3656
+ enabled
3657
+ });
3658
+ },
3659
+ swapIds: async (a, b) => {
3660
+ await pctx.metaStore.rows.swapIds(a, b, tempId);
3661
+ },
3662
+ swapHardwareState: async (a, b) => {
3663
+ const swapped = await swapHardwareState(pctx.settings, a, b);
3664
+ pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
3665
+ pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
3666
+ ctx.logger.info("migration: hardware-scoped device state swapped", {
3667
+ tags: { deviceId: a },
3668
+ meta: {
3669
+ sourceId: a,
3670
+ targetId: b,
3671
+ hardwareKeysToSource: swapped.hardwareKeysToSource,
3672
+ hardwareKeysToTarget: swapped.hardwareKeysToTarget,
3673
+ roleKeysKeptAtSource: swapped.roleKeysKeptAtSource,
3674
+ roleKeysKeptAtTarget: swapped.roleKeysKeptAtTarget
3675
+ }
3676
+ });
3677
+ },
3678
+ forgetStreamState: async (deviceId) => {
3679
+ await ctx.api.streamBroker.forgetDeviceHardware.mutate({ deviceId });
3680
+ },
3681
+ log: (message, meta) => {
3682
+ ctx.logger.info(message, {
3683
+ tags: { deviceId: sourceId },
3684
+ meta
3685
+ });
3686
+ }
3687
+ }, {
3688
+ sourceId,
3689
+ targetId
3690
+ });
3691
+ pctx.migrationGuard.recordCompleted({
3692
+ sourceId,
3693
+ targetId,
3694
+ identityAtSource: {
3695
+ addonId: targetRow.meta.addonId,
3696
+ stableId: targetRow.meta.stableId
3697
+ },
3698
+ identityAtTarget: {
3699
+ addonId: sourceRow.meta.addonId,
3700
+ stableId: sourceRow.meta.stableId
3701
+ },
3702
+ result
3703
+ });
3704
+ return {
3705
+ result,
3706
+ reloadTargets: [{
3707
+ deviceId: sourceId,
3708
+ addonId: targetRow.meta.addonId,
3709
+ stableId: targetRow.meta.stableId
3710
+ }, {
3711
+ deviceId: targetId,
3712
+ addonId: sourceRow.meta.addonId,
3713
+ stableId: sourceRow.meta.stableId
3714
+ }]
3715
+ };
3466
3716
  });
3467
- });
3717
+ if (outcome.reloadTargets !== null) await reloadMigratedDevices(pctx, outcome.reloadTargets);
3718
+ return outcome.result;
3719
+ } finally {
3720
+ pctx.migrationGuard.end(sourceId, targetId);
3721
+ }
3468
3722
  }
3469
3723
  async function registerDevice(pctx, input) {
3470
3724
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
@@ -4151,35 +4405,23 @@ async function setMetadata(pctx, input) {
4151
4405
  });
4152
4406
  }
4153
4407
  /**
4154
- * Soft-disable the device. Persisted on the meta row;
4155
- * lifecycle gating is the driver's responsibility (BaseDevice
4156
- * exposes `this.disabled` for the driver to consult at the top
4157
- * of its lifecycle methods).
4158
- *
4159
- * TWO events leave here, and they are not the same message:
4160
- *
4161
- * - `device.meta-changed` fires on EVERY call. It is the meta-surface echo
4162
- * every UI redraws on, and idempotence is not interesting to it.
4163
- * - `device.disabled` / `device.enabled` fire ONLY when the flag actually
4164
- * MOVES. They are the lifecycle announcement — the operator asked to be
4165
- * told when a camera is switched off, and a write that re-states a flag the
4166
- * device already had is not that. Telemetry semantics (D8) are fine here: a
4167
- * dropped announcement costs a notification, never durable state, and the
4168
- * meta row is the authority either way (D62 — this switch stores nothing of
4169
- * its own).
4408
+ * Read + patch the `disabled` flag. The caller HOLDS the meta write lock —
4409
+ * this is split out of {@link setDisabled} so `migrateDevice`, which holds the
4410
+ * lock for its whole run, can write the same authority without re-acquiring
4411
+ * it (the self-deadlock documented at its `setSwitch`).
4170
4412
  */
4171
- async function setDisabled(pctx, input) {
4172
- const { deviceId, disabled } = input;
4173
- const moved = await pctx.metaStore.withMetaWriteLock(async () => {
4174
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
4175
- if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
4176
- const { meta: m } = persisted;
4177
- await pctx.metaStore.rows.patch(deviceId, { disabled });
4178
- return {
4179
- changed: (m.disabled ?? false) !== disabled,
4180
- integrationId: m.integrationId ?? ""
4181
- };
4182
- });
4413
+ async function writeDisabledFlagUnderLock(pctx, deviceId, disabled) {
4414
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
4415
+ if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
4416
+ const { meta: m } = persisted;
4417
+ await pctx.metaStore.rows.patch(deviceId, { disabled });
4418
+ return {
4419
+ changed: (m.disabled ?? false) !== disabled,
4420
+ integrationId: m.integrationId ?? ""
4421
+ };
4422
+ }
4423
+ /** The two announcements — meta echo always, lifecycle only on a transition. */
4424
+ function emitDisabledEvents(pctx, deviceId, disabled, moved) {
4183
4425
  pctx.host.ctx.eventBus.emit({
4184
4426
  id: (0, node_crypto.randomUUID)(),
4185
4427
  timestamp: /* @__PURE__ */ new Date(),
@@ -4209,6 +4451,10 @@ async function setDisabled(pctx, input) {
4209
4451
  }
4210
4452
  });
4211
4453
  }
4454
+ async function setDisabled(pctx, input) {
4455
+ const { deviceId, disabled } = input;
4456
+ emitDisabledEvents(pctx, deviceId, disabled, await pctx.metaStore.withMetaWriteLock(() => writeDisabledFlagUnderLock(pctx, deviceId, disabled)));
4457
+ }
4212
4458
  async function loadRuntimeState(pctx, input) {
4213
4459
  const { deviceId } = input;
4214
4460
  if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
@@ -5823,6 +6069,43 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
5823
6069
  };
5824
6070
  return resolveRemoteNativeCapFromRegistry(pctx.bindingsDeps, capName, deviceId);
5825
6071
  }
6072
+ function sameIdentity(a, b) {
6073
+ return a.addonId === b.addonId && a.stableId === b.stableId;
6074
+ }
6075
+ /** One guard per device-manager instance. `now` is injectable for the specs. */
6076
+ function createMigrationGuard(now = Date.now) {
6077
+ const inFlight = /* @__PURE__ */ new Set();
6078
+ const completed = /* @__PURE__ */ new Map();
6079
+ const pairKey = (sourceId, targetId) => `${sourceId}->${targetId}`;
6080
+ return {
6081
+ begin(sourceId, targetId) {
6082
+ 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 — this call was refused, not queued. The running migration's report stands; re-read the device rows before retrying.`);
6083
+ inFlight.add(sourceId);
6084
+ inFlight.add(targetId);
6085
+ },
6086
+ end(sourceId, targetId) {
6087
+ inFlight.delete(sourceId);
6088
+ inFlight.delete(targetId);
6089
+ },
6090
+ recordCompleted(record) {
6091
+ completed.set(pairKey(record.sourceId, record.targetId), {
6092
+ ...record,
6093
+ completedAt: now()
6094
+ });
6095
+ },
6096
+ matchReplay(sourceId, targetId, atSource, atTarget) {
6097
+ const key = pairKey(sourceId, targetId);
6098
+ const record = completed.get(key);
6099
+ if (record === void 0) return null;
6100
+ if (now() - record.completedAt > 9e5) {
6101
+ completed.delete(key);
6102
+ return null;
6103
+ }
6104
+ if (!sameIdentity(record.identityAtSource, atSource) || !sameIdentity(record.identityAtTarget, atTarget)) return null;
6105
+ return record.result;
6106
+ }
6107
+ };
6108
+ }
5826
6109
  //#endregion
5827
6110
  //#region src/builtins/device-manager/device-manager.addon.ts
5828
6111
  var DeviceManagerAddon = class extends require_dist.BaseAddon {
@@ -6063,6 +6346,7 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
6063
6346
  settings,
6064
6347
  registry,
6065
6348
  bindingsDeps: this.bindingsDeps,
6349
+ migrationGuard: createMigrationGuard(),
6066
6350
  requireDeviceOps,
6067
6351
  stampIntegrationId: stampIntegrationId$1,
6068
6352
  get provider() {