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