@camstack/system 1.2.313 → 1.2.315

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 (111) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  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/autotrack/index.js +1 -1
  10. package/dist/builtins/autotrack/index.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/camera-grid/index.js +1 -1
  14. package/dist/builtins/camera-grid/index.mjs +1 -1
  15. package/dist/builtins/composer/claim-gate.d.ts +32 -0
  16. package/dist/builtins/composer/composed-device-set.d.ts +38 -0
  17. package/dist/builtins/composer/composed-device.d.ts +4 -3
  18. package/dist/builtins/composer/composer-grafts.d.ts +54 -0
  19. package/dist/builtins/composer/composer-plan.d.ts +96 -0
  20. package/dist/builtins/composer/composer-verdict.d.ts +19 -0
  21. package/dist/builtins/composer/composer.addon.js +2717 -477
  22. package/dist/builtins/composer/composer.addon.mjs +2722 -482
  23. package/dist/builtins/composer/composer.d.ts +71 -52
  24. package/dist/builtins/composer/composition-runtime.d.ts +93 -9
  25. package/dist/builtins/composer/confirmed-seed.d.ts +32 -0
  26. package/dist/builtins/composer/existing-target.d.ts +96 -0
  27. package/dist/builtins/composer/field-record.d.ts +50 -0
  28. package/dist/builtins/composer/graft-host.d.ts +47 -0
  29. package/dist/builtins/composer/held-claims.d.ts +12 -0
  30. package/dist/builtins/composer/owned-fields-target.d.ts +55 -0
  31. package/dist/builtins/composer/slice-assembler.d.ts +10 -0
  32. package/dist/builtins/composer/source-readings.d.ts +122 -0
  33. package/dist/builtins/composer/source-tracker.d.ts +35 -42
  34. package/dist/builtins/console-logging/index.js +1 -1
  35. package/dist/builtins/console-logging/index.mjs +1 -1
  36. package/dist/builtins/core-blocks/composition-api.d.ts +7 -4
  37. package/dist/builtins/core-blocks/composition-peers.d.ts +9 -0
  38. package/dist/builtins/core-blocks/composition-sources.d.ts +16 -3
  39. package/dist/builtins/core-blocks/core-block-store.d.ts +8 -1
  40. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +4 -3
  41. package/dist/builtins/core-blocks/core-blocks.addon.js +255 -79
  42. package/dist/builtins/core-blocks/core-blocks.addon.mjs +255 -79
  43. package/dist/builtins/device-manager/claimed-status-overlay.d.ts +12 -0
  44. package/dist/builtins/device-manager/device-manager.addon.d.ts +16 -0
  45. package/dist/builtins/device-manager/device-manager.addon.js +1825 -317
  46. package/dist/builtins/device-manager/device-manager.addon.mjs +1825 -317
  47. package/dist/builtins/device-manager/device-provider-context.d.ts +24 -2
  48. package/dist/builtins/device-manager/device-row-store.d.ts +2 -0
  49. package/dist/builtins/device-manager/device-state-claim-views.d.ts +77 -0
  50. package/dist/builtins/device-manager/device-state-claims.d.ts +43 -0
  51. package/dist/builtins/device-manager/device-state-mirror.d.ts +145 -61
  52. package/dist/builtins/device-manager/field-claims-index.d.ts +69 -0
  53. package/dist/builtins/device-manager/field-ownership.d.ts +53 -0
  54. package/dist/builtins/device-manager/migrate-device.d.ts +27 -0
  55. package/dist/builtins/device-manager/migrate-guard.d.ts +7 -0
  56. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +55 -0
  57. package/dist/builtins/device-manager/migration-refused.d.ts +23 -0
  58. package/dist/builtins/device-manager/mirror-row-writer.d.ts +138 -0
  59. package/dist/builtins/device-manager/runtime-state-persist-gate.d.ts +4 -0
  60. package/dist/builtins/doorbell/binding-mirror.d.ts +1 -0
  61. package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +59 -0
  62. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +3 -0
  63. package/dist/builtins/doorbell/virtual-doorbell.addon.js +315 -10
  64. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +315 -10
  65. package/dist/builtins/hub-forwarder/index.js +1 -1
  66. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  67. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  68. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  69. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  70. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  71. package/dist/builtins/local-network/local-network.addon.js +1 -1
  72. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  73. package/dist/builtins/loki-logging/index.js +1 -1
  74. package/dist/builtins/loki-logging/index.mjs +1 -1
  75. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  76. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  77. package/dist/builtins/platform-probe/index.js +1 -1
  78. package/dist/builtins/platform-probe/index.mjs +1 -1
  79. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  80. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  81. package/dist/builtins/snapshot/index.js +1 -1
  82. package/dist/builtins/snapshot/index.mjs +1 -1
  83. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  84. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  85. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  86. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  87. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  88. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  89. package/dist/builtins/system-config/system-config.addon.js +1 -1
  90. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  91. package/dist/builtins/winston-logging/index.js +1 -1
  92. package/dist/builtins/winston-logging/index.mjs +1 -1
  93. package/dist/{child-cap-dispatch-CHTiHbsv.mjs → child-cap-dispatch-AAltfBUH.mjs} +1484 -1417
  94. package/dist/{child-cap-dispatch-Dyn_7ezH.js → child-cap-dispatch-DzcUd_ct.js} +1503 -1424
  95. package/dist/composition-sources-DYqQLRzF.js +122 -0
  96. package/dist/composition-sources-yonsRneP.mjs +111 -0
  97. package/dist/{dist-CkRwnhbo.js → dist-DVKv5i-e.js} +5396 -4331
  98. package/dist/{dist-Cj7pJmXq.mjs → dist-MzYJVCLE.mjs} +5330 -4331
  99. package/dist/index.js +137 -8
  100. package/dist/index.mjs +133 -8
  101. package/dist/kernel/capability-registry.d.ts +43 -1
  102. package/dist/kernel/index.d.ts +3 -1
  103. package/dist/kernel/moleculer/device-cap-proxy.d.ts +0 -10
  104. package/dist/kernel/status-overlay.d.ts +18 -0
  105. package/dist/kernel/transport/claimed-get-status.d.ts +15 -0
  106. package/dist/kernel/transport/index.d.ts +2 -0
  107. package/dist/kernel/transport/local-child-registry.d.ts +23 -0
  108. package/dist/kernel/transport/parent-unowned-call.d.ts +25 -0
  109. package/dist/{retired-settings-keys-C1duPikd.mjs → retired-settings-keys-8yY2sTc-.mjs} +1 -1
  110. package/dist/{retired-settings-keys-CGSuXhP8.js → retired-settings-keys-iY5I0nKM.js} +1 -1
  111. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import { Bn as WELL_KNOWN_TAB_MAP, C as MINUTES_PER_DAY, Fn as DeviceFeature, Ht as locationIconKey, In as DeviceRole, Ln as DeviceType, Ot as formatMinutes, Rt as legacyAutoLinkIds, Yn as isDeviceConfigCap, Zt as normalizeUnit, _ as DeviceStatusSchema, bt as enumerateSchemaFields, dt as defineExtensionSlotProvider, en as parseStreamParamsFormPatch, gt as deviceStatusCapability, ht as deviceStateCapability, ir as EventCategory, j as STREAM_PROFILE_META, jn as BaseAddon, kn as errMsg, ln as runtimeStatePolicyFor, mn as siteZoneKey, mt as deviceManagerCapability, o as CAMERA_SWITCH_ORDER, p as DAY_NAMES, pt as deviceExtensionCapability, q as buildStreamParamsConfigSchema, rr as sleep$1, s as CAP_NAMES_WITH_STATUS, st as createDeviceExtensionProvider, t as ALL_CAPABILITY_DEFINITIONS, wt as expandContainer, x as LocationIconIdSchema, yt as enumerateItemArrayFields } from "../../dist-Cj7pJmXq.mjs";
2
- import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-C1duPikd.mjs";
1
+ import { Gn as BaseAddon, Hn as DeviceRole, Kt as normalizeUnit, Lt as locationIconKey, Nt as legacyAutoLinkIds, S as LocationIconIdSchema, Un as DeviceType, Vn as DeviceFeature, Wn as errMsg, Yt as parseStreamParamsFormPatch, Zn as WELL_KNOWN_TAB_MAP, _t as enumerateSchemaFields, an as runtimeStatePolicyFor, bt as expandContainer, ct as defineExtensionSlotProvider, dt as deviceManagerCapability, ft as deviceStateCapability, g as DeviceStatusSchema, gt as enumerateItemArrayFields, hr as EventCategory, it as createDeviceExtensionProvider, j as STREAM_PROFILE_META, mr as sleep$1, o as CAMERA_SWITCH_ORDER, or as isDeviceConfigCap, p as DAY_NAMES, pt as deviceStatusCapability, q as buildStreamParamsConfigSchema, s as CAP_NAMES_WITH_STATUS, t as ALL_CAPABILITY_DEFINITIONS, un as siteZoneKey, ut as deviceExtensionCapability, v as FieldClaimModeSchema, w as MINUTES_PER_DAY, wt as formatMinutes, y as FieldClaimSchema } from "../../dist-MzYJVCLE.mjs";
2
+ import { n as purgeRetiredSettingsRows } from "../../retired-settings-keys-8yY2sTc-.mjs";
3
+ import { z } from "zod";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
5
6
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -292,6 +293,487 @@ var AdoptionJobEngine = class {
292
293
  }
293
294
  };
294
295
  //#endregion
296
+ //#region src/builtins/device-manager/device-projection.ts
297
+ /**
298
+ * Return true when `err` is a transient Moleculer error that is worth
299
+ * retrying — specifically any `MoleculerRetryableError` subclass
300
+ * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
301
+ * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
302
+ *
303
+ * Falls back to a message-substring check for serialised errors that arrive
304
+ * across the Moleculer transport as plain objects rather than real instances.
305
+ */
306
+ function isTransientMoleculerError(err) {
307
+ if (err !== null && typeof err === "object") {
308
+ const e = err;
309
+ if (e["retryable"] === true) return true;
310
+ const code = typeof e["code"] === "string" ? e["code"] : "";
311
+ if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
312
+ }
313
+ if (err instanceof Error) {
314
+ const msg = err.message;
315
+ if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
316
+ }
317
+ return false;
318
+ }
319
+ function shallowEqual(a, b) {
320
+ const ak = Object.keys(a);
321
+ const bk = Object.keys(b);
322
+ if (ak.length !== bk.length) return false;
323
+ for (const k of ak) if (a[k] !== b[k]) return false;
324
+ return true;
325
+ }
326
+ function isCameraDevice(device) {
327
+ return "getStreamSources" in device && typeof device.getStreamSources === "function";
328
+ }
329
+ var DEVICE_FEATURE_VALUES = new Set(Object.values(DeviceFeature));
330
+ /**
331
+ * Validate persisted feature strings against the `DeviceFeature` enum
332
+ * — workers serialise the live `device.features` array (so every entry
333
+ * is a valid enum value at write time) but the persisted blob is loose
334
+ * `string[]` on the wire. The narrow keeps unknown values out of the
335
+ * `getDevice` response without losing the enum-typed contract.
336
+ */
337
+ function persistedFeatures(features) {
338
+ if (!features) return [];
339
+ const out = [];
340
+ for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
341
+ return out;
342
+ }
343
+ /**
344
+ * Infer `isCamera` from a persisted DeviceType.
345
+ *
346
+ * Live projection uses method presence (`getStreamSources` via
347
+ * `isCameraDevice`). Forked-worker devices have no `IDevice` on the hub, so
348
+ * the persisted fallbacks in `listAll` / `getDevice` / `getChildren` share
349
+ * this one inference — they must not drift (the 4263 Campanello contradiction
350
+ * of 2026-08-31: `listAll` said camera, `getDevice` said not).
351
+ */
352
+ function persistedIsCamera(type) {
353
+ return type === DeviceType.Camera;
354
+ }
355
+ /**
356
+ * Build an identity-only `SourceInfo` from the persisted device config blob.
357
+ *
358
+ * Forked-worker accessory children (e.g. HA sensor entities) persist
359
+ * `entityId` and `system` in their config blob at spawn time. The hub has no
360
+ * live `IDevice` instance for these devices, so the persisted-fallback paths
361
+ * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
362
+ * `SourceInfo` from the config so dispatch routing keeps working.
363
+ *
364
+ * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
365
+ * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
366
+ * present) are projected — purely identity, never rendering hints.
367
+ *
368
+ * Returns `undefined` when no identity anchor is resolvable (pure identity
369
+ * devices like cameras/hubs that don't carry `entityId`/`system` in their
370
+ * config blob) — the hub synthetic fallback applies in that case.
371
+ */
372
+ function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
373
+ const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
374
+ const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
375
+ if (id === void 0 && system === void 0) return void 0;
376
+ const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
377
+ return {
378
+ id: id ?? stableId,
379
+ system: system ?? addonId,
380
+ ...uniqueId !== void 0 ? { uniqueId } : {}
381
+ };
382
+ }
383
+ var DEVICE_ROLE_VALUES = new Set(Object.values(DeviceRole));
384
+ /** Type guard: a string is a known `DeviceRole` enum member. */
385
+ function isDeviceRole(value) {
386
+ return DEVICE_ROLE_VALUES.has(value);
387
+ }
388
+ /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
389
+ * Unknown / null values resolve to `null` so a stale or unrecognised role
390
+ * never leaks an off-enum string onto the wire shape. */
391
+ function toDeviceRole(value) {
392
+ return value != null && isDeviceRole(value) ? value : null;
393
+ }
394
+ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
395
+ const configValues = {};
396
+ for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
397
+ const name = metaRow?.name ?? device.name;
398
+ const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
399
+ const disabled = metaRow?.disabled ?? device.disabled;
400
+ const probeSlice = device.runtimeState?.getCapState("feature-probe");
401
+ const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
402
+ return {
403
+ id: device.id,
404
+ stableId: device.stableId,
405
+ addonId,
406
+ type: device.type,
407
+ name,
408
+ location,
409
+ disabled,
410
+ parentDeviceId: device.parentDeviceId,
411
+ role: device.role ?? null,
412
+ online: device.online,
413
+ probed,
414
+ features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
415
+ isCamera: isCameraDevice(device),
416
+ config: configValues,
417
+ metadata,
418
+ ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
419
+ ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
420
+ ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
421
+ ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
422
+ ...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
423
+ };
424
+ }
425
+ function resolveDeviceById(registry, deviceId) {
426
+ const device = registry.getById(deviceId);
427
+ if (!device) return null;
428
+ const addonId = registry.getAddonId(deviceId);
429
+ if (!addonId) return null;
430
+ return {
431
+ addonId,
432
+ device
433
+ };
434
+ }
435
+ //#endregion
436
+ //#region src/builtins/device-manager/field-ownership.ts
437
+ /**
438
+ * Per-field ownership of a runtime-state slice (D663) — the pure half.
439
+ *
440
+ * A composition may claim fields of a cap on an existing device. The native
441
+ * provider keeps writing its slice (it never learns of the claim — D62: one
442
+ * authority per field, and the authority for a claimed field is the owner),
443
+ * the hub keeps that slice as a SHADOW, and what consumers read and receive is
444
+ * the MERGE: the native slice with the owned fields replaced. The shadow is
445
+ * readable only by the owner-side mechanism (D224), never served as a second
446
+ * truth.
447
+ *
448
+ * Both halves live in the device's own runtime-state row: the native slices
449
+ * under their cap names, the claims under `$owned`. One row, one write, so a
450
+ * claim and the slice it governs can never disagree on disk.
451
+ *
452
+ * This module has no mirror, no settings store and no logger: it merges and
453
+ * it (de)serialises. `DeviceStateMirror` owns the state and the log lines.
454
+ */
455
+ /** The row key under which claims travel. Not a cap name: `$` is not legal in one. */
456
+ var OWNED_FIELDS_KEY = "$owned";
457
+ var OwnedCapSchema = z.object({
458
+ owner: z.string().min(1),
459
+ mode: FieldClaimModeSchema,
460
+ fields: z.array(z.string().min(1)).min(1),
461
+ values: z.record(z.string(), z.unknown())
462
+ });
463
+ function isPlainRecord(value) {
464
+ return typeof value === "object" && value !== null && !Array.isArray(value);
465
+ }
466
+ /**
467
+ * Split a runtime-state row into its native slices and its claims. A row with
468
+ * no `$owned` splits to its slices and an empty map — the shape every device
469
+ * without a claim has always had. Non-object entries are dropped, as the
470
+ * mirror seed and `DeviceRuntimeState.fromInitial` have always dropped them.
471
+ */
472
+ function splitRuntimeBlob(blob) {
473
+ const native = {};
474
+ const owned = /* @__PURE__ */ new Map();
475
+ const dropped = [];
476
+ for (const [key, raw] of Object.entries(blob)) {
477
+ if (key === "$owned") continue;
478
+ if (!isPlainRecord(raw)) continue;
479
+ native[key] = { ...raw };
480
+ }
481
+ const rawOwned = blob[OWNED_FIELDS_KEY];
482
+ if (isPlainRecord(rawOwned)) for (const [capName, entry] of Object.entries(rawOwned)) {
483
+ const parsed = OwnedCapSchema.safeParse(entry);
484
+ if (parsed.success) owned.set(capName, parsed.data);
485
+ else dropped.push(capName);
486
+ }
487
+ return {
488
+ native,
489
+ owned,
490
+ dropped
491
+ };
492
+ }
493
+ /**
494
+ * Join native slices and claims into one row. `values` are kept only for caps
495
+ * whose policy is `durability: 'restored'`; a session-only cap keeps its CLAIM
496
+ * (so a restart still knows who owns what) but not its values, and the claimed
497
+ * fields are held after boot until the owner writes again. A device with no
498
+ * claims gets no `$owned` key at all — its row is byte-identical to before.
499
+ */
500
+ function joinRuntimeBlob(native, owned, policyFor) {
501
+ const out = {};
502
+ for (const [capName, slice] of Object.entries(native)) out[capName] = { ...slice };
503
+ if (owned.size === 0) return out;
504
+ const claims = {};
505
+ for (const [capName, cap] of owned) {
506
+ const restored = policyFor(capName).durability === "restored";
507
+ claims[capName] = {
508
+ owner: cap.owner,
509
+ mode: cap.mode,
510
+ fields: [...cap.fields],
511
+ values: restored ? { ...cap.values } : {}
512
+ };
513
+ }
514
+ out[OWNED_FIELDS_KEY] = claims;
515
+ return out;
516
+ }
517
+ /**
518
+ * The one merge. Held when a claimed field has no value yet (S5: never
519
+ * answered from the native), when a `replace` has no native slice to overlay
520
+ * (a partial slice is never emitted), or when the merged slice fails the cap's
521
+ * `runtimeState` schema (the native value never leaks through a bad overlay).
522
+ */
523
+ function mergeOwned(native, owned, schema) {
524
+ const missing = owned.fields.filter((f) => !Object.hasOwn(owned.values, f));
525
+ if (missing.length > 0) return {
526
+ kind: "held",
527
+ reason: `claimed fields with no value yet: ${missing.join(", ")}`
528
+ };
529
+ if (owned.mode === "replace" && native === null) return {
530
+ kind: "held",
531
+ reason: "no native slice yet"
532
+ };
533
+ const merged = {
534
+ ...native,
535
+ ...owned.values
536
+ };
537
+ if (schema === null) return {
538
+ kind: "slice",
539
+ slice: merged
540
+ };
541
+ const parsed = schema.safeParse(merged);
542
+ if (parsed.success) return {
543
+ kind: "slice",
544
+ slice: parsed.data
545
+ };
546
+ return {
547
+ kind: "held",
548
+ reason: `merged slice fails the schema at ${parsed.error.issues.map((i) => i.path.join(".")).join(", ")}`
549
+ };
550
+ }
551
+ //#endregion
552
+ //#region src/builtins/device-manager/device-state-claim-views.ts
553
+ var defaultSchemaTable = null;
554
+ /** Lazily indexes the codegen'd cap set — the hub already carries the barrel. */
555
+ function defaultSchemaFor(capName) {
556
+ if (defaultSchemaTable === null) {
557
+ defaultSchemaTable = /* @__PURE__ */ new Map();
558
+ for (const def of ALL_CAPABILITY_DEFINITIONS) if (def.runtimeState) defaultSchemaTable.set(def.name, def.runtimeState);
559
+ }
560
+ return defaultSchemaTable.get(capName) ?? null;
561
+ }
562
+ function sameStringList(a, b) {
563
+ return a.length === b.length && a.every((v, i) => v === b[i]);
564
+ }
565
+ function sameOwnedCap(a, b) {
566
+ return a.owner === b.owner && a.mode === b.mode && sameStringList(a.fields, b.fields) && shallowEqual({ ...a.values }, { ...b.values });
567
+ }
568
+ function refused(code, message) {
569
+ return {
570
+ ok: false,
571
+ code,
572
+ message
573
+ };
574
+ }
575
+ var ClaimViews = class {
576
+ sink;
577
+ /** Claims by device, by cap. Absent for every device without one. */
578
+ owned = /* @__PURE__ */ new Map();
579
+ /** The merged view of each claimed cap; recomputed on every native or owned write. */
580
+ views = /* @__PURE__ */ new Map();
581
+ constructor(sink) {
582
+ this.sink = sink;
583
+ }
584
+ claim(deviceId, capName) {
585
+ return this.owned.get(deviceId)?.get(capName);
586
+ }
587
+ setClaim(deviceId, capName, claim) {
588
+ this.ownedFor(deviceId).set(capName, claim);
589
+ }
590
+ /** Removes the claim AND its view. */
591
+ deleteClaim(deviceId, capName) {
592
+ this.owned.get(deviceId)?.delete(capName);
593
+ this.views.get(deviceId)?.delete(capName);
594
+ }
595
+ /** Fill gaps only: a claim already in memory was written through and is newer than the row. */
596
+ seedClaims(deviceId, claims) {
597
+ if (claims.size === 0) return;
598
+ const mine = this.ownedFor(deviceId);
599
+ for (const [capName, cap] of claims) if (!mine.has(capName)) mine.set(capName, cap);
600
+ }
601
+ clearDevice(deviceId) {
602
+ this.owned.delete(deviceId);
603
+ this.views.delete(deviceId);
604
+ }
605
+ ownedCaps(deviceId) {
606
+ return new Map(this.owned.get(deviceId) ?? []);
607
+ }
608
+ claimsOf(deviceId) {
609
+ return this.owned.get(deviceId) ?? [];
610
+ }
611
+ /** Devices with at least one view — for the whole-system dump. */
612
+ deviceIds() {
613
+ return this.views.keys();
614
+ }
615
+ isClaimed(deviceId, capName) {
616
+ return this.views.get(deviceId)?.has(capName) ?? false;
617
+ }
618
+ /** `undefined` when the cap is not claimed; `null` when it is claimed and held with nothing to show. */
619
+ visible(deviceId, capName) {
620
+ const view = this.views.get(deviceId)?.get(capName);
621
+ return view === void 0 ? void 0 : view.visible;
622
+ }
623
+ /** Every claimed cap of a device that currently has something to show. */
624
+ *visibleCaps(deviceId) {
625
+ for (const [capName, view] of this.views.get(deviceId) ?? []) if (view.visible) yield [capName, view.visible];
626
+ }
627
+ /** What the merge says for a claimed cap; null when the cap is not claimed. */
628
+ effective(deviceId, capName) {
629
+ const view = this.views.get(deviceId)?.get(capName);
630
+ if (!view) return null;
631
+ return view.merged.kind === "slice" ? {
632
+ kind: "slice",
633
+ slice: { ...view.merged.slice }
634
+ } : {
635
+ kind: "held",
636
+ reason: view.merged.reason
637
+ };
638
+ }
639
+ view(deviceId, capName) {
640
+ return this.views.get(deviceId)?.get(capName);
641
+ }
642
+ /** Put a view back (rollback of a failed write-through); `undefined` removes it. */
643
+ restoreView(deviceId, capName, view) {
644
+ if (view) this.viewsFor(deviceId).set(capName, view);
645
+ else this.views.get(deviceId)?.delete(capName);
646
+ }
647
+ /**
648
+ * Recompute a claimed cap's merged view and emit it when it is a slice and
649
+ * either changed or `emitUnchanged` (the native moved). A held view emits
650
+ * nothing and logs once per transition, naming the fields; a bad overlay
651
+ * keeps the last good slice readable, a missing value or native does not.
652
+ */
653
+ recompute(deviceId, capName, claim, native, emitUnchanged) {
654
+ const views = this.viewsFor(deviceId);
655
+ const prior = views.get(capName);
656
+ const { view, badOverlay } = this.derive(prior, capName, claim, native);
657
+ views.set(capName, view);
658
+ const merged = view.merged;
659
+ if (merged.kind === "slice") {
660
+ if (!prior?.visible || !shallowEqual({ ...prior.visible }, { ...merged.slice }) || emitUnchanged) this.sink.emitSlice(deviceId, capName, { ...merged.slice });
661
+ return;
662
+ }
663
+ if (prior?.merged.kind === "held" && prior.merged.reason === merged.reason) return;
664
+ const extras = {
665
+ tags: { deviceId },
666
+ meta: {
667
+ cap: capName,
668
+ owner: claim.owner,
669
+ reason: merged.reason
670
+ }
671
+ };
672
+ if (badOverlay) this.sink.logger.warn("claimed cap held — nothing emitted for it", extras);
673
+ else this.sink.logger.debug("claimed cap held — nothing emitted for it", extras);
674
+ }
675
+ /**
676
+ * The view a claim has NOW — set, never emitted, never logged. A claim is
677
+ * written through, and readers must see it from the moment it is accepted
678
+ * (S5): without this, `capSlice` answered the NATIVE for an already-claimed
679
+ * field for the whole row round-trip (D663 re-review N-5). The emit is judged
680
+ * later, by `recompute`, against what consumers last saw.
681
+ */
682
+ previewView(deviceId, capName, claim, native) {
683
+ const views = this.viewsFor(deviceId);
684
+ views.set(capName, this.derive(views.get(capName), capName, claim, native).view);
685
+ }
686
+ /** The one rule for what a claimed cap shows: the merge when it is a slice; on
687
+ * a bad overlay the last good slice stays readable; a value or native not yet
688
+ * arrived shows nothing. */
689
+ derive(prior, capName, claim, native) {
690
+ const merged = mergeOwned(native, claim, this.sink.schemaFor(capName));
691
+ if (merged.kind === "slice") return {
692
+ view: {
693
+ merged,
694
+ visible: merged.slice
695
+ },
696
+ badOverlay: false
697
+ };
698
+ const badOverlay = claim.fields.every((f) => Object.hasOwn(claim.values, f)) && (native !== null || claim.mode === "add");
699
+ return {
700
+ view: {
701
+ merged,
702
+ visible: badOverlay ? prior?.visible ?? null : null
703
+ },
704
+ badOverlay
705
+ };
706
+ }
707
+ /** The merged view for a claim that arrived by seed: computed, never emitted. */
708
+ seedView(deviceId, capName, claim, native) {
709
+ const views = this.viewsFor(deviceId);
710
+ if (views.has(capName)) return;
711
+ const merged = mergeOwned(native, claim, this.sink.schemaFor(capName));
712
+ views.set(capName, {
713
+ merged,
714
+ visible: merged.kind === "slice" ? merged.slice : null
715
+ });
716
+ }
717
+ ownedFor(deviceId) {
718
+ let perCap = this.owned.get(deviceId);
719
+ if (!perCap) {
720
+ perCap = /* @__PURE__ */ new Map();
721
+ this.owned.set(deviceId, perCap);
722
+ }
723
+ return perCap;
724
+ }
725
+ viewsFor(deviceId) {
726
+ let views = this.views.get(deviceId);
727
+ if (!views) {
728
+ views = /* @__PURE__ */ new Map();
729
+ this.views.set(deviceId, views);
730
+ }
731
+ return views;
732
+ }
733
+ };
734
+ //#endregion
735
+ //#region src/builtins/device-manager/claimed-status-overlay.ts
736
+ var UNCLAIMED = { kind: "unclaimed" };
737
+ var UNKNOWN = { kind: "unknown" };
738
+ function fromMerge(mirror, deviceId, capName) {
739
+ const merged = mirror.effectiveIfClaimed(deviceId, capName);
740
+ if (merged === null) return UNCLAIMED;
741
+ return {
742
+ kind: "claimed",
743
+ slice: merged.kind === "slice" ? merged.slice : null
744
+ };
745
+ }
746
+ function installClaimedStatusOverlay(registry, deps) {
747
+ const { mirror } = deps;
748
+ let rowResolutionNoted = false;
749
+ const noteRowResolution = (deviceId) => {
750
+ if (rowResolutionNoted || deps.claimsIndex().state === "loaded") return;
751
+ rowResolutionNoted = true;
752
+ deps.logger.info("claims index not loaded: unseeded devices resolve their claims from the row on first getStatus (single-flight per device)", { tags: { deviceId } });
753
+ };
754
+ registry.setStatusOverlay({
755
+ peek(capName, deviceId) {
756
+ if (defaultSchemaFor(capName) === null) return UNCLAIMED;
757
+ if (mirror.isSeeded(deviceId)) return fromMerge(mirror, deviceId, capName);
758
+ if (mirror.seedIfIndexClears(deviceId, deps.claimsIndex())) return UNCLAIMED;
759
+ return UNKNOWN;
760
+ },
761
+ async resolve(capName, deviceId) {
762
+ if (defaultSchemaFor(capName) === null) return UNCLAIMED;
763
+ if (!mirror.isSeeded(deviceId)) {
764
+ noteRowResolution(deviceId);
765
+ try {
766
+ await mirror.ensureSeeded(deviceId, deps.settings(), deps.claimsIndex());
767
+ } catch {
768
+ return UNKNOWN;
769
+ }
770
+ }
771
+ return fromMerge(mirror, deviceId, capName);
772
+ }
773
+ });
774
+ return () => registry.setStatusOverlay(null);
775
+ }
776
+ //#endregion
295
777
  //#region src/builtins/device-manager/contribution-cache.ts
296
778
  /**
297
779
  * The device-details fan-out, made survivable: budgeted, cached, single-flight.
@@ -565,6 +1047,18 @@ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
565
1047
  perDevice = /* @__PURE__ */ new Map();
566
1048
  remoteNativeCaps.set(deviceId, perDevice);
567
1049
  }
1050
+ const existing = perDevice.get(capName);
1051
+ if (addonId === "composer" && existing !== void 0 && existing.addonId !== "composer") {
1052
+ ctx.logger.warn("a graft did not replace a native binding — native always wins", {
1053
+ tags: { deviceId },
1054
+ meta: {
1055
+ capName,
1056
+ native: existing.addonId,
1057
+ nodeId
1058
+ }
1059
+ });
1060
+ return;
1061
+ }
568
1062
  perDevice.set(capName, {
569
1063
  addonId,
570
1064
  nodeId
@@ -572,6 +1066,7 @@ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
572
1066
  } else if (reason === "native-unregistered") {
573
1067
  const perDevice = remoteNativeCaps.get(deviceId);
574
1068
  if (!perDevice) return;
1069
+ if (perDevice.get(capName)?.addonId !== addonId) return;
575
1070
  perDevice.delete(capName);
576
1071
  if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
577
1072
  }
@@ -1856,155 +2351,15 @@ function parseDerivedFormSettingsPatch(builderId, patch) {
1856
2351
  return reducer.parsePatch(patch);
1857
2352
  }
1858
2353
  //#endregion
1859
- //#region src/builtins/device-manager/device-projection.ts
2354
+ //#region src/builtins/device-manager/device-online-fallback.ts
1860
2355
  /**
1861
- * Return true when `err` is a transient Moleculer error that is worth
1862
- * retrying — specifically any `MoleculerRetryableError` subclass
1863
- * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
1864
- * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
1865
- *
1866
- * Falls back to a message-substring check for serialised errors that arrive
1867
- * across the Moleculer transport as plain objects rather than real instances.
2356
+ * The value to hand `resolveDeviceOnline` as its fallback. The mirrored
2357
+ * `device-status` slice, when one exists, still wins over this.
1868
2358
  */
1869
- function isTransientMoleculerError(err) {
1870
- if (err !== null && typeof err === "object") {
1871
- const e = err;
1872
- if (e["retryable"] === true) return true;
1873
- const code = typeof e["code"] === "string" ? e["code"] : "";
1874
- if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
1875
- }
1876
- if (err instanceof Error) {
1877
- const msg = err.message;
1878
- if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
1879
- }
1880
- return false;
1881
- }
1882
- function shallowEqual(a, b) {
1883
- const ak = Object.keys(a);
1884
- const bk = Object.keys(b);
1885
- if (ak.length !== bk.length) return false;
1886
- for (const k of ak) if (a[k] !== b[k]) return false;
1887
- return true;
1888
- }
1889
- function isCameraDevice(device) {
1890
- return "getStreamSources" in device && typeof device.getStreamSources === "function";
1891
- }
1892
- var DEVICE_FEATURE_VALUES = new Set(Object.values(DeviceFeature));
1893
- /**
1894
- * Validate persisted feature strings against the `DeviceFeature` enum
1895
- * — workers serialise the live `device.features` array (so every entry
1896
- * is a valid enum value at write time) but the persisted blob is loose
1897
- * `string[]` on the wire. The narrow keeps unknown values out of the
1898
- * `getDevice` response without losing the enum-typed contract.
1899
- */
1900
- function persistedFeatures(features) {
1901
- if (!features) return [];
1902
- const out = [];
1903
- for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
1904
- return out;
1905
- }
1906
- /**
1907
- * Infer `isCamera` from a persisted DeviceType.
1908
- *
1909
- * Live projection uses method presence (`getStreamSources` via
1910
- * `isCameraDevice`). Forked-worker devices have no `IDevice` on the hub, so
1911
- * the persisted fallbacks in `listAll` / `getDevice` / `getChildren` share
1912
- * this one inference — they must not drift (the 4263 Campanello contradiction
1913
- * of 2026-08-31: `listAll` said camera, `getDevice` said not).
1914
- */
1915
- function persistedIsCamera(type) {
1916
- return type === DeviceType.Camera;
1917
- }
1918
- /**
1919
- * Build an identity-only `SourceInfo` from the persisted device config blob.
1920
- *
1921
- * Forked-worker accessory children (e.g. HA sensor entities) persist
1922
- * `entityId` and `system` in their config blob at spawn time. The hub has no
1923
- * live `IDevice` instance for these devices, so the persisted-fallback paths
1924
- * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
1925
- * `SourceInfo` from the config so dispatch routing keeps working.
1926
- *
1927
- * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
1928
- * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
1929
- * present) are projected — purely identity, never rendering hints.
1930
- *
1931
- * Returns `undefined` when no identity anchor is resolvable (pure identity
1932
- * devices like cameras/hubs that don't carry `entityId`/`system` in their
1933
- * config blob) — the hub synthetic fallback applies in that case.
1934
- */
1935
- function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
1936
- const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
1937
- const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
1938
- if (id === void 0 && system === void 0) return void 0;
1939
- const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
1940
- return {
1941
- id: id ?? stableId,
1942
- system: system ?? addonId,
1943
- ...uniqueId !== void 0 ? { uniqueId } : {}
1944
- };
1945
- }
1946
- var DEVICE_ROLE_VALUES = new Set(Object.values(DeviceRole));
1947
- /** Type guard: a string is a known `DeviceRole` enum member. */
1948
- function isDeviceRole(value) {
1949
- return DEVICE_ROLE_VALUES.has(value);
1950
- }
1951
- /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
1952
- * Unknown / null values resolve to `null` so a stale or unrecognised role
1953
- * never leaks an off-enum string onto the wire shape. */
1954
- function toDeviceRole(value) {
1955
- return value != null && isDeviceRole(value) ? value : null;
1956
- }
1957
- function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
1958
- const configValues = {};
1959
- for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
1960
- const name = metaRow?.name ?? device.name;
1961
- const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
1962
- const disabled = metaRow?.disabled ?? device.disabled;
1963
- const probeSlice = device.runtimeState?.getCapState("feature-probe");
1964
- const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
1965
- return {
1966
- id: device.id,
1967
- stableId: device.stableId,
1968
- addonId,
1969
- type: device.type,
1970
- name,
1971
- location,
1972
- disabled,
1973
- parentDeviceId: device.parentDeviceId,
1974
- role: device.role ?? null,
1975
- online: device.online,
1976
- probed,
1977
- features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
1978
- isCamera: isCameraDevice(device),
1979
- config: configValues,
1980
- metadata,
1981
- ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
1982
- ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
1983
- ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
1984
- ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
1985
- ...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
1986
- };
1987
- }
1988
- function resolveDeviceById(registry, deviceId) {
1989
- const device = registry.getById(deviceId);
1990
- if (!device) return null;
1991
- const addonId = registry.getAddonId(deviceId);
1992
- if (!addonId) return null;
1993
- return {
1994
- addonId,
1995
- device
1996
- };
1997
- }
1998
- //#endregion
1999
- //#region src/builtins/device-manager/device-online-fallback.ts
2000
- /**
2001
- * The value to hand `resolveDeviceOnline` as its fallback. The mirrored
2002
- * `device-status` slice, when one exists, still wins over this.
2003
- */
2004
- function persistedOnlineFallback(input) {
2005
- if (!input.registryPresent) return false;
2006
- if (input.parentDeviceId !== null) return true;
2007
- return Object.keys(input.config ?? {}).length > 0;
2359
+ function persistedOnlineFallback(input) {
2360
+ if (!input.registryPresent) return false;
2361
+ if (input.parentDeviceId !== null) return true;
2362
+ return Object.keys(input.config ?? {}).length > 0;
2008
2363
  }
2009
2364
  //#endregion
2010
2365
  //#region src/builtins/device-manager/device-queries.ts
@@ -4046,7 +4401,6 @@ function createDeviceManagerExtensionProvider(pctx) {
4046
4401
  const implied = await impliedLinks(pctx, deviceId, manualIds, pickedRows);
4047
4402
  return {
4048
4403
  authority: LINKED_DEVICES_AUTHORITY,
4049
- enabled: null,
4050
4404
  activation: null,
4051
4405
  value: {
4052
4406
  slot: "linked-devices",
@@ -4110,6 +4464,34 @@ function createDeviceManagerExtensionProvider(pctx) {
4110
4464
  logger: pctx.host.ctx.logger
4111
4465
  });
4112
4466
  }
4467
+ //#endregion
4468
+ //#region src/builtins/device-manager/migration-refused.ts
4469
+ /**
4470
+ * A migration step that REFUSED with nothing moved.
4471
+ *
4472
+ * `migrateDevice` distinguishes two kinds of failure after the disable phase,
4473
+ * and they must never be confused (D663 task 5b ruling I-1):
4474
+ *
4475
+ * - **refused, nothing moved** — a precheck of `swapIds` (a row missing, the
4476
+ * temp id taken), the mirror drain that runs before the rows move, or a
4477
+ * claim found in the mirror after that drain (it landed while the migration
4478
+ * was starting). Both cameras are switched back to exactly what they were,
4479
+ * because nothing about either device changed.
4480
+ * - **failed while moving** — any other throw from `swapIds` (rows partially
4481
+ * moved), or from `swapHardwareState` (rows swapped, state not). The two
4482
+ * devices may wear each other's identity; switching them back on would run
4483
+ * pipelines against crossed identities, so they stay off and the operator is
4484
+ * told to intervene.
4485
+ *
4486
+ * Only a step that KNOWS it wrote nothing may throw this. An unknown error is
4487
+ * read as "may have moved" — the safe reading.
4488
+ */
4489
+ var MigrationRefusedError = class extends Error {
4490
+ constructor(message, options) {
4491
+ super(message, options);
4492
+ this.name = "MigrationRefusedError";
4493
+ }
4494
+ };
4113
4495
  /** A refusal that names a switch the camera does not offer, rather than one it
4114
4496
  * could not be asked about. The cap documents rejecting the former; anything
4115
4497
  * else is treated as the camera being out of reach, which is the safe reading:
@@ -4129,19 +4511,98 @@ function classify(err) {
4129
4511
  */
4130
4512
  async function setSwitchBounded(deps, deviceId, switchId, enabled) {
4131
4513
  const budget = deps.switchTimeoutMs ?? 15e3;
4132
- const write = deps.setSwitch(deviceId, switchId, enabled);
4133
- write.catch(() => {});
4514
+ await withinBudget(deps.setSwitch(deviceId, switchId, enabled), budget, `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`);
4515
+ }
4516
+ /** A bounded call: the race fails at `budget`, the late loser is marked handled. */
4517
+ async function withinBudget(call, budget, timeoutMessage) {
4518
+ call.catch(() => {});
4134
4519
  let timer;
4135
4520
  try {
4136
- await Promise.race([write, new Promise((_resolve, reject) => {
4521
+ return await Promise.race([call, new Promise((_resolve, reject) => {
4137
4522
  timer = setTimeout(() => {
4138
- 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`));
4523
+ reject(new Error(timeoutMessage));
4139
4524
  }, budget);
4140
4525
  })]);
4141
4526
  } finally {
4142
4527
  if (timer !== void 0) clearTimeout(timer);
4143
4528
  }
4144
4529
  }
4530
+ /** The prior switch states, or `null` when they could not be read (reported). */
4531
+ async function readPriorSwitches(deps, deviceId) {
4532
+ const budget = deps.switchTimeoutMs ?? 15e3;
4533
+ try {
4534
+ return await withinBudget(deps.readSwitches(deviceId), budget, `switch-state read did not answer within ${budget}ms`);
4535
+ } catch (err) {
4536
+ deps.logDevice("warn", deviceId, "migration: prior switch states unreadable", {
4537
+ error: err instanceof Error ? err.message : String(err),
4538
+ consequence: "a refusal before the swap would switch this camera fully back on"
4539
+ });
4540
+ return null;
4541
+ }
4542
+ }
4543
+ /**
4544
+ * Put one camera's switches back to what they were before the disable phase.
4545
+ * A switch that was off stays off (it still is); one that was on is written
4546
+ * on; unknown priors fall back to on — a refusal must not leave a camera dark.
4547
+ */
4548
+ async function restoreSwitches(deps, deviceId, prior) {
4549
+ const out = [];
4550
+ for (const switchId of CAMERA_SWITCH_ORDER) {
4551
+ if (prior !== null && !prior.has(switchId)) continue;
4552
+ if (prior !== null && prior.get(switchId) === false) {
4553
+ out.push({
4554
+ deviceId,
4555
+ switchId,
4556
+ outcome: "off"
4557
+ });
4558
+ continue;
4559
+ }
4560
+ try {
4561
+ await setSwitchBounded(deps, deviceId, switchId, true);
4562
+ out.push({
4563
+ deviceId,
4564
+ switchId,
4565
+ outcome: "on"
4566
+ });
4567
+ } catch (err) {
4568
+ out.push({
4569
+ deviceId,
4570
+ switchId,
4571
+ ...classify(err)
4572
+ });
4573
+ }
4574
+ }
4575
+ return out;
4576
+ }
4577
+ /** A refusal before any row moved: both cameras back as they were, then rethrow. */
4578
+ async function rollBackRefusal(deps, ids, prior, err) {
4579
+ for (const deviceId of [ids.sourceId, ids.targetId]) {
4580
+ const restored = await restoreSwitches(deps, deviceId, prior.get(deviceId) ?? null);
4581
+ deps.logDevice("warn", deviceId, "migration refused before any row moved — switches restored to their prior state", {
4582
+ ...ids,
4583
+ error: err.message,
4584
+ priorKnown: (prior.get(deviceId) ?? null) !== null,
4585
+ restored,
4586
+ stillOff: restored.filter((r) => r.outcome !== "on").map((r) => r.switchId)
4587
+ });
4588
+ }
4589
+ throw err;
4590
+ }
4591
+ /**
4592
+ * A failure once rows may have moved. The two devices may wear each other's
4593
+ * identity, so nothing is switched back on: both stay off and each device gets
4594
+ * an ERROR line saying what moved and that an operator must intervene (D391).
4595
+ */
4596
+ function alarmPartialMigration(deps, ids, phase, err) {
4597
+ const moved = phase === "swapIds" ? "device rows may be PARTIALLY swapped — look for a device at an unfamiliar id" : "device rows swapped; driver config / runtime state swap incomplete — the two devices may wear each other's identity";
4598
+ for (const deviceId of [ids.sourceId, ids.targetId]) deps.logDevice("error", deviceId, "migration FAILED after rows began to move — both cameras left switched off; operator must intervene", {
4599
+ ...ids,
4600
+ phase,
4601
+ moved,
4602
+ error: err instanceof Error ? err.message : String(err),
4603
+ remedy: "inspect both devices (rows, driver config, runtime state) and repair by hand before switching them back on"
4604
+ });
4605
+ }
4145
4606
  async function applyAll(deps, deviceId, enabled) {
4146
4607
  const out = [];
4147
4608
  for (const switchId of CAMERA_SWITCH_ORDER) try {
@@ -4179,10 +4640,27 @@ async function applyAll(deps, deviceId, enabled) {
4179
4640
  async function migrateDevice$1(deps, input) {
4180
4641
  const { sourceId, targetId } = input;
4181
4642
  if (sourceId === targetId) throw new Error(`[device-manager] migrateDevice: ${sourceId} is both source and target`);
4643
+ await deps.assertNoFieldClaims(sourceId, targetId);
4644
+ const ids = {
4645
+ sourceId,
4646
+ targetId
4647
+ };
4648
+ const prior = new Map([[sourceId, await readPriorSwitches(deps, sourceId)], [targetId, await readPriorSwitches(deps, targetId)]]);
4182
4649
  const down = [...await applyAll(deps, sourceId, false), ...await applyAll(deps, targetId, false)];
4183
4650
  const sourceStillLive = down.filter((r) => r.deviceId === sourceId && r.outcome === "unreachable").map((r) => r.switchId);
4184
- await deps.swapIds(sourceId, targetId);
4185
- await deps.swapHardwareState(sourceId, targetId);
4651
+ try {
4652
+ await deps.swapIds(sourceId, targetId);
4653
+ } catch (err) {
4654
+ if (err instanceof MigrationRefusedError) return rollBackRefusal(deps, ids, prior, err);
4655
+ alarmPartialMigration(deps, ids, "swapIds", err);
4656
+ throw err;
4657
+ }
4658
+ try {
4659
+ await deps.swapHardwareState(sourceId, targetId);
4660
+ } catch (err) {
4661
+ alarmPartialMigration(deps, ids, "swapHardwareState", err);
4662
+ throw err;
4663
+ }
4186
4664
  for (const deviceId of [sourceId, targetId]) try {
4187
4665
  await deps.forgetStreamState(deviceId);
4188
4666
  } catch (err) {
@@ -4288,7 +4766,12 @@ async function reloadMigratedDevices(pctx, targets) {
4288
4766
  * map — see `StreamsTab.tsx`, which stopped writing it), and either copy
4289
4767
  * names stream ids of the wrong hardware after a swap.
4290
4768
  * - The runtime-state blobs (`__device-state:<id>` — battery snapshot, sleep
4291
- * flag, feature probe) SWAP wholesale: every slice mirrors the box.
4769
+ * flag, feature probe) SWAP wholesale: every slice mirrors the box — never
4770
+ * while either carries `$owned` (D663): the migration is refused first
4771
+ * ({@link assertNoFieldClaims}). A claim names the NUMBER, the block that
4772
+ * owns it names a STABLE ID, and the swap moves the stable id to the other
4773
+ * number; whatever it did with `$owned`, one of the two keys would end up
4774
+ * on the wrong device.
4292
4775
  *
4293
4776
  * ## Crash points are loud, same as `swapIds`
4294
4777
  *
@@ -4301,6 +4784,92 @@ async function reloadMigratedDevices(pctx, targets) {
4301
4784
  var PROFILE_MAP_CONFIG_KEY = "_profileMap";
4302
4785
  /** Config keys that belong to the camera's ROLE and stay with the number. */
4303
4786
  var ROLE_SCOPED_CONFIG_KEYS = LINKED_DEVICES_RESERVED_KEYS;
4787
+ /**
4788
+ * The refusal of a migration over a device under customization. `blocked`
4789
+ * carries the same facts as the message, per device, so the caller can log
4790
+ * one line per device tagged with its id.
4791
+ */
4792
+ var FieldClaimsBlockMigrationError = class extends Error {
4793
+ sourceId;
4794
+ targetId;
4795
+ blocked;
4796
+ constructor(sourceId, targetId, blocked) {
4797
+ super(`migration ${sourceId} → ${targetId} refused: ${describeBlocked(blocked)} — delete the customization before migrating (the composer releases its claims; then retry)`);
4798
+ this.sourceId = sourceId;
4799
+ this.targetId = targetId;
4800
+ this.blocked = blocked;
4801
+ this.name = "FieldClaimsBlockMigrationError";
4802
+ }
4803
+ };
4804
+ /**
4805
+ * A claim that landed while the migration was already running (D663 final
4806
+ * review I-1). It passed the claim methods' migration gate just before the
4807
+ * migration began, and its write-through had not reached the row when
4808
+ * {@link assertNoFieldClaims} read it. Found after the drain, before any row
4809
+ * moved — so it is a {@link MigrationRefusedError}: both cameras' switches are
4810
+ * restored, and the claim stays on the number it names.
4811
+ */
4812
+ var ClaimLandedDuringMigrationError = class extends MigrationRefusedError {
4813
+ sourceId;
4814
+ targetId;
4815
+ blocked;
4816
+ constructor(sourceId, targetId, blocked) {
4817
+ super(`migration ${sourceId} → ${targetId} refused: ${describeBlocked(blocked)}, landed while the migration was starting — nothing moved; delete the customization, then retry`);
4818
+ this.sourceId = sourceId;
4819
+ this.targetId = targetId;
4820
+ this.blocked = blocked;
4821
+ this.name = "ClaimLandedDuringMigrationError";
4822
+ }
4823
+ };
4824
+ function describeBlocked(blocked) {
4825
+ return blocked.map(({ deviceId, claims }) => {
4826
+ return `device ${deviceId} has fields claimed by a customization (${claims.map((c) => `${c.cap} [${c.fields.join(", ")}]: ${c.owner}`).join(", ")})`;
4827
+ }).join("; ");
4828
+ }
4829
+ function blockingClaims(owned) {
4830
+ return [...owned].map(([cap, o]) => ({
4831
+ cap,
4832
+ owner: o.owner,
4833
+ fields: [...o.fields]
4834
+ }));
4835
+ }
4836
+ /**
4837
+ * The second look, from MEMORY, once the mirror's writer is held and drained
4838
+ * (I-1). A claim is in the mirror from the moment it is accepted, before its
4839
+ * write-through lands, and the hold refuses any claim that reaches the mirror
4840
+ * after it — so an empty answer here means no claim can land on either row
4841
+ * until the swap is done.
4842
+ */
4843
+ function assertNoClaimLandedDuringMigration(ownedCapsOf, sourceId, targetId) {
4844
+ const blocked = [];
4845
+ for (const deviceId of [sourceId, targetId]) {
4846
+ const owned = ownedCapsOf(deviceId);
4847
+ if (owned.size > 0) blocked.push({
4848
+ deviceId,
4849
+ claims: blockingClaims(owned)
4850
+ });
4851
+ }
4852
+ if (blocked.length > 0) throw new ClaimLandedDuringMigrationError(sourceId, targetId, blocked);
4853
+ }
4854
+ /**
4855
+ * A claim names a NUMBER (`$owned` sits in `__device-state:<id>`); the block
4856
+ * that owns it names a STABLE ID; `swapIds` moves the stable id to the other
4857
+ * number. No swap of `$owned` keeps both keys right, so a device under
4858
+ * customization is not migratable (D663). Read from the ROW — claims are
4859
+ * written through, so the row is where one exists. A row that cannot be read
4860
+ * THROWS: an unknown answer must refuse, never read as "no claim" (D49).
4861
+ */
4862
+ async function assertNoFieldClaims(settings, sourceId, targetId) {
4863
+ const blocked = [];
4864
+ for (const deviceId of [sourceId, targetId]) {
4865
+ const { owned } = splitRuntimeBlob(await settings.readDeviceRuntimeState(deviceId));
4866
+ if (owned.size > 0) blocked.push({
4867
+ deviceId,
4868
+ claims: blockingClaims(owned)
4869
+ });
4870
+ }
4871
+ if (blocked.length > 0) throw new FieldClaimsBlockMigrationError(sourceId, targetId, blocked);
4872
+ }
4304
4873
  function splitConfig(config) {
4305
4874
  const hardware = {};
4306
4875
  const role = {};
@@ -4694,6 +5263,7 @@ async function migrateDevice(pctx, input) {
4694
5263
  };
4695
5264
  }
4696
5265
  const tempId = await pctx.metaStore.allocateNextDeviceId();
5266
+ const held = { current: null };
4697
5267
  const result = await migrateDevice$1({
4698
5268
  setSwitch: async (deviceId, switchId, enabled) => {
4699
5269
  if (switchId === "stream-broker") {
@@ -4708,12 +5278,41 @@ async function migrateDevice(pctx, input) {
4708
5278
  });
4709
5279
  },
4710
5280
  swapIds: async (a, b) => {
5281
+ held.current = await pctx.host.holdMirrorWrites([a, b], pctx.settings);
5282
+ try {
5283
+ assertNoClaimLandedDuringMigration((id) => pctx.host.mirrorOwnedCaps(id), a, b);
5284
+ } catch (err) {
5285
+ if (err instanceof ClaimLandedDuringMigrationError) for (const blocked of err.blocked) ctx.logger.warn("migrateDevice refused: a claim landed during the migration", {
5286
+ tags: { deviceId: blocked.deviceId },
5287
+ meta: {
5288
+ sourceId: a,
5289
+ targetId: b,
5290
+ claims: blocked.claims
5291
+ }
5292
+ });
5293
+ throw err;
5294
+ }
4711
5295
  await pctx.metaStore.rows.swapIds(a, b, tempId);
4712
5296
  },
5297
+ assertNoFieldClaims: async (a, b) => {
5298
+ try {
5299
+ await assertNoFieldClaims(pctx.settings, a, b);
5300
+ } catch (err) {
5301
+ if (err instanceof FieldClaimsBlockMigrationError) for (const blocked of err.blocked) ctx.logger.warn("migrateDevice refused: fields claimed by a customization", {
5302
+ tags: { deviceId: blocked.deviceId },
5303
+ meta: {
5304
+ sourceId: a,
5305
+ targetId: b,
5306
+ claims: blocked.claims
5307
+ }
5308
+ });
5309
+ throw err;
5310
+ }
5311
+ },
4713
5312
  swapHardwareState: async (a, b) => {
4714
5313
  const swapped = await swapHardwareState(pctx.settings, a, b);
4715
- pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource));
4716
- pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget));
5314
+ pctx.host.seedMirror(a, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtSource), "replace");
5315
+ pctx.host.seedMirror(b, pctx.host.withResetSessionProbe(swapped.runtimeStateNowAtTarget), "replace");
4717
5316
  ctx.logger.info("migration: hardware-scoped device state swapped", {
4718
5317
  tags: { deviceId: a },
4719
5318
  meta: {
@@ -4734,11 +5333,21 @@ async function migrateDevice(pctx, input) {
4734
5333
  tags: { deviceId: sourceId },
4735
5334
  meta
4736
5335
  });
5336
+ },
5337
+ readSwitches: async (deviceId) => {
5338
+ const group = await ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId });
5339
+ return new Map(group.switches.filter((sw) => sw.available).map((sw) => [sw.id, sw.enabled]));
5340
+ },
5341
+ logDevice: (level, deviceId, message, meta) => {
5342
+ ctx.logger[level](message, {
5343
+ tags: { deviceId },
5344
+ meta
5345
+ });
4737
5346
  }
4738
5347
  }, {
4739
5348
  sourceId,
4740
5349
  targetId
4741
- });
5350
+ }).finally(() => held.current?.release());
4742
5351
  pctx.migrationGuard.recordCompleted({
4743
5352
  sourceId,
4744
5353
  targetId,
@@ -4932,6 +5541,7 @@ async function removeDevice(pctx, input) {
4932
5541
  });
4933
5542
  await pctx.settings.clearDeviceStore(deviceId);
4934
5543
  await pctx.settings.clearDeviceRuntimeState(deviceId);
5544
+ await pctx.host.fieldClaims.pruneDevice(deviceId, pctx.settings);
4935
5545
  const bindingKey = String(deviceId);
4936
5546
  await pctx.bindingsDeps.withAddonStoreWriteLock(async () => {
4937
5547
  const bindingsStore = await readBindingsStore(pctx.bindingsDeps);
@@ -5588,7 +6198,7 @@ async function loadRuntimeState(pctx, input) {
5588
6198
  if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
5589
6199
  const data = await pctx.settings.readDeviceRuntimeState(deviceId);
5590
6200
  pctx.host.seedMirror(deviceId, pctx.host.withResetSessionProbe(data));
5591
- return data;
6201
+ return splitRuntimeBlob(data).native;
5592
6202
  }
5593
6203
  /**
5594
6204
  * Union of (1) operator-curated location registry and (2) labels
@@ -7161,15 +7771,10 @@ var DeviceRowStore = class {
7161
7771
  * functions on both devices in the same critical section.
7162
7772
  */
7163
7773
  async swapIds(a, b, tempId) {
7164
- if (a === b) throw new Error(`[device-manager] swapIds: ${a} and ${b} are the same device`);
7165
- await this.declare();
7166
- const rowA = await this.get(a);
7167
- const rowB = await this.get(b);
7168
- if (rowA === null) throw new Error(`[device-manager] swapIds: device ${a} does not exist`);
7169
- if (rowB === null) throw new Error(`[device-manager] swapIds: device ${b} does not exist`);
7170
- if (await this.get(tempId) !== null) throw new Error(`[device-manager] swapIds: temp id ${tempId} is taken`);
7171
- const childrenOfA = (await this.listByParent(a)).map((r) => r.meta.id);
7172
- const childrenOfB = (await this.listByParent(b)).map((r) => r.meta.id);
7774
+ const { rowA, rowB, childrenOfA, childrenOfB } = await this.planSwap(a, b, tempId).catch((err) => {
7775
+ if (err instanceof MigrationRefusedError) throw err;
7776
+ throw new MigrationRefusedError(`[device-manager] swapIds: precheck failed before any row moved — ${err instanceof Error ? err.message : String(err)}`, { cause: err });
7777
+ });
7173
7778
  await this.moveRow(rowA, tempId);
7174
7779
  await this.moveRow(rowB, a);
7175
7780
  await this.moveRow({
@@ -7191,6 +7796,22 @@ var DeviceRowStore = class {
7191
7796
  }
7192
7797
  });
7193
7798
  }
7799
+ /** The read-only half of {@link swapIds}: every refusal, every read, no write. */
7800
+ async planSwap(a, b, tempId) {
7801
+ if (a === b) throw new MigrationRefusedError(`[device-manager] swapIds: ${a} and ${b} are the same device`);
7802
+ await this.declare();
7803
+ const rowA = await this.get(a);
7804
+ const rowB = await this.get(b);
7805
+ if (rowA === null) throw new MigrationRefusedError(`[device-manager] swapIds: device ${a} does not exist`);
7806
+ if (rowB === null) throw new MigrationRefusedError(`[device-manager] swapIds: device ${b} does not exist`);
7807
+ if (await this.get(tempId) !== null) throw new MigrationRefusedError(`[device-manager] swapIds: temp id ${tempId} is taken`);
7808
+ return {
7809
+ rowA,
7810
+ rowB,
7811
+ childrenOfA: (await this.listByParent(a)).map((r) => r.meta.id),
7812
+ childrenOfB: (await this.listByParent(b)).map((r) => r.meta.id)
7813
+ };
7814
+ }
7194
7815
  /** Rewrite a row under a new id and drop the old key. Not exported: the only
7195
7816
  * legitimate reason to move a row is {@link swapIds}. */
7196
7817
  async moveRow(row, toId) {
@@ -7241,6 +7862,118 @@ var DeviceRowStore = class {
7241
7862
  }
7242
7863
  };
7243
7864
  //#endregion
7865
+ //#region src/builtins/device-manager/device-state-claims.ts
7866
+ function createDeviceStateClaimMethods(deps) {
7867
+ const { mirror, index, settings, logger } = deps;
7868
+ function unknownDevice(deviceId) {
7869
+ return refused("unknown-device", `device ${deviceId} is not registered`);
7870
+ }
7871
+ /** A device under migration takes no claim, patch or release — refused by name, before either store. */
7872
+ function migrating(deviceId, op) {
7873
+ if (!deps.migrationInFlight(deviceId)) return null;
7874
+ logger.warn(`${op} refused — a migration of the device is in flight`, {
7875
+ tags: { deviceId },
7876
+ meta: { op }
7877
+ });
7878
+ return refused("migration-in-flight", `device ${deviceId} is being migrated — its row is about to be swapped; retry once the migration is over`);
7879
+ }
7880
+ function claimOnRow(input) {
7881
+ return mirror.claim(input.deviceId, input.capName, {
7882
+ owner: input.owner,
7883
+ mode: input.mode,
7884
+ fields: [...input.fields],
7885
+ values: { ...input.values }
7886
+ }, settings);
7887
+ }
7888
+ return {
7889
+ async claimFields(input) {
7890
+ const { deviceId, capName, owner, mode, fields } = input;
7891
+ const outside = Object.keys(input.values).filter((k) => !fields.includes(k));
7892
+ if (outside.length > 0) return refused("field-not-claimed", `values name fields outside the claim: ${outside.join(", ")}`);
7893
+ if (!await deps.deviceExists(deviceId)) return unknownDevice(deviceId);
7894
+ const gated = migrating(deviceId, "claim");
7895
+ if (gated) return gated;
7896
+ const key = {
7897
+ deviceId,
7898
+ capName
7899
+ };
7900
+ const entry = {
7901
+ deviceId,
7902
+ capName,
7903
+ owner,
7904
+ mode,
7905
+ fields: [...fields]
7906
+ };
7907
+ if (index.entry(key) !== void 0) {
7908
+ const outcome = await claimOnRow(input);
7909
+ if (outcome.ok) await index.upsert(entry, settings);
7910
+ return outcome;
7911
+ }
7912
+ await index.upsert(entry, settings);
7913
+ let outcome;
7914
+ try {
7915
+ outcome = await claimOnRow(input);
7916
+ } catch (err) {
7917
+ logger.warn("claim row write failed — index entry kept; a release heals it", {
7918
+ tags: { deviceId },
7919
+ meta: {
7920
+ cap: capName,
7921
+ owner,
7922
+ error: errorMessage$3(err)
7923
+ }
7924
+ });
7925
+ throw err;
7926
+ }
7927
+ if (!outcome.ok) await dropRefusedEntry(key, outcome.code);
7928
+ return outcome;
7929
+ },
7930
+ async patchOwnedFields(input) {
7931
+ if (!await deps.deviceExists(input.deviceId)) return unknownDevice(input.deviceId);
7932
+ const gated = migrating(input.deviceId, "patch");
7933
+ if (gated) return gated;
7934
+ return mirror.patchOwned(input.deviceId, input.capName, input.owner, { ...input.values }, settings);
7935
+ },
7936
+ async releaseClaim(input) {
7937
+ const { deviceId, capName, owner } = input;
7938
+ const key = {
7939
+ deviceId,
7940
+ capName
7941
+ };
7942
+ if (!await deps.deviceExists(deviceId)) {
7943
+ await index.remove(key, settings);
7944
+ return unknownDevice(deviceId);
7945
+ }
7946
+ const gated = migrating(deviceId, "release");
7947
+ if (gated) return gated;
7948
+ const outcome = await mirror.release(deviceId, capName, owner, settings);
7949
+ if (outcome.ok || outcome.code === "not-claimed") await index.remove(key, settings);
7950
+ return outcome;
7951
+ },
7952
+ async listClaims(input) {
7953
+ await index.load(settings);
7954
+ return index.list(input.ownerPrefix);
7955
+ }
7956
+ };
7957
+ /** A first claim the row refused must not stay announced: drop the entry this call added. */
7958
+ async function dropRefusedEntry(key, code) {
7959
+ try {
7960
+ await index.remove(key, settings);
7961
+ } catch (err) {
7962
+ logger.warn("claims index not restored after a refused claim — stale entry until the next release", {
7963
+ tags: { deviceId: key.deviceId },
7964
+ meta: {
7965
+ cap: key.capName,
7966
+ refusal: code,
7967
+ error: errorMessage$3(err)
7968
+ }
7969
+ });
7970
+ }
7971
+ }
7972
+ }
7973
+ function errorMessage$3(err) {
7974
+ return err instanceof Error ? err.message : String(err);
7975
+ }
7976
+ //#endregion
7244
7977
  //#region src/builtins/device-manager/runtime-state-persist-gate.ts
7245
7978
  /**
7246
7979
  * The subset of `blob` that is allowed on disk: slices whose capability
@@ -7250,11 +7983,15 @@ var DeviceRowStore = class {
7250
7983
  * direction is deliberate: the cost of losing a session value is a cold window
7251
7984
  * until the provider republishes; the cost of persisting an unasked-for one is
7252
7985
  * a commit per second on the fleet's busiest write path.
7986
+ *
7987
+ * `$owned` is not a cap: it is the device's claims record (D663) and is kept
7988
+ * whatever the lookup says — a restart that forgot who owns a field would hand
7989
+ * it back to the native provider.
7253
7990
  */
7254
7991
  function persistableBlob(blob, policyFor) {
7255
7992
  const out = {};
7256
7993
  for (const [capName, slice] of Object.entries(blob)) {
7257
- if (policyFor(capName).durability !== "restored") continue;
7994
+ if (capName !== "$owned" && policyFor(capName).durability !== "restored") continue;
7258
7995
  out[capName] = { ...slice };
7259
7996
  }
7260
7997
  return out;
@@ -7323,44 +8060,45 @@ function deepEqual(a, b) {
7323
8060
  return true;
7324
8061
  }
7325
8062
  //#endregion
7326
- //#region src/builtins/device-manager/device-state-mirror.ts
8063
+ //#region src/builtins/device-manager/mirror-row-writer.ts
8064
+ function errorMessage$2(err) {
8065
+ return err instanceof Error ? err.message : String(err);
8066
+ }
7327
8067
  /**
7328
- * Hub-side runtime-state mirror for the device-manager addon.
7329
- *
7330
- * `DeviceStateMirror` owns the per-device cap-keyed slice mirror and the
7331
- * debounced disk-write coalescer. It mirrors every `deviceState.setCapSlice`
7332
- * write, emits `DeviceStateChanged`, and coalesces frequent writes into one
7333
- * `writeDeviceRuntimeState` per debounce window.
7334
- *
7335
- * It used to do one more thing: overlay each emitted slice with cross-device
7336
- * LINKED values, walk a `linkDependents` reverse-index and re-emit for every
7337
- * dependent target, guarded by a `lastEmittedOverlay` churn map. Wiring was
7338
- * deleted on 2026-08-08 (0 links across 302 devices), so the mirror emits what
7339
- * the provider wrote — one writer per field, which is what D62 asks for
7340
- * everywhere else.
8068
+ * The drain before a migration could not vouch for a device's row. Nothing has
8069
+ * moved yet, so it is a {@link MigrationRefusedError}: the migration refuses
8070
+ * and restores both cameras' switches.
7341
8071
  */
7342
- var DeviceStateMirror = class DeviceStateMirror {
7343
- ctx;
7344
- policyFor;
7345
- /**
7346
- * Hub-side mirror of every device's cap-keyed runtime state.
7347
- * Key: deviceId. Value: per-cap slice map. Empty by default —
7348
- * slices show up as `setCapSlice` calls trickle in.
7349
- */
7350
- stateMirror = /* @__PURE__ */ new Map();
8072
+ var RuntimeStateDrainError = class extends MigrationRefusedError {
8073
+ deviceId;
8074
+ constructor(deviceId, reason, options) {
8075
+ super(`migration refused: the runtime state of device ${deviceId} could not be drained before the swap (${reason}) — nothing moved; retry the migration`, options);
8076
+ this.deviceId = deviceId;
8077
+ this.name = "RuntimeStateDrainError";
8078
+ }
8079
+ };
8080
+ var MirrorRowWriter = class MirrorRowWriter {
8081
+ deps;
8082
+ static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
7351
8083
  /**
7352
8084
  * Per-device disk-write debouncer for runtime-state. `setCapSlice`
7353
8085
  * updates the in-memory mirror synchronously and emits the change
7354
8086
  * event immediately, but the disk write is coalesced.
7355
8087
  */
7356
8088
  runtimeStateDebounce = /* @__PURE__ */ new Map();
7357
- static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
8089
+ /**
8090
+ * Devices whose debounced writer is HELD off the row — a migration is
8091
+ * swapping it (`holdDiskWrites`). A change while held stays in memory only.
8092
+ */
8093
+ writesHeld = /* @__PURE__ */ new Set();
8094
+ /** Held devices a durable change was scheduled for — what `release` re-arms. */
8095
+ heldDirty = /* @__PURE__ */ new Set();
7358
8096
  /**
7359
8097
  * What is believed to be ON DISK for each device — the persistable projection
7360
- * of the last blob actually written (or seeded at boot). The effective-change
7361
- * gate compares against THIS, not against the previous mirror state: two
7362
- * clock-only ticks in a row must not add up to a write just because each was
7363
- * compared with its immediate predecessor.
8098
+ * of the last blob actually written (or read at seed time). The
8099
+ * effective-change gate compares against THIS, not against the previous
8100
+ * mirror state: two clock-only ticks in a row must not add up to a write just
8101
+ * because each was compared with its immediate predecessor.
7364
8102
  */
7365
8103
  lastPersisted = /* @__PURE__ */ new Map();
7366
8104
  /**
@@ -7370,33 +8108,55 @@ var DeviceStateMirror = class DeviceStateMirror {
7370
8108
  * that drops work silently reads as "never happened".
7371
8109
  */
7372
8110
  skippedSinceWrite = /* @__PURE__ */ new Map();
7373
- constructor(ctx, policyFor = runtimeStatePolicyFor) {
7374
- this.ctx = ctx;
7375
- this.policyFor = policyFor;
8111
+ constructor(deps) {
8112
+ this.deps = deps;
8113
+ }
8114
+ /** What is believed to be on the device's row; undefined before any read or write. */
8115
+ persisted(deviceId) {
8116
+ return this.lastPersisted.get(deviceId);
8117
+ }
8118
+ /** Record what the row now holds — a write that landed, or a row just read. */
8119
+ markPersisted(deviceId, row) {
8120
+ this.lastPersisted.set(deviceId, row);
8121
+ }
8122
+ /** A migration holds the writer off this device's row right now. */
8123
+ isHeld(deviceId) {
8124
+ return this.writesHeld.has(deviceId);
7376
8125
  }
7377
8126
  /**
7378
- * Single-cap mirror update — diff against the current mirror,
7379
- * persist the new slice in-memory, emit `DeviceStateChanged` for
7380
- * this cap. No-op on identical writes (both same shape and same
7381
- * values). Called by `setCapSlice` provider.
7382
- *
7383
- * @returns whether the mirror actually changed. The caller uses this to
7384
- * decide whether the (synchronous, disk-blocking) runtime-state write is
7385
- * worth scheduling — see `scheduleRuntimeStateDiskWrite`. Returning void
7386
- * here and scheduling unconditionally is what made an unchanged poll cost
7387
- * a SQLite commit.
8127
+ * Chain a row write behind whatever is in flight for the device. Returns the
8128
+ * write's own promise (it may reject, to its caller); the queue tail never
8129
+ * rejects and clears itself once it is the last one out.
7388
8130
  */
7389
- applySingleCapUpdate(deviceId, capName, slice) {
7390
- let perCap = this.stateMirror.get(deviceId);
7391
- if (!perCap) {
7392
- perCap = /* @__PURE__ */ new Map();
7393
- this.stateMirror.set(deviceId, perCap);
8131
+ serialiseWrite(deviceId, task) {
8132
+ const slot = this.slotFor(deviceId);
8133
+ const run = (slot.inFlight ?? Promise.resolve()).then(task);
8134
+ const tail = run.then(() => void 0, () => void 0);
8135
+ slot.inFlight = tail;
8136
+ tail.then(() => {
8137
+ if (slot.inFlight === tail) slot.inFlight = null;
8138
+ });
8139
+ return run;
8140
+ }
8141
+ slotFor(deviceId) {
8142
+ let slot = this.runtimeStateDebounce.get(deviceId);
8143
+ if (!slot) {
8144
+ slot = {
8145
+ timer: null,
8146
+ inFlight: null,
8147
+ lastWriteFailed: false
8148
+ };
8149
+ this.runtimeStateDebounce.set(deviceId, slot);
7394
8150
  }
7395
- const prior = perCap.get(capName);
7396
- if (prior && shallowEqual(prior, slice)) return false;
7397
- perCap.set(capName, { ...slice });
7398
- this.emitStateChanged(deviceId, capName, { ...slice });
7399
- return true;
8151
+ return slot;
8152
+ }
8153
+ /** Arm the writer for a durable change, or remember it for `release` while a migration holds the device. */
8154
+ armOrHold(deviceId, settings) {
8155
+ if (this.writesHeld.has(deviceId)) {
8156
+ this.heldDirty.add(deviceId);
8157
+ return;
8158
+ }
8159
+ this.armDiskWrite(deviceId, settings);
7400
8160
  }
7401
8161
  /**
7402
8162
  * Debounced disk writer, behind two gates.
@@ -7414,45 +8174,118 @@ var DeviceStateMirror = class DeviceStateMirror {
7414
8174
  * The blob is read from the live mirror at flush time, so the disk picture is
7415
8175
  * always the latest state — no risk of writing a stale snapshot.
7416
8176
  */
7417
- scheduleRuntimeStateDiskWrite(deviceId, settings, changedCap) {
7418
- if (this.policyFor(changedCap).durability !== "restored") return;
7419
- let slot = this.runtimeStateDebounce.get(deviceId);
7420
- if (!slot) {
7421
- slot = {
7422
- timer: null,
7423
- inFlight: null
7424
- };
7425
- this.runtimeStateDebounce.set(deviceId, slot);
7426
- }
8177
+ schedule(deviceId, settings, changedCap) {
8178
+ if (this.deps.policyFor(changedCap).durability !== "restored") return;
8179
+ this.armOrHold(deviceId, settings);
8180
+ }
8181
+ /** Arm the debounce timer; at fire, queue a write of the live blob unless disk already has it. */
8182
+ armDiskWrite(deviceId, settings) {
8183
+ const slot = this.slotFor(deviceId);
7427
8184
  if (slot.timer) return;
7428
8185
  slot.timer = setTimeout(() => {
7429
8186
  slot.timer = null;
7430
- const blob = this.persistableForDevice(deviceId);
7431
- if (this.isAlreadyPersisted(deviceId, blob)) return;
7432
- const skipped = this.skippedSinceWrite.get(deviceId) ?? 0;
7433
- this.skippedSinceWrite.delete(deviceId);
7434
- const write = (async () => {
7435
- try {
7436
- await settings.writeDeviceRuntimeState(deviceId, blob);
7437
- this.lastPersisted.set(deviceId, blob);
7438
- if (skipped > 0) this.ctx.logger.debug("runtime state persisted", {
7439
- tags: { deviceId },
7440
- meta: {
7441
- caps: Object.keys(blob).length,
7442
- skippedSinceLastWrite: skipped
7443
- }
7444
- });
7445
- } catch (err) {
7446
- this.ctx.logger.warn("writeDeviceRuntimeState failed", {
7447
- tags: { deviceId },
7448
- meta: { error: err instanceof Error ? err.message : String(err) }
7449
- });
7450
- } finally {
7451
- slot.inFlight = null;
8187
+ this.serialiseWrite(deviceId, () => this.writeLiveBlob(deviceId, settings, slot));
8188
+ }, MirrorRowWriter.RUNTIME_STATE_DEBOUNCE_MS);
8189
+ }
8190
+ /** The debounced write itself: the blob as memory holds it NOW, behind the effective-change gate. Never rejects. */
8191
+ async writeLiveBlob(deviceId, settings, slot) {
8192
+ const blob = this.deps.persistableFor(deviceId);
8193
+ if (this.isAlreadyPersisted(deviceId, blob)) return;
8194
+ const skipped = this.skippedSinceWrite.get(deviceId) ?? 0;
8195
+ this.skippedSinceWrite.delete(deviceId);
8196
+ try {
8197
+ await settings.writeDeviceRuntimeState(deviceId, blob);
8198
+ this.lastPersisted.set(deviceId, blob);
8199
+ slot.lastWriteFailed = false;
8200
+ if (skipped > 0) this.deps.logger.debug("runtime state persisted", {
8201
+ tags: { deviceId },
8202
+ meta: {
8203
+ caps: Object.keys(blob).length,
8204
+ skippedSinceLastWrite: skipped
7452
8205
  }
7453
- })();
7454
- slot.inFlight = write;
7455
- }, DeviceStateMirror.RUNTIME_STATE_DEBOUNCE_MS);
8206
+ });
8207
+ } catch (err) {
8208
+ slot.lastWriteFailed = true;
8209
+ this.deps.logger.warn("writeDeviceRuntimeState failed", {
8210
+ tags: { deviceId },
8211
+ meta: { error: errorMessage$2(err) }
8212
+ });
8213
+ }
8214
+ }
8215
+ /**
8216
+ * Hold the debounced writer off these devices' rows while a migration swaps
8217
+ * them (D663 re-review N-6). Resolves once each device is DRAINED: a write
8218
+ * still in its debounce window is written now (it is this hardware's latest
8219
+ * state, and the swap then carries it to the right number), and a write
8220
+ * already in flight has landed. Until `release`, nothing the writer does
8221
+ * reaches these rows — a blob read before the swap and written after it would
8222
+ * put the other box's state back under the number, and make it the baseline.
8223
+ *
8224
+ * Only what the writer already had pending is written — never memory
8225
+ * wholesale, which for a row nobody read would overwrite it with a partial
8226
+ * picture. `release` re-arms the writer for a device changed while held:
8227
+ * after the migration's `replace` reseed memory equals the row and nothing
8228
+ * is written; after a failed swap the row converges to memory.
8229
+ *
8230
+ * The migration must not proceed over a row that is not what memory says it
8231
+ * is. So a drain write that FAILS, and equally a write that was in flight and
8232
+ * FAILED (ruling I-2 — awaiting it is not the same as it landing), release
8233
+ * the hold, log the device and reject with a {@link RuntimeStateDrainError};
8234
+ * the owed write is re-armed on release.
8235
+ *
8236
+ * One hold per device at a time: `writesHeld` is a set, not a refcount, so a
8237
+ * second overlapping hold would be released by the first. Safe because the
8238
+ * only caller is `migrateDevice`, serialised by `migrationGuard` and the meta
8239
+ * write lock; a new caller needs a refcount first.
8240
+ */
8241
+ async holdDiskWrites(deviceIds, settings) {
8242
+ for (const deviceId of deviceIds) this.writesHeld.add(deviceId);
8243
+ const release = () => {
8244
+ for (const deviceId of deviceIds) {
8245
+ this.writesHeld.delete(deviceId);
8246
+ if (this.heldDirty.delete(deviceId)) this.armDiskWrite(deviceId, settings);
8247
+ }
8248
+ };
8249
+ try {
8250
+ for (const deviceId of deviceIds) await this.drainDiskWrite(deviceId, settings);
8251
+ } catch (err) {
8252
+ release();
8253
+ throw err;
8254
+ }
8255
+ return { release };
8256
+ }
8257
+ /** Cancel the timer, await the write in flight, then write (queued) what is not yet on disk. */
8258
+ async drainDiskWrite(deviceId, settings) {
8259
+ const slot = this.runtimeStateDebounce.get(deviceId);
8260
+ const pending = slot?.timer ?? null;
8261
+ if (slot && pending) {
8262
+ clearTimeout(pending);
8263
+ slot.timer = null;
8264
+ }
8265
+ if (slot?.inFlight) await slot.inFlight;
8266
+ const lastFailed = slot?.lastWriteFailed ?? false;
8267
+ if (!pending && !lastFailed) return;
8268
+ const blob = this.deps.persistableFor(deviceId);
8269
+ if (this.isAlreadyPersisted(deviceId, blob)) {
8270
+ if (slot) slot.lastWriteFailed = false;
8271
+ return;
8272
+ }
8273
+ if (lastFailed) this.refuseDrain(deviceId, "its last runtime-state write did not land", null);
8274
+ try {
8275
+ await this.serialiseWrite(deviceId, () => settings.writeDeviceRuntimeState(deviceId, blob));
8276
+ } catch (err) {
8277
+ this.refuseDrain(deviceId, `the pending runtime-state write failed: ${errorMessage$2(err)}`, err);
8278
+ }
8279
+ this.lastPersisted.set(deviceId, blob);
8280
+ }
8281
+ /** A drain that cannot vouch for the row: still owed (re-armed on release), logged, refused. */
8282
+ refuseDrain(deviceId, reason, cause) {
8283
+ this.heldDirty.add(deviceId);
8284
+ this.deps.logger.warn("migration drain: runtime state not on the row — migration refused", {
8285
+ tags: { deviceId },
8286
+ meta: { reason }
8287
+ });
8288
+ throw new RuntimeStateDrainError(deviceId, reason, cause === null ? void 0 : { cause });
7456
8289
  }
7457
8290
  /**
7458
8291
  * True when `blob` differs from what is believed to be on disk only in fields
@@ -7462,35 +8295,468 @@ var DeviceStateMirror = class DeviceStateMirror {
7462
8295
  isAlreadyPersisted(deviceId, blob) {
7463
8296
  const onDisk = this.lastPersisted.get(deviceId);
7464
8297
  if (!onDisk) return false;
7465
- if (!effectivelyEqual(onDisk, blob, this.policyFor)) return false;
8298
+ if (!effectivelyEqual(onDisk, blob, this.deps.policyFor)) return false;
7466
8299
  this.skippedSinceWrite.set(deviceId, (this.skippedSinceWrite.get(deviceId) ?? 0) + 1);
7467
8300
  return true;
7468
8301
  }
7469
- /** The device's mirror, restricted to the slices that may reach disk. */
7470
- persistableForDevice(deviceId) {
7471
- return persistableBlob(this.snapshotForDevice(deviceId), this.policyFor);
8302
+ /** Flush every pending debounced disk write (graceful shutdown). Clears the
8303
+ * debounce slots after awaiting in-flight + scheduled writes so shutdown is
8304
+ * lossless. */
8305
+ async flushPendingWrites(settings) {
8306
+ const pending = [];
8307
+ for (const [deviceId, slot] of this.runtimeStateDebounce) {
8308
+ if (slot.timer) {
8309
+ clearTimeout(slot.timer);
8310
+ slot.timer = null;
8311
+ if (settings) {
8312
+ pending.push(this.serialiseWrite(deviceId, () => this.writeLiveBlob(deviceId, settings, slot)));
8313
+ continue;
8314
+ }
8315
+ }
8316
+ if (slot.inFlight) pending.push(slot.inFlight);
8317
+ }
8318
+ await Promise.all(pending);
8319
+ this.runtimeStateDebounce.clear();
8320
+ }
8321
+ };
8322
+ //#endregion
8323
+ //#region src/builtins/device-manager/device-state-mirror.ts
8324
+ /**
8325
+ * Hub-side runtime-state mirror for the device-manager addon.
8326
+ *
8327
+ * `DeviceStateMirror` owns the per-device cap-keyed slice mirror and the
8328
+ * debounced disk-write coalescer. It mirrors every `deviceState.setCapSlice`
8329
+ * write, emits `DeviceStateChanged`, and coalesces frequent writes into one
8330
+ * `writeDeviceRuntimeState` per debounce window.
8331
+ *
8332
+ * It used to do one more thing: overlay each emitted slice with cross-device
8333
+ * LINKED values, walk a `linkDependents` reverse-index and re-emit for every
8334
+ * dependent target, guarded by a `lastEmittedOverlay` churn map. Wiring was
8335
+ * deleted on 2026-08-08 (0 links across 302 devices), so the mirror emits what
8336
+ * the provider wrote — one writer per field, which is what D62 asks for
8337
+ * everywhere else.
8338
+ *
8339
+ * ── Per-field claims (D663) ────────────────────────────────────────────────
8340
+ * A composition may CLAIM fields of a cap on an existing device. The provider
8341
+ * keeps writing; `stateMirror` keeps its slice as the NATIVE SHADOW, and what
8342
+ * is read and emitted for a claimed cap is the MERGE (`ClaimViews`). A device
8343
+ * with no claims never touches any of this: same slices, same events, same row.
8344
+ * Claims live in the device's own row under `$owned`, written THROUGH (rolled
8345
+ * back in memory when the write fails and nothing newer landed in its window);
8346
+ * owned values follow the native debounce and effective-change gate. Every row
8347
+ * write of a device — debounced, write-through, shutdown flush — goes through
8348
+ * ONE queue per device (`MirrorRowWriter`, `mirror-row-writer.ts`), so two
8349
+ * writes are never in flight at once and each carries the memory of the moment
8350
+ * it runs. The shadow reaches
8351
+ * the owner alone — `nativeSlice` and `DeviceNativeShadowChanged`, a category
8352
+ * the public routers refuse (D224).
8353
+ */
8354
+ var SEED_REFUSAL_MESSAGE = "runtime-state row unreadable — native write refused until it reads (D49)";
8355
+ function errorMessage$1(err) {
8356
+ return err instanceof Error ? err.message : String(err);
8357
+ }
8358
+ var DeviceStateMirror = class {
8359
+ ctx;
8360
+ policyFor;
8361
+ /**
8362
+ * Hub-side mirror of every device's cap-keyed NATIVE runtime state — what the
8363
+ * provider wrote. Key: deviceId. Value: per-cap slice map. Empty by default —
8364
+ * slices show up as `setCapSlice` calls trickle in.
8365
+ */
8366
+ stateMirror = /* @__PURE__ */ new Map();
8367
+ /** The claims and the merged view of every claimed cap (D663). */
8368
+ claims;
8369
+ /** Devices whose row has been consulted (or ruled empty by the index). */
8370
+ seeded = /* @__PURE__ */ new Set();
8371
+ /** Devices whose row was actually READ into memory — a write-through needs this. */
8372
+ rowRead = /* @__PURE__ */ new Set();
8373
+ /** One row read per device, however many writes arrive during it. */
8374
+ seeding = /* @__PURE__ */ new Map();
8375
+ /** Devices whose seed refusal is already logged — once per transition. */
8376
+ seedRefusalLogged = /* @__PURE__ */ new Set();
8377
+ /** Every write of a device's row, and what is believed to be on it. */
8378
+ writer;
8379
+ constructor(ctx, policyFor = runtimeStatePolicyFor, schemaFor = defaultSchemaFor) {
8380
+ this.ctx = ctx;
8381
+ this.policyFor = policyFor;
8382
+ this.claims = new ClaimViews({
8383
+ logger: ctx.logger,
8384
+ schemaFor,
8385
+ emitSlice: (deviceId, capName, slice) => this.emitStateChanged(deviceId, capName, slice)
8386
+ });
8387
+ this.writer = new MirrorRowWriter({
8388
+ logger: ctx.logger,
8389
+ policyFor,
8390
+ persistableFor: (deviceId) => this.persistableForDevice(deviceId)
8391
+ });
7472
8392
  }
7473
8393
  /**
7474
- * One-shot mirror seed used by `loadRuntimeState` at boot so the
7475
- * hub knows about every persisted slice without waiting for the
7476
- * first `setCapSlice` call. No events emitted — this is
7477
- * initial-state population, not a transition.
8394
+ * Single-cap mirror update — diff against the current mirror,
8395
+ * persist the new slice in-memory, emit `DeviceStateChanged` for
8396
+ * this cap. No-op on identical writes (both same shape and same
8397
+ * values). Called by `setCapSlice` provider.
8398
+ *
8399
+ * This is the NATIVE path, unchanged for every writer. Under a claim the
8400
+ * native slice is stored as the shadow and what is emitted is the merge.
7478
8401
  *
7479
- * Callers that must not carry a stale per-session probe across a
7480
- * restart pass the blob through `withResetSessionProbe` first (see
7481
- * `loadRuntimeState`).
8402
+ * @returns whether the mirror actually changed. The caller uses this to
8403
+ * decide whether the (synchronous, disk-blocking) runtime-state write is
8404
+ * worth scheduling — see `scheduleRuntimeStateDiskWrite`. Returning void
8405
+ * here and scheduling unconditionally is what made an unchanged poll cost
8406
+ * a SQLite commit.
7482
8407
  */
7483
- seedMirror(deviceId, blob) {
8408
+ applySingleCapUpdate(deviceId, capName, slice) {
7484
8409
  let perCap = this.stateMirror.get(deviceId);
7485
8410
  if (!perCap) {
7486
8411
  perCap = /* @__PURE__ */ new Map();
7487
8412
  this.stateMirror.set(deviceId, perCap);
7488
8413
  }
7489
- for (const [capName, raw] of Object.entries(blob)) {
7490
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
7491
- perCap.set(capName, { ...raw });
8414
+ const prior = perCap.get(capName);
8415
+ if (prior && shallowEqual(prior, slice)) return false;
8416
+ perCap.set(capName, { ...slice });
8417
+ const claim = this.claims.claim(deviceId, capName);
8418
+ if (claim) {
8419
+ if (claim.mode === "replace") this.emitNativeShadow(deviceId, capName, { ...slice });
8420
+ this.claims.recompute(deviceId, capName, claim, { ...slice }, true);
8421
+ return true;
8422
+ }
8423
+ this.emitStateChanged(deviceId, capName, { ...slice });
8424
+ return true;
8425
+ }
8426
+ /**
8427
+ * Claim `cap.fields` of `capName` on `deviceId` for `cap.owner`. Written
8428
+ * through: resolves after the row write. A re-claim by the same owner
8429
+ * replaces `fields`; values of fields no longer claimed are dropped and the
8430
+ * native shows for them (ruling S1b). An empty `fields` is what the composer
8431
+ * never sends — it releases — and is refused.
8432
+ */
8433
+ async claim(deviceId, capName, cap, settings) {
8434
+ if (cap.fields.length === 0) return refused("invalid-slice", "claim at least one field or release");
8435
+ const outside = Object.keys(cap.values).filter((k) => !cap.fields.includes(k));
8436
+ if (outside.length > 0) return refused("field-not-claimed", `values name fields outside the claim: ${outside.join(", ")}`);
8437
+ const unreadable = await this.readRowOrRefuse(deviceId, settings);
8438
+ if (unreadable) return unreadable;
8439
+ if (this.writer.isHeld(deviceId)) {
8440
+ this.ctx.logger.warn("claim refused — a migration holds the device row", {
8441
+ tags: { deviceId },
8442
+ meta: {
8443
+ cap: capName,
8444
+ owner: cap.owner
8445
+ }
8446
+ });
8447
+ return refused("migration-in-flight", `device ${deviceId} is being migrated — its row is about to be swapped; retry once the migration is over`);
8448
+ }
8449
+ const existing = this.claims.claim(deviceId, capName);
8450
+ if (existing && existing.owner !== cap.owner) return refused("owned-by-other", `${capName} on device ${deviceId} is claimed by ${existing.owner}`);
8451
+ const kept = {};
8452
+ if (existing) {
8453
+ for (const f of cap.fields) if (Object.hasOwn(existing.values, f)) kept[f] = existing.values[f];
8454
+ }
8455
+ const next = {
8456
+ owner: cap.owner,
8457
+ mode: cap.mode,
8458
+ fields: [...cap.fields],
8459
+ values: {
8460
+ ...kept,
8461
+ ...cap.values
8462
+ }
8463
+ };
8464
+ if (existing && sameOwnedCap(existing, next) && this.isClaimPersisted(deviceId, capName, next)) return {
8465
+ ok: true,
8466
+ changed: false
8467
+ };
8468
+ const prevView = this.claims.view(deviceId, capName);
8469
+ this.claims.setClaim(deviceId, capName, next);
8470
+ this.claims.previewView(deviceId, capName, next, this.nativeOf(deviceId, capName));
8471
+ await this.writeThrough(deviceId, settings, {
8472
+ op: "claim",
8473
+ cap: capName,
8474
+ owner: cap.owner
8475
+ }, () => {
8476
+ if (this.claims.claim(deviceId, capName) !== next) return false;
8477
+ if (existing) {
8478
+ this.claims.setClaim(deviceId, capName, existing);
8479
+ this.claims.restoreView(deviceId, capName, prevView);
8480
+ } else this.claims.deleteClaim(deviceId, capName);
8481
+ return true;
8482
+ });
8483
+ const current = this.claims.claim(deviceId, capName);
8484
+ if (current) {
8485
+ this.claims.restoreView(deviceId, capName, prevView);
8486
+ this.claims.recompute(deviceId, capName, current, this.nativeOf(deviceId, capName), false);
7492
8487
  }
7493
- this.lastPersisted.set(deviceId, persistableBlob(this.snapshotForDevice(deviceId), this.policyFor));
8488
+ return {
8489
+ ok: true,
8490
+ changed: true
8491
+ };
8492
+ }
8493
+ /** Is this exact claim what the row carries, per `lastPersisted`? */
8494
+ isClaimPersisted(deviceId, capName, claim) {
8495
+ const onDisk = this.writer.persisted(deviceId)?.[OWNED_FIELDS_KEY]?.[capName];
8496
+ const expected = joinRuntimeBlob({}, new Map([[capName, claim]]), this.policyFor)[OWNED_FIELDS_KEY]?.[capName];
8497
+ if (!isPlainRecord(onDisk) || !isPlainRecord(expected)) return false;
8498
+ return sliceEffectivelyEqual(onDisk, expected, []);
8499
+ }
8500
+ /**
8501
+ * Write owned values. Partial patches MERGE into `values` (ruling S5); a key
8502
+ * outside `fields` is refused. Debounced like a native slice.
8503
+ */
8504
+ async patchOwned(deviceId, capName, owner, values, settings) {
8505
+ const unreadable = await this.readRowOrRefuse(deviceId, settings);
8506
+ if (unreadable) return unreadable;
8507
+ const existing = this.claims.claim(deviceId, capName);
8508
+ if (!existing) return refused("not-claimed", `${capName} on device ${deviceId} has no claim`);
8509
+ if (existing.owner !== owner) return refused("owned-by-other", `${capName} on device ${deviceId} is claimed by ${existing.owner}`);
8510
+ const outside = Object.keys(values).filter((k) => !existing.fields.includes(k));
8511
+ if (outside.length > 0) return refused("field-not-claimed", `not in the claim on ${capName}: ${outside.join(", ")}`);
8512
+ const next = {
8513
+ ...existing,
8514
+ values: {
8515
+ ...existing.values,
8516
+ ...values
8517
+ }
8518
+ };
8519
+ if (sameOwnedCap(existing, next)) return {
8520
+ ok: true,
8521
+ changed: false
8522
+ };
8523
+ this.claims.setClaim(deviceId, capName, next);
8524
+ this.claims.recompute(deviceId, capName, next, this.nativeOf(deviceId, capName), false);
8525
+ this.writer.schedule(deviceId, settings, capName);
8526
+ return {
8527
+ ok: true,
8528
+ changed: true
8529
+ };
8530
+ }
8531
+ /**
8532
+ * Remove the claim. A `replace` hands the cap back: the native slice is
8533
+ * emitted exactly as last written. An `add` had no native — the cap leaves
8534
+ * the mirror and nothing is emitted (consumer mirrors keep the last value
8535
+ * until they re-read; recorded for Task 15). Written through.
8536
+ */
8537
+ async release(deviceId, capName, owner, settings) {
8538
+ const unreadable = await this.readRowOrRefuse(deviceId, settings);
8539
+ if (unreadable) return unreadable;
8540
+ const existing = this.claims.claim(deviceId, capName);
8541
+ if (!existing) return refused("not-claimed", `${capName} on device ${deviceId} has no claim`);
8542
+ if (existing.owner !== owner) return refused("owned-by-other", `${capName} on device ${deviceId} is claimed by ${existing.owner}`);
8543
+ const prevView = this.claims.view(deviceId, capName);
8544
+ this.claims.deleteClaim(deviceId, capName);
8545
+ await this.writeThrough(deviceId, settings, {
8546
+ op: "release",
8547
+ cap: capName,
8548
+ owner
8549
+ }, () => {
8550
+ if (this.claims.claim(deviceId, capName) !== void 0) return false;
8551
+ this.claims.setClaim(deviceId, capName, existing);
8552
+ this.claims.restoreView(deviceId, capName, prevView);
8553
+ return true;
8554
+ });
8555
+ if (this.claims.claim(deviceId, capName) !== void 0) return {
8556
+ ok: true,
8557
+ changed: true
8558
+ };
8559
+ const native = this.nativeOf(deviceId, capName);
8560
+ if (existing.mode === "replace" && native) this.emitStateChanged(deviceId, capName, { ...native });
8561
+ else if (existing.mode === "replace") this.ctx.logger.debug("claim released before any native slice arrived — nothing to emit", {
8562
+ tags: { deviceId },
8563
+ meta: {
8564
+ cap: capName,
8565
+ owner
8566
+ }
8567
+ });
8568
+ return {
8569
+ ok: true,
8570
+ changed: true
8571
+ };
8572
+ }
8573
+ /** The claims on a device; empty for every device without one. */
8574
+ ownedCaps(deviceId) {
8575
+ return this.claims.ownedCaps(deviceId);
8576
+ }
8577
+ /** The NATIVE shadow of one cap — for the owner-side mechanism only (D224). */
8578
+ nativeSlice(deviceId, cap) {
8579
+ const raw = this.stateMirror.get(deviceId)?.get(cap);
8580
+ return raw ? { ...raw } : null;
8581
+ }
8582
+ /** What the merge says for a claimed cap; null when the cap is not claimed. */
8583
+ effectiveIfClaimed(deviceId, cap) {
8584
+ return this.claims.effective(deviceId, cap);
8585
+ }
8586
+ isSeeded(deviceId) {
8587
+ return this.seeded.has(deviceId);
8588
+ }
8589
+ /**
8590
+ * The SYNCHRONOUS half of {@link ensureSeeded}, branch (c): a LOADED claims
8591
+ * index that does not name the device vouches for it — seeded, no read.
8592
+ * Refused (false) while a read is in flight, because that read may be
8593
+ * landing a claim the index does not know yet; refused when the index cannot
8594
+ * vouch. The overlay's `peek` calls this instead of `ensureSeeded`: a sync
8595
+ * path must never hold a promise that can reject (D391 — an unhandled
8596
+ * rejection is an untagged line on hub-main).
8597
+ */
8598
+ seedIfIndexClears(deviceId, index) {
8599
+ if (this.seeded.has(deviceId)) return true;
8600
+ if (this.seeding.has(deviceId)) return false;
8601
+ if (index.state !== "loaded" || index.hasClaims(deviceId)) return false;
8602
+ this.seeded.add(deviceId);
8603
+ return true;
8604
+ }
8605
+ /**
8606
+ * Make sure the device's claims are known before a native write is applied
8607
+ * (D49). Index first, single-flight (ruling S6):
8608
+ * (a) already seeded → nothing;
8609
+ * (b) a seed in flight → await it;
8610
+ * (c) a LOADED index that does not name the device → seeded, no read;
8611
+ * (d) otherwise read the row once and seed it.
8612
+ * A read that throws propagates — the write is refused — and is logged once
8613
+ * per device transition (D391).
8614
+ */
8615
+ async ensureSeeded(deviceId, settings, index) {
8616
+ if (this.seeded.has(deviceId)) return;
8617
+ const inFlight = this.seeding.get(deviceId);
8618
+ if (inFlight) return inFlight;
8619
+ if (this.seedIfIndexClears(deviceId, index)) return;
8620
+ return this.readRowOnce(deviceId, settings);
8621
+ }
8622
+ /** A write-through replaces the whole row: memory must hold what the row held. */
8623
+ async ensureRowRead(deviceId, settings) {
8624
+ if (this.rowRead.has(deviceId)) return;
8625
+ const inFlight = this.seeding.get(deviceId);
8626
+ if (inFlight) return inFlight;
8627
+ return this.readRowOnce(deviceId, settings);
8628
+ }
8629
+ /** An unreadable row is a NAMED refusal (D49): the answer is unknown — a
8630
+ * throw could be read as "gone", `not-claimed` as "never was". Logged by `readRowOnce`. */
8631
+ async readRowOrRefuse(deviceId, settings) {
8632
+ try {
8633
+ await this.ensureRowRead(deviceId, settings);
8634
+ return null;
8635
+ } catch (err) {
8636
+ return refused("row-unreadable", `device ${deviceId}: ${errorMessage$1(err)}`);
8637
+ }
8638
+ }
8639
+ readRowOnce(deviceId, settings) {
8640
+ const read = (async () => {
8641
+ try {
8642
+ const row = await settings.readDeviceRuntimeState(deviceId);
8643
+ this.seedMirror(deviceId, this.withResetSessionProbe(row));
8644
+ this.seedRefusalLogged.delete(deviceId);
8645
+ } catch (err) {
8646
+ if (!this.seedRefusalLogged.has(deviceId)) {
8647
+ this.seedRefusalLogged.add(deviceId);
8648
+ this.ctx.logger.warn(SEED_REFUSAL_MESSAGE, {
8649
+ tags: { deviceId },
8650
+ meta: { error: errorMessage$1(err) }
8651
+ });
8652
+ }
8653
+ throw err;
8654
+ } finally {
8655
+ this.seeding.delete(deviceId);
8656
+ }
8657
+ })();
8658
+ this.seeding.set(deviceId, read);
8659
+ return read;
8660
+ }
8661
+ nativeOf(deviceId, capName) {
8662
+ return this.stateMirror.get(deviceId)?.get(capName) ?? null;
8663
+ }
8664
+ /**
8665
+ * Write the row now — a claim is atomic with the row — and refresh the
8666
+ * baseline. Queued behind any write in flight for the device (one row write
8667
+ * at a time, N-4), and the blob is taken when the write RUNS, so it carries
8668
+ * whatever memory holds by then. A failed write is rolled back in memory IF
8669
+ * what this op put there is still there (the two never disagree); when a
8670
+ * newer same-owner state landed in the window it stands, and the debounced
8671
+ * writer converges the row to it. Either way the failure is logged hub-side
8672
+ * (D391) and rethrown; a repeat of the same claim is then a real write, never
8673
+ * `changed: false`, and consumers are handed the truth that stands.
8674
+ */
8675
+ async writeThrough(deviceId, settings, op, rollback) {
8676
+ let blob;
8677
+ try {
8678
+ blob = await this.writer.serialiseWrite(deviceId, async () => {
8679
+ const live = this.persistableForDevice(deviceId);
8680
+ await settings.writeDeviceRuntimeState(deviceId, live);
8681
+ return live;
8682
+ });
8683
+ } catch (err) {
8684
+ const extras = {
8685
+ tags: { deviceId },
8686
+ meta: {
8687
+ cap: op.cap,
8688
+ owner: op.owner,
8689
+ error: errorMessage$1(err)
8690
+ }
8691
+ };
8692
+ if (rollback()) {
8693
+ this.ctx.logger.warn(`${op.op} write-through failed — ${op.op} not recorded, memory rolled back`, extras);
8694
+ this.reemitAfterRollback(deviceId, op.cap);
8695
+ } else {
8696
+ this.ctx.logger.warn(`${op.op} write-through failed — a newer state of the claim stands, memory kept; the row converges through the writer`, extras);
8697
+ this.writer.armOrHold(deviceId, settings);
8698
+ }
8699
+ throw err;
8700
+ }
8701
+ this.writer.markPersisted(deviceId, blob);
8702
+ }
8703
+ /** After a rollback, re-emit what is true now: the restored claim's merge, or the native. */
8704
+ reemitAfterRollback(deviceId, capName) {
8705
+ const claim = this.claims.claim(deviceId, capName);
8706
+ const native = this.nativeOf(deviceId, capName);
8707
+ if (claim) {
8708
+ this.claims.recompute(deviceId, capName, claim, native, true);
8709
+ return;
8710
+ }
8711
+ if (native) this.emitStateChanged(deviceId, capName, { ...native });
8712
+ }
8713
+ /** Arm the debounced writer for a change to `changedCap` — see `MirrorRowWriter.schedule`. */
8714
+ scheduleRuntimeStateDiskWrite(deviceId, settings, changedCap) {
8715
+ this.writer.schedule(deviceId, settings, changedCap);
8716
+ }
8717
+ /** Hold the writer off these rows for a migration — see `MirrorRowWriter.holdDiskWrites`. */
8718
+ holdDiskWrites(deviceIds, settings) {
8719
+ return this.writer.holdDiskWrites(deviceIds, settings);
8720
+ }
8721
+ /** Flush every pending debounced disk write (graceful shutdown). */
8722
+ flushPendingWrites(settings) {
8723
+ return this.writer.flushPendingWrites(settings);
8724
+ }
8725
+ /** The device's row as it may reach disk: NATIVE slices plus `$owned`, restricted by durability. */
8726
+ persistableForDevice(deviceId) {
8727
+ return persistableBlob(joinRuntimeBlob(this.nativeSnapshotForDevice(deviceId), this.claims.ownedCaps(deviceId), this.policyFor), this.policyFor);
8728
+ }
8729
+ /**
8730
+ * One-shot mirror seed used by `loadRuntimeState` at boot so the hub knows
8731
+ * every persisted slice before the first `setCapSlice`. No events emitted —
8732
+ * population, not a transition. `fill` (default) fills GAPS and never
8733
+ * overwrites memory (ruling S6: a slice in memory is at least as new as the
8734
+ * row, a claim in memory was written through); `replace` (the migration
8735
+ * reseed) drops the device's memory first — the row now belongs to different
8736
+ * hardware. Malformed `$owned` entries are dropped by name and logged once.
8737
+ * Callers pass the blob through `withResetSessionProbe` first.
8738
+ */
8739
+ seedMirror(deviceId, blob, mode = "fill") {
8740
+ const split = splitRuntimeBlob(blob);
8741
+ if (mode === "replace") {
8742
+ this.stateMirror.delete(deviceId);
8743
+ this.claims.clearDevice(deviceId);
8744
+ }
8745
+ let perCap = this.stateMirror.get(deviceId);
8746
+ if (!perCap) {
8747
+ perCap = /* @__PURE__ */ new Map();
8748
+ this.stateMirror.set(deviceId, perCap);
8749
+ }
8750
+ for (const [capName, raw] of Object.entries(split.native)) if (!perCap.has(capName)) perCap.set(capName, { ...raw });
8751
+ this.claims.seedClaims(deviceId, split.owned);
8752
+ if (split.dropped.length > 0) this.ctx.logger.warn("malformed $owned entries on the runtime-state row dropped by name", {
8753
+ tags: { deviceId },
8754
+ meta: { caps: split.dropped }
8755
+ });
8756
+ for (const [capName, cap] of this.claims.claimsOf(deviceId)) this.claims.seedView(deviceId, capName, cap, this.nativeOf(deviceId, capName));
8757
+ this.seeded.add(deviceId);
8758
+ this.rowRead.add(deviceId);
8759
+ this.writer.markPersisted(deviceId, persistableBlob(joinRuntimeBlob(split.native, split.owned, this.policyFor), this.policyFor));
7494
8760
  }
7495
8761
  /**
7496
8762
  * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
@@ -7559,26 +8825,47 @@ var DeviceStateMirror = class DeviceStateMirror {
7559
8825
  if (!raw) return true;
7560
8826
  return (typeof raw.lastProbedAt === "number" ? raw.lastProbedAt : 0) > 0;
7561
8827
  }
8828
+ /** The EFFECTIVE slices of a device: native for an unclaimed cap, the merge
8829
+ * for a claimed one (a held cap is not reported). */
7562
8830
  snapshotForDevice(deviceId) {
7563
- const perCap = this.stateMirror.get(deviceId);
7564
- if (!perCap) return {};
7565
8831
  const out = {};
7566
- for (const [k, v] of perCap) out[k] = { ...v };
8832
+ for (const [k, v] of this.stateMirror.get(deviceId) ?? []) {
8833
+ if (this.claims.isClaimed(deviceId, k)) continue;
8834
+ out[k] = { ...v };
8835
+ }
8836
+ for (const [k, v] of this.claims.visibleCaps(deviceId)) out[k] = { ...v };
7567
8837
  return out;
7568
8838
  }
7569
- /** One cap's mirrored slice, cloned. Null when the device has never written
7570
- * it — callers read that as "the cap has no state", never as an empty slice. */
8839
+ /** One cap's EFFECTIVE slice, cloned. Null when the device has never written
8840
+ * it — callers read that as "the cap has no state", never as an empty slice —
8841
+ * and null while a claim holds it. */
7571
8842
  capSlice(deviceId, cap) {
8843
+ const visible = this.claims.visible(deviceId, cap);
8844
+ if (visible !== void 0) return visible ? { ...visible } : null;
7572
8845
  const raw = this.stateMirror.get(deviceId)?.get(cap);
7573
8846
  return raw ? { ...raw } : null;
7574
8847
  }
8848
+ /** The device's NATIVE slices — what the row stores under the cap names. */
8849
+ nativeSnapshotForDevice(deviceId) {
8850
+ const perCap = this.stateMirror.get(deviceId);
8851
+ if (!perCap) return {};
8852
+ const out = {};
8853
+ for (const [k, v] of perCap) out[k] = { ...v };
8854
+ return out;
8855
+ }
7575
8856
  /** Whole-system mirror dump. Backs `deviceState.getAllSnapshots` — one
7576
8857
  * round-trip for a warm boot instead of N per-device calls. */
7577
8858
  allSnapshots() {
7578
8859
  const out = {};
7579
- for (const deviceId of this.stateMirror.keys()) out[String(deviceId)] = this.snapshotForDevice(deviceId);
8860
+ const deviceIds = new Set([...this.stateMirror.keys(), ...this.claims.deviceIds()]);
8861
+ for (const deviceId of deviceIds) out[String(deviceId)] = this.snapshotForDevice(deviceId);
7580
8862
  return out;
7581
8863
  }
8864
+ /**
8865
+ * The PUBLIC event: exactly `{ deviceId, capName, slice }` for every device,
8866
+ * claimed or not. The native shadow never rides here (D224) — see
8867
+ * `emitNativeShadow`.
8868
+ */
7582
8869
  emitStateChanged(deviceId, capName, slice) {
7583
8870
  this.ctx.eventBus.emit({
7584
8871
  id: randomUUID(),
@@ -7595,29 +8882,194 @@ var DeviceStateMirror = class DeviceStateMirror {
7595
8882
  }
7596
8883
  });
7597
8884
  }
7598
- /** Flush every pending debounced disk write (graceful shutdown). Clears the
7599
- * debounce slots after awaiting in-flight + scheduled writes so shutdown is
7600
- * lossless. */
7601
- async flushPendingWrites(settings) {
7602
- const pending = [];
7603
- for (const [deviceId, slot] of this.runtimeStateDebounce) {
7604
- if (slot.timer) {
7605
- clearTimeout(slot.timer);
7606
- slot.timer = null;
7607
- const blob = settings ? this.persistableForDevice(deviceId) : null;
7608
- if (settings && blob !== null && !this.isAlreadyPersisted(deviceId, blob)) pending.push(settings.writeDeviceRuntimeState(deviceId, blob).then(() => {
7609
- this.lastPersisted.set(deviceId, blob);
7610
- }).catch((err) => {
7611
- this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
7612
- tags: { deviceId },
7613
- meta: { error: err instanceof Error ? err.message : String(err) }
7614
- });
7615
- }));
8885
+ /**
8886
+ * The owner-only event (D224, D663): the native slice as the provider wrote
8887
+ * it, for the composer's native self-reads. Its category is refused by the
8888
+ * public event routers (`isOwnerOnlyEventCategory`), so no UI or share-scope
8889
+ * subscriber ever sees a second truth beside the merged slice.
8890
+ */
8891
+ emitNativeShadow(deviceId, capName, native) {
8892
+ this.ctx.eventBus.emit({
8893
+ id: randomUUID(),
8894
+ timestamp: /* @__PURE__ */ new Date(),
8895
+ source: {
8896
+ type: "device",
8897
+ id: deviceId
8898
+ },
8899
+ category: EventCategory.DeviceNativeShadowChanged,
8900
+ data: {
8901
+ deviceId,
8902
+ capName,
8903
+ native
7616
8904
  }
7617
- if (slot.inFlight) pending.push(slot.inFlight);
8905
+ });
8906
+ }
8907
+ };
8908
+ //#endregion
8909
+ //#region src/builtins/device-manager/field-claims-index.ts
8910
+ /** The addon-store key the index lives under. */
8911
+ var FIELD_CLAIMS_INDEX_KEY = "field-claims-index";
8912
+ function keyOf(key) {
8913
+ return `${key.deviceId}/${key.capName}`;
8914
+ }
8915
+ function errorMessage(err) {
8916
+ return err instanceof Error ? err.message : String(err);
8917
+ }
8918
+ var FieldClaimsIndex = class {
8919
+ deps;
8920
+ /** The loaded entries by key, or null until a load has succeeded. */
8921
+ entries = null;
8922
+ /** Devices with at least one entry — what the seed asks, per native write. */
8923
+ claimedDevices = /* @__PURE__ */ new Set();
8924
+ /** Why the last load did not succeed. */
8925
+ notLoadedReason = "not loaded yet";
8926
+ /** One load in flight at a time; concurrent callers join it. */
8927
+ loading = null;
8928
+ constructor(deps) {
8929
+ this.deps = deps;
8930
+ }
8931
+ /**
8932
+ * Load the blob. Resolves `true` once the index is loaded (now or already);
8933
+ * `false` when the store could not answer — the reason is kept for `list`.
8934
+ * Never throws: an index that cannot load is a state, not a failure.
8935
+ */
8936
+ load(settings) {
8937
+ if (this.entries !== null) return Promise.resolve(true);
8938
+ if (this.loading) return this.loading;
8939
+ const run = this.readBlob(settings).finally(() => {
8940
+ this.loading = null;
8941
+ });
8942
+ this.loading = run;
8943
+ return run;
8944
+ }
8945
+ /** What the seed consults: loaded with a device predicate, or not loaded. */
8946
+ view() {
8947
+ if (this.entries === null) return { state: "not-loaded" };
8948
+ const claimed = this.claimedDevices;
8949
+ return {
8950
+ state: "loaded",
8951
+ hasClaims: (deviceId) => claimed.has(deviceId)
8952
+ };
8953
+ }
8954
+ /** Every entry, optionally those whose owner starts with `ownerPrefix`; or why there is no answer. */
8955
+ list(ownerPrefix) {
8956
+ if (this.entries === null) return {
8957
+ state: "not-loaded",
8958
+ reason: this.notLoadedReason
8959
+ };
8960
+ return {
8961
+ state: "loaded",
8962
+ claims: [...this.entries.values()].filter((c) => ownerPrefix === void 0 || c.owner.startsWith(ownerPrefix))
8963
+ };
8964
+ }
8965
+ /** The entry for a key, when the index is loaded and has one. */
8966
+ entry(key) {
8967
+ return this.entries?.get(keyOf(key));
8968
+ }
8969
+ /** Add or replace one entry — written before the row it announces. Throws when the index cannot be loaded or written. */
8970
+ async upsert(claim, settings) {
8971
+ await this.mutate(settings, claim.deviceId, (current) => {
8972
+ const next = new Map(current);
8973
+ next.set(keyOf(claim), {
8974
+ ...claim,
8975
+ fields: [...claim.fields]
8976
+ });
8977
+ return next;
8978
+ });
8979
+ }
8980
+ /** Remove one entry — written after the row no longer carries the claim. No write when absent. */
8981
+ async remove(key, settings) {
8982
+ await this.mutate(settings, key.deviceId, (current) => {
8983
+ if (!current.has(keyOf(key))) return null;
8984
+ const next = new Map(current);
8985
+ next.delete(keyOf(key));
8986
+ return next;
8987
+ });
8988
+ }
8989
+ /**
8990
+ * Drop every entry of a removed device (condition i). Best effort: an index
8991
+ * that cannot be loaded or written keeps a stale entry, which costs the next
8992
+ * seed of that number one row read and is removed by the next release that
8993
+ * answers `not-claimed` — so it is logged, never thrown, and the removal
8994
+ * proceeds.
8995
+ */
8996
+ async pruneDevice(deviceId, settings) {
8997
+ try {
8998
+ await this.mutate(settings, deviceId, (current) => {
8999
+ const kept = [...current].filter(([, c]) => c.deviceId !== deviceId);
9000
+ return kept.length === current.size ? null : new Map(kept);
9001
+ });
9002
+ } catch (err) {
9003
+ this.deps.logger.warn("claims index not pruned for a removed device — a stale entry stays until the next release", {
9004
+ tags: { deviceId },
9005
+ meta: { error: errorMessage(err) }
9006
+ });
7618
9007
  }
7619
- await Promise.all(pending);
7620
- this.runtimeStateDebounce.clear();
9008
+ }
9009
+ /**
9010
+ * Read-modify-write of the whole blob under the addon-store lock, from the
9011
+ * LOADED entries: the index is never written from a partial picture. `next`
9012
+ * returning null means nothing to write.
9013
+ */
9014
+ async mutate(settings, deviceId, next) {
9015
+ await this.deps.withAddonStoreWriteLock(async () => {
9016
+ if (!await this.load(settings)) throw new Error(`claims index not loaded (${this.notLoadedReason}) — refusing to write it blind`);
9017
+ const updated = next(this.entries ?? /* @__PURE__ */ new Map());
9018
+ if (updated === null) return;
9019
+ try {
9020
+ await settings.writeAddonStore({ [FIELD_CLAIMS_INDEX_KEY]: [...updated.values()] });
9021
+ } catch (err) {
9022
+ this.deps.logger.warn("claims index write failed — index unchanged", {
9023
+ tags: { deviceId },
9024
+ meta: { error: errorMessage(err) }
9025
+ });
9026
+ throw err;
9027
+ }
9028
+ this.install(updated);
9029
+ });
9030
+ }
9031
+ install(entries) {
9032
+ this.entries = entries;
9033
+ this.claimedDevices = new Set([...entries.values()].map((c) => c.deviceId));
9034
+ }
9035
+ /** The three-way read (D651): a failed read is a REASON, never an empty index. */
9036
+ async readBlob(settings) {
9037
+ if (settings.readAddonStoreResult === void 0) {
9038
+ this.notLoadedReason = "this settings view predates the three-way addon-store read (D651)";
9039
+ return false;
9040
+ }
9041
+ let read;
9042
+ try {
9043
+ read = await settings.readAddonStoreResult();
9044
+ } catch (err) {
9045
+ read = {
9046
+ kind: "failed",
9047
+ error: errorMessage(err)
9048
+ };
9049
+ }
9050
+ if (read.kind === "failed") {
9051
+ this.notLoadedReason = `addon store unreadable: ${read.error}`;
9052
+ this.deps.logger.warn("claims index not loaded — every seed reads its row until it is", { meta: { error: read.error } });
9053
+ return false;
9054
+ }
9055
+ const raw = read.kind === "value" ? read.value[FIELD_CLAIMS_INDEX_KEY] : void 0;
9056
+ if (raw !== void 0 && !Array.isArray(raw)) return this.refuseMalformed(`claims index blob malformed: not a list (${typeof raw})`);
9057
+ const entries = /* @__PURE__ */ new Map();
9058
+ const dropped = [];
9059
+ for (const [i, item] of (raw ?? []).entries()) {
9060
+ const parsed = FieldClaimSchema.safeParse(item);
9061
+ if (parsed.success) entries.set(keyOf(parsed.data), parsed.data);
9062
+ else dropped.push(i);
9063
+ }
9064
+ if (dropped.length > 0) return this.refuseMalformed(`claims index blob malformed at positions ${dropped.join(", ")} — repair the '${FIELD_CLAIMS_INDEX_KEY}' key of the device-manager store`);
9065
+ this.install(entries);
9066
+ return true;
9067
+ }
9068
+ /** Not loaded, by name: the index stays unknown and says why. */
9069
+ refuseMalformed(reason) {
9070
+ this.notLoadedReason = reason;
9071
+ this.deps.logger.warn("claims index not loaded — every seed reads its row until it is", { meta: { reason } });
9072
+ return false;
7621
9073
  }
7622
9074
  };
7623
9075
  //#endregion
@@ -7691,6 +9143,9 @@ function createMigrationGuard(now = Date.now) {
7691
9143
  inFlight.delete(sourceId);
7692
9144
  inFlight.delete(targetId);
7693
9145
  },
9146
+ isInFlight(deviceId) {
9147
+ return inFlight.has(deviceId);
9148
+ },
7694
9149
  recordCompleted(record) {
7695
9150
  completed.set(pairKey(record.sourceId, record.targetId), {
7696
9151
  ...record,
@@ -7794,6 +9249,13 @@ var DeviceManagerAddon = class extends BaseAddon {
7794
9249
  */
7795
9250
  stateMirrorImpl = null;
7796
9251
  /**
9252
+ * Removes the claimed-status overlay this addon installed on the capability
9253
+ * registry (D663, S3): `getStatus` of a claimed cap answers the mirror for
9254
+ * every caller while it is installed. Run in `onShutdown`; `null` before
9255
+ * init or when the kernel handed the addon no registry.
9256
+ */
9257
+ uninstallStatusOverlay = null;
9258
+ /**
7797
9259
  * Cross-process native-provider cache: deviceId (numeric) → capName → { addonId, nodeId }.
7798
9260
  * Kept in sync with `DeviceBindingsChanged` push events emitted by forked
7799
9261
  * workers on `ctx.registerNativeCap` / device removal. Union'd into
@@ -7830,6 +9292,18 @@ var DeviceManagerAddon = class extends BaseAddon {
7830
9292
  if (!this.stateMirrorImpl) throw new Error("DeviceManagerAddon: state mirror accessed before onInitialize");
7831
9293
  return this.stateMirrorImpl;
7832
9294
  }
9295
+ /**
9296
+ * The per-field claims index (D663): which devices carry a claim, for the
9297
+ * seed and for `listClaims`. Constructed at init and loaded before the first
9298
+ * provider method can run; a load that fails leaves it `not-loaded`, and
9299
+ * every seed reads its row until a later call loads it.
9300
+ */
9301
+ fieldClaimsImpl = null;
9302
+ /** The claims index, asserted present (constructed in onInitialize). */
9303
+ get fieldClaims() {
9304
+ if (!this.fieldClaimsImpl) throw new Error("DeviceManagerAddon: claims index accessed before onInitialize");
9305
+ return this.fieldClaimsImpl;
9306
+ }
7833
9307
  /** Build the `ProviderHost` the extracted provider-method modules
7834
9308
  * (`device-meta-actions`, `device-queries`) reach — exposes the addon's
7835
9309
  * private state/methods they need without leaking the class internals.
@@ -7838,14 +9312,20 @@ var DeviceManagerAddon = class extends BaseAddon {
7838
9312
  * reference preserves the live semantics. */
7839
9313
  get providerHost() {
7840
9314
  const capabilityRegistry = () => this.capabilityRegistry;
9315
+ const fieldClaims = () => this.fieldClaims;
7841
9316
  return {
7842
9317
  ctx: this.ctx,
7843
9318
  get capabilityRegistry() {
7844
9319
  return capabilityRegistry();
7845
9320
  },
7846
9321
  remoteNativeCaps: this.remoteNativeCaps,
7847
- seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
9322
+ seedMirror: (deviceId, blob, mode) => this.stateMirror.seedMirror(deviceId, blob, mode),
7848
9323
  withResetSessionProbe: (blob) => this.stateMirror.withResetSessionProbe(blob),
9324
+ get fieldClaims() {
9325
+ return fieldClaims();
9326
+ },
9327
+ holdMirrorWrites: (deviceIds, settings) => this.stateMirror.holdDiskWrites(deviceIds, settings),
9328
+ mirrorOwnedCaps: (deviceId) => this.stateMirror.ownedCaps(deviceId),
7849
9329
  resolveDeviceOnline: (deviceId, fallbackOnline) => this.stateMirror.resolveDeviceOnline(deviceId, fallbackOnline),
7850
9330
  resolveDeviceProbed: (deviceId) => this.stateMirror.resolveDeviceProbed(deviceId),
7851
9331
  waitDeviceProvider: (addonId, timeoutMs) => this.waitDeviceProvider(addonId, timeoutMs),
@@ -8013,6 +9493,19 @@ var DeviceManagerAddon = class extends BaseAddon {
8013
9493
  const metaStore = new DeviceMetaStore(settings, registry, deviceRows, this.addonStoreWriteLock);
8014
9494
  this.stateMirrorImpl = new DeviceStateMirror(this.ctx);
8015
9495
  const stateMirror = this.stateMirrorImpl;
9496
+ this.fieldClaimsImpl = new FieldClaimsIndex({
9497
+ logger: this.ctx.logger.child("claims-index"),
9498
+ withAddonStoreWriteLock: this.addonStoreWriteLock
9499
+ });
9500
+ const fieldClaims = this.fieldClaimsImpl;
9501
+ await fieldClaims.load(settings);
9502
+ const registryForOverlay = this.capabilityRegistry;
9503
+ this.uninstallStatusOverlay = registryForOverlay !== void 0 ? installClaimedStatusOverlay(registryForOverlay, {
9504
+ mirror: stateMirror,
9505
+ claimsIndex: () => fieldClaims.view(),
9506
+ settings: () => settings,
9507
+ logger: this.ctx.logger.child("claimed-status")
9508
+ }) : null;
8016
9509
  const resolvePersistedById = metaStore.resolvePersistedById;
8017
9510
  const idToAddonId = metaStore.idToAddonId;
8018
9511
  const bootRows = await deviceRows.listAll();
@@ -8225,6 +9718,34 @@ var DeviceManagerAddon = class extends BaseAddon {
8225
9718
  this.propagator.start();
8226
9719
  this.ctx.logger.info("device-event-propagator started");
8227
9720
  }
9721
+ const deviceStateProvider = {
9722
+ getSnapshot: async (input) => {
9723
+ return stateMirror.snapshotForDevice(input.deviceId);
9724
+ },
9725
+ getCapSlice: async (input) => {
9726
+ return stateMirror.capSlice(input.deviceId, input.capName);
9727
+ },
9728
+ getNativeCapSlice: async (input) => {
9729
+ return stateMirror.nativeSlice(input.deviceId, input.capName);
9730
+ },
9731
+ getAllSnapshots: async () => {
9732
+ return stateMirror.allSnapshots();
9733
+ },
9734
+ setCapSlice: async (input) => {
9735
+ const { deviceId, capName, slice } = input;
9736
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
9737
+ if (!stateMirror.isSeeded(deviceId)) await stateMirror.ensureSeeded(deviceId, settings, fieldClaims.view());
9738
+ if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings, capName);
9739
+ },
9740
+ ...createDeviceStateClaimMethods({
9741
+ mirror: stateMirror,
9742
+ index: fieldClaims,
9743
+ settings,
9744
+ logger: this.ctx.logger.child("claims"),
9745
+ deviceExists: async (deviceId) => await resolvePersistedById(deviceId) !== null,
9746
+ migrationInFlight: (deviceId) => pctx.migrationGuard.isInFlight(deviceId)
9747
+ })
9748
+ };
8228
9749
  return [
8229
9750
  {
8230
9751
  capability: deviceManagerCapability,
@@ -8232,22 +9753,7 @@ var DeviceManagerAddon = class extends BaseAddon {
8232
9753
  },
8233
9754
  {
8234
9755
  capability: deviceStateCapability,
8235
- provider: {
8236
- getSnapshot: async (input) => {
8237
- return stateMirror.snapshotForDevice(input.deviceId);
8238
- },
8239
- getCapSlice: async (input) => {
8240
- return stateMirror.capSlice(input.deviceId, input.capName);
8241
- },
8242
- getAllSnapshots: async () => {
8243
- return stateMirror.allSnapshots();
8244
- },
8245
- setCapSlice: async (input) => {
8246
- const { deviceId, capName, slice } = input;
8247
- if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
8248
- if (stateMirror.applySingleCapUpdate(deviceId, capName, slice)) stateMirror.scheduleRuntimeStateDiskWrite(deviceId, settings, capName);
8249
- }
8250
- }
9756
+ provider: deviceStateProvider
8251
9757
  },
8252
9758
  {
8253
9759
  capability: deviceExtensionCapability,
@@ -8258,6 +9764,8 @@ var DeviceManagerAddon = class extends BaseAddon {
8258
9764
  async onShutdown() {
8259
9765
  this.propagator?.stop();
8260
9766
  this.propagator = null;
9767
+ this.uninstallStatusOverlay?.();
9768
+ this.uninstallStatusOverlay = null;
8261
9769
  await this.stateMirrorImpl?.flushPendingWrites(this.ctx.settings);
8262
9770
  }
8263
9771
  };