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