@camstack/system 1.1.22 → 1.1.23
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/builtins/device-manager/device-aggregation.d.ts +8 -1
- package/dist/builtins/device-manager/device-bindings-store.d.ts +3 -0
- package/dist/builtins/device-manager/device-link-cycle.d.ts +18 -0
- package/dist/builtins/device-manager/device-link-resolver.d.ts +20 -2
- package/dist/builtins/device-manager/device-manager.addon.d.ts +9 -2
- package/dist/builtins/device-manager/device-manager.addon.js +353 -52
- package/dist/builtins/device-manager/device-manager.addon.mjs +354 -53
- package/dist/builtins/device-manager/device-meta-store.d.ts +6 -0
- package/dist/builtins/device-manager/device-provider-context.d.ts +4 -0
- package/dist/builtins/device-manager/device-state-mirror.d.ts +13 -5
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalDeviceFingerprint } from "@camstack/types/node";
|
|
2
|
-
import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
|
|
2
|
+
import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
/**
|
|
@@ -504,6 +504,21 @@ async function getBindings(deps, input) {
|
|
|
504
504
|
});
|
|
505
505
|
seenCaps.add(entry.capName);
|
|
506
506
|
}
|
|
507
|
+
if (deps.devicesWithLinks?.has(input.deviceId)) {
|
|
508
|
+
const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
|
|
509
|
+
for (const link of row?.deviceLinks ?? []) {
|
|
510
|
+
const capName = link.target.cap;
|
|
511
|
+
if (seenCaps.has(capName)) continue;
|
|
512
|
+
entries.push({
|
|
513
|
+
capName,
|
|
514
|
+
kind: "linked",
|
|
515
|
+
providerAddonId: deps.ctx.id,
|
|
516
|
+
providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
|
|
517
|
+
nativeAddonId: ""
|
|
518
|
+
});
|
|
519
|
+
seenCaps.add(capName);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
507
522
|
return {
|
|
508
523
|
deviceId: input.deviceId,
|
|
509
524
|
entries
|
|
@@ -1166,11 +1181,47 @@ async function setWrapperActive(deps, input) {
|
|
|
1166
1181
|
});
|
|
1167
1182
|
}
|
|
1168
1183
|
/**
|
|
1184
|
+
* Build the wireable-cap entry for one capability definition: the status
|
|
1185
|
+
* schema's scalar leaf fields plus — for an item-array cap
|
|
1186
|
+
* (`status.itemArray`) — the per-item fields tagged `item: true` (a link
|
|
1187
|
+
* targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
|
|
1188
|
+
* descriptor so the UI can address items. Returns null when the cap exposes
|
|
1189
|
+
* nothing wireable (no status schema / no leaf fields).
|
|
1190
|
+
*/
|
|
1191
|
+
function wireableEntryForDef(capName, def) {
|
|
1192
|
+
const status = def.status;
|
|
1193
|
+
const schema = status?.schema;
|
|
1194
|
+
if (!schema) return null;
|
|
1195
|
+
const itemArray = status.itemArray;
|
|
1196
|
+
const fields = [...enumerateSchemaFields(schema), ...itemArray ? enumerateItemArrayFields(itemArray) : []].map((f) => ({
|
|
1197
|
+
path: f.path,
|
|
1198
|
+
kind: f.kind,
|
|
1199
|
+
...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
|
|
1200
|
+
...f.item === true ? { item: true } : {}
|
|
1201
|
+
}));
|
|
1202
|
+
if (fields.length === 0) return null;
|
|
1203
|
+
return {
|
|
1204
|
+
cap: capName,
|
|
1205
|
+
fields,
|
|
1206
|
+
...itemArray ? { itemArray: {
|
|
1207
|
+
path: itemArray.path,
|
|
1208
|
+
keyField: itemArray.keyField
|
|
1209
|
+
} } : {}
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1169
1213
|
* Per-device wireable-field catalog — the domain status fields a device's
|
|
1170
1214
|
* bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
|
|
1171
1215
|
* walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
|
|
1172
1216
|
* internal book-keeping, not wireable domain data), and enumerates each cap's
|
|
1173
|
-
* status schema leaf fields
|
|
1217
|
+
* status schema leaf fields (per-item fields included for item-array caps).
|
|
1218
|
+
*
|
|
1219
|
+
* `includeSynthesizable: true` (TARGET pickers only) additionally unions in
|
|
1220
|
+
* UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
|
|
1221
|
+
* marker) and whose `deviceTypes` admit this device's persisted type — so the
|
|
1222
|
+
* FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
|
|
1223
|
+
* authored before any binding exists. Default (absent/false) behavior is
|
|
1224
|
+
* byte-identical to the binding-driven catalog.
|
|
1174
1225
|
*/
|
|
1175
1226
|
async function getWireableFields(deps, input) {
|
|
1176
1227
|
const { deviceId } = input;
|
|
@@ -1184,20 +1235,20 @@ async function getWireableFields(deps, input) {
|
|
|
1184
1235
|
seen.add(entry.capName);
|
|
1185
1236
|
const def = reg.getDefinition(entry.capName);
|
|
1186
1237
|
if (!def || def.kind === "wrapper") continue;
|
|
1187
|
-
const
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1238
|
+
const wireable = wireableEntryForDef(entry.capName, def);
|
|
1239
|
+
if (wireable) caps.push(wireable);
|
|
1240
|
+
}
|
|
1241
|
+
if (input.includeSynthesizable === true) {
|
|
1242
|
+
const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
|
|
1243
|
+
if (deviceType !== void 0) for (const def of ALL_CAPABILITY_DEFINITIONS) {
|
|
1244
|
+
if (seen.has(def.name)) continue;
|
|
1245
|
+
if (def.scope !== "device" || def.kind === "wrapper") continue;
|
|
1246
|
+
if (def.status?.empty === void 0) continue;
|
|
1247
|
+
if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
|
|
1248
|
+
seen.add(def.name);
|
|
1249
|
+
const wireable = wireableEntryForDef(def.name, def);
|
|
1250
|
+
if (wireable) caps.push(wireable);
|
|
1251
|
+
}
|
|
1201
1252
|
}
|
|
1202
1253
|
return { caps };
|
|
1203
1254
|
}
|
|
@@ -1311,6 +1362,99 @@ var DeviceEventPropagator = class {
|
|
|
1311
1362
|
}
|
|
1312
1363
|
};
|
|
1313
1364
|
//#endregion
|
|
1365
|
+
//#region src/builtins/device-manager/device-link-cycle.ts
|
|
1366
|
+
function nodeKey(deviceId, cap) {
|
|
1367
|
+
return `${deviceId}:${cap}`;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Resolve a link source to its `(deviceId, cap)` node, or null when the
|
|
1371
|
+
* source is a literal (no device) or does not resolve to a known device.
|
|
1372
|
+
* Sibling FIELD sources resolve against the TARGET's container stableId
|
|
1373
|
+
* (its parent's, falling back to its own for a top-level target) — the same
|
|
1374
|
+
* rule `rebuildLinkDependents` / `resolveLinkedStatus` apply.
|
|
1375
|
+
*/
|
|
1376
|
+
function resolveSourceNode(link, targetRow, rowById, idByStableId) {
|
|
1377
|
+
const src = link.source;
|
|
1378
|
+
if (src.kind === "literal") return null;
|
|
1379
|
+
const wantedStableId = src.kind === "global" ? src.sourceStableId : `${targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId}-${src.sourceKey}`;
|
|
1380
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
1381
|
+
if (srcId === void 0) return null;
|
|
1382
|
+
return nodeKey(srcId, src.cap);
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
|
|
1386
|
+
* a dependency cycle. Returns the cycle as an ordered list of node keys
|
|
1387
|
+
* (first node repeated at the end) or null when the set is safe.
|
|
1388
|
+
*/
|
|
1389
|
+
function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
|
|
1390
|
+
const rows = Object.values(allMeta);
|
|
1391
|
+
const rowById = /* @__PURE__ */ new Map();
|
|
1392
|
+
const idByStableId = /* @__PURE__ */ new Map();
|
|
1393
|
+
for (const r of rows) {
|
|
1394
|
+
rowById.set(r.id, r);
|
|
1395
|
+
if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
|
|
1396
|
+
}
|
|
1397
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
1398
|
+
const addEdges = (row, links) => {
|
|
1399
|
+
for (const link of links) {
|
|
1400
|
+
const srcNode = resolveSourceNode(link, row, rowById, idByStableId);
|
|
1401
|
+
if (srcNode === null) continue;
|
|
1402
|
+
const tNode = nodeKey(row.id, link.target.cap);
|
|
1403
|
+
const list = dependsOn.get(tNode) ?? [];
|
|
1404
|
+
list.push(srcNode);
|
|
1405
|
+
dependsOn.set(tNode, list);
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1408
|
+
for (const r of rows) {
|
|
1409
|
+
const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
|
|
1410
|
+
if (links.length > 0) addEdges(r, links);
|
|
1411
|
+
}
|
|
1412
|
+
const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
|
|
1413
|
+
for (const root of roots) {
|
|
1414
|
+
const cycle = dfsFindCycle(root, dependsOn);
|
|
1415
|
+
if (cycle) return cycle;
|
|
1416
|
+
}
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
/** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
|
|
1420
|
+
* cycle path (closed — first node repeated last) or null. */
|
|
1421
|
+
function dfsFindCycle(root, dependsOn) {
|
|
1422
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
1423
|
+
const done = /* @__PURE__ */ new Set();
|
|
1424
|
+
const path = [];
|
|
1425
|
+
const stack = [{
|
|
1426
|
+
node: root,
|
|
1427
|
+
nextChild: 0
|
|
1428
|
+
}];
|
|
1429
|
+
onPath.add(root);
|
|
1430
|
+
path.push(root);
|
|
1431
|
+
while (stack.length > 0) {
|
|
1432
|
+
const frame = stack[stack.length - 1];
|
|
1433
|
+
const children = dependsOn.get(frame.node) ?? [];
|
|
1434
|
+
if (frame.nextChild < children.length) {
|
|
1435
|
+
const child = children[frame.nextChild];
|
|
1436
|
+
frame.nextChild += 1;
|
|
1437
|
+
if (onPath.has(child)) {
|
|
1438
|
+
const start = path.indexOf(child);
|
|
1439
|
+
return [...path.slice(start), child];
|
|
1440
|
+
}
|
|
1441
|
+
if (done.has(child)) continue;
|
|
1442
|
+
stack.push({
|
|
1443
|
+
node: child,
|
|
1444
|
+
nextChild: 0
|
|
1445
|
+
});
|
|
1446
|
+
onPath.add(child);
|
|
1447
|
+
path.push(child);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
stack.pop();
|
|
1451
|
+
onPath.delete(frame.node);
|
|
1452
|
+
path.pop();
|
|
1453
|
+
done.add(frame.node);
|
|
1454
|
+
}
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
//#endregion
|
|
1314
1458
|
//#region src/builtins/device-manager/device-meta-actions.ts
|
|
1315
1459
|
/**
|
|
1316
1460
|
* Device meta-mutation + persistence actions for the device-manager addon —
|
|
@@ -1872,6 +2016,8 @@ async function setChildLayout(pctx, input) {
|
|
|
1872
2016
|
*/
|
|
1873
2017
|
async function setDeviceLinks(pctx, input) {
|
|
1874
2018
|
const { deviceId, deviceLinks } = input;
|
|
2019
|
+
const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
|
|
2020
|
+
if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
|
|
1875
2021
|
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
1876
2022
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
1877
2023
|
if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
|
|
@@ -2194,13 +2340,15 @@ function buildLinkIndexes(entries) {
|
|
|
2194
2340
|
sourceDeviceId
|
|
2195
2341
|
});
|
|
2196
2342
|
targets.set(tKey, tList);
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2343
|
+
if (link.source.kind !== "literal") {
|
|
2344
|
+
const sKey = `${sourceDeviceId}:${link.source.cap}`;
|
|
2345
|
+
const sList = dependents.get(sKey) ?? [];
|
|
2346
|
+
sList.push({
|
|
2347
|
+
targetDeviceId,
|
|
2348
|
+
targetCap: link.target.cap
|
|
2349
|
+
});
|
|
2350
|
+
dependents.set(sKey, sList);
|
|
2351
|
+
}
|
|
2204
2352
|
}
|
|
2205
2353
|
return {
|
|
2206
2354
|
targets,
|
|
@@ -2315,6 +2463,15 @@ var DeviceMetaStore = class {
|
|
|
2315
2463
|
for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
|
|
2316
2464
|
return null;
|
|
2317
2465
|
};
|
|
2466
|
+
/** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
|
|
2467
|
+
* FULL stableId equals `sourceStableId`, regardless of parent container.
|
|
2468
|
+
* First match wins (stableIds are effectively unique cluster-wide — see the
|
|
2469
|
+
* `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
|
|
2470
|
+
* pre-read meta map so a multi-link resolve reads the store once. */
|
|
2471
|
+
resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
|
|
2472
|
+
for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
|
|
2473
|
+
return null;
|
|
2474
|
+
};
|
|
2318
2475
|
allocateNextDeviceId = async () => {
|
|
2319
2476
|
const current = (await this.readStore()).nextDeviceId ?? 1;
|
|
2320
2477
|
await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
|
|
@@ -2345,8 +2502,18 @@ var DeviceMetaStore = class {
|
|
|
2345
2502
|
const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
|
|
2346
2503
|
if (container === void 0) continue;
|
|
2347
2504
|
for (const link of targetMeta.deviceLinks ?? []) {
|
|
2348
|
-
|
|
2349
|
-
|
|
2505
|
+
const src = link.source;
|
|
2506
|
+
if (src.kind === "literal") {
|
|
2507
|
+
entries.push({
|
|
2508
|
+
targetDeviceId: targetId,
|
|
2509
|
+
link,
|
|
2510
|
+
sourceDeviceId: -1
|
|
2511
|
+
});
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
|
|
2515
|
+
expectedSources.add(wantedStableId);
|
|
2516
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
2350
2517
|
if (srcId === void 0) continue;
|
|
2351
2518
|
entries.push({
|
|
2352
2519
|
targetDeviceId: targetId,
|
|
@@ -2892,6 +3059,18 @@ async function testField(pctx, input) {
|
|
|
2892
3059
|
function isRecord(x) {
|
|
2893
3060
|
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
2894
3061
|
}
|
|
3062
|
+
/**
|
|
3063
|
+
* Resolve one link's transformed value. Field sources read via `readField`
|
|
3064
|
+
* (the caller supplies the sibling lookup — sync, e.g. the in-hub state
|
|
3065
|
+
* mirror); literal sources use their per-device constant and never consult
|
|
3066
|
+
* the reader. Returns `undefined` to skip the overlay (unreadable field).
|
|
3067
|
+
* Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
|
|
3068
|
+
* path) cannot use this helper for field sources — they branch on
|
|
3069
|
+
* `source.kind` directly.
|
|
3070
|
+
*/
|
|
3071
|
+
function resolveLinkValue(link, readField) {
|
|
3072
|
+
return applyTransform(link.source.kind === "literal" ? link.source.value : readField(link.source.cap, link.source.fieldPath), link.transform);
|
|
3073
|
+
}
|
|
2895
3074
|
/** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
|
|
2896
3075
|
* concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
|
|
2897
3076
|
* is a true `instanceof` guard rather than a cast. */
|
|
@@ -2923,19 +3102,88 @@ function fillNullableDefaults(schema, value) {
|
|
|
2923
3102
|
return out;
|
|
2924
3103
|
}
|
|
2925
3104
|
/**
|
|
3105
|
+
* Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
|
|
3106
|
+
* group as ONE item into the status' item array (see
|
|
3107
|
+
* `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
|
|
3108
|
+
* `keyField` (and `labelField`, when declared) set to the itemKey; an
|
|
3109
|
+
* EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
|
|
3110
|
+
* native fields a link doesn't touch are preserved. Each upserted item is
|
|
3111
|
+
* validated against `itemSchema` — an invalid item is skipped WITHOUT
|
|
3112
|
+
* discarding its valid siblings. Returns the new draft and whether anything
|
|
3113
|
+
* was applied. Pure + immutable (base array/items are never mutated).
|
|
3114
|
+
*/
|
|
3115
|
+
function upsertItemArrayLinks(draft, grouped, itemArray) {
|
|
3116
|
+
const arrRaw = getByPath(draft, itemArray.path);
|
|
3117
|
+
const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
|
|
3118
|
+
let touched = false;
|
|
3119
|
+
for (const [itemKey, group] of grouped) {
|
|
3120
|
+
const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
|
|
3121
|
+
const existing = idx >= 0 ? arr[idx] : null;
|
|
3122
|
+
let item = isRecord(existing) ? { ...existing } : {
|
|
3123
|
+
...itemArray.emptyItem,
|
|
3124
|
+
[itemArray.keyField]: itemKey,
|
|
3125
|
+
...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
|
|
3126
|
+
};
|
|
3127
|
+
let itemTouched = false;
|
|
3128
|
+
for (const { link, sourceValue } of group) {
|
|
3129
|
+
if (sourceValue === void 0) continue;
|
|
3130
|
+
item = setByPath(item, link.target.fieldPath, sourceValue);
|
|
3131
|
+
itemTouched = true;
|
|
3132
|
+
}
|
|
3133
|
+
if (!itemTouched) continue;
|
|
3134
|
+
const repaired = fillNullableDefaults(itemArray.itemSchema, item);
|
|
3135
|
+
const parsed = itemArray.itemSchema.safeParse(repaired);
|
|
3136
|
+
if (!parsed.success) continue;
|
|
3137
|
+
if (isRecord(parsed.data)) item = parsed.data;
|
|
3138
|
+
if (idx >= 0) arr[idx] = item;
|
|
3139
|
+
else arr.push(item);
|
|
3140
|
+
touched = true;
|
|
3141
|
+
}
|
|
3142
|
+
if (!touched) return {
|
|
3143
|
+
draft,
|
|
3144
|
+
touched: false
|
|
3145
|
+
};
|
|
3146
|
+
return {
|
|
3147
|
+
draft: setByPath(draft, itemArray.path, arr),
|
|
3148
|
+
touched: true
|
|
3149
|
+
};
|
|
3150
|
+
}
|
|
3151
|
+
/**
|
|
2926
3152
|
* Overlay transformed source values onto `base` by dot-path, then validate the
|
|
2927
3153
|
* result against the target cap's `statusSchema`. On validation failure the
|
|
2928
3154
|
* overlay is discarded and `base` is returned unchanged (a misconfigured link
|
|
2929
3155
|
* must never corrupt a cap response). Pure — all I/O happens in the caller.
|
|
3156
|
+
*
|
|
3157
|
+
* Item-array grouping (P2b): when the cap declares `status.itemArray`, links
|
|
3158
|
+
* carrying a `target.itemKey` are grouped per key and upserted as items into
|
|
3159
|
+
* the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
|
|
3160
|
+
* ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
|
|
3161
|
+
* scalar dot-path behavior unchanged. A link with an itemKey on a cap that
|
|
3162
|
+
* declares NO `itemArray` is dropped (its item-relative path must never be
|
|
3163
|
+
* scalar-applied to the status root).
|
|
2930
3164
|
*/
|
|
2931
|
-
function mergeLinkedStatus(base, resolved, statusSchema) {
|
|
3165
|
+
function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
|
|
2932
3166
|
let draft = base;
|
|
2933
3167
|
let touched = false;
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
3168
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3169
|
+
for (const entry of resolved) {
|
|
3170
|
+
const itemKey = entry.link.target.itemKey;
|
|
3171
|
+
if (itemKey !== void 0) {
|
|
3172
|
+
if (!itemArray) continue;
|
|
3173
|
+
const group = grouped.get(itemKey);
|
|
3174
|
+
if (group) group.push(entry);
|
|
3175
|
+
else grouped.set(itemKey, [entry]);
|
|
3176
|
+
continue;
|
|
3177
|
+
}
|
|
3178
|
+
if (entry.sourceValue === void 0) continue;
|
|
3179
|
+
draft = setByPath(draft, entry.link.target.fieldPath, entry.sourceValue);
|
|
2937
3180
|
touched = true;
|
|
2938
3181
|
}
|
|
3182
|
+
if (itemArray && grouped.size > 0) {
|
|
3183
|
+
const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
|
|
3184
|
+
draft = upserted.draft;
|
|
3185
|
+
touched = touched || upserted.touched;
|
|
3186
|
+
}
|
|
2939
3187
|
if (!touched) return base;
|
|
2940
3188
|
if (!statusSchema) return draft;
|
|
2941
3189
|
const repaired = fillNullableDefaults(statusSchema, draft);
|
|
@@ -3132,8 +3380,11 @@ var DeviceStateMirror = class DeviceStateMirror {
|
|
|
3132
3380
|
* Read-time overlay of a cap slice with its cross-device linked values.
|
|
3133
3381
|
* Returns a cloned raw mirror slice when the (device, cap) pair has no
|
|
3134
3382
|
* links. Sources are read from the same in-hub stateMirror — sync, no
|
|
3135
|
-
* cross-process call.
|
|
3136
|
-
*
|
|
3383
|
+
* cross-process call. When the raw slice is ABSENT but the cap is a link
|
|
3384
|
+
* target, the slice is SYNTHESIZED: the merge base is seeded from the
|
|
3385
|
+
* cap's declared `status.empty` default; a failed/empty synthesize
|
|
3386
|
+
* returns null (no phantom slices). The disk writer must NOT use this
|
|
3387
|
+
* method; it must persist raw provider truth via snapshotForDevice.
|
|
3137
3388
|
*/
|
|
3138
3389
|
overlayedSlice(deviceId, cap) {
|
|
3139
3390
|
const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
|
|
@@ -3141,34 +3392,50 @@ var DeviceStateMirror = class DeviceStateMirror {
|
|
|
3141
3392
|
if (!links || links.length === 0) return raw ? { ...raw } : null;
|
|
3142
3393
|
const resolved = links.map((rl) => ({
|
|
3143
3394
|
link: rl.link,
|
|
3144
|
-
sourceValue:
|
|
3395
|
+
sourceValue: resolveLinkValue(rl.link, (srcCap, fieldPath) => getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
|
|
3145
3396
|
}));
|
|
3146
|
-
const
|
|
3147
|
-
|
|
3397
|
+
const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
|
|
3398
|
+
const schema = capStatus?.schema;
|
|
3399
|
+
const empty = capStatus?.empty;
|
|
3400
|
+
const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
|
|
3401
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
|
|
3402
|
+
if (!raw && merged === base) return null;
|
|
3403
|
+
return merged;
|
|
3148
3404
|
}
|
|
3149
3405
|
/**
|
|
3150
3406
|
* Like snapshotForDevice but applies the device-link overlay per cap.
|
|
3151
3407
|
* Used exclusively by the device-state READ methods (getSnapshot,
|
|
3152
|
-
* getAllSnapshots) so callers see overlayed values.
|
|
3153
|
-
*
|
|
3408
|
+
* getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
|
|
3409
|
+
* link-target caps (no base mirror slice) are unioned into the iterated
|
|
3410
|
+
* key set so they appear at warm-load — `overlayedSlice` builds them from
|
|
3411
|
+
* the cap's `status.empty` (and drops them when nothing resolves). The
|
|
3412
|
+
* debounced disk writer must continue to call snapshotForDevice (raw truth).
|
|
3154
3413
|
*/
|
|
3155
3414
|
snapshotForDeviceOverlayed(deviceId) {
|
|
3156
3415
|
const perCap = this.stateMirror.get(deviceId);
|
|
3157
|
-
|
|
3416
|
+
const capNames = new Set(perCap ? perCap.keys() : []);
|
|
3417
|
+
const prefix = `${deviceId}:`;
|
|
3418
|
+
for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
|
|
3158
3419
|
const out = {};
|
|
3159
|
-
for (const capName of
|
|
3420
|
+
for (const capName of capNames) {
|
|
3160
3421
|
const s = this.overlayedSlice(deviceId, capName);
|
|
3161
3422
|
if (s) out[capName] = s;
|
|
3162
3423
|
}
|
|
3163
3424
|
return out;
|
|
3164
3425
|
}
|
|
3165
|
-
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3426
|
+
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3427
|
+
* Devices that only exist as link TARGETS (no mirror slice yet) are
|
|
3428
|
+
* included via the link reverse-index so their synthesized caps appear. */
|
|
3166
3429
|
allSnapshotsOverlayed() {
|
|
3430
|
+
const deviceIds = new Set(this.stateMirror.keys());
|
|
3431
|
+
for (const key of this.linkHost.linkTargets.keys()) {
|
|
3432
|
+
const id = Number(key.slice(0, key.indexOf(":")));
|
|
3433
|
+
if (Number.isFinite(id)) deviceIds.add(id);
|
|
3434
|
+
}
|
|
3167
3435
|
const out = {};
|
|
3168
|
-
for (const
|
|
3169
|
-
const dev =
|
|
3170
|
-
|
|
3171
|
-
out[String(deviceId)] = dev;
|
|
3436
|
+
for (const deviceId of deviceIds) {
|
|
3437
|
+
const dev = this.snapshotForDeviceOverlayed(deviceId);
|
|
3438
|
+
if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
|
|
3172
3439
|
}
|
|
3173
3440
|
return out;
|
|
3174
3441
|
}
|
|
@@ -3287,14 +3554,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
|
|
|
3287
3554
|
* `getProviderForDevice` (routes cross-process); merge is pure.
|
|
3288
3555
|
*/
|
|
3289
3556
|
async function resolveLinkedStatus(pctx, input) {
|
|
3290
|
-
const { deviceId, cap
|
|
3557
|
+
const { deviceId, cap } = input;
|
|
3291
3558
|
if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
|
|
3559
|
+
const inFlightKey = `${deviceId}:${cap}`;
|
|
3560
|
+
if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
|
|
3561
|
+
pctx.host.linkResolveInFlight.add(inFlightKey);
|
|
3562
|
+
try {
|
|
3563
|
+
return await resolveLinkedStatusInner(pctx, input);
|
|
3564
|
+
} finally {
|
|
3565
|
+
pctx.host.linkResolveInFlight.delete(inFlightKey);
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
/** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
|
|
3569
|
+
async function resolveLinkedStatusInner(pctx, input) {
|
|
3570
|
+
const { deviceId, cap, baseStatus } = input;
|
|
3292
3571
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
3293
3572
|
if (!persisted) return null;
|
|
3294
3573
|
const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
|
|
3295
3574
|
if (links.length === 0) return null;
|
|
3296
3575
|
const capRegistry = pctx.host.capabilityRegistry;
|
|
3297
|
-
const
|
|
3576
|
+
const capStatus = capRegistry?.getDefinition(cap)?.status;
|
|
3577
|
+
const schema = capStatus?.schema;
|
|
3298
3578
|
if (!schema) return null;
|
|
3299
3579
|
const allMeta = await pctx.metaStore.readMeta();
|
|
3300
3580
|
let containerStableId = persisted.stableId;
|
|
@@ -3305,11 +3585,19 @@ async function resolveLinkedStatus(pctx, input) {
|
|
|
3305
3585
|
}
|
|
3306
3586
|
const resolved = [];
|
|
3307
3587
|
for (const link of links) {
|
|
3308
|
-
const
|
|
3588
|
+
const src = link.source;
|
|
3589
|
+
if (src.kind === "literal") {
|
|
3590
|
+
resolved.push({
|
|
3591
|
+
link,
|
|
3592
|
+
sourceValue: applyTransform(src.value, link.transform)
|
|
3593
|
+
});
|
|
3594
|
+
continue;
|
|
3595
|
+
}
|
|
3596
|
+
const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
|
|
3309
3597
|
if (srcId === null) continue;
|
|
3310
3598
|
try {
|
|
3311
|
-
const srcProvider = capRegistry?.getProviderForDevice(
|
|
3312
|
-
const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0,
|
|
3599
|
+
const srcProvider = capRegistry?.getProviderForDevice(src.cap, srcId);
|
|
3600
|
+
const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, src.fieldPath);
|
|
3313
3601
|
resolved.push({
|
|
3314
3602
|
link,
|
|
3315
3603
|
sourceValue: applyTransform(raw, link.transform)
|
|
@@ -3321,14 +3609,18 @@ async function resolveLinkedStatus(pctx, input) {
|
|
|
3321
3609
|
capName: cap
|
|
3322
3610
|
},
|
|
3323
3611
|
meta: {
|
|
3324
|
-
sourceKey:
|
|
3612
|
+
sourceKey: src.kind === "global" ? src.sourceStableId : src.sourceKey,
|
|
3325
3613
|
error: err instanceof Error ? err.message : String(err)
|
|
3326
3614
|
}
|
|
3327
3615
|
});
|
|
3328
3616
|
}
|
|
3329
3617
|
}
|
|
3330
3618
|
if (resolved.length === 0) return null;
|
|
3331
|
-
|
|
3619
|
+
const synthesizing = !isRecord$1(baseStatus);
|
|
3620
|
+
const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
|
|
3621
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
|
|
3622
|
+
if (synthesizing && merged === base) return null;
|
|
3623
|
+
return merged;
|
|
3332
3624
|
}
|
|
3333
3625
|
//#endregion
|
|
3334
3626
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
@@ -3394,10 +3686,15 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3394
3686
|
linkTargets = /* @__PURE__ */ new Map();
|
|
3395
3687
|
/** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
|
|
3396
3688
|
linkDependents = /* @__PURE__ */ new Map();
|
|
3397
|
-
/** Expected source `stableId`s (`${container}-${sourceKey}`
|
|
3689
|
+
/** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
|
|
3690
|
+
* sources, the full `sourceStableId` for global sources) across all links,
|
|
3398
3691
|
* resolved or not — gates the `registerDevice` rebuild so only a registering
|
|
3399
3692
|
* device that IS a link source triggers a reindex (not every boot restore). */
|
|
3400
3693
|
expectedSourceStableIds = /* @__PURE__ */ new Set();
|
|
3694
|
+
/** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
|
|
3695
|
+
* defensive re-entrancy guard bounding link cycles at resolve time. Mutated
|
|
3696
|
+
* in place (never reassigned), so the host exposes a direct reference. */
|
|
3697
|
+
linkResolveInFlight = /* @__PURE__ */ new Set();
|
|
3401
3698
|
/** Test/diagnostic accessors. */
|
|
3402
3699
|
linkTargetKeys() {
|
|
3403
3700
|
return [...this.linkTargets.keys()];
|
|
@@ -3469,6 +3766,7 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3469
3766
|
get expectedSourceStableIds() {
|
|
3470
3767
|
return expectedSourceStableIds();
|
|
3471
3768
|
},
|
|
3769
|
+
linkResolveInFlight: this.linkResolveInFlight,
|
|
3472
3770
|
dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
|
|
3473
3771
|
remoteNativeCaps: this.remoteNativeCaps,
|
|
3474
3772
|
seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
|
|
@@ -3517,12 +3815,15 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3517
3815
|
}
|
|
3518
3816
|
}))).filter((id) => id !== null);
|
|
3519
3817
|
}
|
|
3520
|
-
/** Build the dependency context the extracted binding resolvers consume.
|
|
3818
|
+
/** Build the dependency context the extracted binding resolvers consume.
|
|
3819
|
+
* `devicesWithLinks` is mutated in place (never reassigned) so the direct
|
|
3820
|
+
* reference stays live — it gates the virtual `linked` binding step. */
|
|
3521
3821
|
get bindingsDeps() {
|
|
3522
3822
|
return {
|
|
3523
3823
|
ctx: this.ctx,
|
|
3524
3824
|
capabilityRegistry: this.capabilityRegistry,
|
|
3525
|
-
remoteNativeCaps: this.remoteNativeCaps
|
|
3825
|
+
remoteNativeCaps: this.remoteNativeCaps,
|
|
3826
|
+
devicesWithLinks: this.devicesWithLinks
|
|
3526
3827
|
};
|
|
3527
3828
|
}
|
|
3528
3829
|
async getBindings(input) {
|
|
@@ -62,6 +62,12 @@ export declare class DeviceMetaStore {
|
|
|
62
62
|
* whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
|
|
63
63
|
* Pure over a pre-read meta map so a multi-link resolve reads the store once. */
|
|
64
64
|
resolveSourceDeviceId: (parentStableId: string, sourceKey: string, meta: Record<string, PersistedDeviceMeta>) => number | null;
|
|
65
|
+
/** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
|
|
66
|
+
* FULL stableId equals `sourceStableId`, regardless of parent container.
|
|
67
|
+
* First match wins (stableIds are effectively unique cluster-wide — see the
|
|
68
|
+
* `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
|
|
69
|
+
* pre-read meta map so a multi-link resolve reads the store once. */
|
|
70
|
+
resolveGlobalSourceDeviceId: (sourceStableId: string, meta: Record<string, PersistedDeviceMeta>) => number | null;
|
|
65
71
|
allocateNextDeviceId: () => Promise<number>;
|
|
66
72
|
/** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
|
|
67
73
|
* current persisted meta. Called at boot (once the `devicesWithLinks` seed
|
|
@@ -14,6 +14,10 @@ export interface ProviderHost {
|
|
|
14
14
|
readonly devicesWithLinks: Set<number>;
|
|
15
15
|
/** Expected link-source stableIds, gating the `registerDevice` rebuild. */
|
|
16
16
|
readonly expectedSourceStableIds: Set<string>;
|
|
17
|
+
/** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the
|
|
18
|
+
* defensive re-entrancy guard that bounds link cycles at resolve time
|
|
19
|
+
* (P2e). A nested resolve of a pair already in flight returns null. */
|
|
20
|
+
readonly linkResolveInFlight: Set<string>;
|
|
17
21
|
/** Drop a removed device's overlay-emit guard entries (state-mirror keyed
|
|
18
22
|
* `${deviceId}:${cap}`). Called from `removeDevice`. */
|
|
19
23
|
dropDeviceOverlays(deviceId: number): void;
|
|
@@ -108,18 +108,26 @@ export declare class DeviceStateMirror {
|
|
|
108
108
|
* Read-time overlay of a cap slice with its cross-device linked values.
|
|
109
109
|
* Returns a cloned raw mirror slice when the (device, cap) pair has no
|
|
110
110
|
* links. Sources are read from the same in-hub stateMirror — sync, no
|
|
111
|
-
* cross-process call.
|
|
112
|
-
*
|
|
111
|
+
* cross-process call. When the raw slice is ABSENT but the cap is a link
|
|
112
|
+
* target, the slice is SYNTHESIZED: the merge base is seeded from the
|
|
113
|
+
* cap's declared `status.empty` default; a failed/empty synthesize
|
|
114
|
+
* returns null (no phantom slices). The disk writer must NOT use this
|
|
115
|
+
* method; it must persist raw provider truth via snapshotForDevice.
|
|
113
116
|
*/
|
|
114
117
|
overlayedSlice(deviceId: number, cap: string): Record<string, unknown> | null;
|
|
115
118
|
/**
|
|
116
119
|
* Like snapshotForDevice but applies the device-link overlay per cap.
|
|
117
120
|
* Used exclusively by the device-state READ methods (getSnapshot,
|
|
118
|
-
* getAllSnapshots) so callers see overlayed values.
|
|
119
|
-
*
|
|
121
|
+
* getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
|
|
122
|
+
* link-target caps (no base mirror slice) are unioned into the iterated
|
|
123
|
+
* key set so they appear at warm-load — `overlayedSlice` builds them from
|
|
124
|
+
* the cap's `status.empty` (and drops them when nothing resolves). The
|
|
125
|
+
* debounced disk writer must continue to call snapshotForDevice (raw truth).
|
|
120
126
|
*/
|
|
121
127
|
snapshotForDeviceOverlayed(deviceId: number): Record<string, Record<string, unknown>>;
|
|
122
|
-
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
128
|
+
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
129
|
+
* Devices that only exist as link TARGETS (no mirror slice yet) are
|
|
130
|
+
* included via the link reverse-index so their synthesized caps appear. */
|
|
123
131
|
allSnapshotsOverlayed(): Record<string, Record<string, Record<string, unknown>>>;
|
|
124
132
|
private emitStateChanged;
|
|
125
133
|
/** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
|