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