@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -77
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +3 -77
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer-apply-waits.d.ts +21 -0
- package/dist/builtins/composer/composer-live-set.d.ts +22 -0
- package/dist/builtins/composer/composer-runtime-factory.d.ts +22 -0
- package/dist/builtins/composer/composer-seeds.d.ts +22 -0
- package/dist/builtins/composer/composer.addon.js +1905 -1809
- package/dist/builtins/composer/composer.addon.mjs +1905 -1809
- package/dist/builtins/composer/composer.d.ts +3 -15
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/shm-lock-canary.d.ts +60 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/sqlite-storage/wal-checkpoint-policy.d.ts +115 -34
- package/dist/builtins/sqlite-storage/wal-checkpointer.d.ts +6 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-backup/system-backup.service.d.ts +2 -17
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{composition-sources-Dep7nBwu.js → composition-sources-BjRPhWtU.js} +1 -1
- package/dist/{composition-sources-DVClu006.mjs → composition-sources-Cg2MP-qf.mjs} +1 -1
- package/dist/{dist-Dori4pf3.mjs → dist-CQ9vXSUh.mjs} +135 -2
- package/dist/{dist-a6p0V-IL.js → dist-Dcy0gVHu.js} +135 -2
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-DVcKRgzm.mjs → retired-settings-keys-DNIbyfTR.mjs} +1 -1
- package/dist/{retired-settings-keys-DC1uHWPZ.js → retired-settings-keys-pzlrLUbA.js} +1 -1
- package/dist/wal-checkpoint-policy-BahrTtyD.mjs +186 -0
- package/dist/wal-checkpoint-policy-d2z2qPqx.js +233 -0
- package/dist/wal-checkpoint-worker.js +63 -57
- package/dist/wal-checkpoint-worker.mjs +63 -57
- package/package.json +1 -1
- package/dist/wal-checkpoint-policy-CAcg63o-.mjs +0 -92
- 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-
|
|
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-
|
|
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/
|
|
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
|
-
*
|
|
302
|
-
*
|
|
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
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
/**
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
343
|
-
var WithdrawalGate = class {
|
|
430
|
+
var TargetDescriptions = class {
|
|
344
431
|
logger;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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.
|
|
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
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
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
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
/**
|
|
458
|
-
function
|
|
459
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
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
|
-
...
|
|
547
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
/**
|
|
574
|
-
function
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
|
584
|
-
*
|
|
585
|
-
*
|
|
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
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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/
|
|
522
|
+
//#region src/builtins/composer/composer-grafts.ts
|
|
634
523
|
/**
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
* `
|
|
638
|
-
*
|
|
639
|
-
*
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
646
|
-
return
|
|
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
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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
|
-
|
|
663
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
/**
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
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
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
* through the
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
794
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
const
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
}
|
|
894
|
-
|
|
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
|
-
|
|
898
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
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
|
|
954
|
-
return
|
|
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
|
-
/** `
|
|
957
|
-
function
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
967
|
-
|
|
1073
|
+
target: before,
|
|
1074
|
+
deviceId: before.deviceId
|
|
968
1075
|
};
|
|
969
1076
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
|
1006
|
-
return
|
|
1124
|
+
function isRecord$1(value) {
|
|
1125
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1007
1126
|
}
|
|
1008
|
-
function
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
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/
|
|
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
|
-
*
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
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
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
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
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
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
|
-
|
|
1079
|
-
"
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
records;
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1419
|
-
|
|
1408
|
+
...extras.meta,
|
|
1409
|
+
error: errorText(error)
|
|
1420
1410
|
}
|
|
1421
1411
|
});
|
|
1422
1412
|
}
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
-
|
|
1463
|
-
if (this.
|
|
1464
|
-
this.
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
const outcome =
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1491
|
-
this.
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
this.
|
|
1526
|
-
|
|
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
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
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
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
-
/**
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
this
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
this.
|
|
1578
|
-
|
|
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
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
to: outcome.state,
|
|
1594
|
-
reason: reasonOf(outcome)
|
|
1726
|
+
cap,
|
|
1727
|
+
code,
|
|
1728
|
+
error: message
|
|
1595
1729
|
}
|
|
1596
|
-
};
|
|
1597
|
-
|
|
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
|
-
/**
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
this.
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
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:
|
|
1682
|
-
|
|
1683
|
-
|
|
1758
|
+
blockId: this.deps.blockId,
|
|
1759
|
+
during,
|
|
1760
|
+
error
|
|
1684
1761
|
}
|
|
1685
1762
|
});
|
|
1686
1763
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
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
|
-
|
|
1738
|
-
const
|
|
1739
|
-
|
|
1740
|
-
const
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
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
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
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
|
-
|
|
1870
|
-
|
|
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
|
-
/**
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
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
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
kind: "
|
|
1928
|
-
error:
|
|
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 (
|
|
1932
|
-
this.
|
|
1933
|
-
|
|
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
|
-
|
|
1973
|
+
this.markPending(cap, op, failed);
|
|
1974
|
+
return false;
|
|
1942
1975
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
this.
|
|
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
|
-
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1961
|
-
const
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
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
|
-
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
const
|
|
1982
|
-
if (
|
|
1983
|
-
this.
|
|
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
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
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
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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
|
-
/**
|
|
2415
|
-
|
|
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.
|
|
2695
|
+
this.waits = new ApplyWaits(deps.logger);
|
|
2696
|
+
this.rowSeed = composedRowSeed({
|
|
2454
2697
|
logger: deps.logger,
|
|
2455
|
-
|
|
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 =
|
|
2704
|
+
this.claimSeed = claimIndexSeed({
|
|
2477
2705
|
logger: deps.logger,
|
|
2478
|
-
|
|
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
|
-
|
|
2550
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
2682
|
-
const
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
|
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;
|