@camstack/system 1.2.317 → 1.2.319

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/autotrack/index.js +1 -1
  8. package/dist/builtins/autotrack/index.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -77
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +3 -77
  11. package/dist/builtins/camera-grid/index.js +1 -1
  12. package/dist/builtins/camera-grid/index.mjs +1 -1
  13. package/dist/builtins/composer/composer-apply-waits.d.ts +21 -0
  14. package/dist/builtins/composer/composer-live-set.d.ts +22 -0
  15. package/dist/builtins/composer/composer-runtime-factory.d.ts +22 -0
  16. package/dist/builtins/composer/composer-seeds.d.ts +22 -0
  17. package/dist/builtins/composer/composer.addon.js +1905 -1809
  18. package/dist/builtins/composer/composer.addon.mjs +1905 -1809
  19. package/dist/builtins/composer/composer.d.ts +3 -15
  20. package/dist/builtins/console-logging/index.js +1 -1
  21. package/dist/builtins/console-logging/index.mjs +1 -1
  22. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  23. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  24. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  25. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  26. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  27. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  28. package/dist/builtins/hub-forwarder/index.js +1 -1
  29. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  30. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  31. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  32. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  33. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  34. package/dist/builtins/local-network/local-network.addon.js +1 -1
  35. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  36. package/dist/builtins/loki-logging/index.js +1 -1
  37. package/dist/builtins/loki-logging/index.mjs +1 -1
  38. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  39. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  40. package/dist/builtins/platform-probe/index.js +1 -1
  41. package/dist/builtins/platform-probe/index.mjs +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  43. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/snapshot/index.js +1 -1
  45. package/dist/builtins/snapshot/index.mjs +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  47. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  48. package/dist/builtins/sqlite-storage/shm-lock-canary.d.ts +60 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  51. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  52. package/dist/builtins/sqlite-storage/wal-checkpoint-policy.d.ts +115 -34
  53. package/dist/builtins/sqlite-storage/wal-checkpointer.d.ts +6 -0
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  55. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  56. package/dist/builtins/system-backup/system-backup.service.d.ts +2 -17
  57. package/dist/builtins/system-config/system-config.addon.js +1 -1
  58. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  59. package/dist/builtins/winston-logging/index.js +1 -1
  60. package/dist/builtins/winston-logging/index.mjs +1 -1
  61. package/dist/{composition-sources-Dep7nBwu.js → composition-sources-BjRPhWtU.js} +1 -1
  62. package/dist/{composition-sources-DVClu006.mjs → composition-sources-Cg2MP-qf.mjs} +1 -1
  63. package/dist/{dist-Dori4pf3.mjs → dist-CQ9vXSUh.mjs} +135 -2
  64. package/dist/{dist-a6p0V-IL.js → dist-Dcy0gVHu.js} +135 -2
  65. package/dist/index.js +1 -1
  66. package/dist/index.mjs +1 -1
  67. package/dist/{retired-settings-keys-DVcKRgzm.mjs → retired-settings-keys-DNIbyfTR.mjs} +1 -1
  68. package/dist/{retired-settings-keys-DC1uHWPZ.js → retired-settings-keys-pzlrLUbA.js} +1 -1
  69. package/dist/wal-checkpoint-policy-BahrTtyD.mjs +186 -0
  70. package/dist/wal-checkpoint-policy-d2z2qPqx.js +233 -0
  71. package/dist/wal-checkpoint-worker.js +63 -57
  72. package/dist/wal-checkpoint-worker.mjs +63 -57
  73. package/package.json +1 -1
  74. package/dist/wal-checkpoint-policy-CAcg63o-.mjs +0 -92
  75. package/dist/wal-checkpoint-policy-CCJZngds.js +0 -127
@@ -1,6 +1,6 @@
1
- import { An as cloneStatefulExpressionMemory, Bn as toExpressionValue, Fn as compositionSliceKey, Gn as BaseAddon, In as compositionSourceKey, K as buildCapabilityLookup, Ln as createStatefulExpressionMemory, Mn as composedDeviceStableId, Nn as compositionFeatureEnabled, Pn as compositionItemFieldPath, Un as DeviceType, Wn as errMsg, X as composerCapability, Xt as planComposition, Z as compositionFieldKey, c as COMPOSED_ITEM_FIXED_FIELDS, h as DeclaredDevices, hr as EventCategory, i as BaseDevice, jn as composedDeviceRef, t as ALL_CAPABILITY_DEFINITIONS, tr as createEvent, vt as evaluateCompositionField, wn as COMPOSER_ADDON_ID } from "../../dist-Dori4pf3.mjs";
1
+ import { An as cloneStatefulExpressionMemory, Bn as toExpressionValue, Fn as compositionSliceKey, Gn as BaseAddon, In as compositionSourceKey, K as buildCapabilityLookup, Ln as createStatefulExpressionMemory, Mn as composedDeviceStableId, Nn as compositionFeatureEnabled, Pn as compositionItemFieldPath, Un as DeviceType, Wn as errMsg, X as composerCapability, Xt as planComposition, Z as compositionFieldKey, c as COMPOSED_ITEM_FIXED_FIELDS, h as DeclaredDevices, hr as EventCategory, i as BaseDevice, jn as composedDeviceRef, t as ALL_CAPABILITY_DEFINITIONS, tr as createEvent, vt as evaluateCompositionField, wn as COMPOSER_ADDON_ID } from "../../dist-CQ9vXSUh.mjs";
2
2
  import { n as parseDeviceStateChanged, t as isRecord$2 } from "../../device-state-changed-DoBbkyEI.mjs";
3
- import { n as normalizedNativeOwner } from "../../composition-sources-DVClu006.mjs";
3
+ import { n as normalizedNativeOwner } from "../../composition-sources-Cg2MP-qf.mjs";
4
4
  import { z } from "zod";
5
5
  //#region src/builtins/composer/composed-device.ts
6
6
  /**
@@ -292,1781 +292,1788 @@ var ComposedDeviceSet = class {
292
292
  }
293
293
  };
294
294
  //#endregion
295
- //#region src/builtins/composer/composition-declarations.ts
295
+ //#region src/builtins/composer/composer-apply-waits.ts
296
+ var INTEGRATION_WAIT = "waiting for the shared Blocks integration to reconcile";
297
+ var STORE_NOT_LOADED = "the core_blocks store has not loaded — nothing is declared or withdrawn until it does (D49)";
298
+ var ApplyWaits = class {
299
+ logger;
300
+ /** Consecutive applies refused because `core_blocks` had not loaded: warned once per run. */
301
+ storeWaitApplies = 0;
302
+ /** Consecutive applies refused because the Blocks integration had not reconciled: warned once per run. */
303
+ integrationWaitApplies = 0;
304
+ constructor(logger) {
305
+ this.logger = logger;
306
+ }
307
+ /** Whether this apply must act on nothing (and why), or may go on. */
308
+ check(input, liveCount) {
309
+ if (!input.storeLoaded) {
310
+ this.storeWaitApplies += 1;
311
+ if (this.storeWaitApplies === 1) this.logger.warn("composer: the block store has not loaded — no device is declared, withdrawn or renamed", { meta: {
312
+ blocks: input.blocks.length,
313
+ live: liveCount
314
+ } });
315
+ return {
316
+ kind: "wait",
317
+ reason: STORE_NOT_LOADED
318
+ };
319
+ }
320
+ if (this.storeWaitApplies > 0) {
321
+ this.logger.info("composer: the block store has loaded — applying again", { meta: { appliesRefused: this.storeWaitApplies } });
322
+ this.storeWaitApplies = 0;
323
+ }
324
+ if (input.integrationId === null) {
325
+ this.integrationWaitApplies += 1;
326
+ if (this.integrationWaitApplies === 1) this.logger.warn("composer: the shared Blocks integration has not reconciled — no composed device can be declared", { meta: { blocks: input.blocks.length } });
327
+ return {
328
+ kind: "wait",
329
+ reason: INTEGRATION_WAIT
330
+ };
331
+ }
332
+ if (this.integrationWaitApplies > 0) {
333
+ this.logger.info("composer: the shared Blocks integration has reconciled — applying again", { meta: { appliesRefused: this.integrationWaitApplies } });
334
+ this.integrationWaitApplies = 0;
335
+ }
336
+ return {
337
+ kind: "go",
338
+ integrationId: input.integrationId
339
+ };
340
+ }
341
+ };
342
+ /** Every block `starting`, naming the wait; nothing acted. */
343
+ function waitingResult(blockIds, reason) {
344
+ return {
345
+ blocks: blockIds.map((blockId) => ({
346
+ blockId,
347
+ status: "starting",
348
+ reason
349
+ })),
350
+ acted: false
351
+ };
352
+ }
353
+ //#endregion
354
+ //#region src/builtins/composer/existing-target.ts
296
355
  /**
297
- * Which composed devices exist (D659): one declaration per composition block,
298
- * owned by `composer` and hung off the shared Blocks integration.
356
+ * What the composer needs to run a composition on an EXISTING device (D663)
357
+ * besides the runtime and the claim-backed target: the target's description,
358
+ * the claim shapes derived from the plan, and the block report's words.
299
359
  *
300
- * `DeclaredDevices` sweeps any row whose declaration is absent. A block missing
301
- * from ONE apply (a transient store read, a race) would delete the device, and
302
- * with it its id, exports and rules. The `WithdrawalGate` is D49 for that path:
303
- * a withdrawal needs a second consecutive apply to agree. The other half of the
304
- * rule — nothing is declared or withdrawn while the `core_blocks` store has not
305
- * loaded — is `Composer.apply`'s, which never reaches this gate then (R2).
360
+ * - The target is DESCRIBED (bound caps → native addon, row type) with ONE
361
+ * `getBindings` per plan change, cached until `device.bindings-changed` for
362
+ * that device. A read that fails keeps the previous description; with none,
363
+ * the block waits and nothing is claimed (D49).
364
+ * - A claim's field set is the PLAN's (S5): the top-level fields of the cap, an
365
+ * item leaf contributing its whole array. Its mode is the feature's.
366
+ * - A self-read of the target (rule 6) is served from the NATIVE view unless it
367
+ * reads an ADDED cap — that is the composition's own output. Whether a native
368
+ * cap's `device.state-changed` is the native or the composition's own merge
369
+ * is NOT decided here from the plan: it follows whether the claim has landed
370
+ * (`HeldClaims`, C-1).
306
371
  */
307
- function declarationFor(input) {
308
- return {
309
- stableId: composedDeviceStableId(input.blockId),
310
- name: input.name,
311
- type: input.target.type,
312
- DeviceClass: composedDeviceClass(input.target.type, input.caps),
313
- ...input.target.role === void 0 ? {} : { role: input.target.role }
314
- };
372
+ /**
373
+ * Review M3: an ADDED `restored` cap seeded from NOTHING says so, once per
374
+ * runtime build, on its device. After a restart its state comes from the
375
+ * target's runtime-state row; a row that lost the slice (D677: 615's doorbell,
376
+ * dropped at a hub boot) used to surface as "not seeded yet" and now seeds a
377
+ * clean `{ null, 0 }` — a lifetime count silently reset unless this line
378
+ * names it. A first composition of a device that never had the slice logs it
379
+ * too: the line cannot tell the two apart, and says both.
380
+ */
381
+ function reportSeedlessRestoredCaps(input) {
382
+ for (const cap of addedCapsOf(input.features)) {
383
+ if (input.lookupCap(cap)?.durability !== "restored") continue;
384
+ if ((input.seed.byCap.get(cap) ?? null) !== null) continue;
385
+ input.logger.info("composed restored capability seeded from nothing — the target had no slice for it (a first composition, or a lost runtime-state row)", {
386
+ tags: { deviceId: input.deviceId },
387
+ meta: {
388
+ blockId: input.blockId,
389
+ cap,
390
+ seededFrom: "none"
391
+ }
392
+ });
393
+ }
315
394
  }
316
- /** What forces a rebuild of the live device: type, role, cap set. Never the name. */
317
- function shapeOf(input) {
318
- return JSON.stringify([
319
- input.target.type,
320
- input.target.role ?? null,
321
- input.caps.map((c) => c.name).toSorted()
322
- ]);
395
+ /**
396
+ * Read every affected effective capability exactly once, before the composer
397
+ * grafts or claims any of them. A null slice is a successful empty seed; a
398
+ * rejection is deliberately left to the caller so ownership cannot begin.
399
+ */
400
+ async function readExistingTargetSeeds(targetDeviceId, caps, read) {
401
+ const byCap = /* @__PURE__ */ new Map();
402
+ for (const cap of new Set(caps)) byCap.set(cap, await read(targetDeviceId, cap));
403
+ return { byCap };
323
404
  }
405
+ /** The reason every ungraft of a gone target carries (Task 9 rule 5 / Task 10 rule 2). */
406
+ var TARGET_GONE_UNGRAFT_REASON = "target device gone";
324
407
  /**
325
- * The declaration that holds a persisted composed row whose block has no plan
326
- * in this apply. `name` and `type` are used only if the row has to be CREATED,
327
- * which `DeclaredDevices` does not do for a row it finds: it adopts it, and the
328
- * row's own name and type stay canonical (`ComposedPlaceholderDevice`).
408
+ * Rule 5, on either path that finds the target gone (`device.unregistered`, or
409
+ * a later plan when that event was dropped): ungraft every cap the block ADDED
410
+ * on that id, with the one ERROR that says so.
329
411
  */
330
- function placeholderDeclaration(stableId) {
331
- return {
332
- stableId,
333
- name: stableId,
334
- type: DeviceType.Generic,
335
- DeviceClass: ComposedPlaceholderDevice
336
- };
412
+ function ungraftGoneTarget(input) {
413
+ const { grafts, deviceId } = input;
414
+ const added = addedCapsOf(input.features);
415
+ for (const cap of added) grafts?.ungraft(deviceId, cap, TARGET_GONE_UNGRAFT_REASON);
416
+ input.logger.error("customization target device is gone — the block failed and its grafts are removed; only the operator ends this (edit or restart the block)", {
417
+ tags: { deviceId },
418
+ meta: {
419
+ blockId: input.blockId,
420
+ target: compositionSourceKey(input.ref),
421
+ ungrafted: added,
422
+ graftHost: grafts !== void 0
423
+ }
424
+ });
337
425
  }
338
- var WITHDRAWAL_CONFIRMATIONS = 2;
339
- var WithdrawalGate = class {
426
+ var TargetDescriptions = class {
340
427
  logger;
341
- deviceIdOf;
342
- known = /* @__PURE__ */ new Map();
343
- misses = /* @__PURE__ */ new Map();
344
- persistedIds = /* @__PURE__ */ new Map();
345
- constructor(logger, deviceIdOf) {
428
+ readBoundCaps;
429
+ cache = /* @__PURE__ */ new Map();
430
+ constructor(logger, readBoundCaps) {
346
431
  this.logger = logger;
347
- this.deviceIdOf = deviceIdOf;
432
+ this.readBoundCaps = readBoundCaps;
433
+ }
434
+ /** `device.bindings-changed` for this device: the next read is a real read. */
435
+ invalidate(deviceId) {
436
+ this.cache = new Map([...this.cache].filter(([, d]) => d.deviceId !== deviceId));
348
437
  }
349
438
  /**
350
- * The rows that exist before this process applied anything. Called once at
351
- * boot, before the first `filter`: without it the mirror starts empty and a
352
- * row missing from the first apply after a restart (a release, a block that
353
- * no longer plans) is swept at once — one apply, not two. A seeded row
354
- * missing from that apply counts as its FIRST miss and is held by
355
- * `placeholderDeclaration`. A row the gate already knows keeps its entry and
356
- * its miss count.
439
+ * The description of `ref`, resolved to `deviceId`. Served from the cache
440
+ * unless `force` (the block's plan changed) or nothing is cached. A read that
441
+ * fails keeps the previous description of the SAME device id, and answers
442
+ * null when there is none: the caller waits, and claims nothing.
357
443
  */
358
- seed(rows) {
359
- const fresh = rows.filter((row) => !this.known.has(row.stableId));
360
- this.known = new Map([...this.known, ...fresh.map((row) => [row.stableId, placeholderDeclaration(row.stableId)])]);
361
- this.persistedIds = new Map([...this.persistedIds, ...rows.map((row) => [row.stableId, row.id])]);
362
- }
363
- filter(current) {
364
- const present = new Map(current.map((d) => [d.stableId, d]));
365
- const kept = [];
366
- const misses = /* @__PURE__ */ new Map();
367
- for (const [stableId, declaration] of this.known) {
368
- if (present.has(stableId)) continue;
369
- const count = (this.misses.get(stableId) ?? 0) + 1;
370
- if (count < WITHDRAWAL_CONFIRMATIONS) {
371
- this.logger.warn("a composition vanished from one apply — its device is kept until a second apply agrees", {
372
- tags: this.tagsOf(stableId),
373
- meta: {
374
- stableId,
375
- misses: count
376
- }
377
- });
378
- kept.push(declaration);
379
- misses.set(stableId, count);
380
- continue;
381
- }
382
- this.logger.info("a composition is gone on two consecutive applies — its device is withdrawn", {
383
- tags: this.tagsOf(stableId),
384
- meta: { stableId }
444
+ async describe(ref, deviceId, type, force) {
445
+ const key = compositionSourceKey(ref);
446
+ const cached = this.cache.get(key);
447
+ const previous = cached !== void 0 && cached.deviceId === deviceId ? cached : null;
448
+ if (!force && previous !== null) return previous;
449
+ const read = await this.readBoundCaps(deviceId);
450
+ if (read.kind === "unreadable") {
451
+ this.logger.warn(previous === null ? "customization target: its bindings could not be read — nothing is claimed until they are; retried on the next apply" : "customization target: its bindings could not be read — the previous description is kept", {
452
+ tags: { deviceId },
453
+ meta: {
454
+ target: key,
455
+ error: read.error
456
+ }
385
457
  });
458
+ return previous;
386
459
  }
387
- this.known = new Map([...kept.map((d) => [d.stableId, d]), ...present]);
388
- this.misses = misses;
389
- return [...current, ...kept];
390
- }
391
- /**
392
- * The live device's id, else the persisted row's. A declaration whose device
393
- * was never built and never persisted has no numeric id to carry: the
394
- * stableId in `meta` is then all there is.
395
- */
396
- tagsOf(stableId) {
397
- const deviceId = this.deviceIdOf(stableId) ?? this.persistedIds.get(stableId) ?? null;
398
- return deviceId === null ? {} : { deviceId };
460
+ const found = {
461
+ kind: "found",
462
+ deviceId,
463
+ type,
464
+ nativeCaps: read.caps
465
+ };
466
+ this.cache = new Map([...this.cache, [key, found]]);
467
+ return found;
399
468
  }
400
469
  };
401
- //#endregion
402
- //#region src/builtins/composer/composer-plan.ts
403
- /**
404
- * What one apply PLANS for a block, before anything is declared, claimed or
405
- * run (D659, D663). Pure over its inputs: the composer hands in what it knows
406
- * (its rows, the previous plan, the tracker) and gets a `PlannedBlock` back.
407
- *
408
- * - A `new` target plans as in slice 1: a declaration, a shape, a placeholder
409
- * when the composition stopped planning but its row exists.
410
- * - An `existing` target declares NOTHING (the row is not the composer's). It
411
- * is resolved through the tracker's path, described with one bindings read
412
- * per plan change, and planned against that description. A settled plan is
413
- * kept until the OPERATOR acts (edit, restart): a bindings change alone never
414
- * re-plans (Task 10 rule 2, D659). While the claims index is unseeded or the
415
- * target is unresolved or undescribed, the block WAITS by name and claims
416
- * nothing (D49). A target gone on two agreeing index reads is `gone`.
417
- * - An unreadable row names no target: whatever it holds is held (D49).
418
- */
419
- /** The shape of a placeholder: no caps, so no plan to take a shape from. */
420
- var PLACEHOLDER_SHAPE = "placeholder";
421
- var NO_PLAN = {
422
- fields: [],
423
- events: [],
424
- problems: [],
425
- declaration: null,
426
- shape: null,
427
- spec: null
428
- };
429
- /**
430
- * A row that exists but does not parse is held exactly like a composition
431
- * that stopped planning: its device (if it has a row) stays as an offline
432
- * placeholder, its claims (if any) are kept, and the block fails naming the
433
- * parse error. Absent, it would be withdrawn on the second apply that omitted
434
- * it (D49).
435
- */
436
- function planUnreadable(block, rows) {
437
- const stableId = composedDeviceStableId(block.id);
438
- const hasRow = rows.has(stableId);
439
- return {
440
- ...NO_PLAN,
441
- block: {
442
- id: block.id,
443
- name: block.name,
444
- enabled: false,
445
- updatedAt: 0
446
- },
447
- problems: [`the stored block does not parse — ${block.error}`],
448
- declaration: hasRow ? placeholderDeclaration(stableId) : null,
449
- shape: hasRow ? PLACEHOLDER_SHAPE : null,
450
- target: { kind: "unreadable" }
451
- };
470
+ /** The PLANNED field set a claim carries for `cap` (S5): an item leaf claims its whole array. */
471
+ function claimFieldsOf(fields, cap) {
472
+ return [...new Set(fields.filter((f) => f.cap === cap).map((f) => f.item === void 0 ? f.path : f.item.arrayPath))];
452
473
  }
453
- /** `target` is the block's own `new` target, narrowed by the caller: this path never sees an existing one (M-5). */
454
- function planNew(block, target, lookupCap, rows) {
455
- const plan = planComposition(block.composition, lookupCap);
456
- const planned = { kind: "new" };
457
- if (plan.problems.length > 0) {
458
- const stableId = composedDeviceStableId(block.id);
459
- const hasRow = rows.has(stableId);
460
- return {
461
- ...NO_PLAN,
462
- block,
463
- problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
464
- declaration: hasRow ? placeholderDeclaration(stableId) : null,
465
- shape: hasRow ? PLACEHOLDER_SHAPE : null,
466
- target: planned
467
- };
468
- }
469
- const caps = block.composition.features.flatMap((f) => {
470
- if (!compositionFeatureEnabled(f)) return [];
471
- const def = lookupCap(f.cap);
472
- return def === null ? [] : [def];
473
- });
474
- const declarationInput = {
475
- blockId: block.id,
476
- name: block.name,
477
- target,
478
- caps
479
- };
480
- return {
481
- block,
482
- fields: plan.fields,
483
- events: plan.events,
484
- problems: [],
485
- declaration: declarationFor(declarationInput),
486
- shape: shapeOf(declarationInput),
487
- spec: {
488
- type: target.type,
489
- ...target.role === void 0 ? {} : { role: target.role }
490
- },
491
- target: planned
492
- };
474
+ /** The claim mode of `cap`: `replace` for a cap the device serves natively, else `add`. */
475
+ function claimModeOf(features, cap) {
476
+ return features.find((f) => f.cap === cap)?.mode === "replace" ? "replace" : "add";
493
477
  }
494
- async function planExisting(input) {
495
- const { block, previous, operatorAction } = input;
496
- const prior = previous?.target;
497
- if (!operatorAction && previous !== void 0 && prior?.kind === "existing" && prior.waiting === null) return {
498
- ...previous,
499
- block
500
- };
501
- const ref = input.target.device;
502
- const key = compositionSourceKey(ref);
503
- const knownId = prior?.kind === "existing" ? prior.deviceId : null;
504
- if (input.claimsWait !== null) return waitingPlan(block, ref, knownId, input.claimsWait);
505
- const resolution = await input.tracker.resolve(ref, operatorAction);
506
- if (resolution.kind === "unknown") return waitingPlan(block, ref, knownId, `the target device \`${key}\` could not be resolved (${resolution.error}) — nothing is claimed until it is; retried on the next apply`);
507
- const existing = {
508
- kind: "existing",
509
- ref,
510
- deviceId: resolution.kind === "found" ? resolution.deviceId : knownId,
511
- waiting: null,
512
- gone: null,
513
- features: [],
514
- nativeCaps: /* @__PURE__ */ new Map()
515
- };
516
- if (resolution.kind === "missing") return {
517
- ...NO_PLAN,
518
- block,
519
- target: {
520
- ...existing,
521
- gone: `${resolution.reason} — two index reads agreed`
522
- }
523
- };
524
- const description = await input.targets.describe(ref, resolution.deviceId, resolution.type, operatorAction);
525
- if (description === null) return waitingPlan(block, ref, resolution.deviceId, `the target device \`${key}\` could not be described (its bindings did not read) — nothing is claimed until it is; retried on the next apply`);
526
- const plan = planComposition(block.composition, input.lookupCap, description);
527
- return {
528
- ...NO_PLAN,
529
- block,
530
- fields: plan.problems.length > 0 ? [] : plan.fields,
531
- events: plan.problems.length > 0 ? [] : plan.events,
532
- problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
533
- target: {
534
- ...existing,
535
- features: plan.features,
536
- nativeCaps: description.nativeCaps
537
- }
538
- };
478
+ /** The caps a customization ADDS to its target — the ones it grafts a native for (Task 10). */
479
+ function addedCapsOf(features) {
480
+ return features.filter((f) => f.mode === "add").map((f) => f.cap);
539
481
  }
540
- function waitingPlan(block, ref, deviceId, waiting) {
482
+ function selfReadCaps(fields, target, features) {
483
+ const targetKey = compositionSourceKey(target);
484
+ const read = new Set(fields.flatMap((f) => f.reads.filter((r) => compositionSourceKey(r.source) === targetKey).map((r) => r.cap)));
485
+ const added = new Set(addedCapsOf(features));
541
486
  return {
542
- ...NO_PLAN,
543
- block,
544
- target: {
545
- kind: "existing",
546
- ref,
547
- deviceId,
548
- waiting,
549
- gone: null,
550
- features: [],
551
- nativeCaps: /* @__PURE__ */ new Map()
552
- }
487
+ effective: [...read].filter((cap) => added.has(cap)),
488
+ native: [...read].filter((cap) => !added.has(cap))
553
489
  };
554
490
  }
555
- /**
556
- * Runtime preparation can still fail after a complete existing-target plan.
557
- * Preserve its fields and description for retry, but mark it waiting so claim
558
- * reconciliation cannot treat the unprepared candidate as live.
559
- */
560
- function withExistingTargetWait(p, waiting) {
561
- return p.target.kind === "existing" ? {
562
- ...p,
563
- target: {
564
- ...p.target,
565
- waiting
566
- }
567
- } : p;
491
+ /** The `degraded` reason (rule 8): one entry per released field, the source's own reason after the dash. */
492
+ function degradedReason(released) {
493
+ return released.map((r) => {
494
+ const path = r.item === void 0 ? r.path : compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
495
+ return `released to the native provider: ${r.cap}.${path} — ${r.reason}`;
496
+ }).join("; ");
568
497
  }
569
- /** What `logPlanTransition` compares: problems, and for an existing target its wait or its loss. */
570
- function planSummary(p) {
571
- const target = p.target;
572
- return (target.kind === "existing" ? [
573
- p.problems.join("; "),
574
- target.waiting ?? "",
575
- target.gone ?? ""
576
- ] : [p.problems.join("; ")]).filter((part) => part !== "").join(" | ");
498
+ /** The `starting` reason of a block with HELD fields: each keeps its last value and claim until its source answers (D49). */
499
+ function heldReason(held) {
500
+ return held.map((r) => {
501
+ const path = r.item === void 0 ? r.path : compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
502
+ return `held on its last value until its source answers (D49): ${r.cap}.${path} — ${r.reason}`;
503
+ }).join("; ");
577
504
  }
578
505
  /**
579
- * Rule 5 on the PLAN path: `device.unregistered` is telemetry and may be
580
- * dropped (D8), so a target is often found gone by the operator's next edit or
581
- * restart. The previous live target is what must be ungrafted — exactly as on
582
- * the event path. Null when nothing was lost by THIS plan.
506
+ * Rule 4: a customization whose row exists but whose plan failed keeps its
507
+ * claims, so its owned fields stay at their last value on the hub. The report
508
+ * says so — each field that had a value is `stale`, with the reason.
583
509
  */
584
- function lostTarget(p, previous) {
585
- const now = p.target;
586
- const before = previous?.target;
587
- if (now.kind !== "existing" || now.gone === null || before?.kind !== "existing") return null;
588
- if (before.deviceId === null || before.gone !== null) return null;
589
- return {
590
- target: before,
591
- deviceId: before.deviceId
592
- };
593
- }
594
- /** Two plans write the same place: both new, or the very same existing-target plan (reused until the operator acts). */
595
- function sameTarget(a, b) {
596
- return a === b || a.kind === "new" && b.kind === "new";
597
- }
598
- /** One line when a block starts or stops planning (or waiting, or loses its target) — never one per apply. */
599
- function logPlanTransition(logger, previous, p, liveTags) {
600
- if (previous !== void 0 && planSummary(previous) === planSummary(p)) return;
601
- if (previous === void 0 && planSummary(p) === "") return;
602
- const target = p.target;
603
- const extras = {
604
- tags: target.kind === "existing" && target.deviceId !== null ? { deviceId: target.deviceId } : liveTags,
605
- meta: {
606
- blockId: p.block.id,
607
- problems: p.problems,
608
- placeholder: p.declaration !== null
609
- }
610
- };
611
- if (target.kind === "existing" && target.gone !== null) logger.error("customization target device is gone — the block failed; only the operator ends this (edit or restart the block)", {
612
- ...extras,
613
- meta: {
614
- ...extras.meta,
615
- target: compositionSourceKey(target.ref)
616
- }
617
- });
618
- else if (target.kind === "existing" && target.waiting !== null) logger.warn("customization waits — nothing is claimed or released until it plans", {
619
- ...extras,
620
- meta: {
621
- ...extras.meta,
622
- reason: target.waiting
623
- }
510
+ function heldFieldsOf(fields, reason) {
511
+ return fields.map((f) => f.state === "unavailable" ? f : {
512
+ ...f,
513
+ state: "stale",
514
+ reason
624
515
  });
625
- else if (p.problems.length === 0) logger.info("composition plans again", extras);
626
- else logger.warn(p.declaration === null ? "composition does not plan — no device is declared for it" : "composition no longer plans — its device is kept offline with no capabilities", extras);
627
516
  }
628
517
  //#endregion
629
- //#region src/builtins/composer/field-record.ts
518
+ //#region src/builtins/composer/composer-grafts.ts
630
519
  /**
631
- * One composed field's record and the pure judgements over it (D659, D663):
632
- * what it may write, whether it is released (a CONFIRMED unavailability of a
633
- * `release`-mode field, S1b) or HELD (no answer yet, D49), and how a report or a
634
- * log line names it. Split from `composition-runtime.ts`, which holds the
635
- * behaviour.
520
+ * The composer's side of grafts (D663, Task 10): WHEN a cap a customization
521
+ * ADDS is grafted, removed or refused — `GraftHost` is only the how. Split from
522
+ * `composer.ts`.
523
+ *
524
+ * - Decided at PLAN time only (rule 2): every added cap is grafted; a grafted
525
+ * cap the plan now finds native is ungrafted (its event was dropped, D8).
526
+ * - NATIVE ALWAYS WINS (I-2). A foreign `native-registered` on a grafted cap
527
+ * ungrafts it (the claim stays). One that lands while a plan is in flight —
528
+ * after the target's description was read, before the graft — is remembered
529
+ * per described device, so that plan never grafts over it. A foreign
530
+ * `native-unregistered` never re-grafts (D659).
531
+ * - A cap no graft serves is NAMED for the block verdict (I-1): refused by the
532
+ * host, or taken by a native. Every added cap unserved → the block fails.
533
+ * - A binding a PREVIOUS composer process left (a respawn, D5) is retracted
534
+ * when a settled plan does not want it (I-3), or when the gate releases the
535
+ * add claim of a block that is gone — after reading that the binding IS the
536
+ * composer's; a read that fails retracts nothing (D49).
636
537
  */
637
- var NO_FIELDS = /* @__PURE__ */ new Set();
638
- function fieldKey(plan) {
639
- return compositionFieldKey(plan);
538
+ /** A foreign native now serves the grafted cap: the claim stays (Task 10 rule 2). */
539
+ var NATIVE_APPEARED_UNGRAFT_REASON = "a native provider appeared";
540
+ /** The block's add claim was released by the D49 gate (deleted, disabled, feature removed): its graft goes with it. */
541
+ var NO_LONGER_ADDED_UNGRAFT_REASON = "the block no longer adds this cap";
542
+ /** A binding a previous composer process left, that nothing wants any more (I-3). */
543
+ var STALE_GRAFT_RETRACT_REASON = "a graft left by a previous composer process that no plan wants";
544
+ function withEntry(map, key, value) {
545
+ return new Map([...map, [key, value]]);
640
546
  }
641
- function isRecord$1(value) {
642
- return typeof value === "object" && value !== null && !Array.isArray(value);
547
+ function nativeTakenReason(addonId, when) {
548
+ return `a native provider (${addonId}) ${when} — the cap is not grafted and its claim is kept; edit or restart the block to replace the native instead`;
643
549
  }
644
- function valueAtPath$1(value, path) {
645
- return path.split(".").reduce((node, segment) => isRecord$1(node) ? node[segment] : void 0, value);
550
+ var ComposerGrafts = class {
551
+ ports;
552
+ logger;
553
+ readBindings;
554
+ /** Per device: the added caps no graft serves, and why. Reset by each plan of that device. */
555
+ unserved = /* @__PURE__ */ new Map();
556
+ /** Per DESCRIBED device: foreign natives registered since its description was read (cap → addonId). */
557
+ foreignSince = /* @__PURE__ */ new Map();
558
+ constructor(ports, logger, readBindings) {
559
+ this.ports = ports;
560
+ this.logger = logger;
561
+ this.readBindings = readBindings;
562
+ }
563
+ /** A description of `deviceId` is about to be read: it covers every registration before now. */
564
+ describing(deviceId) {
565
+ this.foreignSince = withEntry(this.foreignSince, deviceId, /* @__PURE__ */ new Map());
566
+ }
567
+ onBindingsChanged(data) {
568
+ if (data.addonId === "composer") return;
569
+ this.remember(data);
570
+ const ports = this.ports;
571
+ if (data.reason !== "native-registered" || ports === void 0) return;
572
+ if (!ports.grafted(data.deviceId).has(data.capName)) return;
573
+ this.logger.info("a native provider appeared on a grafted cap — the graft is removed; the claim stays", {
574
+ tags: { deviceId: data.deviceId },
575
+ meta: {
576
+ cap: data.capName,
577
+ addonId: data.addonId
578
+ }
579
+ });
580
+ ports.ungraft(data.deviceId, data.capName, NATIVE_APPEARED_UNGRAFT_REASON);
581
+ this.setUnserved(data.deviceId, data.capName, nativeTakenReason(data.addonId, "appeared"));
582
+ }
583
+ /** Rule 2, at plan time only: graft what the plan adds; ungraft what it finds native; retract what a previous process left. */
584
+ plan(input) {
585
+ const ports = this.ports;
586
+ if (ports === void 0) return;
587
+ const { deviceId } = input;
588
+ const added = input.enabled && input.settled ? addedCapsOf(input.features) : [];
589
+ const replaced = new Set(input.features.filter((f) => f.mode === "replace").map((f) => f.cap));
590
+ for (const cap of ports.grafted(deviceId)) {
591
+ if (!replaced.has(cap)) continue;
592
+ this.logger.info("a native provider serves a grafted cap — the graft is removed; the claim stays", {
593
+ tags: { deviceId },
594
+ meta: { cap }
595
+ });
596
+ ports.ungraft(deviceId, cap, NATIVE_APPEARED_UNGRAFT_REASON);
597
+ }
598
+ if (input.settled) this.retractUnwanted(input, new Set(added));
599
+ const foreign = this.foreignSince.get(deviceId) ?? /* @__PURE__ */ new Map();
600
+ const unserved = /* @__PURE__ */ new Map();
601
+ for (const cap of added) {
602
+ const other = foreign.get(cap);
603
+ if (other !== void 0) {
604
+ const reason = nativeTakenReason(other, "registered while the block was planning");
605
+ this.logger.warn("graft skipped — a native provider serves the cap (native always wins)", {
606
+ tags: { deviceId },
607
+ meta: {
608
+ cap,
609
+ addonId: other
610
+ }
611
+ });
612
+ unserved.set(cap, reason);
613
+ continue;
614
+ }
615
+ const outcome = ports.graft(deviceId, input.ref.stableId, cap);
616
+ if (outcome.kind === "refused") unserved.set(cap, `graft refused: ${outcome.reason}`);
617
+ }
618
+ this.unserved = withEntry(this.unserved, deviceId, unserved);
619
+ }
620
+ /** The gate released a claim: a graft of that cap goes with it; a previous process's binding is retracted once read as the composer's. */
621
+ async released(deviceId, capName) {
622
+ const ports = this.ports;
623
+ if (ports === void 0) return;
624
+ this.setUnserved(deviceId, capName, null);
625
+ if (ports.grafted(deviceId).has(capName)) {
626
+ ports.ungraft(deviceId, capName, NO_LONGER_ADDED_UNGRAFT_REASON);
627
+ return;
628
+ }
629
+ let read;
630
+ try {
631
+ read = await this.readBindings(deviceId);
632
+ } catch (err) {
633
+ read = {
634
+ kind: "unreadable",
635
+ error: errMsg(err)
636
+ };
637
+ }
638
+ if (read.kind === "unreadable") {
639
+ this.logger.warn("a released claim: the bindings could not be read — a graft left by a previous process, if any, is not retracted (D49)", {
640
+ tags: { deviceId },
641
+ meta: {
642
+ cap: capName,
643
+ error: read.error
644
+ }
645
+ });
646
+ return;
647
+ }
648
+ if (read.caps.get(capName) === "composer") ports.retractStale(deviceId, capName, STALE_GRAFT_RETRACT_REASON);
649
+ }
650
+ /** The target row is gone: nothing about it is reported any more. */
651
+ gone(deviceId) {
652
+ this.unserved = new Map([...this.unserved].filter(([id]) => id !== deviceId));
653
+ }
654
+ verdict(deviceId, features) {
655
+ const added = addedCapsOf(features);
656
+ const unserved = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
657
+ const named = added.flatMap((cap) => {
658
+ const reason = unserved.get(cap);
659
+ return reason === void 0 ? [] : [`${cap}: ${reason}`];
660
+ });
661
+ if (named.length === 0) return null;
662
+ return {
663
+ allUnserved: named.length === added.length && added.length === features.length,
664
+ reason: `added caps no graft serves — ${named.join("; ")}`
665
+ };
666
+ }
667
+ retractUnwanted(input, wanted) {
668
+ const ports = this.ports;
669
+ if (ports === void 0) return;
670
+ const own = ports.grafted(input.deviceId);
671
+ for (const [cap, addonId] of input.nativeCaps) {
672
+ if (addonId !== "composer" || own.has(cap) || wanted.has(cap)) continue;
673
+ this.logger.info("a graft left by a previous composer process is retracted", {
674
+ tags: { deviceId: input.deviceId },
675
+ meta: { cap }
676
+ });
677
+ ports.retractStale(input.deviceId, cap, STALE_GRAFT_RETRACT_REASON);
678
+ }
679
+ }
680
+ remember(data) {
681
+ const seen = this.foreignSince.get(data.deviceId);
682
+ if (seen === void 0) return;
683
+ if (data.reason === "native-registered") this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen, [data.capName, data.addonId]]));
684
+ else if (data.reason === "native-unregistered" && seen.get(data.capName) === data.addonId) this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen].filter(([cap]) => cap !== data.capName)));
685
+ }
686
+ setUnserved(deviceId, cap, reason) {
687
+ const current = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
688
+ const next = new Map([...current].filter(([c]) => c !== cap));
689
+ if (reason !== null) next.set(cap, reason);
690
+ this.unserved = withEntry(this.unserved, deviceId, next);
691
+ }
692
+ };
693
+ //#endregion
694
+ //#region src/builtins/composer/composer-live-set.ts
695
+ /**
696
+ * Pure reads of the composer's live set: which sources it must track, which
697
+ * sources an operator action re-opens, and which claims it still WANTS.
698
+ * Nothing here touches a port; the composer acts on the answers.
699
+ */
700
+ /** A release answered with one of these hands back nothing of ours: the gate forgets the claim. */
701
+ var RELEASE_SETTLED_CODES = new Set([
702
+ "not-claimed",
703
+ "unknown-device",
704
+ "owned-by-other"
705
+ ]);
706
+ /**
707
+ * The source keys an operator action on `p` re-opens: every source its fields
708
+ * read, except its own target (re-resolved by `planExisting` already, never
709
+ * twice).
710
+ */
711
+ function operatorResetKeys(p) {
712
+ const targetKey = p.target.kind === "existing" ? compositionSourceKey(p.target.ref) : null;
713
+ const keys = [];
714
+ for (const f of p.fields) for (const r of f.reads) {
715
+ const key = compositionSourceKey(r.source);
716
+ if (key !== targetKey) keys.push(key);
717
+ }
718
+ return keys;
646
719
  }
647
- function initialValue(plan, byCap, lookupCap) {
648
- const slice = byCap?.get(plan.cap);
649
- if (slice === void 0 || slice === null) return void 0;
650
- const item = plan.item;
651
- if (item === void 0) return valueAtPath$1(slice, plan.path);
652
- const itemArray = lookupCap(plan.cap)?.status?.itemArray;
653
- if (itemArray === void 0 || itemArray.path !== item.arrayPath) return void 0;
654
- const entries = valueAtPath$1(slice, item.arrayPath);
655
- if (!Array.isArray(entries)) return void 0;
656
- return valueAtPath$1(entries.find((entry) => isRecord$1(entry) && entry[itemArray.keyField] === item.key), plan.path);
720
+ /** Every source the live set reads, with the view each own cap is read in. */
721
+ function trackedSourceRequests(live) {
722
+ const requests = [];
723
+ for (const e of live) {
724
+ const target = e.planned.target;
725
+ const running = e.runtime !== null && e.planned.block.enabled;
726
+ let targetKey = null;
727
+ if (target.kind === "existing") {
728
+ targetKey = compositionSourceKey(target.ref);
729
+ const self = running ? selfReadCaps(e.planned.fields, target.ref, target.features) : null;
730
+ requests.push({
731
+ ref: target.ref,
732
+ caps: self?.effective ?? [],
733
+ nativeCaps: self?.native ?? [],
734
+ nativeOwner: e.planned.block.id
735
+ });
736
+ }
737
+ if (!running) continue;
738
+ for (const f of e.planned.fields) for (const r of f.reads) if (compositionSourceKey(r.source) !== targetKey) requests.push({
739
+ ref: r.source,
740
+ caps: [r.cap]
741
+ });
742
+ }
743
+ return requests;
657
744
  }
658
- function createFieldRecord(plan, byCap, lookupCap) {
659
- const initial = initialValue(plan, byCap, lookupCap);
745
+ /**
746
+ * Rule 3: the claims every live customization WANTS this apply; everything
747
+ * else the gate knows is a release candidate, handed back once two applies
748
+ * agree. A block that cannot plan — waiting, unreadable, failed, or with its
749
+ * target gone — HOLDS its claims (rule 4, rule 5): they are wanted as they
750
+ * are. A disabled block wants none (rule 7).
751
+ */
752
+ function wantedClaims(live, claimGate) {
753
+ const wanted = [];
754
+ const held = [];
755
+ for (const [blockId, entry] of live) {
756
+ const target = entry.planned.target;
757
+ if (target.kind === "new") continue;
758
+ if (target.kind === "unreadable" || entry.planned.problems.length > 0 || target.waiting !== null || target.gone !== null || target.deviceId === null) {
759
+ held.push(blockId);
760
+ continue;
761
+ }
762
+ if (!entry.planned.block.enabled) continue;
763
+ const deviceId = target.deviceId;
764
+ for (const f of target.features) wanted.push({
765
+ deviceId,
766
+ capName: f.cap,
767
+ owner: blockId
768
+ });
769
+ }
770
+ for (const owner of held) wanted.push(...claimGate.claimsOf(owner));
771
+ return wanted;
772
+ }
773
+ //#endregion
774
+ //#region src/builtins/composer/composition-declarations.ts
775
+ /**
776
+ * Which composed devices exist (D659): one declaration per composition block,
777
+ * owned by `composer` and hung off the shared Blocks integration.
778
+ *
779
+ * `DeclaredDevices` sweeps any row whose declaration is absent. A block missing
780
+ * from ONE apply (a transient store read, a race) would delete the device, and
781
+ * with it its id, exports and rules. The `WithdrawalGate` is D49 for that path:
782
+ * a withdrawal needs a second consecutive apply to agree. The other half of the
783
+ * rule — nothing is declared or withdrawn while the `core_blocks` store has not
784
+ * loaded — is `Composer.apply`'s, which never reaches this gate then (R2).
785
+ */
786
+ function declarationFor(input) {
660
787
  return {
661
- plan,
662
- outcome: null,
663
- initial,
664
- hasWritten: false,
665
- written: void 0,
666
- expressionMemory: createStatefulExpressionMemory(),
667
- eventHighWater: typeof initial === "number" && Number.isFinite(initial) ? initial : null
788
+ stableId: composedDeviceStableId(input.blockId),
789
+ name: input.name,
790
+ type: input.target.type,
791
+ DeviceClass: composedDeviceClass(input.target.type, input.caps),
792
+ ...input.target.role === void 0 ? {} : { role: input.target.role }
668
793
  };
669
794
  }
670
- function currentOutput(record) {
671
- return record.hasWritten ? record.written : record.initial;
672
- }
673
- /** The top-level field a record writes: an item leaf writes its whole array. */
674
- function topLevelField(plan) {
675
- return plan.item === void 0 ? plan.path : plan.item.arrayPath;
676
- }
677
- /** The field as a report or a log line names it: `items[desiccant].status` for an item leaf. */
678
- function fieldPath(plan) {
679
- return plan.item === void 0 ? plan.path : compositionItemFieldPath(plan.item.arrayPath, plan.item.key, plan.path);
680
- }
681
- function itemOf(plan) {
682
- return plan.item === void 0 ? {} : { item: plan.item };
683
- }
684
- function sameSet(a, b) {
685
- return a.size === b.size && [...a].every((x) => b.has(x));
686
- }
687
- function valueOf(outcome) {
688
- return outcome === null || outcome.state === "unavailable" ? void 0 : outcome.value;
689
- }
690
- function reasonOf(outcome) {
691
- if (outcome === null) return "not evaluated yet";
692
- return outcome.reason;
795
+ /** What forces a rebuild of the live device: type, role, cap set. Never the name. */
796
+ function shapeOf(input) {
797
+ return JSON.stringify([
798
+ input.target.type,
799
+ input.target.role ?? null,
800
+ input.caps.map((c) => c.name).toSorted()
801
+ ]);
693
802
  }
694
- /** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
695
- function sameOutcome(a, b) {
696
- return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
803
+ /**
804
+ * The declaration that holds a persisted composed row whose block has no plan
805
+ * in this apply. `name` and `type` are used only if the row has to be CREATED,
806
+ * which `DeclaredDevices` does not do for a row it finds: it adopts it, and the
807
+ * row's own name and type stay canonical (`ComposedPlaceholderDevice`).
808
+ */
809
+ function placeholderDeclaration(stableId) {
810
+ return {
811
+ stableId,
812
+ name: stableId,
813
+ type: DeviceType.Generic,
814
+ DeviceClass: ComposedPlaceholderDevice
815
+ };
697
816
  }
698
- /** A source is GONE: the outcome that must never be masked by a non-fatal one. */
699
- function isFatal(outcome) {
700
- return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
701
- }
702
- /** A `release`-mode field whose source is CONFIRMED unavailable: not the composition's to write (S1b). Stale is a value (D49). */
703
- function isReleased(record) {
704
- const outcome = record.outcome;
705
- return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed === true;
706
- }
707
- /** A `release`-mode field with no value and no ANSWER yet (a failed, first or empty read): held on its last value, never released (D49). */
708
- function isHeld(record) {
709
- const outcome = record.outcome;
710
- return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed !== true;
711
- }
712
- function namedField(r) {
713
- return {
714
- cap: r.plan.cap,
715
- path: r.plan.path,
716
- ...itemOf(r.plan),
717
- reason: reasonOf(r.outcome) ?? "unavailable"
718
- };
719
- }
720
- function writable(record) {
721
- const outcome = record.outcome;
722
- if (outcome === null) return void 0;
723
- if (outcome.state !== "unavailable") return outcome.value;
724
- switch (record.plan.unavailable) {
725
- case "null": return null;
726
- case "unknown": return "unknown";
727
- default: return;
817
+ var WITHDRAWAL_CONFIRMATIONS = 2;
818
+ var WithdrawalGate = class {
819
+ logger;
820
+ deviceIdOf;
821
+ known = /* @__PURE__ */ new Map();
822
+ misses = /* @__PURE__ */ new Map();
823
+ persistedIds = /* @__PURE__ */ new Map();
824
+ constructor(logger, deviceIdOf) {
825
+ this.logger = logger;
826
+ this.deviceIdOf = deviceIdOf;
827
+ }
828
+ /**
829
+ * The rows that exist before this process applied anything. Called once at
830
+ * boot, before the first `filter`: without it the mirror starts empty and a
831
+ * row missing from the first apply after a restart (a release, a block that
832
+ * no longer plans) is swept at once — one apply, not two. A seeded row
833
+ * missing from that apply counts as its FIRST miss and is held by
834
+ * `placeholderDeclaration`. A row the gate already knows keeps its entry and
835
+ * its miss count.
836
+ */
837
+ seed(rows) {
838
+ const fresh = rows.filter((row) => !this.known.has(row.stableId));
839
+ this.known = new Map([...this.known, ...fresh.map((row) => [row.stableId, placeholderDeclaration(row.stableId)])]);
840
+ this.persistedIds = new Map([...this.persistedIds, ...rows.map((row) => [row.stableId, row.id])]);
841
+ }
842
+ filter(current) {
843
+ const present = new Map(current.map((d) => [d.stableId, d]));
844
+ const kept = [];
845
+ const misses = /* @__PURE__ */ new Map();
846
+ for (const [stableId, declaration] of this.known) {
847
+ if (present.has(stableId)) continue;
848
+ const count = (this.misses.get(stableId) ?? 0) + 1;
849
+ if (count < WITHDRAWAL_CONFIRMATIONS) {
850
+ this.logger.warn("a composition vanished from one apply — its device is kept until a second apply agrees", {
851
+ tags: this.tagsOf(stableId),
852
+ meta: {
853
+ stableId,
854
+ misses: count
855
+ }
856
+ });
857
+ kept.push(declaration);
858
+ misses.set(stableId, count);
859
+ continue;
860
+ }
861
+ this.logger.info("a composition is gone on two consecutive applies — its device is withdrawn", {
862
+ tags: this.tagsOf(stableId),
863
+ meta: { stableId }
864
+ });
865
+ }
866
+ this.known = new Map([...kept.map((d) => [d.stableId, d]), ...present]);
867
+ this.misses = misses;
868
+ return [...current, ...kept];
869
+ }
870
+ /**
871
+ * The live device's id, else the persisted row's. A declaration whose device
872
+ * was never built and never persisted has no numeric id to carry: the
873
+ * stableId in `meta` is then all there is.
874
+ */
875
+ tagsOf(stableId) {
876
+ const deviceId = this.deviceIdOf(stableId) ?? this.persistedIds.get(stableId) ?? null;
877
+ return deviceId === null ? {} : { deviceId };
728
878
  }
729
- }
730
- function errorText(error) {
731
- return error instanceof Error ? error.message : String(error);
732
- }
733
- function pendingReason(cap, pending) {
734
- return pending.kind === "refused" ? `the \`${cap}\` capability refused the value: ${pending.error}` : `writing the \`${cap}\` capability threw: ${pending.error}`;
735
- }
736
- //#endregion
737
- //#region src/builtins/composer/own-field-reads.ts
738
- /** Derived fields before accumulators: a count reads the value its own pass just computed. */
739
- var EVALUATION_ORDER = {
740
- derived: 0,
741
- accumulator: 1
742
879
  };
743
- /** `keys` in evaluation order: derived, then accumulators, then the rest; stable within a rank. */
744
- function orderForEvaluation(keys, records) {
745
- const rank = (k) => EVALUATION_ORDER[records.get(k)?.plan.role ?? ""] ?? 2;
746
- return [...keys].toSorted((a, b) => rank(a) - rank(b));
747
- }
880
+ //#endregion
881
+ //#region src/builtins/composer/composer-plan.ts
748
882
  /**
749
- * The own field `key` as the runtime holds it right now (`records`: the pass in
750
- * progress) — exactly what the runtime PUBLISHES for it, which is what the hub
751
- * answered before (review H1). An unavailable field under the `null` or
752
- * `unknown` policy is published as `null` / `'unknown'`, so a reader sees that
753
- * value; only a field nothing is written for (`release`, `offline`) reads as
754
- * unavailable. Answering those as unavailable left `count` unprimed while a
755
- * source was late at boot, and it then swallowed the first press as its seed.
883
+ * What one apply PLANS for a block, before anything is declared, claimed or
884
+ * run (D659, D663). Pure over its inputs: the composer hands in what it knows
885
+ * (its rows, the previous plan, the tracker) and gets a `PlannedBlock` back.
756
886
  *
757
- * Not evaluated yet is unavailable: nothing is published, and unknown is never
758
- * `null` (D393). The evaluation order makes this unreachable for a derived
759
- * reader of a derived field. Never fatal here: a gone source fails the block
760
- * through the field that reads it directly.
887
+ * - A `new` target plans as in slice 1: a declaration, a shape, a placeholder
888
+ * when the composition stopped planning but its row exists.
889
+ * - An `existing` target declares NOTHING (the row is not the composer's). It
890
+ * is resolved through the tracker's path, described with one bindings read
891
+ * per plan change, and planned against that description. A settled plan is
892
+ * kept until the OPERATOR acts (edit, restart): a bindings change alone never
893
+ * re-plans (Task 10 rule 2, D659). While the claims index is unseeded or the
894
+ * target is unresolved or undescribed, the block WAITS by name and claims
895
+ * nothing (D49). A target gone on two agreeing index reads is `gone`.
896
+ * - An unreadable row names no target: whatever it holds is held (D49).
761
897
  */
762
- function ownFieldReading(records, key) {
763
- const record = records.get(key);
764
- const outcome = record?.outcome ?? null;
765
- if (record === void 0 || outcome === null) return {
766
- state: "unavailable",
767
- reason: `own field \`${key}\` not evaluated yet`,
768
- fatal: false
898
+ /** The shape of a placeholder: no caps, so no plan to take a shape from. */
899
+ var PLACEHOLDER_SHAPE = "placeholder";
900
+ var NO_PLAN = {
901
+ fields: [],
902
+ events: [],
903
+ problems: [],
904
+ declaration: null,
905
+ shape: null,
906
+ spec: null
907
+ };
908
+ /**
909
+ * A row that exists but does not parse is held exactly like a composition
910
+ * that stopped planning: its device (if it has a row) stays as an offline
911
+ * placeholder, its claims (if any) are kept, and the block fails naming the
912
+ * parse error. Absent, it would be withdrawn on the second apply that omitted
913
+ * it (D49).
914
+ */
915
+ function planUnreadable(block, rows) {
916
+ const stableId = composedDeviceStableId(block.id);
917
+ const hasRow = rows.has(stableId);
918
+ return {
919
+ ...NO_PLAN,
920
+ block: {
921
+ id: block.id,
922
+ name: block.name,
923
+ enabled: false,
924
+ updatedAt: 0
925
+ },
926
+ problems: [`the stored block does not parse — ${block.error}`],
927
+ declaration: hasRow ? placeholderDeclaration(stableId) : null,
928
+ shape: hasRow ? PLACEHOLDER_SHAPE : null,
929
+ target: { kind: "unreadable" }
769
930
  };
770
- if (outcome.state === "unavailable") {
771
- const published = writable(record);
772
- if (published !== void 0) return {
773
- state: "fresh",
774
- value: published
775
- };
931
+ }
932
+ /** `target` is the block's own `new` target, narrowed by the caller: this path never sees an existing one (M-5). */
933
+ function planNew(block, target, lookupCap, rows) {
934
+ const plan = planComposition(block.composition, lookupCap);
935
+ const planned = { kind: "new" };
936
+ if (plan.problems.length > 0) {
937
+ const stableId = composedDeviceStableId(block.id);
938
+ const hasRow = rows.has(stableId);
776
939
  return {
777
- state: "unavailable",
778
- reason: `own field \`${key}\`: ${outcome.reason}`,
779
- fatal: false,
780
- ...outcome.confirmed === true ? { confirmed: true } : {}
940
+ ...NO_PLAN,
941
+ block,
942
+ problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
943
+ declaration: hasRow ? placeholderDeclaration(stableId) : null,
944
+ shape: hasRow ? PLACEHOLDER_SHAPE : null,
945
+ target: planned
781
946
  };
782
947
  }
783
- if (outcome.state === "stale") return {
784
- state: "stale",
785
- value: outcome.value,
786
- reason: `own field \`${key}\`: ${outcome.reason ?? "stale"}`
948
+ const caps = block.composition.features.flatMap((f) => {
949
+ if (!compositionFeatureEnabled(f)) return [];
950
+ const def = lookupCap(f.cap);
951
+ return def === null ? [] : [def];
952
+ });
953
+ const declarationInput = {
954
+ blockId: block.id,
955
+ name: block.name,
956
+ target,
957
+ caps
787
958
  };
788
959
  return {
789
- state: "fresh",
790
- value: outcome.value
960
+ block,
961
+ fields: plan.fields,
962
+ events: plan.events,
963
+ problems: [],
964
+ declaration: declarationFor(declarationInput),
965
+ shape: shapeOf(declarationInput),
966
+ spec: {
967
+ type: target.type,
968
+ ...target.role === void 0 ? {} : { role: target.role }
969
+ },
970
+ target: planned
791
971
  };
792
972
  }
793
- //#endregion
794
- //#region src/builtins/composer/composition-evaluation.ts
795
- /**
796
- * Evaluating a composition's fields (D659, D670): one pass over the keys a
797
- * change affects, the own-field readers it must re-run (D670 amendment), and
798
- * the state-derived events it emits. Split from `composition-runtime.ts`,
799
- * which owns the records and hands them in through {@link EvaluationHost}.
800
- *
801
- * - Stateful expression memory commits only when the whole expression
802
- * succeeds.
803
- * - A declared state-derived event advances its high-water and is emitted
804
- * BEFORE the corresponding target write; the committed records are visible
805
- * to a synchronous subscriber first.
806
- * - Derived fields are evaluated before accumulators, and every field reading
807
- * an own field that changed is re-evaluated in the same pass.
808
- */
809
- function strictFiniteAdvance(previous, next) {
810
- return typeof next === "number" && Number.isFinite(next) && (previous === null || next > previous);
811
- }
812
- /** The record key an own read is answered from; null when `read` is a source's. */
813
- function ownKeyOf(host, read) {
814
- if (host.isOwnRead === void 0 || !host.isOwnRead(read)) return null;
815
- const key = `${read.cap}.${read.fieldPath}`;
816
- return host.records().has(key) ? key : null;
817
- }
818
- /**
819
- * `keys`, derived before accumulators, then — in the same pass — every
820
- * derived/accumulator field that reads an own field that changed. Bounded by
821
- * the record count: the validator refuses a cycle, so a chain is at most that
822
- * long (D6).
823
- */
824
- function evaluateWithOwnReaders(host, keys, now) {
825
- const changed = /* @__PURE__ */ new Set();
826
- const valueCaps = /* @__PURE__ */ new Set();
827
- let round = orderForEvaluation(keys, host.records());
828
- for (let hop = 0; round.length > 0 && hop <= host.records().size; hop++) {
829
- const delta = evaluateFields(host, round, now);
830
- for (const k of delta.keys) changed.add(k);
831
- for (const c of delta.valueCaps) valueCaps.add(c);
832
- round = orderForEvaluation([...host.records().values()].filter((r) => (r.plan.role === "derived" || r.plan.role === "accumulator") && r.plan.reads.some((read) => {
833
- const own = ownKeyOf(host, read);
834
- return own !== null && delta.keys.has(own);
835
- })).map((r) => fieldKey(r.plan)), host.records());
836
- }
837
- return {
838
- keys: changed,
839
- valueCaps
973
+ async function planExisting(input) {
974
+ const { block, previous, operatorAction } = input;
975
+ const prior = previous?.target;
976
+ if (!operatorAction && previous !== void 0 && prior?.kind === "existing" && prior.waiting === null) return {
977
+ ...previous,
978
+ block
840
979
  };
841
- }
842
- /** Evaluate `keys` in order against the records in progress; commit whatever was evaluated, even on a throw. */
843
- function evaluateFields(host, keys, now) {
844
- const changed = /* @__PURE__ */ new Set();
845
- const valueCaps = /* @__PURE__ */ new Set();
846
- const next = new Map(host.records());
847
- const read = (r) => {
848
- const own = ownKeyOf(host, r);
849
- return own === null ? host.read(r) : ownFieldReading(next, own);
980
+ const ref = input.target.device;
981
+ const key = compositionSourceKey(ref);
982
+ const knownId = prior?.kind === "existing" ? prior.deviceId : null;
983
+ if (input.claimsWait !== null) return waitingPlan(block, ref, knownId, input.claimsWait);
984
+ const resolution = await input.tracker.resolve(ref, operatorAction);
985
+ if (resolution.kind === "unknown") return waitingPlan(block, ref, knownId, `the target device \`${key}\` could not be resolved (${resolution.error}) — nothing is claimed until it is; retried on the next apply`);
986
+ const existing = {
987
+ kind: "existing",
988
+ ref,
989
+ deviceId: resolution.kind === "found" ? resolution.deviceId : knownId,
990
+ waiting: null,
991
+ gone: null,
992
+ features: [],
993
+ nativeCaps: /* @__PURE__ */ new Map()
850
994
  };
851
- try {
852
- for (const key of keys) {
853
- const record = next.get(key);
854
- if (record === void 0) continue;
855
- const candidateMemory = cloneStatefulExpressionMemory(record.expressionMemory);
856
- const outcome = evaluateCompositionField({
857
- source: record.plan.source,
858
- read,
859
- now,
860
- stateful: {
861
- currentOutput: currentOutput(record),
862
- expressionMemory: candidateMemory,
863
- startedAtMs: host.startedAtMs,
864
- callPathPrefix: fieldKey(record.plan)
865
- }
866
- });
867
- const expressionSucceeded = outcome.state !== "unavailable";
868
- let evaluated = {
869
- ...record,
870
- outcome,
871
- ...expressionSucceeded ? { expressionMemory: candidateMemory } : {}
872
- };
873
- const eventPlans = host.eventPlans.filter((eventPlan) => eventPlan.fieldKey === fieldKey(record.plan));
874
- if (expressionSucceeded && eventPlans.length > 0 && strictFiniteAdvance(record.eventHighWater, outcome.value)) {
875
- const timestamp = outcome.value;
876
- evaluated = {
877
- ...evaluated,
878
- eventHighWater: timestamp
879
- };
880
- next.set(key, evaluated);
881
- host.commit(next);
882
- for (const eventPlan of eventPlans) host.emit(eventPlan, timestamp);
883
- } else next.set(key, evaluated);
884
- if (sameOutcome(record.outcome, outcome)) continue;
885
- if (record.outcome?.state !== outcome.state) host.logTransition(record, outcome);
886
- if (valueOf(record.outcome) !== valueOf(outcome)) valueCaps.add(record.plan.cap);
887
- changed.add(key);
995
+ if (resolution.kind === "missing") return {
996
+ ...NO_PLAN,
997
+ block,
998
+ target: {
999
+ ...existing,
1000
+ gone: `${resolution.reason} — two index reads agreed`
888
1001
  }
889
- } finally {
890
- host.commit(next);
891
- }
1002
+ };
1003
+ const description = await input.targets.describe(ref, resolution.deviceId, resolution.type, operatorAction);
1004
+ if (description === null) return waitingPlan(block, ref, resolution.deviceId, `the target device \`${key}\` could not be described (its bindings did not read) — nothing is claimed until it is; retried on the next apply`);
1005
+ const plan = planComposition(block.composition, input.lookupCap, description);
892
1006
  return {
893
- keys: changed,
894
- valueCaps
1007
+ ...NO_PLAN,
1008
+ block,
1009
+ fields: plan.problems.length > 0 ? [] : plan.fields,
1010
+ events: plan.problems.length > 0 ? [] : plan.events,
1011
+ problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
1012
+ target: {
1013
+ ...existing,
1014
+ features: plan.features,
1015
+ nativeCaps: description.nativeCaps
1016
+ }
895
1017
  };
896
1018
  }
897
- /** Emit one state-derived event; a delivery failure is logged, never thrown (D670). */
898
- function emitStateDerivedEvent(ctx, plan, timestamp) {
899
- const extras = {
900
- tags: { deviceId: ctx.deviceId },
901
- meta: {
902
- blockId: ctx.blockId,
903
- cap: plan.cap,
904
- eventName: plan.eventName,
905
- category: plan.category,
906
- timestamp
1019
+ function waitingPlan(block, ref, deviceId, waiting) {
1020
+ return {
1021
+ ...NO_PLAN,
1022
+ block,
1023
+ target: {
1024
+ kind: "existing",
1025
+ ref,
1026
+ deviceId,
1027
+ waiting,
1028
+ gone: null,
1029
+ features: [],
1030
+ nativeCaps: /* @__PURE__ */ new Map()
907
1031
  }
908
1032
  };
909
- try {
910
- if (plan.category !== EventCategory.DoorbellOnPressed) throw new Error(`unsupported state-derived event category \`${plan.category}\``);
911
- const event = createEvent(EventCategory.DoorbellOnPressed, {
912
- type: "device",
913
- id: ctx.deviceId,
914
- deviceId: ctx.deviceId
915
- }, {
916
- deviceId: ctx.deviceId,
917
- timestamp
918
- });
919
- ctx.events.emit(event);
920
- ctx.logger.debug("composition: state-derived event emitted", extras);
921
- } catch (error) {
922
- ctx.logger.warn("composition: state-derived event delivery failed", {
923
- ...extras,
924
- meta: {
925
- ...extras.meta,
926
- error: errorText(error)
927
- }
928
- });
929
- }
930
1033
  }
931
- //#endregion
932
- //#region src/builtins/composer/slice-assembler.ts
933
1034
  /**
934
- * From the runtime's flat field values to the slice a target takes (D663).
935
- *
936
- * A top-level field is written under its path. An item leaf
937
- * (`consumables.items[desiccant].remaining.value`) is one entry of an ARRAY:
938
- * every leaf of one item is grouped by `(cap, arrayPath, key)` and set, by
939
- * path segment, onto the cap's OWN `status.itemArray.emptyItem` — so an
940
- * unsourced nullable leaf is `null` because the cap says so, not because this
941
- * module guessed. The key, the label and {@link COMPOSED_ITEM_FIXED_FIELDS}
942
- * are the composer's; items are sorted by key so two writes of the same
943
- * values are one slice.
944
- *
945
- * A cap the lookup does not know as an item array cannot be assembled: that
946
- * is thrown by name, and the runtime's write path turns a throw into a named,
947
- * retried failure — never a half-built array.
1035
+ * Runtime preparation can still fail after a complete existing-target plan.
1036
+ * Preserve its fields and description for retry, but mark it waiting so claim
1037
+ * reconciliation cannot treat the unprepared candidate as live.
948
1038
  */
949
- function isRecord(value) {
950
- return typeof value === "object" && value !== null && !Array.isArray(value);
1039
+ function withExistingTargetWait(p, waiting) {
1040
+ return p.target.kind === "existing" ? {
1041
+ ...p,
1042
+ target: {
1043
+ ...p.target,
1044
+ waiting
1045
+ }
1046
+ } : p;
951
1047
  }
952
- /** `setPath(item, ['remaining', 'value'], 0)` → `{ ...item, remaining: { ...item.remaining, value: 0 } }`. */
953
- function setPath(record, segments, value) {
954
- const [head, ...rest] = segments;
955
- if (head === void 0) return record;
956
- if (rest.length === 0) return {
957
- ...record,
958
- [head]: value
959
- };
960
- const current = record[head];
1048
+ /** What `logPlanTransition` compares: problems, and for an existing target its wait or its loss. */
1049
+ function planSummary(p) {
1050
+ const target = p.target;
1051
+ return (target.kind === "existing" ? [
1052
+ p.problems.join("; "),
1053
+ target.waiting ?? "",
1054
+ target.gone ?? ""
1055
+ ] : [p.problems.join("; ")]).filter((part) => part !== "").join(" | ");
1056
+ }
1057
+ /**
1058
+ * Rule 5 on the PLAN path: `device.unregistered` is telemetry and may be
1059
+ * dropped (D8), so a target is often found gone by the operator's next edit or
1060
+ * restart. The previous live target is what must be ungrafted — exactly as on
1061
+ * the event path. Null when nothing was lost by THIS plan.
1062
+ */
1063
+ function lostTarget(p, previous) {
1064
+ const now = p.target;
1065
+ const before = previous?.target;
1066
+ if (now.kind !== "existing" || now.gone === null || before?.kind !== "existing") return null;
1067
+ if (before.deviceId === null || before.gone !== null) return null;
961
1068
  return {
962
- ...record,
963
- [head]: setPath(isRecord(current) ? current : {}, rest, value)
1069
+ target: before,
1070
+ deviceId: before.deviceId
964
1071
  };
965
1072
  }
966
- function itemArrayOf(group, lookupCap) {
967
- const itemArray = lookupCap(group.cap)?.status?.itemArray;
968
- if (itemArray === void 0 || itemArray.path !== group.arrayPath) throw new Error(`\`${group.cap}\` has no item array at \`${group.arrayPath}\` — \`${compositionItemFieldPath(group.arrayPath, group.key, "*")}\` cannot be assembled`);
969
- return itemArray;
1073
+ /** Two plans write the same place: both new, or the very same existing-target plan (reused until the operator acts). */
1074
+ function sameTarget(a, b) {
1075
+ return a === b || a.kind === "new" && b.kind === "new";
970
1076
  }
971
- function assembleItem(group, lookupCap) {
972
- const itemArray = itemArrayOf(group, lookupCap);
973
- const base = {
974
- ...itemArray.emptyItem,
975
- [itemArray.keyField]: group.key,
976
- ...itemArray.labelField === void 0 ? {} : { [itemArray.labelField]: group.label },
977
- ...COMPOSED_ITEM_FIXED_FIELDS
1077
+ /** One line when a block starts or stops planning (or waiting, or loses its target) — never one per apply. */
1078
+ function logPlanTransition(logger, previous, p, liveTags) {
1079
+ if (previous !== void 0 && planSummary(previous) === planSummary(p)) return;
1080
+ if (previous === void 0 && planSummary(p) === "") return;
1081
+ const target = p.target;
1082
+ const extras = {
1083
+ tags: target.kind === "existing" && target.deviceId !== null ? { deviceId: target.deviceId } : liveTags,
1084
+ meta: {
1085
+ blockId: p.block.id,
1086
+ problems: p.problems,
1087
+ placeholder: p.declaration !== null
1088
+ }
978
1089
  };
979
- return group.leaves.reduce((item, leaf) => setPath(item, leaf.plan.path.split("."), leaf.value), base);
1090
+ if (target.kind === "existing" && target.gone !== null) logger.error("customization target device is gone — the block failed; only the operator ends this (edit or restart the block)", {
1091
+ ...extras,
1092
+ meta: {
1093
+ ...extras.meta,
1094
+ target: compositionSourceKey(target.ref)
1095
+ }
1096
+ });
1097
+ else if (target.kind === "existing" && target.waiting !== null) logger.warn("customization waits — nothing is claimed or released until it plans", {
1098
+ ...extras,
1099
+ meta: {
1100
+ ...extras.meta,
1101
+ reason: target.waiting
1102
+ }
1103
+ });
1104
+ else if (p.problems.length === 0) logger.info("composition plans again", extras);
1105
+ else logger.warn(p.declaration === null ? "composition does not plan — no device is declared for it" : "composition no longer plans — its device is kept offline with no capabilities", extras);
980
1106
  }
981
- function groupItems(entries) {
982
- const groups = /* @__PURE__ */ new Map();
983
- for (const entry of entries) {
984
- const item = entry.plan.item;
985
- if (item === void 0) continue;
986
- const id = `${entry.plan.cap}\u0000${item.arrayPath}\u0000${item.key}`;
987
- const group = groups.get(id) ?? {
988
- cap: entry.plan.cap,
989
- arrayPath: item.arrayPath,
990
- key: item.key,
991
- label: item.label,
992
- leaves: []
993
- };
994
- groups.set(id, {
995
- ...group,
996
- leaves: [...group.leaves, entry]
997
- });
998
- }
999
- return [...groups.values()];
1107
+ //#endregion
1108
+ //#region src/builtins/composer/field-record.ts
1109
+ /**
1110
+ * One composed field's record and the pure judgements over it (D659, D663):
1111
+ * what it may write, whether it is released (a CONFIRMED unavailability of a
1112
+ * `release`-mode field, S1b) or HELD (no answer yet, D49), and how a report or a
1113
+ * log line names it. Split from `composition-runtime.ts`, which holds the
1114
+ * behaviour.
1115
+ */
1116
+ var NO_FIELDS = /* @__PURE__ */ new Set();
1117
+ function fieldKey(plan) {
1118
+ return compositionFieldKey(plan);
1000
1119
  }
1001
- function compareKeys(a, b) {
1002
- return a < b ? -1 : a > b ? 1 : 0;
1120
+ function isRecord$1(value) {
1121
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1003
1122
  }
1004
- function assembleSlice(entries, lookupCap) {
1005
- const top = Object.fromEntries(entries.filter((e) => e.plan.item === void 0).map((e) => [e.plan.path, e.value]));
1006
- const groups = groupItems(entries);
1007
- const arrays = [...new Set(groups.map((g) => g.arrayPath))].map((arrayPath) => [arrayPath, groups.filter((g) => g.arrayPath === arrayPath).toSorted((a, b) => compareKeys(a.key, b.key)).map((g) => assembleItem(g, lookupCap))]);
1123
+ function valueAtPath$1(value, path) {
1124
+ return path.split(".").reduce((node, segment) => isRecord$1(node) ? node[segment] : void 0, value);
1125
+ }
1126
+ function initialValue(plan, byCap, lookupCap) {
1127
+ const slice = byCap?.get(plan.cap);
1128
+ if (slice === void 0 || slice === null) return void 0;
1129
+ const item = plan.item;
1130
+ if (item === void 0) return valueAtPath$1(slice, plan.path);
1131
+ const itemArray = lookupCap(plan.cap)?.status?.itemArray;
1132
+ if (itemArray === void 0 || itemArray.path !== item.arrayPath) return void 0;
1133
+ const entries = valueAtPath$1(slice, item.arrayPath);
1134
+ if (!Array.isArray(entries)) return void 0;
1135
+ return valueAtPath$1(entries.find((entry) => isRecord$1(entry) && entry[itemArray.keyField] === item.key), plan.path);
1136
+ }
1137
+ function createFieldRecord(plan, byCap, lookupCap) {
1138
+ const initial = initialValue(plan, byCap, lookupCap);
1008
1139
  return {
1009
- ...top,
1010
- ...Object.fromEntries(arrays)
1140
+ plan,
1141
+ outcome: null,
1142
+ initial,
1143
+ hasWritten: false,
1144
+ written: void 0,
1145
+ expressionMemory: createStatefulExpressionMemory(),
1146
+ eventHighWater: typeof initial === "number" && Number.isFinite(initial) ? initial : null
1147
+ };
1148
+ }
1149
+ function currentOutput(record) {
1150
+ return record.hasWritten ? record.written : record.initial;
1151
+ }
1152
+ /** The top-level field a record writes: an item leaf writes its whole array. */
1153
+ function topLevelField(plan) {
1154
+ return plan.item === void 0 ? plan.path : plan.item.arrayPath;
1155
+ }
1156
+ /** The field as a report or a log line names it: `items[desiccant].status` for an item leaf. */
1157
+ function fieldPath(plan) {
1158
+ return plan.item === void 0 ? plan.path : compositionItemFieldPath(plan.item.arrayPath, plan.item.key, plan.path);
1159
+ }
1160
+ function itemOf(plan) {
1161
+ return plan.item === void 0 ? {} : { item: plan.item };
1162
+ }
1163
+ function sameSet(a, b) {
1164
+ return a.size === b.size && [...a].every((x) => b.has(x));
1165
+ }
1166
+ function valueOf(outcome) {
1167
+ return outcome === null || outcome.state === "unavailable" ? void 0 : outcome.value;
1168
+ }
1169
+ function reasonOf(outcome) {
1170
+ if (outcome === null) return "not evaluated yet";
1171
+ return outcome.reason;
1172
+ }
1173
+ /** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
1174
+ function sameOutcome(a, b) {
1175
+ return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
1176
+ }
1177
+ /** A source is GONE: the outcome that must never be masked by a non-fatal one. */
1178
+ function isFatal(outcome) {
1179
+ return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
1180
+ }
1181
+ /** A `release`-mode field whose source is CONFIRMED unavailable: not the composition's to write (S1b). Stale is a value (D49). */
1182
+ function isReleased(record) {
1183
+ const outcome = record.outcome;
1184
+ return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed === true;
1185
+ }
1186
+ /** A `release`-mode field with no value and no ANSWER yet (a failed, first or empty read): held on its last value, never released (D49). */
1187
+ function isHeld(record) {
1188
+ const outcome = record.outcome;
1189
+ return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed !== true;
1190
+ }
1191
+ function namedField(r) {
1192
+ return {
1193
+ cap: r.plan.cap,
1194
+ path: r.plan.path,
1195
+ ...itemOf(r.plan),
1196
+ reason: reasonOf(r.outcome) ?? "unavailable"
1011
1197
  };
1012
1198
  }
1199
+ function writable(record) {
1200
+ const outcome = record.outcome;
1201
+ if (outcome === null) return void 0;
1202
+ if (outcome.state !== "unavailable") return outcome.value;
1203
+ switch (record.plan.unavailable) {
1204
+ case "null": return null;
1205
+ case "unknown": return "unknown";
1206
+ default: return;
1207
+ }
1208
+ }
1209
+ function errorText(error) {
1210
+ return error instanceof Error ? error.message : String(error);
1211
+ }
1212
+ function pendingReason(cap, pending) {
1213
+ return pending.kind === "refused" ? `the \`${cap}\` capability refused the value: ${pending.error}` : `writing the \`${cap}\` capability threw: ${pending.error}`;
1214
+ }
1013
1215
  //#endregion
1014
- //#region src/builtins/composer/composition-runtime.ts
1216
+ //#region src/builtins/composer/own-field-reads.ts
1217
+ /** Derived fields before accumulators: a count reads the value its own pass just computed. */
1218
+ var EVALUATION_ORDER = {
1219
+ derived: 0,
1220
+ accumulator: 1
1221
+ };
1222
+ /** `keys` in evaluation order: derived, then accumulators, then the rest; stable within a rank. */
1223
+ function orderForEvaluation(keys, records) {
1224
+ const rank = (k) => EVALUATION_ORDER[records.get(k)?.plan.role ?? ""] ?? 2;
1225
+ return [...keys].toSorted((a, b) => rank(a) - rank(b));
1226
+ }
1015
1227
  /**
1016
- * One running composition: per-field state, writes to the composed device, and
1017
- * one deadline timer (D659).
1018
- *
1019
- * - Only the fields a change affects are recomputed.
1020
- * - A cap is SEEDED once (`setCapState`, every required field present), then
1021
- * PATCHED (`patchCapState` merges; the runtime-state twin of "`setAll`
1022
- * already merges").
1023
- * - Unavailable is per field. A nullable field is written `null`; a
1024
- * non-nullable one is not written and the device goes offline (D393).
1025
- * - A `clock` field (`now` only) is recomputed when a sibling of its cap
1026
- * changes value, so `binary.lastChangedAt` is the last transition. A
1027
- * `constant` is computed once.
1028
- * - A windowed expression reports when it flips; ONE timer at the earliest
1029
- * such instant. It waits for no source (D3). Every evaluation is STORED, so
1030
- * a deadline that moved reaches the timer even when the value did not: an
1031
- * unstored deadline fires early, re-evaluates to "same", and re-arms
1032
- * `setTimeout(0)` until the real window closes — a poll (R1).
1033
- * - A fatal (source gone) outcome is the block's failure, and it is sticky:
1034
- * the device stays offline until a new runtime, created on an operator edit
1035
- * or restart, clears it.
1036
- * - A write the target REFUSES or THROWS on leaves the evaluated outcomes
1037
- * alone and marks its cap write-pending: the next pass that evaluates any
1038
- * field of that cap, and every deadline pass, re-writes the WHOLE current
1039
- * slice (a seed if it was never seeded). No retry timer — the next source
1040
- * event or deadline is the retry (D3). One log line per failure transition.
1041
- * - Nothing escapes: a pass that throws is logged, and the timer is re-armed
1042
- * from the stored deadlines regardless.
1043
- * - `stop()` is terminal: every entry point is a no-op afterwards.
1044
- *
1045
- * Slice 2 (D663):
1046
- * - Records are keyed by `compositionFieldKey`: two items' `status` are two
1047
- * records. An item-array cap is written WHOLE — a change to one item leaf
1048
- * re-sends every item, assembled by `assembleSlice` on the cap's `emptyItem`.
1049
- * A required item leaf with no fallback holds the array, named once.
1050
- * - `'unknown'` unavailable mode writes the enum's own word.
1051
- * - `'release'` unavailable mode hands the field BACK (ruling S1b): the target
1052
- * is told to `withhold` it, the field's record forgets what it wrote, and the
1053
- * source returning re-widens and writes it again. Stale is a value (D49),
1054
- * never a release — and neither is an UNCONFIRMED unavailability (a failed,
1055
- * first or empty read): the field is HELD on its last value, not written,
1056
- * not withheld, its claim kept, until the source answers (D49, fix round 2).
1057
- * `state().released` / `state().held` name every such field for the report.
1058
- * - A target whose writes are asynchronous (claims on the hub) reports what did
1059
- * not land through `onAsyncFailure`: the cap is marked write-pending by name
1060
- * and re-written whole on the next pass — no retry timer (D3).
1228
+ * The own field `key` as the runtime holds it right now (`records`: the pass in
1229
+ * progress) — exactly what the runtime PUBLISHES for it, which is what the hub
1230
+ * answered before (review H1). An unavailable field under the `null` or
1231
+ * `unknown` policy is published as `null` / `'unknown'`, so a reader sees that
1232
+ * value; only a field nothing is written for (`release`, `offline`) reads as
1233
+ * unavailable. Answering those as unavailable left `count` unprimed while a
1234
+ * source was late at boot, and it then swallowed the first press as its seed.
1061
1235
  *
1062
- * Slice 2b (D670): evaluation — stateful memory, state-derived events before
1063
- * their write, and reads of the composition's OWN fields answered from the
1064
- * records rather than the hub (D670 amendment) — is `composition-evaluation.ts`.
1065
- * Neither an event-bus failure nor a refused write can replay an event, and the
1066
- * hub's echo of the runtime's own write re-runs nothing.
1236
+ * Not evaluated yet is unavailable: nothing is published, and unknown is never
1237
+ * `null` (D393). The evaluation order makes this unreachable for a derived
1238
+ * reader of a derived field. Never fatal here: a gone source fails the block
1239
+ * through the field that reads it directly.
1067
1240
  */
1241
+ function ownFieldReading(records, key) {
1242
+ const record = records.get(key);
1243
+ const outcome = record?.outcome ?? null;
1244
+ if (record === void 0 || outcome === null) return {
1245
+ state: "unavailable",
1246
+ reason: `own field \`${key}\` not evaluated yet`,
1247
+ fatal: false
1248
+ };
1249
+ if (outcome.state === "unavailable") {
1250
+ const published = writable(record);
1251
+ if (published !== void 0) return {
1252
+ state: "fresh",
1253
+ value: published
1254
+ };
1255
+ return {
1256
+ state: "unavailable",
1257
+ reason: `own field \`${key}\`: ${outcome.reason}`,
1258
+ fatal: false,
1259
+ ...outcome.confirmed === true ? { confirmed: true } : {}
1260
+ };
1261
+ }
1262
+ if (outcome.state === "stale") return {
1263
+ state: "stale",
1264
+ value: outcome.value,
1265
+ reason: `own field \`${key}\`: ${outcome.reason ?? "stale"}`
1266
+ };
1267
+ return {
1268
+ state: "fresh",
1269
+ value: outcome.value
1270
+ };
1271
+ }
1272
+ //#endregion
1273
+ //#region src/builtins/composer/composition-evaluation.ts
1068
1274
  /**
1069
- * A refusal nothing on this side can retry away: another owner holds the
1070
- * field, the slice is invalid for the cap, the device is not known. Every
1071
- * other outcome — a throw, an unreadable row, a migration in flight, a lost
1072
- * claim — is transient and retried on the next pass (D6: bounded, never a loop).
1275
+ * Evaluating a composition's fields (D659, D670): one pass over the keys a
1276
+ * change affects, the own-field readers it must re-run (D670 amendment), and
1277
+ * the state-derived events it emits. Split from `composition-runtime.ts`,
1278
+ * which owns the records and hands them in through {@link EvaluationHost}.
1279
+ *
1280
+ * - Stateful expression memory commits only when the whole expression
1281
+ * succeeds.
1282
+ * - A declared state-derived event advances its high-water and is emitted
1283
+ * BEFORE the corresponding target write; the committed records are visible
1284
+ * to a synchronous subscriber first.
1285
+ * - Derived fields are evaluated before accumulators, and every field reading
1286
+ * an own field that changed is re-evaluated in the same pass.
1073
1287
  */
1074
- var TERMINAL_REFUSALS = new Set([
1075
- "owned-by-other",
1076
- "invalid-slice",
1077
- "unknown-device"
1078
- ]);
1079
- var CompositionRuntime = class {
1080
- deps;
1081
- records;
1082
- startedAtMs;
1083
- seeded = /* @__PURE__ */ new Set();
1084
- available = null;
1085
- failure = null;
1086
- disabledReason = null;
1087
- stopped = false;
1088
- /** The last `pass threw` logged (`during: message`); cleared by a pass that completes. */
1089
- lastPassError = null;
1090
- pending = /* @__PURE__ */ new Map();
1091
- heldBack = /* @__PURE__ */ new Set();
1092
- /** Caps whose item array is not written because a required leaf has no value. */
1093
- itemsHeld = /* @__PURE__ */ new Set();
1094
- /** Record keys currently released (S1b), for transition logs and the report. */
1095
- released = /* @__PURE__ */ new Set();
1096
- /** Per cap, the top-level fields the target last accepted as withheld; absent = none. */
1097
- withheld = /* @__PURE__ */ new Map();
1098
- /** Per record key, how many times the field has been released — churn the transition line reports (D391). */
1099
- releaseFlaps = /* @__PURE__ */ new Map();
1100
- /** Caps the target refused for good (`TERMINAL_REFUSALS`): never written again by this runtime. */
1101
- terminal = /* @__PURE__ */ new Map();
1102
- /** What `composition-evaluation.ts` reads and commits through. */
1103
- evaluation;
1104
- constructor(deps) {
1105
- this.deps = deps;
1106
- this.startedAtMs = deps.now();
1107
- this.records = new Map(deps.fields.map((plan) => [fieldKey(plan), createFieldRecord(plan, deps.initialByCap, deps.lookupCap)]));
1108
- deps.target.onAsyncFailure?.((cap, failure) => this.onTargetWriteFailed(cap, failure));
1109
- const eventContext = {
1110
- deviceId: deps.target.id,
1111
- blockId: deps.blockId,
1112
- events: deps.events,
1113
- logger: deps.logger
1114
- };
1115
- this.evaluation = {
1116
- records: () => this.records,
1117
- commit: (records) => {
1118
- this.records = records;
1119
- },
1120
- read: deps.read,
1121
- ...deps.isOwnRead === void 0 ? {} : { isOwnRead: deps.isOwnRead },
1122
- eventPlans: deps.eventPlans,
1123
- startedAtMs: this.startedAtMs,
1124
- emit: (plan, timestamp) => emitStateDerivedEvent(eventContext, plan, timestamp),
1125
- logTransition: (record, outcome) => this.logTransition(record, outcome)
1126
- };
1127
- }
1128
- start() {
1129
- if (this.disabledReason !== null) return;
1130
- this.guarded("start", true, () => this.pass([...this.records.keys()], "start"));
1131
- }
1132
- onSourcesChanged(sliceKeys) {
1133
- if (this.disabledReason !== null) return;
1134
- this.guarded("source", true, () => {
1135
- const affected = [...this.records.values()].filter((r) => r.plan.reads.some((read) => ownKeyOf(this.evaluation, read) === null && sliceKeys.has(compositionSliceKey(read.source, read.cap)))).map((r) => fieldKey(r.plan));
1136
- if (affected.length > 0) this.pass(affected, "source");
1137
- });
1138
- }
1139
- disable(reason) {
1140
- if (this.stopped) return;
1141
- this.disabledReason = reason;
1142
- this.deps.timer.disarm();
1143
- this.guarded("disable", false, () => {
1144
- const outcome = {
1145
- state: "unavailable",
1146
- value: null,
1147
- reason,
1148
- fatal: false,
1149
- confirmed: true
1150
- };
1151
- const touched = [...this.records.values()].filter((r) => !isFatal(r.outcome) && !sameOutcome(r.outcome, outcome));
1152
- for (const r of touched) this.logTransition(r, outcome);
1153
- this.records = new Map([...this.records].map(([k, r]) => [k, isFatal(r.outcome) ? r : {
1154
- ...r,
1155
- outcome
1156
- }]));
1157
- this.writeCaps(new Set(touched.map((r) => fieldKey(r.plan))), /* @__PURE__ */ new Set());
1158
- this.updateAvailability();
1159
- });
1160
- }
1161
- /** Terminal: no write, no evaluation and no timer after this. */
1162
- stop() {
1163
- this.stopped = true;
1164
- this.deps.timer.disarm();
1165
- }
1166
- state() {
1167
- return {
1168
- failure: this.failure,
1169
- available: this.available === true,
1170
- fields: [...this.records.values()].map((r) => {
1171
- const terminal = this.terminal.get(r.plan.cap);
1172
- if (terminal !== void 0 && !isFatal(r.outcome)) return {
1173
- cap: r.plan.cap,
1174
- path: r.plan.path,
1175
- ...itemOf(r.plan),
1176
- state: "unavailable",
1177
- value: null,
1178
- reason: terminal
1179
- };
1180
- const pending = this.pending.get(r.plan.cap);
1181
- if (pending !== void 0 && !isFatal(r.outcome)) return {
1182
- cap: r.plan.cap,
1183
- path: r.plan.path,
1184
- ...itemOf(r.plan),
1185
- state: "unavailable",
1186
- value: null,
1187
- reason: pendingReason(r.plan.cap, pending)
1188
- };
1189
- const value = valueOf(r.outcome);
1190
- const reason = reasonOf(r.outcome);
1191
- return {
1192
- cap: r.plan.cap,
1193
- path: r.plan.path,
1194
- ...itemOf(r.plan),
1195
- state: r.outcome?.state ?? "unavailable",
1196
- value: value === void 0 ? null : value,
1197
- ...reason === null ? {} : { reason }
1198
- };
1199
- }),
1200
- released: [...this.records.values()].filter(isReleased).map(namedField),
1201
- held: [...this.records.values()].filter(isHeld).map(namedField)
1202
- };
1203
- }
1204
- /**
1205
- * The target's enqueued write of `cap` did not land: the cap is write-pending
1206
- * by name and re-written WHOLE on the next pass that evaluates it (or the next
1207
- * deadline pass). A withhold sent to that target is forgotten too, so it is
1208
- * re-sent. No retry timer (D3).
1209
- */
1210
- onTargetWriteFailed(cap, failure) {
1211
- this.guarded("target-failure", false, () => {
1212
- if (!this.deps.fields.some((f) => f.cap === cap) || this.terminal.has(cap)) return;
1213
- if (failure.kind === "refused" && TERMINAL_REFUSALS.has(failure.code)) {
1214
- this.markTerminal(cap, failure.code, failure.message);
1215
- return;
1216
- }
1217
- this.withheld = new Map([...this.withheld].filter(([c]) => c !== cap));
1218
- this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failure.kind === "threw" ? {
1219
- kind: "threw",
1220
- error: failure.error
1221
- } : {
1222
- kind: "refused",
1223
- error: `${failure.code}: ${failure.message}`
1224
- });
1225
- this.updateAvailability();
1226
- });
1227
- }
1228
- /**
1229
- * The target refused this cap for good: nothing on this side can retry it
1230
- * away (I-3). The cap is never written again by this runtime, its fields
1231
- * report the refusal, and the block fails naming it — only the operator ends
1232
- * this (edit or delete the block, or the competing one), like a gone source.
1233
- */
1234
- markTerminal(cap, code, message) {
1235
- const reason = `the \`${cap}\` capability was refused for good: ${code}: ${message} — only the operator ends this (edit or delete the block)`;
1236
- this.terminal = new Map([...this.terminal, [cap, reason]]);
1237
- this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1238
- if (this.failure === null) this.failure = `composed write refused for good — ${cap}: ${code}: ${message}`;
1239
- this.deps.logger.error("composed capability refused for good — it is not written again until the operator edits or deletes the block", {
1240
- tags: { deviceId: this.deps.target.id },
1241
- meta: {
1242
- blockId: this.deps.blockId,
1243
- cap,
1244
- code,
1245
- error: message
1246
- }
1247
- });
1248
- this.updateAvailability();
1249
- }
1250
- /**
1251
- * Every entry point runs here: a no-op once stopped, nothing thrown escapes,
1252
- * and the one timer is re-armed from the stored deadlines whatever happened.
1253
- */
1254
- guarded(during, rearm, body) {
1255
- if (this.stopped) return;
1256
- let threw = false;
1257
- try {
1258
- body();
1259
- this.lastPassError = null;
1260
- } catch (error) {
1261
- threw = true;
1262
- this.logPassError(during, errorText(error));
1263
- } finally {
1264
- if (rearm && !this.stopped && this.disabledReason === null) this.armDeadline(threw ? this.deps.now() : null);
1265
- }
1266
- }
1267
- /** Once per distinct failure, not once per pass: a reader that keeps throwing logs one line. */
1268
- logPassError(during, error) {
1269
- const key = `${during}: ${error}`;
1270
- if (key === this.lastPassError) return;
1271
- this.lastPassError = key;
1272
- this.deps.logger.error("composition pass threw — the runtime keeps running; the next source event or deadline re-runs it", {
1273
- tags: { deviceId: this.deps.target.id },
1274
- meta: {
1275
- blockId: this.deps.blockId,
1276
- during,
1277
- error
1278
- }
1279
- });
1280
- }
1281
- pass(keys, trigger) {
1282
- const initial = trigger === "start";
1283
- const now = this.deps.now();
1284
- const calculated = evaluateWithOwnReaders(this.evaluation, keys.filter((k) => {
1285
- const role = this.records.get(k)?.plan.role;
1286
- return role === "derived" || role === "accumulator";
1287
- }), now);
1288
- const followers = [...this.records.values()].filter((r) => r.plan.role === "clock" ? initial || calculated.valueCaps.has(r.plan.cap) : r.plan.role === "constant" && initial).map((r) => fieldKey(r.plan));
1289
- const extra = evaluateFields(this.evaluation, followers, now);
1290
- const evaluatedCaps = new Set([...keys, ...followers].flatMap((k) => {
1291
- const cap = this.records.get(k)?.plan.cap;
1292
- return cap === void 0 ? [] : [cap];
1293
- }));
1294
- const retry = new Set([...this.pending.keys()].filter((cap) => trigger === "deadline" || evaluatedCaps.has(cap)));
1295
- this.writeCaps(new Set([...calculated.keys, ...extra.keys]), retry);
1296
- this.updateFailure();
1297
- this.updateAvailability();
1298
- }
1299
- recordsOf(cap) {
1300
- return [...this.records.values()].filter((r) => r.plan.cap === cap);
1301
- }
1302
- /** `retry`: write-pending caps this pass re-writes WHOLE, whether or not a value changed. */
1303
- writeCaps(touched, retry) {
1304
- const caps = new Set([...[...touched].flatMap((k) => {
1305
- const cap = this.records.get(k)?.plan.cap;
1306
- return cap === void 0 ? [] : [cap];
1307
- }), ...retry]);
1308
- for (const cap of caps) {
1309
- if (this.terminal.has(cap)) continue;
1310
- const sync = this.syncReleased(cap);
1311
- if (sync === "failed") continue;
1312
- if (!this.seeded.has(cap)) this.seedCap(cap);
1313
- else if (this.pending.has(cap) || sync === "changed") this.patchWholeCap(cap, sync === "changed");
1314
- else this.patchCap(cap, touched);
1315
- }
1316
- }
1317
- /**
1318
- * S1b: tell the target which fields of `cap` are released, when that set
1319
- * changed since it last accepted one. A released record forgets what it wrote,
1320
- * so the source returning writes it again. False when the target refused or
1321
- * threw: the cap is write-pending and the withhold is re-sent next pass.
1322
- */
1323
- syncReleased(cap) {
1324
- const records = this.recordsOf(cap);
1325
- this.logReleaseTransitions(cap, records);
1326
- const releasedRecords = records.filter(isReleased);
1327
- this.records = new Map([...this.records].map(([k, r]) => releasedRecords.includes(r) && r.hasWritten ? [k, {
1328
- ...r,
1329
- hasWritten: false,
1330
- written: void 0
1331
- }] : [k, r]));
1332
- const withhold = this.deps.target.withhold;
1333
- if (withhold === void 0) return "unchanged";
1334
- const fields = new Set(releasedRecords.map((r) => topLevelField(r.plan)));
1335
- if (sameSet(fields, this.withheld.get(cap) ?? NO_FIELDS)) return "unchanged";
1336
- let failed;
1337
- try {
1338
- const outcome = withhold.call(this.deps.target, cap, [...fields].toSorted());
1339
- failed = outcome.ok ? null : {
1340
- kind: "refused",
1341
- error: outcome.error
1342
- };
1343
- } catch (error) {
1344
- failed = {
1345
- kind: "threw",
1346
- error: errorText(error)
1347
- };
1348
- }
1349
- if (failed !== null) {
1350
- this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failed);
1351
- return "failed";
1352
- }
1353
- this.withheld = new Map([...this.withheld.entries()].filter(([c]) => c !== cap).concat(fields.size === 0 ? [] : [[cap, fields]]));
1354
- return "changed";
1288
+ function strictFiniteAdvance(previous, next) {
1289
+ return typeof next === "number" && Number.isFinite(next) && (previous === null || next > previous);
1290
+ }
1291
+ /** The record key an own read is answered from; null when `read` is a source's. */
1292
+ function ownKeyOf(host, read) {
1293
+ if (host.isOwnRead === void 0 || !host.isOwnRead(read)) return null;
1294
+ const key = `${read.cap}.${read.fieldPath}`;
1295
+ return host.records().has(key) ? key : null;
1296
+ }
1297
+ /**
1298
+ * `keys`, derived before accumulators, then — in the same pass — every
1299
+ * derived/accumulator field that reads an own field that changed. Bounded by
1300
+ * the record count: the validator refuses a cycle, so a chain is at most that
1301
+ * long (D6).
1302
+ */
1303
+ function evaluateWithOwnReaders(host, keys, now) {
1304
+ const changed = /* @__PURE__ */ new Set();
1305
+ const valueCaps = /* @__PURE__ */ new Set();
1306
+ let round = orderForEvaluation(keys, host.records());
1307
+ for (let hop = 0; round.length > 0 && hop <= host.records().size; hop++) {
1308
+ const delta = evaluateFields(host, round, now);
1309
+ for (const k of delta.keys) changed.add(k);
1310
+ for (const c of delta.valueCaps) valueCaps.add(c);
1311
+ round = orderForEvaluation([...host.records().values()].filter((r) => (r.plan.role === "derived" || r.plan.role === "accumulator") && r.plan.reads.some((read) => {
1312
+ const own = ownKeyOf(host, read);
1313
+ return own !== null && delta.keys.has(own);
1314
+ })).map((r) => fieldKey(r.plan)), host.records());
1355
1315
  }
1356
- /** One info line per field per direction, never one per pass. */
1357
- logReleaseTransitions(cap, records) {
1358
- const now = new Set(records.filter(isReleased).map((r) => fieldKey(r.plan)));
1359
- const before = new Set(records.filter((r) => this.released.has(fieldKey(r.plan))).map((r) => fieldKey(r.plan)));
1360
- for (const r of records) {
1361
- const key = fieldKey(r.plan);
1362
- const direction = now.has(key) && !before.has(key) ? "released" : !now.has(key) && before.has(key) ? "reclaimed" : null;
1363
- if (direction === null) continue;
1364
- const flaps = (this.releaseFlaps.get(key) ?? 0) + (direction === "released" ? 1 : 0);
1365
- this.releaseFlaps = new Map([...this.releaseFlaps, [key, flaps]]);
1366
- this.deps.logger.info(direction === "released" ? "composed field released to the native provider — its source is unavailable" : "composed field reclaimed from the native provider — its source is back", {
1367
- tags: { deviceId: this.deps.target.id },
1368
- meta: {
1369
- blockId: this.deps.blockId,
1370
- cap,
1371
- field: fieldPath(r.plan),
1372
- direction,
1373
- reason: reasonOf(r.outcome) ?? "value available again",
1374
- flaps
1316
+ return {
1317
+ keys: changed,
1318
+ valueCaps
1319
+ };
1320
+ }
1321
+ /** Evaluate `keys` in order against the records in progress; commit whatever was evaluated, even on a throw. */
1322
+ function evaluateFields(host, keys, now) {
1323
+ const changed = /* @__PURE__ */ new Set();
1324
+ const valueCaps = /* @__PURE__ */ new Set();
1325
+ const next = new Map(host.records());
1326
+ const read = (r) => {
1327
+ const own = ownKeyOf(host, r);
1328
+ return own === null ? host.read(r) : ownFieldReading(next, own);
1329
+ };
1330
+ try {
1331
+ for (const key of keys) {
1332
+ const record = next.get(key);
1333
+ if (record === void 0) continue;
1334
+ const candidateMemory = cloneStatefulExpressionMemory(record.expressionMemory);
1335
+ const outcome = evaluateCompositionField({
1336
+ source: record.plan.source,
1337
+ read,
1338
+ now,
1339
+ stateful: {
1340
+ currentOutput: currentOutput(record),
1341
+ expressionMemory: candidateMemory,
1342
+ startedAtMs: host.startedAtMs,
1343
+ callPathPrefix: fieldKey(record.plan)
1375
1344
  }
1376
1345
  });
1346
+ const expressionSucceeded = outcome.state !== "unavailable";
1347
+ let evaluated = {
1348
+ ...record,
1349
+ outcome,
1350
+ ...expressionSucceeded ? { expressionMemory: candidateMemory } : {}
1351
+ };
1352
+ const eventPlans = host.eventPlans.filter((eventPlan) => eventPlan.fieldKey === fieldKey(record.plan));
1353
+ if (expressionSucceeded && eventPlans.length > 0 && strictFiniteAdvance(record.eventHighWater, outcome.value)) {
1354
+ const timestamp = outcome.value;
1355
+ evaluated = {
1356
+ ...evaluated,
1357
+ eventHighWater: timestamp
1358
+ };
1359
+ next.set(key, evaluated);
1360
+ host.commit(next);
1361
+ for (const eventPlan of eventPlans) host.emit(eventPlan, timestamp);
1362
+ } else next.set(key, evaluated);
1363
+ if (sameOutcome(record.outcome, outcome)) continue;
1364
+ if (record.outcome?.state !== outcome.state) host.logTransition(record, outcome);
1365
+ if (valueOf(record.outcome) !== valueOf(outcome)) valueCaps.add(record.plan.cap);
1366
+ changed.add(key);
1377
1367
  }
1378
- this.released = new Set([...[...this.released].filter((k) => !before.has(k)), ...now]);
1379
- }
1380
- seedCap(cap) {
1381
- const records = this.recordsOf(cap);
1382
- const missing = records.filter((r) => (r.plan.required || isHeld(r)) && writable(r) === void 0 && !isReleased(r)).map((r) => fieldPath(r.plan));
1383
- if (missing.length > 0) return this.holdBackSeed(cap, missing);
1384
- this.heldBack = new Set([...this.heldBack].filter((c) => c !== cap));
1385
- const entries = this.entriesFor(cap, records);
1386
- if (!this.write(cap, "seed", entries)) return;
1387
- this.seeded = new Set([...this.seeded, cap]);
1388
- this.markWritten(cap, entries);
1389
- }
1390
- patchCap(cap, touched) {
1391
- const entries = this.entriesFor(cap, this.recordsOf(cap).filter((r) => touched.has(fieldKey(r.plan)) && (!r.hasWritten || writable(r) !== r.written)));
1392
- if (entries.length === 0) return;
1393
- if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
1368
+ } finally {
1369
+ host.commit(next);
1394
1370
  }
1395
- /**
1396
- * After a failed write nothing is known about what landed, and after a
1397
- * changed withhold the claim must be re-stated: send every current value.
1398
- * `afterWithhold` with nothing left to write means the cap was released
1399
- * whole — a pending write is moot then, not retried (M-2).
1400
- */
1401
- patchWholeCap(cap, afterWithhold = false) {
1402
- const entries = this.entriesFor(cap, this.recordsOf(cap));
1403
- if (entries.length === 0) {
1404
- if (afterWithhold && this.pending.has(cap)) this.dropPending(cap);
1405
- return;
1371
+ return {
1372
+ keys: changed,
1373
+ valueCaps
1374
+ };
1375
+ }
1376
+ /** Emit one state-derived event; a delivery failure is logged, never thrown (D670). */
1377
+ function emitStateDerivedEvent(ctx, plan, timestamp) {
1378
+ const extras = {
1379
+ tags: { deviceId: ctx.deviceId },
1380
+ meta: {
1381
+ blockId: ctx.blockId,
1382
+ cap: plan.cap,
1383
+ eventName: plan.eventName,
1384
+ category: plan.category,
1385
+ timestamp
1406
1386
  }
1407
- if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
1408
- }
1409
- dropPending(cap) {
1410
- this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1411
- this.deps.logger.info("composed capability released whole — its pending write is moot, nothing is left to write", {
1412
- tags: { deviceId: this.deps.target.id },
1387
+ };
1388
+ try {
1389
+ if (plan.category !== EventCategory.DoorbellOnPressed) throw new Error(`unsupported state-derived event category \`${plan.category}\``);
1390
+ const event = createEvent(EventCategory.DoorbellOnPressed, {
1391
+ type: "device",
1392
+ id: ctx.deviceId,
1393
+ deviceId: ctx.deviceId
1394
+ }, {
1395
+ deviceId: ctx.deviceId,
1396
+ timestamp
1397
+ });
1398
+ ctx.events.emit(event);
1399
+ ctx.logger.debug("composition: state-derived event emitted", extras);
1400
+ } catch (error) {
1401
+ ctx.logger.warn("composition: state-derived event delivery failed", {
1402
+ ...extras,
1413
1403
  meta: {
1414
- blockId: this.deps.blockId,
1415
- cap
1404
+ ...extras.meta,
1405
+ error: errorText(error)
1416
1406
  }
1417
1407
  });
1418
1408
  }
1419
- /**
1420
- * What one write of `cap` carries: the writable `candidates` — and, when any
1421
- * of them is an item leaf, EVERY item leaf of the cap, because the array is
1422
- * written whole (a patch of `items` replaces it). A required item leaf with
1423
- * no value holds the whole array (its top-level siblings still go out).
1424
- */
1425
- entriesFor(cap, candidates) {
1426
- const top = candidates.filter((r) => r.plan.item === void 0);
1427
- if (!candidates.some((r) => r.plan.item !== void 0)) return this.writableEntries(top);
1428
- const items = this.recordsOf(cap).filter((r) => r.plan.item !== void 0);
1429
- const missing = items.filter((r) => r.plan.required && writable(r) === void 0).map((r) => fieldPath(r.plan));
1430
- if (missing.length > 0) {
1431
- this.holdItems(cap, missing);
1432
- return this.writableEntries(top);
1433
- }
1434
- this.itemsHeld = new Set([...this.itemsHeld].filter((c) => c !== cap));
1435
- return this.writableEntries([...top, ...items]);
1436
- }
1437
- writableEntries(records) {
1438
- return records.flatMap((r) => {
1439
- const value = writable(r);
1440
- return value === void 0 ? [] : [{
1441
- plan: r.plan,
1442
- value
1443
- }];
1409
+ }
1410
+ //#endregion
1411
+ //#region src/builtins/composer/slice-assembler.ts
1412
+ /**
1413
+ * From the runtime's flat field values to the slice a target takes (D663).
1414
+ *
1415
+ * A top-level field is written under its path. An item leaf
1416
+ * (`consumables.items[desiccant].remaining.value`) is one entry of an ARRAY:
1417
+ * every leaf of one item is grouped by `(cap, arrayPath, key)` and set, by
1418
+ * path segment, onto the cap's OWN `status.itemArray.emptyItem` — so an
1419
+ * unsourced nullable leaf is `null` because the cap says so, not because this
1420
+ * module guessed. The key, the label and {@link COMPOSED_ITEM_FIXED_FIELDS}
1421
+ * are the composer's; items are sorted by key so two writes of the same
1422
+ * values are one slice.
1423
+ *
1424
+ * A cap the lookup does not know as an item array cannot be assembled: that
1425
+ * is thrown by name, and the runtime's write path turns a throw into a named,
1426
+ * retried failure — never a half-built array.
1427
+ */
1428
+ function isRecord(value) {
1429
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1430
+ }
1431
+ /** `setPath(item, ['remaining', 'value'], 0)` → `{ ...item, remaining: { ...item.remaining, value: 0 } }`. */
1432
+ function setPath(record, segments, value) {
1433
+ const [head, ...rest] = segments;
1434
+ if (head === void 0) return record;
1435
+ if (rest.length === 0) return {
1436
+ ...record,
1437
+ [head]: value
1438
+ };
1439
+ const current = record[head];
1440
+ return {
1441
+ ...record,
1442
+ [head]: setPath(isRecord(current) ? current : {}, rest, value)
1443
+ };
1444
+ }
1445
+ function itemArrayOf(group, lookupCap) {
1446
+ const itemArray = lookupCap(group.cap)?.status?.itemArray;
1447
+ if (itemArray === void 0 || itemArray.path !== group.arrayPath) throw new Error(`\`${group.cap}\` has no item array at \`${group.arrayPath}\` — \`${compositionItemFieldPath(group.arrayPath, group.key, "*")}\` cannot be assembled`);
1448
+ return itemArray;
1449
+ }
1450
+ function assembleItem(group, lookupCap) {
1451
+ const itemArray = itemArrayOf(group, lookupCap);
1452
+ const base = {
1453
+ ...itemArray.emptyItem,
1454
+ [itemArray.keyField]: group.key,
1455
+ ...itemArray.labelField === void 0 ? {} : { [itemArray.labelField]: group.label },
1456
+ ...COMPOSED_ITEM_FIXED_FIELDS
1457
+ };
1458
+ return group.leaves.reduce((item, leaf) => setPath(item, leaf.plan.path.split("."), leaf.value), base);
1459
+ }
1460
+ function groupItems(entries) {
1461
+ const groups = /* @__PURE__ */ new Map();
1462
+ for (const entry of entries) {
1463
+ const item = entry.plan.item;
1464
+ if (item === void 0) continue;
1465
+ const id = `${entry.plan.cap}\u0000${item.arrayPath}\u0000${item.key}`;
1466
+ const group = groups.get(id) ?? {
1467
+ cap: entry.plan.cap,
1468
+ arrayPath: item.arrayPath,
1469
+ key: item.key,
1470
+ label: item.label,
1471
+ leaves: []
1472
+ };
1473
+ groups.set(id, {
1474
+ ...group,
1475
+ leaves: [...group.leaves, entry]
1444
1476
  });
1445
1477
  }
1446
- holdItems(cap, missing) {
1447
- if (this.itemsHeld.has(cap)) return;
1448
- this.itemsHeld = new Set([...this.itemsHeld, cap]);
1449
- this.deps.logger.warn("composed items held — a required item field has no value; the array is not written until it does", {
1450
- tags: { deviceId: this.deps.target.id },
1451
- meta: {
1452
- blockId: this.deps.blockId,
1453
- cap,
1454
- missing
1455
- }
1456
- });
1478
+ return [...groups.values()];
1479
+ }
1480
+ function compareKeys(a, b) {
1481
+ return a < b ? -1 : a > b ? 1 : 0;
1482
+ }
1483
+ function assembleSlice(entries, lookupCap) {
1484
+ const top = Object.fromEntries(entries.filter((e) => e.plan.item === void 0).map((e) => [e.plan.path, e.value]));
1485
+ const groups = groupItems(entries);
1486
+ const arrays = [...new Set(groups.map((g) => g.arrayPath))].map((arrayPath) => [arrayPath, groups.filter((g) => g.arrayPath === arrayPath).toSorted((a, b) => compareKeys(a.key, b.key)).map((g) => assembleItem(g, lookupCap))]);
1487
+ return {
1488
+ ...top,
1489
+ ...Object.fromEntries(arrays)
1490
+ };
1491
+ }
1492
+ //#endregion
1493
+ //#region src/builtins/composer/composition-runtime.ts
1494
+ /**
1495
+ * One running composition: per-field state, writes to the composed device, and
1496
+ * one deadline timer (D659).
1497
+ *
1498
+ * - Only the fields a change affects are recomputed.
1499
+ * - A cap is SEEDED once (`setCapState`, every required field present), then
1500
+ * PATCHED (`patchCapState` merges; the runtime-state twin of "`setAll`
1501
+ * already merges").
1502
+ * - Unavailable is per field. A nullable field is written `null`; a
1503
+ * non-nullable one is not written and the device goes offline (D393).
1504
+ * - A `clock` field (`now` only) is recomputed when a sibling of its cap
1505
+ * changes value, so `binary.lastChangedAt` is the last transition. A
1506
+ * `constant` is computed once.
1507
+ * - A windowed expression reports when it flips; ONE timer at the earliest
1508
+ * such instant. It waits for no source (D3). Every evaluation is STORED, so
1509
+ * a deadline that moved reaches the timer even when the value did not: an
1510
+ * unstored deadline fires early, re-evaluates to "same", and re-arms
1511
+ * `setTimeout(0)` until the real window closes — a poll (R1).
1512
+ * - A fatal (source gone) outcome is the block's failure, and it is sticky:
1513
+ * the device stays offline until a new runtime, created on an operator edit
1514
+ * or restart, clears it.
1515
+ * - A write the target REFUSES or THROWS on leaves the evaluated outcomes
1516
+ * alone and marks its cap write-pending: the next pass that evaluates any
1517
+ * field of that cap, and every deadline pass, re-writes the WHOLE current
1518
+ * slice (a seed if it was never seeded). No retry timer — the next source
1519
+ * event or deadline is the retry (D3). One log line per failure transition.
1520
+ * - Nothing escapes: a pass that throws is logged, and the timer is re-armed
1521
+ * from the stored deadlines regardless.
1522
+ * - `stop()` is terminal: every entry point is a no-op afterwards.
1523
+ *
1524
+ * Slice 2 (D663):
1525
+ * - Records are keyed by `compositionFieldKey`: two items' `status` are two
1526
+ * records. An item-array cap is written WHOLE — a change to one item leaf
1527
+ * re-sends every item, assembled by `assembleSlice` on the cap's `emptyItem`.
1528
+ * A required item leaf with no fallback holds the array, named once.
1529
+ * - `'unknown'` unavailable mode writes the enum's own word.
1530
+ * - `'release'` unavailable mode hands the field BACK (ruling S1b): the target
1531
+ * is told to `withhold` it, the field's record forgets what it wrote, and the
1532
+ * source returning re-widens and writes it again. Stale is a value (D49),
1533
+ * never a release — and neither is an UNCONFIRMED unavailability (a failed,
1534
+ * first or empty read): the field is HELD on its last value, not written,
1535
+ * not withheld, its claim kept, until the source answers (D49, fix round 2).
1536
+ * `state().released` / `state().held` name every such field for the report.
1537
+ * - A target whose writes are asynchronous (claims on the hub) reports what did
1538
+ * not land through `onAsyncFailure`: the cap is marked write-pending by name
1539
+ * and re-written whole on the next pass — no retry timer (D3).
1540
+ *
1541
+ * Slice 2b (D670): evaluation — stateful memory, state-derived events before
1542
+ * their write, and reads of the composition's OWN fields answered from the
1543
+ * records rather than the hub (D670 amendment) — is `composition-evaluation.ts`.
1544
+ * Neither an event-bus failure nor a refused write can replay an event, and the
1545
+ * hub's echo of the runtime's own write re-runs nothing.
1546
+ */
1547
+ /**
1548
+ * A refusal nothing on this side can retry away: another owner holds the
1549
+ * field, the slice is invalid for the cap, the device is not known. Every
1550
+ * other outcome — a throw, an unreadable row, a migration in flight, a lost
1551
+ * claim — is transient and retried on the next pass (D6: bounded, never a loop).
1552
+ */
1553
+ var TERMINAL_REFUSALS = new Set([
1554
+ "owned-by-other",
1555
+ "invalid-slice",
1556
+ "unknown-device"
1557
+ ]);
1558
+ var CompositionRuntime = class {
1559
+ deps;
1560
+ records;
1561
+ startedAtMs;
1562
+ seeded = /* @__PURE__ */ new Set();
1563
+ available = null;
1564
+ failure = null;
1565
+ disabledReason = null;
1566
+ stopped = false;
1567
+ /** The last `pass threw` logged (`during: message`); cleared by a pass that completes. */
1568
+ lastPassError = null;
1569
+ pending = /* @__PURE__ */ new Map();
1570
+ heldBack = /* @__PURE__ */ new Set();
1571
+ /** Caps whose item array is not written because a required leaf has no value. */
1572
+ itemsHeld = /* @__PURE__ */ new Set();
1573
+ /** Record keys currently released (S1b), for transition logs and the report. */
1574
+ released = /* @__PURE__ */ new Set();
1575
+ /** Per cap, the top-level fields the target last accepted as withheld; absent = none. */
1576
+ withheld = /* @__PURE__ */ new Map();
1577
+ /** Per record key, how many times the field has been released — churn the transition line reports (D391). */
1578
+ releaseFlaps = /* @__PURE__ */ new Map();
1579
+ /** Caps the target refused for good (`TERMINAL_REFUSALS`): never written again by this runtime. */
1580
+ terminal = /* @__PURE__ */ new Map();
1581
+ /** What `composition-evaluation.ts` reads and commits through. */
1582
+ evaluation;
1583
+ constructor(deps) {
1584
+ this.deps = deps;
1585
+ this.startedAtMs = deps.now();
1586
+ this.records = new Map(deps.fields.map((plan) => [fieldKey(plan), createFieldRecord(plan, deps.initialByCap, deps.lookupCap)]));
1587
+ deps.target.onAsyncFailure?.((cap, failure) => this.onTargetWriteFailed(cap, failure));
1588
+ const eventContext = {
1589
+ deviceId: deps.target.id,
1590
+ blockId: deps.blockId,
1591
+ events: deps.events,
1592
+ logger: deps.logger
1593
+ };
1594
+ this.evaluation = {
1595
+ records: () => this.records,
1596
+ commit: (records) => {
1597
+ this.records = records;
1598
+ },
1599
+ read: deps.read,
1600
+ ...deps.isOwnRead === void 0 ? {} : { isOwnRead: deps.isOwnRead },
1601
+ eventPlans: deps.eventPlans,
1602
+ startedAtMs: this.startedAtMs,
1603
+ emit: (plan, timestamp) => emitStateDerivedEvent(eventContext, plan, timestamp),
1604
+ logTransition: (record, outcome) => this.logTransition(record, outcome)
1605
+ };
1606
+ }
1607
+ start() {
1608
+ if (this.disabledReason !== null) return;
1609
+ this.guarded("start", true, () => this.pass([...this.records.keys()], "start"));
1457
1610
  }
1458
- holdBackSeed(cap, missing) {
1459
- if (this.heldBack.has(cap)) return;
1460
- this.heldBack = new Set([...this.heldBack, cap]);
1461
- this.deps.logger.warn("composed capability not seeded yet — a required field has no value", {
1462
- tags: { deviceId: this.deps.target.id },
1463
- meta: {
1464
- blockId: this.deps.blockId,
1465
- cap,
1466
- missing
1467
- }
1611
+ onSourcesChanged(sliceKeys) {
1612
+ if (this.disabledReason !== null) return;
1613
+ this.guarded("source", true, () => {
1614
+ const affected = [...this.records.values()].filter((r) => r.plan.reads.some((read) => ownKeyOf(this.evaluation, read) === null && sliceKeys.has(compositionSliceKey(read.source, read.cap)))).map((r) => fieldKey(r.plan));
1615
+ if (affected.length > 0) this.pass(affected, "source");
1468
1616
  });
1469
1617
  }
1470
- /** One write. A refusal or a throw marks the cap write-pending; true when the target took it. */
1471
- write(cap, op, entries) {
1472
- let failed;
1473
- try {
1474
- const values = assembleSlice(entries, this.deps.lookupCap);
1475
- const outcome = op === "seed" ? this.deps.target.seed(cap, values) : this.deps.target.patch(cap, values);
1476
- failed = outcome.ok ? null : {
1477
- kind: "refused",
1478
- error: outcome.error
1479
- };
1480
- } catch (error) {
1481
- failed = {
1482
- kind: "threw",
1483
- error: errorText(error)
1618
+ disable(reason) {
1619
+ if (this.stopped) return;
1620
+ this.disabledReason = reason;
1621
+ this.deps.timer.disarm();
1622
+ this.guarded("disable", false, () => {
1623
+ const outcome = {
1624
+ state: "unavailable",
1625
+ value: null,
1626
+ reason,
1627
+ fatal: false,
1628
+ confirmed: true
1484
1629
  };
1485
- }
1486
- if (failed === null) {
1487
- this.clearPending(cap);
1488
- return true;
1489
- }
1490
- this.markPending(cap, op, failed);
1491
- return false;
1492
- }
1493
- clearPending(cap) {
1494
- if (!this.pending.has(cap)) return;
1495
- this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1496
- this.deps.logger.info("composed capability written again after a failed write", {
1497
- tags: { deviceId: this.deps.target.id },
1498
- meta: {
1499
- blockId: this.deps.blockId,
1500
- cap
1501
- }
1502
- });
1503
- }
1504
- markPending(cap, op, failed) {
1505
- const wasPending = this.pending.has(cap);
1506
- this.pending = new Map([...this.pending, [cap, failed]]);
1507
- if (wasPending) return;
1508
- const message = failed.kind === "refused" ? "composed capability refused a write — its whole slice is re-written on the next pass that evaluates it" : "composed capability write threw — its whole slice is re-written on the next pass that evaluates it";
1509
- this.deps.logger.warn(message, {
1510
- tags: { deviceId: this.deps.target.id },
1511
- meta: {
1512
- blockId: this.deps.blockId,
1513
- cap,
1514
- op,
1515
- error: failed.error
1516
- }
1630
+ const touched = [...this.records.values()].filter((r) => !isFatal(r.outcome) && !sameOutcome(r.outcome, outcome));
1631
+ for (const r of touched) this.logTransition(r, outcome);
1632
+ this.records = new Map([...this.records].map(([k, r]) => [k, isFatal(r.outcome) ? r : {
1633
+ ...r,
1634
+ outcome
1635
+ }]));
1636
+ this.writeCaps(new Set(touched.map((r) => fieldKey(r.plan))), /* @__PURE__ */ new Set());
1637
+ this.updateAvailability();
1517
1638
  });
1518
1639
  }
1519
- markWritten(cap, entries) {
1520
- const written = new Map(entries.map((e) => [fieldKey(e.plan), e.value]));
1521
- this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(k) ? [k, {
1522
- ...r,
1523
- hasWritten: true,
1524
- written: written.get(k)
1525
- }] : [k, r]));
1640
+ /** Terminal: no write, no evaluation and no timer after this. */
1641
+ stop() {
1642
+ this.stopped = true;
1643
+ this.deps.timer.disarm();
1526
1644
  }
1527
- updateAvailability() {
1528
- const blocking = [...this.records.values()].filter((r) => r.plan.unavailable === "offline" && r.outcome?.state === "unavailable").map((r) => fieldKey(r.plan));
1529
- const unseeded = [...new Set(this.deps.fields.map((f) => f.cap))].filter((c) => !this.seeded.has(c));
1530
- const writePending = [...this.pending.keys()];
1531
- const available = this.disabledReason === null && this.failure === null && blocking.length === 0 && unseeded.length === 0 && writePending.length === 0;
1532
- if (available === this.available) return;
1533
- this.deps.target.setAvailable(available);
1534
- this.available = available;
1535
- const extras = {
1536
- tags: { deviceId: this.deps.target.id },
1537
- meta: {
1538
- blockId: this.deps.blockId,
1539
- blocking,
1540
- unseeded,
1541
- writePending,
1542
- failure: this.failure,
1543
- disabled: this.disabledReason
1544
- }
1645
+ state() {
1646
+ return {
1647
+ failure: this.failure,
1648
+ available: this.available === true,
1649
+ fields: [...this.records.values()].map((r) => {
1650
+ const terminal = this.terminal.get(r.plan.cap);
1651
+ if (terminal !== void 0 && !isFatal(r.outcome)) return {
1652
+ cap: r.plan.cap,
1653
+ path: r.plan.path,
1654
+ ...itemOf(r.plan),
1655
+ state: "unavailable",
1656
+ value: null,
1657
+ reason: terminal
1658
+ };
1659
+ const pending = this.pending.get(r.plan.cap);
1660
+ if (pending !== void 0 && !isFatal(r.outcome)) return {
1661
+ cap: r.plan.cap,
1662
+ path: r.plan.path,
1663
+ ...itemOf(r.plan),
1664
+ state: "unavailable",
1665
+ value: null,
1666
+ reason: pendingReason(r.plan.cap, pending)
1667
+ };
1668
+ const value = valueOf(r.outcome);
1669
+ const reason = reasonOf(r.outcome);
1670
+ return {
1671
+ cap: r.plan.cap,
1672
+ path: r.plan.path,
1673
+ ...itemOf(r.plan),
1674
+ state: r.outcome?.state ?? "unavailable",
1675
+ value: value === void 0 ? null : value,
1676
+ ...reason === null ? {} : { reason }
1677
+ };
1678
+ }),
1679
+ released: [...this.records.values()].filter(isReleased).map(namedField),
1680
+ held: [...this.records.values()].filter(isHeld).map(namedField)
1545
1681
  };
1546
- if (available) this.deps.logger.info("composed device available", extras);
1547
- else this.deps.logger.warn("composed device unavailable — a field has no value, a write is pending, or the block failed", extras);
1548
1682
  }
1549
- updateFailure() {
1550
- if (this.failure !== null) return;
1551
- const fatal = [...this.records.values()].find((r) => r.outcome?.state === "unavailable" && r.outcome.fatal);
1552
- if (fatal === void 0 || fatal.outcome === null || fatal.outcome.state !== "unavailable") return;
1553
- this.failure = `source missing — ${fatal.outcome.reason}`;
1554
- this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
1555
- tags: { deviceId: this.deps.target.id },
1556
- meta: {
1557
- blockId: this.deps.blockId,
1558
- field: fieldKey(fatal.plan),
1559
- reason: fatal.outcome.reason
1683
+ /**
1684
+ * The target's enqueued write of `cap` did not land: the cap is write-pending
1685
+ * by name and re-written WHOLE on the next pass that evaluates it (or the next
1686
+ * deadline pass). A withhold sent to that target is forgotten too, so it is
1687
+ * re-sent. No retry timer (D3).
1688
+ */
1689
+ onTargetWriteFailed(cap, failure) {
1690
+ this.guarded("target-failure", false, () => {
1691
+ if (!this.deps.fields.some((f) => f.cap === cap) || this.terminal.has(cap)) return;
1692
+ if (failure.kind === "refused" && TERMINAL_REFUSALS.has(failure.code)) {
1693
+ this.markTerminal(cap, failure.code, failure.message);
1694
+ return;
1560
1695
  }
1696
+ this.withheld = new Map([...this.withheld].filter(([c]) => c !== cap));
1697
+ this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failure.kind === "threw" ? {
1698
+ kind: "threw",
1699
+ error: failure.error
1700
+ } : {
1701
+ kind: "refused",
1702
+ error: `${failure.code}: ${failure.message}`
1703
+ });
1704
+ this.updateAvailability();
1561
1705
  });
1562
1706
  }
1563
- /** `after`: arm only deadlines later than this instant; null arms the earliest stored one. */
1564
- armDeadline(after) {
1565
- const deadlines = [...this.records.values()].flatMap((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && (after === null || r.outcome.deadline > after) ? [r.outcome.deadline] : []);
1566
- if (deadlines.length === 0) {
1567
- this.deps.timer.disarm();
1568
- return;
1569
- }
1570
- this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
1571
- }
1572
- onDeadline() {
1573
- this.guarded("deadline", true, () => {
1574
- const now = this.deps.now();
1575
- const due = [...this.records.values()].filter((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && r.outcome.deadline <= now).map((r) => fieldKey(r.plan));
1576
- if (due.length > 0) this.pass(due, "deadline");
1577
- });
1578
- }
1579
- logTransition(record, outcome) {
1580
- const from = record.outcome?.state ?? "pending";
1581
- if (from === "pending" && outcome.state === "fresh") return;
1582
- const extras = {
1707
+ /**
1708
+ * The target refused this cap for good: nothing on this side can retry it
1709
+ * away (I-3). The cap is never written again by this runtime, its fields
1710
+ * report the refusal, and the block fails naming it — only the operator ends
1711
+ * this (edit or delete the block, or the competing one), like a gone source.
1712
+ */
1713
+ markTerminal(cap, code, message) {
1714
+ const reason = `the \`${cap}\` capability was refused for good: ${code}: ${message} — only the operator ends this (edit or delete the block)`;
1715
+ this.terminal = new Map([...this.terminal, [cap, reason]]);
1716
+ this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1717
+ if (this.failure === null) this.failure = `composed write refused for good — ${cap}: ${code}: ${message}`;
1718
+ this.deps.logger.error("composed capability refused for good — it is not written again until the operator edits or deletes the block", {
1583
1719
  tags: { deviceId: this.deps.target.id },
1584
1720
  meta: {
1585
1721
  blockId: this.deps.blockId,
1586
- cap: record.plan.cap,
1587
- field: record.plan.path,
1588
- from,
1589
- to: outcome.state,
1590
- reason: reasonOf(outcome)
1722
+ cap,
1723
+ code,
1724
+ error: message
1591
1725
  }
1592
- };
1593
- const message = `composed field ${fieldKey(record.plan)}: ${from} → ${outcome.state}`;
1594
- if (outcome.state === "fresh") this.deps.logger.info(message, extras);
1595
- else this.deps.logger.warn(message, extras);
1596
- }
1597
- };
1598
- //#endregion
1599
- //#region src/builtins/composer/confirmed-seed.ts
1600
- var ConfirmedSeed = class {
1601
- deps;
1602
- seeded = false;
1603
- /** The previous read answered `[]`: one more `[]` confirms it (D49). */
1604
- emptySeen = false;
1605
- /** Consecutive applies whose read did not answer: warned once per run. */
1606
- waitApplies = 0;
1607
- constructor(deps) {
1608
- this.deps = deps;
1609
- }
1610
- get done() {
1611
- return this.seeded;
1726
+ });
1727
+ this.updateAvailability();
1612
1728
  }
1613
- /** Null once seeded, else why this apply must wait. */
1614
- async ensure() {
1615
- if (this.seeded) return null;
1616
- const read = await this.deps.read();
1617
- if (read.kind === "unknown") {
1618
- this.emptySeen = false;
1619
- this.waitApplies += 1;
1620
- if (this.waitApplies === 1) this.deps.logger.warn(`composer: the ${this.deps.subject} could not be read — nothing is declared, withdrawn, claimed or released from them; retried on the next apply`, { meta: {
1621
- subject: this.deps.subject,
1622
- error: read.error
1623
- } });
1624
- return this.deps.unreadReason;
1625
- }
1626
- if (read.items.length === 0 && !this.emptySeen) {
1627
- this.emptySeen = true;
1628
- this.deps.logger.warn(`composer: the ${this.deps.subject} read empty — waiting for the next apply's read to agree before trusting it`, { meta: { subject: this.deps.subject } });
1629
- return this.deps.emptyUnconfirmedReason;
1729
+ /**
1730
+ * Every entry point runs here: a no-op once stopped, nothing thrown escapes,
1731
+ * and the one timer is re-armed from the stored deadlines whatever happened.
1732
+ */
1733
+ guarded(during, rearm, body) {
1734
+ if (this.stopped) return;
1735
+ let threw = false;
1736
+ try {
1737
+ body();
1738
+ this.lastPassError = null;
1739
+ } catch (error) {
1740
+ threw = true;
1741
+ this.logPassError(during, errorText(error));
1742
+ } finally {
1743
+ if (rearm && !this.stopped && this.disabledReason === null) this.armDeadline(threw ? this.deps.now() : null);
1630
1744
  }
1631
- this.deps.onSeeded(read.items);
1632
- this.seeded = true;
1633
- this.deps.logger.info(`composer: seeded from the ${this.deps.subject}`, { meta: {
1634
- subject: this.deps.subject,
1635
- items: read.items.length,
1636
- confirmedEmpty: read.items.length === 0,
1637
- appliesRefused: this.waitApplies
1638
- } });
1639
- return null;
1640
1745
  }
1641
- };
1642
- //#endregion
1643
- //#region src/builtins/composer/existing-target.ts
1644
- /**
1645
- * What the composer needs to run a composition on an EXISTING device (D663)
1646
- * besides the runtime and the claim-backed target: the target's description,
1647
- * the claim shapes derived from the plan, and the block report's words.
1648
- *
1649
- * - The target is DESCRIBED (bound caps → native addon, row type) with ONE
1650
- * `getBindings` per plan change, cached until `device.bindings-changed` for
1651
- * that device. A read that fails keeps the previous description; with none,
1652
- * the block waits and nothing is claimed (D49).
1653
- * - A claim's field set is the PLAN's (S5): the top-level fields of the cap, an
1654
- * item leaf contributing its whole array. Its mode is the feature's.
1655
- * - A self-read of the target (rule 6) is served from the NATIVE view unless it
1656
- * reads an ADDED cap — that is the composition's own output. Whether a native
1657
- * cap's `device.state-changed` is the native or the composition's own merge
1658
- * is NOT decided here from the plan: it follows whether the claim has landed
1659
- * (`HeldClaims`, C-1).
1660
- */
1661
- /**
1662
- * Review M3: an ADDED `restored` cap seeded from NOTHING says so, once per
1663
- * runtime build, on its device. After a restart its state comes from the
1664
- * target's runtime-state row; a row that lost the slice (D677: 615's doorbell,
1665
- * dropped at a hub boot) used to surface as "not seeded yet" and now seeds a
1666
- * clean `{ null, 0 }` — a lifetime count silently reset unless this line
1667
- * names it. A first composition of a device that never had the slice logs it
1668
- * too: the line cannot tell the two apart, and says both.
1669
- */
1670
- function reportSeedlessRestoredCaps(input) {
1671
- for (const cap of addedCapsOf(input.features)) {
1672
- if (input.lookupCap(cap)?.durability !== "restored") continue;
1673
- if ((input.seed.byCap.get(cap) ?? null) !== null) continue;
1674
- input.logger.info("composed restored capability seeded from nothing — the target had no slice for it (a first composition, or a lost runtime-state row)", {
1675
- tags: { deviceId: input.deviceId },
1746
+ /** Once per distinct failure, not once per pass: a reader that keeps throwing logs one line. */
1747
+ logPassError(during, error) {
1748
+ const key = `${during}: ${error}`;
1749
+ if (key === this.lastPassError) return;
1750
+ this.lastPassError = key;
1751
+ this.deps.logger.error("composition pass threw — the runtime keeps running; the next source event or deadline re-runs it", {
1752
+ tags: { deviceId: this.deps.target.id },
1676
1753
  meta: {
1677
- blockId: input.blockId,
1678
- cap,
1679
- seededFrom: "none"
1754
+ blockId: this.deps.blockId,
1755
+ during,
1756
+ error
1680
1757
  }
1681
1758
  });
1682
1759
  }
1683
- }
1684
- /**
1685
- * Read every affected effective capability exactly once, before the composer
1686
- * grafts or claims any of them. A null slice is a successful empty seed; a
1687
- * rejection is deliberately left to the caller so ownership cannot begin.
1688
- */
1689
- async function readExistingTargetSeeds(targetDeviceId, caps, read) {
1690
- const byCap = /* @__PURE__ */ new Map();
1691
- for (const cap of new Set(caps)) byCap.set(cap, await read(targetDeviceId, cap));
1692
- return { byCap };
1693
- }
1694
- /** The reason every ungraft of a gone target carries (Task 9 rule 5 / Task 10 rule 2). */
1695
- var TARGET_GONE_UNGRAFT_REASON = "target device gone";
1696
- /**
1697
- * Rule 5, on either path that finds the target gone (`device.unregistered`, or
1698
- * a later plan when that event was dropped): ungraft every cap the block ADDED
1699
- * on that id, with the one ERROR that says so.
1700
- */
1701
- function ungraftGoneTarget(input) {
1702
- const { grafts, deviceId } = input;
1703
- const added = addedCapsOf(input.features);
1704
- for (const cap of added) grafts?.ungraft(deviceId, cap, TARGET_GONE_UNGRAFT_REASON);
1705
- input.logger.error("customization target device is gone — the block failed and its grafts are removed; only the operator ends this (edit or restart the block)", {
1706
- tags: { deviceId },
1707
- meta: {
1708
- blockId: input.blockId,
1709
- target: compositionSourceKey(input.ref),
1710
- ungrafted: added,
1711
- graftHost: grafts !== void 0
1712
- }
1713
- });
1714
- }
1715
- var TargetDescriptions = class {
1716
- logger;
1717
- readBoundCaps;
1718
- cache = /* @__PURE__ */ new Map();
1719
- constructor(logger, readBoundCaps) {
1720
- this.logger = logger;
1721
- this.readBoundCaps = readBoundCaps;
1760
+ pass(keys, trigger) {
1761
+ const initial = trigger === "start";
1762
+ const now = this.deps.now();
1763
+ const calculated = evaluateWithOwnReaders(this.evaluation, keys.filter((k) => {
1764
+ const role = this.records.get(k)?.plan.role;
1765
+ return role === "derived" || role === "accumulator";
1766
+ }), now);
1767
+ const followers = [...this.records.values()].filter((r) => r.plan.role === "clock" ? initial || calculated.valueCaps.has(r.plan.cap) : r.plan.role === "constant" && initial).map((r) => fieldKey(r.plan));
1768
+ const extra = evaluateFields(this.evaluation, followers, now);
1769
+ const evaluatedCaps = new Set([...keys, ...followers].flatMap((k) => {
1770
+ const cap = this.records.get(k)?.plan.cap;
1771
+ return cap === void 0 ? [] : [cap];
1772
+ }));
1773
+ const retry = new Set([...this.pending.keys()].filter((cap) => trigger === "deadline" || evaluatedCaps.has(cap)));
1774
+ this.writeCaps(new Set([...calculated.keys, ...extra.keys]), retry);
1775
+ this.updateFailure();
1776
+ this.updateAvailability();
1722
1777
  }
1723
- /** `device.bindings-changed` for this device: the next read is a real read. */
1724
- invalidate(deviceId) {
1725
- this.cache = new Map([...this.cache].filter(([, d]) => d.deviceId !== deviceId));
1778
+ recordsOf(cap) {
1779
+ return [...this.records.values()].filter((r) => r.plan.cap === cap);
1780
+ }
1781
+ /** `retry`: write-pending caps this pass re-writes WHOLE, whether or not a value changed. */
1782
+ writeCaps(touched, retry) {
1783
+ const caps = new Set([...[...touched].flatMap((k) => {
1784
+ const cap = this.records.get(k)?.plan.cap;
1785
+ return cap === void 0 ? [] : [cap];
1786
+ }), ...retry]);
1787
+ for (const cap of caps) {
1788
+ if (this.terminal.has(cap)) continue;
1789
+ const sync = this.syncReleased(cap);
1790
+ if (sync === "failed") continue;
1791
+ if (!this.seeded.has(cap)) this.seedCap(cap);
1792
+ else if (this.pending.has(cap) || sync === "changed") this.patchWholeCap(cap, sync === "changed");
1793
+ else this.patchCap(cap, touched);
1794
+ }
1726
1795
  }
1727
1796
  /**
1728
- * The description of `ref`, resolved to `deviceId`. Served from the cache
1729
- * unless `force` (the block's plan changed) or nothing is cached. A read that
1730
- * fails keeps the previous description of the SAME device id, and answers
1731
- * null when there is none: the caller waits, and claims nothing.
1797
+ * S1b: tell the target which fields of `cap` are released, when that set
1798
+ * changed since it last accepted one. A released record forgets what it wrote,
1799
+ * so the source returning writes it again. False when the target refused or
1800
+ * threw: the cap is write-pending and the withhold is re-sent next pass.
1732
1801
  */
1733
- async describe(ref, deviceId, type, force) {
1734
- const key = compositionSourceKey(ref);
1735
- const cached = this.cache.get(key);
1736
- const previous = cached !== void 0 && cached.deviceId === deviceId ? cached : null;
1737
- if (!force && previous !== null) return previous;
1738
- const read = await this.readBoundCaps(deviceId);
1739
- if (read.kind === "unreadable") {
1740
- this.logger.warn(previous === null ? "customization target: its bindings could not be read — nothing is claimed until they are; retried on the next apply" : "customization target: its bindings could not be read — the previous description is kept", {
1741
- tags: { deviceId },
1802
+ syncReleased(cap) {
1803
+ const records = this.recordsOf(cap);
1804
+ this.logReleaseTransitions(cap, records);
1805
+ const releasedRecords = records.filter(isReleased);
1806
+ this.records = new Map([...this.records].map(([k, r]) => releasedRecords.includes(r) && r.hasWritten ? [k, {
1807
+ ...r,
1808
+ hasWritten: false,
1809
+ written: void 0
1810
+ }] : [k, r]));
1811
+ const withhold = this.deps.target.withhold;
1812
+ if (withhold === void 0) return "unchanged";
1813
+ const fields = new Set(releasedRecords.map((r) => topLevelField(r.plan)));
1814
+ if (sameSet(fields, this.withheld.get(cap) ?? NO_FIELDS)) return "unchanged";
1815
+ let failed;
1816
+ try {
1817
+ const outcome = withhold.call(this.deps.target, cap, [...fields].toSorted());
1818
+ failed = outcome.ok ? null : {
1819
+ kind: "refused",
1820
+ error: outcome.error
1821
+ };
1822
+ } catch (error) {
1823
+ failed = {
1824
+ kind: "threw",
1825
+ error: errorText(error)
1826
+ };
1827
+ }
1828
+ if (failed !== null) {
1829
+ this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failed);
1830
+ return "failed";
1831
+ }
1832
+ this.withheld = new Map([...this.withheld.entries()].filter(([c]) => c !== cap).concat(fields.size === 0 ? [] : [[cap, fields]]));
1833
+ return "changed";
1834
+ }
1835
+ /** One info line per field per direction, never one per pass. */
1836
+ logReleaseTransitions(cap, records) {
1837
+ const now = new Set(records.filter(isReleased).map((r) => fieldKey(r.plan)));
1838
+ const before = new Set(records.filter((r) => this.released.has(fieldKey(r.plan))).map((r) => fieldKey(r.plan)));
1839
+ for (const r of records) {
1840
+ const key = fieldKey(r.plan);
1841
+ const direction = now.has(key) && !before.has(key) ? "released" : !now.has(key) && before.has(key) ? "reclaimed" : null;
1842
+ if (direction === null) continue;
1843
+ const flaps = (this.releaseFlaps.get(key) ?? 0) + (direction === "released" ? 1 : 0);
1844
+ this.releaseFlaps = new Map([...this.releaseFlaps, [key, flaps]]);
1845
+ this.deps.logger.info(direction === "released" ? "composed field released to the native provider — its source is unavailable" : "composed field reclaimed from the native provider — its source is back", {
1846
+ tags: { deviceId: this.deps.target.id },
1742
1847
  meta: {
1743
- target: key,
1744
- error: read.error
1848
+ blockId: this.deps.blockId,
1849
+ cap,
1850
+ field: fieldPath(r.plan),
1851
+ direction,
1852
+ reason: reasonOf(r.outcome) ?? "value available again",
1853
+ flaps
1745
1854
  }
1746
1855
  });
1747
- return previous;
1748
1856
  }
1749
- const found = {
1750
- kind: "found",
1751
- deviceId,
1752
- type,
1753
- nativeCaps: read.caps
1754
- };
1755
- this.cache = new Map([...this.cache, [key, found]]);
1756
- return found;
1857
+ this.released = new Set([...[...this.released].filter((k) => !before.has(k)), ...now]);
1757
1858
  }
1758
- };
1759
- /** The PLANNED field set a claim carries for `cap` (S5): an item leaf claims its whole array. */
1760
- function claimFieldsOf(fields, cap) {
1761
- return [...new Set(fields.filter((f) => f.cap === cap).map((f) => f.item === void 0 ? f.path : f.item.arrayPath))];
1762
- }
1763
- /** The claim mode of `cap`: `replace` for a cap the device serves natively, else `add`. */
1764
- function claimModeOf(features, cap) {
1765
- return features.find((f) => f.cap === cap)?.mode === "replace" ? "replace" : "add";
1766
- }
1767
- /** The caps a customization ADDS to its target — the ones it grafts a native for (Task 10). */
1768
- function addedCapsOf(features) {
1769
- return features.filter((f) => f.mode === "add").map((f) => f.cap);
1770
- }
1771
- function selfReadCaps(fields, target, features) {
1772
- const targetKey = compositionSourceKey(target);
1773
- const read = new Set(fields.flatMap((f) => f.reads.filter((r) => compositionSourceKey(r.source) === targetKey).map((r) => r.cap)));
1774
- const added = new Set(addedCapsOf(features));
1775
- return {
1776
- effective: [...read].filter((cap) => added.has(cap)),
1777
- native: [...read].filter((cap) => !added.has(cap))
1778
- };
1779
- }
1780
- /** The `degraded` reason (rule 8): one entry per released field, the source's own reason after the dash. */
1781
- function degradedReason(released) {
1782
- return released.map((r) => {
1783
- const path = r.item === void 0 ? r.path : compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
1784
- return `released to the native provider: ${r.cap}.${path} — ${r.reason}`;
1785
- }).join("; ");
1786
- }
1787
- /** The `starting` reason of a block with HELD fields: each keeps its last value and claim until its source answers (D49). */
1788
- function heldReason(held) {
1789
- return held.map((r) => {
1790
- const path = r.item === void 0 ? r.path : compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
1791
- return `held on its last value until its source answers (D49): ${r.cap}.${path} — ${r.reason}`;
1792
- }).join("; ");
1793
- }
1794
- /**
1795
- * Rule 4: a customization whose row exists but whose plan failed keeps its
1796
- * claims, so its owned fields stay at their last value on the hub. The report
1797
- * says so — each field that had a value is `stale`, with the reason.
1798
- */
1799
- function heldFieldsOf(fields, reason) {
1800
- return fields.map((f) => f.state === "unavailable" ? f : {
1801
- ...f,
1802
- state: "stale",
1803
- reason
1804
- });
1805
- }
1806
- //#endregion
1807
- //#region src/builtins/composer/composer-grafts.ts
1808
- /**
1809
- * The composer's side of grafts (D663, Task 10): WHEN a cap a customization
1810
- * ADDS is grafted, removed or refused — `GraftHost` is only the how. Split from
1811
- * `composer.ts`.
1812
- *
1813
- * - Decided at PLAN time only (rule 2): every added cap is grafted; a grafted
1814
- * cap the plan now finds native is ungrafted (its event was dropped, D8).
1815
- * - NATIVE ALWAYS WINS (I-2). A foreign `native-registered` on a grafted cap
1816
- * ungrafts it (the claim stays). One that lands while a plan is in flight —
1817
- * after the target's description was read, before the graft — is remembered
1818
- * per described device, so that plan never grafts over it. A foreign
1819
- * `native-unregistered` never re-grafts (D659).
1820
- * - A cap no graft serves is NAMED for the block verdict (I-1): refused by the
1821
- * host, or taken by a native. Every added cap unserved → the block fails.
1822
- * - A binding a PREVIOUS composer process left (a respawn, D5) is retracted
1823
- * when a settled plan does not want it (I-3), or when the gate releases the
1824
- * add claim of a block that is gone — after reading that the binding IS the
1825
- * composer's; a read that fails retracts nothing (D49).
1826
- */
1827
- /** A foreign native now serves the grafted cap: the claim stays (Task 10 rule 2). */
1828
- var NATIVE_APPEARED_UNGRAFT_REASON = "a native provider appeared";
1829
- /** The block's add claim was released by the D49 gate (deleted, disabled, feature removed): its graft goes with it. */
1830
- var NO_LONGER_ADDED_UNGRAFT_REASON = "the block no longer adds this cap";
1831
- /** A binding a previous composer process left, that nothing wants any more (I-3). */
1832
- var STALE_GRAFT_RETRACT_REASON = "a graft left by a previous composer process that no plan wants";
1833
- function withEntry(map, key, value) {
1834
- return new Map([...map, [key, value]]);
1835
- }
1836
- function nativeTakenReason(addonId, when) {
1837
- return `a native provider (${addonId}) ${when} — the cap is not grafted and its claim is kept; edit or restart the block to replace the native instead`;
1838
- }
1839
- var ComposerGrafts = class {
1840
- ports;
1841
- logger;
1842
- readBindings;
1843
- /** Per device: the added caps no graft serves, and why. Reset by each plan of that device. */
1844
- unserved = /* @__PURE__ */ new Map();
1845
- /** Per DESCRIBED device: foreign natives registered since its description was read (cap → addonId). */
1846
- foreignSince = /* @__PURE__ */ new Map();
1847
- constructor(ports, logger, readBindings) {
1848
- this.ports = ports;
1849
- this.logger = logger;
1850
- this.readBindings = readBindings;
1859
+ seedCap(cap) {
1860
+ const records = this.recordsOf(cap);
1861
+ const missing = records.filter((r) => (r.plan.required || isHeld(r)) && writable(r) === void 0 && !isReleased(r)).map((r) => fieldPath(r.plan));
1862
+ if (missing.length > 0) return this.holdBackSeed(cap, missing);
1863
+ this.heldBack = new Set([...this.heldBack].filter((c) => c !== cap));
1864
+ const entries = this.entriesFor(cap, records);
1865
+ if (!this.write(cap, "seed", entries)) return;
1866
+ this.seeded = new Set([...this.seeded, cap]);
1867
+ this.markWritten(cap, entries);
1851
1868
  }
1852
- /** A description of `deviceId` is about to be read: it covers every registration before now. */
1853
- describing(deviceId) {
1854
- this.foreignSince = withEntry(this.foreignSince, deviceId, /* @__PURE__ */ new Map());
1869
+ patchCap(cap, touched) {
1870
+ const entries = this.entriesFor(cap, this.recordsOf(cap).filter((r) => touched.has(fieldKey(r.plan)) && (!r.hasWritten || writable(r) !== r.written)));
1871
+ if (entries.length === 0) return;
1872
+ if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
1855
1873
  }
1856
- onBindingsChanged(data) {
1857
- if (data.addonId === "composer") return;
1858
- this.remember(data);
1859
- const ports = this.ports;
1860
- if (data.reason !== "native-registered" || ports === void 0) return;
1861
- if (!ports.grafted(data.deviceId).has(data.capName)) return;
1862
- this.logger.info("a native provider appeared on a grafted cap — the graft is removed; the claim stays", {
1863
- tags: { deviceId: data.deviceId },
1874
+ /**
1875
+ * After a failed write nothing is known about what landed, and after a
1876
+ * changed withhold the claim must be re-stated: send every current value.
1877
+ * `afterWithhold` with nothing left to write means the cap was released
1878
+ * whole — a pending write is moot then, not retried (M-2).
1879
+ */
1880
+ patchWholeCap(cap, afterWithhold = false) {
1881
+ const entries = this.entriesFor(cap, this.recordsOf(cap));
1882
+ if (entries.length === 0) {
1883
+ if (afterWithhold && this.pending.has(cap)) this.dropPending(cap);
1884
+ return;
1885
+ }
1886
+ if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
1887
+ }
1888
+ dropPending(cap) {
1889
+ this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1890
+ this.deps.logger.info("composed capability released whole — its pending write is moot, nothing is left to write", {
1891
+ tags: { deviceId: this.deps.target.id },
1864
1892
  meta: {
1865
- cap: data.capName,
1866
- addonId: data.addonId
1893
+ blockId: this.deps.blockId,
1894
+ cap
1867
1895
  }
1868
1896
  });
1869
- ports.ungraft(data.deviceId, data.capName, NATIVE_APPEARED_UNGRAFT_REASON);
1870
- this.setUnserved(data.deviceId, data.capName, nativeTakenReason(data.addonId, "appeared"));
1871
1897
  }
1872
- /** Rule 2, at plan time only: graft what the plan adds; ungraft what it finds native; retract what a previous process left. */
1873
- plan(input) {
1874
- const ports = this.ports;
1875
- if (ports === void 0) return;
1876
- const { deviceId } = input;
1877
- const added = input.enabled && input.settled ? addedCapsOf(input.features) : [];
1878
- const replaced = new Set(input.features.filter((f) => f.mode === "replace").map((f) => f.cap));
1879
- for (const cap of ports.grafted(deviceId)) {
1880
- if (!replaced.has(cap)) continue;
1881
- this.logger.info("a native provider serves a grafted cap — the graft is removed; the claim stays", {
1882
- tags: { deviceId },
1883
- meta: { cap }
1884
- });
1885
- ports.ungraft(deviceId, cap, NATIVE_APPEARED_UNGRAFT_REASON);
1898
+ /**
1899
+ * What one write of `cap` carries: the writable `candidates` — and, when any
1900
+ * of them is an item leaf, EVERY item leaf of the cap, because the array is
1901
+ * written whole (a patch of `items` replaces it). A required item leaf with
1902
+ * no value holds the whole array (its top-level siblings still go out).
1903
+ */
1904
+ entriesFor(cap, candidates) {
1905
+ const top = candidates.filter((r) => r.plan.item === void 0);
1906
+ if (!candidates.some((r) => r.plan.item !== void 0)) return this.writableEntries(top);
1907
+ const items = this.recordsOf(cap).filter((r) => r.plan.item !== void 0);
1908
+ const missing = items.filter((r) => r.plan.required && writable(r) === void 0).map((r) => fieldPath(r.plan));
1909
+ if (missing.length > 0) {
1910
+ this.holdItems(cap, missing);
1911
+ return this.writableEntries(top);
1886
1912
  }
1887
- if (input.settled) this.retractUnwanted(input, new Set(added));
1888
- const foreign = this.foreignSince.get(deviceId) ?? /* @__PURE__ */ new Map();
1889
- const unserved = /* @__PURE__ */ new Map();
1890
- for (const cap of added) {
1891
- const other = foreign.get(cap);
1892
- if (other !== void 0) {
1893
- const reason = nativeTakenReason(other, "registered while the block was planning");
1894
- this.logger.warn("graft skipped — a native provider serves the cap (native always wins)", {
1895
- tags: { deviceId },
1896
- meta: {
1897
- cap,
1898
- addonId: other
1899
- }
1900
- });
1901
- unserved.set(cap, reason);
1902
- continue;
1913
+ this.itemsHeld = new Set([...this.itemsHeld].filter((c) => c !== cap));
1914
+ return this.writableEntries([...top, ...items]);
1915
+ }
1916
+ writableEntries(records) {
1917
+ return records.flatMap((r) => {
1918
+ const value = writable(r);
1919
+ return value === void 0 ? [] : [{
1920
+ plan: r.plan,
1921
+ value
1922
+ }];
1923
+ });
1924
+ }
1925
+ holdItems(cap, missing) {
1926
+ if (this.itemsHeld.has(cap)) return;
1927
+ this.itemsHeld = new Set([...this.itemsHeld, cap]);
1928
+ this.deps.logger.warn("composed items held — a required item field has no value; the array is not written until it does", {
1929
+ tags: { deviceId: this.deps.target.id },
1930
+ meta: {
1931
+ blockId: this.deps.blockId,
1932
+ cap,
1933
+ missing
1903
1934
  }
1904
- const outcome = ports.graft(deviceId, input.ref.stableId, cap);
1905
- if (outcome.kind === "refused") unserved.set(cap, `graft refused: ${outcome.reason}`);
1906
- }
1907
- this.unserved = withEntry(this.unserved, deviceId, unserved);
1935
+ });
1908
1936
  }
1909
- /** The gate released a claim: a graft of that cap goes with it; a previous process's binding is retracted once read as the composer's. */
1910
- async released(deviceId, capName) {
1911
- const ports = this.ports;
1912
- if (ports === void 0) return;
1913
- this.setUnserved(deviceId, capName, null);
1914
- if (ports.grafted(deviceId).has(capName)) {
1915
- ports.ungraft(deviceId, capName, NO_LONGER_ADDED_UNGRAFT_REASON);
1916
- return;
1917
- }
1918
- let read;
1937
+ holdBackSeed(cap, missing) {
1938
+ if (this.heldBack.has(cap)) return;
1939
+ this.heldBack = new Set([...this.heldBack, cap]);
1940
+ this.deps.logger.warn("composed capability not seeded yet — a required field has no value", {
1941
+ tags: { deviceId: this.deps.target.id },
1942
+ meta: {
1943
+ blockId: this.deps.blockId,
1944
+ cap,
1945
+ missing
1946
+ }
1947
+ });
1948
+ }
1949
+ /** One write. A refusal or a throw marks the cap write-pending; true when the target took it. */
1950
+ write(cap, op, entries) {
1951
+ let failed;
1919
1952
  try {
1920
- read = await this.readBindings(deviceId);
1921
- } catch (err) {
1922
- read = {
1923
- kind: "unreadable",
1924
- error: errMsg(err)
1953
+ const values = assembleSlice(entries, this.deps.lookupCap);
1954
+ const outcome = op === "seed" ? this.deps.target.seed(cap, values) : this.deps.target.patch(cap, values);
1955
+ failed = outcome.ok ? null : {
1956
+ kind: "refused",
1957
+ error: outcome.error
1958
+ };
1959
+ } catch (error) {
1960
+ failed = {
1961
+ kind: "threw",
1962
+ error: errorText(error)
1925
1963
  };
1926
1964
  }
1927
- if (read.kind === "unreadable") {
1928
- this.logger.warn("a released claim: the bindings could not be read — a graft left by a previous process, if any, is not retracted (D49)", {
1929
- tags: { deviceId },
1930
- meta: {
1931
- cap: capName,
1932
- error: read.error
1933
- }
1934
- });
1935
- return;
1965
+ if (failed === null) {
1966
+ this.clearPending(cap);
1967
+ return true;
1936
1968
  }
1937
- if (read.caps.get(capName) === "composer") ports.retractStale(deviceId, capName, STALE_GRAFT_RETRACT_REASON);
1969
+ this.markPending(cap, op, failed);
1970
+ return false;
1938
1971
  }
1939
- /** The target row is gone: nothing about it is reported any more. */
1940
- gone(deviceId) {
1941
- this.unserved = new Map([...this.unserved].filter(([id]) => id !== deviceId));
1972
+ clearPending(cap) {
1973
+ if (!this.pending.has(cap)) return;
1974
+ this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
1975
+ this.deps.logger.info("composed capability written again after a failed write", {
1976
+ tags: { deviceId: this.deps.target.id },
1977
+ meta: {
1978
+ blockId: this.deps.blockId,
1979
+ cap
1980
+ }
1981
+ });
1942
1982
  }
1943
- verdict(deviceId, features) {
1944
- const added = addedCapsOf(features);
1945
- const unserved = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
1946
- const named = added.flatMap((cap) => {
1947
- const reason = unserved.get(cap);
1948
- return reason === void 0 ? [] : [`${cap}: ${reason}`];
1983
+ markPending(cap, op, failed) {
1984
+ const wasPending = this.pending.has(cap);
1985
+ this.pending = new Map([...this.pending, [cap, failed]]);
1986
+ if (wasPending) return;
1987
+ const message = failed.kind === "refused" ? "composed capability refused a write — its whole slice is re-written on the next pass that evaluates it" : "composed capability write threw — its whole slice is re-written on the next pass that evaluates it";
1988
+ this.deps.logger.warn(message, {
1989
+ tags: { deviceId: this.deps.target.id },
1990
+ meta: {
1991
+ blockId: this.deps.blockId,
1992
+ cap,
1993
+ op,
1994
+ error: failed.error
1995
+ }
1949
1996
  });
1950
- if (named.length === 0) return null;
1951
- return {
1952
- allUnserved: named.length === added.length && added.length === features.length,
1953
- reason: `added caps no graft serves — ${named.join("; ")}`
1954
- };
1955
1997
  }
1956
- retractUnwanted(input, wanted) {
1957
- const ports = this.ports;
1958
- if (ports === void 0) return;
1959
- const own = ports.grafted(input.deviceId);
1960
- for (const [cap, addonId] of input.nativeCaps) {
1961
- if (addonId !== "composer" || own.has(cap) || wanted.has(cap)) continue;
1962
- this.logger.info("a graft left by a previous composer process is retracted", {
1963
- tags: { deviceId: input.deviceId },
1964
- meta: { cap }
1965
- });
1966
- ports.retractStale(input.deviceId, cap, STALE_GRAFT_RETRACT_REASON);
1967
- }
1998
+ markWritten(cap, entries) {
1999
+ const written = new Map(entries.map((e) => [fieldKey(e.plan), e.value]));
2000
+ this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(k) ? [k, {
2001
+ ...r,
2002
+ hasWritten: true,
2003
+ written: written.get(k)
2004
+ }] : [k, r]));
1968
2005
  }
1969
- remember(data) {
1970
- const seen = this.foreignSince.get(data.deviceId);
1971
- if (seen === void 0) return;
1972
- if (data.reason === "native-registered") this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen, [data.capName, data.addonId]]));
1973
- else if (data.reason === "native-unregistered" && seen.get(data.capName) === data.addonId) this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen].filter(([cap]) => cap !== data.capName)));
2006
+ updateAvailability() {
2007
+ const blocking = [...this.records.values()].filter((r) => r.plan.unavailable === "offline" && r.outcome?.state === "unavailable").map((r) => fieldKey(r.plan));
2008
+ const unseeded = [...new Set(this.deps.fields.map((f) => f.cap))].filter((c) => !this.seeded.has(c));
2009
+ const writePending = [...this.pending.keys()];
2010
+ const available = this.disabledReason === null && this.failure === null && blocking.length === 0 && unseeded.length === 0 && writePending.length === 0;
2011
+ if (available === this.available) return;
2012
+ this.deps.target.setAvailable(available);
2013
+ this.available = available;
2014
+ const extras = {
2015
+ tags: { deviceId: this.deps.target.id },
2016
+ meta: {
2017
+ blockId: this.deps.blockId,
2018
+ blocking,
2019
+ unseeded,
2020
+ writePending,
2021
+ failure: this.failure,
2022
+ disabled: this.disabledReason
2023
+ }
2024
+ };
2025
+ if (available) this.deps.logger.info("composed device available", extras);
2026
+ else this.deps.logger.warn("composed device unavailable — a field has no value, a write is pending, or the block failed", extras);
1974
2027
  }
1975
- setUnserved(deviceId, cap, reason) {
1976
- const current = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
1977
- const next = new Map([...current].filter(([c]) => c !== cap));
1978
- if (reason !== null) next.set(cap, reason);
1979
- this.unserved = withEntry(this.unserved, deviceId, next);
2028
+ updateFailure() {
2029
+ if (this.failure !== null) return;
2030
+ const fatal = [...this.records.values()].find((r) => r.outcome?.state === "unavailable" && r.outcome.fatal);
2031
+ if (fatal === void 0 || fatal.outcome === null || fatal.outcome.state !== "unavailable") return;
2032
+ this.failure = `source missing — ${fatal.outcome.reason}`;
2033
+ this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
2034
+ tags: { deviceId: this.deps.target.id },
2035
+ meta: {
2036
+ blockId: this.deps.blockId,
2037
+ field: fieldKey(fatal.plan),
2038
+ reason: fatal.outcome.reason
2039
+ }
2040
+ });
1980
2041
  }
1981
- };
1982
- //#endregion
1983
- //#region src/builtins/composer/composer-verdict.ts
1984
- /** A runtime built by an apply, before the apply's sync decides whether it may start. */
1985
- var NOT_STARTED = "the runtime has not started yet";
1986
- function verdictOf(blockId, entry, unsynced, grafts = null) {
1987
- if (entry === void 0) return {
1988
- blockId,
1989
- status: "starting",
1990
- reason: "not applied yet"
1991
- };
1992
- if (entry.rebuildError !== null) return {
1993
- blockId,
1994
- status: "failed",
1995
- reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
1996
- };
1997
- if (entry.planned.problems.length > 0) {
1998
- const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
1999
- return {
2000
- blockId,
2001
- status: "failed",
2002
- reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
2003
- };
2042
+ /** `after`: arm only deadlines later than this instant; null arms the earliest stored one. */
2043
+ armDeadline(after) {
2044
+ const deadlines = [...this.records.values()].flatMap((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && (after === null || r.outcome.deadline > after) ? [r.outcome.deadline] : []);
2045
+ if (deadlines.length === 0) {
2046
+ this.deps.timer.disarm();
2047
+ return;
2048
+ }
2049
+ this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
2004
2050
  }
2005
- if (!entry.planned.block.enabled) return {
2006
- blockId,
2007
- status: "stopped"
2008
- };
2009
- const target = entry.planned.target;
2010
- if (target.kind === "existing") {
2011
- if (target.gone !== null) return {
2012
- blockId,
2013
- status: "failed",
2014
- reason: `the target device is gone: ${target.gone}`
2015
- };
2016
- if (target.waiting !== null) return {
2017
- blockId,
2018
- status: "starting",
2019
- reason: target.waiting
2020
- };
2021
- } else if (entry.device === null) return {
2022
- blockId,
2023
- status: "starting",
2024
- reason: "the composed device has not been constructed yet"
2025
- };
2026
- const runtime = entry.runtime;
2027
- if (runtime === null) {
2028
- if (target.kind !== "existing" || target.features.length === 0) return {
2029
- blockId,
2030
- status: "running"
2031
- };
2032
- return {
2033
- blockId,
2034
- status: "starting",
2035
- reason: NOT_STARTED
2036
- };
2051
+ onDeadline() {
2052
+ this.guarded("deadline", true, () => {
2053
+ const now = this.deps.now();
2054
+ const due = [...this.records.values()].filter((r) => r.outcome !== null && r.outcome.state !== "unavailable" && r.outcome.deadline !== null && r.outcome.deadline <= now).map((r) => fieldKey(r.plan));
2055
+ if (due.length > 0) this.pass(due, "deadline");
2056
+ });
2037
2057
  }
2038
- if (unsynced) return {
2039
- blockId,
2040
- status: "starting",
2041
- reason: NOT_STARTED
2042
- };
2043
- const state = runtime.state();
2044
- if (state.failure !== null) return {
2045
- blockId,
2046
- status: "failed",
2047
- reason: state.failure
2048
- };
2049
- if (grafts?.allUnserved === true) return {
2050
- blockId,
2051
- status: "failed",
2052
- reason: grafts.reason
2053
- };
2054
- const degraded = [...state.released.length > 0 ? [degradedReason(state.released)] : [], ...grafts === null ? [] : [grafts.reason]];
2055
- if (degraded.length > 0) return {
2056
- blockId,
2057
- status: "degraded",
2058
- reason: degraded.join("; ")
2059
- };
2060
- if (state.held.length > 0) return {
2061
- blockId,
2062
- status: "starting",
2063
- reason: heldReason(state.held)
2064
- };
2065
- return {
2066
- blockId,
2067
- status: "running"
2068
- };
2069
- }
2058
+ logTransition(record, outcome) {
2059
+ const from = record.outcome?.state ?? "pending";
2060
+ if (from === "pending" && outcome.state === "fresh") return;
2061
+ const extras = {
2062
+ tags: { deviceId: this.deps.target.id },
2063
+ meta: {
2064
+ blockId: this.deps.blockId,
2065
+ cap: record.plan.cap,
2066
+ field: record.plan.path,
2067
+ from,
2068
+ to: outcome.state,
2069
+ reason: reasonOf(outcome)
2070
+ }
2071
+ };
2072
+ const message = `composed field ${fieldKey(record.plan)}: ${from} → ${outcome.state}`;
2073
+ if (outcome.state === "fresh") this.deps.logger.info(message, extras);
2074
+ else this.deps.logger.warn(message, extras);
2075
+ }
2076
+ };
2070
2077
  //#endregion
2071
2078
  //#region src/builtins/composer/owned-fields-target.ts
2072
2079
  /**
@@ -2350,6 +2357,249 @@ var OwnedFieldsTarget = class {
2350
2357
  }
2351
2358
  };
2352
2359
  //#endregion
2360
+ //#region src/builtins/composer/composer-runtime-factory.ts
2361
+ /**
2362
+ * What a planned block's runtime is built from: WHERE it writes (its composed
2363
+ * device, or a claim-backed target on an existing device) and HOW it reads
2364
+ * (rule 6: a self-read of a field the block did not ADD is the native, never
2365
+ * the merge; the block's own output is answered by the runtime, never through
2366
+ * the hub — D670 amendment).
2367
+ */
2368
+ /** Where a block's runtime writes: its composed device, or a claim-backed target on the existing device. */
2369
+ function writerFor(deps, p, device, onClaimEvent) {
2370
+ const target = p.target;
2371
+ if (target.kind !== "existing") return device;
2372
+ if (target.deviceId === null || target.waiting !== null || target.gone !== null) return null;
2373
+ const deviceId = target.deviceId;
2374
+ const owner = p.block.id;
2375
+ return new OwnedFieldsTarget({
2376
+ deviceId,
2377
+ owner,
2378
+ modeOf: (cap) => claimModeOf(target.features, cap),
2379
+ fieldsOf: (cap) => claimFieldsOf(p.fields, cap),
2380
+ api: deps.claims,
2381
+ logger: deps.logger.child("owned"),
2382
+ onClaim: (cap, event) => onClaimEvent(deviceId, cap, owner, event)
2383
+ });
2384
+ }
2385
+ function newCompositionRuntime(deps, p, target, seed) {
2386
+ const self = p.target.kind === "existing" ? selfReadCaps(p.fields, p.target.ref, p.target.features) : null;
2387
+ const targetKey = p.target.kind === "existing" ? compositionSourceKey(p.target.ref) : null;
2388
+ const nativeCaps = new Set(self?.native ?? []);
2389
+ const ownKey = targetKey ?? compositionSourceKey(composedDeviceRef(p.block.id));
2390
+ return new CompositionRuntime({
2391
+ blockId: p.block.id,
2392
+ fields: p.fields,
2393
+ eventPlans: p.events,
2394
+ events: deps.events,
2395
+ target,
2396
+ lookupCap: deps.lookupCap,
2397
+ ...seed === void 0 ? {} : { initialByCap: seed.byCap },
2398
+ read: (read) => deps.tracker.read(read, compositionSourceKey(read.source) === targetKey && nativeCaps.has(read.cap) ? "native" : "effective"),
2399
+ isOwnRead: (read) => compositionSourceKey(read.source) === ownKey && !nativeCaps.has(read.cap),
2400
+ now: deps.now,
2401
+ timer: deps.newTimer(),
2402
+ logger: deps.logger.child("runtime")
2403
+ });
2404
+ }
2405
+ //#endregion
2406
+ //#region src/builtins/composer/confirmed-seed.ts
2407
+ var ConfirmedSeed = class {
2408
+ deps;
2409
+ seeded = false;
2410
+ /** The previous read answered `[]`: one more `[]` confirms it (D49). */
2411
+ emptySeen = false;
2412
+ /** Consecutive applies whose read did not answer: warned once per run. */
2413
+ waitApplies = 0;
2414
+ constructor(deps) {
2415
+ this.deps = deps;
2416
+ }
2417
+ get done() {
2418
+ return this.seeded;
2419
+ }
2420
+ /** Null once seeded, else why this apply must wait. */
2421
+ async ensure() {
2422
+ if (this.seeded) return null;
2423
+ const read = await this.deps.read();
2424
+ if (read.kind === "unknown") {
2425
+ this.emptySeen = false;
2426
+ this.waitApplies += 1;
2427
+ if (this.waitApplies === 1) this.deps.logger.warn(`composer: the ${this.deps.subject} could not be read — nothing is declared, withdrawn, claimed or released from them; retried on the next apply`, { meta: {
2428
+ subject: this.deps.subject,
2429
+ error: read.error
2430
+ } });
2431
+ return this.deps.unreadReason;
2432
+ }
2433
+ if (read.items.length === 0 && !this.emptySeen) {
2434
+ this.emptySeen = true;
2435
+ this.deps.logger.warn(`composer: the ${this.deps.subject} read empty — waiting for the next apply's read to agree before trusting it`, { meta: { subject: this.deps.subject } });
2436
+ return this.deps.emptyUnconfirmedReason;
2437
+ }
2438
+ this.deps.onSeeded(read.items);
2439
+ this.seeded = true;
2440
+ this.deps.logger.info(`composer: seeded from the ${this.deps.subject}`, { meta: {
2441
+ subject: this.deps.subject,
2442
+ items: read.items.length,
2443
+ confirmedEmpty: read.items.length === 0,
2444
+ appliesRefused: this.waitApplies
2445
+ } });
2446
+ return null;
2447
+ }
2448
+ };
2449
+ //#endregion
2450
+ //#region src/builtins/composer/composer-seeds.ts
2451
+ /**
2452
+ * The two reads the composer takes ONCE before its first decision (D49): the
2453
+ * composed rows it already owns (before any withdrawal) and the claims the hub
2454
+ * holds (before any release). Each is a `ConfirmedSeed`: an unread or a
2455
+ * first-empty answer waits, by name, for an agreeing read.
2456
+ */
2457
+ var ROWS_UNREAD = "the composer's own device rows could not be read — nothing is declared or withdrawn until they are (D49)";
2458
+ var ROWS_EMPTY_UNCONFIRMED = "the composer's own device rows read empty once — a cold registry answers the same, so nothing is declared or withdrawn until the next apply's read agrees (D49)";
2459
+ var CLAIMS_NOT_LOADED = "the claims index has not loaded — nothing is claimed or released until it does (D49)";
2460
+ var CLAIMS_EMPTY_UNCONFIRMED = "the claims index read empty once — nothing is claimed or released until the next apply's read agrees (D49)";
2461
+ /** The rows the composer owns, read ONCE before its first withdrawal decision. */
2462
+ function composedRowSeed(deps) {
2463
+ return new ConfirmedSeed({
2464
+ logger: deps.logger,
2465
+ subject: "composer's own device rows",
2466
+ read: async () => {
2467
+ try {
2468
+ return {
2469
+ kind: "items",
2470
+ items: await deps.readPersisted()
2471
+ };
2472
+ } catch (err) {
2473
+ return {
2474
+ kind: "unknown",
2475
+ error: errMsg(err)
2476
+ };
2477
+ }
2478
+ },
2479
+ onSeeded: deps.onSeeded,
2480
+ unreadReason: ROWS_UNREAD,
2481
+ emptyUnconfirmedReason: ROWS_EMPTY_UNCONFIRMED
2482
+ });
2483
+ }
2484
+ /**
2485
+ * The claims the hub holds, read ONCE before the first release decision (rule
2486
+ * 3). `not-loaded` is UNKNOWN (D315), never empty.
2487
+ */
2488
+ function claimIndexSeed(deps) {
2489
+ return new ConfirmedSeed({
2490
+ logger: deps.logger,
2491
+ subject: "claims index",
2492
+ read: async () => {
2493
+ try {
2494
+ const listing = await deps.claims.listClaims({ ownerPrefix: "" });
2495
+ return listing.state === "loaded" ? {
2496
+ kind: "items",
2497
+ items: listing.claims
2498
+ } : {
2499
+ kind: "unknown",
2500
+ error: listing.reason
2501
+ };
2502
+ } catch (err) {
2503
+ return {
2504
+ kind: "unknown",
2505
+ error: errMsg(err)
2506
+ };
2507
+ }
2508
+ },
2509
+ onSeeded: deps.onSeeded,
2510
+ unreadReason: CLAIMS_NOT_LOADED,
2511
+ emptyUnconfirmedReason: CLAIMS_EMPTY_UNCONFIRMED
2512
+ });
2513
+ }
2514
+ //#endregion
2515
+ //#region src/builtins/composer/composer-verdict.ts
2516
+ /** A runtime built by an apply, before the apply's sync decides whether it may start. */
2517
+ var NOT_STARTED = "the runtime has not started yet";
2518
+ function verdictOf(blockId, entry, unsynced, grafts = null) {
2519
+ if (entry === void 0) return {
2520
+ blockId,
2521
+ status: "starting",
2522
+ reason: "not applied yet"
2523
+ };
2524
+ if (entry.rebuildError !== null) return {
2525
+ blockId,
2526
+ status: "failed",
2527
+ reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
2528
+ };
2529
+ if (entry.planned.problems.length > 0) {
2530
+ const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
2531
+ return {
2532
+ blockId,
2533
+ status: "failed",
2534
+ reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
2535
+ };
2536
+ }
2537
+ if (!entry.planned.block.enabled) return {
2538
+ blockId,
2539
+ status: "stopped"
2540
+ };
2541
+ const target = entry.planned.target;
2542
+ if (target.kind === "existing") {
2543
+ if (target.gone !== null) return {
2544
+ blockId,
2545
+ status: "failed",
2546
+ reason: `the target device is gone: ${target.gone}`
2547
+ };
2548
+ if (target.waiting !== null) return {
2549
+ blockId,
2550
+ status: "starting",
2551
+ reason: target.waiting
2552
+ };
2553
+ } else if (entry.device === null) return {
2554
+ blockId,
2555
+ status: "starting",
2556
+ reason: "the composed device has not been constructed yet"
2557
+ };
2558
+ const runtime = entry.runtime;
2559
+ if (runtime === null) {
2560
+ if (target.kind !== "existing" || target.features.length === 0) return {
2561
+ blockId,
2562
+ status: "running"
2563
+ };
2564
+ return {
2565
+ blockId,
2566
+ status: "starting",
2567
+ reason: NOT_STARTED
2568
+ };
2569
+ }
2570
+ if (unsynced) return {
2571
+ blockId,
2572
+ status: "starting",
2573
+ reason: NOT_STARTED
2574
+ };
2575
+ const state = runtime.state();
2576
+ if (state.failure !== null) return {
2577
+ blockId,
2578
+ status: "failed",
2579
+ reason: state.failure
2580
+ };
2581
+ if (grafts?.allUnserved === true) return {
2582
+ blockId,
2583
+ status: "failed",
2584
+ reason: grafts.reason
2585
+ };
2586
+ const degraded = [...state.released.length > 0 ? [degradedReason(state.released)] : [], ...grafts === null ? [] : [grafts.reason]];
2587
+ if (degraded.length > 0) return {
2588
+ blockId,
2589
+ status: "degraded",
2590
+ reason: degraded.join("; ")
2591
+ };
2592
+ if (state.held.length > 0) return {
2593
+ blockId,
2594
+ status: "starting",
2595
+ reason: heldReason(state.held)
2596
+ };
2597
+ return {
2598
+ blockId,
2599
+ status: "running"
2600
+ };
2601
+ }
2602
+ //#endregion
2353
2603
  //#region src/builtins/composer/composer.ts
2354
2604
  /**
2355
2605
  * The composer: every declarative composition on this hub, in one runner
@@ -2389,28 +2639,20 @@ var OwnedFieldsTarget = class {
2389
2639
  * value with its claim kept — the runtime's `held`, reported `starting` by name.
2390
2640
  * A runtime built by an apply starts in that apply's sync, never earlier from
2391
2641
  * an event (N-5).
2642
+ *
2643
+ * This file is the orchestration; the pieces live beside it:
2644
+ * `composer-apply-waits.ts` (the store / integration preconditions),
2645
+ * `composer-seeds.ts` (the two boot reads), `composer-runtime-factory.ts`
2646
+ * (where a runtime writes and how it reads) and `composer-live-set.ts` (the
2647
+ * pure reads of the live set: tracked sources, reset keys, wanted claims).
2392
2648
  */
2393
- var INTEGRATION_WAIT = "waiting for the shared Blocks integration to reconcile";
2394
- var STORE_NOT_LOADED = "the core_blocks store has not loaded — nothing is declared or withdrawn until it does (D49)";
2395
- var ROWS_UNREAD = "the composer's own device rows could not be read — nothing is declared or withdrawn until they are (D49)";
2396
- var ROWS_EMPTY_UNCONFIRMED = "the composer's own device rows read empty once — a cold registry answers the same, so nothing is declared or withdrawn until the next apply's read agrees (D49)";
2397
- var CLAIMS_NOT_LOADED = "the claims index has not loaded — nothing is claimed or released until it does (D49)";
2398
- var CLAIMS_EMPTY_UNCONFIRMED = "the claims index read empty once — nothing is claimed or released until the next apply's read agrees (D49)";
2399
- /** A release answered with one of these hands back nothing of ours: the gate forgets the claim. */
2400
- var RELEASE_SETTLED_CODES = new Set([
2401
- "not-claimed",
2402
- "unknown-device",
2403
- "owned-by-other"
2404
- ]);
2405
2649
  var Composer = class {
2406
2650
  deps;
2407
2651
  live = /* @__PURE__ */ new Map();
2408
2652
  /** The composed devices of `new`-target blocks, and the rows that exist. */
2409
2653
  composed;
2410
- /** Consecutive applies refused because `core_blocks` had not loaded: warned once per run. */
2411
- storeWaitApplies = 0;
2412
- /** Consecutive applies refused because the Blocks integration had not reconciled: warned once per run. */
2413
- integrationWaitApplies = 0;
2654
+ /** The store / integration preconditions every apply waits on first (D49, R2). */
2655
+ waits;
2414
2656
  gate;
2415
2657
  claimGate;
2416
2658
  targets;
@@ -2446,55 +2688,22 @@ var Composer = class {
2446
2688
  this.grafts.describing(deviceId);
2447
2689
  return deps.describeTarget(deviceId);
2448
2690
  });
2449
- this.rowSeed = new ConfirmedSeed({
2691
+ this.waits = new ApplyWaits(deps.logger);
2692
+ this.rowSeed = composedRowSeed({
2450
2693
  logger: deps.logger,
2451
- subject: "composer's own device rows",
2452
- read: async () => {
2453
- try {
2454
- return {
2455
- kind: "items",
2456
- items: await deps.readPersisted()
2457
- };
2458
- } catch (err) {
2459
- return {
2460
- kind: "unknown",
2461
- error: errMsg(err)
2462
- };
2463
- }
2464
- },
2694
+ readPersisted: deps.readPersisted,
2465
2695
  onSeeded: (rows) => {
2466
2696
  this.gate.seed(rows);
2467
2697
  this.composed.seedRows(rows);
2468
- },
2469
- unreadReason: ROWS_UNREAD,
2470
- emptyUnconfirmedReason: ROWS_EMPTY_UNCONFIRMED
2698
+ }
2471
2699
  });
2472
- this.claimSeed = new ConfirmedSeed({
2700
+ this.claimSeed = claimIndexSeed({
2473
2701
  logger: deps.logger,
2474
- subject: "claims index",
2475
- read: async () => {
2476
- try {
2477
- const listing = await deps.claims.listClaims({ ownerPrefix: "" });
2478
- return listing.state === "loaded" ? {
2479
- kind: "items",
2480
- items: listing.claims
2481
- } : {
2482
- kind: "unknown",
2483
- error: listing.reason
2484
- };
2485
- } catch (err) {
2486
- return {
2487
- kind: "unknown",
2488
- error: errMsg(err)
2489
- };
2490
- }
2491
- },
2702
+ claims: deps.claims,
2492
2703
  onSeeded: (claims) => {
2493
2704
  this.claimGate.seed(claims);
2494
2705
  this.deps.tracker.seedClaims(claims);
2495
- },
2496
- unreadReason: CLAIMS_NOT_LOADED,
2497
- emptyUnconfirmedReason: CLAIMS_EMPTY_UNCONFIRMED
2706
+ }
2498
2707
  });
2499
2708
  }
2500
2709
  apply(input) {
@@ -2542,29 +2751,10 @@ var Composer = class {
2542
2751
  }
2543
2752
  async applyNow(input) {
2544
2753
  const ids = [...input.blocks.map((b) => b.id), ...input.unreadableBlocks.map((u) => u.id)];
2545
- if (!input.storeLoaded) {
2546
- this.storeWaitApplies += 1;
2547
- if (this.storeWaitApplies === 1) this.deps.logger.warn("composer: the block store has not loaded — no device is declared, withdrawn or renamed", { meta: {
2548
- blocks: input.blocks.length,
2549
- live: this.live.size
2550
- } });
2551
- return this.waiting(ids, STORE_NOT_LOADED);
2552
- }
2553
- if (this.storeWaitApplies > 0) {
2554
- this.deps.logger.info("composer: the block store has loaded — applying again", { meta: { appliesRefused: this.storeWaitApplies } });
2555
- this.storeWaitApplies = 0;
2556
- }
2557
- if (input.integrationId === null) {
2558
- this.integrationWaitApplies += 1;
2559
- if (this.integrationWaitApplies === 1) this.deps.logger.warn("composer: the shared Blocks integration has not reconciled — no composed device can be declared", { meta: { blocks: input.blocks.length } });
2560
- return this.waiting(ids, INTEGRATION_WAIT);
2561
- }
2562
- if (this.integrationWaitApplies > 0) {
2563
- this.deps.logger.info("composer: the shared Blocks integration has reconciled — applying again", { meta: { appliesRefused: this.integrationWaitApplies } });
2564
- this.integrationWaitApplies = 0;
2565
- }
2754
+ const gate = this.waits.check(input, this.live.size);
2755
+ if (gate.kind === "wait") return waitingResult(ids, gate.reason);
2566
2756
  const unseeded = await this.rowSeed.ensure();
2567
- if (unseeded !== null) return this.waiting(ids, unseeded);
2757
+ if (unseeded !== null) return waitingResult(ids, unseeded);
2568
2758
  const claimsWait = await this.claimSeed.ensure();
2569
2759
  const restart = new Set(input.restartBlockIds);
2570
2760
  const planned = [];
@@ -2579,7 +2769,7 @@ var Composer = class {
2579
2769
  shape: previous.planned.shape
2580
2770
  };
2581
2771
  });
2582
- await this.composed.reconcile(input.integrationId, declarations, planned);
2772
+ await this.composed.reconcile(gate.integrationId, declarations, planned);
2583
2773
  const rebuilt = await this.rebuildRuntimes(planned, declarations, restart, rebuildErrors);
2584
2774
  await this.syncSources(rebuilt);
2585
2775
  await this.reconcileClaims();
@@ -2588,16 +2778,6 @@ var Composer = class {
2588
2778
  acted: true
2589
2779
  };
2590
2780
  }
2591
- waiting(blockIds, reason) {
2592
- return {
2593
- blocks: blockIds.map((blockId) => ({
2594
- blockId,
2595
- status: "starting",
2596
- reason
2597
- })),
2598
- acted: false
2599
- };
2600
- }
2601
2781
  plan(block, restart, claimsWait) {
2602
2782
  const target = block.composition.target;
2603
2783
  if (target.kind === "new") return Promise.resolve(planNew(block, target, this.deps.lookupCap, this.composed.rows));
@@ -2674,14 +2854,11 @@ var Composer = class {
2674
2854
  continue;
2675
2855
  }
2676
2856
  const heldFields = this.heldFieldsFor(p, previous);
2677
- const operatorAction = previous === void 0 || restart.has(p.block.id) || previous.planned.block.updatedAt !== p.block.updatedAt;
2678
- const targetKey = p.target.kind === "existing" ? compositionSourceKey(p.target.ref) : null;
2679
- if (operatorAction) for (const f of p.fields) for (const r of f.reads) {
2680
- const key = compositionSourceKey(r.source);
2681
- if (key !== targetKey) reset.add(key);
2682
- }
2683
- const writer = this.writerFor(p, device);
2684
- const runtime = writer === null || p.fields.length === 0 ? null : this.newRuntime(p, writer, targetSeed);
2857
+ if (previous === void 0 || restart.has(p.block.id) || previous.planned.block.updatedAt !== p.block.updatedAt) for (const key of operatorResetKeys(p)) reset.add(key);
2858
+ const writer = writerFor(this.deps, p, device, (deviceId, cap, owner, event) => {
2859
+ this.onClaimEvent(deviceId, cap, owner, event);
2860
+ });
2861
+ const runtime = writer === null || p.fields.length === 0 ? null : newCompositionRuntime(this.deps, p, writer, targetSeed);
2685
2862
  if (writer !== null && p.target.kind === "existing" && p.target.deviceId !== null) this.grafts.plan({
2686
2863
  deviceId: p.target.deviceId,
2687
2864
  ref: p.target.ref,
@@ -2723,23 +2900,6 @@ var Composer = class {
2723
2900
  features: target.features
2724
2901
  });
2725
2902
  }
2726
- /** Where a block's runtime writes: its composed device, or a claim-backed target on the existing device. */
2727
- writerFor(p, device) {
2728
- const target = p.target;
2729
- if (target.kind !== "existing") return device;
2730
- if (target.deviceId === null || target.waiting !== null || target.gone !== null) return null;
2731
- const deviceId = target.deviceId;
2732
- const owner = p.block.id;
2733
- return new OwnedFieldsTarget({
2734
- deviceId,
2735
- owner,
2736
- modeOf: (cap) => claimModeOf(target.features, cap),
2737
- fieldsOf: (cap) => claimFieldsOf(p.fields, cap),
2738
- api: this.deps.claims,
2739
- logger: this.deps.logger.child("owned"),
2740
- onClaim: (cap, event) => void this.onClaimEvent(deviceId, cap, owner, event)
2741
- });
2742
- }
2743
2903
  /** C-1/N-1: the tracker follows the block's OWN claim as the hub answers it; every answer re-reads the native once. */
2744
2904
  async onClaimEvent(deviceId, cap, owner, event) {
2745
2905
  try {
@@ -2761,26 +2921,6 @@ var Composer = class {
2761
2921
  if (previous.runtime === null) return previous.heldFields;
2762
2922
  return heldFieldsOf(previous.runtime.state().fields, `the composition no longer plans — the field keeps its last value on the device: ${p.problems.join("; ")}`);
2763
2923
  }
2764
- newRuntime(p, target, seed) {
2765
- const self = p.target.kind === "existing" ? selfReadCaps(p.fields, p.target.ref, p.target.features) : null;
2766
- const targetKey = p.target.kind === "existing" ? compositionSourceKey(p.target.ref) : null;
2767
- const nativeCaps = new Set(self?.native ?? []);
2768
- const ownKey = targetKey ?? compositionSourceKey(composedDeviceRef(p.block.id));
2769
- return new CompositionRuntime({
2770
- blockId: p.block.id,
2771
- fields: p.fields,
2772
- eventPlans: p.events,
2773
- events: this.deps.events,
2774
- target,
2775
- lookupCap: this.deps.lookupCap,
2776
- ...seed === void 0 ? {} : { initialByCap: seed.byCap },
2777
- read: (read) => this.deps.tracker.read(read, compositionSourceKey(read.source) === targetKey && nativeCaps.has(read.cap) ? "native" : "effective"),
2778
- isOwnRead: (read) => compositionSourceKey(read.source) === ownKey && !nativeCaps.has(read.cap),
2779
- now: this.deps.now,
2780
- timer: this.deps.newTimer(),
2781
- logger: this.deps.logger.child("runtime")
2782
- });
2783
- }
2784
2924
  async applyName(device, name, applied) {
2785
2925
  if ((applied ?? device.name) === name) return name;
2786
2926
  try {
@@ -2802,27 +2942,7 @@ var Composer = class {
2802
2942
  }
2803
2943
  }
2804
2944
  async syncSources(rebuilt) {
2805
- const requests = [];
2806
- for (const e of this.live.values()) {
2807
- const target = e.planned.target;
2808
- const running = e.runtime !== null && e.planned.block.enabled;
2809
- let targetKey = null;
2810
- if (target.kind === "existing") {
2811
- targetKey = compositionSourceKey(target.ref);
2812
- const self = running ? selfReadCaps(e.planned.fields, target.ref, target.features) : null;
2813
- requests.push({
2814
- ref: target.ref,
2815
- caps: self?.effective ?? [],
2816
- nativeCaps: self?.native ?? [],
2817
- nativeOwner: e.planned.block.id
2818
- });
2819
- }
2820
- if (!running) continue;
2821
- for (const f of e.planned.fields) for (const r of f.reads) if (compositionSourceKey(r.source) !== targetKey) requests.push({
2822
- ref: r.source,
2823
- caps: [r.cap]
2824
- });
2825
- }
2945
+ const requests = trackedSourceRequests(this.live.values());
2826
2946
  const changed = await this.syncTracker(requests, rebuilt.reset);
2827
2947
  for (const entry of this.live.values()) {
2828
2948
  const runtime = entry.runtime;
@@ -2846,34 +2966,10 @@ var Composer = class {
2846
2966
  return /* @__PURE__ */ new Set();
2847
2967
  }
2848
2968
  }
2849
- /**
2850
- * Rule 3: the claims every live customization WANTS this apply; everything
2851
- * else the gate knows is a release candidate, handed back once two applies
2852
- * agree. A block that cannot plan — waiting, unreadable, failed, or with its
2853
- * target gone — HOLDS its claims (rule 4, rule 5): they are wanted as they
2854
- * are. A disabled block wants none (rule 7).
2855
- */
2969
+ /** Rule 3: release what no live customization wants, once two applies agree (`wantedClaims`). */
2856
2970
  async reconcileClaims() {
2857
2971
  if (!this.claimSeed.done) return;
2858
- const wanted = [];
2859
- const held = [];
2860
- for (const [blockId, entry] of this.live) {
2861
- const target = entry.planned.target;
2862
- if (target.kind === "new") continue;
2863
- if (target.kind === "unreadable" || entry.planned.problems.length > 0 || target.waiting !== null || target.gone !== null || target.deviceId === null) {
2864
- held.push(blockId);
2865
- continue;
2866
- }
2867
- if (!entry.planned.block.enabled) continue;
2868
- const deviceId = target.deviceId;
2869
- for (const f of target.features) wanted.push({
2870
- deviceId,
2871
- capName: f.cap,
2872
- owner: blockId
2873
- });
2874
- }
2875
- for (const owner of held) wanted.push(...this.claimGate.claimsOf(owner));
2876
- for (const key of this.claimGate.filter(wanted)) await this.release(key);
2972
+ for (const key of this.claimGate.filter(wantedClaims(this.live, this.claimGate))) await this.release(key);
2877
2973
  }
2878
2974
  async release(key) {
2879
2975
  let outcome;