@camstack/system 1.1.21 → 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.
@@ -509,6 +509,21 @@ async function getBindings(deps, input) {
509
509
  });
510
510
  seenCaps.add(entry.capName);
511
511
  }
512
+ if (deps.devicesWithLinks?.has(input.deviceId)) {
513
+ const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
514
+ for (const link of row?.deviceLinks ?? []) {
515
+ const capName = link.target.cap;
516
+ if (seenCaps.has(capName)) continue;
517
+ entries.push({
518
+ capName,
519
+ kind: "linked",
520
+ providerAddonId: deps.ctx.id,
521
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
522
+ nativeAddonId: ""
523
+ });
524
+ seenCaps.add(capName);
525
+ }
526
+ }
512
527
  return {
513
528
  deviceId: input.deviceId,
514
529
  entries
@@ -1171,11 +1186,47 @@ async function setWrapperActive(deps, input) {
1171
1186
  });
1172
1187
  }
1173
1188
  /**
1189
+ * Build the wireable-cap entry for one capability definition: the status
1190
+ * schema's scalar leaf fields plus — for an item-array cap
1191
+ * (`status.itemArray`) — the per-item fields tagged `item: true` (a link
1192
+ * targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
1193
+ * descriptor so the UI can address items. Returns null when the cap exposes
1194
+ * nothing wireable (no status schema / no leaf fields).
1195
+ */
1196
+ function wireableEntryForDef(capName, def) {
1197
+ const status = def.status;
1198
+ const schema = status?.schema;
1199
+ if (!schema) return null;
1200
+ const itemArray = status.itemArray;
1201
+ const fields = [...(0, _camstack_types.enumerateSchemaFields)(schema), ...itemArray ? (0, _camstack_types.enumerateItemArrayFields)(itemArray) : []].map((f) => ({
1202
+ path: f.path,
1203
+ kind: f.kind,
1204
+ ...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
1205
+ ...f.item === true ? { item: true } : {}
1206
+ }));
1207
+ if (fields.length === 0) return null;
1208
+ return {
1209
+ cap: capName,
1210
+ fields,
1211
+ ...itemArray ? { itemArray: {
1212
+ path: itemArray.path,
1213
+ keyField: itemArray.keyField
1214
+ } } : {}
1215
+ };
1216
+ }
1217
+ /**
1174
1218
  * Per-device wireable-field catalog — the domain status fields a device's
1175
1219
  * bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
1176
1220
  * walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
1177
1221
  * internal book-keeping, not wireable domain data), and enumerates each cap's
1178
- * status schema leaf fields. Behavior unchanged from the inline provider method.
1222
+ * status schema leaf fields (per-item fields included for item-array caps).
1223
+ *
1224
+ * `includeSynthesizable: true` (TARGET pickers only) additionally unions in
1225
+ * UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
1226
+ * marker) and whose `deviceTypes` admit this device's persisted type — so the
1227
+ * FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
1228
+ * authored before any binding exists. Default (absent/false) behavior is
1229
+ * byte-identical to the binding-driven catalog.
1179
1230
  */
1180
1231
  async function getWireableFields(deps, input) {
1181
1232
  const { deviceId } = input;
@@ -1189,20 +1240,20 @@ async function getWireableFields(deps, input) {
1189
1240
  seen.add(entry.capName);
1190
1241
  const def = reg.getDefinition(entry.capName);
1191
1242
  if (!def || def.kind === "wrapper") continue;
1192
- const schema = def.status?.schema;
1193
- if (!schema) continue;
1194
- const fields = (0, _camstack_types.enumerateSchemaFields)(schema).map((f) => f.enumValues !== void 0 ? {
1195
- path: f.path,
1196
- kind: f.kind,
1197
- enumValues: [...f.enumValues]
1198
- } : {
1199
- path: f.path,
1200
- kind: f.kind
1201
- });
1202
- if (fields.length > 0) caps.push({
1203
- cap: entry.capName,
1204
- fields
1205
- });
1243
+ const wireable = wireableEntryForDef(entry.capName, def);
1244
+ if (wireable) caps.push(wireable);
1245
+ }
1246
+ if (input.includeSynthesizable === true) {
1247
+ const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
1248
+ if (deviceType !== void 0) for (const def of _camstack_types.ALL_CAPABILITY_DEFINITIONS) {
1249
+ if (seen.has(def.name)) continue;
1250
+ if (def.scope !== "device" || def.kind === "wrapper") continue;
1251
+ if (def.status?.empty === void 0) continue;
1252
+ if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
1253
+ seen.add(def.name);
1254
+ const wireable = wireableEntryForDef(def.name, def);
1255
+ if (wireable) caps.push(wireable);
1256
+ }
1206
1257
  }
1207
1258
  return { caps };
1208
1259
  }
@@ -1316,6 +1367,99 @@ var DeviceEventPropagator = class {
1316
1367
  }
1317
1368
  };
1318
1369
  //#endregion
1370
+ //#region src/builtins/device-manager/device-link-cycle.ts
1371
+ function nodeKey(deviceId, cap) {
1372
+ return `${deviceId}:${cap}`;
1373
+ }
1374
+ /**
1375
+ * Resolve a link source to its `(deviceId, cap)` node, or null when the
1376
+ * source is a literal (no device) or does not resolve to a known device.
1377
+ * Sibling FIELD sources resolve against the TARGET's container stableId
1378
+ * (its parent's, falling back to its own for a top-level target) — the same
1379
+ * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply.
1380
+ */
1381
+ function resolveSourceNode(link, targetRow, rowById, idByStableId) {
1382
+ const src = link.source;
1383
+ if (src.kind === "literal") return null;
1384
+ const wantedStableId = src.kind === "global" ? src.sourceStableId : `${targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId}-${src.sourceKey}`;
1385
+ const srcId = idByStableId.get(wantedStableId);
1386
+ if (srcId === void 0) return null;
1387
+ return nodeKey(srcId, src.cap);
1388
+ }
1389
+ /**
1390
+ * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
1391
+ * a dependency cycle. Returns the cycle as an ordered list of node keys
1392
+ * (first node repeated at the end) or null when the set is safe.
1393
+ */
1394
+ function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
1395
+ const rows = Object.values(allMeta);
1396
+ const rowById = /* @__PURE__ */ new Map();
1397
+ const idByStableId = /* @__PURE__ */ new Map();
1398
+ for (const r of rows) {
1399
+ rowById.set(r.id, r);
1400
+ if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
1401
+ }
1402
+ const dependsOn = /* @__PURE__ */ new Map();
1403
+ const addEdges = (row, links) => {
1404
+ for (const link of links) {
1405
+ const srcNode = resolveSourceNode(link, row, rowById, idByStableId);
1406
+ if (srcNode === null) continue;
1407
+ const tNode = nodeKey(row.id, link.target.cap);
1408
+ const list = dependsOn.get(tNode) ?? [];
1409
+ list.push(srcNode);
1410
+ dependsOn.set(tNode, list);
1411
+ }
1412
+ };
1413
+ for (const r of rows) {
1414
+ const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
1415
+ if (links.length > 0) addEdges(r, links);
1416
+ }
1417
+ const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
1418
+ for (const root of roots) {
1419
+ const cycle = dfsFindCycle(root, dependsOn);
1420
+ if (cycle) return cycle;
1421
+ }
1422
+ return null;
1423
+ }
1424
+ /** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
1425
+ * cycle path (closed — first node repeated last) or null. */
1426
+ function dfsFindCycle(root, dependsOn) {
1427
+ const onPath = /* @__PURE__ */ new Set();
1428
+ const done = /* @__PURE__ */ new Set();
1429
+ const path = [];
1430
+ const stack = [{
1431
+ node: root,
1432
+ nextChild: 0
1433
+ }];
1434
+ onPath.add(root);
1435
+ path.push(root);
1436
+ while (stack.length > 0) {
1437
+ const frame = stack[stack.length - 1];
1438
+ const children = dependsOn.get(frame.node) ?? [];
1439
+ if (frame.nextChild < children.length) {
1440
+ const child = children[frame.nextChild];
1441
+ frame.nextChild += 1;
1442
+ if (onPath.has(child)) {
1443
+ const start = path.indexOf(child);
1444
+ return [...path.slice(start), child];
1445
+ }
1446
+ if (done.has(child)) continue;
1447
+ stack.push({
1448
+ node: child,
1449
+ nextChild: 0
1450
+ });
1451
+ onPath.add(child);
1452
+ path.push(child);
1453
+ continue;
1454
+ }
1455
+ stack.pop();
1456
+ onPath.delete(frame.node);
1457
+ path.pop();
1458
+ done.add(frame.node);
1459
+ }
1460
+ return null;
1461
+ }
1462
+ //#endregion
1319
1463
  //#region src/builtins/device-manager/device-meta-actions.ts
1320
1464
  /**
1321
1465
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -1877,6 +2021,8 @@ async function setChildLayout(pctx, input) {
1877
2021
  */
1878
2022
  async function setDeviceLinks(pctx, input) {
1879
2023
  const { deviceId, deviceLinks } = input;
2024
+ const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
2025
+ if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
1880
2026
  await pctx.metaStore.withMetaWriteLock(async () => {
1881
2027
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1882
2028
  if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
@@ -2199,13 +2345,15 @@ function buildLinkIndexes(entries) {
2199
2345
  sourceDeviceId
2200
2346
  });
2201
2347
  targets.set(tKey, tList);
2202
- const sKey = `${sourceDeviceId}:${link.source.cap}`;
2203
- const sList = dependents.get(sKey) ?? [];
2204
- sList.push({
2205
- targetDeviceId,
2206
- targetCap: link.target.cap
2207
- });
2208
- dependents.set(sKey, sList);
2348
+ if (link.source.kind !== "literal") {
2349
+ const sKey = `${sourceDeviceId}:${link.source.cap}`;
2350
+ const sList = dependents.get(sKey) ?? [];
2351
+ sList.push({
2352
+ targetDeviceId,
2353
+ targetCap: link.target.cap
2354
+ });
2355
+ dependents.set(sKey, sList);
2356
+ }
2209
2357
  }
2210
2358
  return {
2211
2359
  targets,
@@ -2320,6 +2468,15 @@ var DeviceMetaStore = class {
2320
2468
  for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
2321
2469
  return null;
2322
2470
  };
2471
+ /** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
2472
+ * FULL stableId equals `sourceStableId`, regardless of parent container.
2473
+ * First match wins (stableIds are effectively unique cluster-wide — see the
2474
+ * `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
2475
+ * pre-read meta map so a multi-link resolve reads the store once. */
2476
+ resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
2477
+ for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
2478
+ return null;
2479
+ };
2323
2480
  allocateNextDeviceId = async () => {
2324
2481
  const current = (await this.readStore()).nextDeviceId ?? 1;
2325
2482
  await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
@@ -2350,8 +2507,18 @@ var DeviceMetaStore = class {
2350
2507
  const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
2351
2508
  if (container === void 0) continue;
2352
2509
  for (const link of targetMeta.deviceLinks ?? []) {
2353
- expectedSources.add(`${container}-${link.source.sourceKey}`);
2354
- const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
2510
+ const src = link.source;
2511
+ if (src.kind === "literal") {
2512
+ entries.push({
2513
+ targetDeviceId: targetId,
2514
+ link,
2515
+ sourceDeviceId: -1
2516
+ });
2517
+ continue;
2518
+ }
2519
+ const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
2520
+ expectedSources.add(wantedStableId);
2521
+ const srcId = idByStableId.get(wantedStableId);
2355
2522
  if (srcId === void 0) continue;
2356
2523
  entries.push({
2357
2524
  targetDeviceId: targetId,
@@ -2897,6 +3064,18 @@ async function testField(pctx, input) {
2897
3064
  function isRecord(x) {
2898
3065
  return x !== null && typeof x === "object" && !Array.isArray(x);
2899
3066
  }
3067
+ /**
3068
+ * Resolve one link's transformed value. Field sources read via `readField`
3069
+ * (the caller supplies the sibling lookup — sync, e.g. the in-hub state
3070
+ * mirror); literal sources use their per-device constant and never consult
3071
+ * the reader. Returns `undefined` to skip the overlay (unreadable field).
3072
+ * Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
3073
+ * path) cannot use this helper for field sources — they branch on
3074
+ * `source.kind` directly.
3075
+ */
3076
+ function resolveLinkValue(link, readField) {
3077
+ return (0, _camstack_types.applyTransform)(link.source.kind === "literal" ? link.source.value : readField(link.source.cap, link.source.fieldPath), link.transform);
3078
+ }
2900
3079
  /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
2901
3080
  * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
2902
3081
  * is a true `instanceof` guard rather than a cast. */
@@ -2928,19 +3107,88 @@ function fillNullableDefaults(schema, value) {
2928
3107
  return out;
2929
3108
  }
2930
3109
  /**
3110
+ * Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
3111
+ * group as ONE item into the status' item array (see
3112
+ * `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
3113
+ * `keyField` (and `labelField`, when declared) set to the itemKey; an
3114
+ * EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
3115
+ * native fields a link doesn't touch are preserved. Each upserted item is
3116
+ * validated against `itemSchema` — an invalid item is skipped WITHOUT
3117
+ * discarding its valid siblings. Returns the new draft and whether anything
3118
+ * was applied. Pure + immutable (base array/items are never mutated).
3119
+ */
3120
+ function upsertItemArrayLinks(draft, grouped, itemArray) {
3121
+ const arrRaw = (0, _camstack_types.getByPath)(draft, itemArray.path);
3122
+ const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
3123
+ let touched = false;
3124
+ for (const [itemKey, group] of grouped) {
3125
+ const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
3126
+ const existing = idx >= 0 ? arr[idx] : null;
3127
+ let item = isRecord(existing) ? { ...existing } : {
3128
+ ...itemArray.emptyItem,
3129
+ [itemArray.keyField]: itemKey,
3130
+ ...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
3131
+ };
3132
+ let itemTouched = false;
3133
+ for (const { link, sourceValue } of group) {
3134
+ if (sourceValue === void 0) continue;
3135
+ item = (0, _camstack_types.setByPath)(item, link.target.fieldPath, sourceValue);
3136
+ itemTouched = true;
3137
+ }
3138
+ if (!itemTouched) continue;
3139
+ const repaired = fillNullableDefaults(itemArray.itemSchema, item);
3140
+ const parsed = itemArray.itemSchema.safeParse(repaired);
3141
+ if (!parsed.success) continue;
3142
+ if (isRecord(parsed.data)) item = parsed.data;
3143
+ if (idx >= 0) arr[idx] = item;
3144
+ else arr.push(item);
3145
+ touched = true;
3146
+ }
3147
+ if (!touched) return {
3148
+ draft,
3149
+ touched: false
3150
+ };
3151
+ return {
3152
+ draft: (0, _camstack_types.setByPath)(draft, itemArray.path, arr),
3153
+ touched: true
3154
+ };
3155
+ }
3156
+ /**
2931
3157
  * Overlay transformed source values onto `base` by dot-path, then validate the
2932
3158
  * result against the target cap's `statusSchema`. On validation failure the
2933
3159
  * overlay is discarded and `base` is returned unchanged (a misconfigured link
2934
3160
  * must never corrupt a cap response). Pure — all I/O happens in the caller.
3161
+ *
3162
+ * Item-array grouping (P2b): when the cap declares `status.itemArray`, links
3163
+ * carrying a `target.itemKey` are grouped per key and upserted as items into
3164
+ * the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
3165
+ * ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
3166
+ * scalar dot-path behavior unchanged. A link with an itemKey on a cap that
3167
+ * declares NO `itemArray` is dropped (its item-relative path must never be
3168
+ * scalar-applied to the status root).
2935
3169
  */
2936
- function mergeLinkedStatus(base, resolved, statusSchema) {
3170
+ function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
2937
3171
  let draft = base;
2938
3172
  let touched = false;
2939
- for (const { link, sourceValue } of resolved) {
2940
- if (sourceValue === void 0) continue;
2941
- draft = (0, _camstack_types.setByPath)(draft, link.target.fieldPath, sourceValue);
3173
+ const grouped = /* @__PURE__ */ new Map();
3174
+ for (const entry of resolved) {
3175
+ const itemKey = entry.link.target.itemKey;
3176
+ if (itemKey !== void 0) {
3177
+ if (!itemArray) continue;
3178
+ const group = grouped.get(itemKey);
3179
+ if (group) group.push(entry);
3180
+ else grouped.set(itemKey, [entry]);
3181
+ continue;
3182
+ }
3183
+ if (entry.sourceValue === void 0) continue;
3184
+ draft = (0, _camstack_types.setByPath)(draft, entry.link.target.fieldPath, entry.sourceValue);
2942
3185
  touched = true;
2943
3186
  }
3187
+ if (itemArray && grouped.size > 0) {
3188
+ const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
3189
+ draft = upserted.draft;
3190
+ touched = touched || upserted.touched;
3191
+ }
2944
3192
  if (!touched) return base;
2945
3193
  if (!statusSchema) return draft;
2946
3194
  const repaired = fillNullableDefaults(statusSchema, draft);
@@ -3137,8 +3385,11 @@ var DeviceStateMirror = class DeviceStateMirror {
3137
3385
  * Read-time overlay of a cap slice with its cross-device linked values.
3138
3386
  * Returns a cloned raw mirror slice when the (device, cap) pair has no
3139
3387
  * links. Sources are read from the same in-hub stateMirror — sync, no
3140
- * cross-process call. The disk writer must NOT use this method; it must
3141
- * persist raw provider truth via snapshotForDevice.
3388
+ * cross-process call. When the raw slice is ABSENT but the cap is a link
3389
+ * target, the slice is SYNTHESIZED: the merge base is seeded from the
3390
+ * cap's declared `status.empty` default; a failed/empty synthesize
3391
+ * returns null (no phantom slices). The disk writer must NOT use this
3392
+ * method; it must persist raw provider truth via snapshotForDevice.
3142
3393
  */
3143
3394
  overlayedSlice(deviceId, cap) {
3144
3395
  const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
@@ -3146,34 +3397,50 @@ var DeviceStateMirror = class DeviceStateMirror {
3146
3397
  if (!links || links.length === 0) return raw ? { ...raw } : null;
3147
3398
  const resolved = links.map((rl) => ({
3148
3399
  link: rl.link,
3149
- sourceValue: (0, _camstack_types.applyTransform)((0, _camstack_types.getByPath)(this.stateMirror.get(rl.sourceDeviceId)?.get(rl.link.source.cap), rl.link.source.fieldPath), rl.link.transform)
3400
+ sourceValue: resolveLinkValue(rl.link, (srcCap, fieldPath) => (0, _camstack_types.getByPath)(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
3150
3401
  }));
3151
- const schema = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status?.schema;
3152
- return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
3402
+ const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
3403
+ const schema = capStatus?.schema;
3404
+ const empty = capStatus?.empty;
3405
+ const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
3406
+ const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
3407
+ if (!raw && merged === base) return null;
3408
+ return merged;
3153
3409
  }
3154
3410
  /**
3155
3411
  * Like snapshotForDevice but applies the device-link overlay per cap.
3156
3412
  * Used exclusively by the device-state READ methods (getSnapshot,
3157
- * getAllSnapshots) so callers see overlayed values. The debounced disk
3158
- * writer must continue to call snapshotForDevice (raw truth).
3413
+ * getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
3414
+ * link-target caps (no base mirror slice) are unioned into the iterated
3415
+ * key set so they appear at warm-load — `overlayedSlice` builds them from
3416
+ * the cap's `status.empty` (and drops them when nothing resolves). The
3417
+ * debounced disk writer must continue to call snapshotForDevice (raw truth).
3159
3418
  */
3160
3419
  snapshotForDeviceOverlayed(deviceId) {
3161
3420
  const perCap = this.stateMirror.get(deviceId);
3162
- if (!perCap) return {};
3421
+ const capNames = new Set(perCap ? perCap.keys() : []);
3422
+ const prefix = `${deviceId}:`;
3423
+ for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
3163
3424
  const out = {};
3164
- for (const capName of perCap.keys()) {
3425
+ for (const capName of capNames) {
3165
3426
  const s = this.overlayedSlice(deviceId, capName);
3166
3427
  if (s) out[capName] = s;
3167
3428
  }
3168
3429
  return out;
3169
3430
  }
3170
- /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
3431
+ /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
3432
+ * Devices that only exist as link TARGETS (no mirror slice yet) are
3433
+ * included via the link reverse-index so their synthesized caps appear. */
3171
3434
  allSnapshotsOverlayed() {
3435
+ const deviceIds = new Set(this.stateMirror.keys());
3436
+ for (const key of this.linkHost.linkTargets.keys()) {
3437
+ const id = Number(key.slice(0, key.indexOf(":")));
3438
+ if (Number.isFinite(id)) deviceIds.add(id);
3439
+ }
3172
3440
  const out = {};
3173
- for (const [deviceId, perCap] of this.stateMirror) {
3174
- const dev = {};
3175
- for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
3176
- out[String(deviceId)] = dev;
3441
+ for (const deviceId of deviceIds) {
3442
+ const dev = this.snapshotForDeviceOverlayed(deviceId);
3443
+ if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
3177
3444
  }
3178
3445
  return out;
3179
3446
  }
@@ -3292,14 +3559,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
3292
3559
  * `getProviderForDevice` (routes cross-process); merge is pure.
3293
3560
  */
3294
3561
  async function resolveLinkedStatus(pctx, input) {
3295
- const { deviceId, cap, baseStatus } = input;
3562
+ const { deviceId, cap } = input;
3296
3563
  if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
3564
+ const inFlightKey = `${deviceId}:${cap}`;
3565
+ if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
3566
+ pctx.host.linkResolveInFlight.add(inFlightKey);
3567
+ try {
3568
+ return await resolveLinkedStatusInner(pctx, input);
3569
+ } finally {
3570
+ pctx.host.linkResolveInFlight.delete(inFlightKey);
3571
+ }
3572
+ }
3573
+ /** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
3574
+ async function resolveLinkedStatusInner(pctx, input) {
3575
+ const { deviceId, cap, baseStatus } = input;
3297
3576
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3298
3577
  if (!persisted) return null;
3299
3578
  const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
3300
3579
  if (links.length === 0) return null;
3301
3580
  const capRegistry = pctx.host.capabilityRegistry;
3302
- const schema = capRegistry?.getDefinition(cap)?.status?.schema;
3581
+ const capStatus = capRegistry?.getDefinition(cap)?.status;
3582
+ const schema = capStatus?.schema;
3303
3583
  if (!schema) return null;
3304
3584
  const allMeta = await pctx.metaStore.readMeta();
3305
3585
  let containerStableId = persisted.stableId;
@@ -3310,11 +3590,19 @@ async function resolveLinkedStatus(pctx, input) {
3310
3590
  }
3311
3591
  const resolved = [];
3312
3592
  for (const link of links) {
3313
- const srcId = pctx.metaStore.resolveSourceDeviceId(containerStableId, link.source.sourceKey, allMeta);
3593
+ const src = link.source;
3594
+ if (src.kind === "literal") {
3595
+ resolved.push({
3596
+ link,
3597
+ sourceValue: (0, _camstack_types.applyTransform)(src.value, link.transform)
3598
+ });
3599
+ continue;
3600
+ }
3601
+ const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
3314
3602
  if (srcId === null) continue;
3315
3603
  try {
3316
- const srcProvider = capRegistry?.getProviderForDevice(link.source.cap, srcId);
3317
- const raw = (0, _camstack_types.getByPath)(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
3604
+ const srcProvider = capRegistry?.getProviderForDevice(src.cap, srcId);
3605
+ const raw = (0, _camstack_types.getByPath)(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, src.fieldPath);
3318
3606
  resolved.push({
3319
3607
  link,
3320
3608
  sourceValue: (0, _camstack_types.applyTransform)(raw, link.transform)
@@ -3326,14 +3614,18 @@ async function resolveLinkedStatus(pctx, input) {
3326
3614
  capName: cap
3327
3615
  },
3328
3616
  meta: {
3329
- sourceKey: link.source.sourceKey,
3617
+ sourceKey: src.kind === "global" ? src.sourceStableId : src.sourceKey,
3330
3618
  error: err instanceof Error ? err.message : String(err)
3331
3619
  }
3332
3620
  });
3333
3621
  }
3334
3622
  }
3335
3623
  if (resolved.length === 0) return null;
3336
- return mergeLinkedStatus(isRecord$1(baseStatus) ? baseStatus : {}, resolved, schema);
3624
+ const synthesizing = !isRecord$1(baseStatus);
3625
+ const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
3626
+ const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
3627
+ if (synthesizing && merged === base) return null;
3628
+ return merged;
3337
3629
  }
3338
3630
  //#endregion
3339
3631
  //#region src/builtins/device-manager/device-manager.addon.ts
@@ -3399,10 +3691,15 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3399
3691
  linkTargets = /* @__PURE__ */ new Map();
3400
3692
  /** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
3401
3693
  linkDependents = /* @__PURE__ */ new Map();
3402
- /** Expected source `stableId`s (`${container}-${sourceKey}`) across all links,
3694
+ /** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
3695
+ * sources, the full `sourceStableId` for global sources) across all links,
3403
3696
  * resolved or not — gates the `registerDevice` rebuild so only a registering
3404
3697
  * device that IS a link source triggers a reindex (not every boot restore). */
3405
3698
  expectedSourceStableIds = /* @__PURE__ */ new Set();
3699
+ /** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
3700
+ * defensive re-entrancy guard bounding link cycles at resolve time. Mutated
3701
+ * in place (never reassigned), so the host exposes a direct reference. */
3702
+ linkResolveInFlight = /* @__PURE__ */ new Set();
3406
3703
  /** Test/diagnostic accessors. */
3407
3704
  linkTargetKeys() {
3408
3705
  return [...this.linkTargets.keys()];
@@ -3474,6 +3771,7 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3474
3771
  get expectedSourceStableIds() {
3475
3772
  return expectedSourceStableIds();
3476
3773
  },
3774
+ linkResolveInFlight: this.linkResolveInFlight,
3477
3775
  dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
3478
3776
  remoteNativeCaps: this.remoteNativeCaps,
3479
3777
  seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
@@ -3522,12 +3820,15 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3522
3820
  }
3523
3821
  }))).filter((id) => id !== null);
3524
3822
  }
3525
- /** Build the dependency context the extracted binding resolvers consume. */
3823
+ /** Build the dependency context the extracted binding resolvers consume.
3824
+ * `devicesWithLinks` is mutated in place (never reassigned) so the direct
3825
+ * reference stays live — it gates the virtual `linked` binding step. */
3526
3826
  get bindingsDeps() {
3527
3827
  return {
3528
3828
  ctx: this.ctx,
3529
3829
  capabilityRegistry: this.capabilityRegistry,
3530
- remoteNativeCaps: this.remoteNativeCaps
3830
+ remoteNativeCaps: this.remoteNativeCaps,
3831
+ devicesWithLinks: this.devicesWithLinks
3531
3832
  };
3532
3833
  }
3533
3834
  async getBindings(input) {