@camstack/system 1.2.313 → 1.2.315
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/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/claim-gate.d.ts +32 -0
- package/dist/builtins/composer/composed-device-set.d.ts +38 -0
- package/dist/builtins/composer/composed-device.d.ts +4 -3
- package/dist/builtins/composer/composer-grafts.d.ts +54 -0
- package/dist/builtins/composer/composer-plan.d.ts +96 -0
- package/dist/builtins/composer/composer-verdict.d.ts +19 -0
- package/dist/builtins/composer/composer.addon.js +2717 -477
- package/dist/builtins/composer/composer.addon.mjs +2722 -482
- package/dist/builtins/composer/composer.d.ts +71 -52
- package/dist/builtins/composer/composition-runtime.d.ts +93 -9
- package/dist/builtins/composer/confirmed-seed.d.ts +32 -0
- package/dist/builtins/composer/existing-target.d.ts +96 -0
- package/dist/builtins/composer/field-record.d.ts +50 -0
- package/dist/builtins/composer/graft-host.d.ts +47 -0
- package/dist/builtins/composer/held-claims.d.ts +12 -0
- package/dist/builtins/composer/owned-fields-target.d.ts +55 -0
- package/dist/builtins/composer/slice-assembler.d.ts +10 -0
- package/dist/builtins/composer/source-readings.d.ts +122 -0
- package/dist/builtins/composer/source-tracker.d.ts +35 -42
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/composition-api.d.ts +7 -4
- package/dist/builtins/core-blocks/composition-peers.d.ts +9 -0
- package/dist/builtins/core-blocks/composition-sources.d.ts +16 -3
- package/dist/builtins/core-blocks/core-block-store.d.ts +8 -1
- package/dist/builtins/core-blocks/core-blocks.addon.d.ts +4 -3
- package/dist/builtins/core-blocks/core-blocks.addon.js +255 -79
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +255 -79
- package/dist/builtins/device-manager/claimed-status-overlay.d.ts +12 -0
- package/dist/builtins/device-manager/device-manager.addon.d.ts +16 -0
- package/dist/builtins/device-manager/device-manager.addon.js +1825 -317
- package/dist/builtins/device-manager/device-manager.addon.mjs +1825 -317
- package/dist/builtins/device-manager/device-provider-context.d.ts +24 -2
- package/dist/builtins/device-manager/device-row-store.d.ts +2 -0
- package/dist/builtins/device-manager/device-state-claim-views.d.ts +77 -0
- package/dist/builtins/device-manager/device-state-claims.d.ts +43 -0
- package/dist/builtins/device-manager/device-state-mirror.d.ts +145 -61
- package/dist/builtins/device-manager/field-claims-index.d.ts +69 -0
- package/dist/builtins/device-manager/field-ownership.d.ts +53 -0
- package/dist/builtins/device-manager/migrate-device.d.ts +27 -0
- package/dist/builtins/device-manager/migrate-guard.d.ts +7 -0
- package/dist/builtins/device-manager/migrate-hardware-state.d.ts +55 -0
- package/dist/builtins/device-manager/migration-refused.d.ts +23 -0
- package/dist/builtins/device-manager/mirror-row-writer.d.ts +138 -0
- package/dist/builtins/device-manager/runtime-state-persist-gate.d.ts +4 -0
- package/dist/builtins/doorbell/binding-mirror.d.ts +1 -0
- package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +59 -0
- package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +3 -0
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +315 -10
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +315 -10
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{child-cap-dispatch-CHTiHbsv.mjs → child-cap-dispatch-AAltfBUH.mjs} +1484 -1417
- package/dist/{child-cap-dispatch-Dyn_7ezH.js → child-cap-dispatch-DzcUd_ct.js} +1503 -1424
- package/dist/composition-sources-DYqQLRzF.js +122 -0
- package/dist/composition-sources-yonsRneP.mjs +111 -0
- package/dist/{dist-CkRwnhbo.js → dist-DVKv5i-e.js} +5396 -4331
- package/dist/{dist-Cj7pJmXq.mjs → dist-MzYJVCLE.mjs} +5330 -4331
- package/dist/index.js +137 -8
- package/dist/index.mjs +133 -8
- package/dist/kernel/capability-registry.d.ts +43 -1
- package/dist/kernel/index.d.ts +3 -1
- package/dist/kernel/moleculer/device-cap-proxy.d.ts +0 -10
- package/dist/kernel/status-overlay.d.ts +18 -0
- package/dist/kernel/transport/claimed-get-status.d.ts +15 -0
- package/dist/kernel/transport/index.d.ts +2 -0
- package/dist/kernel/transport/local-child-registry.d.ts +23 -0
- package/dist/kernel/transport/parent-unowned-call.d.ts +25 -0
- package/dist/{retired-settings-keys-C1duPikd.mjs → retired-settings-keys-8yY2sTc-.mjs} +1 -1
- package/dist/{retired-settings-keys-CGSuXhP8.js → retired-settings-keys-iY5I0nKM.js} +1 -1
- package/package.json +1 -1
|
@@ -3,8 +3,9 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-DVKv5i-e.js");
|
|
7
7
|
const require_device_state_changed = require("../../device-state-changed-Bk7cvTnz.js");
|
|
8
|
+
const require_composition_sources = require("../../composition-sources-DYqQLRzF.js");
|
|
8
9
|
//#region src/builtins/composer/composed-device.ts
|
|
9
10
|
/**
|
|
10
11
|
* A composed device: an ordinary `BaseDevice` whose caps are served by the
|
|
@@ -84,6 +85,217 @@ function isComposedDevice(device) {
|
|
|
84
85
|
return device instanceof ComposedDevice;
|
|
85
86
|
}
|
|
86
87
|
//#endregion
|
|
88
|
+
//#region src/builtins/composer/claim-gate.ts
|
|
89
|
+
function claimKey(k) {
|
|
90
|
+
return `${k.owner}@${k.deviceId}#${k.capName}`;
|
|
91
|
+
}
|
|
92
|
+
var RELEASE_CONFIRMATIONS = 2;
|
|
93
|
+
function entryOf$1(key) {
|
|
94
|
+
return {
|
|
95
|
+
key,
|
|
96
|
+
misses: 0,
|
|
97
|
+
attempts: 0
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
var ClaimGate = class {
|
|
101
|
+
logger;
|
|
102
|
+
known = /* @__PURE__ */ new Map();
|
|
103
|
+
constructor(logger) {
|
|
104
|
+
this.logger = logger;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The claims the hub holds before this process applied anything. Called once
|
|
108
|
+
* at boot, before the first `filter`. A claim the gate already knows keeps its
|
|
109
|
+
* entry and its miss count.
|
|
110
|
+
*/
|
|
111
|
+
seed(claims) {
|
|
112
|
+
const fresh = claims.filter((c) => !this.known.has(claimKey(c)));
|
|
113
|
+
this.known = new Map([...this.known, ...fresh.map((c) => [claimKey(c), entryOf$1({
|
|
114
|
+
deviceId: c.deviceId,
|
|
115
|
+
capName: c.capName,
|
|
116
|
+
owner: c.owner
|
|
117
|
+
})])]);
|
|
118
|
+
}
|
|
119
|
+
/** Every claim known under one owner — what a block whose plan is held keeps (D659 placeholder rule). */
|
|
120
|
+
claimsOf(owner) {
|
|
121
|
+
return [...this.known.values()].filter((e) => e.key.owner === owner).map((e) => e.key);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Learn `wanted` and answer the claims to release NOW: those absent from
|
|
125
|
+
* `wanted` on two consecutive calls, plus those whose earlier release the hub
|
|
126
|
+
* has not confirmed yet. A claim wanted again starts from zero misses.
|
|
127
|
+
*/
|
|
128
|
+
filter(wanted) {
|
|
129
|
+
const present = new Map(wanted.map((k) => [claimKey(k), k]));
|
|
130
|
+
const next = /* @__PURE__ */ new Map();
|
|
131
|
+
const releases = [];
|
|
132
|
+
for (const [key, entry] of this.known) {
|
|
133
|
+
if (present.has(key)) {
|
|
134
|
+
next.set(key, {
|
|
135
|
+
...entry,
|
|
136
|
+
misses: 0,
|
|
137
|
+
attempts: 0
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const misses = Math.min(entry.misses + 1, RELEASE_CONFIRMATIONS);
|
|
142
|
+
if (misses < RELEASE_CONFIRMATIONS) this.logger.warn("a claim vanished from one apply — it is kept until a second apply agrees", {
|
|
143
|
+
tags: this.tagsOf(entry.key),
|
|
144
|
+
meta: {
|
|
145
|
+
...this.metaOf(entry.key),
|
|
146
|
+
misses
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
else {
|
|
150
|
+
if (entry.misses < RELEASE_CONFIRMATIONS) this.logger.info("a claim is unwanted on two consecutive applies — it is released to the native provider", {
|
|
151
|
+
tags: this.tagsOf(entry.key),
|
|
152
|
+
meta: this.metaOf(entry.key)
|
|
153
|
+
});
|
|
154
|
+
releases.push(entry.key);
|
|
155
|
+
}
|
|
156
|
+
next.set(key, {
|
|
157
|
+
...entry,
|
|
158
|
+
misses
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
for (const [key, k] of present) if (!next.has(key)) next.set(key, entryOf$1(k));
|
|
162
|
+
this.known = next;
|
|
163
|
+
return releases;
|
|
164
|
+
}
|
|
165
|
+
/** The hub confirmed the release (or held nothing of ours): forgotten. */
|
|
166
|
+
released(key, how) {
|
|
167
|
+
const k = claimKey(key);
|
|
168
|
+
const attempts = this.known.get(k)?.attempts ?? 0;
|
|
169
|
+
this.known = new Map([...this.known].filter(([other]) => other !== k));
|
|
170
|
+
this.logger.info(attempts > 0 ? "claim release landed after failed attempts" : "claim release confirmed by the hub", {
|
|
171
|
+
tags: this.tagsOf(key),
|
|
172
|
+
meta: {
|
|
173
|
+
...this.metaOf(key),
|
|
174
|
+
outcome: how,
|
|
175
|
+
...attempts > 0 ? { attempts } : {}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** The release did not land: kept, and returned by every later `filter` until it does. Never given up. */
|
|
180
|
+
releaseFailed(key, error) {
|
|
181
|
+
const k = claimKey(key);
|
|
182
|
+
const entry = this.known.get(k);
|
|
183
|
+
if (entry === void 0) return;
|
|
184
|
+
const attempts = entry.attempts + 1;
|
|
185
|
+
this.known = new Map([...this.known, [k, {
|
|
186
|
+
...entry,
|
|
187
|
+
attempts
|
|
188
|
+
}]]);
|
|
189
|
+
if (attempts > 1) return;
|
|
190
|
+
this.logger.warn("claim release did not land — retried on every apply until it does", {
|
|
191
|
+
tags: this.tagsOf(key),
|
|
192
|
+
meta: {
|
|
193
|
+
...this.metaOf(key),
|
|
194
|
+
error
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
tagsOf(key) {
|
|
199
|
+
return { deviceId: key.deviceId };
|
|
200
|
+
}
|
|
201
|
+
metaOf(key) {
|
|
202
|
+
return {
|
|
203
|
+
owner: key.owner,
|
|
204
|
+
cap: key.capName
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/builtins/composer/composed-device-set.ts
|
|
210
|
+
/**
|
|
211
|
+
* The composed devices the composer DECLARED (D659): one row per `new`-target
|
|
212
|
+
* block, hung off the shared Blocks integration, adopted or built by
|
|
213
|
+
* `DeclaredDevices.reconcile`. This is the only place a composed row is created,
|
|
214
|
+
* kept or swept — a composition on an EXISTING device never enters it (D663).
|
|
215
|
+
*
|
|
216
|
+
* - `rows` is the set of stableIds whose row exists: seeded from the persisted
|
|
217
|
+
* rows at boot, then what every declare kept. Planning asks it to decide
|
|
218
|
+
* whether a block that stopped planning has a row to hold as a placeholder.
|
|
219
|
+
* - A changed shape (type, role, cap set, placeholder or not) needs a NEW device
|
|
220
|
+
* object: decommission, and let the reconcile adopt it with the new class. A
|
|
221
|
+
* failure keeps the old device and is retried on the next apply.
|
|
222
|
+
* - A failed reconcile changes nothing (D49): the devices already live stay.
|
|
223
|
+
*/
|
|
224
|
+
var ComposedDeviceSet = class {
|
|
225
|
+
deps;
|
|
226
|
+
devices = /* @__PURE__ */ new Map();
|
|
227
|
+
existingRows = /* @__PURE__ */ new Set();
|
|
228
|
+
declaredFingerprint = null;
|
|
229
|
+
constructor(deps) {
|
|
230
|
+
this.deps = deps;
|
|
231
|
+
}
|
|
232
|
+
/** StableIds whose row exists. */
|
|
233
|
+
get rows() {
|
|
234
|
+
return this.existingRows;
|
|
235
|
+
}
|
|
236
|
+
seedRows(rows) {
|
|
237
|
+
this.existingRows = new Set(rows.map((r) => r.stableId));
|
|
238
|
+
}
|
|
239
|
+
get(stableId) {
|
|
240
|
+
return this.devices.get(stableId) ?? null;
|
|
241
|
+
}
|
|
242
|
+
idOf(stableId) {
|
|
243
|
+
return this.devices.get(stableId)?.id ?? null;
|
|
244
|
+
}
|
|
245
|
+
async rebuildShapes(planned, previousOf) {
|
|
246
|
+
const errors = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const p of planned) {
|
|
248
|
+
const previous = previousOf(p.block.id);
|
|
249
|
+
const device = previous?.device ?? null;
|
|
250
|
+
if (previous === void 0 || device === null || p.shape === null) continue;
|
|
251
|
+
if (previous.shape === p.shape) continue;
|
|
252
|
+
try {
|
|
253
|
+
this.deps.logger.info("composition shape changed — rebuilding its device", {
|
|
254
|
+
tags: { deviceId: device.id },
|
|
255
|
+
meta: {
|
|
256
|
+
blockId: p.block.id,
|
|
257
|
+
from: previous.shape,
|
|
258
|
+
to: p.shape
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
if (p.spec !== null) await this.deps.devices.applyDerivedSpec(device.id, p.spec);
|
|
262
|
+
await this.deps.devices.decommission(device.id);
|
|
263
|
+
const stableId = require_dist.composedDeviceStableId(p.block.id);
|
|
264
|
+
this.devices = new Map([...this.devices].filter(([s]) => s !== stableId));
|
|
265
|
+
this.declaredFingerprint = null;
|
|
266
|
+
} catch (err) {
|
|
267
|
+
const error = require_dist.errMsg(err);
|
|
268
|
+
errors.set(p.block.id, error);
|
|
269
|
+
this.deps.logger.warn("rebuilding a composed device failed — the old one is kept", {
|
|
270
|
+
tags: { deviceId: device.id },
|
|
271
|
+
meta: {
|
|
272
|
+
blockId: p.block.id,
|
|
273
|
+
error
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return errors;
|
|
279
|
+
}
|
|
280
|
+
async reconcile(integrationId, declarations, planned) {
|
|
281
|
+
const shapes = new Map(planned.flatMap((p) => p.declaration === null ? [] : [[p.declaration.stableId, p.shape]]));
|
|
282
|
+
const fingerprint = JSON.stringify(declarations.map((d) => [d.stableId, shapes.get(d.stableId) ?? null]).toSorted());
|
|
283
|
+
const allLive = declarations.every((d) => this.devices.has(d.stableId));
|
|
284
|
+
if (fingerprint === this.declaredFingerprint && allLive) return;
|
|
285
|
+
try {
|
|
286
|
+
const result = await this.deps.declare(integrationId, declarations);
|
|
287
|
+
this.devices = result.devices;
|
|
288
|
+
this.existingRows = new Set(declarations.map((d) => d.stableId).filter((s) => this.existingRows.has(s) || result.devices.has(s)));
|
|
289
|
+
this.declaredFingerprint = fingerprint;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
this.deps.logger.warn("composer: the device declaration failed — keeping the devices already live", { meta: {
|
|
292
|
+
error: require_dist.errMsg(err),
|
|
293
|
+
declarations: declarations.length
|
|
294
|
+
} });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
//#endregion
|
|
87
299
|
//#region src/builtins/composer/composition-declarations.ts
|
|
88
300
|
/**
|
|
89
301
|
* Which composed devices exist (D659): one declaration per composition block,
|
|
@@ -191,6 +403,423 @@ var WithdrawalGate = class {
|
|
|
191
403
|
}
|
|
192
404
|
};
|
|
193
405
|
//#endregion
|
|
406
|
+
//#region src/builtins/composer/composer-plan.ts
|
|
407
|
+
/**
|
|
408
|
+
* What one apply PLANS for a block, before anything is declared, claimed or
|
|
409
|
+
* run (D659, D663). Pure over its inputs: the composer hands in what it knows
|
|
410
|
+
* (its rows, the previous plan, the tracker) and gets a `PlannedBlock` back.
|
|
411
|
+
*
|
|
412
|
+
* - A `new` target plans as in slice 1: a declaration, a shape, a placeholder
|
|
413
|
+
* when the composition stopped planning but its row exists.
|
|
414
|
+
* - An `existing` target declares NOTHING (the row is not the composer's). It
|
|
415
|
+
* is resolved through the tracker's path, described with one bindings read
|
|
416
|
+
* per plan change, and planned against that description. A settled plan is
|
|
417
|
+
* kept until the OPERATOR acts (edit, restart): a bindings change alone never
|
|
418
|
+
* re-plans (Task 10 rule 2, D659). While the claims index is unseeded or the
|
|
419
|
+
* target is unresolved or undescribed, the block WAITS by name and claims
|
|
420
|
+
* nothing (D49). A target gone on two agreeing index reads is `gone`.
|
|
421
|
+
* - An unreadable row names no target: whatever it holds is held (D49).
|
|
422
|
+
*/
|
|
423
|
+
/** The shape of a placeholder: no caps, so no plan to take a shape from. */
|
|
424
|
+
var PLACEHOLDER_SHAPE = "placeholder";
|
|
425
|
+
var NO_PLAN = {
|
|
426
|
+
fields: [],
|
|
427
|
+
events: [],
|
|
428
|
+
problems: [],
|
|
429
|
+
declaration: null,
|
|
430
|
+
shape: null,
|
|
431
|
+
spec: null
|
|
432
|
+
};
|
|
433
|
+
/**
|
|
434
|
+
* A row that exists but does not parse is held exactly like a composition
|
|
435
|
+
* that stopped planning: its device (if it has a row) stays as an offline
|
|
436
|
+
* placeholder, its claims (if any) are kept, and the block fails naming the
|
|
437
|
+
* parse error. Absent, it would be withdrawn on the second apply that omitted
|
|
438
|
+
* it (D49).
|
|
439
|
+
*/
|
|
440
|
+
function planUnreadable(block, rows) {
|
|
441
|
+
const stableId = require_dist.composedDeviceStableId(block.id);
|
|
442
|
+
const hasRow = rows.has(stableId);
|
|
443
|
+
return {
|
|
444
|
+
...NO_PLAN,
|
|
445
|
+
block: {
|
|
446
|
+
id: block.id,
|
|
447
|
+
name: block.name,
|
|
448
|
+
enabled: false,
|
|
449
|
+
updatedAt: 0
|
|
450
|
+
},
|
|
451
|
+
problems: [`the stored block does not parse — ${block.error}`],
|
|
452
|
+
declaration: hasRow ? placeholderDeclaration(stableId) : null,
|
|
453
|
+
shape: hasRow ? PLACEHOLDER_SHAPE : null,
|
|
454
|
+
target: { kind: "unreadable" }
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** `target` is the block's own `new` target, narrowed by the caller: this path never sees an existing one (M-5). */
|
|
458
|
+
function planNew(block, target, lookupCap, rows) {
|
|
459
|
+
const plan = require_dist.planComposition(block.composition, lookupCap);
|
|
460
|
+
const planned = { kind: "new" };
|
|
461
|
+
if (plan.problems.length > 0) {
|
|
462
|
+
const stableId = require_dist.composedDeviceStableId(block.id);
|
|
463
|
+
const hasRow = rows.has(stableId);
|
|
464
|
+
return {
|
|
465
|
+
...NO_PLAN,
|
|
466
|
+
block,
|
|
467
|
+
problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
|
|
468
|
+
declaration: hasRow ? placeholderDeclaration(stableId) : null,
|
|
469
|
+
shape: hasRow ? PLACEHOLDER_SHAPE : null,
|
|
470
|
+
target: planned
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
const caps = block.composition.features.flatMap((f) => {
|
|
474
|
+
if (!require_dist.compositionFeatureEnabled(f)) return [];
|
|
475
|
+
const def = lookupCap(f.cap);
|
|
476
|
+
return def === null ? [] : [def];
|
|
477
|
+
});
|
|
478
|
+
const declarationInput = {
|
|
479
|
+
blockId: block.id,
|
|
480
|
+
name: block.name,
|
|
481
|
+
target,
|
|
482
|
+
caps
|
|
483
|
+
};
|
|
484
|
+
return {
|
|
485
|
+
block,
|
|
486
|
+
fields: plan.fields,
|
|
487
|
+
events: plan.events,
|
|
488
|
+
problems: [],
|
|
489
|
+
declaration: declarationFor(declarationInput),
|
|
490
|
+
shape: shapeOf(declarationInput),
|
|
491
|
+
spec: {
|
|
492
|
+
type: target.type,
|
|
493
|
+
...target.role === void 0 ? {} : { role: target.role }
|
|
494
|
+
},
|
|
495
|
+
target: planned
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
async function planExisting(input) {
|
|
499
|
+
const { block, previous, operatorAction } = input;
|
|
500
|
+
const prior = previous?.target;
|
|
501
|
+
if (!operatorAction && previous !== void 0 && prior?.kind === "existing" && prior.waiting === null) return {
|
|
502
|
+
...previous,
|
|
503
|
+
block
|
|
504
|
+
};
|
|
505
|
+
const ref = input.target.device;
|
|
506
|
+
const key = require_dist.compositionSourceKey(ref);
|
|
507
|
+
const knownId = prior?.kind === "existing" ? prior.deviceId : null;
|
|
508
|
+
if (input.claimsWait !== null) return waitingPlan(block, ref, knownId, input.claimsWait);
|
|
509
|
+
const resolution = await input.tracker.resolve(ref, operatorAction);
|
|
510
|
+
if (resolution.kind === "unknown") return waitingPlan(block, ref, knownId, `the target device \`${key}\` could not be resolved (${resolution.error}) — nothing is claimed until it is; retried on the next apply`);
|
|
511
|
+
const existing = {
|
|
512
|
+
kind: "existing",
|
|
513
|
+
ref,
|
|
514
|
+
deviceId: resolution.kind === "found" ? resolution.deviceId : knownId,
|
|
515
|
+
waiting: null,
|
|
516
|
+
gone: null,
|
|
517
|
+
features: [],
|
|
518
|
+
nativeCaps: /* @__PURE__ */ new Map()
|
|
519
|
+
};
|
|
520
|
+
if (resolution.kind === "missing") return {
|
|
521
|
+
...NO_PLAN,
|
|
522
|
+
block,
|
|
523
|
+
target: {
|
|
524
|
+
...existing,
|
|
525
|
+
gone: `${resolution.reason} — two index reads agreed`
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
const description = await input.targets.describe(ref, resolution.deviceId, resolution.type, operatorAction);
|
|
529
|
+
if (description === null) return waitingPlan(block, ref, resolution.deviceId, `the target device \`${key}\` could not be described (its bindings did not read) — nothing is claimed until it is; retried on the next apply`);
|
|
530
|
+
const plan = require_dist.planComposition(block.composition, input.lookupCap, description);
|
|
531
|
+
return {
|
|
532
|
+
...NO_PLAN,
|
|
533
|
+
block,
|
|
534
|
+
fields: plan.problems.length > 0 ? [] : plan.fields,
|
|
535
|
+
events: plan.problems.length > 0 ? [] : plan.events,
|
|
536
|
+
problems: plan.problems.map((p) => `${p.path}: ${p.message}`),
|
|
537
|
+
target: {
|
|
538
|
+
...existing,
|
|
539
|
+
features: plan.features,
|
|
540
|
+
nativeCaps: description.nativeCaps
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function waitingPlan(block, ref, deviceId, waiting) {
|
|
545
|
+
return {
|
|
546
|
+
...NO_PLAN,
|
|
547
|
+
block,
|
|
548
|
+
target: {
|
|
549
|
+
kind: "existing",
|
|
550
|
+
ref,
|
|
551
|
+
deviceId,
|
|
552
|
+
waiting,
|
|
553
|
+
gone: null,
|
|
554
|
+
features: [],
|
|
555
|
+
nativeCaps: /* @__PURE__ */ new Map()
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Runtime preparation can still fail after a complete existing-target plan.
|
|
561
|
+
* Preserve its fields and description for retry, but mark it waiting so claim
|
|
562
|
+
* reconciliation cannot treat the unprepared candidate as live.
|
|
563
|
+
*/
|
|
564
|
+
function withExistingTargetWait(p, waiting) {
|
|
565
|
+
return p.target.kind === "existing" ? {
|
|
566
|
+
...p,
|
|
567
|
+
target: {
|
|
568
|
+
...p.target,
|
|
569
|
+
waiting
|
|
570
|
+
}
|
|
571
|
+
} : p;
|
|
572
|
+
}
|
|
573
|
+
/** What `logPlanTransition` compares: problems, and for an existing target its wait or its loss. */
|
|
574
|
+
function planSummary(p) {
|
|
575
|
+
const target = p.target;
|
|
576
|
+
return (target.kind === "existing" ? [
|
|
577
|
+
p.problems.join("; "),
|
|
578
|
+
target.waiting ?? "",
|
|
579
|
+
target.gone ?? ""
|
|
580
|
+
] : [p.problems.join("; ")]).filter((part) => part !== "").join(" | ");
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Rule 5 on the PLAN path: `device.unregistered` is telemetry and may be
|
|
584
|
+
* dropped (D8), so a target is often found gone by the operator's next edit or
|
|
585
|
+
* restart. The previous live target is what must be ungrafted — exactly as on
|
|
586
|
+
* the event path. Null when nothing was lost by THIS plan.
|
|
587
|
+
*/
|
|
588
|
+
function lostTarget(p, previous) {
|
|
589
|
+
const now = p.target;
|
|
590
|
+
const before = previous?.target;
|
|
591
|
+
if (now.kind !== "existing" || now.gone === null || before?.kind !== "existing") return null;
|
|
592
|
+
if (before.deviceId === null || before.gone !== null) return null;
|
|
593
|
+
return {
|
|
594
|
+
target: before,
|
|
595
|
+
deviceId: before.deviceId
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/** Two plans write the same place: both new, or the very same existing-target plan (reused until the operator acts). */
|
|
599
|
+
function sameTarget(a, b) {
|
|
600
|
+
return a === b || a.kind === "new" && b.kind === "new";
|
|
601
|
+
}
|
|
602
|
+
/** One line when a block starts or stops planning (or waiting, or loses its target) — never one per apply. */
|
|
603
|
+
function logPlanTransition(logger, previous, p, liveTags) {
|
|
604
|
+
if (previous !== void 0 && planSummary(previous) === planSummary(p)) return;
|
|
605
|
+
if (previous === void 0 && planSummary(p) === "") return;
|
|
606
|
+
const target = p.target;
|
|
607
|
+
const extras = {
|
|
608
|
+
tags: target.kind === "existing" && target.deviceId !== null ? { deviceId: target.deviceId } : liveTags,
|
|
609
|
+
meta: {
|
|
610
|
+
blockId: p.block.id,
|
|
611
|
+
problems: p.problems,
|
|
612
|
+
placeholder: p.declaration !== null
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
if (target.kind === "existing" && target.gone !== null) logger.error("customization target device is gone — the block failed; only the operator ends this (edit or restart the block)", {
|
|
616
|
+
...extras,
|
|
617
|
+
meta: {
|
|
618
|
+
...extras.meta,
|
|
619
|
+
target: require_dist.compositionSourceKey(target.ref)
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
else if (target.kind === "existing" && target.waiting !== null) logger.warn("customization waits — nothing is claimed or released until it plans", {
|
|
623
|
+
...extras,
|
|
624
|
+
meta: {
|
|
625
|
+
...extras.meta,
|
|
626
|
+
reason: target.waiting
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
else if (p.problems.length === 0) logger.info("composition plans again", extras);
|
|
630
|
+
else logger.warn(p.declaration === null ? "composition does not plan — no device is declared for it" : "composition no longer plans — its device is kept offline with no capabilities", extras);
|
|
631
|
+
}
|
|
632
|
+
//#endregion
|
|
633
|
+
//#region src/builtins/composer/slice-assembler.ts
|
|
634
|
+
/**
|
|
635
|
+
* From the runtime's flat field values to the slice a target takes (D663).
|
|
636
|
+
*
|
|
637
|
+
* A top-level field is written under its path. An item leaf
|
|
638
|
+
* (`consumables.items[desiccant].remaining.value`) is one entry of an ARRAY:
|
|
639
|
+
* every leaf of one item is grouped by `(cap, arrayPath, key)` and set, by
|
|
640
|
+
* path segment, onto the cap's OWN `status.itemArray.emptyItem` — so an
|
|
641
|
+
* unsourced nullable leaf is `null` because the cap says so, not because this
|
|
642
|
+
* module guessed. The key, the label and {@link COMPOSED_ITEM_FIXED_FIELDS}
|
|
643
|
+
* are the composer's; items are sorted by key so two writes of the same
|
|
644
|
+
* values are one slice.
|
|
645
|
+
*
|
|
646
|
+
* A cap the lookup does not know as an item array cannot be assembled: that
|
|
647
|
+
* is thrown by name, and the runtime's write path turns a throw into a named,
|
|
648
|
+
* retried failure — never a half-built array.
|
|
649
|
+
*/
|
|
650
|
+
function isRecord$1(value) {
|
|
651
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
652
|
+
}
|
|
653
|
+
/** `setPath(item, ['remaining', 'value'], 0)` → `{ ...item, remaining: { ...item.remaining, value: 0 } }`. */
|
|
654
|
+
function setPath(record, segments, value) {
|
|
655
|
+
const [head, ...rest] = segments;
|
|
656
|
+
if (head === void 0) return record;
|
|
657
|
+
if (rest.length === 0) return {
|
|
658
|
+
...record,
|
|
659
|
+
[head]: value
|
|
660
|
+
};
|
|
661
|
+
const current = record[head];
|
|
662
|
+
return {
|
|
663
|
+
...record,
|
|
664
|
+
[head]: setPath(isRecord$1(current) ? current : {}, rest, value)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function itemArrayOf(group, lookupCap) {
|
|
668
|
+
const itemArray = lookupCap(group.cap)?.status?.itemArray;
|
|
669
|
+
if (itemArray === void 0 || itemArray.path !== group.arrayPath) throw new Error(`\`${group.cap}\` has no item array at \`${group.arrayPath}\` — \`${require_dist.compositionItemFieldPath(group.arrayPath, group.key, "*")}\` cannot be assembled`);
|
|
670
|
+
return itemArray;
|
|
671
|
+
}
|
|
672
|
+
function assembleItem(group, lookupCap) {
|
|
673
|
+
const itemArray = itemArrayOf(group, lookupCap);
|
|
674
|
+
const base = {
|
|
675
|
+
...itemArray.emptyItem,
|
|
676
|
+
[itemArray.keyField]: group.key,
|
|
677
|
+
...itemArray.labelField === void 0 ? {} : { [itemArray.labelField]: group.label },
|
|
678
|
+
...require_dist.COMPOSED_ITEM_FIXED_FIELDS
|
|
679
|
+
};
|
|
680
|
+
return group.leaves.reduce((item, leaf) => setPath(item, leaf.plan.path.split("."), leaf.value), base);
|
|
681
|
+
}
|
|
682
|
+
function groupItems(entries) {
|
|
683
|
+
const groups = /* @__PURE__ */ new Map();
|
|
684
|
+
for (const entry of entries) {
|
|
685
|
+
const item = entry.plan.item;
|
|
686
|
+
if (item === void 0) continue;
|
|
687
|
+
const id = `${entry.plan.cap}\u0000${item.arrayPath}\u0000${item.key}`;
|
|
688
|
+
const group = groups.get(id) ?? {
|
|
689
|
+
cap: entry.plan.cap,
|
|
690
|
+
arrayPath: item.arrayPath,
|
|
691
|
+
key: item.key,
|
|
692
|
+
label: item.label,
|
|
693
|
+
leaves: []
|
|
694
|
+
};
|
|
695
|
+
groups.set(id, {
|
|
696
|
+
...group,
|
|
697
|
+
leaves: [...group.leaves, entry]
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
return [...groups.values()];
|
|
701
|
+
}
|
|
702
|
+
function compareKeys(a, b) {
|
|
703
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
704
|
+
}
|
|
705
|
+
function assembleSlice(entries, lookupCap) {
|
|
706
|
+
const top = Object.fromEntries(entries.filter((e) => e.plan.item === void 0).map((e) => [e.plan.path, e.value]));
|
|
707
|
+
const groups = groupItems(entries);
|
|
708
|
+
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))]);
|
|
709
|
+
return {
|
|
710
|
+
...top,
|
|
711
|
+
...Object.fromEntries(arrays)
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
//#endregion
|
|
715
|
+
//#region src/builtins/composer/field-record.ts
|
|
716
|
+
/**
|
|
717
|
+
* One composed field's record and the pure judgements over it (D659, D663):
|
|
718
|
+
* what it may write, whether it is released (a CONFIRMED unavailability of a
|
|
719
|
+
* `release`-mode field, S1b) or HELD (no answer yet, D49), and how a report or a
|
|
720
|
+
* log line names it. Split from `composition-runtime.ts`, which holds the
|
|
721
|
+
* behaviour.
|
|
722
|
+
*/
|
|
723
|
+
var NO_FIELDS = /* @__PURE__ */ new Set();
|
|
724
|
+
function fieldKey(plan) {
|
|
725
|
+
return require_dist.compositionFieldKey(plan);
|
|
726
|
+
}
|
|
727
|
+
function isRecord(value) {
|
|
728
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
729
|
+
}
|
|
730
|
+
function valueAtPath$1(value, path) {
|
|
731
|
+
return path.split(".").reduce((node, segment) => isRecord(node) ? node[segment] : void 0, value);
|
|
732
|
+
}
|
|
733
|
+
function initialValue(plan, byCap, lookupCap) {
|
|
734
|
+
const slice = byCap?.get(plan.cap);
|
|
735
|
+
if (slice === void 0 || slice === null) return void 0;
|
|
736
|
+
const item = plan.item;
|
|
737
|
+
if (item === void 0) return valueAtPath$1(slice, plan.path);
|
|
738
|
+
const itemArray = lookupCap(plan.cap)?.status?.itemArray;
|
|
739
|
+
if (itemArray === void 0 || itemArray.path !== item.arrayPath) return void 0;
|
|
740
|
+
const entries = valueAtPath$1(slice, item.arrayPath);
|
|
741
|
+
if (!Array.isArray(entries)) return void 0;
|
|
742
|
+
return valueAtPath$1(entries.find((entry) => isRecord(entry) && entry[itemArray.keyField] === item.key), plan.path);
|
|
743
|
+
}
|
|
744
|
+
function createFieldRecord(plan, byCap, lookupCap) {
|
|
745
|
+
const initial = initialValue(plan, byCap, lookupCap);
|
|
746
|
+
return {
|
|
747
|
+
plan,
|
|
748
|
+
outcome: null,
|
|
749
|
+
initial,
|
|
750
|
+
hasWritten: false,
|
|
751
|
+
written: void 0,
|
|
752
|
+
expressionMemory: require_dist.createStatefulExpressionMemory(),
|
|
753
|
+
eventHighWater: typeof initial === "number" && Number.isFinite(initial) ? initial : null
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function currentOutput(record) {
|
|
757
|
+
return record.hasWritten ? record.written : record.initial;
|
|
758
|
+
}
|
|
759
|
+
/** The top-level field a record writes: an item leaf writes its whole array. */
|
|
760
|
+
function topLevelField(plan) {
|
|
761
|
+
return plan.item === void 0 ? plan.path : plan.item.arrayPath;
|
|
762
|
+
}
|
|
763
|
+
/** The field as a report or a log line names it: `items[desiccant].status` for an item leaf. */
|
|
764
|
+
function fieldPath(plan) {
|
|
765
|
+
return plan.item === void 0 ? plan.path : require_dist.compositionItemFieldPath(plan.item.arrayPath, plan.item.key, plan.path);
|
|
766
|
+
}
|
|
767
|
+
function itemOf(plan) {
|
|
768
|
+
return plan.item === void 0 ? {} : { item: plan.item };
|
|
769
|
+
}
|
|
770
|
+
function sameSet(a, b) {
|
|
771
|
+
return a.size === b.size && [...a].every((x) => b.has(x));
|
|
772
|
+
}
|
|
773
|
+
function valueOf(outcome) {
|
|
774
|
+
return outcome === null || outcome.state === "unavailable" ? void 0 : outcome.value;
|
|
775
|
+
}
|
|
776
|
+
function reasonOf(outcome) {
|
|
777
|
+
if (outcome === null) return "not evaluated yet";
|
|
778
|
+
return outcome.reason;
|
|
779
|
+
}
|
|
780
|
+
/** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
|
|
781
|
+
function sameOutcome(a, b) {
|
|
782
|
+
return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
|
|
783
|
+
}
|
|
784
|
+
/** A source is GONE: the outcome that must never be masked by a non-fatal one. */
|
|
785
|
+
function isFatal(outcome) {
|
|
786
|
+
return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
|
|
787
|
+
}
|
|
788
|
+
/** A `release`-mode field whose source is CONFIRMED unavailable: not the composition's to write (S1b). Stale is a value (D49). */
|
|
789
|
+
function isReleased(record) {
|
|
790
|
+
const outcome = record.outcome;
|
|
791
|
+
return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed === true;
|
|
792
|
+
}
|
|
793
|
+
/** 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). */
|
|
794
|
+
function isHeld(record) {
|
|
795
|
+
const outcome = record.outcome;
|
|
796
|
+
return record.plan.unavailable === "release" && outcome?.state === "unavailable" && outcome.confirmed !== true;
|
|
797
|
+
}
|
|
798
|
+
function namedField(r) {
|
|
799
|
+
return {
|
|
800
|
+
cap: r.plan.cap,
|
|
801
|
+
path: r.plan.path,
|
|
802
|
+
...itemOf(r.plan),
|
|
803
|
+
reason: reasonOf(r.outcome) ?? "unavailable"
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
function writable(record) {
|
|
807
|
+
const outcome = record.outcome;
|
|
808
|
+
if (outcome === null) return void 0;
|
|
809
|
+
if (outcome.state !== "unavailable") return outcome.value;
|
|
810
|
+
switch (record.plan.unavailable) {
|
|
811
|
+
case "null": return null;
|
|
812
|
+
case "unknown": return "unknown";
|
|
813
|
+
default: return;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function errorText(error) {
|
|
817
|
+
return error instanceof Error ? error.message : String(error);
|
|
818
|
+
}
|
|
819
|
+
function pendingReason(cap, pending) {
|
|
820
|
+
return pending.kind === "refused" ? `the \`${cap}\` capability refused the value: ${pending.error}` : `writing the \`${cap}\` capability threw: ${pending.error}`;
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
194
823
|
//#region src/builtins/composer/composition-runtime.ts
|
|
195
824
|
/**
|
|
196
825
|
* One running composition: per-field state, writes to the composed device, and
|
|
@@ -221,40 +850,47 @@ var WithdrawalGate = class {
|
|
|
221
850
|
* - Nothing escapes: a pass that throws is logged, and the timer is re-armed
|
|
222
851
|
* from the stored deadlines regardless.
|
|
223
852
|
* - `stop()` is terminal: every entry point is a no-op afterwards.
|
|
853
|
+
*
|
|
854
|
+
* Slice 2 (D663):
|
|
855
|
+
* - Records are keyed by `compositionFieldKey`: two items' `status` are two
|
|
856
|
+
* records. An item-array cap is written WHOLE — a change to one item leaf
|
|
857
|
+
* re-sends every item, assembled by `assembleSlice` on the cap's `emptyItem`.
|
|
858
|
+
* A required item leaf with no fallback holds the array, named once.
|
|
859
|
+
* - `'unknown'` unavailable mode writes the enum's own word.
|
|
860
|
+
* - `'release'` unavailable mode hands the field BACK (ruling S1b): the target
|
|
861
|
+
* is told to `withhold` it, the field's record forgets what it wrote, and the
|
|
862
|
+
* source returning re-widens and writes it again. Stale is a value (D49),
|
|
863
|
+
* never a release — and neither is an UNCONFIRMED unavailability (a failed,
|
|
864
|
+
* first or empty read): the field is HELD on its last value, not written,
|
|
865
|
+
* not withheld, its claim kept, until the source answers (D49, fix round 2).
|
|
866
|
+
* `state().released` / `state().held` name every such field for the report.
|
|
867
|
+
* - A target whose writes are asynchronous (claims on the hub) reports what did
|
|
868
|
+
* not land through `onAsyncFailure`: the cap is marked write-pending by name
|
|
869
|
+
* and re-written whole on the next pass — no retry timer (D3).
|
|
870
|
+
*
|
|
871
|
+
* Slice 2b (D670): stateful expression memory commits only when the whole
|
|
872
|
+
* expression succeeds. A declared state-derived event advances its seeded
|
|
873
|
+
* high-water and is emitted before the corresponding target write; neither an
|
|
874
|
+
* event-bus failure nor a refused write can replay the event.
|
|
224
875
|
*/
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (outcome === null) return "not evaluated yet";
|
|
239
|
-
return outcome.reason;
|
|
240
|
-
}
|
|
241
|
-
/** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
|
|
242
|
-
function sameOutcome(a, b) {
|
|
243
|
-
return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
|
|
244
|
-
}
|
|
245
|
-
/** A source is GONE: the outcome that must never be masked by a non-fatal one. */
|
|
246
|
-
function isFatal(outcome) {
|
|
247
|
-
return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
|
|
248
|
-
}
|
|
249
|
-
function writable(record) {
|
|
250
|
-
const outcome = record.outcome;
|
|
251
|
-
if (outcome === null) return void 0;
|
|
252
|
-
if (outcome.state !== "unavailable") return outcome.value;
|
|
253
|
-
return record.plan.unavailable === "null" ? null : void 0;
|
|
876
|
+
/**
|
|
877
|
+
* A refusal nothing on this side can retry away: another owner holds the
|
|
878
|
+
* field, the slice is invalid for the cap, the device is not known. Every
|
|
879
|
+
* other outcome — a throw, an unreadable row, a migration in flight, a lost
|
|
880
|
+
* claim — is transient and retried on the next pass (D6: bounded, never a loop).
|
|
881
|
+
*/
|
|
882
|
+
var TERMINAL_REFUSALS = new Set([
|
|
883
|
+
"owned-by-other",
|
|
884
|
+
"invalid-slice",
|
|
885
|
+
"unknown-device"
|
|
886
|
+
]);
|
|
887
|
+
function strictFiniteAdvance(previous, next) {
|
|
888
|
+
return typeof next === "number" && Number.isFinite(next) && (previous === null || next > previous);
|
|
254
889
|
}
|
|
255
890
|
var CompositionRuntime = class {
|
|
256
891
|
deps;
|
|
257
892
|
records;
|
|
893
|
+
startedAtMs;
|
|
258
894
|
seeded = /* @__PURE__ */ new Set();
|
|
259
895
|
available = null;
|
|
260
896
|
failure = null;
|
|
@@ -264,13 +900,21 @@ var CompositionRuntime = class {
|
|
|
264
900
|
lastPassError = null;
|
|
265
901
|
pending = /* @__PURE__ */ new Map();
|
|
266
902
|
heldBack = /* @__PURE__ */ new Set();
|
|
903
|
+
/** Caps whose item array is not written because a required leaf has no value. */
|
|
904
|
+
itemsHeld = /* @__PURE__ */ new Set();
|
|
905
|
+
/** Record keys currently released (S1b), for transition logs and the report. */
|
|
906
|
+
released = /* @__PURE__ */ new Set();
|
|
907
|
+
/** Per cap, the top-level fields the target last accepted as withheld; absent = none. */
|
|
908
|
+
withheld = /* @__PURE__ */ new Map();
|
|
909
|
+
/** Per record key, how many times the field has been released — churn the transition line reports (D391). */
|
|
910
|
+
releaseFlaps = /* @__PURE__ */ new Map();
|
|
911
|
+
/** Caps the target refused for good (`TERMINAL_REFUSALS`): never written again by this runtime. */
|
|
912
|
+
terminal = /* @__PURE__ */ new Map();
|
|
267
913
|
constructor(deps) {
|
|
268
914
|
this.deps = deps;
|
|
269
|
-
this.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
written: void 0
|
|
273
|
-
}]));
|
|
915
|
+
this.startedAtMs = deps.now();
|
|
916
|
+
this.records = new Map(deps.fields.map((plan) => [fieldKey(plan), createFieldRecord(plan, deps.initialByCap, deps.lookupCap)]));
|
|
917
|
+
deps.target.onAsyncFailure?.((cap, failure) => this.onTargetWriteFailed(cap, failure));
|
|
274
918
|
}
|
|
275
919
|
start() {
|
|
276
920
|
if (this.disabledReason !== null) return;
|
|
@@ -292,7 +936,8 @@ var CompositionRuntime = class {
|
|
|
292
936
|
state: "unavailable",
|
|
293
937
|
value: null,
|
|
294
938
|
reason,
|
|
295
|
-
fatal: false
|
|
939
|
+
fatal: false,
|
|
940
|
+
confirmed: true
|
|
296
941
|
};
|
|
297
942
|
const touched = [...this.records.values()].filter((r) => !isFatal(r.outcome) && !sameOutcome(r.outcome, outcome));
|
|
298
943
|
for (const r of touched) this.logTransition(r, outcome);
|
|
@@ -314,10 +959,20 @@ var CompositionRuntime = class {
|
|
|
314
959
|
failure: this.failure,
|
|
315
960
|
available: this.available === true,
|
|
316
961
|
fields: [...this.records.values()].map((r) => {
|
|
962
|
+
const terminal = this.terminal.get(r.plan.cap);
|
|
963
|
+
if (terminal !== void 0 && !isFatal(r.outcome)) return {
|
|
964
|
+
cap: r.plan.cap,
|
|
965
|
+
path: r.plan.path,
|
|
966
|
+
...itemOf(r.plan),
|
|
967
|
+
state: "unavailable",
|
|
968
|
+
value: null,
|
|
969
|
+
reason: terminal
|
|
970
|
+
};
|
|
317
971
|
const pending = this.pending.get(r.plan.cap);
|
|
318
972
|
if (pending !== void 0 && !isFatal(r.outcome)) return {
|
|
319
973
|
cap: r.plan.cap,
|
|
320
974
|
path: r.plan.path,
|
|
975
|
+
...itemOf(r.plan),
|
|
321
976
|
state: "unavailable",
|
|
322
977
|
value: null,
|
|
323
978
|
reason: pendingReason(r.plan.cap, pending)
|
|
@@ -327,14 +982,63 @@ var CompositionRuntime = class {
|
|
|
327
982
|
return {
|
|
328
983
|
cap: r.plan.cap,
|
|
329
984
|
path: r.plan.path,
|
|
985
|
+
...itemOf(r.plan),
|
|
330
986
|
state: r.outcome?.state ?? "unavailable",
|
|
331
987
|
value: value === void 0 ? null : value,
|
|
332
988
|
...reason === null ? {} : { reason }
|
|
333
989
|
};
|
|
334
|
-
})
|
|
990
|
+
}),
|
|
991
|
+
released: [...this.records.values()].filter(isReleased).map(namedField),
|
|
992
|
+
held: [...this.records.values()].filter(isHeld).map(namedField)
|
|
335
993
|
};
|
|
336
994
|
}
|
|
337
995
|
/**
|
|
996
|
+
* The target's enqueued write of `cap` did not land: the cap is write-pending
|
|
997
|
+
* by name and re-written WHOLE on the next pass that evaluates it (or the next
|
|
998
|
+
* deadline pass). A withhold sent to that target is forgotten too, so it is
|
|
999
|
+
* re-sent. No retry timer (D3).
|
|
1000
|
+
*/
|
|
1001
|
+
onTargetWriteFailed(cap, failure) {
|
|
1002
|
+
this.guarded("target-failure", false, () => {
|
|
1003
|
+
if (!this.deps.fields.some((f) => f.cap === cap) || this.terminal.has(cap)) return;
|
|
1004
|
+
if (failure.kind === "refused" && TERMINAL_REFUSALS.has(failure.code)) {
|
|
1005
|
+
this.markTerminal(cap, failure.code, failure.message);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
this.withheld = new Map([...this.withheld].filter(([c]) => c !== cap));
|
|
1009
|
+
this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failure.kind === "threw" ? {
|
|
1010
|
+
kind: "threw",
|
|
1011
|
+
error: failure.error
|
|
1012
|
+
} : {
|
|
1013
|
+
kind: "refused",
|
|
1014
|
+
error: `${failure.code}: ${failure.message}`
|
|
1015
|
+
});
|
|
1016
|
+
this.updateAvailability();
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* The target refused this cap for good: nothing on this side can retry it
|
|
1021
|
+
* away (I-3). The cap is never written again by this runtime, its fields
|
|
1022
|
+
* report the refusal, and the block fails naming it — only the operator ends
|
|
1023
|
+
* this (edit or delete the block, or the competing one), like a gone source.
|
|
1024
|
+
*/
|
|
1025
|
+
markTerminal(cap, code, message) {
|
|
1026
|
+
const reason = `the \`${cap}\` capability was refused for good: ${code}: ${message} — only the operator ends this (edit or delete the block)`;
|
|
1027
|
+
this.terminal = new Map([...this.terminal, [cap, reason]]);
|
|
1028
|
+
this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
|
|
1029
|
+
if (this.failure === null) this.failure = `composed write refused for good — ${cap}: ${code}: ${message}`;
|
|
1030
|
+
this.deps.logger.error("composed capability refused for good — it is not written again until the operator edits or deletes the block", {
|
|
1031
|
+
tags: { deviceId: this.deps.target.id },
|
|
1032
|
+
meta: {
|
|
1033
|
+
blockId: this.deps.blockId,
|
|
1034
|
+
cap,
|
|
1035
|
+
code,
|
|
1036
|
+
error: message
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
this.updateAvailability();
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
338
1042
|
* Every entry point runs here: a no-op once stopped, nothing thrown escapes,
|
|
339
1043
|
* and the one timer is re-armed from the stored deadlines whatever happened.
|
|
340
1044
|
*/
|
|
@@ -368,15 +1072,18 @@ var CompositionRuntime = class {
|
|
|
368
1072
|
pass(keys, trigger) {
|
|
369
1073
|
const initial = trigger === "start";
|
|
370
1074
|
const now = this.deps.now();
|
|
371
|
-
const
|
|
372
|
-
|
|
1075
|
+
const calculated = this.evaluate(keys.filter((k) => {
|
|
1076
|
+
const role = this.records.get(k)?.plan.role;
|
|
1077
|
+
return role === "derived" || role === "accumulator";
|
|
1078
|
+
}), now);
|
|
1079
|
+
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));
|
|
373
1080
|
const extra = this.evaluate(followers, now);
|
|
374
1081
|
const evaluatedCaps = new Set([...keys, ...followers].flatMap((k) => {
|
|
375
1082
|
const cap = this.records.get(k)?.plan.cap;
|
|
376
1083
|
return cap === void 0 ? [] : [cap];
|
|
377
1084
|
}));
|
|
378
1085
|
const retry = new Set([...this.pending.keys()].filter((cap) => trigger === "deadline" || evaluatedCaps.has(cap)));
|
|
379
|
-
this.writeCaps(new Set([...
|
|
1086
|
+
this.writeCaps(new Set([...calculated.keys, ...extra.keys]), retry);
|
|
380
1087
|
this.updateFailure();
|
|
381
1088
|
this.updateAvailability();
|
|
382
1089
|
}
|
|
@@ -388,15 +1095,35 @@ var CompositionRuntime = class {
|
|
|
388
1095
|
for (const key of keys) {
|
|
389
1096
|
const record = next.get(key);
|
|
390
1097
|
if (record === void 0) continue;
|
|
1098
|
+
const candidateMemory = require_dist.cloneStatefulExpressionMemory(record.expressionMemory);
|
|
391
1099
|
const outcome = require_dist.evaluateCompositionField({
|
|
392
1100
|
source: record.plan.source,
|
|
393
1101
|
read: this.deps.read,
|
|
394
|
-
now
|
|
1102
|
+
now,
|
|
1103
|
+
stateful: {
|
|
1104
|
+
currentOutput: currentOutput(record),
|
|
1105
|
+
expressionMemory: candidateMemory,
|
|
1106
|
+
startedAtMs: this.startedAtMs,
|
|
1107
|
+
callPathPrefix: fieldKey(record.plan)
|
|
1108
|
+
}
|
|
395
1109
|
});
|
|
396
|
-
|
|
1110
|
+
const expressionSucceeded = outcome.state !== "unavailable";
|
|
1111
|
+
let evaluated = {
|
|
397
1112
|
...record,
|
|
398
|
-
outcome
|
|
399
|
-
|
|
1113
|
+
outcome,
|
|
1114
|
+
...expressionSucceeded ? { expressionMemory: candidateMemory } : {}
|
|
1115
|
+
};
|
|
1116
|
+
const eventPlans = this.deps.eventPlans.filter((eventPlan) => eventPlan.fieldKey === fieldKey(record.plan));
|
|
1117
|
+
if (expressionSucceeded && eventPlans.length > 0 && strictFiniteAdvance(record.eventHighWater, outcome.value)) {
|
|
1118
|
+
const timestamp = outcome.value;
|
|
1119
|
+
evaluated = {
|
|
1120
|
+
...evaluated,
|
|
1121
|
+
eventHighWater: timestamp
|
|
1122
|
+
};
|
|
1123
|
+
next.set(key, evaluated);
|
|
1124
|
+
this.records = next;
|
|
1125
|
+
for (const eventPlan of eventPlans) this.emitStateEvent(eventPlan, timestamp);
|
|
1126
|
+
} else next.set(key, evaluated);
|
|
400
1127
|
if (sameOutcome(record.outcome, outcome)) continue;
|
|
401
1128
|
if (record.outcome?.state !== outcome.state) this.logTransition(record, outcome);
|
|
402
1129
|
if (valueOf(record.outcome) !== valueOf(outcome)) valueCaps.add(record.plan.cap);
|
|
@@ -410,6 +1137,39 @@ var CompositionRuntime = class {
|
|
|
410
1137
|
valueCaps
|
|
411
1138
|
};
|
|
412
1139
|
}
|
|
1140
|
+
emitStateEvent(plan, timestamp) {
|
|
1141
|
+
const extras = {
|
|
1142
|
+
tags: { deviceId: this.deps.target.id },
|
|
1143
|
+
meta: {
|
|
1144
|
+
blockId: this.deps.blockId,
|
|
1145
|
+
cap: plan.cap,
|
|
1146
|
+
eventName: plan.eventName,
|
|
1147
|
+
category: plan.category,
|
|
1148
|
+
timestamp
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
try {
|
|
1152
|
+
if (plan.category !== require_dist.EventCategory.DoorbellOnPressed) throw new Error(`unsupported state-derived event category \`${plan.category}\``);
|
|
1153
|
+
const event = require_dist.createEvent(require_dist.EventCategory.DoorbellOnPressed, {
|
|
1154
|
+
type: "device",
|
|
1155
|
+
id: this.deps.target.id,
|
|
1156
|
+
deviceId: this.deps.target.id
|
|
1157
|
+
}, {
|
|
1158
|
+
deviceId: this.deps.target.id,
|
|
1159
|
+
timestamp
|
|
1160
|
+
});
|
|
1161
|
+
this.deps.events.emit(event);
|
|
1162
|
+
this.deps.logger.debug("composition: state-derived event emitted", extras);
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
this.deps.logger.warn("composition: state-derived event delivery failed", {
|
|
1165
|
+
...extras,
|
|
1166
|
+
meta: {
|
|
1167
|
+
...extras.meta,
|
|
1168
|
+
error: errorText(error)
|
|
1169
|
+
}
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
413
1173
|
recordsOf(cap) {
|
|
414
1174
|
return [...this.records.values()].filter((r) => r.plan.cap === cap);
|
|
415
1175
|
}
|
|
@@ -419,35 +1179,154 @@ var CompositionRuntime = class {
|
|
|
419
1179
|
const cap = this.records.get(k)?.plan.cap;
|
|
420
1180
|
return cap === void 0 ? [] : [cap];
|
|
421
1181
|
}), ...retry]);
|
|
422
|
-
for (const cap of caps)
|
|
423
|
-
|
|
424
|
-
|
|
1182
|
+
for (const cap of caps) {
|
|
1183
|
+
if (this.terminal.has(cap)) continue;
|
|
1184
|
+
const sync = this.syncReleased(cap);
|
|
1185
|
+
if (sync === "failed") continue;
|
|
1186
|
+
if (!this.seeded.has(cap)) this.seedCap(cap);
|
|
1187
|
+
else if (this.pending.has(cap) || sync === "changed") this.patchWholeCap(cap, sync === "changed");
|
|
1188
|
+
else this.patchCap(cap, touched);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* S1b: tell the target which fields of `cap` are released, when that set
|
|
1193
|
+
* changed since it last accepted one. A released record forgets what it wrote,
|
|
1194
|
+
* so the source returning writes it again. False when the target refused or
|
|
1195
|
+
* threw: the cap is write-pending and the withhold is re-sent next pass.
|
|
1196
|
+
*/
|
|
1197
|
+
syncReleased(cap) {
|
|
1198
|
+
const records = this.recordsOf(cap);
|
|
1199
|
+
this.logReleaseTransitions(cap, records);
|
|
1200
|
+
const releasedRecords = records.filter(isReleased);
|
|
1201
|
+
this.records = new Map([...this.records].map(([k, r]) => releasedRecords.includes(r) && r.hasWritten ? [k, {
|
|
1202
|
+
...r,
|
|
1203
|
+
hasWritten: false,
|
|
1204
|
+
written: void 0
|
|
1205
|
+
}] : [k, r]));
|
|
1206
|
+
const withhold = this.deps.target.withhold;
|
|
1207
|
+
if (withhold === void 0) return "unchanged";
|
|
1208
|
+
const fields = new Set(releasedRecords.map((r) => topLevelField(r.plan)));
|
|
1209
|
+
if (sameSet(fields, this.withheld.get(cap) ?? NO_FIELDS)) return "unchanged";
|
|
1210
|
+
let failed;
|
|
1211
|
+
try {
|
|
1212
|
+
const outcome = withhold.call(this.deps.target, cap, [...fields].toSorted());
|
|
1213
|
+
failed = outcome.ok ? null : {
|
|
1214
|
+
kind: "refused",
|
|
1215
|
+
error: outcome.error
|
|
1216
|
+
};
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
failed = {
|
|
1219
|
+
kind: "threw",
|
|
1220
|
+
error: errorText(error)
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
if (failed !== null) {
|
|
1224
|
+
this.markPending(cap, this.seeded.has(cap) ? "patch" : "seed", failed);
|
|
1225
|
+
return "failed";
|
|
1226
|
+
}
|
|
1227
|
+
this.withheld = new Map([...this.withheld.entries()].filter(([c]) => c !== cap).concat(fields.size === 0 ? [] : [[cap, fields]]));
|
|
1228
|
+
return "changed";
|
|
1229
|
+
}
|
|
1230
|
+
/** One info line per field per direction, never one per pass. */
|
|
1231
|
+
logReleaseTransitions(cap, records) {
|
|
1232
|
+
const now = new Set(records.filter(isReleased).map((r) => fieldKey(r.plan)));
|
|
1233
|
+
const before = new Set(records.filter((r) => this.released.has(fieldKey(r.plan))).map((r) => fieldKey(r.plan)));
|
|
1234
|
+
for (const r of records) {
|
|
1235
|
+
const key = fieldKey(r.plan);
|
|
1236
|
+
const direction = now.has(key) && !before.has(key) ? "released" : !now.has(key) && before.has(key) ? "reclaimed" : null;
|
|
1237
|
+
if (direction === null) continue;
|
|
1238
|
+
const flaps = (this.releaseFlaps.get(key) ?? 0) + (direction === "released" ? 1 : 0);
|
|
1239
|
+
this.releaseFlaps = new Map([...this.releaseFlaps, [key, flaps]]);
|
|
1240
|
+
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", {
|
|
1241
|
+
tags: { deviceId: this.deps.target.id },
|
|
1242
|
+
meta: {
|
|
1243
|
+
blockId: this.deps.blockId,
|
|
1244
|
+
cap,
|
|
1245
|
+
field: fieldPath(r.plan),
|
|
1246
|
+
direction,
|
|
1247
|
+
reason: reasonOf(r.outcome) ?? "value available again",
|
|
1248
|
+
flaps
|
|
1249
|
+
}
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
this.released = new Set([...[...this.released].filter((k) => !before.has(k)), ...now]);
|
|
425
1253
|
}
|
|
426
1254
|
seedCap(cap) {
|
|
427
1255
|
const records = this.recordsOf(cap);
|
|
428
|
-
const missing = records.filter((r) => r.plan.required && writable(r) === void 0).map((r) => r.plan
|
|
1256
|
+
const missing = records.filter((r) => (r.plan.required || isHeld(r)) && writable(r) === void 0 && !isReleased(r)).map((r) => fieldPath(r.plan));
|
|
429
1257
|
if (missing.length > 0) return this.holdBackSeed(cap, missing);
|
|
430
1258
|
this.heldBack = new Set([...this.heldBack].filter((c) => c !== cap));
|
|
431
|
-
const entries = this.
|
|
1259
|
+
const entries = this.entriesFor(cap, records);
|
|
432
1260
|
if (!this.write(cap, "seed", entries)) return;
|
|
433
1261
|
this.seeded = new Set([...this.seeded, cap]);
|
|
434
1262
|
this.markWritten(cap, entries);
|
|
435
1263
|
}
|
|
436
1264
|
patchCap(cap, touched) {
|
|
437
|
-
const entries = this.
|
|
1265
|
+
const entries = this.entriesFor(cap, this.recordsOf(cap).filter((r) => touched.has(fieldKey(r.plan)) && (!r.hasWritten || writable(r) !== r.written)));
|
|
438
1266
|
if (entries.length === 0) return;
|
|
439
1267
|
if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
|
|
440
1268
|
}
|
|
441
|
-
/**
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1269
|
+
/**
|
|
1270
|
+
* After a failed write nothing is known about what landed, and after a
|
|
1271
|
+
* changed withhold the claim must be re-stated: send every current value.
|
|
1272
|
+
* `afterWithhold` with nothing left to write means the cap was released
|
|
1273
|
+
* whole — a pending write is moot then, not retried (M-2).
|
|
1274
|
+
*/
|
|
1275
|
+
patchWholeCap(cap, afterWithhold = false) {
|
|
1276
|
+
const entries = this.entriesFor(cap, this.recordsOf(cap));
|
|
1277
|
+
if (entries.length === 0) {
|
|
1278
|
+
if (afterWithhold && this.pending.has(cap)) this.dropPending(cap);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
445
1281
|
if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
|
|
446
1282
|
}
|
|
1283
|
+
dropPending(cap) {
|
|
1284
|
+
this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
|
|
1285
|
+
this.deps.logger.info("composed capability released whole — its pending write is moot, nothing is left to write", {
|
|
1286
|
+
tags: { deviceId: this.deps.target.id },
|
|
1287
|
+
meta: {
|
|
1288
|
+
blockId: this.deps.blockId,
|
|
1289
|
+
cap
|
|
1290
|
+
}
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* What one write of `cap` carries: the writable `candidates` — and, when any
|
|
1295
|
+
* of them is an item leaf, EVERY item leaf of the cap, because the array is
|
|
1296
|
+
* written whole (a patch of `items` replaces it). A required item leaf with
|
|
1297
|
+
* no value holds the whole array (its top-level siblings still go out).
|
|
1298
|
+
*/
|
|
1299
|
+
entriesFor(cap, candidates) {
|
|
1300
|
+
const top = candidates.filter((r) => r.plan.item === void 0);
|
|
1301
|
+
if (!candidates.some((r) => r.plan.item !== void 0)) return this.writableEntries(top);
|
|
1302
|
+
const items = this.recordsOf(cap).filter((r) => r.plan.item !== void 0);
|
|
1303
|
+
const missing = items.filter((r) => r.plan.required && writable(r) === void 0).map((r) => fieldPath(r.plan));
|
|
1304
|
+
if (missing.length > 0) {
|
|
1305
|
+
this.holdItems(cap, missing);
|
|
1306
|
+
return this.writableEntries(top);
|
|
1307
|
+
}
|
|
1308
|
+
this.itemsHeld = new Set([...this.itemsHeld].filter((c) => c !== cap));
|
|
1309
|
+
return this.writableEntries([...top, ...items]);
|
|
1310
|
+
}
|
|
447
1311
|
writableEntries(records) {
|
|
448
1312
|
return records.flatMap((r) => {
|
|
449
|
-
const
|
|
450
|
-
return
|
|
1313
|
+
const value = writable(r);
|
|
1314
|
+
return value === void 0 ? [] : [{
|
|
1315
|
+
plan: r.plan,
|
|
1316
|
+
value
|
|
1317
|
+
}];
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
holdItems(cap, missing) {
|
|
1321
|
+
if (this.itemsHeld.has(cap)) return;
|
|
1322
|
+
this.itemsHeld = new Set([...this.itemsHeld, cap]);
|
|
1323
|
+
this.deps.logger.warn("composed items held — a required item field has no value; the array is not written until it does", {
|
|
1324
|
+
tags: { deviceId: this.deps.target.id },
|
|
1325
|
+
meta: {
|
|
1326
|
+
blockId: this.deps.blockId,
|
|
1327
|
+
cap,
|
|
1328
|
+
missing
|
|
1329
|
+
}
|
|
451
1330
|
});
|
|
452
1331
|
}
|
|
453
1332
|
holdBackSeed(cap, missing) {
|
|
@@ -462,11 +1341,11 @@ var CompositionRuntime = class {
|
|
|
462
1341
|
}
|
|
463
1342
|
});
|
|
464
1343
|
}
|
|
465
|
-
/** One write. A refusal or a throw marks the cap write-pending; true when it
|
|
1344
|
+
/** One write. A refusal or a throw marks the cap write-pending; true when the target took it. */
|
|
466
1345
|
write(cap, op, entries) {
|
|
467
|
-
const values = Object.fromEntries(entries);
|
|
468
1346
|
let failed;
|
|
469
1347
|
try {
|
|
1348
|
+
const values = assembleSlice(entries, this.deps.lookupCap);
|
|
470
1349
|
const outcome = op === "seed" ? this.deps.target.seed(cap, values) : this.deps.target.patch(cap, values);
|
|
471
1350
|
failed = outcome.ok ? null : {
|
|
472
1351
|
kind: "refused",
|
|
@@ -512,10 +1391,11 @@ var CompositionRuntime = class {
|
|
|
512
1391
|
});
|
|
513
1392
|
}
|
|
514
1393
|
markWritten(cap, entries) {
|
|
515
|
-
const written = new Map(entries);
|
|
516
|
-
this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(
|
|
1394
|
+
const written = new Map(entries.map((e) => [fieldKey(e.plan), e.value]));
|
|
1395
|
+
this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(k) ? [k, {
|
|
517
1396
|
...r,
|
|
518
|
-
|
|
1397
|
+
hasWritten: true,
|
|
1398
|
+
written: written.get(k)
|
|
519
1399
|
}] : [k, r]));
|
|
520
1400
|
}
|
|
521
1401
|
updateAvailability() {
|
|
@@ -527,66 +1407,797 @@ var CompositionRuntime = class {
|
|
|
527
1407
|
this.deps.target.setAvailable(available);
|
|
528
1408
|
this.available = available;
|
|
529
1409
|
const extras = {
|
|
530
|
-
tags: { deviceId: this.deps.target.id },
|
|
1410
|
+
tags: { deviceId: this.deps.target.id },
|
|
1411
|
+
meta: {
|
|
1412
|
+
blockId: this.deps.blockId,
|
|
1413
|
+
blocking,
|
|
1414
|
+
unseeded,
|
|
1415
|
+
writePending,
|
|
1416
|
+
failure: this.failure,
|
|
1417
|
+
disabled: this.disabledReason
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
if (available) this.deps.logger.info("composed device available", extras);
|
|
1421
|
+
else this.deps.logger.warn("composed device unavailable — a field has no value, a write is pending, or the block failed", extras);
|
|
1422
|
+
}
|
|
1423
|
+
updateFailure() {
|
|
1424
|
+
if (this.failure !== null) return;
|
|
1425
|
+
const fatal = [...this.records.values()].find((r) => r.outcome?.state === "unavailable" && r.outcome.fatal);
|
|
1426
|
+
if (fatal === void 0 || fatal.outcome === null || fatal.outcome.state !== "unavailable") return;
|
|
1427
|
+
this.failure = `source missing — ${fatal.outcome.reason}`;
|
|
1428
|
+
this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
|
|
1429
|
+
tags: { deviceId: this.deps.target.id },
|
|
1430
|
+
meta: {
|
|
1431
|
+
blockId: this.deps.blockId,
|
|
1432
|
+
field: fieldKey(fatal.plan),
|
|
1433
|
+
reason: fatal.outcome.reason
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
/** `after`: arm only deadlines later than this instant; null arms the earliest stored one. */
|
|
1438
|
+
armDeadline(after) {
|
|
1439
|
+
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] : []);
|
|
1440
|
+
if (deadlines.length === 0) {
|
|
1441
|
+
this.deps.timer.disarm();
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
|
|
1445
|
+
}
|
|
1446
|
+
onDeadline() {
|
|
1447
|
+
this.guarded("deadline", true, () => {
|
|
1448
|
+
const now = this.deps.now();
|
|
1449
|
+
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));
|
|
1450
|
+
if (due.length > 0) this.pass(due, "deadline");
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
logTransition(record, outcome) {
|
|
1454
|
+
const from = record.outcome?.state ?? "pending";
|
|
1455
|
+
if (from === "pending" && outcome.state === "fresh") return;
|
|
1456
|
+
const extras = {
|
|
1457
|
+
tags: { deviceId: this.deps.target.id },
|
|
1458
|
+
meta: {
|
|
1459
|
+
blockId: this.deps.blockId,
|
|
1460
|
+
cap: record.plan.cap,
|
|
1461
|
+
field: record.plan.path,
|
|
1462
|
+
from,
|
|
1463
|
+
to: outcome.state,
|
|
1464
|
+
reason: reasonOf(outcome)
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
const message = `composed field ${fieldKey(record.plan)}: ${from} → ${outcome.state}`;
|
|
1468
|
+
if (outcome.state === "fresh") this.deps.logger.info(message, extras);
|
|
1469
|
+
else this.deps.logger.warn(message, extras);
|
|
1470
|
+
}
|
|
1471
|
+
};
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region src/builtins/composer/confirmed-seed.ts
|
|
1474
|
+
var ConfirmedSeed = class {
|
|
1475
|
+
deps;
|
|
1476
|
+
seeded = false;
|
|
1477
|
+
/** The previous read answered `[]`: one more `[]` confirms it (D49). */
|
|
1478
|
+
emptySeen = false;
|
|
1479
|
+
/** Consecutive applies whose read did not answer: warned once per run. */
|
|
1480
|
+
waitApplies = 0;
|
|
1481
|
+
constructor(deps) {
|
|
1482
|
+
this.deps = deps;
|
|
1483
|
+
}
|
|
1484
|
+
get done() {
|
|
1485
|
+
return this.seeded;
|
|
1486
|
+
}
|
|
1487
|
+
/** Null once seeded, else why this apply must wait. */
|
|
1488
|
+
async ensure() {
|
|
1489
|
+
if (this.seeded) return null;
|
|
1490
|
+
const read = await this.deps.read();
|
|
1491
|
+
if (read.kind === "unknown") {
|
|
1492
|
+
this.emptySeen = false;
|
|
1493
|
+
this.waitApplies += 1;
|
|
1494
|
+
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: {
|
|
1495
|
+
subject: this.deps.subject,
|
|
1496
|
+
error: read.error
|
|
1497
|
+
} });
|
|
1498
|
+
return this.deps.unreadReason;
|
|
1499
|
+
}
|
|
1500
|
+
if (read.items.length === 0 && !this.emptySeen) {
|
|
1501
|
+
this.emptySeen = true;
|
|
1502
|
+
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 } });
|
|
1503
|
+
return this.deps.emptyUnconfirmedReason;
|
|
1504
|
+
}
|
|
1505
|
+
this.deps.onSeeded(read.items);
|
|
1506
|
+
this.seeded = true;
|
|
1507
|
+
this.deps.logger.info(`composer: seeded from the ${this.deps.subject}`, { meta: {
|
|
1508
|
+
subject: this.deps.subject,
|
|
1509
|
+
items: read.items.length,
|
|
1510
|
+
confirmedEmpty: read.items.length === 0,
|
|
1511
|
+
appliesRefused: this.waitApplies
|
|
1512
|
+
} });
|
|
1513
|
+
return null;
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region src/builtins/composer/existing-target.ts
|
|
1518
|
+
/**
|
|
1519
|
+
* What the composer needs to run a composition on an EXISTING device (D663)
|
|
1520
|
+
* besides the runtime and the claim-backed target: the target's description,
|
|
1521
|
+
* the claim shapes derived from the plan, and the block report's words.
|
|
1522
|
+
*
|
|
1523
|
+
* - The target is DESCRIBED (bound caps → native addon, row type) with ONE
|
|
1524
|
+
* `getBindings` per plan change, cached until `device.bindings-changed` for
|
|
1525
|
+
* that device. A read that fails keeps the previous description; with none,
|
|
1526
|
+
* the block waits and nothing is claimed (D49).
|
|
1527
|
+
* - A claim's field set is the PLAN's (S5): the top-level fields of the cap, an
|
|
1528
|
+
* item leaf contributing its whole array. Its mode is the feature's.
|
|
1529
|
+
* - A self-read of the target (rule 6) is served from the NATIVE view unless it
|
|
1530
|
+
* reads an ADDED cap — that is the composition's own output. Whether a native
|
|
1531
|
+
* cap's `device.state-changed` is the native or the composition's own merge
|
|
1532
|
+
* is NOT decided here from the plan: it follows whether the claim has landed
|
|
1533
|
+
* (`HeldClaims`, C-1).
|
|
1534
|
+
*/
|
|
1535
|
+
/**
|
|
1536
|
+
* Read every affected effective capability exactly once, before the composer
|
|
1537
|
+
* grafts or claims any of them. A null slice is a successful empty seed; a
|
|
1538
|
+
* rejection is deliberately left to the caller so ownership cannot begin.
|
|
1539
|
+
*/
|
|
1540
|
+
async function readExistingTargetSeeds(targetDeviceId, caps, read) {
|
|
1541
|
+
const byCap = /* @__PURE__ */ new Map();
|
|
1542
|
+
for (const cap of new Set(caps)) byCap.set(cap, await read(targetDeviceId, cap));
|
|
1543
|
+
return { byCap };
|
|
1544
|
+
}
|
|
1545
|
+
/** The reason every ungraft of a gone target carries (Task 9 rule 5 / Task 10 rule 2). */
|
|
1546
|
+
var TARGET_GONE_UNGRAFT_REASON = "target device gone";
|
|
1547
|
+
/**
|
|
1548
|
+
* Rule 5, on either path that finds the target gone (`device.unregistered`, or
|
|
1549
|
+
* a later plan when that event was dropped): ungraft every cap the block ADDED
|
|
1550
|
+
* on that id, with the one ERROR that says so.
|
|
1551
|
+
*/
|
|
1552
|
+
function ungraftGoneTarget(input) {
|
|
1553
|
+
const { grafts, deviceId } = input;
|
|
1554
|
+
const added = addedCapsOf(input.features);
|
|
1555
|
+
for (const cap of added) grafts?.ungraft(deviceId, cap, TARGET_GONE_UNGRAFT_REASON);
|
|
1556
|
+
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)", {
|
|
1557
|
+
tags: { deviceId },
|
|
1558
|
+
meta: {
|
|
1559
|
+
blockId: input.blockId,
|
|
1560
|
+
target: require_dist.compositionSourceKey(input.ref),
|
|
1561
|
+
ungrafted: added,
|
|
1562
|
+
graftHost: grafts !== void 0
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
var TargetDescriptions = class {
|
|
1567
|
+
logger;
|
|
1568
|
+
readBoundCaps;
|
|
1569
|
+
cache = /* @__PURE__ */ new Map();
|
|
1570
|
+
constructor(logger, readBoundCaps) {
|
|
1571
|
+
this.logger = logger;
|
|
1572
|
+
this.readBoundCaps = readBoundCaps;
|
|
1573
|
+
}
|
|
1574
|
+
/** `device.bindings-changed` for this device: the next read is a real read. */
|
|
1575
|
+
invalidate(deviceId) {
|
|
1576
|
+
this.cache = new Map([...this.cache].filter(([, d]) => d.deviceId !== deviceId));
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* The description of `ref`, resolved to `deviceId`. Served from the cache
|
|
1580
|
+
* unless `force` (the block's plan changed) or nothing is cached. A read that
|
|
1581
|
+
* fails keeps the previous description of the SAME device id, and answers
|
|
1582
|
+
* null when there is none: the caller waits, and claims nothing.
|
|
1583
|
+
*/
|
|
1584
|
+
async describe(ref, deviceId, type, force) {
|
|
1585
|
+
const key = require_dist.compositionSourceKey(ref);
|
|
1586
|
+
const cached = this.cache.get(key);
|
|
1587
|
+
const previous = cached !== void 0 && cached.deviceId === deviceId ? cached : null;
|
|
1588
|
+
if (!force && previous !== null) return previous;
|
|
1589
|
+
const read = await this.readBoundCaps(deviceId);
|
|
1590
|
+
if (read.kind === "unreadable") {
|
|
1591
|
+
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", {
|
|
1592
|
+
tags: { deviceId },
|
|
1593
|
+
meta: {
|
|
1594
|
+
target: key,
|
|
1595
|
+
error: read.error
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
return previous;
|
|
1599
|
+
}
|
|
1600
|
+
const found = {
|
|
1601
|
+
kind: "found",
|
|
1602
|
+
deviceId,
|
|
1603
|
+
type,
|
|
1604
|
+
nativeCaps: read.caps
|
|
1605
|
+
};
|
|
1606
|
+
this.cache = new Map([...this.cache, [key, found]]);
|
|
1607
|
+
return found;
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
/** The PLANNED field set a claim carries for `cap` (S5): an item leaf claims its whole array. */
|
|
1611
|
+
function claimFieldsOf(fields, cap) {
|
|
1612
|
+
return [...new Set(fields.filter((f) => f.cap === cap).map((f) => f.item === void 0 ? f.path : f.item.arrayPath))];
|
|
1613
|
+
}
|
|
1614
|
+
/** The claim mode of `cap`: `replace` for a cap the device serves natively, else `add`. */
|
|
1615
|
+
function claimModeOf(features, cap) {
|
|
1616
|
+
return features.find((f) => f.cap === cap)?.mode === "replace" ? "replace" : "add";
|
|
1617
|
+
}
|
|
1618
|
+
/** The caps a customization ADDS to its target — the ones it grafts a native for (Task 10). */
|
|
1619
|
+
function addedCapsOf(features) {
|
|
1620
|
+
return features.filter((f) => f.mode === "add").map((f) => f.cap);
|
|
1621
|
+
}
|
|
1622
|
+
function selfReadCaps(fields, target, features) {
|
|
1623
|
+
const targetKey = require_dist.compositionSourceKey(target);
|
|
1624
|
+
const read = new Set(fields.flatMap((f) => f.reads.filter((r) => require_dist.compositionSourceKey(r.source) === targetKey).map((r) => r.cap)));
|
|
1625
|
+
const added = new Set(addedCapsOf(features));
|
|
1626
|
+
return {
|
|
1627
|
+
effective: [...read].filter((cap) => added.has(cap)),
|
|
1628
|
+
native: [...read].filter((cap) => !added.has(cap))
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
/** The `degraded` reason (rule 8): one entry per released field, the source's own reason after the dash. */
|
|
1632
|
+
function degradedReason(released) {
|
|
1633
|
+
return released.map((r) => {
|
|
1634
|
+
const path = r.item === void 0 ? r.path : require_dist.compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
|
|
1635
|
+
return `released to the native provider: ${r.cap}.${path} — ${r.reason}`;
|
|
1636
|
+
}).join("; ");
|
|
1637
|
+
}
|
|
1638
|
+
/** The `starting` reason of a block with HELD fields: each keeps its last value and claim until its source answers (D49). */
|
|
1639
|
+
function heldReason(held) {
|
|
1640
|
+
return held.map((r) => {
|
|
1641
|
+
const path = r.item === void 0 ? r.path : require_dist.compositionItemFieldPath(r.item.arrayPath, r.item.key, r.path);
|
|
1642
|
+
return `held on its last value until its source answers (D49): ${r.cap}.${path} — ${r.reason}`;
|
|
1643
|
+
}).join("; ");
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Rule 4: a customization whose row exists but whose plan failed keeps its
|
|
1647
|
+
* claims, so its owned fields stay at their last value on the hub. The report
|
|
1648
|
+
* says so — each field that had a value is `stale`, with the reason.
|
|
1649
|
+
*/
|
|
1650
|
+
function heldFieldsOf(fields, reason) {
|
|
1651
|
+
return fields.map((f) => f.state === "unavailable" ? f : {
|
|
1652
|
+
...f,
|
|
1653
|
+
state: "stale",
|
|
1654
|
+
reason
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region src/builtins/composer/composer-grafts.ts
|
|
1659
|
+
/**
|
|
1660
|
+
* The composer's side of grafts (D663, Task 10): WHEN a cap a customization
|
|
1661
|
+
* ADDS is grafted, removed or refused — `GraftHost` is only the how. Split from
|
|
1662
|
+
* `composer.ts`.
|
|
1663
|
+
*
|
|
1664
|
+
* - Decided at PLAN time only (rule 2): every added cap is grafted; a grafted
|
|
1665
|
+
* cap the plan now finds native is ungrafted (its event was dropped, D8).
|
|
1666
|
+
* - NATIVE ALWAYS WINS (I-2). A foreign `native-registered` on a grafted cap
|
|
1667
|
+
* ungrafts it (the claim stays). One that lands while a plan is in flight —
|
|
1668
|
+
* after the target's description was read, before the graft — is remembered
|
|
1669
|
+
* per described device, so that plan never grafts over it. A foreign
|
|
1670
|
+
* `native-unregistered` never re-grafts (D659).
|
|
1671
|
+
* - A cap no graft serves is NAMED for the block verdict (I-1): refused by the
|
|
1672
|
+
* host, or taken by a native. Every added cap unserved → the block fails.
|
|
1673
|
+
* - A binding a PREVIOUS composer process left (a respawn, D5) is retracted
|
|
1674
|
+
* when a settled plan does not want it (I-3), or when the gate releases the
|
|
1675
|
+
* add claim of a block that is gone — after reading that the binding IS the
|
|
1676
|
+
* composer's; a read that fails retracts nothing (D49).
|
|
1677
|
+
*/
|
|
1678
|
+
/** A foreign native now serves the grafted cap: the claim stays (Task 10 rule 2). */
|
|
1679
|
+
var NATIVE_APPEARED_UNGRAFT_REASON = "a native provider appeared";
|
|
1680
|
+
/** The block's add claim was released by the D49 gate (deleted, disabled, feature removed): its graft goes with it. */
|
|
1681
|
+
var NO_LONGER_ADDED_UNGRAFT_REASON = "the block no longer adds this cap";
|
|
1682
|
+
/** A binding a previous composer process left, that nothing wants any more (I-3). */
|
|
1683
|
+
var STALE_GRAFT_RETRACT_REASON = "a graft left by a previous composer process that no plan wants";
|
|
1684
|
+
function withEntry(map, key, value) {
|
|
1685
|
+
return new Map([...map, [key, value]]);
|
|
1686
|
+
}
|
|
1687
|
+
function nativeTakenReason(addonId, when) {
|
|
1688
|
+
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`;
|
|
1689
|
+
}
|
|
1690
|
+
var ComposerGrafts = class {
|
|
1691
|
+
ports;
|
|
1692
|
+
logger;
|
|
1693
|
+
readBindings;
|
|
1694
|
+
/** Per device: the added caps no graft serves, and why. Reset by each plan of that device. */
|
|
1695
|
+
unserved = /* @__PURE__ */ new Map();
|
|
1696
|
+
/** Per DESCRIBED device: foreign natives registered since its description was read (cap → addonId). */
|
|
1697
|
+
foreignSince = /* @__PURE__ */ new Map();
|
|
1698
|
+
constructor(ports, logger, readBindings) {
|
|
1699
|
+
this.ports = ports;
|
|
1700
|
+
this.logger = logger;
|
|
1701
|
+
this.readBindings = readBindings;
|
|
1702
|
+
}
|
|
1703
|
+
/** A description of `deviceId` is about to be read: it covers every registration before now. */
|
|
1704
|
+
describing(deviceId) {
|
|
1705
|
+
this.foreignSince = withEntry(this.foreignSince, deviceId, /* @__PURE__ */ new Map());
|
|
1706
|
+
}
|
|
1707
|
+
onBindingsChanged(data) {
|
|
1708
|
+
if (data.addonId === "composer") return;
|
|
1709
|
+
this.remember(data);
|
|
1710
|
+
const ports = this.ports;
|
|
1711
|
+
if (data.reason !== "native-registered" || ports === void 0) return;
|
|
1712
|
+
if (!ports.grafted(data.deviceId).has(data.capName)) return;
|
|
1713
|
+
this.logger.info("a native provider appeared on a grafted cap — the graft is removed; the claim stays", {
|
|
1714
|
+
tags: { deviceId: data.deviceId },
|
|
1715
|
+
meta: {
|
|
1716
|
+
cap: data.capName,
|
|
1717
|
+
addonId: data.addonId
|
|
1718
|
+
}
|
|
1719
|
+
});
|
|
1720
|
+
ports.ungraft(data.deviceId, data.capName, NATIVE_APPEARED_UNGRAFT_REASON);
|
|
1721
|
+
this.setUnserved(data.deviceId, data.capName, nativeTakenReason(data.addonId, "appeared"));
|
|
1722
|
+
}
|
|
1723
|
+
/** Rule 2, at plan time only: graft what the plan adds; ungraft what it finds native; retract what a previous process left. */
|
|
1724
|
+
plan(input) {
|
|
1725
|
+
const ports = this.ports;
|
|
1726
|
+
if (ports === void 0) return;
|
|
1727
|
+
const { deviceId } = input;
|
|
1728
|
+
const added = input.enabled && input.settled ? addedCapsOf(input.features) : [];
|
|
1729
|
+
const replaced = new Set(input.features.filter((f) => f.mode === "replace").map((f) => f.cap));
|
|
1730
|
+
for (const cap of ports.grafted(deviceId)) {
|
|
1731
|
+
if (!replaced.has(cap)) continue;
|
|
1732
|
+
this.logger.info("a native provider serves a grafted cap — the graft is removed; the claim stays", {
|
|
1733
|
+
tags: { deviceId },
|
|
1734
|
+
meta: { cap }
|
|
1735
|
+
});
|
|
1736
|
+
ports.ungraft(deviceId, cap, NATIVE_APPEARED_UNGRAFT_REASON);
|
|
1737
|
+
}
|
|
1738
|
+
if (input.settled) this.retractUnwanted(input, new Set(added));
|
|
1739
|
+
const foreign = this.foreignSince.get(deviceId) ?? /* @__PURE__ */ new Map();
|
|
1740
|
+
const unserved = /* @__PURE__ */ new Map();
|
|
1741
|
+
for (const cap of added) {
|
|
1742
|
+
const other = foreign.get(cap);
|
|
1743
|
+
if (other !== void 0) {
|
|
1744
|
+
const reason = nativeTakenReason(other, "registered while the block was planning");
|
|
1745
|
+
this.logger.warn("graft skipped — a native provider serves the cap (native always wins)", {
|
|
1746
|
+
tags: { deviceId },
|
|
1747
|
+
meta: {
|
|
1748
|
+
cap,
|
|
1749
|
+
addonId: other
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
unserved.set(cap, reason);
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
const outcome = ports.graft(deviceId, input.ref.stableId, cap);
|
|
1756
|
+
if (outcome.kind === "refused") unserved.set(cap, `graft refused: ${outcome.reason}`);
|
|
1757
|
+
}
|
|
1758
|
+
this.unserved = withEntry(this.unserved, deviceId, unserved);
|
|
1759
|
+
}
|
|
1760
|
+
/** 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. */
|
|
1761
|
+
async released(deviceId, capName) {
|
|
1762
|
+
const ports = this.ports;
|
|
1763
|
+
if (ports === void 0) return;
|
|
1764
|
+
this.setUnserved(deviceId, capName, null);
|
|
1765
|
+
if (ports.grafted(deviceId).has(capName)) {
|
|
1766
|
+
ports.ungraft(deviceId, capName, NO_LONGER_ADDED_UNGRAFT_REASON);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
let read;
|
|
1770
|
+
try {
|
|
1771
|
+
read = await this.readBindings(deviceId);
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
read = {
|
|
1774
|
+
kind: "unreadable",
|
|
1775
|
+
error: require_dist.errMsg(err)
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
if (read.kind === "unreadable") {
|
|
1779
|
+
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)", {
|
|
1780
|
+
tags: { deviceId },
|
|
1781
|
+
meta: {
|
|
1782
|
+
cap: capName,
|
|
1783
|
+
error: read.error
|
|
1784
|
+
}
|
|
1785
|
+
});
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
if (read.caps.get(capName) === "composer") ports.retractStale(deviceId, capName, STALE_GRAFT_RETRACT_REASON);
|
|
1789
|
+
}
|
|
1790
|
+
/** The target row is gone: nothing about it is reported any more. */
|
|
1791
|
+
gone(deviceId) {
|
|
1792
|
+
this.unserved = new Map([...this.unserved].filter(([id]) => id !== deviceId));
|
|
1793
|
+
}
|
|
1794
|
+
verdict(deviceId, features) {
|
|
1795
|
+
const added = addedCapsOf(features);
|
|
1796
|
+
const unserved = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
|
|
1797
|
+
const named = added.flatMap((cap) => {
|
|
1798
|
+
const reason = unserved.get(cap);
|
|
1799
|
+
return reason === void 0 ? [] : [`${cap}: ${reason}`];
|
|
1800
|
+
});
|
|
1801
|
+
if (named.length === 0) return null;
|
|
1802
|
+
return {
|
|
1803
|
+
allUnserved: named.length === added.length && added.length === features.length,
|
|
1804
|
+
reason: `added caps no graft serves — ${named.join("; ")}`
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
retractUnwanted(input, wanted) {
|
|
1808
|
+
const ports = this.ports;
|
|
1809
|
+
if (ports === void 0) return;
|
|
1810
|
+
const own = ports.grafted(input.deviceId);
|
|
1811
|
+
for (const [cap, addonId] of input.nativeCaps) {
|
|
1812
|
+
if (addonId !== "composer" || own.has(cap) || wanted.has(cap)) continue;
|
|
1813
|
+
this.logger.info("a graft left by a previous composer process is retracted", {
|
|
1814
|
+
tags: { deviceId: input.deviceId },
|
|
1815
|
+
meta: { cap }
|
|
1816
|
+
});
|
|
1817
|
+
ports.retractStale(input.deviceId, cap, STALE_GRAFT_RETRACT_REASON);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
remember(data) {
|
|
1821
|
+
const seen = this.foreignSince.get(data.deviceId);
|
|
1822
|
+
if (seen === void 0) return;
|
|
1823
|
+
if (data.reason === "native-registered") this.foreignSince = withEntry(this.foreignSince, data.deviceId, new Map([...seen, [data.capName, data.addonId]]));
|
|
1824
|
+
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)));
|
|
1825
|
+
}
|
|
1826
|
+
setUnserved(deviceId, cap, reason) {
|
|
1827
|
+
const current = this.unserved.get(deviceId) ?? /* @__PURE__ */ new Map();
|
|
1828
|
+
const next = new Map([...current].filter(([c]) => c !== cap));
|
|
1829
|
+
if (reason !== null) next.set(cap, reason);
|
|
1830
|
+
this.unserved = withEntry(this.unserved, deviceId, next);
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
//#endregion
|
|
1834
|
+
//#region src/builtins/composer/composer-verdict.ts
|
|
1835
|
+
/** A runtime built by an apply, before the apply's sync decides whether it may start. */
|
|
1836
|
+
var NOT_STARTED = "the runtime has not started yet";
|
|
1837
|
+
function verdictOf(blockId, entry, unsynced, grafts = null) {
|
|
1838
|
+
if (entry === void 0) return {
|
|
1839
|
+
blockId,
|
|
1840
|
+
status: "starting",
|
|
1841
|
+
reason: "not applied yet"
|
|
1842
|
+
};
|
|
1843
|
+
if (entry.rebuildError !== null) return {
|
|
1844
|
+
blockId,
|
|
1845
|
+
status: "failed",
|
|
1846
|
+
reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
|
|
1847
|
+
};
|
|
1848
|
+
if (entry.planned.problems.length > 0) {
|
|
1849
|
+
const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
|
|
1850
|
+
return {
|
|
1851
|
+
blockId,
|
|
1852
|
+
status: "failed",
|
|
1853
|
+
reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
if (!entry.planned.block.enabled) return {
|
|
1857
|
+
blockId,
|
|
1858
|
+
status: "stopped"
|
|
1859
|
+
};
|
|
1860
|
+
const target = entry.planned.target;
|
|
1861
|
+
if (target.kind === "existing") {
|
|
1862
|
+
if (target.gone !== null) return {
|
|
1863
|
+
blockId,
|
|
1864
|
+
status: "failed",
|
|
1865
|
+
reason: `the target device is gone: ${target.gone}`
|
|
1866
|
+
};
|
|
1867
|
+
if (target.waiting !== null) return {
|
|
1868
|
+
blockId,
|
|
1869
|
+
status: "starting",
|
|
1870
|
+
reason: target.waiting
|
|
1871
|
+
};
|
|
1872
|
+
} else if (entry.device === null) return {
|
|
1873
|
+
blockId,
|
|
1874
|
+
status: "starting",
|
|
1875
|
+
reason: "the composed device has not been constructed yet"
|
|
1876
|
+
};
|
|
1877
|
+
const runtime = entry.runtime;
|
|
1878
|
+
if (runtime === null) {
|
|
1879
|
+
if (target.kind !== "existing" || target.features.length === 0) return {
|
|
1880
|
+
blockId,
|
|
1881
|
+
status: "running"
|
|
1882
|
+
};
|
|
1883
|
+
return {
|
|
1884
|
+
blockId,
|
|
1885
|
+
status: "starting",
|
|
1886
|
+
reason: NOT_STARTED
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
if (unsynced) return {
|
|
1890
|
+
blockId,
|
|
1891
|
+
status: "starting",
|
|
1892
|
+
reason: NOT_STARTED
|
|
1893
|
+
};
|
|
1894
|
+
const state = runtime.state();
|
|
1895
|
+
if (state.failure !== null) return {
|
|
1896
|
+
blockId,
|
|
1897
|
+
status: "failed",
|
|
1898
|
+
reason: state.failure
|
|
1899
|
+
};
|
|
1900
|
+
if (grafts?.allUnserved === true) return {
|
|
1901
|
+
blockId,
|
|
1902
|
+
status: "failed",
|
|
1903
|
+
reason: grafts.reason
|
|
1904
|
+
};
|
|
1905
|
+
const degraded = [...state.released.length > 0 ? [degradedReason(state.released)] : [], ...grafts === null ? [] : [grafts.reason]];
|
|
1906
|
+
if (degraded.length > 0) return {
|
|
1907
|
+
blockId,
|
|
1908
|
+
status: "degraded",
|
|
1909
|
+
reason: degraded.join("; ")
|
|
1910
|
+
};
|
|
1911
|
+
if (state.held.length > 0) return {
|
|
1912
|
+
blockId,
|
|
1913
|
+
status: "starting",
|
|
1914
|
+
reason: heldReason(state.held)
|
|
1915
|
+
};
|
|
1916
|
+
return {
|
|
1917
|
+
blockId,
|
|
1918
|
+
status: "running"
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
//#endregion
|
|
1922
|
+
//#region src/builtins/composer/owned-fields-target.ts
|
|
1923
|
+
/**
|
|
1924
|
+
* The write target of a composition on an EXISTING device (D663): its fields
|
|
1925
|
+
* are CLAIMED on the hub (`deviceState.claimFields`), then patched
|
|
1926
|
+
* (`patchOwnedFields`). The runtime's `seed`/`patch` are synchronous and never
|
|
1927
|
+
* fail here — the RPC is enqueued — so what did not land is reported through
|
|
1928
|
+
* `onAsyncFailure`, and the runtime marks the cap write-pending and re-writes
|
|
1929
|
+
* it whole on its next pass. No retry timer (D3).
|
|
1930
|
+
*
|
|
1931
|
+
* - **The claim is re-sent until it has LANDED (ruling S5).** A cap enters
|
|
1932
|
+
* `landed` only on an `ok` claim answer. Every write of a cap not in `landed`
|
|
1933
|
+
* is a claim carrying the FULL planned field set (`fieldsOf`, never inferred
|
|
1934
|
+
* from the values). `not-claimed` / `field-not-claimed` — the hub lost or
|
|
1935
|
+
* never had the claim — drop the cap from `landed` and are escalated by
|
|
1936
|
+
* name, so the next write claims again.
|
|
1937
|
+
* - **Concurrent partial patches are MERGED, never dropped (S5).** One chain
|
|
1938
|
+
* per cap: while an RPC is in flight, later patches fold into one pending
|
|
1939
|
+
* value set (later keys win; a seed replaces it wholly), and the landing log
|
|
1940
|
+
* says how many were absorbed (D391).
|
|
1941
|
+
* - **A released field is withheld (S1b).** `withhold(cap, fields)` narrows the
|
|
1942
|
+
* next claim to the rest and omits their values; nothing left → the claim is
|
|
1943
|
+
* released at once. `[]` re-widens.
|
|
1944
|
+
* - **Every claim RPC of a `replace` cap is reported** (`onClaim`, C-1/N-1):
|
|
1945
|
+
* `claiming` when it goes out, then `held` on an `ok`, `free` on a confirmed
|
|
1946
|
+
* release or `not-claimed`, `unsettled` on a throw or any other refusal (D49:
|
|
1947
|
+
* changes nothing). `field-not-claimed` on a patch drops `landed` so the next
|
|
1948
|
+
* write claims again, but the hub still holds the claim: `held` stays (N-3).
|
|
1949
|
+
* The tracker follows this — never the plan — to know whether
|
|
1950
|
+
* `device.state-changed` of the cap is the provider's native, the
|
|
1951
|
+
* composition's own merge, or (while a claim is in flight) neither.
|
|
1952
|
+
* - `setAvailable` is a no-op: `device-status` is the native device's.
|
|
1953
|
+
*/
|
|
1954
|
+
var ACCEPTED = { ok: true };
|
|
1955
|
+
/** The hub has no claim to patch: the next write must claim again. */
|
|
1956
|
+
var CLAIM_LOST_CODES = new Set(["not-claimed", "field-not-claimed"]);
|
|
1957
|
+
/**
|
|
1958
|
+
* Later keys win; a seed replaces the pending values wholly; a release
|
|
1959
|
+
* supersedes any pending values (nothing is left to write) and is itself
|
|
1960
|
+
* superseded by a later write (a claim REPLACES the hub's field set anyway).
|
|
1961
|
+
*/
|
|
1962
|
+
function fold(current, next) {
|
|
1963
|
+
if (current === void 0 || next.kind === "release" || current.kind === "release") return next;
|
|
1964
|
+
if (next.op === "seed") return {
|
|
1965
|
+
...next,
|
|
1966
|
+
merged: current.merged + 1
|
|
1967
|
+
};
|
|
1968
|
+
return {
|
|
1969
|
+
kind: "write",
|
|
1970
|
+
op: current.op,
|
|
1971
|
+
values: {
|
|
1972
|
+
...current.values,
|
|
1973
|
+
...next.values
|
|
1974
|
+
},
|
|
1975
|
+
merged: current.merged + 1
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
function omitKeys(values, keys) {
|
|
1979
|
+
if (keys === void 0 || keys.size === 0) return values;
|
|
1980
|
+
return Object.fromEntries(Object.entries(values).filter(([key]) => !keys.has(key)));
|
|
1981
|
+
}
|
|
1982
|
+
var OwnedFieldsTarget = class {
|
|
1983
|
+
deps;
|
|
1984
|
+
id;
|
|
1985
|
+
landed = /* @__PURE__ */ new Set();
|
|
1986
|
+
withheld = /* @__PURE__ */ new Map();
|
|
1987
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
1988
|
+
pending = /* @__PURE__ */ new Map();
|
|
1989
|
+
listeners = [];
|
|
1990
|
+
/** Per cap, bumped by every `withhold`: a claim that settles under another epoch claimed a stale field set and does not land (I-2). */
|
|
1991
|
+
epoch = /* @__PURE__ */ new Map();
|
|
1992
|
+
constructor(deps) {
|
|
1993
|
+
this.deps = deps;
|
|
1994
|
+
this.id = deps.deviceId;
|
|
1995
|
+
}
|
|
1996
|
+
seed(cap, slice) {
|
|
1997
|
+
this.enqueue(cap, {
|
|
1998
|
+
kind: "write",
|
|
1999
|
+
op: "seed",
|
|
2000
|
+
values: slice,
|
|
2001
|
+
merged: 0
|
|
2002
|
+
});
|
|
2003
|
+
return ACCEPTED;
|
|
2004
|
+
}
|
|
2005
|
+
patch(cap, patch) {
|
|
2006
|
+
this.enqueue(cap, {
|
|
2007
|
+
kind: "write",
|
|
2008
|
+
op: "patch",
|
|
2009
|
+
values: patch,
|
|
2010
|
+
merged: 0
|
|
2011
|
+
});
|
|
2012
|
+
return ACCEPTED;
|
|
2013
|
+
}
|
|
2014
|
+
/** `device-status` belongs to the native device; availability lives in the block report only. */
|
|
2015
|
+
setAvailable(_available) {}
|
|
2016
|
+
onAsyncFailure(listener) {
|
|
2017
|
+
this.listeners = [...this.listeners, listener];
|
|
2018
|
+
}
|
|
2019
|
+
withhold(cap, fields) {
|
|
2020
|
+
this.withheld = new Map([...this.withheld.entries()].filter(([c]) => c !== cap).concat(fields.length === 0 ? [] : [[cap, new Set(fields)]]));
|
|
2021
|
+
this.landed = new Set([...this.landed].filter((c) => c !== cap));
|
|
2022
|
+
this.epoch = new Map([...this.epoch, [cap, (this.epoch.get(cap) ?? 0) + 1]]);
|
|
2023
|
+
if (this.claimableFields(cap).length === 0) this.enqueue(cap, { kind: "release" });
|
|
2024
|
+
return ACCEPTED;
|
|
2025
|
+
}
|
|
2026
|
+
claimableFields(cap) {
|
|
2027
|
+
const held = this.withheld.get(cap);
|
|
2028
|
+
return this.deps.fieldsOf(cap).filter((f) => held === void 0 || !held.has(f));
|
|
2029
|
+
}
|
|
2030
|
+
enqueue(cap, op) {
|
|
2031
|
+
if (!this.inFlight.has(cap)) {
|
|
2032
|
+
this.send(cap, op);
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
this.pending = new Map([...this.pending, [cap, fold(this.pending.get(cap), op)]]);
|
|
2036
|
+
}
|
|
2037
|
+
send(cap, op) {
|
|
2038
|
+
this.inFlight = new Set([...this.inFlight, cap]);
|
|
2039
|
+
(op.kind === "release" ? this.sendRelease(cap) : this.sendWrite(cap, op)).then(() => this.settled(cap), (err) => {
|
|
2040
|
+
this.deps.logger.error("composed write chain threw past its handlers — the chain continues", {
|
|
2041
|
+
tags: { deviceId: this.deps.deviceId },
|
|
2042
|
+
meta: {
|
|
2043
|
+
cap,
|
|
2044
|
+
owner: this.deps.owner,
|
|
2045
|
+
error: require_dist.errMsg(err)
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
this.settled(cap);
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
settled(cap) {
|
|
2052
|
+
this.inFlight = new Set([...this.inFlight].filter((c) => c !== cap));
|
|
2053
|
+
const next = this.pending.get(cap);
|
|
2054
|
+
if (next === void 0) return;
|
|
2055
|
+
this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
|
|
2056
|
+
this.send(cap, next);
|
|
2057
|
+
}
|
|
2058
|
+
async sendWrite(cap, op) {
|
|
2059
|
+
const values = omitKeys(op.values, this.withheld.get(cap));
|
|
2060
|
+
const claim = !this.landed.has(cap);
|
|
2061
|
+
const fields = this.claimableFields(cap);
|
|
2062
|
+
const epoch = this.epoch.get(cap) ?? 0;
|
|
2063
|
+
if (claim && fields.length === 0) {
|
|
2064
|
+
this.deps.logger.debug("composed write skipped — every field of the capability is withheld", {
|
|
2065
|
+
tags: { deviceId: this.deps.deviceId },
|
|
2066
|
+
meta: {
|
|
2067
|
+
cap,
|
|
2068
|
+
owner: this.deps.owner,
|
|
2069
|
+
op: op.op
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
return;
|
|
2073
|
+
}
|
|
2074
|
+
if (!claim && Object.keys(values).length === 0) return;
|
|
2075
|
+
if (claim) this.report(cap, "claiming");
|
|
2076
|
+
let outcome;
|
|
2077
|
+
try {
|
|
2078
|
+
outcome = claim ? await this.deps.api.claimFields({
|
|
2079
|
+
deviceId: this.deps.deviceId,
|
|
2080
|
+
capName: cap,
|
|
2081
|
+
owner: this.deps.owner,
|
|
2082
|
+
mode: this.deps.modeOf(cap),
|
|
2083
|
+
fields: [...fields],
|
|
2084
|
+
values
|
|
2085
|
+
}) : await this.deps.api.patchOwnedFields({
|
|
2086
|
+
deviceId: this.deps.deviceId,
|
|
2087
|
+
capName: cap,
|
|
2088
|
+
owner: this.deps.owner,
|
|
2089
|
+
values
|
|
2090
|
+
});
|
|
2091
|
+
} catch (err) {
|
|
2092
|
+
if (claim) this.report(cap, "unsettled");
|
|
2093
|
+
this.fail(cap, {
|
|
2094
|
+
kind: "threw",
|
|
2095
|
+
error: require_dist.errMsg(err)
|
|
2096
|
+
});
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (outcome.ok) {
|
|
2100
|
+
if (claim) this.report(cap, "held");
|
|
2101
|
+
const current = (this.epoch.get(cap) ?? 0) === epoch;
|
|
2102
|
+
if (claim && current) this.landed = new Set([...this.landed, cap]);
|
|
2103
|
+
if (claim && !current) this.deps.logger.info("composed claim landed with a field set a withhold has since changed — the next write claims again", {
|
|
2104
|
+
tags: { deviceId: this.deps.deviceId },
|
|
2105
|
+
meta: {
|
|
2106
|
+
cap,
|
|
2107
|
+
owner: this.deps.owner,
|
|
2108
|
+
fields
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
this.logLanded(cap, op, claim, fields);
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (CLAIM_LOST_CODES.has(outcome.code)) this.landed = new Set([...this.landed].filter((c) => c !== cap));
|
|
2115
|
+
if (outcome.code === "not-claimed") this.report(cap, "free");
|
|
2116
|
+
else if (claim) this.report(cap, "unsettled");
|
|
2117
|
+
this.fail(cap, {
|
|
2118
|
+
kind: "refused",
|
|
2119
|
+
code: outcome.code,
|
|
2120
|
+
message: outcome.message
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
async sendRelease(cap) {
|
|
2124
|
+
const extras = {
|
|
2125
|
+
tags: { deviceId: this.deps.deviceId },
|
|
531
2126
|
meta: {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
unseeded,
|
|
535
|
-
writePending,
|
|
536
|
-
failure: this.failure,
|
|
537
|
-
disabled: this.disabledReason
|
|
2127
|
+
cap,
|
|
2128
|
+
owner: this.deps.owner
|
|
538
2129
|
}
|
|
539
2130
|
};
|
|
540
|
-
|
|
541
|
-
|
|
2131
|
+
let outcome;
|
|
2132
|
+
try {
|
|
2133
|
+
outcome = await this.deps.api.releaseClaim({
|
|
2134
|
+
deviceId: this.deps.deviceId,
|
|
2135
|
+
capName: cap,
|
|
2136
|
+
owner: this.deps.owner
|
|
2137
|
+
});
|
|
2138
|
+
} catch (err) {
|
|
2139
|
+
this.fail(cap, {
|
|
2140
|
+
kind: "threw",
|
|
2141
|
+
error: require_dist.errMsg(err)
|
|
2142
|
+
});
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
if (outcome.ok) {
|
|
2146
|
+
this.report(cap, "free");
|
|
2147
|
+
this.deps.logger.info("composed claim released — every field of the capability is handed back to the native provider", extras);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
if (outcome.code === "not-claimed") {
|
|
2151
|
+
this.report(cap, "free");
|
|
2152
|
+
this.deps.logger.debug("composed claim release found nothing to hand back", extras);
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
this.fail(cap, {
|
|
2156
|
+
kind: "refused",
|
|
2157
|
+
code: outcome.code,
|
|
2158
|
+
message: outcome.message
|
|
2159
|
+
});
|
|
542
2160
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
this.failure = `source missing — ${fatal.outcome.reason}`;
|
|
548
|
-
this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
|
|
549
|
-
tags: { deviceId: this.deps.target.id },
|
|
2161
|
+
logLanded(cap, op, claim, fields) {
|
|
2162
|
+
const tags = { deviceId: this.deps.deviceId };
|
|
2163
|
+
if (op.merged > 0) this.deps.logger.info("composed write landed after absorbing merged patches", {
|
|
2164
|
+
tags,
|
|
550
2165
|
meta: {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
2166
|
+
cap,
|
|
2167
|
+
owner: this.deps.owner,
|
|
2168
|
+
op: op.op,
|
|
2169
|
+
claim,
|
|
2170
|
+
merged: op.merged
|
|
554
2171
|
}
|
|
555
2172
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
|
|
565
|
-
}
|
|
566
|
-
onDeadline() {
|
|
567
|
-
this.guarded("deadline", true, () => {
|
|
568
|
-
const now = this.deps.now();
|
|
569
|
-
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));
|
|
570
|
-
if (due.length > 0) this.pass(due, "deadline");
|
|
2173
|
+
if (claim) this.deps.logger.info("composed claim landed", {
|
|
2174
|
+
tags,
|
|
2175
|
+
meta: {
|
|
2176
|
+
cap,
|
|
2177
|
+
owner: this.deps.owner,
|
|
2178
|
+
mode: this.deps.modeOf(cap),
|
|
2179
|
+
fields
|
|
2180
|
+
}
|
|
571
2181
|
});
|
|
572
2182
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
if (
|
|
576
|
-
|
|
577
|
-
|
|
2183
|
+
/** Only a `replace` cap has a native the tracker must tell apart from the merge (N-7). */
|
|
2184
|
+
report(cap, event) {
|
|
2185
|
+
if (this.deps.modeOf(cap) === "replace") this.deps.onClaim?.(cap, event);
|
|
2186
|
+
}
|
|
2187
|
+
/** Escalated to the runtime; a failure nobody listens to is logged — never silent (D391). */
|
|
2188
|
+
fail(cap, failure) {
|
|
2189
|
+
if (this.listeners.length === 0) this.deps.logger.warn("composed write did not land and no runtime listens for it", {
|
|
2190
|
+
tags: { deviceId: this.deps.deviceId },
|
|
578
2191
|
meta: {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
2192
|
+
cap,
|
|
2193
|
+
owner: this.deps.owner,
|
|
2194
|
+
...failure.kind === "threw" ? { error: failure.error } : {
|
|
2195
|
+
code: failure.code,
|
|
2196
|
+
error: failure.message
|
|
2197
|
+
}
|
|
585
2198
|
}
|
|
586
|
-
};
|
|
587
|
-
const
|
|
588
|
-
if (outcome.state === "fresh") this.deps.logger.info(message, extras);
|
|
589
|
-
else this.deps.logger.warn(message, extras);
|
|
2199
|
+
});
|
|
2200
|
+
for (const listener of this.listeners) listener(cap, failure);
|
|
590
2201
|
}
|
|
591
2202
|
};
|
|
592
2203
|
//#endregion
|
|
@@ -606,38 +2217,139 @@ var CompositionRuntime = class {
|
|
|
606
2217
|
* (`WithdrawalGate.seed`). A block whose row exists but whose composition no
|
|
607
2218
|
* longer plans (a cap renamed by a release) keeps its device as an offline
|
|
608
2219
|
* placeholder with no capabilities, and reports `failed` naming why.
|
|
2220
|
+
*
|
|
2221
|
+
* Slice 2 (D663) — a composition on an EXISTING device declares nothing: the
|
|
2222
|
+
* row is not the composer's, `DeclaredDevices` never sees it, and the owner
|
|
2223
|
+
* filter in `composer.addon.ts` stays load-bearing. Its fields are CLAIMED on
|
|
2224
|
+
* the hub under the block id (`OwnedFieldsTarget`), and released through the
|
|
2225
|
+
* `ClaimGate` — two applies must agree, seeded at boot from the claims index —
|
|
2226
|
+
* when the block is gone, disabled, or lost the feature. Its own replaced
|
|
2227
|
+
* fields are read in the NATIVE view (rule 6). Its target row gone on two
|
|
2228
|
+
* agreeing index reads fails the block by name and ungrafts what it added —
|
|
2229
|
+
* whether the loss was seen on `device.unregistered` or on a later plan; its
|
|
2230
|
+
* claims die with the row. Nothing is claimed or released while the claims
|
|
2231
|
+
* index has not loaded (`CLAIMS_NOT_LOADED`). Planning is `composer-plan.ts`.
|
|
2232
|
+
*
|
|
2233
|
+
* A cap the customization ADDS is GRAFTED (`GraftHost`, Task 10): a
|
|
2234
|
+
* method-serving native on the target, decided at plan time only; when and why
|
|
2235
|
+
* is `composer-grafts.ts` (native always wins; an unserved cap is named in the
|
|
2236
|
+
* verdict; a previous process's leftover is retracted).
|
|
2237
|
+
*
|
|
2238
|
+
* A replaced field is handed back only on a CONFIRMED unavailability of its
|
|
2239
|
+
* source (D49, fix round 2): a failed, first or empty read HOLDS it on its last
|
|
2240
|
+
* value with its claim kept — the runtime's `held`, reported `starting` by name.
|
|
2241
|
+
* A runtime built by an apply starts in that apply's sync, never earlier from
|
|
2242
|
+
* an event (N-5).
|
|
609
2243
|
*/
|
|
610
2244
|
var INTEGRATION_WAIT = "waiting for the shared Blocks integration to reconcile";
|
|
611
2245
|
var STORE_NOT_LOADED = "the core_blocks store has not loaded — nothing is declared or withdrawn until it does (D49)";
|
|
612
2246
|
var ROWS_UNREAD = "the composer's own device rows could not be read — nothing is declared or withdrawn until they are (D49)";
|
|
613
2247
|
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)";
|
|
614
|
-
|
|
615
|
-
var
|
|
2248
|
+
var CLAIMS_NOT_LOADED = "the claims index has not loaded — nothing is claimed or released until it does (D49)";
|
|
2249
|
+
var CLAIMS_EMPTY_UNCONFIRMED = "the claims index read empty once — nothing is claimed or released until the next apply's read agrees (D49)";
|
|
2250
|
+
/** A release answered with one of these hands back nothing of ours: the gate forgets the claim. */
|
|
2251
|
+
var RELEASE_SETTLED_CODES = new Set([
|
|
2252
|
+
"not-claimed",
|
|
2253
|
+
"unknown-device",
|
|
2254
|
+
"owned-by-other"
|
|
2255
|
+
]);
|
|
616
2256
|
var Composer = class {
|
|
617
2257
|
deps;
|
|
618
2258
|
live = /* @__PURE__ */ new Map();
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
rows = /* @__PURE__ */ new Set();
|
|
622
|
-
seeded = false;
|
|
623
|
-
/** The previous seed read answered `[]`: one more `[]` confirms it (D49). */
|
|
624
|
-
emptySeedSeen = false;
|
|
2259
|
+
/** The composed devices of `new`-target blocks, and the rows that exist. */
|
|
2260
|
+
composed;
|
|
625
2261
|
/** Consecutive applies refused because `core_blocks` had not loaded: warned once per run. */
|
|
626
2262
|
storeWaitApplies = 0;
|
|
627
2263
|
/** Consecutive applies refused because the Blocks integration had not reconciled: warned once per run. */
|
|
628
2264
|
integrationWaitApplies = 0;
|
|
629
|
-
declaredFingerprint = null;
|
|
630
2265
|
gate;
|
|
2266
|
+
claimGate;
|
|
2267
|
+
targets;
|
|
2268
|
+
grafts;
|
|
2269
|
+
/** The rows the composer owns, read ONCE before its first withdrawal decision. */
|
|
2270
|
+
rowSeed;
|
|
2271
|
+
/**
|
|
2272
|
+
* The claims the hub holds, read ONCE before the first release decision (rule
|
|
2273
|
+
* 3). Every owner in it is a composer block id — the Task 6 guard lets nothing
|
|
2274
|
+
* else claim. `not-loaded` is UNKNOWN (D315), never empty. Until seeded, every
|
|
2275
|
+
* existing-target block waits by name; new-target blocks are not held.
|
|
2276
|
+
*/
|
|
2277
|
+
claimSeed;
|
|
631
2278
|
/** Applies never interleave: each one reads and replaces the whole live set. */
|
|
632
2279
|
applyChain = Promise.resolve();
|
|
2280
|
+
/**
|
|
2281
|
+
* Runtimes built by an apply and not started yet: that apply's sync starts
|
|
2282
|
+
* them, with their sources tracked. An event in between never does (N-5) —
|
|
2283
|
+
* it would start one on sources "not tracked yet".
|
|
2284
|
+
*/
|
|
2285
|
+
unsynced = /* @__PURE__ */ new Set();
|
|
633
2286
|
constructor(deps) {
|
|
634
2287
|
this.deps = deps;
|
|
635
|
-
this.
|
|
2288
|
+
this.composed = new ComposedDeviceSet({
|
|
2289
|
+
logger: deps.logger,
|
|
2290
|
+
declare: deps.declare,
|
|
2291
|
+
devices: deps.devices
|
|
2292
|
+
});
|
|
2293
|
+
this.gate = new WithdrawalGate(deps.logger.child("withdrawal"), (stableId) => this.composed.idOf(stableId));
|
|
2294
|
+
this.claimGate = new ClaimGate(deps.logger.child("claims"));
|
|
2295
|
+
this.grafts = new ComposerGrafts(deps.grafts, deps.logger.child("graft"), deps.describeTarget);
|
|
2296
|
+
this.targets = new TargetDescriptions(deps.logger.child("target"), (deviceId) => {
|
|
2297
|
+
this.grafts.describing(deviceId);
|
|
2298
|
+
return deps.describeTarget(deviceId);
|
|
2299
|
+
});
|
|
2300
|
+
this.rowSeed = new ConfirmedSeed({
|
|
2301
|
+
logger: deps.logger,
|
|
2302
|
+
subject: "composer's own device rows",
|
|
2303
|
+
read: async () => {
|
|
2304
|
+
try {
|
|
2305
|
+
return {
|
|
2306
|
+
kind: "items",
|
|
2307
|
+
items: await deps.readPersisted()
|
|
2308
|
+
};
|
|
2309
|
+
} catch (err) {
|
|
2310
|
+
return {
|
|
2311
|
+
kind: "unknown",
|
|
2312
|
+
error: require_dist.errMsg(err)
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
},
|
|
2316
|
+
onSeeded: (rows) => {
|
|
2317
|
+
this.gate.seed(rows);
|
|
2318
|
+
this.composed.seedRows(rows);
|
|
2319
|
+
},
|
|
2320
|
+
unreadReason: ROWS_UNREAD,
|
|
2321
|
+
emptyUnconfirmedReason: ROWS_EMPTY_UNCONFIRMED
|
|
2322
|
+
});
|
|
2323
|
+
this.claimSeed = new ConfirmedSeed({
|
|
2324
|
+
logger: deps.logger,
|
|
2325
|
+
subject: "claims index",
|
|
2326
|
+
read: async () => {
|
|
2327
|
+
try {
|
|
2328
|
+
const listing = await deps.claims.listClaims({ ownerPrefix: "" });
|
|
2329
|
+
return listing.state === "loaded" ? {
|
|
2330
|
+
kind: "items",
|
|
2331
|
+
items: listing.claims
|
|
2332
|
+
} : {
|
|
2333
|
+
kind: "unknown",
|
|
2334
|
+
error: listing.reason
|
|
2335
|
+
};
|
|
2336
|
+
} catch (err) {
|
|
2337
|
+
return {
|
|
2338
|
+
kind: "unknown",
|
|
2339
|
+
error: require_dist.errMsg(err)
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
},
|
|
2343
|
+
onSeeded: (claims) => {
|
|
2344
|
+
this.claimGate.seed(claims);
|
|
2345
|
+
this.deps.tracker.seedClaims(claims);
|
|
2346
|
+
},
|
|
2347
|
+
unreadReason: CLAIMS_NOT_LOADED,
|
|
2348
|
+
emptyUnconfirmedReason: CLAIMS_EMPTY_UNCONFIRMED
|
|
2349
|
+
});
|
|
636
2350
|
}
|
|
637
2351
|
apply(input) {
|
|
638
|
-
|
|
639
|
-
this.applyChain = run.then(() => void 0, () => void 0);
|
|
640
|
-
return run;
|
|
2352
|
+
return this.chained(() => this.applyNow(input));
|
|
641
2353
|
}
|
|
642
2354
|
getStates(blockIds) {
|
|
643
2355
|
return blockIds.flatMap((blockId) => {
|
|
@@ -646,10 +2358,10 @@ var Composer = class {
|
|
|
646
2358
|
const verdict = this.verdict(blockId);
|
|
647
2359
|
return [{
|
|
648
2360
|
blockId,
|
|
649
|
-
deviceId: entry
|
|
2361
|
+
deviceId: this.deviceIdOf(entry),
|
|
650
2362
|
status: verdict.status,
|
|
651
2363
|
...verdict.reason === void 0 ? {} : { reason: verdict.reason },
|
|
652
|
-
fields: entry.runtime?.state().fields.map((f) => ({ ...f })) ?? []
|
|
2364
|
+
fields: entry.runtime?.state().fields.map((f) => ({ ...f })) ?? [...entry.heldFields]
|
|
653
2365
|
}];
|
|
654
2366
|
});
|
|
655
2367
|
}
|
|
@@ -657,15 +2369,28 @@ var Composer = class {
|
|
|
657
2369
|
async onStateChanged(data) {
|
|
658
2370
|
this.dispatch(await this.deps.tracker.onStateChanged(data.deviceId, data.capName, data.slice));
|
|
659
2371
|
}
|
|
2372
|
+
async onNativeChanged(data) {
|
|
2373
|
+
this.dispatch(await this.deps.tracker.onNativeChanged(data.deviceId, data.capName, data.native));
|
|
2374
|
+
}
|
|
660
2375
|
async onDeviceUnregistered(deviceId) {
|
|
661
|
-
|
|
2376
|
+
const changed = await this.deps.tracker.onDeviceUnregistered(deviceId);
|
|
2377
|
+
await this.chained(() => this.failGoneTargets(deviceId));
|
|
2378
|
+
this.dispatch(changed);
|
|
662
2379
|
}
|
|
663
2380
|
async onBindingsChanged(data) {
|
|
2381
|
+
this.targets.invalidate(data.deviceId);
|
|
2382
|
+
this.grafts.onBindingsChanged(data);
|
|
664
2383
|
this.dispatch(await this.deps.tracker.onBindingsChanged(data.deviceId, data.capName, data.reason));
|
|
665
2384
|
}
|
|
666
2385
|
shutdown() {
|
|
667
2386
|
for (const entry of this.live.values()) entry.runtime?.stop();
|
|
668
2387
|
}
|
|
2388
|
+
/** Run `work` after every apply already queued, whether that settled or failed. */
|
|
2389
|
+
chained(work) {
|
|
2390
|
+
const run = this.applyChain.then(work, work);
|
|
2391
|
+
this.applyChain = run.then(() => void 0, () => void 0);
|
|
2392
|
+
return run;
|
|
2393
|
+
}
|
|
669
2394
|
async applyNow(input) {
|
|
670
2395
|
const ids = [...input.blocks.map((b) => b.id), ...input.unreadableBlocks.map((u) => u.id)];
|
|
671
2396
|
if (!input.storeLoaded) {
|
|
@@ -689,15 +2414,26 @@ var Composer = class {
|
|
|
689
2414
|
this.deps.logger.info("composer: the shared Blocks integration has reconciled — applying again", { meta: { appliesRefused: this.integrationWaitApplies } });
|
|
690
2415
|
this.integrationWaitApplies = 0;
|
|
691
2416
|
}
|
|
692
|
-
const unseeded = await this.
|
|
2417
|
+
const unseeded = await this.rowSeed.ensure();
|
|
693
2418
|
if (unseeded !== null) return this.waiting(ids, unseeded);
|
|
694
|
-
const
|
|
695
|
-
|
|
2419
|
+
const claimsWait = await this.claimSeed.ensure();
|
|
2420
|
+
const restart = new Set(input.restartBlockIds);
|
|
2421
|
+
const planned = [];
|
|
2422
|
+
for (const block of input.blocks) planned.push(await this.plan(block, restart, claimsWait));
|
|
2423
|
+
for (const block of input.unreadableBlocks) planned.push(planUnreadable(block, this.composed.rows));
|
|
2424
|
+
for (const p of planned) logPlanTransition(this.deps.logger, this.live.get(p.block.id)?.planned, p, this.tagsOf(p.block.id));
|
|
696
2425
|
const declarations = this.gate.filter(planned.flatMap((p) => p.declaration === null ? [] : [p.declaration]));
|
|
697
|
-
const rebuildErrors = await this.rebuildShapes(planned)
|
|
698
|
-
|
|
699
|
-
|
|
2426
|
+
const rebuildErrors = await this.composed.rebuildShapes(planned, (blockId) => {
|
|
2427
|
+
const previous = this.live.get(blockId);
|
|
2428
|
+
return previous === void 0 ? void 0 : {
|
|
2429
|
+
device: previous.device,
|
|
2430
|
+
shape: previous.planned.shape
|
|
2431
|
+
};
|
|
2432
|
+
});
|
|
2433
|
+
await this.composed.reconcile(input.integrationId, declarations, planned);
|
|
2434
|
+
const rebuilt = await this.rebuildRuntimes(planned, declarations, restart, rebuildErrors);
|
|
700
2435
|
await this.syncSources(rebuilt);
|
|
2436
|
+
await this.reconcileClaims();
|
|
701
2437
|
return {
|
|
702
2438
|
blocks: planned.map((p) => this.verdict(p.block.id)),
|
|
703
2439
|
acted: true
|
|
@@ -713,177 +2449,20 @@ var Composer = class {
|
|
|
713
2449
|
acted: false
|
|
714
2450
|
};
|
|
715
2451
|
}
|
|
716
|
-
|
|
717
|
-
* Seed the withdrawal gate with the rows that exist, ONCE, before its first
|
|
718
|
-
* `filter`; answers null once seeded, else why this apply must wait.
|
|
719
|
-
*
|
|
720
|
-
* An EMPTY answer is the destroying direction: a cold registry answers `[]`
|
|
721
|
-
* too, and `DeclaredDevices.reconcile` sweeps from its OWN later read — so a
|
|
722
|
-
* cold seed followed by a warm sweep would delete the row of a block that no
|
|
723
|
-
* longer plans, with no placeholder to hold it. `[]` is therefore trusted
|
|
724
|
-
* only when the next apply's read agrees (D49); that apply comes from the
|
|
725
|
-
* supervisor's existing converge, never a timer here (D3). A failed read
|
|
726
|
-
* changes nothing and breaks the agreement.
|
|
727
|
-
*/
|
|
728
|
-
async ensureSeeded() {
|
|
729
|
-
if (this.seeded) return null;
|
|
730
|
-
let rows;
|
|
731
|
-
try {
|
|
732
|
-
rows = await this.deps.readPersisted();
|
|
733
|
-
} catch (err) {
|
|
734
|
-
this.emptySeedSeen = false;
|
|
735
|
-
this.deps.logger.warn("composer: reading its own device rows failed — nothing is declared or withdrawn; retried on the next apply", { meta: { error: require_dist.errMsg(err) } });
|
|
736
|
-
return ROWS_UNREAD;
|
|
737
|
-
}
|
|
738
|
-
if (rows.length === 0 && !this.emptySeedSeen) {
|
|
739
|
-
this.emptySeedSeen = true;
|
|
740
|
-
this.deps.logger.warn("composer: its own device rows read empty — waiting for the next apply to confirm before declaring or withdrawing anything");
|
|
741
|
-
return ROWS_EMPTY_UNCONFIRMED;
|
|
742
|
-
}
|
|
743
|
-
this.gate.seed(rows);
|
|
744
|
-
this.rows = new Set(rows.map((r) => r.stableId));
|
|
745
|
-
this.seeded = true;
|
|
746
|
-
this.deps.logger.info("composer: seeded the withdrawal gate from the persisted rows", { meta: {
|
|
747
|
-
rows: rows.length,
|
|
748
|
-
confirmedEmpty: rows.length === 0
|
|
749
|
-
} });
|
|
750
|
-
return null;
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* A row that exists but does not parse is held exactly like a composition
|
|
754
|
-
* that stopped planning: its device (if it has a row) stays as an offline
|
|
755
|
-
* placeholder, and the block fails naming the parse error. Absent, it would
|
|
756
|
-
* be withdrawn on the second apply that omitted it (D49).
|
|
757
|
-
*/
|
|
758
|
-
planUnreadable(block) {
|
|
759
|
-
const stableId = require_dist.composedDeviceStableId(block.id);
|
|
760
|
-
const hasRow = this.rows.has(stableId);
|
|
761
|
-
return {
|
|
762
|
-
block: {
|
|
763
|
-
id: block.id,
|
|
764
|
-
name: block.name,
|
|
765
|
-
enabled: false,
|
|
766
|
-
updatedAt: 0
|
|
767
|
-
},
|
|
768
|
-
fields: [],
|
|
769
|
-
problems: [`the stored block does not parse — ${block.error}`],
|
|
770
|
-
declaration: hasRow ? placeholderDeclaration(stableId) : null,
|
|
771
|
-
shape: hasRow ? PLACEHOLDER_SHAPE : null,
|
|
772
|
-
spec: null
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
plan(block) {
|
|
776
|
-
const plan = require_dist.planComposition(block.composition, this.deps.lookupCap);
|
|
2452
|
+
plan(block, restart, claimsWait) {
|
|
777
2453
|
const target = block.composition.target;
|
|
778
|
-
if (
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const hasRow = this.rows.has(stableId);
|
|
782
|
-
return {
|
|
783
|
-
block,
|
|
784
|
-
fields: [],
|
|
785
|
-
problems,
|
|
786
|
-
declaration: hasRow ? placeholderDeclaration(stableId) : null,
|
|
787
|
-
shape: hasRow ? PLACEHOLDER_SHAPE : null,
|
|
788
|
-
spec: null
|
|
789
|
-
};
|
|
790
|
-
}
|
|
791
|
-
const caps = block.composition.features.flatMap((f) => {
|
|
792
|
-
const def = this.deps.lookupCap(f.cap);
|
|
793
|
-
return def === null ? [] : [def];
|
|
794
|
-
});
|
|
795
|
-
const declarationInput = {
|
|
796
|
-
blockId: block.id,
|
|
797
|
-
name: block.name,
|
|
798
|
-
target,
|
|
799
|
-
caps
|
|
800
|
-
};
|
|
801
|
-
return {
|
|
2454
|
+
if (target.kind === "new") return Promise.resolve(planNew(block, target, this.deps.lookupCap, this.composed.rows));
|
|
2455
|
+
const previous = this.live.get(block.id)?.planned;
|
|
2456
|
+
return planExisting({
|
|
802
2457
|
block,
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
};
|
|
812
|
-
}
|
|
813
|
-
/** One line when a block starts or stops planning — never one per apply. */
|
|
814
|
-
logPlanTransitions(planned) {
|
|
815
|
-
for (const p of planned) {
|
|
816
|
-
const before = this.live.get(p.block.id)?.planned.problems.join("; ") ?? "";
|
|
817
|
-
const now = p.problems.join("; ");
|
|
818
|
-
if (before === now) continue;
|
|
819
|
-
const extras = {
|
|
820
|
-
tags: this.tagsOf(p.block.id),
|
|
821
|
-
meta: {
|
|
822
|
-
blockId: p.block.id,
|
|
823
|
-
problems: p.problems,
|
|
824
|
-
placeholder: p.declaration !== null
|
|
825
|
-
}
|
|
826
|
-
};
|
|
827
|
-
if (now === "") this.deps.logger.info("composition plans again", extras);
|
|
828
|
-
else this.deps.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);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* A changed type/role/cap set (or a switch to or from a placeholder) needs a
|
|
833
|
-
* NEW device object: decommission, and let the reconcile adopt it with the
|
|
834
|
-
* new class. A failure keeps the old device and is retried next apply.
|
|
835
|
-
*/
|
|
836
|
-
async rebuildShapes(planned) {
|
|
837
|
-
const errors = /* @__PURE__ */ new Map();
|
|
838
|
-
for (const p of planned) {
|
|
839
|
-
const previous = this.live.get(p.block.id);
|
|
840
|
-
const device = previous?.device ?? null;
|
|
841
|
-
if (previous === void 0 || device === null || p.shape === null) continue;
|
|
842
|
-
if (previous.planned.shape === p.shape) continue;
|
|
843
|
-
try {
|
|
844
|
-
this.deps.logger.info("composition shape changed — rebuilding its device", {
|
|
845
|
-
tags: { deviceId: device.id },
|
|
846
|
-
meta: {
|
|
847
|
-
blockId: p.block.id,
|
|
848
|
-
from: previous.planned.shape,
|
|
849
|
-
to: p.shape
|
|
850
|
-
}
|
|
851
|
-
});
|
|
852
|
-
if (p.spec !== null) await this.deps.devices.applyDerivedSpec(device.id, p.spec);
|
|
853
|
-
await this.deps.devices.decommission(device.id);
|
|
854
|
-
const stableId = require_dist.composedDeviceStableId(p.block.id);
|
|
855
|
-
this.devices = new Map([...this.devices].filter(([s]) => s !== stableId));
|
|
856
|
-
this.declaredFingerprint = null;
|
|
857
|
-
} catch (err) {
|
|
858
|
-
const error = require_dist.errMsg(err);
|
|
859
|
-
errors.set(p.block.id, error);
|
|
860
|
-
this.deps.logger.warn("rebuilding a composed device failed — the old one is kept", {
|
|
861
|
-
tags: { deviceId: device.id },
|
|
862
|
-
meta: {
|
|
863
|
-
blockId: p.block.id,
|
|
864
|
-
error
|
|
865
|
-
}
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
return errors;
|
|
870
|
-
}
|
|
871
|
-
async reconcileDevices(integrationId, declarations, planned) {
|
|
872
|
-
const shapes = new Map(planned.flatMap((p) => p.declaration === null ? [] : [[p.declaration.stableId, p.shape]]));
|
|
873
|
-
const fingerprint = JSON.stringify(declarations.map((d) => [d.stableId, shapes.get(d.stableId) ?? null]).toSorted());
|
|
874
|
-
const allLive = declarations.every((d) => this.devices.has(d.stableId));
|
|
875
|
-
if (fingerprint === this.declaredFingerprint && allLive) return;
|
|
876
|
-
try {
|
|
877
|
-
const result = await this.deps.declare(integrationId, declarations);
|
|
878
|
-
this.devices = result.devices;
|
|
879
|
-
this.rows = new Set(declarations.map((d) => d.stableId).filter((s) => this.rows.has(s) || result.devices.has(s)));
|
|
880
|
-
this.declaredFingerprint = fingerprint;
|
|
881
|
-
} catch (err) {
|
|
882
|
-
this.deps.logger.warn("composer: the device declaration failed — keeping the devices already live", { meta: {
|
|
883
|
-
error: require_dist.errMsg(err),
|
|
884
|
-
declarations: declarations.length
|
|
885
|
-
} });
|
|
886
|
-
}
|
|
2458
|
+
target,
|
|
2459
|
+
previous,
|
|
2460
|
+
operatorAction: previous === void 0 || restart.has(block.id) || previous.block.updatedAt !== block.updatedAt,
|
|
2461
|
+
claimsWait,
|
|
2462
|
+
tracker: this.deps.tracker,
|
|
2463
|
+
targets: this.targets,
|
|
2464
|
+
lookupCap: this.deps.lookupCap
|
|
2465
|
+
});
|
|
887
2466
|
}
|
|
888
2467
|
async rebuildRuntimes(planned, declarations, restart, rebuildErrors) {
|
|
889
2468
|
const reset = /* @__PURE__ */ new Set();
|
|
@@ -891,6 +2470,8 @@ var Composer = class {
|
|
|
891
2470
|
const next = /* @__PURE__ */ new Map();
|
|
892
2471
|
for (const p of planned) {
|
|
893
2472
|
const previous = this.live.get(p.block.id);
|
|
2473
|
+
const lost = lostTarget(p, previous?.planned);
|
|
2474
|
+
if (lost !== null) this.ungraftGone(p.block.id, lost.target, lost.deviceId);
|
|
894
2475
|
const rebuildError = rebuildErrors.get(p.block.id) ?? null;
|
|
895
2476
|
if (previous !== void 0 && rebuildError !== null) {
|
|
896
2477
|
next.set(p.block.id, {
|
|
@@ -899,28 +2480,68 @@ var Composer = class {
|
|
|
899
2480
|
});
|
|
900
2481
|
continue;
|
|
901
2482
|
}
|
|
902
|
-
const device = this.
|
|
2483
|
+
const device = p.target.kind === "existing" ? null : this.composed.get(require_dist.composedDeviceStableId(p.block.id));
|
|
903
2484
|
const appliedName = device === null ? null : p.block.name === null ? previous?.appliedName ?? device.name : await this.applyName(device, p.block.name, previous?.appliedName ?? null);
|
|
904
|
-
if (previous?.runtime != null && previous.device === device && previous.planned.block.updatedAt === p.block.updatedAt && previous.planned.block.enabled === p.block.enabled && !restart.has(p.block.id)) {
|
|
2485
|
+
if (previous?.runtime != null && previous.device === device && sameTarget(previous.planned.target, p.target) && previous.planned.block.updatedAt === p.block.updatedAt && previous.planned.block.enabled === p.block.enabled && !restart.has(p.block.id)) {
|
|
905
2486
|
next.set(p.block.id, {
|
|
906
2487
|
planned: p,
|
|
907
2488
|
device,
|
|
908
2489
|
runtime: previous.runtime,
|
|
909
2490
|
appliedName,
|
|
910
|
-
rebuildError: null
|
|
2491
|
+
rebuildError: null,
|
|
2492
|
+
heldFields: []
|
|
2493
|
+
});
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
2496
|
+
let targetSeed;
|
|
2497
|
+
if (p.target.kind === "existing" && p.target.deviceId !== null && p.target.waiting === null && p.target.gone === null && p.block.enabled && p.fields.length > 0) try {
|
|
2498
|
+
targetSeed = await readExistingTargetSeeds(p.target.deviceId, p.fields.map((field) => field.cap), this.deps.readExistingTargetCap);
|
|
2499
|
+
} catch (err) {
|
|
2500
|
+
const reason = `the target device state could not be read before ownership (${require_dist.errMsg(err)}) — nothing is grafted or claimed until it is; retried on the next apply`;
|
|
2501
|
+
this.deps.logger.warn(previous?.runtime === null || previous === void 0 ? "customization target state could not be read — ownership is held" : "customization edit target state could not be read — the previous runtime stays installed", {
|
|
2502
|
+
tags: { deviceId: p.target.deviceId },
|
|
2503
|
+
meta: {
|
|
2504
|
+
blockId: p.block.id,
|
|
2505
|
+
error: require_dist.errMsg(err)
|
|
2506
|
+
}
|
|
2507
|
+
});
|
|
2508
|
+
if (previous !== void 0) next.set(p.block.id, previous);
|
|
2509
|
+
else next.set(p.block.id, {
|
|
2510
|
+
planned: withExistingTargetWait(p, reason),
|
|
2511
|
+
device: null,
|
|
2512
|
+
runtime: null,
|
|
2513
|
+
appliedName: null,
|
|
2514
|
+
rebuildError: null,
|
|
2515
|
+
heldFields: []
|
|
911
2516
|
});
|
|
912
2517
|
continue;
|
|
913
2518
|
}
|
|
2519
|
+
const heldFields = this.heldFieldsFor(p, previous);
|
|
2520
|
+
const operatorAction = previous === void 0 || restart.has(p.block.id) || previous.planned.block.updatedAt !== p.block.updatedAt;
|
|
2521
|
+
const targetKey = p.target.kind === "existing" ? require_dist.compositionSourceKey(p.target.ref) : null;
|
|
2522
|
+
if (operatorAction) for (const f of p.fields) for (const r of f.reads) {
|
|
2523
|
+
const key = require_dist.compositionSourceKey(r.source);
|
|
2524
|
+
if (key !== targetKey) reset.add(key);
|
|
2525
|
+
}
|
|
2526
|
+
const writer = this.writerFor(p, device);
|
|
2527
|
+
const runtime = writer === null || p.fields.length === 0 ? null : this.newRuntime(p, writer, targetSeed);
|
|
2528
|
+
if (writer !== null && p.target.kind === "existing" && p.target.deviceId !== null) this.grafts.plan({
|
|
2529
|
+
deviceId: p.target.deviceId,
|
|
2530
|
+
ref: p.target.ref,
|
|
2531
|
+
features: p.target.features,
|
|
2532
|
+
nativeCaps: p.target.nativeCaps,
|
|
2533
|
+
enabled: p.block.enabled,
|
|
2534
|
+
settled: p.problems.length === 0
|
|
2535
|
+
});
|
|
914
2536
|
previous?.runtime?.stop();
|
|
915
|
-
if (previous === void 0 || restart.has(p.block.id) || previous.planned.block.updatedAt !== p.block.updatedAt) for (const f of p.fields) for (const r of f.reads) reset.add(require_dist.compositionSourceKey(r.source));
|
|
916
|
-
const runtime = device === null || p.fields.length === 0 ? null : this.newRuntime(p, device);
|
|
917
2537
|
if (runtime !== null) fresh.add(runtime);
|
|
918
2538
|
next.set(p.block.id, {
|
|
919
2539
|
planned: p,
|
|
920
2540
|
device,
|
|
921
2541
|
runtime,
|
|
922
2542
|
appliedName,
|
|
923
|
-
rebuildError: null
|
|
2543
|
+
rebuildError: null,
|
|
2544
|
+
heldFields
|
|
924
2545
|
});
|
|
925
2546
|
}
|
|
926
2547
|
const held = new Set(declarations.map((d) => d.stableId));
|
|
@@ -930,17 +2551,72 @@ var Composer = class {
|
|
|
930
2551
|
else entry.runtime?.stop();
|
|
931
2552
|
}
|
|
932
2553
|
this.live = next;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
2554
|
+
const liveRuntimes = new Set([...next.values()].map((e) => e.runtime));
|
|
2555
|
+
this.unsynced = new Set([...[...this.unsynced].filter((r) => liveRuntimes.has(r)), ...fresh]);
|
|
2556
|
+
return { reset };
|
|
2557
|
+
}
|
|
2558
|
+
ungraftGone(blockId, target, deviceId) {
|
|
2559
|
+
this.grafts.gone(deviceId);
|
|
2560
|
+
ungraftGoneTarget({
|
|
2561
|
+
grafts: this.deps.grafts,
|
|
2562
|
+
logger: this.deps.logger,
|
|
2563
|
+
blockId,
|
|
2564
|
+
deviceId,
|
|
2565
|
+
ref: target.ref,
|
|
2566
|
+
features: target.features
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
/** Where a block's runtime writes: its composed device, or a claim-backed target on the existing device. */
|
|
2570
|
+
writerFor(p, device) {
|
|
2571
|
+
const target = p.target;
|
|
2572
|
+
if (target.kind !== "existing") return device;
|
|
2573
|
+
if (target.deviceId === null || target.waiting !== null || target.gone !== null) return null;
|
|
2574
|
+
const deviceId = target.deviceId;
|
|
2575
|
+
const owner = p.block.id;
|
|
2576
|
+
return new OwnedFieldsTarget({
|
|
2577
|
+
deviceId,
|
|
2578
|
+
owner,
|
|
2579
|
+
modeOf: (cap) => claimModeOf(target.features, cap),
|
|
2580
|
+
fieldsOf: (cap) => claimFieldsOf(p.fields, cap),
|
|
2581
|
+
api: this.deps.claims,
|
|
2582
|
+
logger: this.deps.logger.child("owned"),
|
|
2583
|
+
onClaim: (cap, event) => void this.onClaimEvent(deviceId, cap, owner, event)
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
/** C-1/N-1: the tracker follows the block's OWN claim as the hub answers it; every answer re-reads the native once. */
|
|
2587
|
+
async onClaimEvent(deviceId, cap, owner, event) {
|
|
2588
|
+
try {
|
|
2589
|
+
this.dispatch(await this.deps.tracker.claimEvent(deviceId, cap, owner, event));
|
|
2590
|
+
} catch (err) {
|
|
2591
|
+
this.deps.logger.warn("composer: following a claim transition failed", {
|
|
2592
|
+
tags: { deviceId },
|
|
2593
|
+
meta: {
|
|
2594
|
+
cap,
|
|
2595
|
+
owner,
|
|
2596
|
+
event,
|
|
2597
|
+
error: require_dist.errMsg(err)
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
937
2601
|
}
|
|
938
|
-
|
|
2602
|
+
heldFieldsFor(p, previous) {
|
|
2603
|
+
if (p.target.kind !== "existing" || p.problems.length === 0 || previous === void 0) return [];
|
|
2604
|
+
if (previous.runtime === null) return previous.heldFields;
|
|
2605
|
+
return heldFieldsOf(previous.runtime.state().fields, `the composition no longer plans — the field keeps its last value on the device: ${p.problems.join("; ")}`);
|
|
2606
|
+
}
|
|
2607
|
+
newRuntime(p, target, seed) {
|
|
2608
|
+
const self = p.target.kind === "existing" ? selfReadCaps(p.fields, p.target.ref, p.target.features) : null;
|
|
2609
|
+
const targetKey = p.target.kind === "existing" ? require_dist.compositionSourceKey(p.target.ref) : null;
|
|
2610
|
+
const nativeCaps = new Set(self?.native ?? []);
|
|
939
2611
|
return new CompositionRuntime({
|
|
940
2612
|
blockId: p.block.id,
|
|
941
2613
|
fields: p.fields,
|
|
942
|
-
|
|
943
|
-
|
|
2614
|
+
eventPlans: p.events,
|
|
2615
|
+
events: this.deps.events,
|
|
2616
|
+
target,
|
|
2617
|
+
lookupCap: this.deps.lookupCap,
|
|
2618
|
+
...seed === void 0 ? {} : { initialByCap: seed.byCap },
|
|
2619
|
+
read: (read) => this.deps.tracker.read(read, require_dist.compositionSourceKey(read.source) === targetKey && nativeCaps.has(read.cap) ? "native" : "effective"),
|
|
944
2620
|
now: this.deps.now,
|
|
945
2621
|
timer: this.deps.newTimer(),
|
|
946
2622
|
logger: this.deps.logger.child("runtime")
|
|
@@ -967,78 +2643,149 @@ var Composer = class {
|
|
|
967
2643
|
}
|
|
968
2644
|
}
|
|
969
2645
|
async syncSources(rebuilt) {
|
|
970
|
-
const requests = [
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
2646
|
+
const requests = [];
|
|
2647
|
+
for (const e of this.live.values()) {
|
|
2648
|
+
const target = e.planned.target;
|
|
2649
|
+
const running = e.runtime !== null && e.planned.block.enabled;
|
|
2650
|
+
let targetKey = null;
|
|
2651
|
+
if (target.kind === "existing") {
|
|
2652
|
+
targetKey = require_dist.compositionSourceKey(target.ref);
|
|
2653
|
+
const self = running ? selfReadCaps(e.planned.fields, target.ref, target.features) : null;
|
|
2654
|
+
requests.push({
|
|
2655
|
+
ref: target.ref,
|
|
2656
|
+
caps: self?.effective ?? [],
|
|
2657
|
+
nativeCaps: self?.native ?? [],
|
|
2658
|
+
nativeOwner: e.planned.block.id
|
|
2659
|
+
});
|
|
2660
|
+
}
|
|
2661
|
+
if (!running) continue;
|
|
2662
|
+
for (const f of e.planned.fields) for (const r of f.reads) if (require_dist.compositionSourceKey(r.source) !== targetKey) requests.push({
|
|
2663
|
+
ref: r.source,
|
|
2664
|
+
caps: [r.cap]
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
974
2667
|
const changed = await this.syncTracker(requests, rebuilt.reset);
|
|
975
2668
|
for (const entry of this.live.values()) {
|
|
976
2669
|
const runtime = entry.runtime;
|
|
977
2670
|
if (runtime === null) continue;
|
|
2671
|
+
const fresh = this.unsynced.has(runtime);
|
|
2672
|
+
this.unsynced = new Set([...this.unsynced].filter((r) => r !== runtime));
|
|
978
2673
|
if (!entry.planned.block.enabled) runtime.disable("the block is disabled");
|
|
979
|
-
else if (
|
|
2674
|
+
else if (fresh) runtime.start();
|
|
980
2675
|
else runtime.onSourcesChanged(changed);
|
|
981
2676
|
}
|
|
982
2677
|
}
|
|
983
|
-
/** A failed sync leaves the tracker as it was: the runtimes read what it holds. */
|
|
984
|
-
async syncTracker(requests, reset) {
|
|
2678
|
+
/** A failed sync leaves the tracker as it was: the runtimes read what it holds. */
|
|
2679
|
+
async syncTracker(requests, reset) {
|
|
2680
|
+
try {
|
|
2681
|
+
return await this.deps.tracker.sync(requests, reset);
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
this.deps.logger.warn("composer: syncing the tracked sources failed — retried on the next apply", { meta: {
|
|
2684
|
+
error: require_dist.errMsg(err),
|
|
2685
|
+
sources: requests.length
|
|
2686
|
+
} });
|
|
2687
|
+
return /* @__PURE__ */ new Set();
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Rule 3: the claims every live customization WANTS this apply; everything
|
|
2692
|
+
* else the gate knows is a release candidate, handed back once two applies
|
|
2693
|
+
* agree. A block that cannot plan — waiting, unreadable, failed, or with its
|
|
2694
|
+
* target gone — HOLDS its claims (rule 4, rule 5): they are wanted as they
|
|
2695
|
+
* are. A disabled block wants none (rule 7).
|
|
2696
|
+
*/
|
|
2697
|
+
async reconcileClaims() {
|
|
2698
|
+
if (!this.claimSeed.done) return;
|
|
2699
|
+
const wanted = [];
|
|
2700
|
+
const held = [];
|
|
2701
|
+
for (const [blockId, entry] of this.live) {
|
|
2702
|
+
const target = entry.planned.target;
|
|
2703
|
+
if (target.kind === "new") continue;
|
|
2704
|
+
if (target.kind === "unreadable" || entry.planned.problems.length > 0 || target.waiting !== null || target.gone !== null || target.deviceId === null) {
|
|
2705
|
+
held.push(blockId);
|
|
2706
|
+
continue;
|
|
2707
|
+
}
|
|
2708
|
+
if (!entry.planned.block.enabled) continue;
|
|
2709
|
+
const deviceId = target.deviceId;
|
|
2710
|
+
for (const f of target.features) wanted.push({
|
|
2711
|
+
deviceId,
|
|
2712
|
+
capName: f.cap,
|
|
2713
|
+
owner: blockId
|
|
2714
|
+
});
|
|
2715
|
+
}
|
|
2716
|
+
for (const owner of held) wanted.push(...this.claimGate.claimsOf(owner));
|
|
2717
|
+
for (const key of this.claimGate.filter(wanted)) await this.release(key);
|
|
2718
|
+
}
|
|
2719
|
+
async release(key) {
|
|
2720
|
+
let outcome;
|
|
985
2721
|
try {
|
|
986
|
-
|
|
2722
|
+
outcome = await this.deps.claims.releaseClaim(key);
|
|
987
2723
|
} catch (err) {
|
|
988
|
-
this.
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
2724
|
+
this.claimGate.releaseFailed(key, require_dist.errMsg(err));
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
if (!outcome.ok && !RELEASE_SETTLED_CODES.has(outcome.code)) {
|
|
2728
|
+
this.claimGate.releaseFailed(key, `${outcome.code}: ${outcome.message}`);
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
this.claimGate.released(key, outcome.ok ? "released" : outcome.code);
|
|
2732
|
+
await this.grafts.released(key.deviceId, key.capName);
|
|
2733
|
+
await this.onClaimEvent(key.deviceId, key.capName, key.owner, "free");
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Rule 5: a customization whose target row is gone — the tracker's two
|
|
2737
|
+
* agreeing reads — fails naming it, stops writing, and ungrafts every cap it
|
|
2738
|
+
* added on that id (Task 10's seam). Its claims are left as they are: the row
|
|
2739
|
+
* and its `$owned` died with the device. No auto-repair: an operator edit or
|
|
2740
|
+
* restart re-resolves the target.
|
|
2741
|
+
*/
|
|
2742
|
+
failGoneTargets(deviceId) {
|
|
2743
|
+
const next = new Map(this.live);
|
|
2744
|
+
for (const [blockId, entry] of this.live) {
|
|
2745
|
+
const target = entry.planned.target;
|
|
2746
|
+
if (target.kind !== "existing" || target.deviceId !== deviceId || target.gone !== null) continue;
|
|
2747
|
+
const resolution = this.deps.tracker.resolution(target.ref);
|
|
2748
|
+
if (resolution.kind !== "missing") continue;
|
|
2749
|
+
entry.runtime?.stop();
|
|
2750
|
+
this.unsynced = new Set([...this.unsynced].filter((r) => r !== entry.runtime));
|
|
2751
|
+
this.ungraftGone(blockId, target, deviceId);
|
|
2752
|
+
next.set(blockId, {
|
|
2753
|
+
...entry,
|
|
2754
|
+
runtime: null,
|
|
2755
|
+
planned: {
|
|
2756
|
+
...entry.planned,
|
|
2757
|
+
target: {
|
|
2758
|
+
...target,
|
|
2759
|
+
gone: `${resolution.reason} — two index reads agreed`
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
});
|
|
993
2763
|
}
|
|
2764
|
+
this.live = next;
|
|
994
2765
|
}
|
|
995
2766
|
dispatch(changed) {
|
|
996
2767
|
if (changed.size === 0) return;
|
|
997
|
-
for (const entry of this.live.values())
|
|
2768
|
+
for (const entry of this.live.values()) {
|
|
2769
|
+
const runtime = entry.runtime;
|
|
2770
|
+
if (runtime === null || !entry.planned.block.enabled) continue;
|
|
2771
|
+
if (!this.unsynced.has(runtime)) runtime.onSourcesChanged(changed);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
deviceIdOf(entry) {
|
|
2775
|
+
if (entry.device !== null) return entry.device.id;
|
|
2776
|
+
return entry.planned.target.kind === "existing" ? entry.planned.target.deviceId : null;
|
|
998
2777
|
}
|
|
999
2778
|
tagsOf(blockId) {
|
|
1000
|
-
const
|
|
2779
|
+
const entry = this.live.get(blockId);
|
|
2780
|
+
const deviceId = this.composed.idOf(require_dist.composedDeviceStableId(blockId)) ?? (entry === void 0 ? null : this.deviceIdOf(entry));
|
|
1001
2781
|
return deviceId === null ? {} : { deviceId };
|
|
1002
2782
|
}
|
|
1003
2783
|
verdict(blockId) {
|
|
1004
2784
|
const entry = this.live.get(blockId);
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
};
|
|
1010
|
-
if (entry.rebuildError !== null) return {
|
|
1011
|
-
blockId,
|
|
1012
|
-
status: "failed",
|
|
1013
|
-
reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
|
|
1014
|
-
};
|
|
1015
|
-
if (entry.planned.problems.length > 0) {
|
|
1016
|
-
const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
|
|
1017
|
-
return {
|
|
1018
|
-
blockId,
|
|
1019
|
-
status: "failed",
|
|
1020
|
-
reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
if (!entry.planned.block.enabled) return {
|
|
1024
|
-
blockId,
|
|
1025
|
-
status: "stopped"
|
|
1026
|
-
};
|
|
1027
|
-
if (entry.device === null) return {
|
|
1028
|
-
blockId,
|
|
1029
|
-
status: "starting",
|
|
1030
|
-
reason: "the composed device has not been constructed yet"
|
|
1031
|
-
};
|
|
1032
|
-
const failure = entry.runtime?.state().failure ?? null;
|
|
1033
|
-
if (failure !== null) return {
|
|
1034
|
-
blockId,
|
|
1035
|
-
status: "failed",
|
|
1036
|
-
reason: failure
|
|
1037
|
-
};
|
|
1038
|
-
return {
|
|
1039
|
-
blockId,
|
|
1040
|
-
status: "running"
|
|
1041
|
-
};
|
|
2785
|
+
const runtime = entry?.runtime ?? null;
|
|
2786
|
+
const target = entry?.planned.target;
|
|
2787
|
+
const grafts = target?.kind === "existing" && target.deviceId !== null ? this.grafts.verdict(target.deviceId, target.features) : null;
|
|
2788
|
+
return verdictOf(blockId, entry, runtime !== null && this.unsynced.has(runtime), grafts);
|
|
1042
2789
|
}
|
|
1043
2790
|
};
|
|
1044
2791
|
//#endregion
|
|
@@ -1079,6 +2826,262 @@ var NodeDeadlineTimer = class {
|
|
|
1079
2826
|
}
|
|
1080
2827
|
};
|
|
1081
2828
|
//#endregion
|
|
2829
|
+
//#region src/builtins/composer/graft-host.ts
|
|
2830
|
+
/**
|
|
2831
|
+
* Grafts (D663): a method-serving native provider for a cap a customization
|
|
2832
|
+
* ADDS to a device the composer does not own — a battery on an HA pet feeder
|
|
2833
|
+
* that registers none. Without it the added cap has claimed values on the hub
|
|
2834
|
+
* but nothing answers its methods, so `getBindings` never lists it and a caller
|
|
2835
|
+
* of `battery.wakeForStream` finds no provider.
|
|
2836
|
+
*
|
|
2837
|
+
* - The provider is built ONLY from the cap's `composedMethods` (Task 1): an
|
|
2838
|
+
* `answer` resolves to its value, a `refuse` rejects naming its reason. A cap
|
|
2839
|
+
* with a served method that has no composed answer is refused here as it is
|
|
2840
|
+
* at plan time (`has-commands`, slice 1). `getStatus` reads the MERGED slice
|
|
2841
|
+
* (`readMerged`): the hub overlays it anyway (Task 7), but a direct call to
|
|
2842
|
+
* this runner must never answer `{}`.
|
|
2843
|
+
* - The context is `createContext(stableId, id)` on a FOREIGN device id: the
|
|
2844
|
+
* second use of the forked-runner native path D659 lists as unverified live
|
|
2845
|
+
* (D659 live item 1). Task 16 step 4.1 is its live proof.
|
|
2846
|
+
* - `ungraft` removes only what THIS host registered, through the context's
|
|
2847
|
+
* optional `unregisterNativeCap`. An older kernel without it leaves the graft
|
|
2848
|
+
* routable until the composer respawns — warned once per device, never
|
|
2849
|
+
* silent.
|
|
2850
|
+
*/
|
|
2851
|
+
/** The served methods of `def` that have no composed answer — non-empty means the cap cannot be grafted. */
|
|
2852
|
+
function unanswered(def) {
|
|
2853
|
+
return Object.entries(def.methods).filter(([name, m]) => m.systemOnly !== true && def.composedMethods?.[name] === void 0).map(([name]) => name);
|
|
2854
|
+
}
|
|
2855
|
+
function composedMethod(answer) {
|
|
2856
|
+
if (answer.kind === "answer") return async () => answer.value;
|
|
2857
|
+
return async () => {
|
|
2858
|
+
throw new Error(answer.reason);
|
|
2859
|
+
};
|
|
2860
|
+
}
|
|
2861
|
+
var GraftHost = class {
|
|
2862
|
+
ports;
|
|
2863
|
+
lookupCap;
|
|
2864
|
+
readMerged;
|
|
2865
|
+
/** One context per foreign device: `registerNativeCap` is per context, and so is its unregister. */
|
|
2866
|
+
contexts = /* @__PURE__ */ new Map();
|
|
2867
|
+
caps = /* @__PURE__ */ new Map();
|
|
2868
|
+
/** Devices already warned that this kernel cannot unregister a graft. */
|
|
2869
|
+
warnedNoUnregister = /* @__PURE__ */ new Set();
|
|
2870
|
+
constructor(ports, lookupCap, readMerged) {
|
|
2871
|
+
this.ports = ports;
|
|
2872
|
+
this.lookupCap = lookupCap;
|
|
2873
|
+
this.readMerged = readMerged;
|
|
2874
|
+
}
|
|
2875
|
+
graft(deviceId, stableId, capName) {
|
|
2876
|
+
if (this.grafted(deviceId).has(capName)) return { kind: "already" };
|
|
2877
|
+
const def = this.lookupCap(capName);
|
|
2878
|
+
if (def === null) return this.refuse(deviceId, capName, `unknown capability \`${capName}\``);
|
|
2879
|
+
const missing = unanswered(def);
|
|
2880
|
+
if (missing.length > 0) return this.refuse(deviceId, capName, `\`${capName}\` has ${missing.map((m) => `\`${m}\``).join(", ")} with no composed answer — a graft cannot serve it until commands arrive (slice 3)`);
|
|
2881
|
+
const ctx = this.contextFor(deviceId, stableId);
|
|
2882
|
+
try {
|
|
2883
|
+
ctx.registerNativeCap(def, this.providerFor(deviceId, def));
|
|
2884
|
+
} catch (err) {
|
|
2885
|
+
return this.refuse(deviceId, capName, `registerNativeCap threw — ${require_dist.errMsg(err)}`);
|
|
2886
|
+
}
|
|
2887
|
+
this.caps = new Map([...this.caps, [deviceId, new Set([...this.grafted(deviceId), capName])]]);
|
|
2888
|
+
this.ports.logger.info("graft: an added cap is served on a device the composer does not own", {
|
|
2889
|
+
tags: { deviceId },
|
|
2890
|
+
meta: {
|
|
2891
|
+
cap: capName,
|
|
2892
|
+
stableId
|
|
2893
|
+
}
|
|
2894
|
+
});
|
|
2895
|
+
return { kind: "grafted" };
|
|
2896
|
+
}
|
|
2897
|
+
ungraft(deviceId, capName, reason) {
|
|
2898
|
+
if (!this.grafted(deviceId).has(capName)) return;
|
|
2899
|
+
const ctx = this.contexts.get(deviceId);
|
|
2900
|
+
if (ctx === void 0) return;
|
|
2901
|
+
const outcome = this.unregister(ctx, deviceId, capName, reason);
|
|
2902
|
+
if (outcome === "threw") return;
|
|
2903
|
+
const remaining = new Set([...this.grafted(deviceId)].filter((c) => c !== capName));
|
|
2904
|
+
this.caps = new Map([...this.caps, [deviceId, remaining]]);
|
|
2905
|
+
if (outcome === "unregistered") this.ports.logger.info("graft removed", {
|
|
2906
|
+
tags: { deviceId },
|
|
2907
|
+
meta: {
|
|
2908
|
+
cap: capName,
|
|
2909
|
+
reason
|
|
2910
|
+
}
|
|
2911
|
+
});
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
2914
|
+
* I-3: a binding a PREVIOUS composer process left on `deviceId` — this one
|
|
2915
|
+
* never registered it. The kernel retracts it under the composer's addon id;
|
|
2916
|
+
* the hub drops only an entry the composer registered (rule 4). The caller has
|
|
2917
|
+
* read that the binding is the composer's.
|
|
2918
|
+
*/
|
|
2919
|
+
retractStale(deviceId, capName, reason) {
|
|
2920
|
+
if (this.grafted(deviceId).has(capName)) {
|
|
2921
|
+
this.ungraft(deviceId, capName, reason);
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2924
|
+
const ctx = this.contextFor(deviceId, `graft-retract:${deviceId}`);
|
|
2925
|
+
if (this.unregister(ctx, deviceId, capName, reason) === "unregistered") this.ports.logger.info("graft left by a previous composer process retracted", {
|
|
2926
|
+
tags: { deviceId },
|
|
2927
|
+
meta: {
|
|
2928
|
+
cap: capName,
|
|
2929
|
+
reason
|
|
2930
|
+
}
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
grafted(deviceId) {
|
|
2934
|
+
return this.caps.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
2935
|
+
}
|
|
2936
|
+
/** A throw keeps the graft known, so a later plan retries (M-2); an older kernel forgets it (warned once). */
|
|
2937
|
+
unregister(ctx, deviceId, capName, reason) {
|
|
2938
|
+
if (ctx.unregisterNativeCap === void 0) {
|
|
2939
|
+
if (!this.warnedNoUnregister.has(deviceId)) {
|
|
2940
|
+
this.warnedNoUnregister = new Set([...this.warnedNoUnregister, deviceId]);
|
|
2941
|
+
this.ports.logger.warn("graft: this kernel cannot unregister a native — the graft stays routable until the composer respawns", {
|
|
2942
|
+
tags: { deviceId },
|
|
2943
|
+
meta: {
|
|
2944
|
+
cap: capName,
|
|
2945
|
+
reason
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
return "unsupported";
|
|
2950
|
+
}
|
|
2951
|
+
try {
|
|
2952
|
+
ctx.unregisterNativeCap(capName);
|
|
2953
|
+
return "unregistered";
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
this.ports.logger.error("graft: unregistering the added cap threw — it stays routable and is retried by the next plan", {
|
|
2956
|
+
tags: { deviceId },
|
|
2957
|
+
meta: {
|
|
2958
|
+
cap: capName,
|
|
2959
|
+
reason,
|
|
2960
|
+
error: require_dist.errMsg(err)
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
return "threw";
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
contextFor(deviceId, stableId) {
|
|
2967
|
+
const existing = this.contexts.get(deviceId);
|
|
2968
|
+
if (existing !== void 0) return existing;
|
|
2969
|
+
const ctx = this.ports.createContext(stableId, deviceId);
|
|
2970
|
+
this.contexts = new Map([...this.contexts, [deviceId, ctx]]);
|
|
2971
|
+
return ctx;
|
|
2972
|
+
}
|
|
2973
|
+
providerFor(deviceId, def) {
|
|
2974
|
+
const answers = Object.entries(def.composedMethods ?? {}).map(([name, answer]) => [name, composedMethod(answer)]);
|
|
2975
|
+
const withStatus = def.status === void 0 && def.runtimeState === void 0 ? [] : [["getStatus", async () => this.readMerged(deviceId, def.name)]];
|
|
2976
|
+
return Object.fromEntries([...answers, ...withStatus]);
|
|
2977
|
+
}
|
|
2978
|
+
refuse(deviceId, capName, reason) {
|
|
2979
|
+
this.ports.logger.error("graft refused — the added cap has no method-serving native", {
|
|
2980
|
+
tags: { deviceId },
|
|
2981
|
+
meta: {
|
|
2982
|
+
cap: capName,
|
|
2983
|
+
reason
|
|
2984
|
+
}
|
|
2985
|
+
});
|
|
2986
|
+
return {
|
|
2987
|
+
kind: "refused",
|
|
2988
|
+
reason
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
};
|
|
2992
|
+
//#endregion
|
|
2993
|
+
//#region src/builtins/composer/held-claims.ts
|
|
2994
|
+
function slot(deviceId, cap, owner) {
|
|
2995
|
+
return `${owner}@${deviceId}#${cap}`;
|
|
2996
|
+
}
|
|
2997
|
+
function without(set, key) {
|
|
2998
|
+
return new Set([...set].filter((k) => k !== key));
|
|
2999
|
+
}
|
|
3000
|
+
var HeldClaims = class {
|
|
3001
|
+
held = /* @__PURE__ */ new Set();
|
|
3002
|
+
claiming = /* @__PURE__ */ new Set();
|
|
3003
|
+
/** The `replace` claims the hub holds before this process wrote any (the boot seed). */
|
|
3004
|
+
seed(claims) {
|
|
3005
|
+
const replaced = claims.filter((c) => c.mode === "replace");
|
|
3006
|
+
this.held = new Set([...this.held, ...replaced.map((c) => slot(c.deviceId, c.capName, c.owner))]);
|
|
3007
|
+
}
|
|
3008
|
+
apply(deviceId, cap, owner, event) {
|
|
3009
|
+
const k = slot(deviceId, cap, owner);
|
|
3010
|
+
if (event === "claiming") {
|
|
3011
|
+
this.claiming = new Set([...this.claiming, k]);
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
this.claiming = without(this.claiming, k);
|
|
3015
|
+
if (event === "held") this.held = new Set([...this.held, k]);
|
|
3016
|
+
else if (event === "free") this.held = without(this.held, k);
|
|
3017
|
+
}
|
|
3018
|
+
/** `device.state-changed` of `cap` is NOT the native for `owner`'s self-reads: its claim is held, or being asked for. */
|
|
3019
|
+
shadowsNative(deviceId, cap, owner) {
|
|
3020
|
+
const k = slot(deviceId, cap, owner);
|
|
3021
|
+
return this.held.has(k) || this.claiming.has(k);
|
|
3022
|
+
}
|
|
3023
|
+
};
|
|
3024
|
+
//#endregion
|
|
3025
|
+
//#region src/builtins/composer/source-readings.ts
|
|
3026
|
+
var DEVICE_STATUS_CAP = "device-status";
|
|
3027
|
+
var NATIVE_SUFFIX = "@native";
|
|
3028
|
+
var NO_CAPS = [];
|
|
3029
|
+
function entryKey(entry) {
|
|
3030
|
+
return entry.view === "native" ? `${entry.cap}${NATIVE_SUFFIX}` : entry.cap;
|
|
3031
|
+
}
|
|
3032
|
+
function entryOf(key) {
|
|
3033
|
+
return key.endsWith("@native") ? {
|
|
3034
|
+
cap: key.slice(0, -7),
|
|
3035
|
+
view: "native"
|
|
3036
|
+
} : {
|
|
3037
|
+
cap: key,
|
|
3038
|
+
view: "effective"
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
function entriesOf(request) {
|
|
3042
|
+
return [...request.caps.map((cap) => ({
|
|
3043
|
+
cap,
|
|
3044
|
+
view: "effective"
|
|
3045
|
+
})), ...(request.nativeCaps ?? NO_CAPS).map((cap) => ({
|
|
3046
|
+
cap,
|
|
3047
|
+
view: "native"
|
|
3048
|
+
}))];
|
|
3049
|
+
}
|
|
3050
|
+
function valueAtPath(slice, path) {
|
|
3051
|
+
return path.split(".").reduce((node, segment) => require_device_state_changed.isRecord(node) ? node[segment] : void 0, slice);
|
|
3052
|
+
}
|
|
3053
|
+
function onlineOf(slice) {
|
|
3054
|
+
const online = slice["online"];
|
|
3055
|
+
return typeof online === "boolean" ? online : null;
|
|
3056
|
+
}
|
|
3057
|
+
function findRow(read, stableId) {
|
|
3058
|
+
if (read.kind === "unreadable") return {
|
|
3059
|
+
kind: "unknown",
|
|
3060
|
+
error: read.error
|
|
3061
|
+
};
|
|
3062
|
+
const row = read.rows.find((r) => r.stableId === stableId);
|
|
3063
|
+
return row === void 0 ? { kind: "absent" } : {
|
|
3064
|
+
kind: "found",
|
|
3065
|
+
deviceId: row.id,
|
|
3066
|
+
type: row.type
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
function resolutionOf(source) {
|
|
3070
|
+
if (source.missingReason !== null) return {
|
|
3071
|
+
kind: "missing",
|
|
3072
|
+
reason: source.missingReason
|
|
3073
|
+
};
|
|
3074
|
+
if (source.deviceId !== null && source.type !== null) return {
|
|
3075
|
+
kind: "found",
|
|
3076
|
+
deviceId: source.deviceId,
|
|
3077
|
+
type: source.type
|
|
3078
|
+
};
|
|
3079
|
+
return {
|
|
3080
|
+
kind: "unknown",
|
|
3081
|
+
error: source.indexError ?? "the device index has not been read for it yet"
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
//#endregion
|
|
1082
3085
|
//#region src/builtins/composer/source-tracker.ts
|
|
1083
3086
|
/**
|
|
1084
3087
|
* What a composition's sources currently say, per source device and per cap
|
|
@@ -1120,29 +3123,35 @@ var NodeDeadlineTimer = class {
|
|
|
1120
3123
|
* It is served to nobody else — no cap method, no getter for another addon
|
|
1121
3124
|
* reads this map; the composed device's slice is the only output. A consumer
|
|
1122
3125
|
* that wants a source's value asks the addon that owns that source.
|
|
3126
|
+
*
|
|
3127
|
+
* Slice 2 (D663): a cap is read in one of two VIEWS. The `effective` view is
|
|
3128
|
+
* what every consumer sees. The `native` view is what the provider wrote — the
|
|
3129
|
+
* shadow under a `replace` claim — and it is how a composition reads its OWN
|
|
3130
|
+
* target's fields without seeing its own output (rule 6, no self-cycle). The hub
|
|
3131
|
+
* emits the shadow (`device.native-shadow-changed`, owner-only) only for a cap
|
|
3132
|
+
* under a claim, and for an unclaimed cap `device.state-changed` IS the native;
|
|
3133
|
+
* so a native-view cap under the block's OWN claim ignores `device.state-changed`,
|
|
3134
|
+
* and one it does not takes it. What is held is what LANDED (`claimEvent`, the
|
|
3135
|
+
* boot seed), never what a plan wants (C-1); while the block's claim RPC is in
|
|
3136
|
+
* flight, `device.state-changed` is neither (N-1). Every claim or release reply
|
|
3137
|
+
* re-reads the native once — the read that closes the window between the RPC
|
|
3138
|
+
* and its answer (N-4). The target ROW is resolved through the same path as a
|
|
3139
|
+
* source (`resolve`): one list read, two agreeing absent reads to call it
|
|
3140
|
+
* missing, sticky until an operator reset.
|
|
3141
|
+
*
|
|
3142
|
+
* An unavailability is CONFIRMED only when it is an answer (D49, fix round 2):
|
|
3143
|
+
* a GONE row (two reads agreed), a cap the provider unregistered that a
|
|
3144
|
+
* bindings re-read agrees is unbound, a field the source reported without. A
|
|
3145
|
+
* failed read (`unread`, retried by the next `sync` — the converge is the
|
|
3146
|
+
* retry), a first read that finds the cap unbound, an empty read, a source not
|
|
3147
|
+
* tracked yet: unconfirmed. Only a confirmed one lets a `release`-mode field
|
|
3148
|
+
* hand its claim back; an unconfirmed one holds it on its last value.
|
|
1123
3149
|
*/
|
|
1124
|
-
var DEVICE_STATUS_CAP = "device-status";
|
|
1125
|
-
function valueAtPath(slice, path) {
|
|
1126
|
-
return path.split(".").reduce((node, segment) => require_device_state_changed.isRecord(node) ? node[segment] : void 0, slice);
|
|
1127
|
-
}
|
|
1128
|
-
function onlineOf(slice) {
|
|
1129
|
-
const online = slice["online"];
|
|
1130
|
-
return typeof online === "boolean" ? online : null;
|
|
1131
|
-
}
|
|
1132
|
-
function findRow(read, stableId) {
|
|
1133
|
-
if (read.kind === "unreadable") return {
|
|
1134
|
-
kind: "unknown",
|
|
1135
|
-
error: read.error
|
|
1136
|
-
};
|
|
1137
|
-
const row = read.rows.find((r) => r.stableId === stableId);
|
|
1138
|
-
return row === void 0 ? { kind: "absent" } : {
|
|
1139
|
-
kind: "found",
|
|
1140
|
-
deviceId: row.id
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
3150
|
var SourceTracker = class {
|
|
1144
3151
|
ports;
|
|
1145
3152
|
sources = /* @__PURE__ */ new Map();
|
|
3153
|
+
/** The claims the hub holds — what LANDED, never what a plan wants (C-1). */
|
|
3154
|
+
held = new HeldClaims();
|
|
1146
3155
|
/** The one chain every entry point joins (R3); the pattern is `BlockSupervisor.inFlight`. */
|
|
1147
3156
|
chain = Promise.resolve();
|
|
1148
3157
|
constructor(ports) {
|
|
@@ -1156,9 +3165,67 @@ var SourceTracker = class {
|
|
|
1156
3165
|
sync(requests, reset) {
|
|
1157
3166
|
return this.serialized(() => this.syncNow(requests, reset));
|
|
1158
3167
|
}
|
|
3168
|
+
/**
|
|
3169
|
+
* Resolve a ref against the device index through the same path as a source
|
|
3170
|
+
* (D663 rule 1): one list read; two agreeing absent reads make it `missing`,
|
|
3171
|
+
* sticky until `reset` (an operator edit or restart). A source already
|
|
3172
|
+
* resolved is answered from memory; one whose index read FAILED is read
|
|
3173
|
+
* again. The bare source stays tracked, so a later `sync` that names it keeps
|
|
3174
|
+
* its resolution and reads only the caps it asks for.
|
|
3175
|
+
*/
|
|
3176
|
+
resolve(ref, reset) {
|
|
3177
|
+
return this.serialized(async () => {
|
|
3178
|
+
const k = require_dist.compositionSourceKey(ref);
|
|
3179
|
+
const prior = this.sources.get(k);
|
|
3180
|
+
const before = reset ? void 0 : prior;
|
|
3181
|
+
if (before !== void 0 && (before.deviceId !== null || before.missingReason !== null)) return resolutionOf(before);
|
|
3182
|
+
const after = await this.hydrate({
|
|
3183
|
+
ref,
|
|
3184
|
+
caps: []
|
|
3185
|
+
}, before, /* @__PURE__ */ new Map());
|
|
3186
|
+
const kept = prior !== void 0 && after.deviceId !== null && prior.deviceId === after.deviceId ? {
|
|
3187
|
+
...after,
|
|
3188
|
+
caps: prior.caps,
|
|
3189
|
+
online: prior.online
|
|
3190
|
+
} : after;
|
|
3191
|
+
this.sources = new Map([...this.sources, [k, kept]]);
|
|
3192
|
+
return resolutionOf(kept);
|
|
3193
|
+
});
|
|
3194
|
+
}
|
|
3195
|
+
/** The claims the hub held at boot (the claims index seed). Nothing is read: no native self-read is tracked before it. */
|
|
3196
|
+
seedClaims(claims) {
|
|
3197
|
+
this.held.seed(claims);
|
|
3198
|
+
}
|
|
3199
|
+
/**
|
|
3200
|
+
* A claim RPC of `owner` on `(deviceId, cap)`: dispatched (`claiming`), or
|
|
3201
|
+
* answered — the hub holds it (`held`), holds none of it (`free`), or said
|
|
3202
|
+
* nothing that changes it (`unsettled`). Every ANSWER re-reads the owner's
|
|
3203
|
+
* native-view readings of that cap ONCE: while the RPC was in flight (or a
|
|
3204
|
+
* release was being applied) the provider's writes arrived as a
|
|
3205
|
+
* `device.state-changed` the tracker could not take as the native.
|
|
3206
|
+
*/
|
|
3207
|
+
claimEvent(deviceId, cap, owner, event) {
|
|
3208
|
+
return this.serialized(async () => {
|
|
3209
|
+
this.held.apply(deviceId, cap, owner, event);
|
|
3210
|
+
if (event === "claiming") return /* @__PURE__ */ new Set();
|
|
3211
|
+
return this.rereadNative(deviceId, cap, owner);
|
|
3212
|
+
});
|
|
3213
|
+
}
|
|
3214
|
+
/** The last settled resolution of a ref; `unknown` when it was never resolved. Synchronous, from memory. */
|
|
3215
|
+
resolution(ref) {
|
|
3216
|
+
const source = this.sources.get(require_dist.compositionSourceKey(ref));
|
|
3217
|
+
return source === void 0 ? {
|
|
3218
|
+
kind: "unknown",
|
|
3219
|
+
error: `source \`${require_dist.compositionSourceKey(ref)}\` is not tracked`
|
|
3220
|
+
} : resolutionOf(source);
|
|
3221
|
+
}
|
|
1159
3222
|
onStateChanged(deviceId, capName, slice) {
|
|
1160
3223
|
return this.serialized(async () => this.applyStateChanged(deviceId, capName, slice));
|
|
1161
3224
|
}
|
|
3225
|
+
/** `device.native-shadow-changed`: the provider's own slice of a cap under a claim (owner-only, D224). */
|
|
3226
|
+
onNativeChanged(deviceId, capName, native) {
|
|
3227
|
+
return this.serialized(async () => this.applyNativeChanged(deviceId, capName, native));
|
|
3228
|
+
}
|
|
1162
3229
|
onDeviceUnregistered(deviceId) {
|
|
1163
3230
|
return this.serialized(() => this.confirmUnregistered(deviceId));
|
|
1164
3231
|
}
|
|
@@ -1178,7 +3245,9 @@ var SourceTracker = class {
|
|
|
1178
3245
|
const prior = merged.get(k);
|
|
1179
3246
|
merged.set(k, {
|
|
1180
3247
|
ref: request.ref,
|
|
1181
|
-
caps: [...new Set([...prior?.caps ?? [], ...request.caps])]
|
|
3248
|
+
caps: [...new Set([...prior?.caps ?? [], ...request.caps])],
|
|
3249
|
+
nativeCaps: [...new Set([...prior?.nativeCaps ?? [], ...request.nativeCaps ?? NO_CAPS])],
|
|
3250
|
+
...(request.nativeOwner ?? prior?.nativeOwner) === void 0 ? {} : { nativeOwner: request.nativeOwner ?? prior?.nativeOwner }
|
|
1182
3251
|
});
|
|
1183
3252
|
}
|
|
1184
3253
|
const lists = /* @__PURE__ */ new Map();
|
|
@@ -1188,12 +3257,15 @@ var SourceTracker = class {
|
|
|
1188
3257
|
const before = reset.has(k) ? void 0 : this.sources.get(k);
|
|
1189
3258
|
const after = await this.hydrate(request, before, lists);
|
|
1190
3259
|
next.set(k, after);
|
|
1191
|
-
for (const
|
|
3260
|
+
for (const entry of entriesOf(request)) {
|
|
3261
|
+
const key = entryKey(entry);
|
|
3262
|
+
if (before?.caps.get(key) !== after.caps.get(key) || before?.missingReason !== after.missingReason) changed.add(require_dist.compositionSliceKey(request.ref, entry.cap));
|
|
3263
|
+
}
|
|
1192
3264
|
}
|
|
1193
3265
|
this.sources = next;
|
|
1194
3266
|
return changed;
|
|
1195
3267
|
}
|
|
1196
|
-
read(read) {
|
|
3268
|
+
read(read, view = "effective") {
|
|
1197
3269
|
const label = `${require_dist.compositionSourceKey(read.source)} ${read.cap}.${read.fieldPath}`;
|
|
1198
3270
|
const tracked = this.sources.get(require_dist.compositionSourceKey(read.source));
|
|
1199
3271
|
if (tracked === void 0) return {
|
|
@@ -1206,22 +3278,30 @@ var SourceTracker = class {
|
|
|
1206
3278
|
reason: tracked.missingReason,
|
|
1207
3279
|
fatal: true
|
|
1208
3280
|
};
|
|
1209
|
-
const cap = tracked.caps.get(
|
|
3281
|
+
const cap = tracked.caps.get(entryKey({
|
|
3282
|
+
cap: read.cap,
|
|
3283
|
+
view
|
|
3284
|
+
}));
|
|
1210
3285
|
if (cap === void 0) return {
|
|
1211
3286
|
state: "unavailable",
|
|
1212
3287
|
reason: `${label}: not tracked yet`,
|
|
1213
3288
|
fatal: false
|
|
1214
3289
|
};
|
|
1215
|
-
if (cap.kind
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
3290
|
+
if (cap.kind !== "slice") {
|
|
3291
|
+
const confirmed = cap.kind === "none" && cap.confirmed;
|
|
3292
|
+
return {
|
|
3293
|
+
state: "unavailable",
|
|
3294
|
+
reason: `${label}: ${cap.reason}`,
|
|
3295
|
+
fatal: false,
|
|
3296
|
+
confirmed
|
|
3297
|
+
};
|
|
3298
|
+
}
|
|
1220
3299
|
const value = require_dist.toExpressionValue(valueAtPath(cap.slice, read.fieldPath));
|
|
1221
3300
|
if (value === void 0) return {
|
|
1222
3301
|
state: "unavailable",
|
|
1223
3302
|
reason: `${label}: the field is absent or not a scalar`,
|
|
1224
|
-
fatal: false
|
|
3303
|
+
fatal: false,
|
|
3304
|
+
confirmed: true
|
|
1225
3305
|
};
|
|
1226
3306
|
if (cap.staleReason !== null) return {
|
|
1227
3307
|
state: "stale",
|
|
@@ -1244,22 +3324,28 @@ var SourceTracker = class {
|
|
|
1244
3324
|
for (const [k, source] of this.sources) {
|
|
1245
3325
|
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
1246
3326
|
let updated = source;
|
|
1247
|
-
if (capName ===
|
|
3327
|
+
if (capName === "device-status") {
|
|
1248
3328
|
const online = onlineOf(slice);
|
|
1249
3329
|
if (online !== source.online) {
|
|
1250
3330
|
updated = {
|
|
1251
3331
|
...updated,
|
|
1252
3332
|
online
|
|
1253
3333
|
};
|
|
1254
|
-
for (const
|
|
3334
|
+
for (const key of source.caps.keys()) changed.add(require_dist.compositionSliceKey(source.ref, entryOf(key).cap));
|
|
1255
3335
|
}
|
|
1256
3336
|
}
|
|
1257
|
-
const
|
|
1258
|
-
|
|
3337
|
+
const views = source.nativeOwner !== null && this.held.shadowsNative(deviceId, capName, source.nativeOwner) ? ["effective"] : ["effective", "native"];
|
|
3338
|
+
for (const view of views) {
|
|
3339
|
+
const key = entryKey({
|
|
3340
|
+
cap: capName,
|
|
3341
|
+
view
|
|
3342
|
+
});
|
|
3343
|
+
const current = source.caps.get(key);
|
|
3344
|
+
if (current === void 0) continue;
|
|
1259
3345
|
if (current.kind === "unbound") this.logHealed(deviceId, capName, "device.state-changed");
|
|
1260
3346
|
updated = {
|
|
1261
3347
|
...updated,
|
|
1262
|
-
caps: new Map([...updated.caps, [
|
|
3348
|
+
caps: new Map([...updated.caps, [key, {
|
|
1263
3349
|
kind: "slice",
|
|
1264
3350
|
slice,
|
|
1265
3351
|
staleReason: null
|
|
@@ -1272,6 +3358,31 @@ var SourceTracker = class {
|
|
|
1272
3358
|
this.sources = next;
|
|
1273
3359
|
return changed;
|
|
1274
3360
|
}
|
|
3361
|
+
applyNativeChanged(deviceId, capName, native) {
|
|
3362
|
+
const changed = /* @__PURE__ */ new Set();
|
|
3363
|
+
const next = new Map(this.sources);
|
|
3364
|
+
const key = entryKey({
|
|
3365
|
+
cap: capName,
|
|
3366
|
+
view: "native"
|
|
3367
|
+
});
|
|
3368
|
+
for (const [k, source] of this.sources) {
|
|
3369
|
+
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
3370
|
+
const current = source.caps.get(key);
|
|
3371
|
+
if (current === void 0) continue;
|
|
3372
|
+
if (current.kind === "unbound") this.logHealed(deviceId, capName, "device.native-shadow-changed");
|
|
3373
|
+
next.set(k, {
|
|
3374
|
+
...source,
|
|
3375
|
+
caps: new Map([...source.caps, [key, {
|
|
3376
|
+
kind: "slice",
|
|
3377
|
+
slice: native,
|
|
3378
|
+
staleReason: null
|
|
3379
|
+
}]])
|
|
3380
|
+
});
|
|
3381
|
+
changed.add(require_dist.compositionSliceKey(source.ref, capName));
|
|
3382
|
+
}
|
|
3383
|
+
this.sources = next;
|
|
3384
|
+
return changed;
|
|
3385
|
+
}
|
|
1275
3386
|
async confirmUnregistered(deviceId) {
|
|
1276
3387
|
const changed = /* @__PURE__ */ new Set();
|
|
1277
3388
|
for (const [k, source] of this.sources) {
|
|
@@ -1291,8 +3402,9 @@ var SourceTracker = class {
|
|
|
1291
3402
|
const next = resolution.kind === "missing" ? {
|
|
1292
3403
|
...source,
|
|
1293
3404
|
deviceId: null,
|
|
3405
|
+
type: null,
|
|
1294
3406
|
missingReason: `source \`${k}\` (device ${deviceId}) was removed — two reads agreed`
|
|
1295
|
-
} : await this.rereadUnderNewId(source, resolution
|
|
3407
|
+
} : await this.rereadUnderNewId(source, resolution);
|
|
1296
3408
|
this.ports.logger.warn(resolution.kind === "missing" ? "composition source removed — its fields are unavailable until the operator acts" : "composition source re-registered under a new id — re-read under it", {
|
|
1297
3409
|
tags: { deviceId },
|
|
1298
3410
|
meta: {
|
|
@@ -1301,43 +3413,61 @@ var SourceTracker = class {
|
|
|
1301
3413
|
}
|
|
1302
3414
|
});
|
|
1303
3415
|
this.sources = new Map([...this.sources, [k, next]]);
|
|
1304
|
-
for (const
|
|
3416
|
+
for (const key of source.caps.keys()) changed.add(require_dist.compositionSliceKey(source.ref, entryOf(key).cap));
|
|
1305
3417
|
}
|
|
1306
3418
|
return changed;
|
|
1307
3419
|
}
|
|
1308
3420
|
async applyBindingsChanged(deviceId, capName, reason) {
|
|
1309
3421
|
const changed = /* @__PURE__ */ new Set();
|
|
3422
|
+
let confirm = null;
|
|
1310
3423
|
for (const [k, source] of this.sources) {
|
|
1311
3424
|
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
1312
|
-
const
|
|
3425
|
+
const entries = [{
|
|
3426
|
+
cap: capName,
|
|
3427
|
+
view: "effective"
|
|
3428
|
+
}, {
|
|
3429
|
+
cap: capName,
|
|
3430
|
+
view: "native"
|
|
3431
|
+
}];
|
|
1313
3432
|
let updated = source;
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
3433
|
+
let healed = false;
|
|
3434
|
+
for (const entry of entries) {
|
|
3435
|
+
const key = entryKey(entry);
|
|
3436
|
+
const current = source.caps.get(key);
|
|
3437
|
+
if (current === void 0) continue;
|
|
3438
|
+
if (reason === "native-unregistered") {
|
|
3439
|
+
confirm ??= (await this.readBindings(deviceId, [capName])).get(capName) ?? "unknown";
|
|
3440
|
+
const reading = confirm === "bound" ? await this.readCap(deviceId, entry, "bound", current) : {
|
|
1317
3441
|
kind: "none",
|
|
1318
|
-
reason: `the provider unregistered \`${capName}\` — no value until it registers again`
|
|
3442
|
+
reason: confirm === "unbound" ? `the provider unregistered \`${capName}\` — a bindings re-read agreed; no value until it registers again` : `the provider unregistered \`${capName}\` — not confirmed (the bindings re-read failed); no value, nothing released`,
|
|
3443
|
+
confirmed: confirm === "unbound"
|
|
1319
3444
|
};
|
|
1320
3445
|
updated = {
|
|
1321
3446
|
...updated,
|
|
1322
|
-
caps: new Map([...updated.caps, [
|
|
3447
|
+
caps: new Map([...updated.caps, [key, reading]])
|
|
1323
3448
|
};
|
|
3449
|
+
continue;
|
|
1324
3450
|
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
if (reading !== null) updated = {
|
|
3451
|
+
const reading = await this.readCap(deviceId, entry, "bound", current);
|
|
3452
|
+
updated = {
|
|
1328
3453
|
...updated,
|
|
1329
|
-
caps: new Map([...updated.caps, [
|
|
3454
|
+
caps: new Map([...updated.caps, [key, reading]])
|
|
1330
3455
|
};
|
|
1331
|
-
if (current
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
3456
|
+
if (current.kind === "unbound" && reading.kind !== "unbound") healed = true;
|
|
3457
|
+
}
|
|
3458
|
+
if (healed) this.logHealed(deviceId, capName, reason);
|
|
3459
|
+
if (reason !== "native-unregistered" && capName === "device-status") {
|
|
3460
|
+
const reading = updated.caps.get(entryKey({
|
|
3461
|
+
cap: capName,
|
|
3462
|
+
view: "effective"
|
|
3463
|
+
}));
|
|
3464
|
+
const online = reading === void 0 ? await this.readOnline(deviceId) : reading.kind === "slice" ? onlineOf(reading.slice) : null;
|
|
3465
|
+
if (online !== null && online !== source.online) {
|
|
3466
|
+
updated = {
|
|
3467
|
+
...updated,
|
|
3468
|
+
online
|
|
3469
|
+
};
|
|
3470
|
+
for (const key of source.caps.keys()) changed.add(require_dist.compositionSliceKey(source.ref, entryOf(key).cap));
|
|
1341
3471
|
}
|
|
1342
3472
|
}
|
|
1343
3473
|
if (updated === source) continue;
|
|
@@ -1347,26 +3477,33 @@ var SourceTracker = class {
|
|
|
1347
3477
|
return changed;
|
|
1348
3478
|
}
|
|
1349
3479
|
/** A source found under a NEW id is a re-registration: read it there now (R7c). */
|
|
1350
|
-
async rereadUnderNewId(source,
|
|
3480
|
+
async rereadUnderNewId(source, found) {
|
|
1351
3481
|
const caps = /* @__PURE__ */ new Map();
|
|
1352
|
-
for (const [
|
|
1353
|
-
const online = await this.readOnline(deviceId) ?? source.online;
|
|
3482
|
+
for (const [key, prior] of source.caps) caps.set(key, await this.readCap(found.deviceId, entryOf(key), "bound", prior));
|
|
3483
|
+
const online = await this.readOnline(found.deviceId) ?? source.online;
|
|
1354
3484
|
return {
|
|
1355
3485
|
...source,
|
|
1356
|
-
deviceId,
|
|
3486
|
+
deviceId: found.deviceId,
|
|
3487
|
+
type: found.type,
|
|
1357
3488
|
online,
|
|
1358
3489
|
caps
|
|
1359
3490
|
};
|
|
1360
3491
|
}
|
|
1361
3492
|
async hydrate(request, before, lists) {
|
|
3493
|
+
const entries = entriesOf(request);
|
|
1362
3494
|
if (before !== void 0 && before.missingReason !== null) return before;
|
|
1363
3495
|
if (before !== void 0 && before.deviceId !== null) {
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
|
|
3496
|
+
const toRead = entries.filter((entry) => {
|
|
3497
|
+
const held = before.caps.get(entryKey(entry));
|
|
3498
|
+
return held === void 0 || held.kind === "unread";
|
|
3499
|
+
});
|
|
3500
|
+
const nativeOwner = request.nativeOwner ?? null;
|
|
3501
|
+
if (toRead.length === 0 && entries.length === before.caps.size && nativeOwner === before.nativeOwner) return before;
|
|
3502
|
+
const caps = await this.readCaps(before.deviceId, entries, before.caps, toRead);
|
|
1367
3503
|
return {
|
|
1368
3504
|
...before,
|
|
1369
|
-
caps
|
|
3505
|
+
caps,
|
|
3506
|
+
nativeOwner
|
|
1370
3507
|
};
|
|
1371
3508
|
}
|
|
1372
3509
|
const k = require_dist.compositionSourceKey(request.ref);
|
|
@@ -1378,9 +3515,12 @@ var SourceTracker = class {
|
|
|
1378
3515
|
return {
|
|
1379
3516
|
ref: request.ref,
|
|
1380
3517
|
deviceId: null,
|
|
3518
|
+
type: null,
|
|
1381
3519
|
missingReason: `source \`${k}\` does not exist — two reads agreed`,
|
|
3520
|
+
indexError: null,
|
|
1382
3521
|
online: null,
|
|
1383
|
-
caps: /* @__PURE__ */ new Map()
|
|
3522
|
+
caps: /* @__PURE__ */ new Map(),
|
|
3523
|
+
nativeOwner: request.nativeOwner ?? null
|
|
1384
3524
|
};
|
|
1385
3525
|
}
|
|
1386
3526
|
if (resolution.kind === "unknown") {
|
|
@@ -1391,34 +3531,41 @@ var SourceTracker = class {
|
|
|
1391
3531
|
return {
|
|
1392
3532
|
ref: request.ref,
|
|
1393
3533
|
deviceId: null,
|
|
3534
|
+
type: null,
|
|
1394
3535
|
missingReason: null,
|
|
3536
|
+
indexError: resolution.error,
|
|
1395
3537
|
online: null,
|
|
1396
|
-
caps: new Map(
|
|
1397
|
-
kind: "
|
|
3538
|
+
caps: new Map(entries.map((entry) => [entryKey(entry), {
|
|
3539
|
+
kind: "unread",
|
|
1398
3540
|
reason: `the source could not be resolved: ${resolution.error}`
|
|
1399
|
-
}]))
|
|
3541
|
+
}])),
|
|
3542
|
+
nativeOwner: request.nativeOwner ?? null
|
|
1400
3543
|
};
|
|
1401
3544
|
}
|
|
1402
|
-
const deviceId = resolution.deviceId;
|
|
1403
3545
|
const known = before?.caps ?? /* @__PURE__ */ new Map();
|
|
1404
|
-
const toRead =
|
|
1405
|
-
const caps = await this.readCaps(deviceId,
|
|
1406
|
-
const online = before?.online ?? await this.readOnline(deviceId);
|
|
3546
|
+
const toRead = entries.filter((entry) => known.get(entryKey(entry))?.kind !== "slice");
|
|
3547
|
+
const caps = await this.readCaps(resolution.deviceId, entries, known, toRead);
|
|
3548
|
+
const online = before?.online ?? await this.readOnline(resolution.deviceId);
|
|
1407
3549
|
return {
|
|
1408
3550
|
ref: request.ref,
|
|
1409
|
-
deviceId,
|
|
3551
|
+
deviceId: resolution.deviceId,
|
|
3552
|
+
type: resolution.type,
|
|
1410
3553
|
missingReason: null,
|
|
3554
|
+
indexError: null,
|
|
1411
3555
|
online,
|
|
1412
|
-
caps
|
|
3556
|
+
caps,
|
|
3557
|
+
nativeOwner: request.nativeOwner ?? null
|
|
1413
3558
|
};
|
|
1414
3559
|
}
|
|
1415
|
-
/** Read `toRead` (bindings first, then slices); every other requested
|
|
3560
|
+
/** Read `toRead` (bindings first, then slices); every other requested entry keeps what `known` holds. */
|
|
1416
3561
|
async readCaps(deviceId, requested, known, toRead) {
|
|
1417
|
-
const bound = toRead.length > 0 ? await this.readBindings(deviceId, toRead) : /* @__PURE__ */ new Map();
|
|
3562
|
+
const bound = toRead.length > 0 ? await this.readBindings(deviceId, [...new Set(toRead.map((e) => e.cap))]) : /* @__PURE__ */ new Map();
|
|
3563
|
+
const reading = new Set(toRead.map(entryKey));
|
|
1418
3564
|
const caps = /* @__PURE__ */ new Map();
|
|
1419
|
-
for (const
|
|
1420
|
-
const
|
|
1421
|
-
|
|
3565
|
+
for (const entry of requested) {
|
|
3566
|
+
const key = entryKey(entry);
|
|
3567
|
+
const prior = known.get(key);
|
|
3568
|
+
caps.set(key, prior !== void 0 && !reading.has(key) ? prior : await this.readCap(deviceId, entry, bound.get(entry.cap) ?? "unknown", prior));
|
|
1422
3569
|
}
|
|
1423
3570
|
return caps;
|
|
1424
3571
|
}
|
|
@@ -1433,7 +3580,10 @@ var SourceTracker = class {
|
|
|
1433
3580
|
});
|
|
1434
3581
|
return second;
|
|
1435
3582
|
}
|
|
1436
|
-
return second.kind === "absent" ? {
|
|
3583
|
+
return second.kind === "absent" ? {
|
|
3584
|
+
kind: "missing",
|
|
3585
|
+
reason: `source \`${require_dist.compositionSourceKey(ref)}\` does not exist`
|
|
3586
|
+
} : second;
|
|
1437
3587
|
}
|
|
1438
3588
|
/**
|
|
1439
3589
|
* One bindings read. A second one would not confirm anything: back to back
|
|
@@ -1447,13 +3597,15 @@ var SourceTracker = class {
|
|
|
1447
3597
|
return [c, read.caps.has(c) ? "bound" : "unbound"];
|
|
1448
3598
|
}));
|
|
1449
3599
|
}
|
|
1450
|
-
async readCap(deviceId,
|
|
3600
|
+
async readCap(deviceId, entry, binding, prior) {
|
|
3601
|
+
const { cap, view } = entry;
|
|
1451
3602
|
if (binding === "unbound") {
|
|
1452
3603
|
const reason = `capability \`${cap}\` is not bound on device ${deviceId} — waiting for \`native-registered\``;
|
|
1453
3604
|
if (prior?.kind !== "unbound") this.ports.logger.warn("composition source: a requested capability is not bound — the field is unavailable until it registers", {
|
|
1454
3605
|
tags: { deviceId },
|
|
1455
3606
|
meta: {
|
|
1456
3607
|
cap,
|
|
3608
|
+
view,
|
|
1457
3609
|
reason
|
|
1458
3610
|
}
|
|
1459
3611
|
});
|
|
@@ -1462,34 +3614,67 @@ var SourceTracker = class {
|
|
|
1462
3614
|
reason
|
|
1463
3615
|
};
|
|
1464
3616
|
}
|
|
1465
|
-
const read = await this.ports.readSlice(deviceId, cap);
|
|
3617
|
+
const read = await this.ports.readSlice(deviceId, cap, view);
|
|
1466
3618
|
if (read.kind === "slice") return {
|
|
1467
3619
|
kind: "slice",
|
|
1468
3620
|
slice: read.slice,
|
|
1469
3621
|
staleReason: null
|
|
1470
3622
|
};
|
|
1471
|
-
if (read.kind === "none")
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
3623
|
+
if (read.kind === "none") {
|
|
3624
|
+
if (prior?.kind === "slice") return {
|
|
3625
|
+
...prior,
|
|
3626
|
+
staleReason: `a re-read found no \`${cap}\` report — the last value is kept`
|
|
3627
|
+
};
|
|
3628
|
+
return {
|
|
3629
|
+
kind: "none",
|
|
3630
|
+
reason: `the source has not reported \`${cap}\` yet`,
|
|
3631
|
+
confirmed: false
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
1478
3634
|
this.ports.logger.warn("composition source: a slice read failed — the last value is kept", {
|
|
1479
3635
|
tags: { deviceId },
|
|
1480
3636
|
meta: {
|
|
1481
3637
|
cap,
|
|
3638
|
+
view,
|
|
1482
3639
|
error: read.error
|
|
1483
3640
|
}
|
|
1484
3641
|
});
|
|
1485
|
-
|
|
3642
|
+
if (prior?.kind === "slice") return {
|
|
1486
3643
|
...prior,
|
|
1487
3644
|
staleReason: `re-read failed: ${read.error}`
|
|
1488
|
-
}
|
|
1489
|
-
|
|
3645
|
+
};
|
|
3646
|
+
if (prior !== void 0 && prior.kind !== "unread") return prior;
|
|
3647
|
+
return {
|
|
3648
|
+
kind: "unread",
|
|
1490
3649
|
reason: `the slice could not be read: ${read.error}`
|
|
1491
3650
|
};
|
|
1492
3651
|
}
|
|
3652
|
+
/** A claim or release of `owner` on `(deviceId, cap)` was answered: read the owner's native-view readings of it once, in place. */
|
|
3653
|
+
async rereadNative(deviceId, cap, owner) {
|
|
3654
|
+
const changed = /* @__PURE__ */ new Set();
|
|
3655
|
+
const key = entryKey({
|
|
3656
|
+
cap,
|
|
3657
|
+
view: "native"
|
|
3658
|
+
});
|
|
3659
|
+
for (const [k, source] of this.sources) {
|
|
3660
|
+
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
3661
|
+
if (source.nativeOwner !== owner) continue;
|
|
3662
|
+
const current = source.caps.get(key);
|
|
3663
|
+
if (current === void 0) continue;
|
|
3664
|
+
const reading = await this.readCap(deviceId, {
|
|
3665
|
+
cap,
|
|
3666
|
+
view: "native"
|
|
3667
|
+
}, "bound", current);
|
|
3668
|
+
if (reading === current) continue;
|
|
3669
|
+
const latest = this.sources.get(k) ?? source;
|
|
3670
|
+
this.sources = new Map([...this.sources, [k, {
|
|
3671
|
+
...latest,
|
|
3672
|
+
caps: new Map([...latest.caps, [key, reading]])
|
|
3673
|
+
}]]);
|
|
3674
|
+
changed.add(require_dist.compositionSliceKey(source.ref, cap));
|
|
3675
|
+
}
|
|
3676
|
+
return changed;
|
|
3677
|
+
}
|
|
1493
3678
|
logHealed(deviceId, cap, via) {
|
|
1494
3679
|
this.ports.logger.info("composition source: an unbound capability is served again", {
|
|
1495
3680
|
tags: { deviceId },
|
|
@@ -1500,7 +3685,7 @@ var SourceTracker = class {
|
|
|
1500
3685
|
});
|
|
1501
3686
|
}
|
|
1502
3687
|
async readOnline(deviceId) {
|
|
1503
|
-
const read = await this.ports.readSlice(deviceId, DEVICE_STATUS_CAP);
|
|
3688
|
+
const read = await this.ports.readSlice(deviceId, DEVICE_STATUS_CAP, "effective");
|
|
1504
3689
|
if (read.kind === "unreadable") this.ports.logger.warn("composition source: `device-status` could not be read — online unknown", {
|
|
1505
3690
|
tags: { deviceId },
|
|
1506
3691
|
meta: { error: read.error }
|
|
@@ -1518,9 +3703,28 @@ var SourceTracker = class {
|
|
|
1518
3703
|
* enforced by `scripts/check-runner-group-isolation.ts`). `CrashSupervisor`
|
|
1519
3704
|
* bounds it like every forked runner (D6). Hub-only: declarations are cluster
|
|
1520
3705
|
* state.
|
|
3706
|
+
*
|
|
3707
|
+
* Slice 2 (D663): a composition on an EXISTING device claims its fields on the
|
|
3708
|
+
* hub (`deviceState.claimFields` …) and reads its own replaced fields from the
|
|
3709
|
+
* native shadow (`getNativeCapSlice`, `device.native-shadow-changed`) — the one
|
|
3710
|
+
* subscriber that category has (`scripts/check-owner-only-events.ts`).
|
|
1521
3711
|
*/
|
|
1522
3712
|
/** Ignored by `DeclaredDevices` whenever `integrationId` is supplied, which is always here. */
|
|
1523
3713
|
var DECLARED_INTEGRATION_NAME_UNUSED = "Blocks";
|
|
3714
|
+
/** The owner-only shadow payload is not in the typed catalog: narrowed at the boundary, like `device.state-changed`. */
|
|
3715
|
+
function parseNativeShadowChanged(data) {
|
|
3716
|
+
if (!require_device_state_changed.isRecord(data)) return null;
|
|
3717
|
+
const deviceId = data["deviceId"];
|
|
3718
|
+
const capName = data["capName"];
|
|
3719
|
+
const native = data["native"];
|
|
3720
|
+
if (typeof deviceId !== "number" || typeof capName !== "string") return null;
|
|
3721
|
+
if (!require_device_state_changed.isRecord(native)) return null;
|
|
3722
|
+
return {
|
|
3723
|
+
deviceId,
|
|
3724
|
+
capName,
|
|
3725
|
+
native
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
1524
3728
|
var ComposerAddon = class extends require_dist.BaseAddon {
|
|
1525
3729
|
composer = null;
|
|
1526
3730
|
constructor() {
|
|
@@ -1529,6 +3733,24 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1529
3733
|
async onInitialize() {
|
|
1530
3734
|
const api = this.ctx.api;
|
|
1531
3735
|
const logger = this.ctx.logger;
|
|
3736
|
+
/** ONE `getBindings`: the tracker's binding check and the target description share it. */
|
|
3737
|
+
const readBoundCaps = async (deviceId) => {
|
|
3738
|
+
try {
|
|
3739
|
+
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
3740
|
+
return {
|
|
3741
|
+
kind: "caps",
|
|
3742
|
+
caps: new Map(bindings.entries.flatMap((entry) => {
|
|
3743
|
+
const owner = require_composition_sources.normalizedNativeOwner(entry.nativeAddonId);
|
|
3744
|
+
return owner === null ? [] : [[entry.capName, owner]];
|
|
3745
|
+
}))
|
|
3746
|
+
};
|
|
3747
|
+
} catch (err) {
|
|
3748
|
+
return {
|
|
3749
|
+
kind: "unreadable",
|
|
3750
|
+
error: require_dist.errMsg(err)
|
|
3751
|
+
};
|
|
3752
|
+
}
|
|
3753
|
+
};
|
|
1532
3754
|
const tracker = new SourceTracker({
|
|
1533
3755
|
logger: logger.child("sources"),
|
|
1534
3756
|
listDevicesOf: async (addonId) => {
|
|
@@ -1540,7 +3762,8 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1540
3762
|
projection: "slim"
|
|
1541
3763
|
})).map((r) => ({
|
|
1542
3764
|
id: r.id,
|
|
1543
|
-
stableId: r.stableId
|
|
3765
|
+
stableId: r.stableId,
|
|
3766
|
+
type: r.type
|
|
1544
3767
|
}))
|
|
1545
3768
|
};
|
|
1546
3769
|
} catch (err) {
|
|
@@ -1550,9 +3773,12 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1550
3773
|
};
|
|
1551
3774
|
}
|
|
1552
3775
|
},
|
|
1553
|
-
readSlice: async (deviceId, capName) => {
|
|
3776
|
+
readSlice: async (deviceId, capName, view) => {
|
|
1554
3777
|
try {
|
|
1555
|
-
const slice = await api.deviceState.
|
|
3778
|
+
const slice = view === "native" ? await api.deviceState.getNativeCapSlice.query({
|
|
3779
|
+
deviceId,
|
|
3780
|
+
capName
|
|
3781
|
+
}) : await api.deviceState.getCapSlice.query({
|
|
1556
3782
|
deviceId,
|
|
1557
3783
|
capName
|
|
1558
3784
|
});
|
|
@@ -1567,20 +3793,7 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1567
3793
|
};
|
|
1568
3794
|
}
|
|
1569
3795
|
},
|
|
1570
|
-
readBoundCaps
|
|
1571
|
-
try {
|
|
1572
|
-
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
1573
|
-
return {
|
|
1574
|
-
kind: "caps",
|
|
1575
|
-
caps: new Set(bindings.entries.map((e) => e.capName))
|
|
1576
|
-
};
|
|
1577
|
-
} catch (err) {
|
|
1578
|
-
return {
|
|
1579
|
-
kind: "unreadable",
|
|
1580
|
-
error: require_dist.errMsg(err)
|
|
1581
|
-
};
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
3796
|
+
readBoundCaps
|
|
1584
3797
|
});
|
|
1585
3798
|
const listOwnDevices = async () => api.deviceManager.listAll.query({ addonId: require_dist.COMPOSER_ADDON_ID });
|
|
1586
3799
|
const declared = new require_dist.DeclaredDevices({
|
|
@@ -1600,10 +3813,18 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1600
3813
|
listOwnDevices
|
|
1601
3814
|
});
|
|
1602
3815
|
const devices = this.ctx.kernel.devices;
|
|
3816
|
+
const lookupCap = require_dist.buildCapabilityLookup(require_dist.ALL_CAPABILITY_DEFINITIONS);
|
|
3817
|
+
const grafts = new GraftHost({
|
|
3818
|
+
createContext: (stableId, id) => devices.createContext(stableId, id),
|
|
3819
|
+
logger: logger.child("graft")
|
|
3820
|
+
}, lookupCap, async (deviceId, capName) => await api.deviceState.getCapSlice.query({
|
|
3821
|
+
deviceId,
|
|
3822
|
+
capName
|
|
3823
|
+
}) ?? null);
|
|
1603
3824
|
this.composer = new Composer({
|
|
1604
3825
|
logger: logger.child("composer"),
|
|
1605
3826
|
tracker,
|
|
1606
|
-
lookupCap
|
|
3827
|
+
lookupCap,
|
|
1607
3828
|
readPersisted: async () => (await listOwnDevices()).map((r) => ({
|
|
1608
3829
|
id: r.id,
|
|
1609
3830
|
stableId: r.stableId
|
|
@@ -1627,6 +3848,19 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1627
3848
|
decommission: (deviceId) => devices.decommission(deviceId),
|
|
1628
3849
|
applyDerivedSpec: (deviceId, derived) => devices.applyDerivedSpec(deviceId, derived)
|
|
1629
3850
|
},
|
|
3851
|
+
claims: {
|
|
3852
|
+
claimFields: (input) => api.deviceState.claimFields.mutate(input),
|
|
3853
|
+
patchOwnedFields: (input) => api.deviceState.patchOwnedFields.mutate(input),
|
|
3854
|
+
releaseClaim: (input) => api.deviceState.releaseClaim.mutate(input),
|
|
3855
|
+
listClaims: (input) => api.deviceState.listClaims.query(input)
|
|
3856
|
+
},
|
|
3857
|
+
events: this.ctx.eventBus,
|
|
3858
|
+
readExistingTargetCap: async (deviceId, capName) => await api.deviceState.getCapSlice.query({
|
|
3859
|
+
deviceId,
|
|
3860
|
+
capName
|
|
3861
|
+
}) ?? null,
|
|
3862
|
+
describeTarget: readBoundCaps,
|
|
3863
|
+
grafts,
|
|
1630
3864
|
now: () => Date.now(),
|
|
1631
3865
|
newTimer: () => new NodeDeadlineTimer(() => Date.now())
|
|
1632
3866
|
});
|
|
@@ -1636,6 +3870,12 @@ var ComposerAddon = class extends require_dist.BaseAddon {
|
|
|
1636
3870
|
this.logEventFailure("device.state-changed", data.deviceId, err);
|
|
1637
3871
|
});
|
|
1638
3872
|
});
|
|
3873
|
+
this.subscribe({ category: require_dist.EventCategory.DeviceNativeShadowChanged }, (event) => {
|
|
3874
|
+
const data = parseNativeShadowChanged(event.data);
|
|
3875
|
+
if (data !== null) this.composer?.onNativeChanged(data).catch((err) => {
|
|
3876
|
+
this.logEventFailure(require_dist.EventCategory.DeviceNativeShadowChanged, data.deviceId, err);
|
|
3877
|
+
});
|
|
3878
|
+
});
|
|
1639
3879
|
this.subscribe({ category: require_dist.EventCategory.DeviceUnregistered }, (event) => {
|
|
1640
3880
|
this.composer?.onDeviceUnregistered(event.data.deviceId).catch((err) => {
|
|
1641
3881
|
this.logEventFailure("device.unregistered", event.data.deviceId, err);
|